From df48bcb4cfe743a20400f79ae2617b80d49aed0b Mon Sep 17 00:00:00 2001 From: Rody Davis Date: Wed, 18 Sep 2024 23:53:29 -0400 Subject: [PATCH] Updating example and adding json import/export --- .../example/lib/main.dart | 330 +++++++++------ .../example/lib/nodes/variables.dart | 371 ----------------- .../example/lib/nodes/widgets.dart | 231 ----------- .../example/macos/Runner/AppDelegate.swift | 2 +- .../example/pubspec.lock | 24 +- .../lib/signals_node_based_editor.dart | 4 +- .../lib/src/graph.dart | 1 - .../lib/src/mixins/json_interop.dart | 176 ++++++++ .../lib/src/node.dart | 384 +++++++++++++++++- .../lib/src/widgets/node_widget_render.dart | 375 ----------------- .../signals_node_based_editor/pubspec.yaml | 1 + 11 files changed, 760 insertions(+), 1139 deletions(-) delete mode 100644 packages/signals_node_based_editor/example/lib/nodes/variables.dart delete mode 100644 packages/signals_node_based_editor/example/lib/nodes/widgets.dart create mode 100644 packages/signals_node_based_editor/lib/src/mixins/json_interop.dart delete mode 100644 packages/signals_node_based_editor/lib/src/widgets/node_widget_render.dart diff --git a/packages/signals_node_based_editor/example/lib/main.dart b/packages/signals_node_based_editor/example/lib/main.dart index d7309cd6..7bd3ade5 100644 --- a/packages/signals_node_based_editor/example/lib/main.dart +++ b/packages/signals_node_based_editor/example/lib/main.dart @@ -1,10 +1,10 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:signals_node_based_editor/signals_node_based_editor.dart'; import 'package:signals/signals_flutter.dart'; -import 'nodes/variables.dart'; -import 'nodes/widgets.dart'; - void main() { runApp(const App()); } @@ -29,7 +29,7 @@ class Example extends StatefulWidget { } class _ExampleState extends State { - final graph = Graph(); + final graph = GraphController(); @override Widget build(BuildContext context) { @@ -44,7 +44,7 @@ class _ExampleState extends State { onPressed: selected == null ? null : () { - if (selected is NodeSelection) { + if (selected is NodeSelection) { graph.removeNode(selected.node); } if (selected is ConnectorSelection) { @@ -54,48 +54,32 @@ class _ExampleState extends State { icon: const Icon(Icons.delete), ); }), - PopupMenuButton( - icon: const Icon(Icons.widgets), - itemBuilder: (context) => [ - PopupMenuItem( - child: const Text('Text'), - onTap: () => graph.nodes.add( - TextWidgetNode(data: 'Hello World!'), - ), - ), - PopupMenuItem( - child: const Text('SizedBox'), - onTap: () => graph.nodes.add( - SizedBoxNode(width: 100, height: 100), - ), - ), - PopupMenuItem( - child: const Text('Button'), - onTap: () => graph.nodes.add( - ButtonWidgetNode( - child: const Text('BUTTON'), - ), - ), - ), - PopupMenuItem( - child: const Text('Increment'), - onTap: () => graph.nodes.add( - IncrementNode(0), - ), - ), - PopupMenuItem( - child: const Text('Set Int'), - onTap: () => graph.nodes.add( - SetIntNode(0), - ), - ), - PopupMenuItem( - child: const Text('Sum Int'), - onTap: () => graph.nodes.add( - SumIntNode(1, 1), - ), - ), - ], + IconButton( + onPressed: () { + const encoder = JsonEncoder.withIndent(' '); + final str = encoder.convert(graph.toJson()); + debugPrint(str); + // Copy to clipboard + Clipboard.setData(ClipboardData(text: str)); + }, + icon: const Icon(Icons.copy), + ), + IconButton( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final data = await Clipboard.getData('text/plain'); + if (data != null) { + final json = jsonDecode(data.text!); + try { + graph.fromJson(json); + } on JsonInteropParseError catch (e) { + messenger.showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + } + }, + icon: const Icon(Icons.paste), ), PopupMenuButton( icon: const Icon(Icons.add), @@ -103,113 +87,37 @@ class _ExampleState extends State { PopupMenuItem( child: const Text('String'), onTap: () => graph.nodes.add( - StringVariableNode('Hello World'), + StringNode(data: 'Hello World'), ), ), PopupMenuItem( child: const Text('String?'), onTap: () => graph.nodes.add( - OptionalStringVariableNode('Hello World'), - ), - ), - PopupMenuItem( - child: const Text('int'), - onTap: () => graph.nodes.add( - IntVariableNode(0), - ), - ), - PopupMenuItem( - child: const Text('int?'), - onTap: () => graph.nodes.add( - OptionalIntVariableNode(0), - ), - ), - PopupMenuItem( - child: const Text('double'), - onTap: () => graph.nodes.add( - DoubleVariableNode(0), - ), - ), - PopupMenuItem( - child: const Text('double?'), - onTap: () => graph.nodes.add( - OptionalDoubleVariableNode(0), - ), - ), - PopupMenuItem( - child: const Text('num'), - onTap: () => graph.nodes.add( - NumVariableNode(0), - ), - ), - PopupMenuItem( - child: const Text('num?'), - onTap: () => graph.nodes.add( - OptionalNumVariableNode(0), + StringNode(data: '', optional: true), ), ), PopupMenuItem( child: const Text('bool'), onTap: () => graph.nodes.add( - BoolVariableNode(false), + BoolNode(data: false), ), ), PopupMenuItem( child: const Text('bool?'), onTap: () => graph.nodes.add( - OptionalBoolVariableNode(null), - ), - ), - PopupMenuItem( - child: const Text('void Function()'), - onTap: () => graph.nodes.add( - VoidFunctionVariableKnob(), + BoolNode(data: null, optional: true), ), ), - // PopupMenuItem( - // child: const Text('void Function()?'), - // onTap: () => graph.nodes.add( - // OptionalVoidFunctionVariableKnob(), - // ), - // ), PopupMenuItem( - child: const Text('ThemeMode'), - onTap: () => graph.nodes.add( - EnumVariableNode( - ThemeMode.system, - ThemeMode.values, - 'ThemeMode', - ), - ), - ), - PopupMenuItem( - child: const Text('ThemeMode?'), - onTap: () => graph.nodes.add( - OptionalEnumVariableNode( - null, - ThemeMode.values, - 'ThemeMode', - ), - ), - ), - PopupMenuItem( - child: const Text('Brightness'), + child: const Text('num'), onTap: () => graph.nodes.add( - EnumVariableNode( - Brightness.dark, - Brightness.values, - 'Brightness', - ), + NumNode(data: 0), ), ), PopupMenuItem( - child: const Text('Brightness?'), + child: const Text('num?'), onTap: () => graph.nodes.add( - OptionalEnumVariableNode( - null, - Brightness.values, - 'Brightness', - ), + NumNode(data: null, optional: true), ), ), ], @@ -222,3 +130,163 @@ class _ExampleState extends State { ); } } + +abstract class BaseKnob extends GraphNode { + @override + final String type$; + BaseKnob(this.type$); + + Map toJson(); +} + +class StringNode extends BaseKnob { + final Knob data; + final bool optional; + + StringNode({ + String? data, + this.optional = false, + }) : data = optional + ? OptionalStringKnob('data', data ?? '') + : StringKnob('data', data ?? ''), + super('String${optional ? '?' : ''}'); + + factory StringNode.fromJson(Map json, bool optional) { + return StringNode( + data: json['data'], + optional: optional, + ); + } + + @override + late Computed> inputs = computed(() { + return [ + ...super.inputs.value, + NodeWidgetInput(data, 'String', optional), + ]; + }); + + @override + late Computed> outputs = computed(() { + return [ + ...super.outputs.value, + NodeWidgetOutput('value', data.source, 'String', false), + ]; + }); + + @override + Map toJson() { + return { + 'type': type$, + 'data': data.value, + }; + } +} + +class BoolNode extends BaseKnob { + final Knob data; + final bool optional; + + BoolNode({ + bool? data, + this.optional = false, + }) : data = optional + ? OptionalBoolKnob('data', data ?? false) + : BoolKnob('data', data ?? false), + super('bool${optional ? '?' : ''}'); + + factory BoolNode.fromJson(Map json, bool optional) { + return BoolNode( + data: json['data'], + optional: optional, + ); + } + + @override + late Computed> inputs = computed(() { + return [ + ...super.inputs.value, + NodeWidgetInput(data, 'bool', optional), + ]; + }); + + @override + late Computed> outputs = computed(() { + return [ + ...super.outputs.value, + NodeWidgetOutput('value', data.source, 'bool', false), + ]; + }); + + @override + Map toJson() { + return { + 'type': type$, + 'data': data.value, + }; + } +} + +class NumNode extends BaseKnob { + final Knob data; + final bool optional; + + NumNode({ + num? data, + this.optional = false, + }) : data = optional + ? OptionalNumKnob('data', data ?? 0) + : NumKnob('data', data ?? 0), + super('num${optional ? '?' : ''}'); + + factory NumNode.fromJson(Map json, bool optional) { + return NumNode( + data: json['data'], + optional: optional, + ); + } + + @override + late Computed> inputs = computed(() { + return [ + ...super.inputs.value, + NodeWidgetInput(data, 'num', optional), + ]; + }); + + @override + late Computed> outputs = computed(() { + return [ + ...super.outputs.value, + NodeWidgetOutput('value', data.source, 'num', false), + ]; + }); + + @override + Map toJson() { + return { + 'type': type$, + 'data': data.value, + }; + } +} + +class GraphController extends Graph with JsonInteropMixin { + @override + Map json)> nodesMapper = { + 'String': (json) => StringNode.fromJson(json, false), + 'String?': (json) => StringNode.fromJson(json, true), + 'bool': (json) => BoolNode.fromJson(json, false), + 'bool?': (json) => BoolNode.fromJson(json, true), + 'num': (json) => NumNode.fromJson(json, false), + 'num?': (json) => NumNode.fromJson(json, true), + }; + + @override + Map nodeToJson(BaseKnob node) { + return { + ...super.nodeToJson(node), + ...node.toJson(), + }; + } +} diff --git a/packages/signals_node_based_editor/example/lib/nodes/variables.dart b/packages/signals_node_based_editor/example/lib/nodes/variables.dart deleted file mode 100644 index 5be02409..00000000 --- a/packages/signals_node_based_editor/example/lib/nodes/variables.dart +++ /dev/null @@ -1,371 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:signals_node_based_editor/signals_node_based_editor.dart'; -import 'package:signals/signals_flutter.dart'; - -abstract class ValueNode> extends GraphNode { - K get source; - String get type; - bool get optional; - - @override - String get type$ => 'value_$type${optional ? '_optional' : ''}_node'; - - @override - late final label$ = signal('Value ($type${optional ? '?' : ''})'); -} - -mixin ReadableNode> on ValueNode { - ReadonlySignal get result; - - @override - String get type$ => '${super.type$}_readable'; - - late ReadonlySignal getter = computed(result.get); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(source, type, optional), - ]; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - // NodeWidgetOutput(source.label, source.source, type, optional), - // NodeWidgetOutput('toString', source.toString$, 'String', false), - // NodeWidgetOutput('isNull', source.isNull$, 'bool', false), - NodeWidgetOutput('value', result, type, optional), - NodeWidgetOutput('get', getter, '$type Function()', optional), - ]; - }); -} - -mixin WriteableNodeNode> on ReadableNode { - @override - Signal get result; - - @override - String get type$ => '${super.type$}_writeable'; - - late ReadonlySignal setter = computed(() { - return this.result.set; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('set', setter, 'void Function($type)', optional), - ]; - }); -} - -class SetIntNode extends GraphNode { - final IntKnob getter; - final ObjectKnob setter; - - @override - String get type$ => 'set_int_knob'; - - @override - late final label$ = signal('Set Int'); - - SetIntNode(int val) - : getter = IntKnob('getter', val), - setter = ObjectKnob('setter', (val) { - print('set: $val'); - }); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(getter, 'int', false), - NodeWidgetInput(setter, 'void Function(int)', false), - ]; - }); - - late ReadonlySignal action = computed(() { - final setter = this.setter.value; - return () { - print('set $setter $getter'); - return setter(getter.value + 1); - }; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('action', action, 'void Function()', false), - ]; - }); -} - -class SumIntNode extends GraphNode { - final IntKnob a; - final IntKnob b; - - @override - String get type$ => 'sum_int_knob'; - - @override - late final label$ = signal('Sum Int'); - - SumIntNode(int a, int b) - : a = IntKnob('a', a), - b = IntKnob('b', b); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(a, 'int', false), - NodeWidgetInput(b, 'int', false), - ]; - }); - - late ReadonlySignal result = computed(() { - return a.value + b.value; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('Result', result, 'int', false), - ]; - }); -} - -class IncrementNode extends ValueNode - with ReadableNode, WriteableNodeNode { - @override - final IntKnob source; - - @override - final String type; - - @override - final bool optional; - - @override - late final label$ = signal('Increment'); - - IncrementNode(int val) - : source = IntKnob('Value', val), - result = signal(val), - type = 'int', - optional = false; - - @override - final Signal result; -} - -abstract class VariableNode> extends GraphNode { - final K source; - final String type; - final bool optional; - - VariableNode(this.source, this.type, this.optional); - - @override - String get type$ => 'variable_$type${optional ? '_optional' : ''}_node'; - - @override - late ReadonlySignal label$ = signal( - 'Variable ($type${optional ? '?' : ''})', - ); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(source, type, optional), - ]; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput(source.label, source.source, type, optional), - NodeWidgetOutput('toString', source.toString$, 'String', false), - NodeWidgetOutput('isNull', source.isNull$, 'bool', false), - ]; - }); -} - -class VoidFunctionVariableKnob - extends VariableNode { - VoidFunctionVariableKnob() - : super(VoidFunctionKnob('Value'), 'void Function()', false); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('calls', source.calls, 'int', false), - ]; - }); - - @override - Computed previewSize$ = - computed(() => const Size(double.infinity, 100)); - - @override - Widget preview(BuildContext context) { - return Center( - child: OutlinedButton( - onPressed: source.call, - child: const Text('Click'), - ), - ); - } -} - -// class OptionalVoidFunctionVariableKnob -// extends VariableNode { -// OptionalVoidFunctionVariableKnob() -// : super(OptionalVoidFunctionKnob('Value'), 'void Function()', true); - -// @override -// Computed previewSize = computed(() => const Size(double.infinity, 100)); - -// @override -// Widget preview(BuildContext context) { -// return Center( -// child: OutlinedButton( -// onPressed: source.call, -// child: const Text('Click'), -// ), -// ); -// } -// } - -class ObjectVariableNode extends VariableNode> { - ObjectVariableNode(Object val) - : super(ObjectKnob('Value', val), 'Object', false); -} - -class OptionalObjectVariableNode extends VariableNode> { - OptionalObjectVariableNode(Object? val) - : super(OptionalObjectKnob('Value', val), 'Object', true); -} - -class StringVariableNode extends VariableNode> { - StringVariableNode(String val) - : super(StringKnob('Value', val), 'String', false); -} - -class OptionalStringVariableNode extends VariableNode> { - OptionalStringVariableNode(String? val) - : super(OptionalStringKnob('Value', val), 'String', true); -} - -class BoolVariableNode extends VariableNode> { - BoolVariableNode(bool val) : super(BoolKnob('Value', val), 'bool', false); -} - -class OptionalBoolVariableNode extends VariableNode> { - OptionalBoolVariableNode(bool? val) - : super(OptionalBoolKnob('Value', val), 'bool', true); -} - -class NumVariableNode extends VariableNode> { - NumVariableNode(T val, {Knob? knob, String? type, bool? optional}) - : super(knob ?? NumKnob('Value', val), type ?? 'num', optional ?? false); - - late final toInt = computed(() => source.value.toInt()); - late final toDouble = computed(() => source.value.toDouble()); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('toInt', toInt, 'int', false), - NodeWidgetOutput('toDouble', toDouble, 'double', false), - ]; - }); -} - -class OptionalNumVariableNode - extends VariableNode> { - OptionalNumVariableNode(T? val, - {Knob? knob, String? type, bool? optional}) - : super(knob ?? OptionalNumKnob('Value', val), type ?? 'num', - optional ?? true); - - late final toInt = computed(() => source.value?.toInt()); - late final toDouble = computed(() => source.value?.toDouble()); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('toInt', toInt, 'int', true), - NodeWidgetOutput('toDouble', toDouble, 'double', true), - ]; - }); -} - -class IntVariableNode extends NumVariableNode { - IntVariableNode(super.val) - : super(knob: IntKnob('Value', val), type: 'int', optional: false); -} - -class OptionalIntVariableNode extends OptionalNumVariableNode { - OptionalIntVariableNode(super.val) - : super(knob: OptionalIntKnob('Value', val), type: 'int', optional: true); -} - -class DoubleVariableNode extends NumVariableNode { - DoubleVariableNode(super.val) - : super(knob: DoubleKnob('Value', val), type: 'double', optional: false); -} - -class OptionalDoubleVariableNode extends OptionalNumVariableNode { - OptionalDoubleVariableNode(super.val) - : super( - knob: OptionalDoubleKnob('Value', val), - type: 'double', - optional: true); -} - -class EnumVariableNode extends VariableNode> { - EnumVariableNode(T val, List values, String type) - : super(EnumKnob('Value', val, values), type, false); - - late final name = computed(() => source.value.name); - late final index = computed(() => source.value.index); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('name', name, 'String', false), - NodeWidgetOutput('index', index, 'int', false), - ]; - }); -} - -class OptionalEnumVariableNode - extends VariableNode> { - OptionalEnumVariableNode(T? val, List values, String type) - : super(OptionalEnumKnob('Value', val, values), type, true); - - late final name = computed(() => source.value?.name); - late final index = computed(() => source.value?.index); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('name', name, 'String', true), - NodeWidgetOutput('index', index, 'int', true), - ]; - }); -} diff --git a/packages/signals_node_based_editor/example/lib/nodes/widgets.dart b/packages/signals_node_based_editor/example/lib/nodes/widgets.dart deleted file mode 100644 index df70f769..00000000 --- a/packages/signals_node_based_editor/example/lib/nodes/widgets.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:signals_node_based_editor/signals_node_based_editor.dart'; -import 'package:signals/signals_flutter.dart'; - -abstract class WidgetNode extends GraphNode { - ReadonlySignal get child$; - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('widget', child$, 'Widget', false), - ]; - }); - - @override - late Computed previewSize$ = computed(() { - return const Size(200, 200); - }); - - @override - Widget preview(BuildContext context) { - return Watch( - (context) { - final colors = Theme.of(context).colorScheme; - return Container( - foregroundDecoration: BoxDecoration( - border: Border.all( - color: colors.outlineVariant, - ), - ), - child: Center( - child: child$.value, - ), - ); - }, - ); - } -} - -class WidgetKnob extends ObjectKnob { - WidgetKnob(super.label, super.source); -} - -class OptionalWidgetKnob extends OptionalObjectKnob { - OptionalWidgetKnob(super.label, super.source); -} - -class TextWidgetNode extends WidgetNode { - final StringKnob data; - final OptionalBoolKnob softWrap; - final OptionalIntKnob maxLines; - - TextWidgetNode({ - String? data, - bool? softWrap, - int? maxLines, - }) : data = StringKnob('data', data ?? ''), - softWrap = OptionalBoolKnob('softWrap', softWrap), - maxLines = OptionalIntKnob('maxLines', maxLines); - - @override - String get type$ => 'widget_text'; - - @override - late ReadonlySignal child$ = computed(() { - return Text( - data.value, - maxLines: maxLines.value, - softWrap: softWrap.value, - ); - }); - - @override - late ReadonlySignal label$ = signal('Text (Widget)'); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(data, 'String', false), - NodeWidgetInput(softWrap, 'bool', true), - NodeWidgetInput(maxLines, 'int', true), - ]; - }); -} - -class SizedBoxNode extends WidgetNode { - final OptionalWidgetKnob child; - final OptionalDoubleKnob width; - final OptionalDoubleKnob height; - - SizedBoxNode({ - Widget? child, - double? width, - double? height, - }) : child = OptionalWidgetKnob('child', child), - width = OptionalDoubleKnob('width', width), - height = OptionalDoubleKnob('height', height); - - @override - late Computed previewSize$ = computed(() { - return Size(width.value ?? 200, height.value ?? 200); - }); - - @override - late ReadonlySignal child$ = computed(() { - return SizedBox( - width: width.value, - height: height.value, - child: child.value, - ); - }); - - @override - String get type$ => 'widget_sized_box'; - - @override - late ReadonlySignal label$ = signal('SizedBox (Widget)'); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(child, 'Widget', true), - NodeWidgetInput(width, 'double', true), - NodeWidgetInput(height, 'double', true), - ]; - }); -} - -class ButtonWidgetNode extends WidgetNode { - final OptionalWidgetKnob child; - final ObjectKnob onPressed; - - ButtonWidgetNode({ - Widget? child, - }) : child = OptionalWidgetKnob('child', child), - onPressed = ObjectKnob('onPressed', () { - print('clicked!'); - }); - - @override - late ReadonlySignal child$ = computed(() { - return FilledButton( - onPressed: onPressed.source.value, - child: child.value, - ); - }); - - @override - String get type$ => 'button_node'; - - @override - late ReadonlySignal label$ = signal('Button (Widget)'); - - @override - late Computed> inputs = computed(() { - return [ - ...super.inputs.value, - NodeWidgetInput(child, 'Widget', false), - NodeWidgetInput(onPressed, 'void Function()', false), - ]; - }); - - @override - late Computed> outputs = computed(() { - return [ - ...super.outputs.value, - NodeWidgetOutput('onPressed', onPressed.source, 'void Function()', false), - ]; - }); -} - -// class IncrementNode extends GraphNode { -// final IntKnob amount; -// final IntKnob value; -// final VoidFunctionKnob action; - -// IncrementNode({ -// int? amount, -// int? value, -// }) : amount = IntKnob('amount', amount ?? 1), -// value = IntKnob('value', value ?? 0), -// action = VoidFunctionKnob('action'); - -// late final result = computed(() { -// action.value; -// final val = value.source.peek(); -// final amount = this.amount.value; -// return untracked(() => value.value = val + amount); -// }); - -// @override -// String get type$ => 'inc_node'; - -// @override -// late ReadonlySignal label$ = signal('Increment'); - -// // @override -// // Computed previewSize = computed(() => const Size(double.infinity, 100)); - -// // @override -// // Widget preview(BuildContext context) { -// // return Center( -// // child: OutlinedButton( -// // onPressed: result.recompute, -// // child: const Text('Increment'), -// // ), -// // ); -// // } - -// @override -// late Computed> inputs = computed(() { -// return [ -// ...super.inputs.value, -// NodeWidgetInput(amount, 'int', false), -// NodeWidgetInput(value, 'int', false), -// NodeWidgetInput(action, 'void Function()', false), -// ]; -// }); - -// @override -// late Computed> outputs = computed(() { -// return [ -// ...super.outputs.value, -// NodeWidgetOutput('result', result, 'int', false), -// NodeWidgetOutput('action', action.source, 'void Function()', false), -// ]; -// }); -// } diff --git a/packages/signals_node_based_editor/example/macos/Runner/AppDelegate.swift b/packages/signals_node_based_editor/example/macos/Runner/AppDelegate.swift index d53ef643..8e02df28 100644 --- a/packages/signals_node_based_editor/example/macos/Runner/AppDelegate.swift +++ b/packages/signals_node_based_editor/example/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true diff --git a/packages/signals_node_based_editor/example/pubspec.lock b/packages/signals_node_based_editor/example/pubspec.lock index fb4c8d4f..e90d684f 100644 --- a/packages/signals_node_based_editor/example/pubspec.lock +++ b/packages/signals_node_based_editor/example/pubspec.lock @@ -103,18 +103,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -151,18 +151,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -267,10 +267,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -299,10 +299,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.4" web: dependency: transitive description: diff --git a/packages/signals_node_based_editor/lib/signals_node_based_editor.dart b/packages/signals_node_based_editor/lib/signals_node_based_editor.dart index a09ff415..cccd26c5 100644 --- a/packages/signals_node_based_editor/lib/signals_node_based_editor.dart +++ b/packages/signals_node_based_editor/lib/signals_node_based_editor.dart @@ -4,8 +4,8 @@ export 'src/knobs.dart'; export 'src/graph.dart'; export 'src/node.dart'; export 'src/widgets/background_painter.dart'; -export 'src/widgets/node_widget_render.dart'; export 'src/widgets/graph_view.dart'; export 'src/widgets/graph_delegate.dart'; export 'src/widgets/foreground_painter.dart'; -export 'src/widgets/grid_background.dart'; \ No newline at end of file +export 'src/widgets/grid_background.dart'; +export 'src/mixins/json_interop.dart'; \ No newline at end of file diff --git a/packages/signals_node_based_editor/lib/src/graph.dart b/packages/signals_node_based_editor/lib/src/graph.dart index e287657e..0f1e759e 100644 --- a/packages/signals_node_based_editor/lib/src/graph.dart +++ b/packages/signals_node_based_editor/lib/src/graph.dart @@ -8,7 +8,6 @@ import 'knobs.dart'; import 'node.dart'; import 'utils/get_distance.dart'; import 'widgets/background_painter.dart'; -import 'widgets/node_widget_render.dart'; import 'package:graphs/graphs.dart' as graphs; typedef ConnectorInput = ({ diff --git a/packages/signals_node_based_editor/lib/src/mixins/json_interop.dart b/packages/signals_node_based_editor/lib/src/mixins/json_interop.dart new file mode 100644 index 00000000..33bf65b1 --- /dev/null +++ b/packages/signals_node_based_editor/lib/src/mixins/json_interop.dart @@ -0,0 +1,176 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:signals/signals.dart'; + +import '../graph.dart'; +import '../node.dart'; + +mixin JsonInteropMixin on Graph { + Map nodeToJson(Node node) { + return { + '@id': node.id, + '@type': node.type$, + '@label': node.label$.value, + '@collapsed': node.collapsed$.value, + '@offset': { + 'x': node.offset$.value.dx, + 'y': node.offset$.value.dy, + }, + }; + } + + Map json)> get nodesMapper; + + Node nodeFromJson(Map json) { + final type = json['@type']; + if (type == null) { + throw JsonInteropParseError('Missing node type'); + } + final node = nodesMapper[type]; + if (node == null) { + throw JsonInteropParseError('Unknown node type: $type'); + } + final val = node(json); + // Update core properties + if (json case {'@label': String label}) { + if (val.label$ is Signal) { + (val.label$ as Signal).value = label; + } + } + if (json case {'@collapsed': bool collapsed}) { + val.collapsed$.value = collapsed; + } + if (json case {'@offset': {'x': num dx, 'y': num dy}}) { + val.offset$.value = Offset( + dx.toDouble(), + dy.toDouble(), + ); + } + return val; + } + + Map toJson() { + return { + '@nodes': nodes.map(nodeToJson).toList(), + '@edges': [ + for (final connector in connectors.value) + { + 'source': { + 'id': connector.output.node.id, + 'label': connector.output.meta.port.label, + }, + 'target': { + 'id': connector.input.node.id, + 'label': connector.input.meta.port.label, + }, + } + ], + '@transform': jsonEncode(transform.value.storage.toList()), + '@date': DateTime.now().toIso8601String(), + }; + } + + (bool, String) validateJson(Map json) { + if (json['@nodes'] == null) { + return (false, 'Missing nodes'); + } + // Check if all nodes have a mapper type + final nodes = json['@nodes'] as List; + for (final node in nodes) { + if (node['@type'] == null) { + return (false, 'Missing node type'); + } + if (nodesMapper[node['@type']] == null) { + return (false, 'Unknown node type: ${node['@type']}'); + } + } + if (json['@edges'] == null) { + return (false, 'Missing edges'); + } + // Check for missing nodes + final edges = json['@edges'] as List; + for (final edge in edges) { + final source = edge['source']; + final target = edge['target']; + if (source == null) { + return (false, 'Missing source node'); + } + if (target == null) { + return (false, 'Missing target node'); + } + + final sourceId = source['id']; + final sourceLabel = source['label']; + final targetId = target['id']; + final targetLabel = target['label']; + if (sourceId == null) { + return (false, 'Missing source node id'); + } + if (sourceLabel == null) { + return (false, 'Missing source node label'); + } + if (targetId == null) { + return (false, 'Missing target node id'); + } + if (targetLabel == null) { + return (false, 'Missing target node label'); + } + } + return (true, ''); + } + + void fromJson(Map json) { + final (valid, msg) = validateJson(json); + if (!valid) { + throw JsonInteropParseError('Invalid JSON: $msg'); + } + + final localNodes = {}; + for (final node in json['@nodes'] as List) { + final id = node['@id']; + final n = nodeFromJson(node); + localNodes[id] = n; + } + for (final edge in json['@edges'] as List) { + final source = edge['source']; + final target = edge['target']; + final sourceNode = localNodes[source['id']]; + final targetNode = localNodes[target['id']]; + if (sourceNode == null) { + throw JsonInteropParseError('Missing source node: ${source['id']}'); + } + if (targetNode == null) { + throw JsonInteropParseError('Missing target node: ${target['id']}'); + } + final sourcePort = sourceNode.outputs.value.firstWhereOrNull( + (port) => port.label == source['label'], + ); + final targetPort = targetNode.inputs.value.firstWhereOrNull( + (port) => port.label == target['label'], + ); + if (sourcePort == null) { + throw JsonInteropParseError('Missing source port: ${source['label']}'); + } + if (targetPort == null) { + throw JsonInteropParseError('Missing target port: ${target['label']}'); + } + targetPort.knob.source = sourcePort.source; + } + batch(() { + nodes.value = localNodes.values.toList(); + // @transform + if (json case {'@transform': String transformJson}) { + final list = (jsonDecode(transformJson) as List).cast(); + transform.value = Matrix4.fromList(list); + } + }); + } +} + +class JsonInteropParseError extends Error { + final String message; + JsonInteropParseError(this.message); +} diff --git a/packages/signals_node_based_editor/lib/src/node.dart b/packages/signals_node_based_editor/lib/src/node.dart index 699bd473..7deb1258 100644 --- a/packages/signals_node_based_editor/lib/src/node.dart +++ b/packages/signals_node_based_editor/lib/src/node.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; -import 'widgets/node_widget_render.dart'; +import 'graph.dart'; +import 'knobs.dart'; +import 'widgets/actions.dart'; typedef NodePort = ({ String label, @@ -11,7 +13,7 @@ typedef NodePort = ({ }); // TODO: Json Schema -abstract class GraphNode extends NodeWidgetRender { +abstract class GraphNode { final offset$ = signal(Offset.zero); late final String id = 'graph_node_${label$.globalId}'; @@ -35,21 +37,349 @@ abstract class GraphNode extends NodeWidgetRender { @override String toString() => label$(); - // Could have been a signal - Map toJson() { - return { - '@id': id, - '@type': type$, - '@label': label$.value, - '@collapsed': collapsed$.value, - '@offset': { - 'x': offset$.value.dx, - 'y': offset$.value.dy, - }, - }; + String get type$; + + final Signal collapsed$ = signal(false); + late final ReadonlySignal label$ = signal(type$); + + List actions$(BuildContext context) { + return []; } - String get type$; + Widget preview(BuildContext context) { + return const SizedBox.shrink(); + } + + static const headerHeight = 30.0; + static const portHeight = 40.0; + static const portWidth = 8.0; + static const nodeWidth = 250.0; + static const portPadding = 4.0; + static const previewSizeFallback = Size(nodeWidth, 100); + static const borderRadius = 12.0; + + final Computed> inputs = computed(() => []); + + final Computed> outputs = computed(() => []); + + late Computed previewSize$ = computed(() { + return const Size(nodeWidth, 0); + }); + + late Computed preferredSize$ = computed(() { + var size = const Size(nodeWidth, headerHeight); + if (collapsed$.value) return size; + if (hasPreview) { + size = Size( + size.width, + size.height + previewSize$.value.height, + ); + } + final count = inputs.value.length + outputs.value.length; + size = Size( + size.width, + size.height + (count * portHeight), + ); + size = Size(size.width, size.height + (portPadding * 2)); + return size; + }); + + bool get hasPreview => previewSize$.value.height > 0; + + late Computed>> outputsMetadata = + computed(() { + final results = >[]; + var top = headerHeight + previewSize$.value.height + portPadding; + for (var i = 0; i < outputs.value.length; i++) { + final connector = Offset( + collapsed$.value ? nodeWidth + -portWidth : nodeWidth, + collapsed$.value ? 0 : top + i * portHeight, + ) & + const Size(portWidth, portHeight); + final control = Offset( + 0, + collapsed$.value ? 0 : top + i * portHeight, + ) & + const Size(nodeWidth, portHeight); + results.add(( + connector: connector, + control: control, + port: outputs.value.elementAt(i), + )); + } + return results; + }); + + late Computed>> inputsMetadata = + computed(() { + final results = >[]; + final top = headerHeight + + previewSize$.value.height + + portPadding + + (outputs.value.length * portHeight); + for (var i = 0; i < inputs.value.length; i++) { + final connector = Offset( + collapsed$.value ? 0 : -portWidth, + collapsed$.value ? 0 : top + i * portHeight, + ) & + const Size(portWidth, portHeight); + final control = Offset( + 0, + collapsed$.value ? 0 : top + i * portHeight, + ) & + const Size(nodeWidth, portHeight); + results.add(( + connector: connector, + control: control, + port: inputs.value.elementAt(i), + )); + } + return results; + }); + + Rect get headerRect => Offset.zero & const Size(nodeWidth, headerHeight); + + late Computed previewRect = computed(() { + return const Offset(0, headerHeight) & + Size( + nodeWidth, + previewSize$.value.height, + ); + }); + + Widget build(BuildContext context, GraphNode node, Graph graph) { + return Watch((context) { + final selected = graph // + .selection + .any((e) => e is NodeSelection && e.node == node); + final colors = Theme.of(context).colorScheme; + return SizedBox.fromSize( + size: preferredSize$.value, + child: Container( + decoration: BoxDecoration( + color: colors.surface, + ), + foregroundDecoration: BoxDecoration( + border: Border.all( + color: selected ? colors.primary : colors.outlineVariant, + ), + borderRadius: BorderRadius.circular(borderRadius), + ), + child: () { + final Widget child = Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fromRect( + rect: headerRect, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(borderRadius), + topRight: const Radius.circular(borderRadius), + bottomLeft: collapsed$.value + ? const Radius.circular(borderRadius) + : Radius.zero, + bottomRight: collapsed$.value + ? const Radius.circular(borderRadius) + : Radius.zero, + ), + child: Container( + height: headerHeight, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 4), + GestureDetector( + onTap: () => collapsed$.value = !collapsed$.value, + child: RotatedBox( + quarterTurns: collapsed$.value ? 2 : 0, + child: const Icon(Icons.arrow_drop_down), + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + label$.value, + style: const TextStyle( + fontSize: 14, + ), + ), + ), + ...actions$(context), + if (label$ is Signal) ...[ + const SizedBox(width: 4), + GestureDetector( + child: const Icon(Icons.edit, size: 16), + onTap: () async { + final result = await prompt( + context, + title: 'Edit Label', + value: label$.value, + ); + if (result == null) return; + (label$ as Signal).value = result; + }, + ), + ], + const SizedBox(width: 4), + ], + ), + ), + ), + ), + ), + if (!collapsed$.value) ...[ + if (hasPreview) + Positioned.fromRect( + rect: previewRect.value, + child: Padding( + padding: const EdgeInsets.all(4), + child: Center( + child: SizedBox.fromSize( + size: previewSize$.value, + child: preview(context), + ), + ), + ), + ), + for (final item in outputsMetadata.value) ...[ + Positioned.fromRect( + rect: item.connector, + child: buildPort(context, item.port), + ), + Positioned.fromRect( + rect: item.control, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: portPadding, + horizontal: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: buildLabel( + context, + item.port.label, + textAlign: TextAlign.right, + type: item.port.type, + optional: item.port.optional, + ), + ), + ], + ), + ), + ), + ], + for (final item in inputsMetadata.value) ...[ + Positioned.fromRect( + rect: item.connector, + child: buildPort(context, item.port), + ), + Positioned.fromRect( + rect: item.control, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: portPadding, + horizontal: 10, + ), + child: Theme( + data: Theme.of(context).copyWith( + inputDecorationTheme: const InputDecorationTheme( + contentPadding: EdgeInsets.zero, + isDense: true, + filled: true, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buildLabel( + context, + item.port.knob.label, + textAlign: TextAlign.left, + type: item.port.type, + optional: item.port.optional, + ), + const SizedBox(width: 8), + Expanded( + child: Container( + alignment: Alignment.centerRight, + child: item.port.knob.render(), + ), + ), + ], + ), + ), + ), + ), + ], + ], + ], + ); + // if (selected) { + // return ContextMenu( + // menu: [ + // MenuEntry( + // label: 'Test', + // ), + // ], + // child: child, + // ); + // } + return child; + }(), + ), + ); + }); + } + + Widget buildLabel( + BuildContext context, + String label, { + required String type, + required bool optional, + TextAlign textAlign = TextAlign.left, + }) { + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 100, + minWidth: 50, + ), + child: Tooltip( + message: () { + var msg = label; + msg += ' (${type.replaceAll('img.', '')}'; + if (optional) msg += '?'; + msg += ')'; + return msg; + }(), + child: Text( + label, + textAlign: textAlign, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } + + Widget buildPort(BuildContext context, NodeWidgetPort port) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Container( + color: colors.secondary, + width: double.infinity, + height: double.infinity, + ), + ); + } } class FallbackNode extends GraphNode { @@ -62,3 +392,27 @@ class FallbackNode extends GraphNode { @override ReadonlySignal get label$ => signal('Fallback'); } + +typedef PortMetadata = ({ + Rect connector, + Rect control, + T port, +}); + +sealed class NodeWidgetPort { + final String label; + final String type; + final bool optional; + NodeWidgetPort(this.label, this.type, this.optional); +} + +class NodeWidgetInput extends NodeWidgetPort { + final Knob knob; + NodeWidgetInput(this.knob, String type, bool optional) + : super(knob.label, type, optional); +} + +class NodeWidgetOutput extends NodeWidgetPort { + final ReadonlySignal source; + NodeWidgetOutput(super.label, this.source, super.type, super.optional); +} diff --git a/packages/signals_node_based_editor/lib/src/widgets/node_widget_render.dart b/packages/signals_node_based_editor/lib/src/widgets/node_widget_render.dart deleted file mode 100644 index c26cf890..00000000 --- a/packages/signals_node_based_editor/lib/src/widgets/node_widget_render.dart +++ /dev/null @@ -1,375 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart'; - -import '../../src/graph.dart'; -import '../node.dart'; -import '../knobs.dart'; -import 'actions.dart'; - -class NodeWidgetRender { - final Signal collapsed$ = signal(false); - final ReadonlySignal label$ = signal(''); - - List actions$(BuildContext context) { - return []; - } - - Widget preview(BuildContext context) { - return const SizedBox.shrink(); - } - - static const headerHeight = 30.0; - static const portHeight = 40.0; - static const portWidth = 8.0; - static const nodeWidth = 250.0; - static const portPadding = 4.0; - static const previewSizeFallback = Size(nodeWidth, 100); - static const borderRadius = 12.0; - - final Computed> inputs = computed(() => []); - - final Computed> outputs = computed(() => []); - - late Computed previewSize$ = computed(() { - return const Size(nodeWidth, 0); - }); - - late Computed preferredSize$ = computed(() { - var size = const Size(nodeWidth, headerHeight); - if (collapsed$.value) return size; - if (hasPreview) { - size = Size( - size.width, - size.height + previewSize$.value.height, - ); - } - final count = inputs.value.length + outputs.value.length; - size = Size( - size.width, - size.height + (count * portHeight), - ); - size = Size(size.width, size.height + (portPadding * 2)); - return size; - }); - - bool get hasPreview => previewSize$.value.height > 0; - - late Computed>> outputsMetadata = - computed(() { - final results = >[]; - var top = headerHeight + previewSize$.value.height + portPadding; - for (var i = 0; i < outputs.value.length; i++) { - final connector = Offset( - collapsed$.value ? nodeWidth + -portWidth : nodeWidth, - collapsed$.value ? 0 : top + i * portHeight, - ) & - const Size(portWidth, portHeight); - final control = Offset( - 0, - collapsed$.value ? 0 : top + i * portHeight, - ) & - const Size(nodeWidth, portHeight); - results.add(( - connector: connector, - control: control, - port: outputs.value.elementAt(i), - )); - } - return results; - }); - - late Computed>> inputsMetadata = - computed(() { - final results = >[]; - final top = headerHeight + - previewSize$.value.height + - portPadding + - (outputs.value.length * portHeight); - for (var i = 0; i < inputs.value.length; i++) { - final connector = Offset( - collapsed$.value ? 0 : -portWidth, - collapsed$.value ? 0 : top + i * portHeight, - ) & - const Size(portWidth, portHeight); - final control = Offset( - 0, - collapsed$.value ? 0 : top + i * portHeight, - ) & - const Size(nodeWidth, portHeight); - results.add(( - connector: connector, - control: control, - port: inputs.value.elementAt(i), - )); - } - return results; - }); - - Rect get headerRect => Offset.zero & const Size(nodeWidth, headerHeight); - - late Computed previewRect = computed(() { - return const Offset(0, headerHeight) & - Size( - nodeWidth, - previewSize$.value.height, - ); - }); - - Widget build(BuildContext context, GraphNode node, Graph graph) { - return Watch((context) { - final selected = graph // - .selection - .any((e) => e is NodeSelection && e.node == node); - final colors = Theme.of(context).colorScheme; - return SizedBox.fromSize( - size: preferredSize$.value, - child: Container( - decoration: BoxDecoration( - color: colors.surface, - ), - foregroundDecoration: BoxDecoration( - border: Border.all( - color: selected ? colors.primary : colors.outlineVariant, - ), - borderRadius: BorderRadius.circular(borderRadius), - ), - child: () { - final Widget child = Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fromRect( - rect: headerRect, - child: ClipRRect( - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(borderRadius), - topRight: const Radius.circular(borderRadius), - bottomLeft: collapsed$.value - ? const Radius.circular(borderRadius) - : Radius.zero, - bottomRight: collapsed$.value - ? const Radius.circular(borderRadius) - : Radius.zero, - ), - child: Container( - height: headerHeight, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(width: 4), - GestureDetector( - onTap: () => collapsed$.value = !collapsed$.value, - child: RotatedBox( - quarterTurns: collapsed$.value ? 2 : 0, - child: const Icon(Icons.arrow_drop_down), - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - label$.value, - style: const TextStyle( - fontSize: 14, - ), - ), - ), - ...actions$(context), - if (label$ is Signal) ...[ - const SizedBox(width: 4), - GestureDetector( - child: const Icon(Icons.edit, size: 16), - onTap: () async { - final result = await prompt( - context, - title: 'Edit Label', - value: label$.value, - ); - if (result == null) return; - (label$ as Signal).value = result; - }, - ), - ], - const SizedBox(width: 4), - ], - ), - ), - ), - ), - ), - if (!collapsed$.value) ...[ - if (hasPreview) - Positioned.fromRect( - rect: previewRect.value, - child: Padding( - padding: const EdgeInsets.all(4), - child: Center( - child: SizedBox.fromSize( - size: previewSize$.value, - child: preview(context), - ), - ), - ), - ), - for (final item in outputsMetadata.value) ...[ - Positioned.fromRect( - rect: item.connector, - child: buildPort(context, item.port), - ), - Positioned.fromRect( - rect: item.control, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: portPadding, - horizontal: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: buildLabel( - context, - item.port.label, - textAlign: TextAlign.right, - type: item.port.type, - optional: item.port.optional, - ), - ), - ], - ), - ), - ), - ], - for (final item in inputsMetadata.value) ...[ - Positioned.fromRect( - rect: item.connector, - child: buildPort(context, item.port), - ), - Positioned.fromRect( - rect: item.control, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: portPadding, - horizontal: 10, - ), - child: Theme( - data: Theme.of(context).copyWith( - inputDecorationTheme: const InputDecorationTheme( - contentPadding: EdgeInsets.zero, - isDense: true, - filled: true, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - buildLabel( - context, - item.port.knob.label, - textAlign: TextAlign.left, - type: item.port.type, - optional: item.port.optional, - ), - const SizedBox(width: 8), - Expanded( - child: Container( - alignment: Alignment.centerRight, - child: item.port.knob.render(), - ), - ), - ], - ), - ), - ), - ), - ], - ], - ], - ); - // if (selected) { - // return ContextMenu( - // menu: [ - // MenuEntry( - // label: 'Test', - // ), - // ], - // child: child, - // ); - // } - return child; - }(), - ), - ); - }); - } - - Widget buildLabel( - BuildContext context, - String label, { - required String type, - required bool optional, - TextAlign textAlign = TextAlign.left, - }) { - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 100, - minWidth: 50, - ), - child: Tooltip( - message: () { - var msg = label; - msg += ' (${type.replaceAll('img.', '')}'; - if (optional) msg += '?'; - msg += ')'; - return msg; - }(), - child: Text( - label, - textAlign: textAlign, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ); - } - - Widget buildPort(BuildContext context, NodeWidgetPort port) { - final colors = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Container( - color: colors.secondary, - width: double.infinity, - height: double.infinity, - ), - ); - } -} - -typedef PortMetadata = ({ - Rect connector, - Rect control, - T port, -}); - -sealed class NodeWidgetPort { - final String label; - final String type; - final bool optional; - NodeWidgetPort(this.label, this.type, this.optional); -} - -class NodeWidgetInput extends NodeWidgetPort { - final Knob knob; - NodeWidgetInput(this.knob, String type, bool optional) - : super(knob.label, type, optional); -} - -class NodeWidgetOutput extends NodeWidgetPort { - final ReadonlySignal source; - NodeWidgetOutput(super.label, this.source, super.type, super.optional); -} diff --git a/packages/signals_node_based_editor/pubspec.yaml b/packages/signals_node_based_editor/pubspec.yaml index 7411a3a1..003e5753 100644 --- a/packages/signals_node_based_editor/pubspec.yaml +++ b/packages/signals_node_based_editor/pubspec.yaml @@ -7,6 +7,7 @@ environment: flutter: ">=1.17.0" dependencies: + collection: ^1.18.0 flutter: sdk: flutter graphs: ^2.3.2