A keyboard that does not help, and an overview that scrolls
Two things a phone was making worse. A handle is not a sentence. A phone keyboard capitalises the first letter, suggests a word it guessed at, and -- the one that prompted this -- puts a space after a full stop, because a full stop ends a sentence. It is also the middle of `alice.bsky.social`, and a handle with a space in it resolves to nothing. `verbatim` asks for the text to be taken as typed: no autocorrect, no suggestions, no capitalisation, and the URL keyboard, which has a full stop on the main plane and no space bar to be helpful with. Every field holding an identifier gets it -- handle, host, port, nick, app password, and the join box. The compose box is prose and keeps its help. And the overview could not be scrolled, which is the whole reason it showed eight entries: eight was what fitted, and anything below the fold could not be reached at all. It scrolls now and shows forty, and the pane takes a share of the column only while it is open -- an empty wrapper claiming one would hold half the screen to show nothing. The heading scrolls with the entries rather than staying above them, and that is the arrangement that works rather than the nicer one: a fixed heading wants an `Expanded` inside a pane that is already claiming its own share with `expand`, and a Column cannot shrink-wrap and fill at once. `frq_ui_demo` gains a third room with a dozen lines in it, because a test that the overview scrolls would otherwise pass on a list that fits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fc42e9b parent: 535605d modified
flutter/lib/nim_renderer.dart +18 -0 | @@ -888,10 +888,28 @@ class _NimAppState extends State<NimApp> { | ||
| 888 | 888 | selection: TextSelection.collapsed(offset: value.length), |
| 889 | 889 | ); |
| 890 | 890 | } |
| 891 | + // A field holding an identifier, not a sentence. | |
| 892 | + // | |
| 893 | + // A phone keyboard is built for prose and every one of its helps | |
| 894 | + // is wrong here: it capitalises the first letter of a handle, | |
| 895 | + // suggests a word it guessed at, and -- the one that prompted | |
| 896 | + // this -- inserts a space after a full stop, because a full stop | |
| 897 | + // ends a sentence. It is also the middle of `alice.bsky.social`, | |
| 898 | + // and a handle with a space in it resolves to nothing. | |
| 899 | + // | |
| 900 | + // `TextInputType.url` is the layout that suits it: a full stop on | |
| 901 | + // the main plane and no space bar to be helpful with. | |
| 902 | + final verbatim = n.prop('verbatim', false); | |
| 891 | 903 | final field = TextField( |
| 892 | 904 | controller: c, |
| 893 | 905 | focusNode: _focus.putIfAbsent(key, FocusNode.new), |
| 894 | 906 | style: _style(t.textBody, t.onBg), |
| 907 | + autocorrect: !verbatim, | |
| 908 | + enableSuggestions: !verbatim, | |
| 909 | + textCapitalization: verbatim | |
| 910 | + ? TextCapitalization.none | |
| 911 | + : TextCapitalization.sentences, | |
| 912 | + keyboardType: verbatim ? TextInputType.url : null, | |
| 895 | 913 | decoration: InputDecoration( |
| 896 | 914 | hintText: n.prop('placeholder', ''), |
| 897 | 915 | hintStyle: _style(t.textBody, t.dim), |
| @@ -888,10 +888,28 @@ class _NimAppState extends State<NimApp> { | |||
| 888 | selection: TextSelection.collapsed(offset: value.length), | 888 | selection: TextSelection.collapsed(offset: value.length), |
| 889 | ); | 889 | ); |
| 890 | } | 890 | } |
| 891 | + // A field holding an identifier, not a sentence. | ||
| 892 | + // | ||
| 893 | + // A phone keyboard is built for prose and every one of its helps | ||
| 894 | + // is wrong here: it capitalises the first letter of a handle, | ||
| 895 | + // suggests a word it guessed at, and -- the one that prompted | ||
| 896 | + // this -- inserts a space after a full stop, because a full stop | ||
| 897 | + // ends a sentence. It is also the middle of `alice.bsky.social`, | ||
| 898 | + // and a handle with a space in it resolves to nothing. | ||
| 899 | + // | ||
| 900 | + // `TextInputType.url` is the layout that suits it: a full stop on | ||
| 901 | + // the main plane and no space bar to be helpful with. | ||
| 902 | + final verbatim = n.prop('verbatim', false); | ||
| 891 | final field = TextField( | 903 | final field = TextField( |
| 892 | controller: c, | 904 | controller: c, |
| 893 | focusNode: _focus.putIfAbsent(key, FocusNode.new), | 905 | focusNode: _focus.putIfAbsent(key, FocusNode.new), |
| 894 | style: _style(t.textBody, t.onBg), | 906 | style: _style(t.textBody, t.onBg), |
| 907 | + autocorrect: !verbatim, | ||
| 908 | + enableSuggestions: !verbatim, | ||
| 909 | + textCapitalization: verbatim | ||
| 910 | + ? TextCapitalization.none | ||
| 911 | + : TextCapitalization.sentences, | ||
| 912 | + keyboardType: verbatim ? TextInputType.url : null, | ||
| 895 | decoration: InputDecoration( | 913 | decoration: InputDecoration( |
| 896 | hintText: n.prop('placeholder', ''), | 914 | hintText: n.prop('placeholder', ''), |
| 897 | hintStyle: _style(t.textBody, t.dim), | 915 | hintStyle: _style(t.textBody, t.dim), |
modified
flutter/test/nim_layout_test.dart +24 -0 | @@ -579,6 +579,30 @@ void main() { | ||
| 579 | 579 | expectLaidOut(tester, 'the overview on a phone'); |
| 580 | 580 | }); |
| 581 | 581 | |
| 582 | + | |
| 583 | + testWidgets('scrolls, so what is below the fold can be reached', | |
| 584 | + (tester) async { | |
| 585 | + // It did not. The pane drew its entries and whatever did not fit was | |
| 586 | + // simply unreachable -- which is why there were only ever eight. | |
| 587 | + core.demoUi(); | |
| 588 | + core.dispatch('overview.toggle'); | |
| 589 | + await layOut(tester, sizes['phone']!); | |
| 590 | + | |
| 591 | + final list = find | |
| 592 | + .ancestor( | |
| 593 | + of: find.textContaining('sandbox-01'), | |
| 594 | + matching: find.byType(Scrollable)) | |
| 595 | + .first; | |
| 596 | + expect(list, findsOneWidget, reason: 'the overview is not scrollable'); | |
| 597 | + | |
| 598 | + final pos = tester.widget<Scrollable>(list).controller!.position; | |
| 599 | + expect(pos.maxScrollExtent, greaterThan(0), | |
| 600 | + reason: 'the overview has nothing below the fold to scroll to'); | |
| 601 | + | |
| 602 | + pos.jumpTo(pos.maxScrollExtent); | |
| 603 | + await tester.pumpAndSettle(); | |
| 604 | + expectLaidOut(tester, 'the overview scrolled to its end'); | |
| 605 | + }); | |
| 582 | 606 | testWidgets('and the whole entry is the control, not an arrow', |
| 583 | 607 | (tester) async { |
| 584 | 608 | core.demoUi(); |
| @@ -579,6 +579,30 @@ void main() { | |||
| 579 | expectLaidOut(tester, 'the overview on a phone'); | 579 | expectLaidOut(tester, 'the overview on a phone'); |
| 580 | }); | 580 | }); |
| 581 | 581 | ||
| 582 | + | ||
| 583 | + testWidgets('scrolls, so what is below the fold can be reached', | ||
| 584 | + (tester) async { | ||
| 585 | + // It did not. The pane drew its entries and whatever did not fit was | ||
| 586 | + // simply unreachable -- which is why there were only ever eight. | ||
| 587 | + core.demoUi(); | ||
| 588 | + core.dispatch('overview.toggle'); | ||
| 589 | + await layOut(tester, sizes['phone']!); | ||
| 590 | + | ||
| 591 | + final list = find | ||
| 592 | + .ancestor( | ||
| 593 | + of: find.textContaining('sandbox-01'), | ||
| 594 | + matching: find.byType(Scrollable)) | ||
| 595 | + .first; | ||
| 596 | + expect(list, findsOneWidget, reason: 'the overview is not scrollable'); | ||
| 597 | + | ||
| 598 | + final pos = tester.widget<Scrollable>(list).controller!.position; | ||
| 599 | + expect(pos.maxScrollExtent, greaterThan(0), | ||
| 600 | + reason: 'the overview has nothing below the fold to scroll to'); | ||
| 601 | + | ||
| 602 | + pos.jumpTo(pos.maxScrollExtent); | ||
| 603 | + await tester.pumpAndSettle(); | ||
| 604 | + expectLaidOut(tester, 'the overview scrolled to its end'); | ||
| 605 | + }); | ||
| 582 | testWidgets('and the whole entry is the control, not an arrow', | 606 | testWidgets('and the whole entry is the control, not an arrow', |
| 583 | (tester) async { | 607 | (tester) async { |
| 584 | core.demoUi(); | 608 | core.demoUi(); |
modified
nim/src/frq/screens/chat.nim +21 -5 | @@ -23,7 +23,7 @@ const | ||
| 23 | 23 | faceSize = 32 |
| 24 | 24 | pillSize = 20 |
| 25 | 25 | chipGap = 4 |
| 26 | - overviewLines* = 8 | |
| 26 | + overviewLines* = 40 | |
| 27 | 27 | |
| 28 | 28 | sidePanelWidth = 150 |
| 29 | 29 | ## What a strip beside the backlog takes. Wide enough for a room name or |
| @@ -297,14 +297,14 @@ proc overviewPane(s: State): Node = | ||
| 297 | 297 | # So the arrow is gone and the whole card takes the press, which is both |
| 298 | 298 | # the larger target and the smaller thing to draw. What is left is what a |
| 299 | 299 | # reader actually skims: who said it and where, then what they said. |
| 300 | - var body = vbox(%*{"spacing": 4}, title2("Overview")) | |
| 300 | + var list = vbox(%*{"spacing": 4}) | |
| 301 | 301 | var n = 0 |
| 302 | 302 | for m in recentEverywhere(s.rooms, s.current): |
| 303 | 303 | if n >= overviewLines: break |
| 304 | 304 | n += 1 |
| 305 | 305 | # Each line carries the room it was said in, since that is the one thing a |
| 306 | 306 | # line taken out of its own conversation no longer says for itself. |
| 307 | - body.children.add card( | |
| 307 | + list.children.add card( | |
| 308 | 308 | %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m), |
| 309 | 309 | "spacing": 2}, |
| 310 | 310 | hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)), |
| @@ -315,8 +315,19 @@ proc overviewPane(s: State): Node = | ||
| 315 | 315 | # what a reader sees is bounded by the thing they are looking at. |
| 316 | 316 | text(summarise(m.text, 160), lines = 2)) |
| 317 | 317 | if n == 0: |
| 318 | - body.children.add dimLabel("Nothing has happened anywhere else.") | |
| 319 | - result = body | |
| 318 | + list.children.add dimLabel("Nothing has happened anywhere else.") | |
| 319 | + | |
| 320 | + # The whole pane scrolls, heading and all. There were eight entries | |
| 321 | + # because eight was all that could be seen: nothing here scrolled, so | |
| 322 | + # what fell below the fold could not be reached at all. | |
| 323 | + # | |
| 324 | + # The heading scrolls with them rather than staying put, and that is not | |
| 325 | + # the nicer arrangement -- it is the one that works. A fixed heading over | |
| 326 | + # a scrolling list wants an `Expanded` inside this pane, and `expand` is | |
| 327 | + # already how the pane claims its own share of the column; a second one | |
| 328 | + # nested in the first asks a Column to shrink-wrap and fill at once. | |
| 329 | + result = scroll(%*{"scrollKey": "overview", "orientation": "vertical"}, | |
| 330 | + vbox(%*{"spacing": 4}, title2("Overview"), list)) | |
| 320 | 331 | |
| 321 | 332 | proc lightboxPane(s: State): Node = |
| 322 | 333 | ## The picture being looked at, as large as the window allows. |
| @@ -539,8 +550,13 @@ proc chatScreen*(s: State, connected: bool): Node = | ||
| 539 | 550 | |
| 540 | 551 | # Both panels are in wrappers that are always there, for the reason the |
| 541 | 552 | # error note is: a child that comes and goes renumbers the row. |
| 553 | + # `expand` only while it is open, and that is not a detail: an empty | |
| 554 | + # wrapper claiming a share of the column would take half the screen to | |
| 555 | + # show nothing. Open, it splits the height with the backlog above it -- | |
| 556 | + # both scroll, so neither has to be given up for the other. | |
| 542 | 557 | var overview = vbox(%*{"key": "overview-pane"}) |
| 543 | 558 | if s.overview: |
| 559 | + overview.props["expand"] = %true | |
| 544 | 560 | overview.children.add overviewPane(s) |
| 545 | 561 | |
| 546 | 562 | var profile = vbox(%*{"key": "profile-pane"}) |
| @@ -23,7 +23,7 @@ const | |||
| 23 | faceSize = 32 | 23 | faceSize = 32 |
| 24 | pillSize = 20 | 24 | pillSize = 20 |
| 25 | chipGap = 4 | 25 | chipGap = 4 |
| 26 | - overviewLines* = 8 | 26 | + overviewLines* = 40 |
| 27 | 27 | ||
| 28 | sidePanelWidth = 150 | 28 | sidePanelWidth = 150 |
| 29 | ## What a strip beside the backlog takes. Wide enough for a room name or | 29 | ## What a strip beside the backlog takes. Wide enough for a room name or |
| @@ -297,14 +297,14 @@ proc overviewPane(s: State): Node = | |||
| 297 | # So the arrow is gone and the whole card takes the press, which is both | 297 | # So the arrow is gone and the whole card takes the press, which is both |
| 298 | # the larger target and the smaller thing to draw. What is left is what a | 298 | # the larger target and the smaller thing to draw. What is left is what a |
| 299 | # reader actually skims: who said it and where, then what they said. | 299 | # reader actually skims: who said it and where, then what they said. |
| 300 | - var body = vbox(%*{"spacing": 4}, title2("Overview")) | 300 | + var list = vbox(%*{"spacing": 4}) |
| 301 | var n = 0 | 301 | var n = 0 |
| 302 | for m in recentEverywhere(s.rooms, s.current): | 302 | for m in recentEverywhere(s.rooms, s.current): |
| 303 | if n >= overviewLines: break | 303 | if n >= overviewLines: break |
| 304 | n += 1 | 304 | n += 1 |
| 305 | # Each line carries the room it was said in, since that is the one thing a | 305 | # Each line carries the room it was said in, since that is the one thing a |
| 306 | # line taken out of its own conversation no longer says for itself. | 306 | # line taken out of its own conversation no longer says for itself. |
| 307 | - body.children.add card( | 307 | + list.children.add card( |
| 308 | %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m), | 308 | %*{"onClick": "overview.goto:" & m.room & ":" & rowId(m), |
| 309 | "spacing": 2}, | 309 | "spacing": 2}, |
| 310 | hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)), | 310 | hbox(%*{"spacing": 6}, dimLabel(m.room), label(m.frm)), |
| @@ -315,8 +315,19 @@ proc overviewPane(s: State): Node = | |||
| 315 | # what a reader sees is bounded by the thing they are looking at. | 315 | # what a reader sees is bounded by the thing they are looking at. |
| 316 | text(summarise(m.text, 160), lines = 2)) | 316 | text(summarise(m.text, 160), lines = 2)) |
| 317 | if n == 0: | 317 | if n == 0: |
| 318 | - body.children.add dimLabel("Nothing has happened anywhere else.") | 318 | + list.children.add dimLabel("Nothing has happened anywhere else.") |
| 319 | - result = body | 319 | + |
| 320 | + # The whole pane scrolls, heading and all. There were eight entries | ||
| 321 | + # because eight was all that could be seen: nothing here scrolled, so | ||
| 322 | + # what fell below the fold could not be reached at all. | ||
| 323 | + # | ||
| 324 | + # The heading scrolls with them rather than staying put, and that is not | ||
| 325 | + # the nicer arrangement -- it is the one that works. A fixed heading over | ||
| 326 | + # a scrolling list wants an `Expanded` inside this pane, and `expand` is | ||
| 327 | + # already how the pane claims its own share of the column; a second one | ||
| 328 | + # nested in the first asks a Column to shrink-wrap and fill at once. | ||
| 329 | + result = scroll(%*{"scrollKey": "overview", "orientation": "vertical"}, | ||
| 330 | + vbox(%*{"spacing": 4}, title2("Overview"), list)) | ||
| 320 | 331 | ||
| 321 | proc lightboxPane(s: State): Node = | 332 | proc lightboxPane(s: State): Node = |
| 322 | ## The picture being looked at, as large as the window allows. | 333 | ## The picture being looked at, as large as the window allows. |
| @@ -539,8 +550,13 @@ proc chatScreen*(s: State, connected: bool): Node = | |||
| 539 | 550 | ||
| 540 | # Both panels are in wrappers that are always there, for the reason the | 551 | # Both panels are in wrappers that are always there, for the reason the |
| 541 | # error note is: a child that comes and goes renumbers the row. | 552 | # error note is: a child that comes and goes renumbers the row. |
| 553 | + # `expand` only while it is open, and that is not a detail: an empty | ||
| 554 | + # wrapper claiming a share of the column would take half the screen to | ||
| 555 | + # show nothing. Open, it splits the height with the backlog above it -- | ||
| 556 | + # both scroll, so neither has to be given up for the other. | ||
| 542 | var overview = vbox(%*{"key": "overview-pane"}) | 557 | var overview = vbox(%*{"key": "overview-pane"}) |
| 543 | if s.overview: | 558 | if s.overview: |
| 559 | + overview.props["expand"] = %true | ||
| 544 | overview.children.add overviewPane(s) | 560 | overview.children.add overviewPane(s) |
| 545 | 561 | ||
| 546 | var profile = vbox(%*{"key": "profile-pane"}) | 562 | var profile = vbox(%*{"key": "profile-pane"}) |
modified
nim/src/frq/screens/chats.nim +1 -1 | @@ -54,7 +54,7 @@ func chatsScreen*(s: State, connected: bool): Node = | ||
| 54 | 54 | var joinRow = hbox(%*{"spacing": 8, "align": "end"}, |
| 55 | 55 | button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"), |
| 56 | 56 | entry("join-input", s.joinInput, "#channel or @nick", "join-input.change", |
| 57 | - onSubmit = "join")) | |
| 57 | + onSubmit = "join", verbatim = true)) | |
| 58 | 58 | |
| 59 | 59 | var searchRow = hbox(%*{"spacing": 8, "align": "end"}) |
| 60 | 60 | if s.search.len > 0: |
| @@ -54,7 +54,7 @@ func chatsScreen*(s: State, connected: bool): Node = | |||
| 54 | var joinRow = hbox(%*{"spacing": 8, "align": "end"}, | 54 | var joinRow = hbox(%*{"spacing": 8, "align": "end"}, |
| 55 | button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"), | 55 | button(if s.joinInput.startsWith("@"): "Message" else: "Join", "join"), |
| 56 | entry("join-input", s.joinInput, "#channel or @nick", "join-input.change", | 56 | entry("join-input", s.joinInput, "#channel or @nick", "join-input.change", |
| 57 | - onSubmit = "join")) | 57 | + onSubmit = "join", verbatim = true)) |
| 58 | 58 | ||
| 59 | var searchRow = hbox(%*{"spacing": 8, "align": "end"}) | 59 | var searchRow = hbox(%*{"spacing": 8, "align": "end"}) |
| 60 | if s.search.len > 0: | 60 | if s.search.len > 0: |
modified
nim/src/frq/screens/connect.nim +9 -6 | @@ -45,8 +45,10 @@ func serverFields(s: State): Node = | ||
| 45 | 45 | vbox(%*{"spacing": 6}, |
| 46 | 46 | label("Server"), |
| 47 | 47 | hbox(%*{"spacing": 8}, |
| 48 | - entry("host", s.formHost, "host", "host.change", width = 220), | |
| 49 | - entry("port", s.formPort, "6697", "port.change", width = 90)), | |
| 48 | + entry("host", s.formHost, "host", "host.change", width = 220, | |
| 49 | + verbatim = true), | |
| 50 | + entry("port", s.formPort, "6697", "port.change", width = 90, | |
| 51 | + verbatim = true)), | |
| 50 | 52 | checkbutton("TLS", s.formTls, "tls.toggle")) |
| 51 | 53 | |
| 52 | 54 | func authFields(s: State): Node = |
| @@ -63,7 +65,7 @@ func authFields(s: State): Node = | ||
| 63 | 65 | "never reaches this app."), |
| 64 | 66 | label("Handle"), |
| 65 | 67 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 66 | - width = 320)) | |
| 68 | + width = 320, verbatim = true)) | |
| 67 | 69 | # Both of these are stable wrappers for the reason `errorNote` is one. |
| 68 | 70 | var remembered = vbox(%*{"key": "remembered", "spacing": 4}) |
| 69 | 71 | if s.brokerToken.len > 0: |
| @@ -86,17 +88,18 @@ func authFields(s: State): Node = | ||
| 86 | 88 | "is handed the session it mints."), |
| 87 | 89 | label("Handle"), |
| 88 | 90 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 89 | - width = 320), | |
| 91 | + width = 320, verbatim = true), | |
| 90 | 92 | label("App password"), |
| 91 | 93 | entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", |
| 92 | - "app-password.change", width = 320), | |
| 94 | + "app-password.change", width = 320, verbatim = true), | |
| 93 | 95 | dimLabel("Make one at bsky.app → Settings → App Passwords.")) |
| 94 | 96 | |
| 95 | 97 | of amGuest: |
| 96 | 98 | result = vbox(%*{"spacing": 6}, |
| 97 | 99 | title2("Connect as guest"), |
| 98 | 100 | label("Nick"), |
| 99 | - entry("nick", s.formNick, "your nick", "nick.change", width = 320)) | |
| 101 | + entry("nick", s.formNick, "your nick", "nick.change", width = 320, | |
| 102 | + verbatim = true)) | |
| 100 | 103 | |
| 101 | 104 | func connectAction(s: State): Node = |
| 102 | 105 | if s.connecting: |
| @@ -45,8 +45,10 @@ func serverFields(s: State): Node = | |||
| 45 | vbox(%*{"spacing": 6}, | 45 | vbox(%*{"spacing": 6}, |
| 46 | label("Server"), | 46 | label("Server"), |
| 47 | hbox(%*{"spacing": 8}, | 47 | hbox(%*{"spacing": 8}, |
| 48 | - entry("host", s.formHost, "host", "host.change", width = 220), | 48 | + entry("host", s.formHost, "host", "host.change", width = 220, |
| 49 | - entry("port", s.formPort, "6697", "port.change", width = 90)), | 49 | + verbatim = true), |
| 50 | + entry("port", s.formPort, "6697", "port.change", width = 90, | ||
| 51 | + verbatim = true)), | ||
| 50 | checkbutton("TLS", s.formTls, "tls.toggle")) | 52 | checkbutton("TLS", s.formTls, "tls.toggle")) |
| 51 | 53 | ||
| 52 | func authFields(s: State): Node = | 54 | func authFields(s: State): Node = |
| @@ -63,7 +65,7 @@ func authFields(s: State): Node = | |||
| 63 | "never reaches this app."), | 65 | "never reaches this app."), |
| 64 | label("Handle"), | 66 | label("Handle"), |
| 65 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | 67 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 66 | - width = 320)) | 68 | + width = 320, verbatim = true)) |
| 67 | # Both of these are stable wrappers for the reason `errorNote` is one. | 69 | # Both of these are stable wrappers for the reason `errorNote` is one. |
| 68 | var remembered = vbox(%*{"key": "remembered", "spacing": 4}) | 70 | var remembered = vbox(%*{"key": "remembered", "spacing": 4}) |
| 69 | if s.brokerToken.len > 0: | 71 | if s.brokerToken.len > 0: |
| @@ -86,17 +88,18 @@ func authFields(s: State): Node = | |||
| 86 | "is handed the session it mints."), | 88 | "is handed the session it mints."), |
| 87 | label("Handle"), | 89 | label("Handle"), |
| 88 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | 90 | entry("handle", s.formHandle, "alice.bsky.social", "handle.change", |
| 89 | - width = 320), | 91 | + width = 320, verbatim = true), |
| 90 | label("App password"), | 92 | label("App password"), |
| 91 | entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", | 93 | entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", |
| 92 | - "app-password.change", width = 320), | 94 | + "app-password.change", width = 320, verbatim = true), |
| 93 | dimLabel("Make one at bsky.app → Settings → App Passwords.")) | 95 | dimLabel("Make one at bsky.app → Settings → App Passwords.")) |
| 94 | 96 | ||
| 95 | of amGuest: | 97 | of amGuest: |
| 96 | result = vbox(%*{"spacing": 6}, | 98 | result = vbox(%*{"spacing": 6}, |
| 97 | title2("Connect as guest"), | 99 | title2("Connect as guest"), |
| 98 | label("Nick"), | 100 | label("Nick"), |
| 99 | - entry("nick", s.formNick, "your nick", "nick.change", width = 320)) | 101 | + entry("nick", s.formNick, "your nick", "nick.change", width = 320, |
| 102 | + verbatim = true)) | ||
| 100 | 103 | ||
| 101 | func connectAction(s: State): Node = | 104 | func connectAction(s: State): Node = |
| 102 | if s.connecting: | 105 | if s.connecting: |
modified
nim/src/frq/ui.nim +7 -1 | @@ -80,13 +80,19 @@ func button*(text: string, onClick: string, kind = "default"): Node = | ||
| 80 | 80 | n("button", %*{"label": text, "kind": kind, "onClick": onClick}) |
| 81 | 81 | |
| 82 | 82 | func entry*(key, text, placeholder, onChange: string, width = 0, |
| 83 | - onSubmit = ""): Node = | |
| 83 | + onSubmit = "", verbatim = false): Node = | |
| 84 | 84 | ## Every entry carries a key, and for the reason the Clojure's comment |
| 85 | 85 | ## gives: a renderer that keeps a text controller per field needs a stable |
| 86 | 86 | ## name for it, and without one the host and the port shared a controller |
| 87 | 87 | ## and both showed the port. |
| 88 | 88 | var p = %*{"key": key, "text": text, "placeholder": placeholder, |
| 89 | 89 | "onChange": onChange} |
| 90 | + # An identifier rather than prose: a handle, a host, a nick. A phone | |
| 91 | + # keyboard helps with prose and the help is wrong here -- it capitalises | |
| 92 | + # the first letter, offers a word it guesses at, and puts a space after a | |
| 93 | + # full stop, which is a sentence ending and is also the middle of | |
| 94 | + # `alice.bsky.social`. `verbatim` asks for the text to be taken as typed. | |
| 95 | + if verbatim: p["verbatim"] = %true | |
| 90 | 96 | if width > 0: p["widthRequest"] = %width |
| 91 | 97 | # Enter, where the field has something to do with it. A compose box that |
| 92 | 98 | # only sends on a button click is one nobody can type into at speed. |
| @@ -80,13 +80,19 @@ func button*(text: string, onClick: string, kind = "default"): Node = | |||
| 80 | n("button", %*{"label": text, "kind": kind, "onClick": onClick}) | 80 | n("button", %*{"label": text, "kind": kind, "onClick": onClick}) |
| 81 | 81 | ||
| 82 | func entry*(key, text, placeholder, onChange: string, width = 0, | 82 | func entry*(key, text, placeholder, onChange: string, width = 0, |
| 83 | - onSubmit = ""): Node = | 83 | + onSubmit = "", verbatim = false): Node = |
| 84 | ## Every entry carries a key, and for the reason the Clojure's comment | 84 | ## Every entry carries a key, and for the reason the Clojure's comment |
| 85 | ## gives: a renderer that keeps a text controller per field needs a stable | 85 | ## gives: a renderer that keeps a text controller per field needs a stable |
| 86 | ## name for it, and without one the host and the port shared a controller | 86 | ## name for it, and without one the host and the port shared a controller |
| 87 | ## and both showed the port. | 87 | ## and both showed the port. |
| 88 | var p = %*{"key": key, "text": text, "placeholder": placeholder, | 88 | var p = %*{"key": key, "text": text, "placeholder": placeholder, |
| 89 | "onChange": onChange} | 89 | "onChange": onChange} |
| 90 | + # An identifier rather than prose: a handle, a host, a nick. A phone | ||
| 91 | + # keyboard helps with prose and the help is wrong here -- it capitalises | ||
| 92 | + # the first letter, offers a word it guesses at, and puts a space after a | ||
| 93 | + # full stop, which is a sentence ending and is also the middle of | ||
| 94 | + # `alice.bsky.social`. `verbatim` asks for the text to be taken as typed. | ||
| 95 | + if verbatim: p["verbatim"] = %true | ||
| 90 | if width > 0: p["widthRequest"] = %width | 96 | if width > 0: p["widthRequest"] = %width |
| 91 | # Enter, where the field has something to do with it. A compose box that | 97 | # Enter, where the field has something to do with it. A compose box that |
| 92 | # only sends on a button click is one nobody can type into at speed. | 98 | # only sends on a button click is one nobody can type into at speed. |
modified
nim/src/frq_core.nim +12 -0 | @@ -265,6 +265,18 @@ proc frq_ui_demo*() {.exportc, dynlib.} = | ||
| 265 | 265 | at: t0 + 200_000)] |
| 266 | 266 | app.rooms["#tasks"] = other |
| 267 | 267 | |
| 268 | + # A third room, and enough in it that the overview is taller than a phone. | |
| 269 | + # Without one nothing here would ever need to scroll, and a test that the | |
| 270 | + # overview scrolls would pass on a list that fits. | |
| 271 | + app.rooms.ensureRoom("#busy") | |
| 272 | + var busy = app.rooms["#busy"] | |
| 273 | + busy.joined = true | |
| 274 | + for i in 1 .. 12: | |
| 275 | + busy.messages.add Message(id: "b" & $i, frm: "carol", | |
| 276 | + text: "line " & $i & " of a long day", | |
| 277 | + at: t0 + 300_000 + i.int64 * 1000) | |
| 278 | + app.rooms["#busy"] = busy | |
| 279 | + | |
| 268 | 280 | app.current = "#test" |
| 269 | 281 | app.screen = scChat |
| 270 | 282 | app.status = "Connected as me" |
| @@ -265,6 +265,18 @@ proc frq_ui_demo*() {.exportc, dynlib.} = | |||
| 265 | at: t0 + 200_000)] | 265 | at: t0 + 200_000)] |
| 266 | app.rooms["#tasks"] = other | 266 | app.rooms["#tasks"] = other |
| 267 | 267 | ||
| 268 | + # A third room, and enough in it that the overview is taller than a phone. | ||
| 269 | + # Without one nothing here would ever need to scroll, and a test that the | ||
| 270 | + # overview scrolls would pass on a list that fits. | ||
| 271 | + app.rooms.ensureRoom("#busy") | ||
| 272 | + var busy = app.rooms["#busy"] | ||
| 273 | + busy.joined = true | ||
| 274 | + for i in 1 .. 12: | ||
| 275 | + busy.messages.add Message(id: "b" & $i, frm: "carol", | ||
| 276 | + text: "line " & $i & " of a long day", | ||
| 277 | + at: t0 + 300_000 + i.int64 * 1000) | ||
| 278 | + app.rooms["#busy"] = busy | ||
| 279 | + | ||
| 268 | app.current = "#test" | 280 | app.current = "#test" |
| 269 | app.screen = scChat | 281 | app.screen = scChat |
| 270 | app.status = "Connected as me" | 282 | app.status = "Connected as me" |
modified
nim/tests/tscreens.nim +12 -0 | @@ -32,6 +32,18 @@ suite "the connect screen": | ||
| 32 | 32 | check "frq" in t.labels("title") |
| 33 | 33 | check cs.transportNote in t.labels("dim-label") |
| 34 | 34 | |
| 35 | + test "the fields holding an identifier ask to be taken as typed": | |
| 36 | + # A phone keyboard puts a space after a full stop, because a full stop | |
| 37 | + # ends a sentence -- and is also the middle of `alice.bsky.social`. The | |
| 38 | + # compose box is prose and is left alone; these are not. | |
| 39 | + for mode in [amGuest, amBluesky, amAppPassword]: | |
| 40 | + var f = initState() | |
| 41 | + f.authMode = mode | |
| 42 | + let fields = cs.connectScreen(f).find("entry") | |
| 43 | + check fields.len > 0 | |
| 44 | + for e in fields: | |
| 45 | + check e.props{"verbatim"}.getBool() == true | |
| 46 | + | |
| 35 | 47 | test "is pure — twice with no change is the same tree": |
| 36 | 48 | check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson |
| 37 | 49 | |
| @@ -32,6 +32,18 @@ suite "the connect screen": | |||
| 32 | check "frq" in t.labels("title") | 32 | check "frq" in t.labels("title") |
| 33 | check cs.transportNote in t.labels("dim-label") | 33 | check cs.transportNote in t.labels("dim-label") |
| 34 | 34 | ||
| 35 | + test "the fields holding an identifier ask to be taken as typed": | ||
| 36 | + # A phone keyboard puts a space after a full stop, because a full stop | ||
| 37 | + # ends a sentence -- and is also the middle of `alice.bsky.social`. The | ||
| 38 | + # compose box is prose and is left alone; these are not. | ||
| 39 | + for mode in [amGuest, amBluesky, amAppPassword]: | ||
| 40 | + var f = initState() | ||
| 41 | + f.authMode = mode | ||
| 42 | + let fields = cs.connectScreen(f).find("entry") | ||
| 43 | + check fields.len > 0 | ||
| 44 | + for e in fields: | ||
| 45 | + check e.props{"verbatim"}.getBool() == true | ||
| 46 | + | ||
| 35 | test "is pure — twice with no change is the same tree": | 47 | test "is pure — twice with no change is the same tree": |
| 36 | check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson | 48 | check $cs.connectScreen(s).toJson == $cs.connectScreen(s).toJson |
| 37 | 49 | ||