Skip to content

Instantly share code, notes, and snippets.

@sma
Last active May 4, 2025 09:33
Show Gist options
  • Save sma/a6bd2513c010728585f096baf4b59b02 to your computer and use it in GitHub Desktop.
Save sma/a6bd2513c010728585f096baf4b59b02 to your computer and use it in GitHub Desktop.
A tiny server UI framework for Flutter

A tiny server UI framework

Let's iteratively create the components to create Flutter UIs from textual descriptions. How to create those descriptions or how you get them from some server shall be out of scope.

A simple HTTP REST is probably sufficient, but especially for debugging, a parallel web socket channel or server side events (SSEs) would be useful to trigger a redisplay as if the app was hot-reloaded. For production, consider caching all responses.

Hierarchical nodes

Here's a generic hierarchical Node class which will be our main building block:

class Node {
  const Node(
    this.name, [
    this.props = const {},
    this.children = const [],
  ]);

  final String name;
  final Map<String, Object?> props;
  final List<Node> children;
}

We could use XML to represent them, or JSON, or a custom format that resembles Dart constructors, but I'll use S-expressions according to this grammar, just for the sake of it:

node = "(" name [string] {prop} {node} ")".
prop = ":" name expr.
expr = name | number | string | "(" {expr} ")".

Nodes are enclosed in parentheses. They start with a name, followed by an optional string, followed by optional properties and children. A property starts with a colon, followed by a name and its value. Such a value can be a name, a number, a string, or a list of such things.

A string is something in double quotes. A number is a name that can be parsed into an int or double and a name is a sequence of non-whitespace characters that is neither of the above. A sequence of expression in round parentheses is a List<Object?>. The name nil could be parsed as null, but I didn't need that so I didn't bother.

(foo "bar") is syntactic sugar for (foo :text bar).

A NodeParser for this grammar needs less than 100 lines of code. I'll omit the implementation for brevity. It is straight forward.

class NodeParser {
  NodeParser(this.input) : _tokens = ...;

  final String input;
  final Iterator<(String, int, int)> _tokens;

  List<Node> parse() { ... }
}

Textual UI descriptions

Here's a simple UI described with such an S-expression:

(column :cross-align center :spacing 16 :padding (all 16)
    (text "1" :color #ee6600)
    (button "Increment"))

This is same same as:

Node(
  'column',
  {
    'cross-align': 'center',
    'spacing': 16,
    'padding': ['all', 16],
  },
  [
    Node('text', {'text', '1', 'color': '#ee6600'}),
    Node('button', {'text': 'increment'},),
  ],
);

Building from nodes

Here is a build function (of type NodeWidgetBuilder) for each of the three node types used in the above example. They make use of a NodeBuilder object that provides a lot of helper methods to translate node properties. They return a configured Widget.

typedef NodeWidgetBuilder = Widget Function(NodeBuilder nb);

final registry = <String, NodeWidgetBuilder>{
  'column': (nb) => nb.withPadding(
    Column(
      crossAxisAlignment: nb.getCrossAlign() ?? CrossAxisAlignment.center,
      spacing: nb.getSpacing() ?? 0,
      children: nb.buildChildren(),
    ),
  ),
  'text': (nb) => nb.buildText()!,
  'button': (nb) => TextButton(
    onPressed: nb.getAction(),
    child: nb.withPadding(nb.buildText() ?? nb.buildChild()),
  ),
};

Here's an excerpt from the NodeBuilder:

class NodeBuilder {
  NodeBuilder(this.registry, this.node);

  final Map<String, NodeWidgetBuilder> registry;
  final Node node;

  Widget build() => (registry[node.name] ?? _error)(this);

  static Widget _error(NodeBuilder nb) {
    return ErrorWidget('no builder for ${nb.node.name}');
  }

  List<Widget> buildChildren() => node.children.map(buildChild).toList();

  Widget buildChild(Node node) => NodeBuilder(registry, node).build();

  Widget withPadding(Widget child) {
    final padding = node.props['padding'];
    if (padding == null) return child;
    return Padding(
      padding: switch (padding) {
        ['all', num all] => EdgeInsets.all(all.toDouble()),
        _ => throw Exception('invalid padding $padding'),
      },
      child: child,
    );
  }

  ...

  VoidCallback? getAction() => null;
}

Now use NodeParser('...').parse().single to convert the example into a Node. Then use NodeBuilder(registry, node).build() to create a hierarchy of widgets.

We should get a Flutter UI similar to this "image":

+---------------+
|               |
|       1       |
|               |
|  (increment)  |
|               |
+---------------+

That example is static, though. The button has no action yet.

Evaluating expressions

For maximum flexibility, I consider property values to be expressions for a tiny Lisp-like language that are evaluated in an enviroment that is a chain of maps that bind values to names:

class Env {
  Env(this.parent, this.values);

  final Env? parent;
  final Map<String, Object?> values;

  Object? eval(Object? expr) {
    ...
  }
}

An expr can be anything. Non-empty lists of values are considered function calls, though, which are looked up in the environment and called with the remaining list elements as unevaluated arguments. This way, special forms like (if (= (a) 1) …), which must not evaluate the arguments before we know whether the first argument is true or false, can be implemented the same way as normal functions like (+ 3 4). This is all text-book stuff.

class Env {
  ...

  Object? eval(Object? expr) {
    if (expr is! List || expr.isEmpty) return expr;
    final name = expr.first;
    final args = expr.sublist(1);
    for (Env? e = this; e != null; e = e.parent) {
      if (e.values.containsKey(name)) {
        final value = e.values[name];
        if (value is Proc) return value(env, args);
        if (args.isEmpty) return value;
        throw Exception('not callable');
      }
    }
    throw Exception('unbound name $name');
  }
}

typedef Proc = Object? Function(Env env, List<Object?> args);

You saw the complete interpreter!

But it needs builtins to become a useful tool.

static final standard = Env(null, {
  'do': form((env, args) => env.evalSeq(args)),
  'if': form((env, args) => env.eval(
    truthy(env.eval(args[0])) 
      ? args[1] 
      : args.elementAtOrNull(2),
  ),
  '=': proc((args) => args[0] == args[1]),
  '*': proc((args) => args.cast<num>().fold<num>(1, (a, b) => a * b)),
  '-': proc((args) {
    final n = args[0] as num;
    if (args.length == 1) return -n;
    return args.skip(1).cast<num>().fold<num>(n, (a, b) => a - b);
  }),
  'define': form((env, args) {
    switch (args[0]) {
      case List func:
        // (define (fac n) (if (= n 0) 1 (* n (fac (- n 1)))))
        final name = func[0] as String;
        final params = func.sublist(1).cast<String>();
        final body = args.skip(1);
        env.globals[name] = proc((args) {
          return env.create(Map.fromIterables(params, args)).evalSeq(body);
        });
        return name;
      case String name:
        // (define answer 42)
        env.globals[name] = env.eval(args[1]);
        return name;
      default:
        throw Exception('define: first argument must be string or list');
    }
  }),
});

static Proc form(Proc proc) => proc;

static Proc proc(Object? Function(List<Object?> args) fn) {
  return (env, args) => fn(env.evalLst(args));
}

static bool truthy(Object? value) => switch (value) {
  null || false => false,
  List list => list.isNotEmpty,
  _ => true,
};

The only complicated thing is define to create user-defined function. It adds a global variable that contains a Proc which implements the correct lexicographic binding for its parameters before evaluating the body.

This is enough to implement the famous factorial function:

(do
  (define (fac n) (if (= n 0) 1 (* n (fac (- n 1)))))
  (fac 10))

Because they way, my parser works, and because I need to convert all variable references to function call, here's the real code to run this:

void main() {
  final expr = NodeParser('''
  (eval :expr
    (do 
      (define (fac n) (if (= (n) 0) 
        1 
        (* (n) (fac (- (n) 1)))))
      (fac (n))))
  ''').parse().single.props['expr'];
  print(Env.standard.create({'n': 10}).eval(expr));
}

I left out error handling to keep it simple(r).

Evaluating UIs

Let's change the UI to make use of evaluatable properties:

(column :cross-align center :spacing 16 :padding (all 16)
    (text :bind count :color #ee6600)
    (button "Increment" :action increment))

In buildText(), I'll check for a :bind property (because of my design decision to make names and strings indistinguishable, I cannot use :text as it would assume a static text "count") and then evaluate a string as a variable and any list as a function call.

Widget buildText() {
  final text =
    stringProp(name) ??
    switch (node.props['bind']) {
      null => null,
      String bind => env.eval([bind])?.toString(),
      List bind => env.eval(bind)?.toString(),
      final bind => throw Exception('invalid bind: $bind'),
    };
  ...
}

In getAction I implement a similar approach:

VoidCallback? getAction() {
  return switch (node.props['action']) {
    null => null,
    String action => env.eval([action]) as VoidCallback?,
    List action => () => env.eval(action),
    final action => throw Exception('invalid action: $action'),
  };
}

Next, I create a stateful widget for a counter like so:

class Counter extends StatefulWidget {
  const Counter({super.key, required this.node});

  final Node node;

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return NodeBuilder(
      registry,
      Env.standard.create({
        'count': _count,
        'increment': () => setState(() => _count++),
      }),
      node,
    ).build();
  }
}

And we're done.

We could even add Counter to the registry:

registry['counter'] = (nb) => Counter(node: nb.node.children.single);

And then use:

(counter (column (text "count:") (text :bind count) (button "+" :action increment)))

Automatic notifiers

But this requires a special stateful widget.

This observe node can introduce (global) value notifiers.

(observe
  :values ((count 0))
  (column :cross-align center :spacing 16 :padding (all 16)
    (text :bind count)
    (button "Increment" :action (count (+ (count) 1)))))

It works with a generic NodeWidget.

It adds an observe handler that creates new ValueNotifiers if they don't exist already and hacks the Env to add a combined getter and setter so that (count) does a notifier.value if called without arguments and a notifier.value = arg[0] if called with one argument. It then adds a ListenableBuilder to the widget tree so that this part of the UI is rebuild if one of the listeners changes.

class NodeWidget extends StatefulWidget {
  const NodeWidget({super.key, required this.node});

  final Node node;

  @override
  State<NodeWidget> createState() => _NodeWidgetState();
}

class _NodeWidgetState extends State<NodeWidget> {
  final _notifiers = <String, ValueNotifier<Object?>>{};

  @override
  void initState() {
    super.initState();
    registry['observe'] = (nb) {
      final values = (nb.node.props['values'] as List).cast<String>();
      for (final value in values) {
        final notifier = _notifiers.putIfAbsent(
          value,
          () => ValueNotifier<Object?>(null),
        );
        nb.env.values[value] = Env.proc((args) {
          if (args.isEmpty) return notifier.value;
          if (args.length == 1) return notifier.value = args[0];
          throw Exception('Too many arguments');
        });
      }
      return ListenableBuilder(
        listenable: Listenable.merge(_notifiers.values),
        builder: (_, _) => nb.buildChild()!,
      );
    };
  }

  @override
  void dispose() {
    registry.remove('observe');
    for (var n in _notifiers.values) {
      n.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return NodeBuilder(registry, Env.standard, widget.node).build();
  }
}

User-defined widgets

We might want to define reusable widgets:

(widget (x-text :text "")
  (frame :padding (horizontal 16) :color #de87a8
    (text :bind text :font-size 80)))

And use them like any built-in node:

(column
  (x-text "big text"))

This would require a modification of the global registry variable which isn't the best design and instead, the NodeBuilder should probably use the Env to lookup handlers, but I don't want to go back and change everything. So, we'll have to live with it. Let's assume we has an initial UI description that is read from the server that defines all user-defined widgets.

for (final node in NodeParser(initialUI).parse()) {
  if (node.name != 'widget') throw ...;
  if (node.children != 2) throw ...;
  final [widget, body] = node.children;
  registry[widget.name] = (nb) {
    final env = nb.env.create(widget.props);
    for (final prop in nb.node.props.entries) {
      env.values[prop.key] = prop.value;
    }
    return NodeBuilder(registry, env, body).build();
  };
}

I haven't tried this, but I think, it will work.

And there you have it, a framework for dynamically creating Flutter UIs from textual description.

import 'dart:math';
/// An environment to evaluate Lisp-like expressions in.
///
/// Non-empty lists are evaluates as variables, functions, or special forms.
/// The first list element must be a string. Then all bound values are checked
/// from local to global. It is an error if no binding is found. If a value is
/// a [Proc], it is called with the **unevaluated** list of arguments.
/// Otherwise, no arguments must be provided.
class Env {
Env(this.parent, this.values);
final Env? parent;
final Map<String, Object?> values;
Map<String, Object?> get globals => parent?.globals ?? values;
/// Evaluates [expr].
Object? eval(Object? expr) {
if (expr is! List || expr.isEmpty) return expr;
if (expr[0] case String name) {
_name = name; // for error messages
final args = expr.length == 1
? const <Object?>[]
: expr.cast<Object?>().sublist(1);
for (Env? e = this; e != null; e = e.parent) {
if (e.values.containsKey(name)) {
final value = e.values[name];
if (value is Proc) return value(this, args);
if (args.isNotEmpty) throw Exception('$name is not a function');
return value;
}
}
throw Exception('unbound name $name');
}
throw Exception('first list element must be a string: $expr');
}
/// Evalues a list of [exprs], returning the last result or `null`.
Object? evalSeq(Iterable<Object?> exprs) {
// do not use `exprs.map(eval).last` because that will not work
Object? result;
for (final expr in exprs) {
result = eval(expr);
}
return result;
}
/// Evalutes a list of [exprs], returning a list of all results.
List<Object?> evalLst(List<Object?> exprs) {
return exprs.isEmpty ? const [] : [...exprs.map(eval)];
}
/// Returns a new environment inheriting from this one.
Env create(Map<String, Object?> values) => Env(this, values);
/// Returns whether [value] is considered to be "truthy."
static bool truthy(Object? value) => switch (value) {
null || false => false,
List list => list.isNotEmpty,
_ => true,
};
String _name = ''; // for better error messages
void checkArgs(List<Object?> args, int min, [int? max]) {
String a(int n) => '$n argument${n == 1 ? '' : 's'}';
if (args.length < min) {
if (max == min) {
throw Exception('$_name: takes exactly ${a(min)}, got ${args.length}');
}
throw Exception('$_name: needs at least ${a(min)}, got ${args.length}');
}
if (max != null && args.length > max) {
throw Exception('$_name: takes at most ${a(max)}, got ${args.length}');
}
}
/// Helper for type casting.
static Proc form(Proc proc) => proc;
/// Evaluates all arguments before calling [fn].
static Proc proc(Object? Function(List<Object?> args) fn) {
return (env, args) => fn(env.evalLst(args));
}
static final standard = Env(null, {
'do': form((env, args) => env.evalSeq(args)), // (do …) => ((fn () …))
'fn': form((env, args) {
// essential (fn (params…) body…)
env.checkArgs(args, 2);
final params = (args[0] as List).cast<String>();
final body = args.skip(1);
return proc(
(args) => env.create(Map.fromIterables(params, args)).evalSeq(body),
);
}),
'apply': form((env, args) {
// essential (apply proc args…)
return (env.eval(args[0]) as Proc)(env, args.sublist(1));
}),
'if': form((env, args) {
// essential (if cond then [else])
env.checkArgs(args, 2, 3);
return env.eval(
truthy(env.eval(args[0])) ? args[1] : args.elementAtOrNull(2),
);
}),
'=': proc((args) => args[0] == args[1]), // essential
'!=': proc((args) => args[0] != args[1]), // (! (= …))
'<': proc((args) => _compare(args[0], args[1]) < 0), // essential
'>': proc((args) => _compare(args[0], args[1]) > 0), // (& (!= …) (>= …))
'<=': proc((args) => _compare(args[0], args[1]) <= 0), // (! (> …))
'>=': proc((args) => _compare(args[0], args[1]) >= 0), // (! (< …))
'!': proc((args) => !truthy(args[0])), // (! …) => (if … false true)
'&': form((env, args) {
// (&) => true
// (& X …) => (if X (& …) false)
bool result = true;
for (final arg in args) {
result = result && truthy(env.eval(arg));
if (!result) break;
}
return result;
}),
'|': form((env, args) {
// (|) => false
// (| X …) (if X true (| …))
bool result = false;
for (final arg in args) {
result = result || truthy(env.eval(arg));
if (result) break;
}
return result;
}),
'+': proc((args) => args.cast<num>().fold<num>(0, (a, b) => a + b)),
'*': proc((args) => args.cast<num>().fold<num>(1, (a, b) => a * b)),
'-': proc((args) {
final n = args[0] as num;
if (args.length == 1) return -n;
return args.skip(1).cast<num>().fold<num>(n, (a, b) => a - b);
}),
'/': proc((args) {
final n = args[0] as num;
if (args.length == 1) return 1 / n;
return args.skip(1).cast<num>().fold<num>(n, (a, b) => a / b);
}),
'%': proc((args) => (args[0] as num) % (args[1] as num)),
'int': proc((args) => (args[0] as num).toInt()),
'dbl': proc((args) => (args[0] as num).toDouble()),
'str': proc((args) => args.join()),
'define': form((env, args) {
switch (args[0]) {
case List func:
// (define (fac n) (if (= n 0) 1 (* n (fac (- n 1)))))
final name = func[0] as String;
final params = func.sublist(1).cast<String>();
final body = args.skip(1);
env.globals[name] = proc((args) {
return env.create(Map.fromIterables(params, args)).evalSeq(body);
// return env
// .create({
// for (final (i, param) in params.indexed) param: args[i],
// })
// .evalSeq(body);
});
return name;
case String name:
// (define answer 42)
env.globals[name] = env.eval(args[1]);
return name;
default:
throw Exception('define: first argument must be string or list');
}
}),
'list': proc((args) => args),
'let': form((env, args) {
// (let () …) => (do …)
// (let ((X Y) …) …) => ((fn (X) (let (…) …)) Y)
env.checkArgs(args, 1);
final nenv = env.create({});
for (final [String name, Object? value] in args[0] as List) {
nenv.values[name] = nenv.eval(value);
}
return nenv.evalSeq(args.skip(1));
}),
'set!': form((env, args) {
// essential (set! answer 42)
env.checkArgs(args, 2);
final name = args[0] as String;
final value = env.eval(args[1]);
for (Env? e = env; e != null; e = e.parent) {
if (e.values.containsKey(name)) {
return e.values[name] = value;
}
}
return env.globals[name] = value;
}),
});
static int _compare(Object? a, Object? b) {
if (a == null) return b == null ? 0 : -1;
if (b == null) return 1;
if (a is num && b is num) return a.compareTo(b);
if (a is String && b is String) return a.compareTo(b);
if (a is DateTime && b is DateTime) return a.compareTo(b);
throw Exception('cannot compare $a and $b');
}
}
typedef Proc = Object? Function(Env, List<Object?>);
class Node {
const Node(
this.name, [
this.props = const {},
this.children = const [],
]);
final String name;
final Map<String, Object?> props;
final List<Node> children;
@override
String toString() =>
'($name${props.entries.map((e) => ' :${e.key} ${_q(e.value)}').join()}${children.map((c) => ' $c').join()})';
static String _q(Object? v) => switch (v) {
null => 'nil',
String v => '"$v"',
List v => '(${v.map(_q).join(' ')})',
Map v => '(${v.entries.map((e) => ':${e.key} ${_q(e.value)}').join(' ')})',
_ => '$v',
};
}
import 'package:flutter/material.dart';
import 'env.dart';
import 'node.dart';
typedef NodeWidgetBuilder = Widget Function(NodeBuilder);
final registry = <String, NodeWidgetBuilder>{
'column': (node) => node.withPadding(
Column(
crossAxisAlignment: node.getCrossAlign() ?? CrossAxisAlignment.center,
spacing: node.getSpacing() ?? 0,
children: node.buildChildren(),
),
),
'text': (node) => node.buildText() ?? Text(''),
'button': (node) => TextButton(
onPressed: node.getAction(),
child: node.buildChild() ?? SizedBox(),
),
};
class NodeBuilder {
NodeBuilder(this.registry, this.env, this.node);
final Map<String, NodeWidgetBuilder> registry;
final Env env;
final Node node;
Widget build() {
return (registry[node.name] ?? error)(this);
}
Widget error(NodeBuilder builder) {
return ErrorWidget('${builder.node.name} widget found');
}
NodeBuilder clone(Node node) {
return NodeBuilder(registry, env, node);
}
String? stringProp(String name) => node.props[name]?.toString();
int? intProp(String name) {
if (stringProp(name) case String s) return int.tryParse(s);
return null;
}
double? doubleProp(String name) {
if (stringProp(name) case String s) return double.tryParse(s);
return null;
}
/// Optionally wraps [child] with padding if it has been defined.
Widget withPadding(Widget child) {
final padding = getPadding();
if (padding == null) return child;
return Padding(padding: padding, child: child);
}
/// Returns a [Text] wiget with an optional style.
Text? buildText([String name = 'text']) {
final text =
stringProp(name) ??
switch (node.props['bind']) {
null => null,
String bind => env.eval([bind])?.toString(),
List bind => env.eval(bind)?.toString(),
final bind => throw Exception('invalid bind: $bind'),
};
if (text == null) return null;
final color = getColor();
final fontSize = doubleProp('font-size');
final style = color != null || fontSize != null
? TextStyle(color: color, fontSize: fontSize)
: null;
return Text(_stripQuotes(text), style: style);
}
String _stripQuotes(String s) =>
s.startsWith('"') && s.endsWith('"') ? s.substring(1, s.length - 1) : s;
Color? getColor([String name = 'color']) {
final color = stringProp(name);
return color == null || color.length != 9 || !color.startsWith('#')
? null
: Color(int.parse(color.substring(1), radix: 16));
}
/// return [Text] if there's `text` property or a row (the default) or
/// column of built children.
Widget? buildChild([Axis direction = Axis.horizontal]) {
final text = buildText();
if (text != null) return text;
if (node.children.isEmpty) return null;
if (node.children.length == 1) {
return clone(node.children.single).build();
}
return Flex(direction: direction, children: buildChildren());
}
/// Returns a list of built children
List<Widget> buildChildren() {
return node.children.map((child) => clone(child).build()).toList();
}
/// Returns `cross-align`.
CrossAxisAlignment? getCrossAlign() {
return switch (stringProp('cross-align')) {
'start' => CrossAxisAlignment.start,
'center' => CrossAxisAlignment.center,
'end' => CrossAxisAlignment.end,
_ => null,
};
}
/// Returns `spacing`.
double? getSpacing() => doubleProp('spacing');
/// Returns `padding`. Currently supports only `(all <number>)`.
EdgeInsets? getPadding() {
final child = node.props['padding'];
if (child == null) return null;
return switch (child) {
['all', num all] => EdgeInsets.all(all.toDouble()),
_ => null,
};
}
VoidCallback? getAction() {
return switch (node.props['action']) {
null => null,
String action => env.eval([action]) as VoidCallback?,
List action => () => env.eval(action),
final action => throw Exception('invalid action: $action'),
};
}
}
import 'node.dart';
/// Parses expressions using the following grammar:
///
/// ```
/// node = "(" name [string] {prop} {node} ")".
/// prop = ":" name expr.
/// expr = name | number | string | list.
/// list = "(" (":" [name expr {prop}] | {expr}) ")".
/// ```
class NodeParser {
NodeParser(this.input)
: _tokens = RegExp(r'(".*?")|([^\s()":]+)|[():]')
.allMatches(input)
.map(
(m) => (
m[0]!,
[if (m[2] != null) 1, if (m[1] != null) 2, 0].first,
m.start,
),
)
.followedBy([('', 0, input.length)])
.iterator;
final String input;
final Iterator<(String, int, int)> _tokens;
String get _token => _tokens.current.$1;
int get _type => _tokens.current.$2;
List<Node> parse() {
_tokens.moveNext();
final nodes = _parseNodes();
_expect('');
return nodes;
}
List<Node> _parseNodes() {
final nodes = <Node>[];
while (_token == '(') {
nodes.add(_parseNode());
}
return nodes;
}
Node _parseNode() {
_expect('(');
final name = _parseName();
final props = <String, Object>{};
if (_type == 2) {
props['text'] = _parseExpr();
}
while (_token == ':') {
_expect(':');
final name = _parseName();
props[name] = _parseExpr();
}
final children = _parseNodes();
final node = Node(name, props, children);
_expect(')');
return node;
}
String _parseName() {
if (_type != 1) _expected('name');
final name = _token;
_tokens.moveNext();
return name;
}
Object _parseExpr() {
if (_token == '(') {
_expect('(');
if (_token == ':') {
final map = <String, Object>{};
while (_token == ':') {
_expect(':');
final name = _parseName();
map[name] = _parseExpr();
}
_expect(')');
return map;
}
final exprs = <Object>[];
while (_token != ')') {
if (_token == '') _expected(')');
exprs.add(_parseExpr());
}
_expect(')');
return exprs;
}
final type = _type;
if (type != 1 && type != 2) _expected('name or string');
final expr = _token;
_tokens.moveNext();
return type == 2
? expr.substring(1, expr.length - 1)
: num.tryParse(expr) ?? expr;
}
void _expect(String token) {
if (_token != token) {
_expected(token);
}
_tokens.moveNext();
}
Never _expected(String token) {
String n(String t) => t == '' ? 'end of input' : t;
throw FormatException(
'expected ${n(token)}, got ${n(_token)}',
input,
_tokens.current.$3,
);
}
}
import 'package:flutter/material.dart';
import 'package:serverui/node_builder.dart';
import 'env.dart';
import 'node.dart';
class NodeWidget extends StatefulWidget {
const NodeWidget({super.key, required this.node});
final Node node;
@override
State<NodeWidget> createState() => _NodeWidgetState();
}
class _NodeWidgetState extends State<NodeWidget> {
final _notifiers = <String, ValueNotifier<Object?>>{};
@override
void initState() {
super.initState();
registry['observe'] = (nb) {
final pairs = (nb.node.props['values'] as List).cast<List<Object?>>();
for (final [name, value] in pairs) {
final notifier = _notifiers.putIfAbsent(
name as String,
() => ValueNotifier<Object?>(value),
);
nb.env.values[name] = Env.proc((args) {
if (args.isEmpty) return notifier.value;
if (args.length == 1) return notifier.value = args[0];
throw Exception('Too many arguments');
});
}
return ListenableBuilder(
listenable: Listenable.merge(_notifiers.values),
builder: (_, _) => nb.buildChild()!,
);
};
}
@override
void dispose() {
registry.remove('observe');
for (var n in _notifiers.values) {
n.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return NodeBuilder(registry, Env.standard, widget.node).build();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment