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);