Introducing tide, my own text editor
EYODF
· 6 min read
My current side project is working on tide, a terminal-based text editor. I doubt it will ever replace my favorite editor helix and its numerous features, but it is a first step in the editor world.
For me, projects that are worth your time are projects you’ll be using. Tide has been designed to fit my needs, so it may not suit you (and is probably buggy as hell too). In this post, I’ll explain how it works, the dev process and different things I’ve done along the way.
Before writing any code, I set up principles to follow:
- use as few dependencies as possible;
- keep it simple and stupid;
- make it fast (any action done by the user should be rendered in less than a millisecond);
- make it config-less, hence make it opinionated.
First version using a single buffer #
Initially, I went down the typical road using a single buffer and a single cursor moving along this buffer.
When you started the program, it loaded the content of the file provided in os.Args[1]
, printed it using github.com/gdamore/tcell
(the only direct dependency I have), and then you could move the cursor along the the content, and insert and erase runes (commit
968bbb4).
It was working pretty well, but two limitations arose:
- I want to have two modes: visual and editing. Similar to Vim and Helix’s normal and insert modes, they will be used respectively to navigate the code, and edit it. It allows adding way more shortcuts because in the visual modes, runes pressed are not inserted and can have other functions.
- Rendering tabs as
\t
makes them looking as a single space, which is not ideal.
Solving the first issue is pretty easy with a simple state machine. We can keep the current mode in the Editor
structure, and have basic switch/case in the main routine:
// editor/editor.go
switch e.Mode {
case editor.EditMode:
e.EditModeRoutine(f, lines)
case editor.VisualMode:
e.VisualModeRoutine(f, lines)
default:
continue
}
Then, in each mode routine we can bind a specific key to a SwitchMode()
function:
// editor/mode_edit.go
case *tcell.EventKey:
switch ev.Key() {
case tcell.KeyEsc:
// ...
e.SwitchMode()
// editor/editor.go
func (e *Editor) SwitchMode() {
if e.Mode == EditMode || e.Mode == VisualMode {
e.Mode ^= (EditMode | VisualMode)
}
}
(commit 5afa211)
However, solving the second issue proved to be harder than expected, and made me rebuild everything almost from scratch.
Using multiple buffers #
The issue with tabs is pretty simple to explain, but no so easy to solve for a guy like me that doesn’t do a lot of Leetcode-like problems.
A tab is a simple ASCII character: \t
. So when you read and print the content of a file on screen, tabs are rendered as a simple space (as the \t
is non-printable). If having single-space tabs is okay for you then there’s not issue, but it wasn’t for me.
Let’s say I want tabs that are TAB_SIZE
long. In my main routine, I could do something like this before printing the buffer on the screen:
b := strings.ReplaceAll(e.Buffer, "\t", strings.Repeat(" ", TAB_SIZE))
lines := buffer.SplitLines(b)
For the rendering part, it’s working nicely and indeed, you’ll see tabs being rendered as multiple spaces.
However, when you’re pressing TAB, you’re still inserting a single tab character (as you want \t
to be written in your file and not multiple spaces). So from a buffer point of view, you inserted TAB_SIZE
elements, but from a cursor point of view you only added a single rune. This makes navigating the code complex, because when going over the tab, one key press will move the cursor once, so you’ll visually still be in the tab, but in reality you’re at the end of the tab rune \t
in the buffer.
To solve this, you can do some wizardry where you look under the cursor if the rune is a white space associated with a tab, so you keep a flag in memory to avoid going off-limits. But doing this kind of things didn’t seem really easy to scale and maintain, so I decided to go toward a two-buffer design.
The idea is to keep two separate buffers: InternalBuffer
that contains the raw data (the one that is read and written to the file, with un-rendered runes such as \t
), and RenderBuffer
that contains what is shown to the user in the editor. Each of those buffer will have its dedicated cursor that will move freely according to the buffer’s content. InternalBuffer
being the single source of truth of what’s going on in the editor, I’ll create a Render()
function that will translate the InternalBuffer
to something “pretty”, as well as multiple other helper functions that will map moves of the internal cursor to the render one, thus handling tabs and other special runes:
// buffer/buffer.go
package buffer
import (
"strings"
)
const (
TAB_SIZE = 4
TAB_SYMBOL = " "
)
type Buffer struct {
Data string
}
// ...
func (b Buffer) Render() Buffer {
tmp := strings.ReplaceAll(b.Data, "\t", strings.Repeat(TAB_SYMBOL, TAB_SIZE))
return Buffer{Data: tmp}
}
// editor/editor.go
//...
func (e *Editor) internalToRenderPos(x, y int) (rx, ry int) {
// y is usually the same in both buffers unless there is folding but it's
// not implemented yet
ry = y
// for x we need to count expanded characters
lines := e.InternalBuffer.SplitLines()
if y < 0 || y >= len(lines) {
return 0, y
}
// limit x to the boundaries
if x < 0 {
x = 0
} else if x > len(lines[y]) {
x = len(lines[y])
}
rx = 0
for i := range x {
if lines[y][i] == '\t' {
// align to the next tab stop
rx += buffer.TAB_SIZE - (rx % buffer.TAB_SIZE)
} else {
rx++
}
}
return rx, ry
}
(commit 968bbb4)
Adding a command palette #
Now that I have a working editor that can handle most cases, I decided to implement a command palette, so I could exit tide using :wq
like a true h4ck3r. To do so, I implemented a third mode called CommandMode
with its dedicated routine:
// editor/editor.go
package editor
// ...
const (
VisualMode = iota
EditMode
CommandMode
)
// ...
func (e *Editor) Run() error {
// ...
for {
e.Screen.Clear()
// ...
switch e.Mode {
case EditMode:
e.editModeRoutine()
case VisualMode:
e.visualModeRoutine()
case CommandMode:
e.commandModeRoutine()
}
The commandModeRoutine()
is pretty simple, and everything needed manipulate the cursor and handle a few commands fits in less than a hundred lines in editor/mode_command.go
(commit d72dc61).
It manipulates the cursor and the buffer properly so the commands are typed in the last line of the screen like Vim or Helix would do:
I set up the editor so it’s only possible to open the command palette from the visual mode, meaning that the Esc : w q sequence that is now pure memory muscle is working:
// editor/mode_visual.go
// ...
case tcell.KeyRune:
switch ev.Rune() {
case ':':
e.Mode = CommandMode
// editor/mode_command.go
func (e *Editor) executeCommand(cmd string) {
// ...
case "wq", "x":
if len(parts) > 1 {
e.Filename = parts[1]
}
e.SaveToFile(false)
e.Quit()
Current limitations and future improvements #
In its current state, tide is working but is pretty limited. Before considering using it as may daily text editor, I should implement/fix a few things:
-
Add lines numbersdone in ec8eb4e. -
Handle Unicode charsdone in 1bf694b. - Clean up the code, variables scopes etc…
-
Implement undo mechanismdone in 551374e. - Implement proper solutions for everything “hacky”.
- Cache
RenderBuffer
to avoid rebuilding it when not needed. - Write tests.
- Syntax highlighting (this would be the Graal).