feat(editor): port geometry fix + type colours + endpoint editor + unsaved badge
Four operator-visible improvements in one cut. Port geometry (the "connection dots don't line up with the labels" bug): - Header now has a fixed two-line structure (title + 16-px subtitle slot) regardless of node kind, so the body's port-row Y-offsets are identical across step nodes and endpoint nodes. - NodeGeometry constants rewritten so the inline 8-px dot inside each port row and the 16-px canvas-side dot both compute to the same Y. Port rows now anchor where the eye expects them. Port type colours: - Inputs endpoint labels carry `name: type` (e.g. `doc: bytes`), so the FlowNode body parses the type and tints the inline dot per type: text → primary, bytes/file → tertiary, json → secondary, number → amber, unknown → muted. Step input ports stay muted until a future commit can read module manifests for type info. Endpoint editor: - Inputs and outputs nodes are now selectable. Selecting opens a dedicated editor in the properties panel: add / rename / retype / remove entries graphically instead of forcing the operator into the text tab. Inputs editor offers a Type dropdown (text / bytes / json / file / number); outputs editor exposes name + expression (e.g. `$step.field`). Unsaved badge: - The toolbar's tiny 8-px dirty dot was easy to miss. Replace it with a coloured "unsaved" / "ungespeichert" chip + colour the file name itself in the primary accent when dirty. Operators see at a glance that a flow has unsaved changes, which matters because the Discard confirmation prompt won't fire if they don't realise they made changes. Version 0.3.0 -> 0.4.0 — first new editor feature surface since 0.3. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
296e5bfd01
commit
fb8892687d
6 changed files with 761 additions and 81 deletions
|
|
@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../editor_controller.dart';
|
||||
import '../l10n.dart';
|
||||
import '../model/auto_layout.dart';
|
||||
import '../model/flow_graph.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
|
|
@ -95,6 +96,24 @@ class _PropertiesPanelState extends State<PropertiesPanel> {
|
|||
final controller = widget.controller;
|
||||
final selected = controller.selectedStepId;
|
||||
if (selected == null) return _emptyHint(theme);
|
||||
// Endpoint nodes (inputs / outputs) get their own
|
||||
// editor — different fields, different mutations,
|
||||
// different vocabulary. Step panel is the default for
|
||||
// everything else.
|
||||
if (selected == AutoLayout.inputsNodeId) {
|
||||
return _EndpointEditor(
|
||||
controller: controller,
|
||||
strings: widget.strings,
|
||||
kind: _EndpointKind.inputs,
|
||||
);
|
||||
}
|
||||
if (selected == AutoLayout.outputsNodeId) {
|
||||
return _EndpointEditor(
|
||||
controller: controller,
|
||||
strings: widget.strings,
|
||||
kind: _EndpointKind.outputs,
|
||||
);
|
||||
}
|
||||
final step = controller.graph.steps.firstWhere(
|
||||
(s) => s.id == selected,
|
||||
orElse: () => _empty,
|
||||
|
|
@ -402,3 +421,526 @@ class _PropertiesPanelState extends State<PropertiesPanel> {
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
enum _EndpointKind { inputs, outputs }
|
||||
|
||||
/// Editor for the flow's inputs / outputs endpoints. Lets the
|
||||
/// operator add, rename, retype, and remove entries
|
||||
/// graphically — same mutations the YAML offers, but without
|
||||
/// requiring the operator to know YAML.
|
||||
class _EndpointEditor extends StatelessWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
final _EndpointKind kind;
|
||||
|
||||
const _EndpointEditor({
|
||||
required this.controller,
|
||||
required this.strings,
|
||||
required this.kind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_header(theme),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: kind == _EndpointKind.inputs
|
||||
? _InputsBody(controller: controller, strings: strings)
|
||||
: _OutputsBody(controller: controller, strings: strings),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(ThemeData theme) {
|
||||
final title = kind == _EndpointKind.inputs
|
||||
? strings.endpointInputsTitle
|
||||
: strings.endpointOutputsTitle;
|
||||
final body = kind == _EndpointKind.inputs
|
||||
? strings.endpointInputsBody
|
||||
: strings.endpointOutputsBody;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
kind == _EndpointKind.inputs ? Icons.input : Icons.output,
|
||||
size: 16,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
body,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InputsBody extends StatelessWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
const _InputsBody({required this.controller, required this.strings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final graph = controller.graph;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final entry in graph.inputs.entries)
|
||||
_InputRow(
|
||||
controller: controller,
|
||||
strings: strings,
|
||||
name: entry.key,
|
||||
input: entry.value,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextButton.icon(
|
||||
onPressed: () => _addInput(),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(strings.endpointAdd),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _addInput() {
|
||||
final graph = controller.graph;
|
||||
var name = 'input';
|
||||
var i = 1;
|
||||
while (graph.inputs.containsKey(name)) {
|
||||
i++;
|
||||
name = 'input_$i';
|
||||
}
|
||||
controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: {
|
||||
...graph.inputs,
|
||||
name: const FlowInput(type: 'text'),
|
||||
},
|
||||
steps: graph.steps,
|
||||
outputs: graph.outputs,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InputRow extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
final String name;
|
||||
final FlowInput input;
|
||||
|
||||
const _InputRow({
|
||||
required this.controller,
|
||||
required this.strings,
|
||||
required this.name,
|
||||
required this.input,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_InputRow> createState() => _InputRowState();
|
||||
}
|
||||
|
||||
class _InputRowState extends State<_InputRow> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late String _trackedName;
|
||||
late String _type;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_trackedName = widget.name;
|
||||
_nameCtrl = TextEditingController(text: widget.name);
|
||||
_type = widget.input.type;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_InputRow old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.name != _trackedName) {
|
||||
_trackedName = widget.name;
|
||||
_nameCtrl.text = widget.name;
|
||||
}
|
||||
if (widget.input.type != _type) {
|
||||
_type = widget.input.type;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commitName() {
|
||||
final newName = _nameCtrl.text.trim();
|
||||
if (newName.isEmpty || newName == widget.name) return;
|
||||
final graph = widget.controller.graph;
|
||||
if (graph.inputs.containsKey(newName)) {
|
||||
_nameCtrl.text = widget.name;
|
||||
return;
|
||||
}
|
||||
final renamed = <String, FlowInput>{};
|
||||
for (final e in graph.inputs.entries) {
|
||||
if (e.key == widget.name) {
|
||||
renamed[newName] = e.value;
|
||||
} else {
|
||||
renamed[e.key] = e.value;
|
||||
}
|
||||
}
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: renamed,
|
||||
steps: graph.steps,
|
||||
outputs: graph.outputs,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _commitType(String newType) {
|
||||
if (newType == widget.input.type) return;
|
||||
final graph = widget.controller.graph;
|
||||
final updated = {
|
||||
for (final e in graph.inputs.entries)
|
||||
e.key: e.key == widget.name
|
||||
? FlowInput(
|
||||
type: newType,
|
||||
defaultValue: e.value.defaultValue,
|
||||
hint: e.value.hint,
|
||||
)
|
||||
: e.value,
|
||||
};
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: updated,
|
||||
steps: graph.steps,
|
||||
outputs: graph.outputs,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _remove() {
|
||||
final graph = widget.controller.graph;
|
||||
final next = {
|
||||
for (final e in graph.inputs.entries)
|
||||
if (e.key != widget.name) e.key: e.value,
|
||||
};
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: next,
|
||||
steps: graph.steps,
|
||||
outputs: graph.outputs,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: _nameCtrl,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.strings.endpointName,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _commitName(),
|
||||
onTapOutside: (_) => _commitName(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _type,
|
||||
isDense: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.strings.endpointType,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'text', child: Text('text')),
|
||||
DropdownMenuItem(value: 'bytes', child: Text('bytes')),
|
||||
DropdownMenuItem(value: 'json', child: Text('json')),
|
||||
DropdownMenuItem(value: 'file', child: Text('file')),
|
||||
DropdownMenuItem(value: 'number', child: Text('number')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
setState(() => _type = v);
|
||||
_commitType(v);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _remove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: widget.strings.endpointRemove,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutputsBody extends StatelessWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
const _OutputsBody({required this.controller, required this.strings});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final graph = controller.graph;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final entry in graph.outputs.entries)
|
||||
_OutputRow(
|
||||
controller: controller,
|
||||
strings: strings,
|
||||
name: entry.key,
|
||||
expression: entry.value,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextButton.icon(
|
||||
onPressed: () => _addOutput(),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(strings.endpointAdd),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _addOutput() {
|
||||
final graph = controller.graph;
|
||||
var name = 'result';
|
||||
var i = 1;
|
||||
while (graph.outputs.containsKey(name)) {
|
||||
i++;
|
||||
name = 'result_$i';
|
||||
}
|
||||
controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: graph.inputs,
|
||||
steps: graph.steps,
|
||||
outputs: {...graph.outputs, name: ''},
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OutputRow extends StatefulWidget {
|
||||
final FlowEditorController controller;
|
||||
final FlowEditorStrings strings;
|
||||
final String name;
|
||||
final String expression;
|
||||
const _OutputRow({
|
||||
required this.controller,
|
||||
required this.strings,
|
||||
required this.name,
|
||||
required this.expression,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_OutputRow> createState() => _OutputRowState();
|
||||
}
|
||||
|
||||
class _OutputRowState extends State<_OutputRow> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _exprCtrl;
|
||||
late String _trackedName;
|
||||
late String _trackedExpr;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_trackedName = widget.name;
|
||||
_trackedExpr = widget.expression;
|
||||
_nameCtrl = TextEditingController(text: widget.name);
|
||||
_exprCtrl = TextEditingController(text: widget.expression);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_OutputRow old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (widget.name != _trackedName) {
|
||||
_trackedName = widget.name;
|
||||
_nameCtrl.text = widget.name;
|
||||
}
|
||||
if (widget.expression != _trackedExpr) {
|
||||
_trackedExpr = widget.expression;
|
||||
_exprCtrl.text = widget.expression;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_exprCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _commitName() {
|
||||
final newName = _nameCtrl.text.trim();
|
||||
if (newName.isEmpty || newName == widget.name) return;
|
||||
final graph = widget.controller.graph;
|
||||
if (graph.outputs.containsKey(newName)) {
|
||||
_nameCtrl.text = widget.name;
|
||||
return;
|
||||
}
|
||||
final renamed = <String, String>{};
|
||||
for (final e in graph.outputs.entries) {
|
||||
if (e.key == widget.name) {
|
||||
renamed[newName] = e.value;
|
||||
} else {
|
||||
renamed[e.key] = e.value;
|
||||
}
|
||||
}
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: graph.inputs,
|
||||
steps: graph.steps,
|
||||
outputs: renamed,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _commitExpr() {
|
||||
final newExpr = _exprCtrl.text;
|
||||
if (newExpr == widget.expression) return;
|
||||
final graph = widget.controller.graph;
|
||||
final updated = {
|
||||
for (final e in graph.outputs.entries)
|
||||
e.key: e.key == widget.name ? newExpr : e.value,
|
||||
};
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: graph.inputs,
|
||||
steps: graph.steps,
|
||||
outputs: updated,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _remove() {
|
||||
final graph = widget.controller.graph;
|
||||
final next = {
|
||||
for (final e in graph.outputs.entries)
|
||||
if (e.key != widget.name) e.key: e.value,
|
||||
};
|
||||
widget.controller.applyGraphEdit(
|
||||
FlowGraph(
|
||||
name: graph.name,
|
||||
inputs: graph.inputs,
|
||||
steps: graph.steps,
|
||||
outputs: next,
|
||||
leadingComment: graph.leadingComment,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: FaiSpace.sm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _nameCtrl,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.strings.endpointName,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _commitName(),
|
||||
onTapOutside: (_) => _commitName(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _remove,
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: widget.strings.endpointRemove,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _exprCtrl,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.strings.endpointExpression,
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: r'$step.field',
|
||||
),
|
||||
onSubmitted: (_) => _commitExpr(),
|
||||
onTapOutside: (_) => _commitExpr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue