termancy Guide Components Commands API

form

A form of fields (text, choices, lists, yes/no, files) on pages, themed

termancy tui add form

Also copies what it requires: input textarea filepicker

termancy ui demo form, recorded

A form, as huh builds them: labeled fields filled in one after another, each checked before the form is submitted, on one page or several.

var fields = [_]Field{
    .{ .label = "Name", .kind = .{ .input = .{ .gpa = gpa } }, .required = true },
    .{ .label = "Plan", .kind = .{ .choice = .{ .options = &.{ "free", "pro" } } } },
    .{ .label = "Region", .kind = .{ .select = .{ .options = &.{ "eu", "us", "ap" } } } },
    .{ .label = "Extras", .kind = .{ .multi_select = .{ .options = &.{ "ssl", "cdn" } } } },
    .{ .label = "Agree?", .kind = .{ .confirm = .{} } },
};
defer for (&fields) |*field| field.deinit();
var form: Form = .{ .fields = &fields };
_ = try tui.run(gpa, io, &form, .{ .environ = environ });
// then fields[i].kind.input.value(), fields[i].kind.select.selected(), ...

Fields: input (a line of text, see input), text (several lines, see textarea), choice (a few options on one line), select (options in a list, filtered by typing after /), multi_select (several options, up to a limit), confirm (yes or no), file (a filepicker), and note (text to read, which takes no answer).

Keys: tab moves to the next field and shift+tab to the previous one (skipping notes and hidden fields); enter moves on when the field is valid, and on the last field of the last page submits once every field is; esc cancels. Up and down move between fields, except in a select, a multi-select, a text, or a file picker, where they move inside it. On a choice, left and right pick an option and space cycles; in a multi-select, space or x toggles an option; a confirm takes left and right, y and n; a text takes enter to move on and alt+enter (or ctrl+j) for a new line. An input is valid when it is filled in (if required) and its validate accepts it; a multi-select when it has at least min options; the others always are. The first invalid field takes the focus and says why.

Pages (pages) split the fields into groups, each with an optional title, shown one at a time; layout shows them stacked or in columns instead. A field's hide decides, from the form, whether it is asked at all (huh's WithHideFunc): a follow-up only for some answers. Styles come in themes: Styles.charm (the default), dracula, catppuccin, base16, and base.

The fields are borrowed: the form points at the caller's array, which must outlive it.

form.zig

//! A form, as huh builds them: labeled fields filled in one after another, each checked
//! before the form is submitted, on one page or several.
//!
//! ```zig
//! var fields = [_]Field{
//!     .{ .label = "Name", .kind = .{ .input = .{ .gpa = gpa } }, .required = true },
//!     .{ .label = "Plan", .kind = .{ .choice = .{ .options = &.{ "free", "pro" } } } },
//!     .{ .label = "Region", .kind = .{ .select = .{ .options = &.{ "eu", "us", "ap" } } } },
//!     .{ .label = "Extras", .kind = .{ .multi_select = .{ .options = &.{ "ssl", "cdn" } } } },
//!     .{ .label = "Agree?", .kind = .{ .confirm = .{} } },
//! };
//! defer for (&fields) |*field| field.deinit();
//! var form: Form = .{ .fields = &fields };
//! _ = try tui.run(gpa, io, &form, .{ .environ = environ });
//! // then fields[i].kind.input.value(), fields[i].kind.select.selected(), ...
//! ```
//!
//! Fields: `input` (a line of text, see `input`), `text` (several lines, see `textarea`),
//! `choice` (a few options on one line), `select` (options in a list, filtered by typing
//! after `/`), `multi_select` (several options, up to a limit), `confirm` (yes or no),
//! `file` (a `filepicker`), and `note` (text to read, which takes no answer).
//!
//! Keys: tab moves to the next field and shift+tab to the previous one (skipping notes and
//! hidden fields); enter moves on when the field is valid, and on the last field of the
//! last page submits once every field is; esc cancels. Up and down move between fields,
//! except in a select, a multi-select, a text, or a file picker, where they move inside it.
//! On a choice, left and right pick an option and space cycles; in a multi-select, space
//! or x toggles an option; a confirm takes left and right, y and n; a text takes enter to
//! move on and alt+enter (or ctrl+j) for a new line. An input is valid when it is filled
//! in (if `required`) and its `validate` accepts it; a multi-select when it has at least
//! `min` options; the others always are. The first invalid field takes the focus and says
//! why.
//!
//! Pages (`pages`) split the fields into groups, each with an optional title, shown one at
//! a time; `layout` shows them stacked or in columns instead. A field's `hide` decides,
//! from the form, whether it is asked at all (huh's `WithHideFunc`): a follow-up only for
//! some answers. Styles come in themes: `Styles.charm` (the default), `dracula`,
//! `catppuccin`, `base16`, and `base`.
//!
//! The fields are borrowed: the form points at the caller's array, which must outlive it.

const std = @import("std");
const tui = @import("tui");
const Writer = std.Io.Writer;
const Input = @import("input.zig");
const Textarea = @import("textarea.zig");
const FilePicker = @import("filepicker.zig");

pub const Status = enum { active, submitted, canceled };

pub const Field = struct {
    label: []const u8,
    /// Dimmed text under the label; none when empty.
    description: []const u8 = "",
    kind: Kind,
    /// Whether an input or a text must be filled in.
    required: bool = false,
    /// Leaves the field out (not shown, not asked, not checked) while this says so.
    hide: ?*const fn (form: *const Form) bool = null,

    pub const Kind = union(enum) {
        input: Input,
        text: Textarea,
        choice: Choice,
        select: Select,
        multi_select: MultiSelect,
        confirm: Confirm,
        file: *FilePicker,
        note,
    };

    /// Frees what an input or a text typed.
    pub fn deinit(field: *Field) void {
        switch (field.kind) {
            .input => |*input| input.deinit(),
            .text => |*area| area.deinit(),
            else => {},
        }
    }

    fn takesFocus(field: *const Field) bool {
        return field.kind != .note;
    }
};

/// One of `options`, all shown on one line.
pub const Choice = struct {
    options: []const []const u8,
    index: usize = 0,

    pub fn selected(choice: Choice) []const u8 {
        return choice.options[choice.index];
    }
};

/// One of `options`, in a list `height` rows high, narrowed by typing after `/`.
pub const Select = struct {
    options: []const []const u8,
    index: usize = 0,
    height: usize = 5,
    /// The first option shown.
    offset: usize = 0,
    /// What was typed after `/`, while filtering.
    filter: [64]u8 = undefined,
    filter_len: usize = 0,
    filtering: bool = false,

    pub fn selected(s: *const Select) []const u8 {
        return s.options[s.index];
    }

    fn query(s: *const Select) []const u8 {
        return s.filter[0..s.filter_len];
    }

    fn visible(s: *const Select, i: usize) bool {
        if (s.filter_len == 0) return true;
        return std.ascii.findIgnoreCase(s.options[i], s.query()) != null;
    }

    fn move(s: *Select, forward: bool) void {
        var i = s.index;
        while (true) {
            if (forward) {
                if (i + 1 >= s.options.len) return;
                i += 1;
            } else {
                if (i == 0) return;
                i -= 1;
            }
            if (s.visible(i)) break;
        }
        s.index = i;
        if (s.index < s.offset) s.offset = s.index;
        if (s.index >= s.offset + s.height) s.offset = s.index + 1 - s.height;
    }

    /// After the filter changed: the cursor on the first option that still matches.
    fn refilter(s: *Select) void {
        if (s.visible(s.index)) return;
        for (0..s.options.len) |i| if (s.visible(i)) {
            s.index = i;
            s.offset = i;
            return;
        };
    }

    fn handleKey(s: *Select, key: tui.input.Key) bool {
        if (s.filtering) {
            if (key.matches("enter") or key.matches("esc")) {
                s.filtering = false;
                if (key.matches("esc")) s.filter_len = 0;
                return true;
            }
            if (key.matches("backspace")) {
                s.filter_len -|= 1;
                s.refilter();
                return true;
            }
            if (key.printable()) |c| {
                if (c < 0x80 and s.filter_len < s.filter.len) {
                    s.filter[s.filter_len] = @intCast(c);
                    s.filter_len += 1;
                    s.refilter();
                }
                return true;
            }
        }
        if (key.matches("down") or key.matches("j") or key.matches("ctrl+n")) {
            s.move(true);
        } else if (key.matches("up") or key.matches("k") or key.matches("ctrl+p")) {
            s.move(false);
        } else if (key.matches("/")) {
            s.filtering = true;
        } else return false;
        return true;
    }
};

/// Any of `options`, up to `limit`, at least `min`.
pub const MultiSelect = struct {
    options: []const []const u8,
    /// Which are chosen: a bit per option, so up to 64 options.
    chosen: u64 = 0,
    cursor: usize = 0,
    limit: ?usize = null,
    min: usize = 0,

    pub fn isChosen(m: *const MultiSelect, i: usize) bool {
        return m.chosen & (@as(u64, 1) << @intCast(i)) != 0;
    }

    pub fn count(m: *const MultiSelect) usize {
        return @popCount(m.chosen);
    }

    fn toggle(m: *MultiSelect, i: usize) void {
        const bit = @as(u64, 1) << @intCast(i);
        if (m.chosen & bit != 0) {
            m.chosen &= ~bit;
        } else if (m.limit == null or m.count() < m.limit.?) {
            m.chosen |= bit;
        }
    }

    fn handleKey(m: *MultiSelect, key: tui.input.Key) bool {
        const last = @min(m.options.len, 64) -| 1;
        if (key.matches("down") or key.matches("j")) {
            m.cursor = @min(m.cursor + 1, last);
        } else if (key.matches("up") or key.matches("k")) {
            m.cursor -|= 1;
        } else if (key.matches("space") or key.matches("x")) {
            if (m.options.len > 0) m.toggle(m.cursor);
        } else return false;
        return true;
    }
};

/// Yes or no, as two buttons.
pub const Confirm = struct {
    value: bool = false,
    affirmative: []const u8 = "Yes",
    negative: []const u8 = "No",

    fn handleKey(c: *Confirm, key: tui.input.Key) bool {
        if (key.matches("left") or key.matches("h") or key.matches("right") or key.matches("l")) {
            c.value = !c.value;
        } else if (key.matches("y")) {
            c.value = true;
        } else if (key.matches("n")) {
            c.value = false;
        } else return false;
        return true;
    }
};

/// A group of fields shown together.
pub const Page = struct {
    title: []const u8 = "",
    description: []const u8 = "",
    /// How many fields, in order from where the page before ended.
    count: usize,
};

/// How pages are shown.
pub const Layout = union(enum) {
    /// One page at a time.
    pages,
    /// Every page, one below another.
    stack,
    /// Every page, side by side in this many columns.
    columns: usize,
};

const Form = @This();

fields: []Field,
/// The field keys go to.
focus: usize = 0,
/// A line above the fields; none when empty.
title: []const u8 = "",
/// Groups of fields; empty puts every field on one page.
pages: []const Page = &.{},
layout: Layout = .pages,
/// What a required field that is empty says.
required_message: []const u8 = "This field is required",
/// What a multi-select with too few options says.
too_few_message: []const u8 = "Choose more",
/// A line of key hints below the fields.
show_help: bool = true,
/// Columns a page takes in the `columns` layout.
column_width: usize = 30,
status: Status = .active,
/// Why the focused field is not valid, for the kinds without a message of their own.
problem: ?[]const u8 = null,
styles: Styles = .charm,

pub const Styles = struct {
    title: tui.Style,
    label: tui.Style,
    focused_label: tui.Style,
    description: tui.Style,
    /// The option a choice has, bracketed as well, so it shows under `NO_COLOR`.
    chosen: tui.Style,
    option: tui.Style,
    /// The cursor's row in a select or multi-select.
    cursor: tui.Style,
    /// The button a confirm has.
    button: tui.Style,
    blurred_button: tui.Style,
    @"error": tui.Style,
    help: tui.Style,
    /// The bar before the focused field.
    focus_bar: tui.Style,

    /// Charm's colors: pink, purple, and gray (huh's default).
    pub const charm: Styles = .{
        .title = .{ .fg = .{ .indexed = 99 }, .bold = true },
        .label = .{ .bold = true },
        .focused_label = .{ .fg = .{ .indexed = 212 }, .bold = true },
        .description = .{ .fg = .{ .indexed = 240 } },
        .chosen = .{ .fg = .{ .indexed = 212 }, .bold = true },
        .option = .{ .fg = .{ .indexed = 245 } },
        .cursor = .{ .fg = .{ .indexed = 212 } },
        .button = .{ .fg = .{ .indexed = 230 }, .bg = .{ .indexed = 212 } },
        .blurred_button = .{ .fg = .{ .indexed = 252 }, .bg = .{ .indexed = 237 } },
        .@"error" = .{ .fg = .{ .indexed = 203 } },
        .help = .{ .fg = .{ .indexed = 240 } },
        .focus_bar = .{ .fg = .{ .indexed = 99 } },
    };
    /// Dracula's palette.
    pub const dracula: Styles = .{
        .title = .{ .fg = .hex(0xbd93f9), .bold = true },
        .label = .{ .fg = .hex(0xf8f8f2), .bold = true },
        .focused_label = .{ .fg = .hex(0xf1fa8c), .bold = true },
        .description = .{ .fg = .hex(0x6272a4) },
        .chosen = .{ .fg = .hex(0xf1fa8c), .bold = true },
        .option = .{ .fg = .hex(0xf8f8f2) },
        .cursor = .{ .fg = .hex(0xf1fa8c) },
        .button = .{ .fg = .hex(0x282a36), .bg = .hex(0xbd93f9) },
        .blurred_button = .{ .fg = .hex(0xf8f8f2), .bg = .hex(0x44475a) },
        .@"error" = .{ .fg = .hex(0xff5555) },
        .help = .{ .fg = .hex(0x6272a4) },
        .focus_bar = .{ .fg = .hex(0xbd93f9) },
    };
    /// Catppuccin Mocha.
    pub const catppuccin: Styles = .{
        .title = .{ .fg = .hex(0xcba6f7), .bold = true },
        .label = .{ .fg = .hex(0xcdd6f4), .bold = true },
        .focused_label = .{ .fg = .hex(0xf5c2e7), .bold = true },
        .description = .{ .fg = .hex(0x6c7086) },
        .chosen = .{ .fg = .hex(0xf5c2e7), .bold = true },
        .option = .{ .fg = .hex(0xcdd6f4) },
        .cursor = .{ .fg = .hex(0xf5c2e7) },
        .button = .{ .fg = .hex(0x1e1e2e), .bg = .hex(0xcba6f7) },
        .blurred_button = .{ .fg = .hex(0xcdd6f4), .bg = .hex(0x313244) },
        .@"error" = .{ .fg = .hex(0xf38ba8) },
        .help = .{ .fg = .hex(0x6c7086) },
        .focus_bar = .{ .fg = .hex(0xcba6f7) },
    };
    /// The terminal's own 16 colors, so its theme decides.
    pub const base16: Styles = .{
        .title = .{ .fg = .blue, .bold = true },
        .label = .{ .bold = true },
        .focused_label = .{ .fg = .magenta, .bold = true },
        .description = .{ .fg = .bright_black },
        .chosen = .{ .fg = .magenta, .bold = true },
        .option = .{},
        .cursor = .{ .fg = .yellow },
        .button = .{ .fg = .black, .bg = .magenta },
        .blurred_button = .{ .bg = .bright_black },
        .@"error" = .{ .fg = .red },
        .help = .{ .fg = .bright_black },
        .focus_bar = .{ .fg = .bright_black },
    };
    /// No colors: bold labels and reverse buttons, for any terminal.
    pub const base: Styles = .{
        .title = .{ .bold = true },
        .label = .{},
        .focused_label = .{ .bold = true },
        .description = .{ .dim = true },
        .chosen = .{ .bold = true },
        .option = .{},
        .cursor = .{ .bold = true },
        .button = .{ .reverse = true },
        .blurred_button = .{},
        .@"error" = .{ .bold = true },
        .help = .{ .dim = true },
        .focus_bar = .{},
    };
};

/// Shows the cursor in the focused field only.
pub fn start(form: *Form) tui.Cmd {
    if (!form.askable(form.focus)) {
        form.focus = form.nextField(form.focus, true) orelse form.focus;
    }
    form.focusOn(form.focus);
    return .none;
}

/// Whether field `i` takes answers now: it takes focus and is not hidden.
fn askable(form: *const Form, i: usize) bool {
    const field = &form.fields[i];
    if (!field.takesFocus()) return false;
    if (field.hide) |hide| if (hide(form)) return false;
    return true;
}

/// The next (or previous) field that takes answers, or null at the end.
fn nextField(form: *const Form, from: usize, forward: bool) ?usize {
    var i = from;
    while (true) {
        if (forward) {
            if (i + 1 >= form.fields.len) return null;
            i += 1;
        } else {
            if (i == 0) return null;
            i -= 1;
        }
        if (form.askable(i)) return i;
    }
}

/// The page field `index` is on.
pub fn pageOf(form: *const Form, index: usize) usize {
    var end: usize = 0;
    for (form.pages, 0..) |page, p| {
        end += page.count;
        if (index < end) return p;
    }
    return form.pages.len -| 1;
}

/// The range of fields on page `page`.
fn pageRange(form: *const Form, page: usize) struct { start: usize, end: usize } {
    if (form.pages.len == 0) return .{ .start = 0, .end = form.fields.len };
    var first: usize = 0;
    for (form.pages[0..page]) |p| first += p.count;
    return .{ .start = first, .end = @min(first + form.pages[page].count, form.fields.len) };
}

pub fn update(form: *Form, msg: tui.Msg) tui.Cmd {
    if (form.fields.len == 0) return .none;
    switch (msg) {
        .key => |key| return form.handleKey(key),
        .paste => |text| switch (form.fields[form.focus].kind) {
            .input => |*input| input.insert(text) catch {},
            .text => |*area| area.insert(text) catch {},
            else => {},
        },
        else => switch (form.fields[form.focus].kind) {
            .file => |picker| return form.filePicked(picker.update(msg)),
            else => {},
        },
    }
    return .none;
}

fn handleKey(form: *Form, key: tui.input.Key) tui.Cmd {
    const field = &form.fields[form.focus];
    // A select being filtered takes every key until enter or esc ends the filter.
    if (field.kind == .select and field.kind.select.filtering) {
        _ = field.kind.select.handleKey(key);
        return .none;
    }
    if (key.matches("esc")) {
        form.status = .canceled;
        return .quit;
    }
    if (key.matches("tab")) return form.advance(false);
    if (key.matches("shift+tab")) return form.back();
    if (field.kind == .text and (key.matches("alt+enter") or key.matches("ctrl+j"))) {
        field.kind.text.insert("\n") catch {};
        return .none;
    }
    if (key.matches("enter")) return form.advance(true);
    switch (field.kind) {
        .input => |*input| {
            if (key.matches("down")) return form.advance(false);
            if (key.matches("up")) return form.back();
            _ = input.handleKey(key);
        },
        .text => |*area| _ = area.handleKey(key),
        .choice => |*choice| {
            if (key.matches("down")) return form.advance(false);
            if (key.matches("up")) return form.back();
            const count = choice.options.len;
            if (key.matches("left") or key.matches("h")) {
                choice.index -|= 1;
            } else if (key.matches("right") or key.matches("l")) {
                choice.index = @min(choice.index + 1, count -| 1);
            } else if (key.matches("space") and count > 0) {
                choice.index = (choice.index + 1) % count;
            }
        },
        .select => |*s| _ = s.handleKey(key),
        .multi_select => |*m| {
            _ = m.handleKey(key);
            form.problem = null;
        },
        .confirm => |*c| {
            if (key.matches("down")) return form.advance(false);
            if (key.matches("up")) return form.back();
            _ = c.handleKey(key);
        },
        .file => |picker| return form.filePicked(picker.update(.{ .key = key })),
        .note => {},
    }
    return .none;
}

/// After the file picker had a message: on to the next field once it chose a file.
fn filePicked(form: *Form, cmd: tui.Cmd) tui.Cmd {
    const picker = form.fields[form.focus].kind.file;
    if (cmd.len > 0 and cmd.slice()[0] == .quit) {
        if (picker.status == .canceled) {
            form.status = .canceled;
            return .quit;
        }
        return form.advance(true);
    }
    return cmd;
}

/// Moves to the next field (checking this one first with `check`), the next page, or
/// submits from the last.
fn advance(form: *Form, checked: bool) tui.Cmd {
    if (checked and !form.validField(form.focus)) return .none;
    if (form.nextField(form.focus, true)) |next| {
        const crossing = form.layout == .pages and form.pages.len > 0 and
            form.pageOf(next) != form.pageOf(form.focus);
        // A page is checked whole before the next one shows.
        const page = form.pageRange(form.pageOf(form.focus));
        if (crossing and !form.checkRange(page)) return .none;
        form.focusOn(next);
        return .none;
    }
    if (!checked) return .none;
    return form.submit();
}

fn back(form: *Form) tui.Cmd {
    if (form.nextField(form.focus, false)) |previous| form.focusOn(previous);
    return .none;
}

fn checkRange(form: *Form, range: anytype) bool {
    for (range.start..range.end) |i| {
        if (form.askable(i) and !form.validField(i)) {
            form.focusOn(i);
            _ = form.validField(i);
            return false;
        }
    }
    return true;
}

/// Checks every field; submits and quits when all pass, otherwise focuses the first
/// that does not.
pub fn submit(form: *Form) tui.Cmd {
    var first_invalid: ?usize = null;
    for (0..form.fields.len) |i| {
        if (!form.askable(i)) continue;
        if (!form.check(i) and first_invalid == null) first_invalid = i;
    }
    if (first_invalid) |i| {
        form.focusOn(i);
        _ = form.validField(i);
        return .none;
    }
    form.status = .submitted;
    return .quit;
}

/// `check`, and for kinds without a message of their own, `problem` set or cleared.
fn validField(form: *Form, index: usize) bool {
    const ok = form.check(index);
    if (index == form.focus) {
        const counted = form.fields[index].kind == .multi_select;
        form.problem = if (ok or !counted) null else form.too_few_message;
        if (!ok and form.fields[index].kind == .text) form.problem = form.required_message;
    }
    return ok;
}

/// Checks field `index`, recording its message; returns whether it is valid.
pub fn check(form: *Form, index: usize) bool {
    const field = &form.fields[index];
    switch (field.kind) {
        .input => |*input| {
            if (field.required and input.value().len == 0) {
                input.message = form.required_message;
                return false;
            }
            return input.check();
        },
        .text => |*area| return !field.required or area.value().len > 0,
        .multi_select => |*m| return m.count() >= m.min,
        .file => |picker| return !field.required or picker.chosen != null,
        else => return true,
    }
}

fn focusOn(form: *Form, index: usize) void {
    form.focus = index;
    form.problem = null;
    for (form.fields, 0..) |*field, i| switch (field.kind) {
        .input => |*input| input.focused = i == index,
        .text => |*area| area.focused = i == index,
        else => {},
    };
}

pub fn view(form: *const Form, w: *Writer) Writer.Error!void {
    if (form.title.len > 0) {
        try form.styles.title.paint(w, form.title);
        try w.writeAll("\n\n");
    }
    if (form.pages.len == 0) {
        try form.viewRange(w, 0, form.fields.len);
    } else switch (form.layout) {
        .pages => {
            const page = form.pageOf(form.focus);
            try form.viewPage(w, page);
        },
        .stack => for (0..form.pages.len) |page| {
            if (page > 0) try w.writeAll("\n\n");
            try form.viewPage(w, page);
        },
        .columns => |n| try form.viewColumns(w, @max(n, 1)),
    }
    if (form.show_help) {
        try w.writeAll("\n\n");
        try form.styles.help.paint(w, form.helpLine());
    }
}

fn helpLine(form: *const Form) []const u8 {
    return switch (form.fields[form.focus].kind) {
        .select => "↑/↓ move • / filter • enter next • esc cancel",
        .multi_select => "↑/↓ move • space toggle • enter next • esc cancel",
        .text => "alt+enter new line • enter next • esc cancel",
        .confirm => "←/→ yes or no • enter next • esc cancel",
        .file => "↑/↓ move • enter open • ← parent • esc cancel",
        else => "tab next • shift+tab back • ←/→ choose • enter submit • esc cancel",
    };
}

fn viewPage(form: *const Form, w: *Writer, page: usize) Writer.Error!void {
    const p = form.pages[page];
    if (p.title.len > 0) {
        try form.styles.title.paint(w, p.title);
        try w.writeByte('\n');
        if (p.description.len > 0) {
            try form.styles.description.paint(w, p.description);
            try w.writeByte('\n');
        }
        try w.writeByte('\n');
    }
    const range = form.pageRange(page);
    try form.viewRange(w, range.start, range.end);
}

fn viewColumns(form: *const Form, w: *Writer, columns: usize) Writer.Error!void {
    // Each page drawn as a block `column_width` wide, the blocks of a row joined side by
    // side, `columns` pages a row (four at most).
    const per_row = @min(columns, 4);
    var page: usize = 0;
    while (page < form.pages.len) {
        if (page > 0) try w.writeAll("\n\n");
        const count = @min(per_row, form.pages.len - page);
        var storage: [4][4096]u8 = undefined;
        var blocks: [4][]const u8 = undefined;
        for (0..count) |i| {
            var raw: [4096]u8 = undefined;
            var block: Writer = .fixed(&raw);
            form.viewPage(&block, page + i) catch {};
            var out: Writer = .fixed(&storage[i]);
            const style: tui.Style = .{
                .width = form.column_width,
                .padding = .{ .right = 2 },
            };
            style.render(&out, block.buffered()) catch {};
            blocks[i] = out.buffered();
        }
        try tui.layout.joinHorizontal(w, .start, blocks[0..count]);
        page += count;
    }
}

fn viewRange(form: *const Form, w: *Writer, from: usize, to: usize) Writer.Error!void {
    var first = true;
    for (from..to) |i| {
        const field = &form.fields[i];
        if (field.hide) |hide| if (hide(form)) continue;
        if (!first) try w.writeAll("\n\n");
        first = false;
        const focused = i == form.focus;
        const label = if (focused and field.takesFocus())
            form.styles.focused_label
        else
            form.styles.label;
        try label.paint(w, field.label);
        const typed = field.kind == .input or field.kind == .text;
        if (field.required and typed) try label.paint(w, " *");
        if (field.description.len > 0) {
            try w.writeByte('\n');
            try form.styles.description.paint(w, field.description);
        }
        if (field.kind == .note) continue;
        try w.writeByte('\n');
        switch (field.kind) {
            .input => |*input| try input.view(w),
            .text => |*area| try area.view(w),
            .choice => |choice| try form.viewChoice(w, choice),
            .select => |*s| try form.viewSelect(w, s, focused),
            .multi_select => |*m| try form.viewMultiSelect(w, m, focused),
            .confirm => |c| try form.viewConfirm(w, c, focused),
            .file => |picker| if (focused) {
                try picker.view(w);
            } else if (picker.chosen) |path| {
                try form.styles.option.paint(w, path);
            },
            .note => unreachable,
        }
        if (focused) if (form.problem) |problem| {
            try w.writeByte('\n');
            try form.styles.@"error".paint(w, problem);
        };
    }
}

fn viewChoice(form: *const Form, w: *Writer, choice: Choice) Writer.Error!void {
    for (choice.options, 0..) |option, i| {
        if (i > 0) try w.writeByte(' ');
        if (i == choice.index) {
            try form.styles.chosen.paint(w, "[");
            try form.styles.chosen.paint(w, option);
            try form.styles.chosen.paint(w, "]");
        } else {
            try w.writeByte(' ');
            try form.styles.option.paint(w, option);
            try w.writeByte(' ');
        }
    }
}

fn viewSelect(
    form: *const Form,
    w: *Writer,
    s: *const Select,
    focused: bool,
) Writer.Error!void {
    if (!focused) return form.styles.chosen.paint(w, s.selected());
    if (s.filtering or s.filter_len > 0) {
        try form.styles.description.paint(w, "/");
        try w.writeAll(s.query());
        if (s.filtering) try (tui.Style{ .reverse = true }).paint(w, " ");
        try w.writeByte('\n');
    }
    var shown: usize = 0;
    var i = s.offset;
    while (i < s.options.len and shown < s.height) : (i += 1) {
        if (!s.visible(i)) continue;
        if (shown > 0) try w.writeByte('\n');
        shown += 1;
        if (i == s.index) {
            try form.styles.cursor.paint(w, "> ");
            try form.styles.chosen.paint(w, s.options[i]);
        } else {
            try w.writeAll("  ");
            try form.styles.option.paint(w, s.options[i]);
        }
    }
}

fn viewMultiSelect(
    form: *const Form,
    w: *Writer,
    m: *const MultiSelect,
    focused: bool,
) Writer.Error!void {
    if (!focused) {
        var any = false;
        for (m.options, 0..) |option, i| if (m.isChosen(i)) {
            if (any) try w.writeAll(", ");
            any = true;
            try form.styles.chosen.paint(w, option);
        };
        if (!any) try form.styles.description.paint(w, "none");
        return;
    }
    for (m.options[0..@min(m.options.len, 64)], 0..) |option, i| {
        if (i > 0) try w.writeByte('\n');
        if (i == m.cursor) try form.styles.cursor.paint(w, "> ") else try w.writeAll("  ");
        if (m.isChosen(i)) {
            try form.styles.chosen.paint(w, "[•] ");
            try form.styles.chosen.paint(w, option);
        } else {
            try form.styles.option.paint(w, "[ ] ");
            try form.styles.option.paint(w, option);
        }
    }
}

fn viewConfirm(form: *const Form, w: *Writer, c: Confirm, focused: bool) Writer.Error!void {
    const on = if (focused) form.styles.button else form.styles.chosen;
    const off = form.styles.blurred_button;
    try (if (c.value) on else off).paint(w, " ");
    try (if (c.value) on else off).paint(w, c.affirmative);
    try (if (c.value) on else off).paint(w, " ");
    try w.writeByte(' ');
    try (if (!c.value) on else off).paint(w, " ");
    try (if (!c.value) on else off).paint(w, c.negative);
    try (if (!c.value) on else off).paint(w, " ");
}

const testing = std.testing;
const Driver = tui.testing.Driver(Form);

/// The default styles without colors, for tests that compare text.
const plain: Form.Styles = .base;

fn noSpaces(text: []const u8) ?[]const u8 {
    if (std.mem.indexOfScalar(u8, text, ' ') != null) return "no spaces, please";
    return null;
}

fn testFields() [2]Field {
    return .{
        .{
            .label = "Name",
            .kind = .{ .input = .{ .gpa = testing.allocator, .validate = noSpaces } },
            .required = true,
        },
        .{
            .label = "Pin",
            .description = "Four digits",
            .kind = .{ .input = .{ .gpa = testing.allocator, .password = true, .prompt = "" } },
        },
    };
}

fn freeFields(fields: []Field) void {
    for (fields) |*field| field.deinit();
}

test "fill in the fields and submit" {
    var fields = testFields();
    defer freeFields(&fields);
    var d: Driver = try .init(testing.allocator, .{ .fields = &fields, .show_help = false }, .{});
    defer d.deinit();
    try d.expectFrame("Name *\n>  \n\nPin\nFour digits"); // an empty, unfocused field draws nothing
    try d.typeText("ada");
    try d.press("enter");
    try testing.expectEqual(1, d.model().focus);
    try d.typeText("1234");
    try d.expectFrame("Name *\n> ada\n\nPin\nFour digits\n•••• ");
    try d.press("enter");
    try testing.expect(d.done());
    try testing.expectEqual(Status.submitted, d.model().status);
    try testing.expectEqualStrings("ada", fields[0].kind.input.value());
    try testing.expectEqualStrings("1234", fields[1].kind.input.value());
}

test "an invalid field keeps the focus and says why" {
    var fields = testFields();
    defer freeFields(&fields);
    var d: Driver = try .init(testing.allocator, .{ .fields = &fields, .title = "Sign up" }, .{});
    defer d.deinit();
    try d.press("enter");
    try testing.expectEqual(0, d.model().focus);
    try d.expectFrameContains("This field is required");
    try d.typeText("a b");
    try d.press("tab");
    try testing.expectEqual(1, d.model().focus);
    try d.press("enter"); // submitting finds the bad name
    try testing.expect(!d.done());
    try testing.expectEqual(0, d.model().focus);
    try d.expectFrame("Sign up\n\nName *\n> a b \nno spaces, please\n\nPin\nFour digits\n\n\n" ++
        "tab next • shift+tab back • ←/→ choose • enter submit • esc cancel");
    try d.press("backspace");
    try d.press("backspace");
    try d.press("shift+tab"); // stays on the first field
    try d.press("down");
    try d.press("enter");
    try testing.expect(d.done());
}

test "esc cancels; pastes go to the focused field" {
    var fields = testFields();
    defer freeFields(&fields);
    var d: Driver = try .init(testing.allocator, .{ .fields = &fields }, .{});
    defer d.deinit();
    try d.press("tab");
    try d.paste("42");
    try testing.expectEqualStrings("42", fields[1].kind.input.value());
    try d.press("esc");
    try testing.expectEqual(Status.canceled, d.model().status);
}

test "a choice moves with left and right, and cycles with space" {
    var fields = [_]Field{
        .{ .label = "Plan", .kind = .{ .choice = .{ .options = &.{ "free", "pro", "team" } } } },
        .{ .label = "Name", .kind = .{ .input = .{ .gpa = testing.allocator } } },
    };
    defer for (&fields) |*field| field.deinit();
    var d: Driver = try .init(testing.allocator, .{ .fields = &fields, .show_help = false }, .{});
    defer d.deinit();
    try d.expectFrame("Plan\n[free]  pro   team \n\nName\n> ");
    try d.press("right");
    try d.press("l");
    try d.press("right"); // stays on the last
    try testing.expectEqualStrings("team", fields[0].kind.choice.selected());
    try d.press("space"); // wraps around
    try d.press("left"); // stays on the first
    try d.paste("ignored");
    try d.expectFrame("Plan\n[free]  pro   team \n\nName\n> ");
    try d.press("enter");
    try d.typeText("x");
    try d.press("enter");
    try testing.expectEqual(Status.submitted, d.model().status);
    try testing.expectEqual(0, fields[0].kind.choice.index);
}

test "selects filter, multi-selects need their minimum, confirms toggle" {
    var fields = [_]Field{
        .{
            .label = "Region",
            .kind = .{ .select = .{ .options = &.{ "eu-west", "us-east", "ap-south" } } },
        },
        .{ .label = "Extras", .kind = .{ .multi_select = .{
            .options = &.{ "ssl", "cdn", "waf" },
            .min = 1,
            .limit = 2,
        } } },
        .{ .label = "Agree?", .kind = .{ .confirm = .{} } },
    };
    var d: Driver = try .init(
        testing.allocator,
        .{ .fields = &fields, .show_help = false, .styles = plain },
        .{},
    );
    defer d.deinit();
    try d.expectFrame("Region\n> eu-west\n  us-east\n  ap-south\n\nExtras\nnone\n\n" ++
        "Agree?\n Yes   No ");
    try d.typeText("/us");
    try d.press("enter"); // ends the filter
    try d.expectFrameContains("> us-east");
    try d.press("enter");
    try testing.expectEqualStrings("us-east", fields[0].kind.select.selected());
    try d.press("enter"); // nothing chosen yet
    try d.expectFrameContains("Choose more");
    try d.press("space");
    try d.press("down");
    try d.press("space");
    try d.press("down");
    try d.press("space"); // the limit is two
    try testing.expectEqual(2, fields[1].kind.multi_select.count());
    try d.press("enter");
    try d.press("y");
    try d.press("enter");
    try testing.expectEqual(Status.submitted, d.model().status);
    try testing.expect(fields[2].kind.confirm.value);
}

fn proOnly(form: *const Form) bool {
    return form.fields[0].kind.choice.index != 1;
}

test "pages show one at a time; hidden fields are skipped" {
    var fields = [_]Field{
        .{ .label = "Plan", .kind = .{ .choice = .{ .options = &.{ "free", "pro" } } } },
        .{
            .label = "Seats",
            .kind = .{ .input = .{ .gpa = testing.allocator } },
            .hide = proOnly,
            .required = true,
        },
        .{ .label = "About", .description = "That is all.", .kind = .note },
        .{ .label = "Notes", .kind = .{ .text = .{ .gpa = testing.allocator } } },
    };
    defer for (&fields) |*field| field.deinit();
    var d: Driver = try .init(testing.allocator, .{
        .fields = &fields,
        .pages = &.{ .{ .title = "Plan", .count = 2 }, .{ .title = "More", .count = 2 } },
        .show_help = false,
        .styles = plain,
    }, .{});
    defer d.deinit();
    // Free plan: no seats asked.
    try d.expectFrame("Plan\n\nPlan\n[free]  pro ");
    try d.press("enter");
    try testing.expectEqual(3, d.model().focus); // the note is skipped too
    try d.expectFrameContains("More");
    try d.typeText("line one");
    try d.press("alt+enter");
    try d.typeText("two");
    try d.press("shift+tab");
    try d.press("right"); // pro
    try d.press("enter"); // now seats is asked, and required
    try testing.expectEqual(1, d.model().focus);
    try d.press("enter");
    try d.expectFrameContains("This field is required");
    try d.typeText("5");
    try d.press("enter");
    try d.press("enter");
    try testing.expectEqual(Status.submitted, d.model().status);
    try testing.expectEqualStrings("line one\ntwo", fields[3].kind.text.value());
}