diff --git a/content/news/2024-06.md b/content/news/2024-06.md index bb23af3d..bb16a286 100644 --- a/content/news/2024-06.md +++ b/content/news/2024-06.md @@ -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 + } + ... +} +``` + + + +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: + + + +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: + + + +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: + + + +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) + } + + } +} +``` + + + +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) +} +``` + + +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() + } +} +``` + + + +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). + + + +--- + diff --git a/static/images/news/2024-06-codin_auto-scroll.mp4 b/static/images/news/2024-06-codin_auto-scroll.mp4 new file mode 100644 index 00000000..ade5a6fa Binary files /dev/null and b/static/images/news/2024-06-codin_auto-scroll.mp4 differ diff --git a/static/images/news/2024-06-codin_edit_text.mp4 b/static/images/news/2024-06-codin_edit_text.mp4 new file mode 100644 index 00000000..0ed68fcb Binary files /dev/null and b/static/images/news/2024-06-codin_edit_text.mp4 differ diff --git a/static/images/news/2024-06-codin_end.mp4 b/static/images/news/2024-06-codin_end.mp4 new file mode 100644 index 00000000..876766b8 Binary files /dev/null and b/static/images/news/2024-06-codin_end.mp4 differ diff --git a/static/images/news/2024-06-codin_good-boxes.mp4 b/static/images/news/2024-06-codin_good-boxes.mp4 new file mode 100644 index 00000000..ff5283bf Binary files /dev/null and b/static/images/news/2024-06-codin_good-boxes.mp4 differ diff --git a/static/images/news/2024-06-codin_keyboard-input.mp4 b/static/images/news/2024-06-codin_keyboard-input.mp4 new file mode 100644 index 00000000..76fbd562 Binary files /dev/null and b/static/images/news/2024-06-codin_keyboard-input.mp4 differ diff --git a/static/images/news/2024-06-codin_line-highlighting.mp4 b/static/images/news/2024-06-codin_line-highlighting.mp4 new file mode 100644 index 00000000..b92bdfa9 Binary files /dev/null and b/static/images/news/2024-06-codin_line-highlighting.mp4 differ diff --git a/static/images/news/2024-06-codin_scrollbox.mp4 b/static/images/news/2024-06-codin_scrollbox.mp4 new file mode 100644 index 00000000..a0a44f29 Binary files /dev/null and b/static/images/news/2024-06-codin_scrollbox.mp4 differ diff --git a/static/images/news/2024-06-codin_too-many-boxes.mp4 b/static/images/news/2024-06-codin_too-many-boxes.mp4 new file mode 100644 index 00000000..69bd870e Binary files /dev/null and b/static/images/news/2024-06-codin_too-many-boxes.mp4 differ