Last active
January 3, 2024 01:35
-
-
Save rodydavis/e7c6f50331ba934a1fbf37f2be31f2a5 to your computer and use it in GitHub Desktop.
This file contains 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 characters
import 'package:flutter/material.dart'; | |
import 'package:signals/signals_flutter.dart'; | |
class Editor extends StatefulWidget { | |
const Editor({super.key}); | |
@override | |
State<Editor> createState() => _EditorState(); | |
} | |
class _EditorState extends State<Editor> { | |
final nodes = mapSignal<Node, Offset>({}); | |
final selection = setSignal<Node>({}); | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Editor'), | |
centerTitle: false, | |
), | |
body: Watch.builder( | |
builder: (context) { | |
return Stack( | |
children: [ | |
Positioned.fill( | |
child: SizedBox.expand( | |
child: CustomPaint( | |
painter: NodeEdges(nodes, selection), | |
child: CustomMultiChildLayout( | |
delegate: NodeLayout(nodes), | |
children: [ | |
for (final node in nodes.keys) | |
LayoutId( | |
id: node.id, | |
child: Container( | |
decoration: BoxDecoration( | |
color: Colors.grey.shade300, | |
border: Border.all( | |
color: selection.contains(node) | |
? Colors.lightBlue | |
: Colors.grey, | |
), | |
borderRadius: BorderRadius.circular(10), | |
), | |
child: GestureDetector( | |
behavior: HitTestBehavior.opaque, | |
onPanUpdate: (details) { | |
nodes[node] = nodes[node]! + details.delta; | |
}, | |
onTap: () { | |
if (selection.contains(node)) { | |
selection.remove(node); | |
} else { | |
selection.add(node); | |
} | |
}, | |
child: Stack( | |
children: [ | |
Positioned.fill(child: node.build()), | |
Positioned( | |
top: 5, | |
left: 5, | |
child: Text(node.name), | |
), | |
], | |
), | |
), | |
), | |
), | |
], | |
), | |
), | |
), | |
), | |
Positioned( | |
top: 10, | |
left: 10, | |
child: Text('Nodes: ${nodes.length}'), | |
), | |
Positioned( | |
bottom: 10, | |
left: 10, | |
child: Column( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
FilledButton( | |
child: const Text('Add Number'), | |
onPressed: () => nodes[NumberNode(0)] = Offset.zero, | |
), | |
if (selection.length > 1 && | |
selection | |
.toList() | |
.every((e) => e is Node<dynamic, num>)) ...[ | |
const SizedBox(height: 10), | |
FilledButton( | |
child: const Text('Create Reducer'), | |
onPressed: () { | |
nodes[ReduceNode( | |
Reducer.add, | |
selection.toList().cast<Node<dynamic, num>>(), | |
)] = Offset.zero; | |
selection.clear(); | |
}, | |
), | |
if (selection.length == 2) ...[ | |
const SizedBox(height: 10), | |
FilledButton( | |
child: const Text('Create If'), | |
onPressed: () { | |
nodes[IfNode( | |
Operator.equalTo, | |
selection.first as Node<dynamic, num>, | |
selection.last as Node<dynamic, num>, | |
)] = Offset.zero; | |
selection.clear(); | |
}, | |
), | |
], | |
], | |
], | |
), | |
), | |
], | |
); | |
}, | |
), | |
); | |
} | |
} | |
class NodeLayout extends MultiChildLayoutDelegate { | |
final Map<Node, Offset> nodes; | |
NodeLayout(this.nodes); | |
@override | |
void performLayout(Size size) { | |
for (final nodeEntry in nodes.entries) { | |
final nodeSize = BoxConstraints.tight(nodeEntry.key.size()); | |
layoutChild(nodeEntry.key.id, nodeSize); | |
positionChild(nodeEntry.key.id, nodeEntry.value); | |
} | |
} | |
@override | |
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) { | |
return true; | |
} | |
} | |
class NodeEdges extends CustomPainter { | |
final Map<Node, Offset> nodes; | |
final Set<Node> selected; | |
NodeEdges(this.nodes, this.selected); | |
@override | |
void paint(Canvas canvas, Size size) { | |
for (final nodeEntry in nodes.entries) { | |
final source = nodeEntry.value; | |
final sourceSize = nodeEntry.key.size(); | |
final sourceOffset = source.translate( | |
sourceSize.width / 2, | |
sourceSize.height / 2, | |
); | |
if (nodeEntry.key.inputs.isNotEmpty) { | |
final paint = Paint() | |
..color = selected.contains(nodeEntry.key) ? Colors.blue : Colors.grey | |
..strokeWidth = 1; | |
for (final input in nodeEntry.key.inputs) { | |
final target = nodes[input]!; | |
final targetSize = input.size(); | |
final targetOffset = target.translate( | |
targetSize.width / 2, | |
targetSize.height / 2, | |
); | |
canvas.drawLine(sourceOffset, targetOffset, paint); | |
} | |
} | |
} | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) { | |
return true; | |
} | |
} | |
abstract class Node<I, O> { | |
final String name; | |
final List<Node<dynamic, I>> inputs; | |
int get id => output.globalId; | |
Node({ | |
required this.name, | |
required this.inputs, | |
}); | |
ReadonlySignal<O> get output; | |
Widget build(); | |
Size size(); | |
} | |
class ReduceNode<T extends num> extends Node<T, T> { | |
@override | |
late final Computed<T> output; | |
late final Signal<Reducer> current; | |
ReduceNode(Reducer currentReducer, List<Node<dynamic, T>> inputs) | |
: current = signal(currentReducer), | |
super(name: 'Reducer', inputs: inputs) { | |
output = computed(() { | |
final source = inputs.map((e) => e.output()).toList(); | |
return source.reduce((a, b) { | |
return switch (current.value) { | |
Reducer.add => a + b, | |
Reducer.subtract => a - b, | |
Reducer.divide => a / b, | |
Reducer.multiply => a * b, | |
} as T; | |
}); | |
}); | |
} | |
@override | |
Widget build() => Stack( | |
children: [ | |
Positioned( | |
top: 5, | |
right: 5, | |
width: 80, | |
child: Column( | |
children: [ | |
for (var i = 0; i < inputs.length; i++) | |
Row( | |
children: [ | |
if (i == inputs.length - 1) | |
Text( | |
switch (current.value) { | |
Reducer.add => '+ ', | |
Reducer.subtract => '- ', | |
Reducer.divide => '/ ', | |
Reducer.multiply => '* ', | |
}, | |
textAlign: TextAlign.left, | |
), | |
Expanded( | |
child: Text( | |
inputs[i].output.toString(), | |
textAlign: TextAlign.right, | |
), | |
), | |
], | |
), | |
const Divider(), | |
Row( | |
children: [ | |
Expanded( | |
child: Text( | |
output.toString(), | |
textAlign: TextAlign.right, | |
), | |
), | |
], | |
), | |
], | |
), | |
), | |
Positioned( | |
bottom: 5, | |
right: 5, | |
left: 5, | |
child: DropdownButton( | |
isDense: true, | |
items: Reducer.values | |
.map((e) => DropdownMenuItem( | |
value: e, | |
child: Text(e.name), | |
)) | |
.toList(), | |
value: current.value, | |
onChanged: (val) => current.set(val!), | |
), | |
), | |
], | |
); | |
@override | |
Size size() => Size(200, 40 + (20.0 * (inputs.length + 2))); | |
} | |
enum Reducer { | |
add, | |
subtract, | |
divide, | |
multiply, | |
} | |
class IfNode<T extends num> extends Node<T, bool> { | |
@override | |
late final Computed<bool> output; | |
late final Signal<Operator> current; | |
IfNode(Operator operatorCompare, Node<dynamic, T> a, Node<dynamic, T> b) | |
: current = signal(operatorCompare), | |
super( | |
name: 'If Node', | |
inputs: [a, b], | |
) { | |
output = computed(() { | |
final aVal = a.output(); | |
final bVal = b.output(); | |
return switch (current.value) { | |
Operator.greaterThan => aVal > bVal, | |
Operator.greaterThanEqualTo => aVal >= bVal, | |
Operator.lessThan => aVal < bVal, | |
Operator.lessThanEqualTo => aVal <= bVal, | |
Operator.equalTo => aVal == bVal, | |
Operator.notEqualTo => aVal != bVal, | |
}; | |
}); | |
} | |
@override | |
Widget build() => Stack( | |
children: [ | |
Positioned( | |
top: 5, | |
right: 5, | |
child: Text('Result: $output'), | |
), | |
Positioned( | |
bottom: 5, | |
right: 5, | |
left: 5, | |
child: DropdownButton( | |
isDense: true, | |
items: Operator.values | |
.map((e) => DropdownMenuItem( | |
value: e, | |
child: Text(e.name), | |
)) | |
.toList(), | |
value: current.value, | |
onChanged: (val) => current.set(val!), | |
), | |
), | |
], | |
); | |
@override | |
Size size() => const Size(200, 60); | |
} | |
enum Operator { | |
greaterThan, | |
greaterThanEqualTo, | |
lessThan, | |
lessThanEqualTo, | |
equalTo, | |
notEqualTo, | |
} | |
class NumberNode extends Node<void, num> { | |
@override | |
final Signal<num> output; | |
NumberNode(num value) | |
: output = signal(value), | |
super( | |
inputs: [], | |
name: 'Number', | |
); | |
@override | |
Widget build() => Stack( | |
children: [ | |
Positioned( | |
bottom: 0, | |
left: 0, | |
child: Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
IconButton( | |
tooltip: 'Increment', | |
onPressed: () => output.value += 1, | |
icon: const Icon(Icons.add), | |
), | |
IconButton( | |
tooltip: 'Decrement', | |
onPressed: () => output.value -= 1, | |
icon: const Icon(Icons.remove), | |
), | |
], | |
), | |
), | |
Positioned( | |
top: 0, | |
right: 0, | |
child: Row( | |
mainAxisSize: MainAxisSize.min, | |
children: [ | |
IconButton( | |
tooltip: 'Reset', | |
onPressed: () => output.value = output.initialValue, | |
icon: const Icon(Icons.restore), | |
), | |
IconButton( | |
tooltip: 'Undo', | |
onPressed: () => output.value = output.previousValue, | |
icon: const Icon(Icons.refresh), | |
), | |
], | |
), | |
), | |
Positioned( | |
bottom: 5, | |
right: 5, | |
child: Text('Value: $output'), | |
) | |
], | |
); | |
@override | |
Size size() => const Size(180, 60); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment