Right now our editor isn't doing much. Usually in a text-editor you'll be able to load up a file, take user input, and then display updates. That's exactly what we'll be starting in this part of the series. Also, from now on I'll be focusing less on the structure of the project itself, and more of the core components of it. Putting everything together yourself is the easy (and sometimes the fun) part! If you do end up lost, my implementation is available for reference.
Scaffolding the Editor
The following is a rough outline of our editor to start with.
const Editor = @This();
const Private = struct {
term: term.Term,
};
// Try making `Point` yourself!
// It should represent an x/y or col/row value.
cursor: Point,
private: Private,
pub fn refresh(self: *Editor) !void {
// ...
}
We'll essentially be moving the main loop to this refresh method which will be responsible for reading the user input, updating the editor's state, then drawing to the terminal.
In the next post, this editor will contain more properties, such as the files and views used for the text editing functionality itself.
Writing
We've already been writing to the terminal for a long time. You would have seen how we can get a handle for stdout and write to that. At the moment we've only been writing ANSI text and not anything actually interesting.
Often when you open a TUI editor, the empty rows will be filled with a "~" character. Since we're currently not opening anything, let's start by displaying this whenever our program is opened.
// Hide cursor, then reposition cursor
_ = try writer.writeAll("\x1b[?25l\x1b[H");
for (0..self.private.term.height()) {
// Write the contents of our row
_ = try writer.write("~");
// At the end of a row, write ANSI clear for the remainder of the line, then a newline.
_ = try writer.write("\x1b[K");
}
// Reposition cursor, then show cursor
_ = try writer.writeAll("\x1b[?25h");
If you add this to your loop and run it, you should see a set of rows, each with a single tilde printed. Nice and simple, no? Well only for now. As we start drawing all sorts of things this approach will grow out of proportion. We don't want to conditionally handle every point in the terminal, instead it would be much nicer to draw each part of our output, which each part imposed on top of one another.
Let's try implementing the same thing, but in a more generic manner.
Drawing
We're going to build up a new struct that will represent the list of text pixels (or texels) for the width and height of the terminal window. Then each component can go and draw onto this canvas, with each layering on top of each other.
const Canvas = @This();
const Texel = struct {
char: u8,
}
const Private = struct {
// The list of texels in the terminal
buffer: []Texel,
/// The length of each row in `buffer`
width: u16,
/// The number of rows in `buffer`
height: u16,
}
/// The offset from the top row of the terminal
top: u16,
/// The offset from the first column of the terminal
left: u16,
/// The number of columns in this canvas
width: u16,
/// The number of rows in this canvas
height: u16,
private: Private,
We have a private struct for the underlying terminal window, and then our canvas is a view into drawing to the terminal. Our texel is pretty bare at the moment too, but don't worry, eventually we'll add on ANSI styling, UTF-8 support, etc.
Let's look into writing the methods we'll need.
pub fn init(width: u16, height: u16, buffer: []Texel) Canvas {
// Try writing this one yourself!
}
/// Returns a canvas that's a subset of this canvas,
/// based on the given view options.
pub fn view(self: Canvas, viewOptions: ViewOptions) Canvas {
// Try writing this one too!
}
/// Returns the texel at the given position.
pub fn getTexel(self: *Canvas, x: usize, y: usize) ?*Texel {
if (x >= self.width) return null;
if (y >= self.height) return null;
const rawX = x + self.left;
const rawY = y + self.top;
return &self.private.buffer[(rawY * self.private.width) + rawX];
}
/// Sets the character at the given position.
pub fn setChar(self: *Canvas, x: usize, y: usize, char: u8) void {
if (self.getTexel(x, y)) |texel| {
texel.char = char;
}
}
pub fn format(self: Canvas, writer: *std.Io.Writer) std.Io.Writer.Error!void {
// Hide cursor, then reposition cursor
_ = try writer.writeAll("\x1b[?25l\x1b[H");
for (self.private.buffer, 0..) |texel, i| {
if (i != 0 and i % self.width == 0) {
// At the end of a row, write ANSI clear for the remainder of the line, then a newline.
_ = try writer.write("\x1b[K");
if (i < self.private.buffer.len - 1) {
_ = try writer.write("\r\n");
}
}
try texel.format(writer);
}
// Reposition cursor, then show cursor
_ = try writer.writeAll("\x1b[?25h");
}
Have a look at that. Now we can take the loop we wrote for printing "~" and adapt it for our canvas. Now we can build up all sorts of utilities to make drawing different elements to this canvas much easier!
For example, if we want to draw a line made up of some uniform content (such as our "~" row), we can build a utility to simplify this functionality.
const Char = @This();
width: u16 = 1,
height: u16 = 1,
x: u16 = 0,
y: u16 = 0,
char: u8,
pub fn draw(self: *const Char, canvas: *Canvas) void {
for (self.x..self.x + self.width) |x| {
for (self.y..self.y + self.height) |y| {
canvas.setChar(x, y, self.char);
}
}
}
We can then compose our terminal UI out of various drawing functions such as this, and then write the final canvas to the terminal window.
pub fn refresh(self: *Editor) !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
defer _ = gpa.deinit();
try self.private.term.getWindowSize();
const width = self.private.term.width();
const height = self.private.term.height();
var allocator = gpa.allocator();
const buffer = try allocator.alloc(Texel, width * height);
@memset(buffer, .default());
var canv: Canvas = .init(width, height, buffer);
defer allocator.free(buffer);
const empty: Char = .{ .height = height, .char = '~' };
empty.draw(&canv);
// ...reading
}
Reading
The writing part is the first part of the main loop, now lets start structuring how we're going to handle user input for the second part of the main loop. At the moment when we takeKeypress we return the byte as we got it, but there's a few curve-balls to consider.
- A keypress may be a normal character; or
- A keypress may be a control character; or
- A keypress may be an ANSI control sequence
So let's update our function to handle this.
const Keypress = @This();
control: bool,
code: u8,
/// Returns the ANSI control code char as a non-control keypress
pub fn rawControl() Keypress {
return .{ .control = false, .code = std.ascii.control_code.esc };
}
/// Returns an ASCII character as a control character.
/// e.g. ('q' -> ctrl-q -> `17`)
pub fn ctrl_key(c: u8) u8 {
return c & 0x1f;
}
pub fn takeKeypress(self: *Term) !?Keypress {
const char = self.takeByte() catch |err| {
switch (err) {
std.Io.Reader.Error.EndOfStream => return null,
else => return err,
}
};
if (char == std.ascii.control_code.esc) {
// Begin of ANSI sequence should be `"\x1b[`.
// If not, return `"\x1b` instead
const maybeControl = self.takeByte() else {
return .rawControl();
};
if (maybeControl != '[') {
return .rawControl();
}
const code = self.takeByte() else {
return .rawControl();
};
// Check if code is part of a longer, unsupported sequence
if (code >= '0' and code <= '9') {
const tilde = self.takeByte() else {
return .rawControl();
};
if (tilde != '~') {
return .rawControl();
}
}
return .{ .control = true, .code = code };
} else {
return .{ .control = false, .code = char };
}
}
And now that we can reliably get the type of keypress from input, we can start mapping it to actions for the editor. So far we have one command that we already implemented: quitting. So let's move that functionality to it's own special place that we'll group together with other future commands we build up in the future. Let's also quickly introduce the two types of commands our editor will have. We have "static" commands, which are commands that don't require arguments, and so can be assigned to keyboard inputs. We'll also eventually have "dynamic" commands, which we'll let users provide arguments to through a command palette.
/// The list of editor commands that require no arguments.
pub const Static = union(enum) {
Quit,
Noop,
pub fn call(self: Static, editor: *Editor) Error!void {
return switch(self) {
.Quit => quit(editor),
.Noop => noop(editor),
};
}
};
fn quit(editor: *Editor) Error!void {
editor.private.term.deinit() catch {
return Error.QuitFailed;
};
std.process.exit(0);
}
fn noop(_: *Editor) void {}
Now, let's create an enum of keyboard inputs that we can associate with these commands. We only have Ctrl-Q right now, so let's add just that for now.
/// The set of assigned keyboard characters.
pub const Char = enum(u8) {
Quit = ctrl_key('q'),
_,
};
/// The set of assigned keyboard control points.
pub const Control = enum(u4) { _ };
And then we just need to map the input/control to the appropriate command, then execute it.
/// Mapping of a control point to the keyboard button
pub const Control = union(enum) {
ArrowUp,
ArrowDown,
ArrowRight,
ArrowLeft,
Home,
Del,
End,
PageUp,
PageDown,
_,
pub fn parse(char: u8) Control {
return switch (char) {
'A' => .ArrowUp,
'B' => .ArrowDown,
'C' => .ArrowRight,
'D' => .ArrowLeft,
'1', '7', 'H' => .Home,
'3' => .Del,
'4', '8', 'F' => .End,
'5' => .PageUp,
'6' => .PageDown,
else => ._,
};
}
};
/// Returns the command associated with the ASCII input
pub fn char(c: u8) Static {
const code: config.Char = @enumFromInt(c);
return switch (code) {
.Quit => .Quit,
_ => .Noop,
};
}
/// Returns the command associated with the ANSI control
pub fn control(c: u8) Static {
const key: Keypress.Control = .parse(c);
const code: config.Control = @enumFromInt(@intFromEnum(key));
return switch (code) {
_ => .Noop,
};
}
/// Get the command associated with the given keypress
pub fn command(self: Keypress) commands.Static {
return if (self.control) commands.control(self.code) else commands.char(self.code);
}
The second half of our main loop can take the input, map it to a command, and then execute it.
pub fn refresh() {
// ...drawing
const char = self.private.term.takeKeypress();
if (char) |c| {
try c.command().call(self);
}
}
Now that we've broken out our input processing, we can much more easily add new commands and do interesting stuff, like moving the cursor around.
Moving the Cursor Around
We added a cursor to our editor at the start of this post, but we're not doing anything with it yet. We can't move it, and we're not even setting the position in the terminal. There will be many ways to move the cursor in our program, lets start with the simplest ones by adding to our list of commands and keyboard mappings.
pub const Static = union(enum) {
CursorLeft,
CursorDown,
CursorUp,
CursorRight,
PageUp,
PageDown,
GotoLineStart,
GotoLineEnd,
Quit,
Noop,
pub fn call(self: Static, editor: *Editor) Error!void {
// Update the switch here to call the appropriate methods
}
}
fn cursorLeft(editor: *Editor) void {
editor.cursor.column = editor.cursor.column -| 1;
}
fn cursorDown(editor: *Editor) void {
editor.cursor.row = @min(
editor.private.term.height(),
editor.cursor.row + 1,
);
}
fn cursorUp(editor: *Editor) void {
editor.cursor.row = editor.cursor.row -| 1;
}
fn cursorRight(editor: *Editor) void {
editor.cursor.column = @min(
editor.private.term.width(),
editor.cursor.column + 1,
);
}
fn pageUp(editor: *Editor) void {
editor.cursor.row = 0;
}
fn pageDown(editor: *Editor) void {
editor.cursor.row = editor.private.term.height();
}
fn gotoLineStart(editor: *Editor) void {
editor.cursor.column = 0;
}
fn gotoLineEnd(editor: *Editor) void {
editor.cursor.column = editor.private.term.width();
}
I trust you now to be able to update the keyboard mapping to the appropriate controls.
pub const Char = enum(u8) {
CursorLeft = 'h',
// ...
}
pub const Control = enum(u4) {
CursorLeft = @intFromEnum(Keypress.Control.ArrowLeft),
// ...
}
The last tidbit to get this all working is to tell the terminal where the cursor should be placed. This can be done by using the ANSI sequence "\x1b[<y>;<x>H". Go ahead and add a setCursorPosition method to our terminal implementation, and call that at the end of the editor's writing half of the refresh loop.
Nice work, now we have a program that actually does something! It will print out a column of "~" characters and a cursor that we can move around the screen. I hope you're getting excited for the next part because it will be getting meaty. We'll read a file and learn all about the data structures and algorithms we'll need to be able to work with them efficiently!