feat(editor): FaiEditorStyle + frosted glass panel + pattern/zoom dropdowns

Visual-effect knobs (FaiEditorStyle) the host can override —
distinct from ColorScheme, which stays orthogonal. Today
Studio passes it through FlowEditorPage's new style:
parameter; tomorrow a theme plugin can ship both colours and
effects in one move.

Two presets ship today: modern (frosted glass, gradient
backdrop, animated flow, shadowed nodes — the default) and
minimal (everything off, flat surfaces — accessibility /
reduce-motion / low-spec).

Properties panel rebuilt as a floating sidebar with
BackdropFilter blur when glass-style is on; falls back to
the pre-0.7 flush-divider layout under solid style.

Pattern + zoom controls promoted to PopupMenuButton dropdowns
so operators reach a specific zoom level / pattern in one
click instead of cycling.

Bumps fai_studio_flow_editor to 0.8.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 18:10:51 +02:00
parent 7aeeae717f
commit 1f5601a461
6 changed files with 386 additions and 130 deletions

View file

@ -35,6 +35,7 @@
import 'package:flutter/material.dart';
import '../editor_controller.dart';
import '../editor_style.dart';
import '../model/auto_layout.dart';
import '../model/flow_graph.dart';
import '../model/layout_store.dart';
@ -57,7 +58,12 @@ const NodePosition _outputsFallback = NodePosition(1200, 80);
class FlowCanvas extends StatefulWidget {
final FlowEditorController controller;
const FlowCanvas({super.key, required this.controller});
final FaiEditorStyle style;
const FlowCanvas({
super.key,
required this.controller,
this.style = FaiEditorStyle.modern,
});
@override
State<FlowCanvas> createState() => _FlowCanvasState();
@ -180,21 +186,23 @@ class _FlowCanvasState extends State<FlowCanvas>
});
}
return Container(
// Two-stop linear gradient gives the canvas a subtle
// depth cue corners feel slightly recessed, centre
// reads as the working area. Very low contrast so it
// doesn't compete with the nodes; just enough to make
// a flat dark page feel "lit" instead of "painted".
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surfaceContainer,
theme.colorScheme.surface,
],
),
),
// Backdrop honours the active editor style. Default is
// a subtle two-stop diagonal gradient that gives the
// 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
? BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.surfaceContainer,
theme.colorScheme.surface,
],
),
)
: BoxDecoration(color: theme.colorScheme.surface),
child: Stack(
children: [
InteractiveViewer(
@ -364,11 +372,31 @@ class _FlowCanvasState extends State<FlowCanvas>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _cyclePattern,
// Pattern dropdown pick directly instead
// of cycling. Faster when the operator
// already knows which look they want.
PopupMenuButton<_CanvasPattern>(
icon: Icon(_patternIcon(), size: 18),
tooltip: 'Background: ${_patternLabel()}',
visualDensity: VisualDensity.compact,
tooltip: 'Background',
initialValue: _pattern,
onSelected: (p) => setState(() => _pattern = p),
itemBuilder: (_) => [
_patternMenuItem(
_CanvasPattern.dots,
Icons.grain,
'Dots',
),
_patternMenuItem(
_CanvasPattern.grid,
Icons.grid_on,
'Grid',
),
_patternMenuItem(
_CanvasPattern.blank,
Icons.layers_clear,
'Blank',
),
],
),
IconButton(
onPressed: _resetLayout,
@ -382,25 +410,42 @@ class _FlowCanvasState extends State<FlowCanvas>
tooltip: 'Fit to screen',
visualDensity: VisualDensity.compact,
),
// Zoom % readout. Tappable to reset zoom
// back to 100 % without losing pan.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xs,
),
child: GestureDetector(
onTap: _resetZoom,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 4,
// Zoom % dropdown preset levels (25 %
// 200 %) plus "Fit". Faster than scroll-
// wheel zooming to a specific level.
PopupMenuButton<double>(
tooltip: 'Zoom',
onSelected: _setZoom,
itemBuilder: (_) => [
_zoomItem(0.25),
_zoomItem(0.5),
_zoomItem(0.75),
_zoomItem(1.0),
_zoomItem(1.25),
_zoomItem(1.5),
_zoomItem(2.0),
const PopupMenuDivider(),
const PopupMenuItem(
value: -1.0,
child: Row(
children: [
Icon(Icons.fit_screen_outlined, size: 16),
SizedBox(width: 8),
Text('Fit to screen'),
],
),
child: Text(
'${(_zoom * 100).round()}%',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.sm,
vertical: 6,
),
child: Text(
'${(_zoom * 100).round()}%',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurfaceVariant,
),
),
),
@ -502,6 +547,7 @@ class _FlowCanvasState extends State<FlowCanvas>
kind: kindForStep(step),
selected: selected,
status: status,
elevated: widget.style.nodeShadows,
onTap: () => widget.controller.selectStep(step.id),
onDrag: (delta) => _applyDrag(
step.id,
@ -675,12 +721,14 @@ class _FlowCanvasState extends State<FlowCanvas>
}
}
// Edge is "live" when its target step is currently
// executing marching dashes show the operator data
// is moving on this wire RIGHT NOW.
// executing AND the active style allows flow animation
// (operators on reduce-motion preferences get a static
// edge during runs).
final targetStatus = edge.toKind == EdgeEndpointKind.step
? widget.controller.stepStatuses[edge.toId]
: null;
final animated = targetStatus == StepRunStatus.running;
final animated =
widget.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.
@ -855,16 +903,6 @@ class _FlowCanvasState extends State<FlowCanvas>
// --- Canvas chrome controls ---
void _cyclePattern() {
setState(() {
_pattern = switch (_pattern) {
_CanvasPattern.dots => _CanvasPattern.grid,
_CanvasPattern.grid => _CanvasPattern.blank,
_CanvasPattern.blank => _CanvasPattern.dots,
};
});
}
IconData _patternIcon() {
return switch (_pattern) {
_CanvasPattern.dots => Icons.grain,
@ -873,20 +911,58 @@ class _FlowCanvasState extends State<FlowCanvas>
};
}
String _patternLabel() {
return switch (_pattern) {
_CanvasPattern.dots => 'dots',
_CanvasPattern.grid => 'grid',
_CanvasPattern.blank => 'blank',
};
PopupMenuItem<_CanvasPattern> _patternMenuItem(
_CanvasPattern value,
IconData icon,
String label,
) {
final selected = _pattern == value;
return PopupMenuItem(
value: value,
child: Row(
children: [
Icon(icon, size: 16),
const SizedBox(width: 8),
Text(label),
if (selected) ...[const Spacer(), const Icon(Icons.check, size: 14)],
],
),
);
}
void _resetZoom() {
// Preserve the current translation, drop the scale back
// to 1×. Operators who panned away keep their position;
// just the zoom resets.
PopupMenuItem<double> _zoomItem(double level) {
final selected = (_zoom - level).abs() < 0.02;
return PopupMenuItem(
value: level,
child: Row(
children: [
SizedBox(
width: 48,
child: Text(
'${(level * 100).round()}%',
style: const TextStyle(fontFamily: 'monospace'),
),
),
if (selected) const Icon(Icons.check, size: 14),
],
),
);
}
/// Apply a discrete zoom level from the dropdown.
/// -1.0 is the sentinel meaning "fit to screen". Anything
/// positive = exact scale; preserve the current translation
/// so the operator doesn't lose their pan when they pick
/// a zoom value.
void _setZoom(double level) {
if (level < 0) {
_fitToContent();
return;
}
final tx = _transform.value.getTranslation();
_transform.value = Matrix4.identity()..translateByDouble(tx.x, tx.y, 0, 1);
_transform.value = Matrix4.identity()
..translateByDouble(tx.x, tx.y, 0, 1)
..scaleByDouble(level, level, 1, 1);
}
// --- Port overlays (drag handles for creating edges) ---