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
|
|
@ -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) ---
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue