nandi/frqpublic Fork 0
b4172e953d70d5164f0837f6a88b0b92448c32f6
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

A link in a message is a thing you can follow again 8d6f6cb · on b4172e953d70d5164f0837f6a88b0b92448c32f6 · nandi · yesterday
hiccup.cljd · 1462 lines · 72.2 KBGDScript3 Blame HistoryRaw
   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)))))])))))