mirror of
https://github.com/dancojocaru2000/logic-circuits-simulator.git
synced 2025-06-19 10:32:28 +03:00
Compare commits
7 commits
fc361b04d3
...
3cdee540fc
Author | SHA1 | Date | |
---|---|---|---|
3cdee540fc | |||
b34e65544b | |||
6e2dda60e2 | |||
b40811585c | |||
bac2d1e1c7 | |||
9e5cf4f92f | |||
e2731623f1 |
10 changed files with 675 additions and 40 deletions
10
examples/componentTest.ht
Normal file
10
examples/componentTest.ht
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
fun simulate(inputs) -> Map {
|
||||||
|
final result = Map()
|
||||||
|
if (inputs['A'] && inputs['B']) {
|
||||||
|
result['OUT'] = true
|
||||||
|
} else {
|
||||||
|
result['OUT'] = false
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
42
examples/simulateTest.ht
Normal file
42
examples/simulateTest.ht
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
var inputsValue = 0
|
||||||
|
|
||||||
|
fun onLoad {
|
||||||
|
snackBar("Script loaded", "Start", start)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFunctions {
|
||||||
|
return ["start", "random"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start {
|
||||||
|
inputsValue = 0
|
||||||
|
simSetPartiallySimulating(false)
|
||||||
|
simRestart()
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tick {
|
||||||
|
final inputs = getInputs()
|
||||||
|
final inputsLength = inputs.length
|
||||||
|
|
||||||
|
simSetInputsBinary(inputsValue)
|
||||||
|
inputsValue += 1
|
||||||
|
|
||||||
|
if (inputsValue >= Math.pow(2, inputsLength)) {
|
||||||
|
inputsValue = 0
|
||||||
|
snackBar("Finished going through all possible values", "Restart", () {
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setTimeout(1000, tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun random {
|
||||||
|
final inputs = getInputs()
|
||||||
|
final inputsLength = inputs.length
|
||||||
|
|
||||||
|
simSetInputsBinary(Math.randomInt(Math.pow(2, inputsLength)))
|
||||||
|
}
|
||||||
|
|
BIN
examples/standard.lcsproj
Normal file
BIN
examples/standard.lcsproj
Normal file
Binary file not shown.
|
@ -140,7 +140,7 @@ class VisualComponent extends HookWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
static double getHeightOfIO(BuildContext context, List<String> options, int index, [TextStyle? textStyle]) {
|
static double getHeightOfIO(BuildContext context, List<String> options, int index, [TextStyle? textStyle]) {
|
||||||
assert(index < options.length);
|
assert(index <= options.length);
|
||||||
getHeightOfText(String text) {
|
getHeightOfText(String text) {
|
||||||
final textPainter = TextPainter(
|
final textPainter = TextPainter(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
|
@ -163,7 +163,9 @@ class VisualComponent extends HookWidget {
|
||||||
for (var i = 0; i < index; i++) {
|
for (var i = 0; i < index; i++) {
|
||||||
result += 5.0 + getHeightOfText(options[i]) + 5.0;
|
result += 5.0 + getHeightOfText(options[i]) + 5.0;
|
||||||
}
|
}
|
||||||
|
if (index < options.length) {
|
||||||
result += 5.0 + getHeightOfText(options[index]);
|
result += 5.0 + getHeightOfText(options[index]);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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/components/visual_component.dart';
|
||||||
import 'package:logic_circuits_simulator/models.dart';
|
import 'package:logic_circuits_simulator/models.dart';
|
||||||
import 'package:logic_circuits_simulator/pages_arguments/design_component.dart';
|
import 'package:logic_circuits_simulator/pages_arguments/design_component.dart';
|
||||||
|
@ -12,7 +16,9 @@ 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/iterable_extension.dart';
|
||||||
import 'package:logic_circuits_simulator/utils/provider_hook.dart';
|
import 'package:logic_circuits_simulator/utils/provider_hook.dart';
|
||||||
import 'package:logic_circuits_simulator/utils/stack_canvas_controller_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:stack_canvas/stack_canvas.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
Key canvasKey = GlobalKey();
|
Key canvasKey = GlobalKey();
|
||||||
|
@ -40,6 +46,239 @@ class DesignComponentPage extends HookWidget {
|
||||||
|
|
||||||
useListenable(componentState.partialVisualSimulation!);
|
useListenable(componentState.partialVisualSimulation!);
|
||||||
|
|
||||||
|
// Scripting
|
||||||
|
final scriptingEnvironment = useState<Hetu?>(null);
|
||||||
|
final loadScript = useMemoized(() => (String script) {
|
||||||
|
scriptingEnvironment.value = Hetu();
|
||||||
|
scriptingEnvironment.value!.init(
|
||||||
|
externalFunctions: {
|
||||||
|
'unload': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
scriptingEnvironment.value = null;
|
||||||
|
},
|
||||||
|
'alert': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return componentState.currentComponent!.inputs;
|
||||||
|
},
|
||||||
|
'getOutputs': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return componentState.currentComponent!.outputs;
|
||||||
|
},
|
||||||
|
'simGetInputValues': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return Map.of(componentState.partialVisualSimulation!.inputsValues);
|
||||||
|
},
|
||||||
|
'simGetOutputValues': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return Map.of(componentState.partialVisualSimulation!.outputsValues);
|
||||||
|
},
|
||||||
|
'simSetInput': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
final inputName = positionalArgs[0] as String;
|
||||||
|
final value = positionalArgs[1] as bool;
|
||||||
|
|
||||||
|
return componentState.partialVisualSimulation!.modifyInput(inputName, value);
|
||||||
|
},
|
||||||
|
'simSetInputs': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return componentState.partialVisualSimulation!.nextStep();
|
||||||
|
},
|
||||||
|
'simRestart': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return componentState.partialVisualSimulation!.restart();
|
||||||
|
},
|
||||||
|
'simIsPartiallySimulating': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> typeArgs = const [],
|
||||||
|
}) {
|
||||||
|
return simulatePartially.value;
|
||||||
|
},
|
||||||
|
'simSetPartiallySimulating': (
|
||||||
|
HTEntity entity, {
|
||||||
|
List<dynamic> positionalArgs = const [],
|
||||||
|
Map<String, dynamic> namedArgs = const {},
|
||||||
|
List<HTType> 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<void Function(double dx, double dy)?>(null);
|
final movingWidgetUpdater = useState<void Function(double dx, double dy)?>(null);
|
||||||
final movingWidget = useState<dynamic>(null);
|
final movingWidget = useState<dynamic>(null);
|
||||||
final deleteOnDrop = useState<bool>(false);
|
final deleteOnDrop = useState<bool>(false);
|
||||||
|
@ -56,6 +295,9 @@ class DesignComponentPage extends HookWidget {
|
||||||
final cs = componentState;
|
final cs = componentState;
|
||||||
// First remove all connected wires
|
// First remove all connected wires
|
||||||
if (w is DesignComponent) {
|
if (w is DesignComponent) {
|
||||||
|
// Get project state to be able to remove dependency
|
||||||
|
final projectState = Provider.of<ProjectState>(context, listen: false);
|
||||||
|
|
||||||
final wires = cs.wiringDraft.wires
|
final wires = cs.wiringDraft.wires
|
||||||
.where(
|
.where(
|
||||||
(wire) => wire.input.startsWith('${w.instanceId}/') || wire.output.startsWith('${w.instanceId}/')
|
(wire) => wire.input.startsWith('${w.instanceId}/') || wire.output.startsWith('${w.instanceId}/')
|
||||||
|
@ -63,6 +305,9 @@ class DesignComponentPage extends HookWidget {
|
||||||
.map((wire) => wire.wireId)
|
.map((wire) => wire.wireId)
|
||||||
.toList();
|
.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(
|
await cs.updateDesign(cs.designDraft.copyWith(
|
||||||
wires: cs.designDraft.wires
|
wires: cs.designDraft.wires
|
||||||
.where((wire) => !wires.contains(wire.wireId))
|
.where((wire) => !wires.contains(wire.wireId))
|
||||||
|
@ -83,6 +328,12 @@ class DesignComponentPage extends HookWidget {
|
||||||
.where((comp) => comp.instanceId != w.instanceId)
|
.where((comp) => comp.instanceId != w.instanceId)
|
||||||
.toList(),
|
.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) {
|
else if (w is DesignInput) {
|
||||||
final wires = cs.wiringDraft.wires
|
final wires = cs.wiringDraft.wires
|
||||||
|
@ -519,6 +770,95 @@ class DesignComponentPage extends HookWidget {
|
||||||
designSelection.value = null;
|
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!);
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nav.pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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(
|
body: OrientationBuilder(
|
||||||
|
@ -534,8 +874,16 @@ class DesignComponentPage extends HookWidget {
|
||||||
hw(update.delta.dx, update.delta.dy);
|
hw(update.delta.dx, update.delta.dy);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTapUp: (update) async {
|
||||||
if (designSelection.value == 'wiring') {
|
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
|
// Handle wire creation
|
||||||
if (hoveredIO.value == null) {
|
if (hoveredIO.value == null) {
|
||||||
// If clicking on something not hovered, ignore
|
// If clicking on something not hovered, ignore
|
||||||
|
@ -568,6 +916,90 @@ class DesignComponentPage extends HookWidget {
|
||||||
hoveredIO.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<ProjectState>(context, listen: false);
|
||||||
|
|
||||||
|
// Add subcomponent
|
||||||
|
final splitted = ds.split('/');
|
||||||
|
var projectId = splitted[0];
|
||||||
|
final componentId = splitted[1];
|
||||||
|
|
||||||
|
if (Provider.of<ProjectState>(context, listen: false).currentProject!.projectId == projectId) {
|
||||||
|
projectId = 'self';
|
||||||
|
}
|
||||||
|
|
||||||
|
final depId = '$projectId/$componentId';
|
||||||
|
final project = projectId == 'self'
|
||||||
|
? Provider.of<ProjectState>(context, listen: false).currentProject!
|
||||||
|
: Provider.of<ProjectsState>(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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
@ -619,13 +1051,14 @@ class DesignComponentPage extends HookWidget {
|
||||||
|
|
||||||
final componentPicker = ComponentPicker(
|
final componentPicker = ComponentPicker(
|
||||||
key: pickerKey,
|
key: pickerKey,
|
||||||
onSeletionUpdate: (selection) {
|
onSelectionUpdate: (selection) {
|
||||||
designSelection.value = selection;
|
designSelection.value = selection;
|
||||||
if (selection != 'wiring') {
|
if (selection != 'wiring') {
|
||||||
wireToDelete.value = null;
|
wireToDelete.value = null;
|
||||||
sourceToConnect.value = null;
|
sourceToConnect.value = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
selection: designSelection.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (orientation == Orientation.portrait) {
|
if (orientation == Orientation.portrait) {
|
||||||
|
@ -726,33 +1159,29 @@ class DebuggingButtons extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class ComponentPicker extends HookWidget {
|
class ComponentPicker extends HookWidget {
|
||||||
const ComponentPicker({required this.onSeletionUpdate, super.key});
|
const ComponentPicker({required this.onSelectionUpdate, required this.selection, super.key});
|
||||||
|
|
||||||
final void Function(String? selection) onSeletionUpdate;
|
final String? selection;
|
||||||
|
final void Function(String? selection) onSelectionUpdate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final projectsState = useProvider<ProjectsState>();
|
final projectsState = useProvider<ProjectsState>();
|
||||||
final tickerProvider = useSingleTickerProvider();
|
final tickerProvider = useSingleTickerProvider();
|
||||||
final selection = useState<String?>(null);
|
final tabBarControllerState = useState<TabController?>(null);
|
||||||
final tabBarControllerState = useState<TabController?>(null );
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
selection.addListener(() {
|
|
||||||
onSeletionUpdate(selection.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
tabBarControllerState.value = TabController(
|
tabBarControllerState.value = TabController(
|
||||||
length: 1 + projectsState.projects.length,
|
length: 3 + projectsState.projects.length,
|
||||||
vsync: tickerProvider,
|
vsync: tickerProvider,
|
||||||
initialIndex: 1,
|
initialIndex: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
tabBarControllerState.value!.addListener(() {
|
tabBarControllerState.value!.addListener(() {
|
||||||
if (tabBarControllerState.value!.index == 0) {
|
if (tabBarControllerState.value!.index == 0) {
|
||||||
selection.value = 'wiring';
|
onSelectionUpdate('wiring');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
selection.value = null;
|
onSelectionUpdate(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -776,6 +1205,12 @@ class ComponentPicker extends HookWidget {
|
||||||
const Tab(
|
const Tab(
|
||||||
text: 'Wiring',
|
text: 'Wiring',
|
||||||
),
|
),
|
||||||
|
const Tab(
|
||||||
|
text: 'Inputs',
|
||||||
|
),
|
||||||
|
const Tab(
|
||||||
|
text: 'Outputs',
|
||||||
|
),
|
||||||
for (final project in projectsState.projects)
|
for (final project in projectsState.projects)
|
||||||
Tab(
|
Tab(
|
||||||
text: project.projectName,
|
text: project.projectName,
|
||||||
|
@ -806,6 +1241,18 @@ class ComponentPicker extends HookWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
IOComponentPickerOptions(
|
||||||
|
orientation: orientation,
|
||||||
|
outputs: false,
|
||||||
|
selection: selection,
|
||||||
|
onSelected: onSelectionUpdate,
|
||||||
|
),
|
||||||
|
IOComponentPickerOptions(
|
||||||
|
orientation: orientation,
|
||||||
|
outputs: true,
|
||||||
|
selection: selection,
|
||||||
|
onSelected: onSelectionUpdate,
|
||||||
|
),
|
||||||
for (final project in projectsState.projects)
|
for (final project in projectsState.projects)
|
||||||
HookBuilder(
|
HookBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
@ -844,21 +1291,21 @@ class ComponentPicker extends HookWidget {
|
||||||
for (final component in components)
|
for (final component in components)
|
||||||
IntrinsicWidth(
|
IntrinsicWidth(
|
||||||
child: Card(
|
child: Card(
|
||||||
color: selection.value == '${project.projectId}/${component.componentId}' ? Theme.of(context).colorScheme.primaryContainer : null,
|
color: selection == '${project.projectId}/${component.componentId}' ? Theme.of(context).colorScheme.primaryContainer : null,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (selection.value != '${project.projectId}/${component.componentId}') {
|
if (selection != '${project.projectId}/${component.componentId}') {
|
||||||
selection.value = '${project.projectId}/${component.componentId}';
|
onSelectionUpdate('${project.projectId}/${component.componentId}');
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
selection.value = null;
|
onSelectionUpdate(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
component.componentName,
|
component.componentName,
|
||||||
style: selection.value == '${project.projectId}/${component.componentId}'
|
style: selection == '${project.projectId}/${component.componentId}'
|
||||||
? TextStyle(
|
? TextStyle(
|
||||||
inherit: true,
|
inherit: true,
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
@ -888,3 +1335,94 @@ class ComponentPicker extends HookWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<ComponentState>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -616,7 +616,7 @@ class EditComponentPage extends HookWidget {
|
||||||
nav.pushNamed(
|
nav.pushNamed(
|
||||||
DesignComponentPage.routeName,
|
DesignComponentPage.routeName,
|
||||||
arguments: DesignComponentPageArguments(
|
arguments: DesignComponentPageArguments(
|
||||||
component: component,
|
component: ce(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on DependenciesNotSatisfiedException catch (e) {
|
} on DependenciesNotSatisfiedException catch (e) {
|
||||||
|
|
|
@ -37,7 +37,7 @@ class ComponentState extends ChangeNotifier {
|
||||||
await state.setCurrentComponent(
|
await state.setCurrentComponent(
|
||||||
project: proj,
|
project: proj,
|
||||||
component: comp,
|
component: comp,
|
||||||
onDependencyNeeded: (projId, compId) async => _dependenciesMap['$projId/$compId'],
|
onDependencyNeeded: (projId, compId) async => _dependenciesMap['${projId == "self" ? proj.projectId : projId}/$compId'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return SimulatedComponent(
|
return SimulatedComponent(
|
||||||
|
@ -108,14 +108,30 @@ class ComponentState extends ChangeNotifier {
|
||||||
|
|
||||||
// Load dependencies
|
// Load dependencies
|
||||||
final unsatisfiedDependencies = <String>[];
|
final unsatisfiedDependencies = <String>[];
|
||||||
for (final depId in component.dependencies) {
|
final neededDependencies = component.dependencies.toList();
|
||||||
|
while (neededDependencies.isNotEmpty) {
|
||||||
|
final tmp = neededDependencies.toList();
|
||||||
|
neededDependencies.clear();
|
||||||
|
for (final depId in tmp) {
|
||||||
|
if (!hasDependency(depId)) {
|
||||||
final splitted = depId.split('/');
|
final splitted = depId.split('/');
|
||||||
final maybeDep = await onDependencyNeeded(splitted[0], splitted[1]);
|
final maybeDep = await onDependencyNeeded(splitted[0], splitted[1]);
|
||||||
if (maybeDep == null) {
|
if (maybeDep == null) {
|
||||||
unsatisfiedDependencies.add(depId);
|
unsatisfiedDependencies.add(depId);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
_dependenciesMap[depId] = maybeDep;
|
addDependency(depId, maybeDep);
|
||||||
|
neededDependencies.addAll(
|
||||||
|
maybeDep.item2.dependencies
|
||||||
|
.map((depId) {
|
||||||
|
final splitted = depId.split('/');
|
||||||
|
final projectId = splitted[0] == 'self' ? maybeDep.item1.projectId : splitted[0];
|
||||||
|
return '$projectId/${splitted[1]}';
|
||||||
|
})
|
||||||
|
.where((depId) => !hasDependency(depId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (unsatisfiedDependencies.isNotEmpty) {
|
if (unsatisfiedDependencies.isNotEmpty) {
|
||||||
|
@ -124,10 +140,34 @@ class ComponentState extends ChangeNotifier {
|
||||||
|
|
||||||
await _loadComponentFiles();
|
await _loadComponentFiles();
|
||||||
|
|
||||||
if (component.visualDesigned) {
|
await recreatePartialSimulation();
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addDependency(String depId, Tuple2<ProjectEntry, ComponentEntry> dependency, {bool modifyCurrentComponent = false}) {
|
||||||
|
_dependenciesMap[depId] = dependency;
|
||||||
|
if (modifyCurrentComponent && _currentComponent?.dependencies.contains(depId) == false) {
|
||||||
|
_currentComponent = _currentComponent?.copyWith(
|
||||||
|
dependencies: (_currentComponent?.dependencies ?? []) + [depId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeDependency(String depId, {bool modifyCurrentComponent = false}) {
|
||||||
|
_dependenciesMap.remove(depId);
|
||||||
|
if (modifyCurrentComponent && _currentComponent?.dependencies.contains(depId) == true) {
|
||||||
|
_currentComponent = _currentComponent?.copyWith(
|
||||||
|
dependencies: _currentComponent?.dependencies.where((dep) => dep != depId).toList() ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recreatePartialSimulation() async {
|
||||||
|
if (_currentComponent!.visualDesigned) {
|
||||||
_partialVisualSimulation = await PartialVisualSimulation.init(
|
_partialVisualSimulation = await PartialVisualSimulation.init(
|
||||||
project: project,
|
project: _currentProject!,
|
||||||
component: component,
|
component: _currentComponent!,
|
||||||
state: this,
|
state: this,
|
||||||
onRequiredDependency: _onRequiredDependency,
|
onRequiredDependency: _onRequiredDependency,
|
||||||
);
|
);
|
||||||
|
@ -136,6 +176,8 @@ class ComponentState extends ChangeNotifier {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasDependency(String depId) => _dependenciesMap.containsKey(depId);
|
||||||
|
|
||||||
void noComponent() {
|
void noComponent() {
|
||||||
_dependenciesMap.clear();
|
_dependenciesMap.clear();
|
||||||
_currentProject = null;
|
_currentProject = null;
|
||||||
|
|
|
@ -90,8 +90,8 @@ class ProjectState extends ChangeNotifier {
|
||||||
await _updateIndex(
|
await _updateIndex(
|
||||||
index.copyWith(
|
index.copyWith(
|
||||||
components: index.components
|
components: index.components
|
||||||
.where((c) => c.componentId != component.componentId)
|
.map((c) => c.componentId == component.componentId ? component : c)
|
||||||
.toList() + [component],
|
.toList(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,8 +73,7 @@ class ProjectsState extends ChangeNotifier {
|
||||||
await _updateIndex(
|
await _updateIndex(
|
||||||
index.copyWith(
|
index.copyWith(
|
||||||
projects: index.projects
|
projects: index.projects
|
||||||
.where((p) => p.projectId != project.projectId)
|
.map((p) => p.projectId == project.projectId ? project : p)
|
||||||
.followedBy([project])
|
|
||||||
.toList()
|
.toList()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -24,7 +24,9 @@ class SimulatedComponent {
|
||||||
String instanceId, String? depId) async {
|
String instanceId, String? depId) async {
|
||||||
if (!_instances.containsKey(instanceId)) {
|
if (!_instances.containsKey(instanceId)) {
|
||||||
if (depId != null) {
|
if (depId != null) {
|
||||||
_instances[instanceId] = await onRequiredDependency(depId);
|
final splitted = depId.split('/');
|
||||||
|
final projectId = splitted[0] == 'self' ? project.projectId : splitted[0];
|
||||||
|
_instances[instanceId] = await onRequiredDependency('$projectId/${splitted[1]}');
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Attempted to get instance of unknown component');
|
throw Exception('Attempted to get instance of unknown component');
|
||||||
}
|
}
|
||||||
|
@ -177,7 +179,7 @@ class PartialVisualSimulation with ChangeNotifier {
|
||||||
if (depId != null) {
|
if (depId != null) {
|
||||||
_instances[instanceId] = await onRequiredDependency(depId);
|
_instances[instanceId] = await onRequiredDependency(depId);
|
||||||
} else {
|
} else {
|
||||||
throw Exception('Attempted to get instance of unknown component');
|
throw Exception('Attempted to get instance of unknown component: $instanceId');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return _instances[instanceId]!;
|
return _instances[instanceId]!;
|
||||||
|
|
Loading…
Add table
Reference in a new issue