import 'dart:io'; import 'dart:math'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hetu_script/hetu_script.dart'; import 'package:hetu_script/values.dart'; import 'package:logic_circuits_simulator/components/visual_component.dart'; import 'package:logic_circuits_simulator/models.dart'; import 'package:logic_circuits_simulator/pages_arguments/design_component.dart'; import 'package:logic_circuits_simulator/state/component.dart'; import 'package:logic_circuits_simulator/state/project.dart'; import 'package:logic_circuits_simulator/state/projects.dart'; import 'package:logic_circuits_simulator/utils/future_call_debounce.dart'; import 'package:logic_circuits_simulator/utils/iterable_extension.dart'; import 'package:logic_circuits_simulator/utils/provider_hook.dart'; import 'package:logic_circuits_simulator/utils/stack_canvas_controller_hook.dart'; import 'package:provider/provider.dart'; import 'package:stack_canvas/stack_canvas.dart'; import 'package:tuple/tuple.dart'; import 'package:uuid/uuid.dart'; Key canvasKey = GlobalKey(); Key pickerKey = GlobalKey(); class DesignComponentPage extends HookWidget { final ComponentEntry component; const DesignComponentPage({required this.component, super.key}); DesignComponentPage.fromArguments(DesignComponentPageArguments args, {super.key}) : component = args.component; static const String routeName = '/project/component/design'; @override Widget build(BuildContext context) { final componentState = useProvider(); final canvasController = useStackCanvasController( offsetReference: Reference.Center, ); // Simulation vars final isSimulating = useState(false); final simulatePartially = useState(false); useListenable(componentState.partialVisualSimulation!); // Scripting final scriptingEnvironment = useState(null); final loadScript = useMemoized(() => (String script) { scriptingEnvironment.value = Hetu(); scriptingEnvironment.value!.init( externalFunctions: { 'unload': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { scriptingEnvironment.value = null; }, 'alert': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final content = positionalArgs[0] as String; final title = positionalArgs[1] as String? ?? 'Script Alert'; return showDialog( context: context, builder: (context) { return AlertDialog( title: Text(title), content: Text(content), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('OK'), ), ], ); }, ); }, 'snackBar': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final content = positionalArgs[0] as String; final actionName = positionalArgs[1] as String?; final actionFunction = positionalArgs[2]; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(content), action: actionName == null ? null : SnackBarAction( label: actionName, onPressed: () { if (actionFunction is String) { scriptingEnvironment.value?.invoke(actionFunction); } else if (actionFunction is HTFunction && scriptingEnvironment.value != null) { actionFunction.call(); } }, ), )); }, 'setTimeout': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final millis = positionalArgs[0] as int; final function = positionalArgs[1]; final pos = namedArgs['positionalArgs'] ?? []; final named = namedArgs['namedArgs'] ?? {}; Future.delayed(Duration(milliseconds: millis)) .then((_) { if (function is String) { scriptingEnvironment.value?.invoke(function, positionalArgs: pos, namedArgs: Map.castFrom(named)); } else if (function is HTFunction && scriptingEnvironment.value != null) { function.call(positionalArgs: pos, namedArgs: Map.castFrom(named)); } }); }, 'getInputs': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return componentState.currentComponent!.inputs; }, 'getOutputs': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return componentState.currentComponent!.outputs; }, 'simGetInputValues': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return Map.of(componentState.partialVisualSimulation!.inputsValues); }, 'simGetOutputValues': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return Map.of(componentState.partialVisualSimulation!.outputsValues); }, 'simSetInput': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final inputName = positionalArgs[0] as String; final value = positionalArgs[1] as bool; return componentState.partialVisualSimulation!.modifyInput(inputName, value); }, 'simSetInputs': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final inputs = positionalArgs[0] as Map; return componentState.partialVisualSimulation!.provideInputs(inputs.map((key, value) => MapEntry(key as String, value as bool))); }, 'simSetInputsBinary': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { final inputs = componentState.currentComponent!.inputs; final inputsNum = positionalArgs[0] as int; final inputsBinary = inputsNum.toRadixString(2).padLeft(inputs.length, '0'); final inputsMap = Map.fromIterables(inputs, inputsBinary.characters.map((c) => c == '1')); return componentState.partialVisualSimulation!.provideInputs(inputsMap); }, 'simNextStep': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return componentState.partialVisualSimulation!.nextStep(); }, 'simRestart': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return componentState.partialVisualSimulation!.restart(); }, 'simIsPartiallySimulating': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { return simulatePartially.value; }, 'simSetPartiallySimulating': ( HTEntity entity, { List positionalArgs = const [], Map namedArgs = const {}, List typeArgs = const [], }) { simulatePartially.value = positionalArgs[0] as bool; }, }, ); scriptingEnvironment.value!.eval(''' external fun unload external fun alert(message: String, [title]) external fun snackBar(message: String, [actionName, actionFunction]) external fun setTimeout(millis: int, function, {positionalArgs, namedArgs}) external fun getInputs -> List external fun getOutputs -> List external fun simGetInputValues -> Map external fun simGetOutputValues -> Map external fun simSetInput(inputName: String, value: bool) external fun simSetInputs(values: Map) external fun simSetInputsBinary(values: int) external fun simNextStep external fun simRestart external fun simIsPartiallySimulating -> bool external fun simSetPartiallySimulating(partiallySimulating: bool) '''); scriptingEnvironment.value!.eval(script, type: ResourceType.hetuModule); try { scriptingEnvironment.value!.invoke('onLoad'); } catch (e) { // onLoad handling is optional } try { scriptingEnvironment.value!.invoke('getFunctions'); } catch (e) { // Getting the callable functions of the script is mandatory showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Script Loading Failed'), content: const Text("The script doesn't implement the getFunctions function."), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('OK'), ), ], ); }, ); scriptingEnvironment.value = null; } }, [scriptingEnvironment.value]); // Design final movingWidgetUpdater = useState(null); final movingWidget = useState(null); final deleteOnDrop = useState(false); final designSelection = useState(null); final wireToDelete = useState(null); final sourceToConnect = useState(null); final hoveredIO = useState(null); final deleteMovingWidget = useMemoized( () => () async { if (!deleteOnDrop.value) { return; } final w = movingWidget.value; final cs = componentState; // First remove all connected wires if (w is DesignComponent) { // Get project state to be able to remove dependency final projectState = Provider.of(context, listen: false); final wires = cs.wiringDraft.wires .where( (wire) => wire.input.startsWith('${w.instanceId}/') || wire.output.startsWith('${w.instanceId}/') ) .map((wire) => wire.wireId) .toList(); // Get component id before removing final componentId = cs.wiringDraft.instances.where((inst) => inst.instanceId == w.instanceId).first.componentId; await cs.updateDesign(cs.designDraft.copyWith( wires: cs.designDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), ), commit: false); await cs.updateWiring(cs.wiringDraft.copyWith( wires: cs.wiringDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), ), commit: false); await cs.updateDesign(cs.designDraft.copyWith( components: cs.designDraft.components .where((comp) => comp.instanceId != w.instanceId) .toList(), )); await cs.updateWiring(cs.wiringDraft.copyWith( instances: cs.wiringDraft.instances .where((comp) => comp.instanceId != w.instanceId) .toList(), )); // Remove dependency if it's the last of its kind if (!cs.wiringDraft.instances.map((inst) => inst.componentId).contains(componentId)) { componentState.removeDependency(componentId, modifyCurrentComponent: true); await projectState.editComponent(componentState.currentComponent!); } } else if (w is DesignInput) { final wires = cs.wiringDraft.wires .where( (wire) => wire.output == 'self/${w.name}', ) .map((wire) => wire.wireId) .toList(); await cs.updateDesign(cs.designDraft.copyWith( wires: cs.designDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), ), commit: false); await cs.updateWiring(cs.wiringDraft.copyWith( wires: cs.wiringDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), )); await cs.updateDesign(cs.designDraft.copyWith( inputs: cs.designDraft.inputs .where((input) => input.name != w.name) .toList(), )); } else if (w is DesignOutput) { final wires = cs.wiringDraft.wires .where( (wire) => wire.input == 'self/${w.name}', ) .map((wire) => wire.wireId) .toList(); await cs.updateDesign(cs.designDraft.copyWith( wires: cs.designDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), ), commit: false); await cs.updateWiring(cs.wiringDraft.copyWith( wires: cs.wiringDraft.wires .where((wire) => !wires.contains(wire.wireId)) .toList(), )); await cs.updateDesign(cs.designDraft.copyWith( outputs: cs.designDraft.outputs .where((output) => output.name != w.name) .toList(), )); } }, [ movingWidget.value, deleteOnDrop.value, componentState.wiringDraft, componentState.designDraft, ], ); final widgets = useMemoized(() => [ for (final subcomponent in componentState.designDraft.components) CanvasObject( dx: subcomponent.x, dy: subcomponent.y, width: VisualComponent.getNeededWidth( context, componentState .getMetaByInstance(subcomponent.instanceId) .item2 .inputs, componentState .getMetaByInstance(subcomponent.instanceId) .item2 .outputs, Theme.of(context).textTheme.bodyMedium, ), height: max( componentState.getMetaByInstance(subcomponent.instanceId).item2.inputs.length, componentState.getMetaByInstance(subcomponent.instanceId).item2.outputs.length, ) * 30 + 10, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { final debouncer = FutureCallDebounce>( futureCall: (xyList) { final dx = xyList[0]; final dy = xyList[1]; return componentState.updateDesign(componentState.designDraft.copyWith( components: componentState.designDraft.components.map( (e) => e.instanceId == subcomponent.instanceId ? e.copyWith( x: subcomponent.x + dx, y: subcomponent.y + dy, ) : e, ).toList(), ), commit: false); }, combiner: (oldParams, newParams) { return oldParams.zipWith([newParams], (deltas) => deltas.fold(0.0, (prev, elem) => prev + elem)).toList(); }, ); movingWidgetUpdater.value = (dx, dy) { debouncer.call([dx, dy]); }; movingWidget.value = subcomponent; }, onPointerUp: (event) { deleteMovingWidget(); componentState.updateDesign(componentState.designDraft); movingWidgetUpdater.value = null; movingWidget.value = null; }, child: MouseRegion( opaque: false, hitTestBehavior: HitTestBehavior.translucent, cursor: movingWidget.value == subcomponent ? SystemMouseCursors.move : MouseCursor.defer, child: VisualComponent( name: componentState.getMetaByInstance(subcomponent.instanceId).item2.componentName, inputs: componentState.getMetaByInstance(subcomponent.instanceId).item2.inputs, outputs: componentState.getMetaByInstance(subcomponent.instanceId).item2.outputs, inputColors: isSimulating.value ? { for (final input in componentState.getMetaByInstance(subcomponent.instanceId).item2.inputs) input: componentState.partialVisualSimulation!.inputsValues['${subcomponent.instanceId}/$input'] == true ? Colors.green : componentState.partialVisualSimulation!.inputsValues['${subcomponent.instanceId}/$input'] == false ? Colors.red : Colors.black, } : null, outputColors: isSimulating.value ? { for (final output in componentState.getMetaByInstance(subcomponent.instanceId).item2.outputs) output: componentState.partialVisualSimulation!.outputsValues['${subcomponent.instanceId}/$output'] == true ? Colors.green : componentState.partialVisualSimulation!.outputsValues['${subcomponent.instanceId}/$output'] == false ? Colors.red : Colors.black, } : null, isNextToSimulate: isSimulating.value && componentState.partialVisualSimulation!.nextToSimulate.contains(subcomponent.instanceId), onInputHovered: designSelection.value == 'wiring' && sourceToConnect.value != null ? (input) { hoveredIO.value = '${subcomponent.instanceId}/$input'; } : null, onInputUnhovered: designSelection.value == 'wiring' && sourceToConnect.value != null ? (input) { hoveredIO.value = null; } : null, onOutputHovered: designSelection.value == 'wiring' && sourceToConnect.value == null ? (output) { hoveredIO.value = '${subcomponent.instanceId}/$output'; } : null, onOutputUnhovered: designSelection.value == 'wiring' && sourceToConnect.value == null ? (output) { hoveredIO.value = null; } : null, ), ), ), ), for (final input in componentState.designDraft.inputs) CanvasObject( dx: input.x, dy: input.y, width: IOComponent.getNeededWidth(context, input.name, textStyle: Theme.of(context).textTheme.bodyMedium), height: 40, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { final debouncer = FutureCallDebounce>( futureCall: (xyList) { final dx = xyList[0]; final dy = xyList[1]; return componentState.updateDesign(componentState.designDraft.copyWith( inputs: componentState.designDraft.inputs.map( (e) => e.name == input.name ? e.copyWith( x: input.x + dx, y: input.y + dy, ) : e, ).toList(), ), commit: false); }, combiner: (oldParams, newParams) { return oldParams.zipWith([newParams], (deltas) => deltas.fold(0.0, (prev, elem) => prev + elem)).toList(); }, ); movingWidgetUpdater.value = (dx, dy) { debouncer.call([dx, dy]); }; movingWidget.value = input; }, onPointerUp: (event) { deleteMovingWidget(); componentState.updateDesign(componentState.designDraft); movingWidgetUpdater.value = null; movingWidget.value = null; }, child: IOComponent( input: true, name: input.name, width: IOComponent.getNeededWidth(context, input.name, textStyle: Theme.of(context).textTheme.bodyMedium), color: isSimulating.value ? (componentState.partialVisualSimulation!.outputsValues['self/${input.name}']! ? Colors.green : Colors.red) : null, onTap: isSimulating.value ? () { componentState.partialVisualSimulation!.toggleInput(input.name); } : null, onHovered: designSelection.value == 'wiring' && sourceToConnect.value == null ? () { hoveredIO.value = 'self/${input.name}'; } : null, onUnhovered: designSelection.value == 'wiring' && sourceToConnect.value == null ? () { hoveredIO.value = null; } : null, flashing: designSelection.value == 'wiring' && sourceToConnect.value == null, ), ), ), for (final output in componentState.designDraft.outputs) CanvasObject( dx: output.x, dy: output.y, width: IOComponent.getNeededWidth(context, output.name, textStyle: Theme.of(context).textTheme.bodyMedium), height: 40, child: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { final debouncer = FutureCallDebounce>( futureCall: (xyList) { final dx = xyList[0]; final dy = xyList[1]; return componentState.updateDesign(componentState.designDraft.copyWith( outputs: componentState.designDraft.outputs.map( (e) => e.name == output.name ? e.copyWith( x: output.x + dx, y: output.y + dy, ) : e, ).toList(), ), commit: false); }, combiner: (oldParams, newParams) { return oldParams.zipWith([newParams], (deltas) => deltas.fold(0.0, (prev, elem) => prev + elem)).toList(); }, ); movingWidgetUpdater.value = (dx, dy) { debouncer.call([dx, dy]); }; movingWidget.value = output; }, onPointerUp: (event) { deleteMovingWidget(); componentState.updateDesign(componentState.designDraft); movingWidgetUpdater.value = null; movingWidget.value = null; }, child: IOComponent( input: false, name: output.name, width: IOComponent.getNeededWidth(context, output.name, textStyle: Theme.of(context).textTheme.bodyMedium), color: isSimulating.value ? (componentState.partialVisualSimulation!.inputsValues['self/${output.name}'] == true ? Colors.green : componentState.partialVisualSimulation!.inputsValues['self/${output.name}'] == false ? Colors.red : null) : null, onHovered: designSelection.value == 'wiring' && sourceToConnect.value != null ? () { hoveredIO.value = 'self/${output.name}'; } : null, onUnhovered: designSelection.value == 'wiring' && sourceToConnect.value != null ? () { hoveredIO.value = null; } : null, flashing: designSelection.value == 'wiring' && sourceToConnect.value != null, ), ), ), for (final wire in componentState.wiringDraft.wires) (() { const ioCircleDiameter = 20; Offset from, to; if (wire.output.split('/')[0] == 'self') { // It's a component input // Find input final inputName = wire.output.split('/')[1]; final design = componentState.designDraft.inputs.where((i) => i.name == inputName).first; from = Offset( // Take into account widget length design.x + IOComponent.getNeededWidth( context, inputName, textStyle: Theme.of(context).textTheme.bodyMedium, ), design.y + ioCircleDiameter + 1, ); } else { // It's a subcomponent output // Find subcomponent final split = wire.output.split('/'); final subcomponentId = split[0]; final outputName = split[1]; final design = componentState.designDraft.components.where((c) => c.instanceId == subcomponentId).first; final subcomponent = componentState.getMetaByInstance(subcomponentId).item2; from = Offset( // Take into account widget length design.x + VisualComponent.getNeededWidth( context, subcomponent.inputs, subcomponent.outputs, Theme.of(context).textTheme.bodyMedium, ), design.y + VisualComponent.getHeightOfIO( context, subcomponent.outputs, subcomponent.outputs.indexOf(outputName), Theme.of(context).textTheme.bodyMedium, ), ); } if (wire.input.split('/')[0] == 'self') { // It's a component output // Find output final outputName = wire.input.split('/')[1]; final design = componentState.designDraft.outputs.where((o) => o.name == outputName).first; to = Offset( design.x, design.y + ioCircleDiameter + 1, ); } else { // It's a subcomponent input // Find subcomponent final split = wire.input.split('/'); final subcomponentId = split[0]; final inputName = split[1]; final design = componentState.designDraft.components.where((c) => c.instanceId == subcomponentId).first; final subcomponent = componentState.getMetaByInstance(subcomponentId).item2; to = Offset( // Take into account widget length design.x, design.y + VisualComponent.getHeightOfIO( context, subcomponent.inputs, subcomponent.inputs.indexOf(inputName), Theme.of(context).textTheme.bodyMedium, ), ); } var wireColor = Colors.black; if (isSimulating.value) { final wireValue = componentState.partialVisualSimulation!.outputsValues[wire.output]; if (wireValue == true) { wireColor = Colors.green; } else if (wireValue == false) { wireColor = Colors.red; } } return CanvasObject( dx: min(from.dx, to.dx), dy: min(from.dy, to.dy), width: (to - from).dx.abs(), height: (to - from).dy.abs(), child: MouseRegion( hitTestBehavior: HitTestBehavior.translucent, opaque: false, onEnter: (_) { if (designSelection.value == 'wiring') { wireToDelete.value = wire.wireId; } }, onExit: (_) { wireToDelete.value = null; }, child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () async { if (designSelection.value == 'wiring') { if (wireToDelete.value != wire.wireId) { wireToDelete.value = wire.wireId; } else { // Delete the wire await componentState.updateDesign(componentState.designDraft.copyWith( wires: componentState.designDraft.wires.where((w) => w.wireId != wireToDelete.value).toList(), )); await componentState.updateWiring(componentState.wiringDraft.copyWith( wires: componentState.wiringDraft.wires.where((w) => w.wireId != wireToDelete.value).toList(), )); wireToDelete.value = null; } } }, child: WireWidget( from: from, to: to, color: wireToDelete.value == wire.wireId ? Colors.red : wireColor, ), ), ), ); })(), ], [componentState.wiringDraft, componentState.designDraft, isSimulating.value, componentState.partialVisualSimulation!.outputsValues, designSelection.value, sourceToConnect.value]); useEffect(() { final wList = widgets; canvasController.addCanvasObjects(wList); return () { // Cleanup for (final obj in wList) { canvasController.removeCanvasObject(obj); } }; }, [widgets]); useEffect(() { if (isSimulating.value && !simulatePartially.value && componentState.partialVisualSimulation!.nextToSimulate.isNotEmpty) { componentState.partialVisualSimulation!.nextStep(); } return null; }, [componentState.partialVisualSimulation!.outputsValues.entries.map((e) => '${e.key}:${e.value}').join(';'), simulatePartially.value, isSimulating.value]); return Scaffold( appBar: AppBar( centerTitle: true, title: Text('${isSimulating.value ? 'Simulation' : 'Design'} - ${component.componentName}'), actions: [ IconButton( icon: Icon(isSimulating.value ? Icons.stop : Icons.play_arrow), tooltip: isSimulating.value ? 'Stop Simulation' : 'Start Simulation', onPressed: () { isSimulating.value = !isSimulating.value; designSelection.value = null; }, ), if (isSimulating.value) IconButton( icon: const Icon(Icons.description), tooltip: 'Scripting', onPressed: () { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Scripting'), content: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: const Text('Load Script...'), onTap: () async { final nav = Navigator.of(context); final selectedFiles = await FilePicker.platform.pickFiles( dialogTitle: "Load Script", // allowedExtensions: ['ht', 'txt'], type: FileType.any, ); if (selectedFiles == null || selectedFiles.files.isEmpty) { return; } try { final file = File(selectedFiles.files[0].path!); nav.pop(); loadScript(await file.readAsString()); } catch (e) { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Script Loading Error'), content: Text(e.toString()), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: const Text('OK'), ), ], ); }, ); } }, ), if (scriptingEnvironment.value != null) ...[ const Divider(), for (final function in scriptingEnvironment.value!.invoke('getFunctions')) ListTile( title: Text(function), onTap: () { Navigator.of(context).pop(); try { scriptingEnvironment.value!.invoke(function); } on HTError catch (e) { showDialog( context: context, builder: (context) { return AlertDialog( title: const Text('Script Error'), content: Text(e.toString()), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('OK'), ), ], ); }, ); scriptingEnvironment.value = null; } }, ), ], ], ), ); }, ); }, ) ], ), body: OrientationBuilder( builder: (context, orientation) { final stackCanvas = GestureDetector( behavior: HitTestBehavior.translucent, onPanUpdate: (update) { final hw = movingWidgetUpdater.value; if (hw == null || isSimulating.value) { canvasController.offset = canvasController.offset.translate(update.delta.dx, update.delta.dy); } else { hw(update.delta.dx, update.delta.dy); } }, onTapUp: (update) async { final canvasCenterLocation = canvasController.canvasSize / 2; final canvasCenterLocationOffset = Offset(canvasCenterLocation.width, canvasCenterLocation.height); final canvasLocation = update.localPosition - canvasCenterLocationOffset + canvasController.offset; final ds = designSelection.value; if (ds == null) { return; } if (ds == 'wiring') { // Handle wire creation if (hoveredIO.value == null) { // If clicking on something not hovered, ignore return; } else if (sourceToConnect.value == null) { sourceToConnect.value = hoveredIO.value; hoveredIO.value = null; } else { // Create wire only if sink is not already connected if (componentState.wiringDraft.wires.where((w) => w.input == hoveredIO.value).isNotEmpty) { // Sink already connected final sinkType = hoveredIO.value!.startsWith('self/') ? 'component output' : 'subcomponent input'; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Wire already connected to that $sinkType.'), )); return; } componentState.updateWiring(componentState.wiringDraft.copyWith( wires: componentState.wiringDraft.wires + [ WiringWire( wireId: const Uuid().v4(), output: sourceToConnect.value!, input: hoveredIO.value!, ), ], )); sourceToConnect.value = null; hoveredIO.value = null; } } else if (ds.startsWith('input:')) { final inputName = ds.substring(6); componentState.updateDesign(componentState.designDraft.copyWith( inputs: componentState.designDraft.inputs + [ DesignInput( name: inputName, x: canvasLocation.dx - IOComponent.getNeededWidth(context, inputName) / 2, y: canvasLocation.dy, ), ], )); designSelection.value = null; } else if (ds.startsWith('output:')) { final outputName = ds.substring(7); componentState.updateDesign(componentState.designDraft.copyWith( outputs: componentState.designDraft.outputs + [ DesignOutput( name: outputName, x: canvasLocation.dx - IOComponent.getNeededWidth(context, outputName) / 2, y: canvasLocation.dy, ), ], )); designSelection.value = null; } else { final currentProjectState = Provider.of(context, listen: false); // Add subcomponent final splitted = ds.split('/'); var projectId = splitted[0]; final componentId = splitted[1]; if (Provider.of(context, listen: false).currentProject!.projectId == projectId) { projectId = 'self'; } final depId = '$projectId/$componentId'; final project = projectId == 'self' ? Provider.of(context, listen: false).currentProject! : Provider.of(context, listen: false).index.projects.where((p) => p.projectId == projectId).first; final projectState = ProjectState(); await projectState.setCurrentProject(project); final component = projectState.index.components.where((c) => c.componentId == componentId).first; // Add dependency if (!componentState.hasDependency(depId)) { componentState.addDependency( depId, Tuple2( project, component, ), modifyCurrentComponent: true, ); await currentProjectState.editComponent(componentState.currentComponent!); } // Create component instance final instanceId = const Uuid().v4(); await componentState.updateWiring(componentState.wiringDraft.copyWith( instances: componentState.wiringDraft.instances + [ WiringInstance( componentId: depId, instanceId: instanceId, ), ], )); await componentState.updateDesign(componentState.designDraft.copyWith( components: componentState.designDraft.components + [ DesignComponent( instanceId: instanceId, x: canvasLocation.dx, y: canvasLocation.dy, ), ], )); // Recreate simulation with new subcomponent await componentState.recreatePartialSimulation(); designSelection.value = null; } }, child: Stack( children: [ StackCanvas( key: canvasKey, canvasController: canvasController, animationDuration: const Duration(milliseconds: 50), // disposeController: false, backgroundColor: Theme.of(context).colorScheme.background, ), if (!isSimulating.value) Positioned( bottom: 0, right: 0, child: MouseRegion( hitTestBehavior: HitTestBehavior.translucent, opaque: false, onEnter: (_) { deleteOnDrop.value = true; }, onExit: (_) { deleteOnDrop.value = false; }, child: Padding( padding: const EdgeInsets.all(16.0), child: Icon( Icons.delete, color: movingWidget.value != null && deleteOnDrop.value ? Colors.red : null, ), ), ), ), ], ), ); final debuggingButtons = DebuggingButtons( partialSimulation: simulatePartially.value, onPartialSimulationToggle: () { simulatePartially.value = !simulatePartially.value; }, onReset: simulatePartially.value ? () { componentState.partialVisualSimulation!.restart(); } : null, onNextStep: simulatePartially.value && componentState.partialVisualSimulation!.nextToSimulate.isNotEmpty ? () { componentState.partialVisualSimulation!.nextStep(); } : null, ); final componentPicker = ComponentPicker( key: pickerKey, onSelectionUpdate: (selection) { designSelection.value = selection; if (selection != 'wiring') { wireToDelete.value = null; sourceToConnect.value = null; } }, selection: designSelection.value, ); if (orientation == Orientation.portrait) { return Column( mainAxisSize: MainAxisSize.max, children: [ Expanded( child: Stack( children: [ stackCanvas, if (isSimulating.value) Positioned( top: 8, left: 0, right: 0, child: Center( child: debuggingButtons, ), ), ], ), ), if (!isSimulating.value) componentPicker, ], ); } else { return Row( mainAxisSize: MainAxisSize.max, children: [ Expanded( child: Stack( children: [ stackCanvas, if (isSimulating.value) Positioned( top: 8, left: 0, right: 0, child: Center( child: debuggingButtons, ), ), ], ), ), if (!isSimulating.value) componentPicker, ], ); } } ), ); } } class DebuggingButtons extends StatelessWidget { final bool partialSimulation; final void Function() onPartialSimulationToggle; final void Function()? onReset; final void Function()? onNextStep; const DebuggingButtons({super.key, required this.partialSimulation, required this.onPartialSimulationToggle, this.onReset, this.onNextStep}); @override Widget build(BuildContext context) { return Card( child: Padding( padding: const EdgeInsets.all(8.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( onPressed: onPartialSimulationToggle, icon: Icon( partialSimulation ? Icons.play_arrow : Icons.pause, ), tooltip: partialSimulation ? 'Simulate to end' : 'Simulate partially', ), IconButton( onPressed: onNextStep, icon: const Icon(Icons.navigate_next), tooltip: 'Simulate next step', ), IconButton( onPressed: onReset, icon: const Icon(Icons.replay_outlined), tooltip: 'Restart simulation', ), ], ), ), ); } } class ComponentPicker extends HookWidget { const ComponentPicker({required this.onSelectionUpdate, required this.selection, super.key}); final String? selection; final void Function(String? selection) onSelectionUpdate; @override Widget build(BuildContext context) { final projectsState = useProvider(); final tickerProvider = useSingleTickerProvider(); final tabBarControllerState = useState(null); useEffect(() { tabBarControllerState.value = TabController( length: 3 + projectsState.projects.length, vsync: tickerProvider, initialIndex: 1, ); tabBarControllerState.value!.addListener(() { if (tabBarControllerState.value!.index == 0) { onSelectionUpdate('wiring'); } else { onSelectionUpdate(null); } }); return () { tabBarControllerState.value?.dispose(); }; }, []); final tabBarController = tabBarControllerState.value!; return OrientationBuilder( builder: (context, orientation) { return SizedBox( height: orientation == Orientation.portrait ? 200 : null, width: orientation == Orientation.landscape ? 300 : null, child: Column( mainAxisSize: MainAxisSize.min, children: [ TabBar( controller: tabBarController, tabs: [ const Tab( text: 'Wiring', ), const Tab( text: 'Inputs', ), const Tab( text: 'Outputs', ), for (final project in projectsState.projects) Tab( text: project.projectName, ), ], isScrollable: true, ), Expanded( child: TabBarView( controller: tabBarController, children: [ Center( child: Column( mainAxisSize: MainAxisSize.min, children: const [ Padding( padding: EdgeInsets.all(8.0), child: Text( 'To create wires, click a source and then click a sink to link them.', ), ), Padding( padding: EdgeInsets.all(8.0), child: Text( 'To remove wires, click them or tap them twice.', ), ), ], ), ), IOComponentPickerOptions( orientation: orientation, outputs: false, selection: selection, onSelected: onSelectionUpdate, ), IOComponentPickerOptions( orientation: orientation, outputs: true, selection: selection, onSelected: onSelectionUpdate, ), for (final project in projectsState.projects) HookBuilder( builder: (context) { final scrollController = useScrollController(); final projectState = useFuture(() async { final projectState = ProjectState(); await projectState.setCurrentProject(project); return projectState; }()); if (projectState.data == null) { return Container(); } final components = projectState.data!.index.components; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Padding( padding: EdgeInsets.all(8.0), child: Text('To add a component, select it below and then click on the canvas to place it.'), ), Expanded( child: Scrollbar( controller: scrollController, scrollbarOrientation: orientation == Orientation.portrait ? ScrollbarOrientation.bottom : ScrollbarOrientation.right, child: SingleChildScrollView( controller: scrollController, scrollDirection: orientation == Orientation.portrait ? Axis.horizontal : Axis.vertical, child: Wrap( direction: orientation == Orientation.portrait ? Axis.vertical : Axis.horizontal, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final component in components) IntrinsicWidth( child: Card( color: selection == '${project.projectId}/${component.componentId}' ? Theme.of(context).colorScheme.primaryContainer : null, child: InkWell( onTap: () { if (selection != '${project.projectId}/${component.componentId}') { onSelectionUpdate('${project.projectId}/${component.componentId}'); } else { onSelectionUpdate(null); } }, child: Padding( padding: const EdgeInsets.all(8.0), child: Text( component.componentName, style: selection == '${project.projectId}/${component.componentId}' ? TextStyle( inherit: true, color: Theme.of(context).colorScheme.onPrimaryContainer, ) : null, ), ), ), ), ), ], ), ), ), ), ], ); } ), ], ), ), ], ), ); }, ); } } class IOComponentPickerOptions extends HookWidget { final Orientation orientation; final bool outputs; final String? selection; final void Function(String? selection) onSelected; const IOComponentPickerOptions({required this.orientation, required this.outputs, required this.selection, required this.onSelected, super.key,}); String getSelectionName(String option) => '${!outputs ? "input" : "output"}:$option'; @override Widget build(BuildContext context) { final componentState = useProvider(); final scrollController = useScrollController(); final options = !outputs ? componentState.currentComponent!.inputs : componentState.currentComponent!.outputs; return Builder( builder: (context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.all(8.0), child: Text('To add an ${!outputs ? "input" : "output"}, select it below and then click on the canvas to place it. You can only add one of each. Red ${!outputs ? "inputs" : "outputs"} have already been placed.'), ), Expanded( child: Scrollbar( controller: scrollController, scrollbarOrientation: orientation == Orientation.portrait ? ScrollbarOrientation.bottom : ScrollbarOrientation.right, child: SingleChildScrollView( controller: scrollController, scrollDirection: orientation == Orientation.portrait ? Axis.horizontal : Axis.vertical, child: Wrap( direction: orientation == Orientation.portrait ? Axis.vertical : Axis.horizontal, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final option in options) IntrinsicWidth( child: Card( color: ( !outputs ? componentState.designDraft.inputs.map((input) => input.name).contains(option) : componentState.designDraft.outputs.map((output) => output.name).contains(option) ) ? const Color.fromARGB(100, 255, 0, 0) : selection == getSelectionName(option) ? Theme.of(context).colorScheme.primaryContainer : null, child: InkWell( onTap: ( !outputs ? componentState.designDraft.inputs.map((input) => input.name).contains(option) : componentState.designDraft.outputs.map((output) => output.name).contains(option) ) ? null : () { if (selection == getSelectionName(option)) { onSelected(null); } else { onSelected(getSelectionName(option)); } }, child: Padding( padding: const EdgeInsets.all(8.0), child: Text( option, style: selection == getSelectionName(option) ? TextStyle( inherit: true, color: Theme.of(context).colorScheme.onPrimaryContainer, ) : null, ), ), ), ), ), ], ), ), ), ), ], ); } ); } }