Skip to content

Commit

Permalink
Merge pull request #210 from markersniffen/markersniffen
Browse files Browse the repository at this point in the history
markersniffen blog post - codin
  • Loading branch information
Skytrias authored May 27, 2024
2 parents 4f93f26 + 9b7b212 commit 9f56183
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 0 deletions.
211 changes: 211 additions & 0 deletions content/news/2024-06.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,214 @@ Since the short blog is getting quite long already we'll take a break here and d
If you're interested in AST editing let us know!

---

## codin (markersniffen)

A few years ago I decided that I was going to figure out how to write cross-platform apps. I was a motion graphics artist by trade, getting frustrated with the limitations and bloat of the software I had access to. My experience was limited to simple scripts in Python (for Blender), writing "expressions" in After Effects (think slower than normal javascript), and a bout in Java (I think?) trying to control robotic cameras. At the beginning I remember trying to write an Adobe Illustrator clone in the browser before my brother (who has an actual CS degree) pointed me to Handmade Hero, which through some series of events led me to Odin. Odin completely changed everything for me - everything seemed to click and I started the slow journey of learning how things a little lower down worked.

### UI

My apps were going to be graphical by nature - as a graphics artist most of what I wanted to do involved them in some capacity. I found Dear Imgui early on and used that during my first serious odin project. I quickly ran into some frustrating limitations, and decided I wanted to write my own UI. My design is based heavily on the [fantastic series of blog posts](https://www.rfleury.com/p/ui-series-table-of-contents) by Ryan Fleury, so reading and digesting some of that is highly recommended.

All of my UI development has been at the service of other projects, and I keep a few "example" projects in the repository to help test a variety of conditions that my library needs to be able to handle. One of these projects is a code editor. Dubbed *codin* (for obvious reasons), there are several parts of the project that were both fun and interesting for me to figure out. For this post I decided to focus on the way the UI builder code works. When it comes down to it, core of the editor is based on a single widget of my UI library, `edit_multiline`.

As an *imgui*, the builder code in my library looks something like this:

```
{
panel_begin()
size(.PARENT, 1, .TEXT, 1)
if button(buffer.name).clicked do fmt.println("You clicked a button!")
panel_end()
}
```

Everything in the UI is composed of a hierarchy of *boxes* that compose everything displayed in the UI. A *widget* is a chunk of code the creates and manipulates the *boxes*. For example, the `button()` function in the above example looks like this:
```
button :: proc(name:..any) -> Box_Ops {
set_hover_cursor(.HAND)
box := create_box({name, "###Button"}, { .CLICKABLE, .DRAW_TEXT, .HOT_EFFECTS, })
process_ops(box)
return box.ops
}
```

For codin, I created a widget for editing a single line of text:
```
edit_text :: proc(name: []any, text:^string) -> Box_Ops {
set_hover_cursor(.IBEAM)
text_align(.LEFT)
box := create_box({name, "EditTextBox"}, { .CLICKABLE, .DRAW_TEXT }, text)
process_ops(box)
if box.ops.clicked do set_focused(box.key)
if is_focused(box) {
// handle keyboard input to edit text string
}
...
}
```

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_edit_text.mp4"></video>

But in codin we need to edit multiple lines of text, so why don't we just put that widget into another one:
```
edit_multiline :: proc(name: []any, lines:^[dynamic]string) -> Box_Ops {
{
for line in lines {
edit_text(name, line)
}
}
```

That, as the name suggests, lets you edit a chunk of text that is composed of multiple lines. But what we have more lines than fill the screen? My library has a `scrollbox()` widget that scales to fit it's children and adds bars you can grab to slide it up and down:

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_scrollbox.mp4"></video>

The problem with this approach is that if you have a large text file, there are potentially hundreds or thousands of lines of text that are offscreen and do not need boxes created for them. That wastes a lot of cycles on invisible content. I adjusted the demo so that you could see the offscreen boxes outlined in blue:

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_too-many-boxes.mp4"></video>

My solution to this problem was to only create boxes for visible lines of text. However, this breaks the scrollbox - the scrollbox needs a full set of children to determine the proper height for it's own box. What I needed to do was create a spacer box before the visible boxes to account for the offscreen boxes that are no longer created, and *also* manually calculate the height of the scrollbox, since we don't have the children boxes that appear *after* the last visible line:
- First calculate the number of visible rows
- Then calculate what the height of the scrollbox would be if I created boxes for all the rows
- Then create the scrollbox widget, telling it exactly how tall to be in pixels
- Finally calculate which line of text is the first one to draw by looking at the vertical offset of the scrollbox
```
edit_multiline :: proc(...) {
visible_rows := get_panel_height() / get_line_space()
row_count := len(lines)
scrollbox_height := f32(row_count) * get_line_space()
size(.PARENT, 1, .PIXELS, scrollbox_height)
sbox := scrollbox({"text buffer"})
first_line_index := int(abs(sbox.offset.y) / get_line_space())
last_line_index := first_line_index + visible_rows
// add spacer
spacer(0, get_line_space() * f32(first_line_index))
// for each line..
for li in first_line_index..=last_line_index
line := &lines[li]
edit_text(name, line)
}
pop()
}
```
And now we only create and calculate the number of boxes that would be visible:

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_good-boxes.mp4"></video>

Now that we are drawing the lines, how do we know which line our cursor is on? We get the cursor position as an input, so let's compare that to the box indices, and when they are equal we can draw a border around it:

```
edit_multiline :: proc(..., cursor:^Txt_Pt, ...)
{
...
// for each line..
for li in first_line_index..=last_line_index
line := &lines[li]
txt_box := edit_text(name, line)
if li == cursor.row {
txt_box.border = 2
txt_box.border_color == get_color(.FOCUSED)
}
}
}
```

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_line-highlighting.mp4"></video>

Now that we highlight the line that cursor is on, let's look at moving it around. We can move the cursor up and down by checking keyboard input events:

```
edit_multiline :: proc(...cursor:^Txt_Pt, auto_scroll:^bool ...)
{
...
if os_key_event(.PRESS, .UP, {} ) do cursor.row -= 1
if os_key_event(.PRESS, .UP, {.CTRL}) do cursor.row -= visible_rows
if os_key_event(.PRESS, .DOWN, {} ) do cursor.row += 1
if os_key_event(.PRESS, .DOWN, {.CTRL}) do cursor.row += visible_rows
// clamp cursor so it stays in range...
cursor.row = clamp(cursor.row, 0, len(lines)-1)
}
```
<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_keyboard-input.mp4"></video>

How about auto scrolling when the cursor is not in the visible range?
- first check if the cursor is in the visible range of boxes
- then set the scrollbox vertical offset to a position that would show the cursor's row

```
edit_multiline :: proc(..., cursor:^Txt_Pt, ...)
{
...
cursor_in_range := (cursor.row >= first_line_index) && (cursor.row < last_line_index)
if !cursor_in_range {
sbox.offset.y = cursor.row * get_line_space()
}
}
```

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_auto-scroll.mp4"></video>

One thing I realized was that we don't always want to auto scroll...often I manually scroll around a file and let the cursor go off screen. So I added a `scroll_trigger:^bool` as an input to give us a way to limit the auto scroll:

```
edit_multiline :: proc(..., cursor:^Txt_Pt, auto_scroll:^bool ...)
{
...
if auto_scroll^ {
cursor_in_range := (cursor.row >= first_line_index) && (cursor.row < last_line_index)
if !cursor_in_range {
sbox.offset.y = cursor.row * get_line_space()
}
}
}
```

Although the code above is a simpler version of codin's `edit_multiline` widget, it accurately captures how I implemented the described features. The actual `edit_multiline` fuction definition looks like:

```
edit_multiline :: proc(
name: []any,
lines: ^[dynamic]string,
active: bool,
cursor, mark: ^Text_Pt,
scroll_trigger: ^bool,
bars: f32=16
) -> (Box_Ops, []^Box, []int)
{
// actual implementation goes here...
}
```

The inputs:
- `name` is just a unique identifier for all the boxes in the widget.
- `lines` is an array of strings that we can view and edit.
- `cursor` is a `[2]int` that contain the cursor location.
- `mark` is another `[2]int` that contains a cursor that marks the beginning or end of a text selection.
- `scroll_trigger` is a `bool` that tells the widget whether or not to auto-scroll to the line that contains the cursor on the screen.
- `bars` is a float that indicates the width of the scroll bar.

The return values:
- Box_Ops is a struct that contains any state affected by input, e.g. clicking, hovering, releasing, etc.
- []^Box is a temporary array of the visible boxes. This allows me to easily access the boxes after I run through main widget. For example, I use this returned array when highlighting rows that contain build errors.
- []int is an array of line indices that correspond to each of the boxes returned.

### Conclusion

What I've described is just a snippet of what goes on beneath the hood of codin. Some parts are built into the UI library (panel splitting, string allocation, text parsing) while and others are specific to the codin (build commands, build error highlighting, searching).

<video class="ratio ratio-16x9 mb-1 rounded" controls src="images/news/2024-06-codin_end.mp4"></video>

---

Binary file added static/images/news/2024-06-codin_auto-scroll.mp4
Binary file not shown.
Binary file added static/images/news/2024-06-codin_edit_text.mp4
Binary file not shown.
Binary file added static/images/news/2024-06-codin_end.mp4
Binary file not shown.
Binary file added static/images/news/2024-06-codin_good-boxes.mp4
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added static/images/news/2024-06-codin_scrollbox.mp4
Binary file not shown.
Binary file not shown.

0 comments on commit 9f56183

Please sign in to comment.