Skip to content

Instantly share code, notes, and snippets.

@keenanwoodall
Last active September 13, 2025 13:23
Show Gist options
  • Save keenanwoodall/b6f7ecf6346ba3be4842c7d9fd1f372d to your computer and use it in GitHub Desktop.
Save keenanwoodall/b6f7ecf6346ba3be4842c7d9fd1f372d to your computer and use it in GitHub Desktop.
Easy two-line raylib + microui integration for the Odin programming language

Odin + Raylib + microui

  1. Copy/paste rlmu.odin into an rlmu/ folder in your Odin project
  2. Import rlmu package import "rlmu"
  3. Import microui package import mu "vendor:microui"
  4. Call rlmu lifecycle procs like so:
main :: proc() {
    rl.SetWindowState({ rl.ConfigFlag.WINDOW_RESIZABLE })
    rl.InitWindow(720, 600, "Odin/Raylib/microui Demo")
    defer rl.CloseWindow()

    ctx := rlmu.init_scope() // same as calling, `rlmu.init(); defer rlmu.destroy()`

    for !rl.WindowShouldClose() {
        defer free_all(context.temp_allocator)

        rl.BeginDrawing(); defer rl.EndDrawing()
        rl.ClearBackground(rl.BLACK)
        
        rlmu.begin_scope()  // same as calling, `rlmu.begin(); defer rlmu.end()`
        
        // make micro ui calls here!
        if mu.begin_window(ctx, "Test Window", { 100, 100, 100, 100 }) {
            defer mu.end_window(ctx)
            
            mu.label(ctx, "Hello, world")
        }
    } 
}

image


Refer to demo.odin for a more complete example:

rlmui_demo_LKXzupZVtY

package demo
// port of micro ui c demo to odin, using rlmu as renderer
import "rlmu"
import "core:fmt"
import "core:strings"
import rl "vendor:raylib"
import mu "vendor:microui"
log_sb := strings.builder_make()
log_updated := false
log_input_text := make_slice([]u8, 128)
log_input_text_len : int
bg : [3]u8 = { 90, 95, 100 }
main :: proc() {
rl.SetWindowState({ .WINDOW_RESIZABLE })
rl.InitWindow(720, 600, "Odin/Raylib/microui Demo")
defer rl.CloseWindow()
ctx := rlmu.init_scope() // same as calling, `rlmu.init(); defer rlmu.destroy()`
for !rl.WindowShouldClose() {
defer free_all(context.temp_allocator)
rl.BeginDrawing(); defer rl.EndDrawing()
rl.ClearBackground({ bg.r, bg.g, bg.b, 255 })
rlmu.begin_scope() // same as calling, `rlmu.begin(); defer rlmu.end()`
style_window(ctx)
test_window(ctx)
log_window(ctx)
}
}
style_window :: proc(ctx : ^mu.Context) {
Style_Color :: struct {
label: string,
type: mu.Color_Type
}
@(static)
colors := [?]Style_Color {
{ "text:", .TEXT },
{ "border:", .BORDER },
{ "windowbg:", .WINDOW_BG },
{ "titlebg:", .TITLE_BG },
{ "titletext:", .TITLE_TEXT },
{ "panelbg:", .PANEL_BG },
{ "button:", .BUTTON },
{ "buttonhover:", .BUTTON_HOVER },
{ "buttonfocus:", .BUTTON_FOCUS },
{ "base:", .BASE },
{ "basehover:", .BASE_HOVER },
{ "basefocus:", .BASE_FOCUS },
{ "scrollbase:", .SCROLL_BASE },
{ "scrollthumb:", .SCROLL_THUMB },
}
if mu.begin_window(ctx, "Style Editor", mu.Rect { 350, 250, 300, 240 }) {
defer mu.end_window(ctx)
slider_width := i32(f32(mu.get_current_container(ctx).body.w) * 0.14)
mu.layout_row(ctx, { 80, slider_width, slider_width, slider_width, slider_width, -1 }, 0)
for i in 0..<len(colors) {
color_type := colors[i].type
color := &ctx.style.colors[color_type]
mu.label(ctx, colors[i].label)
u8_slider(ctx, &color.r, 0, 255)
u8_slider(ctx, &color.g, 0, 255)
u8_slider(ctx, &color.b, 0, 255)
u8_slider(ctx, &color.a, 0, 255)
mu.draw_rect(ctx, mu.layout_next(ctx), color^)
}
}
}
test_window :: proc(ctx: ^mu.Context) {
if mu.begin_window(ctx, "Demo Window", mu.Rect { 40, 40, 300, 450 }) {
defer mu.end_window(ctx)
win := mu.get_current_container(ctx)
win.rect.w = max(win.rect.w, 240)
win.rect.h = max(win.rect.h, 300)
/* window info */
if .ACTIVE in mu.header(ctx, "Window Info") {
win = mu.get_current_container(ctx)
mu.layout_row(ctx, { 54, -1 }, 0)
mu.label(ctx,"Position:")
mu.label(ctx, fmt.tprintf("%d, %d", win.rect.x, win.rect.y))
mu.label(ctx, "Size:")
mu.label(ctx, fmt.tprintf("%d, %d", win.rect.w, win.rect.h))
}
/* labels + buttons */
if .ACTIVE in mu.header(ctx, "Test Buttons", { .EXPANDED }) {
mu.layout_row(ctx, { 86, -110, -1 }, 0)
mu.label(ctx, "Test buttons 1:")
if .SUBMIT in mu.button(ctx, "Button 1") do write_log("Pressed button 1")
if .SUBMIT in mu.button(ctx, "Button 2") do write_log("Pressed button 2")
mu.label(ctx, "Test buttons 2:")
if .SUBMIT in mu.button(ctx, "Button 3") do write_log("Pressed button 3")
if .SUBMIT in mu.button(ctx, "Popup") do mu.open_popup(ctx, "Test Popup")
if mu.begin_popup(ctx, "Test Popup") {
defer mu.end_popup(ctx)
if .SUBMIT in mu.button(ctx, "Hello") do write_log("Hello")
if .SUBMIT in mu.button(ctx, "World") do write_log("World")
}
}
/* tree */
if .ACTIVE in mu.header(ctx, "Tree and Text", { .EXPANDED }) {
mu.layout_row(ctx, { 140, -1 }, 0)
{
mu.layout_begin_column(ctx)
defer mu.layout_end_column(ctx)
if .ACTIVE in mu.begin_treenode(ctx, "Test 1") {
defer mu.end_treenode(ctx)
if .ACTIVE in mu.begin_treenode(ctx, "Test 1a") {
defer mu.end_treenode(ctx)
mu.label(ctx, "Hello")
mu.label(ctx, "world")
}
if .ACTIVE in mu.begin_treenode(ctx, "Test 1b") {
defer mu.end_treenode(ctx)
if .SUBMIT in mu.button(ctx, "Button 1") do write_log("Pressed button 1")
if .SUBMIT in mu.button(ctx, "Button 2") do write_log("Pressed button 2")
}
}
if .ACTIVE in mu.begin_treenode(ctx, "Test 2") {
mu.layout_row(ctx, { -1 }, 0)
defer mu.end_treenode(ctx)
if .SUBMIT in mu.button(ctx, "Button 3") do write_log("Pressed button 3")
if .SUBMIT in mu.button(ctx, "Button 4") do write_log("Pressed button 4")
if .SUBMIT in mu.button(ctx, "Button 5") do write_log("Pressed button 5")
if .SUBMIT in mu.button(ctx, "Button 6") do write_log("Pressed button 6")
}
if .ACTIVE in mu.begin_treenode(ctx, "Test 3") {
defer mu.end_treenode(ctx)
@static checks : [3]bool = { true, false, true }
mu.checkbox(ctx, "Checkbox 1", &checks[0])
mu.checkbox(ctx, "Checkbox 2", &checks[1])
mu.checkbox(ctx, "Checkbox 3", &checks[2])
}
}
{
mu.layout_begin_column(ctx)
defer mu.layout_end_column(ctx)
mu.layout_row(ctx, { -1 }, 0)
mu.text(ctx, "Lorem ipsum dolor sit amet, consectetur adipiscing " +
"elit. Maecenas lacinia, sem eu lacinia molestie, mi risus faucibus " +
"ipsum, eu varius magna felis a nulla."
)
}
}
/* background color sliders */
if .ACTIVE in mu.header(ctx, "Background Color", { .EXPANDED }) {
mu.layout_row(ctx, { -78, -1 }, 74)
/* sliders */
mu.layout_begin_column(ctx)
mu.layout_row(ctx, { 46, -1 }, 0)
mu.label(ctx, "Red:")
u8_slider(ctx, &bg[0], 0, 255)
mu.label(ctx, "Green:")
u8_slider(ctx, &bg[1], 0, 255)
mu.label(ctx, "Blue:")
u8_slider(ctx, &bg[2], 0, 255)
mu.layout_end_column(ctx)
/* color preview */
r := mu.layout_next(ctx)
mu.draw_rect(ctx, r, { bg[0], bg[1], bg[2], 255 })
mu.draw_control_text(ctx, fmt.tprintf("#%02X%02X%02X", bg[0], bg[1], bg[2]), r, .TEXT, { .ALIGN_CENTER })
}
}
}
log_window :: proc (ctx : ^mu.Context) {
if mu.begin_window(ctx, "Log Window", mu.Rect{ 350, 40, 300, 200 }) {
defer mu.end_window(ctx)
/* output text panel */
mu.layout_row(ctx, { -1 }, -25)
mu.begin_panel(ctx, "Log Output")
panel := mu.get_current_container(ctx)
mu.layout_row(ctx, { -1 }, -1)
mu.text(ctx, strings.to_string(log_sb))
mu.end_panel(ctx)
if log_updated {
panel.scroll.y = panel.content_size.y
log_updated = false
}
/* input textbox + submit button */
submitted := false
mu.layout_row(ctx, { -70, -1 }, 0)
if .SUBMIT in mu.textbox(ctx, log_input_text, &log_input_text_len) {
mu.set_focus(ctx, ctx.last_id)
submitted = true
}
if .SUBMIT in mu.button(ctx, "Submit") {
submitted = true
}
if submitted == true {
write_log(string(log_input_text[:log_input_text_len]))
log_input_text_len = 0
}
}
}
write_log :: proc(text: string) {
if strings.builder_len(log_sb) != 0 {
// Append newline if log isn't empty
fmt.sbprint(&log_sb, "\n")
}
fmt.sbprint(&log_sb, text)
log_updated = true
}
u8_slider :: proc(ctx: ^mu.Context, value: ^u8, low, high: int) -> mu.Result_Set {
mu.push_id_uintptr(ctx, transmute(uintptr)value)
defer mu.pop_id(ctx)
@(static) tmp: f32
tmp = f32(value^)
res := mu.slider(ctx, &tmp, f32(low), f32(high), 0, "%.f", { .ALIGN_CENTER })
value ^= u8(tmp)
return res
}
// paste me into your raylib project!
package rlmu
import "core:fmt"
import "core:unicode/utf8"
import "core:strings"
import mu "vendor:microui"
import rl "vendor:raylib"
@(private) Color32 :: [4]u8
RLMU_State :: struct {
atlas : rl.Texture2D,
ctx : mu.Context,
}
global_state := new(RLMU_State)
init :: proc(state := global_state) {
// Allocate an array of 32 bit pixel colors for the atlas used by mui
// An atlas is a big image packed with multiple smaller images. Like a sprite sheet!
pixels := make([]Color32, mu.DEFAULT_ATLAS_WIDTH * mu.DEFAULT_ATLAS_HEIGHT)
// mui has its default atlas already "baked" into a pixel array like ours
// However it only stores the alpha of each pixel since there are not colors in the atlas
// We can just copy the alpha values into the alpha channel of our pixels array, leaving the rgb as white
for alpha, i in mu.default_atlas_alpha do pixels[i] = {255, 255, 255, alpha}
// Create a raylib Image whose data is the pixels we just allocated
image := rl.Image {
data = raw_data(pixels),
width = mu.DEFAULT_ATLAS_WIDTH,
height = mu.DEFAULT_ATLAS_HEIGHT,
format = .UNCOMPRESSED_R8G8B8A8,
mipmaps = 1,
}
// The image we just created lives on the CPU side of things
// If we want to actually draw stuff on the GPU we need to create a *Texture*
// This will actually send the pixel data to the GPU so it can be rendered
state.atlas = rl.LoadTextureFromImage(image)
// Now that the pixel data is stored in video memory, we can delete the pixel array in system memory
delete(pixels)
image = {}
// Initialize mui with our mui context
mu.init(ctx = &state.ctx,)
// and point the text_width/height callback functions of our context to the default ones since we're using the default atlas
state.ctx.text_width = mu.default_atlas_text_width
state.ctx.text_height = mu.default_atlas_text_height
// These variables are actually pointers to `(font: Font, str: string) -> i32` functions,
// which mui uses to calculate the pixel width/height of a string when rendered with a certain font
// Let's also assign the clipboard callbacks for copy/paste support. We have our own functions for handling these events.
state.ctx.textbox_state.set_clipboard = set_clipboard
state.ctx.textbox_state.get_clipboard = get_clipboard
}
destroy :: proc(state := global_state) {
// Free atlas texture
rl.UnloadTexture(state.atlas)
}
begin :: proc(state : ^RLMU_State = global_state) -> ^mu.Context {
// Forward all input from raylib to mui
// We need to tell mui what keys are pressed, where the mouse is etc so the ui can react accordingly
forward_text_input(state)
forward_mouse_input(state)
forward_keyboard_input(state)
// Now we can tell mui that we're ready to tell it what UI we want to draw
mu.begin(&state.ctx)
return &state.ctx
}
end :: proc(state := global_state) {
// Tell mui that we're done drawing ui
mu.end(&state.ctx)
// We've "declared" the ui, but it's just a list of commands
// Now we need to render the UI
// mui transforms our high-level UI calls into a list of primitive render commands: text, rectangle, icon, clip (masking)
current_command: ^mu.Command
for cmd_variant in mu.next_command_iterator(&state.ctx, &current_command) {
#partial switch cmd in cmd_variant {
// Draw a (single-line) string
case ^mu.Command_Text:
// This is the top-left position of the first character in the string we need to draw
// We will move it to the right as we draw each character
draw_position := [2]i32{cmd.pos.x, cmd.pos.y}
// Loop over each character in the text. This "do if" condition ensures that we only process single-byte ASCII characters.
for char in cmd.str do if !is_utf8_continuation_byte(char) {
// We need to convert the UTF8 character to an plain ASCII integer so that we can use it to index
// into the mui default atlas.
ascii := char_to_ascii(char)
// "mu.default_atlas" is an array of rects for every character and icon in the default atlas texture
// "mu.DEFAULT_ATLAS_FONT" stores the index of the atlas rect for the first ASCII character texture
// By adding our ascii int to the base index we can get the rect for our char's texture
rect := mu.default_atlas[mu.DEFAULT_ATLAS_FONT + ascii]
// Now that we have the atlas rect for the current char we can draw it to the screen
draw_mu_atlas_texture(state.atlas, rect, draw_position, cmd.color)
// Finally, we need to offset our draw position before drawing the next char
draw_position.x += rect.w
}
// Draw a rectangle
case ^mu.Command_Rect:
rl.DrawRectangle(cmd.rect.x, cmd.rect.y, cmd.rect.w, cmd.rect.h, transmute(rl.Color)cmd.color)
// Draw an icon
case ^mu.Command_Icon:
// cmd.id stores the index into the default_atlas array of rects which we can use to get the icons atlas rect
rect := mu.default_atlas[cmd.id]
x := cmd.rect.x + (cmd.rect.w - rect.w) / 2
y := cmd.rect.y + (cmd.rect.h - rect.h) / 2
draw_mu_atlas_texture(state.atlas, rect, {x, y}, cmd.color)
// Start masking what we draw
case ^mu.Command_Clip:
// End any previous masking
rl.EndScissorMode()
// Begin a mask using the current commands rect
rl.BeginScissorMode(cmd.rect.x, cmd.rect.y, cmd.rect.w, cmd.rect.h)
}
}
// Make sure we end any lingering scissor mode.
// Without this precaution it's possible for clipping to persist into the next frame
// and obstruct stuff until the next clip command from microui is handled!
rl.EndScissorMode()
}
@(deferred_in=destroy)
init_scope :: proc(state := global_state) -> ^mu.Context { init(state); return &state.ctx }
@(deferred_in=end)
begin_scope :: proc(state := global_state) -> ^mu.Context { begin(state); return &state.ctx }
// Sends the current text (typing) input from raylib to mui
@(private)
forward_text_input :: proc(state : ^RLMU_State) {
// Create a buffer to hold UTF8 text input
// UTF8 is a "variable width encoding" so some characters may be 1 byte whereas other may be up to 4
text_input : [512]byte = ---
// This will track the index into the text_input buffer where characters bytes will be copied to
text_input_offset := 0
// This loops reads the characters currently pressed until we've read all the pressed characters or we hit the
// limit of our text input buffer. We'd only hit the limit if someone was holding a LOT of characters
for text_input_offset < len(text_input) {
// Get the pressed UTF8 character rune
// Called multiple times since multiple keys may be pressed
pressed_rune := rl.GetCharPressed()
// If the pressed rune (Unicode character) is 0 there are no more keys pressed
if pressed_rune == 0 do break
// UTF8 characters are stored using "variable width encoding"
// This means a character could be represented by 1-4 bytes
// Encoding the rune into UTF8 always returns 4 bytes, but the count indicates how many runes the character actually is
bytes, count := utf8.encode_rune(pressed_rune)
// We'll copy from the start of the bytes array, up to the number of bytes used to represent the character,
// into our text input buffer at the current text input offset
copy(text_input[text_input_offset:], bytes[:count])
// Finally we can offset the text_input_offset by the number of bytes we copied into the buffer
text_input_offset += count
}
// Now we can send our text_input buffer to mui so it knows the currently pressed characters
// We'll just send a slice of the full text_input buffer, up to the latest input offset since it probably wasn't filled
mu.input_text(&state.ctx, string(text_input[:text_input_offset]))
}
// Sends the current mouse input from raylib to mui
@(private)
forward_mouse_input :: proc(state : ^RLMU_State) {
// Get the current mouse position/scroll from raylib
mouse_position := [2]i32{rl.GetMouseX(), rl.GetMouseY()}
mouse_scroll := rl.GetMouseWheelMove() * -30
// Send the mouse position/scroll to mui
mu.input_mouse_move(&state.ctx, mouse_position.x, mouse_position.y)
mu.input_scroll(&state.ctx, 0, i32(mouse_scroll))
// This struct stores a mapping from a raylib mouse button enum to the equivalent mui enum
MouseButtonMapping :: struct {
rl : rl.MouseButton,
mu : mu.Mouse
}
// We'll create an array of mappings using the struct above
@static
MouseButtonMappings := [?]MouseButtonMapping {
{.LEFT, .LEFT},
{.RIGHT, .RIGHT},
{.MIDDLE, .MIDDLE}
}
// Check each if each mouse button is down or released with Raylib and forward the event to mui if so
for button in MouseButtonMappings {
if rl.IsMouseButtonPressed(button.rl) {
mu.input_mouse_down(&state.ctx, mouse_position.x, mouse_position.y, button.mu)
}
else if rl.IsMouseButtonReleased(button.rl) {
mu.input_mouse_up(&state.ctx, mouse_position.x, mouse_position.y, button.mu)
}
}
}
// Sends the current keyboard input from raylib to mui
// Not quite the same as forward_text_input_to_mui() which sends any pressed *characters*
// whereas this sends specific "modifier" keys like Shift, Enter and Backspace
@(private)
forward_keyboard_input :: proc(state : ^RLMU_State) {
// This struct stores a mapping from a raylib key enum to the equivalent mui enum
KeyMapping :: struct {
rl : rl.KeyboardKey,
mu : mu.Key
}
// We'll create an array of mappings using the struct above
// We don't need to map every raylib key - just the ones mui needs for ui stuff
@static
KeyMappings := [?]KeyMapping {
{.LEFT_SHIFT, .SHIFT},
{.RIGHT_SHIFT, .SHIFT},
{.LEFT_CONTROL, .CTRL},
{.RIGHT_CONTROL, .CTRL},
{.LEFT_ALT, .ALT},
{.RIGHT_ALT, .ALT},
{.ENTER, .RETURN},
{.KP_ENTER, .RETURN},
{.BACKSPACE, .BACKSPACE},
{.DELETE, .DELETE},
{.END, .END},
{.HOME, .HOME},
{.LEFT, .LEFT},
{.RIGHT, .RIGHT},
{.A, .A},
{.C, .C},
{.V, .V},
{.X, .X},
}
for key in KeyMappings {
if rl.IsKeyPressed(key.rl) {
mu.input_key_down(&state.ctx, key.mu)
}
else if rl.IsKeyReleased(key.rl) {
mu.input_key_up(&state.ctx, key.mu)
}
}
}
// Draws a section of the mui atlas to the screen with a tint
// The target_position it draws to will be the top left of the drawn texture
@(private)
draw_mu_atlas_texture :: proc(atlas : rl.Texture2D, atlas_source : mu.Rect, target_position : [2]i32, color : mu.Color) {
// Create a raylib version of the source rect and target position.
// We don't have to do this for the color since its memory layout is identical
rl_target_position := rl.Vector2 { f32(target_position.x), f32(target_position.y) }
rl_atlas_source := rl.Rectangle {
f32(atlas_source.x),
f32(atlas_source.y),
f32(atlas_source.w),
f32(atlas_source.h),
}
// Draw the source rect part of the atlas (sprite) to the screen
// We can transmute the mu.Color to a rl.Color since the memory layout is the same. This is like casting without type-checking
rl.DrawTextureRec(atlas, rl_atlas_source, rl_target_position, transmute(rl.Color)color)
}
@(private)
set_clipboard :: proc(user_data: rawptr, text: string) -> (ok: bool) {
// Try and convert the text string handed to us from mui to a cstring, which raylib expects
ctext, err := strings.clone_to_cstring(text, context.temp_allocator)
if err == .None {
rl.SetClipboardText(ctext)
return true
}
return false
}
@(private)
get_clipboard :: proc(user_data: rawptr) -> (text: string, ok: bool) {
// Try and convert the cstring handed to us from raylib to a string, which mui expects
ctext := rl.GetClipboardText()
if ctext == nil || len(ctext) == 0 {
ok = false
return
}
clipboard_text, err := strings.clone_from_cstring(ctext, context.temp_allocator)
text = clipboard_text
ok = err == .None
return
}
// Checks if the character is a continuation byte in UTF-8 encoding.
// In UTF-8, any byte where the top two bits are 10 (binary 0x80 in hexadecimal) is a continuation byte.
@(private)
is_utf8_continuation_byte :: proc(char : rune) -> bool {
return char & 0xc0 == 0x80
}
@(private)
char_to_ascii :: proc(char : rune) -> int {
return min(int(char), 127)
}
@flebedev77
Copy link

Assertion failed: (window != NULL), function glfwSetWindowAttrib, file window.c, line 931. [1] 59461 abort odin run .

This happens because rl.SetWindowState({ rl.ConfigFlag.WINDOW_RESIZABLE }) is called before rl.InitWindow(). SetWindowState is trying to set the window resizable before the window is created. Just call rl.InitWindow() before rl.SetWindowState({ rl.ConfigFlag.WINDOW_RESIZABLE })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment