From ed3366b2e329b0be5f00a7b2711e7630935b6d11 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 8 Sep 2026 19:08:37 -0400 Subject: [PATCH 1/5] Add background gradients and corner cuts to View A View could only ever be a flat rectangle, rounded or not. Two properties widen that: a background gradient, and a horizontal inset per corner that slants the edge it shares with the corner above or below it. Together they draw the angled, gradient filled cards a HUD or a panel wants, from CSS alone, with no new View subclass. MVC_Vertex::color was already a per vertex attribute that both shaders interpolate, but pushDrawArrays overwrote it with its color argument, so no caller could reach it. That argument is now optional: NULL keeps the colors the vertices carry, which is what makes a gradient expressible. Renderer gains drawPolygon, drawPolygonFilled and drawRoundedRectGradientFilled, and drawLines factors out the stroking it shares with drawPolygon so a border can follow a slanted edge at any width. The gradient interpolant is a vertex's projection onto the gradient axis, normalized by the extent of the bounds along it. That makes the color an affine function of position, which barycentric interpolation reproduces exactly, so a gradient at any angle is right from colors assigned only at the four corners. A cut corner leaves the rounded rectangle shader, which is what supplies the anti aliasing band, so its diagonals are hard edged and View::borderRadius does not apply to it. Cuts are clamped to the width, so an over large one degenerates to a triangle rather than crossing over itself. --- Sources/ObjectivelyMVC/Renderer.c | 199 ++++++++++++++++++++++++++---- Sources/ObjectivelyMVC/Renderer.h | 52 +++++++- Sources/ObjectivelyMVC/View.c | 62 +++++++++- Sources/ObjectivelyMVC/View.h | 42 +++++++ 4 files changed, 326 insertions(+), 29 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index c60aa77c..7c37d7a8 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -22,6 +22,7 @@ */ #include +#include #include #include @@ -158,6 +159,87 @@ static SDL_FRect textureRegion(const Texture *texture, const SDL_Rect *src) { return (SDL_FRect) { src->x / w, src->y / h, src->w / w, src->h / h }; } +/** + * @brief The color at `x, y` for a gradient of `angle` degrees across `bounds`. + * @details `angle` is measured clockwise from north, so `0` fills upwards and `90` to the + * right. The interpolant is the point's projection onto the gradient axis, normalized by the + * extent of `bounds` along that axis, which makes the color an affine function of position: + * barycentric interpolation across the triangle then reproduces the gradient exactly, at any + * angle, from colors assigned only at the vertices. + */ +static SDL_Color gradientColor(float x, float y, const SDL_FRect *bounds, int angle, + const SDL_Color *from, const SDL_Color *to) { + + const float radians = (float) angle * (float) M_PI / 180.f; + const float dx = sinf(radians), dy = -cosf(radians); + + const float cx = bounds->x + bounds->w * 0.5f; + const float cy = bounds->y + bounds->h * 0.5f; + + // the extent of the bounds along the axis, which is the box's support in that direction + const float extent = fabsf(bounds->w * dx) + fabsf(bounds->h * dy); + + float t = 0.5f; + if (extent > 0.001f) { + t = 0.5f + ((x - cx) * dx + (y - cy) * dy) / extent; + t = t < 0.f ? 0.f : (t > 1.f ? 1.f : t); + } + + return (SDL_Color) { + (Uint8) (from->r + (to->r - from->r) * t + 0.5f), + (Uint8) (from->g + (to->g - from->g) * t + 0.5f), + (Uint8) (from->b + (to->b - from->b) * t + 0.5f), + (Uint8) (from->a + (to->a - from->a) * t + 0.5f), + }; +} + +/** + * @brief Appends the quads stroking `points`, of `width`, optionally closing the loop. + * @remarks Each segment is stroked as its own quad, centered on the segment. The joints are + * therefore not mitered; at the one and two pixel widths borders actually use, the notch is + * smaller than a pixel. + */ +static void strokePolyline(const Renderer *self, const SDL_Point *points, size_t count, + bool closed, float width, const SDL_Color *color) { + + const size_t segments = closed ? count : count - 1; + + MVC_Vertex verts[16 * 6]; + const size_t batchSize = lengthof(verts) / 6; + + for (size_t s = 0; s < segments; ) { + + const size_t batch = min(segments - s, batchSize); + + for (size_t i = 0; i < batch; i++, s++) { + + const SDL_Point *a = &points[s], *b = &points[(s + 1) % count]; + + const float ax = (float) a->x, ay = (float) a->y; + const float bx = (float) b->x, by = (float) b->y; + + const float dx = bx - ax, dy = by - ay; + const float len = sqrtf(dx * dx + dy * dy); + + float nx = 0.0f, ny = 0.0f; + if (len > 0.001f) { + nx = (-dy / len) * width * 0.5f; + ny = ( dx / len) * width * 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); + } +} + /** * @fn void Renderer::drawBevel(const Renderer *self, const SDL_Rect *rect, int radius, int width, const SDL_Color *topLeft, const SDL_Color *bottomRight) * @memberof Renderer @@ -201,39 +283,78 @@ static void drawLines(const Renderer *self, const SDL_Point *points, size_t coun return; } - const size_t segments = count - 1; + strokePolyline(self, points, count, false, 1.f, color); +} - MVC_Vertex verts[16 * 6]; - const size_t batchSize = lengthof(verts) / 6; +/** + * @fn void Renderer::drawPolygon(const Renderer *self, const SDL_Point *points, size_t count, int width, const SDL_Color *color) + * @memberof Renderer + */ +static void drawPolygon(const Renderer *self, const SDL_Point *points, size_t count, int width, + const SDL_Color *color) { - for (size_t s = 0; s < segments; ) { + assert(points); + assert(color); - const size_t batch = min(segments - s, batchSize); + if (count < 3 || width < 1) { + return; + } - for (size_t i = 0; i < batch; i++, s++) { + strokePolyline(self, points, count, true, (float) width, color); +} - 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; +/** + * @fn void Renderer::drawPolygonFilled(const Renderer *self, const SDL_Point *points, size_t count, int angle, const SDL_Color *from, const SDL_Color *to) + * @memberof Renderer + */ +static void drawPolygonFilled(const Renderer *self, const SDL_Point *points, size_t count, + int angle, const SDL_Color *from, const SDL_Color *to) { - const float dx = bx - ax, dy = by - ay; - const float len = sqrtf(dx * dx + dy * dy); + assert(points); + assert(from); - float nx = 0.0f, ny = 0.0f; - if (len > 0.001f) { - nx = (-dy / len) * 0.5f; - ny = ( dx / len) * 0.5f; - } + if (count < 3) { + return; + } - 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 } }; + SDL_FRect bounds = { (float) points[0].x, (float) points[0].y, 0.f, 0.f }; + float x2 = bounds.x, y2 = bounds.y; + + for (size_t i = 1; i < count; i++) { + bounds.x = min(bounds.x, (float) points[i].x); + bounds.y = min(bounds.y, (float) points[i].y); + x2 = max(x2, (float) points[i].x); + y2 = max(y2, (float) points[i].y); + } + + bounds.w = x2 - bounds.x; + bounds.h = y2 - bounds.y; + + // a convex polygon triangulates as a fan from its first vertex + const size_t triangles = count - 2; + + MVC_Vertex verts[16 * 3]; + const size_t batchSize = lengthof(verts) / 3; + + for (size_t t = 0; t < triangles; ) { + + const size_t batch = min(triangles - t, batchSize); + + for (size_t i = 0; i < batch; i++, t++) { + + const SDL_Point *p[3] = { &points[0], &points[t + 1], &points[t + 2] }; + + for (size_t j = 0; j < 3; j++) { + + const float x = (float) p[j]->x, y = (float) p[j]->y; + + verts[i * 3 + j] = (MVC_Vertex) { { { x, y } }, { { 0.f, 0.f } }, + to ? gradientColor(x, y, &bounds, angle, from, to) : *from + }; + } } - $(self, pushDrawArrays, verts, batch * 6, NULL, color); + $(self, pushDrawArrays, verts, batch * 3, NULL, NULL); } } @@ -313,6 +434,30 @@ static void drawRoundedRectFilled(const Renderer *self, const SDL_Rect *rect, in $(self, pushDrawArrays, verts, 6, NULL, color); } +/** + * @fn void Renderer::drawRoundedRectGradientFilled(const Renderer *self, const SDL_Rect *rect, int radius, int angle, const SDL_Color *from, const SDL_Color *to) + * @memberof Renderer + */ +static void drawRoundedRectGradientFilled(const Renderer *self, const SDL_Rect *rect, int radius, + int angle, const SDL_Color *from, const SDL_Color *to) { + + assert(rect); + assert(from); + assert(to); + + SDL_FRect frect; + SDL_RectToFRect(rect, &frect); + + MVC_Vertex verts[6]; + roundedRectVertices(verts, &frect, radius, 0, NULL); + + for (size_t i = 0; i < lengthof(verts); i++) { + verts[i].color = gradientColor(verts[i].position.x, verts[i].position.y, &frect, angle, from, to); + } + + $(self, pushDrawArrays, verts, lengthof(verts), NULL, NULL); +} + /** * @fn void Renderer::drawRoundedTexture(const Renderer *self, Texture *texture, const SDL_FRect *dest, int radius, const SDL_Color *color) * @memberof Renderer @@ -509,7 +654,6 @@ static Renderer *initWithDevice(Renderer *self, RenderDevice *device) { static void pushDrawArrays(const Renderer *self, const MVC_Vertex *verts, size_t count, Texture *texture, const SDL_Color *color) { assert(verts); - assert(color); const MVC_DrawArrays draw = { .firstVertex = (Uint32) self->vertices->count, @@ -527,7 +671,9 @@ static void pushDrawArrays(const Renderer *self, const MVC_Vertex *verts, size_t MVC_Vertex *out = VectorElement(vertices, MVC_Vertex, vertices->count); for (size_t i = 0; i < count; i++) { out[i] = verts[i]; - out[i].color = *color; + if (color) { + out[i].color = *color; + } } vertices->count += count; @@ -709,10 +855,13 @@ static void initialize(Class *clazz) { ((RendererInterface *) clazz->interface)->drawBevel = drawBevel; ((RendererInterface *) clazz->interface)->drawLine = drawLine; ((RendererInterface *) clazz->interface)->drawLines = drawLines; + ((RendererInterface *) clazz->interface)->drawPolygon = drawPolygon; + ((RendererInterface *) clazz->interface)->drawPolygonFilled = drawPolygonFilled; ((RendererInterface *) clazz->interface)->drawRect = drawRect; ((RendererInterface *) clazz->interface)->drawRectFilled = drawRectFilled; ((RendererInterface *) clazz->interface)->drawRoundedRect = drawRoundedRect; ((RendererInterface *) clazz->interface)->drawRoundedRectFilled = drawRoundedRectFilled; + ((RendererInterface *) clazz->interface)->drawRoundedRectGradientFilled = drawRoundedRectGradientFilled; ((RendererInterface *) clazz->interface)->drawRoundedTexture = drawRoundedTexture; ((RendererInterface *) clazz->interface)->drawRoundedTextureRegion = drawRoundedTextureRegion; ((RendererInterface *) clazz->interface)->drawTexture = drawTexture; diff --git a/Sources/ObjectivelyMVC/Renderer.h b/Sources/ObjectivelyMVC/Renderer.h index 009615ae..0dcb4d65 100644 --- a/Sources/ObjectivelyMVC/Renderer.h +++ b/Sources/ObjectivelyMVC/Renderer.h @@ -231,6 +231,39 @@ struct RendererInterface { */ void (*drawLines)(const Renderer *self, const SDL_Point *points, size_t count, const SDL_Color *color); + /** + * @fn void Renderer::drawPolygon(const Renderer *self, const SDL_Point *points, size_t count, int width, const SDL_Color *color) + * @brief Records a closed polygon outline of the given width. + * @details The stroke is centered on each edge. Joints are not mitered, so a width well + * above the one or two pixels a border uses will notch at sharp corners. + * @param self The Renderer. + * @param points The points, in order; the last is joined back to the first. + * @param count The number of points; fewer than three draws nothing. + * @param width The stroke width. + * @param color The outline color. + * @memberof Renderer + */ + void (*drawPolygon)(const Renderer *self, const SDL_Point *points, size_t count, int width, + const SDL_Color *color); + + /** + * @fn void Renderer::drawPolygonFilled(const Renderer *self, const SDL_Point *points, size_t count, int angle, const SDL_Color *from, const SDL_Color *to) + * @brief Records a filled convex polygon, optionally with a linear gradient. + * @details The polygon is triangulated as a fan from its first vertex, so it MUST be + * convex. It is drawn without the rounded rectangle shader, and so without its + * anti-aliasing: diagonal edges are hard. + * @param self The Renderer. + * @param points The points, in order. + * @param count The number of points; fewer than three draws nothing. + * @param angle The gradient angle in degrees, clockwise from north. Ignored when `to` is + * `NULL`. + * @param from The fill color, and the near color of the gradient. + * @param to The far color of the gradient, or `NULL` to fill flat with `from`. + * @memberof Renderer + */ + void (*drawPolygonFilled)(const Renderer *self, const SDL_Point *points, size_t count, + int angle, const SDL_Color *from, const SDL_Color *to); + /** * @fn void Renderer::drawRect(const Renderer *self, const SDL_Rect *rect, const SDL_Color *color) * @brief Records a rectangle outline. @@ -274,6 +307,21 @@ struct RendererInterface { */ void (*drawRoundedRectFilled)(const Renderer *self, const SDL_Rect *rect, int radius, const SDL_Color *color); + /** + * @fn void Renderer::drawRoundedRectGradientFilled(const Renderer *self, const SDL_Rect *rect, int radius, int angle, const SDL_Color *from, const SDL_Color *to) + * @brief Records a rounded rectangle filled with a linear gradient. + * @param self The Renderer. + * @param rect The rectangle. + * @param radius The corner radius. + * @param angle The gradient angle in degrees, clockwise from north: `0` fills upwards, + * `90` to the right, `180` downwards. + * @param from The near color of the gradient. + * @param to The far color of the gradient. + * @memberof Renderer + */ + void (*drawRoundedRectGradientFilled)(const Renderer *self, const SDL_Rect *rect, int radius, + int angle, const SDL_Color *from, const SDL_Color *to); + /** * @fn void Renderer::drawRoundedTexture(const Renderer *self, Texture *texture, const SDL_FRect *dest, int radius, const SDL_Color *color) * @brief Records a textured quad clipped to a rounded rectangle. @@ -374,7 +422,9 @@ struct RendererInterface { * @param verts The vertices to append (in logical screen coordinates). * @param count The number of vertices. * @param texture The texture to bind, or `NULL` to use the 1×1 white fallback. - * @param color The color multiplier applied in the fragment shader. + * @param color The color to apply to every vertex, or `NULL` to keep the colors the + * vertices already carry. MVC_Vertex::color is interpolated across the triangle, so + * `NULL` is how a caller draws a gradient. * @memberof Renderer */ void (*pushDrawArrays)(const Renderer *self, const MVC_Vertex *verts, size_t count, diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 02d1db10..2f496b8e 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -266,10 +266,17 @@ static void applyStyle(View *self, const Style *style) { MakeInlet("alignment", InletTypeEnum, &self->alignment, (ident) ViewAlignmentNames), MakeInlet("autoresizing-mask", InletTypeEnum, &self->autoresizingMask, (ident) ViewAutoresizingNames), MakeInlet("background-color", InletTypeColor, &self->backgroundColor, NULL), + MakeInlet("background-gradient-angle", InletTypeInteger, &self->backgroundGradientAngle, NULL), + MakeInlet("background-gradient-color", InletTypeColor, &self->backgroundGradientColor, NULL), MakeInlet("border-color", InletTypeColor, &self->borderColor, NULL), MakeInlet("border-radius", InletTypeInteger, &self->borderRadius, NULL), MakeInlet("border-width", InletTypeInteger, &self->borderWidth, NULL), MakeInlet("clips-subviews", InletTypeBool, &self->clipsSubviews, NULL), + MakeInlet("corner-cut", InletTypeRectangle, &self->cornerCut, NULL), + MakeInlet("corner-cut-top-left", InletTypeInteger, &self->cornerCut.topLeft, NULL), + MakeInlet("corner-cut-top-right", InletTypeInteger, &self->cornerCut.topRight, NULL), + MakeInlet("corner-cut-bottom-right", InletTypeInteger, &self->cornerCut.bottomRight, NULL), + MakeInlet("corner-cut-bottom-left", InletTypeInteger, &self->cornerCut.bottomLeft, NULL), MakeInlet("frame", InletTypeRectangle, &self->frame, NULL), MakeInlet("hidden", InletTypeBool, &self->hidden, NULL), MakeInlet("height", InletTypeInteger, &self->frame.h, NULL), @@ -1040,6 +1047,7 @@ static View *initWithFrame(View *self, const SDL_Rect *frame) { self->warnings = $$(Array, arrayWithCapacity, 0); assert(self->warnings); + self->backgroundGradientAngle = 180; self->maxSize = MakeSize(INT32_MAX, INT32_MAX); self->pointerEvents = true; @@ -1446,6 +1454,25 @@ static void removeSubview(View *self, View *subview) { } } +/** + * @brief Writes the four corners of `rect`, inset by `cut`, in clockwise order. + * @details Each inset pulls its corner in along the x axis, so an inset on one of a pair of + * vertically adjacent corners slants the edge between them. Insets are clamped to the width, + * so an over-large cut degenerates to a triangle rather than crossing over. + */ +static void cornerCutPoints(SDL_Point *points, const SDL_Rect *rect, const ViewCornerCut *cut) { + + const int x1 = rect->x, y1 = rect->y; + const int x2 = rect->x + rect->w, y2 = rect->y + rect->h; + + const int w = rect->w; + + points[0] = MakePoint(x1 + clamp(cut->topLeft, 0, w), y1); + points[1] = MakePoint(x2 - clamp(cut->topRight, 0, w), y1); + points[2] = MakePoint(x2 - clamp(cut->bottomRight, 0, w), y2); + points[3] = MakePoint(x1 + clamp(cut->bottomLeft, 0, w), y2); +} + /** * @fn void View::render(View *self, Renderer *renderer) * @memberof View @@ -1458,10 +1485,24 @@ static void render(View *self, Renderer *renderer) { SDL_TriggerBreakpoint(); } - if (self->backgroundColor.a) { + const bool cut = !CornerCutIsEmpty(self->cornerCut); + + if (self->backgroundColor.a || self->backgroundGradientColor.a) { const SDL_Rect frame = $(self, renderFrame); - if (self->borderRadius > 0) { + const SDL_Color *gradient = self->backgroundGradientColor.a ? &self->backgroundGradientColor : NULL; + + if (cut) { + SDL_Point points[4]; + cornerCutPoints(points, &frame, &self->cornerCut); + + $(renderer, drawPolygonFilled, points, lengthof(points), self->backgroundGradientAngle, + &self->backgroundColor, gradient); + + } else if (gradient) { + $(renderer, drawRoundedRectGradientFilled, &frame, self->borderRadius, + self->backgroundGradientAngle, &self->backgroundColor, gradient); + } else if (self->borderRadius > 0) { $(renderer, drawRoundedRectFilled, &frame, self->borderRadius, &self->backgroundColor); } else { $(renderer, drawRectFilled, &frame, &self->backgroundColor); @@ -1472,7 +1513,22 @@ static void render(View *self, Renderer *renderer) { SDL_Rect frame = $(self, renderFrame); - if (self->borderRadius > 0) { + if (cut) { + + // The stroke is centered on the outline, so grow by half of it to sit outside the fill + const int inset = self->borderWidth / 2; + + frame.x -= inset; + frame.y -= inset; + frame.w += inset * 2; + frame.h += inset * 2; + + SDL_Point points[4]; + cornerCutPoints(points, &frame, &self->cornerCut); + + $(renderer, drawPolygon, points, lengthof(points), self->borderWidth, &self->borderColor); + + } else if (self->borderRadius > 0) { // The border grows outward, so its inner edge shares the background's radius frame.x -= self->borderWidth; diff --git a/Sources/ObjectivelyMVC/View.h b/Sources/ObjectivelyMVC/View.h index 6089a15a..d553f454 100644 --- a/Sources/ObjectivelyMVC/View.h +++ b/Sources/ObjectivelyMVC/View.h @@ -154,6 +154,28 @@ typedef struct { #define AddPadding(a, b) \ MakePadding(a.top + b.top, a.right + b.right, a.bottom + b.bottom, a.left + b.left) +/** + * @brief The horizontal inset of each of a View's corners. + * @details A non-zero value draws that corner pulled in along the x axis, slanting the edge + * it shares with the corner below or above it. This is an inset, not a 45 degree chamfer: + * the slant runs the full height of the View, however small the inset. + */ +typedef struct { + int topLeft, topRight, bottomRight, bottomLeft; +} ViewCornerCut; + +/** + * @brief Creates a ViewCornerCut with the given insets. + */ +#define MakeCornerCut(topLeft, topRight, bottomRight, bottomLeft) \ + (ViewCornerCut) { (topLeft), (topRight), (bottomRight), (bottomLeft) } + +/** + * @return True if `cut` insets no corner. + */ +#define CornerCutIsEmpty(cut) \ + ((cut).topLeft == 0 && (cut).topRight == 0 && (cut).bottomRight == 0 && (cut).bottomLeft == 0) + /** * @brief Relative positioning of subviews within their superview. */ @@ -200,6 +222,19 @@ struct View { */ SDL_Color backgroundColor; + /** + * @brief The angle of the background gradient, in degrees, clockwise from north: `0` fills + * upwards, `90` to the right, `180` (the default) downwards. + */ + int backgroundGradientAngle; + + /** + * @brief The far color of the background gradient, which runs from View::backgroundColor. + * @remarks The gradient is drawn only when this color has a non-zero alpha; otherwise the + * background is a flat View::backgroundColor. + */ + SDL_Color backgroundGradientColor; + /** * @brief The border color. */ @@ -241,6 +276,13 @@ struct View { */ Style *computedStyle; + /** + * @brief The horizontal inset of each corner of the background and border. + * @remarks A View with any corner inset is drawn as a polygon rather than through the + * rounded rectangle shader, so View::borderRadius does not apply to it. + */ + ViewCornerCut cornerCut; + /** * @brief The frame, relative to the superview. */ From a0bba2ee89c5c364c3bde2cc1a1e0dbe57b58095 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 8 Sep 2026 19:08:41 -0400 Subject: [PATCH 2/5] v2.4.7 --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index 6ce03cd3..6532c7eb 100644 --- a/configure.ac +++ b/configure.ac @@ -1,6 +1,6 @@ AC_PREREQ(2.69) -AC_INIT([ObjectivelyMVC], [2.4.6], [jay@jaydolan.com]) +AC_INIT([ObjectivelyMVC], [2.4.7], [jay@jaydolan.com]) AC_SUBST([RELEASE_VERSION], [m4_bpatsubst(AC_PACKAGE_VERSION, [\.[0-9]*$], [])]) AC_CONFIG_HEADERS([Sources/ObjectivelyMVC/Config.h]) From 238621557accbd43e996f2bea86750a0ced2ce9e Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 8 Sep 2026 19:17:07 -0400 Subject: [PATCH 3/5] Add StackView::reversed A StackView lays its subviews out in the order they were added, and that order is baked into whatever built the view. A HUD element that wants its icon leading in one variant and trailing in another had no way to say so from CSS. reversed mirrors the positions along the axis, the way flexbox's row-reverse does, and composes with both axes. Only the placement is mirrored. View::subviews keeps its order, so drawing and hit testing are unaffected. --- Sources/ObjectivelyMVC/StackView.c | 5 ++++- Sources/ObjectivelyMVC/StackView.h | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/ObjectivelyMVC/StackView.c b/Sources/ObjectivelyMVC/StackView.c index b3ef98a1..3922d59e 100644 --- a/Sources/ObjectivelyMVC/StackView.c +++ b/Sources/ObjectivelyMVC/StackView.c @@ -51,6 +51,7 @@ static void applyStyle(View *self, const Style *style) { const Inlet inlets[] = MakeInlets( MakeInlet("axis", InletTypeEnum, &this->axis, (ident) StackViewAxisNames), + MakeInlet("reversed", InletTypeBool, &this->reversed, NULL), MakeInlet("distribution", InletTypeEnum, &this->distribution, (ident) StackViewDistributionNames), MakeInlet("spacing", InletTypeInteger, &this->spacing, NULL) ); @@ -113,7 +114,9 @@ static void layoutSubviews(View *self) { const float scale = requestedSize ? availableSize / (float) requestedSize : 1.f; - for (size_t i = 0; i < subviews->count; i++) { + for (size_t j = 0; j < subviews->count; j++) { + + const size_t i = this->reversed ? subviews->count - 1 - j : j; View *subview = $(subviews, objectAtIndex, i); diff --git a/Sources/ObjectivelyMVC/StackView.h b/Sources/ObjectivelyMVC/StackView.h index 706e785d..59807fe3 100644 --- a/Sources/ObjectivelyMVC/StackView.h +++ b/Sources/ObjectivelyMVC/StackView.h @@ -88,6 +88,13 @@ struct StackView { */ StackViewDistribution distribution; + /** + * @brief If true, subviews are laid out in reverse order along View::axis. + * @remarks The order of View::subviews, and so of hit testing and drawing, is unchanged; + * only the positions along the axis are mirrored. + */ + bool reversed; + /** * @brief The subview spacing. */ From 24556f4eff03a20ef9ddef4f53b34262b24ac3d4 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 8 Sep 2026 21:54:44 -0400 Subject: [PATCH 4/5] Address review of the gradient and corner cut M_PI is not standard C and needs a feature macro on MSVC. Mathlib.h defines it when the platform does not, and offers float_radians, which says what the conversion is for. Clamping each of an edge's two insets to the width independently still let them sum past it: two over-large cuts on one edge crossed the quad over itself rather than closing it, as the comment claimed. Each is now clamped against what the other leaves. --- Sources/ObjectivelyMVC/Renderer.c | 2 +- Sources/ObjectivelyMVC/View.c | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index 7c37d7a8..7f3570e5 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -170,7 +170,7 @@ static SDL_FRect textureRegion(const Texture *texture, const SDL_Rect *src) { static SDL_Color gradientColor(float x, float y, const SDL_FRect *bounds, int angle, const SDL_Color *from, const SDL_Color *to) { - const float radians = (float) angle * (float) M_PI / 180.f; + const float radians = float_radians((float) angle); const float dx = sinf(radians), dy = -cosf(radians); const float cx = bounds->x + bounds->w * 0.5f; diff --git a/Sources/ObjectivelyMVC/View.c b/Sources/ObjectivelyMVC/View.c index 2f496b8e..a561a7ff 100644 --- a/Sources/ObjectivelyMVC/View.c +++ b/Sources/ObjectivelyMVC/View.c @@ -1457,8 +1457,9 @@ static void removeSubview(View *self, View *subview) { /** * @brief Writes the four corners of `rect`, inset by `cut`, in clockwise order. * @details Each inset pulls its corner in along the x axis, so an inset on one of a pair of - * vertically adjacent corners slants the edge between them. Insets are clamped to the width, - * so an over-large cut degenerates to a triangle rather than crossing over. + * vertically adjacent corners slants the edge between them. The two insets on an edge are + * clamped against each other as well as the width, so however large a cut is the edge closes + * to a point rather than crossing over itself. */ static void cornerCutPoints(SDL_Point *points, const SDL_Rect *rect, const ViewCornerCut *cut) { @@ -1467,10 +1468,16 @@ static void cornerCutPoints(SDL_Point *points, const SDL_Rect *rect, const ViewC const int w = rect->w; - points[0] = MakePoint(x1 + clamp(cut->topLeft, 0, w), y1); - points[1] = MakePoint(x2 - clamp(cut->topRight, 0, w), y1); - points[2] = MakePoint(x2 - clamp(cut->bottomRight, 0, w), y2); - points[3] = MakePoint(x1 + clamp(cut->bottomLeft, 0, w), y2); + const int topLeft = clamp(cut->topLeft, 0, w); + const int topRight = clamp(cut->topRight, 0, w - topLeft); + + const int bottomLeft = clamp(cut->bottomLeft, 0, w); + const int bottomRight = clamp(cut->bottomRight, 0, w - bottomLeft); + + points[0] = MakePoint(x1 + topLeft, y1); + points[1] = MakePoint(x2 - topRight, y1); + points[2] = MakePoint(x2 - bottomRight, y2); + points[3] = MakePoint(x1 + bottomLeft, y2); } /** From 515401c75e33af55ed7f48d7cfcc31ce84a4fab9 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Tue, 8 Sep 2026 22:05:20 -0400 Subject: [PATCH 5/5] Stop an empty frame from crashing the renderer The vertex buffer is created only when a frame has drawn something, but endFrame bound it either way, so any frame that drew nothing dereferenced NULL. A View culled to an empty frame before its first layout is enough to reach it. --- Sources/ObjectivelyMVC/Renderer.c | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/Sources/ObjectivelyMVC/Renderer.c b/Sources/ObjectivelyMVC/Renderer.c index 7f3570e5..be0b108f 100644 --- a/Sources/ObjectivelyMVC/Renderer.c +++ b/Sources/ObjectivelyMVC/Renderer.c @@ -606,18 +606,24 @@ static void endFrame(Renderer *self) { $(self->commands, pushVertexUniformData, 0, projection.f, sizeof(projection)); $(renderPass, bindPipeline, self->pipeline); - $(renderPass, bindVertexBuffers, 0, &(SDL_GPUBufferBinding) { .buffer = self->vertexBuffer->buffer }, 1); - for (size_t i = 0; i < self->drawArrays->count; i++) { - const MVC_DrawArrays *draw = VectorElement(self->drawArrays, MVC_DrawArrays, i); + // the vertex buffer is created only once something has been drawn into the frame, so a + // frame that drew nothing has none to bind + if (vertexCount) { - $(renderPass, setScissor, &draw->scissor); + $(renderPass, bindVertexBuffers, 0, &(SDL_GPUBufferBinding) { .buffer = self->vertexBuffer->buffer }, 1); - $(renderPass, bindFragmentSamplers, 0, &(SDL_GPUTextureSamplerBinding) { - .texture = draw->texture->texture, .sampler = self->sampler->sampler, - }, 1); + for (size_t i = 0; i < self->drawArrays->count; i++) { + const MVC_DrawArrays *draw = VectorElement(self->drawArrays, MVC_DrawArrays, i); - $(renderPass, drawPrimitives, draw->vertexCount, 1, draw->firstVertex, 0); + $(renderPass, setScissor, &draw->scissor); + + $(renderPass, bindFragmentSamplers, 0, &(SDL_GPUTextureSamplerBinding) { + .texture = draw->texture->texture, .sampler = self->sampler->sampler, + }, 1); + + $(renderPass, drawPrimitives, draw->vertexCount, 1, draw->firstVertex, 0); + } } release(renderPass);