Skip to content

Add support for opting-in to SDL main callbacks - #618

Merged
RandyGaul merged 4 commits into
RandyGaul:masterfrom
bullno1:app-callbacks
Sep 18, 2026
Merged

RandyGaul merged 4 commits into
RandyGaul:masterfrom
bullno1:app-callbacks

Conversation

@bullno1

@bullno1 bullno1 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Motivation: using callbacks,,apps no longer freeze while being dragged. On the web, vsync is done for free. It seems cf_app_set_present_mode never really works on web (SDL_GL_SwapWindow only yield, not wait for requestAnimationFrame).

Opt in path

  • In the main entry file: define SDL_MAIN_USE_CALLBACKS, CF_MAIN (existing check) then include cute.h
  • Define SDL_AppInit, SDL_AppIterate and SDL_AppQuit to handle the application lifecycle.

The names are not wrapped because using callback is not that common.
But they could be.

New API functions

cf_app_push_event to send a SDL_Event to CF.

This is what SDL_AppEvent should call.
cute.h includes cute_main_callbacks.h if CF_MAIN is defined.
This header defines a default SDL_AppEvent which does the forwarding so user code only has to write Init, Iterate and Quit.
This can be disabled with CF_MAIN_CUSTOM_APP_EVENT.

A custom SDL_AppEvent also allows one to filter or see events before CF (e.g: smooth mouse look) if needed.

Implementations details

Internally, events are pushed into a queue with cf_app_push_event.
Text events are deep copied.
s_poll_event checks whether CF is on the callback or main loop path and either drains from the queue or polls from SDL.
This replaces the call to SDL_PollEvent in cf_pump_input_msgs.

So event works just as before.
Inputs are still coalesced at every fixed ticks.

@pusewicz

Copy link
Copy Markdown
Contributor

There is some prior art at #550 regarding this particular problem.

Comment thread src/cute_input.cpp Outdated
app->pending_event_text.add(0);
}
app->pending_events.add(pending);
app->using_main_callbacks = true;

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.

Could this be moved somewhere where we do not write the boolean over and over again?

Given that we return on some of the events above, and as mentioned those are dispatched immediately, would it not be beneficial to set the using_main_callbacks to true at the very top of this function?

@bullno1 bullno1 Sep 17, 2026

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.

A boolean write is basically nothing in the grand scheme of things. Esp when it's next to a pointer bump and write (pending_events.add). And the writes are independent.
The majority of times, this is called from the "main" thread anw.

The clanker's threading claim is overblown. SDL guarantees that events dispatched by SDL itself are serialized:

SDL is responsible for pumping the event queue between each call to SDL_AppIterate, so in normal operation one should only get events in a serial fashion

https://wiki.libsdl.org/SDL3/SDL_AppEvent

The implementation does serialize calls to SDL_AppEvent: https://github.com/libsdl-org/SDL/blob/dc05826028d6850ccc48661010ac444942cec0af/src/core/android/SDL_android.c#L2720

It's only a problem if you call SDL_PushEvent yourself because it bypasses the internal lock.

The bigger concern is since we give up the main loop, AppIterate could get called before any AppEvent and some events are handled in poll mode but it's harmless. Checked the implementation, it's impossible. SDL's main loop will pump event and it will trigger pending event through AppEvent.

This function can be simplified further actually. Just check for text and copy. Ignore the whole dropping lifecycle event thing.

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.

So in short, I think it's overly defensive again but I don't see a way for it to be wrong.

@bullno1 bullno1 Sep 17, 2026

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.

Done, now it's guaranteed that if the app uses callback at all, it will always set the flag as early as possible. SDL_PollEvent won't even be called.

@pusewicz pusewicz 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.

Love it!

@RandyGaul

Copy link
Copy Markdown
Owner

Not a big fan of callbacks. Is there not a way to get unfreezing without forcing CF to hijack the main loop?

@bullno1

bullno1 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

There is a way but it can be fragile and maybe equally annoying.

First, the problem: Take Windows for example, during a resize, it will only call WndProc and block PeekMessage, which is what SDL uses for SDL_PollEvent.
So to update during a resize, SDL decided to have callbacks that they can invoke inside the WndProc.
SDL effectively define the API for "update during resize" as callbacks. Or rather: "update during modal mode".

Windows is not the only platform with that behavior and resize is not the only thing: vsync in web, weird OS features like gamecenter on iOS... They all have about the same shape: The OS wants to own the loop so SDL complies and plugs the user-provided callbacks into it. That's how they deal with an OS that has a "modal mode".

But I can think of a way: coroutine or thread..

If we lift the entire entrypoint into a coroutine, it can work. Probably.
The main loop is suspended and resumed at appropriate times.
This is how web already works today.
Vsync is just not done today because SDL doesn't know about the "boundary" between init, update and cleanup.
It defines the API as those callbacks so it doesn't know what to do when we don't.

To do this, CF has to supplies the required callbacks to satisfy SDL.
Take the getting started example:

#include <cute.h>

int main(int argc, char* argv[])  // Something similar to SDL_main shenanigans to make this cf_main and lift it into a coroutine
{
    // The start of the function is called by `SDL_AppInit`
    cf_make_app("Fancy Window Title", 0, 0, 0, 640, 480, CF_APP_OPTIONS_WINDOW_POS_CENTERED_BIT, argv[0]);

    while (cf_app_is_running())  // Yield on the first call, this is the end of `SDL_AppInit`
    {
        cf_app_update();  // No change
						  // or alternatively, yield on the first call of `cf_app_update`
						  // so a `while(true)` loop with `if (!cf_app_is_running){ break; }` works too
        // All your game logic and updates go here...

        app_draw_onto_screen(); // Yield on every call, now the entire loop is driven by `SDL_AppIterate`
    }
    // The moment CF detects that the app is no longer running, it sets a flag.
    // `SDL_AppIterate` must **not** resume the coroutine.
    // Only now `SDL_AppQuit` can resume **once**.
	// The rest of this function becomes `SDL_AppQuit`

    destroy_app();  // Run as usual

    return 0;  // Result translated into SDL status code
}

So with coroutine, a single function with a loop can be split into 3 functions with carefully engineered suspension points.
Now to opt-in, there are several options:

  • Define a macro before including cute.h
  • Include a special header

Both will do the wiring:

  • alias main to something else
  • Supply the callbacks to plug into SDL
  • Tell SDL that we want callback mode by including SDL_main

So almost no change from user code.
Either add a #define CF_MAIN_WITH_CALLBACKS before cute.h or #include <cute_main_callbacks.h>.
Naming is hard.

Why it's fragile:

  • This will require coroutine. But it's available for all platforms we support.
    There are problems like it may be weird in a debugger or a crash log.
  • If the user writes the loop in a different shape, it might break. For example, goto or break out of the loop.
    But it's detectable: as soon as the coroutine terminates, SDL_AppIterate can flag that the application has ended and return to SDL the correct status code. SDL_AppQuit becomes noop. It already has to have a terminated guard anw.

The callbacks supplied by CF will be something like:

extern int cf_main(int argc, char* argv[]);  // `main` is renamed into this through macro

SDL_AppResult SDL_AppInit(void** appstate, int argc, char* argv[])
{
    return cf_app_callback_init(cf_main, argc, argv);  // init CF in callback mode, spawn the coroutine
													   // it will be suspended on the first `cf_app_update`
}

SDL_AppResult SDL_AppIterate(void** appstate)
{
  return cf_app_callback_iterate();  // Resume the coroutine if it has not terminated and `cf_app_is_running` is true
}

SDL_AppResult SDL_AppQuit(void** appstate)
{
  return cf_app_callback_quit();  // Resume the coroutine **once** iff `cf_app_is_running` is false
}

SDL_AppResult SDL_AppEvent(void* appstate, SDL_Event* event)
{
  return cf_app_callback_event(event);  // same behavior as cf_app_push_event in this PR
										// simply buffer the events so `cf_app_update` can process them
}

The majority of it is just forwarding into CF. cf_app_callback_init switches the mode entirely so functions like cf_app_update or cf_app_draw_onto_screen can behave differently.

So the answer to "not hijack the main loop" is "hijack the entire entrypoint instead".

@RandyGaul
RandyGaul merged commit 6535f70 into RandyGaul:master Sep 18, 2026
14 checks passed
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