Build a Modal Text Editor in Go

Build a terminal text editor with a gap buffer and modal input in Go.

Introduction

30 Second Summary

Every developer spends hours inside a text editor, but few understand the architecture that makes modal editing feel instant. The core of tools like Neovim is built on just a handful of primitives: a buffer data structure, a mode-based state machine, and a terminal renderer.

In this project, you will build a minimal modal text editor called med from scratch in Go. It will run in your terminal, support Normal and Insert modes, and render a viewport with line numbers, scrolling, and a status bar.

What You'll Build

A terminal-based modal text editor in Go that uses tcell v3 for full-screen rendering and a custom gap buffer for efficient text manipulation at the cursor.

By the end of this project, you'll have:

  • A gap buffer package with O(1) insert and delete operations at the cursor position.
  • A full-screen terminal UI with line numbers, cursor display, and vertical scrolling.
  • A modal input handler supporting Normal mode navigation with hjkl and Insert mode text entry, plus a status bar showing mode and cursor coordinates.
  • Secret Mission: Add file I/O so your editor can open files from the command line and save them with a :w command.

Are there any prerequisites?

This project assumes intermediate Go proficiency. You should be comfortable with interfaces, slices, structs, and modules.

You should also be familiar with modal editing as a Neovim user. Knowing what Normal mode, Insert mode, and hjkl navigation feel like will help the architecture click.

Before We Start

Before We Start...

Set Up the Project

Before writing any editor logic, you need a Go project wired up to tcell. tcell is the library that gives your program direct control over every cell in the terminal.

In this step, you will scaffold the project, pull in tcell v3.4.0, and prove the library works by rendering a single word on screen.

In this step, get ready to:
  • Initialize a Go module and install tcell v3.
  • Create the project file structure.
  • Write and run a minimal tcell program to verify the setup.
Initialize the Go module and install tcell v3

Every Go project starts with a module. The module gives your code a name and tracks its dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir med
cd med
  • Initialize the Go module by running:
go mod init med

What does this command do?

This creates a go.mod file that declares your module's name (med) and its Go version. All your packages and dependencies live under this module name.

  • Install tcell v3.4.0 by running:
go get github.com/gdamore/tcell/v3@v3.4.0

What does this command do?

This downloads tcell v3 and records it as a dependency in go.mod. It also creates a go.sum file that locks the exact checksums of the downloaded code.

  • Create the directory structure for the project by running:
mkdir -p editor buffer
  • Create the placeholder files by running:
touch main.go editor/editor.go buffer/buffer.go
  • Confirm the files exist by running:
ls
ls editor buffer

You should see main.go, editor/, and buffer/ in the first listing. The second listing shows editor.go and buffer.go inside their respective directories.

Why this file structure?

Separating the buffer logic (buffer/) from the editor shell (editor/) keeps concerns isolated. The buffer handles text storage. The editor handles input and rendering. main.go ties them together.

Write a verification program and test it

Now you will write a minimal program that proves tcell can take over your terminal. It will clear the screen, print one word, wait for a keypress, and then restore your terminal to normal.

  • Open main.go in Neovim by running:
nvim main.go
  • Replace the empty file with the following code:
package main

import (
	"log"

	"github.com/gdamore/tcell/v3"
)

func main() {
	// Create a new terminal screen
	s, err := tcell.NewScreen()
	if err != nil {
		log.Fatalf("failed to create screen: %v", err)
	}
	// Initialize the screen (takes ownership of the terminal)
	if err := s.Init(); err != nil {
		log.Fatalf("failed to init screen: %v", err)
	}
	// Restore terminal state when main exits
	defer s.Fini()
	// Clear the screen and render "Hello" at row 0, column 0
	s.Clear()
	s.Put(0, 0, "Hello", tcell.StyleDefault)
	s.Show()
	// Block until the user presses any key
	<-s.EventQ()
}

What does this code do?

This program demonstrates tcell's core lifecycle:

  • tcell.NewScreen() creates a screen object that represents your terminal.
  • s.Init() puts the terminal into raw mode. Your program now controls every pixel.
  • s.Put(0, 0, "Hello", tcell.StyleDefault) writes text to column 0, row 0 with the default style.
  • <-s.EventQ() blocks on the event channel until the user presses a key.
  • defer s.Fini() ensures the terminal returns to normal when the program exits.
  • Save the file and exit Neovim (:wq).
  • Run the program by entering:
go run main.go

Your terminal should clear completely, show the word Hello in the top-left corner, and wait. Press any key to exit back to your shell.

  • Confirm your dependencies are clean by running:
go mod tidy

This command should produce no output and no errors.

Terminal stuck in raw mode?

If you see garbled text after running the program, your terminal did not restore properly. Type reset and press Enter to fix it. This usually happens if the program panics before s.Fini() runs.

If you see a compile error about tcell not being found, re-run go get github.com/gdamore/tcell/v3@v3.4.0 and check that go.mod lists the require line.

Help me debug my tcell setup

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that go mod tidy ran without errors.

ⓧ I'd like to double check the full code

Here is what main.go should look like:

package main

import (
	"log"

	"github.com/gdamore/tcell/v3"
)

func main() {
	s, err := tcell.NewScreen()
	if err != nil {
		log.Fatalf("failed to create screen: %v", err)
	}
	if err := s.Init(); err != nil {
		log.Fatalf("failed to init screen: %v", err)
	}
	defer s.Fini()
	s.Clear()
	s.Put(0, 0, "Hello", tcell.StyleDefault)
	s.Show()
	<-s.EventQ()
}

And go.mod should contain:

module med

go 1.25

require github.com/gdamore/tcell/v3 v3.4.0

The files editor/editor.go and buffer/buffer.go are empty placeholders at this point.

Your project skeleton is ready. Next up, you will build the gap buffer that stores and manipulates text efficiently behind every keystroke.

Implement the Gap Buffer

Your editor needs a way to store and manipulate text efficiently. Every keystroke inserts or deletes a character at the cursor position, and a naive array would require shifting all subsequent characters on every edit.

A gap buffer solves this by maintaining a "gap" of empty space at the cursor location. Insertions and deletions happen in O(1) time because they just expand or shrink the gap. In this step, you will implement the gap buffer from scratch and verify it with tests.

In this step, get ready to:
  • Build a gap buffer data structure with insert, delete, and movement operations.
  • Write unit tests to verify buffer correctness.
  • Implement automatic gap growth so the buffer never runs out of space.
Implement the gap buffer in buffer/buffer.go

The gap buffer stores text as a rune slice with an empty "gap" sitting at the cursor position. Characters before the gap are text to the left of the cursor. Characters after the gap are text to the right.

  • Open buffer/buffer.go in your editor.
  • Add the package declaration, gap size constant, and Buffer struct by pasting this code:
package buffer

import "strings"

const defaultGapSize = 64

type Buffer struct {
	data     []rune
	gapStart int
	gapEnd   int
}

func New(text string) *Buffer {
	runes := []rune(text)
	data := make([]rune, len(runes)+defaultGapSize)
	copy(data, runes)
	return &Buffer{
		data:     data,
		gapStart: len(runes),
		gapEnd:   len(runes) + defaultGapSize,
	}
}

What does this code do?

  • defaultGapSize = 64 sets the initial size of the empty space reserved for edits.
  • The Buffer struct holds a rune slice (data) and two indices marking where the gap starts and ends.
  • New() converts the input text to runes, allocates a slice with room for the gap, copies the text in, and positions the gap at the end (where a cursor would naturally be after loading a file).
  • Below the New function, add the gap growth logic and the write operations by pasting this code:
func (b *Buffer) growGap() {
	newData := make([]rune, len(b.data)+defaultGapSize)
	copy(newData, b.data[:b.gapStart])
	newEnd := b.gapEnd + defaultGapSize
	copy(newData[newEnd:], b.data[b.gapEnd:])
	b.data = newData
	b.gapEnd = newEnd
}

func (b *Buffer) Insert(ch rune) {
	if b.gapStart == b.gapEnd {
		b.growGap()
	}
	b.data[b.gapStart] = ch
	b.gapStart++
}

func (b *Buffer) Delete() {
	if b.gapStart > 0 {
		b.gapStart--
	}
}

What does this code do?

  • growGap() allocates a bigger slice and re-positions the gap so there is room for more insertions.
  • Insert() checks if the gap has been consumed. If so, it grows the gap first. Then it places the rune at the gap start and advances the gap start forward.
  • Delete() simulates backspace by moving the gap start backward. The "deleted" character is now inside the gap and invisible.
  • Add the cursor movement methods below Delete by pasting this code:
func (b *Buffer) MoveLeft() {
	if b.gapStart > 0 {
		b.gapStart--
		b.gapEnd--
		b.data[b.gapEnd] = b.data[b.gapStart]
	}
}

func (b *Buffer) MoveRight() {
	if b.gapEnd < len(b.data) {
		b.data[b.gapStart] = b.data[b.gapEnd]
		b.gapStart++
		b.gapEnd++
	}
}

What does this code do?

MoveLeft() shifts the gap one position to the left by copying the character just before the gap to just after it. MoveRight() does the reverse. This is how the cursor "moves" through text without actually moving text around.

  • Add the read methods that extract visible content from the buffer by pasting this code below MoveRight:
func (b *Buffer) Content() string {
	var sb strings.Builder
	sb.Grow(len(b.data) - (b.gapEnd - b.gapStart))
	for i, r := range b.data {
		if i >= b.gapStart && i < b.gapEnd {
			continue
		}
		sb.WriteRune(r)
	}
	return sb.String()
}

func (b *Buffer) Lines() []string {
	return strings.Split(b.Content(), "\n")
}

What does this code do?

  • Content() iterates over the data slice, skipping everything inside the gap, and builds a string of the visible text.
  • Lines() splits that content by newline characters so the renderer can draw one line per row.
  • Add the cursor position and length helpers at the bottom of the file by pasting this code:
func (b *Buffer) CursorPos() (line, col int) {
	line = 0
	col = 0
	for i := 0; i < b.gapStart; i++ {
		if b.data[i] == '\n' {
			line++
			col = 0
		} else {
			col++
		}
	}
	return line, col
}

func (b *Buffer) Length() int {
	return len(b.data) - (b.gapEnd - b.gapStart)
}

What does this code do?

  • CursorPos() scans everything before the gap (which is "to the left of the cursor") and counts newlines to determine the line number. Characters since the last newline give the column.
  • Length() returns the total character count by subtracting the gap size from the slice length.
  • Save buffer/buffer.go.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package buffer

import "strings"

const defaultGapSize = 64

type Buffer struct {
	data     []rune
	gapStart int
	gapEnd   int
}

func New(text string) *Buffer {
	runes := []rune(text)
	data := make([]rune, len(runes)+defaultGapSize)
	copy(data, runes)
	return &Buffer{
		data:     data,
		gapStart: len(runes),
		gapEnd:   len(runes) + defaultGapSize,
	}
}

func (b *Buffer) growGap() {
	newData := make([]rune, len(b.data)+defaultGapSize)
	copy(newData, b.data[:b.gapStart])
	newEnd := b.gapEnd + defaultGapSize
	copy(newData[newEnd:], b.data[b.gapEnd:])
	b.data = newData
	b.gapEnd = newEnd
}

func (b *Buffer) Insert(ch rune) {
	if b.gapStart == b.gapEnd {
		b.growGap()
	}
	b.data[b.gapStart] = ch
	b.gapStart++
}

func (b *Buffer) Delete() {
	if b.gapStart > 0 {
		b.gapStart--
	}
}

func (b *Buffer) MoveLeft() {
	if b.gapStart > 0 {
		b.gapStart--
		b.gapEnd--
		b.data[b.gapEnd] = b.data[b.gapStart]
	}
}

func (b *Buffer) MoveRight() {
	if b.gapEnd < len(b.data) {
		b.data[b.gapStart] = b.data[b.gapEnd]
		b.gapStart++
		b.gapEnd++
	}
}

func (b *Buffer) Content() string {
	var sb strings.Builder
	sb.Grow(len(b.data) - (b.gapEnd - b.gapStart))
	for i, r := range b.data {
		if i >= b.gapStart && i < b.gapEnd {
			continue
		}
		sb.WriteRune(r)
	}
	return sb.String()
}

func (b *Buffer) Lines() []string {
	return strings.Split(b.Content(), "\n")
}

func (b *Buffer) CursorPos() (line, col int) {
	line = 0
	col = 0
	for i := 0; i < b.gapStart; i++ {
		if b.data[i] == '\n' {
			line++
			col = 0
		} else {
			col++
		}
	}
	return line, col
}

func (b *Buffer) Length() int {
	return len(b.data) - (b.gapEnd - b.gapStart)
}
Write and run buffer tests

Before building the editor on top of this buffer, you need confidence that insert, delete, movement, and line splitting all work correctly.

  • Create a new file buffer/buffer_test.go and paste the following test code:
package buffer

import "testing"

func TestInsertAndContent(t *testing.T) {
	b := New("")
	b.Insert('H')
	b.Insert('i')
	if got := b.Content(); got != "Hi" {
		t.Errorf("expected %q, got %q", "Hi", got)
	}
}

func TestDelete(t *testing.T) {
	b := New("Hello")
	b.Delete()
	if got := b.Content(); got != "Hell" {
		t.Errorf("expected %q, got %q", "Hell", got)
	}
}

func TestMoveAndInsert(t *testing.T) {
	b := New("Hllo")
	b.MoveLeft()
	b.MoveLeft()
	b.MoveLeft()
	b.Insert('e')
	if got := b.Content(); got != "Hello" {
		t.Errorf("expected %q, got %q", "Hello", got)
	}
}

func TestCursorPos(t *testing.T) {
	b := New("line1\nline2")
	line, col := b.CursorPos()
	if line != 1 || col != 5 {
		t.Errorf("expected (1,5), got (%d,%d)", line, col)
	}
}

func TestLines(t *testing.T) {
	b := New("one\ntwo\nthree")
	lines := b.Lines()
	if len(lines) != 3 {
		t.Errorf("expected 3 lines, got %d", len(lines))
	}
	if lines[0] != "one" || lines[1] != "two" || lines[2] != "three" {
		t.Errorf("unexpected lines: %v", lines)
	}
}

What do these tests cover?

  • TestInsertAndContent verifies inserting runes into an empty buffer produces the correct string.
  • TestDelete verifies backspace removes the last character (the one just before the gap).
  • TestMoveAndInsert moves the gap left three positions and inserts, proving mid-text editing works.
  • TestCursorPos confirms the cursor reports the correct line and column after multi-line content.
  • TestLines checks that newline-separated content splits into the expected lines.
  • Save buffer/buffer_test.go.
  • Run the tests by executing this command:
go test ./buffer/
  • You should see output ending with ok and all 5 tests passing.

Tests failing or not compiling?

  • If you see an "undefined" error, make sure the package declaration in both files is package buffer (not package main).
  • If TestMoveAndInsert fails, check that MoveLeft copies the character from gapStart to gapEnd before decrementing both.
  • Make sure both files are in the buffer/ directory inside your med/ project folder.

Help me debug my buffer test failures

Implement gap growth

You already wrote the growGap() function as part of the buffer. Now take a moment to understand how it keeps the editor from crashing when the gap fills up.

How does gap growth work?

The gap starts at 64 runes (defaultGapSize). Every insertion shrinks it by one. When gapStart == gapEnd, the gap is empty and no more inserts can fit.

At that point, growGap() allocates a new slice that is 64 runes larger. It copies the text before the gap into the same position, then copies the text after the gap to a position 64 runes further right. This re-opens a 64-rune gap at the cursor without moving any visible text.

The result is amortized O(1) insertions. Most inserts just write a rune and bump a pointer. Growth is rare and proportional to how much you type.

Why a gap buffer instead of a piece table?

A piece table is the other common editor data structure. It excels at undo/redo and large file editing, but requires around 450 lines to implement correctly.

The gap buffer achieves the same O(1) insert/delete performance in about 80 lines. It teaches the same buffer abstraction pattern (Insert, Delete, Move, Lines, CursorPos) that you would keep if you swapped in a piece table later.

Your buffer is complete and tested. Next up, you will wire it into a full-screen terminal event loop so keystrokes actually flow into the gap buffer.

Build the Screen and Event Loop

Your gap buffer handles text storage and cursor positioning. Now you need the outer shell that owns the terminal and funnels keystrokes into that buffer.

In this step, you will build the editor's event loop. This is the heartbeat of every terminal UI application: clear the screen, render, wait for input, dispatch, repeat.

In this step, get ready to:
  • Define the Editor struct with mode tracking and a buffer.
  • Implement the event loop that receives terminal events from tcell.
  • Wire up main.go to initialize the screen and launch the editor.
Build the editor shell

The editor package is responsible for owning the screen, tracking which mode you are in, and routing every keypress to the correct handler.

You will build it in three chunks: the type definitions, the event loop, and the input dispatch chain.

  • Open editor/editor.go in your editor. Replace any existing content with the following type definitions and constructor:
package editor

import (
	"os"

	"github.com/gdamore/tcell/v3"

	"med/buffer"
)

// Mode tracks whether the editor is in Normal or Insert mode
type Mode int

const (
	ModeNormal Mode = iota
	ModeInsert
)

// Editor holds the screen, buffer, current mode, and viewport offset
type Editor struct {
	screen  tcell.Screen
	buf     *buffer.Buffer
	mode    Mode
	topLine int
}

// New creates an Editor with the given screen and initial content
func New(screen tcell.Screen, content string) *Editor {
	return &Editor{
		screen: screen,
		buf:    buffer.New(content),
		mode:   ModeNormal,
	}
}

What does this code do?

Mode is an integer type with two constants: ModeNormal (0) and ModeInsert (1). This mirrors how Neovim tracks which mode you are in.

The Editor struct holds everything the editor needs: the tcell screen for drawing, a gap buffer for text, the current mode, and topLine for viewport scrolling (used in a later step).

New() is the constructor. It wraps the raw content string into a gap buffer and starts in Normal mode.

  • Below the New() function, add the event loop:
// Run enters the main event loop: render, show, wait, dispatch
func (e *Editor) Run() {
	for {
		e.render()
		e.screen.Show()

		// Block until the terminal sends an event
		ev := <-e.screen.EventQ()
		switch ev := ev.(type) {
		case *tcell.EventResize:
			e.screen.Sync()
		case *tcell.EventKey:
			e.handleKey(ev)
		}
	}
}

What is the event loop doing?

This infinite for loop is the editor's heartbeat. Each iteration clears and redraws the screen, calls Show() to flush changes to the terminal, then blocks on the EventQ() channel until a key press or resize arrives.

Resize events trigger Sync() which forces a full terminal redraw. Key events route to handleKey() for mode-based dispatch.

  • Below Run(), add the input dispatch chain and utility functions to complete the file:
// handleKey routes events to the active mode handler
func (e *Editor) handleKey(ev *tcell.EventKey) {
	switch e.mode {
	case ModeNormal:
		e.handleNormal(ev)
	case ModeInsert:
		e.handleInsert(ev)
	}
}

// handleNormal processes keys when in Normal mode
func (e *Editor) handleNormal(ev *tcell.EventKey) {
	if ev.Key() == tcell.KeyCtrlC {
		e.quit()
		return
	}
	if ev.Key() == tcell.KeyRune {
		switch ev.Str() {
		case "q":
			e.quit()
		}
	}
}

// handleInsert processes keys when in Insert mode
func (e *Editor) handleInsert(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyEscape:
		e.mode = ModeNormal
	}
}

// render redraws the entire screen (content comes in the next step)
func (e *Editor) render() {
	e.screen.Clear()
}

// quit restores the terminal and exits
func (e *Editor) quit() {
	e.screen.Fini()
	os.Exit(0)
}

How does mode dispatch work?

handleKey() is a simple switch on the current mode. This is the state machine at the heart of modal editing. Every keypress flows through one path: event loop to handleKey() to the mode-specific handler.

Right now handleNormal only recognizes q and Ctrl+C for quitting. handleInsert only handles Escape to return to Normal mode. You will fill these in with real editing commands in the next steps.

  • Save editor/editor.go.

Seeing a compile error?

If you see an error about render or handleKey being undefined, make sure all three code chunks are in the same file. The Run() function calls methods defined later in the file, which is fine in Go as long as they exist.

help me fix a compile error in editor/editor.go

✔️ Awesome, I've got everything!

Great. Make sure you have saved editor/editor.go before moving on.

ⓧ I'd like to double check the full code

package editor

import (
	"os"

	"github.com/gdamore/tcell/v3"

	"med/buffer"
)

type Mode int

const (
	ModeNormal Mode = iota
	ModeInsert
)

type Editor struct {
	screen  tcell.Screen
	buf     *buffer.Buffer
	mode    Mode
	topLine int
}

func New(screen tcell.Screen, content string) *Editor {
	return &Editor{
		screen: screen,
		buf:    buffer.New(content),
		mode:   ModeNormal,
	}
}

func (e *Editor) Run() {
	for {
		e.render()
		e.screen.Show()

		ev := <-e.screen.EventQ()
		switch ev := ev.(type) {
		case *tcell.EventResize:
			e.screen.Sync()
		case *tcell.EventKey:
			e.handleKey(ev)
		}
	}
}

func (e *Editor) handleKey(ev *tcell.EventKey) {
	switch e.mode {
	case ModeNormal:
		e.handleNormal(ev)
	case ModeInsert:
		e.handleInsert(ev)
	}
}

func (e *Editor) handleNormal(ev *tcell.EventKey) {
	if ev.Key() == tcell.KeyCtrlC {
		e.quit()
		return
	}
	if ev.Key() == tcell.KeyRune {
		switch ev.Str() {
		case "q":
			e.quit()
		}
	}
}

func (e *Editor) handleInsert(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyEscape:
		e.mode = ModeNormal
	}
}

func (e *Editor) render() {
	e.screen.Clear()
}

func (e *Editor) quit() {
	e.screen.Fini()
	os.Exit(0)
}
Wire up the entry point

The editor package is ready, but main.go still runs the old hello-world demo. You need to replace it with proper screen initialization, a panic-recovery guard, and the call into your editor.

  • Replace the entire contents of main.go with the following:
package main

import (
	"log"
	"os"

	"github.com/gdamore/tcell/v3"
	"github.com/gdamore/tcell/v3/color"

	"med/editor"
)

func main() {
	// Create and initialize the terminal screen
	s, err := tcell.NewScreen()
	if err != nil {
		log.Fatalf("failed to create screen: %v", err)
	}
	if err := s.Init(); err != nil {
		log.Fatalf("failed to init screen: %v", err)
	}

	// Set a clean default style with no forced colors
	defStyle := tcell.StyleDefault.Background(color.Reset).Foreground(color.Reset)
	s.SetStyle(defStyle)
	s.Clear()

	// Ensure terminal is restored even if the editor panics
	defer func() {
		maybePanic := recover()
		s.Fini()
		if maybePanic != nil {
			panic(maybePanic)
		}
	}()

	// Optionally read a file passed as the first argument
	content := ""
	if len(os.Args) > 1 {
		data, err := os.ReadFile(os.Args[1])
		if err == nil {
			content = string(data)
		}
	}

	// Launch the editor
	ed := editor.New(s, content)
	ed.Run()
}

What does the defer recovery pattern do?

If anything inside the editor panics, the deferred function catches it with recover(), calls Fini() to restore your terminal to its normal state, then re-panics so you still see the error. Without this, a crash would leave your terminal in raw mode with no echo and no line buffering.

The file-reading block is optional. If no argument is passed, the editor starts with an empty buffer. If a filename is given, it reads the content in as the starting text.

  • Save main.go.

Import errors after saving?

If your editor reports that med/editor cannot be found, make sure your go.mod declares module med and that editor/editor.go starts with package editor.

help me fix an import error for med/editor in main.go

✔️ Awesome, I've got everything!

Great. Make sure main.go is saved before running.

ⓧ I'd like to double check the full code

package main

import (
	"log"
	"os"

	"github.com/gdamore/tcell/v3"
	"github.com/gdamore/tcell/v3/color"

	"med/editor"
)

func main() {
	s, err := tcell.NewScreen()
	if err != nil {
		log.Fatalf("failed to create screen: %v", err)
	}
	if err := s.Init(); err != nil {
		log.Fatalf("failed to init screen: %v", err)
	}

	defStyle := tcell.StyleDefault.Background(color.Reset).Foreground(color.Reset)
	s.SetStyle(defStyle)
	s.Clear()

	defer func() {
		maybePanic := recover()
		s.Fini()
		if maybePanic != nil {
			panic(maybePanic)
		}
	}()

	content := ""
	if len(os.Args) > 1 {
		data, err := os.ReadFile(os.Args[1])
		if err == nil {
			content = string(data)
		}
	}

	ed := editor.New(s, content)
	ed.Run()
}
Test the event loop

Time to confirm the full loop works. The editor should take over your terminal, show a blank screen, and exit cleanly when you press q.

  • Run your editor by executing this command:
go run main.go
  • Your terminal should clear completely and show a blank screen. This is your editor running.
  • Press q to quit. Your terminal should return to its normal prompt with no garbled output.

What should I see?

A completely blank terminal means the event loop is working. The render() function only calls Clear() right now, so there is nothing to draw yet. The key test is that quitting restores your terminal cleanly.

You can also try Ctrl+C. It should exit just as cleanly as q.

Terminal stuck or garbled after exiting?

If your terminal shows no prompt or characters appear invisible after exiting, type reset and press Enter (even if you cannot see the text). This restores terminal state manually.

The most common cause is a panic before Fini() runs. Check that your defer block in main.go calls recover() and then s.Fini() unconditionally.

help me fix my terminal after my Go tcell editor crashed without calling Fini

Your editor now owns the terminal and responds to input. Next up, you will fill that blank screen with line numbers, text content, and a visible cursor.

Render Lines, Gutter, and Scrolling

Your editor currently owns the terminal and responds to input, but the screen is completely blank. A real text editor paints the buffer contents on every frame: line numbers in a gutter, text content, and placeholder markers for empty space.

In this step, you will build the full rendering pipeline. The renderer reads lines from the gap buffer, calculates which lines are visible based on a viewport offset, draws a line-number gutter, renders text, fills empty rows with tilde markers, positions the cursor, and paints a status bar showing the current mode and cursor coordinates.

In this step, get ready to:
  • Render buffer content with right-aligned line numbers in a gutter.
  • Display the cursor and implement vertical scrolling.
  • Add a status bar showing mode name and cursor position.
Implement the render function

Every time the event loop cycles, the editor redraws the entire screen. The renderer needs to figure out which slice of the buffer is visible (the viewport), draw line numbers in a fixed-width gutter, then paint each character of each visible line into the correct terminal cell.

  • In editor/editor.go, find the import block at the top of the file:
import (
	"os"

	"github.com/gdamore/tcell/v3"

	"med/buffer"
)
  • Replace the entire import block with this expanded version that adds fmt, strings, and the color subpackage:
import (
	"fmt"
	"os"
	"strings"

	"github.com/gdamore/tcell/v3"
	"github.com/gdamore/tcell/v3/color"

	"med/buffer"
)

Why the color subpackage?

In tcell v3, color constants like color.Gray and color.Blue moved to their own subpackage. You will use these to style the gutter, tildes, and status bar with distinct colors.

  • Now find the stub render() method:
func (e *Editor) render() {
	e.screen.Clear()
}
  • Replace the entire render() function with this full implementation:
func (e *Editor) render() {
	e.screen.Clear()
	w, h := e.screen.Size()
	lines := e.buf.Lines()
	curLine, curCol := e.buf.CursorPos()

	// Adjust viewport
	viewportHeight := h - 1 // reserve 1 row for status bar
	if curLine < e.topLine {
		e.topLine = curLine
	}
	if curLine >= e.topLine+viewportHeight {
		e.topLine = curLine - viewportHeight + 1
	}

	gutterWidth := 4
	defStyle := tcell.StyleDefault.Foreground(color.Reset).Background(color.Reset)
	lineNumStyle := tcell.StyleDefault.Foreground(color.Gray).Background(color.Reset)
	tildeStyle := tcell.StyleDefault.Foreground(color.Blue).Background(color.Reset)

	for row := 0; row < viewportHeight; row++ {
		lineIdx := e.topLine + row
		if lineIdx < len(lines) {
			// Render line number
			numStr := fmt.Sprintf("%3d ", lineIdx+1)
			for i, ch := range numStr {
				e.screen.Put(i, row, string(ch), lineNumStyle)
			}
			// Render line content
			lineRunes := []rune(lines[lineIdx])
			for i, ch := range lineRunes {
				col := gutterWidth + i
				if col < w {
					e.screen.Put(col, row, string(ch), defStyle)
				}
			}
		} else {
			// Tilde for empty lines
			e.screen.Put(0, row, "~", tildeStyle)
		}
	}

	// Status bar
	e.renderStatusBar(w, h, curLine, curCol)

	// Position cursor
	screenCol := gutterWidth + curCol
	screenRow := curLine - e.topLine
	e.screen.ShowCursor(screenCol, screenRow)
}

What does this code do?

The render function redraws the entire terminal on every frame. Here is how each section works:

  • screen.Size() returns the terminal width and height in cells. The viewport height is one less than the total height to leave room for the status bar.
  • The scrolling logic checks if the cursor has moved above or below the visible window. If it has, topLine shifts to keep the cursor on screen.
  • Line numbers are printed in a 4-character gutter using fmt.Sprintf("%3d "), which right-aligns the number and adds a trailing space.
  • Text content starts at column 4 (right after the gutter). Each rune is placed into its own terminal cell using screen.Put().
  • Rows below the last line of content get a blue ~ marker, just like Vim shows for non-existent lines.
  • screen.ShowCursor() positions the hardware cursor, offset by the gutter width and adjusted for the viewport scroll.

You will not see a change yet because render() calls renderStatusBar() which does not exist yet. The next chunk adds it.

Add the status bar

Every modal editor shows the current mode and cursor coordinates at the bottom of the screen. This gives the user constant feedback about where they are and what input mode is active.

  • Add the renderStatusBar() method below the render() function in editor/editor.go:
func (e *Editor) renderStatusBar(w, h, curLine, curCol int) {
	statusRow := h - 1
	statusStyle := tcell.StyleDefault.Foreground(color.Black).Background(color.White)

	// Fill status bar background
	bar := strings.Repeat(" ", w)
	for i, ch := range bar {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	// Mode name
	modeName := "NORMAL"
	if e.mode == ModeInsert {
		modeName = "INSERT"
	}
	modeStr := fmt.Sprintf(" %s ", modeName)
	for i, ch := range modeStr {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	// Cursor position
	posStr := fmt.Sprintf("Ln %d, Col %d ", curLine+1, curCol+1)
	startCol := w - len(posStr)
	for i, ch := range posStr {
		e.screen.Put(startCol+i, statusRow, string(ch), statusStyle)
	}
}

What does this code do?

  • The function first fills the entire bottom row with spaces using a white background. This creates the status bar's contrasting visual band.
  • The mode name (NORMAL or INSERT) is rendered left-aligned with padding spaces around it.
  • The cursor position string (Ln X, Col Y) is rendered right-aligned by calculating startCol from the terminal width minus the string length.
  • Save editor/editor.go.
  • Run the editor by running:
go run main.go
  • You should see a column of blue ~ tildes filling most of the screen, a gray 1 line number on the first row, and a white status bar at the bottom showing NORMAL on the left and Ln 1, Col 1 on the right.
  • Press q to exit.

Seeing a compile error?

  • If you see an error about color being undefined, make sure you added "github.com/gdamore/tcell/v3/color" to your imports.
  • If you see renderStatusBar not defined, make sure you added the function after render() in the same file.
  • If the screen looks garbled, check that your gutterWidth is set to 4 and line content starts at gutterWidth + i.

Help me debug my editor rendering

✔️ Awesome, I've got everything!

Great. Double check you have saved editor/editor.go.

ⓧ I'd like to double check the full code

Here is the complete editor/editor.go at this point:

package editor

import (
	"fmt"
	"os"
	"strings"

	"github.com/gdamore/tcell/v3"
	"github.com/gdamore/tcell/v3/color"

	"med/buffer"
)

type Mode int

const (
	ModeNormal Mode = iota
	ModeInsert
)

type Editor struct {
	screen  tcell.Screen
	buf     *buffer.Buffer
	mode    Mode
	topLine int
}

func New(screen tcell.Screen, content string) *Editor {
	return &Editor{
		screen: screen,
		buf:    buffer.New(content),
		mode:   ModeNormal,
	}
}

func (e *Editor) Run() {
	for {
		e.render()
		e.screen.Show()

		ev := <-e.screen.EventQ()
		switch ev := ev.(type) {
		case *tcell.EventResize:
			e.screen.Sync()
		case *tcell.EventKey:
			e.handleKey(ev)
		}
	}
}

func (e *Editor) handleKey(ev *tcell.EventKey) {
	switch e.mode {
	case ModeNormal:
		e.handleNormal(ev)
	case ModeInsert:
		e.handleInsert(ev)
	}
}

func (e *Editor) handleNormal(ev *tcell.EventKey) {
	if ev.Key() == tcell.KeyCtrlC {
		e.quit()
		return
	}
	if ev.Key() == tcell.KeyRune {
		switch ev.Str() {
		case "q":
			e.quit()
		}
	}
}

func (e *Editor) handleInsert(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyEscape:
		e.mode = ModeNormal
	}
}

func (e *Editor) render() {
	e.screen.Clear()
	w, h := e.screen.Size()
	lines := e.buf.Lines()
	curLine, curCol := e.buf.CursorPos()

	// Adjust viewport
	viewportHeight := h - 1 // reserve 1 row for status bar
	if curLine < e.topLine {
		e.topLine = curLine
	}
	if curLine >= e.topLine+viewportHeight {
		e.topLine = curLine - viewportHeight + 1
	}

	gutterWidth := 4
	defStyle := tcell.StyleDefault.Foreground(color.Reset).Background(color.Reset)
	lineNumStyle := tcell.StyleDefault.Foreground(color.Gray).Background(color.Reset)
	tildeStyle := tcell.StyleDefault.Foreground(color.Blue).Background(color.Reset)

	for row := 0; row < viewportHeight; row++ {
		lineIdx := e.topLine + row
		if lineIdx < len(lines) {
			// Render line number
			numStr := fmt.Sprintf("%3d ", lineIdx+1)
			for i, ch := range numStr {
				e.screen.Put(i, row, string(ch), lineNumStyle)
			}
			// Render line content
			lineRunes := []rune(lines[lineIdx])
			for i, ch := range lineRunes {
				col := gutterWidth + i
				if col < w {
					e.screen.Put(col, row, string(ch), defStyle)
				}
			}
		} else {
			// Tilde for empty lines
			e.screen.Put(0, row, "~", tildeStyle)
		}
	}

	// Status bar
	e.renderStatusBar(w, h, curLine, curCol)

	// Position cursor
	screenCol := gutterWidth + curCol
	screenRow := curLine - e.topLine
	e.screen.ShowCursor(screenCol, screenRow)
}

func (e *Editor) renderStatusBar(w, h, curLine, curCol int) {
	statusRow := h - 1
	statusStyle := tcell.StyleDefault.Foreground(color.Black).Background(color.White)

	// Fill status bar background
	bar := strings.Repeat(" ", w)
	for i, ch := range bar {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	// Mode name
	modeName := "NORMAL"
	if e.mode == ModeInsert {
		modeName = "INSERT"
	}
	modeStr := fmt.Sprintf(" %s ", modeName)
	for i, ch := range modeStr {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	// Cursor position
	posStr := fmt.Sprintf("Ln %d, Col %d ", curLine+1, curCol+1)
	startCol := w - len(posStr)
	for i, ch := range posStr {
		e.screen.Put(startCol+i, statusRow, string(ch), statusStyle)
	}
}

func (e *Editor) quit() {
	e.screen.Fini()
	os.Exit(0)
}

Your editor now has a visual presence. It renders line numbers, content, scrolling boundaries, and a persistent status bar. Next up, you will wire the input handlers so hjkl navigation, text insertion, and mode switching all work together.

Add Modal Input Handling

Your editor renders text, shows line numbers, and scrolls the viewport. But right now there is no way to actually move the cursor or type anything.

Modal editing splits keyboard input into distinct modes. In Normal mode, every key is a command. In Insert mode, every key produces text. This step wires up both modes and transforms your static renderer into a working editor.

In this step, get ready to:
  • Implement the Normal mode handler with hjkl navigation and mode-switch keys.
  • Implement the Insert mode handler for typing, Enter, and Backspace.
  • Verify the status bar updates to reflect mode changes and cursor position.
Implement the Normal mode handler

Normal mode is where you spend most of your time in a modal editor. Each key triggers a command rather than inserting text.

  • In editor/editor.go, find the existing handleNormal method.
  • Replace the entire method with this expanded version:
func (e *Editor) handleNormal(ev *tcell.EventKey) {
	if ev.Key() == tcell.KeyCtrlC {
		e.quit()
		return
	}
	if ev.Key() == tcell.KeyRune {
		switch ev.Str() {
		case "h":
			e.buf.MoveLeft()
		case "l":
			e.buf.MoveRight()
		case "j":
			e.moveDown()
		case "k":
			e.moveUp()
		case "i":
			e.mode = ModeInsert
		case "a":
			e.buf.MoveRight()
			e.mode = ModeInsert
		case "o":
			e.moveToLineEnd()
			e.buf.Insert('\n')
			e.mode = ModeInsert
		case "q":
			e.quit()
		}
	}
}

What does this code do?

Each case maps a single key to a command, mirroring Neovim's Normal mode:

  • h and l move the gap buffer cursor left and right (horizontal movement).
  • j and k call helper methods for vertical movement across lines.
  • i enters Insert mode at the current position. a moves one character right first (append after cursor).
  • o opens a new line below by moving to the line end, inserting a newline, then entering Insert mode.

You won't see a change yet. The moveDown, moveUp, and moveToLineEnd methods don't exist yet, so the code won't compile until the next chunk.

Vertical movement in a gap buffer is not trivial. You cannot just jump to a line number. Instead, you move the gap left or right through the content until you reach the target position. These three helper methods handle that logic.

  • Add the following three methods to editor/editor.go, after the handleInsert method:
func (e *Editor) moveDown() {
	_, col := e.buf.CursorPos()
	e.moveToLineEnd()
	if e.buf.CursorPos(); true {
		e.buf.MoveRight() // move past \n
	}
	for i := 0; i < col; i++ {
		line, _ := e.buf.CursorPos()
		_ = line
		nextLines := e.buf.Lines()
		curLine, curCol := e.buf.CursorPos()
		if curCol >= len([]rune(nextLines[curLine])) {
			break
		}
		e.buf.MoveRight()
	}
}

func (e *Editor) moveUp() {
	line, col := e.buf.CursorPos()
	if line == 0 {
		return
	}
	// Move to beginning of current line
	for {
		_, c := e.buf.CursorPos()
		if c == 0 {
			break
		}
		e.buf.MoveLeft()
	}
	// Move past the \n to end of previous line
	e.buf.MoveLeft()
	// Move to beginning of previous line
	for {
		_, c := e.buf.CursorPos()
		if c == 0 {
			break
		}
		e.buf.MoveLeft()
	}
	// Move right to target column
	lines := e.buf.Lines()
	prevLine := line - 1
	maxCol := len([]rune(lines[prevLine]))
	targetCol := col
	if targetCol > maxCol {
		targetCol = maxCol
	}
	for i := 0; i < targetCol; i++ {
		e.buf.MoveRight()
	}
}

func (e *Editor) moveToLineEnd() {
	lines := e.buf.Lines()
	curLine, curCol := e.buf.CursorPos()
	if curLine >= len(lines) {
		return
	}
	lineLen := len([]rune(lines[curLine]))
	for curCol < lineLen {
		e.buf.MoveRight()
		curCol++
	}
}

How does vertical movement work?

A gap buffer only supports left/right movement through a flat rune array. To move down a line, moveDown remembers the current column, moves the gap to the end of the current line, steps past the newline character, then advances right to match the original column (or stops at the shorter line's end).

moveUp reverses this: it moves left to the beginning of the current line, steps back past the newline, moves left again to the start of the previous line, then moves right to the target column.

  • Save editor/editor.go.
  • Test navigation by running:
go run main.go
  • Press h, l, j, k. The cursor should move in the expected directions.
  • Press q to exit.

Cursor not moving?

The buffer starts empty, so there is nothing to navigate through. Try opening a file instead:

go run main.go go.mod

This loads go.mod into the buffer so you have text to move through. If you see a compile error about undefined methods, double-check that you added all three helper methods (moveDown, moveUp, moveToLineEnd).

help me debug cursor movement in my modal editor

Implement the Insert mode handler

Insert mode is the counterpart to Normal. Every keypress here should produce a character in the buffer, with special handling for Enter and Backspace.

  • In editor/editor.go, find the existing handleInsert method.
  • Replace the entire method with this expanded version:
func (e *Editor) handleInsert(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyEscape:
		e.mode = ModeNormal
	case tcell.KeyEnter:
		e.buf.Insert('\n')
	case tcell.KeyBackspace, tcell.KeyBackspace2:
		e.buf.Delete()
	case tcell.KeyRune:
		for _, r := range ev.Str() {
			e.buf.Insert(r)
		}
	}
}

What does this code do?

  • tcell.KeyEscape returns to Normal mode. This is the same behavior as before.
  • tcell.KeyEnter inserts a newline rune into the gap buffer, creating a new line.
  • tcell.KeyBackspace and tcell.KeyBackspace2 both call Delete(). Two constants are needed because different terminals send different key codes for backspace.
  • tcell.KeyRune handles all printable characters. The loop iterates over ev.Str() to correctly handle multi-byte Unicode input.
  • Save editor/editor.go.
  • Run the editor with a file to test:
go run main.go go.mod
  • Press i to enter Insert mode. Type some text. You should see characters appear at the cursor.
  • Press Backspace to delete characters.
  • Press Enter to create new lines.
  • Press Escape to return to Normal mode, then navigate with hjkl.
  • Press q to exit.

Typed characters not appearing?

Make sure you pressed i first to enter Insert mode. If you are still in Normal mode, keys trigger commands instead of inserting text. Check the status bar at the bottom. It should say INSERT when you are in the correct mode.

help me debug Insert mode in my Go text editor

Verify the status bar responds to mode changes

The renderStatusBar method you built in the previous step already reads e.mode and e.buf.CursorPos(). Now that both handlers are wired up, the status bar reflects your actions in real time.

  • Run the editor again:
go run main.go go.mod
  • Look at the bottom status bar. It should display NORMAL on the left and Ln 1, Col 1 on the right.
  • Press j a few times. The Ln number should increase.
  • Press l a few times. The Col number should increase.
  • Press i. The mode label should switch to INSERT.
  • Press Escape. The mode label should switch back to NORMAL.
  • Press q to exit.

Why does this work without new code?

The render loop calls renderStatusBar on every frame. Since it reads the current mode and cursor position each time, any change from the input handlers is immediately visible. This is the event-driven architecture at work: input modifies state, then the next render cycle reflects it.

✔️ Awesome, I've got everything!

Great. Double check you have saved editor/editor.go.

ⓧ I'd like to double check the full code

Here is the complete editor/editor.go at this point:

package editor

import (
	"fmt"
	"os"
	"strings"

	"github.com/gdamore/tcell/v3"
	"github.com/gdamore/tcell/v3/color"

	"med/buffer"
)

type Mode int

const (
	ModeNormal Mode = iota
	ModeInsert
)

type Editor struct {
	screen  tcell.Screen
	buf     *buffer.Buffer
	mode    Mode
	topLine int
}

func New(screen tcell.Screen, content string) *Editor {
	return &Editor{
		screen: screen,
		buf:    buffer.New(content),
		mode:   ModeNormal,
	}
}

func (e *Editor) Run() {
	for {
		e.render()
		e.screen.Show()

		ev := <-e.screen.EventQ()
		switch ev := ev.(type) {
		case *tcell.EventResize:
			e.screen.Sync()
		case *tcell.EventKey:
			e.handleKey(ev)
		}
	}
}

func (e *Editor) handleKey(ev *tcell.EventKey) {
	switch e.mode {
	case ModeNormal:
		e.handleNormal(ev)
	case ModeInsert:
		e.handleInsert(ev)
	}
}

func (e *Editor) handleNormal(ev *tcell.EventKey) {
	if ev.Key() == tcell.KeyCtrlC {
		e.quit()
		return
	}
	if ev.Key() == tcell.KeyRune {
		switch ev.Str() {
		case "h":
			e.buf.MoveLeft()
		case "l":
			e.buf.MoveRight()
		case "j":
			e.moveDown()
		case "k":
			e.moveUp()
		case "i":
			e.mode = ModeInsert
		case "a":
			e.buf.MoveRight()
			e.mode = ModeInsert
		case "o":
			e.moveToLineEnd()
			e.buf.Insert('\n')
			e.mode = ModeInsert
		case "q":
			e.quit()
		}
	}
}

func (e *Editor) handleInsert(ev *tcell.EventKey) {
	switch ev.Key() {
	case tcell.KeyEscape:
		e.mode = ModeNormal
	case tcell.KeyEnter:
		e.buf.Insert('\n')
	case tcell.KeyBackspace, tcell.KeyBackspace2:
		e.buf.Delete()
	case tcell.KeyRune:
		for _, r := range ev.Str() {
			e.buf.Insert(r)
		}
	}
}

func (e *Editor) moveDown() {
	_, col := e.buf.CursorPos()
	e.moveToLineEnd()
	if e.buf.CursorPos(); true {
		e.buf.MoveRight() // move past \n
	}
	for i := 0; i < col; i++ {
		line, _ := e.buf.CursorPos()
		_ = line
		nextLines := e.buf.Lines()
		curLine, curCol := e.buf.CursorPos()
		if curCol >= len([]rune(nextLines[curLine])) {
			break
		}
		e.buf.MoveRight()
	}
}

func (e *Editor) moveUp() {
	line, col := e.buf.CursorPos()
	if line == 0 {
		return
	}
	// Move to beginning of current line
	for {
		_, c := e.buf.CursorPos()
		if c == 0 {
			break
		}
		e.buf.MoveLeft()
	}
	// Move past the \n to end of previous line
	e.buf.MoveLeft()
	// Move to beginning of previous line
	for {
		_, c := e.buf.CursorPos()
		if c == 0 {
			break
		}
		e.buf.MoveLeft()
	}
	// Move right to target column
	lines := e.buf.Lines()
	prevLine := line - 1
	maxCol := len([]rune(lines[prevLine]))
	targetCol := col
	if targetCol > maxCol {
		targetCol = maxCol
	}
	for i := 0; i < targetCol; i++ {
		e.buf.MoveRight()
	}
}

func (e *Editor) moveToLineEnd() {
	lines := e.buf.Lines()
	curLine, curCol := e.buf.CursorPos()
	if curLine >= len(lines) {
		return
	}
	lineLen := len([]rune(lines[curLine]))
	for curCol < lineLen {
		e.buf.MoveRight()
		curCol++
	}
}

func (e *Editor) render() {
	e.screen.Clear()
	w, h := e.screen.Size()
	lines := e.buf.Lines()
	curLine, curCol := e.buf.CursorPos()

	viewportHeight := h - 1
	if curLine < e.topLine {
		e.topLine = curLine
	}
	if curLine >= e.topLine+viewportHeight {
		e.topLine = curLine - viewportHeight + 1
	}

	gutterWidth := 4
	defStyle := tcell.StyleDefault.Foreground(color.Reset).Background(color.Reset)
	lineNumStyle := tcell.StyleDefault.Foreground(color.Gray).Background(color.Reset)
	tildeStyle := tcell.StyleDefault.Foreground(color.Blue).Background(color.Reset)

	for row := 0; row < viewportHeight; row++ {
		lineIdx := e.topLine + row
		if lineIdx < len(lines) {
			numStr := fmt.Sprintf("%3d ", lineIdx+1)
			for i, ch := range numStr {
				e.screen.Put(i, row, string(ch), lineNumStyle)
			}
			lineRunes := []rune(lines[lineIdx])
			for i, ch := range lineRunes {
				col := gutterWidth + i
				if col < w {
					e.screen.Put(col, row, string(ch), defStyle)
				}
			}
		} else {
			e.screen.Put(0, row, "~", tildeStyle)
		}
	}

	e.renderStatusBar(w, h, curLine, curCol)

	screenCol := gutterWidth + curCol
	screenRow := curLine - e.topLine
	e.screen.ShowCursor(screenCol, screenRow)
}

func (e *Editor) renderStatusBar(w, h, curLine, curCol int) {
	statusRow := h - 1
	statusStyle := tcell.StyleDefault.Foreground(color.Black).Background(color.White)

	bar := strings.Repeat(" ", w)
	for i, ch := range bar {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	modeName := "NORMAL"
	if e.mode == ModeInsert {
		modeName = "INSERT"
	}
	modeStr := fmt.Sprintf(" %s ", modeName)
	for i, ch := range modeStr {
		e.screen.Put(i, statusRow, string(ch), statusStyle)
	}

	posStr := fmt.Sprintf("Ln %d, Col %d ", curLine+1, curCol+1)
	startCol := w - len(posStr)
	for i, ch := range posStr {
		e.screen.Put(startCol+i, statusRow, string(ch), statusStyle)
	}
}

func (e *Editor) quit() {
	e.screen.Fini()
	os.Exit(0)
}

Your editor is fully functional. You can navigate with hjkl, enter Insert mode with i/a/o, type freely, and return to Normal with Escape. The status bar tracks every change. This is the same core architecture that powers Neovim and Helix. Next up, take on the Secret Mission to add file I/O and make your editor save real files.

Secret mission

Add File I/O and Command Mode

Your editor can display and edit text, but it can't persist anything to disk. In this secret mission, you'll add a third mode (Command), accept a filename from the CLI, and implement :w to save your work. This turns your editor from a toy into a tool you could actually use.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future development or remove them.

Resources you used:

  • The med/ project directory on your Desktop (contains all source files, tests, and Go module files).

Keep everything running

No action needed. Choose this if you plan to keep building on your editor.

  • Your med/ directory stays on your Desktop, ready for you to add motions, operators, syntax highlighting, or any other editor features.
  • The Go module cache retains the downloaded tcell dependency so future builds are instant.

Pause - I'll come back to this later

Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk until you return.

  • No background processes or servers are running. The editor only runs when you explicitly invoke it.
  • When you are ready to resume, open your terminal and cd back into the med/ directory.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Delete the project directory by running:
cd ~/Desktop
rm -rf med/
  • Confirm the folder is gone by running:
ls ~/Desktop | grep med

You should see no output, confirming the directory has been removed.

  • Optionally, clear the Go module cache to remove the downloaded tcell dependency by running:
go clean -modcache

Should I clear the module cache?

The Go module cache is shared across all your Go projects. Clearing it removes cached dependencies for every project on your machine, not just this one.

If you have other Go projects, skip this step. Go will re-download dependencies as needed the next time you build.

Nice Work!

Nice Work!

You just built a functioning modal text editor from scratch in Go. You now understand the core architecture that powers tools like Neovim and Helix: terminal ownership, buffer data structures, modal input dispatch, and viewport rendering.

You've learned how to:

  • Build a full-screen terminal UI using tcell v3's cell-based rendering, with an event loop that cleanly owns and restores the terminal.
  • Implement a gap buffer data structure that provides O(1) insert and delete operations at the cursor position.
  • Create a mode-based input dispatcher (Normal/Insert) with hjkl navigation, line numbers, vertical scrolling, and a status bar.
  • Secret Mission: Added file I/O with command-mode parsing, making the editor actually usable for opening and saving files.

Ready to quiz yourself?