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:
parent
7aeeae717f
commit
1f5601a461
6 changed files with 386 additions and 130 deletions
|
|
@ -22,6 +22,8 @@
|
||||||
/// returns when the run finishes.
|
/// returns when the run finishes.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
export 'src/editor_style.dart'
|
||||||
|
show FaiEditorStyle, EditorCanvasBackdrop, EditorPanelStyle;
|
||||||
export 'src/flow_editor_page.dart' show FlowEditorPage;
|
export 'src/flow_editor_page.dart' show FlowEditorPage;
|
||||||
export 'src/l10n.dart' show FlowEditorLocale;
|
export 'src/l10n.dart' show FlowEditorLocale;
|
||||||
export 'src/run_driver.dart'
|
export 'src/run_driver.dart'
|
||||||
|
|
|
||||||
107
lib/src/editor_style.dart
Normal file
107
lib/src/editor_style.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,12 +23,14 @@
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||||
|
|
||||||
import 'editor_controller.dart';
|
import 'editor_controller.dart';
|
||||||
|
import 'editor_style.dart';
|
||||||
import 'l10n.dart';
|
import 'l10n.dart';
|
||||||
import 'model/flow_graph.dart';
|
import 'model/flow_graph.dart';
|
||||||
import 'run_driver.dart';
|
import 'run_driver.dart';
|
||||||
|
|
@ -65,12 +67,21 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
/// Empty list = the picker shows a free-form text field.
|
/// Empty list = the picker shows a free-form text field.
|
||||||
final List<String> availableCapabilities;
|
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({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
this.locale = FlowEditorLocale.en,
|
this.locale = FlowEditorLocale.en,
|
||||||
this.runDriver,
|
this.runDriver,
|
||||||
this.availableCapabilities = const [],
|
this.availableCapabilities = const [],
|
||||||
|
this.style,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -432,71 +443,119 @@ outputs:
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _graphTab(ThemeData theme) {
|
Widget _graphTab(ThemeData theme) {
|
||||||
// When a step is selected, show the properties panel on
|
// When a step is selected, show the properties panel as a
|
||||||
// the right as a fixed sidebar. Selection is owned by
|
// floating sidebar on the right. In glass-style mode the
|
||||||
// the controller and ChangeNotifier rebuilds drive the
|
// panel overlaps the canvas so the BackdropFilter has
|
||||||
// panel show / hide. When the flow has no steps at all,
|
// canvas content to blur. In solid-style mode the panel
|
||||||
// overlay a call-to-action so the operator's first
|
// sits flush against the canvas with a divider — no
|
||||||
// instinct is the right action rather than staring at an
|
// overlap needed.
|
||||||
// empty grid.
|
//
|
||||||
|
// 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 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(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
FlowCanvas(controller: _controller),
|
FlowCanvas(controller: _controller, style: style),
|
||||||
if (!hasSteps)
|
if (!hasSteps) 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_controller.selectedStepId != null) ...[
|
if (hasSelection) ...[
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
SizedBox(
|
SizedBox(width: 320, child: panel()),
|
||||||
width: 320,
|
|
||||||
child: PropertiesPanel(
|
|
||||||
controller: _controller,
|
|
||||||
strings: _l,
|
|
||||||
availableCapabilities: widget.availableCapabilities,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../editor_controller.dart';
|
import '../editor_controller.dart';
|
||||||
|
import '../editor_style.dart';
|
||||||
import '../model/auto_layout.dart';
|
import '../model/auto_layout.dart';
|
||||||
import '../model/flow_graph.dart';
|
import '../model/flow_graph.dart';
|
||||||
import '../model/layout_store.dart';
|
import '../model/layout_store.dart';
|
||||||
|
|
@ -57,7 +58,12 @@ const NodePosition _outputsFallback = NodePosition(1200, 80);
|
||||||
|
|
||||||
class FlowCanvas extends StatefulWidget {
|
class FlowCanvas extends StatefulWidget {
|
||||||
final FlowEditorController controller;
|
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
|
@override
|
||||||
State<FlowCanvas> createState() => _FlowCanvasState();
|
State<FlowCanvas> createState() => _FlowCanvasState();
|
||||||
|
|
@ -180,21 +186,23 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Container(
|
return Container(
|
||||||
// Two-stop linear gradient gives the canvas a subtle
|
// Backdrop honours the active editor style. Default is
|
||||||
// depth cue — corners feel slightly recessed, centre
|
// a subtle two-stop diagonal gradient that gives the
|
||||||
// reads as the working area. Very low contrast so it
|
// canvas depth; "flat" preset drops it for the older
|
||||||
// doesn't compete with the nodes; just enough to make
|
// single-surface look (lower visual chrome, useful for
|
||||||
// a flat dark page feel "lit" instead of "painted".
|
// low-spec terminals).
|
||||||
decoration: BoxDecoration(
|
decoration: widget.style.canvasBackdrop == EditorCanvasBackdrop.gradient
|
||||||
gradient: LinearGradient(
|
? BoxDecoration(
|
||||||
begin: Alignment.topLeft,
|
gradient: LinearGradient(
|
||||||
end: Alignment.bottomRight,
|
begin: Alignment.topLeft,
|
||||||
colors: [
|
end: Alignment.bottomRight,
|
||||||
theme.colorScheme.surfaceContainer,
|
colors: [
|
||||||
theme.colorScheme.surface,
|
theme.colorScheme.surfaceContainer,
|
||||||
],
|
theme.colorScheme.surface,
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: BoxDecoration(color: theme.colorScheme.surface),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
InteractiveViewer(
|
InteractiveViewer(
|
||||||
|
|
@ -364,11 +372,31 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
// Pattern dropdown — pick directly instead
|
||||||
onPressed: _cyclePattern,
|
// of cycling. Faster when the operator
|
||||||
|
// already knows which look they want.
|
||||||
|
PopupMenuButton<_CanvasPattern>(
|
||||||
icon: Icon(_patternIcon(), size: 18),
|
icon: Icon(_patternIcon(), size: 18),
|
||||||
tooltip: 'Background: ${_patternLabel()}',
|
tooltip: 'Background',
|
||||||
visualDensity: VisualDensity.compact,
|
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(
|
IconButton(
|
||||||
onPressed: _resetLayout,
|
onPressed: _resetLayout,
|
||||||
|
|
@ -382,25 +410,42 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
tooltip: 'Fit to screen',
|
tooltip: 'Fit to screen',
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
// Zoom % readout. Tappable to reset zoom
|
// Zoom % dropdown — preset levels (25 % …
|
||||||
// back to 100 % without losing pan.
|
// 200 %) plus "Fit". Faster than scroll-
|
||||||
Padding(
|
// wheel zooming to a specific level.
|
||||||
padding: const EdgeInsets.symmetric(
|
PopupMenuButton<double>(
|
||||||
horizontal: FaiSpace.xs,
|
tooltip: 'Zoom',
|
||||||
),
|
onSelected: _setZoom,
|
||||||
child: GestureDetector(
|
itemBuilder: (_) => [
|
||||||
onTap: _resetZoom,
|
_zoomItem(0.25),
|
||||||
child: Container(
|
_zoomItem(0.5),
|
||||||
padding: const EdgeInsets.symmetric(
|
_zoomItem(0.75),
|
||||||
horizontal: FaiSpace.sm,
|
_zoomItem(1.0),
|
||||||
vertical: 4,
|
_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(
|
child: Padding(
|
||||||
fontFamily: 'monospace',
|
padding: const EdgeInsets.symmetric(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
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),
|
kind: kindForStep(step),
|
||||||
selected: selected,
|
selected: selected,
|
||||||
status: status,
|
status: status,
|
||||||
|
elevated: widget.style.nodeShadows,
|
||||||
onTap: () => widget.controller.selectStep(step.id),
|
onTap: () => widget.controller.selectStep(step.id),
|
||||||
onDrag: (delta) => _applyDrag(
|
onDrag: (delta) => _applyDrag(
|
||||||
step.id,
|
step.id,
|
||||||
|
|
@ -675,12 +721,14 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Edge is "live" when its target step is currently
|
// Edge is "live" when its target step is currently
|
||||||
// executing — marching dashes show the operator data
|
// executing AND the active style allows flow animation
|
||||||
// is moving on this wire RIGHT NOW.
|
// (operators on reduce-motion preferences get a static
|
||||||
|
// edge during runs).
|
||||||
final targetStatus = edge.toKind == EdgeEndpointKind.step
|
final targetStatus = edge.toKind == EdgeEndpointKind.step
|
||||||
? widget.controller.stepStatuses[edge.toId]
|
? widget.controller.stepStatuses[edge.toId]
|
||||||
: null;
|
: null;
|
||||||
final animated = targetStatus == StepRunStatus.running;
|
final animated =
|
||||||
|
widget.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.
|
||||||
|
|
@ -855,16 +903,6 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
|
|
||||||
// --- Canvas chrome controls ---
|
// --- Canvas chrome controls ---
|
||||||
|
|
||||||
void _cyclePattern() {
|
|
||||||
setState(() {
|
|
||||||
_pattern = switch (_pattern) {
|
|
||||||
_CanvasPattern.dots => _CanvasPattern.grid,
|
|
||||||
_CanvasPattern.grid => _CanvasPattern.blank,
|
|
||||||
_CanvasPattern.blank => _CanvasPattern.dots,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _patternIcon() {
|
IconData _patternIcon() {
|
||||||
return switch (_pattern) {
|
return switch (_pattern) {
|
||||||
_CanvasPattern.dots => Icons.grain,
|
_CanvasPattern.dots => Icons.grain,
|
||||||
|
|
@ -873,20 +911,58 @@ class _FlowCanvasState extends State<FlowCanvas>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
String _patternLabel() {
|
PopupMenuItem<_CanvasPattern> _patternMenuItem(
|
||||||
return switch (_pattern) {
|
_CanvasPattern value,
|
||||||
_CanvasPattern.dots => 'dots',
|
IconData icon,
|
||||||
_CanvasPattern.grid => 'grid',
|
String label,
|
||||||
_CanvasPattern.blank => 'blank',
|
) {
|
||||||
};
|
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() {
|
PopupMenuItem<double> _zoomItem(double level) {
|
||||||
// Preserve the current translation, drop the scale back
|
final selected = (_zoom - level).abs() < 0.02;
|
||||||
// to 1×. Operators who panned away keep their position;
|
return PopupMenuItem(
|
||||||
// just the zoom resets.
|
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();
|
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) ---
|
// --- Port overlays (drag handles for creating edges) ---
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,11 @@ class FlowNode extends StatelessWidget {
|
||||||
/// Delete / Disconnect inputs etc.).
|
/// Delete / Disconnect inputs etc.).
|
||||||
final void Function(Offset globalPos)? onContextMenu;
|
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
|
/// Live status from the most recent run, when this node is
|
||||||
/// a step. Coloured dot in the header so the operator can
|
/// a step. Coloured dot in the header so the operator can
|
||||||
/// glance at the canvas and see what's running.
|
/// glance at the canvas and see what's running.
|
||||||
|
|
@ -151,6 +156,7 @@ class FlowNode extends StatelessWidget {
|
||||||
this.onDrag,
|
this.onDrag,
|
||||||
this.onDragEnd,
|
this.onDragEnd,
|
||||||
this.onContextMenu,
|
this.onContextMenu,
|
||||||
|
this.elevated = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -189,19 +195,25 @@ class FlowNode extends StatelessWidget {
|
||||||
// inner shadow + a softer outer halo. Selected
|
// inner shadow + a softer outer halo. Selected
|
||||||
// nodes get a coloured accent halo on top so the
|
// nodes get a coloured accent halo on top so the
|
||||||
// selection state reads from across the canvas.
|
// selection state reads from across the canvas.
|
||||||
boxShadow: [
|
// When the active style turns elevation off, we
|
||||||
BoxShadow(
|
// drop the shadows entirely for a flat-design look.
|
||||||
color: Colors.black.withValues(alpha: selected ? 0.32 : 0.18),
|
boxShadow: elevated
|
||||||
blurRadius: selected ? 18 : 10,
|
? [
|
||||||
offset: const Offset(0, 4),
|
BoxShadow(
|
||||||
),
|
color: Colors.black.withValues(
|
||||||
if (selected)
|
alpha: selected ? 0.32 : 0.18,
|
||||||
BoxShadow(
|
),
|
||||||
color: accent.withValues(alpha: 0.30),
|
blurRadius: selected ? 18 : 10,
|
||||||
blurRadius: 24,
|
offset: const Offset(0, 4),
|
||||||
spreadRadius: 1,
|
),
|
||||||
),
|
if (selected)
|
||||||
],
|
BoxShadow(
|
||||||
|
color: accent.withValues(alpha: 0.30),
|
||||||
|
blurRadius: 24,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||||
|
|
|
||||||
|
|
@ -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.7.0
|
version: 0.8.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