const std = @import("std");
const uucode = @import("uucode");
const vaxis = @import("../main.zig");

const vxfw = @import("vxfw.zig");

const assert = std.debug.assert;

const Allocator = std.mem.Allocator;
const Key = vaxis.Key;
const Cell = vaxis.Cell;
const Window = vaxis.Window;
const unicode = vaxis.unicode;

const TextField = @This();

const ellipsis: Cell.Character = .{ .grapheme = "…", .width = 1 };

// Index of our cursor
buf: Buffer,

/// Style to draw the TextField with
style: vaxis.Style = .{},

/// the number of graphemes to skip when drawing. Used for horizontal scrolling
draw_offset: u16 = 0,
/// the column we placed the cursor the last time we drew
prev_cursor_col: u16 = 0,
/// the grapheme index of the cursor the last time we drew
prev_cursor_idx: u16 = 0,
/// approximate distance from an edge before we scroll
scroll_offset: u4 = 4,
/// Previous width we drew at
prev_width: u16 = 0,

previous_val: []const u8 = "",

userdata: ?*anyopaque = null,
onChange: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,
onSubmit: ?*const fn (?*anyopaque, *vxfw.EventContext, []const u8) anyerror!void = null,

pub fn init(alloc: std.mem.Allocator) TextField {
    return TextField{
        .buf = Buffer.init(alloc),
    };
}

pub fn deinit(self: *TextField) void {
    self.buf.allocator.free(self.previous_val);
    self.buf.deinit();
}

pub fn widget(self: *TextField) vxfw.Widget {
    return .{
        .userdata = self,
        .eventHandler = typeErasedEventHandler,
        .drawFn = typeErasedDrawFn,
    };
}

fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
    const self: *TextField = @ptrCast(@alignCast(ptr));
    return self.handleEvent(ctx, event);
}

pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
    switch (event) {
        .focus_out, .focus_in => ctx.redraw = true,
        .key_press => |key| {
            if (key.matches(Key.backspace, .{})) {
                self.deleteBeforeCursor();
                return self.checkChanged(ctx);
            } else if (key.matches(Key.delete, .{}) or key.matches('d', .{ .ctrl = true })) {
                self.deleteAfterCursor();
                return self.checkChanged(ctx);
            } else if (key.matches(Key.left, .{}) or key.matches('b', .{ .ctrl = true })) {
                self.cursorLeft();
                return ctx.consumeAndRedraw();
            } else if (key.matches(Key.right, .{}) or key.matches('f', .{ .ctrl = true })) {
                self.cursorRight();
                return ctx.consumeAndRedraw();
            } else if (key.matches('a', .{ .ctrl = true }) or key.matches(Key.home, .{})) {
                self.buf.moveGapLeft(self.buf.firstHalf().len);
                return ctx.consumeAndRedraw();
            } else if (key.matches('e', .{ .ctrl = true }) or key.matches(Key.end, .{})) {
                self.buf.moveGapRight(self.buf.secondHalf().len);
                return ctx.consumeAndRedraw();
            } else if (key.matches('k', .{ .ctrl = true })) {
                self.deleteToEnd();
                return self.checkChanged(ctx);
            } else if (key.matches('u', .{ .ctrl = true })) {
                self.deleteToStart();
                return self.checkChanged(ctx);
            } else if (key.matches('b', .{ .alt = true }) or key.matches(Key.left, .{ .alt = true })) {
                self.moveBackwardWordwise();
                return ctx.consumeAndRedraw();
            } else if (key.matches('f', .{ .alt = true }) or key.matches(Key.right, .{ .alt = true })) {
                self.moveForwardWordwise();
                return ctx.consumeAndRedraw();
            } else if (key.matches(Key.backspace, .{ .alt = true })) {
                self.deleteWordBefore();
                return self.checkChanged(ctx);
            } else if (key.matches('w', .{ .ctrl = true })) {
                self.deleteWordBeforeWhitespace();
                return self.checkChanged(ctx);
            } else if (key.matches('d', .{ .alt = true })) {
                self.deleteWordAfter();
                return self.checkChanged(ctx);
            } else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
                if (self.onSubmit) |onSubmit| {
                    const value = try self.toOwnedSlice();
                    // Get a ref to the allocator in case onSubmit deinits the TextField
                    const allocator = self.buf.allocator;
                    defer allocator.free(value);
                    try onSubmit(self.userdata, ctx, value);
                    return ctx.consumeAndRedraw();
                }
            } else if (key.text) |text| {
                try self.insertSliceAtCursor(text);
                return self.checkChanged(ctx);
            }
        },
        else => {},
    }
}

fn checkChanged(self: *TextField, ctx: *vxfw.EventContext) anyerror!void {
    ctx.consumeAndRedraw();
    const onChange = self.onChange orelse return;
    const new = try self.buf.dupe();
    defer {
        self.buf.allocator.free(self.previous_val);
        self.previous_val = new;
    }
    if (std.mem.eql(u8, new, self.previous_val)) return;
    try onChange(self.userdata, ctx, new);
}

/// insert text at the cursor position
pub fn insertSliceAtCursor(self: *TextField, data: []const u8) std.mem.Allocator.Error!void {
    var iter = unicode.graphemeIterator(data);
    while (iter.next()) |text| {
        try self.buf.insertSliceAtCursor(text.bytes(data));
    }
}

pub fn sliceToCursor(self: *TextField, buf: []u8) []const u8 {
    assert(buf.len >= self.buf.cursor);
    @memcpy(buf[0..self.buf.cursor], self.buf.firstHalf());
    return buf[0..self.buf.cursor];
}

/// calculates the display width from the draw_offset to the cursor
pub fn widthToCursor(self: *TextField, ctx: vxfw.DrawContext) u16 {
    var width: u16 = 0;
    const first_half = self.buf.firstHalf();
    var first_iter = unicode.graphemeIterator(first_half);
    var i: usize = 0;
    while (first_iter.next()) |grapheme| {
        defer i += 1;
        if (i < self.draw_offset) {
            continue;
        }
        const g = grapheme.bytes(first_half);
        width += @intCast(ctx.stringWidth(g));
    }
    return width;
}

pub fn cursorLeft(self: *TextField) void {
    // We need to find the size of the last grapheme in the first half
    var iter = unicode.graphemeIterator(self.buf.firstHalf());
    var len: usize = 0;
    while (iter.next()) |grapheme| {
        len = grapheme.len;
    }
    self.buf.moveGapLeft(len);
}

pub fn cursorRight(self: *TextField) void {
    var iter = unicode.graphemeIterator(self.buf.secondHalf());
    const grapheme = iter.next() orelse return;
    self.buf.moveGapRight(grapheme.len);
}

pub fn graphemesBeforeCursor(self: *const TextField) u16 {
    const first_half = self.buf.firstHalf();
    var first_iter = unicode.graphemeIterator(first_half);
    var i: u16 = 0;
    while (first_iter.next()) |_| {
        i += 1;
    }
    return i;
}

fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
    const self: *TextField = @ptrCast(@alignCast(ptr));
    return self.draw(ctx);
}

pub fn draw(self: *TextField, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
    std.debug.assert(ctx.max.width != null);
    const max_width = ctx.max.width.?;
    if (max_width != self.prev_width) {
        self.prev_width = max_width;
        self.draw_offset = 0;
        self.prev_cursor_col = 0;
    }
    // Create a surface with max width and a minimum height of 1.
    var surface = try vxfw.Surface.init(
        ctx.arena,
        self.widget(),
        .{ .width = max_width, .height = @max(ctx.min.height, 1) },
    );

    const base: vaxis.Cell = .{ .style = self.style };
    @memset(surface.buffer, base);
    const style = self.style;
    const cursor_idx = self.graphemesBeforeCursor();
    if (cursor_idx < self.draw_offset) self.draw_offset = cursor_idx;
    if (max_width == 0) return surface;
    while (true) {
        const width = self.widthToCursor(ctx);
        if (width >= max_width) {
            self.draw_offset +|= width - max_width + 1;
            continue;
        } else break;
    }

    self.prev_cursor_idx = cursor_idx;
    self.prev_cursor_col = 0;

    const first_half = self.buf.firstHalf();
    var first_iter = unicode.graphemeIterator(first_half);
    var col: u16 = 0;
    var i: u16 = 0;
    while (first_iter.next()) |grapheme| {
        if (i < self.draw_offset) {
            i += 1;
            continue;
        }
        const g = grapheme.bytes(first_half);
        const w: u8 = @intCast(ctx.stringWidth(g));
        if (col + w >= max_width) {
            surface.writeCell(max_width - 1, 0, .{
                .char = ellipsis,
                .style = style,
            });
            break;
        }
        surface.writeCell(@intCast(col), 0, .{
            .char = .{
                .grapheme = g,
                .width = w,
            },
            .style = style,
        });
        col += w;
        i += 1;
        if (i == cursor_idx) self.prev_cursor_col = col;
    }
    const second_half = self.buf.secondHalf();
    var second_iter = unicode.graphemeIterator(second_half);
    while (second_iter.next()) |grapheme| {
        if (i < self.draw_offset) {
            i += 1;
            continue;
        }
        const g = grapheme.bytes(second_half);
        const w: u8 = @intCast(ctx.stringWidth(g));
        if (col + w > max_width) {
            surface.writeCell(max_width - 1, 0, .{
                .char = ellipsis,
                .style = style,
            });
            break;
        }
        surface.writeCell(@intCast(col), 0, .{
            .char = .{
                .grapheme = g,
                .width = w,
            },
            .style = style,
        });
        col += w;
        i += 1;
        if (i == cursor_idx) self.prev_cursor_col = col;
    }
    if (self.draw_offset > 0) {
        surface.writeCell(0, 0, .{
            .char = ellipsis,
            .style = style,
        });
    }
    surface.cursor = .{ .col = @intCast(self.prev_cursor_col), .row = 0 };
    return surface;
    // win.showCursor(self.prev_cursor_col, 0);
}

pub fn clearAndFree(self: *TextField) void {
    self.buf.clearAndFree();
    self.reset();
}

pub fn clearRetainingCapacity(self: *TextField) void {
    self.buf.clearRetainingCapacity();
    self.reset();
}

pub fn toOwnedSlice(self: *TextField) ![]const u8 {
    defer self.reset();
    return self.buf.toOwnedSlice();
}

pub fn reset(self: *TextField) void {
    self.draw_offset = 0;
    self.prev_cursor_col = 0;
    self.prev_cursor_idx = 0;
}

// returns the number of bytes before the cursor
pub fn byteOffsetToCursor(self: TextField) usize {
    return self.buf.cursor;
}

pub fn deleteToEnd(self: *TextField) void {
    self.buf.growGapRight(self.buf.secondHalf().len);
}

pub fn deleteToStart(self: *TextField) void {
    self.buf.growGapLeft(self.buf.cursor);
}

pub fn deleteBeforeCursor(self: *TextField) void {
    // We need to find the size of the last grapheme in the first half
    var iter = unicode.graphemeIterator(self.buf.firstHalf());
    var len: usize = 0;
    while (iter.next()) |grapheme| {
        len = grapheme.len;
    }
    self.buf.growGapLeft(len);
}

pub fn deleteAfterCursor(self: *TextField) void {
    var iter = unicode.graphemeIterator(self.buf.secondHalf());
    const grapheme = iter.next() orelse return;
    self.buf.growGapRight(grapheme.len);
}

const DecodedCodepoint = struct {
    cp: u21,
    start: usize,
    len: usize,
};

fn decodeCodepointAt(bytes: []const u8, start: usize) DecodedCodepoint {
    const first = bytes[start];
    const len = std.unicode.utf8ByteSequenceLength(first) catch 1;
    const capped_len = @min(len, bytes.len - start);
    const slice = bytes[start .. start + capped_len];
    const cp = std.unicode.utf8Decode(slice) catch {
        return .{ .cp = first, .start = start, .len = 1 };
    };
    return .{ .cp = cp, .start = start, .len = capped_len };
}

fn isUtf8ContinuationByte(c: u8) bool {
    return (c & 0b1100_0000) == 0b1000_0000;
}

fn decodeCodepointBefore(bytes: []const u8, end: usize) DecodedCodepoint {
    var start = end - 1;
    while (start > 0 and isUtf8ContinuationByte(bytes[start])) : (start -= 1) {}
    const slice = bytes[start..end];
    const cp = std.unicode.utf8Decode(slice) catch {
        return .{ .cp = bytes[end - 1], .start = end - 1, .len = 1 };
    };
    return .{ .cp = cp, .start = start, .len = end - start };
}

/// Returns true if the codepoint is a readline-style word constituent.
fn isWordCodepoint(cp: u21) bool {
    if (cp == '_') return true;
    return switch (uucode.get(.general_category, cp)) {
        .letter_uppercase,
        .letter_lowercase,
        .letter_titlecase,
        .letter_modifier,
        .letter_other,
        .number_decimal_digit,
        .number_letter,
        .number_other,
        .mark_nonspacing,
        .mark_spacing_combining,
        .mark_enclosing,
        .punctuation_connector,
        => true,
        else => false,
    };
}

fn isWhitespaceCodepoint(cp: u21) bool {
    return switch (cp) {
        ' ', '\t', '\n', '\r', 0x0b, 0x0c, 0x85 => true,
        else => switch (uucode.get(.general_category, cp)) {
            .separator_space,
            .separator_line,
            .separator_paragraph,
            => true,
            else => false,
        },
    };
}

/// Moves the cursor backward by one word using character-class boundaries.
/// Skips non-word characters, then skips word characters (matching readline backward-word).
pub fn moveBackwardWordwise(self: *TextField) void {
    const first_half = self.buf.firstHalf();
    var i: usize = first_half.len;
    // Skip non-word characters
    while (i > 0) {
        const decoded = decodeCodepointBefore(first_half, i);
        if (isWordCodepoint(decoded.cp)) break;
        i = decoded.start;
    }
    // Skip word characters
    while (i > 0) {
        const decoded = decodeCodepointBefore(first_half, i);
        if (!isWordCodepoint(decoded.cp)) break;
        i = decoded.start;
    }
    self.buf.moveGapLeft(self.buf.cursor - i);
}

/// Moves the cursor forward by one word using character-class boundaries.
/// Skips non-word characters, then skips word characters — landing at the end of the next word
/// (matching readline forward-word).
pub fn moveForwardWordwise(self: *TextField) void {
    const second_half = self.buf.secondHalf();
    var i: usize = 0;
    // Skip non-word characters
    while (i < second_half.len) {
        const decoded = decodeCodepointAt(second_half, i);
        if (isWordCodepoint(decoded.cp)) break;
        i += decoded.len;
    }
    // Skip word characters
    while (i < second_half.len) {
        const decoded = decodeCodepointAt(second_half, i);
        if (!isWordCodepoint(decoded.cp)) break;
        i += decoded.len;
    }
    self.buf.moveGapRight(i);
}

/// Deletes the word before the cursor using character-class boundaries
/// (matching readline backward-kill-word / Alt+Backspace).
pub fn deleteWordBefore(self: *TextField) void {
    const pre = self.buf.cursor;
    self.moveBackwardWordwise();
    self.buf.growGapRight(pre - self.buf.cursor);
}

/// Deletes the word before the cursor using whitespace boundaries
/// (matching readline unix-word-rubout / Ctrl+W).
pub fn deleteWordBeforeWhitespace(self: *TextField) void {
    const first_half = self.buf.firstHalf();
    var i: usize = first_half.len;
    // Skip trailing whitespace
    while (i > 0) {
        const decoded = decodeCodepointBefore(first_half, i);
        if (!isWhitespaceCodepoint(decoded.cp)) break;
        i = decoded.start;
    }
    // Skip non-whitespace
    while (i > 0) {
        const decoded = decodeCodepointBefore(first_half, i);
        if (isWhitespaceCodepoint(decoded.cp)) break;
        i = decoded.start;
    }
    const to_delete = self.buf.cursor - i;
    self.buf.moveGapLeft(to_delete);
    self.buf.growGapRight(to_delete);
}

/// Deletes the word after the cursor using character-class boundaries
/// (matching readline kill-word / Alt+D).
pub fn deleteWordAfter(self: *TextField) void {
    const second_half = self.buf.secondHalf();
    var i: usize = 0;
    // Skip non-word characters
    while (i < second_half.len) {
        const decoded = decodeCodepointAt(second_half, i);
        if (isWordCodepoint(decoded.cp)) break;
        i += decoded.len;
    }
    // Skip word characters
    while (i < second_half.len) {
        const decoded = decodeCodepointAt(second_half, i);
        if (!isWordCodepoint(decoded.cp)) break;
        i += decoded.len;
    }
    self.buf.growGapRight(i);
}

test "sliceToCursor" {
    var input = init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello, world");
    input.cursorLeft();
    input.cursorLeft();
    input.cursorLeft();
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("hello, wo", input.sliceToCursor(&buf));
    input.cursorRight();
    try std.testing.expectEqualStrings("hello, wor", input.sliceToCursor(&buf));
}

pub const Buffer = struct {
    allocator: std.mem.Allocator,
    buffer: []u8,
    cursor: usize,
    gap_size: usize,

    pub fn init(allocator: std.mem.Allocator) Buffer {
        return .{
            .allocator = allocator,
            .buffer = &.{},
            .cursor = 0,
            .gap_size = 0,
        };
    }

    pub fn deinit(self: *Buffer) void {
        self.allocator.free(self.buffer);
    }

    pub fn firstHalf(self: Buffer) []const u8 {
        return self.buffer[0..self.cursor];
    }

    pub fn secondHalf(self: Buffer) []const u8 {
        return self.buffer[self.cursor + self.gap_size ..];
    }

    pub fn grow(self: *Buffer, n: usize) std.mem.Allocator.Error!void {
        // Always grow by 512 bytes
        const new_size = self.buffer.len + n + 512;
        // Allocate the new memory
        const new_memory = try self.allocator.alloc(u8, new_size);
        // Copy the first half
        @memcpy(new_memory[0..self.cursor], self.firstHalf());
        // Copy the second half
        const second_half = self.secondHalf();
        @memcpy(new_memory[new_size - second_half.len ..], second_half);
        self.allocator.free(self.buffer);
        self.buffer = new_memory;
        self.gap_size = new_size - second_half.len - self.cursor;
    }

    pub fn insertSliceAtCursor(self: *Buffer, slice: []const u8) std.mem.Allocator.Error!void {
        if (slice.len == 0) return;
        if (self.gap_size <= slice.len) try self.grow(slice.len);
        @memcpy(self.buffer[self.cursor .. self.cursor + slice.len], slice);
        self.cursor += slice.len;
        self.gap_size -= slice.len;
    }

    /// Move the gap n bytes to the left
    pub fn moveGapLeft(self: *Buffer, n: usize) void {
        const new_idx = self.cursor -| n;
        const dst = self.buffer[new_idx + self.gap_size ..];
        const src = self.buffer[new_idx..self.cursor];
        std.mem.copyForwards(u8, dst, src);
        self.cursor = new_idx;
    }

    pub fn moveGapRight(self: *Buffer, n: usize) void {
        const new_idx = self.cursor + n;
        const dst = self.buffer[self.cursor..];
        const src = self.buffer[self.cursor + self.gap_size .. new_idx + self.gap_size];
        std.mem.copyForwards(u8, dst, src);
        self.cursor = new_idx;
    }

    /// grow the gap by moving the cursor n bytes to the left
    pub fn growGapLeft(self: *Buffer, n: usize) void {
        // gap grows by the delta
        self.gap_size += n;
        self.cursor -|= n;
    }

    /// grow the gap by removing n bytes after the cursor
    pub fn growGapRight(self: *Buffer, n: usize) void {
        self.gap_size = @min(self.gap_size + n, self.buffer.len - self.cursor);
    }

    pub fn clearAndFree(self: *Buffer) void {
        self.cursor = 0;
        self.allocator.free(self.buffer);
        self.buffer = &.{};
        self.gap_size = 0;
    }

    pub fn clearRetainingCapacity(self: *Buffer) void {
        self.cursor = 0;
        self.gap_size = self.buffer.len;
    }

    pub fn toOwnedSlice(self: *Buffer) std.mem.Allocator.Error![]const u8 {
        const slice = try self.dupe();
        self.clearAndFree();
        return slice;
    }

    pub fn realLength(self: *const Buffer) usize {
        return self.firstHalf().len + self.secondHalf().len;
    }

    pub fn dupe(self: *const Buffer) std.mem.Allocator.Error![]const u8 {
        const first_half = self.firstHalf();
        const second_half = self.secondHalf();
        const buf = try self.allocator.alloc(u8, first_half.len + second_half.len);
        @memcpy(buf[0..first_half.len], first_half);
        @memcpy(buf[first_half.len..], second_half);
        return buf;
    }
};

test "TextField.zig: Buffer" {
    var gap_buf = Buffer.init(std.testing.allocator);
    defer gap_buf.deinit();

    try gap_buf.insertSliceAtCursor("abc");
    try std.testing.expectEqualStrings("abc", gap_buf.firstHalf());
    try std.testing.expectEqualStrings("", gap_buf.secondHalf());

    gap_buf.moveGapLeft(1);
    try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
    try std.testing.expectEqualStrings("c", gap_buf.secondHalf());

    try gap_buf.insertSliceAtCursor(" ");
    try std.testing.expectEqualStrings("ab ", gap_buf.firstHalf());
    try std.testing.expectEqualStrings("c", gap_buf.secondHalf());

    gap_buf.growGapLeft(1);
    try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
    try std.testing.expectEqualStrings("c", gap_buf.secondHalf());
    try std.testing.expectEqual(2, gap_buf.cursor);

    gap_buf.growGapRight(1);
    try std.testing.expectEqualStrings("ab", gap_buf.firstHalf());
    try std.testing.expectEqualStrings("", gap_buf.secondHalf());
    try std.testing.expectEqual(2, gap_buf.cursor);
}

test TextField {
    const io = std.testing.io;

    // Boiler plate draw context init
    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
    defer arena.deinit();
    vxfw.DrawContext.init(.unicode);

    // Create some object which reacts to text field changes
    const Foo = struct {
        allocator: std.mem.Allocator,
        text: []const u8,

        fn onChange(ptr: ?*anyopaque, ctx: *vxfw.EventContext, str: []const u8) anyerror!void {
            const foo: *@This() = @ptrCast(@alignCast(ptr));
            foo.text = try foo.allocator.dupe(u8, str);
            ctx.consumeAndRedraw();
        }
    };
    var foo: Foo = .{ .text = "", .allocator = arena.allocator() };

    // Text field expands to the width, so it can't be null. It is always 1 line tall
    const draw_ctx: vxfw.DrawContext = .{
        .arena = arena.allocator(),
        .min = .{},
        .max = .{ .width = 8, .height = 1 },
        .cell_size = .{ .width = 10, .height = 20 },
    };
    _ = draw_ctx;

    var ctx: vxfw.EventContext = .{
        .io = io,
        .alloc = arena.allocator(),
        .cmds = .empty,
    };

    // Enough boiler plate...Create the text field
    var text_field = TextField.init(std.testing.allocator);
    defer text_field.deinit();
    text_field.onChange = Foo.onChange;
    text_field.onSubmit = Foo.onChange;
    text_field.userdata = &foo;

    const tf_widget = text_field.widget();
    // Send some key events to the widget
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'H', .text = "H" } });
    // The foo object stores the last text that we saw from an onChange call
    try std.testing.expectEqualStrings("H", foo.text);
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'e', .text = "e" } });
    try std.testing.expectEqualStrings("He", foo.text);
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
    try std.testing.expectEqualStrings("Hel", foo.text);
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'l', .text = "l" } });
    try std.testing.expectEqualStrings("Hell", foo.text);
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = 'o', .text = "o" } });
    try std.testing.expectEqualStrings("Hello", foo.text);

    // An arrow moves the cursor. The text doesn't change
    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = vaxis.Key.left } });
    try std.testing.expectEqualStrings("Hello", foo.text);

    try tf_widget.handleEvent(&ctx, .{ .key_press = .{ .codepoint = '_', .text = "_" } });
    try std.testing.expectEqualStrings("Hell_o", foo.text);
}

test "moveBackwardWordwise stops at word boundary" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello-world");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
    try std.testing.expectEqualStrings("world", input.buf.secondHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
    try std.testing.expectEqualStrings("hello-world", input.buf.secondHalf());
}

test "moveForwardWordwise stops at end of word" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello-world");
    input.buf.moveGapLeft(input.buf.firstHalf().len);
    input.moveForwardWordwise();
    // Stops at end of "hello": "hello|-world"
    try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
    try std.testing.expectEqualStrings("-world", input.buf.secondHalf());
    input.moveForwardWordwise();
    // Skips "-" then stops at end of "world": "hello-world|"
    try std.testing.expectEqualStrings("hello-world", input.buf.firstHalf());
    try std.testing.expectEqualStrings("", input.buf.secondHalf());
}

test "moveBackwardWordwise with path separators" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("/usr/local/bin");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("/usr/local/", input.buf.firstHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("/usr/", input.buf.firstHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("/", input.buf.firstHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "deleteWordBefore with hyphens" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello-world");
    input.deleteWordBefore();
    try std.testing.expectEqualStrings("hello-", input.buf.firstHalf());
    try std.testing.expectEqualStrings("", input.buf.secondHalf());
    input.deleteWordBefore();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "deleteWordBeforeWhitespace deletes to whitespace" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello-world foo.bar");
    input.deleteWordBeforeWhitespace();
    try std.testing.expectEqualStrings("hello-world ", input.buf.firstHalf());
    input.deleteWordBeforeWhitespace();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "deleteWordAfter with mixed punctuation" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("foo.bar baz");
    input.buf.moveGapLeft(input.buf.firstHalf().len);
    input.deleteWordAfter();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
    try std.testing.expectEqualStrings(".bar baz", input.buf.secondHalf());
    input.deleteWordAfter();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
    try std.testing.expectEqualStrings(" baz", input.buf.secondHalf());
}

test "moveForwardWordwise with dots" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("foo.bar.baz");
    input.buf.moveGapLeft(input.buf.firstHalf().len);
    input.moveForwardWordwise();
    try std.testing.expectEqualStrings("foo", input.buf.firstHalf());
    input.moveForwardWordwise();
    try std.testing.expectEqualStrings("foo.bar", input.buf.firstHalf());
    input.moveForwardWordwise();
    try std.testing.expectEqualStrings("foo.bar.baz", input.buf.firstHalf());
}

test "word motion with underscores treats them as word chars" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello_world-test");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("hello_world-", input.buf.firstHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "word motion with non-ASCII text" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("café-latte");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("café-", input.buf.firstHalf());
    try std.testing.expectEqualStrings("latte", input.buf.secondHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());

    input.moveForwardWordwise();
    try std.testing.expectEqualStrings("caf\xc3\xa9", input.buf.firstHalf());
    try std.testing.expectEqualStrings("-latte", input.buf.secondHalf());
}

test "non-ASCII punctuation acts as a separator" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello\u{2014}world");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("hello\u{2014}", input.buf.firstHalf());
    try std.testing.expectEqualStrings("world", input.buf.secondHalf());

    input.buf.moveGapLeft(input.buf.firstHalf().len);
    input.moveForwardWordwise();
    try std.testing.expectEqualStrings("hello", input.buf.firstHalf());
    try std.testing.expectEqualStrings("\u{2014}world", input.buf.secondHalf());
}

test "deleteWordBeforeWhitespace handles unicode whitespace" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello\u{3000}world");
    input.deleteWordBeforeWhitespace();
    try std.testing.expectEqualStrings("hello\u{3000}", input.buf.firstHalf());
    try std.testing.expectEqualStrings("", input.buf.secondHalf());
}

test "deleteWordBefore with non-ASCII text" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("über-cool");
    input.deleteWordBefore();
    try std.testing.expectEqualStrings("über-", input.buf.firstHalf());
    input.deleteWordBefore();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "word motion with spaces" {
    var input = TextField.init(std.testing.allocator);
    defer input.deinit();
    try input.insertSliceAtCursor("hello world");
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("hello ", input.buf.firstHalf());
    input.moveBackwardWordwise();
    try std.testing.expectEqualStrings("", input.buf.firstHalf());
}

test "refAllDecls" {
    std.testing.refAllDecls(@This());
}
