Repository navigation
Expand file tree
/
Copy pathUIKit.lua
More file actions
1762 lines (1668 loc) · 80.5 KB
/
Copy pathUIKit.lua
File metadata and controls
1762 lines (1668 loc) · 80.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- UIKitNative is registered by the host before this layer runs.
local bridge = require("UIKitNative")
local UIKit = bridge
UIKit.platform = "UIKit"
local Scope = require("ui.scope")(bridge)
UIKit.Scope = Scope
local function resumeCoroutine(co, ...)
local ok, err = coroutine.resume(co, ...)
if not ok then
io.stderr:write("coroutine error: " .. tostring(err) .. "\n")
end
return ok, err
end
local layout_properties = {
"padding",
"paddingHorizontal",
"paddingLeading",
"paddingTrailing",
"paddingVertical",
"paddingTop",
"paddingBottom",
"spacing",
"maxRows",
"trailingMaxWidthFraction",
"alignment",
"fixedWidth",
"fixedHeight",
"minWidth",
"minHeight",
"maxWidth",
"maxHeight",
"flexGrow",
"flexShrink",
"flexBasis",
"fillWidth",
"containerRelativeWidth",
"fillHeight",
"fitDiameter",
"fixedSize",
"hidden",
"allowsHitTesting",
"cornerRadius",
"clipsToBounds",
"opacity",
"offsetX",
"offsetY",
"colorScheme",
"ignoresSafeArea",
"contentModeName",
"background",
"tint",
"onTap",
"onClick",
"onDrag",
"onEdgeSwipe",
}
local function applyLayout(view, props)
if type(props) ~= "table" then return view end
for _, key in ipairs(layout_properties) do
if props[key] ~= nil then
if key == "background" then
view.backgroundColor = bridge._systemColor(props[key])
elseif key == "tint" then
-- SwiftUI `.tint`: descendants that draw in the accent colour
-- (UIColor.tintColor) take this colour instead.
view.tintColor = bridge._systemColor(props[key])
elseif key == "onTap" or key == "onClick" then
bridge._addTap(view, props[key])
elseif key == "onDrag" then
bridge._addDrag(view, props[key])
elseif key == "onEdgeSwipe" then
bridge._addEdgeSwipe(view, props[key])
else
view[key] = props[key]
end
end
end
return view
end
local function addChildren(parent, children)
if type(children) ~= "table" then return end
for _, child in ipairs(children) do
if type(child) == "userdata" then
parent:add(child)
elseif type(child) == "table" and child.__appkitGroup then
addChildren(parent, child)
end
end
end
local function asViewController(content)
if content == nil then
error("UIKit.Window requires content")
end
if type(content) == "table" then
local stack = UIKit.VStack(content)
return bridge._hostingController(stack)
end
local ok, vc = pcall(function()
return bridge._hostingController(content)
end)
if ok then return vc end
return content
end
--- Creates the app window and hosts the rendered root view.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop appearance string optional. Window appearance: `system`, `light`, or `dark`.
--- @prop orientation string optional. `landscape` or `portrait` locks the screen to it; a game played with
--- on-screen controls is landscape. Every orientation the app allows by default.
--- @prop content value optional. Rendered child content or the control’s text value.
--- @prop title value optional. Component-specific setting passed to the native control.
--- @example <Window title="Example" />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.Window(props)
props = props or {}
local scope = Scope.push()
local content = props.content or props[1]
local vc = asViewController(content)
local userInterfaceStyles = { automatic = 0, light = 1, dark = 2 }
if props.appearance and userInterfaceStyles[props.appearance] ~= nil then
vc.overrideUserInterfaceStyle = userInterfaceStyles[props.appearance]
end
-- If scene installation fails, do not leak the pushed scope.
local ok, win = pcall(bridge._installScene, vc, props.title or "", props.orientation)
if not ok then
scope:close()
error(win, 2)
end
-- Like AppKit, window teardown closes the scope automatically so apps
-- never call dispose by hand.
if bridge._onWindowClose then
bridge._onWindowClose(win, function()
scope:close()
end)
end
return win
end
--- Presents mutually exclusive content in native tabs.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop onChange function optional. Callback invoked when the value changes.
--- @prop selected table optional. Selected option, tab, or row identifier.
--- @prop tabs table optional. Tab definitions containing a title and content.
--- @prop accessory table optional. A `TabAccessory` record: SwiftUI `tabViewBottomAccessory`.
--- @example <TabView />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.TabView(props)
props = props or {}
local tbc = bridge._tabview(props.minimizeBehavior or "automatic")
local tabs = props.tabs or {}
for _, tab in ipairs(tabs) do
if type(tab) == "table" and tab.__tab then
local vc = asViewController(tab.content)
bridge._tabViewAddTab(tbc, vc, tab.title or "", tab.systemImage or "", tab.role or "")
end
end
if props.selected ~= nil then
bridge._tabViewSelectTab(tbc, props.selected)
end
if type(props.onChange) == "function" then
bridge._tabViewOnChange(tbc, props.onChange)
end
if props.accessory then
bridge._tabViewAccessory(tbc, props.accessory.content, props.accessory.hidden == true)
end
return tbc
end
--- Creates a fixed-size preview root for the IDE canvas.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @example <Preview />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.Preview(props)
return applyLayout(bridge._preview(), props or {})
end
function UIKit.HostingController(view, onDisappear, props)
props = props or {}
local controller = bridge._hostingController(view, onDisappear,
props.hidesNavigationBar == true)
if props and props.hidesTabBar ~= nil then
controller.hidesBottomBarWhenPushed = props.hidesTabBar == true
end
return controller
end
--- A navigation destination: one content view plus its title, toolbar items
--- and presentation, as SwiftUI's `.navigationTitle` and `.toolbar` modifiers.
--- Push the returned controller onto a NavigationStack.
--- @tag Page
--- @prop title string optional. Navigation title.
--- @prop toolbar table optional. ToolbarItem records; `placement` is principal, primaryAction, topBarLeading, topBarTrailing, cancellationAction, confirmationAction or keyboard (one view child, shown above the keyboard while a text input edits; AppKit leaves it out).
--- @prop hidesTabBar boolean optional. Hides the tab bar while the page is visible.
--- @prop titleDisplayMode string optional. automatic, inline or large.
--- @prop backButtonDisplayMode string optional. default, generic or minimal.
--- @prop onDisappear function optional. Called once when the page leaves the stack.
--- @platform AppKit puts toolbar items and a back item in the window toolbar. UIKit uses the navigation bar.
function UIKit.Page(props)
assert(type(props) == "table" and type(props.content) == "userdata", "Page requires one content view")
local controller = UIKit.HostingController(props.content, props.onDisappear, {
hidesTabBar = props.hidesTabBar,
hidesNavigationBar = props.hidesNavigationBar,
})
controller.title = props.title or ""
bridge._pageToolbar(controller, props.toolbar or {}, {
titleDisplayMode = props.titleDisplayMode,
backButtonDisplayMode = props.backButtonDisplayMode,
})
return controller
end
--- Describes content for a platform sheet presentation.
--- @platform AppKit creates a native sheet panel. UIKit hosts its single child view controller.
function UIKit.Sheet(props)
props = props or {}
assert(type(props[1]) == "userdata" and props[2] == nil,
"Sheet requires exactly one content child")
return props[1]
end
local navScreenScopes = setmetatable({}, { __mode = "k" })
local sheetScopes = {}
--- Manages a stack of screens and navigation transitions.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop content value optional. Rendered child content or the control’s text value.
--- @prop path table optional. A `ui.navigation`.Path value path.
--- @prop destinations table optional. Map path types to destination view builders.
--- @prop hidesNavigationBar boolean optional. Hides the navigation bar when true.
--- @prop hidesTabBar boolean optional. Hides the tab bar when true.
--- @prop largeTitle boolean optional. Uses the large navigation title style when true.
--- @prop toolbar table optional. ToolbarItem records for the root view, placed as on a `Page`.
--- @prop subtitle string optional. Second line under the root's title (SwiftUI `.navigationSubtitle`).
--- @prop background string optional. Color behind the root view where it does not reach, such as behind the keyboard.
--- @prop title value optional. Component-specific setting passed to the native control.
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.NavigationStack(props)
props = props or {}
local content = props.content or props[1]
local root = asViewController(content)
local navigation = bridge._navigationStack(root, props.hidesNavigationBar == true)
if props.title then root.title = props.title end
-- SwiftUI `.navigationSubtitle`: a second line under the title (iOS 26).
if props.subtitle and props.subtitle ~= "" then root.navigationItem.subtitle = props.subtitle end
if props.toolbar then bridge._pageToolbar(root, props.toolbar) end
-- What shows where the root does not reach: the root ends at the
-- keyboard's top, so this color fills behind the keyboard's corners.
if props.background then root.view.backgroundColor = bridge._systemColor(props.background) end
if props.largeTitle ~= nil then
navigation.navigationBar.prefersLargeTitles = props.largeTitle
end
if props.hidesTabBar ~= nil then
root.hidesBottomBarWhenPushed = props.hidesTabBar
end
if props.topPalette then
bridge._navigationPalette(navigation, props.topPalette, "top",
props.enablePrivateNavigationPalettes == true)
end
if props.bottomPalette then
bridge._navigationPalette(navigation, props.bottomPalette, "bottom",
props.enablePrivateNavigationPalettes == true)
end
navScreenScopes[navigation] = {}
if props.path then
for kind, builder in pairs(props.destinations or {}) do
props.path:registerDestination(kind, builder)
end
require("ui.navigation").bindPath(props.path,
function(value, builder)
UIKit.pushScreen(navigation, type(value) == "table" and value.title or nil,
function() return builder(value) end)
end,
function() UIKit.popScreen(navigation) end)
end
return navigation
end
function UIKit.pushScreen(nav, title, builder)
assert(nav ~= nil, "pushScreen requires a navigation stack")
assert(type(builder) == "function", "pushScreen requires a builder function")
local stack = navScreenScopes[nav]
assert(stack ~= nil, "pushScreen requires a NavigationStack host")
local screenScope = Scope.push()
local ok, content = pcall(builder)
if not ok then
screenScope:close()
error(content, 2)
end
local vc = asViewController(content)
if title then vc.title = title end
nav:push(vc)
table.insert(stack, screenScope)
return vc
end
function UIKit.popScreen(nav)
assert(nav ~= nil, "popScreen requires a navigation stack")
local stack = navScreenScopes[nav]
assert(stack ~= nil, "popScreen requires a NavigationStack host")
nav:pop()
local screenScope = table.remove(stack)
if screenScope then screenScope:close() end
end
--- Navigates to a destination within a navigation stack.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop destination string optional. Navigation destination associated with the link.
--- @prop label value optional. Component-specific setting passed to the native control.
--- @prop navigation string optional. Navigation stack that receives the destination.
--- @prop title value optional. Component-specific setting passed to the native control.
--- @platform UIKit uses the UIKit implementation.
function UIKit.NavigationLink(props)
props = props or {}
assert(props.navigation, "NavigationLink requires a navigation stack")
assert(props.destination, "NavigationLink requires a destination")
local destination = asViewController(props.destination)
return applyLayout(bridge._navigationLink(props.navigation, destination,
props.title or props.label or props[1] or "Open"), props)
end
-- Shares AppKit.presentSheet's signature. `options.detents` selects sheet
-- heights; `options.parent` is ignored because UIKit presents from the
-- active view controller. Builder results (sheet, refs) are returned intact.
function UIKit.presentSheet(contentOrBuilder, options)
options = options or {}
local sheetScope = Scope.push()
local results = table.pack(true, contentOrBuilder)
if type(contentOrBuilder) == "function" then
results = table.pack(pcall(contentOrBuilder))
if not results[1] then
sheetScope:close()
error(results[2], 2)
end
end
bridge._presentSheet(asViewController(results[2]), options)
table.insert(sheetScopes, sheetScope)
return table.unpack(results, 2, results.n)
end
function UIKit.dismiss()
local sheetScope = table.remove(sheetScopes)
local ok, err = pcall(bridge._dismiss)
if sheetScope then sheetScope:close() end
if not ok then error(err, 2) end
end
function UIKit.Font(props)
assert(type(props) == "table" and tonumber(props.size), "Font requires a size")
return bridge._font(props.size, props.weight, props.italic == true, props.design,
props.monospacedDigit == true, props.fontName, props.smallCaps == true)
end
function UIKit.Color(name)
return bridge._systemColor(name)
end
function UIKit.confirm(props)
props = props or {}
return bridge._confirm(props.title or "Confirm", props.message or "",
props.destructive or "OK", props.cancel or "Cancel", props.action)
end
--- Arranges child views vertically with sibling spacing.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @example <VStack />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.VStack(props)
local view = bridge._vstack()
if type(props) == "table" then
applyLayout(view, props)
addChildren(view, props)
end
return view
end
-- Attach the native drag interaction to a collection of item views.
function UIKit.attachReorder(container, children, onReorder)
assert(type(onReorder) == "function", "reorder container requires an action")
local Difference = require("ui.reorder").Difference
bridge._attachReorder(container, children, function(from, to)
onReorder(Difference.new():move(from, to))
end)
return container
end
--- Arranges child views horizontally with sibling spacing.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @example <HStack />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.HStack(props)
local view = bridge._hstack()
if type(props) == "table" then
applyLayout(view, props)
addChildren(view, props)
end
return view
end
--- Wraps child views into additional rows or columns as space runs out.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <FlowStack />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.FlowStack(props)
local view = bridge._flowStack()
if type(props) == "table" then
applyLayout(view, props)
addChildren(view, props)
end
return view
end
local function stackChildren(props, header)
local content = {
spacing = props.spacing or 8,
alignment = props.alignment or "leading",
}
if header and header ~= "" then
table.insert(content, (UIKit.Text({ header, weight = "bold" })))
end
for _, child in ipairs(props) do table.insert(content, child) end
return content
end
--- Groups related content and may display a header.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop header table optional. Section or group heading.
--- @example <Section />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.Section(props)
props = props or {}
return UIKit.VStack(stackChildren(props, props.header))
end
--- Groups related controls inside a titled native box.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop background color optional. Background color or semantic background value.
--- @prop cornerRadius number optional. Corner radius for this component where supported.
--- @prop header table optional. Section or group heading.
--- @prop padding number optional. Component-specific setting passed to the native control.
--- @example <GroupBox />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.GroupBox(props)
props = props or {}
local content = stackChildren(props, props.header)
content.padding = props.padding or 12
-- SwiftUI's GroupBox fills with the secondary system background.
content.background = props.background or "secondaryBackground"
content.cornerRadius = props.cornerRadius or 10
content.clipsToBounds = true
-- SwiftUI's GroupBox takes the width it is offered.
content.fillWidth = true
return UIKit.VStack(content)
end
--- Arranges controls as a settings or data-entry form.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <Form />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.Form(props)
props = props or {}
local content = {
spacing = props.spacing or 12,
alignment = props.alignment or "leading",
}
for _, child in ipairs(props) do table.insert(content, child) end
return applyLayout(UIKit.VStack(content), props)
end
--- Pairs a descriptive label with its value or child controls.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop label value optional. Component-specific setting passed to the native control.
--- @prop labelWeight value optional. System font weight for the label.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <LabeledContent />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.LabeledContent(props)
props = props or {}
local row = { spacing = props.spacing or 12, alignment = "center" }
if props.label and props.label ~= "" then
table.insert(row, (UIKit.Text({ props.label, weight = props.labelWeight })))
end
for _, child in ipairs(props) do table.insert(row, child) end
return applyLayout(UIKit.HStack(row), props)
end
--- Groups related controls into a compact row.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <ControlGroup />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.ControlGroup(props)
props = props or {}
local row = { spacing = props.spacing or 8, alignment = props.alignment or "center" }
for _, child in ipairs(props) do table.insert(row, child) end
return applyLayout(UIKit.HStack(row), props)
end
--- Shows a header that expands or collapses its child content.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop expanded boolean optional. Component-specific setting passed to the native control.
--- @prop header table optional. Section or group heading.
--- @prop label value optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <DisclosureGroup />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.DisclosureGroup(props)
props = props or {}
local content = UIKit.VStack({
spacing = props.spacing or 8,
alignment = props.alignment or "leading",
})
for _, child in ipairs(props) do content:add(child) end
local container = UIKit.VStack { spacing = 8, alignment = "leading" }
local expanded = props.expanded ~= false
local button = UIKit.Button {
title = props.label or props.header or "Details",
action = function()
expanded = not expanded
content.hidden = not expanded
container:layout()
end,
}
container:add(button)
container:add(content)
content.hidden = not expanded
return applyLayout(container, props)
end
local function outlineItems(ns, items, expanded)
local views = {}
for _, item in ipairs(items or {}) do
local title = item.title or item.name or tostring(item)
local children = item.children
if type(children) == "table" and #children > 0 then
local nested = ns.VStack { spacing = 4, alignment = "leading" }
for _, child in ipairs(outlineItems(ns, children, expanded)) do
nested:add(child)
end
table.insert(views, (ns.DisclosureGroup {
label = title,
expanded = expanded,
nested,
}))
else
table.insert(views, (ns.Text(title)))
end
end
return views
end
--- Builds a nested disclosure hierarchy from tree data.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop data table optional. Input rows or values consumed by the component.
--- @prop expanded boolean optional. Component-specific setting passed to the native control.
--- @prop items table optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @example <OutlineGroup />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.OutlineGroup(props)
props = props or {}
local data = props.data or props.items or {}
assert(type(data) == "table", "OutlineGroup requires data or items")
local content = outlineItems(UIKit, data, props.expanded)
content.spacing = props.spacing or 4
content.alignment = props.alignment or "leading"
return applyLayout(UIKit.VStack(content), props)
end
--- Layers child views in the same coordinate area.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @example <ZStack />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.ZStack(props)
local view = bridge._zstack()
if type(props) == "table" then
applyLayout(view, props)
addChildren(view, props)
end
return view
end
-- UIScrollViewKeyboardDismissMode for each SwiftUI ScrollDismissesKeyboardMode.
local KEYBOARD_DISMISS_MODES = { automatic = 0, never = 0, immediately = 1, interactively = 2 }
--- Adds native scrolling around one content view.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop content value optional. Rendered child content or the control’s text value.
--- @prop contentHeight number optional. Scroll content height; zero lets the content size itself.
--- @prop contentWidth number optional. Scroll content width; zero lets the content size itself.
--- @prop horizontal boolean optional. Component-specific setting passed to the native control.
--- @prop scrollDismissesKeyboard string optional. `automatic`, `immediately`, `interactively` or `never`, as SwiftUI's modifier.
--- @prop vertical boolean optional. Component-specific setting passed to the native control.
--- @example <ScrollView />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.ScrollView(props)
assert(type(props) == "table", "ScrollView requires a property table")
local content = props.content or props[1]
assert(type(content) == "userdata", "ScrollView requires one content view")
local view = bridge._scrollView(content, props.contentWidth or 0,
props.contentHeight or 0, props.horizontal == true, props.vertical ~= false)
if props.scrollOnKeyboard then view.scrollOnKeyboard = true end
-- SwiftUI scrollDismissesKeyboard. `interactively` lets the keyboard
-- follow a downward drag as Messages does; `immediately` dismisses it when
-- a drag begins. `automatic` keeps UIKit's default, which leaves it up.
local dismissMode = KEYBOARD_DISMISS_MODES[props.scrollDismissesKeyboard or "automatic"]
assert(dismissMode, "ScrollView scrollDismissesKeyboard must be automatic, immediately, interactively or never")
view.keyboardDismissMode = dismissMode
-- SwiftUI scrollTargetBehavior: `viewAligned` settles on a child of the
-- content stack; `paging` pages by the visible width.
if props.scrollTargetBehavior then view.scrollTargetBehavior = props.scrollTargetBehavior end
return applyLayout(view, props)
end
--- Draws a circular arc in screen coordinates.
---
--- Angles are degrees, clockwise from east, in a y-down view. Equal angles
--- close the circle. `stroke` is a semantic color name.
--- @prop cornerRadius number optional. Draws the arc as a filled sector of its band with corners rounded this many points.
--- @prop endAngle number optional. Ending angle in degrees.
--- @prop height number optional. Component-specific setting passed to the native control.
--- @prop lineCap string optional. `butt` or `round`.
--- @prop diameter number optional. With `fitDiameter`, the circle's diameter in units.
--- @prop inset number optional. Draws the arc as a filled sector of its band, every edge moved in by half this many points, so neighbours are this far apart.
--- @prop fitDiameter number optional. Scales the arc to its frame: the frame's shorter side spans this many units, and `diameter` and `lineWidth` are in those units.
--- @prop lineWidth number optional. Stroke width in points.
--- @prop startAngle number optional. Starting angle in degrees.
--- @prop stroke string optional. Semantic stroke color.
--- @prop strokeAlpha number optional. Stroke opacity from 0 to 1.
--- @prop width number optional. Component-specific setting passed to the native control.
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.Arc(props)
props = props or {}
if props.width and not props.fixedWidth then props.fixedWidth = props.width end
if props.height and not props.fixedHeight then props.fixedHeight = props.height end
local width = props.fixedWidth or 48
local height = props.fixedHeight or width
local view = bridge._arc(width, height)
view.startAngle = props.startAngle or 0
view.endAngle = props.endAngle or 0
if props.lineWidth then view.lineWidth = props.lineWidth end
if props.stroke then view.stroke = props.stroke end
if props.strokeAlpha then view.strokeAlpha = props.strokeAlpha end
if props.lineCap then view.lineCap = props.lineCap end
if props.diameter then view.diameter = props.diameter end
if props.fitDiameter then view.fitDiameter = props.fitDiameter end
if props.inset then view.inset = props.inset end
if props.cornerRadius then view.cornerRadius = props.cornerRadius end
return applyLayout(view, props)
end
require("ui.meshgradient").install(UIKit, bridge, applyLayout)
require("ui.lazy").install(UIKit, bridge, applyLayout)
require("ui.webpage").install(UIKit, bridge, applyLayout)
-- Local notifications (see ui/notifications.lua).
require("ui.notifications").install(UIKit, bridge)
--- Draws a pie or donut chart from `SectorMark` records (SwiftUI Charts).
--- @prop innerRadius number optional. Hole radius as a fraction of the outer radius (0 draws a pie).
--- @prop angularInset number optional. Gap between neighbouring sectors, in points.
--- @prop scalable boolean optional. Lays the sectors out in `diameter` units and scales them to fill the room the chart is given, centered.
--- @prop diameter number optional. With `scalable`, the chart's geometry in units (default 360).
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.SectorChart(props)
return require("ui.sectors").chart(UIKit, props)
end
--- Pins the second child to a safe-area edge while the first child uses the remaining space.
---
--- The bottom edge includes the hosting controller's safe-area inset. Use a flexible
--- scroll view as the first child and the persistent control as the second child.
--- @prop edge string optional. Currently `bottom`.
--- @prop minimumBottomInset number optional. Minimum resting clearance on devices without a bottom safe-area inset.
--- @prop keyboardBottomInset number optional. Clearance above the software keyboard.
--- @prop horizontalInset number optional. Horizontal clearance while the keyboard is visible.
--- @prop matchBottomHorizontalInset boolean optional. Match resting horizontal clearance to the bottom safe-area inset.
--- @platform UIKit.
function UIKit.SafeAreaInset(props)
props = props or {}
assert(props.edge == nil or props.edge == "bottom", "SafeAreaInset currently supports edge=bottom")
assert(type(props[1]) == "userdata" and type(props[2]) == "userdata" and props[3] == nil,
"SafeAreaInset requires content and inset children")
local accessory = UIKit.VStack {
spacing = 0, fillWidth = true, paddingHorizontal = props.horizontalInset or 0, props[2],
}
accessory.safeAreaInsetBottom = true
accessory.minimumBottomInset = props.minimumBottomInset or 0
accessory.keyboardBottomInset = props.keyboardBottomInset or 0
accessory.horizontalInset = props.horizontalInset or 0
accessory.matchBottomHorizontalInset = props.matchBottomHorizontalInset == true
return applyLayout(UIKit.VStack {
spacing = 0, fillWidth = true, fillHeight = true, props[1], accessory,
}, props)
end
--- Edits a single line of text.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop accessibilityLabel value optional. Component-specific setting passed to the native control.
--- @prop bezeled boolean optional. Shows the native bezel when true.
--- @prop color string optional. Text color: a semantic color name or a `#light|#dark` hex pair.
--- @prop disabled boolean optional. Component-specific setting passed to the native control.
--- @prop editable boolean optional. Allows text editing when true.
--- @prop onChange function optional. Callback invoked when the value changes.
--- @prop onCommand function optional. Callback invoked for the corresponding keyboard command.
--- @prop onFocus function optional. Callback invoked when editing begins and the keyboard is shown.
--- @prop placeholder string optional. Component-specific setting passed to the native control.
--- @prop style string optional. `plain` removes the field border; `roundedBorder` keeps the system field bezel.
--- @prop secure boolean optional. Masks entered text when true.
--- @prop size number optional. Component-specific setting passed to the native control.
--- @prop value table optional. Current selected, edited, or measured value.
--- @prop weight value optional. Component-specific setting passed to the native control.
--- @example <TextField />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.TextField(props)
if type(props) ~= "table" then props = { value = tostring(props or "") } end
assert(props.style == nil or props.style == "plain" or props.style == "roundedBorder",
"TextField style must be 'plain' or 'roundedBorder'")
local field = bridge._textField(props.value or props[1] or "")
field.placeholder = props.placeholder or ""
field.borderStyle = (props.style == "plain" or props.bezeled == false) and 0 or 3
field.secureTextEntry = props.secure == true
field.enabled = props.disabled ~= true and props.editable ~= false
if props.size then field.font = bridge._font(props.size, props.weight, false, props.design) end
if props.color then field.textColor = bridge._systemColor(props.color) end
if props.accessibilityLabel then field.accessibilityLabel = props.accessibilityLabel end
bridge._textFieldCallbacks(field, props.onChange, props.onCommand, props.onFocus)
field:sizeToFit()
-- SwiftUI text fields accept the available width while keeping native height.
field.fillWidth = true
return applyLayout(field, props)
end
--- Edits multiline text.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop drawsBackground boolean optional. Draws the control’s background when true.
--- @prop editable boolean optional. Allows text editing when true.
--- @prop italic boolean optional. Component-specific setting passed to the native control.
--- @prop onChange function optional. Receives the text after each edit.
--- @prop placeholder string optional. Prompt shown in the system placeholder color while the editor is empty.
--- @prop selectable boolean optional. Allows text or rows to be selected when true.
--- @prop size number optional. Component-specific setting passed to the native control.
--- @prop text string optional. Initial or displayed text value.
--- @prop value table optional. Current selected, edited, or measured value.
--- @prop weight value optional. Component-specific setting passed to the native control.
--- @prop wrapMode string optional. Text wrapping mode.
--- @example <TextEditor />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.TextEditor(props)
props = props or {}
local v = bridge._textEditor(props.text or props.value or "",
props.editable, props.selectable, props.drawsBackground)
if props.size and props.size > 0 then
v.font = bridge._font(props.size, props.weight, props.italic, props.design)
end
if props.wrapMode == false then
v.textContainer.lineBreakMode = 1
end
-- SwiftUI `TextEditor(text:)`: each edit hands the new text to the page.
if props.onChange then bridge._textEditorCallbacks(v, props.onChange) end
if props.placeholder then v.placeholder = props.placeholder end
if props.accessibilityLabel then v.accessibilityLabel = props.accessibilityLabel end
return applyLayout(v, props)
end
--- Displays read-only source with native, rule-driven syntax coloring.
--- @prop language string required. Language key in `syntaxRules.languages`.
--- @prop syntaxRules table required. Colors, regular-expression matches and embedded-language regions.
--- @prop size number optional. Monospaced font size.
--- @prop text string required. Source text.
--- @example <CodeView language="lua" text="source" syntaxRules="rules" />
--- @platform UIKit UITextView with attributed TextKit storage.
function UIKit.CodeView(props)
props = props or {}
local v = bridge._codeView(props.text or "", props.language or "lua", props.syntaxRules or {})
if props.size and props.size > 0 then v.font = bridge._font(props.size, "regular", false, "monospaced") end
return applyLayout(v, props)
end
-- The host streams files from the packager instead of holding them, but
-- SceneKit imports models from disk: stage each model, the materials its
-- OBJ names and their textures where it can read them, and point the record
-- at the staged copy. Records already pointing at a local path pass through.
local stagedModels = {}
local function stageFile(path)
local body, err = bridge._readFile(path)
if not body then error(err or ("cannot read " .. path), 0) end
return bridge._stageAsset(path, body), body
end
local function stageModel(path)
if stagedModels[path] then return stagedModels[path] end
local local_, body = stageFile(path)
local dir = path:match("^(.*)/[^/]*$") or "."
for name in body:gmatch("mtllib%s+([^\r\n]+)") do
local _, mtl = stageFile(dir .. "/" .. name)
for texture in mtl:gmatch("map_%w+%s+([^\r\n]+)") do stageFile(dir .. "/" .. texture) end
end
stagedModels[path] = local_
return local_
end
local function stageRecords(records)
for _, record in ipairs(records) do
if type(record.model) == "string" and record.model:sub(1, 1) ~= "/" and bridge._readFile then
record.model = stageModel(record.model)
end
if record.items then stageRecords(record.items) end
end
end
--- Displays a SceneKit scene described by `Node`, `Camera` and `Light` records.
---
--- Like SwiftUI's `SceneView`, the view fills its proposal. The records form
--- the scene graph and reconcile by `id` when the template renders again:
--- nodes keep their pose and running behaviours, changed attributes apply in
--- place, and `transition` plays when a node is inserted or removed. Game
--- state moves nodes every frame with `view.nodeStates = {{id, x, y, z, yaw,
--- pitch, roll, scale, opacity, hidden}, ...}` (angles in degrees) without
--- describing the scene again.
--- @prop background string optional. Color behind the scene (semantic name or `#rrggbb`).
--- @prop onKey function optional. `onKey(view, key, pressed) -> handled`: presses and releases, never repeats; the view takes the keyboard when it appears.
--- @prop onFrame function optional. `onFrame(view, dt)` once per displayed frame while the view is in a window, `dt` in seconds.
--- @prop onSwipe function optional. `onSwipe(view, direction)` with `left`, `right`, `up` or `down`, sent as soon as a drag (a finger, or the mouse) has travelled far enough.
--- @prop onTap function optional. `onTap(view)` when a press is released without travelling. A drag is one swipe or one tap, never both.
--- @prop showsStatistics boolean optional. Shows SceneKit's frame-rate and draw-call overlay.
--- @example <SceneView background="#8fd3f4"><Camera position="0 6 8" lookAt="0 0 0" /><Light type="directional" rotation="-60 30 0" castsShadow="true" /><Node model="assets/coin.obj" spin="90" /></SceneView>
--- @platform UIKit.
function UIKit.SceneView(props)
props = props or {}
local records = {}
for _, child in ipairs(props) do
if type(child) ~= "table" or not child.sceneKind then
error("SceneView accepts only Node, Camera and Light records")
end
table.insert(records, child)
end
stageRecords(records)
local view = bridge._sceneView(props.onKey, props.onFrame, props.onSwipe, props.onTap)
if props.background then bridge._sceneBackground(view, props.background) end
if props.showsStatistics then view.showsStatistics = true end
if props.virtualGamepad then view.virtualGamepad = true end
UIKit.sceneGraph(view, records)
-- A 3-D viewport has no intrinsic size and fills its proposal.
view.fillWidth, view.fillHeight = true, true
local layout = {}
for key, value in pairs(props) do
if type(key) ~= "number" and key ~= "onKey" and key ~= "onFrame" and key ~= "onSwipe" and key ~= "onTap" and key ~= "background"
and key ~= "virtualGamepad" then layout[key] = value end
end
return applyLayout(view, layout)
end
--- Reconciles a SceneView's graph with new `Node`, `Camera` and `Light` records.
---
--- The XML reconciler calls this when a template renders new records; a
--- model that cannot load or an unknown geometry raises an error naming the node.
function UIKit.sceneGraph(view, records)
stageRecords(records)
local problems = bridge._sceneGraph(view, records)
if #problems > 0 then error("SceneView: " .. table.concat(problems, "; "), 2) end
return true
end
--- Provides native search input and search-specific behavior.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop accessibilityLabel value optional. Component-specific setting passed to the native control.
--- @prop placeholder string optional. Component-specific setting passed to the native control.
--- @prop value table optional. Current selected, edited, or measured value.
--- @example <SearchField />
--- @platform AppKit uses the AppKit implementation. UIKit uses the UIKit implementation.
function UIKit.SearchField(props)
props = props or {}
local v = bridge._searchField(props.value or props[1] or "",
props.placeholder or "Search")
if props.accessibilityLabel then
v.accessibilityLabel = props.accessibilityLabel
end
bridge._textFieldCallbacks(v, props.onChange, props.onCommand)
v.fillWidth = true
return applyLayout(v, props)
end
--- Combines an icon and title in a standard platform label.
---
--- This component is backed by the platform control or container. Prefer its XML tag in an `.etlua` template; keep view-tree construction out of controllers.
--- @prop accessibilityLabel value optional. Component-specific setting passed to the native control.
--- @prop alignment value optional. Component-specific setting passed to the native control.
--- @prop color color optional. Component-specific setting passed to the native control.
--- @prop iconSize number optional. Component-specific setting passed to the native control.
--- @prop iconWeight value optional. System symbol weight for the label icon.
--- @prop italic boolean optional. Component-specific setting passed to the native control.
--- @prop lineLimit number optional. Maximum number of visible text lines.
--- @prop lines number optional. Maximum number of visible text lines.
--- @prop reservesSpace boolean optional. Always as tall as `lines` lines, so shorter text never moves siblings (SwiftUI `.lineLimit(n, reservesSpace: true)`).
--- @prop size number optional. Component-specific setting passed to the native control.
--- @prop spacing number optional. Component-specific setting passed to the native control.
--- @prop systemImage string optional. Component-specific setting passed to the native control.
--- @prop symbolSize number optional. SF Symbol point size for an image-only button.
--- @prop truncation string optional. Text truncation position: `head`, `middle`, or `tail`.
--- @prop minimumScaleFactor number optional. Smallest fraction of `size` the text shrinks to when space is short.
--- @prop weight value optional. Component-specific setting passed to the native control.
--- @prop wrapping boolean optional. Component-specific setting passed to the native control.
--- @platform UIKit uses the UIKit implementation.
function UIKit.Label(arg)
local text
local props
if type(arg) == "table" then
text = arg[1] or arg.text or arg.value or ""
props = arg
elseif type(arg) == "string" then
text = arg
else
text = tostring(arg)
end
-- An empty symbol name is no symbol, so templates can bind it conditionally.
if type(props) == "table" and props.systemImage and props.systemImage ~= "" then
local row = {
spacing = props.spacing or 6,
alignment = "center",
UIKit.SystemImage({
name = props.systemImage,
accessibilityLabel = props.accessibilityLabel,
size = props.iconSize or props.size,
weight = props.iconWeight or props.weight,
color = props.color,
}),
UIKit.Label({ text, size = props.size, weight = props.weight,
italic = props.italic, design = props.design, color = props.color,
monospacedDigit = props.monospacedDigit, fontName = props.fontName, smallCaps = props.smallCaps,
lineLimit = props.lineLimit, reservesSpace = props.reservesSpace, truncation = props.truncation, wrapping = props.wrapping,
minimumScaleFactor = props.minimumScaleFactor }),
}
return applyLayout(UIKit.HStack(row), props)
end
local v = bridge._label(text)
if type(props) == "table" then
if props.size and props.size > 0 then
v.font = bridge._font(props.size, props.weight, props.italic, props.design,
props.monospacedDigit == true, props.fontName, props.smallCaps == true)
end
local lines = props.lineLimit or props.lines
if lines then
v.numberOfLines = lines
if lines > 1 then v.lineBreakMode = 0 end
end
if props.reservesSpace then v.reservesSpace = true end
if props.wrapping then v.lineBreakMode = props.wrapping == "character" and 1 or 0 end
if props.truncation then
local modes = { head = 3, tail = 4, middle = 5 }
v.lineBreakMode = modes[props.truncation] or 4
end
if props.color then
v.textColor = bridge._systemColor(props.color)
end
if props.alignment then
v.textAlignment = ({ leading = 0, center = 1, trailing = 2 })[props.alignment] or 0
end
if props.accessibilityLabel then v.accessibilityLabel = props.accessibilityLabel end
-- SwiftUI `.minimumScaleFactor` is UILabel's own shrink-to-fit.
if props.minimumScaleFactor then
v.adjustsFontSizeToFitWidth = true
v.minimumScaleFactor = props.minimumScaleFactor
end
v:sizeToFit()
end
return applyLayout(v, props)
end