42. Editor Side Panel Imgui
In this lecture we will be implementing an immediate-mode side panel GUI for the editor.
Immediate mode GUI is a rendering approach where the UI is redrawn from scratch every frame, with widget states stored in program variables rather than a retained widget tree
This panel will allow us to add some simple elements that don't fit in the regular view, such as a way to select an on_enter procedure for a level.
In editor.odin, we need a new constant:
PANEL_WIDTH :: 260
Right at the beginning of editor_update we will exit the procedure if the mouse is over the panel.
This is to prevent clicking on the panel performing an action in the regular editor.
Since we are using an IMGUI approach, our interaction code is handled in the draw procedure.
editor_update :: proc(gs: ^Game_State, dt: f32) {
mouse_pos := rl.GetMousePosition()
if mouse_pos.x < PANEL_WIDTH {
return
}
// ...
}
Speaking of which, if you added the es := es statement to editor_draw, go ahead and delete it. It was there because the debugger wasn't showing me globals so I copied the value. The problem is, if you update a field (es.my_field = x) then you will update only the local copy.
Also, go ahead and delete the camera target from the editor text. We don't need it.
Consider adding the position to one of the corners like other editors have
At the end of editor_draw, we'll have our IMGUI panel calling code:
if es.tool == .Level {
// Initialise panel
editor_panel(PANEL_WIDTH)
// Draw a row of text
editor_panel_text(fmt.ctprintf("Level ID: %d", gs.level.id))
level_pos_tiles := coords_from_pos(gs.level.pos)
editor_panel_text(fmt.ctprintf("Pos: %d, %d", level_pos_tiles.x, level_pos_tiles.y))
level_size_tiles := coords_from_pos(gs.level.size)
editor_panel_text(fmt.ctprintf("Size: %d, %d", level_size_tiles.x, level_size_tiles.y))
editor_panel_text("---")
editor_panel_text("Level On Enter:")
// Iterate over map (shown below)
for k, v in level_on_enter_map {
// If current level procedure matches, draw orange button
if gs.level.on_enter == v {
// Returns true when clicked
if editor_panel_button(k, rl.ORANGE) {
gs.level.on_enter = nil
}
// Otherwise draw default button
} else if editor_panel_button(k) {
gs.level.on_enter = v
}
}
}
I decided to put all panel code in a new file called editor_panel.odin to keep things better organised.
The specs for this panel are simple and limited.
- Fixed width, on the left side of the window
- Each element has it's own row, so we don't need to deal with horizontal layouts
- Single instance, so using a private variable to store state
I have added a couple of comments, though I think the code is straightforward enough and we have enough experience by this point that is should be clear. As usual, if you have any questions, please don't hesitate to ask.
package main
import "core:strings"
import rl "vendor:raylib"
Editor_Panel :: struct {
pos: Vec2, // Position the next element will be drawn
line_height: f32,
padding: f32,
width: f32,
}
@(private = "file")
panel: Editor_Panel
editor_panel :: proc(w: f32, padding: f32 = 8, line_height: f32 = 20, bg := rl.BLACK) {
rl.DrawRectangleV(0, {w, WINDOW_HEIGHT}, bg)
panel.width = w
panel.padding = padding
panel.line_height = line_height
panel.pos = {padding, padding}
}
editor_panel_row_increment :: proc() {
panel.pos += {0, panel.line_height}
}
editor_panel_text :: proc(text: cstring) {
rl.DrawTextEx(gs.font_18, text, panel.pos, 18, 0, rl.WHITE)
editor_panel_row_increment()
}
editor_panel_button :: proc(text: cstring, bg := rl.GRAY, hover_overlay := rl.Color{0, 255, 255, 80}) -> bool {
is_pressed := false
is_hovered := false
size := Vec2{panel.width - 16, 20}
rect := rect_from_pos_size(panel.pos, size)
mouse_pos := rl.GetMousePosition()
if rl.CheckCollisionPointRec(mouse_pos, rect) {
is_hovered = true
if rl.IsMouseButtonPressed(.LEFT) {
is_pressed = true
}
}
text_pos := panel.pos + {8, 1}
rl.DrawRectangleRounded(rect, 0.2, 5, bg)
if is_hovered {
rl.DrawRectangleRounded(rect, 0.2, 5, hover_overlay)
}
rl.DrawTextEx(gs.font_18, text, text_pos, 18, 0, rl.BLACK)
editor_panel_row_increment()
return is_pressed
}
// This element was being used during development before I switched
// to a series of buttons for on_enter.
// I left it in here to show how a simple text box may be made
editor_panel_textbox :: proc(sb: ^strings.Builder) -> bool {
size := Vec2{panel.width - 16, 20}
rect := rect_from_pos_size(panel.pos, size)
text := strings.to_cstring(sb)
rl.DrawRectangleRec(rect, rl.GRAY)
rl.DrawTextEx(gs.font_18, text, panel.pos, 18, 0, rl.BLACK)
key := rl.GetKeyPressed()
if key == .KEY_NULL {
return false
}
if key == .BACKSPACE {
strings.pop_byte(sb)
return false
}
if key == .ENTER {
return true
}
char := rl.GetCharPressed()
strings.write_rune(sb, char)
editor_panel_row_increment()
return false
}
You may have noticed we are using a new font font_18.
It's the same as the other fonts, just smaller.
Game_State :: struct {
// ...
font_18: rl.Font,
font_48: rl.Font,
font_64: rl.Font,
// ...
}
main :: proc() {
// ...
gs.font_18 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 18, nil, 256)
gs.font_48 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 48, nil, 256)
gs.font_64 = rl.LoadFontEx("assets/fonts/Gogh-ExtraBold.ttf", 64, nil, 256)
// ...
}
The last parameter that is current 256 can also be 0 to load the default character set.
This map will need to be initialised differently depending on your Odin version.
At the time of writing, I was using the last 2024 Odin version.
As of 2025, implicit allocations are disallowed by default.
There are two options:
- Add
#+feature dynamic-literalsto the first line of your file - Rewrite implicit allocations to be explicit
I cover the rewriting in the next lecture, though you may have faced this issue at any point.
Implicit main.odin global:
level_on_enter_map := map[cstring]proc(gs: ^Game_State) {
"demon_boss_init" = demon_boss_init,
"some_other_init" = some_other_init,
"yet_other_init" = yet_other_init,
"and_other_init" = and_other_init,
}
// These procedures are just for testing the UI
some_other_init :: proc(_: ^Game_State) {}
yet_other_init :: proc(_: ^Game_State) {}
and_other_init :: proc(_: ^Game_State) {}
Explicit main.odin global + main proc:
// Declare map without initialising
level_on_enter_map: map[cstring]proc(gs: ^Game_State)
main :: proc() {
level_on_enter_map["demon_boss_init"] = demon_boss_init
level_on_enter_map["some_other_init"] = some_other_init
level_on_enter_map["yet_other_init"] = yet_other_init
level_on_enter_map["and_other_init"] = and_other_init
// ...
}