diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ba5c7578..385ef9628 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,6 +121,21 @@ jobs: if: runner.os != 'Linux' run: ./.github/scripts/smoke_test.sh ./build/hrc 8 + # The only end-to-end check that the main-callbacks loop actually boots: the + # host drives the frames here, so a broken SDL_AppInit/SDL_AppIterate path + # exits immediately instead of failing to build. Also catches a regression in + # the CF_APP_OPTIONS_MAIN_CALLBACKS_BIT guard, which exits non-zero at startup. + - name: Smoke test main callbacks (Linux, headless GPU) + if: runner.os == 'Linux' + run: xvfb-run -a -s "-screen 0 1280x720x24" ./.github/scripts/smoke_test.sh ./build/main_callbacks 8 + env: + SDL_AUDIODRIVER: dummy + LIBGL_ALWAYS_SOFTWARE: "1" + + - name: Smoke test main callbacks + if: runner.os != 'Linux' + run: ./.github/scripts/smoke_test.sh ./build/main_callbacks 8 + - name: Tests (Linux, headless GPU) if: runner.os == 'Linux' run: xvfb-run -a -s "-screen 0 1280x720x24" ./build/tests diff --git a/CMakeLists.txt b/CMakeLists.txt index 91ad87946..ef75cddd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -174,6 +174,7 @@ set(CF_PUBLIC_HDRS include/cute_image.h include/cute_color.h include/cute.h + include/cute_main.h include/cute_graphics.h include/cute_rnd.h include/cute_sprite.h diff --git a/docs/samples/main_callbacks.md b/docs/samples/main_callbacks.md new file mode 100644 index 000000000..94c18b4ec --- /dev/null +++ b/docs/samples/main_callbacks.md @@ -0,0 +1,15 @@ +--- +hide: + - toc +--- + +# Main Callbacks + +
+ +
+ +
+ [:material-fullscreen: Fullscreen](../play/main_callbacks.html){: .md-button target="_blank" } + [:material-code-tags: View Source](https://github.com/RandyGaul/cute_framework/blob/master/samples/main_callbacks.c){: .md-button target="_blank" } +
diff --git a/include/cute_app.h b/include/cute_app.h index fb6e45bf5..abd5e7c6d 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -202,6 +202,8 @@ CF_API CF_DisplayOrientation CF_CALL cf_display_orientation(CF_DisplayID display CF_ENUM(APP_OPTIONS_GFX_DEBUG_BIT, 1 << 12) \ /* @entry Disables the OS's high-pixel-density (Retina/HiDPI) backbuffer, forcing 1:1 logical-to-physical rendering. `cf_app_get_pixel_scale` will always return 1.0f. */ \ CF_ENUM(APP_OPTIONS_NO_HIGH_DPI_BIT, 1 << 13) \ + /* @entry The host drives the main loop and owns the event queue, so CF will not poll for events itself -- forward each one to `cf_app_process_event` instead. Set this when you include `cute_main.h`. Not sticky: pass it to every `cf_make_app` call, including after a `cf_destroy_app`. */ \ + CF_ENUM(APP_OPTIONS_MAIN_CALLBACKS_BIT, 1 << 14) \ /* @end */ typedef int CF_AppOptionFlags; @@ -932,6 +934,40 @@ struct SDL_Window; */ CF_API struct SDL_Window* CF_CALL cf_app_get_window(void); +/** + * @function cf_app_get_options + * @category app + * @brief Returns the bitmask of `CF_AppOptionFlagBits` the app was created with. + * @return Returns the `options` value passed to `cf_make_app`, or 0 if no app exists. + * @remarks Returns 0 before `cf_make_app` and after `cf_destroy_app`, so it is always safe to call. + * @related cf_make_app CF_AppOptionFlagBits cf_app_process_event + */ +CF_API CF_AppOptionFlags CF_CALL cf_app_get_options(void); + +union SDL_Event; + +/** + * @function cf_app_process_event + * @category app + * @brief Feeds one platform event to CF's input system. + * @param event Pointer to the `SDL_Event` to process. Under `cute_main.h` this is handed to you already. + * @remarks Only needed when the host drives the main loop -- pass `CF_APP_OPTIONS_MAIN_CALLBACKS_BIT` to + * `cf_make_app` to enable that mode. Feeding events alone does not enable it, and in a classic loop a + * stray call is harmless: the event is applied at the next update alongside the normal pump. Safe to + * call before `cf_make_app` (the event is ignored). + * + * Events are deep-copied and buffered, then applied at the start of the next `cf_app_update`, preserving + * the exact input timing of a classic `cf_app_is_running` loop. Thread-safe, since some events (e.g. + * mobile lifecycle events) can be delivered from other threads. If more than 4096 events pile up between + * updates the oldest are dropped -- note that dropping a key-down while keeping its key-up leaves that + * key reading as stuck. + * + * `SDL_Event` is only forward-declared here, so this header does not pull in SDL3's headers; the + * `cute_main.h` glue is the only caller inside CF and passes the right type by construction. + * @related cf_app_update cf_make_app CF_AppOptionFlagBits + */ +CF_API void CF_CALL cf_app_process_event(union SDL_Event* event); + #ifdef __cplusplus } #endif // __cplusplus @@ -1009,6 +1045,8 @@ CF_INLINE void app_set_canvas_size(int w, int h) { cf_app_set_canvas_size(w, h); CF_INLINE void app_set_canvas_blit_filter(CF_Filter filter) { cf_app_set_canvas_blit_filter(filter); } CF_INLINE CF_PowerInfo app_power_info() { return cf_app_power_info(); } CF_INLINE struct SDL_Window* app_get_window() { return cf_app_get_window(); } +CF_INLINE CF_AppOptionFlags app_get_options() { return cf_app_get_options(); } +CF_INLINE void app_process_event(union SDL_Event* event) { cf_app_process_event(event); } } diff --git a/include/cute_main.h b/include/cute_main.h new file mode 100644 index 000000000..62f2e56a8 --- /dev/null +++ b/include/cute_main.h @@ -0,0 +1,135 @@ +/* + Cute Framework + Copyright (C) 2024 Randy Gaul https://randygaul.github.io/ + + This software is dual-licensed with zlib or Unlicense, check LICENSE.txt for more info +*/ + +#ifndef CF_MAIN_H +#define CF_MAIN_H + +// Deliberately not included by cute.h -- the entry-point glue below has external linkage and must +// live in exactly one translation unit, so including this header is itself the opt-in. Include it +// alongside cute.h in the one file that owns your app's entry point, not anywhere else. + +#if defined(CF_MAIN) +# error "cute_main.h: define either CF_MAIN or include cute_main.h, not both -- they are two different entry points." +#endif + +// If something already pulled in , its include guard would silently swallow the +// callback machinery below and your program would link with no `main` at all. Catch it here +// instead: include cute_main.h before any other header that reaches SDL_main.h. +#ifdef SDL_main_h_ +# error "cute_main.h: was already included. Include cute_main.h first, before any header that reaches it." +#endif + +#define SDL_MAIN_USE_CALLBACKS +#include "cute_app.h" +#include +#include + +//-------------------------------------------------------------------------------------------------- +// C API + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * @function cf_main_init + * @category app + * @brief Your app's startup function -- implement it and call `cf_make_app` inside. + * @param argc The argument count, forwarded from the entry point. + * @param argv The argument vector, forwarded from the entry point. Pass `argv[0]` along to `cf_make_app`. + * @return Return the `CF_Result` from `cf_make_app` (or your own error). An error result aborts startup. + * @remarks Include `cute_main.h` in exactly one source file to let the host drive your main loop instead of + * writing your own `while` loop -- don't define `main` yourself, implement `cf_main_init`, + * `cf_main_update` and `cf_main_quit` instead. All three must live in this one file: in C++ they + * are only declared here, so defining one in a file that lacks this include gives it C++ linkage + * and you get an undefined-symbol error at link time. + * + * Called once before the first `cf_main_update`. Your `cf_make_app` call must include + * `CF_APP_OPTIONS_MAIN_CALLBACKS_BIT` -- startup fails with a message if it doesn't, rather than + * leaving you with a window that silently receives no input. + * + * The exact same code then runs on desktop, web, and mobile -- no platform-specific main-loop + * forks needed. The app quits once `cf_app_is_running` returns false (window close, or call + * `cf_app_signal_shutdown`). The platform decides the frame rate, so drive animation off + * `CF_DELTA_TIME` rather than assuming 60hz. + * + * Note for fixed-timestep games (`cf_set_fixed_timestep`): events are delivered between frames, + * so all sub-steps of one frame share the same input snapshot (the classic loop re-polls the OS + * queue per sub-step). + * + * Implemented on [SDL's main callbacks](https://wiki.libsdl.org/SDL3/README-main-functions): CF + * defines `SDL_AppInit`/`SDL_AppIterate`/`SDL_AppEvent`/`SDL_AppQuit` for you. If you want those + * four yourself, skip this header entirely: define `SDL_MAIN_USE_CALLBACKS`, write them, pass + * `CF_APP_OPTIONS_MAIN_CALLBACKS_BIT` to `cf_make_app`, and forward every event to + * `cf_app_process_event` (see cute_app.h). + * @related cf_main_update cf_main_quit cf_make_app cf_app_process_event CF_AppOptionFlagBits + */ +CF_Result cf_main_init(int argc, char* argv[]); + +/** + * @function cf_main_update + * @category app + * @brief Your app's frame function -- one frame of the main loop. + * @remarks Call `cf_app_update` at the top and `cf_app_draw_onto_screen` at the bottom, with your game logic in + * between, exactly like the body of a classic `while (cf_app_is_running())` loop. The platform decides + * the call rate (e.g. the browser's requestAnimationFrame on web). The app quits once `cf_app_is_running` + * returns false -- call `cf_app_signal_shutdown` to request that. + * @related cf_main_init cf_main_quit cf_app_update cf_app_draw_onto_screen cf_app_signal_shutdown + */ +void cf_main_update(void); + +/** + * @function cf_main_quit + * @category app + * @brief Your app's cleanup function -- called once at shutdown. + * @remarks Free your own resources here. `cf_destroy_app` is called for you right afterwards. Note this runs even + * when `cf_main_init` failed, so don't assume the app was created. + * @related cf_main_init cf_main_update cf_destroy_app + */ +void cf_main_quit(void); + +SDL_AppResult SDLCALL SDL_AppInit(void** appstate, int argc, char* argv[]) +{ + (void)appstate; + if (cf_is_error(cf_main_init(argc, argv))) return SDL_APP_FAILURE; + // Without the bit CF would keep polling an event queue the callback harness already drained, + // leaving a window with no keyboard, no mouse and no way to quit. Fail loudly instead. + if (!(cf_app_get_options() & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT)) { + SDL_SetError("cf_make_app was called without CF_APP_OPTIONS_MAIN_CALLBACKS_BIT, which cute_main.h requires. Add it to the options passed to cf_make_app in cf_main_init."); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", SDL_GetError()); + return SDL_APP_FAILURE; + } + return SDL_APP_CONTINUE; +} + +SDL_AppResult SDLCALL SDL_AppIterate(void* appstate) +{ + (void)appstate; + cf_main_update(); + return cf_app_is_running() ? SDL_APP_CONTINUE : SDL_APP_SUCCESS; +} + +SDL_AppResult SDLCALL SDL_AppEvent(void* appstate, SDL_Event* event) +{ + (void)appstate; + cf_app_process_event(event); + return SDL_APP_CONTINUE; +} + +void SDLCALL SDL_AppQuit(void* appstate, SDL_AppResult result) +{ + (void)appstate; + (void)result; + cf_main_quit(); + cf_destroy_app(); +} + +#ifdef __cplusplus +} +#endif // __cplusplus + +#endif // CF_MAIN_H diff --git a/mkdocs.yml b/mkdocs.yml index bcfdee5f8..0a738d61e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -162,6 +162,7 @@ nav: - Import Spritesheet: samples/import_spritesheet.md - Input Binding: samples/input_binding.md - Joypad: samples/joypad.md + - Main Callbacks: samples/main_callbacks.md - Mandala: samples/mandala.md - Metaballs: samples/metaballs.md - Model 3D: samples/model3d.md diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index e2cace941..37b674e6e 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -110,6 +110,7 @@ add_sample(canvas_readback canvas_readback.c) add_sample(glitch glitch.cpp) add_sample(customsprite custom_sprite.c) add_sample(sound_pan sound_pan.c) +add_sample(main_callbacks main_callbacks.c) # Ensure that sample data is included in web build if (EMSCRIPTEN) diff --git a/samples/main_callbacks.c b/samples/main_callbacks.c new file mode 100644 index 000000000..06853fead --- /dev/null +++ b/samples/main_callbacks.c @@ -0,0 +1,52 @@ +// Lets the host drive the main loop instead of writing your own `while` loop. The same three +// functions run on desktop, web and mobile -- no `#ifdef CF_EMSCRIPTEN` fork anywhere. +// +// Try dragging the window edge and holding it: the orbiting dot keeps moving and the clock keeps +// ticking, where a classic `while (cf_app_is_running())` loop freezes until you let go. +// +// Including cute_main.h is itself the opt-in -- do it in exactly one source file, the one that +// owns your app's entry point. +#include +#include + +#include + +static float s_elapsed; +static int s_presses; + +CF_Result cf_main_init(int argc, char* argv[]) +{ + (void)argc; + // CF checks this result for you -- no `if (cf_is_error(result)) return -1;` needed. + // CF_APP_OPTIONS_MAIN_CALLBACKS_BIT is required here: it stops CF from polling an event + // queue it no longer owns. Leave it out and CF refuses to start rather than handing you a + // window that silently receives no input. + return cf_make_app("Main Callbacks", 0, 0, 0, 640, 480, + CF_APP_OPTIONS_WINDOW_POS_CENTERED_BIT | CF_APP_OPTIONS_RESIZABLE_BIT | CF_APP_OPTIONS_MAIN_CALLBACKS_BIT, argv[0]); +} + +void cf_main_update(void) +{ + // One frame of the main loop -- exactly the body of a classic `while` loop. + cf_app_update(NULL); + + // The host picks the frame rate (requestAnimationFrame on web), so animate off delta time + // instead of assuming 60hz. + s_elapsed += CF_DELTA_TIME; + if (cf_key_just_pressed(CF_KEY_SPACE)) s_presses++; + + cf_draw_circle_fill2(cf_v2(cosf(s_elapsed * 2.0f) * 120.0f, sinf(s_elapsed * 2.0f) * 120.0f), 16.0f); + + char buf[64]; + snprintf(buf, sizeof(buf), "elapsed %.2fs", s_elapsed); + cf_draw_text(buf, cf_v2(-60, 30), -1); + snprintf(buf, sizeof(buf), "space pressed %d", s_presses); + cf_draw_text(buf, cf_v2(-60, 10), -1); + + cf_app_draw_onto_screen(true); +} + +void cf_main_quit(void) +{ + // Free your own resources here -- cf_destroy_app runs for you right afterwards. +} diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 03f6641d0..3ad7e4fd6 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -432,8 +432,11 @@ void cf_destroy_app() cf_destroy_custom_sprite_cache(); cs_shutdown(); destroy_mutex(&app->on_sound_finish_mutex); + cf_free_buffered_events(); + destroy_mutex(&app->buffered_events_mutex); if (app->window) SDL_DestroyWindow(app->window); - SDL_Quit(); + // Under SDL's main callbacks, SDL itself calls SDL_Quit after SDL_AppQuit returns. + if (!(app->options & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT)) SDL_Quit(); CF_Image* easy_sprites = app->easy_sprites.items(); for (int i = 0; i < app->easy_sprites.count(); ++i) { cf_image_free(&easy_sprites[i]); @@ -446,17 +449,32 @@ void cf_destroy_app() bool cf_app_is_running() { - return app->running; + // Null-safe so the cute_main.h glue (and classic loops) behave when the + // user already destroyed the app, e.g. from within their own update function. + return app ? app->running : false; +} + +CF_AppOptionFlags cf_app_get_options() +{ + // Null-safe, matching cf_app_is_running. The cute_main.h glue reads this to + // check the app actually opted into callback mode, and must not fault when cf_main_init + // returned success without ever calling cf_make_app. + return app ? app->options : 0; } void cf_app_signal_shutdown() { - app->running = 0; + if (app) app->running = 0; } static void s_on_update(void* udata) { - cf_pump_input_msgs(); + // Buffered events drain in every mode: a stray cf_app_process_event call in a classic + // loop must not lose events, and must never turn the internal pump off. + cf_drain_buffered_events(); + if (!(app->options & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT)) { + cf_pump_input_msgs(); + } cf_binding_update(); if (app->audio_needs_updates) { cs_update(CF_DELTA_TIME); diff --git a/src/cute_input.cpp b/src/cute_input.cpp index 7785286d0..d143e5cec 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -519,211 +519,285 @@ static void s_refresh_pixel_scale() } } -void cf_pump_input_msgs() +static void s_handle_event(SDL_Event* event) { - // Handle SDL messages. - SDL_Event event; - while (SDL_PollEvent(&event)) { - if (app->using_imgui) { - ImGui_ImplSDL3_ProcessEvent(&event); - } - - switch (event.type) - { - case SDL_EVENT_QUIT: - app->running = false; - break; + if (app->using_imgui) { + ImGui_ImplSDL3_ProcessEvent(event); + } - case SDL_EVENT_WINDOW_RESIZED: - app->window_state.resized = true; - app->w = event.window.data1; - app->h = event.window.data2; - cf_app_recreate_default_canvas_if_needed(); - break; + switch (event->type) + { + case SDL_EVENT_QUIT: + app->running = false; + break; + + case SDL_EVENT_WINDOW_RESIZED: + app->window_state.resized = true; + app->w = event->window.data1; + app->h = event->window.data2; + cf_app_recreate_default_canvas_if_needed(); + break; - case SDL_EVENT_WINDOW_MOVED: - app->window_state.moved = true; - app->x = event.window.data1; - app->y = event.window.data2; - break; + case SDL_EVENT_WINDOW_MOVED: + app->window_state.moved = true; + app->x = event->window.data1; + app->y = event->window.data2; + break; - case SDL_EVENT_WINDOW_MINIMIZED: - app->window_state.minimized = true; - break; + case SDL_EVENT_WINDOW_MINIMIZED: + app->window_state.minimized = true; + break; - case SDL_EVENT_WINDOW_MAXIMIZED: - app->window_state.maximized = true; - break; + case SDL_EVENT_WINDOW_MAXIMIZED: + app->window_state.maximized = true; + break; - case SDL_EVENT_WINDOW_RESTORED: - app->window_state.restored = true; - break; + case SDL_EVENT_WINDOW_RESTORED: + app->window_state.restored = true; + break; - case SDL_EVENT_WINDOW_MOUSE_ENTER: - app->window_state.mouse_inside_window = true; - break; + case SDL_EVENT_WINDOW_MOUSE_ENTER: + app->window_state.mouse_inside_window = true; + break; - case SDL_EVENT_WINDOW_MOUSE_LEAVE: - app->window_state.mouse_inside_window = false; - break; + case SDL_EVENT_WINDOW_MOUSE_LEAVE: + app->window_state.mouse_inside_window = false; + break; - case SDL_EVENT_WINDOW_FOCUS_GAINED: - app->window_state.has_keyboard_focus = true; - break; + case SDL_EVENT_WINDOW_FOCUS_GAINED: + app->window_state.has_keyboard_focus = true; + break; - case SDL_EVENT_WINDOW_FOCUS_LOST: - app->window_state.has_keyboard_focus = false; - break; + case SDL_EVENT_WINDOW_FOCUS_LOST: + app->window_state.has_keyboard_focus = false; + break; - case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: - app->dpi_scale = SDL_GetWindowDisplayScale(app->window); - app->dpi_scale_was_changed = true; - s_refresh_pixel_scale(); - break; + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + app->dpi_scale = SDL_GetWindowDisplayScale(app->window); + app->dpi_scale_was_changed = true; + s_refresh_pixel_scale(); + break; - case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: - s_refresh_pixel_scale(); - break; + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + s_refresh_pixel_scale(); + break; - case SDL_EVENT_KEY_DOWN: - { - if (event.key.repeat) continue; - int key = SDL_GetKeyFromScancode(event.key.scancode, event.key.mod, true); - key = s_map_SDL_keys(key); - CF_ASSERT(key >= 0 && key < 512); - app->keys[key] = 1; - app->keys[CF_KEY_ANY] = 1; - app->keys_timestamp[key] = app->keys_timestamp[CF_KEY_ANY] = CF_SECONDS; - if (app->key_callback) app->key_callback((CF_KeyButton)key, true); - } break; - - case SDL_EVENT_KEY_UP: - { - if (event.key.repeat) continue; - int key = SDL_GetKeyFromScancode(event.key.scancode, event.key.mod, true); - key = s_map_SDL_keys(key); - CF_ASSERT(key >= 0 && key < 512); - app->keys[key] = 0; - if (app->key_callback) app->key_callback((CF_KeyButton)key, false); - } break; - - case SDL_EVENT_TEXT_INPUT: + case SDL_EVENT_KEY_DOWN: + { + if (event->key.repeat) return; + int key = SDL_GetKeyFromScancode(event->key.scancode, event->key.mod, true); + key = s_map_SDL_keys(key); + CF_ASSERT(key >= 0 && key < 512); + app->keys[key] = 1; + app->keys[CF_KEY_ANY] = 1; + app->keys_timestamp[key] = app->keys_timestamp[CF_KEY_ANY] = CF_SECONDS; + if (app->key_callback) app->key_callback((CF_KeyButton)key, true); + } break; + + case SDL_EVENT_KEY_UP: + { + if (event->key.repeat) return; + int key = SDL_GetKeyFromScancode(event->key.scancode, event->key.mod, true); + key = s_map_SDL_keys(key); + CF_ASSERT(key >= 0 && key < 512); + app->keys[key] = 0; + if (app->key_callback) app->key_callback((CF_KeyButton)key, false); + } break; + + case SDL_EVENT_TEXT_INPUT: + { + // text.text can be NULL if s_deep_copy_event's SDL_strdup failed under OOM. + if (event->text.text) cf_input_text_add_utf8(event->text.text); + app->ime_composition.clear(); + app->ime_composition_cursor = 0; + app->ime_composition_selection_len = 0; + } break; + + case SDL_EVENT_TEXT_EDITING: + { + // edit.text can be NULL if s_deep_copy_event's SDL_strdup failed under OOM. + app->ime_composition.clear(); + const char* text = event->edit.text; + if (text) while (*text) app->ime_composition.add(*text++); + app->ime_composition.add(0); + app->ime_composition_cursor = text ? event->edit.start : 0; + app->ime_composition_selection_len = text ? event->edit.length : 0; + } break; + + case SDL_EVENT_MOUSE_MOTION: + app->mouse.x = event->motion.x; + app->mouse.y = event->motion.y; + app->mouse.xrel = event->motion.xrel; + app->mouse.yrel = -event->motion.yrel; + break; + + case SDL_EVENT_MOUSE_BUTTON_DOWN: + switch (event->button.button) { - cf_input_text_add_utf8(event.text.text); - app->ime_composition.clear(); - app->ime_composition_cursor = 0; - app->ime_composition_selection_len = 0; - } break; + case SDL_BUTTON_LEFT: app->mouse.left_button = 1; break; + case SDL_BUTTON_RIGHT: app->mouse.right_button = 1; break; + case SDL_BUTTON_MIDDLE: app->mouse.middle_button = 1; break; + case SDL_BUTTON_X1: app->mouse.x1_button = 1; break; + case SDL_BUTTON_X2: app->mouse.x2_button = 1; break; + } + app->mouse.x = event->button.x; + app->mouse.y = event->button.y; + if (event->button.clicks == 1) { + app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; + } else if (event->button.clicks == 2) { + app->mouse.click_type = CF_MOUSE_CLICK_DOUBLE; + } + break; - case SDL_EVENT_TEXT_EDITING: + case SDL_EVENT_MOUSE_BUTTON_UP: + switch (event->button.button) { - app->ime_composition.clear(); - const char* text = event.edit.text; - while (*text) app->ime_composition.add(*text++); - app->ime_composition.add(0); - app->ime_composition_cursor = event.edit.start; - app->ime_composition_selection_len = event.edit.length; - } break; - - case SDL_EVENT_MOUSE_MOTION: - app->mouse.x = event.motion.x; - app->mouse.y = event.motion.y; - app->mouse.xrel = event.motion.xrel; - app->mouse.yrel = -event.motion.yrel; - break; + case SDL_BUTTON_LEFT: app->mouse.left_button = 0; break; + case SDL_BUTTON_RIGHT: app->mouse.right_button = 0; break; + case SDL_BUTTON_MIDDLE: app->mouse.middle_button = 0; break; + case SDL_BUTTON_X1: app->mouse.x1_button = 0; break; + case SDL_BUTTON_X2: app->mouse.x2_button = 0; break; + } + app->mouse.x = event->button.x; + app->mouse.y = event->button.y; + if (event->button.clicks == 1) { + app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; + } else if (event->button.clicks == 2) { + app->mouse.click_type = CF_MOUSE_CLICK_DOUBLE; + } + break; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - switch (event.button.button) - { - case SDL_BUTTON_LEFT: app->mouse.left_button = 1; break; - case SDL_BUTTON_RIGHT: app->mouse.right_button = 1; break; - case SDL_BUTTON_MIDDLE: app->mouse.middle_button = 1; break; - case SDL_BUTTON_X1: app->mouse.x1_button = 1; break; - case SDL_BUTTON_X2: app->mouse.x2_button = 1; break; - } - app->mouse.x = event.button.x; - app->mouse.y = event.button.y; - if (event.button.clicks == 1) { - app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; - } else if (event.button.clicks == 2) { - app->mouse.click_type = CF_MOUSE_CLICK_DOUBLE; - } - break; + case SDL_EVENT_MOUSE_WHEEL: + app->mouse.wheel_motion = event->wheel.y; + break; - case SDL_EVENT_MOUSE_BUTTON_UP: - switch (event.button.button) - { - case SDL_BUTTON_LEFT: app->mouse.left_button = 0; break; - case SDL_BUTTON_RIGHT: app->mouse.right_button = 0; break; - case SDL_BUTTON_MIDDLE: app->mouse.middle_button = 0; break; - case SDL_BUTTON_X1: app->mouse.x1_button = 0; break; - case SDL_BUTTON_X2: app->mouse.x2_button = 0; break; - } - app->mouse.x = event.button.x; - app->mouse.y = event.button.y; - if (event.button.clicks == 1) { - app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; - } else if (event.button.clicks == 2) { - app->mouse.click_type = CF_MOUSE_CLICK_DOUBLE; + case SDL_EVENT_GAMEPAD_BUTTON_UP: + { + SDL_JoystickID id = event->gbutton.which; + cf_joypad_on_button_up(id, (int)event->gbutton.button); + } break; + + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + { + SDL_JoystickID id = event->gbutton.which; + cf_joypad_on_button_down(id, (int)event->gbutton.button); + } break; + + case SDL_EVENT_GAMEPAD_AXIS_MOTION: + { + SDL_JoystickID id = event->gaxis.which; + cf_joypad_on_axis_motion(id, (int)event->gaxis.axis, (int)event->gaxis.value); + } break; + + case SDL_EVENT_FINGER_DOWN: + { + uint64_t id = (uint64_t)event->tfinger.fingerID; + s_touch_remove(id); + CF_Touch& touch = app->touches.add(); + touch.id = id; + touch.pressure = event->tfinger.pressure; + touch.x = event->tfinger.x * app->w; + touch.y = event->tfinger.y * app->h; + } break; + + case SDL_EVENT_FINGER_MOTION: + { + uint64_t id = (uint64_t)event->tfinger.fingerID; + bool found = false; + for (int i = 0; i < app->touches.size(); ++i) { + if (app->touches[i].id == id) { + app->touches[i].pressure = event->tfinger.pressure; + app->touches[i].x = event->tfinger.x * app->w; + app->touches[i].y = event->tfinger.y * app->h; + found = true; + break; } - break; + } + if (!found) { + CF_Touch& touch = app->touches.add(); + touch.id = id; + touch.pressure = event->tfinger.pressure; + touch.x = event->tfinger.x * app->w; + touch.y = event->tfinger.y * app->h; + } + } break; - case SDL_EVENT_MOUSE_WHEEL: - app->mouse.wheel_motion = event.wheel.y; - break; + case SDL_EVENT_FINGER_UP: + { + uint64_t id = (uint64_t)event->tfinger.fingerID; + s_touch_remove(id); + } break; + } +} - case SDL_EVENT_GAMEPAD_BUTTON_UP: - { - SDL_JoystickID id = event.gbutton.which; - cf_joypad_on_button_up(id, (int)event.gbutton.button); - } break; +void cf_pump_input_msgs() +{ + // Handle SDL messages. + SDL_Event event; + while (SDL_PollEvent(&event)) { + s_handle_event(&event); + } +} - case SDL_EVENT_GAMEPAD_BUTTON_DOWN: - { - SDL_JoystickID id = event.gbutton.which; - cf_joypad_on_button_down(id, (int)event.gbutton.button); - } break; +// SDL text events carry pointers into SDL "temporary memory" that SDL frees before the next +// update can drain the buffer -- deep-copy the strings on buffer, free them after handling. +static void s_deep_copy_event(SDL_Event* event) +{ + if (event->type == SDL_EVENT_TEXT_INPUT && event->text.text) { + event->text.text = SDL_strdup(event->text.text); + } else if (event->type == SDL_EVENT_TEXT_EDITING && event->edit.text) { + event->edit.text = SDL_strdup(event->edit.text); + } +} - case SDL_EVENT_GAMEPAD_AXIS_MOTION: - { - SDL_JoystickID id = event.gaxis.which; - cf_joypad_on_axis_motion(id, (int)event.gaxis.axis, (int)event.gaxis.value); - } break; +static void s_free_event(SDL_Event* event) +{ + if (event->type == SDL_EVENT_TEXT_INPUT) { + SDL_free((void*)event->text.text); + } else if (event->type == SDL_EVENT_TEXT_EDITING) { + SDL_free((void*)event->edit.text); + } +} - case SDL_EVENT_FINGER_DOWN: - { - uint64_t id = (uint64_t)event.tfinger.fingerID; - s_touch_remove(id); - CF_Touch& touch = app->touches.add(); - touch.id = id; - touch.pressure = event.tfinger.pressure; - touch.x = event.tfinger.x * app->w; - touch.y = event.tfinger.y * app->h; - } break; +void cf_app_process_event(SDL_Event* event) +{ + if (!app || !event) return; + SDL_Event copy = *event; + s_deep_copy_event(©); + // The mutex matters because SDL can invoke SDL_AppEvent from other threads for some + // events (e.g. mobile lifecycle events are dispatched from the pushing thread). + cf_mutex_lock(&app->buffered_events_mutex); + if (app->buffered_events.count() == CF_MAX_BUFFERED_EVENTS) { + // Full: drop the oldest event, preserving the order of the rest. + s_free_event(&app->buffered_events[0]); + CF_MEMMOVE(app->buffered_events.data(), app->buffered_events.data() + 1, sizeof(SDL_Event) * (CF_MAX_BUFFERED_EVENTS - 1)); + app->buffered_events.pop(); + } + app->buffered_events.add(copy); + cf_mutex_unlock(&app->buffered_events_mutex); +} - case SDL_EVENT_FINGER_MOTION: - { - uint64_t id = (uint64_t)event.tfinger.fingerID; - CF_Touch touch; - if (cf_touch_get(id, &touch)) { - touch.pressure = event.tfinger.pressure; - touch.x = event.tfinger.x * app->w; - touch.y = event.tfinger.y * app->h; - } else { - CF_Touch& touch = app->touches.add(); - touch.id = id; - touch.pressure = event.tfinger.pressure; - touch.x = event.tfinger.x * app->w; - touch.y = event.tfinger.y * app->h; - } - } break; +void cf_drain_buffered_events() +{ + // Move the array out under the lock so event handlers run unlocked. + cf_mutex_lock(&app->buffered_events_mutex); + Cute::Array events = cf_move(app->buffered_events); + cf_mutex_unlock(&app->buffered_events_mutex); + for (int i = 0; i < events.count(); ++i) { + s_handle_event(&events[i]); + s_free_event(&events[i]); + } +} - case SDL_EVENT_FINGER_UP: - { - uint64_t id = (uint64_t)event.tfinger.fingerID; - s_touch_remove(id); - } break; - } +void cf_free_buffered_events() +{ + // Same move-out-then-free-unlocked pattern as cf_drain_buffered_events, so a concurrent + // cf_app_process_event call can't iterate/mutate the array at the same time as this loop. + cf_mutex_lock(&app->buffered_events_mutex); + Cute::Array events = cf_move(app->buffered_events); + cf_mutex_unlock(&app->buffered_events_mutex); + for (int i = 0; i < events.count(); ++i) { + s_free_event(&events[i]); } } diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index c6248cc59..c2337d8da 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -31,6 +31,10 @@ struct cs_context_t; CF_API extern struct CF_App* app; +// Upper bound on events buffered by cf_app_process_event between updates. Oldest events are +// dropped first once full, so an app that stops updating can't grow the buffer forever. +#define CF_MAX_BUFFERED_EVENTS 4096 + // Recreates the default offscreen canvas at logical_size * pixel_scale. Called on init and on // every canvas recreation event (window resize, display density change, cf_app_set_size, // cf_app_set_msaa) -- this is what makes cf_app_set_canvas_size a one-shot override. @@ -89,6 +93,8 @@ struct CF_App { // App stuff. bool running = true; + Cute::Array buffered_events; // Events fed by cf_app_process_event, applied at the top of the next update so input transitions survive the begin-frame prev-state copy. Guarded by buffered_events_mutex -- SDL can dispatch SDL_AppEvent from other threads (e.g. Android lifecycle events). + CF_Mutex buffered_events_mutex = cf_make_mutex(); int options = 0; void* platform_handle = NULL; CF_OnUpdateFn* user_on_update = NULL; diff --git a/src/internal/cute_input_internal.h b/src/internal/cute_input_internal.h index c02aa1605..5f5189fa6 100644 --- a/src/internal/cute_input_internal.h +++ b/src/internal/cute_input_internal.h @@ -23,6 +23,8 @@ struct CF_Haptic; void cf_begin_frame_input(); void cf_pump_input_msgs(); +void cf_drain_buffered_events(); +void cf_free_buffered_events(); void cf_joypad_update(); void cf_joypad_on_button_up(SDL_JoystickID id, int button); void cf_joypad_on_button_down(SDL_JoystickID id, int button); diff --git a/test/test_app.cpp b/test/test_app.cpp index 637384789..317648ffd 100644 --- a/test/test_app.cpp +++ b/test/test_app.cpp @@ -20,6 +20,10 @@ TEST_CASE(test_app_destroy_safety) CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); cf_destroy_app(); cf_destroy_app(); + // Queries must be safe (not crash) after destruction -- the cute_main.h glue + // calls cf_app_is_running right after a user's cf_main_update, which may have destroyed + // the app. + REQUIRE(!cf_app_is_running()); return true; } @@ -47,6 +51,176 @@ TEST_CASE(test_app_no_gfx_state_defaults) return true; } +TEST_CASE(test_app_main_callbacks_event_buffering) +{ + // Feeding an event with no app must be a safe no-op. + SDL_Event event = { }; + event.type = SDL_EVENT_KEY_DOWN; + event.key.scancode = SDL_SCANCODE_SPACE; + event.key.repeat = false; + cf_app_process_event(&event); + + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + + // Events fed from SDL_AppEvent are buffered, not applied immediately -- otherwise + // cf_app_update's begin-frame copy of key state to `prev` would erase the transition + // and just_pressed could never fire in callback mode. + cf_app_process_event(&event); + REQUIRE(!cf_key_down(CF_KEY_SPACE)); + + cf_app_update(NULL); + REQUIRE(cf_key_down(CF_KEY_SPACE)); + REQUIRE(cf_key_just_pressed(CF_KEY_SPACE)); + + // A stray cf_app_process_event call must NOT flip the app into callback mode -- + // that would permanently disable the internal event pump for classic-loop apps. + // Only CF_APP_OPTIONS_MAIN_CALLBACKS_BIT enables callback mode. + REQUIRE(!(cf_app_get_options() & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT)); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_main_callbacks_text_event_deep_copy) +{ + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + + // SDL text events carry pointers into SDL temporary memory that is freed before the + // next update runs -- the buffer must deep-copy the string, not the pointer. Simulate + // the free by clobbering the string after feeding the event. + char text[8]; + CF_STRCPY(text, "hi"); + SDL_Event event = { }; + event.type = SDL_EVENT_TEXT_INPUT; + event.text.text = text; + cf_app_process_event(&event); + CF_MEMSET(text, 'X', sizeof(text) - 1); + text[sizeof(text) - 1] = 0; + + cf_app_update(NULL); + REQUIRE(cf_input_text_has_data()); + // cf_input_text_pop_utf32 pops from the end of the buffer. + REQUIRE(cf_input_text_pop_utf32() == (int)'i'); + REQUIRE(cf_input_text_pop_utf32() == (int)'h'); + cf_input_text_clear(); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_main_callbacks_null_text_is_safe) +{ + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + + // s_deep_copy_event's SDL_strdup can return NULL under OOM, leaving text.text/edit.text + // NULL -- s_handle_event must not dereference it unconditionally. + SDL_Event text_input = { }; + text_input.type = SDL_EVENT_TEXT_INPUT; + text_input.text.text = NULL; + cf_app_process_event(&text_input); + + SDL_Event text_editing = { }; + text_editing.type = SDL_EVENT_TEXT_EDITING; + text_editing.edit.text = NULL; + text_editing.edit.start = 3; + text_editing.edit.length = 2; + cf_app_process_event(&text_editing); + + cf_app_update(NULL); + REQUIRE(!cf_input_text_has_data()); + REQUIRE(app->ime_composition_cursor == 0); + REQUIRE(app->ime_composition_selection_len == 0); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_main_callbacks_event_buffer_cap) +{ + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + + // The buffer must not grow without bound if events arrive while the app is not + // updating; oldest events are dropped first. + SDL_Event event = { }; + event.type = SDL_EVENT_MOUSE_WHEEL; + for (int i = 0; i < CF_MAX_BUFFERED_EVENTS + 10; ++i) { + cf_app_process_event(&event); + } + REQUIRE(app->buffered_events.count() == CF_MAX_BUFFERED_EVENTS); + + cf_app_update(NULL); + REQUIRE(app->buffered_events.count() == 0); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_touch_motion_updates_stored_touch) +{ + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + + SDL_Event event = { }; + event.type = SDL_EVENT_FINGER_DOWN; + event.tfinger.fingerID = 7; + event.tfinger.x = 0.25f; + event.tfinger.y = 0.25f; + event.tfinger.pressure = 0.5f; + cf_app_process_event(&event); + cf_app_update(NULL); + + // Finger motion must update the touch stored in the app, not a local copy. + event.type = SDL_EVENT_FINGER_MOTION; + event.tfinger.x = 0.75f; + event.tfinger.y = 0.75f; + event.tfinger.pressure = 1.0f; + cf_app_process_event(&event); + cf_app_update(NULL); + + CF_Touch touch = { }; + REQUIRE(cf_touch_get(7, &touch)); + REQUIRE(touch.pressure == 1.0f); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_main_callbacks_quit_event) +{ + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + REQUIRE(cf_app_is_running()); + + SDL_Event event = { }; + event.type = SDL_EVENT_QUIT; + cf_app_process_event(&event); + REQUIRE(cf_app_is_running()); + + cf_app_update(NULL); + REQUIRE(!cf_app_is_running()); + + cf_destroy_app(); + return true; +} + +TEST_CASE(test_app_main_callbacks_option_bit) +{ + // The bit is what turns the internal pump off, and cf_app_get_options reports it back -- + // the cute_main.h glue reads it to catch an app that forgot to pass it. + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT | CF_APP_OPTIONS_MAIN_CALLBACKS_BIT, NULL))); + REQUIRE(cf_app_get_options() & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT); + cf_destroy_app(); + + // Unlike the process-wide latch this replaced, the mode is per-app: recreating without + // the bit must come back in classic-loop mode, with no lingering process state. + CHECK(cf_is_error(cf_make_app(NULL, 0, 0, 0, 0, 0, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_GFX_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); + REQUIRE(!(cf_app_get_options() & CF_APP_OPTIONS_MAIN_CALLBACKS_BIT)); + cf_destroy_app(); + + // Null-safe outside an app's lifetime, so the glue's guard can't fault when cf_main_init + // returns success without ever calling cf_make_app. + REQUIRE(cf_app_get_options() == 0); + return true; +} + TEST_CASE(test_display_count_matches_list) { int count = cf_display_count(); @@ -216,6 +390,13 @@ TEST_SUITE(test_app) RUN_TEST_CASE(test_app_destroy_safety); RUN_TEST_CASE(test_app_power_state_mapping); RUN_TEST_CASE(test_app_no_gfx_state_defaults); + RUN_TEST_CASE(test_app_main_callbacks_event_buffering); + RUN_TEST_CASE(test_app_main_callbacks_text_event_deep_copy); + RUN_TEST_CASE(test_app_main_callbacks_null_text_is_safe); + RUN_TEST_CASE(test_app_main_callbacks_event_buffer_cap); + RUN_TEST_CASE(test_app_touch_motion_updates_stored_touch); + RUN_TEST_CASE(test_app_main_callbacks_quit_event); + RUN_TEST_CASE(test_app_main_callbacks_option_bit); RUN_TEST_CASE(test_display_count_matches_list); RUN_TEST_CASE(test_display_invalid_id_is_safe);