aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAlec Goncharow <alec@goncharow.dev>2026-05-27 14:34:32 -0400
committerAlec Goncharow <alec@goncharow.dev>2026-05-27 14:34:32 -0400
commit93ad8a5cdeb8421bcf02195bc470257935062138 (patch)
treebd76666718c27f3c79e015f6c9f7404a38663207 /src
parent13335d5ee6778ad65d49a774274bae84a64a7edf (diff)
Working widget idea, makings of a maze app
Forgot to commit as I went over the weekend, which is fine, not a ton of interesting stuff just yet.
Diffstat (limited to 'src')
-rw-r--r--src/apps/example_app.jai33
-rw-r--r--src/apps/maze_app.jai257
-rw-r--r--src/debug_overlay.jai48
-rw-r--r--src/main.jai207
-rw-r--r--src/menu_app.jai217
-rw-r--r--src/ui_panel.jai252
-rw-r--r--src/widget_interface.jai12
7 files changed, 1026 insertions, 0 deletions
diff --git a/src/apps/example_app.jai b/src/apps/example_app.jai
new file mode 100644
index 0000000..2298fb7
--- /dev/null
+++ b/src/apps/example_app.jai
@@ -0,0 +1,33 @@
+#import "Basic";
+#import "Math";
+
+Example_Data :: struct {
+ timer: float;
+}
+
+init_example_widget :: (widget: *Widget_State) {
+ data := New(Example_Data);
+ data.timer = 0;
+ widget.data = data;
+
+ widget.init = (app: *Widget_State) {};
+
+ widget.update = (widget: *Widget_State, dt: float) {
+ data := cast(*Example_Data) widget.data;
+ data.timer += dt;
+ };
+
+ widget.handle_input = (widget: *Widget_State, occluded: bool) -> (occludes: bool) {return false;};
+
+ widget.render = (widget: *Widget_State) {
+ data := cast(*Example_Data) widget.data;
+
+ // Pulse background color to red to show it's updating and distinct from menu
+ r := 0.5 + 0.5 * sin(data.timer * 2.0);
+ Simp.clear_render_target(r, 0.1, 0.1, 1.0);
+ };
+
+ widget.shutdown = (widget: *Widget_State) {
+ free(widget.data);
+ };
+}
diff --git a/src/apps/maze_app.jai b/src/apps/maze_app.jai
new file mode 100644
index 0000000..b3b588c
--- /dev/null
+++ b/src/apps/maze_app.jai
@@ -0,0 +1,257 @@
+#import "Basic";
+#import "Math";
+#import "String";
+
+Maze_Data :: struct {
+ rows: int;
+ cols: int;
+ grid: [..] bool; // false = empty, true = wall
+
+ // Camera
+ camera_x: float;
+ camera_y: float;
+ zoom: float;
+
+ // Panning State
+ is_panning: bool;
+ last_mouse_x: float;
+ last_mouse_y: float;
+
+ // UI State
+ width_input: [..] u8;
+ height_input: [..] u8;
+
+ font: *Simp.Dynamic_Font;
+ panel: UI_Panel;
+}
+
+init_maze_app :: (widget: *Widget_State) {
+ data := New(Maze_Data);
+ data.rows = 10;
+ data.cols = 10;
+ array_resize(*data.grid, data.rows * data.cols);
+ for 0..data.grid.count-1 { data.grid[it] = false; }
+
+ data.camera_x = cast(float) window_width / 2.0 - (5.0 * 50.0);
+ data.camera_y = cast(float) window_height / 2.0 - (5.0 * 50.0);
+ data.zoom = 50.0; // 50 pixels per cell
+ data.is_panning = false;
+
+ // prefill text inputs
+ // TODO reconsider if the UI element should just manage this directly and parse the given value into backing string buffer
+ str_w := sprint("%", data.cols);
+ defer free(str_w);
+ for 0..str_w.count-1 array_add(*data.width_input, str_w[it]);
+
+ str_h := sprint("%", data.rows);
+ defer free(str_h);
+ for 0..str_h.count-1 array_add(*data.height_input, str_h[it]);
+
+ // Setup UI Panel
+ data.panel.x = 20.0;
+ data.panel.y = cast(float)window_height - 300.0;
+ data.panel.w = 200.0;
+ data.panel.h = 240.0;
+ data.panel.userdata = data; // Give callback access to our data!
+ data.panel.app_owner = widget; // Bind panel visibility to this app!
+
+ array_add(*data.panel.elements, .{type=.HEADER, id=5, x0=0.0, y0=data.panel.h - 40.0, x1=data.panel.w, y1=data.panel.h, label="Controls"});
+
+ // Inputs (from top to bottom, note Simp Y=0 is bottom, so highest Y is top)
+ array_add(*data.panel.elements, .{type=.TEXT_INPUT, id=1, x0=10.0, y0=data.panel.h - 100.0, x1=190.0, y1=data.panel.h - 60.0, label="Width:", input_buf=*data.width_input});
+ array_add(*data.panel.elements, .{type=.TEXT_INPUT, id=2, x0=10.0, y0=data.panel.h - 160.0, x1=190.0, y1=data.panel.h - 120.0, label="Height:", input_buf=*data.height_input});
+
+ // Back button at bottom
+ array_add(*data.panel.elements, .{type=.BUTTON, id=4, x0=10.0, y0=10.0, x1=190.0, y1=50.0, label="Back", action=(userdata: *void) { switch_app(*menu_state); }});
+
+ widget.data = data;
+
+ widget.init = (widget: *Widget_State) {
+ data := cast(*Maze_Data) widget.data;
+ data.panel.font = Simp.get_font_at_size("data", "Anonymous_Pro.ttf", 32);
+ data.font = data.panel.font;
+ ui_register_panel(*data.panel);
+ };
+
+ widget.update = (widget: *Widget_State, dt: float) {
+ data := cast(*Maze_Data) widget.data;
+
+ // maze gen / solving step happens in here maybe?
+ };
+
+ widget.shutdown = (widget: *Widget_State) {
+ data := cast(*Maze_Data) widget.data;
+ ui_unregister_panel(*data.panel);
+ };
+
+ widget.update = (widget: *Widget_State, dt: float) {};
+ widget.handle_input = handle_input;
+ widget.render = render;
+
+ widget.shutdown = (widget: *Widget_State) {
+ data := cast(*Maze_Data) widget.data;
+ array_free(data.grid);
+ array_free(data.width_input);
+ array_free(data.height_input);
+ free(data);
+ };
+}
+
+#scope_file
+
+apply_resize :: (data: *Maze_Data) {
+ w_str: string;
+ w_str.data = data.width_input.data;
+ w_str.count = data.width_input.count;
+
+ h_str: string;
+ h_str.data = data.height_input.data;
+ h_str.count = data.height_input.count;
+
+ new_cols, ok_w := string_to_int(w_str);
+ new_rows, ok_h := string_to_int(h_str);
+
+ if ok_w && ok_h && new_cols > 0 && new_rows > 0 {
+ new_grid: [..] bool;
+ array_resize(*new_grid, new_rows * new_cols);
+ for r: 0..new_rows-1 {
+ for c: 0..new_cols-1 {
+ val := false;
+ if r < data.rows && c < data.cols {
+ val = data.grid[r * data.cols + c];
+ }
+ new_grid[r * new_cols + c] = val;
+ }
+ }
+ array_free(data.grid);
+ data.grid = new_grid;
+ data.rows = new_rows;
+ data.cols = new_cols;
+ }
+}
+
+handle_input :: (widget: *Widget_State, occluded: bool) -> (occludes: bool) {
+ data := cast(*Maze_Data) widget.data;
+
+ mouse_x, mouse_y, success := get_mouse_pointer_position(window, true);
+ if !success return occluded;
+ mouse_x_f := cast(float) mouse_x;
+ mouse_y_f := cast(float) mouse_y;
+
+ space_state := Input.input_button_states[Input.Key_Code.SPACEBAR];
+ space_down := (space_state & Input.Key_Current_State.DOWN) != 0;
+
+ for event: Input.events_this_frame {
+ if event.type == .MOUSE_WHEEL {
+ zoom_factor := 1.0;
+ if event.wheel_delta < 0 {
+ zoom_factor = 0.9;
+ } else if event.wheel_delta > 0 {
+ zoom_factor = 1.1;
+ }
+ if zoom_factor != 1.0 {
+ world_x := (mouse_x_f - data.camera_x) / data.zoom;
+ world_y := (mouse_y_f - data.camera_y) / data.zoom;
+
+ data.zoom *= zoom_factor;
+ if data.zoom < 5.0 data.zoom = 5.0;
+ if data.zoom > 300.0 data.zoom = 300.0;
+
+ data.camera_x = mouse_x_f - world_x * data.zoom;
+ data.camera_y = mouse_y_f - world_y * data.zoom;
+ }
+ }
+
+ // if event.type == .TEXT_INPUT && data.panel.focus != 0 {
+ // print("we are text inputting into panel %", data.panel.focus);
+ // value := event.utf32;
+ // if value >= #char "0" && value <= #char "9" {
+ // if data.panel.focus == 1 array_add(*data.width_input, cast(u8)value);
+ // if data.panel.focus == 2 array_add(*data.height_input, cast(u8)value);
+ // }
+ // }
+
+ if event.type == .KEYBOARD && event.key_pressed {
+ if event.key_code == .ENTER {
+ if data.panel.focus != 0 {
+ apply_resize(data);
+ data.panel.focus = 0;
+ }
+ }
+
+ // Mouse clicks
+ if event.key_code == .MOUSE_BUTTON_LEFT {
+ if !occluded {
+ // data.panel.focus = 0;
+
+ // Clicked on grid (only if not panning with spacebar)
+ if !space_down {
+ world_x := (mouse_x_f - data.camera_x) / data.zoom;
+ world_y := (mouse_y_f - data.camera_y) / data.zoom;
+ col := cast(int) world_x;
+ row := cast(int) world_y;
+
+ // Check if within bounds
+ if col >= 0 && col < data.cols && row >= 0 && row < data.rows {
+ data.grid[row * data.cols + col] = !data.grid[row * data.cols + col];
+ }
+ }
+ }
+ }
+ }
+ }
+
+ mouse_down := (Input.input_button_states[Input.Key_Code.MOUSE_BUTTON_LEFT] & Input.Key_Current_State.DOWN) != 0;
+
+ // Panning logic
+ if space_down && mouse_down {
+ if !data.is_panning {
+ data.is_panning = true;
+ data.last_mouse_x = mouse_x_f;
+ data.last_mouse_y = mouse_y_f;
+ } else {
+ dx := mouse_x_f - data.last_mouse_x;
+ dy := mouse_y_f - data.last_mouse_y;
+ data.camera_x += dx;
+ data.camera_y += dy;
+ data.last_mouse_x = mouse_x_f;
+ data.last_mouse_y = mouse_y_f;
+ }
+ } else {
+ data.is_panning = false;
+ }
+
+ return false;
+}
+
+render :: (widget: *Widget_State) {
+ data := cast(*Maze_Data) widget.data;
+
+ Simp.clear_render_target(0.1, 0.1, 0.1, 1.0);
+ Simp.set_shader_for_color(true);
+ Simp.immediate_begin();
+ Simp.immediate_set_2d_projection(window_width, window_height);
+
+
+ // Draw cells
+ for r: 0..data.rows-1 {
+ for c: 0..data.cols-1 {
+ x0 := data.camera_x + cast(float) c * data.zoom;
+ y0 := data.camera_y + cast(float) r * data.zoom;
+
+ cx0 := x0 + 1.0;
+ cy0 := y0 + 1.0;
+ cx1 := x0 + data.zoom - 1.0;
+ cy1 := y0 + data.zoom - 1.0;
+
+ // Culling optimization
+ if cx1 < 0 || cx0 > cast(float)window_width || cy1 < 0 || cy0 > cast(float)window_height continue;
+
+ is_wall := data.grid[r * data.cols + c];
+ color := ifx is_wall then Vector4.{0.8, 0.2, 0.2, 1.0} else Vector4.{0.6, 0.6, 0.6, 1.0};
+ Simp.immediate_quad(cx0, cy0, cx1, cy1, color);
+ }
+ }
+
+ // UI_Panel drawing is entirely handled by the global Window Manager in main.jai!
+}
diff --git a/src/debug_overlay.jai b/src/debug_overlay.jai
new file mode 100644
index 0000000..90adc87
--- /dev/null
+++ b/src/debug_overlay.jai
@@ -0,0 +1,48 @@
+// Loaded into main.jai so it inherits global imports.
+
+#scope_file
+debug_lines: [..] string;
+debug_panel: UI_Panel;
+panel_initialized := false;
+
+#scope_export
+
+debug_add_value :: (fmt: string, args: .. Any) {
+ builder: String_Builder;
+ print_to_builder(*builder, fmt, ..args);
+ array_add(*debug_lines, builder_to_string(*builder));
+}
+
+render_debug_overlay :: (font: *Simp.Dynamic_Font) {
+ if !font return;
+
+ if !panel_initialized {
+ debug_panel.x = cast(float)window_width - 800.0;
+ debug_panel.y = cast(float)window_height - 40.0;
+ debug_panel.w = 300.0;
+ debug_panel.h = 40.0;
+ debug_panel.font = font;
+ ui_register_panel(*debug_panel);
+ panel_initialized = true;
+ }
+
+ target_h := 50.0 + debug_lines.count * 32.0;
+ top := debug_panel.y + debug_panel.h;
+ debug_panel.h = target_h;
+ debug_panel.y = top - debug_panel.h;
+
+ array_reset_keeping_memory(*debug_panel.elements);
+
+ array_add(*debug_panel.elements, .{type=.HEADER, id=0, x0=0.0, y0=debug_panel.h - 40.0, x1=debug_panel.w, y1=debug_panel.h, label="Debug"});
+
+ y := debug_panel.h - 40.0;
+ for debug_lines {
+ y -= 32.0;
+ array_add(*debug_panel.elements, .{type=.DYNAMIC_TEXT, id=0, x0=10.0, y0=y, x1=debug_panel.w, y1=y+32.0, label=it});
+ }
+}
+
+clear_debug_overlay :: () {
+ for debug_lines free(it);
+ array_reset_keeping_memory(*debug_lines);
+}
diff --git a/src/main.jai b/src/main.jai
new file mode 100644
index 0000000..897cfb7
--- /dev/null
+++ b/src/main.jai
@@ -0,0 +1,207 @@
+#import "Basic";
+#import "Window_Creation";
+Simp :: #import "Simp";
+Input :: #import "Input";
+Unicode :: #import "Unicode";
+
+#load "widget_interface.jai";
+#load "menu_app.jai";
+#load "apps/example_app.jai";
+#load "apps/maze_app.jai";
+#load "ui_panel.jai";
+#load "debug_overlay.jai";
+
+// Global reference to active app state
+active_app: *Widget_State;
+
+// The different apps available
+menu_state: Widget_State;
+example_state: Widget_State;
+maze_state: Widget_State;
+
+window: Window_Type;
+window_width : s32 = 1920;
+window_height : s32 = 1080;
+
+// silly
+FRAMES :: 32_768;
+frame_times : [FRAMES] float;
+frame_times_sum: float;
+frame_times_cursor: int;
+
+FONT_HEIGHT :: 32;
+
+switch_app :: (new_app: *Widget_State) {
+ active_app = new_app;
+}
+
+
+main :: () {
+ window = create_window(window_width, window_height, "Jai Sandbox");
+ Simp.set_render_target(window);
+ // Initialize all apps
+ init_menu_app(*menu_state);
+ init_example_widget(*example_state);
+ init_maze_app(*maze_state);
+
+ text_edit: [..] u8;
+ held_keys: String_Builder;
+
+ if menu_state.init then menu_state.init(*menu_state);
+ if example_state.init then example_state.init(*example_state);
+ if maze_state.init then maze_state.init(*maze_state);
+
+ the_font := (cast(*Menu_Data) menu_state.data).font;
+ active_app = *menu_state;
+
+ quit := false;
+ last_time := seconds_since_init();
+
+ sub_frames := false;
+
+ while !quit {
+ // FIXME lazy FPS cap
+ sleep_milliseconds(10);
+
+ Input.update_window_events();
+
+ for Input.get_window_resizes() {
+ Simp.update_window(it.window);
+ if it.window == window {
+
+ window_width = it.width;
+ window_height = it.height;
+ }
+ }
+
+ for Input.events_this_frame {
+ if it.type == {
+ case .QUIT;
+ quit = true;
+
+ case .KEYBOARD;
+ if it.key_pressed == 0 continue;
+
+ // If escape is pressed, toggle back to menu
+ if it.key_code == .ESCAPE {
+ if active_app != *menu_state {
+ switch_app(*menu_state);
+ } else {
+ quit = true; // Escape on menu quits
+ }
+ }
+
+ if it.key_pressed && it.key_code == .BACKSPACE {
+ // @FixMe this works fine for English characters since most of that character set
+ // is one-byte in UTF8, but will not work correctly for many other language characters.
+ if text_edit.count then pop(*text_edit);
+ }
+
+ case .TEXT_INPUT;
+ value := it.utf32;
+
+ str := Unicode.character_utf32_to_utf8(value);
+ for 0..str.count-1 {
+ array_add(*text_edit, str[it]);
+ }
+ free(str);
+ }
+ }
+
+ for Input.input_button_states {
+ if it & .DOWN {
+ if it_index >= 0x20 && it_index < 0x7F {
+ value: u8 = cast(u8) it_index;
+ str: string;
+ str.data = *value;
+ str.count = 1;
+ append(*held_keys, str);
+ } else {
+ print_to_builder(*held_keys, "% ", cast(Input.Key_Code) it_index);
+ }
+ }
+ }
+
+ held_keys_text := builder_to_string(*held_keys);
+ defer free(held_keys_text);
+
+ now := seconds_since_init();
+ dt := cast(float)(now - last_time);
+ last_time = now;
+
+ if sub_frames {
+ frame_times_sum -= frame_times[frame_times_cursor];
+ }
+ frame_times_sum += dt;
+ frame_times[frame_times_cursor] = dt;
+ frame_times_cursor += 1;
+
+ if frame_times_cursor >= FRAMES {
+ sub_frames = true;
+ frame_times_cursor = 0;
+ }
+
+ fps := 0.0;
+ if sub_frames {
+ fps = FRAMES / frame_times_sum;
+ } else {
+ fps = frame_times_cursor / frame_times_sum;
+ }
+
+ text_edit_text: string;
+ text_edit_text.data = text_edit.data;
+ text_edit_text.count = text_edit.count;
+
+ // Update active app
+ if active_app && active_app.update {
+ active_app.update(active_app, dt);
+ }
+
+ ui_occludes := ui_handle_input(active_app);
+ debug_add_value("UI occludes: %", ui_occludes);
+ active_app.handle_input(active_app, ui_occludes);
+
+ // Render active app
+ if active_app && active_app.render {
+ active_app.render(active_app);
+ }
+
+ mouse_x, mouse_y, _ := get_mouse_pointer_position(window, true);
+
+ // overlay
+ debug_add_value("FPS: %", fps);
+ debug_add_value("frame_times_sum: %", frame_times_sum);
+ debug_add_value("frame_times_cursor: %", frame_times_cursor);
+ debug_add_value("Window Size: %, %", window_width, window_height);
+ debug_add_value("Text Input: %", text_edit_text);
+ debug_add_value("Held keys: %", held_keys_text);
+ debug_add_value("mouse_pos: %, %", mouse_x, mouse_y);
+
+ // Set up debug overlay elements
+ render_debug_overlay(the_font);
+
+ // Render global UI panels (on top of active app)
+ ui_render(active_app);
+ clear_debug_overlay();
+
+ finish_frame();
+ }
+
+ if menu_state.shutdown then menu_state.shutdown(*menu_state);
+ if example_state.shutdown then example_state.shutdown(*example_state);
+}
+
+draw_text :: (font: *Simp.Dynamic_Font, color: Vector4, x: int, y: int, fmt: string, args: .. Any) {
+ assert(font != null);
+ builder: String_Builder;
+
+ print_to_builder(*builder, fmt, ..args);
+ text := builder_to_string(*builder);
+ defer free(text);
+
+ Simp.draw_text(font, x, y, text, color);
+}
+
+finish_frame :: () {
+ Simp.swap_buffers(window);
+}
diff --git a/src/menu_app.jai b/src/menu_app.jai
new file mode 100644
index 0000000..1570a1f
--- /dev/null
+++ b/src/menu_app.jai
@@ -0,0 +1,217 @@
+#import "Basic";
+#import "Math";
+
+Button :: struct {
+ x0, y0, x1, y1: float;
+ text: string;
+ alpha: float = 0.4;
+ scale: float = 1.0;
+}
+
+Menu_Data :: struct {
+ selection: int;
+ font: *Simp.Dynamic_Font;
+ buttons: [..] Button;
+}
+
+init_menu_app :: (widget: *Widget_State) {
+ data := New(Menu_Data);
+ data.selection = 0;
+ data.font = null;
+
+ // Define the buttons up front
+ array_add(*data.buttons, .{ 100.0, 500.0, 400.0, 600.0, "Example widget", 0.4, 1.0 });
+ array_add(*data.buttons, .{ 100.0, 350.0, 400.0, 450.0, "Maze App", 0.4, 1.0 });
+ array_add(*data.buttons, .{ 100.0, 200.0, 400.0, 300.0, "Quit", 0.4, 1.0 });
+
+ widget.data = data;
+
+ widget.init = (widget: *Widget_State) {
+ data := cast(*Menu_Data) widget.data;
+ // Load the font
+ data.font = Simp.get_font_at_size("data", "Anonymous_Pro.ttf", 48);
+
+ // Dynamically resize our buttons so they always fit their text!
+ if data.font {
+ for * data.buttons {
+ text_width := Simp.prepare_text(data.font, it.text);
+ center_x := (it.x0 + it.x1) / 2.0;
+
+ // Ensure the button is at least as wide as the text + 60px of padding
+ needed_width := cast(float)text_width + 60.0;
+ actual_width := max(it.x1 - it.x0, needed_width);
+
+ it.x0 = center_x - actual_width / 2.0;
+ it.x1 = center_x + actual_width / 2.0;
+ }
+ }
+ };
+
+ widget.update = update;
+ widget.handle_input = handle_input;
+ widget.render = render;
+
+ widget.shutdown = (widget: *Widget_State) {
+ data := cast(*Menu_Data) widget.data;
+ array_free(data.buttons);
+ free(data);
+ };
+}
+
+#scope_file
+
+time_selected := 0.0;
+
+update :: (widget: *Widget_State, dt: float) {
+ data := cast(*Menu_Data) widget.data;
+ time_selected += dt;
+ debug_add_value("time_selected: %", time_selected);
+
+ for * data.buttons {
+ // 1. Set our targets
+ target_scale := ifx data.selection == it_index then 1.05 else 1.0;
+
+ target_alpha: float;
+ if data.selection == it_index {
+ // make button GROW
+ target_alpha = 1.0;
+ target_scale = target_scale + (sin(cast(float)time_selected * 2.0) * 0.03);
+ // STOP GROW
+ if time_selected > (3.14 / 4.0) {
+ target_scale = 1.08;
+ }
+ } else {
+ // Dimmer when unselected
+ target_alpha = 0.4;
+ }
+
+ // 2. Smoothly lerp current values towards the targets using exponential decay
+ it.scale = it.scale + (target_scale - it.scale) * (15.0 * dt);
+ it.alpha = it.alpha + (target_alpha - it.alpha) * (15.0 * dt);
+ }
+}
+
+handle_input :: (widget: *Widget_State, occluded: bool) -> (occludes: bool) {
+ data := cast(*Menu_Data) widget.data;
+ start_select := data.selection;
+
+ occludes := false;
+ if !occluded {
+ // Query the mouse position! By passing true for right_handed,
+ // Y=0 is at the bottom, which perfectly matches our rendering projection.
+ mouse_x, mouse_y, success := get_mouse_pointer_position(window, true);
+ if success {
+ mouse_x_f := cast(float) mouse_x;
+ mouse_y_f := cast(float) mouse_y;
+ for data.buttons {
+ if mouse_x_f >= it.x0 && mouse_x_f <= it.x1 && mouse_y_f >= it.y0 && mouse_y_f <= it.y1 {
+ occludes = true;
+ data.selection = it_index;
+ }
+ }
+ }
+ if Input.mouse_delta_x + Input.mouse_delta_y + Input.mouse_delta_z != 0 {
+ if !occludes {
+ data.selection = -1; // Deselect if moving mouse outside
+ }
+ }
+ } else {
+ data.selection = -1;
+ }
+
+ for event: Input.events_this_frame {
+ if event.type == .KEYBOARD && event.key_pressed {
+ if event.key_code == .ARROW_UP {
+ if data.selection <= 0 {
+ data.selection = data.buttons.count - 1;
+ } else {
+ data.selection -= 1;
+ }
+ }
+ if event.key_code == .ARROW_DOWN {
+ data.selection += 1;
+ if data.selection >= data.buttons.count then data.selection = 0;
+ }
+ if event.key_code == .ENTER || event.key_code == .MOUSE_BUTTON_LEFT {
+ // Execute button action
+ if data.selection == 0 {
+ switch_app(*example_state);
+ } else if data.selection == 1 {
+ switch_app(*maze_state);
+ } else if data.selection == 2 {
+ exit(0);
+ }
+ }
+ }
+ }
+
+ if start_select != data.selection {
+ time_selected = 0;
+ }
+
+ return occludes;
+}
+
+render :: (widget: *Widget_State) {
+ data := cast(*Menu_Data) widget.data;
+ // Clear to a blueish color to represent the menu
+ Simp.clear_render_target(0.2, 0.3, 0.4, 1.0);
+
+ Simp.set_shader_for_color(true);
+ Simp.immediate_begin();
+ Simp.immediate_set_2d_projection(window_width, window_height);
+
+ // Iterate through and draw all buttons dynamically based on their struct definition
+ for data.buttons {
+ color := ifx data.selection == it_index then Vector4.{0.8, 0.8, 0.2, it.alpha} else Vector4.{0.5, 0.5, 0.5, it.alpha};
+
+ // Scale the button from its center
+ center_x := (it.x0 + it.x1) / 2.0;
+ center_y := (it.y0 + it.y1) / 2.0;
+ width := it.x1 - it.x0;
+ height := it.y1 - it.y0;
+
+ draw_x0 := center_x - (width / 2.0) * it.scale;
+ draw_x1 := center_x + (width / 2.0) * it.scale;
+ draw_y0 := center_y - (height / 2.0) * it.scale;
+ draw_y1 := center_y + (height / 2.0) * it.scale;
+
+ Simp.immediate_quad(draw_x0, draw_y0, draw_x1, draw_y1, color);
+ }
+
+ Simp.immediate_flush();
+
+ // Draw text on the buttons (perfectly centered!)
+ if data.font {
+ for data.buttons {
+ text_width := Simp.prepare_text(data.font, it.text);
+
+ // Calculate the dead-center of the unscaled button
+ center_x := (it.x0 + it.x1) / 2.0;
+ center_y := (it.y0 + it.y1) / 2.0;
+
+ // Calculate text placement (subtract half text width/height)
+ // Note: Font size is 48, so we subtract ~24 for vertical centering
+ text_x := center_x - (cast(float)text_width / 2.0);
+ text_y := center_y - 24.0 + 8.0; // 8.0 is a minor visual baseline tweak
+
+ // 1. Generate the unscaled quads in world space
+ Simp.generate_quads_for_prepared_text(data.font, cast(int)text_x, cast(int)text_y, 0);
+
+ // 2. Mathematically scale every quad outward from the button's center!
+ for * quad: data.font.current_quads {
+ quad.p0.x = center_x + (quad.p0.x - center_x) * it.scale;
+ quad.p0.y = center_y + (quad.p0.y - center_y) * it.scale;
+ quad.p1.x = center_x + (quad.p1.x - center_x) * it.scale;
+ quad.p1.y = center_y + (quad.p1.y - center_y) * it.scale;
+ quad.p2.x = center_x + (quad.p2.x - center_x) * it.scale;
+ quad.p2.y = center_y + (quad.p2.y - center_y) * it.scale;
+ quad.p3.x = center_x + (quad.p3.x - center_x) * it.scale;
+ quad.p3.y = center_y + (quad.p3.y - center_y) * it.scale;
+ }
+
+ // 3. Draw the newly scaled quads
+ Simp.draw_generated_quads(data.font, .{0, 0, 0, it.alpha});
+ }
+ }
+}
diff --git a/src/ui_panel.jai b/src/ui_panel.jai
new file mode 100644
index 0000000..cc0a2e2
--- /dev/null
+++ b/src/ui_panel.jai
@@ -0,0 +1,252 @@
+// UI_Panel and UI_Element are loaded into main.jai so they inherit the global imports.
+
+UI_Element_Type :: enum { HEADER; BUTTON; TEXT_INPUT; DYNAMIC_TEXT; }
+
+UI_Element :: struct {
+ type: UI_Element_Type;
+ id: int;
+
+ x0, y0, x1, y1: float;
+ label: string;
+ input_buf: *[..] u8;
+
+ action: (userdata: *void) = null;
+}
+
+UI_Panel :: struct {
+ x, y, w, h: float;
+
+ is_dragging: bool;
+ drag_offset_x: float;
+ drag_offset_y: float;
+
+ is_collapsed: bool;
+ focus: int;
+ elements: [..] UI_Element;
+
+ app_owner: *void; // If non-null, panel is only visible when this app is active
+ userdata: *void;
+ font: *Simp.Dynamic_Font;
+}
+
+#scope_file
+active_panels: [..] *UI_Panel;
+text_edit: *[..] u8;
+
+#scope_export
+ui_register_panel :: (panel: *UI_Panel) {
+ array_add(*active_panels, panel);
+}
+
+ui_unregister_panel :: (panel: *UI_Panel) {
+ array_ordered_remove_by_value(*active_panels, panel);
+}
+
+ui_bring_to_front :: (panel: *UI_Panel) {
+ array_ordered_remove_by_value(*active_panels, panel);
+ array_add(*active_panels, panel);
+}
+
+ui_handle_input :: (active_app: *void) -> (occludes: bool) {
+ occludes := false;
+ mouse_x, mouse_y, success := get_mouse_pointer_position(window, true);
+ if !success return false;
+ mouse_x_f := cast(float) mouse_x;
+ mouse_y_f := cast(float) mouse_y;
+
+ mouse_down := (Input.input_button_states[Input.Key_Code.MOUSE_BUTTON_LEFT] & Input.Key_Current_State.DOWN) != 0;
+
+ mouse_btn := false;
+ mouse_released := false;
+ dirty_text := false;
+ for Input.events_this_frame {
+
+ if it.type == {
+ case .KEYBOARD;
+
+ if it.key_pressed && it.key_code == .BACKSPACE {
+
+ if text_edit {
+ dirty_text = true;
+ // @FixMe this works fine for English characters since most of that character set
+ // is one-byte in UTF8, but will not work correctly for many other language characters.
+ if text_edit.count then pop(text_edit);
+ }
+ }
+
+ if it.key_code == .MOUSE_BUTTON_LEFT {
+ mouse_btn = true;
+ mouse_released = it.key_pressed == 0;
+ }
+
+ case .TEXT_INPUT;
+ if text_edit {
+ dirty_text = true;
+ value := it.utf32;
+
+ str := Unicode.character_utf32_to_utf8(value);
+ for 0..str.count-1 {
+ array_add(text_edit, str[it]);
+ }
+ free(str);
+ }
+ }
+ }
+
+ // Process dragging
+ for panel: active_panels {
+ if panel.app_owner != null && panel.app_owner != active_app continue;
+
+ if panel.is_dragging {
+ if mouse_down {
+ panel.x = mouse_x_f - panel.drag_offset_x;
+ panel.y = mouse_y_f - panel.drag_offset_y;
+ return true;
+ } else {
+ panel.is_dragging = false;
+ }
+ }
+ }
+
+ // Hit test from top to bottom (end of array to start)
+ for < panel: active_panels {
+ if panel.app_owner != null && panel.app_owner != active_app continue;
+
+ // if text input append to builder and rebuild string
+
+ hit_panel := false;
+ if panel.is_collapsed {
+ for elem: panel.elements {
+ if elem.type == .HEADER {
+ abs_x0 := panel.x + elem.x0;
+ abs_y0 := panel.y + elem.y0;
+ abs_x1 := panel.x + elem.x1;
+ abs_y1 := panel.y + elem.y1;
+ if mouse_x_f >= abs_x0 && mouse_x_f <= abs_x1 && mouse_y_f >= abs_y0 && mouse_y_f <= abs_y1 {
+ hit_panel = true;
+ }
+ break;
+ }
+ }
+ } else {
+ if mouse_x_f >= panel.x && mouse_x_f <= panel.x + panel.w &&
+ mouse_y_f >= panel.y && mouse_y_f <= panel.y + panel.h {
+ hit_panel = true;
+ }
+ }
+
+ if hit_panel {
+ if mouse_btn {
+ ui_bring_to_front(panel);
+
+ hit_element := false;
+
+ for * elem: panel.elements {
+ if panel.is_collapsed && elem.type != .HEADER continue;
+
+ abs_x0 := panel.x + elem.x0;
+ abs_y0 := panel.y + elem.y0;
+ abs_x1 := panel.x + elem.x1;
+ abs_y1 := panel.y + elem.y1;
+
+ if mouse_x_f >= abs_x0 && mouse_x_f <= abs_x1 && mouse_y_f >= abs_y0 && mouse_y_f <= abs_y1 {
+ if elem.type == .HEADER {
+ if mouse_x_f >= abs_x1 - 30.0 && mouse_released {
+ panel.is_collapsed = !panel.is_collapsed;
+ } else if !mouse_released {
+ panel.is_dragging = true;
+ panel.drag_offset_x = mouse_x_f - panel.x;
+ panel.drag_offset_y = mouse_y_f - panel.y;
+ }
+ } else if elem.type == .TEXT_INPUT {
+ panel.focus = elem.id;
+ text_edit = elem.input_buf;
+ } else if elem.type == .BUTTON && mouse_released {
+ if elem.action elem.action(panel.userdata);
+ }
+ hit_element = true;
+
+ break;
+ }
+ }
+
+
+ if !hit_element {
+ panel.focus = 0;
+ text_edit = null;
+ }
+ }
+ return true;
+ }
+ }
+ return false;
+}
+
+ui_render :: (active_app: *void) {
+ mouse_x, mouse_y, _ := get_mouse_pointer_position(window, true);
+ mouse_x_f := cast(float) mouse_x;
+ mouse_y_f := cast(float) mouse_y;
+
+ Simp.set_shader_for_color(true);
+ Simp.immediate_begin();
+ Simp.immediate_set_2d_projection(window_width, window_height);
+
+ // Draw from bottom to top
+ for panel: active_panels {
+ if panel.app_owner != null && panel.app_owner != active_app continue;
+
+ if !panel.is_collapsed {
+ // Draw Panel Body Background
+ Simp.immediate_quad(panel.x, panel.y, panel.x + panel.w, panel.y + panel.h, .{0.15, 0.15, 0.15, 0.95});
+ }
+
+
+ for elem: panel.elements {
+ if panel.is_collapsed && elem.type != .HEADER continue;
+
+ abs_x0 := panel.x + elem.x0;
+ abs_y0 := panel.y + elem.y0;
+ abs_x1 := panel.x + elem.x1;
+ abs_y1 := panel.y + elem.y1;
+
+ is_hovered := mouse_x_f >= abs_x0 && mouse_x_f <= abs_x1 && mouse_y_f >= abs_y0 && mouse_y_f <= abs_y1;
+
+ if elem.type == .HEADER {
+ color := ifx is_hovered then Vector4.{0.3, 0.3, 0.3, 1.0} else Vector4.{0.2, 0.2, 0.2, 1.0};
+ Simp.immediate_quad(abs_x0, abs_y0, abs_x1, abs_y1, color);
+
+ // Collapse button
+ btn_color := ifx (is_hovered && mouse_x_f >= abs_x1 - 30.0) then Vector4.{0.8, 0.3, 0.3, 1.0} else Vector4.{0.4, 0.4, 0.4, 1.0};
+ Simp.immediate_quad(abs_x1 - 30.0, abs_y0 + 5.0, abs_x1 - 5.0, abs_y1 - 5.0, btn_color);
+
+ if panel.font {
+ Simp.draw_text(panel.font, cast(int)abs_x0 + 10, cast(int)abs_y0 + 6, elem.label, .{1,1,1,1});
+ Simp.draw_text(panel.font, cast(int)abs_x1 - 25, cast(int)abs_y0 + 6, "-", .{1,1,1,1});
+ }
+ } else if elem.type == .BUTTON {
+ color := ifx is_hovered then Vector4.{0.4, 0.4, 0.4, 1.0} else Vector4.{0.3, 0.3, 0.3, 1.0};
+ Simp.immediate_quad(abs_x0, abs_y0, abs_x1, abs_y1, color);
+ if panel.font Simp.draw_text(panel.font, cast(int)abs_x0 + 60, cast(int)abs_y0 + 6, elem.label, .{1,1,1,1});
+ } else if elem.type == .TEXT_INPUT {
+ color := ifx panel.focus == elem.id then Vector4.{0.4, 0.4, 0.8, 1.0}
+ else ifx is_hovered then Vector4.{0.3, 0.3, 0.3, 1.0} else Vector4.{0.2, 0.2, 0.2, 1.0};
+ Simp.immediate_quad(abs_x0, abs_y0, abs_x1, abs_y1, color);
+ if panel.font {
+ Simp.draw_text(panel.font, cast(int)abs_x0 + 10, cast(int)abs_y0 + 6, elem.label, .{1,1,1,1});
+
+ val: string;
+ val.data = elem.input_buf.data;
+ val.count = elem.input_buf.count;
+
+ // FIXME lol
+ offset_x := elem.label.count * 20;
+
+ Simp.draw_text(panel.font, cast(int)abs_x0 + offset_x, cast(int)abs_y0 + 6, val, .{1,1,1,1});
+ }
+ } else if elem.type == .DYNAMIC_TEXT {
+ if panel.font Simp.draw_text(panel.font, cast(int)abs_x0, cast(int)abs_y0, elem.label, .{0, 1, 0, 1});
+ }
+ }
+ }
+ Simp.immediate_flush();
+}
diff --git a/src/widget_interface.jai b/src/widget_interface.jai
new file mode 100644
index 0000000..62c84a3
--- /dev/null
+++ b/src/widget_interface.jai
@@ -0,0 +1,12 @@
+#import "Basic";
+
+Widget_State :: struct {
+ init: (widget: *Widget_State) -> ();
+ update: (widget: *Widget_State, dt: float) -> ();
+ handle_input: (widget: *Widget_State, occluded: bool) -> (occludes: bool);
+ render: (widget: *Widget_State) -> ();
+ shutdown: (widget: *Widget_State) -> ();
+
+ // We can store a pointer to the specific widget's state here
+ data: *void;
+}