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

107
lib/src/editor_style.dart Normal file
View file

@ -0,0 +1,107 @@
// FaiEditorStyle visual-effect knobs the host can override.
//
// Distinct from ColorScheme: that's WHICH colours. This is
// WHAT EFFECTS to apply. The two are orthogonal: a theme
// plugin can ship its own ColorScheme + its own
// FaiEditorStyle independently, so an operator could pick
// "Solarized colours, no glass" or "Default colours, glass on"
// without one bleeding into the other.
//
// Today the host passes a FaiEditorStyle directly into
// FlowEditorPage (`style:` constructor parameter). Tomorrow,
// the studio.theme.* plugin contract grows a second return
// channel for FaiEditorStyle so a single plugin install
// changes both colours and effects in one move. The editor
// reads the same class either way, so the future migration
// touches Studio's plugin loader, not this package.
import 'package:flutter/material.dart';
/// Three backdrop styles for the canvas surface itself.
/// - flat: solid surface colour, no gradient
/// - gradient: subtle diagonal gradient (currently default)
enum EditorCanvasBackdrop { flat, gradient }
/// Material density for floating panels (properties panel,
/// run progress sheet, etc.).
/// - solid: opaque surface tint (the pre-0.7 look)
/// - glass: BackdropFilter blur with translucent surface
/// tint. Reads as native macOS sidebar / 2026-era
/// desktop. Requires the panel to overlap canvas
/// content for the blur to have anything to blur.
enum EditorPanelStyle { solid, glass }
@immutable
class FaiEditorStyle {
/// Floating panels (properties panel today; potentially
/// run-result sheets, dialogs later) use frosted glass
/// over the canvas content behind them.
final EditorPanelStyle panelStyle;
/// Whether the canvas surface gets the subtle diagonal
/// gradient or stays a flat surface colour. Operators
/// running on low-spec terminals or sensitive accessibility
/// setups may prefer the flat look.
final EditorCanvasBackdrop canvasBackdrop;
/// Whether the marching-dash flow animation runs on edges
/// pointing at currently-executing steps. Off = static
/// edges throughout, useful for "reduce motion" preferences.
final bool flowAnimation;
/// Whether node cards carry layered drop shadows. Off
/// drops them to a single thin border flat-design feel,
/// less visual chrome on tiny screens.
final bool nodeShadows;
/// Blur sigma applied behind glass panels. Higher = softer,
/// lower = sharper background reading. 16 reads as "frosted",
/// 8 as "lightly tinted", 24 as "deep frost".
final double panelBlurSigma;
/// Alpha of the panel's surface tint when in glass mode.
/// Lower = more canvas shows through, higher = panel is
/// almost opaque (loses the glass effect).
final double panelBackgroundAlpha;
const FaiEditorStyle({
this.panelStyle = EditorPanelStyle.glass,
this.canvasBackdrop = EditorCanvasBackdrop.gradient,
this.flowAnimation = true,
this.nodeShadows = true,
this.panelBlurSigma = 16,
this.panelBackgroundAlpha = 0.62,
});
/// The package default frosted glass, gradient backdrop,
/// animated flow, shadowed nodes. Modern desktop feel.
static const FaiEditorStyle modern = FaiEditorStyle();
/// "Minimal" preset for accessibility / low-spec /
/// reduce-motion preferences. Everything off, plain
/// surfaces.
static const FaiEditorStyle minimal = FaiEditorStyle(
panelStyle: EditorPanelStyle.solid,
canvasBackdrop: EditorCanvasBackdrop.flat,
flowAnimation: false,
nodeShadows: false,
);
FaiEditorStyle copyWith({
EditorPanelStyle? panelStyle,
EditorCanvasBackdrop? canvasBackdrop,
bool? flowAnimation,
bool? nodeShadows,
double? panelBlurSigma,
double? panelBackgroundAlpha,
}) {
return FaiEditorStyle(
panelStyle: panelStyle ?? this.panelStyle,
canvasBackdrop: canvasBackdrop ?? this.canvasBackdrop,
flowAnimation: flowAnimation ?? this.flowAnimation,
nodeShadows: nodeShadows ?? this.nodeShadows,
panelBlurSigma: panelBlurSigma ?? this.panelBlurSigma,
panelBackgroundAlpha: panelBackgroundAlpha ?? this.panelBackgroundAlpha,
);
}
}

View file

@ -23,12 +23,14 @@
library;
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'editor_controller.dart';
import 'editor_style.dart';
import 'l10n.dart';
import 'model/flow_graph.dart';
import 'run_driver.dart';
@ -65,12 +67,21 @@ class FlowEditorPage extends StatefulWidget {
/// Empty list = the picker shows a free-form text field.
final List<String> availableCapabilities;
/// Visual-effect overrides frosted glass on / off, canvas
/// backdrop style, flow animation on / off. When null the
/// package's [FaiEditorStyle.modern] preset is used. A
/// theme-plugin host can pass its own to flip the editor
/// between Studio's active style modes without touching
/// the editor's source.
final FaiEditorStyle? style;
const FlowEditorPage({
super.key,
this.initialFlowName,
this.locale = FlowEditorLocale.en,
this.runDriver,
this.availableCapabilities = const [],
this.style,
});
@override
@ -432,71 +443,119 @@ outputs:
}
Widget _graphTab(ThemeData theme) {
// When a step is selected, show the properties panel on
// the right as a fixed sidebar. Selection is owned by
// the controller and ChangeNotifier rebuilds drive the
// panel show / hide. When the flow has no steps at all,
// overlay a call-to-action so the operator's first
// instinct is the right action rather than staring at an
// empty grid.
// When a step is selected, show the properties panel as a
// floating sidebar on the right. In glass-style mode the
// panel overlaps the canvas so the BackdropFilter has
// canvas content to blur. In solid-style mode the panel
// sits flush against the canvas with a divider no
// overlap needed.
//
// When the flow has no steps at all, overlay a CTA so the
// operator's first instinct is the right action rather
// than staring at an empty grid.
final hasSteps = _controller.graph.steps.isNotEmpty;
final style = widget.style ?? FaiEditorStyle.modern;
final glass = style.panelStyle == EditorPanelStyle.glass;
final hasSelection = _controller.selectedStepId != null;
Widget emptyOverlay() => Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(_l.graphEmptyTitle, style: theme.textTheme.titleMedium),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
);
Widget panel() => PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
);
if (glass) {
// Stack layout so the frosted panel can overlap canvas.
return Stack(
children: [
Positioned.fill(
child: FlowCanvas(controller: _controller, style: style),
),
if (!hasSteps) emptyOverlay(),
if (hasSelection)
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 320,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: style.panelBlurSigma,
sigmaY: style.panelBlurSigma,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(
alpha: style.panelBackgroundAlpha,
),
border: Border(
left: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(
alpha: 0.5,
),
width: 1,
),
),
),
child: panel(),
),
),
),
),
],
);
}
// Solid-style: flush sidebar, divider, no blur.
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
children: [
FlowCanvas(controller: _controller),
if (!hasSteps)
Positioned.fill(
child: Container(
color: theme.colorScheme.surface.withValues(alpha: 0.92),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_tree_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: FaiSpace.md),
Text(
_l.graphEmptyTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: FaiSpace.sm),
Text(
_l.graphEmptyBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
FilledButton.icon(
onPressed: _addStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(_l.addStep),
),
],
),
),
),
),
FlowCanvas(controller: _controller, style: style),
if (!hasSteps) emptyOverlay(),
],
),
),
if (_controller.selectedStepId != null) ...[
if (hasSelection) ...[
const VerticalDivider(width: 1),
SizedBox(
width: 320,
child: PropertiesPanel(
controller: _controller,
strings: _l,
availableCapabilities: widget.availableCapabilities,
),
),
SizedBox(width: 320, child: panel()),
],
],
);

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) ---

View file

@ -130,6 +130,11 @@ class FlowNode extends StatelessWidget {
/// Delete / Disconnect inputs etc.).
final void Function(Offset globalPos)? onContextMenu;
/// Whether to paint the layered drop shadows under the
/// node. Off = flat-design feel; on = depth. Driven by
/// the active editor style.
final bool elevated;
/// Live status from the most recent run, when this node is
/// a step. Coloured dot in the header so the operator can
/// glance at the canvas and see what's running.
@ -151,6 +156,7 @@ class FlowNode extends StatelessWidget {
this.onDrag,
this.onDragEnd,
this.onContextMenu,
this.elevated = true,
});
@override
@ -189,19 +195,25 @@ class FlowNode extends StatelessWidget {
// inner shadow + a softer outer halo. Selected
// nodes get a coloured accent halo on top so the
// selection state reads from across the canvas.
boxShadow: [
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,
),
],
// 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(
borderRadius: BorderRadius.circular(FaiRadius.md),