Skip to content

Add opt-in support for SDL's main callbacks - #550

Open
pusewicz wants to merge 9 commits into
RandyGaul:masterfrom
pusewicz:sdl-main-callbacks
Open

Add opt-in support for SDL's main callbacks#550
pusewicz wants to merge 9 commits into
RandyGaul:masterfrom
pusewicz:sdl-main-callbacks

Conversation

@pusewicz

@pusewicz pusewicz commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

CF apps freeze while their window is being dragged or resized. SDL runs a nested iterate from inside its Cocoa/Win32 resize handler, but only when main callbacks are active — otherwise it just posts SDL_EVENT_WINDOW_EXPOSED, which CF ignores.

This adds an opt-in path: include cute_main.h in one source file, implement cf_main_init / cf_main_update / cf_main_quit, and pass CF_APP_OPTIONS_MAIN_CALLBACKS_BIT to cf_make_app. CF supplies the four SDL_App* callbacks. Existing apps are untouched — without the header the internal pump behaves exactly as before. cute_main.h is deliberately not pulled in by the umbrella cute.h, since it defines symbols with external linkage that may only exist in one translation unit — bullno1 flagged that the original #define CF_MAIN_USE_CALLBACKS + cute.h scheme put that in the wrong place.

It also removes the main-loop fork web games currently need. Today a CF game targeting emscripten hand-rolls #ifdef CF_EMSCRIPTEN around emscripten_set_main_loop(update, 60, true) against a while loop for native — samples/metaballs.cpp still does, and porting it is a natural follow-up. Under callbacks one code path covers both. Two further wins there: frames are paced by requestAnimationFrame rather than pinned at 60, and cf_main_quit actually executes at shutdown, which simulate_infinite_loop structurally cannot do. The pacing change does mean web games should drive animation off CF_DELTA_TIME rather than assuming 60hz.

The freeze fix is measured, not reasoned from SDL's source: two samples with identical drawing code and window options, differing only in loop shape. The classic while twin froze while the window edge was held; the callback-mode one kept animating, with CF's GPU frame surviving the nested iterate. The web behaviour above was checked in a browser, not just linked.

Events are deep-copied and buffered rather than applied on arrival. Applying immediately would race cf_app_update's begin-frame copy of key state to prev and eat every just_pressed transition; deep-copying is needed because SDL frees the temporary memory behind text events before the next update drains the buffer. No SDL type appears in the public API — cf_app_process_event takes void*, so cute_app.h gains no SDL declaration.

Both ways to get this wrong are loud. Omitting the option bit fails at startup with a message naming it, rather than leaving a window that silently receives no input; including SDL_main.h before cute_main.h is an #error rather than a program that links with no main.

One thing deliberately left alone: cf_destroy_app frees the event buffer and destroys buffered_events_mutex while another thread could still be inside cf_app_process_event. Narrow (mobile lifecycle events only) but real.

CI note: adds a smoke-test step for the new sample on all three platforms, reusing the existing smoke_test.sh.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt

Copilot AI review requested due to automatic review settings August 2, 2026 23:10
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in “SDL main callbacks” execution path to Cute Framework so apps can keep animating during window drag/resize (and support clean shutdown in callback-driven hosts, including web). This is implemented by buffering SDL events delivered via SDL_AppEvent and draining them at the start of cf_app_update, while keeping classic-loop behavior unchanged unless CF_APP_OPTIONS_MAIN_CALLBACKS_BIT is enabled.

Changes:

  • Introduces buffered event ingestion via cf_app_process_event (deep-copies text events) and drains buffered events during app update; classic polling remains the default.
  • Adds CF_MAIN_USE_CALLBACKS glue in include/cute.h that provides SDL_AppInit/Iterate/Event/Quit and validates CF_APP_OPTIONS_MAIN_CALLBACKS_BIT is set.
  • Adds a new main_callbacks sample, documentation, CI smoke coverage, and unit tests for buffering/deep-copy/cap/quit/option-bit behavior.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/test_app.cpp Adds tests for callback-mode buffering, text deep-copy, cap behavior, quit handling, and option-bit semantics.
src/internal/cute_input_internal.h Declares new internal helpers for draining/freeing buffered events.
src/internal/cute_app_internal.h Adds buffered event storage + mutex to CF_App and defines CF_MAX_BUFFERED_EVENTS.
src/cute_input.cpp Refactors event handling and implements event buffering, draining, and deep-copy/free for text events.
src/cute_app.cpp Integrates buffered-event draining into update, adds null-safe queries, and avoids double SDL_Quit under main callbacks.
samples/main_callbacks.c New sample demonstrating callback-driven main loop usage.
samples/CMakeLists.txt Builds the new main_callbacks sample.
mkdocs.yml Adds the new sample page to docs navigation.
include/cute.h Adds CF_MAIN_USE_CALLBACKS entrypoint/docs and defines the SDL main callbacks.
include/cute_app.h Adds CF_APP_OPTIONS_MAIN_CALLBACKS_BIT and public APIs cf_app_get_options / cf_app_process_event with docs.
docs/samples/main_callbacks.md New docs page embedding the sample.
.github/workflows/build.yml Adds CI smoke tests for the new sample.
Suppressed comments (1)

src/cute_input.cpp:619

  • SDL_EVENT_TEXT_EDITING assumes event->edit.text is non-NULL and unconditionally dereferences it in while (*text) .... If the pointer is NULL (e.g., SDL sends NULL or deep-copying fails), this will crash. Handle NULL by leaving an empty IME composition string and resetting cursor/selection fields.
		while (*text) app->ime_composition.add(*text++);

Comment thread src/cute_input.cpp
Comment on lines +607 to +613
case SDL_EVENT_TEXT_INPUT:
{
cf_input_text_add_utf8(event->text.text);
app->ime_composition.clear();
app->ime_composition_cursor = 0;
app->ime_composition_selection_len = 0;
} break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 96c33df. s_deep_copy_event's SDL_strdup can return NULL under OOM, so both event->text.text (line 609) and event->edit.text (line 618, the suppressed comment) needed the guard. For TEXT_EDITING, the NULL path also zeroes ime_composition_cursor/ime_composition_selection_len rather than leaving them pointing into an empty composition string. Added test_app_main_callbacks_null_text_is_safe covering both event types.

Comment thread src/cute_input.cpp
Comment on lines +791 to 797
void cf_free_buffered_events()
{
for (int i = 0; i < app->buffered_events.count(); ++i) {
s_free_event(&app->buffered_events[i]);
}
app->buffered_events.clear();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b629011, matching cf_drain_buffered_events's move-out-then-free-unlocked pattern. This closes the iterate-while-mutated race on the array itself, but not the broader destroy-time race you flagged in the next comment -- see reply there.

Comment thread src/cute_app.cpp
Comment on lines +420 to +421
cf_free_buffered_events();
destroy_mutex(&app->buffered_events_mutex);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the limitation the PR description already calls out: "cf_destroy_app frees the event buffer and destroys buffered_events_mutex while another thread could still be inside cf_app_process_event ... Narrow (mobile lifecycle events only) but real." Agreed it's real -- a proper fix needs a shutdown protocol (e.g. an in-flight counter the destroy path waits to drain, or moving the mutex/queue to a lifetime outside CF_App) that's a bigger design change than this PR's scope. Leaving it as a disclosed limitation for now rather than bolting on synchronization here; open to doing it as a follow-up if you'd rather it land before merge.

pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 3, 2026
s_deep_copy_event's SDL_strdup can return NULL under OOM, and
SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced
text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 3, 2026
It iterated and cleared app->buffered_events without the lock that
cf_app_process_event and cf_drain_buffered_events both take, so a
concurrent SDL_AppEvent dispatch could mutate the array mid-iteration.
Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the
broader destroy-time race the PR description already discloses: this
function still runs after the point where SDL could still be dispatching
into cf_app_process_event during shutdown.
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 5, 2026
s_deep_copy_event's SDL_strdup can return NULL under OOM, and
SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced
text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 5, 2026
It iterated and cleared app->buffered_events without the lock that
cf_app_process_event and cf_drain_buffered_events both take, so a
concurrent SDL_AppEvent dispatch could mutate the array mid-iteration.
Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the
broader destroy-time race the PR description already discloses: this
function still runs after the point where SDL could still be dispatching
into cf_app_process_event during shutdown.
@pusewicz
pusewicz force-pushed the sdl-main-callbacks branch from b629011 to 1fb3152 Compare August 5, 2026 06:12
@pusewicz

pusewicz commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Documentation CI on this PR will stay red until #553 lands — the remaining warnings come from master's 3d headers (via the merge preview), not from this branch.

pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 5, 2026
s_deep_copy_event's SDL_strdup can return NULL under OOM, and
SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced
text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
pusewicz added a commit to pusewicz/cute_framework that referenced this pull request Aug 5, 2026
It iterated and cleared app->buffered_events without the lock that
cf_app_process_event and cf_drain_buffered_events both take, so a
concurrent SDL_AppEvent dispatch could mutate the array mid-iteration.
Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the
broader destroy-time race the PR description already discloses: this
function still runs after the point where SDL could still be dispatching
into cf_app_process_event during shutdown.
@pusewicz
pusewicz force-pushed the sdl-main-callbacks branch from c6b6bc0 to 7670ebf Compare August 5, 2026 21:18
@bullno1

bullno1 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

I think instead of define and include cute.h (which is more of an umbrella header), this could be a separate header called cute_main.h instead. Merely including is enough to opt in and it should only be included in the main entry compilation unit.

That or it should at least be in cute_app, not the umbrella header

pusewicz and others added 7 commits August 6, 2026 09:40
Pure refactor: the per-event switch moves into s_handle_event so it can
be fed from sources other than the internal SDL_PollEvent loop (SDL's
main callbacks, next commit). The two key-repeat `continue`s become
`return`s; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyCqnpVTb6YAPyxTbDGGWw
Define CF_MAIN_USE_CALLBACKS before including cute.h and implement
cf_main_init/cf_main_update/cf_main_quit; CF supplies the four SDL_App*
callbacks. Opt in per-app with CF_APP_OPTIONS_MAIN_CALLBACKS_BIT, which
stops CF polling an event queue it no longer owns.

Three reasons this is worth having:

- Live resize no longer freezes. Verified A/B on macOS with two samples
  sharing identical drawing code and window options, differing only in
  loop shape: the classic while-loop twin froze while the window edge was
  held, the callback-mode one kept animating. SDL runs a nested iterate
  from inside its resize handler; CF's GPU frame survives it.
- The web build stops needing a main-loop fork. The same source runs on
  desktop and under emscripten with no #ifdef CF_EMSCRIPTEN, and
  cf_main_quit actually runs at shutdown -- something
  emscripten_set_main_loop with simulate_infinite_loop could never do.
  Confirmed in a browser, not just linked.
- Mobile lifecycle events are dispatched immediately rather than waiting
  on a poll, which is the platform-correct entry point on iOS/Android.

Events are deep-copied and buffered rather than applied on arrival.
Applying immediately would race cf_app_update's begin-frame copy of key
state to prev and eat every just_pressed transition. Deep-copying is
required because SDL frees the temporary memory backing text events
before the next update can drain the buffer. The buffer is mutex-guarded
since SDL can dispatch events from other threads, and capped at 4096 with
oldest-dropped so an app that stops updating cannot grow it forever.

No SDL type appears in the public API: cf_app_process_event takes void*,
so cute_app.h needs no SDL declaration. The glue in cute.h is the only
in-tree caller and passes the right type by construction.

Both ways to get this wrong are loud rather than silent. Omitting the
option bit under CF_MAIN_USE_CALLBACKS would otherwise leave a window
with no keyboard, mouse or quit, so startup fails with a message naming
the bit. Including SDL_main.h before cute.h would otherwise swallow the
callback machinery via SDL's include guard and link a program with no
main at all, so that is now an #error.

samples/main_callbacks.c demonstrates the shape and is the instrument for
the live-resize check above; it is registered for the web samples nav and
smoke-tested in CI on all three platforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt
The motion handler wrote to a stack copy returned by cf_touch_get and
never stored it back, so cf_touch_get/cf_touch_get_all reported the
touch-down coordinates for an entire drag. Pre-existing bug surfaced by
review of the event-pump split; the handler now updates the touch in
place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt
s_deep_copy_event's SDL_strdup can return NULL under OOM, and
SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced
text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
It iterated and cleared app->buffered_events without the lock that
cf_app_process_event and cf_drain_buffered_events both take, so a
concurrent SDL_AppEvent dispatch could mutate the array mid-iteration.
Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the
broader destroy-time race the PR description already discloses: this
function still runs after the point where SDL could still be dispatching
into cf_app_process_event during shutdown.
The Documentation CI check flags backticked references to symbols with
no docs page. CF_MAIN_USE_CALLBACKS is referenced throughout the new
callbacks API, so promote cute.h's explanatory comment block into a
real doc block on a dummy define (guarded so a user-supplied define is
never clobbered). Also point cf_app_get_options' brief at the
documented CF_AppOptionFlagBits enum instead of the undocumented
CF_AppOptionFlags typedef.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhrxqgfYE2bwuuwR3UiADA
bullno1 flagged on PR RandyGaul#550 that defining CF_MAIN_USE_CALLBACKS before
including the umbrella header puts entry-point code -- symbols with
external linkage that may only exist in one translation unit -- inside a
header every TU includes. Including cute_main.h is now the opt-in itself,
and it stays out of cute.h the way cute_debug_printf.h already does for a
different reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111WZiHxAfDcTkoLQ7Xg97Y
@pusewicz
pusewicz force-pushed the sdl-main-callbacks branch from 7670ebf to 5f34811 Compare August 6, 2026 07:47
The header split renamed the opt-in but missed three comments outside
the doc-parsed API surface.
@pusewicz

pusewicz commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Good call — split it out into a dedicated cute_main.h in 5f34811, kept out of the cute.h umbrella (same reasoning as cute_debug_printf.h's existing exclusion). Including it is now the opt-in itself; CF_MAIN_USE_CALLBACKS is gone. CF_MAIN is left alone -- different scope, no reports of it being awkward.

Comment thread include/cute_app.h Outdated
* 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(void* event);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of void* event, just forward declare SDL_Event:

union SDL_Event;

CF_API void CF_CALL cf_app_process_event(union SDL_Event* event);

See cf_app_get_window

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3309e77.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bullno1 Makes more sense, thanks!

union SDL_Event; is enough to type the parameter without pulling
SDL3's headers into cute_app.h, so the compiler can now check the
argument instead of accepting any void*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgaAhtyzb1Q9bpL7bSGCZN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants