You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
479 lines
18 KiB
479 lines
18 KiB
import 'dart:math'; |
|
|
|
import 'package:flutter/material.dart'; |
|
import 'package:flutter_hooks/flutter_hooks.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/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:stack_canvas/stack_canvas.dart'; |
|
|
|
Key canvasKey = 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<ComponentState>(); |
|
final canvasController = useStackCanvasController( |
|
offsetReference: Reference.Center, |
|
); |
|
|
|
// Simulation vars |
|
final isSimulating = useState(false); |
|
final simulatePartially = useState(false); |
|
|
|
useListenable(componentState.partialVisualSimulation!); |
|
|
|
final movingWidgetUpdater = useState<void Function(double dx, double dy)?>(null); |
|
final movingWidget = useState<dynamic>(null); |
|
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<List<double>>( |
|
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<double>(0.0, (prev, elem) => prev + elem)).toList(); |
|
}, |
|
); |
|
movingWidgetUpdater.value = (dx, dy) { |
|
debouncer.call([dx, dy]); |
|
}; |
|
movingWidget.value = subcomponent; |
|
}, |
|
onPointerUp: (event) { |
|
componentState.updateDesign(componentState.designDraft); |
|
movingWidgetUpdater.value = null; |
|
movingWidget.value = null; |
|
}, |
|
child: MouseRegion( |
|
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, |
|
), |
|
), |
|
), |
|
), |
|
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<List<double>>( |
|
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<double>(0.0, (prev, elem) => prev + elem)).toList(); |
|
}, |
|
); |
|
movingWidgetUpdater.value = (dx, dy) { |
|
debouncer.call([dx, dy]); |
|
}; |
|
}, |
|
onPointerUp: (event) { |
|
componentState.updateDesign(componentState.designDraft); |
|
movingWidgetUpdater.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, |
|
), |
|
), |
|
), |
|
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<List<double>>( |
|
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<double>(0.0, (prev, elem) => prev + elem)).toList(); |
|
}, |
|
); |
|
movingWidgetUpdater.value = (dx, dy) { |
|
debouncer.call([dx, dy]); |
|
}; |
|
}, |
|
onPointerUp: (event) { |
|
componentState.updateDesign(componentState.designDraft); |
|
movingWidgetUpdater.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, |
|
), |
|
), |
|
), |
|
for (final wire in componentState.wiring.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: IgnorePointer( |
|
child: WireWidget( |
|
from: from, |
|
to: to, |
|
color: wireColor, |
|
), |
|
), |
|
); |
|
})(), |
|
], [componentState.designDraft, isSimulating.value, componentState.partialVisualSimulation!.outputsValues]); |
|
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.start), |
|
tooltip: isSimulating.value ? 'Stop Simulation' : 'Start Simulation', |
|
onPressed: () { |
|
isSimulating.value = !isSimulating.value; |
|
}, |
|
), |
|
], |
|
), |
|
body: GestureDetector( |
|
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); |
|
} |
|
}, |
|
child: OrientationBuilder( |
|
builder: (context, orientation) { |
|
final stackCanvas = StackCanvas( |
|
key: canvasKey, |
|
canvasController: canvasController, |
|
animationDuration: const Duration(milliseconds: 50), |
|
// disposeController: false, |
|
backgroundColor: Theme.of(context).colorScheme.background, |
|
); |
|
|
|
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, |
|
); |
|
|
|
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, |
|
), |
|
), |
|
], |
|
), |
|
), |
|
], |
|
); |
|
} |
|
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, |
|
), |
|
), |
|
], |
|
), |
|
), |
|
], |
|
); |
|
} |
|
} |
|
), |
|
), |
|
); |
|
} |
|
} |
|
|
|
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', |
|
), |
|
], |
|
), |
|
), |
|
); |
|
} |
|
} |