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.
447 lines
13 KiB
447 lines
13 KiB
import 'dart:math'; |
|
import 'dart:ui'; |
|
|
|
import 'package:flutter/material.dart'; |
|
import 'package:flutter_hooks/flutter_hooks.dart'; |
|
|
|
class VisualComponent extends HookWidget { |
|
final String name; |
|
final List<String> inputs; |
|
final List<String> outputs; |
|
final Map<String, Color?> inputColors; |
|
final Map<String, Color?> 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<String, Color?>? inputColors, |
|
Map<String, Color?>? outputColors, |
|
this.isNextToSimulate = false, |
|
this.onInputHovered, |
|
this.onInputUnhovered, |
|
this.onOutputHovered, |
|
this.onOutputUnhovered, |
|
}) |
|
: inputColors = inputColors ?? {} |
|
, outputColors = outputColors ?? {} |
|
, assert((onInputHovered == null) == (onInputUnhovered == null)) |
|
, assert((onOutputHovered == null) == (onOutputUnhovered == null)); |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
final nextToSimulateFlashingAnimation = useAnimationController( |
|
duration: const Duration(milliseconds: 500), |
|
initialValue: 0.0, |
|
lowerBound: 0.0, |
|
upperBound: 1.0, |
|
); |
|
useEffect(() { |
|
if (isNextToSimulate) { |
|
nextToSimulateFlashingAnimation.repeat( |
|
reverse: true, |
|
); |
|
} |
|
else { |
|
nextToSimulateFlashingAnimation.reset(); |
|
} |
|
|
|
return null; |
|
}, [isNextToSimulate]); |
|
final nextToSimAnimProgress = useAnimation(nextToSimulateFlashingAnimation); |
|
|
|
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<double>(0, (previousValue, element) => max(previousValue, element)); |
|
final outputsWidth = outputs.map((output) => IOLabel.getNeededWidth(context, output)).fold<double>(0, (previousValue, element) => max(previousValue, element)); |
|
|
|
return MouseRegion( |
|
onEnter: onInputHovered == null && onOutputHovered == null ? (event) => hovered.value = true : null, |
|
onExit: onInputUnhovered == null && onOutputUnhovered== null ? (event) => hovered.value = false : null, |
|
child: Row( |
|
children: [ |
|
Column( |
|
children: [ |
|
for (final input in inputs) Padding( |
|
padding: const EdgeInsets.symmetric(vertical: 5.0), |
|
child: IOLabel( |
|
label: input, |
|
input: true, |
|
lineColor: hovered.value |
|
? 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, |
|
), |
|
), |
|
], |
|
), |
|
Container( |
|
width: 100, |
|
decoration: BoxDecoration( |
|
border: Border.all( |
|
color: Color.lerp( |
|
hovered.value ? Theme.of(context).colorScheme.primary : Colors.black, |
|
Colors.blue, |
|
nextToSimAnimProgress, |
|
)!, |
|
), |
|
), |
|
child: Center( |
|
child: Text( |
|
name, |
|
softWrap: true, |
|
), |
|
), |
|
), |
|
Column( |
|
children: [ |
|
for (final output in outputs) Padding( |
|
padding: const EdgeInsets.symmetric(vertical: 5.0), |
|
child: IOLabel( |
|
label: output, |
|
input: false, |
|
lineColor: hovered.value |
|
? 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, |
|
), |
|
), |
|
], |
|
), |
|
], |
|
), |
|
); |
|
} |
|
|
|
static double getNeededWidth(BuildContext context, List<String> inputs, List<String> outputs, [TextStyle? textStyle]) { |
|
final inputsWidth = inputs.map((input) => IOLabel.getNeededWidth(context, input, textStyle)).fold<double>(0, (previousValue, element) => max(previousValue, element)); |
|
final outputsWidth = outputs.map((output) => IOLabel.getNeededWidth(context, output, textStyle)).fold<double>(0, (previousValue, element) => max(previousValue, element)); |
|
return inputsWidth + outputsWidth + 100; |
|
} |
|
|
|
static double getHeightOfIO(BuildContext context, List<String> options, int index, [TextStyle? textStyle]) { |
|
assert(index <= options.length); |
|
getHeightOfText(String text) { |
|
final textPainter = TextPainter( |
|
text: TextSpan( |
|
text: text, |
|
style: (textStyle ?? DefaultTextStyle.of(context).style).merge( |
|
const TextStyle( |
|
inherit: true, |
|
fontFeatures: [ |
|
FontFeature.tabularFigures(), |
|
], |
|
), |
|
), |
|
), |
|
textDirection: TextDirection.ltr, |
|
maxLines: 1, |
|
)..layout(); |
|
return textPainter.height; |
|
} |
|
var result = 0.0; |
|
for (var i = 0; i < index; i++) { |
|
result += 5.0 + getHeightOfText(options[i]) + 5.0; |
|
} |
|
if (index < options.length) { |
|
result += 5.0 + getHeightOfText(options[index]); |
|
} |
|
return result; |
|
} |
|
} |
|
|
|
class IOComponent extends HookWidget { |
|
final bool input; |
|
final String name; |
|
final double width; |
|
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, |
|
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) => onHovered != null ? onHovered!() : hovered.value = true, |
|
onExit: (event) => onUnhovered != null ? onUnhovered!() : hovered.value = false, |
|
hitTestBehavior: HitTestBehavior.translucent, |
|
opaque: false, |
|
child: GestureDetector( |
|
behavior: HitTestBehavior.translucent, |
|
onTap: onTap, |
|
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, |
|
children: [ |
|
if (input) Container( |
|
width: circleDiameter, |
|
height: circleDiameter, |
|
decoration: BoxDecoration( |
|
border: Border.all(color: animLineColor), |
|
shape: BoxShape.circle, |
|
color: color, |
|
), |
|
), |
|
Padding( |
|
padding: EdgeInsets.only(bottom: circleDiameter - 2), |
|
child: IOLabel( |
|
label: name, |
|
input: !input, |
|
lineColor: animLineColor, |
|
width: width - circleDiameter, |
|
), |
|
), |
|
if (!input) Container( |
|
width: circleDiameter, |
|
height: circleDiameter, |
|
decoration: BoxDecoration( |
|
border: Border.all(color: animLineColor), |
|
shape: BoxShape.circle, |
|
color: color, |
|
), |
|
), |
|
], |
|
); |
|
} |
|
), |
|
), |
|
); |
|
} |
|
|
|
static double getNeededWidth(BuildContext context, String name, {double circleDiameter = 20, TextStyle? textStyle}) { |
|
return circleDiameter + IOLabel.getNeededWidth(context, name, textStyle); |
|
} |
|
} |
|
|
|
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, 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); |
|
|
|
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(), |
|
], |
|
), |
|
), |
|
), |
|
), |
|
), |
|
); |
|
} |
|
|
|
static double getNeededWidth(BuildContext context, String text, [TextStyle? textStyle]) { |
|
final textPainter = TextPainter( |
|
text: TextSpan( |
|
text: text, |
|
style: (textStyle ?? DefaultTextStyle.of(context).style).merge( |
|
const TextStyle( |
|
inherit: true, |
|
fontFeatures: [ |
|
FontFeature.tabularFigures(), |
|
], |
|
), |
|
), |
|
), |
|
textDirection: TextDirection.ltr, |
|
maxLines: 1, |
|
)..layout(); |
|
return textPainter.width + 10; |
|
} |
|
} |
|
|
|
class WireWidget extends StatelessWidget { |
|
final Offset from; |
|
final Offset to; |
|
final Color color; |
|
|
|
const WireWidget({ |
|
required this.from, |
|
required this.to, |
|
this.color = Colors.black, |
|
super.key, |
|
}); |
|
|
|
@override |
|
Widget build(BuildContext context) { |
|
return CustomPaint( |
|
painter: _WireCustomPainter( |
|
color: color, |
|
primaryDiagonal: |
|
(from.dx < to.dx && from.dy < to.dy) || |
|
(from.dx > to.dx && from.dy > to.dy), |
|
), |
|
child: SizedBox( |
|
height: (to - from).dy.abs(), |
|
width: (to - from).dx.abs(), |
|
), |
|
); |
|
} |
|
} |
|
|
|
class _WireCustomPainter extends CustomPainter { |
|
final Color color; |
|
final bool primaryDiagonal; |
|
|
|
const _WireCustomPainter({required this.color, required this.primaryDiagonal}); |
|
|
|
@override |
|
void paint(Canvas canvas, Size size) { |
|
final paint = Paint() |
|
..color = color; |
|
if (primaryDiagonal) { |
|
canvas.drawLine( |
|
Offset.zero, |
|
Offset(size.width, size.height), |
|
paint, |
|
); |
|
} |
|
else { |
|
canvas.drawLine( |
|
Offset(size.width, 0), |
|
Offset(0, size.height), |
|
paint, |
|
); |
|
} |
|
} |
|
|
|
@override |
|
bool shouldRepaint(covariant CustomPainter oldDelegate) { |
|
return true; |
|
} |
|
}
|
|
|