Bubbly Editor - bootstrap
One of my primary goals when starting my Sunday post series about working on LazerBunny was to have some references on how I think about some of the problems I am working on. Especially when newer technology I did not spend a few decades with is involved. And judging from some comments I got the series is very much appreciated by younger engineers. However, one common point people made was that I am not diving deeper into application layout and design. Let us fix that.
I decided to pull the editor out of my shell for now. Once it is done I will bring it back in, but for now it is a separate repository we can use to talk about evolving a codebase step by step. Each commit should map to a logic piece of work to follow along and it will hopefully show how to start with some experiments, move to minimal viable product and to stable V1. As I mentioned in an earlier post this is the first editor I am writing. While most of the concepts are not new to me, I never put them together in this shape, especially not with bubbletea and lipgloss as UI libraries.
The most important part to understand about bubbletea is that it is a framework meant to build user interfaces in a way that scales nicely as complexity and the number of features increases. To oversimplify the concept you can think of it as model, view and controller - a concept many of you might be familiar with already. Except for a controller you have an update method handling messages emitted by whatever you fancy.
Time to dig into the first commit, which renders a few lines on the screen and lets you navigate around with your cursor keys.
type model struct {
lines []string
cursorX int
cursorY int
width int
height int
}
The model is extremely small right now. We keep track of the width and height of the display area, the position of the cursor and the text we want to display split by lines. While most of this will be the same, the one thing that will have to change sooner than later is how we manage the text we are displaying.
The view is equally simple right now - remember we are bootstrapping the whole thing.
func (m model) View() string {
var b strings.Builder
for y, line := range m.lines {
if y == m.cursorY {
for x, char := range line {
if x == m.cursorX {
b.WriteString(cursorSyle.Render(string(char)))
} else {
b.WriteString(string(char))
}
}
if m.cursorX == len(line) {
b.WriteString(cursorSyle.Render(" "))
}
} else {
b.WriteString(line)
}
b.WriteString("\n")
}
return b.String()
}
So all we do is iterate over each line in our model and check if the cursor y position matches the index of our loop. If this is the case we iterate over the line itself until we have a match of the line index for the cursor x position to style the one single character differently. Also known as rendering a cursor.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c":
return m, tea.Quit
case "up":
if m.cursorY > 0 {
m.cursorY--
m.clampCursorX()
}
case "down":
if m.cursorY < len(m.lines)-1 {
m.cursorY++
m.clampCursorX()
}
case "left":
if m.cursorX > 0 {
m.cursorX--
}
case "right":
if m.cursorX < len(m.lines[m.cursorY]) {
m.cursorX++
}
}
}
return m, nil
}
Our update method reacts to a message if the window is resized, cursor keys being pressed or ctrl+c. Let us assume for a second you would want to implement vim style keybindings because you cannot be bothered to move your hand to the cursor or because muscle memory is hard to retrain after a few decades. All you have to do is add k to your case.
case "up", âkâ:
Not too bad. And having worked a decent amount on my shell the last few weeks let me tell you, ctrl+c is a lot nicer to write and mentally keep track off than 3 or 65. Handling key presses with three character slices is the fastest way to remind you to comment your code.
This is mostly it for this week. We got the basic pieces in place to get starting building out all the more fun stuff like a proper treesitter integration, split views, a file browser (because we will have one - hey Helix) and language server support.
Progress
Doing kind of a reset on the editor to get this series off the ground certainly slowed progress down a bit, but at the same time I could get some work in on the shell. There are a few edge cases that behave slightly oddly when calling external apps I still have to track down. And I finally need to add proper $PATH support for autocomplete. Otherwise I have to say using it day in day out is fairly uneventful, which is a very good sign if you ask me.
posted on Sept. 13, 2026, 7:44 p.m. in golang, lazerbunny