feat(editor): tab-aware action strip + simpler tap handling

Reposition the per-tab action buttons under the TabBar so the
operator's eye flows TabBar → context buttons → canvas. Graph
keeps Add-Step + Save + Run, Text trims to Save + Run, Run
hides them entirely (the Run tab has its own start button).

Drop the outer wrapping GestureDetector that competed with the
inner Positioned.fill detector inside the canvas Stack — two
TapGestureRecognizers in the same arena meant the inner one
sometimes lost edge-tap and background-tap.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-02 16:32:27 +02:00
parent 59aa8fe78e
commit 73736769a0
3 changed files with 106 additions and 69 deletions

View file

@ -357,17 +357,10 @@ outputs:
strings: _l, strings: _l,
activeName: _controller.activeName, activeName: _controller.activeName,
dirty: _controller.isDirty, dirty: _controller.isDirty,
saving: _controller.isSaving,
running: _controller.isRunning,
onBack: Navigator.of(context).canPop() onBack: Navigator.of(context).canPop()
? () => Navigator.of(context).maybePop() ? () => Navigator.of(context).maybePop()
: null, : null,
onSave: _controller.activeName != null ? _save : null,
onAddStep: _controller.activeName != null ? _addStep : null,
onNew: _newFlow, onNew: _newFlow,
onRun: _controller.activeName != null
? () => _tabs.animateTo(2)
: null,
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
@ -421,6 +414,17 @@ outputs:
], ],
), ),
), ),
_TabActionStrip(
strings: _l,
tabs: _tabs,
saving: _controller.isSaving,
running: _controller.isRunning,
onAddStep: _controller.activeName != null ? _addStep : null,
onSave: _controller.activeName != null ? _save : null,
onRun: _controller.activeName != null
? () => _tabs.animateTo(2)
: null,
),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabs, controller: _tabs,
@ -688,24 +692,14 @@ class _Toolbar extends StatelessWidget {
final FlowEditorStrings strings; final FlowEditorStrings strings;
final String? activeName; final String? activeName;
final bool dirty; final bool dirty;
final bool saving;
final bool running;
final VoidCallback? onBack; final VoidCallback? onBack;
final VoidCallback? onSave;
final VoidCallback? onAddStep;
final VoidCallback onNew; final VoidCallback onNew;
final VoidCallback? onRun;
const _Toolbar({ const _Toolbar({
required this.strings, required this.strings,
required this.activeName, required this.activeName,
required this.dirty, required this.dirty,
required this.saving,
required this.running,
required this.onBack, required this.onBack,
required this.onSave,
required this.onAddStep,
required this.onNew, required this.onNew,
required this.onRun,
}); });
@override @override
@ -776,43 +770,106 @@ class _Toolbar extends StatelessWidget {
icon: const Icon(Icons.add, size: 16), icon: const Icon(Icons.add, size: 16),
label: Text(strings.newFlow), label: Text(strings.newFlow),
), ),
const SizedBox(width: FaiSpace.sm),
if (onAddStep != null)
FilledButton.tonalIcon(
onPressed: onAddStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(strings.addStep),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.tonalIcon(
onPressed: saving ? null : onSave,
icon: saving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined, size: 16),
label: Text(strings.save),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
onPressed: onRun,
icon: running
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow, size: 18),
label: Text(strings.run),
),
], ],
), ),
); );
} }
} }
// Tab-aware action strip lives directly under the TabBar so
// the operator's eye flows from TabBar → context buttons →
// canvas. Each tab exposes a different set of actions:
//
// Graph: [+ Step] [Save] [Run]
// Text: [Save] [Run]
// Run: (no buttons Run tab has its own start button)
//
// Rebuilds when the TabController index changes via the
// passed-in animation.
class _TabActionStrip extends StatelessWidget {
final FlowEditorStrings strings;
final TabController tabs;
final bool saving;
final bool running;
final VoidCallback? onAddStep;
final VoidCallback? onSave;
final VoidCallback? onRun;
const _TabActionStrip({
required this.strings,
required this.tabs,
required this.saving,
required this.running,
required this.onAddStep,
required this.onSave,
required this.onRun,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: tabs.animation ?? tabs,
builder: (context, _) {
final idx = tabs.index;
final showAddStep = idx == 0;
final showSaveRun = idx == 0 || idx == 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
vertical: FaiSpace.sm,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
),
child: Row(
children: [
if (showAddStep) ...[
FilledButton.tonalIcon(
onPressed: onAddStep,
icon: const Icon(Icons.add_box_outlined, size: 16),
label: Text(strings.addStep),
),
const SizedBox(width: FaiSpace.sm),
],
if (showSaveRun) ...[
FilledButton.tonalIcon(
onPressed: saving ? null : onSave,
icon: saving
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined, size: 16),
label: Text(strings.save),
),
const SizedBox(width: FaiSpace.sm),
FilledButton.icon(
onPressed: onRun,
icon: running
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow, size: 18),
label: Text(strings.run),
),
],
const Spacer(),
],
),
);
},
);
}
}
// --- file list --- // --- file list ---
class _FlowFile { class _FlowFile {

View file

@ -429,26 +429,6 @@ class _FlowCanvasState extends State<FlowCanvas>
: BoxDecoration(color: theme.colorScheme.surface), : BoxDecoration(color: theme.colorScheme.surface),
child: Stack( child: Stack(
children: [ children: [
// Outer-most tap layer catches clicks on the
// dark area OUTSIDE the canvas grid (when the
// operator has panned / zoomed so the grid
// doesn't fill the viewport). Translucent so
// InteractiveViewer below still handles pan +
// zoom; the gesture arena gives the tap to
// whichever child claims it first, and on
// canvas-empty pointers nobody else does.
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
// Deselect both step + edge so the
// properties panel closes when the operator
// clicks the empty backdrop.
widget.controller.selectStep(null);
widget.controller.selectEdge(null);
},
),
),
InteractiveViewer( InteractiveViewer(
transformationController: _transform, transformationController: _transform,
constrained: false, constrained: false,

View file

@ -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.13.0 version: 0.14.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