eno writer

016 - a cursory look at cursors

It would be hard to imagine a program for editing text that doesn't have a cursor. When eno was a terminal application, I got a cursor for free from the terminal. Now eno has a full graphical user interface so I need to build my own cursor from scratch.

In eno, the Editor struct stores all the state of the current editing session. That seemed like the obvious place to store the cursor state so I start by adding some attributes there. As far as I can tell, there are only two pieces of information required: the paragraph the cursor is in and the index of the character that the cursor is currently over top of.

const Editor = struct {
   cursor_char_idx: usize,
   cursor_paragraph: *Paragraph,
   …
}

To actually display the cursor on the screen, something needs to figure out where the cursor's character actually ends up when it is rendered. This seems like a job for the UI layer. The UI is composed of Boxes so I just need to add some similar attributes to the Box struct.

const Box = struct {
    cursor_char_idx: ?usize = null,
    cursor_rect: ?Rect = null,
};

Each paragraph gets its own box so I don't need a cursor_paragraph equivalent. The ? makes cursor_char_idx an optional parameter because not every box will have a cursor (whereas every editor will have a cursor). I also include cursor_rect which is where I will store the coordinates to draw the cursor once we figure them out.

eno uses an immediate mode UI paradigm. This means that every frame the UI is built from scratch. During this build phase, I can sync the cursor state from the Editor to the Box representing the paragraph that the cursor is in.

fn buildUI(self: *Editor) void {
    for(editor.paragraphs) |paragraph| {
       const box = ui.makeBox();
       box.text = paragraph.text;
       if(editor.cursor_paragraph == paragraph) {
           box.cursor_char_idx = editor.cursor_char_idx;
        }
    }
    …
}

The UI goes through a lifecycle of three phases each frame: build -> layout -> draw. The purpose of the layout phase is to determine where things should appear on the screen. Sounds like a good place to position the cursor!

Here's how the layout code looked BEFORE I added cursor positioning:

fn layout(boxes: []Box) void {
    for(boxes) |child| {
       if(child.text.len > 0) {
           const wrapper = LineWrapper.create(child.text);
           wrapText(child, &wrapper);
       }
    }
}

fn wrapText(box: *Box, wrapper: *LineWrapper) void {
   const break_at = wrapper.suggestBreak(box.width);
   var new_box: ?*Box = null;
   if(break_at < box.text.len) {
       new_box = makeBox();
       box.text = box.text[0..break_at];
       new_box.text = box.text[break_at..];
       new_box.starting_text_idx = break_at;
   }
   // wrapText also performs text layout, indicating where each
   // glyph should be positioned when we render the text to the screen
   box.text_rects = wrapper.getTextRects(box.text)
   if(new_box) |n| wrapText(n, wrapper);
    
}

While the Editor was simply making one box per paragraph, the line wrapping code may split that box into multiple boxes to distribute the paragraph across many lines. This means I need to figure out which box the cursor actually ends up in. This could be done after the fact, but that would require iterating through all the boxes a second time. That might be a decent amount of extra work.

fn layout(boxes: []Box) void {
    for(boxes) |child| {
       …
       if(child.text.len > 0) {
           const wrapper = LineWrapper.create(child.text);
           wrapText(child, &wrapper, child);
       }
    }
}

fn wrapText(box: *Box, wrapper: *LineWrapper, root_box: *Box) void {
   const break_at = wrapper.suggestBreak(box.width);
   var new_box: ?*Box = null;
   if(break_at < box.text.len) {
       new_box = makeBox();
       box.text = box.text[0..break_at];
       new_box.text = box.text[break_at..];
       new_box.starting_text_idx = break_at;
   }

   box.text_rects = wrapper.getTextRects(box.text)
   const start = box.starting_char_idx;
   const end = start + box.text.len;
   if(root_box.cursor_char_idx >= start and root_box.cursor_char_idx < end) {
     // move the cursor over to the correct box and adjust its char_idx accordingly
     box.cursor_char_idx = root_box.cursor_char_idx - start;  
     root_box.cursor_char_idx = null; 
     box.cursor_rect = text_rects[box.cursor_char_idx];
   }

   if(new_box) |n| wrapText(n, wrapper, root_box);
}

Now, in the draw phase, I can draw the cursor. The draw phase doesn't literally "draw", instead it creates a list of quads to send to the gpu to render.

Here's how the draw code looks once I add cursor support:

fn draw(boxes: []Box) void {
    var quads = std.ArrayList(Quad).init(allocator);
    for(boxes) |box| {
          quads.append(<box background>);
          if(box.cursor_char_idx == idx) {
              quads.append(<cursor>);
          }
          for(box.text_rects, 0..) |rect, idx| {
              quads.append(<glyph>);
          }
    }
}

It is important to append the cursor before the glyphs because I want the letters to appear on top of the cursor visually.

Ok. This is all working nicely! The Editor is the single source of truth for the cursor's position. Everything else is downstream of that. The editor stores the minimum state representing the cursor position. That gets transformed into more detailed state over the UI phases, culminating in the draw phase rendering the cursor on the screen.

Now I can go back to the Editor and trivially implement an action to move the cursor left and right:

fn moveCursorRight(self: *Editor) void {
   self.cursor_char_idx += 1;
}

fn moveCursorLeft(self: *Editor) void {
   self.cursor_char_idx -= 1;
}

Awesome! All our careful architecture paid off! So clean and elegant! Now let's do the down button:

fn moveCursorDown(self: *Editor) void {
   self.cursor_char_idx += ???;
}

Hmmm. I don't know what to add here. I want the cursor to go one line down but I don't know how many characters are on a line. What's more is that the number of characters on each line is completely variable. Moving the cursor down requires information that is only available in the UI layer! My beautifully simple architecture is starting to have issues.

How can I reconcile this? I want the Editor to continue to be the single source of truth for the cursor position. That can still be representing with the cursor_char_idx property. The issue is the editor cannot independently determine what cursor_char_idx is one line down from the current one. It needs to use UI data for that.

The minimum UI information needed by the editor is the idx of the line that the cursor is currently on and the current X position of the cursor relative to its box. The editor does not need to know about the position of every character and all the lines and so on.

So here's what I came up with.

// Editor
fn moveCursorDown(self: *Editor) void {
   self.cursor_intent = .{
       .line_at_x = .{
          .line_idx = self.selection.box.?.selection.?.cursor_line_idx.? + 1,
          .rel_x = self.selection.box.?.selection.?.cursor_rect.?[0][0],
          .cursor_char_idx_result_dest: &self.cursor_char_idx,
       }
   };
}

// Layout

fn layout() void {
    …
    if(box.cursor_intent) |intent| {
        // majorly glossing over how this works to keep this blog post short
        // basically we just search for the box on the correct line that contains the rel_x
        // then we search through the glyphs to find the one closest to rel_x
        const char_idx =  findClosestChar(intent.line_idx, intent.rel_x);
        box.cursor_char_idx =        
        intent.cursor_char_idx_result_dest.* = box.cursor_char_idx

    }
}

I'm not sure if I like it or not, but it works for now.

Stepping back, this is another example where the problem I'm designing for doesn't care about my architecture. I can decide to slice up my code however I want but if the problem I'm trying to solve with the code doesn't work that way, I might just end up making a big mess.

Note: if you are reading careful you may find this code doesn't exactly make sense. I am trying to boil it down the simplest possible form that illustrates the progressions I went through so I had to remove a lot of details.


If you enjoyed this post, subscribe below to get notifications for the next one.

Powered by Buttondown.

We also have an RSS feed

#software #startups #zig