feat(editor): reduce-motion clamp + edge gradient + breathing pulse + style sheet
Four polish features in one push. 1. Reduce-motion clamp. FaiEditorStyle.clampedForA11y( disableAnimations: ...) returns a style with glass, gradient, flow animation, and node shadows forced off when the OS reduce-motion preference is set. FlowCanvas + FlowEditorPage call it on every build with MediaQuery.disableAnimationsOf(context) so operators on accessibility settings get a flat, static editor without an explicit Studio setting. 2. Edge gradient source→target. EdgePainter now accepts an optional colorEnd per segment and paints a linear shader along the bezier when both ends carry distinct type accents. In practice this draws attention to type mismatches: a text→json wire reads as a colour transition; a same-type wire stays a solid colour. ModuleSpec lookup at both edge endpoints feeds the colour pair. 3. Breathing pulse on running steps. FlowNode accepts a Listenable pulse; when status=running AND the canvas's _flowAnim is ticking, the node wraps in an AnimatedBuilder that drives a sine-wave shadow halo (alpha + blur + spread oscillating). Stops when the run completes. Gated by _style.flowAnimation so reduce-motion / minimal-style modes stay still. 4. In-editor style sheet. Bottom-right canvas chrome gains a ⚙ Style button that opens a modal bottom sheet with four SwitchListTiles (glass, gradient backdrop, flow animation, node shadows) plus 'Reset to default'. Edits live in _styleOverride on the canvas state — operator can tweak the look for the session without restarting or editing code. No persistence in this version; follow-up could pipe the override through SharedPreferences via the host. Bumps fai_studio_flow_editor to 0.10.0. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
b1fe765468
commit
a71f654162
6 changed files with 377 additions and 73 deletions
|
|
@ -104,4 +104,27 @@ class FaiEditorStyle {
|
||||||
panelBackgroundAlpha: panelBackgroundAlpha ?? this.panelBackgroundAlpha,
|
panelBackgroundAlpha: panelBackgroundAlpha ?? this.panelBackgroundAlpha,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a style clamped to the operator's accessibility
|
||||||
|
/// preferences. When `disableAnimations` (the OS-level
|
||||||
|
/// "Reduce Motion" toggle, surfaced through Flutter's
|
||||||
|
/// MediaQuery) is set, motion-dependent features collapse
|
||||||
|
/// to their off variants regardless of what the caller
|
||||||
|
/// declared:
|
||||||
|
/// - canvasBackdrop → flat
|
||||||
|
/// - panelStyle → solid
|
||||||
|
/// - flowAnimation → false
|
||||||
|
/// - nodeShadows → false
|
||||||
|
/// Tints + colours are unaffected.
|
||||||
|
FaiEditorStyle clampedForA11y({required bool disableAnimations}) {
|
||||||
|
if (!disableAnimations) return this;
|
||||||
|
return FaiEditorStyle(
|
||||||
|
panelStyle: EditorPanelStyle.solid,
|
||||||
|
canvasBackdrop: EditorCanvasBackdrop.flat,
|
||||||
|
flowAnimation: false,
|
||||||
|
nodeShadows: false,
|
||||||
|
panelBlurSigma: panelBlurSigma,
|
||||||
|
panelBackgroundAlpha: panelBackgroundAlpha,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -454,7 +454,12 @@ outputs:
|
||||||
// operator's first instinct is the right action rather
|
// operator's first instinct is the right action rather
|
||||||
// than staring at an empty grid.
|
// than staring at an empty grid.
|
||||||
final hasSteps = _controller.graph.steps.isNotEmpty;
|
final hasSteps = _controller.graph.steps.isNotEmpty;
|
||||||
final style = widget.style ?? FaiEditorStyle.modern;
|
// Honor the OS reduce-motion preference for the
|
||||||
|
// properties-panel glass effect — same clamp the canvas
|
||||||
|
// applies to its own surfaces.
|
||||||
|
final style = (widget.style ?? FaiEditorStyle.modern).clampedForA11y(
|
||||||
|
disableAnimations: MediaQuery.disableAnimationsOf(context),
|
||||||
|
);
|
||||||
final glass = style.panelStyle == EditorPanelStyle.glass;
|
final glass = style.panelStyle == EditorPanelStyle.glass;
|
||||||
final hasSelection = _controller.selectedStepId != null;
|
final hasSelection = _controller.selectedStepId != null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
// canvas; the painter itself is wrapped in IgnorePointer in
|
// canvas; the painter itself is wrapped in IgnorePointer in
|
||||||
// the canvas so nothing here eats taps meant for nodes.
|
// the canvas so nothing here eats taps meant for nodes.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Which horizontal edge of its host node a port sits on.
|
/// Which horizontal edge of its host node a port sits on.
|
||||||
|
|
@ -38,6 +40,14 @@ class EdgeSegment {
|
||||||
/// bytes=orange, …) instead of one uniform muted grey.
|
/// bytes=orange, …) instead of one uniform muted grey.
|
||||||
final Color? color;
|
final Color? color;
|
||||||
|
|
||||||
|
/// Optional second colour for a source→target gradient.
|
||||||
|
/// When both [color] and [colorEnd] are set, the painter
|
||||||
|
/// draws the wire with a linear shader running from
|
||||||
|
/// `color` at `from` to `colorEnd` at `to` — operators
|
||||||
|
/// can read flow direction from the colour transition
|
||||||
|
/// alone. When `colorEnd` is null the wire stays solid.
|
||||||
|
final Color? colorEnd;
|
||||||
|
|
||||||
/// When true, the segment renders as marching dashes
|
/// When true, the segment renders as marching dashes
|
||||||
/// driven by the painter's `phase` parameter — used to
|
/// driven by the painter's `phase` parameter — used to
|
||||||
/// show "data is flowing right now" for edges pointing at
|
/// show "data is flowing right now" for edges pointing at
|
||||||
|
|
@ -52,6 +62,7 @@ class EdgeSegment {
|
||||||
this.shortenFrom = true,
|
this.shortenFrom = true,
|
||||||
this.shortenTo = true,
|
this.shortenTo = true,
|
||||||
this.color,
|
this.color,
|
||||||
|
this.colorEnd,
|
||||||
this.animated = false,
|
this.animated = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +126,19 @@ class EdgePainter extends CustomPainter {
|
||||||
final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
|
final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
|
||||||
final path = _bezier(shortenedFrom, shortenedTo);
|
final path = _bezier(shortenedFrom, shortenedTo);
|
||||||
|
|
||||||
|
// Source→target gradient: when both colours are set,
|
||||||
|
// build a linear shader running from the segment's
|
||||||
|
// `from` to its `to`. The wire's hue transitions along
|
||||||
|
// the bezier — operators read direction from colour
|
||||||
|
// alone without needing arrow heads.
|
||||||
|
Shader? shader;
|
||||||
|
if (seg.colorEnd != null && !seg.animated) {
|
||||||
|
shader = ui.Gradient.linear(shortenedFrom, shortenedTo, [
|
||||||
|
color,
|
||||||
|
seg.colorEnd!,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if (seg.animated) {
|
if (seg.animated) {
|
||||||
// Underlying muted line so the path's geometry stays
|
// Underlying muted line so the path's geometry stays
|
||||||
// visible between dash gaps.
|
// visible between dash gaps.
|
||||||
|
|
@ -129,10 +153,14 @@ class EdgePainter extends CustomPainter {
|
||||||
_drawAnimatedDashes(canvas, path, color, strokeWidth);
|
_drawAnimatedDashes(canvas, path, color, strokeWidth);
|
||||||
} else {
|
} else {
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..color = color
|
|
||||||
..style = PaintingStyle.stroke
|
..style = PaintingStyle.stroke
|
||||||
..strokeWidth = strokeWidth
|
..strokeWidth = strokeWidth
|
||||||
..strokeCap = StrokeCap.round;
|
..strokeCap = StrokeCap.round;
|
||||||
|
if (shader != null) {
|
||||||
|
paint.shader = shader;
|
||||||
|
} else {
|
||||||
|
paint.color = color;
|
||||||
|
}
|
||||||
canvas.drawPath(path, paint);
|
canvas.drawPath(path, paint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,21 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
// pattern button on the bottom-right canvas controls.
|
// pattern button on the bottom-right canvas controls.
|
||||||
_CanvasPattern _pattern = _CanvasPattern.dots;
|
_CanvasPattern _pattern = _CanvasPattern.dots;
|
||||||
|
|
||||||
|
// Effective style for this build pass — `widget.style`
|
||||||
|
// (or _styleOverride) clamped against MediaQuery's
|
||||||
|
// reduce-motion preference. Set at the start of every
|
||||||
|
// build so subsequent helpers (_buildSegments,
|
||||||
|
// _stepPositioned, etc.) read a single coherent style
|
||||||
|
// instead of separately querying the source + OS
|
||||||
|
// preference.
|
||||||
|
FaiEditorStyle _style = FaiEditorStyle.modern;
|
||||||
|
|
||||||
|
// Operator's runtime style override — set by the
|
||||||
|
// bottom-right ⚙ Style sheet. When non-null, replaces
|
||||||
|
// `widget.style` for the lifetime of the canvas state.
|
||||||
|
// null = follow whatever the host passed.
|
||||||
|
FaiEditorStyle? _styleOverride;
|
||||||
|
|
||||||
// ModuleSpec cache: capability identifier → declared
|
// ModuleSpec cache: capability identifier → declared
|
||||||
// inputs/outputs. Populated lazily on each build by
|
// inputs/outputs. Populated lazily on each build by
|
||||||
// walking the current graph's `step.use` values and
|
// walking the current graph's `step.use` values and
|
||||||
|
|
@ -239,6 +254,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final graph = widget.controller.graph;
|
final graph = widget.controller.graph;
|
||||||
final layout = widget.controller.layout;
|
final layout = widget.controller.layout;
|
||||||
|
// Clamp the host-supplied style against the OS reduce-
|
||||||
|
// motion preference. Operators on accessibility settings
|
||||||
|
// — or kiosk-mode deployments — get the same editor with
|
||||||
|
// GPU-heavy blur, gradients, and dash animation switched
|
||||||
|
// off. Helpers below read `_style` instead of `widget.style`
|
||||||
|
// so the effective shape is single-sourced.
|
||||||
|
_style = (_styleOverride ?? widget.style).clampedForA11y(
|
||||||
|
disableAnimations: MediaQuery.disableAnimationsOf(context),
|
||||||
|
);
|
||||||
// Best-effort lazy module-spec fetch so the per-field
|
// Best-effort lazy module-spec fetch so the per-field
|
||||||
// input/output ports paint on the next frame. Cheap when
|
// input/output ports paint on the next frame. Cheap when
|
||||||
// every spec is already cached; only triggers async work
|
// every spec is already cached; only triggers async work
|
||||||
|
|
@ -275,7 +299,7 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
// canvas depth; "flat" preset drops it for the older
|
// canvas depth; "flat" preset drops it for the older
|
||||||
// single-surface look (lower visual chrome, useful for
|
// single-surface look (lower visual chrome, useful for
|
||||||
// low-spec terminals).
|
// low-spec terminals).
|
||||||
decoration: widget.style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||||
? BoxDecoration(
|
? BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
|
|
@ -502,6 +526,12 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _openStyleSheet,
|
||||||
|
icon: const Icon(Icons.tune, size: 18),
|
||||||
|
tooltip: 'Style',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _resetLayout,
|
onPressed: _resetLayout,
|
||||||
icon: const Icon(Icons.dashboard_outlined, size: 18),
|
icon: const Icon(Icons.dashboard_outlined, size: 18),
|
||||||
|
|
@ -680,7 +710,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
kind: kindForStep(step),
|
kind: kindForStep(step),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
status: status,
|
status: status,
|
||||||
elevated: widget.style.nodeShadows,
|
elevated: _style.nodeShadows,
|
||||||
|
// Breathing-pulse on running steps when the active
|
||||||
|
// style allows flow animation. The canvas's existing
|
||||||
|
// _flowAnim ticks 0..1 during runs and is gated on
|
||||||
|
// the same accessibility / style preferences, so we
|
||||||
|
// can route it straight through.
|
||||||
|
pulse: _style.flowAnimation && status == FlowNodeStatus.running
|
||||||
|
? _flowAnim
|
||||||
|
: null,
|
||||||
onTap: () => widget.controller.selectStep(step.id),
|
onTap: () => widget.controller.selectStep(step.id),
|
||||||
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
||||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||||
|
|
@ -867,19 +905,56 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
_hoveredEdge == edgeKey ||
|
_hoveredEdge == edgeKey ||
|
||||||
edge.fromId == widget.controller.selectedStepId ||
|
edge.fromId == widget.controller.selectedStepId ||
|
||||||
edge.toId == widget.controller.selectedStepId;
|
edge.toId == widget.controller.selectedStepId;
|
||||||
// Type-aware wire colour: edges leaving the inputs
|
// Type-aware wire colours at both endpoints. Inputs-
|
||||||
// endpoint carry the declared type's accent so the
|
// endpoint side carries the declared input type's
|
||||||
// operator can trace "this is the text input, this is
|
// accent; step-output side reads the ModuleSpec when
|
||||||
// the bytes input" by colour alone. Edges leaving step
|
// resolved so the wire's target colour matches the
|
||||||
// outputs use a softer "step result" colour because we
|
// declared output type. With both ends typed, the
|
||||||
// don't have module manifests yet to know the actual
|
// painter draws a source→target gradient — operators
|
||||||
// output type. Once the hub exposes manifests, this
|
// read flow direction from colour alone, no arrow
|
||||||
// lookup grows.
|
// heads needed.
|
||||||
Color? wireColor;
|
final theme2 = Theme.of(context);
|
||||||
|
Color? wireColorStart;
|
||||||
if (edge.fromKind == EdgeEndpointKind.inputs) {
|
if (edge.fromKind == EdgeEndpointKind.inputs) {
|
||||||
final input = graph.inputs[edge.fromField];
|
final input = graph.inputs[edge.fromField];
|
||||||
if (input != null) {
|
if (input != null) {
|
||||||
wireColor = _typeAccent(input.type, Theme.of(context));
|
wireColorStart = _typeAccent(input.type, theme2);
|
||||||
|
}
|
||||||
|
} else if (edge.fromKind == EdgeEndpointKind.step) {
|
||||||
|
final fromStep = graph.steps.firstWhere(
|
||||||
|
(s) => s.id == edge.fromId,
|
||||||
|
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||||
|
);
|
||||||
|
final fromSpec = _specForStep(fromStep);
|
||||||
|
if (fromSpec != null) {
|
||||||
|
final f = fromSpec.outputs.firstWhere(
|
||||||
|
(f) => f.name == edge.fromField,
|
||||||
|
orElse: () => const ModuleField(name: '', type: ''),
|
||||||
|
);
|
||||||
|
if (f.type.isNotEmpty) {
|
||||||
|
wireColorStart = _typeAccent(f.type, theme2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color? wireColorEnd;
|
||||||
|
// outputs endpoint has no declared type of its own —
|
||||||
|
// it's a pass-through. The wire reads as the source
|
||||||
|
// type, so we leave wireColorEnd null and the painter
|
||||||
|
// collapses to a solid wire.
|
||||||
|
if (edge.toKind == EdgeEndpointKind.step) {
|
||||||
|
final toStep = graph.steps.firstWhere(
|
||||||
|
(s) => s.id == edge.toId,
|
||||||
|
orElse: () => const FlowStep(id: '__missing__', use: ''),
|
||||||
|
);
|
||||||
|
final toSpec = _specForStep(toStep);
|
||||||
|
if (toSpec != null) {
|
||||||
|
final f = toSpec.inputs.firstWhere(
|
||||||
|
(f) => f.name == edge.toField,
|
||||||
|
orElse: () => const ModuleField(name: '', type: ''),
|
||||||
|
);
|
||||||
|
if (f.type.isNotEmpty) {
|
||||||
|
wireColorEnd = _typeAccent(f.type, theme2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Edge is "live" when its target step is currently
|
// Edge is "live" when its target step is currently
|
||||||
|
|
@ -890,11 +965,19 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
? widget.controller.stepStatuses[edge.toId]
|
? widget.controller.stepStatuses[edge.toId]
|
||||||
: null;
|
: null;
|
||||||
final animated =
|
final animated =
|
||||||
widget.style.flowAnimation && targetStatus == StepRunStatus.running;
|
_style.flowAnimation && targetStatus == StepRunStatus.running;
|
||||||
// Hover / selection always wins the colour treatment
|
// Hover / selection always wins the colour treatment
|
||||||
// — operators need a clear "I'm looking at this one"
|
// — operators need a clear "I'm looking at this one"
|
||||||
// signal that overrides the type colour.
|
// signal that overrides the type colour.
|
||||||
final color = highlight ? null : wireColor;
|
final color = highlight ? null : (wireColorStart ?? wireColorEnd);
|
||||||
|
final colorEnd = highlight ? null : (wireColorEnd ?? wireColorStart);
|
||||||
|
// Only attach the gradient end-colour when both sides
|
||||||
|
// resolved to a real type AND they differ. Same colour
|
||||||
|
// both ends → solid wire (the painter's existing path).
|
||||||
|
final gradientEnd =
|
||||||
|
(color != null && colorEnd != null && color != colorEnd)
|
||||||
|
? colorEnd
|
||||||
|
: null;
|
||||||
out.add(
|
out.add(
|
||||||
EdgeSegment(
|
EdgeSegment(
|
||||||
from: from,
|
from: from,
|
||||||
|
|
@ -903,6 +986,7 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
toSide: toSide,
|
toSide: toSide,
|
||||||
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
|
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
|
||||||
color: color,
|
color: color,
|
||||||
|
colorEnd: gradientEnd,
|
||||||
animated: animated,
|
animated: animated,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -1139,6 +1223,116 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
..scaleByDouble(level, level, 1, 1);
|
..scaleByDouble(level, level, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modal bottom sheet listing the four style toggles
|
||||||
|
/// (glass, gradient backdrop, flow animation, node
|
||||||
|
/// shadows). Edits are kept in `_styleOverride` for the
|
||||||
|
/// lifetime of the canvas; "Reset to host default" drops
|
||||||
|
/// the override and the host-supplied style wins again.
|
||||||
|
Future<void> _openStyleSheet() async {
|
||||||
|
// Seed from the current effective source (override or
|
||||||
|
// host) so toggles open in the right state.
|
||||||
|
final base = _styleOverride ?? widget.style;
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
var draft = base;
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setLocal) {
|
||||||
|
Widget tile({
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required bool value,
|
||||||
|
required ValueChanged<bool> onChanged,
|
||||||
|
}) {
|
||||||
|
return SwitchListTile(
|
||||||
|
title: Text(title),
|
||||||
|
subtitle: Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
value: value,
|
||||||
|
onChanged: (v) {
|
||||||
|
setLocal(() => onChanged(v));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Editor style'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Toggles apply to this canvas for the rest of the session.',
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _styleOverride = null);
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
},
|
||||||
|
child: const Text('Reset to default'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tile(
|
||||||
|
title: 'Frosted glass panels',
|
||||||
|
subtitle: 'BackdropFilter blur on the properties panel.',
|
||||||
|
value: draft.panelStyle == EditorPanelStyle.glass,
|
||||||
|
onChanged: (v) {
|
||||||
|
draft = draft.copyWith(
|
||||||
|
panelStyle: v
|
||||||
|
? EditorPanelStyle.glass
|
||||||
|
: EditorPanelStyle.solid,
|
||||||
|
);
|
||||||
|
setState(() => _styleOverride = draft);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tile(
|
||||||
|
title: 'Gradient canvas backdrop',
|
||||||
|
subtitle: 'Subtle diagonal gradient on the canvas.',
|
||||||
|
value:
|
||||||
|
draft.canvasBackdrop == EditorCanvasBackdrop.gradient,
|
||||||
|
onChanged: (v) {
|
||||||
|
draft = draft.copyWith(
|
||||||
|
canvasBackdrop: v
|
||||||
|
? EditorCanvasBackdrop.gradient
|
||||||
|
: EditorCanvasBackdrop.flat,
|
||||||
|
);
|
||||||
|
setState(() => _styleOverride = draft);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tile(
|
||||||
|
title: 'Flow animation',
|
||||||
|
subtitle:
|
||||||
|
'Marching dashes + breathing pulse during runs.',
|
||||||
|
value: draft.flowAnimation,
|
||||||
|
onChanged: (v) {
|
||||||
|
draft = draft.copyWith(flowAnimation: v);
|
||||||
|
setState(() => _styleOverride = draft);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tile(
|
||||||
|
title: 'Node shadows',
|
||||||
|
subtitle: 'Layered drop shadows under each step card.',
|
||||||
|
value: draft.nodeShadows,
|
||||||
|
onChanged: (v) {
|
||||||
|
draft = draft.copyWith(nodeShadows: v);
|
||||||
|
setState(() => _styleOverride = draft);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Port overlays (drag handles for creating edges) ---
|
// --- Port overlays (drag handles for creating edges) ---
|
||||||
|
|
||||||
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
|
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
// just outside the card border so edges can visually anchor
|
// just outside the card border so edges can visually anchor
|
||||||
// to them.
|
// to them.
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../model/flow_graph.dart';
|
import '../model/flow_graph.dart';
|
||||||
|
|
@ -170,6 +172,14 @@ class FlowNode extends StatelessWidget {
|
||||||
/// glance at the canvas and see what's running.
|
/// glance at the canvas and see what's running.
|
||||||
final FlowNodeStatus status;
|
final FlowNodeStatus status;
|
||||||
|
|
||||||
|
/// 0..1 oscillating value driving the "breathing pulse"
|
||||||
|
/// glow on running steps. The canvas hooks this to its
|
||||||
|
/// flow-animation controller, so it's already gated on the
|
||||||
|
/// active style's `flowAnimation` setting + on whether any
|
||||||
|
/// step is actually running. When null (or status != running),
|
||||||
|
/// no pulse is rendered.
|
||||||
|
final Listenable? pulse;
|
||||||
|
|
||||||
const FlowNode({
|
const FlowNode({
|
||||||
super.key,
|
super.key,
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -189,6 +199,7 @@ class FlowNode extends StatelessWidget {
|
||||||
this.onDragEnd,
|
this.onDragEnd,
|
||||||
this.onContextMenu,
|
this.onContextMenu,
|
||||||
this.elevated = true,
|
this.elevated = true,
|
||||||
|
this.pulse,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -199,66 +210,109 @@ class FlowNode extends StatelessWidget {
|
||||||
inputPortLabels.length,
|
inputPortLabels.length,
|
||||||
outputPortLabels.length,
|
outputPortLabels.length,
|
||||||
);
|
);
|
||||||
|
final cardWidget = _card(theme, accent, height);
|
||||||
|
// Breathing-pulse glow on running steps. The pulse
|
||||||
|
// listenable is the canvas's own flow-animation
|
||||||
|
// controller (0..1), so a single host-side tick drives
|
||||||
|
// every running step's halo in sync. Skipped when no
|
||||||
|
// animation source is wired or the step isn't running.
|
||||||
|
if (pulse != null && status == FlowNodeStatus.running) {
|
||||||
|
return SizedBox(
|
||||||
|
width: NodeGeometry.width,
|
||||||
|
height: height,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: pulse!,
|
||||||
|
builder: (context, child) {
|
||||||
|
// Derive a 0..1 breath value from the controller.
|
||||||
|
// Use a sine wave so the glow eases in + out
|
||||||
|
// rather than ramping linearly — reads as
|
||||||
|
// "breathing" instead of "flickering".
|
||||||
|
final t = (pulse! is Animation<double>)
|
||||||
|
? (pulse! as Animation<double>).value
|
||||||
|
: 0.0;
|
||||||
|
final breath = 0.5 + 0.5 * math.sin(t * 2 * math.pi);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: accent.withValues(alpha: 0.18 + 0.22 * breath),
|
||||||
|
blurRadius: 20 + 8 * breath,
|
||||||
|
spreadRadius: 0.5 + 1.5 * breath,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: cardWidget,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: NodeGeometry.width,
|
width: NodeGeometry.width,
|
||||||
height: height,
|
height: height,
|
||||||
child: GestureDetector(
|
child: cardWidget,
|
||||||
behavior: HitTestBehavior.opaque,
|
);
|
||||||
onTap: onTap,
|
}
|
||||||
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
|
||||||
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
Widget _card(ThemeData theme, Color accent, double height) {
|
||||||
onSecondaryTapDown: onContextMenu == null
|
return GestureDetector(
|
||||||
? null
|
behavior: HitTestBehavior.opaque,
|
||||||
: (details) => onContextMenu!(details.globalPosition),
|
onTap: onTap,
|
||||||
// Long-press is the trackpad fallback for the context
|
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
||||||
// menu — macOS trackpad users whose "two-finger click"
|
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
||||||
// isn't mapped to secondary still get the same menu.
|
onSecondaryTapDown: onContextMenu == null
|
||||||
onLongPressStart: onContextMenu == null
|
? null
|
||||||
? null
|
: (details) => onContextMenu!(details.globalPosition),
|
||||||
: (details) => onContextMenu!(details.globalPosition),
|
// Long-press is the trackpad fallback for the context
|
||||||
child: AnimatedContainer(
|
// menu — macOS trackpad users whose "two-finger click"
|
||||||
duration: const Duration(milliseconds: 180),
|
// isn't mapped to secondary still get the same menu.
|
||||||
curve: Curves.easeOutCubic,
|
onLongPressStart: onContextMenu == null
|
||||||
decoration: BoxDecoration(
|
? null
|
||||||
color: theme.colorScheme.surfaceContainerHigh,
|
: (details) => onContextMenu!(details.globalPosition),
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
child: AnimatedContainer(
|
||||||
border: Border.all(
|
duration: const Duration(milliseconds: 180),
|
||||||
color: selected ? accent : theme.dividerColor,
|
curve: Curves.easeOutCubic,
|
||||||
width: selected ? 2 : 1,
|
decoration: BoxDecoration(
|
||||||
),
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
// Layered shadows give nodes real depth: a sharp
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
// inner shadow + a softer outer halo. Selected
|
border: Border.all(
|
||||||
// nodes get a coloured accent halo on top so the
|
color: selected ? accent : theme.dividerColor,
|
||||||
// selection state reads from across the canvas.
|
width: selected ? 2 : 1,
|
||||||
// When the active style turns elevation off, we
|
|
||||||
// drop the shadows entirely for a flat-design look.
|
|
||||||
boxShadow: elevated
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(
|
|
||||||
alpha: selected ? 0.32 : 0.18,
|
|
||||||
),
|
|
||||||
blurRadius: selected ? 18 : 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
if (selected)
|
|
||||||
BoxShadow(
|
|
||||||
color: accent.withValues(alpha: 0.30),
|
|
||||||
blurRadius: 24,
|
|
||||||
spreadRadius: 1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
// Layered shadows give nodes real depth: a sharp
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
// inner shadow + a softer outer halo. Selected
|
||||||
child: Column(
|
// nodes get a coloured accent halo on top so the
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
// selection state reads from across the canvas.
|
||||||
children: [
|
// When the active style turns elevation off, we
|
||||||
_header(theme, accent),
|
// drop the shadows entirely for a flat-design look.
|
||||||
Expanded(child: _body(theme)),
|
boxShadow: elevated
|
||||||
],
|
? [
|
||||||
),
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: selected ? 0.32 : 0.18,
|
||||||
|
),
|
||||||
|
blurRadius: selected ? 18 : 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
if (selected)
|
||||||
|
BoxShadow(
|
||||||
|
color: accent.withValues(alpha: 0.30),
|
||||||
|
blurRadius: 24,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_header(theme, accent),
|
||||||
|
Expanded(child: _body(theme)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: fai_studio_flow_editor
|
name: fai_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.9.0
|
version: 0.10.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue