From 0526c61a401d9e7fd51d12a17658f7d8814d88ec Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Fri, 14 Aug 2026 10:48:00 +0200 Subject: [PATCH 01/13] Spike: bullno1's explicit pixel-scale model (PR #575 discussion) Experiment, not a proposal: remove all event-driven HiDPI automagic in favor of user-controlled state, to see what the model looks like. - cf_app_set_pixel_scale: pixel scale is now a plain user value (AA + glyph density); the canvas is resized explicitly via set_canvas_size. - cf_app_pixel_scale_was_changed + cf_app_get_natural_pixel_scale; a density change only raises dpi_scale_was_changed, never applies. - Startup is the single automatic step: canvas at natural density, default 2d projection from the logical window size -- both set once. - cf_draw_projection is now truly sticky: window resizes, canvas recreation, and MSAA changes never touch the projection. - hidpi sample carries the copy-paste resize/density recipe and forced 1x/2x/4x switching; test_hidpi reworked for the new contracts. 350/350 tests pass on a 2x display (master baseline: 319/339 -- the 20 Retina failures were the half-size regression, which dies structurally here since nothing ever rebuilds the projection in pixel units). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn --- include/cute_app.h | 74 ++++-- samples/hidpi.c | 89 +++++-- src/cute_app.cpp | 44 ++-- src/cute_draw.cpp | 14 +- src/cute_input.cpp | 34 +-- src/internal/cute_app_internal.h | 9 +- src/internal/cute_draw_internal.h | 8 +- test/CMakeLists.txt | 1 + test/main.cpp | 2 + test/test_app.cpp | 27 ++- test/test_app_shared.cpp | 12 +- test/test_hidpi.cpp | 380 ++++++++++++++++++++++++++++++ 12 files changed, 596 insertions(+), 98 deletions(-) create mode 100644 test/test_hidpi.cpp diff --git a/include/cute_app.h b/include/cute_app.h index ead3b798c..852425b12 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -200,7 +200,7 @@ CF_API CF_DisplayOrientation CF_CALL cf_display_orientation(CF_DisplayID display CF_ENUM(APP_OPTIONS_GFX_OPENGL_BIT, 1 << 11) \ /* @entry Starts the application with a debug mode graphics context. */ \ 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. */ \ + /* @entry Disables the OS's high-pixel-density (Retina/HiDPI) backbuffer, so the window starts with a 1:1 logical-to-physical backbuffer and an initial pixel scale of 1.0f. */ \ CF_ENUM(APP_OPTIONS_NO_HIGH_DPI_BIT, 1 << 13) \ /* @end */ @@ -421,22 +421,61 @@ CF_API bool CF_CALL cf_app_display_scale_was_changed(void); /** * @function cf_app_get_pixel_scale * @category app - * @brief Returns the number of physical pixels per logical point for the app's window. - * @remarks This is the ratio CF actually renders at internally -- e.g. 2.0f on a 2x Retina display. Unlike - * `cf_app_get_display_scale` (the OS's suggested UI content scale, which is informational only), this value - * directly reflects the backbuffer/canvas pixel density and is what you'd multiply a logical size by to - * get physical pixels. Returns 1.0f if `CF_APP_OPTIONS_NO_HIGH_DPI_BIT` was passed to `cf_make_app`. - * @related cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height + * @brief Returns the pixel scale used for rendering: physical pixels per logical point. + * @remarks This value drives antialiasing width and glyph rasterization density, and is what you'd multiply a + * logical size by to size a pixel-perfect render target. It is a user-controlled value, like the window + * or canvas size: it starts at the window's natural display density (e.g. 2.0f on a 2x Retina display, + * or 1.0f if `CF_APP_OPTIONS_NO_HIGH_DPI_BIT` was passed to `cf_make_app`) and afterwards changes only + * through `cf_app_set_pixel_scale`. See `cf_app_get_natural_pixel_scale` for the display's live density. + * @related cf_app_set_pixel_scale cf_app_get_natural_pixel_scale cf_app_pixel_scale_was_changed cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height */ CF_API float CF_CALL cf_app_get_pixel_scale(void); +/** + * @function cf_app_set_pixel_scale + * @category app + * @brief Sets the pixel scale (physical pixels per logical point) used for rendering. + * @param scale The new pixel scale. Must be greater than zero; other values are ignored. + * @remarks Only the scale value itself changes: antialiasing width adjusts immediately and text re-rasterizes at + * the new density, but the app canvas keeps its current size. To render at the new density also resize + * the canvas, e.g. `cf_app_set_canvas_size(window_w * scale, window_h * scale)`. The draw API stays in + * logical points throughout. `cf_app_pixel_scale_was_changed` reports true for one frame after a change. + * See the hidpi sample for the full resize/density-change recipe. + * @related cf_app_get_pixel_scale cf_app_get_natural_pixel_scale cf_app_pixel_scale_was_changed cf_app_set_canvas_size + */ +CF_API void CF_CALL cf_app_set_pixel_scale(float scale); + +/** + * @function cf_app_pixel_scale_was_changed + * @category app + * @brief Returns true for one frame after the pixel scale was changed by `cf_app_set_pixel_scale`. + * @remarks Latched at the next `cf_app_update` after the change, mirroring `cf_app_display_scale_was_changed` -- + * code that reads it sees the flag for exactly one full frame. + * @related cf_app_set_pixel_scale cf_app_get_pixel_scale cf_app_display_scale_was_changed + */ +CF_API bool CF_CALL cf_app_pixel_scale_was_changed(void); + +/** + * @function cf_app_get_natural_pixel_scale + * @category app + * @brief Returns the display's native density for the app's window: physical pixels per logical point as reported by the OS. + * @remarks This is informational and refreshed automatically when the window moves to a display with a different + * density (which also raises `cf_app_display_scale_was_changed`). It is never applied automatically -- pass it + * to `cf_app_set_pixel_scale` to track the display's density, or ignore it to keep a fixed scale. + * @related cf_app_get_pixel_scale cf_app_set_pixel_scale cf_app_display_scale_was_changed + */ +CF_API float CF_CALL cf_app_get_natural_pixel_scale(void); + /** * @function cf_app_set_size * @category app - * @brief Sets the size of the window in pixels. - * @param w The width of the window in pixels. - * @param h The height of the window in pixels. - * @related cf_app_get_size cf_app_get_position cf_app_set_position + * @brief Sets the size of the window in logical points. + * @param w The width of the window in logical points. + * @param h The height of the window in logical points. + * @remarks Only the window changes. The app canvas and the default 2d projection keep their current size -- + * update them alongside if desired, e.g. `cf_app_set_canvas_size` and `cf_draw_projection`; see the + * hidpi sample for the recipe. + * @related cf_app_get_size cf_app_get_position cf_app_set_position cf_app_set_canvas_size */ CF_API void CF_CALL cf_app_set_size(int w, int h); @@ -729,11 +768,11 @@ CF_API CF_Canvas CF_CALL cf_app_get_canvas(void); * @param h The height in pixels to resize the canvas to. * @remarks Be careful about calling this function, as it will invalidate any old references from `cf_app_get_canvas`. * - * This is a one-shot override. The app's canvas is automatically recreated at window size (in points) times - * `cf_app_get_pixel_scale` on every canvas recreation event -- a window resize, moving to a display with a - * different pixel density, `cf_app_set_size`, or `cf_app_set_msaa` -- so a custom size lasts only until the - * next such event. For a persistent fixed-resolution render target (e.g. a retro/pixel-art look) make your - * own canvas with `cf_make_canvas` and draw it scaled-up with `cf_draw_canvas`; see the canvas_modes sample. + * The canvas keeps this size until the next `cf_app_set_canvas_size` call -- nothing resizes it behind + * your back. It is created once at startup at window size (in points) times the display's pixel density; + * after that, window resizes and display density changes only raise `cf_app_was_resized` / + * `cf_app_display_scale_was_changed`, and resizing the canvas in response is up to you (see the hidpi sample + * for the recipe). * @related cf_app_get_canvas cf_app_get_canvas_width cf_app_get_canvas_height cf_app_get_pixel_scale cf_app_set_canvas_blit_filter cf_make_canvas cf_draw_canvas */ CF_API void CF_CALL cf_app_set_canvas_size(int w, int h); @@ -973,6 +1012,9 @@ CF_INLINE int app_get_height() { return cf_app_get_height(); } CF_INLINE float app_get_display_scale() { return cf_app_get_display_scale(); } CF_INLINE bool app_display_scale_was_changed() { return cf_app_display_scale_was_changed(); } CF_INLINE float app_get_pixel_scale() { return cf_app_get_pixel_scale(); } +CF_INLINE void app_set_pixel_scale(float scale) { cf_app_set_pixel_scale(scale); } +CF_INLINE bool app_pixel_scale_was_changed() { return cf_app_pixel_scale_was_changed(); } +CF_INLINE float app_get_natural_pixel_scale() { return cf_app_get_natural_pixel_scale(); } CF_INLINE void app_center_window() { cf_app_center_window(); } CF_INLINE bool app_was_resized() { return cf_app_was_resized(); } CF_INLINE bool app_was_moved() { return cf_app_was_moved(); } diff --git a/samples/hidpi.c b/samples/hidpi.c index 3b0dcd32f..742a21f93 100644 --- a/samples/hidpi.c +++ b/samples/hidpi.c @@ -1,15 +1,37 @@ /* - hidpi.c -- HiDPI / Retina rendering visual verification. - - Cute Framework renders its default canvas at physical resolution (logical - size scaled by `cf_app_get_pixel_scale()`), so text and shapes stay crisp - on Retina/HiDPI displays without any extra work from the user. This sample - is a quick visual check of that: run it on a HiDPI display and glyph edges - and shape antialiasing should look sharp, not soft/blurry. - - See docs/topics/hidpi.md for the full point/pixel model, and - samples/canvas_modes.c for an interactive tour of app-canvas sizing - (custom scale, forced 1x, and fixed-resolution retro canvases). + hidpi.c -- HiDPI / Retina rendering, the manual way. + + The pixel scale (physical pixels per logical point) is a plain user-controlled + value, like the window or canvas size. At startup CF creates the app canvas + once at window_points * the display's natural density and sets the default 2d + projection once from the logical window size -- and never touches either again. + Reacting to window resizes and display-density changes is YOUR code, and this + sample is the copy-paste recipe: + + // React to a window resize (and/or a density change while tracking the + // display): re-apply scale, canvas, and projection in one place. + static void apply_pixel_scale(float scale) + { + int w = cf_app_get_width(); + int h = cf_app_get_height(); + cf_app_set_pixel_scale(scale); + cf_app_set_canvas_size((int)(w * scale + 0.5f), (int)(h * scale + 0.5f)); + cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); + } + + // In the main loop: + if (cf_app_was_resized()) apply_pixel_scale(cf_app_get_pixel_scale()); + if (cf_app_dpi_scale_was_changed() && tracking_the_display) { + apply_pixel_scale(cf_app_get_natural_pixel_scale()); + } + + A fixed-size, non-resizable window on one display needs NONE of this -- the + startup defaults are already correct. + + Interactivity: press N to track the display's natural density (the default), + or 1 / 2 / 4 to force a 1x / 2x / 4x pixel scale -- forcing a value is also + how you test HiDPI behavior on a non-HiDPI monitor. Resize the window to + watch the recipe keep everything crisp. What it draws: - Text at three sizes (12px / 24px / 48px) to eyeball glyph @@ -17,16 +39,24 @@ - A row of basic SDF shapes (filled circle, outlined circle, lines of varying thickness including a thin ~1px line, a filled rounded box, and an outlined triangle) to eyeball shape edge antialiasing. - - A live readout of `cf_app_get_pixel_scale()` alongside the physical - canvas size, so the current display's HiDPI scale factor is visible - at a glance. - - No interactivity beyond closing the window; no external assets needed. + - A live readout of the applied and natural pixel scales alongside + the physical canvas size. */ #include #include +// The whole "automatic HiDPI" replacement, in one function: apply a pixel scale and +// rebuild the canvas and projection to match the current window size. +static void apply_pixel_scale(float scale) +{ + int w = cf_app_get_width(); + int h = cf_app_get_height(); + cf_app_set_pixel_scale(scale); + cf_app_set_canvas_size((int)(w * scale + 0.5f), (int)(h * scale + 0.5f)); + cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); +} + // Draws `text` such that it's horizontally centered underneath/at `top_center`, // with `top_center.y` acting as the top of the text (matches cf_draw_text's // top-left-origin convention). @@ -54,13 +84,27 @@ int main(int argc, char* argv[]) cf_sprite_play(&sprite, "idle"); sprite.scale = cf_v2(3.0f, 3.0f); + // true = follow the display's natural density; false = a forced 1x/2x/4x scale. + bool track_natural = true; + while (cf_app_is_running()) { cf_app_update(NULL); + // Scale mode switching. Forcing a scale on purpose is exactly the same call the + // engine-side recipe uses -- there is no separate "override" concept. + if (cf_key_just_pressed(CF_KEY_N)) { track_natural = true; apply_pixel_scale(cf_app_get_natural_pixel_scale()); } + if (cf_key_just_pressed(CF_KEY_1)) { track_natural = false; apply_pixel_scale(1.0f); } + if (cf_key_just_pressed(CF_KEY_2)) { track_natural = false; apply_pixel_scale(2.0f); } + if (cf_key_just_pressed(CF_KEY_4)) { track_natural = false; apply_pixel_scale(4.0f); } + + // The manual-model recipe: window resized -> rebuild canvas + projection at the + // current scale. Density changed (moved to another monitor) -> re-apply the new + // natural scale, but only when tracking it. if (cf_app_was_resized()) { - // Nothing special to handle here -- part of the point of this - // sample is to observe how resizing/HiDPI scaling affects - // rendering crispness. + apply_pixel_scale(cf_app_get_pixel_scale()); + } + if (cf_app_dpi_scale_was_changed() && track_natural) { + apply_pixel_scale(cf_app_get_natural_pixel_scale()); } cf_push_font("Calibri"); @@ -79,14 +123,15 @@ int main(int argc, char* argv[]) cf_pop_font_size(); // -- Live pixel-scale readout -- - char pixel_scale_buf[128]; + char pixel_scale_buf[192]; float pixel_scale = cf_app_get_pixel_scale(); int physical_w = cf_app_get_canvas_width(); int physical_h = cf_app_get_canvas_height(); snprintf( pixel_scale_buf, sizeof(pixel_scale_buf), - "pixel_scale: %.2fx (physical canvas: %dx%d)", - pixel_scale, physical_w, physical_h + "pixel_scale: %.2fx %s (natural: %.2fx, physical canvas: %dx%d) -- press N/1/2/4", + pixel_scale, track_natural ? "[natural]" : "[forced]", + cf_app_get_natural_pixel_scale(), physical_w, physical_h ); cf_push_font_size(12); draw_text_centered(pixel_scale_buf, cf_v2(0, 300)); diff --git a/src/cute_app.cpp b/src/cute_app.cpp index fe7a07845..89a96d8d6 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -156,14 +156,6 @@ static void s_canvas(int w, int h) app->offscreen_canvas = cf_make_canvas(params); app->canvas_w = w; app->canvas_h = h; - cf_draw_on_app_canvas_resized(w, h); -} - -void cf_app_recreate_default_canvas_if_needed() -{ - int w = (int)CF_ROUNDF(app->w * app->pixel_scale); - int h = (int)CF_ROUNDF(app->h * app->pixel_scale); - s_canvas(w, h); } CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, int y, int w, int h, CF_AppOptionFlags options, const char* argv0) @@ -344,6 +336,10 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, app->pixel_scale = window ? SDL_GetWindowPixelDensity(app->window) : 1.0f; if (app->pixel_scale <= 0.0f) app->pixel_scale = 1.0f; if (options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT) app->pixel_scale = 1.0f; + // The initial pixel scale IS the natural density (with NO_HIGH_DPI the window has a + // 1x backbuffer, so SDL reports 1.0 and both values agree). After init, pixel_scale + // only ever changes via cf_app_set_pixel_scale. + app->natural_pixel_scale = app->pixel_scale; } ::app = app; cf_make_aseprite_cache(); @@ -361,7 +357,10 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, cf_load_internal_shaders(); cf_make_draw(); - cf_app_recreate_default_canvas_if_needed(); + // The one and only automatic canvas/projection setup: canvas at the window's natural + // pixel size, default 2d projection (set by cf_make_draw) spanning the logical size. + // From here on, canvas size, pixel scale, and projection change only by user calls. + s_canvas((int)CF_ROUNDF(app->w * app->pixel_scale), (int)CF_ROUNDF(app->h * app->pixel_scale)); // Create the default font. make_font_from_memory(calibri_data, calibri_sz, "Calibri"); @@ -650,16 +649,31 @@ float cf_app_get_pixel_scale() return app->pixel_scale; } +void cf_app_set_pixel_scale(float scale) +{ + if (!(scale > 0)) return; + if (scale == app->pixel_scale) return; + app->pixel_scale = scale; + app->pixel_scale_changed_pending = true; + cf_draw_on_pixel_scale_changed(); +} + +bool cf_app_pixel_scale_was_changed() +{ + return app->pixel_scale_was_changed; +} + +float cf_app_get_natural_pixel_scale() +{ + return app->natural_pixel_scale; +} + void cf_app_set_size(int w, int h) { SDL_SetWindowSize(app->window, w, h); app->w = w; app->h = h; app->sync_window = true; - // Recreate the app canvas now rather than waiting for the resize event: hidden windows - // don't reliably deliver one, and a caller who set the size expects the canvas (and the - // default 2d projection that tracks it) to match immediately. - cf_app_recreate_default_canvas_if_needed(); } void cf_app_get_position(int* x, int* y) @@ -773,7 +787,9 @@ bool cf_app_set_msaa(int sample_count) if (supported && app->sample_count != sample_count) { app->sample_count = sample_count; - cf_app_recreate_default_canvas_if_needed(); + // Rebuild the canvas with the new sample count at its current size -- an MSAA + // change must not stomp a user-chosen canvas size. + s_canvas(app->canvas_w, app->canvas_h); } return supported; diff --git a/src/cute_draw.cpp b/src/cute_draw.cpp index 545bfb43c..2b4263740 100644 --- a/src/cute_draw.cpp +++ b/src/cute_draw.cpp @@ -5312,20 +5312,16 @@ static void s_process_command(CF_Canvas canvas, CF_Command* cmd, CF_Command* nex } } +void cf_draw_on_pixel_scale_changed() +{ + if (s_draw) s_draw->set_aaf(); +} + // Runs the atlas defrag at most once per frame. Defrag walks every atlas and can rebuild // pages (re-fetching every resident image's pixels), so per-flush invocation turns a frame // with N mesh/canvas fences into N full defrags. Images first seen after this frame's defrag // ride the lonely buffer (own texture, own batch) until the next frame's defrag packs them: // one frame of extra draw calls for brand-new content, instead of N defrags every frame. -void cf_draw_on_app_canvas_resized(int w, int h) -{ - // The default 2d projection tracks the app canvas 1:1. It used to be computed once at - // startup and never again, so any resize (cf_app_set_size or a user dragging a resizable - // window) silently rescaled every world-space 2d draw. Refresh it with the canvas; a - // custom cf_draw_projection is per-frame state and simply overrides this as usual. - if (s_draw) s_draw->projection = ortho_2d(0, 0, (float)w, (float)h); -} - void cf_atlas_defrag_once() { if (s_draw->delay_defrag || s_draw->defragged_this_frame) return; diff --git a/src/cute_input.cpp b/src/cute_input.cpp index 321959f23..c3976b82b 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -477,6 +477,10 @@ void cf_begin_frame_input() app->window_state.restored = false; app->window_state.resized = false; app->display_scale_was_changed = false; + // cf_app_set_pixel_scale runs from user code mid-frame; latching here makes the flag + // visible for exactly the following frame, mirroring display_scale_was_changed. + app->pixel_scale_was_changed = app->pixel_scale_changed_pending; + app->pixel_scale_changed_pending = false; cf_joypad_update(); // Update key durations to simulate "press and hold" style for `key_repeating`. @@ -499,23 +503,22 @@ void cf_begin_frame_input() } } -// Re-queries the window's physical pixel density and, if it changed, updates -// app->pixel_scale and recreates the default canvas to match. -// No-ops entirely when CF_APP_OPTIONS_NO_HIGH_DPI_BIT is set, since pixel_scale -// must stay pinned at 1.0f in that mode. +// Re-queries the window's physical pixel density and records it. Nothing is applied +// automatically -- the applied scale (app->pixel_scale) only changes when the user calls +// cf_app_set_pixel_scale. A density change raises display_scale_was_changed so scale-tracking +// user code has a single event to watch. // Called from both SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED and // SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED -- the former is the OS's content-scale // signal and the latter is the authoritative physical-pixel-size signal; // either can fire without the other depending on platform/monitor setup, so // both are handled the same way and this is idempotent when both fire together. -static void s_refresh_pixel_scale() -{ - if (app->options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT) return; - float pixel_scale = SDL_GetWindowPixelDensity(app->window); - if (pixel_scale <= 0.0f) pixel_scale = 1.0f; - if (pixel_scale != app->pixel_scale) { - app->pixel_scale = pixel_scale; - cf_app_recreate_default_canvas_if_needed(); +static void s_refresh_natural_pixel_scale() +{ + float density = SDL_GetWindowPixelDensity(app->window); + if (density <= 0.0f) density = 1.0f; + if (density != app->natural_pixel_scale) { + app->natural_pixel_scale = density; + app->display_scale_was_changed = true; } } @@ -535,10 +538,11 @@ void cf_pump_input_msgs() break; case SDL_EVENT_WINDOW_RESIZED: + // Bookkeeping only: the canvas and projection are the user's to update in + // response (see cf_app_was_resized and the hidpi sample for the recipe). 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: @@ -578,11 +582,11 @@ void cf_pump_input_msgs() case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: app->display_scale = SDL_GetWindowDisplayScale(app->window); app->display_scale_was_changed = true; - s_refresh_pixel_scale(); + s_refresh_natural_pixel_scale(); break; case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: - s_refresh_pixel_scale(); + s_refresh_natural_pixel_scale(); break; case SDL_EVENT_KEY_DOWN: diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index d5f674ba5..2f6bd1803 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -31,10 +31,6 @@ struct cs_context_t; CF_API extern struct CF_App* app; -// 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. -void cf_app_recreate_default_canvas_if_needed(); // Maps an SDL_PowerState to the corresponding CF_PowerState. Header-inline (rather than // CF_API) so it stays testable from test/test_app.cpp without crossing the shared-library @@ -104,7 +100,10 @@ struct CF_App bool gfx_enabled = false; float display_scale = 1.0f; bool display_scale_was_changed = false; - float pixel_scale = 1.0f; // Physical pixels per logical point (from SDL_GetWindowPixelDensity). Drives default-canvas sizing, AA, and glyph rasterization. + float pixel_scale = 1.0f; // Physical pixels per logical point. User-controlled via cf_app_set_pixel_scale; initialized to the window's natural density (or 1.0 with NO_HIGH_DPI). Drives AA and glyph rasterization. + float natural_pixel_scale = 1.0f; // The density SDL reports for the window's current display; refreshed on density events, never applied automatically. + bool pixel_scale_was_changed = false; // Visible flag: true for the one frame following a pixel_scale change. + bool pixel_scale_changed_pending = false; // Set by cf_app_set_pixel_scale mid-frame; transferred to the visible flag at the next input pump. CF_Filter canvas_blit_filter = CF_FILTER_NEAREST; // Filter used when blitting the app canvas onto the screen, if their sizes differ (e.g. after cf_app_set_canvas_size). Defaults to nearest for a crisp/blocky pixel-art look. bool sync_window = false; int draw_call_count = 0; diff --git a/src/internal/cute_draw_internal.h b/src/internal/cute_draw_internal.h index 01a5e7cd9..9591983d8 100644 --- a/src/internal/cute_draw_internal.h +++ b/src/internal/cute_draw_internal.h @@ -469,9 +469,11 @@ void cf_draw3d_free_cmd(CF_Command* cmd); // Runs the atlas defrag at most once per frame (see CF_Draw::defragged_this_frame). void cf_atlas_defrag_once(); -// Called when the app's offscreen canvas is recreated (window resize / cf_app_set_size): -// refreshes the default 2d projection, which tracks the app canvas 1:1. -void cf_draw_on_app_canvas_resized(int w, int h); +// Called by cf_app_set_pixel_scale: the AA band width divides by pixel_scale, so a scale +// change must refresh it immediately (glyphs re-rasterize lazily -- pixel_scale is part of +// the glyph cache key). The projection is deliberately NOT touched: it is set once at init +// and only ever changed by the user via cf_draw_projection. +void cf_draw_on_pixel_scale_changed(); // Called by cf_render_layers_to before the canvas (and its render pass) is applied: stages // every in-range untextured mesh command's instance data into one shared GPU instance buffer diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index bca058697..a121cbd09 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -12,6 +12,7 @@ set(CF_TEST_SRCS test_coroutine.cpp test_doubly_list.cpp test_hashtable.cpp + test_hidpi.cpp test_path.cpp test_custom_sprite.cpp test_sprite.cpp diff --git a/test/main.cpp b/test/main.cpp index f4fe5ceb9..b65a7af24 100644 --- a/test/main.cpp +++ b/test/main.cpp @@ -39,6 +39,7 @@ TEST_SUITE(test_color); TEST_SUITE(test_coroutine); TEST_SUITE(test_doubly_list); TEST_SUITE(test_hashtable); +TEST_SUITE(test_hidpi); TEST_SUITE(test_path); TEST_SUITE(test_custom_sprite); TEST_SUITE(test_sprite); @@ -126,6 +127,7 @@ int main(int argc, char* argv[]) RUN_TRACED(test_shader_reload); RUN_TRACED(test_shader_directory); RUN_TRACED(test_canvas_clear); + RUN_TRACED(test_hidpi); RUN_TRACED(test_mrt); RUN_TRACED(test_texture_types); RUN_TRACED(test_shadow_sampling); diff --git a/test/test_app.cpp b/test/test_app.cpp index 637384789..0fe1ae6aa 100644 --- a/test/test_app.cpp +++ b/test/test_app.cpp @@ -91,12 +91,13 @@ struct OwnedAppGuard ~OwnedAppGuard() { cf_destroy_app(); } }; -TEST_CASE(test_app_set_canvas_size_is_one_shot) +TEST_CASE(test_app_set_canvas_size_is_persistent) { REQUIRE(!cf_is_error(cf_make_app(NULL, 0, 0, 0, 200, 100, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); OwnedAppGuard guard; - // The default canvas tracks the window at window_points * pixel_scale. + // Startup creates the default canvas at window_points * natural density -- the one and + // only automatic sizing. float scale = cf_app_get_pixel_scale(); REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(200 * scale)); REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(100 * scale)); @@ -106,16 +107,16 @@ TEST_CASE(test_app_set_canvas_size_is_one_shot) REQUIRE(cf_app_get_canvas_width() == 320); REQUIRE(cf_app_get_canvas_height() == 180); - // ...but is one-shot: the next recreation event snaps back to window * pixel_scale. + // ...and persists: a window resize is bookkeeping only, nothing resizes the canvas + // behind the user's back. cf_app_set_size(256, 128); - scale = cf_app_get_pixel_scale(); - REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(256 * scale)); - REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(128 * scale)); + REQUIRE(cf_app_get_canvas_width() == 320); + REQUIRE(cf_app_get_canvas_height() == 180); return true; } -TEST_CASE(test_app_msaa_change_resets_canvas_size) +TEST_CASE(test_app_msaa_change_preserves_canvas_size) { REQUIRE(!cf_is_error(cf_make_app(NULL, 0, 0, 0, 200, 100, CF_APP_OPTIONS_HIDDEN_BIT | CF_APP_OPTIONS_NO_AUDIO_BIT, NULL))); OwnedAppGuard guard; @@ -125,10 +126,10 @@ TEST_CASE(test_app_msaa_change_resets_canvas_size) REQUIRE(cf_app_get_canvas_height() == 180); if (cf_app_set_msaa(2)) { // MSAA support varies by backend/driver. - // An MSAA change is a recreation event like any other -- the one-shot size does not persist. - float scale = cf_app_get_pixel_scale(); - REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(200 * scale)); - REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(100 * scale)); + // The canvas is rebuilt for the new sample count at its CURRENT size -- an MSAA + // change must not stomp a user-chosen canvas size. + REQUIRE(cf_app_get_canvas_width() == 320); + REQUIRE(cf_app_get_canvas_height() == 180); } return true; @@ -221,8 +222,8 @@ TEST_SUITE(test_app) // Requires headless GPU context support in CI -- see // https://github.com/RandyGaul/cute_framework/pull/517 - RUN_TEST_CASE(test_app_set_canvas_size_is_one_shot); - RUN_TEST_CASE(test_app_msaa_change_resets_canvas_size); + RUN_TEST_CASE(test_app_set_canvas_size_is_persistent); + RUN_TEST_CASE(test_app_msaa_change_preserves_canvas_size); RUN_TEST_CASE(test_app_present_mode_vsync_always_supported); RUN_TEST_CASE(test_app_present_mode_off_round_trip); RUN_TEST_CASE(test_app_present_mode_mailbox_failure_does_not_corrupt_state); diff --git a/test/test_app_shared.cpp b/test/test_app_shared.cpp index ac527db5e..caadd6006 100644 --- a/test/test_app_shared.cpp +++ b/test/test_app_shared.cpp @@ -34,7 +34,17 @@ bool test_make_app(int w, int h, int extra_options) } if (s_alive && options == s_options) { - cf_app_set_size(w, h); // Recreates the app canvas + default 2d projection immediately. + cf_app_set_size(w, h); + // Nothing tracks the window automatically anymore: restore the startup-equivalent + // pixel scale, canvas size, and default 2d projection a previous test may have + // changed. (This is the same recipe an app with a resizable window runs -- see the + // hidpi sample.) + cf_app_set_pixel_scale(cf_app_get_natural_pixel_scale()); + if (!(options & CF_APP_OPTIONS_NO_GFX_BIT)) { + float scale = cf_app_get_pixel_scale(); + cf_app_set_canvas_size((int)CF_ROUNDF(w * scale), (int)CF_ROUNDF(h * scale)); + cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); + } // Well-known process globals a test legitimately mutates and rarely thinks to restore // -- with one app per test their reset came free from cf_destroy_app. Everything else // (push/pop stacks, canvases, shaders) is the test's own balance to keep; run with diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp new file mode 100644 index 000000000..147789dd2 --- /dev/null +++ b/test/test_hidpi.cpp @@ -0,0 +1,380 @@ +/* + Cute Framework + Copyright (C) 2026 Randy Gaul https://randygaul.github.io/ + + This software is dual-licensed with zlib or Unlicense, check LICENSE.txt for more info +*/ + +#include "test_harness.h" +#include "test_app_shared.h" + +#include +#include +#include + +using namespace Cute; + +// The pixel scale (physical pixels per logical point) is a user-controlled value: it starts +// at the display's natural density and afterwards changes only via cf_app_set_pixel_scale. +// Nothing -- not window resizes, not display density changes -- resizes the app canvas or +// touches the default 2d projection behind the user's back. CI and most dev machines run at +// density 1.0 where points and pixels agree, so these tests set a non-unity scale explicitly +// to make the HiDPI contract testable everywhere. + +#define LOGICAL_W 320 +#define LOGICAL_H 240 + +static bool s_px_near(CF_Pixel p, int r, int g, int b, int a, int tol) +{ + bool ok = cf_abs((int)p.colors.r - r) <= tol && cf_abs((int)p.colors.g - g) <= tol && cf_abs((int)p.colors.b - b) <= tol && cf_abs((int)p.colors.a - a) <= tol; + if (!ok) printf("pixel (%d %d %d %d) expected (%d %d %d %d) +/-%d\n", p.colors.r, p.colors.g, p.colors.b, p.colors.a, r, g, b, a, tol); + return ok; +} + +// A failed REQUIRE returns out of the test case early; RAII keeps a modified pixel scale or +// canvas size from leaking into whichever test runs next (see also test_app_shared's sweep). +struct HidpiGuard +{ + ~HidpiGuard() + { + test_destroy_app(); + } +}; + +static bool s_readback_canvas(CF_Canvas canvas, int w, int h, CF_Pixel* out) +{ + CF_Readback rb = cf_canvas_readback(canvas); + REQUIRE(rb.id); + while (!cf_readback_ready(rb)) {} + int size = w * h * (int)sizeof(CF_Pixel); + if (cf_readback_size(rb) != size) { + printf("readback size mismatch: got %d expected %d (w=%d h=%d) app_canvas=%dx%d app_window=%dx%d pixel_scale=%f\n", + cf_readback_size(rb), size, w, h, + cf_app_get_canvas_width(), cf_app_get_canvas_height(), + cf_app_get_width(), cf_app_get_height(), (double)cf_app_get_pixel_scale()); + } + REQUIRE(cf_readback_size(rb) == size); + cf_readback_data(rb, out, size); + cf_destroy_readback(rb); + return true; +} + +// The manual 2x recipe: scale for AA/glyph density, canvas for resolution, projection stays +// in logical points (untouched -- it was set once at startup from the logical window size). +static void s_apply_2x() +{ + cf_app_set_pixel_scale(2.0f); + cf_app_set_canvas_size(LOGICAL_W * 2, LOGICAL_H * 2); +} + +// cf_app_set_pixel_scale changes only the scale value -- the canvas and window keep their +// sizes. Rendering at the new density is an explicit second step (cf_app_set_canvas_size). +TEST_CASE(test_hidpi_set_pixel_scale_is_value_only) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + // 3.0f can't collide with any real display density, so this is a guaranteed change + // even on a machine whose natural scale is already 2.0. + int canvas_w = cf_app_get_canvas_width(); + int canvas_h = cf_app_get_canvas_height(); + cf_app_set_pixel_scale(3.0f); + REQUIRE(cf_app_get_pixel_scale() == 3.0f); + REQUIRE(cf_app_get_canvas_width() == canvas_w); + REQUIRE(cf_app_get_canvas_height() == canvas_h); + REQUIRE(cf_app_get_width() == LOGICAL_W); + REQUIRE(cf_app_get_height() == LOGICAL_H); + + // Zero/negative values are ignored, not applied. + cf_app_set_pixel_scale(0); + REQUIRE(cf_app_get_pixel_scale() == 3.0f); + cf_app_set_pixel_scale(-1.0f); + REQUIRE(cf_app_get_pixel_scale() == 3.0f); + return true; +} + +// The change flag latches at the next cf_app_update and reads true for exactly one frame, +// mirroring cf_app_dpi_scale_was_changed. +TEST_CASE(test_hidpi_pixel_scale_was_changed_flag) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + // test_make_app's shared-app sweep may itself have changed the scale (an honest change, + // honestly flagged); settle its one visible frame before observing. + cf_app_update(NULL); + + // 3.0f can't collide with the natural density this machine started at. + REQUIRE(!cf_app_pixel_scale_was_changed()); + cf_app_set_pixel_scale(3.0f); + REQUIRE(!cf_app_pixel_scale_was_changed()); // Not visible until the next update. + cf_app_update(NULL); + REQUIRE(cf_app_pixel_scale_was_changed()); // Visible for this one frame. + cf_app_update(NULL); + REQUIRE(!cf_app_pixel_scale_was_changed()); // Cleared again. + + // Setting the same value is not a change. + cf_app_set_pixel_scale(3.0f); + cf_app_update(NULL); + REQUIRE(!cf_app_pixel_scale_was_changed()); + return true; +} + +// NO_HIGH_DPI only pins the INITIAL scale at 1.0 (the window gets a 1x backbuffer, so the +// natural density is 1.0 too). The value stays user-controllable afterwards. +TEST_CASE(test_hidpi_no_high_dpi_initial_scale) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H, CF_APP_OPTIONS_NO_HIGH_DPI_BIT)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + REQUIRE(cf_app_get_pixel_scale() == 1.0f); + REQUIRE(cf_app_get_natural_pixel_scale() == 1.0f); + REQUIRE(cf_app_get_canvas_width() == LOGICAL_W); + REQUIRE(cf_app_get_canvas_height() == LOGICAL_H); + return true; +} + +// NO_GFX apps have no canvas; setting a scale is a plain value change and must not touch a +// NULL backend. +TEST_CASE(test_hidpi_set_scale_safe_without_gfx) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H, CF_APP_OPTIONS_NO_GFX_BIT)) return true; + HidpiGuard guard; + + cf_app_set_pixel_scale(2.0f); + REQUIRE(cf_app_get_pixel_scale() == 2.0f); + return true; +} + +// Startup is the one automatic step: canvas at logical size times the natural density, and +// the applied scale starts equal to the natural one. +TEST_CASE(test_hidpi_startup_defaults) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + float natural = cf_app_get_natural_pixel_scale(); + REQUIRE(natural > 0); + REQUIRE(cf_app_get_pixel_scale() == natural); + REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(LOGICAL_W * natural)); + REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(LOGICAL_H * natural)); + return true; +} + +// The default 2d projection spans logical points, so world coordinates land on the same +// spot of a 1:1 user canvas no matter the pixel scale. If the projection wrongly tracked +// canvas pixels, everything would shrink toward the center and the probe read background. +TEST_CASE(test_hidpi_default_projection_is_points) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + s_apply_2x(); + + int w = LOGICAL_W, h = LOGICAL_H; + CF_Canvas canvas = cf_make_canvas(cf_canvas_defaults(w, h)); + CF_Pixel* px = (CF_Pixel*)cf_alloc(w * h * (int)sizeof(CF_Pixel)); + + // A bar left of center, spanning y=0 so the probe row is insensitive to readback + // row order: world x in [-140, -60]. + cf_draw_push_color(cf_make_color_rgb_f(1.0f, 0, 0)); + cf_draw_quad_fill(cf_make_aabb(cf_v2(-140, -20), cf_v2(-60, 20)), 0); + cf_draw_pop_color(); + cf_render_to(canvas, true); + cf_app_draw_onto_screen(false); + + REQUIRE(s_readback_canvas(canvas, w, h, px)); + // World (-100, 0) is pixel column w/2 - 100 when one world unit is one point. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 - 100)], 255, 0, 0, 255, 3)); + // Just outside the bar: untouched. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 - 150)], 0, 0, 0, 0, 0)); + + cf_free(px); + cf_destroy_canvas(canvas); + return true; +} + +// Through the real present path: a quad spanning the exact logical extent must cover every +// pixel of the (2x larger) app canvas. +TEST_CASE(test_hidpi_full_extent_covers_app_canvas) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + s_apply_2x(); + + int w = cf_app_get_canvas_width(); + int h = cf_app_get_canvas_height(); + REQUIRE(w == LOGICAL_W * 2 && h == LOGICAL_H * 2); + CF_Pixel* px = (CF_Pixel*)cf_alloc(w * h * (int)sizeof(CF_Pixel)); + + cf_draw_push_color(cf_make_color_rgb_f(1.0f, 0, 0)); + cf_draw_quad_fill(cf_make_aabb(cf_v2(-LOGICAL_W / 2.0f, -LOGICAL_H / 2.0f), cf_v2(LOGICAL_W / 2.0f, LOGICAL_H / 2.0f)), 0); + cf_draw_pop_color(); + cf_app_draw_onto_screen(true); // Renders the remaining draw commands onto the (cleared) app canvas. + + REQUIRE(s_readback_canvas(cf_app_get_canvas(), w, h, px)); + // Probe 5px inside each corner (clear of the AA band) plus the center. + REQUIRE(s_px_near(px[5 * w + 5], 255, 0, 0, 255, 3)); + REQUIRE(s_px_near(px[5 * w + (w - 6)], 255, 0, 0, 255, 3)); + REQUIRE(s_px_near(px[(h - 6) * w + 5], 255, 0, 0, 255, 3)); + REQUIRE(s_px_near(px[(h - 6) * w + (w - 6)], 255, 0, 0, 255, 3)); + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2)], 255, 0, 0, 255, 3)); + + cf_free(px); + return true; +} + +// Stickiness is now a guarantee: neither cf_app_set_canvas_size nor cf_app_set_msaa touches +// the projection. A custom cf_draw_projection survives every canvas recreation. +TEST_CASE(test_hidpi_projection_sticky_across_canvas_recreate) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + // A custom projection spanning HALF the logical size: world content renders at 2x the + // scale of the default projection, so probes can tell the two apart. + cf_draw_projection(cf_ortho_2d(0, 0, LOGICAL_W / 2.0f, LOGICAL_H / 2.0f)); + + // Both recreation paths, back to back. + cf_app_set_canvas_size(LOGICAL_W, LOGICAL_H); + cf_app_set_msaa(1); + + int w = LOGICAL_W, h = LOGICAL_H; + CF_Pixel* px = (CF_Pixel*)cf_alloc(w * h * (int)sizeof(CF_Pixel)); + + // An 80x60-world quad: under the custom projection it covers half the canvas each way; + // under the (stomped) default it would cover only a quarter as much area. + cf_draw_push_color(cf_make_color_rgb_f(1.0f, 0, 0)); + cf_draw_quad_fill(cf_make_aabb(cf_v2(-40, -30), cf_v2(40, 30)), 0); + cf_draw_pop_color(); + cf_app_draw_onto_screen(true); + + REQUIRE(s_readback_canvas(cf_app_get_canvas(), w, h, px)); + // Inside the doubled quad but outside the default-projection footprint. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 + 70)], 255, 0, 0, 255, 3)); + REQUIRE(s_px_near(px[(h / 2 + 50) * w + (w / 2)], 255, 0, 0, 255, 3)); + // Outside the doubled quad: background. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 + 130)], 0, 0, 0, 0, 0)); + + cf_free(px); + // Restore the default projection for whichever test shares the app next. + cf_draw_projection(cf_ortho_2d(0, 0, (float)LOGICAL_W, (float)LOGICAL_H)); + return true; +} + +// The manual resize recipe from the docs/sample, in one frame: set_size, then canvas + +// projection by hand, then draw -- the very same frame must render correctly. +TEST_CASE(test_hidpi_manual_resize_recipe) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + cf_app_set_pixel_scale(2.0f); + cf_app_update(NULL); + + // The recipe. + cf_app_set_size(400, 300); + float scale = cf_app_get_pixel_scale(); + cf_app_set_canvas_size((int)CF_ROUNDF(400 * scale), (int)CF_ROUNDF(300 * scale)); + cf_draw_projection(cf_ortho_2d(0, 0, 400, 300)); + + int w = cf_app_get_canvas_width(); + int h = cf_app_get_canvas_height(); + REQUIRE(w == 800 && h == 600); + CF_Pixel* px = (CF_Pixel*)cf_alloc(w * h * (int)sizeof(CF_Pixel)); + + cf_draw_push_color(cf_make_color_rgb_f(0, 1.0f, 0)); + cf_draw_quad_fill(cf_make_aabb(cf_v2(-200, -150), cf_v2(200, 150)), 0); + cf_draw_pop_color(); + cf_app_draw_onto_screen(true); + + REQUIRE(s_readback_canvas(cf_app_get_canvas(), w, h, px)); + REQUIRE(s_px_near(px[5 * w + 5], 0, 255, 0, 255, 3)); + REQUIRE(s_px_near(px[(h - 6) * w + (w - 6)], 0, 255, 0, 255, 3)); + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2)], 0, 255, 0, 255, 3)); + + cf_free(px); + return true; +} + +// A window resize EVENT is bookkeeping only: cf_app_was_resized fires and app w/h update, +// but the canvas -- including a custom-sized one -- is untouched until the user reacts. +TEST_CASE(test_hidpi_resize_event_does_not_recreate_canvas) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + cf_app_set_canvas_size(300, 200); + REQUIRE(cf_app_get_canvas_width() == 300); + REQUIRE(cf_app_get_canvas_height() == 200); + + SDL_Event e = { }; + e.type = SDL_EVENT_WINDOW_RESIZED; + e.window.windowID = SDL_GetWindowID(app->window); + e.window.data1 = 500; + e.window.data2 = 400; + SDL_PushEvent(&e); + cf_app_update(NULL); + + REQUIRE(cf_app_was_resized()); + REQUIRE(cf_app_get_width() == 500); + REQUIRE(cf_app_get_height() == 400); + REQUIRE(cf_app_get_canvas_width() == 300); + REQUIRE(cf_app_get_canvas_height() == 200); + return true; +} + +// With no hidden refresh machinery, changing the scale mid-recording is harmless: the +// recording's identity space and the live projection are both left alone. +TEST_CASE(test_hidpi_draw_list_recorded_across_scale_change) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + s_apply_2x(); + + CF_DrawList list = cf_make_draw_list(); + cf_draw_list_begin(list); + cf_app_set_pixel_scale(3.0f); // Mid-recording: a plain value change, nothing to defer. + cf_draw_push_color(cf_make_color_rgb_f(1.0f, 0, 0)); + cf_draw_quad_fill(cf_make_aabb(cf_v2(-40, -40), cf_v2(40, 40)), 0); + cf_draw_pop_color(); + cf_draw_list_end(); + cf_app_set_pixel_scale(2.0f); + cf_app_draw_onto_screen(false); + + int w = cf_app_get_canvas_width(); + int h = cf_app_get_canvas_height(); + REQUIRE(w == LOGICAL_W * 2 && h == LOGICAL_H * 2); + CF_Pixel* px = (CF_Pixel*)cf_alloc(w * h * (int)sizeof(CF_Pixel)); + + cf_app_update(NULL); + cf_draw_list(list); + cf_app_draw_onto_screen(true); + + REQUIRE(s_readback_canvas(cf_app_get_canvas(), w, h, px)); + // The 80x80-logical quad replays centered at 160x160 device pixels on the 640x480 canvas. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2)], 255, 0, 0, 255, 3)); // Center. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 + 70)], 255, 0, 0, 255, 3)); // Inside the quad. + REQUIRE(s_px_near(px[(h / 2) * w + (w / 2 + 100)], 0, 0, 0, 0, 0)); // Outside the quad. + + cf_free(px); + cf_destroy_draw_list(list); + return true; +} + +TEST_SUITE(test_hidpi) +{ + RUN_TEST_CASE(test_hidpi_set_pixel_scale_is_value_only); + RUN_TEST_CASE(test_hidpi_pixel_scale_was_changed_flag); + RUN_TEST_CASE(test_hidpi_no_high_dpi_initial_scale); + RUN_TEST_CASE(test_hidpi_set_scale_safe_without_gfx); + RUN_TEST_CASE(test_hidpi_startup_defaults); + RUN_TEST_CASE(test_hidpi_default_projection_is_points); + RUN_TEST_CASE(test_hidpi_full_extent_covers_app_canvas); + RUN_TEST_CASE(test_hidpi_projection_sticky_across_canvas_recreate); + RUN_TEST_CASE(test_hidpi_manual_resize_recipe); + RUN_TEST_CASE(test_hidpi_resize_event_does_not_recreate_canvas); + RUN_TEST_CASE(test_hidpi_draw_list_recorded_across_scale_change); +} From 209235f495fd11a754dcc67d81bee7cf92441fe8 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Fri, 14 Aug 2026 18:07:52 +0200 Subject: [PATCH 02/13] Add cf_app_apply_pixel_scale: the resize/density recipe as one call bullno1's follow-up suggestion on the PR #575 thread: package the recurring three-step reaction (set pixel scale, resize canvas to window * scale, rebuild the logical-points projection) into a single public helper. The window size is read internally rather than passed in -- it is always current by the time user code reacts to an event, and passing it would only invite stale values. The sample, the shared test-app sweep, and the hidpi tests all shrink to one call each, which was the tell that the helper deserved to be public API. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn --- include/cute_app.h | 16 +++++++++++++++ samples/hidpi.c | 44 ++++++++++------------------------------ src/cute_app.cpp | 11 ++++++++++ test/test_app_shared.cpp | 9 ++------ test/test_hidpi.cpp | 34 +++++++++++++++++++++++++++---- 5 files changed, 70 insertions(+), 44 deletions(-) diff --git a/include/cute_app.h b/include/cute_app.h index 852425b12..63ce14f34 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -466,6 +466,21 @@ CF_API bool CF_CALL cf_app_pixel_scale_was_changed(void); */ CF_API float CF_CALL cf_app_get_natural_pixel_scale(void); +/** + * @function cf_app_apply_pixel_scale + * @category app + * @brief Applies a pixel scale in one call: sets the scale, resizes the app canvas to window size times scale, and rebuilds the default 2d projection from the logical window size. + * @param scale The pixel scale to apply. Must be greater than zero; other values are ignored. + * @remarks This is the standard reaction to a window resize or a display density change, packaged up -- equivalent + * to `cf_app_set_pixel_scale`, then `cf_app_set_canvas_size(window_w * scale, window_h * scale)`, then + * `cf_draw_projection` spanning the logical window size. Call it when `cf_app_was_resized` fires (passing + * `cf_app_get_pixel_scale` to keep the current scale), when `cf_app_dpi_scale_was_changed` fires (passing + * `cf_app_get_natural_pixel_scale` to track the display), or any time with an arbitrary scale for testing. + * It overwrites a custom `cf_draw_projection` -- re-apply yours after, if you use one. See the hidpi sample. + * @related cf_app_set_pixel_scale cf_app_get_pixel_scale cf_app_get_natural_pixel_scale cf_app_set_canvas_size cf_app_dpi_scale_was_changed + */ +CF_API void CF_CALL cf_app_apply_pixel_scale(float scale); + /** * @function cf_app_set_size * @category app @@ -1015,6 +1030,7 @@ CF_INLINE float app_get_pixel_scale() { return cf_app_get_pixel_scale(); } CF_INLINE void app_set_pixel_scale(float scale) { cf_app_set_pixel_scale(scale); } CF_INLINE bool app_pixel_scale_was_changed() { return cf_app_pixel_scale_was_changed(); } CF_INLINE float app_get_natural_pixel_scale() { return cf_app_get_natural_pixel_scale(); } +CF_INLINE void app_apply_pixel_scale(float scale) { cf_app_apply_pixel_scale(scale); } CF_INLINE void app_center_window() { cf_app_center_window(); } CF_INLINE bool app_was_resized() { return cf_app_was_resized(); } CF_INLINE bool app_was_moved() { return cf_app_was_moved(); } diff --git a/samples/hidpi.c b/samples/hidpi.c index 742a21f93..9c9aa7bc7 100644 --- a/samples/hidpi.c +++ b/samples/hidpi.c @@ -5,24 +5,13 @@ value, like the window or canvas size. At startup CF creates the app canvas once at window_points * the display's natural density and sets the default 2d projection once from the logical window size -- and never touches either again. - Reacting to window resizes and display-density changes is YOUR code, and this - sample is the copy-paste recipe: - - // React to a window resize (and/or a density change while tracking the - // display): re-apply scale, canvas, and projection in one place. - static void apply_pixel_scale(float scale) - { - int w = cf_app_get_width(); - int h = cf_app_get_height(); - cf_app_set_pixel_scale(scale); - cf_app_set_canvas_size((int)(w * scale + 0.5f), (int)(h * scale + 0.5f)); - cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); - } + Reacting to window resizes and display-density changes is YOUR code, and + cf_app_apply_pixel_scale (= set the scale, resize the canvas to match the + window, rebuild the projection) is the whole recipe: - // In the main loop: - if (cf_app_was_resized()) apply_pixel_scale(cf_app_get_pixel_scale()); + if (cf_app_was_resized()) cf_app_apply_pixel_scale(cf_app_get_pixel_scale()); if (cf_app_dpi_scale_was_changed() && tracking_the_display) { - apply_pixel_scale(cf_app_get_natural_pixel_scale()); + cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); } A fixed-size, non-resizable window on one display needs NONE of this -- the @@ -46,17 +35,6 @@ #include #include -// The whole "automatic HiDPI" replacement, in one function: apply a pixel scale and -// rebuild the canvas and projection to match the current window size. -static void apply_pixel_scale(float scale) -{ - int w = cf_app_get_width(); - int h = cf_app_get_height(); - cf_app_set_pixel_scale(scale); - cf_app_set_canvas_size((int)(w * scale + 0.5f), (int)(h * scale + 0.5f)); - cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); -} - // Draws `text` such that it's horizontally centered underneath/at `top_center`, // with `top_center.y` acting as the top of the text (matches cf_draw_text's // top-left-origin convention). @@ -92,19 +70,19 @@ int main(int argc, char* argv[]) // Scale mode switching. Forcing a scale on purpose is exactly the same call the // engine-side recipe uses -- there is no separate "override" concept. - if (cf_key_just_pressed(CF_KEY_N)) { track_natural = true; apply_pixel_scale(cf_app_get_natural_pixel_scale()); } - if (cf_key_just_pressed(CF_KEY_1)) { track_natural = false; apply_pixel_scale(1.0f); } - if (cf_key_just_pressed(CF_KEY_2)) { track_natural = false; apply_pixel_scale(2.0f); } - if (cf_key_just_pressed(CF_KEY_4)) { track_natural = false; apply_pixel_scale(4.0f); } + if (cf_key_just_pressed(CF_KEY_N)) { track_natural = true; cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); } + if (cf_key_just_pressed(CF_KEY_1)) { track_natural = false; cf_app_apply_pixel_scale(1.0f); } + if (cf_key_just_pressed(CF_KEY_2)) { track_natural = false; cf_app_apply_pixel_scale(2.0f); } + if (cf_key_just_pressed(CF_KEY_4)) { track_natural = false; cf_app_apply_pixel_scale(4.0f); } // The manual-model recipe: window resized -> rebuild canvas + projection at the // current scale. Density changed (moved to another monitor) -> re-apply the new // natural scale, but only when tracking it. if (cf_app_was_resized()) { - apply_pixel_scale(cf_app_get_pixel_scale()); + cf_app_apply_pixel_scale(cf_app_get_pixel_scale()); } if (cf_app_dpi_scale_was_changed() && track_natural) { - apply_pixel_scale(cf_app_get_natural_pixel_scale()); + cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); } cf_push_font("Calibri"); diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 89a96d8d6..c75e428ae 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -668,6 +668,17 @@ float cf_app_get_natural_pixel_scale() return app->natural_pixel_scale; } +void cf_app_apply_pixel_scale(float scale) +{ + if (!(scale > 0)) return; + cf_app_set_pixel_scale(scale); + // NO_GFX apps have no canvas or draw state -- the scale value is all there is to apply. + if (app->gfx_enabled) { + cf_app_set_canvas_size((int)CF_ROUNDF(app->w * scale), (int)CF_ROUNDF(app->h * scale)); + cf_draw_projection(cf_ortho_2d(0, 0, (float)app->w, (float)app->h)); + } +} + void cf_app_set_size(int w, int h) { SDL_SetWindowSize(app->window, w, h); diff --git a/test/test_app_shared.cpp b/test/test_app_shared.cpp index caadd6006..c1cbedf90 100644 --- a/test/test_app_shared.cpp +++ b/test/test_app_shared.cpp @@ -37,14 +37,9 @@ bool test_make_app(int w, int h, int extra_options) cf_app_set_size(w, h); // Nothing tracks the window automatically anymore: restore the startup-equivalent // pixel scale, canvas size, and default 2d projection a previous test may have - // changed. (This is the same recipe an app with a resizable window runs -- see the + // changed. (This is the same call an app with a resizable window makes -- see the // hidpi sample.) - cf_app_set_pixel_scale(cf_app_get_natural_pixel_scale()); - if (!(options & CF_APP_OPTIONS_NO_GFX_BIT)) { - float scale = cf_app_get_pixel_scale(); - cf_app_set_canvas_size((int)CF_ROUNDF(w * scale), (int)CF_ROUNDF(h * scale)); - cf_draw_projection(cf_ortho_2d(0, 0, (float)w, (float)h)); - } + cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); // Well-known process globals a test legitimately mutates and rarely thinks to restore // -- with one app per test their reset came free from cf_destroy_app. Everything else // (push/pop stacks, canvases, shaders) is the test's own balance to keep; run with diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp index 147789dd2..c808729ac 100644 --- a/test/test_hidpi.cpp +++ b/test/test_hidpi.cpp @@ -59,12 +59,11 @@ static bool s_readback_canvas(CF_Canvas canvas, int w, int h, CF_Pixel* out) return true; } -// The manual 2x recipe: scale for AA/glyph density, canvas for resolution, projection stays -// in logical points (untouched -- it was set once at startup from the logical window size). +// The manual 2x recipe in its packaged form: scale for AA/glyph density, canvas for +// resolution, projection rebuilt in logical points (the same value startup chose). static void s_apply_2x() { - cf_app_set_pixel_scale(2.0f); - cf_app_set_canvas_size(LOGICAL_W * 2, LOGICAL_H * 2); + cf_app_apply_pixel_scale(2.0f); } // cf_app_set_pixel_scale changes only the scale value -- the canvas and window keep their @@ -143,6 +142,10 @@ TEST_CASE(test_hidpi_set_scale_safe_without_gfx) cf_app_set_pixel_scale(2.0f); REQUIRE(cf_app_get_pixel_scale() == 2.0f); + + // The packaged helper skips its canvas/projection half without gfx. + cf_app_apply_pixel_scale(3.0f); + REQUIRE(cf_app_get_pixel_scale() == 3.0f); return true; } @@ -161,6 +164,28 @@ TEST_CASE(test_hidpi_startup_defaults) return true; } +// cf_app_apply_pixel_scale is the packaged recipe: one call sets the scale AND resizes the +// canvas to window * scale (the projection half of its contract is readback-verified by the +// tests below, which all go through s_apply_2x). Invalid scales are ignored wholesale. +TEST_CASE(test_hidpi_apply_pixel_scale_helper) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + cf_app_apply_pixel_scale(2.0f); + REQUIRE(cf_app_get_pixel_scale() == 2.0f); + REQUIRE(cf_app_get_canvas_width() == LOGICAL_W * 2); + REQUIRE(cf_app_get_canvas_height() == LOGICAL_H * 2); + REQUIRE(cf_app_get_width() == LOGICAL_W); + REQUIRE(cf_app_get_height() == LOGICAL_H); + + // An invalid scale must not half-apply (no canvas resize either). + cf_app_apply_pixel_scale(0); + REQUIRE(cf_app_get_pixel_scale() == 2.0f); + REQUIRE(cf_app_get_canvas_width() == LOGICAL_W * 2); + return true; +} + // The default 2d projection spans logical points, so world coordinates land on the same // spot of a 1:1 user canvas no matter the pixel scale. If the projection wrongly tracked // canvas pixels, everything would shrink toward the center and the probe read background. @@ -371,6 +396,7 @@ TEST_SUITE(test_hidpi) RUN_TEST_CASE(test_hidpi_no_high_dpi_initial_scale); RUN_TEST_CASE(test_hidpi_set_scale_safe_without_gfx); RUN_TEST_CASE(test_hidpi_startup_defaults); + RUN_TEST_CASE(test_hidpi_apply_pixel_scale_helper); RUN_TEST_CASE(test_hidpi_default_projection_is_points); RUN_TEST_CASE(test_hidpi_full_extent_covers_app_canvas); RUN_TEST_CASE(test_hidpi_projection_sticky_across_canvas_recreate); From 93f8f3a1a533c0ad65c6a9f6b736e44bd889287e Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Sat, 15 Aug 2026 05:03:49 +0200 Subject: [PATCH 03/13] Match bullno1's refined naming and normalize coordinates to points Terminology follows the SDL functions underneath, per the PR thread: - cf_app_get_display_scale / cf_app_display_scale_was_changed (backed by SDL_GetWindowDisplayScale): what the OS wants, point-to-pixel, consistent across platforms. Replaces both the dpi_scale pair (term dropped) and cf_app_get_natural_pixel_scale (SDL_GetWindowPixelDensity is 1.0 on Windows/X11 where the scale lives in content-scale, so display scale is the right value to follow everywhere). - cf_app_update_display(scale): renamed from cf_app_apply_pixel_scale. - pixel_scale keeps its name: how fonts/shapes are scaled, arbitrary or following the reported display scale. - Initial pixel_scale now comes from display scale (identical on Mac, correct on Windows where density alone under-reports). Window size and mouse coordinates are now normalized to logical points at the SDL boundary via SDL_GetDisplayContentScale (window create, set_size, resize events, mouse events; touch already derives from app->w/h). A no-op on macOS where content scale is 1.0 -- the normalization needs verification on Windows and X11 at >100% scaling. 351/351 tests, docsparser clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn --- include/cute_app.h | 74 +++++++++++++------------------- samples/hidpi.c | 42 +++++++++--------- src/cute_app.cpp | 37 +++++++++------- src/cute_input.cpp | 69 ++++++++++++++--------------- src/internal/cute_app_internal.h | 11 +++-- test/test_app_shared.cpp | 2 +- test/test_hidpi.cpp | 40 ++++++++--------- 7 files changed, 138 insertions(+), 137 deletions(-) diff --git a/include/cute_app.h b/include/cute_app.h index 63ce14f34..070b4dc81 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -400,21 +400,21 @@ CF_API void CF_CALL cf_app_show_window(void); /** * @function cf_app_get_display_scale * @category app - * @brief Returns the OS's display scale for the window's current display. - * @remarks On some devices (e.g. Apple Retina or iOS) pixels are clustered in 4x4 packs and abstracted as a single pixel - * called a "point". The intent is for applications to work in points, and scale their UI elements by a factor of 2x - * to aid in readability. These devices have very small pixels. Most of the time you should ignore dpi and let the OS - * handle this. CF enables DPI settings by default, but, you can see if this function returns 2.0f to let you know if - * pixels are clustered for you under the hood. - * @related cf_app_set_size cf_app_get_position cf_app_set_position cf_app_get_width cf_app_get_height cf_app_get_display_scale cf_app_display_scale_was_changed + * @brief Returns the display scale the OS wants for the app's window: the factor converting logical points to physical pixels. + * @remarks E.g. 2.0f on a 2x Retina display, or 1.5f on a Windows desktop at 150% scaling -- consistent across platforms. + * Only the OS changes this value (moving the window to a different display, or the user changing display settings), + * and a change raises `cf_app_display_scale_was_changed`. It is informational: CF never applies it for you. Pass it + * to `cf_app_update_display` to render at the display's native crispness, or ignore it to keep a fixed scale. + * @related cf_app_display_scale_was_changed cf_app_update_display cf_app_get_pixel_scale cf_app_get_size */ CF_API float CF_CALL cf_app_get_display_scale(void); /** * @function cf_app_display_scale_was_changed * @category app - * @brief Returns true if the display scale changed, such as moving from one screen to another. - * @related cf_app_get_display_scale cf_app_display_scale_was_changed + * @brief Returns true if the display scale changed, such as the window moving to a screen with a different scale. + * @remarks The standard reaction is `cf_app_update_display(cf_app_get_display_scale())`; see the hidpi sample. + * @related cf_app_get_display_scale cf_app_update_display */ CF_API bool CF_CALL cf_app_display_scale_was_changed(void); @@ -422,12 +422,12 @@ CF_API bool CF_CALL cf_app_display_scale_was_changed(void); * @function cf_app_get_pixel_scale * @category app * @brief Returns the pixel scale used for rendering: physical pixels per logical point. - * @remarks This value drives antialiasing width and glyph rasterization density, and is what you'd multiply a - * logical size by to size a pixel-perfect render target. It is a user-controlled value, like the window - * or canvas size: it starts at the window's natural display density (e.g. 2.0f on a 2x Retina display, - * or 1.0f if `CF_APP_OPTIONS_NO_HIGH_DPI_BIT` was passed to `cf_make_app`) and afterwards changes only - * through `cf_app_set_pixel_scale`. See `cf_app_get_natural_pixel_scale` for the display's live density. - * @related cf_app_set_pixel_scale cf_app_get_natural_pixel_scale cf_app_pixel_scale_was_changed cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height + * @remarks This is how CF scales fonts and shapes: it drives antialiasing width and glyph rasterization density, + * and is what you'd multiply a logical size by to size a pixel-perfect render target. It is a + * user-controlled value, like the window or canvas size: it starts at the display scale the OS wants + * (e.g. 2.0f on a 2x Retina display, or 1.0f if `CF_APP_OPTIONS_NO_HIGH_DPI_BIT` was passed to + * `cf_make_app`) and afterwards changes only through `cf_app_set_pixel_scale` or `cf_app_update_display`. + * @related cf_app_set_pixel_scale cf_app_update_display cf_app_pixel_scale_was_changed cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height */ CF_API float CF_CALL cf_app_get_pixel_scale(void); @@ -437,11 +437,11 @@ CF_API float CF_CALL cf_app_get_pixel_scale(void); * @brief Sets the pixel scale (physical pixels per logical point) used for rendering. * @param scale The new pixel scale. Must be greater than zero; other values are ignored. * @remarks Only the scale value itself changes: antialiasing width adjusts immediately and text re-rasterizes at - * the new density, but the app canvas keeps its current size. To render at the new density also resize - * the canvas, e.g. `cf_app_set_canvas_size(window_w * scale, window_h * scale)`. The draw API stays in - * logical points throughout. `cf_app_pixel_scale_was_changed` reports true for one frame after a change. - * See the hidpi sample for the full resize/density-change recipe. - * @related cf_app_get_pixel_scale cf_app_get_natural_pixel_scale cf_app_pixel_scale_was_changed cf_app_set_canvas_size + * the new density, but the app canvas keeps its current size -- `cf_app_set_canvas_size` is a separate + * call (or use `cf_app_update_display` to do both plus the projection). The scale can be arbitrary, or + * follow the reported `cf_app_get_display_scale`. The draw API stays in logical points throughout. + * `cf_app_pixel_scale_was_changed` reports true for one frame after a change. + * @related cf_app_get_pixel_scale cf_app_update_display cf_app_pixel_scale_was_changed cf_app_set_canvas_size cf_app_get_display_scale */ CF_API void CF_CALL cf_app_set_pixel_scale(float scale); @@ -456,30 +456,19 @@ CF_API void CF_CALL cf_app_set_pixel_scale(float scale); CF_API bool CF_CALL cf_app_pixel_scale_was_changed(void); /** - * @function cf_app_get_natural_pixel_scale + * @function cf_app_update_display * @category app - * @brief Returns the display's native density for the app's window: physical pixels per logical point as reported by the OS. - * @remarks This is informational and refreshed automatically when the window moves to a display with a different - * density (which also raises `cf_app_display_scale_was_changed`). It is never applied automatically -- pass it - * to `cf_app_set_pixel_scale` to track the display's density, or ignore it to keep a fixed scale. - * @related cf_app_get_pixel_scale cf_app_set_pixel_scale cf_app_display_scale_was_changed - */ -CF_API float CF_CALL cf_app_get_natural_pixel_scale(void); - -/** - * @function cf_app_apply_pixel_scale - * @category app - * @brief Applies a pixel scale in one call: sets the scale, resizes the app canvas to window size times scale, and rebuilds the default 2d projection from the logical window size. + * @brief The all-in-one display update: sets the pixel scale, resizes the app canvas to window size times scale, and rebuilds the default 2d projection from the logical window size. * @param scale The pixel scale to apply. Must be greater than zero; other values are ignored. - * @remarks This is the standard reaction to a window resize or a display density change, packaged up -- equivalent - * to `cf_app_set_pixel_scale`, then `cf_app_set_canvas_size(window_w * scale, window_h * scale)`, then - * `cf_draw_projection` spanning the logical window size. Call it when `cf_app_was_resized` fires (passing - * `cf_app_get_pixel_scale` to keep the current scale), when `cf_app_dpi_scale_was_changed` fires (passing - * `cf_app_get_natural_pixel_scale` to track the display), or any time with an arbitrary scale for testing. - * It overwrites a custom `cf_draw_projection` -- re-apply yours after, if you use one. See the hidpi sample. - * @related cf_app_set_pixel_scale cf_app_get_pixel_scale cf_app_get_natural_pixel_scale cf_app_set_canvas_size cf_app_dpi_scale_was_changed + * @remarks Just a helper bringing `cf_app_set_pixel_scale`, `cf_app_set_canvas_size(window_w * scale, window_h * scale)`, + * and `cf_draw_projection` (spanning the logical window size) together. Use it on both resize and scale change: + * when `cf_app_was_resized` fires pass `cf_app_get_pixel_scale` to keep the current scale, and when + * `cf_app_display_scale_was_changed` fires pass `cf_app_get_display_scale` to follow the display. The scale can + * also be arbitrary -- e.g. a forced 2.0f to test HiDPI rendering on a normal monitor. It overwrites a custom + * `cf_draw_projection` -- re-apply yours after, if you use one. See the hidpi sample. + * @related cf_app_set_pixel_scale cf_app_get_pixel_scale cf_app_get_display_scale cf_app_set_canvas_size cf_app_display_scale_was_changed */ -CF_API void CF_CALL cf_app_apply_pixel_scale(float scale); +CF_API void CF_CALL cf_app_update_display(float scale); /** * @function cf_app_set_size @@ -1029,8 +1018,7 @@ CF_INLINE bool app_display_scale_was_changed() { return cf_app_display_scale_was CF_INLINE float app_get_pixel_scale() { return cf_app_get_pixel_scale(); } CF_INLINE void app_set_pixel_scale(float scale) { cf_app_set_pixel_scale(scale); } CF_INLINE bool app_pixel_scale_was_changed() { return cf_app_pixel_scale_was_changed(); } -CF_INLINE float app_get_natural_pixel_scale() { return cf_app_get_natural_pixel_scale(); } -CF_INLINE void app_apply_pixel_scale(float scale) { cf_app_apply_pixel_scale(scale); } +CF_INLINE void app_update_display(float scale) { cf_app_update_display(scale); } CF_INLINE void app_center_window() { cf_app_center_window(); } CF_INLINE bool app_was_resized() { return cf_app_was_resized(); } CF_INLINE bool app_was_moved() { return cf_app_was_moved(); } diff --git a/samples/hidpi.c b/samples/hidpi.c index 9c9aa7bc7..0f56f9599 100644 --- a/samples/hidpi.c +++ b/samples/hidpi.c @@ -3,21 +3,21 @@ The pixel scale (physical pixels per logical point) is a plain user-controlled value, like the window or canvas size. At startup CF creates the app canvas - once at window_points * the display's natural density and sets the default 2d + once at window_points * the display scale the OS wants and sets the default 2d projection once from the logical window size -- and never touches either again. Reacting to window resizes and display-density changes is YOUR code, and - cf_app_apply_pixel_scale (= set the scale, resize the canvas to match the + cf_app_update_display (= set the scale, resize the canvas to match the window, rebuild the projection) is the whole recipe: - if (cf_app_was_resized()) cf_app_apply_pixel_scale(cf_app_get_pixel_scale()); - if (cf_app_dpi_scale_was_changed() && tracking_the_display) { - cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); + if (cf_app_was_resized()) cf_app_update_display(cf_app_get_pixel_scale()); + if (cf_app_display_scale_was_changed() && tracking_the_display) { + cf_app_update_display(cf_app_get_display_scale()); } A fixed-size, non-resizable window on one display needs NONE of this -- the startup defaults are already correct. - Interactivity: press N to track the display's natural density (the default), + Interactivity: press N to follow the OS display scale (the default), or 1 / 2 / 4 to force a 1x / 2x / 4x pixel scale -- forcing a value is also how you test HiDPI behavior on a non-HiDPI monitor. Resize the window to watch the recipe keep everything crisp. @@ -28,7 +28,7 @@ - A row of basic SDF shapes (filled circle, outlined circle, lines of varying thickness including a thin ~1px line, a filled rounded box, and an outlined triangle) to eyeball shape edge antialiasing. - - A live readout of the applied and natural pixel scales alongside + - A live readout of the applied pixel scale and the OS display scale alongside the physical canvas size. */ @@ -62,27 +62,27 @@ int main(int argc, char* argv[]) cf_sprite_play(&sprite, "idle"); sprite.scale = cf_v2(3.0f, 3.0f); - // true = follow the display's natural density; false = a forced 1x/2x/4x scale. - bool track_natural = true; + // true = follow the OS display scale; false = a forced 1x/2x/4x scale. + bool track_display = true; while (cf_app_is_running()) { cf_app_update(NULL); // Scale mode switching. Forcing a scale on purpose is exactly the same call the // engine-side recipe uses -- there is no separate "override" concept. - if (cf_key_just_pressed(CF_KEY_N)) { track_natural = true; cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); } - if (cf_key_just_pressed(CF_KEY_1)) { track_natural = false; cf_app_apply_pixel_scale(1.0f); } - if (cf_key_just_pressed(CF_KEY_2)) { track_natural = false; cf_app_apply_pixel_scale(2.0f); } - if (cf_key_just_pressed(CF_KEY_4)) { track_natural = false; cf_app_apply_pixel_scale(4.0f); } + if (cf_key_just_pressed(CF_KEY_N)) { track_display = true; cf_app_update_display(cf_app_get_display_scale()); } + if (cf_key_just_pressed(CF_KEY_1)) { track_display = false; cf_app_update_display(1.0f); } + if (cf_key_just_pressed(CF_KEY_2)) { track_display = false; cf_app_update_display(2.0f); } + if (cf_key_just_pressed(CF_KEY_4)) { track_display = false; cf_app_update_display(4.0f); } // The manual-model recipe: window resized -> rebuild canvas + projection at the - // current scale. Density changed (moved to another monitor) -> re-apply the new - // natural scale, but only when tracking it. + // current scale. Display scale changed (moved to another monitor) -> update to + // the new scale, but only in follow mode. if (cf_app_was_resized()) { - cf_app_apply_pixel_scale(cf_app_get_pixel_scale()); + cf_app_update_display(cf_app_get_pixel_scale()); } - if (cf_app_dpi_scale_was_changed() && track_natural) { - cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); + if (cf_app_display_scale_was_changed() && track_display) { + cf_app_update_display(cf_app_get_display_scale()); } cf_push_font("Calibri"); @@ -107,9 +107,9 @@ int main(int argc, char* argv[]) int physical_h = cf_app_get_canvas_height(); snprintf( pixel_scale_buf, sizeof(pixel_scale_buf), - "pixel_scale: %.2fx %s (natural: %.2fx, physical canvas: %dx%d) -- press N/1/2/4", - pixel_scale, track_natural ? "[natural]" : "[forced]", - cf_app_get_natural_pixel_scale(), physical_w, physical_h + "pixel_scale: %.2fx %s (display: %.2fx, physical canvas: %dx%d) -- press N/1/2/4", + pixel_scale, track_display ? "[display]" : "[forced]", + cf_app_get_display_scale(), physical_w, physical_h ); cf_push_font_size(12); draw_text_centered(pixel_scale_buf, cf_v2(0, 300)); diff --git a/src/cute_app.cpp b/src/cute_app.cpp index c75e428ae..806b42277 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -286,6 +286,10 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, } SDL_Window* window = NULL; + // The app isn't constructed yet, so resolve the creation display's content scale + // directly: w/h are logical points, SDL_CreateWindow wants raw window coordinates. + float creation_content_scale = SDL_GetDisplayContentScale(display_id ? display_id : SDL_GetPrimaryDisplay()); + if (creation_content_scale <= 0) creation_content_scale = 1.0f; if (use_gfx) { Uint32 flags = 0; if (!(options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT)) { @@ -299,8 +303,8 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, SDL_PropertiesID props = SDL_CreateProperties(); SDL_SetStringProperty(props, SDL_PROP_WINDOW_CREATE_TITLE_STRING, window_title); - SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, w); - SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_HEIGHT_NUMBER, h); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, (int)CF_ROUNDF(w * creation_content_scale)); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_HEIGHT_NUMBER, (int)CF_ROUNDF(h * creation_content_scale)); SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_FLAGS_NUMBER, flags); if (options & CF_APP_OPTIONS_WINDOW_POS_CENTERED_BIT) { SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_X_NUMBER, SDL_WINDOWPOS_CENTERED_DISPLAY(display_id)); @@ -333,13 +337,12 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, if (window) { SDL_GetWindowPosition(app->window, &app->x, &app->y); app->display_scale = SDL_GetWindowDisplayScale(app->window); - app->pixel_scale = window ? SDL_GetWindowPixelDensity(app->window) : 1.0f; - if (app->pixel_scale <= 0.0f) app->pixel_scale = 1.0f; + if (app->display_scale <= 0.0f) app->display_scale = 1.0f; + // The initial pixel scale follows what the OS wants (with NO_HIGH_DPI the window + // has a 1x backbuffer, so pin to 1.0 to match it). After init, pixel_scale only + // ever changes via cf_app_set_pixel_scale / cf_app_update_display. + app->pixel_scale = app->display_scale; if (options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT) app->pixel_scale = 1.0f; - // The initial pixel scale IS the natural density (with NO_HIGH_DPI the window has a - // 1x backbuffer, so SDL reports 1.0 and both values agree). After init, pixel_scale - // only ever changes via cf_app_set_pixel_scale. - app->natural_pixel_scale = app->pixel_scale; } ::app = app; cf_make_aseprite_cache(); @@ -634,6 +637,12 @@ void cf_app_show_window() SDL_ShowWindow(app->window); } +float cf_app_content_scale() +{ + float scale = app->window ? SDL_GetDisplayContentScale(SDL_GetDisplayForWindow(app->window)) : 1.0f; + return scale > 0 ? scale : 1.0f; +} + float cf_app_get_display_scale() { return app->display_scale; @@ -663,12 +672,7 @@ bool cf_app_pixel_scale_was_changed() return app->pixel_scale_was_changed; } -float cf_app_get_natural_pixel_scale() -{ - return app->natural_pixel_scale; -} - -void cf_app_apply_pixel_scale(float scale) +void cf_app_update_display(float scale) { if (!(scale > 0)) return; cf_app_set_pixel_scale(scale); @@ -681,7 +685,10 @@ void cf_app_apply_pixel_scale(float scale) void cf_app_set_size(int w, int h) { - SDL_SetWindowSize(app->window, w, h); + // Public sizes are logical points; SDL_SetWindowSize wants raw window coordinates + // (identical on macOS, points * content scale on Windows/X11). + float cs = cf_app_content_scale(); + SDL_SetWindowSize(app->window, (int)CF_ROUNDF(w * cs), (int)CF_ROUNDF(h * cs)); app->w = w; app->h = h; app->sync_window = true; diff --git a/src/cute_input.cpp b/src/cute_input.cpp index c3976b82b..8555ef9fe 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -503,21 +503,21 @@ void cf_begin_frame_input() } } -// Re-queries the window's physical pixel density and records it. Nothing is applied -// automatically -- the applied scale (app->pixel_scale) only changes when the user calls -// cf_app_set_pixel_scale. A density change raises display_scale_was_changed so scale-tracking -// user code has a single event to watch. +// Re-queries what the OS wants (SDL_GetWindowDisplayScale: the point-to-pixel conversion +// for the window's display) and records it. Nothing is applied automatically -- the applied +// scale (app->pixel_scale) only changes when the user calls cf_app_set_pixel_scale or +// cf_app_update_display. A change raises display_scale_was_changed so scale-tracking user +// code has a single event to watch. // Called from both SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED and -// SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED -- the former is the OS's content-scale -// signal and the latter is the authoritative physical-pixel-size signal; -// either can fire without the other depending on platform/monitor setup, so -// both are handled the same way and this is idempotent when both fire together. -static void s_refresh_natural_pixel_scale() -{ - float density = SDL_GetWindowPixelDensity(app->window); - if (density <= 0.0f) density = 1.0f; - if (density != app->natural_pixel_scale) { - app->natural_pixel_scale = density; +// SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED -- either can fire without the other +// depending on platform/monitor setup, so both are handled the same way and +// this is idempotent when both fire together. +static void s_refresh_display_scale() +{ + float scale = SDL_GetWindowDisplayScale(app->window); + if (scale <= 0.0f) scale = 1.0f; + if (scale != app->display_scale) { + app->display_scale = scale; app->display_scale_was_changed = true; } } @@ -537,13 +537,16 @@ void cf_pump_input_msgs() app->running = false; break; - case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_RESIZED: { // Bookkeeping only: the canvas and projection are the user's to update in // response (see cf_app_was_resized and the hidpi sample for the recipe). + // SDL reports raw window coordinates; CF stores logical points (identical on + // macOS, divided by the OS content scale on Windows/X11). + float cs = cf_app_content_scale(); app->window_state.resized = true; - app->w = event.window.data1; - app->h = event.window.data2; - break; + app->w = (int)CF_ROUNDF(event.window.data1 / cs); + app->h = (int)CF_ROUNDF(event.window.data2 / cs); + } break; case SDL_EVENT_WINDOW_MOVED: app->window_state.moved = true; @@ -580,13 +583,8 @@ void cf_pump_input_msgs() break; case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: - app->display_scale = SDL_GetWindowDisplayScale(app->window); - app->display_scale_was_changed = true; - s_refresh_natural_pixel_scale(); - break; - case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: - s_refresh_natural_pixel_scale(); + s_refresh_display_scale(); break; case SDL_EVENT_KEY_DOWN: @@ -629,12 +627,15 @@ void cf_pump_input_msgs() 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_EVENT_MOUSE_MOTION: { + // SDL reports raw window coordinates; CF works in logical points (identical on + // macOS, divided by the OS content scale on Windows/X11). + float cs = cf_app_content_scale(); + app->mouse.x = event.motion.x / cs; + app->mouse.y = event.motion.y / cs; + app->mouse.xrel = event.motion.xrel / cs; + app->mouse.yrel = -event.motion.yrel / cs; + } break; case SDL_EVENT_MOUSE_BUTTON_DOWN: switch (event.button.button) @@ -645,8 +646,8 @@ void cf_pump_input_msgs() 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; + app->mouse.x = event.button.x / cf_app_content_scale(); + app->mouse.y = event.button.y / cf_app_content_scale(); if (event.button.clicks == 1) { app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; } else if (event.button.clicks == 2) { @@ -663,8 +664,8 @@ void cf_pump_input_msgs() 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; + app->mouse.x = event.button.x / cf_app_content_scale(); + app->mouse.y = event.button.y / cf_app_content_scale(); if (event.button.clicks == 1) { app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; } else if (event.button.clicks == 2) { diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index 2f6bd1803..5ebd1081b 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -31,6 +31,12 @@ struct cs_context_t; CF_API extern struct CF_App* app; +// SDL's content scale for the window's display: the conversion factor between SDL's raw +// window coordinates (window size, mouse position) and CF's logical points. 1.0 on macOS, +// the OS UI scale (e.g. 1.5 at 150%) on Windows/X11. Purely internal -- the public API is +// points everywhere, so this concept is never exposed to the user. +float cf_app_content_scale(); + // Maps an SDL_PowerState to the corresponding CF_PowerState. Header-inline (rather than // CF_API) so it stays testable from test/test_app.cpp without crossing the shared-library @@ -98,10 +104,9 @@ struct CF_App Cute::CF_Path shader_directory; Cute::Map shader_file_infos; bool gfx_enabled = false; - float display_scale = 1.0f; + float display_scale = 1.0f; // What the OS wants: points-to-pixels for the window's display (SDL_GetWindowDisplayScale). Only the OS changes it; refreshed on scale events, never applied automatically. bool display_scale_was_changed = false; - float pixel_scale = 1.0f; // Physical pixels per logical point. User-controlled via cf_app_set_pixel_scale; initialized to the window's natural density (or 1.0 with NO_HIGH_DPI). Drives AA and glyph rasterization. - float natural_pixel_scale = 1.0f; // The density SDL reports for the window's current display; refreshed on density events, never applied automatically. + float pixel_scale = 1.0f; // How CF scales fonts and shapes: physical pixels per logical point. User-controlled via cf_app_set_pixel_scale; initialized to display_scale (or 1.0 with NO_HIGH_DPI). bool pixel_scale_was_changed = false; // Visible flag: true for the one frame following a pixel_scale change. bool pixel_scale_changed_pending = false; // Set by cf_app_set_pixel_scale mid-frame; transferred to the visible flag at the next input pump. CF_Filter canvas_blit_filter = CF_FILTER_NEAREST; // Filter used when blitting the app canvas onto the screen, if their sizes differ (e.g. after cf_app_set_canvas_size). Defaults to nearest for a crisp/blocky pixel-art look. diff --git a/test/test_app_shared.cpp b/test/test_app_shared.cpp index c1cbedf90..ee7c9198c 100644 --- a/test/test_app_shared.cpp +++ b/test/test_app_shared.cpp @@ -39,7 +39,7 @@ bool test_make_app(int w, int h, int extra_options) // pixel scale, canvas size, and default 2d projection a previous test may have // changed. (This is the same call an app with a resizable window makes -- see the // hidpi sample.) - cf_app_apply_pixel_scale(cf_app_get_natural_pixel_scale()); + cf_app_update_display(cf_app_get_display_scale()); // Well-known process globals a test legitimately mutates and rarely thinks to restore // -- with one app per test their reset came free from cf_destroy_app. Everything else // (push/pop stacks, canvases, shaders) is the test's own balance to keep; run with diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp index c808729ac..200b99131 100644 --- a/test/test_hidpi.cpp +++ b/test/test_hidpi.cpp @@ -15,7 +15,7 @@ using namespace Cute; // The pixel scale (physical pixels per logical point) is a user-controlled value: it starts -// at the display's natural density and afterwards changes only via cf_app_set_pixel_scale. +// at the display scale the OS wants and afterwards changes only via cf_app_set_pixel_scale. // Nothing -- not window resizes, not display density changes -- resizes the app canvas or // touches the default 2d projection behind the user's back. CI and most dev machines run at // density 1.0 where points and pixels agree, so these tests set a non-unity scale explicitly @@ -63,7 +63,7 @@ static bool s_readback_canvas(CF_Canvas canvas, int w, int h, CF_Pixel* out) // resolution, projection rebuilt in logical points (the same value startup chose). static void s_apply_2x() { - cf_app_apply_pixel_scale(2.0f); + cf_app_update_display(2.0f); } // cf_app_set_pixel_scale changes only the scale value -- the canvas and window keep their @@ -74,7 +74,7 @@ TEST_CASE(test_hidpi_set_pixel_scale_is_value_only) HidpiGuard guard; // 3.0f can't collide with any real display density, so this is a guaranteed change - // even on a machine whose natural scale is already 2.0. + // even on a machine whose display scale is already 2.0. int canvas_w = cf_app_get_canvas_width(); int canvas_h = cf_app_get_canvas_height(); cf_app_set_pixel_scale(3.0f); @@ -93,7 +93,7 @@ TEST_CASE(test_hidpi_set_pixel_scale_is_value_only) } // The change flag latches at the next cf_app_update and reads true for exactly one frame, -// mirroring cf_app_dpi_scale_was_changed. +// mirroring cf_app_display_scale_was_changed. TEST_CASE(test_hidpi_pixel_scale_was_changed_flag) { if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. @@ -103,7 +103,7 @@ TEST_CASE(test_hidpi_pixel_scale_was_changed_flag) // honestly flagged); settle its one visible frame before observing. cf_app_update(NULL); - // 3.0f can't collide with the natural density this machine started at. + // 3.0f can't collide with the display scale this machine started at. REQUIRE(!cf_app_pixel_scale_was_changed()); cf_app_set_pixel_scale(3.0f); REQUIRE(!cf_app_pixel_scale_was_changed()); // Not visible until the next update. @@ -120,14 +120,14 @@ TEST_CASE(test_hidpi_pixel_scale_was_changed_flag) } // NO_HIGH_DPI only pins the INITIAL scale at 1.0 (the window gets a 1x backbuffer, so the -// natural density is 1.0 too). The value stays user-controllable afterwards. +// display scale is 1.0 too). The value stays user-controllable afterwards. TEST_CASE(test_hidpi_no_high_dpi_initial_scale) { if (!test_make_app(LOGICAL_W, LOGICAL_H, CF_APP_OPTIONS_NO_HIGH_DPI_BIT)) return true; // Headless CI: no display/GPU. HidpiGuard guard; REQUIRE(cf_app_get_pixel_scale() == 1.0f); - REQUIRE(cf_app_get_natural_pixel_scale() == 1.0f); + REQUIRE(cf_app_get_display_scale() == 1.0f); REQUIRE(cf_app_get_canvas_width() == LOGICAL_W); REQUIRE(cf_app_get_canvas_height() == LOGICAL_H); return true; @@ -144,35 +144,35 @@ TEST_CASE(test_hidpi_set_scale_safe_without_gfx) REQUIRE(cf_app_get_pixel_scale() == 2.0f); // The packaged helper skips its canvas/projection half without gfx. - cf_app_apply_pixel_scale(3.0f); + cf_app_update_display(3.0f); REQUIRE(cf_app_get_pixel_scale() == 3.0f); return true; } -// Startup is the one automatic step: canvas at logical size times the natural density, and -// the applied scale starts equal to the natural one. +// Startup is the one automatic step: canvas at logical size times the display scale, and +// the applied pixel scale starts equal to it. TEST_CASE(test_hidpi_startup_defaults) { if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. HidpiGuard guard; - float natural = cf_app_get_natural_pixel_scale(); - REQUIRE(natural > 0); - REQUIRE(cf_app_get_pixel_scale() == natural); - REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(LOGICAL_W * natural)); - REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(LOGICAL_H * natural)); + float display_scale = cf_app_get_display_scale(); + REQUIRE(display_scale > 0); + REQUIRE(cf_app_get_pixel_scale() == display_scale); + REQUIRE(cf_app_get_canvas_width() == (int)CF_ROUNDF(LOGICAL_W * display_scale)); + REQUIRE(cf_app_get_canvas_height() == (int)CF_ROUNDF(LOGICAL_H * display_scale)); return true; } -// cf_app_apply_pixel_scale is the packaged recipe: one call sets the scale AND resizes the +// cf_app_update_display is the packaged recipe: one call sets the scale AND resizes the // canvas to window * scale (the projection half of its contract is readback-verified by the // tests below, which all go through s_apply_2x). Invalid scales are ignored wholesale. -TEST_CASE(test_hidpi_apply_pixel_scale_helper) +TEST_CASE(test_hidpi_update_display_helper) { if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. HidpiGuard guard; - cf_app_apply_pixel_scale(2.0f); + cf_app_update_display(2.0f); REQUIRE(cf_app_get_pixel_scale() == 2.0f); REQUIRE(cf_app_get_canvas_width() == LOGICAL_W * 2); REQUIRE(cf_app_get_canvas_height() == LOGICAL_H * 2); @@ -180,7 +180,7 @@ TEST_CASE(test_hidpi_apply_pixel_scale_helper) REQUIRE(cf_app_get_height() == LOGICAL_H); // An invalid scale must not half-apply (no canvas resize either). - cf_app_apply_pixel_scale(0); + cf_app_update_display(0); REQUIRE(cf_app_get_pixel_scale() == 2.0f); REQUIRE(cf_app_get_canvas_width() == LOGICAL_W * 2); return true; @@ -396,7 +396,7 @@ TEST_SUITE(test_hidpi) RUN_TEST_CASE(test_hidpi_no_high_dpi_initial_scale); RUN_TEST_CASE(test_hidpi_set_scale_safe_without_gfx); RUN_TEST_CASE(test_hidpi_startup_defaults); - RUN_TEST_CASE(test_hidpi_apply_pixel_scale_helper); + RUN_TEST_CASE(test_hidpi_update_display_helper); RUN_TEST_CASE(test_hidpi_default_projection_is_points); RUN_TEST_CASE(test_hidpi_full_extent_covers_app_canvas); RUN_TEST_CASE(test_hidpi_projection_sticky_across_canvas_recreate); From c7d1e12619e8347678caef3a393c2fb65bc6ffc8 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Sat, 15 Aug 2026 05:14:06 +0200 Subject: [PATCH 04/13] Fix CI: null s_draw on destroy, speak raw coordinates in resize test cf_destroy_draw freed s_draw but never nulled it (the make-error path did), so cf_app_set_pixel_scale on a NO_GFX app created after a gfx app's destruction passed the hook's s_draw guard with a dangling pointer -- an instant access violation on Windows, silently-readable freed memory on POSIX. The synthetic WINDOW_RESIZED event in test_hidpi pushed 500 raw, but SDL resize events carry raw window coordinates which CF now divides by the display content scale -- not 1.0 on the X11 CI runners. Push points * content_scale like a real event, and compare against the handler's exact round-trip. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn --- src/cute_draw.cpp | 4 ++++ test/test_hidpi.cpp | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/cute_draw.cpp b/src/cute_draw.cpp index 2b4263740..891854eb8 100644 --- a/src/cute_draw.cpp +++ b/src/cute_draw.cpp @@ -1131,6 +1131,10 @@ void cf_destroy_draw() cf_destroy_material(s_draw->material); s_draw->~CF_Draw(); CF_FREE(s_draw); + // cf_draw_on_pixel_scale_changed guards on s_draw, which can be reached with no draw + // context alive (cf_app_set_pixel_scale on a NO_GFX app after a gfx app was destroyed) + // -- a dangling pointer here passes the guard and derefs freed memory. + s_draw = NULL; } //-------------------------------------------------------------------------------------------------- diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp index 200b99131..b9f88555d 100644 --- a/test/test_hidpi.cpp +++ b/test/test_hidpi.cpp @@ -334,17 +334,24 @@ TEST_CASE(test_hidpi_resize_event_does_not_recreate_canvas) REQUIRE(cf_app_get_canvas_width() == 300); REQUIRE(cf_app_get_canvas_height() == 200); + // SDL resize events carry raw window coordinates, which CF converts to logical points + // by the display's content scale (1.0 on macOS, but e.g. X11 can report otherwise) -- + // the synthetic event must speak raw coordinates like a real one. + float cs = SDL_GetDisplayContentScale(SDL_GetDisplayForWindow(app->window)); + if (cs <= 0) cs = 1.0f; SDL_Event e = { }; e.type = SDL_EVENT_WINDOW_RESIZED; e.window.windowID = SDL_GetWindowID(app->window); - e.window.data1 = 500; - e.window.data2 = 400; + e.window.data1 = (int)CF_ROUNDF(500 * cs); + e.window.data2 = (int)CF_ROUNDF(400 * cs); SDL_PushEvent(&e); cf_app_update(NULL); REQUIRE(cf_app_was_resized()); - REQUIRE(cf_app_get_width() == 500); - REQUIRE(cf_app_get_height() == 400); + // Compare against the exact raw->points round-trip the handler computes: for content + // scales that don't divide integers cleanly the result can differ from 500 by one. + REQUIRE(cf_app_get_width() == (int)CF_ROUNDF(e.window.data1 / cs)); + REQUIRE(cf_app_get_height() == (int)CF_ROUNDF(e.window.data2 / cs)); REQUIRE(cf_app_get_canvas_width() == 300); REQUIRE(cf_app_get_canvas_height() == 200); return true; From 52b28c2fb10d1a6efdcac8a97027d7b545b7d7c9 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Sat, 15 Aug 2026 05:22:22 +0200 Subject: [PATCH 05/13] test_hidpi: settle the shared app's pending resize before the synthetic event On X11 the ConfigureNotify confirming test_make_app's SDL_SetWindowSize can arrive late and land in the same pump as the synthetic resize event, stomping app->w after it. Drain it first -- the same settling dance the one-shot test used on the previous branch, lost in the rewrite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LHq3Cx7xcjAQhji9m1ADJn --- test/test_hidpi.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp index b9f88555d..12b1769ee 100644 --- a/test/test_hidpi.cpp +++ b/test/test_hidpi.cpp @@ -330,6 +330,13 @@ TEST_CASE(test_hidpi_resize_event_does_not_recreate_canvas) if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. HidpiGuard guard; + // test_make_app's cf_app_set_size may still have an SDL_SetWindowSize confirmation in + // flight (on X11 the ConfigureNotify can arrive late, as a stale-then-fresh burst). + // Settle it now so a late real resize event can't land in the same pump as the + // synthetic one below and stomp app->w after it. + cf_app_draw_onto_screen(false); + cf_app_update(NULL); + cf_app_set_canvas_size(300, 200); REQUIRE(cf_app_get_canvas_width() == 300); REQUIRE(cf_app_get_canvas_height() == 200); From 5ce484f3826da4b4123b9a523d9765945cd20031 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 17 Aug 2026 16:42:54 +0200 Subject: [PATCH 06/13] samples/hidpi: add live mouse coordinate readout Shows raw screen coords next to cf_screen_to_world's translation, so the HiDPI mouse mapping can be eyeballed against known shape positions at any forced pixel scale. Co-Authored-By: Claude Sonnet 5 --- samples/hidpi.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/samples/hidpi.c b/samples/hidpi.c index 0f56f9599..8b0933bf0 100644 --- a/samples/hidpi.c +++ b/samples/hidpi.c @@ -115,6 +115,23 @@ int main(int argc, char* argv[]) draw_text_centered(pixel_scale_buf, cf_v2(0, 300)); cf_pop_font_size(); + // -- Live mouse-coordinate readout -- screen space is raw, top-left-origin, + // y-down input; world space is what cf_screen_to_world hands back, matching + // the space cf_draw_text etc. draw into. If HiDPI translation is correct, the + // world coordinate should track the cursor 1:1 over the canvas regardless of + // pixel_scale. + char mouse_buf[128]; + CF_V2 mouse_screen = cf_v2(cf_mouse_x(), cf_mouse_y()); + CF_V2 mouse_world = cf_screen_to_world(mouse_screen); + snprintf( + mouse_buf, sizeof(mouse_buf), + "mouse: screen (%.0f, %.0f) -> world (%.0f, %.0f)", + mouse_screen.x, mouse_screen.y, mouse_world.x, mouse_world.y + ); + cf_push_font_size(12); + draw_text_centered(mouse_buf, cf_v2(0, 300 - 18)); + cf_pop_font_size(); + cf_draw_pop_color(); cf_draw_push_color(cf_make_color_rgb(120, 130, 150)); From 879f18ea954283d292a510519b52a7cef4370273 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 17 Aug 2026 22:55:23 +0200 Subject: [PATCH 07/13] Remove cf_app_pixel_scale_was_changed The pixel scale only ever changes when user code calls cf_app_set_pixel_scale, so this flag just echoed the caller's own action back one frame later -- no OS event ever set it. The real event remains cf_app_display_scale_was_changed, and anyone wanting change detection can compare cf_app_get_pixel_scale against a cached value. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01S4anUDPNkAio27kQbWAqrn --- include/cute_app.h | 16 ++-------------- src/cute_app.cpp | 6 ------ src/cute_input.cpp | 4 ---- src/internal/cute_app_internal.h | 2 -- test/test_hidpi.cpp | 28 ---------------------------- 5 files changed, 2 insertions(+), 54 deletions(-) diff --git a/include/cute_app.h b/include/cute_app.h index 070b4dc81..67f509a6c 100644 --- a/include/cute_app.h +++ b/include/cute_app.h @@ -427,7 +427,7 @@ CF_API bool CF_CALL cf_app_display_scale_was_changed(void); * user-controlled value, like the window or canvas size: it starts at the display scale the OS wants * (e.g. 2.0f on a 2x Retina display, or 1.0f if `CF_APP_OPTIONS_NO_HIGH_DPI_BIT` was passed to * `cf_make_app`) and afterwards changes only through `cf_app_set_pixel_scale` or `cf_app_update_display`. - * @related cf_app_set_pixel_scale cf_app_update_display cf_app_pixel_scale_was_changed cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height + * @related cf_app_set_pixel_scale cf_app_update_display cf_app_get_display_scale cf_app_get_size cf_app_get_canvas_width cf_app_get_canvas_height */ CF_API float CF_CALL cf_app_get_pixel_scale(void); @@ -440,21 +440,10 @@ CF_API float CF_CALL cf_app_get_pixel_scale(void); * the new density, but the app canvas keeps its current size -- `cf_app_set_canvas_size` is a separate * call (or use `cf_app_update_display` to do both plus the projection). The scale can be arbitrary, or * follow the reported `cf_app_get_display_scale`. The draw API stays in logical points throughout. - * `cf_app_pixel_scale_was_changed` reports true for one frame after a change. - * @related cf_app_get_pixel_scale cf_app_update_display cf_app_pixel_scale_was_changed cf_app_set_canvas_size cf_app_get_display_scale + * @related cf_app_get_pixel_scale cf_app_update_display cf_app_set_canvas_size cf_app_get_display_scale */ CF_API void CF_CALL cf_app_set_pixel_scale(float scale); -/** - * @function cf_app_pixel_scale_was_changed - * @category app - * @brief Returns true for one frame after the pixel scale was changed by `cf_app_set_pixel_scale`. - * @remarks Latched at the next `cf_app_update` after the change, mirroring `cf_app_display_scale_was_changed` -- - * code that reads it sees the flag for exactly one full frame. - * @related cf_app_set_pixel_scale cf_app_get_pixel_scale cf_app_display_scale_was_changed - */ -CF_API bool CF_CALL cf_app_pixel_scale_was_changed(void); - /** * @function cf_app_update_display * @category app @@ -1017,7 +1006,6 @@ CF_INLINE float app_get_display_scale() { return cf_app_get_display_scale(); } CF_INLINE bool app_display_scale_was_changed() { return cf_app_display_scale_was_changed(); } CF_INLINE float app_get_pixel_scale() { return cf_app_get_pixel_scale(); } CF_INLINE void app_set_pixel_scale(float scale) { cf_app_set_pixel_scale(scale); } -CF_INLINE bool app_pixel_scale_was_changed() { return cf_app_pixel_scale_was_changed(); } CF_INLINE void app_update_display(float scale) { cf_app_update_display(scale); } CF_INLINE void app_center_window() { cf_app_center_window(); } CF_INLINE bool app_was_resized() { return cf_app_was_resized(); } diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 806b42277..27980dedc 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -663,15 +663,9 @@ void cf_app_set_pixel_scale(float scale) if (!(scale > 0)) return; if (scale == app->pixel_scale) return; app->pixel_scale = scale; - app->pixel_scale_changed_pending = true; cf_draw_on_pixel_scale_changed(); } -bool cf_app_pixel_scale_was_changed() -{ - return app->pixel_scale_was_changed; -} - void cf_app_update_display(float scale) { if (!(scale > 0)) return; diff --git a/src/cute_input.cpp b/src/cute_input.cpp index 8555ef9fe..3089d3861 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -477,10 +477,6 @@ void cf_begin_frame_input() app->window_state.restored = false; app->window_state.resized = false; app->display_scale_was_changed = false; - // cf_app_set_pixel_scale runs from user code mid-frame; latching here makes the flag - // visible for exactly the following frame, mirroring display_scale_was_changed. - app->pixel_scale_was_changed = app->pixel_scale_changed_pending; - app->pixel_scale_changed_pending = false; cf_joypad_update(); // Update key durations to simulate "press and hold" style for `key_repeating`. diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index 5ebd1081b..3ce4f2cf8 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -107,8 +107,6 @@ struct CF_App float display_scale = 1.0f; // What the OS wants: points-to-pixels for the window's display (SDL_GetWindowDisplayScale). Only the OS changes it; refreshed on scale events, never applied automatically. bool display_scale_was_changed = false; float pixel_scale = 1.0f; // How CF scales fonts and shapes: physical pixels per logical point. User-controlled via cf_app_set_pixel_scale; initialized to display_scale (or 1.0 with NO_HIGH_DPI). - bool pixel_scale_was_changed = false; // Visible flag: true for the one frame following a pixel_scale change. - bool pixel_scale_changed_pending = false; // Set by cf_app_set_pixel_scale mid-frame; transferred to the visible flag at the next input pump. CF_Filter canvas_blit_filter = CF_FILTER_NEAREST; // Filter used when blitting the app canvas onto the screen, if their sizes differ (e.g. after cf_app_set_canvas_size). Defaults to nearest for a crisp/blocky pixel-art look. bool sync_window = false; int draw_call_count = 0; diff --git a/test/test_hidpi.cpp b/test/test_hidpi.cpp index 12b1769ee..dacf1fe46 100644 --- a/test/test_hidpi.cpp +++ b/test/test_hidpi.cpp @@ -92,33 +92,6 @@ TEST_CASE(test_hidpi_set_pixel_scale_is_value_only) return true; } -// The change flag latches at the next cf_app_update and reads true for exactly one frame, -// mirroring cf_app_display_scale_was_changed. -TEST_CASE(test_hidpi_pixel_scale_was_changed_flag) -{ - if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. - HidpiGuard guard; - - // test_make_app's shared-app sweep may itself have changed the scale (an honest change, - // honestly flagged); settle its one visible frame before observing. - cf_app_update(NULL); - - // 3.0f can't collide with the display scale this machine started at. - REQUIRE(!cf_app_pixel_scale_was_changed()); - cf_app_set_pixel_scale(3.0f); - REQUIRE(!cf_app_pixel_scale_was_changed()); // Not visible until the next update. - cf_app_update(NULL); - REQUIRE(cf_app_pixel_scale_was_changed()); // Visible for this one frame. - cf_app_update(NULL); - REQUIRE(!cf_app_pixel_scale_was_changed()); // Cleared again. - - // Setting the same value is not a change. - cf_app_set_pixel_scale(3.0f); - cf_app_update(NULL); - REQUIRE(!cf_app_pixel_scale_was_changed()); - return true; -} - // NO_HIGH_DPI only pins the INITIAL scale at 1.0 (the window gets a 1x backbuffer, so the // display scale is 1.0 too). The value stays user-controllable afterwards. TEST_CASE(test_hidpi_no_high_dpi_initial_scale) @@ -406,7 +379,6 @@ TEST_CASE(test_hidpi_draw_list_recorded_across_scale_change) TEST_SUITE(test_hidpi) { RUN_TEST_CASE(test_hidpi_set_pixel_scale_is_value_only); - RUN_TEST_CASE(test_hidpi_pixel_scale_was_changed_flag); RUN_TEST_CASE(test_hidpi_no_high_dpi_initial_scale); RUN_TEST_CASE(test_hidpi_set_scale_safe_without_gfx); RUN_TEST_CASE(test_hidpi_startup_defaults); From d84791b40eb437a40de0759a65be9f6f355a318d Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 24 Aug 2026 23:05:02 +0200 Subject: [PATCH 08/13] Move the variable to the scope where it's used --- src/cute_app.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 27980dedc..bf7ae91a3 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -288,8 +288,6 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, SDL_Window* window = NULL; // The app isn't constructed yet, so resolve the creation display's content scale // directly: w/h are logical points, SDL_CreateWindow wants raw window coordinates. - float creation_content_scale = SDL_GetDisplayContentScale(display_id ? display_id : SDL_GetPrimaryDisplay()); - if (creation_content_scale <= 0) creation_content_scale = 1.0f; if (use_gfx) { Uint32 flags = 0; if (!(options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT)) { @@ -301,6 +299,8 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, if (options & CF_APP_OPTIONS_RESIZABLE_BIT) flags |= SDL_WINDOW_RESIZABLE; if (options & CF_APP_OPTIONS_HIDDEN_BIT) flags |= (SDL_WINDOW_HIDDEN | SDL_WINDOW_MINIMIZED); + float creation_content_scale = SDL_GetDisplayContentScale(display_id ? display_id : SDL_GetPrimaryDisplay()); + if (creation_content_scale <= 0) creation_content_scale = 1.0f; SDL_PropertiesID props = SDL_CreateProperties(); SDL_SetStringProperty(props, SDL_PROP_WINDOW_CREATE_TITLE_STRING, window_title); SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, (int)CF_ROUNDF(w * creation_content_scale)); From 3160c75a5c4ab818252ee80ed82523da0dc840ec Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 24 Aug 2026 23:08:01 +0200 Subject: [PATCH 09/13] Rename to cf_app_get_content_scale --- src/cute_app.cpp | 4 ++-- src/cute_input.cpp | 12 ++++++------ src/internal/cute_app_internal.h | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cute_app.cpp b/src/cute_app.cpp index bf7ae91a3..18e933657 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -637,7 +637,7 @@ void cf_app_show_window() SDL_ShowWindow(app->window); } -float cf_app_content_scale() +float cf_app_get_content_scale() { float scale = app->window ? SDL_GetDisplayContentScale(SDL_GetDisplayForWindow(app->window)) : 1.0f; return scale > 0 ? scale : 1.0f; @@ -681,7 +681,7 @@ void cf_app_set_size(int w, int h) { // Public sizes are logical points; SDL_SetWindowSize wants raw window coordinates // (identical on macOS, points * content scale on Windows/X11). - float cs = cf_app_content_scale(); + float cs = cf_app_get_content_scale(); SDL_SetWindowSize(app->window, (int)CF_ROUNDF(w * cs), (int)CF_ROUNDF(h * cs)); app->w = w; app->h = h; diff --git a/src/cute_input.cpp b/src/cute_input.cpp index 3089d3861..f1b03d0a6 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -538,7 +538,7 @@ void cf_pump_input_msgs() // response (see cf_app_was_resized and the hidpi sample for the recipe). // SDL reports raw window coordinates; CF stores logical points (identical on // macOS, divided by the OS content scale on Windows/X11). - float cs = cf_app_content_scale(); + float cs = cf_app_get_content_scale(); app->window_state.resized = true; app->w = (int)CF_ROUNDF(event.window.data1 / cs); app->h = (int)CF_ROUNDF(event.window.data2 / cs); @@ -626,7 +626,7 @@ void cf_pump_input_msgs() case SDL_EVENT_MOUSE_MOTION: { // SDL reports raw window coordinates; CF works in logical points (identical on // macOS, divided by the OS content scale on Windows/X11). - float cs = cf_app_content_scale(); + float cs = cf_app_get_content_scale(); app->mouse.x = event.motion.x / cs; app->mouse.y = event.motion.y / cs; app->mouse.xrel = event.motion.xrel / cs; @@ -642,8 +642,8 @@ void cf_pump_input_msgs() 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 / cf_app_content_scale(); - app->mouse.y = event.button.y / cf_app_content_scale(); + app->mouse.x = event.button.x / cf_app_get_content_scale(); + app->mouse.y = event.button.y / cf_app_get_content_scale(); if (event.button.clicks == 1) { app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; } else if (event.button.clicks == 2) { @@ -660,8 +660,8 @@ void cf_pump_input_msgs() 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 / cf_app_content_scale(); - app->mouse.y = event.button.y / cf_app_content_scale(); + app->mouse.x = event.button.x / cf_app_get_content_scale(); + app->mouse.y = event.button.y / cf_app_get_content_scale(); if (event.button.clicks == 1) { app->mouse.click_type = CF_MOUSE_CLICK_SINGLE; } else if (event.button.clicks == 2) { diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index 3ce4f2cf8..2f1334ae4 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -35,7 +35,7 @@ CF_API extern struct CF_App* app; // window coordinates (window size, mouse position) and CF's logical points. 1.0 on macOS, // the OS UI scale (e.g. 1.5 at 150%) on Windows/X11. Purely internal -- the public API is // points everywhere, so this concept is never exposed to the user. -float cf_app_content_scale(); +float cf_app_get_content_scale(); // Maps an SDL_PowerState to the corresponding CF_PowerState. Header-inline (rather than From b9308732697a02c267d42bf0d8c21d3abafb2f63 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 24 Aug 2026 23:14:03 +0200 Subject: [PATCH 10/13] Remove comment --- src/cute_app.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 18e933657..3ecc8d790 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -286,8 +286,6 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, } SDL_Window* window = NULL; - // The app isn't constructed yet, so resolve the creation display's content scale - // directly: w/h are logical points, SDL_CreateWindow wants raw window coordinates. if (use_gfx) { Uint32 flags = 0; if (!(options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT)) { From fa15d703ab74cd50ea39ae444521a710290eca28 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 24 Aug 2026 23:18:03 +0200 Subject: [PATCH 11/13] Comment cleanup --- src/cute_app.cpp | 9 --------- src/cute_draw.cpp | 3 --- 2 files changed, 12 deletions(-) diff --git a/src/cute_app.cpp b/src/cute_app.cpp index 3ecc8d790..f0cb0a678 100644 --- a/src/cute_app.cpp +++ b/src/cute_app.cpp @@ -336,9 +336,6 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, SDL_GetWindowPosition(app->window, &app->x, &app->y); app->display_scale = SDL_GetWindowDisplayScale(app->window); if (app->display_scale <= 0.0f) app->display_scale = 1.0f; - // The initial pixel scale follows what the OS wants (with NO_HIGH_DPI the window - // has a 1x backbuffer, so pin to 1.0 to match it). After init, pixel_scale only - // ever changes via cf_app_set_pixel_scale / cf_app_update_display. app->pixel_scale = app->display_scale; if (options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT) app->pixel_scale = 1.0f; } @@ -358,9 +355,6 @@ CF_Result cf_make_app(const char* window_title, CF_DisplayID display_id, int x, cf_load_internal_shaders(); cf_make_draw(); - // The one and only automatic canvas/projection setup: canvas at the window's natural - // pixel size, default 2d projection (set by cf_make_draw) spanning the logical size. - // From here on, canvas size, pixel scale, and projection change only by user calls. s_canvas((int)CF_ROUNDF(app->w * app->pixel_scale), (int)CF_ROUNDF(app->h * app->pixel_scale)); // Create the default font. @@ -668,7 +662,6 @@ void cf_app_update_display(float scale) { if (!(scale > 0)) return; cf_app_set_pixel_scale(scale); - // NO_GFX apps have no canvas or draw state -- the scale value is all there is to apply. if (app->gfx_enabled) { cf_app_set_canvas_size((int)CF_ROUNDF(app->w * scale), (int)CF_ROUNDF(app->h * scale)); cf_draw_projection(cf_ortho_2d(0, 0, (float)app->w, (float)app->h)); @@ -797,8 +790,6 @@ bool cf_app_set_msaa(int sample_count) if (supported && app->sample_count != sample_count) { app->sample_count = sample_count; - // Rebuild the canvas with the new sample count at its current size -- an MSAA - // change must not stomp a user-chosen canvas size. s_canvas(app->canvas_w, app->canvas_h); } diff --git a/src/cute_draw.cpp b/src/cute_draw.cpp index 891854eb8..2c607bf51 100644 --- a/src/cute_draw.cpp +++ b/src/cute_draw.cpp @@ -1131,9 +1131,6 @@ void cf_destroy_draw() cf_destroy_material(s_draw->material); s_draw->~CF_Draw(); CF_FREE(s_draw); - // cf_draw_on_pixel_scale_changed guards on s_draw, which can be reached with no draw - // context alive (cf_app_set_pixel_scale on a NO_GFX app after a gfx app was destroyed) - // -- a dangling pointer here passes the guard and derefs freed memory. s_draw = NULL; } From b9837fe5d401f65aea37646af91c10bc0ba28068 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Mon, 24 Aug 2026 23:58:14 +0200 Subject: [PATCH 12/13] Assing scale to avriable an reuse --- src/cute_input.cpp | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/cute_input.cpp b/src/cute_input.cpp index f1b03d0a6..dc7d671a9 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -633,7 +633,7 @@ void cf_pump_input_msgs() app->mouse.yrel = -event.motion.yrel / cs; } break; - case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_DOWN: { switch (event.button.button) { case SDL_BUTTON_LEFT: app->mouse.left_button = 1; break; @@ -641,17 +641,18 @@ void cf_pump_input_msgs() 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 / cf_app_get_content_scale(); - app->mouse.y = event.button.y / cf_app_get_content_scale(); + }; + float cs = cf_app_get_content_scale(); + app->mouse.x = event.button.x / cs; + app->mouse.y = event.button.y / cs; 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; + } break; - case SDL_EVENT_MOUSE_BUTTON_UP: + case SDL_EVENT_MOUSE_BUTTON_UP: { switch (event.button.button) { case SDL_BUTTON_LEFT: app->mouse.left_button = 0; break; @@ -660,14 +661,15 @@ void cf_pump_input_msgs() 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 / cf_app_get_content_scale(); - app->mouse.y = event.button.y / cf_app_get_content_scale(); + float cs = cf_app_get_content_scale(); + app->mouse.x = event.button.x / cs; + app->mouse.y = event.button.y / cs; 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; + } + } break; case SDL_EVENT_MOUSE_WHEEL: app->mouse.wheel_motion = event.wheel.y; From a0a7dc8f60aa431d58f2a158d9ca83d7c6174ed1 Mon Sep 17 00:00:00 2001 From: Piotr Usewicz Date: Tue, 25 Aug 2026 00:12:14 +0200 Subject: [PATCH 13/13] Simplify pixel-scale comment Co-Authored-By: Claude Sonnet 5 --- src/internal/cute_draw_internal.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/internal/cute_draw_internal.h b/src/internal/cute_draw_internal.h index 9591983d8..ecfd54a6e 100644 --- a/src/internal/cute_draw_internal.h +++ b/src/internal/cute_draw_internal.h @@ -469,10 +469,9 @@ void cf_draw3d_free_cmd(CF_Command* cmd); // Runs the atlas defrag at most once per frame (see CF_Draw::defragged_this_frame). void cf_atlas_defrag_once(); -// Called by cf_app_set_pixel_scale: the AA band width divides by pixel_scale, so a scale -// change must refresh it immediately (glyphs re-rasterize lazily -- pixel_scale is part of -// the glyph cache key). The projection is deliberately NOT touched: it is set once at init -// and only ever changed by the user via cf_draw_projection. +// Called by cf_app_set_pixel_scale to refresh the AA band width (depends on pixel_scale). +// Glyphs re-rasterize lazily, so they're skipped here. Projection is untouched -- it's +// set once at init and only changed via cf_draw_projection. void cf_draw_on_pixel_scale_changed(); // Called by cf_render_layers_to before the canvas (and its render pass) is applied: stages