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
|
|
@ -123,6 +123,21 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
// pattern button on the bottom-right canvas controls.
|
||||
_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
|
||||
// inputs/outputs. Populated lazily on each build by
|
||||
// walking the current graph's `step.use` values and
|
||||
|
|
@ -239,6 +254,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
final theme = Theme.of(context);
|
||||
final graph = widget.controller.graph;
|
||||
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
|
||||
// input/output ports paint on the next frame. Cheap when
|
||||
// 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
|
||||
// single-surface look (lower visual chrome, useful for
|
||||
// low-spec terminals).
|
||||
decoration: widget.style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||
decoration: _style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||
? BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
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(
|
||||
onPressed: _resetLayout,
|
||||
icon: const Icon(Icons.dashboard_outlined, size: 18),
|
||||
|
|
@ -680,7 +710,15 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
kind: kindForStep(step),
|
||||
selected: selected,
|
||||
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),
|
||||
onDrag: (delta) => _applyDrag(step.id, pos, delta, cardHeight),
|
||||
onContextMenu: (globalPos) => _showStepContextMenu(step, globalPos),
|
||||
|
|
@ -867,19 +905,56 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
_hoveredEdge == edgeKey ||
|
||||
edge.fromId == widget.controller.selectedStepId ||
|
||||
edge.toId == widget.controller.selectedStepId;
|
||||
// Type-aware wire colour: edges leaving the inputs
|
||||
// endpoint carry the declared type's accent so the
|
||||
// operator can trace "this is the text input, this is
|
||||
// the bytes input" by colour alone. Edges leaving step
|
||||
// outputs use a softer "step result" colour because we
|
||||
// don't have module manifests yet to know the actual
|
||||
// output type. Once the hub exposes manifests, this
|
||||
// lookup grows.
|
||||
Color? wireColor;
|
||||
// Type-aware wire colours at both endpoints. Inputs-
|
||||
// endpoint side carries the declared input type's
|
||||
// accent; step-output side reads the ModuleSpec when
|
||||
// resolved so the wire's target colour matches the
|
||||
// declared output type. With both ends typed, the
|
||||
// painter draws a source→target gradient — operators
|
||||
// read flow direction from colour alone, no arrow
|
||||
// heads needed.
|
||||
final theme2 = Theme.of(context);
|
||||
Color? wireColorStart;
|
||||
if (edge.fromKind == EdgeEndpointKind.inputs) {
|
||||
final input = graph.inputs[edge.fromField];
|
||||
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
|
||||
|
|
@ -890,11 +965,19 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
? widget.controller.stepStatuses[edge.toId]
|
||||
: null;
|
||||
final animated =
|
||||
widget.style.flowAnimation && targetStatus == StepRunStatus.running;
|
||||
_style.flowAnimation && targetStatus == StepRunStatus.running;
|
||||
// Hover / selection always wins the colour treatment
|
||||
// — operators need a clear "I'm looking at this one"
|
||||
// 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(
|
||||
EdgeSegment(
|
||||
from: from,
|
||||
|
|
@ -903,6 +986,7 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
toSide: toSide,
|
||||
accent: highlight ? EdgeAccent.highlight : EdgeAccent.normal,
|
||||
color: color,
|
||||
colorEnd: gradientEnd,
|
||||
animated: animated,
|
||||
),
|
||||
);
|
||||
|
|
@ -1139,6 +1223,116 @@ class _FlowCanvasState extends State<FlowCanvas>
|
|||
..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) ---
|
||||
|
||||
Iterable<Widget> _portOverlays(FlowGraph graph, FlowLayout layout) sync* {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue