From a86a5e6aec20a14d888c8d08fc1265d3eac2f640 Mon Sep 17 00:00:00 2001 From: Dan Cojocaru Date: Sat, 9 Jul 2022 17:10:52 +0300 Subject: [PATCH] Added connecting wires --- lib/components/visual_component.dart | 182 ++++++++++++++++++++++----- lib/pages/design_component.dart | 71 ++++++++++- 2 files changed, 220 insertions(+), 33 deletions(-) diff --git a/lib/components/visual_component.dart b/lib/components/visual_component.dart index 38aa690..91b9940 100644 --- a/lib/components/visual_component.dart +++ b/lib/components/visual_component.dart @@ -11,10 +11,28 @@ class VisualComponent extends HookWidget { final Map inputColors; final Map outputColors; final bool isNextToSimulate; + final void Function(String)? onInputHovered; + final void Function(String)? onInputUnhovered; + final void Function(String)? onOutputHovered; + final void Function(String)? onOutputUnhovered; - VisualComponent({super.key, required this.name, required this.inputs, required this.outputs, Map? inputColors, Map? outputColors, this.isNextToSimulate = false}) + VisualComponent({ + super.key, + required this.name, + required this.inputs, + required this.outputs, + Map? inputColors, + Map? outputColors, + this.isNextToSimulate = false, + this.onInputHovered, + this.onInputUnhovered, + this.onOutputHovered, + this.onOutputUnhovered, + }) : inputColors = inputColors ?? {} - , outputColors = outputColors ?? {}; + , outputColors = outputColors ?? {} + , assert((onInputHovered == null) == (onInputUnhovered == null)) + , assert((onOutputHovered == null) == (onOutputUnhovered == null)); @override Widget build(BuildContext context) { @@ -40,12 +58,20 @@ class VisualComponent extends HookWidget { final hovered = useState(false); + useEffect(() { + if (onInputHovered != null || onOutputHovered != null) { + hovered.value = false; + } + + return null; + }, [onInputHovered, onOutputHovered]); + final inputsWidth = inputs.map((input) => IOLabel.getNeededWidth(context, input)).fold(0, (previousValue, element) => max(previousValue, element)); final outputsWidth = outputs.map((output) => IOLabel.getNeededWidth(context, output)).fold(0, (previousValue, element) => max(previousValue, element)); return MouseRegion( - onEnter: (event) => hovered.value = true, - onExit: (event) => hovered.value = false, + onEnter: onInputHovered == null && onOutputHovered == null ? (event) => hovered.value = true : null, + onExit: onInputUnhovered == null && onOutputUnhovered== null ? (event) => hovered.value = false : null, child: Row( children: [ Column( @@ -59,6 +85,9 @@ class VisualComponent extends HookWidget { ? Theme.of(context).colorScheme.primary : inputColors[input] ?? Colors.black, width: inputsWidth, + onHovered: onInputHovered == null ? null : () => onInputHovered!(input), + onUnhovered: onInputUnhovered == null ? null : () => onInputUnhovered!(input), + flashing: onInputHovered != null, ), ), ], @@ -92,6 +121,9 @@ class VisualComponent extends HookWidget { ? Theme.of(context).colorScheme.primary : outputColors[output] ?? Colors.black, width: outputsWidth, + onHovered: onOutputHovered == null ? null : () => onOutputHovered!(output), + onUnhovered: onOutputUnhovered == null ? null : () => onOutputUnhovered!(output), + flashing: onOutputHovered != null, ), ), ], @@ -143,16 +175,58 @@ class IOComponent extends HookWidget { final double circleDiameter; final Color? color; final void Function()? onTap; + final void Function()? onHovered; + final void Function()? onUnhovered; + final bool flashing; - const IOComponent({super.key, required this.name, required this.input, this.width = 100, this.circleDiameter = 20, this.color, this.onTap}); + const IOComponent({ + super.key, + required this.name, + required this.input, + this.width = 100, + this.circleDiameter = 20, + this.color, + this.onTap, + this.onHovered, + this.onUnhovered, + this.flashing = false, + }) : assert((onHovered == null) == (onUnhovered == null)); @override Widget build(BuildContext context) { + final flashingAnimation = useAnimationController( + duration: const Duration(milliseconds: 500), + initialValue: 0.0, + lowerBound: 0.0, + upperBound: 1.0, + ); + useEffect(() { + if (flashing) { + flashingAnimation.repeat( + reverse: true, + ); + } + else { + flashingAnimation.reset(); + } + + return null; + }, [flashing]); + final flashingAnimProgress = useAnimation(flashingAnimation); + final hovered = useState(false); + useEffect(() { + if (onHovered != null) { + hovered.value = false; + } + + return null; + }, [onHovered]); + return MouseRegion( - onEnter: (event) => hovered.value = true, - onExit: (event) => hovered.value = false, + onEnter: (event) => onHovered != null ? onHovered!() : hovered.value = true, + onExit: (event) => onUnhovered != null ? onUnhovered!() : hovered.value = false, hitTestBehavior: HitTestBehavior.translucent, opaque: false, child: GestureDetector( @@ -161,6 +235,11 @@ class IOComponent extends HookWidget { child: Builder( builder: (context) { final lineColor = hovered.value ? Theme.of(context).colorScheme.primary : color ?? Colors.black; + final animLineColor = Color.lerp( + lineColor, + Colors.blue, + flashingAnimProgress, + )!; return Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -169,7 +248,7 @@ class IOComponent extends HookWidget { width: circleDiameter, height: circleDiameter, decoration: BoxDecoration( - border: Border.all(color: lineColor), + border: Border.all(color: animLineColor), shape: BoxShape.circle, color: color, ), @@ -179,7 +258,7 @@ class IOComponent extends HookWidget { child: IOLabel( label: name, input: !input, - lineColor: lineColor, + lineColor: animLineColor, width: width - circleDiameter, ), ), @@ -187,7 +266,7 @@ class IOComponent extends HookWidget { width: circleDiameter, height: circleDiameter, decoration: BoxDecoration( - border: Border.all(color: lineColor), + border: Border.all(color: animLineColor), shape: BoxShape.circle, color: color, ), @@ -205,35 +284,78 @@ class IOComponent extends HookWidget { } } -class IOLabel extends StatelessWidget { +class IOLabel extends HookWidget { final bool input; final String label; final Color lineColor; final double width; + final void Function()? onHovered; + final void Function()? onUnhovered; + final bool flashing; - const IOLabel({super.key, required this.lineColor, required this.label, required this.input, this.width = 80}); + const IOLabel({super.key, required this.lineColor, required this.label, required this.input, this.width = 80, this.onHovered, this.onUnhovered, this.flashing = false,}) + : assert((onHovered == null) == (onUnhovered == null)); @override Widget build(BuildContext context) { - return Container( - width: width, - height: 20, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: lineColor), + final flashingAnimation = useAnimationController( + duration: const Duration(milliseconds: 500), + initialValue: 0.0, + lowerBound: 0.0, + upperBound: 1.0, + ); + useEffect(() { + if (flashing) { + flashingAnimation.repeat( + reverse: true, + ); + } + else { + flashingAnimation.reset(); + } + + return null; + }, [flashing]); + final flashingAnimProgress = useAnimation(flashingAnimation); + + final hovered = useState(false); + + return MouseRegion( + hitTestBehavior: onHovered != null ? HitTestBehavior.translucent : HitTestBehavior.deferToChild, + onEnter: onHovered == null ? null : (_) { + hovered.value = true; + onHovered!(); + }, + onExit: onUnhovered == null ? null : (_) { + hovered.value = false; + onUnhovered!(); + }, + child: Container( + width: width, + height: 20, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Color.lerp( + lineColor, + Colors.blue, + flashingAnimProgress, + )!, + ), + ), ), - ), - child: Align( - alignment: input ? Alignment.bottomRight : Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Text( - label, - style: const TextStyle( - inherit: true, - fontFeatures: [ - FontFeature.tabularFigures(), - ], + child: Align( + alignment: input ? Alignment.bottomRight : Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + label, + style: const TextStyle( + inherit: true, + fontFeatures: [ + FontFeature.tabularFigures(), + ], + ), ), ), ), diff --git a/lib/pages/design_component.dart b/lib/pages/design_component.dart index e8bf496..396f138 100644 --- a/lib/pages/design_component.dart +++ b/lib/pages/design_component.dart @@ -13,6 +13,7 @@ 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:stack_canvas/stack_canvas.dart'; +import 'package:uuid/uuid.dart'; Key canvasKey = GlobalKey(); Key pickerKey = GlobalKey(); @@ -44,6 +45,8 @@ class DesignComponentPage extends HookWidget { final deleteOnDrop = useState(false); final designSelection = useState(null); final wireToDelete = useState(null); + final sourceToConnect = useState(null); + final hoveredIO = useState(null); final widgets = useMemoized(() => [ for (final subcomponent in componentState.designDraft.components) @@ -117,6 +120,18 @@ class DesignComponentPage extends HookWidget { : 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, ), ), ), @@ -169,6 +184,13 @@ class DesignComponentPage extends HookWidget { 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, ), ), ), @@ -217,6 +239,13 @@ class DesignComponentPage extends HookWidget { : 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, ), ), ), @@ -362,7 +391,7 @@ class DesignComponentPage extends HookWidget { ), ); })(), - ], [componentState.designDraft, isSimulating.value, componentState.partialVisualSimulation!.outputsValues]); + ], [componentState.designDraft, isSimulating.value, componentState.partialVisualSimulation!.outputsValues, designSelection.value, sourceToConnect.value]); useEffect(() { final wList = widgets; canvasController.addCanvasObjects(wList); @@ -409,6 +438,41 @@ class DesignComponentPage extends HookWidget { hw(update.delta.dx, update.delta.dy); } }, + onTap: () { + if (designSelection.value == '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; + } + } + }, child: Stack( children: [ StackCanvas( @@ -463,6 +527,7 @@ class DesignComponentPage extends HookWidget { designSelection.value = selection; if (selection != 'wiring') { wireToDelete.value = null; + sourceToConnect.value = null; } }, ); @@ -716,10 +781,10 @@ class ComponentPicker extends HookWidget { ], ); } - ) + ), ], ), - ) + ), ], ), );