run method

FormResult? run()

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());
}