423 lines
15 KiB
Plaintext
423 lines
15 KiB
Plaintext
#import "modules/std.sx";
|
|
#import "build.sx";
|
|
#import "modules/compiler.sx";
|
|
#import "modules/sdl3.sx";
|
|
#import "modules/opengl.sx";
|
|
#import "modules/math";
|
|
#import "modules/stb.sx";
|
|
#import "modules/stb_truetype.sx";
|
|
#import "modules/wasm.sx";
|
|
#import "ui";
|
|
|
|
#run configure_build();
|
|
|
|
// --- Frame state (globals for emscripten callback) ---
|
|
g_window : *void = ---;
|
|
g_pipeline : *UIPipeline = ---;
|
|
g_running : bool = true;
|
|
g_width : s32 = 800; // logical window size
|
|
g_height : s32 = 600;
|
|
g_pixel_w : s32 = 800; // physical pixel size
|
|
g_pixel_h : s32 = 600;
|
|
|
|
// --- FPS / delta time tracking ---
|
|
g_delta_time : f32 = 0.008;
|
|
g_last_perf : u64 = 0;
|
|
g_frame_count : u64 = 0;
|
|
g_total_time : f64 = 0.0;
|
|
g_min_fps : f32 = 999999.0;
|
|
g_max_fps : f32 = 0.0;
|
|
|
|
// --- Persistent UI state (survives arena resets) ---
|
|
g_scroll_state : ScrollState = ---;
|
|
g_dock_interaction : *DockInteraction = xx 0;
|
|
|
|
FPS_REGRESSION_THRESHOLD :f32: 1400.0;
|
|
|
|
update_delta_time :: () {
|
|
current := SDL_GetPerformanceCounter();
|
|
freq := SDL_GetPerformanceFrequency();
|
|
if freq > 0 and g_last_perf > 0 {
|
|
g_delta_time = xx (current - g_last_perf) / xx freq;
|
|
}
|
|
g_last_perf = current;
|
|
|
|
// Track FPS stats (skip first 10 frames for warmup)
|
|
if g_frame_count > 10 {
|
|
g_total_time += xx g_delta_time;
|
|
fps : f32 = 1.0 / g_delta_time;
|
|
if fps < g_min_fps { g_min_fps = fps; }
|
|
if fps > g_max_fps { g_max_fps = fps; }
|
|
}
|
|
g_frame_count += 1;
|
|
}
|
|
|
|
print_fps_summary :: () {
|
|
if g_frame_count <= 11 or g_total_time <= 0.0 { return; }
|
|
measured : u64 = g_frame_count - 11;
|
|
if measured > 0 {
|
|
avg_fps : f32 = xx measured / xx g_total_time;
|
|
passed := avg_fps >= FPS_REGRESSION_THRESHOLD;
|
|
status := if passed then "PASS" else "FAIL";
|
|
out("\n=== FPS Summary ===\n");
|
|
print("Frames: {}\n", measured);
|
|
print("Time: {}s\n", g_total_time);
|
|
print("Avg: {} FPS\n", xx avg_fps);
|
|
print("Min: {} FPS\n", xx g_min_fps);
|
|
print("Max: {} FPS\n", xx g_max_fps);
|
|
out("-------------------\n");
|
|
print("Threshold: {} FPS\n", xx FPS_REGRESSION_THRESHOLD);
|
|
print("Status: {}\n", status);
|
|
out("===================\n");
|
|
}
|
|
}
|
|
|
|
load_texture :: (path: [:0]u8) -> u32 {
|
|
w : s32 = 0;
|
|
h : s32 = 0;
|
|
ch : s32 = 0;
|
|
pixels := stbi_load(path, @w, @h, @ch, 4);
|
|
if xx pixels == 0 { return 0; }
|
|
|
|
tex : u32 = 0;
|
|
glGenTextures(1, @tex);
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
glTexImage2D(GL_TEXTURE_2D, 0, xx GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, xx GL_LINEAR);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, xx GL_LINEAR);
|
|
|
|
stbi_image_free(pixels);
|
|
tex;
|
|
}
|
|
|
|
save_snapshot :: (path: [:0]u8, w: s32, h: s32) {
|
|
stride : s64 = w * 4;
|
|
buf_size : s64 = stride * h;
|
|
pixels : [*]u8 = xx context.allocator.alloc(buf_size);
|
|
glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
|
|
|
|
// Flip vertically (GL reads bottom-up, PNG expects top-down)
|
|
row_buf : [*]u8 = xx context.allocator.alloc(stride);
|
|
i : s32 = 0;
|
|
while i < h / 2 {
|
|
top : s64 = xx i * stride;
|
|
bot : s64 = xx (h - 1 - i) * stride;
|
|
memcpy(row_buf, @pixels[top], stride);
|
|
memcpy(@pixels[top], @pixels[bot], stride);
|
|
memcpy(@pixels[bot], row_buf, stride);
|
|
i += 1;
|
|
}
|
|
|
|
stbi_write_png(path, w, h, 4, pixels, xx stride);
|
|
out("Saved ");
|
|
out(path);
|
|
out("\n");
|
|
}
|
|
|
|
run_dock_drag_test :: (pipeline: *UIPipeline) {
|
|
out("=== Dock Drag Test: move Statistics panel to left zone ===\n");
|
|
|
|
// Initial layout pass
|
|
glClearColor(0.12, 0.12, 0.15, 1.0);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
pipeline.tick();
|
|
|
|
// Print the initial interaction state
|
|
print("BEFORE drag: has_override[1]={}, is_floating[1]={}\n",
|
|
g_dock_interaction.has_alignment_override.items[1],
|
|
g_dock_interaction.is_floating.items[1]);
|
|
print("BEFORE drag: child_bounds[1]=({},{} {}x{})\n",
|
|
g_dock_interaction.child_bounds.items[1].origin.x,
|
|
g_dock_interaction.child_bounds.items[1].origin.y,
|
|
g_dock_interaction.child_bounds.items[1].size.width,
|
|
g_dock_interaction.child_bounds.items[1].size.height);
|
|
|
|
// Statistics panel (index 1) is at ALIGN_TOP_TRAILING.
|
|
// Its header is the top 28px of the panel frame.
|
|
// Use the actual child_bounds to find the header center.
|
|
panel_frame := g_dock_interaction.child_bounds.items[1];
|
|
header_x := panel_frame.origin.x + panel_frame.size.width * 0.5;
|
|
header_y := panel_frame.origin.y + 14.0; // middle of 28px header
|
|
header_pos := Point.{ x = header_x, y = header_y };
|
|
print("clicking header at ({}, {})\n", header_x, header_y);
|
|
|
|
// Step 1: Mouse down on the Statistics panel header
|
|
e : Event = .mouse_down(MouseButtonData.{ position = header_pos, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
print("after mouse_down: dragging_child={}\n", g_dock_interaction.dragging_child);
|
|
|
|
// Step 2: Drag to the "left" zone.
|
|
// Left zone hint: (8, cy, 40, 40) where cy = (height - 40) / 2
|
|
// Use actual screen size from pipeline
|
|
screen_w := pipeline.screen_width;
|
|
screen_h := pipeline.screen_height;
|
|
zone_cx := 8.0 + 20.0; // center of left zone hint
|
|
zone_cy := screen_h * 0.5;
|
|
print("screen={}x{}, left zone center=({}, {})\n", xx screen_w, xx screen_h, zone_cx, zone_cy);
|
|
target := Point.{ x = zone_cx, y = zone_cy };
|
|
steps : s64 = 20;
|
|
i : s64 = 1;
|
|
while i <= steps {
|
|
t : f32 = xx i / xx steps;
|
|
cur_x := header_pos.x + (target.x - header_pos.x) * t;
|
|
cur_y := header_pos.y + (target.y - header_pos.y) * t;
|
|
e = .mouse_moved(MouseMotionData.{
|
|
position = Point.{ x = cur_x, y = cur_y },
|
|
delta = Point.{ x = (target.x - header_pos.x) / xx steps, y = (target.y - header_pos.y) / xx steps }
|
|
});
|
|
pipeline.dispatch_event(@e);
|
|
i += 1;
|
|
}
|
|
print("after drag: hovered_zone={}\n", g_dock_interaction.hovered_zone);
|
|
|
|
// Step 3: Mouse up at the target zone
|
|
e = .mouse_up(MouseButtonData.{ position = target, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
|
|
// Check the result
|
|
print("AFTER drop: has_override[1]={}, is_floating[1]={}, is_fill[1]={}\n",
|
|
g_dock_interaction.has_alignment_override.items[1],
|
|
g_dock_interaction.is_floating.items[1],
|
|
g_dock_interaction.is_fill.items[1]);
|
|
print("AFTER drop: dragging_child={}\n", g_dock_interaction.dragging_child);
|
|
|
|
// Render with new layout and save snapshot
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
pipeline.tick();
|
|
save_snapshot("goldens/test_dock_drag.png", g_pixel_w, g_pixel_h);
|
|
|
|
// Print final child_bounds to see where the panel ended up
|
|
print("FINAL: child_bounds[1]=({},{} {}x{})\n",
|
|
g_dock_interaction.child_bounds.items[1].origin.x,
|
|
g_dock_interaction.child_bounds.items[1].origin.y,
|
|
g_dock_interaction.child_bounds.items[1].size.width,
|
|
g_dock_interaction.child_bounds.items[1].size.height);
|
|
out("=== end dock drag test ===\n");
|
|
}
|
|
|
|
run_ui_tests :: (pipeline: *UIPipeline) {
|
|
// Do a layout pass first so frames are computed
|
|
glClearColor(0.12, 0.12, 0.15, 1.0);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
pipeline.tick();
|
|
|
|
// Button "Click Me" is roughly at center x=400, y=165 based on golden
|
|
btn_x : f32 = 400.0;
|
|
btn_y : f32 = 165.0;
|
|
btn_pos := Point.{ x = btn_x, y = btn_y };
|
|
// Empty area below the rects
|
|
empty_x : f32 = 400.0;
|
|
empty_y : f32 = 500.0;
|
|
|
|
// --- Test 1: Click button (no drag) ---
|
|
out("=== Test 1: button click ===\n");
|
|
out("expect: Button tapped!\n");
|
|
e :Event = .mouse_down(MouseButtonData.{ position = btn_pos, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
e = .mouse_up(MouseButtonData.{ position = btn_pos, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
out("=== end test 1 ===\n\n");
|
|
|
|
// --- Test 2: Drag from button area (should scroll, NOT tap) ---
|
|
out("=== Test 2: drag from button ===\n");
|
|
out("expect: NO Button tapped, scroll should move\n");
|
|
e = .mouse_down(MouseButtonData.{ position = btn_pos, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
// Move down 50px in steps (exceeds 4px threshold)
|
|
i : s32 = 1;
|
|
while i <= 10 {
|
|
e = .mouse_moved(MouseMotionData.{
|
|
position = Point.{ x = btn_x, y = btn_y + xx i * 5.0 },
|
|
delta = Point.{ x = 0.0, y = 5.0 }
|
|
});
|
|
pipeline.dispatch_event(@e);
|
|
i += 1;
|
|
}
|
|
e = .mouse_up(MouseButtonData.{ position = Point.{ x = btn_x, y = btn_y + 50.0 }, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
out("=== end test 2 ===\n\n");
|
|
|
|
// --- Test 3: Drag from empty area (should scroll) ---
|
|
out("=== Test 3: drag from empty area ===\n");
|
|
out("expect: scroll should move\n");
|
|
e = .mouse_down(MouseButtonData.{ position = Point.{ x = empty_x, y = empty_y }, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
i = 1;
|
|
while i <= 10 {
|
|
e = .mouse_moved(MouseMotionData.{
|
|
position = Point.{ x = empty_x, y = empty_y - xx i * 5.0 },
|
|
delta = Point.{ x = 0.0, y = 0.0 - 5.0 }
|
|
});
|
|
pipeline.dispatch_event(@e);
|
|
i += 1;
|
|
}
|
|
e = .mouse_up(MouseButtonData.{ position = Point.{ x = empty_x, y = empty_y - 50.0 }, button = .left });
|
|
pipeline.dispatch_event(@e);
|
|
out("=== end test 3 ===\n\n");
|
|
|
|
// Render after tests and save snapshot to see scrolled state
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
pipeline.tick();
|
|
save_snapshot("goldens/test_after_drag.png", g_pixel_w, g_pixel_h);
|
|
}
|
|
|
|
// One frame of the main loop — called repeatedly by emscripten or desktop while-loop
|
|
frame :: () {
|
|
update_delta_time();
|
|
|
|
sdl_event : SDL_Event = .none;
|
|
while SDL_PollEvent(@sdl_event) {
|
|
if sdl_event == {
|
|
case .quit: { g_running = false; }
|
|
case .key_up: (e) {
|
|
if e.key == { case .escape: { g_running = false; } }
|
|
}
|
|
case .window_resized: (data) {
|
|
g_width = data.data1;
|
|
g_height = data.data2;
|
|
SDL_GetWindowSizeInPixels(g_window, @g_pixel_w, @g_pixel_h);
|
|
g_pipeline.resize(xx g_width, xx g_height);
|
|
}
|
|
}
|
|
|
|
ui_event := translate_sdl_event(@sdl_event);
|
|
if ui_event != .none {
|
|
g_pipeline.dispatch_event(@ui_event);
|
|
}
|
|
}
|
|
|
|
glViewport(0, 0, g_pixel_w, g_pixel_h);
|
|
glClearColor(0.12, 0.12, 0.15, 1.0);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
|
|
g_pipeline.*.tick();
|
|
SDL_GL_SwapWindow(g_window);
|
|
|
|
// Auto-quit after 300 frames for benchmarking
|
|
if g_frame_count > 300 { g_running = false; }
|
|
}
|
|
|
|
// Body function — rebuilds the entire view tree each frame (arena-allocated)
|
|
build_ui :: () -> View {
|
|
scroll_content := VStack.{ spacing = 10.0, alignment = .center } {
|
|
self.add(
|
|
Label.{ text = "Hello, SX!", font_size = 24.0, color = COLOR_WHITE }
|
|
|> padding(EdgeInsets.all(8.0))
|
|
);
|
|
self.add(
|
|
RectView.{ color = COLOR_YELLOW, preferred_height = 80.0, corner_radius = 8.0 }
|
|
|> padding(EdgeInsets.all(8.0))
|
|
|> on_tap(closure(() { out("Yellow tapped!\n"); }))
|
|
);
|
|
self.add(
|
|
Button.{ label = "Click Me", font_size = 14.0, style = ButtonStyle.default(), on_tap = closure(() { out("Button tapped!\n"); }) }
|
|
);
|
|
self.add(HStack.{ spacing = 10.0, alignment = .center } {
|
|
self.add(RectView.{ color = COLOR_RED, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 });
|
|
self.add(RectView.{ color = COLOR_GREEN, preferred_width = 200.0, preferred_height = 300.0, corner_radius = 4.0 });
|
|
});
|
|
self.add(
|
|
RectView.{ color = COLOR_DARK_GRAY, preferred_height = 60.0 }
|
|
|> padding(.symmetric(16.0, 8.0))
|
|
|> background(COLOR_BLUE, 8.0)
|
|
);
|
|
self.add(RectView.{ color = COLOR_ORANGE, preferred_height = 120.0, corner_radius = 12.0 });
|
|
self.add(RectView.{ color = COLOR_GRAY, preferred_height = 200.0, corner_radius = 8.0 });
|
|
};
|
|
|
|
scroll := ScrollView.{ child = ViewChild.{ view = scroll_content }, state = @g_scroll_state, axes = .vertical };
|
|
stats := StatsPanel.{ delta_time = @g_delta_time, font_size = 12.0 };
|
|
|
|
dock := Dock.make(g_dock_interaction);
|
|
content_panel := DockPanel.make("Content", ALIGN_CENTER, scroll);
|
|
content_panel.fill = true;
|
|
dock.add_panel(content_panel);
|
|
dock.add_panel(DockPanel.make("Statistics", ALIGN_TOP_TRAILING, stats));
|
|
|
|
xx dock;
|
|
}
|
|
|
|
main :: () -> void {
|
|
SDL_Init(SDL_INIT_VIDEO);
|
|
|
|
inline if OS == .wasm {
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
|
|
} else {
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
|
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
|
|
}
|
|
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
|
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
|
|
|
// Auto-size: on desktop use 75% of usable display, on WASM SDL picks up canvas size
|
|
init_w : s32 = 800;
|
|
init_h : s32 = 600;
|
|
inline if OS == .wasm {
|
|
init_w = emscripten_run_script_int("window.innerWidth");
|
|
init_h = emscripten_run_script_int("window.innerHeight");
|
|
} else {
|
|
display_id := SDL_GetPrimaryDisplay();
|
|
bounds : SDL_Rect = ---;
|
|
if SDL_GetDisplayUsableBounds(display_id, @bounds) {
|
|
init_w = bounds.w * 3 / 4;
|
|
init_h = bounds.h * 3 / 4;
|
|
}
|
|
}
|
|
|
|
window := SDL_CreateWindow("SX UI Demo", init_w, init_h, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
|
|
gl_ctx := SDL_GL_CreateContext(window);
|
|
SDL_GL_MakeCurrent(window, gl_ctx);
|
|
SDL_GL_SetSwapInterval(0);
|
|
|
|
load_gl(xx SDL_GL_GetProcAddress);
|
|
|
|
// Query actual window size (may differ from requested, especially on WASM)
|
|
SDL_GetWindowSize(window, @g_width, @g_height);
|
|
SDL_GetWindowSizeInPixels(window, @g_pixel_w, @g_pixel_h);
|
|
width_f : f32 = xx g_width;
|
|
height_f : f32 = xx g_height;
|
|
dpi_scale : f32 = if width_f > 0.0 then xx g_pixel_w / width_f else 1.0;
|
|
|
|
// Set viewport to physical pixel dimensions
|
|
glViewport(0, 0, g_pixel_w, g_pixel_h);
|
|
|
|
// --- Build UI ---
|
|
pipeline : *UIPipeline = xx context.allocator.alloc(size_of(UIPipeline));
|
|
pipeline.init(width_f, height_f);
|
|
pipeline.init_font("assets/fonts/default.ttf", 32.0, dpi_scale);
|
|
|
|
// Initialize persistent state (on GPA, before arena is active)
|
|
g_scroll_state = ScrollState.{};
|
|
g_dock_interaction = xx context.allocator.alloc(size_of(DockInteraction));
|
|
g_dock_interaction.init();
|
|
g_dock_delta_time = @g_delta_time;
|
|
|
|
pipeline.set_body(closure(build_ui));
|
|
|
|
// Store state in globals for frame callback
|
|
g_window = xx window;
|
|
g_pipeline = pipeline;
|
|
|
|
// Reset perf counter so first frame doesn't include init time
|
|
g_last_perf = SDL_GetPerformanceCounter();
|
|
|
|
// --- Main loop ---
|
|
inline if OS == .wasm {
|
|
emscripten_set_main_loop(xx frame, 0, 1);
|
|
} else {
|
|
while g_running {
|
|
frame();
|
|
}
|
|
}
|
|
|
|
print_fps_summary();
|
|
save_snapshot("goldens/last_frame.png", g_pixel_w, g_pixel_h);
|
|
|
|
SDL_GL_DestroyContext(gl_ctx);
|
|
SDL_DestroyWindow(window);
|
|
SDL_Quit();
|
|
}
|