fix(editor): true top-align via CodeField expands + move refresh
Two changes Stefan flagged on 0.1.3: (1) Short files were still vertically centered. The previous fix (CrossAxisAlignment.stretch on the Row) only stretched the SingleChildScrollView viewport — the CodeField inside still had its natural content-sized height and sat centered in the larger viewport. Drop the SCV wrapper entirely and set `expands: true` (with `minLines: null` + `maxLines: null`, the underlying TextField's required combination) on the CodeField. The widget now fills its parent's bounded height, the line-number gutter runs the full editor height, and line 1 sits at the literal top edge. Standard IDE behaviour. (2) The round outlined Refresh button sandwiched between New (filled-tonal) and Save (filled-tonal) in the editor toolbar was a visual-style outlier — Stefan called it "dazwischen, sieht falsch aus". Refresh is also semantically scoped to the file list, not the open editor, so move it out of the editor toolbar entirely and into a small FLOWS header bar at the top of the file-list panel. The editor toolbar now reads: [Back] file.yaml ● [Spacer] [New] [Save] [Run] — four filled-tonal/filled buttons, no style outliers. The file-list panel reads: ┌───────────────────────┐ │ FLOWS ⟲ │ ├───────────────────────┤ │ flow-a │ │ flow-b │ └───────────────────────┘ Adds `listHeader` to FlowEditorStrings (de + en both "FLOWS" — same word, all-caps). Version 0.1.3 -> 0.1.4. Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
a7485cadb5
commit
daf6732893
3 changed files with 171 additions and 118 deletions
|
|
@ -14,7 +14,6 @@ library;
|
|||
|
||||
import 'dart:io';
|
||||
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
|
|
@ -26,7 +25,8 @@ import 'widgets.dart';
|
|||
|
||||
/// Compute the flows directory under the operator's home.
|
||||
String _defaultFlowsDir() {
|
||||
final home = Platform.environment['HOME'] ??
|
||||
final home =
|
||||
Platform.environment['HOME'] ??
|
||||
Platform.environment['USERPROFILE'] ??
|
||||
'.';
|
||||
return '$home/.fai/data/flows';
|
||||
|
|
@ -102,16 +102,20 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
|||
.where((e) => e is File && e.path.endsWith('.yaml'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
final files = entries
|
||||
.map(
|
||||
(f) => _FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||
path: f.path,
|
||||
sizeBytes: f.statSync().size,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
final files =
|
||||
entries
|
||||
.map(
|
||||
(f) => _FlowFile(
|
||||
name: f.uri.pathSegments.last.replaceAll(
|
||||
RegExp(r'\.yaml$'),
|
||||
'',
|
||||
),
|
||||
path: f.path,
|
||||
sizeBytes: f.statSync().size,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.name.compareTo(b.name));
|
||||
return files;
|
||||
}
|
||||
|
||||
|
|
@ -188,9 +192,9 @@ class _FlowEditorPageState extends State<FlowEditorPage> {
|
|||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
|
|
@ -223,9 +227,9 @@ outputs:
|
|||
final f = File('${dir.path}/$name.yaml');
|
||||
if (f.existsSync()) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(_l.alreadyExists(name))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name))));
|
||||
return;
|
||||
}
|
||||
await f.writeAsString(template, flush: true);
|
||||
|
|
@ -238,9 +242,9 @@ outputs:
|
|||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -328,7 +332,6 @@ outputs:
|
|||
? _run
|
||||
: null,
|
||||
onNew: _newFlow,
|
||||
onRefresh: _refresh,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
|
|
@ -396,7 +399,6 @@ class _Toolbar extends StatelessWidget {
|
|||
final VoidCallback? onSave;
|
||||
final VoidCallback? onRun;
|
||||
final VoidCallback onNew;
|
||||
final VoidCallback onRefresh;
|
||||
const _Toolbar({
|
||||
required this.strings,
|
||||
required this.activeName,
|
||||
|
|
@ -407,7 +409,6 @@ class _Toolbar extends StatelessWidget {
|
|||
required this.onSave,
|
||||
required this.onRun,
|
||||
required this.onNew,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -452,12 +453,6 @@ class _Toolbar extends StatelessWidget {
|
|||
label: Text(strings.newFlow),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
IconButton.outlined(
|
||||
onPressed: onRefresh,
|
||||
tooltip: strings.refresh,
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: saving ? null : onSave,
|
||||
icon: saving
|
||||
|
|
@ -507,81 +502,128 @@ class _FileList extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
color: theme.colorScheme.surface,
|
||||
child: FutureBuilder<List<_FlowFile>>(
|
||||
future: filesFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiErrorBox(error: snap.error, isError: true),
|
||||
);
|
||||
}
|
||||
final files = snap.data ?? <_FlowFile>[];
|
||||
if (files.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.folder_outlined,
|
||||
title: strings.listEmptyTitle,
|
||||
hint: strings.listEmptyBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
final isActive = f.name == activeName;
|
||||
return InkWell(
|
||||
onTap: () => onOpen(f),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: null,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
width: 3,
|
||||
color: isActive
|
||||
? theme.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// List header — refresh icon lives here (not in the
|
||||
// main toolbar) because Refresh re-lists the files,
|
||||
// it doesn't touch the open editor's content. Keeping
|
||||
// it in the file-list panel keeps the editor toolbar
|
||||
// focused on the four primary actions (back / new /
|
||||
// save / run) without a stylistically-outlier outline
|
||||
// icon sandwiched between filled buttons.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: theme.dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
strings.listHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
f.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${f.sizeBytes} B',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onRefresh,
|
||||
tooltip: strings.refresh,
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 28,
|
||||
minHeight: 28,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody(context, theme)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, ThemeData theme) {
|
||||
return FutureBuilder<List<_FlowFile>>(
|
||||
future: filesFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiErrorBox(error: snap.error, isError: true),
|
||||
);
|
||||
}
|
||||
final files = snap.data ?? <_FlowFile>[];
|
||||
if (files.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
child: FaiEmptyState(
|
||||
icon: Icons.folder_outlined,
|
||||
title: strings.listEmptyTitle,
|
||||
hint: strings.listEmptyBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||
itemCount: files.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = files[i];
|
||||
final isActive = f.name == activeName;
|
||||
return InkWell(
|
||||
onTap: () => onOpen(f),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.md,
|
||||
vertical: FaiSpace.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? theme.colorScheme.secondaryContainer : null,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
width: 3,
|
||||
color: isActive
|
||||
? theme.colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
f.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${f.sizeBytes} B',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorPane extends StatelessWidget {
|
||||
|
|
@ -609,33 +651,43 @@ class _EditorPane extends StatelessWidget {
|
|||
color: theme.colorScheme.onSurface,
|
||||
);
|
||||
return Row(
|
||||
// Stretch so the editor's scroll viewport fills the row's
|
||||
// full vertical extent. Without this the Row defaults to
|
||||
// CrossAxisAlignment.center, which centers a short file
|
||||
// vertically — uncomfortable in an IDE where readers
|
||||
// expect content to always start at the top. Default
|
||||
// SingleChildScrollView origin is top, so stretching the
|
||||
// viewport is enough to anchor content at the top edge.
|
||||
// Stretch + CodeField(expands: true) is what actually
|
||||
// pins the editor to the top edge. The previous attempt
|
||||
// (just CrossAxisAlignment.stretch with a
|
||||
// SingleChildScrollView) only stretched the SCROLL
|
||||
// viewport — the CodeField inside still had its natural,
|
||||
// content-sized height and sat vertically centered within
|
||||
// the viewport because CodeField defers to its parent's
|
||||
// alignment when smaller than the viewport. Removing the
|
||||
// SCV wrapper and setting expands:true on the CodeField
|
||||
// (with explicit minLines:null + maxLines:null, which is
|
||||
// what the underlying TextField requires) makes the
|
||||
// CodeField fill its parent's bounded height. The line-
|
||||
// number gutter then runs the full height of the editor
|
||||
// pane, line 1 sits at the top edge, and vertical
|
||||
// scrolling is handled by the CodeField itself for long
|
||||
// files. Standard behaviour for any IDE-grade editor
|
||||
// widget.
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(styles: _yamlStyle(theme)),
|
||||
child: SingleChildScrollView(
|
||||
child: CodeField(
|
||||
controller: controller,
|
||||
textStyle: mono,
|
||||
expands: false,
|
||||
gutterStyle: GutterStyle(
|
||||
textStyle: mono.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
background: theme.colorScheme.surfaceContainer,
|
||||
showLineNumbers: true,
|
||||
child: CodeField(
|
||||
controller: controller,
|
||||
textStyle: mono,
|
||||
expands: true,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
gutterStyle: GutterStyle(
|
||||
textStyle: mono.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
background: theme.colorScheme.surface,
|
||||
background: theme.colorScheme.surfaceContainer,
|
||||
showLineNumbers: true,
|
||||
),
|
||||
background: theme.colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue