@swiftbrowser/swift 0.3.1 → 0.4.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.
@@ -7,11 +7,13 @@
7
7
  // file representation is a copy in the temporary directory, removed once the
8
8
  // handler returns, as on iOS.
9
9
  //
10
- // SwiftUI's drag-and-drop API takes providers, so it lives here (the SwiftUI
11
- // shim cannot see this module): `onDrop`, `onDrag`, `DropInfo`,
12
- // `DropDelegate`. The page has no drag and drop between views or apps yet,
13
- // so the views are left as they are and nothing is ever dropped
14
- // (docs/ops-protocol.md, "Sharing and media").
10
+ // SwiftUI's provider-based drag-and-drop API lives here (the SwiftUI shim
11
+ // cannot see this module): `onDrop`, `onDrag`, `DropInfo`, `DropDelegate`,
12
+ // over the shim's `draggable` / `dropdestination` elements. A drag's
13
+ // provider is made when it begins; a drop hands the destination the
14
+ // source's provider (a `Transferable` payload becomes one) and one provider
15
+ // per item dragged in from outside the page (docs/ops-protocol.md, "Drag
16
+ // and drop").
15
17
 
16
18
  import Foundation
17
19
  import SwiftUI
@@ -572,7 +574,6 @@ struct _UnsafeBox<Value>: @unchecked Sendable {
572
574
  // MARK: - SwiftUI: drag and drop
573
575
 
574
576
  /// What a drop offers: where it is and the providers of what was dropped.
575
- /// The page has no drag and drop yet, so no drop is ever delivered.
576
577
  public struct DropInfo {
577
578
  public let location: SwiftUI.CGPoint
578
579
  let providers: [NSItemProvider]
@@ -610,7 +611,7 @@ public struct DropProposal: Sendable {
610
611
  }
611
612
 
612
613
  /// What a drop target is told as a drag moves over it and drops. Every
613
- /// method has a default, as on iOS. Never called in the browser yet.
614
+ /// method has a default, as on iOS.
614
615
  @MainActor
615
616
  public protocol DropDelegate {
616
617
  func validateDrop(info: DropInfo) -> Bool
@@ -627,28 +628,97 @@ extension DropDelegate {
627
628
  public func dropExited(info: DropInfo) {}
628
629
  }
629
630
 
631
+ extension Transferable {
632
+ /// A provider of `self` in every type it exports.
633
+ fileprivate func _itemProvider() -> NSItemProvider {
634
+ let provider = NSItemProvider()
635
+ let boxed = _UnsafeBox(self)
636
+ provider.register(boxed.value)
637
+ return provider
638
+ }
639
+ }
640
+
641
+ extension DropInfo {
642
+ /// What a drop offers as providers: the dragged view's provider (its
643
+ /// `onDrag` provider as it is, a `draggable` payload registered in the
644
+ /// types it exports) and one per item from outside the page.
645
+ init(_ items: _DropItems) {
646
+ var providers: [NSItemProvider] = []
647
+ if let provider = items.source as? NSItemProvider {
648
+ providers.append(provider)
649
+ } else if let transferable = items.source as? any Transferable {
650
+ providers.append(transferable._itemProvider())
651
+ }
652
+ for content in items.contents {
653
+ let provider = NSItemProvider()
654
+ let data = content.data ?? Data()
655
+ provider.registerDataRepresentation(for: content.contentType) { completion in
656
+ completion(data, nil)
657
+ return nil
658
+ }
659
+ provider.suggestedName = content.suggestedName
660
+ providers.append(provider)
661
+ }
662
+ self.init(_location: items.location, providers: providers)
663
+ }
664
+ }
665
+
630
666
  extension View {
631
- /// Accepted; nothing is dropped in the browser, so `action` is never called.
667
+ /// The view takes drops that carry one of `supportedContentTypes`: a
668
+ /// dragged view's provider, or text, links and files dragged in from
669
+ /// outside the page. `isTargeted` is set while a drag is over the view;
670
+ /// `action` gets the providers and says whether it took them.
632
671
  public func onDrop(of supportedContentTypes: [UTType], isTargeted: Binding<Bool>?,
633
- perform action: @escaping ([NSItemProvider]) -> Bool) -> some View { self }
672
+ perform action: @escaping ([NSItemProvider]) -> Bool) -> some View {
673
+ onDrop(of: supportedContentTypes, isTargeted: isTargeted) { providers, _ in action(providers) }
674
+ }
634
675
 
635
- /// Accepted; nothing is dropped in the browser, so `action` is never called.
676
+ /// As above, with the drop's point in the view's coordinates.
636
677
  public func onDrop(of supportedContentTypes: [UTType], isTargeted: Binding<Bool>?,
637
- perform action: @escaping ([NSItemProvider], SwiftUI.CGPoint) -> Bool) -> some View { self }
678
+ perform action: @escaping ([NSItemProvider], SwiftUI.CGPoint) -> Bool) -> some View {
679
+ _DropDestinationView(content: self, accepts: _dropAccepts(supportedContentTypes),
680
+ targeted: { targeted, _ in isTargeted?.wrappedValue = targeted }) { items in
681
+ let info = DropInfo(items)
682
+ let providers = info.itemProviders(for: supportedContentTypes)
683
+ guard !providers.isEmpty else { return false }
684
+ return action(providers, items.location)
685
+ }
686
+ }
638
687
 
639
- /// Accepted; nothing is dropped in the browser, so the delegate hears nothing.
640
- public func onDrop(of supportedContentTypes: [UTType], delegate: any DropDelegate) -> some View { self }
688
+ /// The delegate hears the drag enter and leave (`dropEntered`,
689
+ /// `dropExited`, with what it carries) and takes the drop
690
+ /// (`validateDrop`, then `performDrop`).
691
+ public func onDrop(of supportedContentTypes: [UTType], delegate: any DropDelegate) -> some View {
692
+ _DropDestinationView(content: self, accepts: _dropAccepts(supportedContentTypes),
693
+ targeted: { targeted, items in
694
+ if targeted { delegate.dropEntered(info: DropInfo(items)) } else { delegate.dropExited(info: DropInfo(items)) }
695
+ }) { items in
696
+ let info = DropInfo(items)
697
+ guard info.hasItemsConforming(to: supportedContentTypes), delegate.validateDrop(info: info) else { return false }
698
+ return delegate.performDrop(info: info)
699
+ }
700
+ }
641
701
 
642
702
  public func onDrop(of supportedTypes: [String], isTargeted: Binding<Bool>?,
643
- perform action: @escaping ([NSItemProvider]) -> Bool) -> some View { self }
703
+ perform action: @escaping ([NSItemProvider]) -> Bool) -> some View {
704
+ onDrop(of: supportedTypes.map(NSItemProvider.type), isTargeted: isTargeted, perform: action)
705
+ }
644
706
 
645
- public func onDrop(of supportedTypes: [String], delegate: any DropDelegate) -> some View { self }
707
+ public func onDrop(of supportedTypes: [String], delegate: any DropDelegate) -> some View {
708
+ onDrop(of: supportedTypes.map(NSItemProvider.type), delegate: delegate)
709
+ }
646
710
 
647
- /// Accepted; the view cannot be dragged in the browser.
648
- public func onDrag(_ data: @escaping () -> NSItemProvider) -> some View { self }
711
+ /// The view can be dragged: a long press lifts it on a touch screen, a
712
+ /// drag does with a mouse. `data` runs when the drag begins; its provider
713
+ /// reaches the `onDrop` (or `dropDestination`) the view is dropped on.
714
+ public func onDrag(_ data: @escaping () -> NSItemProvider) -> some View {
715
+ _DraggableView(content: self) { data() }
716
+ }
649
717
 
650
- /// Accepted; the view cannot be dragged in the browser.
651
- public func onDrag<Preview: View>(_ data: @escaping () -> NSItemProvider, @ViewBuilder preview: () -> Preview) -> some View { self }
718
+ /// The page drags a copy of the view itself; `preview` is accepted and not drawn.
719
+ public func onDrag<Preview: View>(_ data: @escaping () -> NSItemProvider, @ViewBuilder preview: () -> Preview) -> some View {
720
+ _DraggableView(content: self) { data() }
721
+ }
652
722
 
653
723
  /// Accepted (macOS's services and iOS's drag source): nothing asks for the provider.
654
724
  public func itemProvider(_ action: (() -> NSItemProvider?)?) -> some View { self }
@@ -42,11 +42,14 @@ public protocol DynamicViewContent: View {
42
42
 
43
43
  extension ForEach: DynamicViewContent where Content: View {}
44
44
 
45
- /// A keyed group whose rows a `List` can delete (and, when the renderer learns
46
- /// to, move). The runtime wraps each row in a `listrow` with `deletable`.
45
+ /// A keyed group whose rows a `List` can delete or move. The runtime wraps
46
+ /// each row in a `listrow` with `deletable` / `movable` (docs/ops-protocol.md,
47
+ /// "Reordering rows").
47
48
  protocol _DeletableGroup: _TraitBearing {
48
49
  /// Calls the group's `onDelete` with the row at `offset`; nil without one.
49
50
  var _delete: ((Int) -> Void)? { get }
51
+ /// Calls the group's `onMove` with the row at the first offset and the
52
+ /// destination offset (both in the group's data); nil without one.
50
53
  var _move: ((Int, Int) -> Void)? { get }
51
54
  }
52
55
 
@@ -76,7 +79,10 @@ extension DynamicViewContent {
76
79
  _EditableForEach(base: self, delete: action.map { action in { action(IndexSet(integer: $0)) } }, move: nil)
77
80
  }
78
81
 
79
- /// Accepted for source compatibility; the renderer offers no reordering yet.
82
+ /// Lets a `List` reorder the rows: in edit mode each row gains a grip at
83
+ /// its trailing edge, and outside it a long press lifts the row; `action`
84
+ /// receives the moved row's offset and the destination offset in `data`,
85
+ /// as `move(fromOffsets:toOffset:)` takes them. Pass `nil` to opt out.
80
86
  public func onMove(perform action: ((IndexSet, Int) -> Void)?) -> _EditableForEach<Self> {
81
87
  _EditableForEach(base: self, delete: nil, move: action.map { action in { action(IndexSet(integer: $0), $1) } })
82
88
  }
@@ -96,27 +102,33 @@ extension _EditableForEach {
96
102
  /// `.deleteDisabled` / `.moveDisabled` on a row.
97
103
  protocol _RowEditingDisabling: _TraitBearing {
98
104
  var _deleteDisabled: Bool { get }
105
+ var _moveDisabled: Bool { get }
99
106
  }
100
107
 
101
- struct _DeleteDisabledView<Content: View> {
108
+ struct _RowEditingDisabledView<Content: View> {
102
109
  let content: Content
103
- let disabled: Bool
110
+ var deleteDisabled = false
111
+ var moveDisabled = false
104
112
  }
105
- extension _DeleteDisabledView: _PrimitiveView {}
106
- extension _DeleteDisabledView: _GroupView {
113
+ extension _RowEditingDisabledView: _PrimitiveView {}
114
+ extension _RowEditingDisabledView: _GroupView {
107
115
  var _children: [any View] { [content] }
108
116
  }
109
- extension _DeleteDisabledView: _RowEditingDisabling {
110
- var _deleteDisabled: Bool { disabled }
117
+ extension _RowEditingDisabledView: _RowEditingDisabling {
118
+ var _deleteDisabled: Bool { deleteDisabled }
119
+ var _moveDisabled: Bool { moveDisabled }
111
120
  }
112
121
 
113
122
  nonisolated extension View {
114
123
  public func deleteDisabled(_ isDisabled: Bool) -> some View {
115
- _DeleteDisabledView(content: self, disabled: isDisabled)
124
+ _RowEditingDisabledView(content: self, deleteDisabled: isDisabled)
116
125
  }
117
126
 
118
- /// Accepted for source compatibility; rows cannot be moved yet.
119
- public func moveDisabled(_ isDisabled: Bool) -> some View { self }
127
+ /// The row keeps its place: no grip in edit mode, and a long press does
128
+ /// not lift it.
129
+ public func moveDisabled(_ isDisabled: Bool) -> some View {
130
+ _RowEditingDisabledView(content: self, moveDisabled: isDisabled)
131
+ }
120
132
  }
121
133
 
122
134
  // MARK: - Edit mode
@@ -0,0 +1,64 @@
1
+ // `.inspector(isPresented:content:)`: a trailing panel beside the content in
2
+ // the regular size class (an `inspector` element whose last child is the
3
+ // `inspectorpanel`), a sheet in the compact one, as on iPadOS / iOS. See
4
+ // docs/ops-protocol.md, "Inspector".
5
+
6
+ extension View {
7
+ /// Shows `content` in a trailing column beside this view while
8
+ /// `isPresented` is true (regular width), or in a sheet (compact width).
9
+ public func inspector<Content: View>(isPresented: Binding<Bool>,
10
+ @ViewBuilder content: () -> Content) -> some View {
11
+ _InspectorView(base: self, isPresented: isPresented, inspector: content())
12
+ }
13
+ }
14
+
15
+ nonisolated extension View {
16
+ /// The inspector column's width (on the inspector's content).
17
+ public func inspectorColumnWidth(_ width: CGFloat) -> some View {
18
+ _styled { $0.extras["inspectorColumnWidth"] = .number(width) }
19
+ }
20
+
21
+ /// The inspector column's width: the ideal one (the column does not resize).
22
+ public func inspectorColumnWidth(min: CGFloat? = nil, ideal: CGFloat, max: CGFloat? = nil) -> some View {
23
+ inspectorColumnWidth(ideal)
24
+ }
25
+ }
26
+
27
+ struct _InspectorView<Base: View, Inspector: View>: View {
28
+ let base: Base
29
+ let isPresented: Binding<Bool>
30
+ let inspector: Inspector
31
+ @Environment(\.horizontalSizeClass) private var sizeClass
32
+
33
+ var body: some View {
34
+ if sizeClass == .regular {
35
+ _InspectorColumns(main: base, panel: _InspectorPanel(content: inspector), presented: isPresented.wrappedValue)
36
+ } else {
37
+ base.sheet(isPresented: isPresented) { inspector }
38
+ }
39
+ }
40
+ }
41
+
42
+ /// The content and, last, the panel; `presented` slides the panel in.
43
+ struct _InspectorColumns<Main: View, Panel: View> {
44
+ let main: Main
45
+ let panel: Panel
46
+ let presented: Bool
47
+ }
48
+ extension _InspectorColumns: _PrimitiveView {}
49
+ extension _InspectorColumns: _HostView {
50
+ var _kind: String { "inspector" }
51
+ func _props(in environment: EnvironmentValues) -> _JSON { .object(["presented": .bool(presented)]) }
52
+ var _content: [any View] { [main, panel] }
53
+ }
54
+
55
+ /// The inspector column's content.
56
+ struct _InspectorPanel<Content: View> {
57
+ let content: Content
58
+ }
59
+ extension _InspectorPanel: _PrimitiveView {}
60
+ extension _InspectorPanel: _HostView {
61
+ var _kind: String { "inspectorpanel" }
62
+ func _props(in environment: EnvironmentValues) -> _JSON { .object([:]) }
63
+ var _content: [any View] { [content] }
64
+ }
@@ -3,9 +3,16 @@
3
3
  public protocol ListStyle {
4
4
  var _name: String { get }
5
5
  }
6
+ /// `.automatic`: inset grouped, or `sidebar` in a `NavigationSplitView`'s
7
+ /// sidebar in the regular size class (resolved in `List._props`).
6
8
  public struct DefaultListStyle: ListStyle {
7
9
  public init() {}
8
- public var _name: String { "insetGrouped" }
10
+ public var _name: String { "automatic" }
11
+ }
12
+ /// iPadOS's sidebar list: rows on the background, no cards or hairlines.
13
+ public struct SidebarListStyle: ListStyle {
14
+ public init() {}
15
+ public var _name: String { "sidebar" }
9
16
  }
10
17
  public struct PlainListStyle: ListStyle {
11
18
  public init() {}
@@ -31,6 +38,9 @@ extension ListStyle where Self == InsetGroupedListStyle {
31
38
  extension ListStyle where Self == GroupedListStyle {
32
39
  public static var grouped: GroupedListStyle { GroupedListStyle() }
33
40
  }
41
+ extension ListStyle where Self == SidebarListStyle {
42
+ public static var sidebar: SidebarListStyle { SidebarListStyle() }
43
+ }
34
44
 
35
45
  nonisolated extension View {
36
46
  public func listStyle<S: ListStyle>(_ style: S) -> some View {
@@ -203,11 +213,13 @@ extension List: _HostView {
203
213
  /// `editing` (the environment's edit mode) and `refreshable` / `refreshing`
204
214
  /// (`.refreshable`) are present only when true / when refreshable.
205
215
  func _props(in environment: EnvironmentValues) -> _JSON {
206
- var props: [String: _JSON] = ["style": .string(environment._listStyle)]
216
+ var props: [String: _JSON] = ["style": .string(Self._resolvedStyle(in: environment))]
207
217
  if environment.editMode?.wrappedValue.isEditing == true { props["editing"] = .bool(true) }
208
218
  _refresh(in: environment).addProps(to: &props)
209
219
  // `.scrollDismissesKeyboard` (ScrollModifiers.swift).
210
220
  if environment._scrollDismissesKeyboard { props["dismissesKeyboard"] = .bool(true) }
221
+ // `.scrollEdgeEffectStyle` / `.scrollEdgeEffectHidden` (ScrollModifiers.swift).
222
+ if let edges = environment._scrollEdgeEffects.props { props["edgeEffect"] = edges }
211
223
  // `.scrollContentBackground(.hidden)` (ScrollViewReader.swift).
212
224
  if environment._scrollContentBackground == .hidden { props["background"] = .string("hidden") }
213
225
  // `.environment(\.defaultMinListRowHeight, 1)`: rows as short as their content.
@@ -217,6 +229,14 @@ extension List: _HostView {
217
229
  var _content: [any View] { [content] }
218
230
  var _handler: ((_JSON) -> Void)? { nil }
219
231
 
232
+ /// `.automatic` is `sidebar` in a split view's sidebar on a regular-width
233
+ /// screen (as SwiftUI does), else inset grouped.
234
+ static func _resolvedStyle(in environment: EnvironmentValues) -> String {
235
+ let style = environment._listStyle
236
+ guard style == "automatic" else { return style }
237
+ return environment._splitSidebar && environment.horizontalSizeClass == .regular ? "sidebar" : "insetGrouped"
238
+ }
239
+
220
240
  func _refresh(in environment: EnvironmentValues) -> _Refresh {
221
241
  _Refresh(action: environment._refreshAction, refreshing: $refreshing)
222
242
  }
@@ -226,10 +226,13 @@ extension NavigationLink: _NavigationLinkView {
226
226
 
227
227
  // MARK: - NavigationSplitView
228
228
 
229
- /// Which columns of a `NavigationSplitView` are shown. Accepted for source
230
- /// compatibility; the shim shows the sidebar and the detail (see below).
229
+ /// Which columns of a `NavigationSplitView` are shown: `detailOnly` hides
230
+ /// the sidebar; every other value shows it beside the detail (the optional
231
+ /// middle column is part of the detail, see below).
231
232
  public struct NavigationSplitViewVisibility: Hashable, Sendable {
232
233
  let name: String
234
+ /// Whether the sidebar column is shown.
235
+ var _showsSidebar: Bool { name != "detailOnly" }
233
236
  public static let automatic = NavigationSplitViewVisibility(name: "automatic")
234
237
  public static let all = NavigationSplitViewVisibility(name: "all")
235
238
  public static let doubleColumn = NavigationSplitViewVisibility(name: "doubleColumn")
@@ -266,6 +269,9 @@ protocol _NavigationSplitHost: _TraitBearing {
266
269
  var _detail: any View { get }
267
270
  /// Screens pushed from the compact detail (links inside it).
268
271
  var _pushedBox: _StateBox<[_PushedScreen]> { get }
272
+ /// The columns shown in the regular size class: the `columnVisibility`
273
+ /// binding, else the split view's own state. The sidebar toggle sets it.
274
+ var _columnVisibility: Binding<NavigationSplitViewVisibility> { get }
269
275
  }
270
276
 
271
277
  /// A two-column navigation interface: a sidebar whose list selection drives
@@ -274,11 +280,14 @@ protocol _NavigationSplitHost: _TraitBearing {
274
280
  public struct NavigationSplitView<Sidebar: View, Content: View, Detail: View> {
275
281
  let sidebar: Sidebar
276
282
  let detail: Detail
283
+ let columnVisibility: Binding<NavigationSplitViewVisibility>?
277
284
  @State var pushed: [_PushedScreen] = []
285
+ @State var visibility: NavigationSplitViewVisibility = .automatic
278
286
 
279
287
  public init(@ViewBuilder sidebar: () -> Sidebar, @ViewBuilder detail: () -> Detail) where Content == EmptyView {
280
288
  self.sidebar = sidebar()
281
289
  self.detail = detail()
290
+ columnVisibility = nil
282
291
  }
283
292
 
284
293
  public init(@ViewBuilder sidebar: () -> Sidebar, @ViewBuilder content: () -> Content,
@@ -286,13 +295,16 @@ public struct NavigationSplitView<Sidebar: View, Content: View, Detail: View> {
286
295
  self.sidebar = sidebar()
287
296
  _ = content()
288
297
  self.detail = detail()
298
+ columnVisibility = nil
289
299
  }
290
300
 
291
- /// The visibility binding is accepted and ignored.
301
+ /// `columnVisibility` shows or hides the sidebar (`detailOnly` hides it),
302
+ /// and the sidebar toggle sets it.
292
303
  public init(columnVisibility: Binding<NavigationSplitViewVisibility>,
293
304
  @ViewBuilder sidebar: () -> Sidebar, @ViewBuilder detail: () -> Detail) where Content == EmptyView {
294
305
  self.sidebar = sidebar()
295
306
  self.detail = detail()
307
+ self.columnVisibility = columnVisibility
296
308
  }
297
309
 
298
310
  public init(columnVisibility: Binding<NavigationSplitViewVisibility>,
@@ -301,6 +313,7 @@ public struct NavigationSplitView<Sidebar: View, Content: View, Detail: View> {
301
313
  self.sidebar = sidebar()
302
314
  _ = content()
303
315
  self.detail = detail()
316
+ self.columnVisibility = columnVisibility
304
317
  }
305
318
  }
306
319
  extension NavigationSplitView: _PrimitiveView {}
@@ -313,6 +326,7 @@ extension NavigationSplitView: _NavigationSplitHost {
313
326
  var _sidebar: any View { sidebar }
314
327
  var _detail: any View { detail }
315
328
  var _pushedBox: _StateBox<[_PushedScreen]> { _pushed.box }
329
+ var _columnVisibility: Binding<NavigationSplitViewVisibility> { columnVisibility ?? $visibility }
316
330
  }
317
331
 
318
332
  /// Marks the sidebar subtree of a split view: value links there select
@@ -46,7 +46,7 @@ nonisolated extension View {
46
46
  }
47
47
 
48
48
  enum _PickerStyleKey: EnvironmentKey { static let defaultValue = "menu" }
49
- enum _ListStyleKey: EnvironmentKey { static let defaultValue = "insetGrouped" }
49
+ enum _ListStyleKey: EnvironmentKey { static let defaultValue = "automatic" }
50
50
 
51
51
  extension EnvironmentValues {
52
52
  var _pickerStyle: String {
@@ -363,6 +363,8 @@ extension ScrollView: _HostView {
363
363
  if let snap = environment._scrollTargetBehavior { props["snap"] = .string(snap) }
364
364
  // `.scrollDismissesKeyboard` (ScrollModifiers.swift).
365
365
  if environment._scrollDismissesKeyboard { props["dismissesKeyboard"] = .bool(true) }
366
+ // `.scrollEdgeEffectStyle` / `.scrollEdgeEffectHidden` (ScrollModifiers.swift).
367
+ if let edges = environment._scrollEdgeEffects.props { props["edgeEffect"] = edges }
366
368
  return .object(props)
367
369
  }
368
370
  var _content: [any View] { [content] }
@@ -7,8 +7,14 @@
7
7
  // no keyboard to drag down). `.automatic` and `.never` add nothing.
8
8
  // - `.scrollIndicators(_:axes:)`: accepted; the page's scroll views never
9
9
  // show scroll bars (iOS shows them only while scrolling).
10
- // - `.scrollEdgeEffectStyle(_:for:)` (iOS 26): accepted; the page draws no
11
- // edge blur under the bars.
10
+ // - `.scrollEdgeEffectStyle(_:for:)` and `.scrollEdgeEffectHidden(_:for:)`
11
+ // (iOS 26): the lists and scroll views below carry `"edgeEffect": {"top":
12
+ // …, "bottom": …}`, each `"hard"`, `"soft"` or `"hidden"` (absent:
13
+ // automatic, the soft effect). The page draws the effect where a scroll
14
+ // view runs under its screen's bar or a bar covering the bottom (styles.css):
15
+ // soft blurs and fades the content there, hard puts an opaque backing with a
16
+ // dividing line behind the bar. The closest modifier wins per edge; hidden
17
+ // wins over a style; leading and trailing are accepted and ignored.
12
18
  // - `.safeAreaInset(edge:alignment:spacing:content:)`: approximated as a
13
19
  // stack: the inset view takes its room above or below (before or after)
14
20
  // the view, which gets the rest, instead of overlapping a scroll view's
@@ -39,12 +45,38 @@ public struct ScrollEdgeEffectStyle: Hashable, Sendable {
39
45
 
40
46
  enum _ScrollDismissesKeyboardKey: EnvironmentKey { static let defaultValue = false }
41
47
 
48
+ /// `.scrollEdgeEffectStyle` / `.scrollEdgeEffectHidden` above a scroll view,
49
+ /// for its top and bottom edges.
50
+ struct _ScrollEdgeEffects: Hashable, Sendable {
51
+ /// `"hard"` or `"soft"`; nil is automatic.
52
+ var topStyle: String?
53
+ var bottomStyle: String?
54
+ var topHidden = false
55
+ var bottomHidden = false
56
+
57
+ /// The `edgeEffect` prop of a list or scroll view, nil when both edges are automatic.
58
+ var props: _JSON? {
59
+ var edges: [String: _JSON] = [:]
60
+ if let top = topHidden ? "hidden" : topStyle { edges["top"] = .string(top) }
61
+ if let bottom = bottomHidden ? "hidden" : bottomStyle { edges["bottom"] = .string(bottom) }
62
+ return edges.isEmpty ? nil : .object(edges)
63
+ }
64
+ }
65
+
66
+ enum _ScrollEdgeEffectsKey: EnvironmentKey { static let defaultValue = _ScrollEdgeEffects() }
67
+
42
68
  extension EnvironmentValues {
43
69
  /// `.scrollDismissesKeyboard(.immediately | .interactively)` above a list or scroll view.
44
70
  var _scrollDismissesKeyboard: Bool {
45
71
  get { self[_ScrollDismissesKeyboardKey.self] }
46
72
  set { self[_ScrollDismissesKeyboardKey.self] = newValue }
47
73
  }
74
+
75
+ /// `.scrollEdgeEffectStyle` / `.scrollEdgeEffectHidden` above a list or scroll view.
76
+ var _scrollEdgeEffects: _ScrollEdgeEffects {
77
+ get { self[_ScrollEdgeEffectsKey.self] }
78
+ set { self[_ScrollEdgeEffectsKey.self] = newValue }
79
+ }
48
80
  }
49
81
 
50
82
  nonisolated extension View {
@@ -56,9 +88,23 @@ nonisolated extension View {
56
88
  self
57
89
  }
58
90
 
59
- public func scrollEdgeEffectStyle(_ style: ScrollEdgeEffectStyle?, for edges: Edge.Set) -> some View { self }
91
+ /// The scroll edge effect of the scroll views below on `edges` (top and
92
+ /// bottom): `.hard`, `.soft`, or `.automatic` / nil (soft on iOS).
93
+ public func scrollEdgeEffectStyle(_ style: ScrollEdgeEffectStyle?, for edges: Edge.Set) -> some View {
94
+ transformEnvironment(\._scrollEdgeEffects) { effects in
95
+ let name = style.flatMap { $0 == .automatic ? nil : $0.name }
96
+ if edges.contains(.top) { effects.topStyle = name }
97
+ if edges.contains(.bottom) { effects.bottomStyle = name }
98
+ }
99
+ }
60
100
 
61
- public func scrollEdgeEffectHidden(_ hidden: Bool = true, for edges: Edge.Set = .all) -> some View { self }
101
+ /// Hides (or shows again) the scroll edge effect of the scroll views below on `edges`.
102
+ public func scrollEdgeEffectHidden(_ hidden: Bool = true, for edges: Edge.Set = .all) -> some View {
103
+ transformEnvironment(\._scrollEdgeEffects) { effects in
104
+ if edges.contains(.top) { effects.topHidden = hidden }
105
+ if edges.contains(.bottom) { effects.bottomHidden = hidden }
106
+ }
107
+ }
62
108
 
63
109
  public func safeAreaInset<V: View>(edge: VerticalEdge, alignment: HorizontalAlignment = .center,
64
110
  spacing: CGFloat? = nil, @ViewBuilder content: () -> V) -> some View {
@@ -4,9 +4,9 @@ import Foundation
4
4
  @_exported import CoreTransferable
5
5
 
6
6
  // SwiftUI's side of `Transferable`: `Image` as a transferable value,
7
- // `ShareLink`'s export of other items, and the drag-and-drop and clipboard
8
- // modifiers that take `Transferable` payloads (docs/ops-protocol.md,
9
- // "Sharing and media").
7
+ // `ShareLink`'s export of other items (docs/ops-protocol.md, "Sharing and
8
+ // media"), the drag-and-drop modifiers ("Drag and drop") and the clipboard
9
+ // modifiers that take `Transferable` payloads.
10
10
 
11
11
  // MARK: - Image
12
12
 
@@ -112,26 +112,234 @@ enum _ShareExport {
112
112
  }
113
113
  }
114
114
 
115
- // MARK: - Drag and drop, copy and paste
115
+ // MARK: - Drag and drop
116
116
 
117
- // The page has no drag and drop between views or apps yet, and the clipboard
118
- // takes text through `UIPasteboard` (UIKit). These modifiers accept their
119
- // payloads and actions and leave the view as it is: nothing is dragged,
120
- // nothing is dropped, and the actions are never called.
117
+ // A `draggable` element wraps a view the page can lift (a long press on a
118
+ // touch screen, a drag with a mouse) and carry over the screen; a
119
+ // `dropdestination` element hears `drop` events with the phase `entered`,
120
+ // `exited` or `performed`. What travels stays in Swift: the source's payload
121
+ // is kept by `_DragSession` while the drag lasts (from `dragstart` to
122
+ // `dragend`), and a drop names the source element. Text, links and files
123
+ // dragged in from outside the page arrive as `items`, which become
124
+ // `_TransferContent`s the destination's type imports
125
+ // (docs/ops-protocol.md, "Drag and drop").
126
+
127
+ /// The drag in progress: the `draggable` element it started on and the
128
+ /// payload its closure made when it began. Nil between drags.
129
+ @MainActor
130
+ public enum _DragSession {
131
+ public static var current: (source: Int, payload: Any)?
132
+ }
133
+
134
+ /// `.draggable(_:)` / `onDrag`: the content under a `draggable` element.
135
+ /// `payload` runs when the drag begins, as `onDrag`'s provider closure does.
136
+ public struct _DraggableView<Content: View> {
137
+ let content: Content
138
+ let payload: @MainActor () -> Any
139
+
140
+ public init(content: Content, payload: @escaping @MainActor () -> Any) {
141
+ self.content = content
142
+ self.payload = payload
143
+ }
144
+ }
145
+ extension _DraggableView: _PrimitiveView {}
146
+ extension _DraggableView: _HostView {
147
+ var _kind: String { "draggable" }
148
+ func _props(in environment: EnvironmentValues) -> _JSON { .object([:]) }
149
+ var _content: [any View] { [content] }
150
+ var _handler: ((_JSON) -> Void)? {
151
+ let payload = self.payload
152
+ return { event in
153
+ guard case .object(let fields) = event, case .string(let type)? = fields["type"],
154
+ case .number(let id)? = fields["id"] else { return }
155
+ MainActor.assumeIsolated {
156
+ switch type {
157
+ case "dragstart":
158
+ _DragSession.current = (source: Int(id), payload: payload())
159
+ case "dragend":
160
+ if _DragSession.current?.source == Int(id) { _DragSession.current = nil }
161
+ default:
162
+ break
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ /// What a drop (or a drag passing over a destination) offers.
170
+ public struct _DropItems {
171
+ /// Where in the destination's own coordinates (zero while entering and leaving).
172
+ public let location: CGPoint
173
+ /// The payload of the `draggable` / `onDrag` source the drag started on,
174
+ /// when it started in the app; nil for a drag from outside the page.
175
+ public let source: Any?
176
+ /// What came from outside the page: text as UTF-8 plain text, a link as
177
+ /// `public.url`, a file in its type, named after the file.
178
+ public let contents: [_TransferContent]
179
+
180
+ public init(location: CGPoint, source: Any?, contents: [_TransferContent]) {
181
+ self.location = location
182
+ self.source = source
183
+ self.contents = contents
184
+ }
185
+
186
+ /// The source payload as `T`, else the source exported into a type `T`
187
+ /// imports, then every outside content `T` imports; in that order.
188
+ @MainActor
189
+ public func values<T: Transferable>(of type: T.Type) async -> [T] {
190
+ var values: [T] = []
191
+ if let source {
192
+ if let value = source as? T {
193
+ values.append(value)
194
+ } else if let item = source as? any Transferable,
195
+ let content = try? await item._export(as: _Transfer.importedContentTypes(T.self)),
196
+ let value = try? await _Transfer.import(T.self, from: content) {
197
+ values.append(value)
198
+ }
199
+ }
200
+ for content in contents {
201
+ if let value = try? await _Transfer.import(T.self, from: content) { values.append(value) }
202
+ }
203
+ return values
204
+ }
205
+
206
+ /// The `items` of a `drop` event as transfer contents: `{type, text}`
207
+ /// for text and links, `{type, name, data}` (base64) for files.
208
+ static func contents(of items: _JSON?) -> [_TransferContent] {
209
+ guard case .array(let list)? = items else { return [] }
210
+ return list.compactMap { item -> _TransferContent? in
211
+ guard case .object(let fields) = item else { return nil }
212
+ let mime: String = if case .string(let type)? = fields["type"] { type } else { "" }
213
+ if case .string(let text)? = fields["text"] {
214
+ let isLink = mime == "text/uri-list"
215
+ return _TransferContent(contentType: isLink ? .url : .utf8PlainText, data: Data(text.utf8))
216
+ }
217
+ guard case .string(let encoded)? = fields["data"], let data = Data(base64Encoded: encoded) else { return nil }
218
+ let name: String? = if case .string(let name)? = fields["name"] { name } else { nil }
219
+ let type = UTType(mimeType: mime) ?? name.flatMap { UTType(filenameExtension: ($0 as NSString).pathExtension) } ?? .data
220
+ return _TransferContent(contentType: type, data: data, suggestedName: name.map { ($0 as NSString).deletingPathExtension })
221
+ }
222
+ }
223
+ }
224
+
225
+ extension Transferable {
226
+ /// `self` exported in the first of `types` it exports; nil if none.
227
+ nonisolated(nonsending) func _export(as types: [UTType]) async throws -> _TransferContent? {
228
+ for type in types {
229
+ if let content = try await _Transfer.export(self, as: type) { return content }
230
+ }
231
+ return nil
232
+ }
233
+ }
234
+
235
+ /// The MIME patterns (`text/plain`, `image/*`, `*/*`) of what dragged in from
236
+ /// outside the page a destination accepting `types` takes: the page matches
237
+ /// them against the drag's types. A type's own MIME type (`application/json`)
238
+ /// when it declares one; any text for a text type, any image for `image`,
239
+ /// anything for `data`, `item`, `content` and files. An app's own type that
240
+ /// no file or text carries matches nothing.
241
+ public func _dropAccepts(_ types: [UTType]) -> [String] {
242
+ var out: [String] = []
243
+ for type in types {
244
+ let pattern: String? =
245
+ if type == .data || type == .item || type == .content || type == .fileURL { "*/*" }
246
+ else if let mime = type.preferredMIMEType { mime }
247
+ else if type.conforms(to: .text) { "text/plain" }
248
+ else if type.conforms(to: .url) { "text/uri-list" }
249
+ else if type.conforms(to: .image) { "image/*" }
250
+ else { nil }
251
+ if let pattern, !out.contains(pattern) { out.append(pattern) }
252
+ }
253
+ return out
254
+ }
255
+
256
+ /// `.dropDestination` / `onDrop`: the content under a `dropdestination`
257
+ /// element. `targeted` is told when a drag enters and leaves, `perform` when
258
+ /// one drops; both receive what is offered.
259
+ public struct _DropDestinationView<Content: View> {
260
+ let content: Content
261
+ let accepts: [String]
262
+ let targeted: @MainActor (Bool, _DropItems) -> Void
263
+ let perform: @MainActor (_DropItems) -> Bool
264
+
265
+ public init(content: Content, accepts: [String],
266
+ targeted: @escaping @MainActor (Bool, _DropItems) -> Void,
267
+ perform: @escaping @MainActor (_DropItems) -> Bool) {
268
+ self.content = content
269
+ self.accepts = accepts
270
+ self.targeted = targeted
271
+ self.perform = perform
272
+ }
273
+ }
274
+ extension _DropDestinationView: _PrimitiveView {}
275
+ extension _DropDestinationView: _HostView {
276
+ var _kind: String { "dropdestination" }
277
+ func _props(in environment: EnvironmentValues) -> _JSON {
278
+ .object(["accepts": .array(accepts.map { .string($0) })])
279
+ }
280
+ var _content: [any View] { [content] }
281
+ var _handler: ((_JSON) -> Void)? {
282
+ let targeted = self.targeted
283
+ let perform = self.perform
284
+ return { event in
285
+ guard case .object(let fields) = event, case .string("drop")? = fields["type"],
286
+ case .string(let phase)? = fields["phase"] else { return }
287
+ var location = CGPoint.zero
288
+ if case .number(let x)? = fields["x"], case .number(let y)? = fields["y"] { location = CGPoint(x: x, y: y) }
289
+ MainActor.assumeIsolated {
290
+ var source: Any?
291
+ if case .number(let id)? = fields["source"], let current = _DragSession.current, current.source == Int(id) {
292
+ source = current.payload
293
+ }
294
+ let items = _DropItems(location: location, source: source, contents: _DropItems.contents(of: fields["items"]))
295
+ switch phase {
296
+ case "entered": targeted(true, items)
297
+ case "exited": targeted(false, items)
298
+ case "performed":
299
+ targeted(false, items)
300
+ _ = perform(items)
301
+ default: break
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
121
307
 
122
308
  extension View {
123
- /// Accepted; the view cannot be dragged in the browser.
124
- public func draggable<T: Transferable>(_ payload: @autoclosure @escaping () -> T) -> some View { self }
309
+ /// The view can be dragged: a long press lifts it on a touch screen, a
310
+ /// drag does with a mouse. `payload` is made when the drag begins and
311
+ /// reaches the `dropDestination` (or `onDrop`) it is dropped on.
312
+ public func draggable<T: Transferable>(_ payload: @autoclosure @escaping () -> T) -> some View {
313
+ _DraggableView(content: self) { payload() }
314
+ }
125
315
 
126
- /// Accepted; the view cannot be dragged in the browser.
316
+ /// The page drags a copy of the view itself; `preview` is accepted and not drawn.
127
317
  public func draggable<T: Transferable, Preview: View>(_ payload: @autoclosure @escaping () -> T,
128
- @ViewBuilder preview: () -> Preview) -> some View { self }
318
+ @ViewBuilder preview: () -> Preview) -> some View {
319
+ _DraggableView(content: self) { payload() }
320
+ }
129
321
 
130
- /// Accepted; nothing is dropped in the browser, so `action` is never called
131
- /// and `isTargeted` never reports true.
322
+ /// The view takes drops of `T`: a dragged view's payload when it is a `T`
323
+ /// (or exports a type `T` imports), and text, links or files dragged in
324
+ /// from outside the page that `T` imports. `isTargeted` is told while a
325
+ /// drag is over the view; `action` gets the values and the drop's point
326
+ /// in the view's coordinates. Values that import asynchronously reach
327
+ /// `action` after the drop.
132
328
  public func dropDestination<T: Transferable>(for payloadType: T.Type = T.self,
133
329
  action: @escaping ([T], CGPoint) -> Bool,
134
- isTargeted: @escaping (Bool) -> Void = { _ in }) -> some View { self }
330
+ isTargeted: @escaping (Bool) -> Void = { _ in }) -> some View {
331
+ _DropDestinationView(content: self, accepts: _dropAccepts(_Transfer.importedContentTypes(T.self)),
332
+ targeted: { targeted, _ in isTargeted(targeted) }) { items in
333
+ if let value = items.source as? T, items.contents.isEmpty { return action([value], items.location) }
334
+ let boxed = _UncheckedSendableBox((items, action))
335
+ Task { @MainActor in
336
+ let (items, action) = boxed.value
337
+ let values = await items.values(of: T.self)
338
+ if !values.isEmpty { _ = action(values, items.location) }
339
+ }
340
+ return true
341
+ }
342
+ }
135
343
 
136
344
  /// Accepted; the browser's copy command copies the page's selected text.
137
345
  public func copyable<T: Transferable>(_ payload: @autoclosure @escaping () -> [T]) -> some View { self }
@@ -27,16 +27,22 @@ struct _Element {
27
27
 
28
28
  /// Row metadata the runtime attaches while producing elements and consumes
29
29
  /// when it emits a `list`: a `.tag` (or a `ForEach` id, the implicit tag), a
30
- /// `NavigationLink(value:)`'s value and handler, and the `onDelete` of the
31
- /// row's `ForEach`.
30
+ /// `NavigationLink(value:)`'s value and handler, and the `onDelete` /
31
+ /// `onMove` of the row's `ForEach`.
32
32
  struct _RowMark {
33
33
  var tag: AnyHashable?
34
34
  var linkValue: AnyHashable?
35
35
  var linkHandler: ((_JSON) -> Void)?
36
36
  var delete: (() -> Void)?
37
37
  var deleteDisabled = false
38
+ /// The `ForEach`'s `onMove`: (the row's offset, the destination offset).
39
+ var move: ((Int, Int) -> Void)?
40
+ /// The `ForEach` node the row belongs to, and the row's offset in its data.
41
+ var moveGroup: ObjectIdentifier?
42
+ var moveOffset = 0
43
+ var moveDisabled = false
38
44
 
39
- var isEmpty: Bool { tag == nil && linkValue == nil && delete == nil && !deleteDisabled }
45
+ var isEmpty: Bool { tag == nil && linkValue == nil && delete == nil && !deleteDisabled && move == nil && !moveDisabled }
40
46
 
41
47
  /// Fills what `self` lacks from an inner element's mark.
42
48
  func merged(with inner: _RowMark) -> _RowMark {
@@ -48,6 +54,12 @@ struct _RowMark {
48
54
  }
49
55
  if result.delete == nil { result.delete = inner.delete }
50
56
  result.deleteDisabled = result.deleteDisabled || inner.deleteDisabled
57
+ if result.move == nil {
58
+ result.move = inner.move
59
+ result.moveGroup = inner.moveGroup
60
+ result.moveOffset = inner.moveOffset
61
+ }
62
+ result.moveDisabled = result.moveDisabled || inner.moveDisabled
51
63
  return result
52
64
  }
53
65
  }
@@ -72,7 +72,7 @@ extension String {
72
72
  /// The event types whose first field is a target.
73
73
  private let _targetedEvents: Set<String> = [
74
74
  "tap", "text", "toggle", "select", "step", "slide", "date", "geometry",
75
- "drag", "longpress", "delete", "refresh",
75
+ "drag", "longpress", "delete", "move", "drop", "refresh",
76
76
  ]
77
77
 
78
78
  /// The element kinds a selector picks for each event type, unless it names
@@ -84,6 +84,9 @@ private let _targetKinds: [String: Set<String>] = [
84
84
  "longpress": ["gesture"],
85
85
  "drag": ["gesture"],
86
86
  "delete": ["listrow"],
87
+ "move": ["listrow"],
88
+ "drop": ["dropdestination"],
89
+ "dragstart": ["draggable"],
87
90
  "text": ["textfield", "searchfield", "texteditor"],
88
91
  "toggle": ["toggle"],
89
92
  "select": ["picker", "tabview"],
@@ -350,6 +353,18 @@ extension _Reconciler {
350
353
  return find(in: roots)
351
354
  }
352
355
 
356
+ /// The index of the element with this id among its parent's children, if it is on the tree.
357
+ func containerIndex(of id: Int) -> Int? {
358
+ func find(in elements: [Rendered]) -> Int? {
359
+ if let index = elements.firstIndex(where: { $0.id == id }) { return index }
360
+ for element in elements {
361
+ if let found = find(in: element.children) { return found }
362
+ }
363
+ return nil
364
+ }
365
+ return find(in: roots)
366
+ }
367
+
353
368
  /// `back`: the visible screen's back button.
354
369
  func backTarget() throws(_ScriptError) -> Rendered {
355
370
  try resolve(_Selector(kind: "navscreen"), for: "tap")
@@ -250,7 +250,7 @@ public final class _Runtime {
250
250
  private var measuringRoot: _MountedNode?
251
251
  /// Set when measurements arrive: the next render re-runs every body, none skipped as unchanged.
252
252
  private var renderEverythingPass = false
253
- private let reconciler = _Reconciler()
253
+ let reconciler = _Reconciler()
254
254
  private var needsRender = false
255
255
  /// The next render re-runs every body (environment changes, `invalidate()`).
256
256
  private var fullRender = true
@@ -644,11 +644,14 @@ public final class _Runtime {
644
644
  // Host measurement of a custom `Layout`'s children; the handler
645
645
  // parses the whole event (see CustomLayout.swift).
646
646
  if let id { reconciler.handlers[id]?(event) }
647
- case "drag", "longpress", "delete", "refresh", "focus", "submit", "video", "lazy", "menu":
647
+ case "drag", "longpress", "delete", "move", "refresh", "focus", "submit", "video", "lazy", "menu",
648
+ "dragstart", "dragend", "drop":
648
649
  // Phase 8: the handler receives the whole event and reads `type`
649
- // (a `listrow` handles both `tap` and `delete`; Phase 12: a
650
+ // (a `listrow` handles `tap`, `delete` and `move`; Phase 12: a
650
651
  // `textfield` handles `text`, `focus` and `submit`; a `video`
651
- // reads its `event`, docs/ops-protocol.md, "Video and audio").
652
+ // reads its `event`, docs/ops-protocol.md, "Video and audio"; a
653
+ // `draggable` hears `dragstart` / `dragend` and a
654
+ // `dropdestination` `drop`, "Drag and drop").
652
655
  if let id { reconciler.handlers[id]?(event) }
653
656
  case "response":
654
657
  // Phase 13: the host's answer to a `request` op.
@@ -997,6 +1000,14 @@ public final class _Runtime {
997
1000
  if !clean { node.splitSelection = nil }
998
1001
  let sidebar = _SplitSidebarView(content: split._sidebar, split: node)
999
1002
  if context.environment.horizontalSizeClass == .regular {
1003
+ // The sidebar toggle (a `tap` on the split view) shows or
1004
+ // hides the sidebar, sliding the columns.
1005
+ let visibility = split._columnVisibility
1006
+ node.handler = { _ in
1007
+ withAnimation(.default) {
1008
+ visibility.wrappedValue = visibility.wrappedValue._showsSidebar ? .detailOnly : .all
1009
+ }
1010
+ }
1000
1011
  // Two columns, each its own stack; they are not nested in a
1001
1012
  // screen even when the split view is, so they are not flattened.
1002
1013
  context.navStack = nil
@@ -1585,11 +1596,12 @@ public final class _Runtime {
1585
1596
  // `navstack` whose screens are the sidebar, the detail and what
1586
1597
  // the detail pushed.
1587
1598
  let regular = node.environment.horizontalSizeClass == .regular
1599
+ let shows = (node.view as? any _NavigationSplitHost)?._columnVisibility.wrappedValue._showsSidebar ?? true
1588
1600
  produced = [_Element(
1589
1601
  kind: regular ? "navsplit" : "navstack",
1590
- props: .object([:]),
1602
+ props: regular ? .object(["sidebarVisible": .bool(shows)]) : .object([:]),
1591
1603
  children: node.children.flatMap(elements(of:)),
1592
- handler: nil
1604
+ handler: regular ? node.handler : nil
1593
1605
  )]
1594
1606
  } else if traits.contains(.navigationStack), node.navFlattened {
1595
1607
  produced = node.children.flatMap(elements(of:))
@@ -1681,9 +1693,11 @@ public final class _Runtime {
1681
1693
  produced[index].row = (produced[index].row ?? _RowMark()).merged(with: _RowMark(tag: tag))
1682
1694
  }
1683
1695
  }
1684
- if traits.contains(.rowEditingDisabling), let disabling = node.view as? any _RowEditingDisabling, disabling._deleteDisabled {
1696
+ if traits.contains(.rowEditingDisabling), let disabling = node.view as? any _RowEditingDisabling,
1697
+ disabling._deleteDisabled || disabling._moveDisabled {
1698
+ let mark = _RowMark(deleteDisabled: disabling._deleteDisabled, moveDisabled: disabling._moveDisabled)
1685
1699
  for index in produced.indices {
1686
- produced[index].row = (produced[index].row ?? _RowMark()).merged(with: _RowMark(deleteDisabled: true))
1700
+ produced[index].row = (produced[index].row ?? _RowMark()).merged(with: mark)
1687
1701
  }
1688
1702
  }
1689
1703
  if traits.contains(.layoutValueProviding), let providing = node.view as? any _LayoutValueProviding {
@@ -1722,11 +1736,15 @@ public final class _Runtime {
1722
1736
  /// the siblings. Without that the element diff matched the two groups'
1723
1737
  /// elements to each other on alternate passes, swapping their props.
1724
1738
  private func childElements(of node: _MountedNode) -> [_Element] {
1725
- let delete = _ViewTraits.of(node.view).contains(.deletableGroup) ? (node.view as? any _DeletableGroup)?._delete : nil
1739
+ let editable = _ViewTraits.of(node.view).contains(.deletableGroup) ? node.view as? any _DeletableGroup : nil
1740
+ let delete = editable?._delete
1741
+ let move = editable?._move
1742
+ let group = ObjectIdentifier(node)
1726
1743
  return node.children.enumerated().flatMap { position, child -> [_Element] in
1727
1744
  var items = elements(of: child)
1728
- // A `ForEach` id is the row's implicit tag; an `onDelete` group's
1729
- // child at `position` is the row at that offset of its data.
1745
+ // A `ForEach` id is the row's implicit tag; an `onDelete` /
1746
+ // `onMove` group's child at `position` is the row at that offset
1747
+ // of its data.
1730
1748
  if let key = child.key {
1731
1749
  for index in items.indices where items[index].row?.tag == nil {
1732
1750
  items[index].row = (items[index].row ?? _RowMark()).merged(with: _RowMark(tag: key))
@@ -1737,6 +1755,11 @@ public final class _Runtime {
1737
1755
  items[index].row = (items[index].row ?? _RowMark()).merged(with: _RowMark(delete: { delete(position) }))
1738
1756
  }
1739
1757
  }
1758
+ if let move {
1759
+ for index in items.indices where items[index].row?.move == nil {
1760
+ items[index].row = (items[index].row ?? _RowMark()).merged(with: _RowMark(move: move, moveGroup: group, moveOffset: position))
1761
+ }
1762
+ }
1740
1763
  if child.key == nil, _ViewTraits.of(child.view).contains(.group), let group = child.view as? any _GroupView, group._keys != nil {
1741
1764
  for index in items.indices {
1742
1765
  if let key = items[index].key {
@@ -1748,15 +1771,19 @@ public final class _Runtime {
1748
1771
  }
1749
1772
  }
1750
1773
 
1751
- /// Wraps a `list`'s rows (also those of its `section`s) that can be selected
1752
- /// or deleted in `listrow` elements (docs/ops-protocol.md, Phase 8). A row
1774
+ /// Wraps a `list`'s rows (also those of its `section`s) that can be selected,
1775
+ /// deleted or moved in `listrow` elements (docs/ops-protocol.md, Phase 8). A row
1753
1776
  /// is selectable when the list has a selection and the row's `.tag`,
1754
1777
  /// `ForEach` id or `NavigationLink(value:)` is of the selection's type;
1755
1778
  /// deletable when its `ForEach` has `onDelete` and the row is not
1756
- /// `deleteDisabled`. The row's key moves to the wrapper.
1779
+ /// `deleteDisabled`; movable when its `ForEach` has `onMove` and the row
1780
+ /// is not `moveDisabled`. The row's key moves to the wrapper.
1757
1781
  private func wrapListRows(_ elements: [_Element], in node: _MountedNode) -> [_Element] {
1758
1782
  let selection = node.environment._listSelection
1759
1783
  let editMode = node.environment.editMode
1784
+ // Which `ForEach` each child belongs to, by position: a `move` event's
1785
+ // destination counts the children of the moved row's group before it.
1786
+ let groups = elements.map { $0.row?.moveGroup }
1760
1787
  return elements.map { element in
1761
1788
  if element.kind == "section" {
1762
1789
  var section = element
@@ -1767,7 +1794,8 @@ public final class _Runtime {
1767
1794
  let value = mark.linkValue ?? mark.tag
1768
1795
  let selectable: Bool = if let selection, let value { selection.accepts(value) } else { false }
1769
1796
  let deletable = mark.delete != nil && !mark.deleteDisabled
1770
- guard selectable || deletable else { return element }
1797
+ let movable = mark.move != nil && !mark.moveDisabled
1798
+ guard selectable || deletable || movable else { return element }
1771
1799
  let selected = selectable && selection!.contains(value!)
1772
1800
 
1773
1801
  var inner = element
@@ -1778,6 +1806,18 @@ public final class _Runtime {
1778
1806
  if deletable { mark.delete?() }
1779
1807
  return
1780
1808
  }
1809
+ if case .object(let fields) = payload, case .string("move")? = fields["type"] {
1810
+ // `to`: the index among the container's children before
1811
+ // which the row goes, counted before it leaves (as
1812
+ // `move(fromOffsets:toOffset:)` takes its destination).
1813
+ // In the group's data that is the number of the group's
1814
+ // rows before that index.
1815
+ guard movable, let move = mark.move, case .number(let to)? = fields["to"] else { return }
1816
+ let bound = Swift.max(0, Swift.min(Int(to), groups.count))
1817
+ let destination = groups[0..<bound].filter { $0 == mark.moveGroup }.count
1818
+ move(mark.moveOffset, destination)
1819
+ return
1820
+ }
1781
1821
  guard selectable, let selection, let value else {
1782
1822
  mark.linkHandler?(.null)
1783
1823
  return
@@ -1792,13 +1832,15 @@ public final class _Runtime {
1792
1832
  selection.set(value)
1793
1833
  }
1794
1834
  }
1835
+ var props: [String: _JSON] = [
1836
+ "selectable": .bool(selectable),
1837
+ "selected": .bool(selected),
1838
+ "deletable": .bool(deletable),
1839
+ ]
1840
+ if movable { props["movable"] = .bool(true) }
1795
1841
  return _Element(
1796
1842
  kind: "listrow",
1797
- props: .object([
1798
- "selectable": .bool(selectable),
1799
- "selected": .bool(selected),
1800
- "deletable": .bool(deletable),
1801
- ]),
1843
+ props: .object(props),
1802
1844
  children: [inner],
1803
1845
  handler: handler,
1804
1846
  key: element.key
@@ -1843,7 +1885,9 @@ public final class _Runtime {
1843
1885
  /// `text:"Name":Milk`, `toggle:"Done":true`, `select:"Sort":"Title"`,
1844
1886
  /// `env:dark`, `type:xxxLarge`, `size:regular`, `step:"8 hours":1`,
1845
1887
  /// `slide:7:0.35`, `date:7:1700000000`, `drag:12:changed:40x-3`,
1846
- /// `longpress:12`, `delete:"Coffee"`, `refresh:40`, `back`, `dismiss`.
1888
+ /// `longpress:12`, `delete:"Coffee"`, `move:"Coffee":2` /
1889
+ /// `move:"Coffee":"Lunch"`, `drop:"Trash":"Coffee"` / `drop:"Trash":Hello`,
1890
+ /// `refresh:40`, `back`, `dismiss`.
1847
1891
  /// Each is echoed, with the id its target resolved to, before its ops.
1848
1892
  /// Returns false, after printing an error op, at the first event that
1849
1893
  /// cannot be parsed or whose selector does not name exactly one element.
@@ -1863,6 +1907,13 @@ public final class _Runtime {
1863
1907
  print(_JSON.object(["op": .string("error"), "message": .string(error.message)]).serialized)
1864
1908
  return false
1865
1909
  }
1910
+ if event["type"] == .string("drop"), case .number(let source)? = event["source"] {
1911
+ dispatch(event: .object(["type": .string("dragstart"), "id": .number(source)]))
1912
+ host.apply([.event(.object(event))])
1913
+ dispatch(event: .object(event))
1914
+ dispatch(event: .object(["type": .string("dragend"), "id": .number(source)]))
1915
+ continue
1916
+ }
1866
1917
  host.apply([.event(.object(event))])
1867
1918
  dispatch(event: .object(event))
1868
1919
  }
@@ -1938,6 +1989,30 @@ public final class _Runtime {
1938
1989
  guard rest.count == 2 else { throw unrecognized }
1939
1990
  event["phase"] = .string(rest[0])
1940
1991
  event["translation"] = try size(rest[1])
1992
+ case "move":
1993
+ // move:"Coffee":2 (the index among the row's container's children
1994
+ // before which it goes) or move:"Coffee":"Lunch" (before that row).
1995
+ if parsed.valueQuoted, let text = value {
1996
+ let row = try reconciler.resolve(_Selector(text: text), for: "move")
1997
+ guard let index = reconciler.containerIndex(of: row.id) else { throw unrecognized }
1998
+ event["to"] = .number(Double(index))
1999
+ } else {
2000
+ event["to"] = try number(value)
2001
+ }
2002
+ case "drop":
2003
+ // drop:"Trash":"Coffee" (the draggable showing Coffee, dropped on
2004
+ // the destination showing Trash) or drop:"Trash":Hello (the text
2005
+ // "Hello" dropped from outside the page). A dragged source starts
2006
+ // and ends its drag around the drop (replayed as three events).
2007
+ event["phase"] = .string("performed")
2008
+ event["x"] = .number(0)
2009
+ event["y"] = .number(0)
2010
+ if parsed.valueQuoted, let text = value {
2011
+ let source = try reconciler.resolve(_Selector(text: text), for: "dragstart")
2012
+ event["source"] = .number(Double(source.id))
2013
+ } else {
2014
+ event["items"] = .array([.object(["type": .string("text/plain"), "text": .string(value ?? "")])])
2015
+ }
1941
2016
  default:
1942
2017
  throw unrecognized
1943
2018
  }
@@ -34,13 +34,14 @@ open class UIFont: NSObject, NSSecureCoding, @unchecked Sendable {
34
34
  // MARK: Metrics (SF Pro's, per point)
35
35
 
36
36
  public var familyName: String { design == .rounded ? ".AppleSystemUIFontRounded" : ".AppleSystemUIFont" }
37
- public var ascender: CGFloat { pointSize * 0.952 }
38
- public var descender: CGFloat { -pointSize * 0.241 }
37
+ public var ascender: CGFloat { pointSize * 1950 / 2048 }
38
+ public var descender: CGFloat { -pointSize * 494 / 2048 }
39
39
  public var capHeight: CGFloat { pointSize * 0.705 }
40
40
  public var xHeight: CGFloat { pointSize * 0.53 }
41
41
  public var leading: CGFloat { 0 }
42
- /// The line height iOS uses for the standard sizes (17 → 22), else 1.2 × the size.
43
- public var lineHeight: CGFloat { UIFont.standardLineHeights[pointSize] ?? (pointSize * 1.2).rounded() }
42
+ /// The ascender plus the descender, as on iOS (17 → 20.29); SwiftUI makes
43
+ /// a line of `Text` this tall, rounded up to a pixel (typography.ts).
44
+ public var lineHeight: CGFloat { ascender - descender }
44
45
 
45
46
  public var fontDescriptor: UIFontDescriptor {
46
47
  UIFontDescriptor(fontName: fontName, pointSize: pointSize, weight: weight, design: design)
@@ -128,8 +129,6 @@ open class UIFont: NSObject, NSSecureCoding, @unchecked Sendable {
128
129
  super.init()
129
130
  }
130
131
 
131
- static let standardLineHeights: [CGFloat: CGFloat] = [34: 41, 28: 34, 22: 28, 20: 25, 17: 22, 16: 21, 15: 20, 13: 18, 12: 16, 11: 13]
132
-
133
132
  // MARK: Nested types
134
133
 
135
134
  public struct Weight: RawRepresentable, Hashable, Sendable {
@@ -159,7 +159,9 @@ enum _TextEstimate {
159
159
  }
160
160
  if maximumLines > 0 { lines = min(lines, maximumLines) }
161
161
  let resolvedWidth = width.flatMap { $0 > 0 && $0.isFinite ? min($0, widest) : nil } ?? widest
162
- return CGSize(width: resolvedWidth.rounded(.up), height: CGFloat(max(1, lines)) * font.lineHeight)
162
+ // Whole 3x pixels, as iOS rounds a text's size up.
163
+ let height = (CGFloat(max(1, lines)) * font.lineHeight * 3).rounded(.up) / 3
164
+ return CGSize(width: resolvedWidth.rounded(.up), height: height)
163
165
  }
164
166
  }
165
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/swift",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "The SwiftBrowser Swift package (the SwiftUI shim, Charts, SwiftData and FoundationBridge) as an npm package, so @swiftbrowser/cli builds apps against the exact version it ships with.",
5
5
  "repository": {
6
6
  "type": "git",