termancy Guide Components Commands API

input

A single-line text input: placeholder, password mode, validation

termancy tui add input
termancy ui demo input, recorded

A single-line text input: a prompt, the text with a cursor, an optional placeholder, password masking, a length limit, and a validation hook.

var input: Input = .{ .gpa = gpa, .placeholder = "your name" };
defer input.deinit();
// ... run it as a program, or forward messages to it from a larger model ...
if (input.status == .submitted) use(input.value());

Keys: printable characters insert; left/right (ctrl+b/ctrl+f) move by character, alt+left/alt+right (alt+b/alt+f) by word; home/end (ctrl+a/ctrl+e) jump; backspace and delete (ctrl+d) erase; ctrl+w erases the word before the cursor, ctrl+u everything before it, ctrl+k everything after it. Enter submits when validate accepts the text (its message is shown otherwise); esc cancels. A paste inserts its text with line breaks and tabs turned into spaces.

Ctrl+z undoes the last edit and ctrl+y (or ctrl+shift+z) redoes it, cursor included. Typing joins one undo step per word, and so does a run of backspaces or deletes; a paste, a ctrl+w/ctrl+u/ctrl+k, setValue (an accepted suggestion too), and any edit after the cursor moved are steps of their own. A new edit forgets what was undone. undo_limit steps are kept, as copies of the text; 0 turns undo off. A tui.Program with suspend_on_ctrl_z on stops the process on ctrl+z instead; ctrl+y still redoes.

With suggestions, the rest of the first one that starts with what was typed shows after the cursor, dimmed: tab (or right at the end) takes it, ctrl+n and ctrl+p go through the others that match. hidden shows nothing as you type (for a secret whose length should not show either); password shows mask for each character. cursorColumn is where a real terminal cursor goes (tui.Cursor).

The cursor moves by grapheme cluster (see tui.width), so it never splits a character. The text grows with gpa; a keystroke that needs memory that is not there is dropped.

input.zig

//! A single-line text input: a prompt, the text with a cursor, an optional placeholder,
//! password masking, a length limit, and a validation hook.
//!
//! ```zig
//! var input: Input = .{ .gpa = gpa, .placeholder = "your name" };
//! defer input.deinit();
//! // ... run it as a program, or forward messages to it from a larger model ...
//! if (input.status == .submitted) use(input.value());
//! ```
//!
//! Keys: printable characters insert; left/right (ctrl+b/ctrl+f) move by character,
//! alt+left/alt+right (alt+b/alt+f) by word; home/end (ctrl+a/ctrl+e) jump; backspace and
//! delete (ctrl+d) erase; ctrl+w erases the word before the cursor, ctrl+u everything before
//! it, ctrl+k everything after it. Enter submits when `validate` accepts the text (its
//! message is shown otherwise); esc cancels. A paste inserts its text with line breaks and
//! tabs turned into spaces.
//!
//! Ctrl+z undoes the last edit and ctrl+y (or ctrl+shift+z) redoes it, cursor included.
//! Typing joins one undo step per word, and so does a run of backspaces or deletes; a
//! paste, a ctrl+w/ctrl+u/ctrl+k, `setValue` (an accepted suggestion too), and any edit
//! after the cursor moved are steps of their own. A new edit forgets what was undone.
//! `undo_limit` steps are kept, as copies of the text; 0 turns undo off. A `tui.Program`
//! with `suspend_on_ctrl_z` on stops the process on ctrl+z instead; ctrl+y still redoes.
//!
//! With `suggestions`, the rest of the first one that starts with what was typed shows
//! after the cursor, dimmed: tab (or right at the end) takes it, ctrl+n and ctrl+p go
//! through the others that match. `hidden` shows nothing as you type (for a secret whose
//! length should not show either); `password` shows `mask` for each character.
//! `cursorColumn` is where a real terminal cursor goes (`tui.Cursor`).
//!
//! The cursor moves by grapheme cluster (see `tui.width`), so it never splits a character.
//! The text grows with `gpa`; a keystroke that needs memory that is not there is dropped.

const std = @import("std");
const tui = @import("tui");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;

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

/// Checks the text on submit: null to accept it, or a message saying what is wrong.
pub const Validator = *const fn (text: []const u8) ?[]const u8;

const Input = @This();

gpa: Allocator,
text: std.ArrayList(u8) = .empty,
/// Byte offset of the cursor in `text`, always at a cluster boundary.
cursor: usize = 0,
prompt: []const u8 = "> ",
/// Shown, dimmed, while the text is empty.
placeholder: []const u8 = "",
/// Show `mask` for each character instead of the text.
password: bool = false,
mask: []const u8 = "•",
/// The most characters (grapheme clusters) the text may hold.
char_limit: ?usize = null,
validate: ?Validator = null,
/// Whether the cursor is drawn; a model holding several inputs focuses one.
focused: bool = true,
status: Status = .active,
/// What `validate` said about the last submitted text, until the next edit.
message: ?[]const u8 = null,
/// Completions offered as you type: the first that starts with the text (ignoring
/// ASCII case) shows after it.
suggestions: []const []const u8 = &.{},
/// Which of the matching suggestions is offered.
suggestion: usize = 0,
/// Show nothing of the text, not even its length.
hidden: bool = false,
/// The most edits ctrl+z can take back; 0 turns undo off.
undo_limit: usize = 100,
/// The text and cursor before each edit that can be undone, the oldest first.
undo_steps: std.ArrayList(Snapshot) = .empty,
/// The states undone, the latest undone last.
redo_steps: std.ArrayList(Snapshot) = .empty,
/// The last edit and where it left the cursor: another like it there joins its step.
last_edit: Edit = .none,
edit_end: usize = 0,
styles: Styles = .{},

pub const Snapshot = struct { text: std.ArrayList(u8), cursor: usize };

/// What an edit was, for joining edits into one undo step.
pub const Edit = enum { none, typing, erasing, other };

pub const Styles = struct {
    prompt: tui.Style = .{ .fg = .{ .indexed = 212 } },
    text: tui.Style = .{},
    placeholder: tui.Style = .{ .fg = .{ .indexed = 240 } },
    cursor: tui.Style = .{ .reverse = true },
    message: tui.Style = .{ .fg = .{ .indexed = 203 } },
    suggestion: tui.Style = .{ .fg = .{ .indexed = 240 } },
};

/// The suggestion on offer, whole, or null when none matches what was typed.
pub fn offered(input: *const Input) ?[]const u8 {
    var index: usize = 0;
    var first: ?[]const u8 = null;
    for (input.suggestions) |candidate| {
        if (!matchesPrefix(candidate, input.value())) continue;
        if (first == null) first = candidate;
        if (index == input.suggestion) return candidate;
        index += 1;
    }
    return first;
}

fn matchCount(input: *const Input) usize {
    var n: usize = 0;
    for (input.suggestions) |candidate| {
        n += @intFromBool(matchesPrefix(candidate, input.value()));
    }
    return n;
}

fn matchesPrefix(candidate: []const u8, typed: []const u8) bool {
    return typed.len > 0 and candidate.len > typed.len and
        std.ascii.startsWithIgnoreCase(candidate, typed);
}

/// Takes the suggestion on offer as the text; returns whether there was one.
pub fn acceptSuggestion(input: *Input) bool {
    const choice = input.offered() orelse return false;
    input.setValue(choice) catch return false;
    input.suggestion = 0;
    return true;
}

/// The column of the cursor in the view: after the prompt and the text before it.
pub fn cursorColumn(input: *const Input) usize {
    const before = input.value()[0..input.cursor];
    const typed = if (input.hidden)
        0
    else if (input.password)
        clusterCount(before) * tui.width.of(input.mask)
    else
        tui.width.of(before);
    return tui.width.of(input.prompt) + typed;
}

/// Frees the text and the undo history.
pub fn deinit(input: *Input) void {
    input.text.deinit(input.gpa);
    for (input.undo_steps.items) |*step| step.text.deinit(input.gpa);
    for (input.redo_steps.items) |*step| step.text.deinit(input.gpa);
    input.undo_steps.deinit(input.gpa);
    input.redo_steps.deinit(input.gpa);
    input.* = undefined;
}

/// The text typed so far.
pub fn value(input: *const Input) []const u8 {
    return input.text.items;
}

/// Replaces the text (cut to `char_limit`) and puts the cursor at its end, as one
/// step ctrl+z takes back.
pub fn setValue(input: *Input, text: []const u8) Allocator.Error!void {
    input.remember(.other);
    input.text.clearRetainingCapacity();
    input.cursor = 0;
    try input.insertAs(text, .none);
}

/// Inserts `text` at the cursor, as much of it as `char_limit` allows. Line breaks and
/// tabs become spaces; other control characters and escapes are dropped.
pub fn insert(input: *Input, text: []const u8) Allocator.Error!void {
    return input.insertAs(text, .other);
}

/// `insert`, recorded for undo as `kind` (`.none`: not recorded).
fn insertAs(input: *Input, text: []const u8, kind: Edit) Allocator.Error!void {
    var clean: std.ArrayList(u8) = .empty;
    defer clean.deinit(input.gpa);
    var room = if (input.char_limit) |limit| limit -| clusterCount(input.value()) else null;
    var it: tui.width.Iterator = .init(text);
    while (it.next()) |cluster| {
        if (room) |left| {
            if (left == 0) break;
        }
        const bytes = sanitize(cluster) orelse continue;
        try clean.appendSlice(input.gpa, bytes);
        if (room) |*left| left.* -= 1;
    }
    input.message = null;
    if (clean.items.len == 0) return;
    input.remember(kind);
    try input.text.insertSlice(input.gpa, input.cursor, clean.items);
    input.cursor += clean.items.len;
    input.edit_end = input.cursor;
}

/// Records the text before an edit of `kind`, unless it continues the last edit (the
/// same kind of typing or erasing, where that one left the cursor). A new step
/// forgets what was undone. Without memory for it, the edit goes unrecorded.
fn remember(input: *Input, kind: Edit) void {
    if (kind == .none or input.undo_limit == 0) return;
    const joins = kind != .other and kind == input.last_edit and
        input.cursor == input.edit_end;
    input.last_edit = kind;
    if (joins) return;
    for (input.redo_steps.items) |*step| step.text.deinit(input.gpa);
    input.redo_steps.clearRetainingCapacity();
    input.undo_steps.ensureUnusedCapacity(input.gpa, 1) catch return;
    const copy = input.gpa.dupe(u8, input.value()) catch return;
    if (input.undo_steps.items.len >= input.undo_limit) {
        var oldest = input.undo_steps.orderedRemove(0);
        oldest.text.deinit(input.gpa);
    }
    const step: Snapshot = .{ .text = .fromOwnedSlice(copy), .cursor = input.cursor };
    input.undo_steps.appendAssumeCapacity(step);
}

/// Takes back the last edit step; returns whether there was one.
pub fn undo(input: *Input) bool {
    return input.swapStep(&input.undo_steps, &input.redo_steps);
}

/// Makes again the last step undone; returns whether there was one.
pub fn redo(input: *Input) bool {
    return input.swapStep(&input.redo_steps, &input.undo_steps);
}

/// Restores the last state in `from`, keeping the current one in `to`.
fn swapStep(input: *Input, from: *std.ArrayList(Snapshot), to: *std.ArrayList(Snapshot)) bool {
    if (from.items.len == 0) return false;
    to.ensureUnusedCapacity(input.gpa, 1) catch return false;
    const step = from.pop().?;
    to.appendAssumeCapacity(.{ .text = input.text, .cursor = input.cursor });
    input.text = step.text;
    input.cursor = step.cursor;
    input.last_edit = .none;
    input.message = null;
    return true;
}

fn sanitize(cluster: tui.width.Cluster) ?[]const u8 {
    if (cluster.escape) return null;
    return switch (cluster.bytes[0]) {
        '\n', '\r', '\t' => " ",
        0...0x08, 0x0b, 0x0c, 0x0e...0x1f, 0x7f => null,
        else => cluster.bytes,
    };
}

pub fn update(input: *Input, msg: tui.Msg) tui.Cmd {
    switch (msg) {
        .key => |key| {
            if (key.matches("enter")) return input.submit();
            if (key.matches("esc")) {
                input.status = .canceled;
                return .quit;
            }
            _ = input.handleKey(key);
        },
        .paste => |text| input.insert(text) catch {},
        else => {},
    }
    return .none;
}

/// Checks the text with `validate`: submits and quits when it passes, shows the
/// message otherwise.
pub fn submit(input: *Input) tui.Cmd {
    if (!input.check()) return .none;
    input.status = .submitted;
    return .quit;
}

/// Runs `validate`, recording its message; returns whether the text passes.
pub fn check(input: *Input) bool {
    const validate = input.validate orelse return true;
    input.message = validate(input.value());
    return input.message == null;
}

/// Applies an editing or movement key; returns whether the key was one. Enter and esc
/// are not: the caller decides what they mean.
pub fn handleKey(input: *Input, key: tui.input.Key) bool {
    if (input.suggestions.len > 0) {
        const at_end = input.cursor == input.text.items.len;
        if (key.matches("tab") or (at_end and key.matches("right"))) {
            if (input.acceptSuggestion()) return true;
        }
        if (key.matches("ctrl+n") or key.matches("ctrl+p")) {
            const count = input.matchCount();
            if (count > 0) {
                input.suggestion = if (key.matches("ctrl+n"))
                    (input.suggestion + 1) % count
                else
                    (input.suggestion + count - 1) % count;
            }
            return true;
        }
    }
    defer if (input.suggestion >= @max(input.matchCount(), 1)) {
        input.suggestion = 0;
    };
    if (key.printable()) |c| {
        var buffer: [4]u8 = undefined;
        const len = std.unicode.utf8Encode(c, &buffer) catch return true;
        // A word typed after a space is a new undo step.
        const text = input.value();
        if (c != ' ' and input.cursor > 0 and text[input.cursor - 1] == ' ') {
            input.last_edit = .none;
        }
        input.insertAs(buffer[0..len], .typing) catch {};
    } else if (key.matches("ctrl+z")) {
        _ = input.undo();
    } else if (key.matches("ctrl+y") or key.matches("ctrl+Z")) {
        // The decoder reports ctrl+shift+z as ctrl+Z.
        _ = input.redo();
    } else if (key.matches("left") or key.matches("ctrl+b")) {
        input.cursor = previousBoundary(input.value(), input.cursor);
    } else if (key.matches("right") or key.matches("ctrl+f")) {
        input.cursor = nextBoundary(input.value(), input.cursor);
    } else if (key.matches("alt+left") or key.matches("alt+b")) {
        input.cursor = wordStart(input.value(), input.cursor);
    } else if (key.matches("alt+right") or key.matches("alt+f")) {
        input.cursor = wordEnd(input.value(), input.cursor);
    } else if (key.matches("home") or key.matches("ctrl+a")) {
        input.cursor = 0;
    } else if (key.matches("end") or key.matches("ctrl+e")) {
        input.cursor = input.text.items.len;
    } else if (key.matches("backspace")) {
        input.erase(previousBoundary(input.value(), input.cursor), input.cursor, .erasing);
    } else if (key.matches("delete") or key.matches("ctrl+d")) {
        input.erase(input.cursor, nextBoundary(input.value(), input.cursor), .erasing);
    } else if (key.matches("ctrl+w") or key.matches("alt+backspace")) {
        input.erase(wordStart(input.value(), input.cursor), input.cursor, .other);
    } else if (key.matches("ctrl+u")) {
        input.erase(0, input.cursor, .other);
    } else if (key.matches("ctrl+k")) {
        input.erase(input.cursor, input.text.items.len, .other);
    } else return false;
    return true;
}

/// Removes `text[from..to]` and leaves the cursor at `from`.
fn erase(input: *Input, from: usize, to: usize, kind: Edit) void {
    input.message = null;
    if (from == to) return;
    input.remember(kind);
    input.text.replaceRangeAssumeCapacity(from, to - from, &.{});
    input.cursor = from;
    input.edit_end = from;
}

pub fn view(input: *const Input, w: *Writer) Writer.Error!void {
    try input.styles.prompt.paint(w, input.prompt);
    if (input.text.items.len == 0) {
        try input.writePlaceholder(w);
    } else {
        try input.writeText(w);
    }
    if (input.message) |message| {
        try w.writeByte('\n');
        try input.styles.message.paint(w, message);
    }
}

fn writePlaceholder(input: *const Input, w: *Writer) Writer.Error!void {
    if (!input.focused) return input.styles.placeholder.paint(w, input.placeholder);
    const first = nextBoundary(input.placeholder, 0);
    try input.styles.cursor.paint(w, if (first == 0) " " else input.placeholder[0..first]);
    try input.styles.placeholder.paint(w, input.placeholder[first..]);
}

fn writeText(input: *const Input, w: *Writer) Writer.Error!void {
    const text = input.value();
    if (input.hidden) {
        if (input.focused) try input.styles.cursor.paint(w, " ");
        return;
    }
    var it: tui.width.Iterator = .init(text);
    var start: usize = 0;
    while (it.next()) |cluster| : (start = it.index) {
        const shown = if (input.password) input.mask else cluster.bytes;
        const style = if (input.focused and start == input.cursor)
            input.styles.cursor
        else
            input.styles.text;
        try style.paint(w, shown);
    }
    // The rest of the suggestion on offer, its first character under the cursor.
    const rest = if (input.offered()) |choice| choice[text.len..] else "";
    if (input.focused and input.cursor == text.len) {
        const first = nextBoundary(rest, 0);
        try input.styles.cursor.paint(w, if (first == 0) " " else rest[0..first]);
        try input.styles.suggestion.paint(w, rest[first..]);
    } else {
        try input.styles.suggestion.paint(w, rest);
    }
}

/// The number of grapheme clusters in `text`.
pub fn clusterCount(text: []const u8) usize {
    var count: usize = 0;
    var it: tui.width.Iterator = .init(text);
    while (it.next()) |_| count += 1;
    return count;
}

/// The cluster boundary before `index`, or 0.
pub fn previousBoundary(text: []const u8, index: usize) usize {
    var it: tui.width.Iterator = .init(text);
    var last: usize = 0;
    while (it.index < index) {
        last = it.index;
        _ = it.next() orelse break;
    }
    return last;
}

/// The cluster boundary after `index`, or the end of `text`.
pub fn nextBoundary(text: []const u8, index: usize) usize {
    var it: tui.width.Iterator = .{ .bytes = text, .index = index };
    _ = it.next();
    return it.index;
}

/// The start of the word before `index`: back over spaces, then over the word.
pub fn wordStart(text: []const u8, index: usize) usize {
    var i = index;
    while (i > 0 and text[i - 1] == ' ') i -= 1;
    while (i > 0 and text[i - 1] != ' ') i -= 1;
    return i;
}

/// The end of the word after `index`: forward over spaces, then over the word.
pub fn wordEnd(text: []const u8, index: usize) usize {
    var i = index;
    while (i < text.len and text[i] == ' ') i += 1;
    while (i < text.len and text[i] != ' ') i += 1;
    return i;
}

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

test "suggestions show, cycle, and are taken with tab" {
    var d: Driver = try .init(testing.allocator, .{
        .gpa = testing.allocator,
        .prompt = "",
        .suggestions = &.{ "apple", "apricot", "banana" },
        .styles = .{ .cursor = .{}, .suggestion = .{} },
    }, .{});
    defer d.deinit();
    try d.typeText("ap");
    try d.expectFrame("apple");
    try d.press("ctrl+n");
    try d.expectFrame("apricot");
    try d.press("tab");
    try testing.expectEqualStrings("apricot", d.model().value());
    try testing.expectEqual(7, d.model().cursorColumn());
}

test "hidden input shows nothing" {
    var d: Driver = try .init(testing.allocator, .{
        .gpa = testing.allocator,
        .hidden = true,
        .styles = .{ .cursor = .{}, .prompt = .{} },
    }, .{});
    defer d.deinit();
    try d.typeText("secret");
    try d.expectFrame(">  ");
    try testing.expectEqual(2, d.model().cursorColumn());
}

fn startInput(d: *Driver, input: Input) !void {
    d.* = try .init(testing.allocator, input, .{});
}

fn finish(d: *Driver) void {
    d.deinit();
}

test "typing, moving, and erasing" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.expectFrame(">  ");
    try d.typeText("helo");
    try d.expectFrame("> helo ");
    try d.press("left");
    try d.typeText("l");
    try testing.expectEqualStrings("hello", d.model().value());
    try testing.expectEqual(4, d.model().cursor);
    try d.press("home");
    try d.press("delete");
    try d.press("end");
    try d.press("backspace");
    try testing.expectEqualStrings("ell", d.model().value());
    try d.press("ctrl+a");
    try d.press("right");
    try d.press("ctrl+k");
    try testing.expectEqualStrings("e", d.model().value());
    try d.press("enter");
    try testing.expect(d.done());
    try testing.expectEqual(Status.submitted, d.model().status);
}

test "word movement and erasing" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.typeText("one two  three");
    try d.press("alt+left");
    try testing.expectEqual(9, d.model().cursor);
    try d.press("alt+b");
    try testing.expectEqual(4, d.model().cursor);
    try d.press("alt+right");
    try testing.expectEqual(7, d.model().cursor);
    try d.press("ctrl+w");
    try testing.expectEqualStrings("one   three", d.model().value());
    try d.press("end");
    try d.press("ctrl+u");
    try testing.expectEqualStrings("", d.model().value());
}

test "the cursor moves by whole characters" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.typeText("a中é");
    try d.press("left");
    try testing.expectEqual(4, d.model().cursor);
    try d.press("left");
    try testing.expectEqual(1, d.model().cursor);
    try d.press("delete");
    try testing.expectEqualStrings("aé", d.model().value());
    try d.expectFrame("> aé");
}

test "the cursor is drawn in reverse video" {
    var buffer: [128]u8 = undefined;
    var w: Writer = .fixed(&buffer);
    var input: Input = .{ .gpa = testing.allocator, .styles = .{ .prompt = .{} } };
    defer input.deinit();
    try input.setValue("ab");
    input.cursor = 1;
    try input.view(&w);
    try testing.expectEqualStrings("> a\x1b[7mb\x1b[0m", w.buffered());
}

test "placeholder and password" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator, .placeholder = "secret", .password = true });
    defer finish(&d);
    try d.expectFrame("> secret");
    try d.typeText("hunter2");
    try d.expectFrame("> ••••••• ");
    try testing.expectEqualStrings("hunter2", d.model().value());
}

test "char_limit stops typing and cuts pastes" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator, .char_limit = 3 });
    defer finish(&d);
    try d.typeText("abcd");
    try testing.expectEqualStrings("abc", d.model().value());
    try d.press("home");
    try d.press("delete");
    try d.paste("xyz");
    try testing.expectEqualStrings("xbc", d.model().value());
}

test "a paste keeps to one line" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.paste("one\ntwo\tthree\x07");
    try testing.expectEqualStrings("one two three", d.model().value());
}

fn notEmpty(text: []const u8) ?[]const u8 {
    return if (text.len == 0) "required" else null;
}

test "validation blocks submit and shows its message until the next edit" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator, .validate = notEmpty });
    defer finish(&d);
    try d.press("enter");
    try testing.expect(!d.done());
    try d.expectFrame(">  \nrequired");
    try d.typeText("x");
    try d.expectFrame("> x ");
    try d.press("enter");
    try testing.expect(d.done());
}

test "esc cancels" {
    var d: Driver = undefined;
    try startInput(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.typeText("abc");
    try d.press("esc");
    try testing.expect(d.done());
    try testing.expectEqual(Status.canceled, d.model().status);
}

test "setValue and an unfocused input" {
    var buffer: [64]u8 = undefined;
    var w: Writer = .fixed(&buffer);
    var input: Input = .{ .gpa = testing.allocator, .focused = false, .prompt = "" };
    defer input.deinit();
    try input.setValue("ready");
    try testing.expectEqual(5, input.cursor);
    try input.view(&w);
    try testing.expectEqualStrings("ready", w.buffered());
}

test "boundaries and words" {
    try testing.expectEqual(0, previousBoundary("abc", 0));
    try testing.expectEqual(1, previousBoundary("a中", 4));
    try testing.expectEqual(4, nextBoundary("a中", 1));
    try testing.expectEqual(4, nextBoundary("a中", 4));
    try testing.expectEqual(3, clusterCount("a中é"));
    try testing.expectEqual(4, wordStart("ab  cd", 6));
    try testing.expectEqual(0, wordStart("ab  cd", 4));
    try testing.expectEqual(6, wordEnd("ab  cd", 2));
}

test "typing undoes a word at a time, and redo puts it back, cursor included" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.typeText("hello big world");
    try d.press("ctrl+z");
    try testing.expectEqualStrings("hello big ", d.model().value());
    try testing.expectEqual(10, d.model().cursor);
    try d.press("ctrl+z");
    try d.press("ctrl+z");
    try testing.expectEqualStrings("", d.model().value());
    try testing.expect(!d.model().undo());
    try d.press("ctrl+y");
    try testing.expectEqualStrings("hello ", d.model().value());
    try testing.expectEqual(6, d.model().cursor);
    try d.press("ctrl+Z"); // ctrl+shift+z
    try testing.expectEqualStrings("hello big ", d.model().value());
}

test "undo returns the cursor to where the edit was, and a move starts a new step" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.typeText("abcd");
    try d.press("home");
    try d.typeText("xy");
    try d.press("end");
    try d.press("backspace");
    try d.press("backspace");
    try testing.expectEqualStrings("xyab", d.model().value());
    try d.press("ctrl+z"); // both backspaces
    try testing.expectEqualStrings("xyabcd", d.model().value());
    try testing.expectEqual(6, d.model().cursor);
    try d.press("ctrl+z");
    try testing.expectEqualStrings("abcd", d.model().value());
    try testing.expectEqual(0, d.model().cursor);
}

test "a new edit forgets what was undone" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.typeText("one two");
    try d.press("ctrl+z");
    try d.typeText("x");
    try testing.expect(!d.model().redo());
    try testing.expectEqualStrings("one x", d.model().value());
}

test "a paste and a ctrl+w are one step each" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.typeText("a");
    try d.paste("bc de");
    try d.typeText("f");
    try d.press("ctrl+w");
    try testing.expectEqualStrings("abc ", d.model().value());
    try d.press("ctrl+z");
    try testing.expectEqualStrings("abc def", d.model().value());
    try d.press("ctrl+z");
    try testing.expectEqualStrings("abc de", d.model().value());
    try d.press("ctrl+z");
    try testing.expectEqualStrings("a", d.model().value());
}

test "undo_limit keeps the latest steps, and 0 turns undo off" {
    var input: Input = .{ .gpa = testing.allocator, .undo_limit = 2 };
    defer input.deinit();
    try input.setValue("a");
    try input.setValue("b");
    try input.setValue("c");
    try testing.expect(input.undo());
    try testing.expect(input.undo());
    try testing.expect(!input.undo());
    try testing.expectEqualStrings("a", input.value());

    var off: Input = .{ .gpa = testing.allocator, .undo_limit = 0 };
    defer off.deinit();
    try off.setValue("a");
    try testing.expect(!off.undo());
    try testing.expectEqualStrings("a", off.value());
}