Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/samples/main_callbacks.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions include/cute_app.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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); }

}

Expand Down
135 changes: 135 additions & 0 deletions include/cute_main.h
Original file line number Diff line number Diff line change
@@ -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 <SDL3/SDL_main.h>, 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: <SDL3/SDL_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 <SDL3/SDL_main.h>
#include <SDL3/SDL_log.h>

//--------------------------------------------------------------------------------------------------
// 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
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions samples/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions samples/main_callbacks.c
Original file line number Diff line number Diff line change
@@ -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 <cute.h>
#include <cute_main.h>

#include <stdio.h>

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.
}
26 changes: 22 additions & 4 deletions src/cute_app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +435 to +436

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.

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]);
Expand All @@ -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);
Expand Down
Loading
Loading