Last active
          May 4, 2025 09:33 
        
      - 
      
- 
        Save sma/a6bd2513c010728585f096baf4b59b02 to your computer and use it in GitHub Desktop. 
Revisions
- 
        sma revised this gist May 4, 2025 . 1 changed file with 7 additions and 7 deletions.There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -126,13 +126,13 @@ Now use `NodeParser('...').parse().single` to convert the example into a `Node`. We should get a Flutter UI similar to this "image": +---------------+ | | | 1 | | | | (increment) | | | +---------------+ That example is static, though. The button has no action yet. 
- 
        sma created this gist May 4, 2025 .There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,422 @@ # 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 `ValueNotifier`s 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. This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,212 @@ 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?>); This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,23 @@ 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', }; } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,140 @@ 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'), }; } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,116 @@ 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, ); } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,55 @@ 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(); } }