From 1f5601a461097c62140354fb0e9b9bdea598b89c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 18:10:51 +0200 Subject: [PATCH] feat(editor): FaiEditorStyle + frosted glass panel + pattern/zoom dropdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/fai_studio_flow_editor.dart | 2 + lib/src/editor_style.dart | 107 +++++++++++++++++ lib/src/flow_editor_page.dart | 167 +++++++++++++++++--------- lib/src/widgets/flow_canvas.dart | 200 +++++++++++++++++++++---------- lib/src/widgets/flow_node.dart | 38 ++++-- pubspec.yaml | 2 +- 6 files changed, 386 insertions(+), 130 deletions(-) create mode 100644 lib/src/editor_style.dart diff --git a/lib/fai_studio_flow_editor.dart b/lib/fai_studio_flow_editor.dart index ad9f3c4..0ca9e2d 100644 --- a/lib/fai_studio_flow_editor.dart +++ b/lib/fai_studio_flow_editor.dart @@ -22,6 +22,8 @@ /// returns when the run finishes. library; +export 'src/editor_style.dart' + show FaiEditorStyle, EditorCanvasBackdrop, EditorPanelStyle; export 'src/flow_editor_page.dart' show FlowEditorPage; export 'src/l10n.dart' show FlowEditorLocale; export 'src/run_driver.dart' diff --git a/lib/src/editor_style.dart b/lib/src/editor_style.dart new file mode 100644 index 0000000..e1f3985 --- /dev/null +++ b/lib/src/editor_style.dart @@ -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, + ); + } +} diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 35b33c3..b9617e0 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -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 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()), ], ], ); diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index c24dec3..3737bb4 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -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 createState() => _FlowCanvasState(); @@ -180,21 +186,23 @@ class _FlowCanvasState extends State }); } 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 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 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( + 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 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 } } // 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 // --- 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 }; } - 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 _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) --- diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 68b835c..d2ff009 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -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), diff --git a/pubspec.yaml b/pubspec.yaml index 4883c33..6533595 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.7.0 +version: 0.8.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor