diff --git a/include/cute_app.h b/include/cute_app.h index ead3b798..67f509a6 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 */ @@ -400,43 +400,75 @@ 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); /** * @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 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_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 -- `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. + * @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_update_display + * @category app + * @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 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_update_display(float scale); + /** * @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 +761,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 +1005,8 @@ 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 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 3b0dcd32..8b0933bf 100644 --- a/samples/hidpi.c +++ b/samples/hidpi.c @@ -1,15 +1,26 @@ /* - hidpi.c -- HiDPI / Retina rendering visual verification. + hidpi.c -- HiDPI / Retina rendering, the manual way. - 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. + 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 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_update_display (= set the scale, resize the canvas to match the + window, rebuild the projection) is the whole recipe: - 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). + 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 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. What it draws: - Text at three sizes (12px / 24px / 48px) to eyeball glyph @@ -17,11 +28,8 @@ - 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 pixel scale and the OS display scale alongside + the physical canvas size. */ #include @@ -54,13 +62,27 @@ int main(int argc, char* argv[]) cf_sprite_play(&sprite, "idle"); sprite.scale = cf_v2(3.0f, 3.0f); + // 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_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. Display scale changed (moved to another monitor) -> update to + // the new scale, but only in follow mode. 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. + cf_app_update_display(cf_app_get_pixel_scale()); + } + if (cf_app_display_scale_was_changed() && track_display) { + cf_app_update_display(cf_app_get_display_scale()); } cf_push_font("Calibri"); @@ -79,19 +101,37 @@ 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 (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)); 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)); diff --git a/src/cute_app.cpp b/src/cute_app.cpp index fe7a0784..f0cb0a67 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) @@ -305,10 +297,12 @@ 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, 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)); @@ -341,8 +335,8 @@ 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; + app->pixel_scale = app->display_scale; if (options & CF_APP_OPTIONS_NO_HIGH_DPI_BIT) app->pixel_scale = 1.0f; } ::app = app; @@ -361,7 +355,7 @@ 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(); + 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"); @@ -635,6 +629,12 @@ void cf_app_show_window() SDL_ShowWindow(app->window); } +float cf_app_get_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; @@ -650,16 +650,33 @@ 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; + cf_draw_on_pixel_scale_changed(); +} + +void cf_app_update_display(float scale) +{ + if (!(scale > 0)) return; + cf_app_set_pixel_scale(scale); + 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); + // Public sizes are logical points; SDL_SetWindowSize wants raw window coordinates + // (identical on macOS, points * content scale on Windows/X11). + 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; 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 +790,7 @@ 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(); + s_canvas(app->canvas_w, app->canvas_h); } return supported; diff --git a/src/cute_draw.cpp b/src/cute_draw.cpp index 545bfb43..2c607bf5 100644 --- a/src/cute_draw.cpp +++ b/src/cute_draw.cpp @@ -1131,6 +1131,7 @@ void cf_destroy_draw() cf_destroy_material(s_draw->material); s_draw->~CF_Draw(); CF_FREE(s_draw); + s_draw = NULL; } //-------------------------------------------------------------------------------------------------- @@ -5312,20 +5313,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 321959f2..dc7d671a 100644 --- a/src/cute_input.cpp +++ b/src/cute_input.cpp @@ -499,23 +499,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 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_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(); +// 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; } } @@ -534,12 +533,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_get_content_scale(); app->window_state.resized = true; - app->w = event.window.data1; - app->h = event.window.data2; - cf_app_recreate_default_canvas_if_needed(); - 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; @@ -576,13 +579,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_pixel_scale(); - break; - case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: - s_refresh_pixel_scale(); + s_refresh_display_scale(); break; case SDL_EVENT_KEY_DOWN: @@ -625,14 +623,17 @@ 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_get_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: + case SDL_EVENT_MOUSE_BUTTON_DOWN: { switch (event.button.button) { case SDL_BUTTON_LEFT: app->mouse.left_button = 1; break; @@ -640,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; - app->mouse.y = event.button.y; + }; + 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; @@ -659,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; - app->mouse.y = event.button.y; + 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; diff --git a/src/internal/cute_app_internal.h b/src/internal/cute_app_internal.h index d5f674ba..2f1334ae 100644 --- a/src/internal/cute_app_internal.h +++ b/src/internal/cute_app_internal.h @@ -31,10 +31,12 @@ 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(); +// 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_get_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 @@ -102,9 +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 (from SDL_GetWindowPixelDensity). Drives default-canvas sizing, AA, and glyph rasterization. + 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). 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 01a5e7cd..ecfd54a6 100644 --- a/src/internal/cute_draw_internal.h +++ b/src/internal/cute_draw_internal.h @@ -469,9 +469,10 @@ 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 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 // 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 bca05869..a121cbd0 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 f4fe5ceb..b65a7af2 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 63738478..0fe1ae6a 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 ac527db5..ee7c9198 100644 --- a/test/test_app_shared.cpp +++ b/test/test_app_shared.cpp @@ -34,7 +34,12 @@ 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 call an app with a resizable window makes -- see the + // hidpi sample.) + 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 new file mode 100644 index 00000000..dacf1fe4 --- /dev/null +++ b/test/test_hidpi.cpp @@ -0,0 +1,392 @@ +/* + 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 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 +// 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 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_update_display(2.0f); +} + +// 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 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); + 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; +} + +// 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) +{ + 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_display_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); + + // The packaged helper skips its canvas/projection half without gfx. + 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 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 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_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_update_display_helper) +{ + if (!test_make_app(LOGICAL_W, LOGICAL_H)) return true; // Headless CI: no display/GPU. + HidpiGuard guard; + + 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); + 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_update_display(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. +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; + + // 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); + + // 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 = (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()); + // 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; +} + +// 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_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_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); + 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); +}