PVG
42. Editor Side Panel Imgui — Program Video Games

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:

  1. Add #+feature dynamic-literals to the first line of your file
  2. 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
  // ...
}