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.
|
||||
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'
|
||||
|
|
|
|||
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;
|
||||
|
||||
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()),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue