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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
|
(ns frq.hiccup
"glimmer's hiccup, painted by Flutter.
This is a backend, not a port. `frq.app` is 1,834 lines of
`[:vbox {:spacing 6} ...]` over about twenty tags, and it names no toolkit
anywhere — the same trick `frq.cosmic` and `frq.tui` rely on, where the
components do not know what is under the reconciler. So the screens do not
get rewritten for the phone; this interprets them.
The tags are glimmer's and so is the styling: every arm below is what
`crates/jolt-cosmic` asks libcosmic for, in `frq.theme`'s tokens. `:title` is
title3 and `:title-2` is title4; `:card` is Container::Card — padding 12,
spacing 8, a surface a step from the background rather than an elevation,
because COSMIC does not float things; `:button` is suggested, destructive or
standard; `:dim-label` is the caption class, which is the body colour stepped
back rather than a colour of its own.
Material is underneath and deliberately not visible. No elevation, no ripple
shadows, no Material 3 pill heights — a screen laid out against libcosmic's
4/8/12/16/24 spacing lands at the same proportions here.
What this does NOT do is glimmer's reconciliation. glimmer patches the tree
it painted last; Flutter rebuilds from the top and diffs its own element
tree, which is the same job done by the framework instead of by us. The cost
is that a cell firing rebuilds the whole screen rather than the subtree that
read it — fine at this size, and the thing to revisit if a message list ever
feels it."
(:require ["dart:io" :as io]
;; `defaultTargetPlatform` lives here and is NOT re-exported by
;; material: widgets.dart re-exports foundation behind a `show`
;; list that carries `TargetPlatform` and not the getter beside it.
["package:flutter/foundation.dart" :as fnd]
["package:flutter/gestures.dart" :as g]
;; `LogicalKeyboardKey` and the key events, for the one key the
;; composer wants before Flutter's focus traversal gets it.
["package:flutter/services.dart" :as sv]
["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[frq.theme :as t]))
(defn- width-of
"A `:width-request`, or nil when there is none to honour.
Zero means no request, not a width of nothing — the chat screen writes
`(if show-users? (messages-width) 0)` and every number is truthy in Clojure,
so taking it at face value gave the message list a SizedBox of zero and an
empty screen."
[p]
(let [w (:width-request p)]
(when (and (number? w) (pos? w)) (double w))))
;; How a network image URL is reached from here. Identity, unless told
;; otherwise.
;;
;; The web needs telling. `cdn.bsky.app` serves avatars with no
;; `Access-Control-Allow-Origin`, and Flutter loads images over XHR in a
;; browser — so the bytes arrive and are thrown away for want of a header the
;; far side does not send. The deployed build answers by fetching them through
;; its own origin; `frq.main-web` installs that, and the desktop and the APK
;; never call it because neither is subject to CORS at all.
;;
;; An atom in the renderer rather than a seam in `frq.io`: nothing under
;; `common/` renders an image, and the two call sites are both here.
;;
;; A comment and not a docstring, for the reason `frq.net.dart/wire-log?`
;; gives: `defonce` is `(defonce name expr)` and nothing else, and the extra
;; form is an arity error reported against the namespace rather than the line.
(defonce image-url (atom identity))
(defn- src-url
"`src` as this platform can actually load it."
[src]
(let [s (str src)]
(if (or (.startsWith s "http://") (.startsWith s "https://"))
(str (@image-url s))
s)))
(defn pointer?
"Whether there is a mouse here rather than a finger.
Both Flutter targets compile this file — `just apk` and
`just flutter-desktop` — so the phone is no longer a safe assumption to bury
in an arm below. What hangs on it is everything a pointer can do that a
finger cannot: resting on a thing to ask about it, and the card that answers
floating over the screen instead of taking a place in it.
`frq.screens.*` asks the host the same question as `actions/desktop?`, and
gets its answer from the same place; this is the renderer's side of it, for
the parts of a hover that never reach the shared screens at all.
`defaultTargetPlatform` and not `Platform.isAndroid`, since the web target
arrived: `dart:io`'s `Platform` throws `Unsupported operation:
Platform._operatingSystem` in a browser, and it threw here — inside build,
where what it leaves is the grey of an app that got as far as its first
frame and no further. Flutter's own answer works on all three targets and
is the better question anyway: on the web it reports the machine the browser
is running on, so a phone browser gets a finger and a desktop one a mouse,
which is what this is actually asking."
[]
(not= fnd/TargetPlatform.android fnd/defaultTargetPlatform))
(defn- hovered
"`w`, told when the pointer arrives and when it leaves.
Plain `w` where there is nothing to tell — `MouseRegion` is cheap but not
free, and on a phone the callbacks could never fire anyway."
[p w]
(let [in (:on-hover p)
out (:on-unhover p)]
(if (and (pointer?) (or in out))
(m/MouseRegion
.onEnter (when in (fn [_] (in)))
.onExit (when out (fn [_] (out)))
.child w)
w)))
(defn- dbl [x default]
(cond (number? x) (double x)
:else default))
(defn- insets-of
"The margin a container asks for, or nil when it asks for none.
Three spellings, and a box may mix them: `:margin` is all four sides, the
per-side keys are each one side, and a per-side key wins over `:margin`
where both are given — that is how `message-row` writes `:margin 0` with a
`:margin-top` and a `:margin-right` over it.
The per-side keys are not decoration: the backlog's gap between messages and
its clearance for the scrollbar are both written that way, so a backend that
reads `:margin` alone draws the conversation edge to edge."
[p]
(let [side (fn [k] (dbl (get p k) (dbl (:margin p) 0.0)))
l (side :margin-left) r (side :margin-right)
tp (side :margin-top) b (side :margin-bottom)]
(when (some pos? [l r tp b])
(m/EdgeInsets.only .left l .right r .top tp .bottom b))))
;; ------------------------------------------------------------- text entry
(defonce ^:private controllers
;; TextEditingController per `:key`, because a controller made during a build
;; is a new one every rebuild — the cursor jumps to the start and the
;; selection is lost on the keystroke after it.
;;
;; Keyed rather than positional: glimmer's own reconciler matches children by
;; `:key` for the same reason, and an entry that moves in the tree should
;; keep what is typed in it.
(atom {}))
(defn- controller-for [k text]
(let [k (or k ::anonymous)
^m/TextEditingController c (or (get @controllers k)
(let [c (m/TextEditingController .text (or text ""))]
(swap! controllers assoc k c)
c))]
;; Only when it differs: the cell is the authority for what the entry
;; says, but the person typing is the authority for where they are in it,
;; and every keystroke would otherwise be written back over itself.
;;
;; The whole value and not just `.text`, which is the bug Tab completion
;; found. Assigning `.text` leaves the selection at offset -1, and Flutter
;; draws that as the entire field selected — so completing a nick handed
;; back `nandi.uk: ` highlighted, and the next character typed would have
;; replaced it. The caret belongs after what was just put there.
(when (and text (not= text (.-text c)))
(set! (.-value c)
(m/TextEditingValue
.text text
.selection (m/TextSelection.collapsed .offset (count text)))))
c))
;; ------------------------------------------------------------------ props
(defn- props [node]
(let [p (second node)] (if (map? p) p {})))
(defn- body [node]
(let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node))))
(declare render fills-row?)
(defn- expand
"A component vector reduced to the tags it produces.
`[chat-screen]` is a function, and asking whether it fills means calling it —
the root wraps every screen in a plain `:vbox`, and without this the wrapper
could not see that the screen inside it wants the whole height. Expanded
once and both asked and rendered, so the component runs once either way.
Two rules taken from glimmer, because the screens are written against it
and a renderer that reads them differently reads them wrong.
A leading map carrying `:key` is for the reconciler, not for the component:
`:key` is dropped, and a map that held nothing else goes with it.
`[action-chips {:key :actions} channel m]` is a two-argument call, and
passing the map on threw NoSuchMethodError on the one line in the shared
screens written that way — the line that draws react and reply.
And a component may answer with its render function rather than with
hiccup: glimmer's Form-2, where the outer call is the mount and the inner
one is the render. Nothing here caches the inner fn the way glimmer does,
so this calls it every time — a call more, and the same output."
[node]
(if (and (vector? node) (seq node) (not (keyword? (first node))))
(let [raw (rest node)
args (if (and (seq raw) (map? (first raw)) (contains? (first raw) :key))
(let [m (dissoc (first raw) :key)]
(if (seq m) (cons m (rest raw)) (rest raw)))
raw)
out (apply (first node) args)]
(expand (if (fn? out) (out) out)))
node))
(defn- fills-row?
"Whether a node takes the width its row has left over.
jolt-cosmic gives an entry Length::Fixed(w) when it has a width and
Length::Fill otherwise; Fill in a Row is Flutter's Expanded. A TextField that
is neither is offered unbounded width, which is a layout error.
A row aligned to its end fills too, for the same reason it does in glimmer:
`:align :end` is \"against the right edge of what is left\", and a row sized
to its own children has no left-over to sit at the end of. The message
heading is the one that showed it — the chips rode along directly after the
clock, and on a phone the last of them was off the edge of the screen."
[node]
(and (vector? node)
(let [p (second node)]
(case (first node)
:entry (or (not (map? p)) (nil? (width-of p)) (:hexpand p))
:hbox (and (map? p) (= :end (:align p)))
false))))
(declare body)
(defn- prose?
"Whether a subtree is prose — text that should wrap — rather than controls.
The distinction matters because of what Flexible does: it hands every such
child an equal share of the row, and a child whose natural size is larger
then shrinks into it. That is right for a message body, which wraps to fit,
and wrong for a button, whose label then wraps down the middle of the word.
A header of four buttons came out reading Ch/at/s."
[node]
(cond
(seq? node) (boolean (some prose? node))
(not (vector? node)) false
:else
(let [tag (first node)]
(cond
(contains? #{:button :entry :checkbutton :image :avatar :emoji :reaction} tag) false
(contains? #{:label :dim-label :text :title :title-2 :link} tag) true
(contains? #{:vbox :hbox :card} tag)
(let [kids (let [p (second node)] (if (map? p) (drop 2 node) (drop 1 node)))]
(and (some prose? kids)
(not (some #(and (vector? %)
(contains? #{:button :entry :checkbutton} (first %)))
kids))))
:else false))))
(defn- flexes-in-row?
"Whether a node should be given the width a row has left, loosely.
iced wraps a line of text at whatever width is available; Flutter's Text in
an unbounded Row does not wrap at all, and the longest message then decides
the width of the conversation and overflows off the right. So prose in a row
is Flexible — it takes what is there and wraps inside it, rather than
insisting on it the way Expanded does."
[node]
(prose? node))
;; What was last done with each `:scroll-key`.
;;
;; The controller itself is not here — it belongs to the widget, see `:scroll`
;; below. This outlives it on purpose: whether a reader had been taken to the
;; end is a fact about the conversation, not about the widget currently
;; showing it, and it has to survive the rebuild that replaces one.
(defonce ^:private scroll-marks (atom {}))
;; Where a jump is aiming, once the frame that paints it has a context to
;; aim at.
;;
;; `message-row` marks one row `:scroll-here` while a jump is on it, and a
;; row is not a widget this backend can find afterwards — the tree is rebuilt
;; from the top every time a cell fires, so there is no handle on it that
;; outlives the build. The build itself has one: the marked row's own
;; BuildContext, recorded here as it is built and read back by the scroll
;; around it after the frame.
(defonce ^:private jump-context (atom nil))
;; How many jumps have been served. `here-ward!` bumps it as it scrolls, and
;; `end-ward!` reads it when it schedules and again when it runs: a jump that
;; landed in between is one this frame belongs to, whatever `jump-context`
;; says by then.
;;
;; The pending flag alone was not enough, and the trace says why. There is
;; more than one `:scroll` on the chat screen — the message list and the
;; people panel — so `end-ward!` and `here-ward!` are each called once per
;; scroll per frame, and the callbacks interleave: the first scroll's
;; here-ward serves the jump and clears the context, and the message list's
;; end-ward then reads that nil as "no jump pending" and hauls the view back
;; to the bottom. The scroll was right for one frame and gone by the next.
(defonce ^:private jumps-served (atom 0))
(defn- end-ward!
"Put `k` at the end after this frame, when it should be.
Two reasons to, and they are not the same reason. The token — what the
screen passes as `:scroll-to-bottom`, which is `jump-tick` — changing means
someone asked to be taken to the present. Already being at the end means new
lines should push the view along rather than pile up below it, which is what
a conversation does and the whole reason a chat opens at the bottom.
And not otherwise: someone reading back through the backlog is at neither,
and yanking them to the end as each message arrives is the one behaviour
worse than not following at all.
After the frame, because the extent being scrolled to is the height of
content that has not been laid out yet at the point this is called."
[k ^m/ScrollController ctrl last-key token]
(let [;; A widget handed a new key is showing something it has never
;; shown — switching rooms rebuilds the tree into the same shape, so
;; Flutter keeps the State and the offset in it while the backlog
;; underneath becomes another conversation. Whatever this key was
;; told last time, the offset the controller is holding is where the
;; reader was in the room they have just left, so the answer to "are
;; they at the end" is not in it: treat the pair as new.
moved (not= k @last-key)
_ (reset! last-key k)
mark (if moved ::fresh (get @scroll-marks k ::fresh))
;; Not hinted here: a controller with no clients has no position, so
;; this is nil as often as not, and a non-nullable hint on the binding
;; is a cast that fails before `(nil? pos)` below ever gets to run.
;; The hint goes on the uses instead, where the `or` has already ruled
;; nil out.
pos (when (.-hasClients ctrl) (.-position ctrl))
at-end (or (= mark ::fresh)
(nil? pos)
(>= (.-pixels ^m/ScrollPosition pos)
(- (.-maxScrollExtent ^m/ScrollPosition pos) 24.0)))]
(when (or (not= mark token) at-end)
(swap! scroll-marks assoc k token)
(let [served @jumps-served]
(.addPostFrameCallback
(.-instance m/WidgetsBinding)
(fn [_]
;; Not when a jump is pending, and not when one was served after
;; this callback was scheduled: being taken to a line and being
;; taken to the end are the same gesture as far as `jump-tick` is
;; concerned, and the end is the wrong one of the two.
;;
;; The counter is what makes that ordering-proof. The pending flag
;; alone read right and was not: there is more than one `:scroll`
;; on the chat screen, so these callbacks interleave with
;; `here-ward!`'s, and the first scroll's here-ward serves the jump
;; and clears the flag before the message list's end-ward ever
;; looks at it. Every jump landed and was hauled back to the bottom
;; in the same frame.
(when (and (nil? @jump-context)
(= served @jumps-served)
(.-hasClients ctrl))
(.jumpTo ctrl (.-maxScrollExtent (.-position ctrl))))))))))
(defonce ^:private scroll-watch
;; Per `:scroll-key`: the controller currently being listened to, the
;; `:on-change` the screen last handed us, and the last answer given.
;;
;; The listener is added once per controller and the callback is looked up
;; through here when it fires, because the closure a build hands us is a new
;; one every build — adding it each time would stack a listener per frame on
;; a widget whose whole job is to fire often.
(atom {}))
(defn- watch-end!
"Tell `on-change` whether `k` is showing the end, as the reader moves it.
\"end\" or \"middle\", which is what the window backend's scroll area says
and what `chat-screen` reads to decide whether the jump button is needed.
Only when the answer changes: every pixel of a flick is a callback, and each
one that reached a cell would rebuild the screen under the finger.
The same 24-point slack `end-ward!` uses, and for the same reason — a list
that has just been jumped to its extent can sit a fraction short of it, and
a reader who is at the bottom should not be told they are not."
[k ^m/ScrollController ctrl on-change]
(when on-change
(let [entry (get @scroll-watch k)]
(swap! scroll-watch assoc k (assoc entry :on-change on-change :ctrl ctrl))
(when-not (identical? ctrl (:ctrl entry))
(.addListener
ctrl
(fn []
(when (.-hasClients ctrl)
(let [pos (.-position ctrl)
at-end (>= (.-pixels ^m/ScrollPosition pos)
(- (.-maxScrollExtent ^m/ScrollPosition pos) 24.0))
said (if at-end "end" "middle")
{prev :said f :on-change} (get @scroll-watch k)]
(when (not= prev said)
(swap! scroll-watch assoc-in [k :said] said)
(when f (f said)))))
nil))))))
(defn- here-ward!
"Bring the row a jump is aiming at into view, when one was built.
After the frame, for the reason `end-ward!` is: the row is built inside
this scroll and so does not exist yet when the scroll is, and a context
with no render object behind it cannot be scrolled to.
Aimed a third of the way down rather than at the top edge, because a line
arrived at reads as an answer to the lines above it, and pinned to the top
it has none of them."
[]
(.addPostFrameCallback
(.-instance m/WidgetsBinding)
(fn [_]
(when-let [^m/BuildContext c @jump-context]
;; Cleared here rather than when the jump does: `jump-to` comes off a
;; frame or two later and this has to have happened by then, and a
;; context kept past the frame that made it is a context to a widget
;; that may be gone.
(reset! jump-context nil)
(when (.-mounted c)
;; Counted before the scroll rather than after it: an `end-ward!`
;; callback queued behind this one has to see that this frame was
;; spent on a jump, and `ensureVisible` answers a Future — by the
;; time that completes, the callback it is racing has run.
(swap! jumps-served inc)
(m/Scrollable.ensureVisible c .alignment 0.3)))
;; The callback is void and `ensureVisible` answers a Future: returned
;; from the tail, it is a value out of a function that has none to give,
;; and Dart says so at compile time rather than here.
nil)))
(defn- fills-column?
"Whether a node takes the height its column has left over.
`:fill-height` is Length::Fill down the other axis — Expanded, not a taller
mainAxisSize. A `:scroll` fills by definition; it is what the space is left
over *for*.
And it is recursive, which is the part that is easy to miss: a plain `:vbox`
holding a `:scroll` fills too, because the scroll inside it needs a height
and a column sized to its contents hands its children an unbounded one. The
chat screen is three such wrappers deep, and each one has to pass the
question up or the innermost Expanded lands in unbounded space —
\"RenderFlex children have non-zero flex but incoming height constraints are
unbounded\", which paints nothing."
[node]
(and (vector? node)
(let [tag (first node)
p (second node)]
(or (contains? #{:scroll :page} tag)
(and (map? p) (:fill-height p))
;; The lightbox's one picture, which is `:fit` and means the same
;; thing: every point of the window below the row that closes it.
(and (= :image tag) (map? p) (:fit p))
;; A `:page` fills for the same reason a `:scroll` does — it is
;; one — but it does not pass the question *up* from its
;; children, because nothing inside a scroll can take what is
;; left of a height the scroll does not have. Sized to its
;; contents instead, a page overflows the moment the viewport
;; shrinks under it, which on a phone is every time the keyboard
;; opens.
;;
;; `:hbox` is in the list for the chat screen: its message band is
;; a fill-height column sitting in a row beside the people panel,
;; so the row must be given a height before the column can take
;; what is left of it.
(and (contains? #{:vbox :card :hbox} tag)
(boolean (some #(fills-column? (expand %)) (body node))))))))
(defn- asks-width?
"Whether a node has already said how wide it is.
Asked of a row's children, and recursive through plain boxes for the reason
`fills-column?` is: the people panel is the width request and the wrapper
that comes and goes around it is what the row actually holds.
It exists to keep `Expanded` off such a child. Expanded hands out a *tight*
width — the child's share of the row — and a tight width beats the SizedBox
underneath it, so the panel that asked for 150 got whatever was going and
every nick in it ellipsised to fit."
[node]
(and (vector? node)
(let [tag (first node)
p (second node)]
(or (and (map? p) (some? (width-of p)))
(and (contains? #{:vbox :card} tag)
(boolean (some #(asks-width? (expand %)) (body node))))))))
(defn- children
"Flatten seqs, drop nils. `(for [...] ...)` in a component yields a seq in
the child position and glimmer splices it; so does this."
[nodes]
(persistent!
(reduce (fn [acc n]
(cond (nil? n) acc
(seq? n) (reduce conj! acc (children n))
:else (conj! acc (render n))))
(transient []) nodes)))
;; ------------------------------------------------------------------ text
(defn- any-fills-row?
"Whether anything in a row's children asks for what the row has left.
Through nested seqs, because a `for` over rows arrives as one child."
[nodes]
(boolean (some (fn [n0]
(let [n (expand n0)]
(if (seq? n) (any-fills-row? n) (fills-row? n))))
nodes)))
(defn- flexed
"Children of a Flex, with the ones that fill wrapped in Expanded.
`fills?` says which, so a Row asks about width and a Column about height.
`sibling-fills?` is whether anything in the row already asks for what is
left, and it is the Join row that needs the question asked: an Expanded entry
beside a Flexible button are two flex children of one flex each, so the
button is allotted half the row, draws its label in a corner of it, and
leaves the rest of its half empty — the row then has slack, `:align :end`
pushes what is in it to the right, and the box the reader types into starts
halfway across the card. A button beside something that fills takes the width
of its label and no share at all; the thing that fills is what gives. Prose
gives way the same way, and for the same arithmetic — the message heading is
where that one showed."
([fills? nodes]
(flexed fills? nodes
(and (identical? fills? fills-row?) (any-fills-row? nodes))))
([fills? nodes sibling-fills?]
(persistent!
(reduce (fn [acc n0]
(let [n (expand n0)]
(cond (nil? n) acc
(seq? n) (reduce conj! acc (flexed fills? n sibling-fills?))
(fills? n) (conj! acc (m/Expanded .child (render n)))
;; A pane that fills the column takes the row's width too:
;; the message list sits in a row beside the people panel,
;; and left to its natural width it is as wide as its
;; longest line — which is one URL and a screen and a half
;; of overflow.
;; Unless it has said how wide it is. The row already
;; stretches its children to the full height when one of
;; them fills the column, so a pane with a width of its own
;; needs nothing from Expanded and loses everything to it.
(and (identical? fills? fills-row?) (fills-column? n)
(not (asks-width? n)))
(conj! acc (m/Expanded .child (render n)))
;; And not if it has said how wide it is, for the reason
;; the Expanded arm above says it: a child with a width
;; needs no share of the row. The people panel is where it
;; showed — every row there is a 14-point mode slot holding
;; a dim label and a nick beside it, and a box of text with
;; no control in it is prose. So the slot took a flex of
;; its own, the two children halved the row between them,
;; and every nick ellipsised at the width of three letters
;; while the slot sat in 60 points of air it had asked for
;; 14 of.
;;
;; And not beside something that fills, for the reason the
;; button arm below says it: the message heading is a face,
;; a nick, a clock, an "(edited)" and the chip row that
;; fills, so four flex children took a quarter of the row
;; each, the chips were allotted less than three pills are
;; wide, and every line you had rewritten wore Flutter's
;; overflow stripes. Prose beside a filling sibling takes
;; the width of its words; what fills is what gives.
(and (identical? fills? fills-row?) (flexes-in-row? n)
(not sibling-fills?)
(not (asks-width? n)))
(conj! acc (m/Flexible .child (render n)))
;; A button in a row is loosely flexible for the same
;; reason prose is, and it is the people panel that says
;; so: that column is `users-width` wide and every row in
;; it is a mode slot and a nick, so any nick longer than
;; the remainder painted its lozenge off the right edge
;; and struck the row through with Flutter's overflow
;; stripes. Loose rather than Expanded — a button asks for
;; the width of its label and gets it whenever the row has
;; it, and only gives way when the row has not.
(and (identical? fills? fills-row?) (not sibling-fills?)
(vector? n) (= :button (first n)))
(conj! acc (m/Flexible .child (render n)))
:else (conj! acc (render n)))))
(transient []) nodes))))
(defn- txt
[ctx s size color & {:keys [weight]}]
(m/Text (str s)
.style (m/TextStyle .fontSize size
.color color
.height 1.35
.fontWeight (or weight m/FontWeight.w400))))
;; ------------------------------------------------------------ inline text
(defn- inline-style [size color]
(m/TextStyle .fontSize size .color color .height 1.35))
(defn- inline?
"Whether a node can be a run *inside* a paragraph rather than a box beside
one.
Only the tags a sentence is made of: words, a link, an emoji, and a row
holding nothing but those. Anything with a shape of its own — a button, a
card, a picture — is a box, and a box in a Wrap is what a Wrap is for."
[node]
(let [n (expand node)]
(cond
(nil? n) true
(seq? n) (every? inline? n)
(not (vector? n)) false
:else (let [tag (first n)]
(cond
(contains? #{:label :dim-label :link :emoji} tag) true
(= :hbox tag) (every? inline? (body n))
:else false)))))
(defn- inline-spans
"The runs of a paragraph as Flutter InlineSpans.
Nested rows are flattened rather than nested, and their `:spacing` goes with
them: spacing is a gap between boxes, and there are no boxes left here. What
an emoji needs instead is the air `run-node` already gives it, which is the
WidgetSpan's own — a picture set in a line of text sits on the baseline like
a letter does.
A link's tap is a TapGestureRecognizer rather than an InkWell, because an
InkWell is a widget and a widget is a box: it is precisely the box that
stops the line breaker seeing the URL as part of the sentence. The
recognizers are never disposed — Flutter asks that they be, and the honest
note is that these live as long as the message list does and a conversation
is at most a screen of them."
[ctx nodes]
(persistent!
(reduce
(fn [acc n0]
(let [n (expand n0)]
(cond
(nil? n) acc
(seq? n) (reduce conj! acc (inline-spans ctx n))
(not (vector? n)) acc
:else
(let [p (props n)]
(case (first n)
:label
(conj! acc (m/TextSpan .text (str (:label p ""))
.style (inline-style t/text-body t/on-bg)))
:dim-label
(conj! acc (m/TextSpan .text (str (:label p ""))
.style (inline-style t/text-caption t/dim)))
:link
(conj! acc (m/TextSpan
.text (str (:label p ""))
.style (inline-style t/text-body t/accent)
.recognizer (when-let [on (:on-click p)]
(let [r (g/TapGestureRecognizer)]
(set! (.-onTap r) #(on))
r))))
:emoji
(conj! acc (m/WidgetSpan
.alignment m/PlaceholderAlignment.middle
.child (m/Text (str (:emoji p ""))
.style (t/emoji-style (dbl (:size p) 16.0)))))
:hbox
(reduce conj! acc (inline-spans ctx (body n)))
acc)))))
(transient []) nodes)))
;; ---------------------------------------------------------------- buttons
(defn- cosmic-button
"libcosmic's three button classes. Filled accent for suggested, filled red
for destructive, and a component-coloured fill for standard — COSMIC's
standard button is a filled surface, not an outline."
[ctx p on]
;; `:kind` carries a keyword in frq.app's own hiccup — `:kind :primary` —
;; and a string was what this compared against, so the connect screen's
;; Connect button came out standard. Both spellings, since `:primary true`
;; is also written.
(let [k (:kind p)
kind (cond (or (:destructive p) (= :destructive k) (= "destructive" k))
:destructive
(or (:primary p) (= :primary k) (= "primary" k))
:suggested
:else :standard)
bg (case kind
:suggested t/accent
:destructive t/destructive
t/component)
;; COSMIC names the foreground for each role, and for this theme both
;; accent.on and destructive.on are black — a cream accent with white
;; text on it is unreadable, which is exactly what guessing produced.
fg (case kind
:suggested t/on-accent
:destructive t/on-destructive
t/on-bg)]
(m/Material
.color bg
.borderRadius (m/BorderRadius.circular t/radius-m)
.child (m/InkWell
.borderRadius (m/BorderRadius.circular t/radius-m)
.onTap (when on #(on))
.child (m/Padding
.padding (m/EdgeInsets.symmetric .horizontal t/space-s
.vertical t/space-xxs)
;; Ellipsis and no wrap: a label wider than the row it
;; is in has to end somewhere, and a nick broken across
;; two lines inside a lozenge is not a button any more.
;; It only ever comes up because `flexed` below hands a
;; button a bounded width — unbounded, this changes
;; nothing.
;;
;; Unless the caller says the label is the whole point,
;; and then it is the type size that gives instead. The
;; people panel is where that is true: it is a column of
;; nicks, and "chadfowler.c…" and "chadfowler.co…" are
;; the same word to a reader — a name a point or two
;; smaller is still a name, and a name cut short is not.
;; `:whole-label` puts the text in a FittedBox, which
;; measures it on one unbounded line and scales it down
;; only by as much as the width it was given demands: a
;; nick that already fits is drawn at t/text-body like
;; every other, so a short column does not shrink.
.child (let [lbl (m/Text (str (:label p ""))
.maxLines 1
.softWrap false
.overflow m/TextOverflow.ellipsis
.style (m/TextStyle
.fontSize t/text-body
.color fg
.height 1.35
.fontWeight m/FontWeight.w500))]
(if (:whole-label p)
(m/FittedBox .fit m/BoxFit.scaleDown
.alignment m/Alignment.centerLeft
.child lbl)
lbl)))))))
;; ----------------------------------------------------------------- widget
(defn- render-tag [tag node]
(f/widget
:context ctx
(let [p (props node)
;; The row a jump is aiming at, saying so as it is built. Here and
;; not in an arm of the `case` below, because the mark is a property
;; any tag may carry — it happens to be a `:vbox` today.
_ (when (:scroll-here p)
(reset! jump-context ctx))
kids (children (body node))
;; One column builder for every container, so the rule about what
;; fills is applied in one place: children that take what is left are
;; wrapped, and a column holding one asks for the whole height rather
;; than for its contents. Doing this only in `:vbox` left a `:card`
;; and a `:scroll` handing their children unbounded space.
col (fn [sp nodes]
(m/Column .crossAxisAlignment m/CrossAxisAlignment.start
.mainAxisSize (if (some #(fills-column? (expand %)) nodes)
m/MainAxisSize.max
m/MainAxisSize.min)
.spacing sp
.children (flexed fills-column? nodes)))]
(case tag
:vbox
;; `:fill-height` is what pins a tab bar to the bottom: the band above
;; it asks for the rest of the screen, and a column sized to its
;; contents has no rest to give.
(let [c (m/Column
.crossAxisAlignment m/CrossAxisAlignment.start
;; Max when something inside wants the space left over: a
;; column sized to its contents has none to give.
.mainAxisSize (if (or (:fill-height p)
(some #(fills-column? (expand %)) (body node)))
m/MainAxisSize.max
m/MainAxisSize.min)
.spacing (dbl (:spacing p) 0.0)
.children (flexed fills-column? (body node)))
c (if-let [mg (insets-of p)]
(m/Padding .padding mg .child c)
c)]
(if-let [w (width-of p)]
(m/SizedBox .width w .child c)
c))
;; `page` is jolt-cosmic's scrollable container, centred and capped.
;;
;; Align with a heightFactor rather than Center: a Center inside an
;; unbounded height — which is what a scroll view gives — grows to
;; infinity and centres its content somewhere far below the screen.
;; The page looked blank and nothing was logged, because nothing was
;; wrong: the content was exactly where it had been asked to go.
:page
(m/SingleChildScrollView
.child
(m/Align
.alignment m/Alignment.topCenter
.heightFactor 1.0
.child (m/ConstrainedBox
.constraints (m/BoxConstraints .maxWidth (dbl (:max-width p) 520.0))
.child (m/Padding
.padding (m/EdgeInsets.all t/space-s)
.child (col t/space-xs (body node))))))
;; `:wrap true` is a row that runs onto the next line — how a message
;; body lays its words, its links and its emoji out, since a line of
;; text is a row of runs here rather than one string. Flutter calls it
;; Wrap. Without it the longest message decided the width of the
;; conversation and the rest overflowed off the right.
:hbox
(let [row (cond
;; `:inline true` is a row that is a *sentence*: its runs
;; go into one paragraph, so Flutter's line breaker sees
;; the whole line at once and may break inside a run as
;; well as between two. A Wrap cannot — it hands each child
;; unbounded width and then places the finished box, so a
;; URL is an atom that either fits on the line or starts a
;; new one, and a message with two links in it came out as
;; five lines with a link alone on two of them.
;;
;; The check is not just the prop: a sentence with a button
;; in it is not a sentence, and a caller that asks for one
;; anyway gets the Wrap it would have got before.
;; `Text.rich` with the runs' own recognizers. Two things
;; were tried above this line and both swallowed the tap:
;; the app-wide `SelectionArea` takes it before a
;; `TextSpan.recognizer` sees it, and `SelectableText.rich`
;; does not dispatch to span recognizers at all. Verified by
;; clicking a real link in #test with the console open --
;; `open-url!` logged nothing in either case, which is what
;; a gesture that never arrives looks like.
;;
;; So the recognizer stays and the SelectionArea goes; see
;; `frq.main`.
(and (true? (:inline p)) (inline? (body node)))
(m/Text.rich (m/TextSpan .children (inline-spans ctx (body node)))
.softWrap true)
(true? (:wrap p))
(m/Wrap
.spacing (dbl (:spacing p) 0.0)
.runSpacing (dbl (:spacing p) 0.0)
.crossAxisAlignment m/WrapCrossAlignment.center
.children kids)
;; `:align` is where in the row its children sit, and it is
;; glimmer's meaning that the shared screens are written
;; against: `:end` lays them out *from* the right, so the
;; first child in the source is the rightmost on screen.
;; Ignoring it put the message heading's chips in the
;; opposite order and left the pencil — the one written
;; first so it would land beside the words — hanging off the
;; end of a phone's width, which is why a line you wrote had
;; no way to rewrite it.
:else
(let [end? (= :end (:align p))
kids (flexed fills-row? (body node))]
(m/Row
.mainAxisAlignment (case (:align p)
:end m/MainAxisAlignment.end
:center m/MainAxisAlignment.center
m/MainAxisAlignment.start)
;; Stretch when a child fills vertically: a Row hands its
;; children a loose height by default, and a column that
;; wants what is left of it needs a tight one.
.crossAxisAlignment (if (some #(fills-column? (expand %)) (body node))
m/CrossAxisAlignment.stretch
m/CrossAxisAlignment.center)
;; A row holding something that fills has to be given the
;; width to divide, so it is max rather than min whenever a
;; child asks. A row aligned to an edge needs it too: there
;; is no edge to sit against inside a row the width of its
;; own children.
.mainAxisSize (if (or end?
(some #(fills-row? (expand %)) (body node)))
m/MainAxisSize.max
m/MainAxisSize.min)
.spacing (dbl (:spacing p) 0.0)
.children (if end? (vec (reverse kids)) kids))))]
(if-let [mg (insets-of p)]
(m/Padding .padding mg .child row)
row))
:label (txt ctx (:label p "") t/text-body t/on-bg)
:dim-label (txt ctx (:label p "") t/text-caption t/dim)
:title (txt ctx (:label p "") t/text-title-3 t/on-bg
:weight m/FontWeight.w600)
:title-2 (txt ctx (:label p "") t/text-title-4 t/on-bg
:weight m/FontWeight.w600)
:button (cosmic-button ctx p (:on-click p))
;; button::link — accent text, no underline. COSMIC links are buttons.
:link
(m/InkWell
.onTap (when-let [on (:on-click p)] #(on))
.child (txt ctx (:label p "") t/text-body t/accent))
;; Container::Card: padding 12, spacing 8, fills its width unless it is
;; sitting in a row.
:card
(m/Container
.width double/infinity
.padding (m/EdgeInsets.all t/space-xs)
.decoration (m/BoxDecoration
.color t/card
.borderRadius (m/BorderRadius.circular t/radius-s))
.child (col (dbl (:spacing p) t/space-xxs) (body node)))
;; A reaction pill: the glyph, and the tally beside it where there is
;; one to show. The same shape whether it is a reaction under a message,
;; a swatch in the picker or the react/reply/edit chips on the sender's
;; row — which is the point, as `action-chips` says: what you press to
;; react and what appears once you have should look like one family.
;;
;; A count of zero is no count. The picker passes 0 for every swatch
;; because none of them has been pressed, and a grid of little grey
;; zeroes is noise where a reader is scanning for a face.
;;
;; `:mine` is the accent, because the only thing a pill has to say at a
;; glance is whether pressing it again would take yours off.
:reaction
(let [size (dbl (:size p) 14.0)
n (:count p)
mine (boolean (:mine p))
pad (max 2.0 (* 0.25 size))]
;; And, where there is a pointer, resting on a pill says who put the
;; reaction there — the card `reactor-dialog` raises out of
;; `reaction-hover`. The pill's only job is to say where the pointer
;; is; see the `:avatar` arm, which answers the same way about a face.
(hovered
p
(m/InkWell
.onTap (when-let [on (:on-click p)] #(on))
.borderRadius (m/BorderRadius.circular t/radius-s)
.child
(m/Container
.padding (m/EdgeInsets.symmetric .horizontal pad .vertical (* 0.5 pad))
.decoration (m/BoxDecoration
.color (if mine t/accent t/card-component)
.borderRadius (m/BorderRadius.circular t/radius-s))
.child
(m/Row
.mainAxisSize m/MainAxisSize.min
.children
(into [(m/Text (str (:emoji p ""))
.style (t/emoji-style size))]
(when (and (number? n) (pos? n))
[(m/SizedBox .width pad)
(m/Text (str n)
.style (m/TextStyle
.fontSize (* 0.85 size)
.color (if mine t/on-accent t/on-card)))])))))))
;; libcosmic's dialog is centred over the window with what you were
;; reading dimmed behind it, and there are two answers to that here
;; because there are two Flutter targets.
;;
;; On a phone there is no behind worth keeping — the screen is the width
;; of the dialog already — so this is a sheet: the title, a rule, and
;; the body, drawn where the tree puts it, above the conversation it was
;; opened from. `:modal` is read and ignored: a sheet the width of the
;; screen has nothing behind it to make deaf.
;;
;; In a window it is what libcosmic's is: capped at `:max-width`,
;; centred over the screen, and the buttons gathered into a foot.
;; Nothing about the floating is here,
;; though; a widget cannot lift itself out of the column it is in. That
;; is `render-root`, which takes the dialog out of the tree and stacks
;; it over what is left. This arm only has to draw the card.
:dialog
(if-not (pointer?)
(m/Container
.width double/infinity
.margin (m/EdgeInsets.only .bottom t/space-xs)
.padding (m/EdgeInsets.all t/space-xs)
.decoration (m/BoxDecoration
.color t/card
.border (m/Border.all .color t/divider .width 1.0)
.borderRadius (m/BorderRadius.circular t/radius-m))
.child
(col t/space-xxs
(into [[:title-2 {:label (:label p "")}]
[:separator {}]]
(body node))))
;; `:slot` is libcosmic's word for "this belongs in the foot rather
;; than in the body". Read here for the same reason: stacked into the
;; body instead, Close and Bluesky are two full-width bars under the
;; bio rather than a row of buttons under a rule.
(let [kids (remove nil? (map expand (body node)))
slotted (fn [n] (let [q (props n)] (not-empty (str (:slot q "")))))
foot (filter slotted kids)
;; Secondary first, so the button that closes the card is the
;; one nearest the corner the pointer leaves by.
foot (concat (remove #(= "primary" (:slot (props %))) foot)
(filter #(= "primary" (:slot (props %))) foot))]
(m/ConstrainedBox
;; Capped both ways. The width is what libcosmic is asked for; the
;; height is so that a long bio is a card with a scroll in it
;; rather than a card taller than the window. The body is the part
;; that gives — the title and the buttons are why it is a dialog.
.constraints (m/BoxConstraints
.maxWidth (dbl (:max-width p) 520.0)
.maxHeight (* 0.8 (.-height (.-size (m/MediaQuery.of ctx)))))
.child
(m/Container
;; Infinity under a maxWidth of 520 is 520, and under a narrower
;; window it is the window: the card is as wide as it is allowed
;; to be, never as wide as its longest line. Without it the
;; Column is sized to its children and the rules inside it ask
;; for an unbounded width, which is a layout error rather than a
;; narrow dialog.
.width double/infinity
.padding (m/EdgeInsets.all t/space-s)
.decoration (m/BoxDecoration
.color t/card
.border (m/Border.all .color t/divider .width 1.0)
.borderRadius (m/BorderRadius.circular t/radius-m))
.child
;; Built out rather than handed to `col`, because the foot is a
;; Row aligned to the end and `:hbox` has no word for that. One
;; place that wants it and no prop invented for it.
(m/Column
.crossAxisAlignment m/CrossAxisAlignment.start
.mainAxisSize m/MainAxisSize.min
.spacing t/space-xxs
.children
(concat [(render [:title-2 {:label (:label p "")}])
(render [:separator {}])]
;; Flexible and loose: the body takes what it needs up
;; to what the cap leaves, and scrolls inside that. A
;; short profile is still a short card.
[(m/Flexible
.child
(m/SingleChildScrollView
.child (m/Column
.crossAxisAlignment m/CrossAxisAlignment.start
.mainAxisSize m/MainAxisSize.min
.spacing t/space-xxs
.children (children (remove slotted kids)))))]
(when (seq foot)
[(render [:separator {}])
(m/Row
.mainAxisSize m/MainAxisSize.max
.mainAxisAlignment m/MainAxisAlignment.end
.spacing t/space-xxs
.children (children foot))])))))))
:separator (m/Divider .height 1.0 .thickness 1.0 .color t/divider)
;; A 16px indeterminate circle, with the label as a caption beside it.
:spinner
(m/Row
.mainAxisSize m/MainAxisSize.min
.spacing t/space-xxs
.children (into [(m/SizedBox
.width 16.0 .height 16.0
.child (m/CircularProgressIndicator
.strokeWidth 2.0
.color t/accent))]
(when-let [l (not-empty (str (:label p "")))]
[(txt ctx l t/text-caption t/dim)])))
;; A dot that says whether the thing is live, and the words beside it.
:status
(m/Row
.mainAxisSize m/MainAxisSize.min
.spacing 6.0
.children [(m/Container
.width 8.0 .height 8.0
.decoration (m/BoxDecoration
.color (if (:live p) t/success t/dim)
.borderRadius (m/BorderRadius.circular t/radius-xs)))
(txt ctx (:label p "") t/text-caption t/dim)])
:spacer
(let [size (dbl (or (:size p) (:gap p) (:width-request p)) t/space-xxs)]
(m/SizedBox .width size .height size))
;; A tick and its words. `:on-toggled` takes no arguments — it is the
;; same zero-arity handler every other tag here is given, and the box
;; reads its state back out of `:active` on the next render rather than
;; being told what it now is. Handing Checkbox's own bool to it is what
;; left the settings tick dead: `toggle-hide-join-part!` is 0-arity.
;;
;; The label is part of the target. 20 logical pixels is a fine tick on
;; a desktop pointer and a miss on a thumb, so the whole row taps.
:checkbutton
(let [on (:on-toggled p)
row (m/Row
.mainAxisSize m/MainAxisSize.min
.spacing t/space-xxxs
.children [(m/SizedBox
.width 20.0 .height 20.0
.child (m/Checkbox
.value (boolean (:active p))
.activeColor t/accent
.onChanged (when on (fn [_] (on)))))
(txt ctx (:label p "") t/text-body t/on-bg)])]
(if on
(m/InkWell .onTap #(on) .child row)
row))
;; text_input: a filled rounded field, no outline. COSMIC entries sit in
;; the component colour rather than behind a border.
;; An entry sizes itself, as it does in jolt-cosmic: Fixed(w) from
;; :width-request unless :hexpand asks for the rest of the row.
;;
;; Not optional. A TextField takes its width from its parent, and a Row
;; offers its children unbounded width — so two entries side by side in
;; an :hbox, which is exactly what the connect screen's host and port
;; are, is a hard layout error. That is what painted the whole screen
;; blank with nothing in the log.
:entry
(let [on-change (:on-change p)
on-activate (:on-activate p)
on-tab (:on-tab p)
rows (:rows p)
w (width-of p)
field (m/TextField
.controller (controller-for (:key p) (:text p))
.onChanged (when on-change #(on-change %))
.onSubmitted (when on-activate (fn [_] (on-activate)))
.maxLines (if rows (int rows) 1)
.style (m/TextStyle .fontSize t/text-body .color t/on-bg)
.cursorColor t/accent
.decoration (m/InputDecoration
.isDense true
.filled true
.fillColor t/component
.hintText (:placeholder p)
.hintStyle (m/TextStyle .fontSize t/text-body
.color t/dim)
.contentPadding (m/EdgeInsets.symmetric
.horizontal t/space-xs
.vertical t/space-xxs)
.border (m/OutlineInputBorder
.borderRadius (m/BorderRadius.circular t/radius-s)
.borderSide m/BorderSide.none)
.enabledBorder (m/OutlineInputBorder
.borderRadius (m/BorderRadius.circular t/radius-s)
.borderSide m/BorderSide.none)
.focusedBorder (m/OutlineInputBorder
.borderRadius (m/BorderRadius.circular t/radius-s)
.borderSide (m/BorderSide .color t/accent
.width 1.0))))]
;; Tab, claimed before Flutter's focus traversal has it. A
;; TextField does not see Tab at all — the framework reads it as
;; "move to the next widget" and moves — so completing a nick with it
;; means catching the key one level up and saying it was handled.
;;
;; `KeyDownEvent` and not every event: a key that is held down
;; repeats, and each repeat would complete again against the word the
;; last one just finished.
;;
;; Only where something asked. An entry with no `:on-tab` returns
;; `ignored` and Tab still moves the focus, which is what every other
;; box on every screen should keep doing.
(let [field (if-not on-tab
field
(m/Focus
.onKeyEvent
(fn [_ ^sv/KeyEvent event]
(if (and (dart/is? event sv/KeyDownEvent)
(= (.-logicalKey event)
sv/LogicalKeyboardKey.tab))
(do (on-tab) m/KeyEventResult.handled)
m/KeyEventResult.ignored))
.child field))]
;; No Expanded when there is no width: Expanded in a Column expands
;; along the main axis, which is vertical, and an entry in a card
;; would grow to fill the card. A bare field is right there — a
;; Column hands its children bounded width — and a row wants the
;; width-request the caller already writes.
(if w
(m/SizedBox .width w .child field)
field)))
:emoji
(m/Text (str (:emoji p "")) .style (t/emoji-style (dbl (:size p) 16.0)))
;; A face is a way in to who someone is, so it takes the press that
;; opens their profile — the one gesture a phone has for this, and the
;; only one either target uses. It was drawn without one and the chat
;; screen's `:on-click` went nowhere.
:avatar
(let [s (dbl (:size p) 32.0)
src (:src p)
;; Either kind of source, as `:image` takes either kind: the
;; desktop's `avatar-path` is a file it downloaded and this
;; half's is the CDN URL itself, and the screen hands over
;; whichever its host answered with.
bg (when (and src (not= "" src))
(if (or (.startsWith (str src) "http://")
(.startsWith (str src) "https://"))
(m/NetworkImage (src-url src))
(m/FileImage (io/File. (str src)))))
face (m/CircleAvatar
.radius (/ s 2.0)
.backgroundColor t/component
.backgroundImage bg
;; A picture that will not load is a face that stays its
;; initial, and nothing else: without this the failure goes
;; to `FlutterError.onError`, which this app points at
;; debugPrint — one deleted avatar, one line of log per
;; frame. CircleAvatar asserts if this is given without an
;; image, hence the `when`.
.onBackgroundImageError (when bg (fn [_ _] nil))
.child (when (nil? bg)
(txt ctx (let [l (str (:label p ""))]
(if (pos? (count l))
(.toUpperCase (subs l 0 1))
"?"))
t/text-body t/on-bg)))]
(if-let [on (:on-click p)]
(m/InkWell .onTap #(on)
.customBorder (m/CircleBorder)
.child face)
face))
:image
(let [src (or (:src p) (:path p))
w (:max-width p)
h (:max-height p)
;; A half-written cache file, or one deleted under us: the
;; decoder throws during the build, and an exception in a build
;; is a red screen for the whole conversation rather than a gap
;; where one picture was.
oops (fn [_ _ _] (m/SizedBox .width 0.0 .height 0.0))
img (cond
(nil? src) (m/SizedBox .width 0.0 .height 0.0)
;; A picture the bundle carries rather than one the reader
;; chose or the network holds: `pubspec.yaml`'s assets,
;; named `asset:<path>` so the three sources stay one
;; `:src`. A file path could not do this job — the bundle
;; is not the filesystem on Android, and the desktop build
;; is launched from wherever it was installed.
(.startsWith (str src) "asset:")
(m/Image.asset (subs (str src) 6) .fit m/BoxFit.contain
.errorBuilder oops)
(or (.startsWith (str src) "http://")
(.startsWith (str src) "https://"))
(m/Image.network (src-url src) .fit m/BoxFit.contain
.errorBuilder oops)
:else (m/Image.file (io/File. (str src)) .fit m/BoxFit.contain
.errorBuilder oops))
img (m/ClipRRect .borderRadius (m/BorderRadius.circular t/radius-s)
.child img)
;; `:fit` is the lightbox: the picture takes what the column has
;; left, which `fills-column?` has already asked Flutter for, and
;; spreads across the width inside it. Without the SizedBox the
;; Expanded gives it the height and its own intrinsic width, and
;; a portrait screenshot is a strip up the middle.
img (if (:fit p)
(m/SizedBox .width double/infinity .height double/infinity
.child img)
img)
img (if (or w h)
(m/ConstrainedBox
.constraints (m/BoxConstraints
.maxWidth (dbl w double/infinity)
.maxHeight (dbl h double/infinity))
.child img)
img)]
(if-let [on (:on-click p)]
(m/InkWell .onTap #(on) .child img)
img))
;; No Expanded of its own: `fills-column?` says a scroll takes what is
;; left, so its parent column wraps it — and wrapping here as well put
;; two ParentDataWidgets on one RenderObject, which Flutter calls
;; "competing" and then draws nothing.
:scroll
(let [k (str (:scroll-key p))
token (:scroll-to-bottom p)
on-change (:on-change p)]
(f/widget
;; `:managed`, so the controller belongs to this widget and is
;; disposed with it. One controller per key in a global map was the
;; obvious thing and the wrong thing: Flutter builds a new subtree
;; before unmounting the old one whenever the tree changes shape, so
;; two live scroll views briefly shared it and it asserted —
;; "ScrollController attached to multiple scroll views", which took
;; the whole screen.
;;
;; And an atom beside it for the key that controller is serving —
;; a fact about this widget and not about the key, which is the
;; whole point of it: see `end-ward!`. `:dispose nil` because an
;; atom has no resource to give back.
:managed [ctrl (m/ScrollController)
last-key (atom nil) :dispose nil]
;; `here-ward!` after `end-ward!`, so the callback that aims at a
;; line is queued behind the one that aims at the end: the end's
;; runs first, sees the pending jump and stands down, and then this
;; one clears it.
:let [_ (watch-end! k ctrl on-change)
_ (end-ward! k ctrl last-key token)
_ (here-ward!)]
(m/SingleChildScrollView
.controller ctrl
.child (col (dbl (:spacing p) 0.0) (body node)))))
;; A tag this backend has not grown yet still shows its children — which
;; is what libvidya did and what jolt-cosmic kept. The marker is here so
;; it is obvious which ones are missing: glimmer-cosmic's spike painted
;; every unknown tag as a silent column, and that is why most of frq
;; came out of it as stacked text.
(m/Column
.crossAxisAlignment m/CrossAxisAlignment.start
.mainAxisSize m/MainAxisSize.min
.children (into [(txt ctx (str "?" (name tag)) 10.0 m/Colors.orange)]
kids))))))
(defn render
"One hiccup node as a Flutter widget.
A vector whose head is a function is a component, and `expand` is what calls
one — here as well as in the fills- questions above. It used to be applied
again right here, which is how a component could be asked whether it fills
by one set of rules and rendered by another: the `:key` map that `expand`
learned to strip still reached the function on the way to the screen, and
the chat screen still threw. One door now.
Everything else is a tag."
[node]
(cond
(nil? node) (m/SizedBox .width 0.0 .height 0.0)
(string? node) (m/Text node)
(vector? node)
(let [head (first node)]
(if (keyword? head)
(render-tag head node)
(render (expand node))))
(seq? node)
(m/Column .crossAxisAlignment m/CrossAxisAlignment.start
.mainAxisSize m/MainAxisSize.min
.children (children node))
:else (m/Text (str node))))
;; ------------------------------------------------------------------- root
(defn- dialog-node?
[node]
(and (vector? node) (= :dialog (first node))))
(defn- lift-dialog
"`[tree-without-its-dialog dialog-or-nil]`.
`frq.screens.app` puts the dialog beside the screens rather than instead of
one, in a wrapper that is always there so that a child coming and going
cannot renumber the root — see the comment on `app`. libcosmic takes that
node and floats it itself. Flutter has no such hand-off: the node is a widget
in a Column, and a widget cannot lift itself out of the column it is in. So
this does the lifting, and `render-root` does the floating.
Only `:vbox` is descended into, which is every wrapper between the root and
the dialog and nothing else — there is no reason to walk a conversation
looking for one. And the node is replaced by its expansion on the way past,
so the component behind it runs once rather than once here and once again
when the tree it came out of is rendered."
[node]
(let [n (expand node)]
(cond
(dialog-node? n) [nil n]
(and (vector? n) (= :vbox (first n)))
(let [p (props n)
head (if (map? (second n)) [(first n) p] [(first n)])]
(reduce (fn [[acc found] kid]
(if found
[(conj acc kid) found]
(let [[kid' d] (lift-dialog kid)]
[(conj acc kid') (or d found)])))
[head nil]
(body n)))
:else [n nil])))
(defn render-root
"The whole app as one widget: the screens, and the dialog floated over them.
On a phone this is `render` and nothing more. The dialog there is a sheet
drawn where the tree puts it, which is what a screen the width of a dialog
wants.
In a window it is libcosmic's arrangement, built out of a Stack: the screens
at their full size, a scrim, and the card centred over both. The screen keeps
its place in the tree either way — and so its scroll position — because the
dialog was never in its subtree to begin with.
Two things about the scrim, and they are the same two things `profile-dialog`
says about modality. A card that was *pressed* open dims what is behind it
and swallows the presses meant for it: it is the thing on the screen until it
is dismissed. A card the pointer is merely *holding* open does neither — it
paints no dim, and `IgnorePointer` lets every event through to the face
underneath, because the face has to keep hearing the pointer or nothing would
ever tell the card to go."
[node]
(if-not (pointer?)
(render node)
(let [[tree dialog] (lift-dialog node)]
;; The Stack is built whether or not there is a dialog, and that is the
;; whole of this. Returning a bare `(render tree)` when there is none
;; meant the root changed SHAPE the moment a hover put a card up — one
;; child, then two — and Flutter rebuilds a subtree rather than updating
;; it when the shape under a slot changes. Two things went with it, and
;; both were blamed on the card:
;;
;; The scroll view's `:managed` ScrollController is state, so it was
;; disposed and remade with a fresh offset: resting on a reaction threw
;; the reader back up the backlog. The same hazard the `:managed`
;; comment on `:scroll` warns about, arrived at from the other side.
;;
;; And the MouseRegion under the pointer was remade too, so its `onExit`
;; never fired — the pointer left a widget that no longer existed, the
;; unhover never ran, and the card stayed up for ever. It got worse the
;; moment avatars started loading, because every one that lands calls
;; `bump!` and rebuilds the tree.
;;
;; So the second layer is always there; with nothing to show it is an
;; empty box that hears nothing.
(let [modal (boolean (and dialog (:modal (props dialog))))]
(m/Stack
;; Expand, and not the default loose fit: a Stack hands its
;; unpositioned children the parent's constraints *loosened*, so
;; the screens went from filling the window to shrinking onto their
;; own contents the moment a hover put a card up — the conversation
;; narrowed and every message moved under the pointer that was only
;; resting on a face. The window is bounded here (Scaffold body,
;; see `frq.main`), so asking for all of it is a size there is.
.fit m/StackFit.expand
.children
[(render tree)
(m/Positioned.fill
.child
(let [scrim (m/ColoredBox
.color (if modal
(m/Color.fromRGBO 0 0 0 0.45)
(m/Color.fromRGBO 0 0 0 0.0)))]
(if (and modal (some? dialog))
;; Absorbing and not merely painting: a press on the dimmed
;; half of the window is a press meant for the card.
(m/GestureDetector .onTap (fn [] nil) .child scrim)
(m/IgnorePointer .child scrim))))
;; `Center` and not a scroll view over the screen: a Center hands
;; the hit test to its child's bounds and nothing more, while a
;; viewport absorbs every event inside its own — which here is the
;; whole window, faces included. The card does its own scrolling.
(m/Positioned.fill
.child
(if (nil? dialog)
;; Nothing to show, and nothing to hear: an empty layer that
;; keeps the shape constant so the screens below are updated
;; rather than rebuilt.
(m/IgnorePointer .child (m/SizedBox.shrink))
(m/Padding
.padding (m/EdgeInsets.all t/space-s)
.child (m/Center .child (render dialog)))))])))))
|