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

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

The window connects 1d62d1a · on 1d62d1a437b844c37e3e4d49efa7e255ef4deac7 · nandi · 19h ago
nim_renderer.dart · 251 lines · 8.8 KBDart Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/// The renderer: a Nim widget tree, walked into Flutter widgets.
///
/// This is the Dart half of the spike's claim. It knows the tag vocabulary
/// and nothing else — no screens, no state, no idea what "connect" means. Nim
/// decides what the screen is; this decides what a `vbox` looks like.
///
/// The measure of whether the split is honest is how boring this file is. If
/// a feature ever needs a change here AND in Nim, the boundary is in the
/// wrong place.
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:frq_core/frq_core.dart' as core;

/// Rebuilds from Nim on every event. One `setState` per dispatch, and the
/// whole tree is rebuilt — which is what Flutter does anyway, and is why the
/// Nim side does not need a reconciler of its own.
class NimApp extends StatefulWidget {
  const NimApp({super.key});
  @override
  State<NimApp> createState() => _NimAppState();
}

class _NimAppState extends State<NimApp> {
  late core.UiNode _tree = core.render();
  Timer? _poll;

  @override
  void initState() {
    super.initState();
    // Polling, because the socket lives on a Nim thread and there is no
    // callback into Dart. A Dart callback invoked from a foreign thread has to
    // be marshalled onto the main isolate — NativeCallable, ports, a whole
    // mechanism — and at 70µs a render a 100ms timer does the same job for
    // nothing. It is also why `render` is allowed to be impure.
    _poll = Timer.periodic(const Duration(milliseconds: 100), (_) {
      final t = core.poll();
      // Only when it actually differs: a setState per tick would rebuild the
      // whole tree ten times a second for a screen nobody is touching.
      if (t.toString() != _tree.toString()) {
        setState(() => _tree = t);
      }
    });
  }

  // One controller per keyed entry, kept across rebuilds.
  //
  // This is the whole reason `:key` is on every entry in both the Clojure and
  // the Nim: a controller identified by position instead of name meant the
  // host field and the port field shared one and both showed the port. The
  // comment survives three languages now.
  final _controllers = <String, TextEditingController>{};

  // One focus node per keyed entry, for the same reason as the controllers.
  // Without it, sending with Enter drops focus and the next line is typed
  // into nothing — the field is rebuilt from a fresh tree every time.
  final _focus = <String, FocusNode>{};

  void _send(String id, [String value = '']) {
    setState(() => _tree = core.dispatch(id, value));
    // Enter in the compose box clears the draft in Nim and rebuilds the
    // field; putting focus back is what makes a second line typeable.
    if (id == 'send') _focus['draft']?.requestFocus();
  }

  @override
  void dispose() {
    _poll?.cancel();
    for (final c in _controllers.values) {
      c.dispose();
    }
    for (final f in _focus.values) {
      f.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
        title: 'frq',
        theme: ThemeData.dark(useMaterial3: true),
        home: Scaffold(
          body: SafeArea(child: SingleChildScrollView(child: _build(_tree))),
        ),
      );

  Widget _build(core.UiNode n) {
    final kids = n.children.map(_build).toList();

    switch (n.tag) {
      case 'page':
        return Center(
          child: ConstrainedBox(
            constraints:
                BoxConstraints(maxWidth: n.prop('maxWidth', 520).toDouble()),
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start, children: kids),
            ),
          ),
        );

      case 'vbox':
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: _spaced(kids, n.prop('spacing', 0), vertical: true),
        );

      case 'hbox':
        // Wrap and not Row, and this was a bug before it was a decision: the
        // three mode buttons are wider than the 520-point page, and a Row
        // answers that with a RenderFlex overflow rather than a second line.
        // A `:hbox` in the screens means "these go together across", not "these
        // fit"; the tree has no idea how wide the window is and should not.
        final gap = n.prop('spacing', 0).toDouble();
        return Wrap(
          spacing: gap,
          runSpacing: gap,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: kids,
        );

      case 'scroll':
        return SizedBox(
          height: n.prop('height', 300).toDouble(),
          child: Scrollbar(
            child: SingleChildScrollView(
              reverse: true,
              child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: kids),
            ),
          ),
        );

      case 'card':
        return Card(
          margin: const EdgeInsets.symmetric(vertical: 8),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
                crossAxisAlignment: CrossAxisAlignment.start, children: kids),
          ),
        );

      case 'title':
        return Text(n.prop('label', ''),
            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold));

      case 'title-2':
        return Padding(
          padding: const EdgeInsets.only(top: 8, bottom: 4),
          child: Text(n.prop('label', ''),
              style:
                  const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
        );

      case 'label':
        return Text(n.prop('label', ''));

      case 'dim-label':
        return Opacity(
            opacity: 0.7,
            child: Text(n.prop('label', ''),
                style: const TextStyle(fontSize: 12)));

      case 'spinner':
        return const SizedBox(
            width: 16,
            height: 16,
            child: CircularProgressIndicator(strokeWidth: 2));

      case 'button':
        final onClick = n.prop('onClick', '');
        final label = Text(n.prop('label', ''));
        // No padding of its own: spacing belongs to the container, which is
        // the only thing that knows whether this is in a row or a column.
        return n.prop('kind', 'default') == 'primary'
            ? FilledButton(onPressed: () => _send(onClick), child: label)
            : OutlinedButton(onPressed: () => _send(onClick), child: label);

      case 'checkbutton':
        return Row(mainAxisSize: MainAxisSize.min, children: [
          Checkbox(
            value: n.prop('active', false),
            onChanged: (_) => _send(n.prop('onToggled', '')),
          ),
          Text(n.prop('label', '')),
        ]);

      case 'entry':
        final key = n.prop('key', '');
        final text = n.prop('text', '');
        final c = _controllers.putIfAbsent(
            key, () => TextEditingController(text: text));
        // Only when it actually differs: assigning unconditionally moves the
        // caret to the end on every keystroke, which is the classic way to
        // make a controlled text field unusable.
        if (c.text != text) {
          c.value = c.value.copyWith(
            text: text,
            selection: TextSelection.collapsed(offset: text.length),
          );
        }
        final field = TextField(
          controller: c,
          focusNode: _focus.putIfAbsent(key, FocusNode.new),
          decoration: InputDecoration(
            hintText: n.prop('placeholder', ''),
            isDense: true,
            border: const OutlineInputBorder(),
          ),
          onChanged: (v) => _send(n.prop('onChange', ''), v),
          onSubmitted: (_) {
            final submit = n.prop('onSubmit', '');
            if (submit.isNotEmpty) _send(submit);
          },
        );
        final w = n.prop('widthRequest', 0);
        // A width request is a minimum in the screens' vocabulary, but here it
        // has to be a maximum too: an unconstrained TextField inside a Wrap
        // has no width at all to take.
        return w > 0 ? SizedBox(width: w.toDouble(), child: field) : field;

      default:
        // An unknown tag paints as itself rather than crashing or vanishing.
        // Nim can add one and see it before this file has heard of it, which
        // is the behaviour that makes the boundary pleasant to work across.
        return Container(
          padding: const EdgeInsets.all(4),
          color: Colors.orange.withValues(alpha: 0.3),
          child: Text('?${n.tag}'),
        );
    }
  }

  List<Widget> _spaced(List<Widget> kids, num gap, {required bool vertical}) {
    if (gap <= 0 || kids.length < 2) return kids;
    final out = <Widget>[];
    for (var i = 0; i < kids.length; i++) {
      if (i > 0) {
        out.add(vertical
            ? SizedBox(height: gap.toDouble())
            : SizedBox(width: gap.toDouble()));
      }
      out.add(kids[i]);
    }
    return out;
  }
}