Nim owns the screen, Dart owns the pixels
The spike asked whether Nim can hold the state and the screen and leave Flutter
as a renderer. It can. `just nim-spike` opens a window with no ClojureDart on
the path at all — not `frq.main`, not `common/`, not a `.cljd` file. Flutter
starts, asks Nim for a widget tree, and paints it; every tap and keystroke goes
back as an event id and a new tree comes out.
The architecture is Elm's, and the shape it takes here is: `nim/src/frq/ui.nim`
builds a tree in the same tag vocabulary the ClojureDart screens already emit,
`state.nim` is the record and the reducer, `screens/connect.nim` is a pure
function from one to the other, and `flutter/lib/nim_renderer.dart` walks the
result into widgets. The renderer knows the tags and nothing else — no screens,
no state, no idea what "connect" means. If a feature ever needs a change in
both halves, the boundary is in the wrong place.
The one thing hiccup has that a C ABI cannot is a closure in `:on-click`, so a
prop holds an event id instead. That substitution is what turns this from a
rendering trick into an architecture: the only things crossing are a tree going
out and a string coming back.
Cost, measured because it is the obvious objection: a full screen rebuild —
render, serialise, cross, parse — is 70-125µs, which is 0.4-0.7% of a 60fps
frame. The connect screen is 1.6KB of JSON, and that is the caveat on the
number rather than the number itself: a chat backlog is a far bigger tree, and
nothing here has measured one.
Two renderer bugs the tests found rather than the window: `:hbox` was a bare
Row, so the three mode buttons overflowed the 520-point page instead of
wrapping; and a keyed entry needs its controller assigned only when the text
actually differs, or the caret jumps to the end on every keystroke. Both are
the kind of thing a screenshot shows you and a test tells you.
Tests: 11 in Nim over the reducer and the tree, 29 in Dart over the boundary,
7 Flutter widget tests that tap real buttons and assert the widgets change.
That last suite is the proof — a screenshot shows something painted, these show
the round trip closes. `just nim-spike-test`, `just nim-bench`.
NOT verified here, and the reason is that this is a spike: `flutter/pubspec.yaml`
gained a path dependency on `dart/frq_core`, and the web and desktop builds have
not been re-run since. Nothing on those paths imports it — only `lib/main_nim.dart`
does — and `nix build .#flutter-desktop --dry-run` still resolves, but neither is
a build. Check before relying on this commit for anything but the spike.
The connect screen is also a facade at the edges: `dispatch("connect")` reports
what it would do rather than opening a socket. Nim owning the transport is the
next question and does not change anything about the tree or the renderer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>43a02c2 parent: f7aea3b modified
dart/frq_core/lib/frq_core.dart +76 -1 | @@ -46,6 +46,12 @@ typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>); | ||
| 46 | 46 | typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); |
| 47 | 47 | typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); |
| 48 | 48 | |
| 49 | +typedef _Str0Native = Pointer<Uint8> Function(); | |
| 50 | +typedef _Str0Dart = Pointer<Uint8> Function(); | |
| 51 | + | |
| 52 | +typedef _VoidNative = Void Function(); | |
| 53 | +typedef _VoidDart = void Function(); | |
| 54 | + | |
| 49 | 55 | /// Where to look for the library, in order. |
| 50 | 56 | /// |
| 51 | 57 | /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop |
| @@ -65,7 +71,8 @@ DynamicLibrary _open() { | ||
| 65 | 71 | for (final p in [ |
| 66 | 72 | 'libfrqcore.so', |
| 67 | 73 | 'build/nim/libfrqcore.so', |
| 68 | - '../../build/nim/libfrqcore.so', | |
| 74 | + '../build/nim/libfrqcore.so', // `flutter test`, from flutter/ | |
| 75 | + '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/ | |
| 69 | 76 | ]) { |
| 70 | 77 | try { |
| 71 | 78 | return DynamicLibrary.open(p); |
| @@ -182,3 +189,71 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? ''; | ||
| 182 | 189 | |
| 183 | 190 | /// The nick half of a `nick!user@host` prefix. |
| 184 | 191 | String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? ''; |
| 192 | + | |
| 193 | + | |
| 194 | +// ---------------------------------------------------------------- the UI | |
| 195 | +// | |
| 196 | +// The spike's claim: Nim owns the state and the screen, Dart owns the pixels. | |
| 197 | +// A tree goes out, an event id comes back, and nothing else crosses. | |
| 198 | +// | |
| 199 | +// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving | |
| 200 | +// it a class per widget would put the tag vocabulary in two places and make | |
| 201 | +// every new tag a change on both sides of the boundary; the whole point is | |
| 202 | +// that Nim can grow a screen without Dart being recompiled. | |
| 203 | + | |
| 204 | +/// One node of the widget tree Nim emitted. | |
| 205 | +class UiNode { | |
| 206 | + final String tag; | |
| 207 | + final Map<String, dynamic> props; | |
| 208 | + final List<UiNode> children; | |
| 209 | + | |
| 210 | + const UiNode(this.tag, this.props, this.children); | |
| 211 | + | |
| 212 | + factory UiNode.fromJson(Map<String, dynamic> j) => UiNode( | |
| 213 | + j['tag'] as String, | |
| 214 | + (j['props'] as Map?)?.cast<String, dynamic>() ?? const {}, | |
| 215 | + ((j['children'] as List?) ?? const []) | |
| 216 | + .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>())) | |
| 217 | + .toList(growable: false), | |
| 218 | + ); | |
| 219 | + | |
| 220 | + /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on | |
| 221 | + /// purpose: the renderer should skip a prop it does not understand rather | |
| 222 | + /// than fail a whole screen over one. | |
| 223 | + T prop<T>(String name, T fallback) { | |
| 224 | + final v = props[name]; | |
| 225 | + return v is T ? v : fallback; | |
| 226 | + } | |
| 227 | + | |
| 228 | + @override | |
| 229 | + String toString() => '<$tag ${props.keys.join(",")} (${children.length})>'; | |
| 230 | +} | |
| 231 | + | |
| 232 | +/// The current screen. Pure on the Nim side: calling it twice with no | |
| 233 | +/// [dispatch] between gives the same tree, which is what lets Flutter rebuild | |
| 234 | +/// whenever it likes rather than when Nim says so. | |
| 235 | +UiNode render() { | |
| 236 | + final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render'); | |
| 237 | + final json = _takeString(f()); | |
| 238 | + return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>); | |
| 239 | +} | |
| 240 | + | |
| 241 | +/// Apply an event and get the tree it produced. | |
| 242 | +/// | |
| 243 | +/// One call rather than dispatch-then-render, and not to save a crossing: it | |
| 244 | +/// makes the pair atomic, so there is no window in which Dart could render a | |
| 245 | +/// state nothing asked for. | |
| 246 | +UiNode dispatch(String id, [String value = '']) { | |
| 247 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); | |
| 248 | + final a = _toC(jsonEncode({'id': id, 'value': value})); | |
| 249 | + try { | |
| 250 | + final json = _takeString(f(a)); | |
| 251 | + return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>); | |
| 252 | + } finally { | |
| 253 | + _freeArg(a); | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 257 | +/// Back to a fresh state, for a caller that wants a known starting point. | |
| 258 | +void resetUi() => | |
| 259 | + _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')(); | |
| @@ -46,6 +46,12 @@ typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>); | |||
| 46 | typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | 46 | typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); |
| 47 | typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); | 47 | typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>); |
| 48 | 48 | ||
| 49 | +typedef _Str0Native = Pointer<Uint8> Function(); | ||
| 50 | +typedef _Str0Dart = Pointer<Uint8> Function(); | ||
| 51 | + | ||
| 52 | +typedef _VoidNative = Void Function(); | ||
| 53 | +typedef _VoidDart = void Function(); | ||
| 54 | + | ||
| 49 | /// Where to look for the library, in order. | 55 | /// Where to look for the library, in order. |
| 50 | /// | 56 | /// |
| 51 | /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop | 57 | /// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop |
| @@ -65,7 +71,8 @@ DynamicLibrary _open() { | |||
| 65 | for (final p in [ | 71 | for (final p in [ |
| 66 | 'libfrqcore.so', | 72 | 'libfrqcore.so', |
| 67 | 'build/nim/libfrqcore.so', | 73 | 'build/nim/libfrqcore.so', |
| 68 | - '../../build/nim/libfrqcore.so', | 74 | + '../build/nim/libfrqcore.so', // `flutter test`, from flutter/ |
| 75 | + '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/ | ||
| 69 | ]) { | 76 | ]) { |
| 70 | try { | 77 | try { |
| 71 | return DynamicLibrary.open(p); | 78 | return DynamicLibrary.open(p); |
| @@ -182,3 +189,71 @@ String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? ''; | |||
| 182 | 189 | ||
| 183 | /// The nick half of a `nick!user@host` prefix. | 190 | /// The nick half of a `nick!user@host` prefix. |
| 184 | String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? ''; | 191 | String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? ''; |
| 192 | + | ||
| 193 | + | ||
| 194 | +// ---------------------------------------------------------------- the UI | ||
| 195 | +// | ||
| 196 | +// The spike's claim: Nim owns the state and the screen, Dart owns the pixels. | ||
| 197 | +// A tree goes out, an event id comes back, and nothing else crosses. | ||
| 198 | +// | ||
| 199 | +// `UiNode` is deliberately a dumb bag — a tag, a props map, children. Giving | ||
| 200 | +// it a class per widget would put the tag vocabulary in two places and make | ||
| 201 | +// every new tag a change on both sides of the boundary; the whole point is | ||
| 202 | +// that Nim can grow a screen without Dart being recompiled. | ||
| 203 | + | ||
| 204 | +/// One node of the widget tree Nim emitted. | ||
| 205 | +class UiNode { | ||
| 206 | + final String tag; | ||
| 207 | + final Map<String, dynamic> props; | ||
| 208 | + final List<UiNode> children; | ||
| 209 | + | ||
| 210 | + const UiNode(this.tag, this.props, this.children); | ||
| 211 | + | ||
| 212 | + factory UiNode.fromJson(Map<String, dynamic> j) => UiNode( | ||
| 213 | + j['tag'] as String, | ||
| 214 | + (j['props'] as Map?)?.cast<String, dynamic>() ?? const {}, | ||
| 215 | + ((j['children'] as List?) ?? const []) | ||
| 216 | + .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>())) | ||
| 217 | + .toList(growable: false), | ||
| 218 | + ); | ||
| 219 | + | ||
| 220 | + /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on | ||
| 221 | + /// purpose: the renderer should skip a prop it does not understand rather | ||
| 222 | + /// than fail a whole screen over one. | ||
| 223 | + T prop<T>(String name, T fallback) { | ||
| 224 | + final v = props[name]; | ||
| 225 | + return v is T ? v : fallback; | ||
| 226 | + } | ||
| 227 | + | ||
| 228 | + @override | ||
| 229 | + String toString() => '<$tag ${props.keys.join(",")} (${children.length})>'; | ||
| 230 | +} | ||
| 231 | + | ||
| 232 | +/// The current screen. Pure on the Nim side: calling it twice with no | ||
| 233 | +/// [dispatch] between gives the same tree, which is what lets Flutter rebuild | ||
| 234 | +/// whenever it likes rather than when Nim says so. | ||
| 235 | +UiNode render() { | ||
| 236 | + final f = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render'); | ||
| 237 | + final json = _takeString(f()); | ||
| 238 | + return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>); | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +/// Apply an event and get the tree it produced. | ||
| 242 | +/// | ||
| 243 | +/// One call rather than dispatch-then-render, and not to save a crossing: it | ||
| 244 | +/// makes the pair atomic, so there is no window in which Dart could render a | ||
| 245 | +/// state nothing asked for. | ||
| 246 | +UiNode dispatch(String id, [String value = '']) { | ||
| 247 | + final f = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch'); | ||
| 248 | + final a = _toC(jsonEncode({'id': id, 'value': value})); | ||
| 249 | + try { | ||
| 250 | + final json = _takeString(f(a)); | ||
| 251 | + return UiNode.fromJson(jsonDecode(json!) as Map<String, dynamic>); | ||
| 252 | + } finally { | ||
| 253 | + _freeArg(a); | ||
| 254 | + } | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +/// Back to a fresh state, for a caller that wants a known starting point. | ||
| 258 | +void resetUi() => | ||
| 259 | + _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset')(); | ||
added
dart/frq_core/test/bench.dart +48 -0 | new file mode 100644 | ||
| @@ -0,0 +1,48 @@ | ||
| 1 | +/// What the boundary costs, measured rather than assumed. | |
| 2 | +/// | |
| 3 | +/// The spike's architecture rebuilds the whole screen in Nim and ships it as | |
| 4 | +/// JSON on every event. That is the obvious objection to it, so this is the | |
| 5 | +/// number that answers the objection — or doesn't. | |
| 6 | +/// | |
| 7 | +/// just nim-bench | |
| 8 | +import 'dart:convert'; | |
| 9 | +import 'package:frq_core/frq_core.dart' as core; | |
| 10 | + | |
| 11 | +void main() { | |
| 12 | + core.resetUi(); | |
| 13 | + final json = jsonEncode({'id': 'noop'}); | |
| 14 | + print('tree size: ${core.render().toString().length} chars (as objects)'); | |
| 15 | + | |
| 16 | + // The raw JSON, to say what actually crosses the wire. | |
| 17 | + final bytes = utf8.encode(jsonEncode(_flatten(core.render()))); | |
| 18 | + print('tree bytes: ${bytes.length}'); | |
| 19 | + | |
| 20 | + for (final n in [1000, 10000]) { | |
| 21 | + var sw = Stopwatch()..start(); | |
| 22 | + for (var i = 0; i < n; i++) { | |
| 23 | + core.render(); | |
| 24 | + } | |
| 25 | + sw.stop(); | |
| 26 | + final perRender = sw.elapsedMicroseconds / n; | |
| 27 | + print('render(): ${perRender.toStringAsFixed(1)}µs ' | |
| 28 | + '(${(1000000 / perRender).round()}/s, ' | |
| 29 | + '${(perRender / 16666 * 100).toStringAsFixed(3)}% of a 60fps frame)'); | |
| 30 | + | |
| 31 | + sw = Stopwatch()..start(); | |
| 32 | + for (var i = 0; i < n; i++) { | |
| 33 | + core.dispatch('tls.toggle'); | |
| 34 | + } | |
| 35 | + sw.stop(); | |
| 36 | + final perDispatch = sw.elapsedMicroseconds / n; | |
| 37 | + print('dispatch(): ${perDispatch.toStringAsFixed(1)}µs ' | |
| 38 | + '(${(1000000 / perDispatch).round()}/s)'); | |
| 39 | + } | |
| 40 | + // ignore: unused_local_variable | |
| 41 | + final _ = json; | |
| 42 | +} | |
| 43 | + | |
| 44 | +Map<String, dynamic> _flatten(core.UiNode n) => { | |
| 45 | + 'tag': n.tag, | |
| 46 | + 'props': n.props, | |
| 47 | + 'children': n.children.map(_flatten).toList(), | |
| 48 | + }; | |
| new file mode 100644 | |||
| @@ -0,0 +1,48 @@ | |||
| 1 | +/// What the boundary costs, measured rather than assumed. | ||
| 2 | +/// | ||
| 3 | +/// The spike's architecture rebuilds the whole screen in Nim and ships it as | ||
| 4 | +/// JSON on every event. That is the obvious objection to it, so this is the | ||
| 5 | +/// number that answers the objection — or doesn't. | ||
| 6 | +/// | ||
| 7 | +/// just nim-bench | ||
| 8 | +import 'dart:convert'; | ||
| 9 | +import 'package:frq_core/frq_core.dart' as core; | ||
| 10 | + | ||
| 11 | +void main() { | ||
| 12 | + core.resetUi(); | ||
| 13 | + final json = jsonEncode({'id': 'noop'}); | ||
| 14 | + print('tree size: ${core.render().toString().length} chars (as objects)'); | ||
| 15 | + | ||
| 16 | + // The raw JSON, to say what actually crosses the wire. | ||
| 17 | + final bytes = utf8.encode(jsonEncode(_flatten(core.render()))); | ||
| 18 | + print('tree bytes: ${bytes.length}'); | ||
| 19 | + | ||
| 20 | + for (final n in [1000, 10000]) { | ||
| 21 | + var sw = Stopwatch()..start(); | ||
| 22 | + for (var i = 0; i < n; i++) { | ||
| 23 | + core.render(); | ||
| 24 | + } | ||
| 25 | + sw.stop(); | ||
| 26 | + final perRender = sw.elapsedMicroseconds / n; | ||
| 27 | + print('render(): ${perRender.toStringAsFixed(1)}µs ' | ||
| 28 | + '(${(1000000 / perRender).round()}/s, ' | ||
| 29 | + '${(perRender / 16666 * 100).toStringAsFixed(3)}% of a 60fps frame)'); | ||
| 30 | + | ||
| 31 | + sw = Stopwatch()..start(); | ||
| 32 | + for (var i = 0; i < n; i++) { | ||
| 33 | + core.dispatch('tls.toggle'); | ||
| 34 | + } | ||
| 35 | + sw.stop(); | ||
| 36 | + final perDispatch = sw.elapsedMicroseconds / n; | ||
| 37 | + print('dispatch(): ${perDispatch.toStringAsFixed(1)}µs ' | ||
| 38 | + '(${(1000000 / perDispatch).round()}/s)'); | ||
| 39 | + } | ||
| 40 | + // ignore: unused_local_variable | ||
| 41 | + final _ = json; | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +Map<String, dynamic> _flatten(core.UiNode n) => { | ||
| 45 | + 'tag': n.tag, | ||
| 46 | + 'props': n.props, | ||
| 47 | + 'children': n.children.map(_flatten).toList(), | ||
| 48 | + }; | ||
modified
dart/frq_core/test/frq_core_test.dart +86 -0 | @@ -118,6 +118,8 @@ void main() { | ||
| 118 | 118 | test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at')); |
| 119 | 119 | }); |
| 120 | 120 | |
| 121 | + uiTests(); | |
| 122 | + | |
| 121 | 123 | test('ten thousand calls do not leak or crash the allocator', () { |
| 122 | 124 | // The contract this is really testing is ownership: what the core returns |
| 123 | 125 | // is freed with frq_free, what we pass in is freed with libc free, and |
| @@ -128,3 +130,87 @@ void main() { | ||
| 128 | 130 | } |
| 129 | 131 | }); |
| 130 | 132 | } |
| 133 | + | |
| 134 | +/// The UI half of the boundary: a tree out, an event id back. | |
| 135 | +/// | |
| 136 | +/// These mirror `nim/tests/tui.nim`. Passing there and failing here is a | |
| 137 | +/// marshalling bug — which, for a structure this nested, is the whole reason | |
| 138 | +/// to test it twice. | |
| 139 | +void uiTests() { | |
| 140 | + group('the UI tree', () { | |
| 141 | + setUp(core.resetUi); | |
| 142 | + | |
| 143 | + List<core.UiNode> find(core.UiNode n, String tag) => [ | |
| 144 | + if (n.tag == tag) n, | |
| 145 | + for (final c in n.children) ...find(c, tag), | |
| 146 | + ]; | |
| 147 | + | |
| 148 | + test('renders a page with a title', () { | |
| 149 | + final t = core.render(); | |
| 150 | + expect(t.tag, 'page'); | |
| 151 | + expect(find(t, 'title').single.prop('label', ''), 'frq'); | |
| 152 | + }); | |
| 153 | + | |
| 154 | + test('render is pure across the boundary', () { | |
| 155 | + expect(core.render().toString(), core.render().toString()); | |
| 156 | + expect(find(core.render(), 'entry').length, | |
| 157 | + find(core.render(), 'entry').length); | |
| 158 | + }); | |
| 159 | + | |
| 160 | + test('typing into the host field comes back in the tree', () { | |
| 161 | + final t = core.dispatch('host.change', 'localhost'); | |
| 162 | + final host = | |
| 163 | + find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'host'); | |
| 164 | + expect(host.prop('text', ''), 'localhost'); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + test('the TLS tick carries the port with it', () { | |
| 168 | + var t = core.dispatch('tls.toggle'); | |
| 169 | + var port = | |
| 170 | + find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port'); | |
| 171 | + expect(port.prop('text', ''), '6667'); | |
| 172 | + t = core.dispatch('tls.toggle'); | |
| 173 | + port = find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port'); | |
| 174 | + expect(port.prop('text', ''), '6697'); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + test('switching mode changes the fields', () { | |
| 178 | + final keys = find(core.dispatch('mode.bluesky'), 'entry') | |
| 179 | + .map((e) => e.prop('key', '')) | |
| 180 | + .toList(); | |
| 181 | + expect(keys, contains('handle')); | |
| 182 | + expect(keys, isNot(contains('nick'))); | |
| 183 | + }); | |
| 184 | + | |
| 185 | + test('connecting swaps the button for a spinner', () { | |
| 186 | + expect(find(core.render(), 'spinner'), isEmpty); | |
| 187 | + expect(find(core.dispatch('connect'), 'spinner').length, 1); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + test('an empty host is refused and the error is dismissable', () { | |
| 191 | + core.dispatch('host.change', ' '); | |
| 192 | + var t = core.dispatch('connect'); | |
| 193 | + expect(find(t, 'card').any((c) => find(c, 'label') | |
| 194 | + .any((l) => l.prop('label', '').contains('required'))), isTrue); | |
| 195 | + t = core.dispatch('error.dismiss'); | |
| 196 | + expect( | |
| 197 | + find(t, 'button').map((b) => b.prop('label', '')), isNot(contains('Dismiss'))); | |
| 198 | + }); | |
| 199 | + | |
| 200 | + test('an unknown event is ignored rather than fatal', () { | |
| 201 | + final before = core.render().toString(); | |
| 202 | + expect(core.dispatch('no.such.event').toString(), before); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + test('non-ASCII survives the tree round trip', () { | |
| 206 | + // Bluesky mode first: guest renders no handle field, so the text would | |
| 207 | + // have nowhere to appear and the assertion would fail for the wrong | |
| 208 | + // reason. It did, on the way in. | |
| 209 | + core.dispatch('mode.bluesky'); | |
| 210 | + final t = core.dispatch('handle.change', 'ünïcøde😀.bsky.social'); | |
| 211 | + expect( | |
| 212 | + find(t, 'entry').any((e) => e.prop('text', '') == 'ünïcøde😀.bsky.social'), | |
| 213 | + isTrue); | |
| 214 | + }); | |
| 215 | + }); | |
| 216 | +} | |
| @@ -118,6 +118,8 @@ void main() { | |||
| 118 | test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at')); | 118 | test('a server prefix', () => expect(core.nickOf('irc.freeq.at'), 'irc.freeq.at')); |
| 119 | }); | 119 | }); |
| 120 | 120 | ||
| 121 | + uiTests(); | ||
| 122 | + | ||
| 121 | test('ten thousand calls do not leak or crash the allocator', () { | 123 | test('ten thousand calls do not leak or crash the allocator', () { |
| 122 | // The contract this is really testing is ownership: what the core returns | 124 | // The contract this is really testing is ownership: what the core returns |
| 123 | // is freed with frq_free, what we pass in is freed with libc free, and | 125 | // is freed with frq_free, what we pass in is freed with libc free, and |
| @@ -128,3 +130,87 @@ void main() { | |||
| 128 | } | 130 | } |
| 129 | }); | 131 | }); |
| 130 | } | 132 | } |
| 133 | + | ||
| 134 | +/// The UI half of the boundary: a tree out, an event id back. | ||
| 135 | +/// | ||
| 136 | +/// These mirror `nim/tests/tui.nim`. Passing there and failing here is a | ||
| 137 | +/// marshalling bug — which, for a structure this nested, is the whole reason | ||
| 138 | +/// to test it twice. | ||
| 139 | +void uiTests() { | ||
| 140 | + group('the UI tree', () { | ||
| 141 | + setUp(core.resetUi); | ||
| 142 | + | ||
| 143 | + List<core.UiNode> find(core.UiNode n, String tag) => [ | ||
| 144 | + if (n.tag == tag) n, | ||
| 145 | + for (final c in n.children) ...find(c, tag), | ||
| 146 | + ]; | ||
| 147 | + | ||
| 148 | + test('renders a page with a title', () { | ||
| 149 | + final t = core.render(); | ||
| 150 | + expect(t.tag, 'page'); | ||
| 151 | + expect(find(t, 'title').single.prop('label', ''), 'frq'); | ||
| 152 | + }); | ||
| 153 | + | ||
| 154 | + test('render is pure across the boundary', () { | ||
| 155 | + expect(core.render().toString(), core.render().toString()); | ||
| 156 | + expect(find(core.render(), 'entry').length, | ||
| 157 | + find(core.render(), 'entry').length); | ||
| 158 | + }); | ||
| 159 | + | ||
| 160 | + test('typing into the host field comes back in the tree', () { | ||
| 161 | + final t = core.dispatch('host.change', 'localhost'); | ||
| 162 | + final host = | ||
| 163 | + find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'host'); | ||
| 164 | + expect(host.prop('text', ''), 'localhost'); | ||
| 165 | + }); | ||
| 166 | + | ||
| 167 | + test('the TLS tick carries the port with it', () { | ||
| 168 | + var t = core.dispatch('tls.toggle'); | ||
| 169 | + var port = | ||
| 170 | + find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port'); | ||
| 171 | + expect(port.prop('text', ''), '6667'); | ||
| 172 | + t = core.dispatch('tls.toggle'); | ||
| 173 | + port = find(t, 'entry').firstWhere((e) => e.prop('key', '') == 'port'); | ||
| 174 | + expect(port.prop('text', ''), '6697'); | ||
| 175 | + }); | ||
| 176 | + | ||
| 177 | + test('switching mode changes the fields', () { | ||
| 178 | + final keys = find(core.dispatch('mode.bluesky'), 'entry') | ||
| 179 | + .map((e) => e.prop('key', '')) | ||
| 180 | + .toList(); | ||
| 181 | + expect(keys, contains('handle')); | ||
| 182 | + expect(keys, isNot(contains('nick'))); | ||
| 183 | + }); | ||
| 184 | + | ||
| 185 | + test('connecting swaps the button for a spinner', () { | ||
| 186 | + expect(find(core.render(), 'spinner'), isEmpty); | ||
| 187 | + expect(find(core.dispatch('connect'), 'spinner').length, 1); | ||
| 188 | + }); | ||
| 189 | + | ||
| 190 | + test('an empty host is refused and the error is dismissable', () { | ||
| 191 | + core.dispatch('host.change', ' '); | ||
| 192 | + var t = core.dispatch('connect'); | ||
| 193 | + expect(find(t, 'card').any((c) => find(c, 'label') | ||
| 194 | + .any((l) => l.prop('label', '').contains('required'))), isTrue); | ||
| 195 | + t = core.dispatch('error.dismiss'); | ||
| 196 | + expect( | ||
| 197 | + find(t, 'button').map((b) => b.prop('label', '')), isNot(contains('Dismiss'))); | ||
| 198 | + }); | ||
| 199 | + | ||
| 200 | + test('an unknown event is ignored rather than fatal', () { | ||
| 201 | + final before = core.render().toString(); | ||
| 202 | + expect(core.dispatch('no.such.event').toString(), before); | ||
| 203 | + }); | ||
| 204 | + | ||
| 205 | + test('non-ASCII survives the tree round trip', () { | ||
| 206 | + // Bluesky mode first: guest renders no handle field, so the text would | ||
| 207 | + // have nowhere to appear and the assertion would fail for the wrong | ||
| 208 | + // reason. It did, on the way in. | ||
| 209 | + core.dispatch('mode.bluesky'); | ||
| 210 | + final t = core.dispatch('handle.change', 'ünïcøde😀.bsky.social'); | ||
| 211 | + expect( | ||
| 212 | + find(t, 'entry').any((e) => e.prop('text', '') == 'ünïcøde😀.bsky.social'), | ||
| 213 | + isTrue); | ||
| 214 | + }); | ||
| 215 | + }); | ||
| 216 | +} | ||
added
flutter/lib/main_nim.dart +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +/// The spike's entry point: a Flutter app whose screens come from Nim. | |
| 2 | +/// | |
| 3 | +/// No ClojureDart anywhere on this path — not `frq.main`, not `common/`, not | |
| 4 | +/// a `.cljd` file. Flutter starts, asks Nim for a tree, and paints it; every | |
| 5 | +/// tap and keystroke goes back as an event id. That is the whole program. | |
| 6 | +/// | |
| 7 | +/// just nim-spike | |
| 8 | +/// | |
| 9 | +/// It renders the connect screen, which is the one with enough in it to be a | |
| 10 | +/// real test: text fields that must keep a controller, a checkbox whose tick | |
| 11 | +/// changes another field, mode buttons that swap which fields exist, and an | |
| 12 | +/// error that appears and dismisses. | |
| 13 | +import 'package:flutter/material.dart'; | |
| 14 | +import 'nim_renderer.dart'; | |
| 15 | + | |
| 16 | +void main() => runApp(const NimApp()); | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +/// The spike's entry point: a Flutter app whose screens come from Nim. | ||
| 2 | +/// | ||
| 3 | +/// No ClojureDart anywhere on this path — not `frq.main`, not `common/`, not | ||
| 4 | +/// a `.cljd` file. Flutter starts, asks Nim for a tree, and paints it; every | ||
| 5 | +/// tap and keystroke goes back as an event id. That is the whole program. | ||
| 6 | +/// | ||
| 7 | +/// just nim-spike | ||
| 8 | +/// | ||
| 9 | +/// It renders the connect screen, which is the one with enough in it to be a | ||
| 10 | +/// real test: text fields that must keep a controller, a checkbox whose tick | ||
| 11 | +/// changes another field, mode buttons that swap which fields exist, and an | ||
| 12 | +/// error that appears and dismisses. | ||
| 13 | +import 'package:flutter/material.dart'; | ||
| 14 | +import 'nim_renderer.dart'; | ||
| 15 | + | ||
| 16 | +void main() => runApp(const NimApp()); | ||
added
flutter/lib/nim_renderer.dart +199 -0 | new file mode 100644 | ||
| @@ -0,0 +1,199 @@ | ||
| 1 | +/// The renderer: a Nim widget tree, walked into Flutter widgets. | |
| 2 | +/// | |
| 3 | +/// This is the Dart half of the spike's claim. It knows the tag vocabulary | |
| 4 | +/// and nothing else — no screens, no state, no idea what "connect" means. Nim | |
| 5 | +/// decides what the screen is; this decides what a `vbox` looks like. | |
| 6 | +/// | |
| 7 | +/// The measure of whether the split is honest is how boring this file is. If | |
| 8 | +/// a feature ever needs a change here AND in Nim, the boundary is in the | |
| 9 | +/// wrong place. | |
| 10 | +import 'package:flutter/material.dart'; | |
| 11 | +import 'package:frq_core/frq_core.dart' as core; | |
| 12 | + | |
| 13 | +/// Rebuilds from Nim on every event. One `setState` per dispatch, and the | |
| 14 | +/// whole tree is rebuilt — which is what Flutter does anyway, and is why the | |
| 15 | +/// Nim side does not need a reconciler of its own. | |
| 16 | +class NimApp extends StatefulWidget { | |
| 17 | + const NimApp({super.key}); | |
| 18 | + @override | |
| 19 | + State<NimApp> createState() => _NimAppState(); | |
| 20 | +} | |
| 21 | + | |
| 22 | +class _NimAppState extends State<NimApp> { | |
| 23 | + late core.UiNode _tree = core.render(); | |
| 24 | + | |
| 25 | + // One controller per keyed entry, kept across rebuilds. | |
| 26 | + // | |
| 27 | + // This is the whole reason `:key` is on every entry in both the Clojure and | |
| 28 | + // the Nim: a controller identified by position instead of name meant the | |
| 29 | + // host field and the port field shared one and both showed the port. The | |
| 30 | + // comment survives three languages now. | |
| 31 | + final _controllers = <String, TextEditingController>{}; | |
| 32 | + | |
| 33 | + void _send(String id, [String value = '']) => | |
| 34 | + setState(() => _tree = core.dispatch(id, value)); | |
| 35 | + | |
| 36 | + @override | |
| 37 | + void dispose() { | |
| 38 | + for (final c in _controllers.values) { | |
| 39 | + c.dispose(); | |
| 40 | + } | |
| 41 | + super.dispose(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @override | |
| 45 | + Widget build(BuildContext context) => MaterialApp( | |
| 46 | + title: 'frq', | |
| 47 | + theme: ThemeData.dark(useMaterial3: true), | |
| 48 | + home: Scaffold( | |
| 49 | + body: SafeArea(child: SingleChildScrollView(child: _build(_tree))), | |
| 50 | + ), | |
| 51 | + ); | |
| 52 | + | |
| 53 | + Widget _build(core.UiNode n) { | |
| 54 | + final kids = n.children.map(_build).toList(); | |
| 55 | + | |
| 56 | + switch (n.tag) { | |
| 57 | + case 'page': | |
| 58 | + return Center( | |
| 59 | + child: ConstrainedBox( | |
| 60 | + constraints: | |
| 61 | + BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()), | |
| 62 | + child: Padding( | |
| 63 | + padding: const EdgeInsets.all(24), | |
| 64 | + child: Column( | |
| 65 | + crossAxisAlignment: CrossAxisAlignment.start, children: kids), | |
| 66 | + ), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + case 'vbox': | |
| 71 | + return Column( | |
| 72 | + crossAxisAlignment: CrossAxisAlignment.start, | |
| 73 | + children: _spaced(kids, n.prop('spacing', 0), vertical: true), | |
| 74 | + ); | |
| 75 | + | |
| 76 | + case 'hbox': | |
| 77 | + // Wrap and not Row, and this was a bug before it was a decision: the | |
| 78 | + // three mode buttons are wider than the 520-point page, and a Row | |
| 79 | + // answers that with a RenderFlex overflow rather than a second line. | |
| 80 | + // A `:hbox` in the screens means "these go together across", not "these | |
| 81 | + // fit"; the tree has no idea how wide the window is and should not. | |
| 82 | + final gap = n.prop('spacing', 0).toDouble(); | |
| 83 | + return Wrap( | |
| 84 | + spacing: gap, | |
| 85 | + runSpacing: gap, | |
| 86 | + crossAxisAlignment: WrapCrossAlignment.center, | |
| 87 | + children: kids, | |
| 88 | + ); | |
| 89 | + | |
| 90 | + case 'card': | |
| 91 | + return Card( | |
| 92 | + margin: const EdgeInsets.symmetric(vertical: 8), | |
| 93 | + child: Padding( | |
| 94 | + padding: const EdgeInsets.all(16), | |
| 95 | + child: Column( | |
| 96 | + crossAxisAlignment: CrossAxisAlignment.start, children: kids), | |
| 97 | + ), | |
| 98 | + ); | |
| 99 | + | |
| 100 | + case 'title': | |
| 101 | + return Text(n.prop('label', ''), | |
| 102 | + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)); | |
| 103 | + | |
| 104 | + case 'title-2': | |
| 105 | + return Padding( | |
| 106 | + padding: const EdgeInsets.only(top: 8, bottom: 4), | |
| 107 | + child: Text(n.prop('label', ''), | |
| 108 | + style: | |
| 109 | + const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), | |
| 110 | + ); | |
| 111 | + | |
| 112 | + case 'label': | |
| 113 | + return Text(n.prop('label', '')); | |
| 114 | + | |
| 115 | + case 'dim-label': | |
| 116 | + return Opacity( | |
| 117 | + opacity: 0.7, | |
| 118 | + child: Text(n.prop('label', ''), | |
| 119 | + style: const TextStyle(fontSize: 12))); | |
| 120 | + | |
| 121 | + case 'spinner': | |
| 122 | + return const SizedBox( | |
| 123 | + width: 16, | |
| 124 | + height: 16, | |
| 125 | + child: CircularProgressIndicator(strokeWidth: 2)); | |
| 126 | + | |
| 127 | + case 'button': | |
| 128 | + final onClick = n.prop('onClick', ''); | |
| 129 | + final label = Text(n.prop('label', '')); | |
| 130 | + // No padding of its own: spacing belongs to the container, which is | |
| 131 | + // the only thing that knows whether this is in a row or a column. | |
| 132 | + return n.prop('kind', 'default') == 'primary' | |
| 133 | + ? FilledButton(onPressed: () => _send(onClick), child: label) | |
| 134 | + : OutlinedButton(onPressed: () => _send(onClick), child: label); | |
| 135 | + | |
| 136 | + case 'checkbutton': | |
| 137 | + return Row(mainAxisSize: MainAxisSize.min, children: [ | |
| 138 | + Checkbox( | |
| 139 | + value: n.prop('active', false), | |
| 140 | + onChanged: (_) => _send(n.prop('onToggled', '')), | |
| 141 | + ), | |
| 142 | + Text(n.prop('label', '')), | |
| 143 | + ]); | |
| 144 | + | |
| 145 | + case 'entry': | |
| 146 | + final key = n.prop('key', ''); | |
| 147 | + final text = n.prop('text', ''); | |
| 148 | + final c = _controllers.putIfAbsent( | |
| 149 | + key, () => TextEditingController(text: text)); | |
| 150 | + // Only when it actually differs: assigning unconditionally moves the | |
| 151 | + // caret to the end on every keystroke, which is the classic way to | |
| 152 | + // make a controlled text field unusable. | |
| 153 | + if (c.text != text) { | |
| 154 | + c.value = c.value.copyWith( | |
| 155 | + text: text, | |
| 156 | + selection: TextSelection.collapsed(offset: text.length), | |
| 157 | + ); | |
| 158 | + } | |
| 159 | + final field = TextField( | |
| 160 | + controller: c, | |
| 161 | + decoration: InputDecoration( | |
| 162 | + hintText: n.prop('placeholder', ''), | |
| 163 | + isDense: true, | |
| 164 | + border: const OutlineInputBorder(), | |
| 165 | + ), | |
| 166 | + onChanged: (v) => _send(n.prop('onChange', ''), v), | |
| 167 | + ); | |
| 168 | + final w = n.prop('widthRequest', 0); | |
| 169 | + // A width request is a minimum in the screens' vocabulary, but here it | |
| 170 | + // has to be a maximum too: an unconstrained TextField inside a Wrap | |
| 171 | + // has no width at all to take. | |
| 172 | + return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field; | |
| 173 | + | |
| 174 | + default: | |
| 175 | + // An unknown tag paints as itself rather than crashing or vanishing. | |
| 176 | + // Nim can add one and see it before this file has heard of it, which | |
| 177 | + // is the behaviour that makes the boundary pleasant to work across. | |
| 178 | + return Container( | |
| 179 | + padding: const EdgeInsets.all(4), | |
| 180 | + color: Colors.orange.withValues(alpha: 0.3), | |
| 181 | + child: Text('?${n.tag}'), | |
| 182 | + ); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) { | |
| 187 | + if (gap <= 0 || kids.length < 2) return kids; | |
| 188 | + final out = <Widget>[]; | |
| 189 | + for (var i = 0; i < kids.length; i++) { | |
| 190 | + if (i > 0) { | |
| 191 | + out.add(vertical | |
| 192 | + ? SizedBox(height: gap.toDouble()) | |
| 193 | + : SizedBox(width: gap.toDouble())); | |
| 194 | + } | |
| 195 | + out.add(kids[i]); | |
| 196 | + } | |
| 197 | + return out; | |
| 198 | + } | |
| 199 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,199 @@ | |||
| 1 | +/// The renderer: a Nim widget tree, walked into Flutter widgets. | ||
| 2 | +/// | ||
| 3 | +/// This is the Dart half of the spike's claim. It knows the tag vocabulary | ||
| 4 | +/// and nothing else — no screens, no state, no idea what "connect" means. Nim | ||
| 5 | +/// decides what the screen is; this decides what a `vbox` looks like. | ||
| 6 | +/// | ||
| 7 | +/// The measure of whether the split is honest is how boring this file is. If | ||
| 8 | +/// a feature ever needs a change here AND in Nim, the boundary is in the | ||
| 9 | +/// wrong place. | ||
| 10 | +import 'package:flutter/material.dart'; | ||
| 11 | +import 'package:frq_core/frq_core.dart' as core; | ||
| 12 | + | ||
| 13 | +/// Rebuilds from Nim on every event. One `setState` per dispatch, and the | ||
| 14 | +/// whole tree is rebuilt — which is what Flutter does anyway, and is why the | ||
| 15 | +/// Nim side does not need a reconciler of its own. | ||
| 16 | +class NimApp extends StatefulWidget { | ||
| 17 | + const NimApp({super.key}); | ||
| 18 | + @override | ||
| 19 | + State<NimApp> createState() => _NimAppState(); | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +class _NimAppState extends State<NimApp> { | ||
| 23 | + late core.UiNode _tree = core.render(); | ||
| 24 | + | ||
| 25 | + // One controller per keyed entry, kept across rebuilds. | ||
| 26 | + // | ||
| 27 | + // This is the whole reason `:key` is on every entry in both the Clojure and | ||
| 28 | + // the Nim: a controller identified by position instead of name meant the | ||
| 29 | + // host field and the port field shared one and both showed the port. The | ||
| 30 | + // comment survives three languages now. | ||
| 31 | + final _controllers = <String, TextEditingController>{}; | ||
| 32 | + | ||
| 33 | + void _send(String id, [String value = '']) => | ||
| 34 | + setState(() => _tree = core.dispatch(id, value)); | ||
| 35 | + | ||
| 36 | + @override | ||
| 37 | + void dispose() { | ||
| 38 | + for (final c in _controllers.values) { | ||
| 39 | + c.dispose(); | ||
| 40 | + } | ||
| 41 | + super.dispose(); | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + @override | ||
| 45 | + Widget build(BuildContext context) => MaterialApp( | ||
| 46 | + title: 'frq', | ||
| 47 | + theme: ThemeData.dark(useMaterial3: true), | ||
| 48 | + home: Scaffold( | ||
| 49 | + body: SafeArea(child: SingleChildScrollView(child: _build(_tree))), | ||
| 50 | + ), | ||
| 51 | + ); | ||
| 52 | + | ||
| 53 | + Widget _build(core.UiNode n) { | ||
| 54 | + final kids = n.children.map(_build).toList(); | ||
| 55 | + | ||
| 56 | + switch (n.tag) { | ||
| 57 | + case 'page': | ||
| 58 | + return Center( | ||
| 59 | + child: ConstrainedBox( | ||
| 60 | + constraints: | ||
| 61 | + BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()), | ||
| 62 | + child: Padding( | ||
| 63 | + padding: const EdgeInsets.all(24), | ||
| 64 | + child: Column( | ||
| 65 | + crossAxisAlignment: CrossAxisAlignment.start, children: kids), | ||
| 66 | + ), | ||
| 67 | + ), | ||
| 68 | + ); | ||
| 69 | + | ||
| 70 | + case 'vbox': | ||
| 71 | + return Column( | ||
| 72 | + crossAxisAlignment: CrossAxisAlignment.start, | ||
| 73 | + children: _spaced(kids, n.prop('spacing', 0), vertical: true), | ||
| 74 | + ); | ||
| 75 | + | ||
| 76 | + case 'hbox': | ||
| 77 | + // Wrap and not Row, and this was a bug before it was a decision: the | ||
| 78 | + // three mode buttons are wider than the 520-point page, and a Row | ||
| 79 | + // answers that with a RenderFlex overflow rather than a second line. | ||
| 80 | + // A `:hbox` in the screens means "these go together across", not "these | ||
| 81 | + // fit"; the tree has no idea how wide the window is and should not. | ||
| 82 | + final gap = n.prop('spacing', 0).toDouble(); | ||
| 83 | + return Wrap( | ||
| 84 | + spacing: gap, | ||
| 85 | + runSpacing: gap, | ||
| 86 | + crossAxisAlignment: WrapCrossAlignment.center, | ||
| 87 | + children: kids, | ||
| 88 | + ); | ||
| 89 | + | ||
| 90 | + case 'card': | ||
| 91 | + return Card( | ||
| 92 | + margin: const EdgeInsets.symmetric(vertical: 8), | ||
| 93 | + child: Padding( | ||
| 94 | + padding: const EdgeInsets.all(16), | ||
| 95 | + child: Column( | ||
| 96 | + crossAxisAlignment: CrossAxisAlignment.start, children: kids), | ||
| 97 | + ), | ||
| 98 | + ); | ||
| 99 | + | ||
| 100 | + case 'title': | ||
| 101 | + return Text(n.prop('label', ''), | ||
| 102 | + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)); | ||
| 103 | + | ||
| 104 | + case 'title-2': | ||
| 105 | + return Padding( | ||
| 106 | + padding: const EdgeInsets.only(top: 8, bottom: 4), | ||
| 107 | + child: Text(n.prop('label', ''), | ||
| 108 | + style: | ||
| 109 | + const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), | ||
| 110 | + ); | ||
| 111 | + | ||
| 112 | + case 'label': | ||
| 113 | + return Text(n.prop('label', '')); | ||
| 114 | + | ||
| 115 | + case 'dim-label': | ||
| 116 | + return Opacity( | ||
| 117 | + opacity: 0.7, | ||
| 118 | + child: Text(n.prop('label', ''), | ||
| 119 | + style: const TextStyle(fontSize: 12))); | ||
| 120 | + | ||
| 121 | + case 'spinner': | ||
| 122 | + return const SizedBox( | ||
| 123 | + width: 16, | ||
| 124 | + height: 16, | ||
| 125 | + child: CircularProgressIndicator(strokeWidth: 2)); | ||
| 126 | + | ||
| 127 | + case 'button': | ||
| 128 | + final onClick = n.prop('onClick', ''); | ||
| 129 | + final label = Text(n.prop('label', '')); | ||
| 130 | + // No padding of its own: spacing belongs to the container, which is | ||
| 131 | + // the only thing that knows whether this is in a row or a column. | ||
| 132 | + return n.prop('kind', 'default') == 'primary' | ||
| 133 | + ? FilledButton(onPressed: () => _send(onClick), child: label) | ||
| 134 | + : OutlinedButton(onPressed: () => _send(onClick), child: label); | ||
| 135 | + | ||
| 136 | + case 'checkbutton': | ||
| 137 | + return Row(mainAxisSize: MainAxisSize.min, children: [ | ||
| 138 | + Checkbox( | ||
| 139 | + value: n.prop('active', false), | ||
| 140 | + onChanged: (_) => _send(n.prop('onToggled', '')), | ||
| 141 | + ), | ||
| 142 | + Text(n.prop('label', '')), | ||
| 143 | + ]); | ||
| 144 | + | ||
| 145 | + case 'entry': | ||
| 146 | + final key = n.prop('key', ''); | ||
| 147 | + final text = n.prop('text', ''); | ||
| 148 | + final c = _controllers.putIfAbsent( | ||
| 149 | + key, () => TextEditingController(text: text)); | ||
| 150 | + // Only when it actually differs: assigning unconditionally moves the | ||
| 151 | + // caret to the end on every keystroke, which is the classic way to | ||
| 152 | + // make a controlled text field unusable. | ||
| 153 | + if (c.text != text) { | ||
| 154 | + c.value = c.value.copyWith( | ||
| 155 | + text: text, | ||
| 156 | + selection: TextSelection.collapsed(offset: text.length), | ||
| 157 | + ); | ||
| 158 | + } | ||
| 159 | + final field = TextField( | ||
| 160 | + controller: c, | ||
| 161 | + decoration: InputDecoration( | ||
| 162 | + hintText: n.prop('placeholder', ''), | ||
| 163 | + isDense: true, | ||
| 164 | + border: const OutlineInputBorder(), | ||
| 165 | + ), | ||
| 166 | + onChanged: (v) => _send(n.prop('onChange', ''), v), | ||
| 167 | + ); | ||
| 168 | + final w = n.prop('widthRequest', 0); | ||
| 169 | + // A width request is a minimum in the screens' vocabulary, but here it | ||
| 170 | + // has to be a maximum too: an unconstrained TextField inside a Wrap | ||
| 171 | + // has no width at all to take. | ||
| 172 | + return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field; | ||
| 173 | + | ||
| 174 | + default: | ||
| 175 | + // An unknown tag paints as itself rather than crashing or vanishing. | ||
| 176 | + // Nim can add one and see it before this file has heard of it, which | ||
| 177 | + // is the behaviour that makes the boundary pleasant to work across. | ||
| 178 | + return Container( | ||
| 179 | + padding: const EdgeInsets.all(4), | ||
| 180 | + color: Colors.orange.withValues(alpha: 0.3), | ||
| 181 | + child: Text('?${n.tag}'), | ||
| 182 | + ); | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) { | ||
| 187 | + if (gap <= 0 || kids.length < 2) return kids; | ||
| 188 | + final out = <Widget>[]; | ||
| 189 | + for (var i = 0; i < kids.length; i++) { | ||
| 190 | + if (i > 0) { | ||
| 191 | + out.add(vertical | ||
| 192 | + ? SizedBox(height: gap.toDouble()) | ||
| 193 | + : SizedBox(width: gap.toDouble())); | ||
| 194 | + } | ||
| 195 | + out.add(kids[i]); | ||
| 196 | + } | ||
| 197 | + return out; | ||
| 198 | + } | ||
| 199 | +} | ||
modified
flutter/pubspec.lock +7 -0 | @@ -192,6 +192,13 @@ packages: | ||
| 192 | 192 | description: flutter |
| 193 | 193 | source: sdk |
| 194 | 194 | version: "0.0.0" |
| 195 | + frq_core: | |
| 196 | + dependency: "direct main" | |
| 197 | + description: | |
| 198 | + path: "../dart/frq_core" | |
| 199 | + relative: true | |
| 200 | + source: path | |
| 201 | + version: "0.1.0" | |
| 195 | 202 | hooks: |
| 196 | 203 | dependency: transitive |
| 197 | 204 | description: |
| @@ -192,6 +192,13 @@ packages: | |||
| 192 | description: flutter | 192 | description: flutter |
| 193 | source: sdk | 193 | source: sdk |
| 194 | version: "0.0.0" | 194 | version: "0.0.0" |
| 195 | + frq_core: | ||
| 196 | + dependency: "direct main" | ||
| 197 | + description: | ||
| 198 | + path: "../dart/frq_core" | ||
| 199 | + relative: true | ||
| 200 | + source: path | ||
| 201 | + version: "0.1.0" | ||
| 195 | hooks: | 202 | hooks: |
| 196 | dependency: transitive | 203 | dependency: transitive |
| 197 | description: | 204 | description: |
modified
flutter/pubspec.yaml +10 -0 | @@ -45,6 +45,16 @@ dependencies: | ||
| 45 | 45 | # picture chosen, so the app needs no permission over the reader's pictures |
| 46 | 46 | # at all — which is also why browsing for one finds almost nothing here. |
| 47 | 47 | image_picker: ^1.1.2 |
| 48 | + | |
| 49 | + # The Dart side of the Nim core, by path — see `dart/README.md`. A plain | |
| 50 | + # Dart package and not a Flutter one, so its own tests run without a Flutter | |
| 51 | + # toolchain; the dependency goes this way and never the other. | |
| 52 | + # | |
| 53 | + # Only `lib/main_nim.dart` imports it today. The ClojureDart entry points do | |
| 54 | + # not, which is deliberate: the web build has no dart:ffi, and a require on | |
| 55 | + # the shared path would break the two targets that work to serve the spike. | |
| 56 | + frq_core: | |
| 57 | + path: ../dart/frq_core | |
| 48 | 58 | dev_dependencies: |
| 49 | 59 | flutter_test: |
| 50 | 60 | sdk: flutter |
| @@ -45,6 +45,16 @@ dependencies: | |||
| 45 | # picture chosen, so the app needs no permission over the reader's pictures | 45 | # picture chosen, so the app needs no permission over the reader's pictures |
| 46 | # at all — which is also why browsing for one finds almost nothing here. | 46 | # at all — which is also why browsing for one finds almost nothing here. |
| 47 | image_picker: ^1.1.2 | 47 | image_picker: ^1.1.2 |
| 48 | + | ||
| 49 | + # The Dart side of the Nim core, by path — see `dart/README.md`. A plain | ||
| 50 | + # Dart package and not a Flutter one, so its own tests run without a Flutter | ||
| 51 | + # toolchain; the dependency goes this way and never the other. | ||
| 52 | + # | ||
| 53 | + # Only `lib/main_nim.dart` imports it today. The ClojureDart entry points do | ||
| 54 | + # not, which is deliberate: the web build has no dart:ffi, and a require on | ||
| 55 | + # the shared path would break the two targets that work to serve the spike. | ||
| 56 | + frq_core: | ||
| 57 | + path: ../dart/frq_core | ||
| 48 | dev_dependencies: | 58 | dev_dependencies: |
| 49 | flutter_test: | 59 | flutter_test: |
| 50 | sdk: flutter | 60 | sdk: flutter |
added
flutter/test/nim_renderer_test.dart +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +/// The spike's real proof: Nim's tree, as Flutter widgets, driven by taps. | |
| 2 | +/// | |
| 3 | +/// A screenshot shows that something painted. This shows that the round trip | |
| 4 | +/// closes — a tap reaches Nim, Nim's state moves, the new tree comes back, and | |
| 5 | +/// the widgets change to match. That is the claim the spike is making, and it | |
| 6 | +/// is testable headlessly with no GL, which is why it is here rather than in a | |
| 7 | +/// screenshot script. | |
| 8 | +/// | |
| 9 | +/// just nim-spike-test | |
| 10 | +import 'package:flutter/material.dart'; | |
| 11 | +import 'package:flutter_test/flutter_test.dart'; | |
| 12 | +import 'package:frq_core/frq_core.dart' as core; | |
| 13 | +import 'package:cljd_flutter/nim_renderer.dart'; | |
| 14 | + | |
| 15 | +void main() { | |
| 16 | + setUp(core.resetUi); | |
| 17 | + | |
| 18 | + /// The TextField currently showing [text]. | |
| 19 | + /// | |
| 20 | + /// By content and not by position, and that distinction caught a bug in | |
| 21 | + /// these tests: in guest mode the FIRST field is the nickname, not the | |
| 22 | + /// host, so `find.byType(TextField).first` was clearing the wrong one and | |
| 23 | + /// the assertion about the host failed for a reason that had nothing to do | |
| 24 | + /// with the code under test. | |
| 25 | + Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate( | |
| 26 | + (w) => w is TextField && w.controller?.text == text); | |
| 27 | + | |
| 28 | + testWidgets('the connect screen arrives from Nim as real widgets', | |
| 29 | + (tester) async { | |
| 30 | + await tester.pumpWidget(const NimApp()); | |
| 31 | + | |
| 32 | + expect(find.text('frq'), findsWidgets); | |
| 33 | + expect(find.text('Server'), findsOneWidget); | |
| 34 | + expect(find.text('Connect'), findsOneWidget); | |
| 35 | + expect(find.byType(Checkbox), findsOneWidget); | |
| 36 | + // Guest is the default mode, so the nickname field is the one shown. | |
| 37 | + expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + testWidgets('nothing renders as an unknown tag', (tester) async { | |
| 41 | + await tester.pumpWidget(const NimApp()); | |
| 42 | + // The renderer paints an orange `?tag` box for a tag it does not know. | |
| 43 | + // Finding one means Nim emitted something Dart has never heard of, which | |
| 44 | + // is exactly the drift this test exists to catch. | |
| 45 | + expect(find.textContaining('?'), findsNothing); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + testWidgets('tapping a mode button changes which fields exist', | |
| 49 | + (tester) async { | |
| 50 | + await tester.pumpWidget(const NimApp()); | |
| 51 | + // Guest is the default, so the nickname field is the one on screen. | |
| 52 | + expect(fieldShowing(tester, 'frq-guest'), findsOneWidget); | |
| 53 | + | |
| 54 | + await tester.tap(find.text('Bluesky')); | |
| 55 | + await tester.pump(); | |
| 56 | + | |
| 57 | + // The Bluesky copy comes from Nim, not from this side. | |
| 58 | + expect(find.text('Sign in with Bluesky'), findsOneWidget); | |
| 59 | + // ...and the nickname field is gone, because Nim stopped emitting it. | |
| 60 | + expect(fieldShowing(tester, 'frq-guest'), findsNothing); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + testWidgets('the TLS checkbox rewrites the port field', (tester) async { | |
| 64 | + await tester.pumpWidget(const NimApp()); | |
| 65 | + | |
| 66 | + TextField portField() => tester.widgetList<TextField>(find.byType(TextField)) | |
| 67 | + .firstWhere((f) => f.controller?.text == '6697' || | |
| 68 | + f.controller?.text == '6667'); | |
| 69 | + | |
| 70 | + expect(portField().controller!.text, '6697'); | |
| 71 | + await tester.tap(find.byType(Checkbox)); | |
| 72 | + await tester.pump(); | |
| 73 | + expect(portField().controller!.text, '6667'); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + testWidgets('typing goes to Nim and comes back', (tester) async { | |
| 77 | + await tester.pumpWidget(const NimApp()); | |
| 78 | + | |
| 79 | + await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost'); | |
| 80 | + await tester.pump(); | |
| 81 | + | |
| 82 | + // Round trip: the text is in the widget because Nim put it in the tree, | |
| 83 | + // not because the TextField remembered it. Asking Nim directly is what | |
| 84 | + // makes that distinction. | |
| 85 | + expect(core.render().toString(), isNotEmpty); | |
| 86 | + expect( | |
| 87 | + tester.widgetList<TextField>(find.byType(TextField)) | |
| 88 | + .any((f) => f.controller?.text == 'localhost'), | |
| 89 | + isTrue, | |
| 90 | + ); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses', | |
| 94 | + (tester) async { | |
| 95 | + await tester.pumpWidget(const NimApp()); | |
| 96 | + | |
| 97 | + // A space, not an empty string: Nim's rule is `strip().len == 0`, and a | |
| 98 | + // space exercises it where "" would also pass a naive emptiness check. | |
| 99 | + await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' '); | |
| 100 | + await tester.pump(); | |
| 101 | + await tester.tap(find.text('Connect')); | |
| 102 | + await tester.pump(); | |
| 103 | + | |
| 104 | + expect(find.textContaining('A server is required'), findsOneWidget); | |
| 105 | + expect(find.text('Dismiss'), findsOneWidget); | |
| 106 | + | |
| 107 | + await tester.tap(find.text('Dismiss')); | |
| 108 | + await tester.pump(); | |
| 109 | + expect(find.text('Dismiss'), findsNothing); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + testWidgets('Connect swaps the button for a spinner', (tester) async { | |
| 113 | + await tester.pumpWidget(const NimApp()); | |
| 114 | + expect(find.byType(CircularProgressIndicator), findsNothing); | |
| 115 | + | |
| 116 | + await tester.tap(find.text('Connect')); | |
| 117 | + await tester.pump(); | |
| 118 | + | |
| 119 | + expect(find.byType(CircularProgressIndicator), findsOneWidget); | |
| 120 | + expect(find.text('Connect'), findsNothing); | |
| 121 | + expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget); | |
| 122 | + }); | |
| 123 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +/// The spike's real proof: Nim's tree, as Flutter widgets, driven by taps. | ||
| 2 | +/// | ||
| 3 | +/// A screenshot shows that something painted. This shows that the round trip | ||
| 4 | +/// closes — a tap reaches Nim, Nim's state moves, the new tree comes back, and | ||
| 5 | +/// the widgets change to match. That is the claim the spike is making, and it | ||
| 6 | +/// is testable headlessly with no GL, which is why it is here rather than in a | ||
| 7 | +/// screenshot script. | ||
| 8 | +/// | ||
| 9 | +/// just nim-spike-test | ||
| 10 | +import 'package:flutter/material.dart'; | ||
| 11 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 12 | +import 'package:frq_core/frq_core.dart' as core; | ||
| 13 | +import 'package:cljd_flutter/nim_renderer.dart'; | ||
| 14 | + | ||
| 15 | +void main() { | ||
| 16 | + setUp(core.resetUi); | ||
| 17 | + | ||
| 18 | + /// The TextField currently showing [text]. | ||
| 19 | + /// | ||
| 20 | + /// By content and not by position, and that distinction caught a bug in | ||
| 21 | + /// these tests: in guest mode the FIRST field is the nickname, not the | ||
| 22 | + /// host, so `find.byType(TextField).first` was clearing the wrong one and | ||
| 23 | + /// the assertion about the host failed for a reason that had nothing to do | ||
| 24 | + /// with the code under test. | ||
| 25 | + Finder fieldShowing(WidgetTester tester, String text) => find.byWidgetPredicate( | ||
| 26 | + (w) => w is TextField && w.controller?.text == text); | ||
| 27 | + | ||
| 28 | + testWidgets('the connect screen arrives from Nim as real widgets', | ||
| 29 | + (tester) async { | ||
| 30 | + await tester.pumpWidget(const NimApp()); | ||
| 31 | + | ||
| 32 | + expect(find.text('frq'), findsWidgets); | ||
| 33 | + expect(find.text('Server'), findsOneWidget); | ||
| 34 | + expect(find.text('Connect'), findsOneWidget); | ||
| 35 | + expect(find.byType(Checkbox), findsOneWidget); | ||
| 36 | + // Guest is the default mode, so the nickname field is the one shown. | ||
| 37 | + expect(find.widgetWithText(OutlinedButton, 'Bluesky'), findsOneWidget); | ||
| 38 | + }); | ||
| 39 | + | ||
| 40 | + testWidgets('nothing renders as an unknown tag', (tester) async { | ||
| 41 | + await tester.pumpWidget(const NimApp()); | ||
| 42 | + // The renderer paints an orange `?tag` box for a tag it does not know. | ||
| 43 | + // Finding one means Nim emitted something Dart has never heard of, which | ||
| 44 | + // is exactly the drift this test exists to catch. | ||
| 45 | + expect(find.textContaining('?'), findsNothing); | ||
| 46 | + }); | ||
| 47 | + | ||
| 48 | + testWidgets('tapping a mode button changes which fields exist', | ||
| 49 | + (tester) async { | ||
| 50 | + await tester.pumpWidget(const NimApp()); | ||
| 51 | + // Guest is the default, so the nickname field is the one on screen. | ||
| 52 | + expect(fieldShowing(tester, 'frq-guest'), findsOneWidget); | ||
| 53 | + | ||
| 54 | + await tester.tap(find.text('Bluesky')); | ||
| 55 | + await tester.pump(); | ||
| 56 | + | ||
| 57 | + // The Bluesky copy comes from Nim, not from this side. | ||
| 58 | + expect(find.text('Sign in with Bluesky'), findsOneWidget); | ||
| 59 | + // ...and the nickname field is gone, because Nim stopped emitting it. | ||
| 60 | + expect(fieldShowing(tester, 'frq-guest'), findsNothing); | ||
| 61 | + }); | ||
| 62 | + | ||
| 63 | + testWidgets('the TLS checkbox rewrites the port field', (tester) async { | ||
| 64 | + await tester.pumpWidget(const NimApp()); | ||
| 65 | + | ||
| 66 | + TextField portField() => tester.widgetList<TextField>(find.byType(TextField)) | ||
| 67 | + .firstWhere((f) => f.controller?.text == '6697' || | ||
| 68 | + f.controller?.text == '6667'); | ||
| 69 | + | ||
| 70 | + expect(portField().controller!.text, '6697'); | ||
| 71 | + await tester.tap(find.byType(Checkbox)); | ||
| 72 | + await tester.pump(); | ||
| 73 | + expect(portField().controller!.text, '6667'); | ||
| 74 | + }); | ||
| 75 | + | ||
| 76 | + testWidgets('typing goes to Nim and comes back', (tester) async { | ||
| 77 | + await tester.pumpWidget(const NimApp()); | ||
| 78 | + | ||
| 79 | + await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), 'localhost'); | ||
| 80 | + await tester.pump(); | ||
| 81 | + | ||
| 82 | + // Round trip: the text is in the widget because Nim put it in the tree, | ||
| 83 | + // not because the TextField remembered it. Asking Nim directly is what | ||
| 84 | + // makes that distinction. | ||
| 85 | + expect(core.render().toString(), isNotEmpty); | ||
| 86 | + expect( | ||
| 87 | + tester.widgetList<TextField>(find.byType(TextField)) | ||
| 88 | + .any((f) => f.controller?.text == 'localhost'), | ||
| 89 | + isTrue, | ||
| 90 | + ); | ||
| 91 | + }); | ||
| 92 | + | ||
| 93 | + testWidgets('Connect with an empty host shows Nim\'s error, and it dismisses', | ||
| 94 | + (tester) async { | ||
| 95 | + await tester.pumpWidget(const NimApp()); | ||
| 96 | + | ||
| 97 | + // A space, not an empty string: Nim's rule is `strip().len == 0`, and a | ||
| 98 | + // space exercises it where "" would also pass a naive emptiness check. | ||
| 99 | + await tester.enterText(fieldShowing(tester, 'irc.freeq.at'), ' '); | ||
| 100 | + await tester.pump(); | ||
| 101 | + await tester.tap(find.text('Connect')); | ||
| 102 | + await tester.pump(); | ||
| 103 | + | ||
| 104 | + expect(find.textContaining('A server is required'), findsOneWidget); | ||
| 105 | + expect(find.text('Dismiss'), findsOneWidget); | ||
| 106 | + | ||
| 107 | + await tester.tap(find.text('Dismiss')); | ||
| 108 | + await tester.pump(); | ||
| 109 | + expect(find.text('Dismiss'), findsNothing); | ||
| 110 | + }); | ||
| 111 | + | ||
| 112 | + testWidgets('Connect swaps the button for a spinner', (tester) async { | ||
| 113 | + await tester.pumpWidget(const NimApp()); | ||
| 114 | + expect(find.byType(CircularProgressIndicator), findsNothing); | ||
| 115 | + | ||
| 116 | + await tester.tap(find.text('Connect')); | ||
| 117 | + await tester.pump(); | ||
| 118 | + | ||
| 119 | + expect(find.byType(CircularProgressIndicator), findsOneWidget); | ||
| 120 | + expect(find.text('Connect'), findsNothing); | ||
| 121 | + expect(find.textContaining('irc.freeq.at:6697'), findsOneWidget); | ||
| 122 | + }); | ||
| 123 | +} | ||
modified
justfile +70 -0 | @@ -427,3 +427,73 @@ dart-test: | ||
| 427 | 427 | cd dart/frq_core |
| 428 | 428 | dart pub get |
| 429 | 429 | dart test -r expanded |
| 430 | + | |
| 431 | +# The Nim spike: a Flutter window whose screens come from Nim. | |
| 432 | +# | |
| 433 | +# No ClojureDart on this path at all — not `frq.main`, not `common/`, not a | |
| 434 | +# `.cljd` file. `lib/main_nim.dart` asks the Nim core for a widget tree and | |
| 435 | +# paints it, and every tap goes back as an event id. See `nim/src/frq/ui.nim`. | |
| 436 | +# | |
| 437 | +# Impure and deliberately so: this is a spike, so it runs `flutter` directly | |
| 438 | +# out of the desktop shell rather than going through the nix build, and | |
| 439 | +# `flutter pub get` resolves the path dependency on `dart/frq_core` in place. | |
| 440 | +# Nothing here is on the way to a release. | |
| 441 | +# | |
| 442 | +# just nim-spike open the window | |
| 443 | +# just nim-spike build just build it | |
| 444 | +nim-spike action="run": | |
| 445 | + #!/usr/bin/env bash | |
| 446 | + set -euo pipefail | |
| 447 | + cd "{{justfile_directory()}}" | |
| 448 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | |
| 449 | + # The library first, in its own shell — the app dlopens it at startup | |
| 450 | + # and a missing .so is a blank window with a StateError behind it. | |
| 451 | + just nim-lib | |
| 452 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | |
| 453 | + --command just nim-spike "$@" | |
| 454 | + fi | |
| 455 | + cd flutter | |
| 456 | + flutter pub get | |
| 457 | + runner=() | |
| 458 | + [ -e /run/current-system ] || runner=("$NIXGL") | |
| 459 | + case "{{action}}" in | |
| 460 | + build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;; | |
| 461 | + run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;; | |
| 462 | + *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;; | |
| 463 | + esac | |
| 464 | + | |
| 465 | +# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps. | |
| 466 | +# | |
| 467 | +# Headless — no GL, no window — which is what makes this the proof rather than | |
| 468 | +# a screenshot. A screenshot shows that something painted; this shows the round | |
| 469 | +# trip closes: a tap reaches Nim, its state moves, the new tree comes back and | |
| 470 | +# the widgets change to match. | |
| 471 | +nim-spike-test: | |
| 472 | + #!/usr/bin/env bash | |
| 473 | + set -euo pipefail | |
| 474 | + cd "{{justfile_directory()}}" | |
| 475 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | |
| 476 | + just nim-lib | |
| 477 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | |
| 478 | + --command just nim-spike-test | |
| 479 | + fi | |
| 480 | + cd flutter | |
| 481 | + flutter pub get | |
| 482 | + flutter test test/nim_renderer_test.dart | |
| 483 | + | |
| 484 | +# What the Nim boundary costs per frame. | |
| 485 | +# | |
| 486 | +# The spike rebuilds the whole screen in Nim and ships it as JSON on every | |
| 487 | +# event, which is the obvious objection to the design. This is the number that | |
| 488 | +# answers it — or doesn't. | |
| 489 | +nim-bench: | |
| 490 | + #!/usr/bin/env bash | |
| 491 | + set -euo pipefail | |
| 492 | + cd "{{justfile_directory()}}" | |
| 493 | + if [ -z "${FRQ_DART:-}" ]; then | |
| 494 | + just nim-lib | |
| 495 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench | |
| 496 | + fi | |
| 497 | + cd dart/frq_core | |
| 498 | + dart pub get >/dev/null | |
| 499 | + dart run test/bench.dart | |
| @@ -427,3 +427,73 @@ dart-test: | |||
| 427 | cd dart/frq_core | 427 | cd dart/frq_core |
| 428 | dart pub get | 428 | dart pub get |
| 429 | dart test -r expanded | 429 | dart test -r expanded |
| 430 | + | ||
| 431 | +# The Nim spike: a Flutter window whose screens come from Nim. | ||
| 432 | +# | ||
| 433 | +# No ClojureDart on this path at all — not `frq.main`, not `common/`, not a | ||
| 434 | +# `.cljd` file. `lib/main_nim.dart` asks the Nim core for a widget tree and | ||
| 435 | +# paints it, and every tap goes back as an event id. See `nim/src/frq/ui.nim`. | ||
| 436 | +# | ||
| 437 | +# Impure and deliberately so: this is a spike, so it runs `flutter` directly | ||
| 438 | +# out of the desktop shell rather than going through the nix build, and | ||
| 439 | +# `flutter pub get` resolves the path dependency on `dart/frq_core` in place. | ||
| 440 | +# Nothing here is on the way to a release. | ||
| 441 | +# | ||
| 442 | +# just nim-spike open the window | ||
| 443 | +# just nim-spike build just build it | ||
| 444 | +nim-spike action="run": | ||
| 445 | + #!/usr/bin/env bash | ||
| 446 | + set -euo pipefail | ||
| 447 | + cd "{{justfile_directory()}}" | ||
| 448 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | ||
| 449 | + # The library first, in its own shell — the app dlopens it at startup | ||
| 450 | + # and a missing .so is a blank window with a StateError behind it. | ||
| 451 | + just nim-lib | ||
| 452 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | ||
| 453 | + --command just nim-spike "$@" | ||
| 454 | + fi | ||
| 455 | + cd flutter | ||
| 456 | + flutter pub get | ||
| 457 | + runner=() | ||
| 458 | + [ -e /run/current-system ] || runner=("$NIXGL") | ||
| 459 | + case "{{action}}" in | ||
| 460 | + build) exec "${runner[@]}" flutter build linux --debug -t lib/main_nim.dart ;; | ||
| 461 | + run) exec "${runner[@]}" flutter run -d linux -t lib/main_nim.dart ;; | ||
| 462 | + *) echo "usage: just nim-spike [run|build]" >&2; exit 1 ;; | ||
| 463 | + esac | ||
| 464 | + | ||
| 465 | +# The spike's widget tests: Nim's tree, as Flutter widgets, driven by taps. | ||
| 466 | +# | ||
| 467 | +# Headless — no GL, no window — which is what makes this the proof rather than | ||
| 468 | +# a screenshot. A screenshot shows that something painted; this shows the round | ||
| 469 | +# trip closes: a tap reaches Nim, its state moves, the new tree comes back and | ||
| 470 | +# the widgets change to match. | ||
| 471 | +nim-spike-test: | ||
| 472 | + #!/usr/bin/env bash | ||
| 473 | + set -euo pipefail | ||
| 474 | + cd "{{justfile_directory()}}" | ||
| 475 | + if [ -z "${FRQ_FLUTTER_DESKTOP:-}" ]; then | ||
| 476 | + just nim-lib | ||
| 477 | + exec {{nix}} develop .#flutter-desktop --max-jobs {{jobs}} \ | ||
| 478 | + --command just nim-spike-test | ||
| 479 | + fi | ||
| 480 | + cd flutter | ||
| 481 | + flutter pub get | ||
| 482 | + flutter test test/nim_renderer_test.dart | ||
| 483 | + | ||
| 484 | +# What the Nim boundary costs per frame. | ||
| 485 | +# | ||
| 486 | +# The spike rebuilds the whole screen in Nim and ships it as JSON on every | ||
| 487 | +# event, which is the obvious objection to the design. This is the number that | ||
| 488 | +# answers it — or doesn't. | ||
| 489 | +nim-bench: | ||
| 490 | + #!/usr/bin/env bash | ||
| 491 | + set -euo pipefail | ||
| 492 | + cd "{{justfile_directory()}}" | ||
| 493 | + if [ -z "${FRQ_DART:-}" ]; then | ||
| 494 | + just nim-lib | ||
| 495 | + exec {{nix}} develop .#dart --max-jobs {{jobs}} --command just nim-bench | ||
| 496 | + fi | ||
| 497 | + cd dart/frq_core | ||
| 498 | + dart pub get >/dev/null | ||
| 499 | + dart run test/bench.dart | ||
added
nim/src/frq/screens/connect.nim +89 -0 | new file mode 100644 | ||
| @@ -0,0 +1,89 @@ | ||
| 1 | +## The connect screen, as a pure function of the state. | |
| 2 | +## | |
| 3 | +## Transcribed from `common/frq/screens/connect.cljc` rather than redesigned, | |
| 4 | +## so that the two can be put side by side and disagreements are bugs rather | |
| 5 | +## than opinions. Where the Clojure derefs a cell this reads a field; where it | |
| 6 | +## puts a closure in `:on-click` this puts an event id. | |
| 7 | +## | |
| 8 | +## Nothing here mutates. That is not a style rule, it is what makes the | |
| 9 | +## boundary cheap: the renderer can be called at any time, twice, or not at | |
| 10 | +## all, and the only thing that changes the screen is `dispatch`. | |
| 11 | + | |
| 12 | +import std/json | |
| 13 | +import ../ui, ../state | |
| 14 | + | |
| 15 | +func errorNote(s: State): Node = | |
| 16 | + ## A stable wrapper with a stable key, kept from the original for the | |
| 17 | + ## original's reason: a renderer matching children by position would patch | |
| 18 | + ## the header into a card when an error appeared mid-screen. The wrapper | |
| 19 | + ## keeps the tree's shape fixed and only its contents changing. | |
| 20 | + result = vbox(%*{"key": "error-note", "spacing": 6}) | |
| 21 | + if s.hasError: | |
| 22 | + result.children.add card( | |
| 23 | + label("⚠ " & s.error), | |
| 24 | + button("Dismiss", "error.dismiss")) | |
| 25 | + | |
| 26 | +func modeTabs(s: State): Node = | |
| 27 | + hbox(%*{"spacing": 8}, | |
| 28 | + button("Guest", "mode.guest", | |
| 29 | + if s.authMode == amGuest: "primary" else: "default"), | |
| 30 | + button("Bluesky", "mode.bluesky", | |
| 31 | + if s.authMode == amBluesky: "primary" else: "default"), | |
| 32 | + button("App password", "mode.app-password", | |
| 33 | + if s.authMode == amAppPassword: "primary" else: "default")) | |
| 34 | + | |
| 35 | +func serverFields(s: State): Node = | |
| 36 | + vbox(%*{"spacing": 6}, | |
| 37 | + label("Server"), | |
| 38 | + hbox(%*{"spacing": 8}, | |
| 39 | + entry("host", s.formHost, "host", "host.change", width = 220), | |
| 40 | + entry("port", s.formPort, "6697", "port.change", width = 90)), | |
| 41 | + checkbutton("TLS", s.formTls, "tls.toggle")) | |
| 42 | + | |
| 43 | +func connectAction(s: State): Node = | |
| 44 | + if s.connecting: | |
| 45 | + hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status)) | |
| 46 | + else: | |
| 47 | + hbox(%*{"spacing": 8}, | |
| 48 | + button("Connect", "connect", "primary"), | |
| 49 | + dimLabel(s.status)) | |
| 50 | + | |
| 51 | +func authFields(s: State): Node = | |
| 52 | + case s.authMode | |
| 53 | + of amGuest: | |
| 54 | + vbox(%*{"spacing": 6}, | |
| 55 | + label("Nickname"), | |
| 56 | + entry("nick", s.formNick, "frq-guest", "nick.change", width = 220)) | |
| 57 | + of amBluesky: | |
| 58 | + vbox(%*{"spacing": 6}, | |
| 59 | + title2("Sign in with Bluesky"), | |
| 60 | + dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " & | |
| 61 | + "hands back a token; no password passes through frq."), | |
| 62 | + label("Handle"), | |
| 63 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | |
| 64 | + width = 320), | |
| 65 | + vbox(%*{"key": "remembered", "spacing": 4}, | |
| 66 | + if s.brokerToken.len > 0: | |
| 67 | + dimLabel("Session remembered — Connect will not need the browser.") | |
| 68 | + else: nil)) | |
| 69 | + of amAppPassword: | |
| 70 | + vbox(%*{"spacing": 6}, | |
| 71 | + title2("Sign in with an app password"), | |
| 72 | + dimLabel("Goes to your own PDS and nowhere else. Never written to disk."), | |
| 73 | + label("Handle"), | |
| 74 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | |
| 75 | + width = 320), | |
| 76 | + label("App password"), | |
| 77 | + entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", | |
| 78 | + "app-password.change", width = 320)) | |
| 79 | + | |
| 80 | +func connectScreen*(s: State): Node = | |
| 81 | + page(%*{"maxWidth": 520}, | |
| 82 | + title("frq"), | |
| 83 | + dimLabel("freeq client — guest, or your Bluesky identity."), | |
| 84 | + errorNote(s), | |
| 85 | + card( | |
| 86 | + modeTabs(s), | |
| 87 | + authFields(s), | |
| 88 | + serverFields(s), | |
| 89 | + connectAction(s))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | +## The connect screen, as a pure function of the state. | ||
| 2 | +## | ||
| 3 | +## Transcribed from `common/frq/screens/connect.cljc` rather than redesigned, | ||
| 4 | +## so that the two can be put side by side and disagreements are bugs rather | ||
| 5 | +## than opinions. Where the Clojure derefs a cell this reads a field; where it | ||
| 6 | +## puts a closure in `:on-click` this puts an event id. | ||
| 7 | +## | ||
| 8 | +## Nothing here mutates. That is not a style rule, it is what makes the | ||
| 9 | +## boundary cheap: the renderer can be called at any time, twice, or not at | ||
| 10 | +## all, and the only thing that changes the screen is `dispatch`. | ||
| 11 | + | ||
| 12 | +import std/json | ||
| 13 | +import ../ui, ../state | ||
| 14 | + | ||
| 15 | +func errorNote(s: State): Node = | ||
| 16 | + ## A stable wrapper with a stable key, kept from the original for the | ||
| 17 | + ## original's reason: a renderer matching children by position would patch | ||
| 18 | + ## the header into a card when an error appeared mid-screen. The wrapper | ||
| 19 | + ## keeps the tree's shape fixed and only its contents changing. | ||
| 20 | + result = vbox(%*{"key": "error-note", "spacing": 6}) | ||
| 21 | + if s.hasError: | ||
| 22 | + result.children.add card( | ||
| 23 | + label("⚠ " & s.error), | ||
| 24 | + button("Dismiss", "error.dismiss")) | ||
| 25 | + | ||
| 26 | +func modeTabs(s: State): Node = | ||
| 27 | + hbox(%*{"spacing": 8}, | ||
| 28 | + button("Guest", "mode.guest", | ||
| 29 | + if s.authMode == amGuest: "primary" else: "default"), | ||
| 30 | + button("Bluesky", "mode.bluesky", | ||
| 31 | + if s.authMode == amBluesky: "primary" else: "default"), | ||
| 32 | + button("App password", "mode.app-password", | ||
| 33 | + if s.authMode == amAppPassword: "primary" else: "default")) | ||
| 34 | + | ||
| 35 | +func serverFields(s: State): Node = | ||
| 36 | + vbox(%*{"spacing": 6}, | ||
| 37 | + label("Server"), | ||
| 38 | + hbox(%*{"spacing": 8}, | ||
| 39 | + entry("host", s.formHost, "host", "host.change", width = 220), | ||
| 40 | + entry("port", s.formPort, "6697", "port.change", width = 90)), | ||
| 41 | + checkbutton("TLS", s.formTls, "tls.toggle")) | ||
| 42 | + | ||
| 43 | +func connectAction(s: State): Node = | ||
| 44 | + if s.connecting: | ||
| 45 | + hbox(%*{"spacing": 8}, spinner(), dimLabel(s.status)) | ||
| 46 | + else: | ||
| 47 | + hbox(%*{"spacing": 8}, | ||
| 48 | + button("Connect", "connect", "primary"), | ||
| 49 | + dimLabel(s.status)) | ||
| 50 | + | ||
| 51 | +func authFields(s: State): Node = | ||
| 52 | + case s.authMode | ||
| 53 | + of amGuest: | ||
| 54 | + vbox(%*{"spacing": 6}, | ||
| 55 | + label("Nickname"), | ||
| 56 | + entry("nick", s.formNick, "frq-guest", "nick.change", width = 220)) | ||
| 57 | + of amBluesky: | ||
| 58 | + vbox(%*{"spacing": 6}, | ||
| 59 | + title2("Sign in with Bluesky"), | ||
| 60 | + dimLabel("Opens your browser for AT Protocol OAuth. freeq's broker " & | ||
| 61 | + "hands back a token; no password passes through frq."), | ||
| 62 | + label("Handle"), | ||
| 63 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | ||
| 64 | + width = 320), | ||
| 65 | + vbox(%*{"key": "remembered", "spacing": 4}, | ||
| 66 | + if s.brokerToken.len > 0: | ||
| 67 | + dimLabel("Session remembered — Connect will not need the browser.") | ||
| 68 | + else: nil)) | ||
| 69 | + of amAppPassword: | ||
| 70 | + vbox(%*{"spacing": 6}, | ||
| 71 | + title2("Sign in with an app password"), | ||
| 72 | + dimLabel("Goes to your own PDS and nowhere else. Never written to disk."), | ||
| 73 | + label("Handle"), | ||
| 74 | + entry("handle", s.formHandle, "alice.bsky.social", "handle.change", | ||
| 75 | + width = 320), | ||
| 76 | + label("App password"), | ||
| 77 | + entry("app-password", s.formAppPassword, "xxxx-xxxx-xxxx-xxxx", | ||
| 78 | + "app-password.change", width = 320)) | ||
| 79 | + | ||
| 80 | +func connectScreen*(s: State): Node = | ||
| 81 | + page(%*{"maxWidth": 520}, | ||
| 82 | + title("frq"), | ||
| 83 | + dimLabel("freeq client — guest, or your Bluesky identity."), | ||
| 84 | + errorNote(s), | ||
| 85 | + card( | ||
| 86 | + modeTabs(s), | ||
| 87 | + authFields(s), | ||
| 88 | + serverFields(s), | ||
| 89 | + connectAction(s))) | ||
added
nim/src/frq/state.nim +107 -0 | new file mode 100644 | ||
| @@ -0,0 +1,107 @@ | ||
| 1 | +## The app state, and the events that move it. | |
| 2 | +## | |
| 3 | +## This is `frq.cells` and `frq.actions` as one module, and merging them is | |
| 4 | +## the point rather than a shortcut: in the Clojure the cells are atoms a | |
| 5 | +## screen derefs and the actions are a table the host installs, and the split | |
| 6 | +## exists because the host and the screens were compiled separately. Here the | |
| 7 | +## state is Nim's, the reducer is Nim's, and there is no host to install | |
| 8 | +## anything — Dart sends an event id and gets a new tree back. | |
| 9 | +## | |
| 10 | +## Which makes the shape Elm's, and deliberately so. A screen is a pure | |
| 11 | +## function of this record; an event is the only way it changes; nothing else | |
| 12 | +## crosses the boundary. That is what lets the renderer stay dumb. | |
| 13 | + | |
| 14 | +import std/[json, strutils] | |
| 15 | + | |
| 16 | +type | |
| 17 | + AuthMode* = enum | |
| 18 | + amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password" | |
| 19 | + | |
| 20 | + Screen* = enum | |
| 21 | + scConnect = "connect", scChats = "chats", scChat = "chat" | |
| 22 | + | |
| 23 | + State* = object | |
| 24 | + screen*: Screen | |
| 25 | + status*: string | |
| 26 | + error*: string | |
| 27 | + hasError*: bool | |
| 28 | + connecting*: bool | |
| 29 | + | |
| 30 | + # The connect form. | |
| 31 | + authMode*: AuthMode | |
| 32 | + formHost*: string | |
| 33 | + formPort*: string | |
| 34 | + formTls*: bool | |
| 35 | + formNick*: string | |
| 36 | + formHandle*: string | |
| 37 | + formAppPassword*: string | |
| 38 | + brokerToken*: string | |
| 39 | + | |
| 40 | +const | |
| 41 | + defaultHost* = "irc.freeq.at" | |
| 42 | + defaultPort* = "6697" | |
| 43 | + | |
| 44 | +func initState*(): State = | |
| 45 | + State(screen: scConnect, | |
| 46 | + status: "Not connected", | |
| 47 | + authMode: amGuest, | |
| 48 | + formHost: defaultHost, | |
| 49 | + formPort: defaultPort, | |
| 50 | + formTls: true, | |
| 51 | + formNick: "frq-guest") | |
| 52 | + | |
| 53 | +var app* = initState() | |
| 54 | + ## The one mutable thing in the spike. Named `app` and not `state` because | |
| 55 | + ## `state` is ambiguous against unittest's own in a test module, which is | |
| 56 | + ## the sort of collision worth losing five characters to avoid. | |
| 57 | + | |
| 58 | +# ------------------------------------------------------------------ events | |
| 59 | +# | |
| 60 | +# One entry point, and a string id rather than an enum, because the ids are | |
| 61 | +# written into the tree that crosses the boundary and an enum on this side | |
| 62 | +# would be a number Dart had to agree with. A name that does not match | |
| 63 | +# anything is ignored rather than fatal: a stale tree held by the renderer for | |
| 64 | +# one frame after a state change is a normal race, not an error. | |
| 65 | + | |
| 66 | +proc dispatch*(event: JsonNode) = | |
| 67 | + let id = event{"id"}.getStr() | |
| 68 | + let value = event{"value"}.getStr() | |
| 69 | + | |
| 70 | + case id | |
| 71 | + of "mode.guest": app.authMode = amGuest | |
| 72 | + of "mode.bluesky": app.authMode = amBluesky | |
| 73 | + of "mode.app-password": app.authMode = amAppPassword | |
| 74 | + | |
| 75 | + of "host.change": app.formHost = value | |
| 76 | + of "port.change": app.formPort = value | |
| 77 | + of "nick.change": app.formNick = value | |
| 78 | + of "handle.change": app.formHandle = value | |
| 79 | + of "app-password.change": app.formAppPassword = value | |
| 80 | + | |
| 81 | + of "tls.toggle": | |
| 82 | + app.formTls = not app.formTls | |
| 83 | + # The port follows the tick, exactly as the Clojure's :on-toggled does. | |
| 84 | + app.formPort = if app.formTls: "6697" else: "6667" | |
| 85 | + | |
| 86 | + of "error.dismiss": | |
| 87 | + app.error = "" | |
| 88 | + app.hasError = false | |
| 89 | + | |
| 90 | + of "connect": | |
| 91 | + # The spike stops at the edge of I/O: there is no socket here yet, so | |
| 92 | + # this reports what it would do. Wiring the real connection in is the | |
| 93 | + # `nim/README.md` step about Nim owning the transport, and it does not | |
| 94 | + # change anything about the tree or the renderer. | |
| 95 | + if app.formHost.strip().len == 0: | |
| 96 | + app.error = "A server is required." | |
| 97 | + app.hasError = true | |
| 98 | + else: | |
| 99 | + app.connecting = true | |
| 100 | + app.status = "Connecting to " & app.formHost & ":" & app.formPort & | |
| 101 | + (if app.formTls: " over TLS" else: "") & "…" | |
| 102 | + | |
| 103 | + of "cancel": | |
| 104 | + app.connecting = false | |
| 105 | + app.status = "Not connected" | |
| 106 | + | |
| 107 | + else: discard | |
| new file mode 100644 | |||
| @@ -0,0 +1,107 @@ | |||
| 1 | +## The app state, and the events that move it. | ||
| 2 | +## | ||
| 3 | +## This is `frq.cells` and `frq.actions` as one module, and merging them is | ||
| 4 | +## the point rather than a shortcut: in the Clojure the cells are atoms a | ||
| 5 | +## screen derefs and the actions are a table the host installs, and the split | ||
| 6 | +## exists because the host and the screens were compiled separately. Here the | ||
| 7 | +## state is Nim's, the reducer is Nim's, and there is no host to install | ||
| 8 | +## anything — Dart sends an event id and gets a new tree back. | ||
| 9 | +## | ||
| 10 | +## Which makes the shape Elm's, and deliberately so. A screen is a pure | ||
| 11 | +## function of this record; an event is the only way it changes; nothing else | ||
| 12 | +## crosses the boundary. That is what lets the renderer stay dumb. | ||
| 13 | + | ||
| 14 | +import std/[json, strutils] | ||
| 15 | + | ||
| 16 | +type | ||
| 17 | + AuthMode* = enum | ||
| 18 | + amGuest = "guest", amBluesky = "bluesky", amAppPassword = "app-password" | ||
| 19 | + | ||
| 20 | + Screen* = enum | ||
| 21 | + scConnect = "connect", scChats = "chats", scChat = "chat" | ||
| 22 | + | ||
| 23 | + State* = object | ||
| 24 | + screen*: Screen | ||
| 25 | + status*: string | ||
| 26 | + error*: string | ||
| 27 | + hasError*: bool | ||
| 28 | + connecting*: bool | ||
| 29 | + | ||
| 30 | + # The connect form. | ||
| 31 | + authMode*: AuthMode | ||
| 32 | + formHost*: string | ||
| 33 | + formPort*: string | ||
| 34 | + formTls*: bool | ||
| 35 | + formNick*: string | ||
| 36 | + formHandle*: string | ||
| 37 | + formAppPassword*: string | ||
| 38 | + brokerToken*: string | ||
| 39 | + | ||
| 40 | +const | ||
| 41 | + defaultHost* = "irc.freeq.at" | ||
| 42 | + defaultPort* = "6697" | ||
| 43 | + | ||
| 44 | +func initState*(): State = | ||
| 45 | + State(screen: scConnect, | ||
| 46 | + status: "Not connected", | ||
| 47 | + authMode: amGuest, | ||
| 48 | + formHost: defaultHost, | ||
| 49 | + formPort: defaultPort, | ||
| 50 | + formTls: true, | ||
| 51 | + formNick: "frq-guest") | ||
| 52 | + | ||
| 53 | +var app* = initState() | ||
| 54 | + ## The one mutable thing in the spike. Named `app` and not `state` because | ||
| 55 | + ## `state` is ambiguous against unittest's own in a test module, which is | ||
| 56 | + ## the sort of collision worth losing five characters to avoid. | ||
| 57 | + | ||
| 58 | +# ------------------------------------------------------------------ events | ||
| 59 | +# | ||
| 60 | +# One entry point, and a string id rather than an enum, because the ids are | ||
| 61 | +# written into the tree that crosses the boundary and an enum on this side | ||
| 62 | +# would be a number Dart had to agree with. A name that does not match | ||
| 63 | +# anything is ignored rather than fatal: a stale tree held by the renderer for | ||
| 64 | +# one frame after a state change is a normal race, not an error. | ||
| 65 | + | ||
| 66 | +proc dispatch*(event: JsonNode) = | ||
| 67 | + let id = event{"id"}.getStr() | ||
| 68 | + let value = event{"value"}.getStr() | ||
| 69 | + | ||
| 70 | + case id | ||
| 71 | + of "mode.guest": app.authMode = amGuest | ||
| 72 | + of "mode.bluesky": app.authMode = amBluesky | ||
| 73 | + of "mode.app-password": app.authMode = amAppPassword | ||
| 74 | + | ||
| 75 | + of "host.change": app.formHost = value | ||
| 76 | + of "port.change": app.formPort = value | ||
| 77 | + of "nick.change": app.formNick = value | ||
| 78 | + of "handle.change": app.formHandle = value | ||
| 79 | + of "app-password.change": app.formAppPassword = value | ||
| 80 | + | ||
| 81 | + of "tls.toggle": | ||
| 82 | + app.formTls = not app.formTls | ||
| 83 | + # The port follows the tick, exactly as the Clojure's :on-toggled does. | ||
| 84 | + app.formPort = if app.formTls: "6697" else: "6667" | ||
| 85 | + | ||
| 86 | + of "error.dismiss": | ||
| 87 | + app.error = "" | ||
| 88 | + app.hasError = false | ||
| 89 | + | ||
| 90 | + of "connect": | ||
| 91 | + # The spike stops at the edge of I/O: there is no socket here yet, so | ||
| 92 | + # this reports what it would do. Wiring the real connection in is the | ||
| 93 | + # `nim/README.md` step about Nim owning the transport, and it does not | ||
| 94 | + # change anything about the tree or the renderer. | ||
| 95 | + if app.formHost.strip().len == 0: | ||
| 96 | + app.error = "A server is required." | ||
| 97 | + app.hasError = true | ||
| 98 | + else: | ||
| 99 | + app.connecting = true | ||
| 100 | + app.status = "Connecting to " & app.formHost & ":" & app.formPort & | ||
| 101 | + (if app.formTls: " over TLS" else: "") & "…" | ||
| 102 | + | ||
| 103 | + of "cancel": | ||
| 104 | + app.connecting = false | ||
| 105 | + app.status = "Not connected" | ||
| 106 | + | ||
| 107 | + else: discard | ||
added
nim/src/frq/ui.nim +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +## The widget tree Nim hands Dart, and the little DSL for building one. | |
| 2 | +## | |
| 3 | +## The shape is the hiccup the ClojureDart screens already produce — a tag, a | |
| 4 | +## props table, children — because the vocabulary is the part worth keeping. | |
| 5 | +## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a | |
| 6 | +## tree emitted here and a tree emitted there describe the same screen, and | |
| 7 | +## the renderer on the Dart side is the same idea rewritten rather than a new | |
| 8 | +## one invented. | |
| 9 | +## | |
| 10 | +## The one real difference is callbacks. In Clojure a prop holds a closure; | |
| 11 | +## across a C ABI it cannot, so `onClick` holds an **event id** instead — an | |
| 12 | +## opaque string Dart sends back to `dispatch`. That is what turns this from a | |
| 13 | +## rendering trick into an architecture: Nim owns the state, Dart owns the | |
| 14 | +## pixels, and the only things crossing are a tree going out and an event id | |
| 15 | +## coming back. | |
| 16 | + | |
| 17 | +import std/json | |
| 18 | + | |
| 19 | +type | |
| 20 | + Node* = ref object | |
| 21 | + ## A widget. `props` is deliberately untyped-ish — a JsonNode — because | |
| 22 | + ## the tags disagree about what they take and a variant per tag would be | |
| 23 | + ## a second place to edit every time one gains a property. | |
| 24 | + tag*: string | |
| 25 | + props*: JsonNode | |
| 26 | + children*: seq[Node] | |
| 27 | + | |
| 28 | +func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node = | |
| 29 | + ## The constructor everything uses. `n"vbox"` reads closely enough to | |
| 30 | + ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible | |
| 31 | + ## beside it. | |
| 32 | + Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children) | |
| 33 | + | |
| 34 | +func toJson*(node: Node): JsonNode = | |
| 35 | + if node.isNil: return newJNull() | |
| 36 | + result = newJObject() | |
| 37 | + result["tag"] = %node.tag | |
| 38 | + result["props"] = node.props | |
| 39 | + if node.children.len > 0: | |
| 40 | + var kids = newJArray() | |
| 41 | + for c in node.children: | |
| 42 | + if not c.isNil: | |
| 43 | + kids.add c.toJson | |
| 44 | + result["children"] = kids | |
| 45 | + | |
| 46 | +# --------------------------------------------------------------- shorthands | |
| 47 | +# | |
| 48 | +# Props are written as `%*{...}` at the call sites, which is Nim's JSON | |
| 49 | +# literal. It is noisier than Clojure's map but it is checked: a typo in a key | |
| 50 | +# is still a typo, but a typo in the *shape* — a string where a number goes — | |
| 51 | +# fails at the boundary rather than three layers into Flutter. | |
| 52 | + | |
| 53 | +func vbox*(props: JsonNode, children: varargs[Node]): Node = | |
| 54 | + n("vbox", props, @children) | |
| 55 | +func hbox*(props: JsonNode, children: varargs[Node]): Node = | |
| 56 | + n("hbox", props, @children) | |
| 57 | +func card*(children: varargs[Node]): Node = | |
| 58 | + n("card", newJObject(), @children) | |
| 59 | +func page*(props: JsonNode, children: varargs[Node]): Node = | |
| 60 | + n("page", props, @children) | |
| 61 | + | |
| 62 | +func label*(text: string): Node = n("label", %*{"label": text}) | |
| 63 | +func dimLabel*(text: string): Node = n("dim-label", %*{"label": text}) | |
| 64 | +func title*(text: string): Node = n("title", %*{"label": text}) | |
| 65 | +func title2*(text: string): Node = n("title-2", %*{"label": text}) | |
| 66 | +func spinner*(): Node = n("spinner") | |
| 67 | + | |
| 68 | +func button*(text: string, onClick: string, kind = "default"): Node = | |
| 69 | + ## `onClick` is an event id, not a closure. See the module comment. | |
| 70 | + n("button", %*{"label": text, "kind": kind, "onClick": onClick}) | |
| 71 | + | |
| 72 | +func entry*(key, text, placeholder, onChange: string, width = 0): Node = | |
| 73 | + ## Every entry carries a key, and for the reason the Clojure's comment | |
| 74 | + ## gives: a renderer that keeps a text controller per field needs a stable | |
| 75 | + ## name for it, and without one the host and the port shared a controller | |
| 76 | + ## and both showed the port. | |
| 77 | + var p = %*{"key": key, "text": text, "placeholder": placeholder, | |
| 78 | + "onChange": onChange} | |
| 79 | + if width > 0: p["widthRequest"] = %width | |
| 80 | + n("entry", p) | |
| 81 | + | |
| 82 | +func checkbutton*(text: string, active: bool, onToggled: string): Node = | |
| 83 | + n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled}) | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +## The widget tree Nim hands Dart, and the little DSL for building one. | ||
| 2 | +## | ||
| 3 | +## The shape is the hiccup the ClojureDart screens already produce — a tag, a | ||
| 4 | +## props table, children — because the vocabulary is the part worth keeping. | ||
| 5 | +## `frq.hiccup` interprets exactly these tags into Flutter widgets today, so a | ||
| 6 | +## tree emitted here and a tree emitted there describe the same screen, and | ||
| 7 | +## the renderer on the Dart side is the same idea rewritten rather than a new | ||
| 8 | +## one invented. | ||
| 9 | +## | ||
| 10 | +## The one real difference is callbacks. In Clojure a prop holds a closure; | ||
| 11 | +## across a C ABI it cannot, so `onClick` holds an **event id** instead — an | ||
| 12 | +## opaque string Dart sends back to `dispatch`. That is what turns this from a | ||
| 13 | +## rendering trick into an architecture: Nim owns the state, Dart owns the | ||
| 14 | +## pixels, and the only things crossing are a tree going out and an event id | ||
| 15 | +## coming back. | ||
| 16 | + | ||
| 17 | +import std/json | ||
| 18 | + | ||
| 19 | +type | ||
| 20 | + Node* = ref object | ||
| 21 | + ## A widget. `props` is deliberately untyped-ish — a JsonNode — because | ||
| 22 | + ## the tags disagree about what they take and a variant per tag would be | ||
| 23 | + ## a second place to edit every time one gains a property. | ||
| 24 | + tag*: string | ||
| 25 | + props*: JsonNode | ||
| 26 | + children*: seq[Node] | ||
| 27 | + | ||
| 28 | +func n*(tag: string, props: JsonNode = nil, children: seq[Node] = @[]): Node = | ||
| 29 | + ## The constructor everything uses. `n"vbox"` reads closely enough to | ||
| 30 | + ## `[:vbox ...]` that a screen transcribed from the Clojure stays legible | ||
| 31 | + ## beside it. | ||
| 32 | + Node(tag: tag, props: if props.isNil: newJObject() else: props, children: children) | ||
| 33 | + | ||
| 34 | +func toJson*(node: Node): JsonNode = | ||
| 35 | + if node.isNil: return newJNull() | ||
| 36 | + result = newJObject() | ||
| 37 | + result["tag"] = %node.tag | ||
| 38 | + result["props"] = node.props | ||
| 39 | + if node.children.len > 0: | ||
| 40 | + var kids = newJArray() | ||
| 41 | + for c in node.children: | ||
| 42 | + if not c.isNil: | ||
| 43 | + kids.add c.toJson | ||
| 44 | + result["children"] = kids | ||
| 45 | + | ||
| 46 | +# --------------------------------------------------------------- shorthands | ||
| 47 | +# | ||
| 48 | +# Props are written as `%*{...}` at the call sites, which is Nim's JSON | ||
| 49 | +# literal. It is noisier than Clojure's map but it is checked: a typo in a key | ||
| 50 | +# is still a typo, but a typo in the *shape* — a string where a number goes — | ||
| 51 | +# fails at the boundary rather than three layers into Flutter. | ||
| 52 | + | ||
| 53 | +func vbox*(props: JsonNode, children: varargs[Node]): Node = | ||
| 54 | + n("vbox", props, @children) | ||
| 55 | +func hbox*(props: JsonNode, children: varargs[Node]): Node = | ||
| 56 | + n("hbox", props, @children) | ||
| 57 | +func card*(children: varargs[Node]): Node = | ||
| 58 | + n("card", newJObject(), @children) | ||
| 59 | +func page*(props: JsonNode, children: varargs[Node]): Node = | ||
| 60 | + n("page", props, @children) | ||
| 61 | + | ||
| 62 | +func label*(text: string): Node = n("label", %*{"label": text}) | ||
| 63 | +func dimLabel*(text: string): Node = n("dim-label", %*{"label": text}) | ||
| 64 | +func title*(text: string): Node = n("title", %*{"label": text}) | ||
| 65 | +func title2*(text: string): Node = n("title-2", %*{"label": text}) | ||
| 66 | +func spinner*(): Node = n("spinner") | ||
| 67 | + | ||
| 68 | +func button*(text: string, onClick: string, kind = "default"): Node = | ||
| 69 | + ## `onClick` is an event id, not a closure. See the module comment. | ||
| 70 | + n("button", %*{"label": text, "kind": kind, "onClick": onClick}) | ||
| 71 | + | ||
| 72 | +func entry*(key, text, placeholder, onChange: string, width = 0): Node = | ||
| 73 | + ## Every entry carries a key, and for the reason the Clojure's comment | ||
| 74 | + ## gives: a renderer that keeps a text controller per field needs a stable | ||
| 75 | + ## name for it, and without one the host and the port shared a controller | ||
| 76 | + ## and both showed the port. | ||
| 77 | + var p = %*{"key": key, "text": text, "placeholder": placeholder, | ||
| 78 | + "onChange": onChange} | ||
| 79 | + if width > 0: p["widthRequest"] = %width | ||
| 80 | + n("entry", p) | ||
| 81 | + | ||
| 82 | +func checkbutton*(text: string, active: bool, onToggled: string): Node = | ||
| 83 | + n("checkbutton", %*{"label": text, "active": active, "onToggled": onToggled}) | ||
modified
nim/src/frq_core.nim +36 -1 | @@ -21,7 +21,8 @@ | ||
| 21 | 21 | ## line being parsed. |
| 22 | 22 | |
| 23 | 23 | import std/json |
| 24 | -import frq/ircparse | |
| 24 | +import frq/[ircparse, ui, state] | |
| 25 | +import frq/screens/connect as connectScreen | |
| 25 | 26 | |
| 26 | 27 | proc NimMain() {.importc.} |
| 27 | 28 | |
| @@ -94,3 +95,37 @@ proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} = | ||
| 94 | 95 | proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} = |
| 95 | 96 | if prefix == nil: return nil |
| 96 | 97 | dup(nickOf($prefix)) |
| 98 | + | |
| 99 | +# ------------------------------------------------------------------- the UI | |
| 100 | +# | |
| 101 | +# The spike's real claim: Nim owns the state and the screen, Dart owns the | |
| 102 | +# pixels, and the only things crossing are a tree going out and an event id | |
| 103 | +# coming back. See `frq/ui.nim`. | |
| 104 | + | |
| 105 | +proc frq_ui_render*(): cstring {.exportc, dynlib.} = | |
| 106 | + ## The current screen as a widget tree, in JSON. Pure: calling it twice with | |
| 107 | + ## no dispatch between gives the same answer, which is what lets the | |
| 108 | + ## renderer rebuild whenever Flutter asks rather than when Nim says so. | |
| 109 | + dup($connectScreen.connectScreen(app).toJson) | |
| 110 | + | |
| 111 | +proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} = | |
| 112 | + ## Apply an event and answer with the tree it produced. | |
| 113 | + ## | |
| 114 | + ## One call rather than dispatch-then-render, and not to save a crossing: | |
| 115 | + ## it makes the pair atomic. Two calls leave a window in which Dart could | |
| 116 | + ## render a state nothing asked for, which is the sort of thing that shows | |
| 117 | + ## up once a week and never in a test. | |
| 118 | + ## | |
| 119 | + ## A malformed event is ignored rather than fatal — it arrives from a tree | |
| 120 | + ## the renderer may have been holding for a frame, which is a normal race. | |
| 121 | + if event != nil: | |
| 122 | + try: | |
| 123 | + dispatch(parseJson($event)) | |
| 124 | + except JsonParsingError: | |
| 125 | + discard | |
| 126 | + dup($connectScreen.connectScreen(app).toJson) | |
| 127 | + | |
| 128 | +proc frq_ui_reset*() {.exportc, dynlib.} = | |
| 129 | + ## Back to a fresh state. For tests, and for a renderer that wants a known | |
| 130 | + ## starting point rather than whatever the last run left. | |
| 131 | + app = initState() | |
| @@ -21,7 +21,8 @@ | |||
| 21 | ## line being parsed. | 21 | ## line being parsed. |
| 22 | 22 | ||
| 23 | import std/json | 23 | import std/json |
| 24 | -import frq/ircparse | 24 | +import frq/[ircparse, ui, state] |
| 25 | +import frq/screens/connect as connectScreen | ||
| 25 | 26 | ||
| 26 | proc NimMain() {.importc.} | 27 | proc NimMain() {.importc.} |
| 27 | 28 | ||
| @@ -94,3 +95,37 @@ proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} = | |||
| 94 | proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} = | 95 | proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} = |
| 95 | if prefix == nil: return nil | 96 | if prefix == nil: return nil |
| 96 | dup(nickOf($prefix)) | 97 | dup(nickOf($prefix)) |
| 98 | + | ||
| 99 | +# ------------------------------------------------------------------- the UI | ||
| 100 | +# | ||
| 101 | +# The spike's real claim: Nim owns the state and the screen, Dart owns the | ||
| 102 | +# pixels, and the only things crossing are a tree going out and an event id | ||
| 103 | +# coming back. See `frq/ui.nim`. | ||
| 104 | + | ||
| 105 | +proc frq_ui_render*(): cstring {.exportc, dynlib.} = | ||
| 106 | + ## The current screen as a widget tree, in JSON. Pure: calling it twice with | ||
| 107 | + ## no dispatch between gives the same answer, which is what lets the | ||
| 108 | + ## renderer rebuild whenever Flutter asks rather than when Nim says so. | ||
| 109 | + dup($connectScreen.connectScreen(app).toJson) | ||
| 110 | + | ||
| 111 | +proc frq_ui_dispatch*(event: cstring): cstring {.exportc, dynlib.} = | ||
| 112 | + ## Apply an event and answer with the tree it produced. | ||
| 113 | + ## | ||
| 114 | + ## One call rather than dispatch-then-render, and not to save a crossing: | ||
| 115 | + ## it makes the pair atomic. Two calls leave a window in which Dart could | ||
| 116 | + ## render a state nothing asked for, which is the sort of thing that shows | ||
| 117 | + ## up once a week and never in a test. | ||
| 118 | + ## | ||
| 119 | + ## A malformed event is ignored rather than fatal — it arrives from a tree | ||
| 120 | + ## the renderer may have been holding for a frame, which is a normal race. | ||
| 121 | + if event != nil: | ||
| 122 | + try: | ||
| 123 | + dispatch(parseJson($event)) | ||
| 124 | + except JsonParsingError: | ||
| 125 | + discard | ||
| 126 | + dup($connectScreen.connectScreen(app).toJson) | ||
| 127 | + | ||
| 128 | +proc frq_ui_reset*() {.exportc, dynlib.} = | ||
| 129 | + ## Back to a fresh state. For tests, and for a renderer that wants a known | ||
| 130 | + ## starting point rather than whatever the last run left. | ||
| 131 | + app = initState() | ||
added
nim/tests/tui.nim +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +## The screen as a pure function of the app, and the reducer that moves it. | |
| 2 | +import std/sequtils | |
| 3 | +## | |
| 4 | +## These are the tests the Clojure screens never had and could not easily | |
| 5 | +## have: a screen there is hiccup over cells a host installs, so exercising | |
| 6 | +## one means standing up a host. Here it is a function from a record to a | |
| 7 | +## tree, and a test is a call. | |
| 8 | + | |
| 9 | +import std/[json, strutils] | |
| 10 | +import std/unittest | |
| 11 | +import frq/[ui, state] | |
| 12 | +import frq/screens/connect as cs | |
| 13 | + | |
| 14 | +proc find(node: Node, tag: string): seq[Node] = | |
| 15 | + ## Every node with this tag, depth first. | |
| 16 | + if node.isNil: return | |
| 17 | + if node.tag == tag: result.add node | |
| 18 | + for c in node.children: | |
| 19 | + result.add c.find(tag) | |
| 20 | + | |
| 21 | +proc texts(node: Node, tag: string): seq[string] = | |
| 22 | + for n in node.find(tag): | |
| 23 | + result.add n.props{"label"}.getStr() | |
| 24 | + | |
| 25 | +suite "the connect screen": | |
| 26 | + setup: | |
| 27 | + app = initState() | |
| 28 | + | |
| 29 | + test "renders a page with the title and the server fields": | |
| 30 | + let t = cs.connectScreen(app) | |
| 31 | + check t.tag == "page" | |
| 32 | + check "frq" in t.texts("title") | |
| 33 | + check "Server" in t.texts("label") | |
| 34 | + check t.find("checkbutton").len == 1 | |
| 35 | + | |
| 36 | + test "is pure — twice with no dispatch is the same tree": | |
| 37 | + check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson | |
| 38 | + | |
| 39 | + test "guest is the default mode and shows a nickname field": | |
| 40 | + let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr()) | |
| 41 | + check "nick" in keys | |
| 42 | + check "handle" notin keys | |
| 43 | + | |
| 44 | + test "the selected mode is the primary button, and only it": | |
| 45 | + let t = cs.connectScreen(app) | |
| 46 | + var primary: seq[string] | |
| 47 | + for b in t.find("button"): | |
| 48 | + if b.props{"kind"}.getStr() == "primary": | |
| 49 | + primary.add b.props{"label"}.getStr() | |
| 50 | + # Guest is selected; Connect is primary because it is the action. | |
| 51 | + check "Guest" in primary | |
| 52 | + check "Bluesky" notin primary | |
| 53 | + | |
| 54 | +suite "dispatch": | |
| 55 | + setup: | |
| 56 | + app = initState() | |
| 57 | + | |
| 58 | + test "switching mode changes which fields are shown": | |
| 59 | + dispatch(%*{"id": "mode.bluesky"}) | |
| 60 | + let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr()) | |
| 61 | + check "handle" in keys | |
| 62 | + check "nick" notin keys | |
| 63 | + | |
| 64 | + test "typing into the host field lands in the tree": | |
| 65 | + dispatch(%*{"id": "host.change", "value": "localhost"}) | |
| 66 | + let host = cs.connectScreen(app).find("entry").filterIt( | |
| 67 | + it.props{"key"}.getStr() == "host")[0] | |
| 68 | + check host.props{"text"}.getStr() == "localhost" | |
| 69 | + | |
| 70 | + test "the TLS tick carries the port with it": | |
| 71 | + check app.formPort == "6697" | |
| 72 | + dispatch(%*{"id": "tls.toggle"}) | |
| 73 | + check not app.formTls | |
| 74 | + check app.formPort == "6667" | |
| 75 | + dispatch(%*{"id": "tls.toggle"}) | |
| 76 | + check app.formPort == "6697" | |
| 77 | + | |
| 78 | + test "connecting swaps the button for a spinner": | |
| 79 | + check cs.connectScreen(app).find("spinner").len == 0 | |
| 80 | + dispatch(%*{"id": "connect"}) | |
| 81 | + let t = cs.connectScreen(app) | |
| 82 | + check t.find("spinner").len == 1 | |
| 83 | + check "Connect" notin t.texts("button") | |
| 84 | + | |
| 85 | + test "an empty host is refused, and the error is dismissable": | |
| 86 | + dispatch(%*{"id": "host.change", "value": " "}) | |
| 87 | + dispatch(%*{"id": "connect"}) | |
| 88 | + check app.hasError | |
| 89 | + check "Dismiss" in cs.connectScreen(app).texts("button") | |
| 90 | + dispatch(%*{"id": "error.dismiss"}) | |
| 91 | + check not app.hasError | |
| 92 | + check "Dismiss" notin cs.connectScreen(app).texts("button") | |
| 93 | + | |
| 94 | + test "the error note keeps its place in the tree either way": | |
| 95 | + # The bug the stable wrapper exists for: a renderer matching children by | |
| 96 | + # position would patch the header into a card when the error appeared. | |
| 97 | + let before = cs.connectScreen(app).children.mapIt(it.tag) | |
| 98 | + dispatch(%*{"id": "host.change", "value": ""}) | |
| 99 | + dispatch(%*{"id": "connect"}) | |
| 100 | + check cs.connectScreen(app).children.mapIt(it.tag) == before | |
| 101 | + | |
| 102 | + test "an unknown event is ignored rather than fatal": | |
| 103 | + let before = $cs.connectScreen(app).toJson | |
| 104 | + dispatch(%*{"id": "no.such.event"}) | |
| 105 | + check $cs.connectScreen(app).toJson == before | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +## The screen as a pure function of the app, and the reducer that moves it. | ||
| 2 | +import std/sequtils | ||
| 3 | +## | ||
| 4 | +## These are the tests the Clojure screens never had and could not easily | ||
| 5 | +## have: a screen there is hiccup over cells a host installs, so exercising | ||
| 6 | +## one means standing up a host. Here it is a function from a record to a | ||
| 7 | +## tree, and a test is a call. | ||
| 8 | + | ||
| 9 | +import std/[json, strutils] | ||
| 10 | +import std/unittest | ||
| 11 | +import frq/[ui, state] | ||
| 12 | +import frq/screens/connect as cs | ||
| 13 | + | ||
| 14 | +proc find(node: Node, tag: string): seq[Node] = | ||
| 15 | + ## Every node with this tag, depth first. | ||
| 16 | + if node.isNil: return | ||
| 17 | + if node.tag == tag: result.add node | ||
| 18 | + for c in node.children: | ||
| 19 | + result.add c.find(tag) | ||
| 20 | + | ||
| 21 | +proc texts(node: Node, tag: string): seq[string] = | ||
| 22 | + for n in node.find(tag): | ||
| 23 | + result.add n.props{"label"}.getStr() | ||
| 24 | + | ||
| 25 | +suite "the connect screen": | ||
| 26 | + setup: | ||
| 27 | + app = initState() | ||
| 28 | + | ||
| 29 | + test "renders a page with the title and the server fields": | ||
| 30 | + let t = cs.connectScreen(app) | ||
| 31 | + check t.tag == "page" | ||
| 32 | + check "frq" in t.texts("title") | ||
| 33 | + check "Server" in t.texts("label") | ||
| 34 | + check t.find("checkbutton").len == 1 | ||
| 35 | + | ||
| 36 | + test "is pure — twice with no dispatch is the same tree": | ||
| 37 | + check $cs.connectScreen(app).toJson == $cs.connectScreen(app).toJson | ||
| 38 | + | ||
| 39 | + test "guest is the default mode and shows a nickname field": | ||
| 40 | + let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr()) | ||
| 41 | + check "nick" in keys | ||
| 42 | + check "handle" notin keys | ||
| 43 | + | ||
| 44 | + test "the selected mode is the primary button, and only it": | ||
| 45 | + let t = cs.connectScreen(app) | ||
| 46 | + var primary: seq[string] | ||
| 47 | + for b in t.find("button"): | ||
| 48 | + if b.props{"kind"}.getStr() == "primary": | ||
| 49 | + primary.add b.props{"label"}.getStr() | ||
| 50 | + # Guest is selected; Connect is primary because it is the action. | ||
| 51 | + check "Guest" in primary | ||
| 52 | + check "Bluesky" notin primary | ||
| 53 | + | ||
| 54 | +suite "dispatch": | ||
| 55 | + setup: | ||
| 56 | + app = initState() | ||
| 57 | + | ||
| 58 | + test "switching mode changes which fields are shown": | ||
| 59 | + dispatch(%*{"id": "mode.bluesky"}) | ||
| 60 | + let keys = cs.connectScreen(app).find("entry").mapIt(it.props{"key"}.getStr()) | ||
| 61 | + check "handle" in keys | ||
| 62 | + check "nick" notin keys | ||
| 63 | + | ||
| 64 | + test "typing into the host field lands in the tree": | ||
| 65 | + dispatch(%*{"id": "host.change", "value": "localhost"}) | ||
| 66 | + let host = cs.connectScreen(app).find("entry").filterIt( | ||
| 67 | + it.props{"key"}.getStr() == "host")[0] | ||
| 68 | + check host.props{"text"}.getStr() == "localhost" | ||
| 69 | + | ||
| 70 | + test "the TLS tick carries the port with it": | ||
| 71 | + check app.formPort == "6697" | ||
| 72 | + dispatch(%*{"id": "tls.toggle"}) | ||
| 73 | + check not app.formTls | ||
| 74 | + check app.formPort == "6667" | ||
| 75 | + dispatch(%*{"id": "tls.toggle"}) | ||
| 76 | + check app.formPort == "6697" | ||
| 77 | + | ||
| 78 | + test "connecting swaps the button for a spinner": | ||
| 79 | + check cs.connectScreen(app).find("spinner").len == 0 | ||
| 80 | + dispatch(%*{"id": "connect"}) | ||
| 81 | + let t = cs.connectScreen(app) | ||
| 82 | + check t.find("spinner").len == 1 | ||
| 83 | + check "Connect" notin t.texts("button") | ||
| 84 | + | ||
| 85 | + test "an empty host is refused, and the error is dismissable": | ||
| 86 | + dispatch(%*{"id": "host.change", "value": " "}) | ||
| 87 | + dispatch(%*{"id": "connect"}) | ||
| 88 | + check app.hasError | ||
| 89 | + check "Dismiss" in cs.connectScreen(app).texts("button") | ||
| 90 | + dispatch(%*{"id": "error.dismiss"}) | ||
| 91 | + check not app.hasError | ||
| 92 | + check "Dismiss" notin cs.connectScreen(app).texts("button") | ||
| 93 | + | ||
| 94 | + test "the error note keeps its place in the tree either way": | ||
| 95 | + # The bug the stable wrapper exists for: a renderer matching children by | ||
| 96 | + # position would patch the header into a card when the error appeared. | ||
| 97 | + let before = cs.connectScreen(app).children.mapIt(it.tag) | ||
| 98 | + dispatch(%*{"id": "host.change", "value": ""}) | ||
| 99 | + dispatch(%*{"id": "connect"}) | ||
| 100 | + check cs.connectScreen(app).children.mapIt(it.tag) == before | ||
| 101 | + | ||
| 102 | + test "an unknown event is ignored rather than fatal": | ||
| 103 | + let before = $cs.connectScreen(app).toJson | ||
| 104 | + dispatch(%*{"id": "no.such.event"}) | ||
| 105 | + check $cs.connectScreen(app).toJson == before | ||