1

I am trying to implement vertical cursor movement (up and down arrow keys) in my custom nano-like text editor, but I could not get it working properly and keep introducing new bugs. My text editor uses a gap-buffer to store the text and a single variable pos_idx to handle the insert position. It is the job of the render function to convert that linear cursor position to x and y coordinates. The left and right arrow keys work just fine, as they only need to increment or decrement pos_idx.

main.zig

const std = @import("std");
const ncurses = @cImport({
    @cInclude("ncurses.h");
});
const buffer = @import("gap_buffer.zig");
const editor = @import("editor.zig");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer {
        _ = gpa.detectLeaks();
        _ = gpa.deinit();
    }

    const alloc = gpa.allocator();

    _ = ncurses.initscr();
    defer _ = ncurses.endwin();
    _ = ncurses.keypad(ncurses.stdscr, true);
    _ = ncurses.noecho();
    _ = ncurses.raw();
    _ = ncurses.refresh();

    var e = try editor.Editor.init(alloc);
    defer e.deinit();

    var char: c_int = 0;

    while (char != 'q' & 0x1f) {
        char = ncurses.getch();
        try e.handle_key(char);
        try e.render();
    }
}

editor.zig

const std = @import("std");
const gap_buffer = @import("gap_buffer.zig");
const ncurses = @cImport({
    @cInclude("ncurses.h");
});

pub const Editor = struct {
    buf: gap_buffer.GapBuffer,
    alloc: std.mem.Allocator,
    render_buf: []u8,
    pos_idx: usize = 0,
    render_buf_dirty: bool = false,

    pub fn init(allocator: std.mem.Allocator) std.mem.Allocator.Error!Editor {
        return Editor{ .buf = try gap_buffer.GapBuffer.init(8, allocator), .alloc = allocator, .render_buf = try allocator.alloc(u8, 9) };
    }

    pub fn deinit(self: *Editor) void {
        self.buf.deinit();
        self.alloc.free(self.render_buf);
    }

    pub fn handle_key(self: *Editor, key: c_int) std.mem.Allocator.Error!void {
        if (key == ncurses.KEY_LEFT) {
            if (self.pos_idx > 0)
                self.pos_idx -= 1;
        } else if (key == ncurses.KEY_RIGHT) {
            if (self.pos_idx < self.buf.buf.len - self.buf.gap_len)
                self.pos_idx += 1;
        } else if (key == ncurses.KEY_UP) {
            try self.handle_up();
        } else if (key == ncurses.KEY_DOWN) {
            try self.handle_down();
        } else if (key <= 255) {
            self.render_buf_dirty = true;
            try self.insert(@intCast(key));
        }
    }

    pub fn render(self: *Editor) std.mem.Allocator.Error!void {
        const t = self.buf;

        if (t.gap_len == t.buf.len) return; // nothing to render

        try self.refresh_render_buffer();

        var y: u64 = 0;
        var line_begin_idx: ?usize = null;
        var line_len: usize = 0;

        for (self.render_buf[0..self.pos_idx], 0..) |c, i| {
            if (c == '\n') {
                y += 1;
                line_begin_idx = i;
            } else {
                line_len += 1;
            }
        }
        var x: usize = undefined;

        if (line_begin_idx == null) {
            x = self.pos_idx;
        } else if (self.pos_idx == line_begin_idx.?) {
            x = line_len;
        } else {
            x = self.pos_idx - line_begin_idx.? - 1;
        }

        _ = ncurses.clear();
        _ = ncurses.mvprintw(0, 0, @ptrCast(self.render_buf));
        _ = ncurses.move(@intCast(y), @intCast(x));
        _ = ncurses.refresh();
    }

    fn insert(self: *Editor, char: u8) std.mem.Allocator.Error!void {
        self.buf.set_pos_idx(self.pos_idx);
        try self.buf.insert(char);
        self.pos_idx += 1;
    }

    fn handle_up(self: *Editor) std.mem.Allocator.Error!void {
        try self.refresh_render_buffer();
        var curr_line_idx: usize = std.mem.lastIndexOf(u8, self.render_buf[0..self.pos_idx], "\n") orelse return; //
        var x: usize = undefined;

        // this is an edge-case, where we are on a newline
        if (self.render_buf[self.pos_idx] == '\n') {
            x = 0;
            curr_line_idx = self.pos_idx;
        } else {
            x = self.pos_idx - curr_line_idx - 1;
        }

        const prev_line_idx: ?usize = std.mem.lastIndexOf(u8, self.render_buf[0..curr_line_idx], "\n");

        if (prev_line_idx) |prev_idx| {
            self.pos_idx = prev_idx + @min(x, curr_line_idx - prev_idx - 1);
            return;
        }

        self.pos_idx = @min(x, curr_line_idx);
    }

    fn handle_down(self: *Editor) std.mem.Allocator.Error!void {
        try self.refresh_render_buffer();
    }

    fn expand_render_buffer(self: *Editor) std.mem.Allocator.Error!void {
        const t = self.buf;
        const l = t.buf.len - t.gap_len;
        if (self.render_buf.len >= l + 1)
            return;

        self.alloc.free(self.render_buf);
        self.render_buf = try self.alloc.alloc(u8, l * 2 + 1);
    }

    fn refresh_render_buffer(self: *Editor) std.mem.Allocator.Error!void {
        if (!self.render_buf_dirty)
            return;

        const t = self.buf;
        try self.expand_render_buffer();

        @memcpy(self.render_buf[0..t.gap_begin_idx], t.buf[0..t.gap_begin_idx]);
        @memcpy(self.render_buf[t.gap_begin_idx .. t.buf.len - t.gap_len], t.buf[t.gap_begin_idx + t.gap_len ..]);
        self.render_buf[t.buf.len - t.gap_len] = 0; // null terminator for c-printing

        self.render_buf_dirty = false;
    }
};

gap_buffer.zig

const std = @import("std");

pub const GapBuffer = struct {
    gap_begin_idx: usize,
    gap_len: usize,
    pos_idx: usize,
    buf: []u8,
    alloc: std.mem.Allocator,

    pub fn init(gap_size: usize, allocator: std.mem.Allocator) std.mem.Allocator.Error!GapBuffer {
        const buffer = try allocator.alloc(u8, gap_size);
        return GapBuffer{ .gap_begin_idx = 0, .gap_len = gap_size, .buf = buffer, .pos_idx = 0, .alloc = allocator };
    }

    pub fn deinit(self: *GapBuffer) void {
        self.alloc.free(self.buf);
    }

    pub fn insert(self: *GapBuffer, char: u8) std.mem.Allocator.Error!void {
        if (self.gap_len == 0)
            try self.expand_gap();

        self.move_gap_to_cursor();
        self.buf[self.pos_idx] = char;
        self.gap_begin_idx += 1;
        self.gap_len -= 1;
    }

    pub fn set_pos_idx(self: *GapBuffer, idx: usize) void {
        if (idx <= self.gap_begin_idx) {
            self.pos_idx = idx;
        } else {
            self.pos_idx = idx + self.gap_len;
        }
    }

    fn expand_gap(self: *GapBuffer) std.mem.Allocator.Error!void {
        std.debug.assert(self.gap_len == 0);

        const text_size = self.buf.len - self.gap_len;

        const new_gap_len = (self.gap_len + 1) * 2;

        var new_buf = try self.alloc.alloc(u8, text_size + new_gap_len);
        @memcpy(new_buf[0..self.pos_idx], self.buf[0..self.pos_idx]);
        @memcpy(new_buf[self.pos_idx + new_gap_len ..], self.buf[self.pos_idx..]);

        self.alloc.free(self.buf);
        self.gap_begin_idx = self.pos_idx;
        self.gap_len = new_gap_len;
        self.buf = new_buf;
    }

    fn move_gap_to_cursor(self: *GapBuffer) void {
        if (self.gap_begin_idx == self.pos_idx) {
            return;
        }

        if (self.pos_idx > self.gap_begin_idx) {
            const gap_end_idx = self.gap_begin_idx + self.gap_len;
            const diff = self.pos_idx - gap_end_idx;
            std.mem.copyForwards(u8, self.buf[self.gap_begin_idx .. self.gap_begin_idx + diff], self.buf[gap_end_idx .. gap_end_idx + diff]);
            self.gap_begin_idx = self.gap_begin_idx + diff;
            self.pos_idx = self.gap_begin_idx;
        } else {
            const diff = self.gap_begin_idx - self.pos_idx;
            const save_pos = self.pos_idx + self.gap_len;
            std.mem.copyBackwards(u8, self.buf[save_pos .. save_pos + diff], self.buf[self.pos_idx .. self.pos_idx + diff]);
            self.gap_begin_idx = self.pos_idx;
        }
    }
};

The functions where I need help are handle_up and handle_down in editor.zig. The implementation in handle_up doesn't work i.e. the cursor seems to jump to wrong x-positions and sometimes skip empty lines.

Any help on the implementation would be appreciated.

I tried using lldb to find out what was wrong, but I kept introducing new bugs and edge-cases.

1
  • 1
    Why is this tagged C if you're using Zig? Commented May 29, 2024 at 15:46

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.