diff --git a/Examples/.gitignore b/Examples/.gitignore index e965047a..eb16b152 100644 --- a/Examples/.gitignore +++ b/Examples/.gitignore @@ -1 +1,2 @@ Hello +HUD diff --git a/Examples/HUD.c b/Examples/HUD.c new file mode 100644 index 00000000..23363486 --- /dev/null +++ b/Examples/HUD.c @@ -0,0 +1,436 @@ +/* + * ObjectivelyMVC: Object oriented MVC framework for SDL3 and C. + * Copyright (C) 2014 Jay Dolan + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any damages + * arising from the use of this software. + * + * Permission is granted to anyone to use this software for any purpose, + * including commercial applications, and to alter it and redistribute it + * freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you must not + * claim that you wrote the original software. If you use this software + * in a product, an acknowledgment in the product documentation would be + * appreciated but is not required. + * + * 2. Altered source versions must be plainly marked as such, and must not be + * misrepresented as being the original software. + * + * 3. This notice may not be removed or altered from any source distribution. + */ + +/** + * @file + * @brief A representative game HUD, used as a performance benchmark. + * @details Renders health, armor and ammo counters, a crosshair, a countdown + * timer, a chat log and a toggling scoreboard, each updating on an interval. + * The frame passes (style, layout, draw, endFrame) are timed individually and + * a summary is printed once per second. + * + * Environment: + * - `MVC_HUD_FRAMES=N` exits successfully after N frames (for benchmarking). + * - `MVC_HUD_HIDDEN=1` creates the window hidden (best effort headless). + * - `MVC_HUD_SCALE=N` multiplies the scoreboard row count (default 1), + * scaling the View tree to gauge how frame cost grows with UI complexity. + */ + +#define SDL_MAIN_USE_CALLBACKS + +#include + +#include +#include + +#include +#include + +#define HUD_WINDOW_W 1024 +#define HUD_WINDOW_H 720 + +/** + * @brief Accumulated timing for one frame pass. + */ +typedef struct { + double sum; + double max; +} PassStats; + +/** + * @brief SDL application state passed via pointer to callbacks. + */ +typedef struct { + + SDL_Window *window; + RenderDevice *renderDevice; + Framebuffer *framebuffer; + WindowController *windowController; + + /** + * @brief HUD widgets, borrowed references into the View hierarchy. + */ + Label *health, *armor, *ammo, *timer; + StackView *chat; + Panel *scoreboard; + + /** + * @brief Next update deadline per widget, in SDL ticks. + */ + Uint64 healthDue, armorDue, ammoDue, timerDue, chatDue, scoreboardDue; + + /** + * @brief Frame counters and per-pass timing since the last report. + */ + Uint64 frames, maxFrames, reportDue, reportFrames; + PassStats acquire, style, layout, draw, endFrame, submit; + size_t draws, vertices; +} AppState; + +static AppState application; + +#pragma mark - HUD construction + +/** + * @brief Creates a Label with the given text, aligned within its superview. + */ +static Label *label(View *superview, const char *text, ViewAlignment alignment) { + + Label *label = $(alloc(Label), initWithText, text, NULL); + label->view.alignment = alignment; + + $(superview, addSubview, (View *) label); + release(label); + + return label; +} + +/** + * @brief Creates a StackView aligned within its superview. + */ +static StackView *stackView(View *superview, ViewAlignment alignment) { + + StackView *stack = $(alloc(StackView), initWithFrame, NULL); + stack->view.alignment = alignment; + stack->view.autoresizingMask = ViewAutoresizingContain; + stack->spacing = 4; + + $(superview, addSubview, (View *) stack); + release(stack); + + return stack; +} + +/** + * @brief Builds the HUD View hierarchy on the given root View. + */ +static void buildHUD(AppState *app, View *root) { + + StackView *status = stackView(root, ViewAlignmentBottomLeft); + app->health = label((View *) status, "Health 100", ViewAlignmentNone); + app->armor = label((View *) status, "Armor 100", ViewAlignmentNone); + + app->ammo = label(root, "Ammo 50", ViewAlignmentBottomRight); + app->timer = label(root, "10:00", ViewAlignmentTopCenter); + + label(root, "+", ViewAlignmentMiddleCenter); + + app->chat = stackView(root, ViewAlignmentTopLeft); + + Panel *scoreboard = $(alloc(Panel), initWithFrame, NULL); + scoreboard->control.view.alignment = ViewAlignmentMiddleCenter; + + int rows = 8; + + const char *scale = SDL_getenv("MVC_HUD_SCALE"); + if (scale) { + rows *= SDL_max(1, SDL_atoi(scale)); + } + + for (int i = 0; i < rows; i++) { + StackView *row = $(alloc(StackView), initWithFrame, NULL); + row->axis = StackViewAxisHorizontal; + row->spacing = 32; + row->view.autoresizingMask = ViewAutoresizingContain; + + Label *name = $(alloc(Label), initWithText, "Player", NULL); + $(name->text, setTextWithFormat, "Player %d", i + 1); + $((View *) row, addSubview, (View *) name); + release(name); + + Label *score = $(alloc(Label), initWithText, "0", NULL); + $(score->text, setTextWithFormat, "%d", (8 - i) * 5); + $((View *) row, addSubview, (View *) score); + release(score); + + $((View *) scoreboard->contentView, addSubview, (View *) row); + release(row); + } + + $((View *) scoreboard, setHidden, true); + $(root, addSubview, (View *) scoreboard); + release(scoreboard); + + app->scoreboard = scoreboard; +} + +#pragma mark - HUD updates + +/** + * @brief Applies interval-based updates to the HUD widgets. + * @details Deliberately NOT per-frame: most frames are idle, which is what a + * real HUD looks like, and what exposes both the idle cost and the cost of a + * single widget update. + */ +static void updateHUD(AppState *app, Uint64 ticks) { + + if (ticks >= app->healthDue) { + app->healthDue = ticks + 2000; + $(app->health->text, setTextWithFormat, "Health %d", (int) (25 + ticks / 100 % 75)); + } + + if (ticks >= app->armorDue) { + app->armorDue = ticks + 3000; + $(app->armor->text, setTextWithFormat, "Armor %d", (int) (ticks / 200 % 100)); + } + + if (ticks >= app->ammoDue) { + app->ammoDue = ticks + 700; + $(app->ammo->text, setTextWithFormat, "Ammo %d", (int) (50 - ticks / 700 % 50)); + } + + if (ticks >= app->timerDue) { + app->timerDue = ticks + 1000; + const int remaining = (int) (600 - ticks / 1000 % 600); + $(app->timer->text, setTextWithFormat, "%d:%02d", remaining / 60, remaining % 60); + } + + if (ticks >= app->chatDue) { + app->chatDue = ticks + 4000; + + Label *message = $(alloc(Label), initWithText, NULL, NULL); + $(message->text, setTextWithFormat, "Player %d: message at %d", (int) (ticks / 4000 % 8 + 1), (int) ticks); + $((View *) app->chat, addSubview, (View *) message); + release(message); + + const Array *messages = (Array *) app->chat->view.subviews; + if (messages->count > 5) { + View *first = $(messages, firstObject); + $((View *) app->chat, removeSubview, first); + } + } + + if (ticks >= app->scoreboardDue) { + app->scoreboardDue = ticks + 5000; + $((View *) app->scoreboard, setHidden, !app->scoreboard->control.view.hidden); + } +} + +#pragma mark - Timing + +/** + * @return Elapsed microseconds between the given performance counter values. + */ +static double microseconds(Uint64 start, Uint64 end) { + return (end - start) * 1e6 / (double) SDL_GetPerformanceFrequency(); +} + +/** + * @brief Accumulates one sample into the given PassStats. + */ +static void sample(PassStats *stats, Uint64 start, Uint64 end) { + + const double us = microseconds(start, end); + + stats->sum += us; + stats->max = SDL_max(stats->max, us); +} + +/** + * @brief Prints the per-pass summary and resets the accumulators. + */ +static void report(AppState *app) { + + const double n = (double) app->reportFrames; + + printf("HUD %llu frames | acquire avg %.1fus max %.1fus | style avg %.1fus max %.1fus | " + "layout avg %.1fus max %.1fus | draw avg %.1fus max %.1fus | endFrame avg %.1fus max %.1fus | " + "submit avg %.1fus max %.1fus | draws %zu verts %zu\n", + (unsigned long long) app->reportFrames, + app->acquire.sum / n, app->acquire.max, + app->style.sum / n, app->style.max, + app->layout.sum / n, app->layout.max, + app->draw.sum / n, app->draw.max, + app->endFrame.sum / n, app->endFrame.max, + app->submit.sum / n, app->submit.max, + app->draws, app->vertices); + + app->reportFrames = 0; + app->acquire = app->style = app->layout = app->draw = app->endFrame = app->submit = (PassStats) { 0 }; +} + +#pragma mark - SDL application callbacks + +/** + * @brief SDL3 application initialization callback. + */ +SDL_AppResult SDL_AppInit(void **appState, int argc, char *argv[]) { + + AppState *app = *appState = &application; + + MVC_LogSetPriority(SDL_LOG_PRIORITY_WARN); + + MVC_Assert(SDL_Init(SDL_INIT_VIDEO), "SDL_Init"); + + SDL_WindowFlags flags = SDL_WINDOW_HIGH_PIXEL_DENSITY; + + const char *hidden = SDL_getenv("MVC_HUD_HIDDEN"); + if (hidden && *hidden == '1') { + flags |= SDL_WINDOW_HIDDEN; + } + + const char *frames = SDL_getenv("MVC_HUD_FRAMES"); + if (frames) { + app->maxFrames = SDL_strtoull(frames, NULL, 10); + } + + app->window = SDL_CreateWindow("ObjectivelyMVC HUD", HUD_WINDOW_W, HUD_WINDOW_H, flags); + MVC_Assert(app->window, "SDL_CreateWindow"); + + app->renderDevice = $(alloc(RenderDevice), initWithWindow, app->window, NULL); + + int w = 0, h = 0; + SDL_GetWindowSizeInPixels(app->window, &w, &h); + + const SDL_GPUTextureFormat format = $(app->renderDevice, getSwapchainTextureFormat); + + app->framebuffer = $(app->renderDevice, createFramebuffer, &(GPU_FramebufferCreateInfo) { + .size = MakeSize(w, h), + .colorAttachments = { { .format = format, .clearColor = { 0.05f, 0.05f, 0.1f, 1.f } } }, + .numColorTargets = 1, + .sampleCount = SDL_GPU_SAMPLECOUNT_1, + }); + + $(app->renderDevice, setFramebuffer, app->framebuffer); + + app->windowController = $(alloc(WindowController), initWithDevice, app->renderDevice); + + ViewController *viewController = $(alloc(ViewController), init); + $(app->windowController, setViewController, viewController); + release(viewController); + + buildHUD(app, viewController->view); + + app->scoreboardDue = SDL_GetTicks() + 5000; + app->reportDue = SDL_GetTicks() + 1000; + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 frame iteration callback. + * @details Hand-rolls WindowController::renderTo in order to time each pass + * (style, layout, draw, endFrame) individually. + */ +SDL_AppResult SDL_AppIterate(void *appState) { + + AppState *app = appState; + + const Uint64 ticks = SDL_GetTicks(); + + updateHUD(app, ticks); + + const Uint64 tAcquire = SDL_GetPerformanceCounter(); + CommandBuffer *commands = $(app->renderDevice, beginFrame); + if (commands) { + + const SDL_GPUColorTargetInfo color = $(app->framebuffer, colorTargetInfo, 0, SDL_GPU_LOADOP_CLEAR, SDL_GPU_STOREOP_STORE); + RenderPass *clear = $(commands, beginRenderPass, &color, 1, NULL); + release(clear); + + WindowController *windowController = app->windowController; + View *view = windowController->viewController->view; + Renderer *renderer = windowController->renderer; + + $(renderer, beginFrameWith, commands, app->framebuffer); + + const Uint64 t0 = SDL_GetPerformanceCounter(); + $(view, applyThemeIfNeeded, windowController->theme); + + const Uint64 t1 = SDL_GetPerformanceCounter(); + $(view, layoutIfNeeded); + + MVC_InvalidateRenderFrames(); + + const Uint64 t2 = SDL_GetPerformanceCounter(); + $(view, draw, renderer); + + const Uint64 t3 = SDL_GetPerformanceCounter(); + app->draws = renderer->drawArrays->count; + app->vertices = renderer->vertices->count; + $(renderer, endFrame); + + const Uint64 t4 = SDL_GetPerformanceCounter(); + $(app->renderDevice, endFrame); + + const Uint64 t5 = SDL_GetPerformanceCounter(); + + sample(&app->acquire, tAcquire, t0); + sample(&app->style, t0, t1); + sample(&app->layout, t1, t2); + sample(&app->draw, t2, t3); + sample(&app->endFrame, t3, t4); + sample(&app->submit, t4, t5); + app->reportFrames++; + } + + app->frames++; + + if (ticks >= app->reportDue && app->reportFrames) { + app->reportDue = ticks + 1000; + report(app); + } + + if (app->maxFrames && app->frames >= app->maxFrames) { + if (app->reportFrames) { + report(app); + } + return SDL_APP_SUCCESS; + } + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 event callback. + */ +SDL_AppResult SDL_AppEvent(void *appState, SDL_Event *event) { + + AppState *app = appState; + + $(app->windowController, respondToEvent, event); + + if (event->type == SDL_EVENT_QUIT) { + return SDL_APP_SUCCESS; + } + + return SDL_APP_CONTINUE; +} + +/** + * @brief SDL3 quit callback. + */ +void SDL_AppQuit(void *appState, SDL_AppResult result) { + + AppState *app = appState; + + $(app->renderDevice, waitForIdle); + + release(app->windowController); + release(app->framebuffer); + release(app->renderDevice); + + SDL_DestroyWindow(app->window); + + SDL_Quit(); +} diff --git a/Examples/Hello.c b/Examples/Hello.c index eb15c0c8..ebd1b512 100644 --- a/Examples/Hello.c +++ b/Examples/Hello.c @@ -65,7 +65,14 @@ typedef struct { * @brief A decoded PCM sound effect, loaded from a WAV resource. */ typedef struct { + /** + * @brief The PCM buffer. + */ Uint8 *buffer; + + /** + * @brief The buffer length in bytes. + */ Uint32 length; } Sound; @@ -119,6 +126,9 @@ static AppState application; #pragma mark - Scene management +/** + * @brief The cube vertex type. + */ typedef struct { vec3 position; vec3 color; diff --git a/Examples/Makefile.am b/Examples/Makefile.am index 7b1dcc06..a1a1edf1 100644 --- a/Examples/Makefile.am +++ b/Examples/Makefile.am @@ -2,7 +2,8 @@ noinst_HEADERS = \ HelloViewController.h noinst_PROGRAMS = \ - Hello + Hello \ + HUD CFLAGS += \ -I$(top_srcdir)/Assets \ @@ -25,6 +26,9 @@ Hello_SOURCES = \ HelloViewController.c \ Hello.c +HUD_SOURCES = \ + HUD.c + # Runtime assets loaded via the Resource API (from the EXAMPLES path), plus the # versioned shader sources/blobs. Normal builds never need glslc/shadercross; # run 'make shaders' after editing a .glsl. diff --git a/ObjectivelyMVC.xcodeproj/project.pbxproj b/ObjectivelyMVC.xcodeproj/project.pbxproj index 580860c4..39ce9a0a 100644 --- a/ObjectivelyMVC.xcodeproj/project.pbxproj +++ b/ObjectivelyMVC.xcodeproj/project.pbxproj @@ -87,6 +87,11 @@ CE12D4471C4C38C700CD0B13 /* ViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4261C4C367100CD0B13 /* ViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D4481C4C38C700CD0B13 /* ObjectivelyMVC.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4271C4C367100CD0B13 /* ObjectivelyMVC.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D4611C4C8F2A00CD0B13 /* ObjectivelyMVC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */; }; + HUDA00000000000000000003 /* HUD.c in Sources */ = {isa = PBXBuildFile; fileRef = HUDA00000000000000000001 /* HUD.c */; }; + HUDA00000000000000000004 /* SDL3.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = B1AA0001000000000000000A /* SDL3.xcframework */; }; + HUDA00000000000000000005 /* Objectively.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE73E9FA2FEEA5F60048BC24 /* Objectively.framework */; }; + HUDA00000000000000000006 /* ObjectivelyGPU.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CEEAAFF52FED7E6000FFEBE6 /* ObjectivelyGPU.framework */; }; + HUDA00000000000000000007 /* ObjectivelyMVC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */; }; CE12D4621C4C8F4800CD0B13 /* Hello.c in Sources */ = {isa = PBXBuildFile; fileRef = CE12D42B1C4C37E500CD0B13 /* Hello.c */; }; CE12D4661C4D587E00CD0B13 /* Log.h in Headers */ = {isa = PBXBuildFile; fileRef = CE12D4651C4D587E00CD0B13 /* Log.h */; settings = {ATTRIBUTES = (Public, ); }; }; CE12D46B1C4D810F00CD0B13 /* Button.c in Sources */ = {isa = PBXBuildFile; fileRef = CE12D4691C4D810F00CD0B13 /* Button.c */; }; @@ -225,6 +230,13 @@ remoteGlobalIDString = CED157CC1C4BF3AC00FBA2DE; remoteInfo = "ObjectivelyMVC-Xcode"; }; + HUDA00000000000000000010 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = CED157931C4BEA8900FBA2DE /* Project object */; + proxyType = 1; + remoteGlobalIDString = CED157CC1C4BF3AC00FBA2DE; + remoteInfo = "ObjectivelyMVC-Xcode"; + }; CE34C8971FB29F700025F231 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CED157931C4BEA8900FBA2DE /* Project object */; @@ -402,8 +414,10 @@ CE12D4261C4C367100CD0B13 /* ViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; CE12D4271C4C367100CD0B13 /* ObjectivelyMVC.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ObjectivelyMVC.h; sourceTree = ""; }; CE12D42B1C4C37E500CD0B13 /* Hello.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = Hello.c; sourceTree = ""; }; + HUDA00000000000000000001 /* HUD.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = HUD.c; sourceTree = ""; }; CE12D42D1C4C383D00CD0B13 /* Makefile.am */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Makefile.am; sourceTree = ""; }; CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "ObjectivelyMVC-Hello"; sourceTree = BUILT_PRODUCTS_DIR; }; + HUDA00000000000000000002 /* ObjectivelyMVC-HUD */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "ObjectivelyMVC-HUD"; sourceTree = BUILT_PRODUCTS_DIR; }; CE12D4651C4D587E00CD0B13 /* Log.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Log.h; sourceTree = ""; }; CE12D4691C4D810F00CD0B13 /* Button.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = Button.c; sourceTree = ""; }; CE12D46A1C4D810F00CD0B13 /* Button.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = Button.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; @@ -601,6 +615,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + HUDA00000000000000000009 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + HUDA00000000000000000004 /* SDL3.xcframework in Frameworks */, + HUDA00000000000000000005 /* Objectively.framework in Frameworks */, + HUDA00000000000000000006 /* ObjectivelyGPU.framework in Frameworks */, + HUDA00000000000000000007 /* ObjectivelyMVC.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CE34C89A1FB29F700025F231 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -790,6 +815,7 @@ isa = PBXGroup; children = ( CE12D42B1C4C37E500CD0B13 /* Hello.c */, + HUDA00000000000000000001 /* HUD.c */, HS00000000000000000000A3 /* Hello.frag.glsl */, HS00000000000000000000A4 /* Hello.frag.spv */, HS00000000000000000000A5 /* Hello.frag.metal */, @@ -919,6 +945,7 @@ children = ( CED157CD1C4BF3AC00FBA2DE /* ObjectivelyMVC.framework */, CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */, + HUDA00000000000000000002 /* ObjectivelyMVC-HUD */, CE8819971F91B0BE000D5AB7 /* ObjectivelyMVC-Style */, CE55C1C91F944C6D00D5A326 /* ObjectivelyMVC-Selector */, CE34C8A21FB29F700025F231 /* ObjectivelyMVC-Stylesheet */, @@ -1052,6 +1079,23 @@ productReference = CE12D4551C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */; productType = "com.apple.product-type.tool"; }; + HUDA00000000000000000015 /* ObjectivelyMVC-HUD */ = { + isa = PBXNativeTarget; + buildConfigurationList = HUDA00000000000000000014 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-HUD" */; + buildPhases = ( + HUDA00000000000000000008 /* Sources */, + HUDA00000000000000000009 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + HUDA00000000000000000011 /* PBXTargetDependency */, + ); + name = "ObjectivelyMVC-HUD"; + productName = "ObjectivelyMVC-HUD"; + productReference = HUDA00000000000000000002 /* ObjectivelyMVC-HUD */; + productType = "com.apple.product-type.tool"; + }; CE34C8951FB29F700025F231 /* ObjectivelyMVC-Stylesheet */ = { isa = PBXNativeTarget; buildConfigurationList = CE34C89F1FB29F700025F231 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-Stylesheet" */; @@ -1172,6 +1216,7 @@ 8A563153D68C9DE444FBF31C /* ObjectivelyMVC-View */, CE423A451F534657002767E7 /* ObjectivelyMVC-Tests */, CE12D4541C4C8E8E00CD0B13 /* ObjectivelyMVC-Hello */, + HUDA00000000000000000015 /* ObjectivelyMVC-HUD */, B1DD00010000000000000001 /* ObjectivelyMVC-Hello-iOS */, CE3CB2991F40F9A100FAA016 /* ObjectivelyMVC-Examples */, ); @@ -1339,6 +1384,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + HUDA00000000000000000008 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + HUDA00000000000000000003 /* HUD.c in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CE34C8981FB29F700025F231 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1436,6 +1489,11 @@ target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; targetProxy = CE12D4631C4C8F5400CD0B13 /* PBXContainerItemProxy */; }; + HUDA00000000000000000011 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; + targetProxy = HUDA00000000000000000010 /* PBXContainerItemProxy */; + }; CE34C8961FB29F700025F231 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = CED157CC1C4BF3AC00FBA2DE /* ObjectivelyMVC */; @@ -1629,6 +1687,40 @@ }; name = Release; }; + HUDA00000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + HUDA00000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + FRAMEWORK_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + LD_RUNPATH_SEARCH_PATHS = ( + "$(SRCROOT)/Frameworks", + "$(BUILT_PRODUCTS_DIR)", + "$(inherited)", + ); + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; CE34C8A01FB29F700025F231 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1880,6 +1972,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + HUDA00000000000000000014 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-HUD" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + HUDA00000000000000000012 /* Debug */, + HUDA00000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; CE34C89F1FB29F700025F231 /* Build configuration list for PBXNativeTarget "ObjectivelyMVC-Stylesheet" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme new file mode 100644 index 00000000..0eb2c0c7 --- /dev/null +++ b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-HUD.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-Hello.xcscheme b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-Hello.xcscheme index 9c0520a0..1b772831 100644 --- a/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-Hello.xcscheme +++ b/ObjectivelyMVC.xcodeproj/xcshareddata/xcschemes/ObjectivelyMVC-Hello.xcscheme @@ -49,6 +49,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES" consoleMode = "0" structuredConsoleMode = "2"> diff --git a/Sources/ObjectivelyMVC/CollectionView.c b/Sources/ObjectivelyMVC/CollectionView.c index cc678aed..e8965d4c 100644 --- a/Sources/ObjectivelyMVC/CollectionView.c +++ b/Sources/ObjectivelyMVC/CollectionView.c @@ -429,7 +429,7 @@ static void reloadData(CollectionView *self) { release(indexPath); } - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Control.c b/Sources/ObjectivelyMVC/Control.c index 866cb7b3..f78edb91 100644 --- a/Sources/ObjectivelyMVC/Control.c +++ b/Sources/ObjectivelyMVC/Control.c @@ -352,7 +352,7 @@ static void stateDidChange(Control *self) { $(this, invalidateStyle); - this->needsLayout = true; + $(this, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/Font.c b/Sources/ObjectivelyMVC/Font.c index 8b5f8051..6fe90833 100644 --- a/Sources/ObjectivelyMVC/Font.c +++ b/Sources/ObjectivelyMVC/Font.c @@ -57,11 +57,11 @@ static void dealloc(Object *self) { Font *this = (Font *) self; + TTF_CloseFont(this->font); + free(this->family); - release(this->data); - - TTF_CloseFont(this->font); + release(this->data); super(Object, self, dealloc); } @@ -110,7 +110,6 @@ static bool isEqual(const Object *self, const Object *other) { #pragma mark - Font static Dictionary *_cache; -static Array *_fonts; /** * @fn void Font::cacheFont(Data *data, const char *family) @@ -121,46 +120,46 @@ static void cacheFont(Data *data, const char *family) { } /** - * @fn Font *Font::cachedFont(const char *family, int size, int style) - * @memberof Font + * @brief Normalizes Font attributes to their defaults, in one place, so that + * Font::nameWithAttributes and Font::fontWithAttributes always agree. */ -static Font *cachedFont(const char *family, int size, int style) { +static void normalizeAttributes(const char **family, int *size, int *style) { - if (family == NULL) { - family = DEFAULT_FONT_FAMILY; + if (*family == NULL) { + *family = DEFAULT_FONT_FAMILY; } - if (size < 1) { - size = DEFAULT_FONT_SIZE; + + if (*size < 1) { + *size = DEFAULT_FONT_SIZE; } - if (style < FontStyleRegular || style > FontStyleStrikeThrough) { - style = DEFAULT_FONT_STYLE; + + if (*style < FontStyleRegular || *style > FontStyleStrikeThrough) { + *style = DEFAULT_FONT_STYLE; } +} - const Array *fonts = (Array *) _fonts; - for (size_t i = 0; i < fonts->count; i++) { +/** + * @fn Font *Font::fontWithAttributes(const char *family, int size, int style, float pixelDensity) + * @memberof Font + */ +static Font *fontWithAttributes(const char *family, int size, int style, float pixelDensity) { - Font *font = $(fonts, objectAtIndex, i); + normalizeAttributes(&family, &size, &style); - if (!strcmp(font->family, family) && - font->size == size && - font->style == style) { - return font; - } + if (pixelDensity < 1.f) { + pixelDensity = 1.f; } Data *data = $((Dictionary *) _cache, objectForKeyPath, family); if (data) { - Font *font = $(alloc(Font), initWithData, data, family, size, style); + Font *font = $(alloc(Font), initWithData, data, family, size, style, pixelDensity); assert(font); - $(_fonts, addObject, font); - release(font); - return font; } MVC_LogWarn("%s-%d-%d not found\n", family, size, style); - return $$(Font, defaultFont); + return retain($$(Font, defaultFont)); } /** @@ -171,6 +170,8 @@ static void clearCache(void) { $(_cache, removeAllObjects); } +static Font *_defaultFont; + /** * @fn Font *Font::defaultFont(void) * @memberof Font @@ -185,16 +186,57 @@ static Font *defaultFont(void) { $$(Font, cacheFont, data, DEFAULT_FONT_FAMILY); release(data); + + _defaultFont = $$(Font, fontWithAttributes, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_FONT_STYLE, 1.f); + assert(_defaultFont); }); - return $$(Font, cachedFont, DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, DEFAULT_FONT_STYLE); + return _defaultFont; +} + +/** + * @fn Font *Font::fontWithName(const char *name, float pixelDensity) + * @memberof Font + */ +static Font *fontWithName(const char *name, float pixelDensity) { + + assert(name); + + char *chars = strdup(name); + assert(chars); + + Font *font = NULL; + + char *styleToken = strrchr(chars, '-'); + if (styleToken) { + *styleToken++ = '\0'; + + char *sizeToken = strrchr(chars, '-'); + if (sizeToken) { + *sizeToken++ = '\0'; + + const int style = valueof(FontStyleNames, styleToken); + const int size = (int) strtol(sizeToken, NULL, 10); + + font = $$(Font, fontWithAttributes, chars, size, style, pixelDensity); + } + } + + free(chars); + + if (font == NULL) { + MVC_LogWarn("%s is not a valid Font name\n", name); + font = retain($$(Font, defaultFont)); + } + + return font; } /** * @fn Font *Font::initWithData(Font *self, Data *data, int size, int index) * @memberof Font */ -static Font *initWithData(Font *self, Data *data, const char *family, int size, int style) { +static Font *initWithData(Font *self, Data *data, const char *family, int size, int style, float pixelDensity) { self = (Font *) super(Object, self, init); if (self) { @@ -209,14 +251,48 @@ static Font *initWithData(Font *self, Data *data, const char *family, int size, assert(self->size); self->style = style; - self->scale = 1.0f; - $(self, renderDeviceDidReset); + self->pixelDensity = pixelDensity; + self->renderSize = self->size * self->pixelDensity; + + SDL_IOStream *buffer = SDL_IOFromConstMem(self->data->bytes, (int) self->data->length); + assert(buffer); + + self->font = TTF_OpenFontIO(buffer, 1, self->renderSize); + assert(self->font); + + TTF_SetFontStyle(self->font, self->style); + TTF_SetFontHinting(self->font, TTF_HINTING_LIGHT_SUBPIXEL); } return self; } +/** + * @fn String *Font::name(const Font *self) + * @memberof Font + */ +static String *name(const Font *self) { + return $$(Font, nameWithAttributes, self->family, self->size, self->style); +} + +/** + * @fn String *Font::nameWithAttributes(const char *family, int size, int style) + * @memberof Font + */ +static String *nameWithAttributes(const char *family, int size, int style) { + + normalizeAttributes(&family, &size, &style); + + for (const EnumName *en = FontStyleNames; en->name; en++) { + if (en->value == style) { + return str("%s-%d-%s", family, size, en->alias ?: en->name); + } + } + + return str("%s-%d-%d", family, size, style); +} + /** * @fn void Font::renderCharacters(const Font *self, const char *chars, SDL_Color color, int wrapWidth) * @memberof Font @@ -225,7 +301,7 @@ static SDL_Surface *renderCharacters(const Font *self, const char *chars, SDL_Co SDL_Surface *surface; if (wrapWidth) { - surface = TTF_RenderText_Blended_Wrapped(self->font, chars, 0, color, wrapWidth * self->scale); + surface = TTF_RenderText_Blended_Wrapped(self->font, chars, 0, color, wrapWidth * self->pixelDensity); } else { surface = TTF_RenderText_Blended(self->font, chars, 0, color); } @@ -241,32 +317,6 @@ static SDL_Surface *renderCharacters(const Font *self, const char *chars, SDL_Co return converted; } -/** - * @fn void Font::renderDeviceDidReset(Font *self) - * @memberof Font - */ -static void renderDeviceDidReset(Font *self) { - - const int renderSize = self->size * self->scale; - if (renderSize != self->renderSize) { - - self->renderSize = renderSize; - - if (self->font) { - TTF_CloseFont(self->font); - } - - SDL_IOStream *buffer = SDL_IOFromConstMem(self->data->bytes, (int) self->data->length); - assert(buffer); - - self->font = TTF_OpenFontIO(buffer, 1, self->renderSize); - assert(self->font); - - TTF_SetFontStyle(self->font, self->style); - TTF_SetFontHinting(self->font, TTF_HINTING_LIGHT_SUBPIXEL); - } -} - /** * @fn void Font::sizeCharacters(const Font *self, const char *chars, int *w, int *h) * @memberof Font @@ -315,10 +365,10 @@ static void sizeCharacters(const Font *self, const char *chars, int *w, int *h) free(lines); if (w) { - *w = ceilf(*w / self->scale); + *w = ceilf(*w / self->pixelDensity); } if (h) { - *h = ceilf(*h / self->scale); + *h = ceilf(*h / self->pixelDensity); } } } @@ -334,13 +384,15 @@ static void initialize(Class *clazz) { ((ObjectInterface *) clazz->interface)->hash = hash; ((ObjectInterface *) clazz->interface)->isEqual = isEqual; - ((FontInterface *) clazz->interface)->cachedFont = cachedFont; + ((FontInterface *) clazz->interface)->fontWithAttributes = fontWithAttributes; ((FontInterface *) clazz->interface)->cacheFont = cacheFont; ((FontInterface *) clazz->interface)->clearCache = clearCache; ((FontInterface *) clazz->interface)->defaultFont = defaultFont; + ((FontInterface *) clazz->interface)->fontWithName = fontWithName; ((FontInterface *) clazz->interface)->initWithData = initWithData; + ((FontInterface *) clazz->interface)->name = name; + ((FontInterface *) clazz->interface)->nameWithAttributes = nameWithAttributes; ((FontInterface *) clazz->interface)->renderCharacters = renderCharacters; - ((FontInterface *) clazz->interface)->renderDeviceDidReset = renderDeviceDidReset; ((FontInterface *) clazz->interface)->sizeCharacters = sizeCharacters; const bool init = TTF_Init(); @@ -349,9 +401,6 @@ static void initialize(Class *clazz) { _cache = $$(Dictionary, dictionary); assert(_cache); - - _fonts = $$(Array, array); - assert(_fonts); } /** @@ -360,7 +409,7 @@ static void initialize(Class *clazz) { static void destroy(Class *clazz) { release(_cache); - release(_fonts); + release(_defaultFont); TTF_Quit(); } diff --git a/Sources/ObjectivelyMVC/Font.h b/Sources/ObjectivelyMVC/Font.h index 8a2dc3e5..e1ac1959 100644 --- a/Sources/ObjectivelyMVC/Font.h +++ b/Sources/ObjectivelyMVC/Font.h @@ -28,6 +28,7 @@ #include #include #include +#include #include @@ -89,15 +90,14 @@ struct Font { TTF_Font *font; /** - * @brief The render size, adjusted for display density. + * @brief The display pixel density scale, greater than 1.0 on high-density displays. */ - int renderSize; + float pixelDensity; /** - * @brief The display pixel density scale (e.g. 2.0 on Retina). Updated by callers - * via the scale field before invoking renderDeviceDidReset. + * @brief The render size, adjusted for pixel density. */ - float scale; + int renderSize; /** * @brief The point size. @@ -120,18 +120,6 @@ struct FontInterface { */ ObjectInterface objectInterface; - /** - * @static - * @fn Font *Font::cachedFont(const char *family, int size, int style) - * @brief Resolves the cached Font with the given attributes. - * @param family The family. - * @param size The size. - * @param style The style. - * @return The cached Font, or the default Font if not found. - * @memberof Font - */ - Font *(*cachedFont)(const char *family, int size, int style); - /** * @static * @brief Caches the specified font Data. @@ -152,23 +140,80 @@ struct FontInterface { /** * @static * @fn Font *Font::defaultFont(void) - * @return The default Font. + * @return The default Font, at a pixel density of `1.0`. + * @remarks This is the fallback for Views that are not yet attached to a window; on + * attachment, style application re-resolves Fonts through the window's cache. * @memberof Font */ Font *(*defaultFont)(void); /** - * @fn Font *Font::initWithData(Font *self, Data *data, const char *family, int size, int style) + * @static + * @fn Font *Font::fontWithName(const char *name, float pixelDensity) + * @brief Resolves a Font from a name in the format produced by Font::name. + * @details The name is parsed from the right, so families MAY contain `-`: the final + * token is the style, the preceding token the point size, and the remainder the family. + * @param name The name, e.g. `"Coda-16-regular"`. + * @param pixelDensity The pixel density. + * @return The Font, or the default Font if `name` is not parseable or not registered. + * The caller owns the returned Font, and MUST release it. + * @memberof Font + */ + Font *(*fontWithName)(const char *name, float pixelDensity); + + /** + * @static + * @fn Font *Font::fontWithAttributes(const char *family, int size, int style, float pixelDensity) + * @brief Resolves a new Font with the given attributes from the cached font Data. + * @details "Cached" refers to the font Data registered via Font::cacheFont; the returned + * instance itself is newly created. Instance caching is provided per-window by + * WindowController::cachedFont, which callers with a window SHOULD prefer, since it + * supplies the window's pixel density implicitly. Attributes are normalized to their + * defaults exactly as Font::nameWithAttributes normalizes them. + * @param family The family, or `NULL` for the default. + * @param size The point size, or `0` for the default. + * @param style The FontStyle, or `-1` for the default. + * @param pixelDensity The pixel density. + * @return The Font, or the default Font if `family` is not registered. The caller owns + * the returned Font, and MUST release it. + * @memberof Font + */ + Font *(*fontWithAttributes)(const char *family, int size, int style, float pixelDensity); + + /** + * @fn Font *Font::initWithData(Font *self, Data *data, const char *family, int size, int style, float pixelDensity) * @brief Initializes this Font with the given TTF Data and attributes. + * @details Fonts are immutable: the backing TTF_Font is opened here at + * `size * pixelDensity` and never re-opened. A pixel density change MUST be handled by + * resolving a new instance (see WindowController::cachedFont). * @param self The Font. * @param data The Data. * @param family The family. - * @param size The size. + * @param size The point size. * @param style The style. + * @param pixelDensity The pixel density. * @return The initialized Font, or `NULL` on error. * @memberof Font */ - Font *(*initWithData)(Font *self, Data *data, const char *family, int size, int style); + Font *(*initWithData)(Font *self, Data *data, const char *family, int size, int style, float pixelDensity); + + /** + * @fn String *Font::name(const Font *self) + * @return This Font's name, in the format `family-size-style`, e.g. `"Coda-16-regular"`. + * @remarks The format is stable and MAY be parsed by Font::fontWithName; it is also the + * key under which WindowController caches Font instances. Pixel density is deliberately + * absent: it is implied by the window whose cache is consulted. + * @memberof Font + */ + String *(*name)(const Font *self); + + /** + * @static + * @fn String *Font::nameWithAttributes(const char *family, int size, int style) + * @return The Font name for the given attributes, in the format of Font::name. + * @memberof Font + */ + String *(*nameWithAttributes)(const char *family, int size, int style); /** * @fn SDL_Surface *Font::renderCharacters(const Font *self, const char *chars, SDL_Color color, int wrapWidth) @@ -182,15 +227,6 @@ struct FontInterface { */ SDL_Surface *(*renderCharacters)(const Font *self, const char *chars, SDL_Color color, int wrapWidth); - /** - * @fn void Font::renderDeviceDidReset(Font *self) - * @brief This method should be invoked when the render context is invalidated. - * Callers must update self->scale before invoking this method. - * @param self The Font. - * @memberof Font - */ - void (*renderDeviceDidReset)(Font *self); - /** * @fn void Font::sizeCharacters(const Font *self, const char *chars, int *w, int *h) * @brief Measures the given characters in this Font. diff --git a/Sources/ObjectivelyMVC/Label.c b/Sources/ObjectivelyMVC/Label.c index 22831809..0a28b37a 100644 --- a/Sources/ObjectivelyMVC/Label.c +++ b/Sources/ObjectivelyMVC/Label.c @@ -78,7 +78,7 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); - self->needsLayout = true; + $(self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Option.c b/Sources/ObjectivelyMVC/Option.c index b563ebcf..601b6e80 100644 --- a/Sources/ObjectivelyMVC/Option.c +++ b/Sources/ObjectivelyMVC/Option.c @@ -118,7 +118,7 @@ static void setSelected(Option *self, bool isSelected) { $((View *) self, invalidateStyle); - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/PageView.c b/Sources/ObjectivelyMVC/PageView.c index 5aa28aaa..93b07278 100644 --- a/Sources/ObjectivelyMVC/PageView.c +++ b/Sources/ObjectivelyMVC/PageView.c @@ -148,7 +148,7 @@ static void setCurrentPage(PageView *self, View *currentPage) { } } - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/Panel.c b/Sources/ObjectivelyMVC/Panel.c index 10c6be14..20b8cdbb 100644 --- a/Sources/ObjectivelyMVC/Panel.c +++ b/Sources/ObjectivelyMVC/Panel.c @@ -173,6 +173,10 @@ static bool captureEvent(Control *self, const SDL_Event *event) { self->view.frame.x += dx; self->view.frame.y += dy; + + // Direct frame mutation outside of layout: refresh the render frame caches so + // hit-testing for the remainder of this event batch sees the moved Panel. + MVC_InvalidateRenderFrames(); } return true; diff --git a/Sources/ObjectivelyMVC/ProgressBar.c b/Sources/ObjectivelyMVC/ProgressBar.c index db873b7b..63a40ce8 100644 --- a/Sources/ObjectivelyMVC/ProgressBar.c +++ b/Sources/ObjectivelyMVC/ProgressBar.c @@ -140,7 +140,12 @@ static ProgressBar *initWithFrame(ProgressBar *self, const SDL_Rect *frame) { * @memberof ProgressBar */ static double progress(const ProgressBar *self) { - return 100.0 * self->value / (self->max - self->min); + + if (self->max <= self->min) { + return 0.0; + } + + return 100.0 * (self->value - self->min) / (self->max - self->min); } /** @@ -170,10 +175,10 @@ static void setValue(ProgressBar *self, double value) { self->value = value; const SDL_Rect bounds = $((View *) self, bounds); - const double frac = self->value / (self->max - self->min); + const double frac = $(self, progress) / 100.0; self->foreground->view.frame.w = bounds.w * frac; - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); $(self, formatLabel); diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index 9cf32150..b538a5a9 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -143,35 +143,40 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun return; } - const size_t segCount = count - 1; - MVC_Vertex *verts = malloc(segCount * 6 * sizeof(MVC_Vertex)); - assert(verts); + const size_t segments = count - 1; - for (size_t i = 0; i < segCount; i++) { - const float ax = (float) points[i].x, ay = (float) points[i].y; - const float bx = (float) points[i+1].x, by = (float) points[i+1].y; + MVC_Vertex verts[16 * 6]; + const size_t batchSize = lengthof(verts) / 6; - const float dx = bx - ax, dy = by - ay; - const float len = sqrtf(dx * dx + dy * dy); + for (size_t s = 0; s < segments; ) { - float nx = 0.0f, ny = 0.0f; - if (len > 0.001f) { - nx = (-dy / len) * 0.5f; - ny = ( dx / len) * 0.5f; - } + const size_t batch = min(segments - s, batchSize); - MVC_Vertex *v = &verts[i * 6]; - v[0] = (MVC_Vertex) { { { ax - nx, ay - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[1] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[2] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[3] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[4] = (MVC_Vertex) { { { bx + nx, by + ny } }, { { 0.0f, 0.0f } }, { 0 } }; - v[5] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; - } + for (size_t i = 0; i < batch; i++, s++) { + + const float ax = (float) points[s].x, ay = (float) points[s].y; + const float bx = (float) points[s + 1].x, by = (float) points[s + 1].y; - $(self, pushDrawArrays, verts, segCount * 6, NULL, color); + const float dx = bx - ax, dy = by - ay; + const float len = sqrtf(dx * dx + dy * dy); - free(verts); + float nx = 0.0f, ny = 0.0f; + if (len > 0.001f) { + nx = (-dy / len) * 0.5f; + ny = ( dx / len) * 0.5f; + } + + MVC_Vertex *v = &verts[i * 6]; + v[0] = (MVC_Vertex) { { { ax - nx, ay - ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[1] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[2] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[3] = (MVC_Vertex) { { { ax + nx, ay + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[4] = (MVC_Vertex) { { { bx + nx, by + ny } }, { { 0.0f, 0.0f } }, { 0 } }; + v[5] = (MVC_Vertex) { { { bx - nx, by - ny } }, { { 0.0f, 0.0f } }, { 0 } }; + } + + $(self, pushDrawArrays, verts, batch * 6, NULL, color); + } } /** @@ -369,13 +374,32 @@ static void pushDrawArrays(const Renderer *self, const MVC_Vertex *verts, size_t .scissor = self->scissor, }; + Vector *vertices = self->vertices; + + if (vertices->count + count > vertices->capacity) { + $(vertices, resize, max(vertices->capacity * 2, vertices->count + count)); + } + + MVC_Vertex *out = VectorElement(vertices, MVC_Vertex, vertices->count); for (size_t i = 0; i < count; i++) { - MVC_Vertex v = verts[i]; - v.color = *color; - $(self->vertices, add, &v); + out[i] = verts[i]; + out[i].color = *color; } - $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + vertices->count += count; + + // If this drawArrays is contiguous with the last one, combine them into a single call + + if (self->drawArrays->count) { + MVC_DrawArrays *last = VectorElement(self->drawArrays, MVC_DrawArrays, self->drawArrays->count - 1); + if (last->texture == draw.texture && SDL_RectsEqual(&last->scissor, &draw.scissor)) { + last->vertexCount += draw.vertexCount; + } else { + $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + } + } else { + $(self->drawArrays, add, (MVC_DrawArrays *) &draw); + } } /** diff --git a/Sources/ObjectivelyMVC/ScrollBar.c b/Sources/ObjectivelyMVC/ScrollBar.c index 94692746..6617f5f3 100644 --- a/Sources/ObjectivelyMVC/ScrollBar.c +++ b/Sources/ObjectivelyMVC/ScrollBar.c @@ -58,7 +58,7 @@ static void didDragHandle(ScrollHandle *handle, float delta) { offset.y -= (int) (delta * ((float) scrollRange / travel)); $(self->scrollView, scrollToOffset, &offset); - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } } } @@ -146,7 +146,7 @@ static void respondToEvent(View *self, const SDL_Event *event) { } $(this->scrollView, scrollToOffset, &offset); - self->needsLayout = true; + $(self, setNeedsLayout); } return; @@ -198,7 +198,7 @@ static void setScrollView(ScrollBar *self, ScrollView *scrollView) { self->scrollView = scrollView; - ((View *) self)->needsLayout = true; + $((View *) self, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/ScrollBar.h b/Sources/ObjectivelyMVC/ScrollBar.h index 59854316..aac7da4d 100644 --- a/Sources/ObjectivelyMVC/ScrollBar.h +++ b/Sources/ObjectivelyMVC/ScrollBar.h @@ -52,7 +52,7 @@ typedef struct ScrollView ScrollView; * * The handle's size tracks the visible/content ratio and its position tracks * the ScrollView's contentOffset; dragging the handle scrolls the content. - * Anything that changes either just flags `needsLayout` -- ScrollBar's own + * Anything that changes either just calls View::setNeedsLayout -- ScrollBar's own * `layoutSubviews` is what actually repositions/resizes the handle. * * Styling is plain View attributes, set in the stylesheet like any other diff --git a/Sources/ObjectivelyMVC/ScrollView.c b/Sources/ObjectivelyMVC/ScrollView.c index edfc10e6..4d41d854 100644 --- a/Sources/ObjectivelyMVC/ScrollView.c +++ b/Sources/ObjectivelyMVC/ScrollView.c @@ -76,7 +76,7 @@ static void applyStyle(View *self, const Style *style) { $(self, bind, inlets, (Dictionary *) style->attributes); - self->needsLayout = true; + $(self, setNeedsLayout); } /** @@ -207,8 +207,8 @@ static void scrollToOffset(ScrollView *self, const SDL_Point *offset) { self->contentOffset.x = self->contentOffset.y = 0; } - self->control.view.needsLayout = true; - ((View *) self->scrollBar)->needsLayout = true; + $((View *) self, setNeedsLayout); + $((View *) self->scrollBar, setNeedsLayout); } /** @@ -244,7 +244,7 @@ static void setContentView(ScrollView *self, View *contentView) { static void setScrollBarVisibility(ScrollView *self, ScrollBarVisibility visibility) { self->scrollBarVisibility = visibility; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } #pragma mark - Class lifecycle diff --git a/Sources/ObjectivelyMVC/Select.c b/Sources/ObjectivelyMVC/Select.c index a65a5527..ec43a440 100644 --- a/Sources/ObjectivelyMVC/Select.c +++ b/Sources/ObjectivelyMVC/Select.c @@ -296,7 +296,7 @@ static void addOption(Select *self, const char *title, ident value) { release(option); - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** @@ -372,7 +372,7 @@ static void removeOption(Select *self, Option *option) { } } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } @@ -412,7 +412,7 @@ static void selectOption(Select *self, Option *option) { } } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Slider.c b/Sources/ObjectivelyMVC/Slider.c index e5b3f83c..fcf194f9 100644 --- a/Sources/ObjectivelyMVC/Slider.c +++ b/Sources/ObjectivelyMVC/Slider.c @@ -309,7 +309,7 @@ static void setValue(Slider *self, double value) { const double delta = fabs(self->value - value); if (delta > __DBL_EPSILON__) { self->value = value; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); $(self, formatLabel); } diff --git a/Sources/ObjectivelyMVC/TabView.c b/Sources/ObjectivelyMVC/TabView.c index c17f31ce..c30d74e6 100644 --- a/Sources/ObjectivelyMVC/TabView.c +++ b/Sources/ObjectivelyMVC/TabView.c @@ -250,7 +250,7 @@ static void selectTab(TabView *self, TabViewItem *tab) { } } - self->stackView.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/TableView.c b/Sources/ObjectivelyMVC/TableView.c index a7a997a9..25f7fe3a 100644 --- a/Sources/ObjectivelyMVC/TableView.c +++ b/Sources/ObjectivelyMVC/TableView.c @@ -111,7 +111,7 @@ static void layoutSubviews(View *self) { } scrollView->frame = frame; - scrollView->needsLayout = true; + $(scrollView, setNeedsLayout); $(scrollView, layoutIfNeeded); } @@ -438,7 +438,7 @@ static void reloadData(TableView *self) { $((View *) self->contentView, addSubview, (View *) row); } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } /** diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 59b80d2f..3ebac666 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -32,6 +32,7 @@ #include "Colors.h" #include "Text.h" +#include "WindowController.h" #define _Class _Text @@ -141,7 +142,7 @@ static CharInfo *buildCharInfo(const Font *font, const char *text, const size_t strippedLen = strlen(stripped); CharInfo *chars = malloc(sizeof(CharInfo) * strippedLen); - const int scaledWrapWidth = wrapWidth ? (int) (wrapWidth * font->scale) : 0; + const int scaledWrapWidth = wrapWidth ? (int) (wrapWidth * font->pixelDensity) : 0; int lineHeight; $(font, sizeCharacters, "A", NULL, &lineHeight); @@ -178,9 +179,9 @@ static CharInfo *buildCharInfo(const Font *font, const char *text, int charW; TTF_GetStringSize(font->font, stripped + charIdx, 1, &charW, NULL); - chars[charIdx].rect.x = (int) (lineX / font->scale); - chars[charIdx].rect.y = (int) (currH / font->scale) - lineHeight; - chars[charIdx].rect.w = (int) (charW / font->scale); + chars[charIdx].rect.x = (int) (lineX / font->pixelDensity); + chars[charIdx].rect.y = (int) (currH / font->pixelDensity) - lineHeight; + chars[charIdx].rect.w = (int) (charW / font->pixelDensity); chars[charIdx].rect.h = lineHeight; chars[charIdx].color = currentColor; @@ -211,7 +212,7 @@ static SDL_Surface *renderWithColorEscapes(const Text *self, int wrapWidth) { if (charInfo) { SDL_LockSurface(surface); for (int i = 0; i < charCount; i++) { - colorize(surface, &charInfo[i], self->font->scale); + colorize(surface, &charInfo[i], self->font->pixelDensity); } SDL_UnlockSurface(surface); free(charInfo); @@ -286,6 +287,7 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, colorInlets, style->attributes)) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); + this->naturalSizeCache.isValid = false; } char *fontFamily = NULL; @@ -299,10 +301,24 @@ static void applyStyle(View *self, const Style *style) { if ($(self, bind, fontInlets, style->attributes)) { - Font *font = $$(Font, cachedFont, fontFamily , fontSize, fontStyle); + // Attached Views resolve through the window's Font cache, which supplies the window's + // pixel density; unattached Views fall back to a density of 1.0, and re-resolve in + // didMoveToWindow on attachment. The cache owns its Fonts, so its reference is + // retained here to mirror the owned reference the fallback returns. + WindowController *windowController = self->window ? + $$(WindowController, windowController, self->window) : NULL; + + Font *font; + if (windowController) { + font = retain($(windowController, cachedFont, fontFamily, fontSize, fontStyle)); + } else { + font = $$(Font, fontWithAttributes, fontFamily, fontSize, fontStyle, 1.f); + } + assert(font); $(this, setFont, font); + release(font); if (fontFamily) { free(fontFamily); @@ -329,9 +345,32 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { $(self, bind, inlets, dictionary); + this->naturalSizeCache.isValid = false; + $(self, sizeToFit); } +/** + * @see View::didMoveToWindow(View *, SDL_Window *) + */ +static void didMoveToWindow(View *self, SDL_Window *window) { + + super(View, self, didMoveToWindow, window); + + // A Font resolved before attachment (e.g. the default Font) was opened at a pixel + // density of 1.0; re-resolve through the window's cache at the window's actual density. + if (window) { + Text *this = (Text *) self; + if (this->font) { + WindowController *windowController = $$(WindowController, windowController, window); + if (windowController) { + Font *font = $(windowController, cachedFont, this->font->family, this->font->size, this->font->style); + $(this, setFont, font); + } + } + } +} + /** * @see View::init(View *) */ @@ -350,13 +389,10 @@ static void render(View *self, Renderer *renderer) { assert(this->font); - const float scale = SDL_GetWindowPixelDensity(self->window); - - if (this->font->scale != scale) { - $(self, renderDeviceDidReset); - this->texture = release(this->texture); - this->textureSize = MakeSize(0, 0); - } + // The Font is opened at the window's pixel density: density changes arrive via the + // WindowController, which empties its font cache and resets the render device, so by + // draw time this Font is always current. + const float scale = this->font->pixelDensity; if (this->text) { @@ -382,44 +418,17 @@ static void render(View *self, Renderer *renderer) { this->textureSize.w = (int) textureWidth; this->textureSize.h = (int) textureHeight; - SDL_Surface *upload = surface; - SDL_Surface *converted = NULL; - - if (SDL_BYTESPERPIXEL(surface->format) == 1) { - converted = SDL_CreateSurface(surface->w, surface->h, SDL_PIXELFORMAT_RGBA32); - assert(converted); - const SDL_PixelFormatDetails *details = SDL_GetPixelFormatDetails(SDL_PIXELFORMAT_RGBA32); - assert(details); - const Uint8 *src = (const Uint8 *) surface->pixels; - Uint32 *dst = (Uint32 *) converted->pixels; - for (int y = 0; y < surface->h; y++) { - for (int x = 0; x < surface->w; x++) { - const Uint8 a = src[y * surface->pitch + x]; - dst[y * surface->w + x] = SDL_MapRGBA(details, NULL, 255, 255, 255, a); - } - } - upload = converted; - } else if (surface->format != SDL_PIXELFORMAT_RGBA32) { - converted = SDL_ConvertSurface(surface, SDL_PIXELFORMAT_RGBA32); - assert(converted); - upload = converted; - } - const SDL_GPUTextureCreateInfo texInfo = { .type = SDL_GPU_TEXTURETYPE_2D, .format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, .usage = SDL_GPU_TEXTUREUSAGE_SAMPLER, - .width = (Uint32) upload->w, - .height = (Uint32) upload->h, + .width = (Uint32) surface->w, + .height = (Uint32) surface->h, .layer_count_or_depth = 1, .num_levels = 1, }; - this->texture = $(renderer->device, createTexture, &texInfo, upload->pixels); - - if (converted) { - SDL_DestroySurface(converted); - } + this->texture = $(renderer->device, createTexture, &texInfo, surface->pixels); SDL_DestroySurface(surface); } @@ -432,10 +441,12 @@ static void render(View *self, Renderer *renderer) { // from the texture's actual resolution -- stretching it by that (sub-)pixel remainder. Since // the remainder depends on the string's own pixel width, this stretch changes with every // keystroke, visibly shifting every glyph in the string, not just the one that was typed. + const SDL_FRect draw_rect = { (float) frame.x, (float) frame.y, this->texture->size.w / scale, this->texture->size.h / scale }; + $(renderer, drawTexture, this->texture, &draw_rect, &Colors.White); } } @@ -447,10 +458,16 @@ static void renderDeviceDidReset(View *self) { Text *this = (Text *) self; - this->font->scale = SDL_GetWindowPixelDensity(self->window); + // Fonts are immutable, opened at their window's pixel density; re-resolve through the + // window's cache, which the WindowController empties before resetting the device. + if (self->window) { + WindowController *windowController = $$(WindowController, windowController, self->window); + if (windowController) { + Font *font = $(windowController, cachedFont, this->font->family, this->font->size, this->font->style); + $(this, setFont, font); + } + } - $(this->font, renderDeviceDidReset); - $(self, sizeToFit); super(View, self, renderDeviceDidReset); @@ -465,6 +482,7 @@ static void renderDeviceWillReset(View *self) { this->texture = release(this->texture); this->textureSize = MakeSize(0, 0); + this->naturalSizeCache.isValid = false; super(View, self, renderDeviceWillReset); } @@ -499,6 +517,11 @@ static Text *initWithText(Text *self, const char *text, Font *font) { */ static SDL_Size naturalSize(const Text *self) { + if (self->naturalSizeCache.isValid && self->font && self->font->pixelDensity == self->naturalSizeCache.pixelDensity && + self->colorEscapes == self->naturalSizeCache.colorEscapes) { + return self->naturalSizeCache.size; + } + SDL_Size size = MakeSize(0, 0); if (self->font) { @@ -509,6 +532,13 @@ static SDL_Size naturalSize(const Text *self) { } else { $(self->font, sizeCharacters, text, &size.w, &size.h); } + + Text *this = (Text *) self; + + this->naturalSizeCache.size = size; + this->naturalSizeCache.pixelDensity = self->font->pixelDensity; + this->naturalSizeCache.colorEscapes = self->colorEscapes; + this->naturalSizeCache.isValid = true; } return size; @@ -529,6 +559,7 @@ static void setFont(Text *self, Font *font) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); + self->naturalSizeCache.isValid = false; $((View *) self, sizeToFit); } @@ -552,6 +583,7 @@ static void setText(Text *self, const char *text) { self->texture = release(self->texture); self->textureSize = MakeSize(0, 0); + self->naturalSizeCache.isValid = false; $((View *) self, sizeToFit); } @@ -591,6 +623,7 @@ static void initialize(Class *clazz) { ((ViewInterface *) clazz->interface)->applyStyle = applyStyle; ((ViewInterface *) clazz->interface)->awakeWithDictionary = awakeWithDictionary; + ((ViewInterface *) clazz->interface)->didMoveToWindow = didMoveToWindow; ((ViewInterface *) clazz->interface)->init = init; ((ViewInterface *) clazz->interface)->render = render; ((ViewInterface *) clazz->interface)->renderDeviceDidReset = renderDeviceDidReset; diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 3e4c9e06..c9dd5a3e 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -105,6 +105,19 @@ struct Text { */ bool lineWrap; + /** + * @brief The cached Text::naturalSize, valid while `isValid` is set and `pixelDensity` + * and `colorEscapes` match the Font's pixel density and this Text's `colorEscapes` -- + * the latter because it is a public, setter-less field that changes the measurement path. + * @private + */ + struct { + SDL_Size size; + float pixelDensity; + bool colorEscapes; + bool isValid; + } naturalSizeCache; + /** * @brief The text. * @remarks Do not set this property directly. diff --git a/Sources/ObjectivelyMVC/TextView.c b/Sources/ObjectivelyMVC/TextView.c index 5967db70..81ea1ba3 100644 --- a/Sources/ObjectivelyMVC/TextView.c +++ b/Sources/ObjectivelyMVC/TextView.c @@ -308,7 +308,7 @@ static bool captureEvent(Control *self, const SDL_Event *event) { } if (didEdit) { - self->view.needsLayout = true; + $((View *) self, setNeedsLayout); if (this->delegate.didEdit) { this->delegate.didEdit(this); } @@ -388,7 +388,7 @@ static void setAttributedText(TextView *self, const char *attributedText) { self->position = self->attributedText->length; - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } @@ -408,7 +408,7 @@ static void setDefaultText(TextView *self, const char *defaultText) { self->defaultText = NULL; } - self->control.view.needsLayout = true; + $((View *) self, setNeedsLayout); } } diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 5fe8795b..8f7bb398 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -36,6 +36,19 @@ Uint32 MVC_NOTIFICATION_EVENT; Uint32 MVC_VIEW_EVENT; +/** + * @brief The render frame generation; per-View renderFrame and clippingFrame caches are + * valid only while their generation matches this. Zero disables caching entirely, for callers + * that never invalidate (e.g. unit tests). + */ +static unsigned _renderFrameGeneration; + +void MVC_InvalidateRenderFrames(void) { + if (++_renderFrameGeneration == 0) { + _renderFrameGeneration = 1; + } +} + const EnumName ViewAlignmentNames[] = MakeEnumNames( MakeEnumAlias(ViewAlignmentNone, none), MakeEnumAlias(ViewAlignmentTop, top), @@ -217,7 +230,7 @@ static void addSubviewRelativeTo(View *self, View *subview, View *other, ViewPos $(subview, invalidateStyle); - self->needsLayout = true; + $(self, setNeedsLayout); } /** @@ -327,6 +340,14 @@ static void applyThemeIfNeeded(View *self, const Theme *theme) { assert(theme); + if (!self->needsApplyTheme && !self->needsApplyThemeSubviews) { + return; + } + + // Cleared before descending so that any re-invalidation during the traversal + // propagates fresh subtree flags and survives to the next frame. + self->needsApplyThemeSubviews = false; + $(self, enumerateSubviews, _applyThemeIfNeeded, (ident) theme); if (self->needsApplyTheme) { @@ -485,8 +506,8 @@ static bool _bind(View *self, const Inlet *inlets, const Dictionary *dictionary) if (inlets) { if (bindInlets(inlets, dictionary)) { - self->needsApplyTheme = true; - self->needsLayout = true; + $(self, setNeedsApplyTheme); + $(self, setNeedsLayout); return true; } } @@ -554,29 +575,38 @@ static void clearWarnings(const View *self, WarningType type) { */ static SDL_Rect clippingFrame(const View *self) { + View *this = (View *) self; + + if (_renderFrameGeneration && this->clippingFrameCache.generation == _renderFrameGeneration) { + return this->clippingFrameCache.frame; + } + SDL_Rect frame = $(self, renderFrame); if (self->borderWidth && self->borderColor.a) { - for (int i = 0; i < self->borderWidth; i++) { - frame.x -= 1; - frame.y -= 1; - frame.w += 2; - frame.h += 2; - } + frame.x -= self->borderWidth; + frame.y -= self->borderWidth; + frame.w += self->borderWidth * 2; + frame.h += self->borderWidth * 2; } + // The nearest clipping ancestor's clippingFrame already folds in every outer clip, so a + // single intersection with it is equivalent to intersecting each clipping ancestor in turn. const View *superview = self->superview; - while (superview) { - if (superview->clipsSubviews) { - const SDL_Rect clippingFrame = $(superview, clippingFrame); - if (SDL_GetRectIntersection(&clippingFrame, &frame, &frame) == false) { - frame.w = frame.h = 0; - break; - } - } + while (superview && !superview->clipsSubviews) { superview = superview->superview; } + if (superview) { + const SDL_Rect clippingFrame = $(superview, clippingFrame); + if (SDL_GetRectIntersection(&clippingFrame, &frame, &frame) == false) { + frame.w = frame.h = 0; + } + } + + this->clippingFrameCache.frame = frame; + this->clippingFrameCache.generation = _renderFrameGeneration; + return frame; } @@ -655,7 +685,7 @@ static void didMoveToWindow(View *self, SDL_Window *window) { $(self, sizeToFill); } - self->needsLayout = true; + $(self, setNeedsLayout); } } @@ -1021,7 +1051,7 @@ static View *initWithFrame(View *self, const SDL_Rect *frame) { * @brief ViewEnumerator for invalidateStyle. */ static void invalidateStyle_enumerate(View *view, ident data) { - view->needsApplyTheme = true; + $(view, setNeedsApplyTheme); } /** @@ -1112,6 +1142,17 @@ static void layoutIfNeeded_enumerate(View *subview, ident data) { */ static void layoutIfNeeded(View *self) { + if (!self->needsLayout && !self->needsLayoutSubviews) { + return; + } + + // Cleared before laying out so that any re-invalidation during layout (e.g. a resize + // propagating setNeedsLayout, or a widget marking a sibling) survives to the next frame. + self->needsLayoutSubviews = false; + + // Subviews first, so that a dirty descendant whose layout resizes it marks its ancestors + // before their own dirty checks below run; the whole tree then converges bottom-up in a + // single pass, exactly as invalidations propagate. $(self, enumerateSubviews, layoutIfNeeded_enumerate, NULL); if (self->needsLayout) { @@ -1398,7 +1439,7 @@ static void removeSubview(View *self, View *subview) { $(self->subviews, removeObject, subview); - self->needsLayout = true; + $(self, setNeedsLayout); } } @@ -1471,24 +1512,31 @@ static void renderDeviceWillReset(View *self) { */ static SDL_Rect renderFrame(const View *self) { + View *this = (View *) self; + + if (_renderFrameGeneration && this->renderFrameCache.generation == _renderFrameGeneration) { + return this->renderFrameCache.frame; + } + SDL_Rect frame = self->frame; - const View *view = self; - const View *superview = view->superview; - while (superview) { + const View *superview = self->superview; + if (superview) { + + const SDL_Rect superFrame = $(superview, renderFrame); - frame.x += superview->frame.x; - frame.y += superview->frame.y; + frame.x += superFrame.x; + frame.y += superFrame.y; - if (view->alignment != ViewAlignmentInternal) { + if (self->alignment != ViewAlignmentInternal) { frame.x += superview->padding.left; frame.y += superview->padding.top; } - - view = superview; - superview = view->superview; } + this->renderFrameCache.frame = frame; + this->renderFrameCache.generation = _renderFrameGeneration; + return frame; } @@ -1551,10 +1599,10 @@ static void resize(View *self, const SDL_Size *size) { self->frame.w = w; self->frame.h = h; - self->needsLayout = true; + $(self, setNeedsLayout); if (self->superview && $(self->superview, isContainer)) { - self->superview->needsLayout = true; + $(self->superview, setNeedsLayout); } } } @@ -1672,11 +1720,37 @@ static void setHidden(View *self, bool hidden) { self->hidden = hidden; if (self->superview && $(self->superview, isContainer)) { - self->superview->needsLayout = true; + $(self->superview, setNeedsLayout); } } } +/** + * @fn void View::setNeedsApplyTheme(View *self) + * @memberof View + */ +static void setNeedsApplyTheme(View *self) { + + self->needsApplyTheme = true; + + for (View *view = self->superview; view && !view->needsApplyThemeSubviews; view = view->superview) { + view->needsApplyThemeSubviews = true; + } +} + +/** + * @fn void View::setNeedsLayout(View *self) + * @memberof View + */ +static void setNeedsLayout(View *self) { + + self->needsLayout = true; + + for (View *view = self->superview; view && !view->needsLayoutSubviews; view = view->superview) { + view->needsLayoutSubviews = true; + } +} + /** * @fn SDL_Size View::size(const View *self) * @memberof View @@ -2110,6 +2184,8 @@ static void initialize(Class *clazz) { ((ViewInterface *) clazz->interface)->select = _select; ((ViewInterface *) clazz->interface)->selectFirst = selectFirst; ((ViewInterface *) clazz->interface)->setHidden = setHidden; + ((ViewInterface *) clazz->interface)->setNeedsApplyTheme = setNeedsApplyTheme; + ((ViewInterface *) clazz->interface)->setNeedsLayout = setNeedsLayout; ((ViewInterface *) clazz->interface)->size = size; ((ViewInterface *) clazz->interface)->sizeThatContains = sizeThatContains; ((ViewInterface *) clazz->interface)->sizeThatFills = sizeThatFills; diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 2fd90596..b427a773 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -213,6 +213,16 @@ struct View { */ Set *classNames; + /** + * @brief The cached View::clippingFrame, valid while `generation` matches the current render + * frame generation. + * @private + */ + struct { + SDL_Rect frame; + unsigned generation; + } clippingFrameCache; + /** * @brief If true, subviews will be clipped to this View's frame. */ @@ -252,14 +262,35 @@ struct View { /** * @brief If true, this View will apply the Theme before it is drawn. + * @remarks Set this via View::setNeedsApplyTheme, which propagates + * `needsApplyThemeSubviews` to ancestors; a direct write does not, and the View MAY be + * skipped by View::applyThemeIfNeeded. */ bool needsApplyTheme; + /** + * @brief If true, a descendant of this View has `needsApplyTheme` set. + * @remarks Maintained by View::setNeedsApplyTheme; View::applyThemeIfNeeded only descends + * into subtrees with this flag set. + * @private + */ + bool needsApplyThemeSubviews; + /** * @brief If true, this View will layout its subviews before it is drawn. + * @remarks Set this via View::setNeedsLayout, which propagates `needsLayoutSubviews` to + * ancestors; a direct write does not, and the View MAY be skipped by View::layoutIfNeeded. */ bool needsLayout; + /** + * @brief If true, a descendant of this View has `needsLayout` set. + * @remarks Maintained by View::setNeedsLayout; View::layoutIfNeeded only descends into + * subtrees with this flag set. + * @private + */ + bool needsLayoutSubviews; + /** * @brief The next responder, or event handler, in the chain. * @remarks By default, Views propagate events to their superview. If this member is not `NULL`, @@ -272,6 +303,16 @@ struct View { */ ViewPadding padding; + /** + * @brief The cached View::renderFrame, valid while `generation` matches the current + * `renderFrameGeneration`. + * @private + */ + struct { + SDL_Rect frame; + unsigned generation; + } renderFrameCache; + /** * @brief The element-level Style of this View. * @remarks Attributes in this Style are local to this View, and override any Attributes matched @@ -1055,6 +1096,28 @@ struct ViewInterface { */ void (*setHidden)(View *self, bool hidden); + /** + * @fn void View::setNeedsApplyTheme(View *self) + * @brief Marks this View as needing Theme application before it is next drawn. + * @param self The View. + * @remarks Callers MUST use this method rather than assigning `needsApplyTheme` directly: + * it propagates `needsApplyThemeSubviews` to ancestors, which View::applyThemeIfNeeded + * requires in order to find this View. A View flagged by direct assignment MAY be skipped. + * @memberof View + */ + void (*setNeedsApplyTheme)(View *self); + + /** + * @fn void View::setNeedsLayout(View *self) + * @brief Marks this View as needing layout before it is next drawn. + * @param self The View. + * @remarks Callers MUST use this method rather than assigning `needsLayout` directly: it + * propagates `needsLayoutSubviews` to ancestors, which View::layoutIfNeeded requires in + * order to find this View. A View flagged by direct assignment MAY be skipped. + * @memberof View + */ + void (*setNeedsLayout)(View *self); + /** * @fn SDL_Size View::size(const View *self) * @param self The View. @@ -1263,3 +1326,12 @@ struct ViewInterface { * @memberof View */ OBJECTIVELYMVC_EXPORT Class *_View(void); + +/** + * @brief Invalidates every View's cached View::renderFrame and View::clippingFrame. + * @details WindowController::renderTo invokes this after layout, before drawing, so that + * drawing and subsequent hit-testing observe post-layout frames; both methods memoize their + * result until the next invocation. Callers that mutate View frames outside of a layout pass + * MUST invoke this afterwards if hit-testing precision is required before the next render. + */ +OBJECTIVELYMVC_EXPORT void MVC_InvalidateRenderFrames(void); diff --git a/Sources/ObjectivelyMVC/WindowController.c b/Sources/ObjectivelyMVC/WindowController.c index 30dca2c7..b5e6087b 100644 --- a/Sources/ObjectivelyMVC/WindowController.c +++ b/Sources/ObjectivelyMVC/WindowController.c @@ -60,6 +60,7 @@ static void dealloc(Object *self) { WindowController *this = (WindowController *) self; release(this->debugViewController); + release(this->fontCache); release(this->renderer); release(this->theme); release(this->viewController); @@ -69,6 +70,43 @@ static void dealloc(Object *self) { #pragma mark - WindowController +/** + * @fn Font *WindowController::cachedFont(WindowController *self, const char *family, int size, int style) + * @memberof WindowController + */ +static Font *cachedFont(WindowController *self, const char *family, int size, int style) { + + String *name = $$(Font, nameWithAttributes, family, size, style); + assert(name); + + Font *font = $(self->fontCache, objectForKeyPath, name->chars); + if (font == NULL) { + + Font *resolved = $$(Font, fontWithAttributes, family, size, style, SDL_GetWindowPixelDensity(self->window)); + assert(resolved); + + // Keyed by the resolved Font's own name: when the requested family is not registered, + // Font::fontWithAttributes falls back to the default Font, and caching that under the + // requested key would pin the wrong Font to it permanently. This way the request + // simply misses again, and heals once the family is registered via Font::cacheFont. + String *resolvedName = $(resolved, name); + assert(resolvedName); + + font = $(self->fontCache, objectForKeyPath, resolvedName->chars); + if (font == NULL) { + $(self->fontCache, setObjectForKeyPath, resolved, resolvedName->chars); + font = resolved; + } + + release(resolvedName); + release(resolved); + } + + release(name); + + return font; +} + /** * @fn void WindowController::debug(WindowController *self) * @memberof WindowController @@ -90,6 +128,9 @@ static void debug(WindowController *self) { $(debugViewController->view, applyThemeIfNeeded, self->theme); $(debugViewController->view, layoutIfNeeded); + + MVC_InvalidateRenderFrames(); + $(debugViewController->view, draw, self->renderer); } } @@ -134,6 +175,9 @@ static WindowController *initWithDevice(WindowController *self, RenderDevice *de self = (WindowController *) super(Object, self, init); if (self) { + self->fontCache = $$(Dictionary, dictionary); + assert(self->fontCache); + self->renderer = $(alloc(Renderer), initWithDevice, device); assert(self->renderer); @@ -174,6 +218,9 @@ static void renderTo(WindowController *self, CommandBuffer *commands, Framebuffe $(self->viewController->view, applyThemeIfNeeded, self->theme); $(self->viewController->view, layoutIfNeeded); + + MVC_InvalidateRenderFrames(); + $(self->viewController->view, draw, self->renderer); $(self, debug); @@ -257,11 +304,29 @@ static void respondToEvent(WindowController *self, const SDL_Event *event) { break; case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: case SDL_EVENT_WINDOW_DISPLAY_CHANGED: - case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: { $(self, setWindow, self->window); + + // Emptied only when the density actually changed: SDL emits PIXEL_SIZE_CHANGED + // continuously during interactive resizes, and discarding open TTF_Fonts on each + // event would re-parse every face, tens of times per second. + const float pixelDensity = SDL_GetWindowPixelDensity(self->window); + if (pixelDensity != self->pixelDensity) { + self->pixelDensity = pixelDensity; + $(self->fontCache, removeAllObjects); + } + $(self->viewController, renderDeviceWillReset); $(self->viewController, renderDeviceDidReset); $(self->viewController->view, updateBindings); + + if (self->debugViewController) { + ViewController *debugViewController = (ViewController *) self->debugViewController; + $(debugViewController, renderDeviceWillReset); + $(debugViewController, renderDeviceDidReset); + $(debugViewController->view, updateBindings); + } + } break; case SDL_EVENT_WINDOW_DESTROYED: case SDL_EVENT_WINDOW_CLOSE_REQUESTED: @@ -412,8 +477,16 @@ static void setViewController(WindowController *self, ViewController *viewContro */ static void setWindow(WindowController *self, SDL_Window *window) { + assert(window); + + // A different window may reside on a different-density display; discard Fonts opened + // at the previous window's density so they re-resolve against the new one. + if (window != self->window) { + self->pixelDensity = SDL_GetWindowPixelDensity(window); + $(self->fontCache, removeAllObjects); + } + self->window = window; - assert(self->window); SDL_PropertiesID properties = SDL_GetWindowProperties(self->window); @@ -522,6 +595,7 @@ static void initialize(Class *clazz) { ((ObjectInterface *) clazz->interface)->dealloc = dealloc; + ((WindowControllerInterface *) clazz->interface)->cachedFont = cachedFont; ((WindowControllerInterface *) clazz->interface)->debug = debug; ((WindowControllerInterface *) clazz->interface)->keyResponder = keyResponder; ((WindowControllerInterface *) clazz->interface)->keyResponders = keyResponders; diff --git a/Sources/ObjectivelyMVC/WindowController.h b/Sources/ObjectivelyMVC/WindowController.h index 1c3adc87..e477697d 100644 --- a/Sources/ObjectivelyMVC/WindowController.h +++ b/Sources/ObjectivelyMVC/WindowController.h @@ -26,6 +26,7 @@ #include #include +#include #include #include @@ -60,6 +61,23 @@ struct WindowController { */ DebugViewController *debugViewController; + /** + * @brief The Font instance cache for this window, keyed by Font::name. + * @details Fonts are opened at this window's pixel density, which is why the cache lives + * here rather than on Font: density is implied by the window. The cache is emptied when + * the window's pixel density or display changes, and Views re-resolve their Fonts during + * the ensuing render device reset. + * @private + */ + Dictionary *fontCache; + + /** + * @brief The pixel density of the window when `fontCache` was last emptied, used to + * empty it only when the density actually changes. + * @private + */ + float pixelDensity; + /** * @brief The Renderer. */ @@ -91,6 +109,19 @@ struct WindowControllerInterface { */ ObjectInterface objectInterface; + /** + * @fn Font *WindowController::cachedFont(WindowController *self, const char *family, int size, int style) + * @brief Resolves the cached Font with the given attributes at this window's pixel density. + * @param self The WindowController. + * @param family The family, or `NULL` for the default. + * @param size The point size, or `0` for the default. + * @param style The FontStyle, or `-1` for the default. + * @return The cached Font. The returned Font is owned by the cache; callers retaining it + * beyond the current frame MUST re-resolve after a pixel density change. + * @memberof WindowController + */ + Font *(*cachedFont)(WindowController *self, const char *family, int size, int style); + /** * @private * @fn void WindowController::debug(WindowController *self) diff --git a/Tests/ObjectivelyMVC/View.c b/Tests/ObjectivelyMVC/View.c index b39b0425..439ad2b7 100644 --- a/Tests/ObjectivelyMVC/View.c +++ b/Tests/ObjectivelyMVC/View.c @@ -118,7 +118,7 @@ START_TEST(containStackViewWithFillChild) { const SDL_Rect frame = fillChild->view.frame; - stackView->view.needsLayout = true; + $((View *) stackView, setNeedsLayout); $((View *) stackView, layoutIfNeeded); ck_assert_int_eq(frame.w, fillChild->view.frame.w); @@ -198,7 +198,7 @@ START_TEST(standaloneRelayoutDoesNotShrinkFillChild) { // Simulate a style rebind on the row alone (e.g. a `:selected` pseudo-class match), which // marks only the row -- not its superview -- needsLayout, per View::_bind's contract. - row->view.needsLayout = true; + $((View *) row, setNeedsLayout); $((View *) row, layoutIfNeeded); ck_assert_int_eq(200, row->view.frame.w);