run method
Runs the form and returns a FormResult on confirmation, or null
if cancelled.
Implementation
FormResult? run() {
if (fields.isEmpty) return const FormResult([]);
final states = fields.map((f) => _FieldState(f)).toList();
var focusIndex = 0;
var cancelled = false;
var confirmed = false;
String? crossError;
void clearAllErrors() {
for (final s in states) {
s.clearError();
}
crossError = null;
}
bool validateAll() {
var valid = true;
for (final s in states) {
s.clearError();
if (s.config.required && s.text.trim().isEmpty) {
s.setError('Required');
valid = false;
} else if (s.config.validator != null) {
final err =
normalizeValidationError(s.config.validator!(s.text.trim()));
if (err != null) {
s.setError(err);
valid = false;
}
}
}
if (valid && crossValidator != null) {
final err = normalizeValidationError(
crossValidator!(states.map((s) => s.text.trim()).toList()),
);
if (err != null) {
crossError = err;
valid = false;
}
}
return valid;
}
void moveFocus(int delta) {
focusIndex = (focusIndex + delta).clamp(0, states.length - 1);
}
// Build the active-field text input bindings dynamically so they
// always target the focused buffer.
final bindings = KeyBindings([
// Typing, backspace, cursor movement → active buffer
KeyBinding(
keys: {
KeyEventType.char,
KeyEventType.space,
KeyEventType.backspace,
KeyEventType.arrowLeft,
KeyEventType.arrowRight,
},
action: (event) {
final state = states[focusIndex];
if (state.buffer.handleKey(event)) {
clearAllErrors();
return KeyActionResult.handled;
}
return KeyActionResult.ignored;
},
),
]) +
// Tab / ↓: next field
KeyBindings([
KeyBinding.multi(
{KeyEventType.tab, KeyEventType.arrowDown},
(event) {
if (focusIndex < states.length - 1) {
moveFocus(1);
return KeyActionResult.handled;
}
return KeyActionResult.ignored;
},
hintLabel: 'Tab',
hintDescription: 'next field',
),
]) +
// ↑: previous field
KeyBindings([
KeyBinding.single(
KeyEventType.arrowUp,
(event) {
if (focusIndex > 0) {
moveFocus(-1);
return KeyActionResult.handled;
}
return KeyActionResult.ignored;
},
),
]) +
// Enter: next field or submit
KeyBindings.confirm(onConfirm: () {
if (focusIndex < states.length - 1) {
moveFocus(1);
return KeyActionResult.handled;
}
if (validateAll()) {
confirmed = true;
return KeyActionResult.confirmed;
}
return KeyActionResult.handled;
}) +
// Ctrl+R: toggle reveal on focused field
KeyBindings([
KeyBinding.single(
KeyEventType.ctrlR,
(event) {
final state = states[focusIndex];
if (state.config.masked && state.config.allowReveal) {
state.showPlain = !state.showPlain;
return KeyActionResult.handled;
}
return KeyActionResult.ignored;
},
hintLabel: _anyFieldRevealable(fields) ? 'Ctrl+R' : null,
hintDescription: _anyFieldRevealable(fields) ? 'reveal' : null,
),
]) +
KeyBindings.cancel(onCancel: () => cancelled = true);
// Compute label column width once
final labelWidth = _maxLabelWidth(fields);
void render(RenderOutput out) {
final frame = FrameView(
title: title,
theme: theme,
bindings: bindings,
);
frame.render(out, (ctx) {
for (var i = 0; i < states.length; i++) {
final state = states[i];
final focused = i == focusIndex;
_renderField(ctx, state, focused, labelWidth, theme);
if (state.error != null) {
final pad = ' ' * 4;
ctx.gutterLine('$pad${theme.error}${state.error}${theme.reset}');
}
}
if (crossError != null) {
ctx.gutterLine('${theme.error}$crossError${theme.reset}');
}
});
}
final runner = PromptRunner(hideCursor: true);
runner.runWithBindings(render: render, bindings: bindings);
if (cancelled || !confirmed) return null;
return FormResult(states.map((s) => s.text.trim()).toList());
}