termancy Guide Components Commands API

textarea

A multi-line text editor that scrolls

termancy tui add textarea
termancy ui demo textarea, recorded

A multi-line text editor: each line behind a prompt, a cursor that moves across lines keeping its column, an optional placeholder and length limit, and scrolling when the text is taller than height.

var area: Textarea = .{ .gpa = gpa, .placeholder = "Tell us more", .height = 5 };
defer area.deinit();
// ... run it; ctrl+d submits ...
if (area.status == .submitted) use(area.value());

Keys: printable characters insert, enter starts a new line; left/right move by character, alt+left/alt+right by word, up/down by line (keeping the column), home/end (ctrl+a/ctrl+e) to the ends of the line; backspace and delete erase; ctrl+w erases the word before the cursor, ctrl+u the line before it, ctrl+k the line after it. Ctrl+d submits; esc cancels. A paste inserts its text with \r\n and \r as line breaks.

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

The text grows with gpa; a keystroke that needs memory that is not there is dropped.

textarea.zig

//! A multi-line text editor: each line behind a prompt, a cursor that moves across lines
//! keeping its column, an optional placeholder and length limit, and scrolling when the
//! text is taller than `height`.
//!
//! ```zig
//! var area: Textarea = .{ .gpa = gpa, .placeholder = "Tell us more", .height = 5 };
//! defer area.deinit();
//! // ... run it; ctrl+d submits ...
//! if (area.status == .submitted) use(area.value());
//! ```
//!
//! Keys: printable characters insert, enter starts a new line; left/right move by
//! character, alt+left/alt+right by word, up/down by line (keeping the column), home/end
//! (ctrl+a/ctrl+e) to the ends of the line; backspace and delete erase; ctrl+w erases the
//! word before the cursor, ctrl+u the line before it, ctrl+k the line after it. Ctrl+d
//! submits; esc cancels. A paste inserts its text with `\r\n` and `\r` as line breaks.
//!
//! Ctrl+z undoes the last edit and ctrl+y (or ctrl+shift+z) redoes it, cursor included.
//! Typing joins one undo step per word (a line break ends a word), and so does a run of
//! backspaces or deletes; a paste, a ctrl+w/ctrl+u/ctrl+k, `setValue`, and any edit after
//! the cursor moved are steps of their own. A new edit forgets what was undone.
//! `undo_limit` steps are kept, each a copy of the whole text; 0 turns undo off. A
//! `tui.Program` with `suspend_on_ctrl_z` on stops the process on ctrl+z instead.
//!
//! 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 };

const Textarea = @This();

gpa: Allocator,
text: std.ArrayList(u8) = .empty,
/// Byte offset of the cursor in `text`, always at a cluster boundary.
cursor: usize = 0,
/// Written before every line.
prompt: []const u8 = "┃ ",
/// Shown, dimmed, while the text is empty.
placeholder: []const u8 = "",
/// The most characters (grapheme clusters, line breaks included) the text may hold.
char_limit: ?usize = null,
/// The most lines shown; null shows them all.
height: ?usize = null,
/// The first line shown.
offset: usize = 0,
/// The column up and down aim for: where the cursor was before it met a shorter line.
goal: ?usize = null,
/// Whether the cursor is drawn.
focused: bool = true,
/// Number the lines, after the prompt.
line_numbers: bool = false,
status: Status = .active,
/// The most edits ctrl+z can take back; 0 turns undo off.
// shortcut: each step copies the whole text, so memory is undo_limit times its size;
// store the edits instead if texts grow to megabytes.
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 = 238 } },
    text: tui.Style = .{},
    placeholder: tui.Style = .{ .fg = .{ .indexed = 240 } },
    cursor: tui.Style = .{ .reverse = true },
    line_number: tui.Style = .{ .fg = .{ .indexed = 240 } },
};

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

/// The text typed so far.
pub fn value(area: *const Textarea) []const u8 {
    return area.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(area: *Textarea, text: []const u8) Allocator.Error!void {
    area.remember(.other);
    area.text.clearRetainingCapacity();
    area.cursor = 0;
    area.offset = 0;
    try area.insertAs(text, .none);
}

/// Inserts `text` at the cursor, as much as `char_limit` allows. `\r\n` and `\r` become
/// line breaks, a tab four spaces; other control characters and escapes are dropped.
pub fn insert(area: *Textarea, text: []const u8) Allocator.Error!void {
    return area.insertAs(text, .other);
}

/// `insert`, recorded for undo as `kind` (`.none`: not recorded).
fn insertAs(area: *Textarea, text: []const u8, kind: Edit) Allocator.Error!void {
    var clean: std.ArrayList(u8) = .empty;
    defer clean.deinit(area.gpa);
    var room = if (area.char_limit) |limit| limit -| clusterCount(area.value()) else null;
    var it: tui.width.Iterator = .init(text);
    while (it.next()) |cluster| {
        if (room) |left| {
            if (left == 0) break;
        }
        // The `\r` of a `\r\n`: the `\n` that follows is the line break.
        if (cluster.bytes[0] == '\r' and std.mem.startsWith(u8, text[it.index..], "\n")) {
            continue;
        }
        const bytes = sanitize(cluster) orelse continue;
        try clean.appendSlice(area.gpa, bytes);
        if (room) |*left| left.* -= 1;
    }
    if (clean.items.len > 0) {
        area.remember(kind);
        try area.text.insertSlice(area.gpa, area.cursor, clean.items);
        area.cursor += clean.items.len;
        area.edit_end = area.cursor;
    }
    area.goal = null;
    area.scroll();
}

/// 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(area: *Textarea, kind: Edit) void {
    if (kind == .none or area.undo_limit == 0) return;
    const joins = kind != .other and kind == area.last_edit and
        area.cursor == area.edit_end;
    area.last_edit = kind;
    if (joins) return;
    for (area.redo_steps.items) |*step| step.text.deinit(area.gpa);
    area.redo_steps.clearRetainingCapacity();
    area.undo_steps.ensureUnusedCapacity(area.gpa, 1) catch return;
    const copy = area.gpa.dupe(u8, area.value()) catch return;
    if (area.undo_steps.items.len >= area.undo_limit) {
        var oldest = area.undo_steps.orderedRemove(0);
        oldest.text.deinit(area.gpa);
    }
    const step: Snapshot = .{ .text = .fromOwnedSlice(copy), .cursor = area.cursor };
    area.undo_steps.appendAssumeCapacity(step);
}

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

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

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

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

pub fn update(area: *Textarea, msg: tui.Msg) tui.Cmd {
    switch (msg) {
        .key => |key| {
            if (key.matches("ctrl+d")) {
                area.status = .submitted;
                return .quit;
            }
            if (key.matches("esc")) {
                area.status = .canceled;
                return .quit;
            }
            _ = area.handleKey(key);
        },
        .paste => |text| area.insert(text) catch {},
        else => {},
    }
    return .none;
}

/// Applies an editing or movement key; returns whether the key was one. Ctrl+d and esc
/// are not: the caller decides what they mean.
pub fn handleKey(area: *Textarea, key: tui.input.Key) bool {
    if (key.matches("up")) {
        area.moveLine(.up);
    } else if (key.matches("down")) {
        area.moveLine(.down);
    } else if (area.edit(key)) {
        area.goal = null;
    } else return false;
    area.scroll();
    return true;
}

/// Every key but up and down: they forget the goal column.
fn edit(area: *Textarea, key: tui.input.Key) bool {
    const text = area.value();
    const line = lineOf(text, area.cursor);
    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 or a line break is a new undo step.
        if (c != ' ' and area.cursor > 0 and isSpace(text[area.cursor - 1])) {
            area.last_edit = .none;
        }
        area.insertAs(buffer[0..len], .typing) catch {};
    } else if (key.matches("enter")) {
        area.insertAs("\n", .typing) catch {};
    } else if (key.matches("ctrl+z")) {
        _ = area.undo();
    } else if (key.matches("ctrl+y") or key.matches("ctrl+Z")) {
        // The decoder reports ctrl+shift+z as ctrl+Z.
        _ = area.redo();
    } else if (key.matches("left") or key.matches("ctrl+b")) {
        area.cursor = previousBoundary(text, area.cursor);
    } else if (key.matches("right") or key.matches("ctrl+f")) {
        area.cursor = nextBoundary(text, area.cursor);
    } else if (key.matches("alt+left") or key.matches("alt+b")) {
        area.cursor = wordStart(text, area.cursor);
    } else if (key.matches("alt+right") or key.matches("alt+f")) {
        area.cursor = wordEnd(text, area.cursor);
    } else if (key.matches("home") or key.matches("ctrl+a")) {
        area.cursor = line.start;
    } else if (key.matches("end") or key.matches("ctrl+e")) {
        area.cursor = line.end;
    } else if (key.matches("backspace")) {
        area.erase(previousBoundary(text, area.cursor), area.cursor, .erasing);
    } else if (key.matches("delete")) {
        area.erase(area.cursor, nextBoundary(text, area.cursor), .erasing);
    } else if (key.matches("ctrl+w") or key.matches("alt+backspace")) {
        area.erase(wordStart(text, area.cursor), area.cursor, .other);
    } else if (key.matches("ctrl+u")) {
        area.erase(line.start, area.cursor, .other);
    } else if (key.matches("ctrl+k")) {
        area.erase(area.cursor, line.end, .other);
    } else return false;
    return true;
}

fn erase(area: *Textarea, from: usize, to: usize, kind: Edit) void {
    if (from == to) return;
    area.remember(kind);
    area.text.replaceRangeAssumeCapacity(from, to - from, &.{});
    area.cursor = from;
    area.edit_end = from;
}

const Direction = enum { up, down };

/// Moves to the line above or below, as near the goal column as that line allows. On
/// the first (last) line, up (down) goes to its start (end).
fn moveLine(area: *Textarea, direction: Direction) void {
    const text = area.value();
    const here = lineOf(text, area.cursor);
    const column = area.goal orelse tui.width.of(text[here.start..area.cursor]);
    area.goal = column;
    const target = switch (direction) {
        .up => if (here.start == 0) {
            area.cursor = 0;
            return;
        } else lineOf(text, here.start - 1),
        .down => if (here.end == text.len) {
            area.cursor = text.len;
            return;
        } else lineOf(text, here.end + 1),
    };
    area.cursor = target.start + offsetAtColumn(text[target.start..target.end], column);
}

/// Scrolls so the cursor's line is shown.
fn scroll(area: *Textarea) void {
    const height = area.height orelse return;
    const row = std.mem.count(u8, area.value()[0..area.cursor], "\n");
    if (row < area.offset) area.offset = row;
    if (row >= area.offset + height) area.offset = row + 1 - @max(height, 1);
}

pub fn view(area: *const Textarea, w: *Writer) Writer.Error!void {
    try area.styles.prompt.paint(w, area.prompt);
    if (area.text.items.len == 0) {
        try area.writeNumber(w, 0);
        return area.writePlaceholder(w);
    }
    const text = area.value();
    const last = if (area.height) |height| area.offset + height else std.math.maxInt(usize);
    var start: usize = 0;
    var row: usize = 0;
    while (row < last) : (row += 1) {
        const end = std.mem.indexOfScalarPos(u8, text, start, '\n') orelse text.len;
        if (row >= area.offset) {
            if (row > area.offset) try area.styles.prompt.paint(w, area.prompt);
            try area.writeNumber(w, row);
            try area.writeLine(w, text, start, end);
            if (end < text.len and row + 1 < last) try w.writeByte('\n');
        }
        if (end == text.len) break;
        start = end + 1;
    }
}

/// The number of line `row` (from 0), right-aligned to the widest, when
/// `line_numbers` is on.
fn writeNumber(area: *const Textarea, w: *Writer, row: usize) Writer.Error!void {
    if (!area.line_numbers) return;
    const lines = std.mem.count(u8, area.value(), "\n") + 1;
    var widest: usize = 1;
    var n = lines;
    while (n >= 10) : (n /= 10) widest += 1;
    var buffer: [24]u8 = undefined;
    const number = std.fmt.bufPrint(&buffer, "{d: >[1]} ", .{ row + 1, widest }) catch "";
    try area.styles.line_number.paint(w, number);
}

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

/// Writes `text[start..end]`, with the cursor if it is on this line.
fn writeLine(
    area: *const Textarea,
    w: *Writer,
    text: []const u8,
    start: usize,
    end: usize,
) Writer.Error!void {
    var it: tui.width.Iterator = .{ .bytes = text[0..end], .index = start };
    var at = start;
    while (it.next()) |cluster| : (at = it.index) {
        const on_cursor = area.focused and at == area.cursor;
        const style = if (on_cursor) area.styles.cursor else area.styles.text;
        try style.paint(w, cluster.bytes);
    }
    if (area.focused and area.cursor == end) try area.styles.cursor.paint(w, " ");
}

/// The bounds of the line holding `index`: its first byte and its `\n` (or the end).
pub const Line = struct { start: usize, end: usize };

/// The line `index` is on.
pub fn lineOf(text: []const u8, index: usize) Line {
    const start = if (std.mem.lastIndexOfScalar(u8, text[0..index], '\n')) |i| i + 1 else 0;
    const end = std.mem.indexOfScalarPos(u8, text, index, '\n') orelse text.len;
    return .{ .start = start, .end = end };
}

/// The byte offset in `line` of the last cluster boundary at or before display `column`.
pub fn offsetAtColumn(line: []const u8, column: usize) usize {
    var it: tui.width.Iterator = .init(line);
    var used: usize = 0;
    var offset: usize = 0;
    while (it.next()) |cluster| {
        if (used + cluster.width > column) break;
        used += cluster.width;
        offset = it.index;
    }
    return offset;
}

/// 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;
}

fn isSpace(c: u8) bool {
    return c == ' ' or c == '\n';
}

/// 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 isSpace(text[i - 1])) i -= 1;
    while (i > 0 and !isSpace(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 isSpace(text[i])) i += 1;
    while (i < text.len and !isSpace(text[i])) i += 1;
    return i;
}

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

fn startArea(d: *Driver, area: Textarea) !void {
    d.* = try .init(testing.allocator, area, .{});
}

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

test "typing lines and submitting with ctrl+d" {
    var d: Driver = undefined;
    try startArea(&d, .{ .gpa = testing.allocator, .prompt = "| " });
    defer finish(&d);
    try d.typeText("one");
    try d.press("enter");
    try d.typeText("two");
    try d.expectFrame("| one\n| two ");
    try d.press("ctrl+d");
    try testing.expect(d.done());
    try testing.expectEqual(Status.submitted, d.model().status);
    try testing.expectEqualStrings("one\ntwo", d.model().value());
}

test "up and down keep the column" {
    var d: Driver = undefined;
    try startArea(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.typeText("long line\nab\nanother");
    try d.press("left"); // column 6 of "another"
    try d.press("up");
    try testing.expectEqual(12, d.model().cursor); // the end of "ab"
    try d.press("up");
    try testing.expectEqual(6, d.model().cursor); // column 6 again
    try d.press("up");
    try testing.expectEqual(0, d.model().cursor);
    try d.press("down");
    try d.press("down");
    try d.press("down");
    try testing.expectEqual(d.model().value().len, d.model().cursor);
}

test "home, end, and erasing within a line" {
    var d: Driver = undefined;
    try startArea(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.typeText("first\nsecond line");
    try d.press("home");
    try testing.expectEqual(6, d.model().cursor);
    try d.press("backspace"); // joins the lines
    try testing.expectEqualStrings("firstsecond line", d.model().value());
    try d.press("ctrl+k");
    try testing.expectEqualStrings("first", d.model().value());
    try d.press("ctrl+w");
    try testing.expectEqualStrings("", d.model().value());
}

test "a paste keeps line breaks and normalizes them" {
    var d: Driver = undefined;
    try startArea(&d, .{ .gpa = testing.allocator });
    defer finish(&d);
    try d.paste("a\r\nb\rc\td");
    try testing.expectEqualStrings("a\nb\nc    d", d.model().value());
}

test "height scrolls to the cursor's line" {
    var d: Driver = undefined;
    try startArea(&d, .{ .gpa = testing.allocator, .prompt = "", .height = 2 });
    defer finish(&d);
    try d.typeText("1\n2\n3");
    try d.expectFrame("2\n3 ");
    try d.press("up");
    try d.press("up");
    try d.expectFrame("1 \n2");
    try d.press("down");
    try d.press("down");
    try d.expectFrame("2\n3 ");
}

test "placeholder, char_limit, and esc" {
    var d: Driver = undefined;
    try startArea(&d, .{
        .gpa = testing.allocator,
        .prompt = "",
        .placeholder = "Write",
        .char_limit = 4,
    });
    defer finish(&d);
    try d.expectFrame("Write");
    try d.typeText("ab");
    try d.press("enter");
    try d.typeText("cd");
    try testing.expectEqualStrings("ab\nc", d.model().value());
    try d.press("esc");
    try testing.expectEqual(Status.canceled, d.model().status);
}

test "the cursor at the end of a line is a reversed space" {
    var buffer: [128]u8 = undefined;
    var w: Writer = .fixed(&buffer);
    var area: Textarea = .{ .gpa = testing.allocator, .styles = .{ .prompt = .{} } };
    defer area.deinit();
    try area.setValue("x\ny");
    area.cursor = 1;
    try area.view(&w);
    try testing.expectEqualStrings("┃ x\x1b[7m \x1b[0m\n┃ y", w.buffered());
}

test lineOf {
    try testing.expectEqual(Line{ .start = 0, .end = 2 }, lineOf("ab\ncd", 1));
    try testing.expectEqual(Line{ .start = 3, .end = 5 }, lineOf("ab\ncd", 3));
    try testing.expectEqual(Line{ .start = 3, .end = 3 }, lineOf("ab\n", 3));
}

test offsetAtColumn {
    try testing.expectEqual(2, offsetAtColumn("abc", 2));
    try testing.expectEqual(3, offsetAtColumn("abc", 9));
    try testing.expectEqual(1, offsetAtColumn("a中b", 2)); // the wide char does not fit
    try testing.expectEqual(4, offsetAtColumn("a中b", 3));
}

test "line numbers line up" {
    var area: Textarea = .{
        .gpa = testing.allocator,
        .prompt = "",
        .line_numbers = true,
        .focused = false,
        .styles = .{ .line_number = .{}, .prompt = .{} },
    };
    defer area.deinit();
    try area.insert("a\nb\nc\nd\ne\nf\ng\nh\ni\nj");
    var out: Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try area.view(&out.writer);
    try testing.expect(std.mem.startsWith(u8, out.written(), " 1 a\n 2 b"));
    try testing.expect(std.mem.endsWith(u8, out.written(), "10 j"));
}

test "undo and redo across lines, a word at a time, cursor included" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.typeText("one two");
    try d.press("enter");
    try d.typeText("three");
    try d.press("ctrl+z");
    try testing.expectEqualStrings("one two\n", d.model().value());
    try testing.expectEqual(8, d.model().cursor);
    try d.press("ctrl+z");
    try testing.expectEqualStrings("one ", d.model().value());
    try d.press("ctrl+y");
    try d.press("ctrl+Z"); // ctrl+shift+z
    try testing.expectEqualStrings("one two\nthree", d.model().value());
    try testing.expectEqual(13, d.model().cursor);
    try testing.expect(!d.model().redo());
}

test "a multi-line paste, a ctrl+u, and a run of backspaces are one step each" {
    var d: Driver = try .init(testing.allocator, .{ .gpa = testing.allocator }, .{});
    defer finish(&d);
    try d.paste("first\nsecond line");
    try d.press("ctrl+u");
    try testing.expectEqualStrings("first\n", d.model().value());
    try d.press("backspace");
    try d.press("backspace");
    try testing.expectEqualStrings("firs", d.model().value());
    try d.press("ctrl+z");
    try testing.expectEqualStrings("first\n", d.model().value());
    try d.press("ctrl+z");
    try testing.expectEqualStrings("first\nsecond line", d.model().value());
    try testing.expectEqual(17, d.model().cursor);
    try d.press("ctrl+z");
    try testing.expectEqualStrings("", d.model().value());
    try d.typeText("x");
    try testing.expect(!d.model().redo());
}

test "undo_limit bounds the textarea's history" {
    var area: Textarea = .{ .gpa = testing.allocator, .undo_limit = 1 };
    defer area.deinit();
    try area.setValue("a\nb");
    try area.setValue("c");
    try testing.expect(area.undo());
    try testing.expect(!area.undo());
    try testing.expectEqualStrings("a\nb", area.value());
}