From 78a3951d8213469403ed5b6da8b0fda3d59a135f Mon Sep 17 00:00:00 2001 From: wooshings Date: Thu, 22 Oct 2026 18:03:12 -0700 Subject: [PATCH] immediate mode ui --- src/ecs.odin | 14 +- src/systems.odin | 10 ++ ui/rlmu.odin | 323 +++++++++++++++++++++++++++++++++++++++++++++++ ui/shoal.odin | 9 ++ 4 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 ui/rlmu.odin create mode 100644 ui/shoal.odin diff --git a/src/ecs.odin b/src/ecs.odin index a45fe1f..71cb322 100644 --- a/src/ecs.odin +++ b/src/ecs.odin @@ -1,11 +1,12 @@ package shoal +import ui "../ui" import "core:strings" +import "core:thread" +import ml "vendor:microui" import rl "vendor:raylib" -STARTUP :: 0 -UPDATE :: 1 -DRAW :: 2 +update_pool: thread.Pool @(private) sparse_entity_limit: int @@ -36,6 +37,7 @@ add_plugin :: proc(app: ^App, plugin: proc(app: ^App)) { // run the app run :: proc(app: ^App, entity_limit: int = 1000) { sparse_entity_limit = entity_limit + for p in app.plugins { p(app) } @@ -43,14 +45,16 @@ run :: proc(app: ^App, entity_limit: int = 1000) { window, _ := single(&Window) rl.InitWindow(window.width, window.height, strings.clone_to_cstring(window.title)) + ui.ui_ctx = ui.init_scope() defer rl.CloseWindow() rl.SetTargetFPS(60) for !rl.WindowShouldClose() { + defer free_all(context.temp_allocator) for i in app.systems[UPDATE] {i()} - rl.BeginDrawing() + rl.BeginDrawing(); defer rl.EndDrawing() + ui.begin_scope() rl.ClearBackground(window.background) for i in app.systems[DRAW] {i()} - rl.EndDrawing() } } diff --git a/src/systems.odin b/src/systems.odin index 4f175fe..a80a323 100644 --- a/src/systems.odin +++ b/src/systems.odin @@ -6,6 +6,7 @@ DefaultPlugin :: proc(app: ^App) { add_system(app, DRAW, draw_sprite) add_system(app, UPDATE, signal_system) add_system(app, UPDATE, run_timers) + add_system(app, UPDATE, update_cursor_position) display := spawn_entity(app) add_component( @@ -13,6 +14,15 @@ DefaultPlugin :: proc(app: ^App) { &Window, WindowComponent{width = 800, height = 600, title = "shoal window", background = rl.BLACK}, ) + + cursor := spawn_entity(app) + add_component(cursor, &Cursor) +} + +@(private) +update_cursor_position :: proc() { + cursor, _ := single(&Cursor) + cursor.position = rl.GetMousePosition() } @(private) diff --git a/ui/rlmu.odin b/ui/rlmu.odin new file mode 100644 index 0000000..e3f8a31 --- /dev/null +++ b/ui/rlmu.odin @@ -0,0 +1,323 @@ +package ui + +import "base:runtime" +import "core:fmt" +import "core:strings" +import "core:unicode/utf8" +import mu "vendor:microui" +import rl "vendor:raylib" + +@(private) +Color32 :: [4]u8 + +RLMU_State :: struct { + atlas: rl.Texture2D, + ctx: mu.Context, +} + +global_state: ^RLMU_State +@(init) +rlmu_init :: proc "contextless" () { + context = runtime.default_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, ¤t_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) +} diff --git a/ui/shoal.odin b/ui/shoal.odin new file mode 100644 index 0000000..9a631f3 --- /dev/null +++ b/ui/shoal.odin @@ -0,0 +1,9 @@ +package ui + +import ml "vendor:microui" + +ui_ctx: ^ml.Context + +get_ctx :: proc() -> ^ml.Context { + return ui_ctx +}