From 9763ede5c17325657a288f75a7013582f8a709a4 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Sun, 6 Sep 2026 16:07:09 -0400 Subject: [PATCH 1/4] Add text-transform to Text Text gains a TextTransform (none, uppercase, lowercase, capitalize), set from the `text-transform` style attribute, `textTransform` in JSON, or Text::setTransform. The transform applies to a display copy of the string that render and naturalSize use, so Text::text stays as set. Only ASCII letters change: multi-byte UTF-8, color escapes and resolved `:icon:` escapes pass through, so icon names keep their case. Co-Authored-By: Claude Fable 5.1 --- Sources/ObjectivelyMVC/Text.c | 115 ++++++++++++++++++++++++++++++++-- Sources/ObjectivelyMVC/Text.h | 37 +++++++++++ Tests/ObjectivelyMVC/Text.c | 38 +++++++++++ 3 files changed, 184 insertions(+), 6 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 3c4647d8..cab61315 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -62,6 +62,13 @@ bool MVC_HasColorEscapes(const char *text) { return false; } +const EnumName TextTransformNames[] = MakeEnumNames( + MakeEnumAlias(TextTransformNone, none), + MakeEnumAlias(TextTransformUppercase, uppercase), + MakeEnumAlias(TextTransformLowercase, lowercase), + MakeEnumAlias(TextTransformCapitalize, capitalize) +); + char *MVC_StripColorEscapes(const char *text) { assert(text); @@ -285,6 +292,60 @@ static ImageAtlas *iconsFor(const Text *self) { return theme ? $(theme, icons) : NULL; } +/** + * @brief Rebuilds `transformed` from `text` and `transform`. Color escapes pass through without + * counting as letters, and icon escapes that resolve against the window's Theme are copied + * verbatim, since icon names are case sensitive. + */ +static void applyTransform(Text *self) { + + free(self->transformed); + self->transformed = NULL; + + if (self->transform == TextTransformNone || self->text == NULL) { + return; + } + + self->transformed = strdup(self->text); + assert(self->transformed); + + const ImageAtlas *icons = iconsFor(self); + + bool wordStart = true; + for (char *c = self->transformed; *c; c++) { + + if (*c == '^' && ((c[1] >= '0' && c[1] <= '9') || c[1] == '^')) { + c++; + continue; + } + + if (*c == ':') { + const size_t length = MVC_IconEscapeLength(c, icons, NULL); + if (length) { + c += length - 1; + wordStart = true; + continue; + } + } + + switch (self->transform) { + case TextTransformUppercase: + *c = (char) SDL_toupper(*c); + break; + case TextTransformLowercase: + *c = (char) SDL_tolower(*c); + break; + case TextTransformCapitalize: + *c = (char) (wordStart ? SDL_toupper(*c) : SDL_tolower(*c)); + break; + default: + break; + } + + wordStart = SDL_isspace(*c) != 0; + } +} + /** * @brief Invalidates this Text if the icon atlas it was prepared against has changed: a Theme * swap, attaching to a window, or an icon registered since. Sets `needsLayout` when it does, @@ -299,10 +360,18 @@ static void checkIcons(Text *self) { invalidate(self); self->icons.atlas = icons; self->icons.generation = generation; + applyTransform(self); $((View *) self, setNeedsLayout); } } +/** + * @return The string this Text draws and measures: `transformed` when a transform is set. + */ +static const char *displayText(const Text *self) { + return self->transformed ?: self->text; +} + /** * @return True if `text` contains anything MVC_LayoutText might resolve: a caret or a colon. */ @@ -418,6 +487,7 @@ static void dealloc(Object *self) { release(this->font); free(this->text); + free(this->transformed); super(Object, self, dealloc); } @@ -461,6 +531,16 @@ static void applyStyle(View *self, const Style *style) { invalidate(this); } + TextTransform transform = this->transform; + + const Inlet transformInlets[] = MakeInlets( + MakeInlet("text-transform", InletTypeEnum, &transform, (ident) TextTransformNames) + ); + + if ($(self, bind, transformInlets, style->attributes)) { + $(this, setTransform, transform); + } + char *fontFamily = NULL; int fontSize = -1, fontStyle = -1; @@ -493,11 +573,14 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { const Inlet inlets[] = MakeInlets( MakeInlet("color", InletTypeColor, &this->color, NULL), MakeInlet("lineWrap", InletTypeBool, &this->lineWrap, NULL), - MakeInlet("text", InletTypeCharacters, &this->text, NULL) + MakeInlet("text", InletTypeCharacters, &this->text, NULL), + MakeInlet("textTransform", InletTypeEnum, &this->transform, (ident) TextTransformNames) ); $(self, bind, inlets, dictionary); + applyTransform(this); + this->naturalSizeCache.isValid = false; $(self, sizeToFit); @@ -555,12 +638,14 @@ static void render(View *self, Renderer *renderer) { checkIcons(this); + const char *text = displayText(this); + const SDL_Rect frame = $(self, renderFrame); const int wrapWidth = this->lineWrap ? frame.w : 0; if (this->font->bitmap.surface) { - $(this->font, renderBitmapCharacters, renderer, this->text, this->color, wrapWidth, + $(this->font, renderBitmapCharacters, renderer, text, this->color, wrapWidth, &(const SDL_Point) { frame.x, frame.y }, this->icons.atlas); return; } @@ -568,11 +653,11 @@ static void render(View *self, Renderer *renderer) { if (this->texture == NULL) { SDL_Surface *surface = NULL; - if (hasEscapes(this->text)) { + if (hasEscapes(text)) { TextSpan *spans = NULL; size_t count = 0; - char *layout = MVC_LayoutText(this->font, this->text, this->color, this->icons.atlas, &spans, &count); + char *layout = MVC_LayoutText(this->font, text, this->color, this->icons.atlas, &spans, &count); // A colon or caret that resolved to nothing -- "Health: 100", a URL -- is plain text, // and takes the single-quad path rather than a run per line @@ -597,7 +682,7 @@ static void render(View *self, Renderer *renderer) { return; } } else { - surface = $(this->font, renderCharacters, this->text, this->color, wrapWidth); + surface = $(this->font, renderCharacters, text, this->color, wrapWidth); } assert(surface); @@ -730,7 +815,7 @@ static SDL_Size naturalSize(const Text *self) { return self->naturalSizeCache.size; } - const SDL_Size size = $(self, sizeText, self->text ?: ""); + const SDL_Size size = $(self, sizeText, displayText(self) ?: ""); this->naturalSizeCache.size = size; this->naturalSizeCache.pixelDensity = font->pixelDensity; @@ -801,6 +886,7 @@ static void setText(Text *self, const char *text) { self->text = NULL; } + applyTransform(self); invalidate(self); $((View *) self, sizeToFit); @@ -829,6 +915,22 @@ static void setTextWithFormat(Text *self, const char *fmt, ...) { va_end(args); } +/** + * @fn void Text::setTransform(Text *self, TextTransform transform) + * @memberof Text + */ +static void setTransform(Text *self, TextTransform transform) { + + if (transform != self->transform) { + self->transform = transform; + + applyTransform(self); + invalidate(self); + + $((View *) self, sizeToFit); + } +} + #pragma mark - Class lifecycle /** @@ -853,6 +955,7 @@ static void initialize(Class *clazz) { ((TextInterface *) clazz->interface)->setFont = setFont; ((TextInterface *) clazz->interface)->setText = setText; ((TextInterface *) clazz->interface)->setTextWithFormat = setTextWithFormat; + ((TextInterface *) clazz->interface)->setTransform = setTransform; ((TextInterface *) clazz->interface)->sizeText = sizeText; } diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index e2334e39..88cae893 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -59,6 +59,20 @@ OBJECTIVELYMVC_EXPORT bool MVC_HasColorEscapes(const char *text); */ OBJECTIVELYMVC_EXPORT char *MVC_StripColorEscapes(const char *text); +/** + * @brief Case transforms a Text applies when drawing, leaving Text::text as set. + * @details ASCII letters only; multi-byte UTF-8 sequences, color escapes and `:icon:` escapes + * pass through unchanged, so icon names keep their case. + */ +typedef enum { + TextTransformNone, + TextTransformUppercase, + TextTransformLowercase, + TextTransformCapitalize +} TextTransform; + +OBJECTIVELYMVC_EXPORT const EnumName TextTransformNames[]; + /** * @brief Parses an icon escape at the start of `chars`: `:name:`, where `name` is one or more of * `[A-Za-z0-9_-]` and under 64 bytes, and is registered in `icons`. An unregistered name is not @@ -193,6 +207,20 @@ struct Text { */ Texture *texture; + /** + * @brief The case transform applied when drawing: the `text-transform` style attribute, or + * `textTransform` in JSON. + * @remarks Do not set this property directly. + * @see Text::setTransform(Text *, TextTransform) + */ + TextTransform transform; + + /** + * @brief `text` with `transform` applied, or `NULL` when `transform` is `TextTransformNone`. + * @private + */ + char *transformed; + }; /** @@ -261,6 +289,15 @@ struct TextInterface { * @memberof Text */ void (*setTextWithFormat)(Text *self, const char *fmt, ...); + + /** + * @fn void Text::setTransform(Text *self, TextTransform transform) + * @brief Sets the case transform this Text draws with. Text::text is left as set. + * @param self The Text. + * @param transform The TextTransform. + * @memberof Text + */ + void (*setTransform)(Text *self, TextTransform transform); }; OBJECTIVELYMVC_EXPORT Class *_Text(void); diff --git a/Tests/ObjectivelyMVC/Text.c b/Tests/ObjectivelyMVC/Text.c index 309249b9..4a25b876 100644 --- a/Tests/ObjectivelyMVC/Text.c +++ b/Tests/ObjectivelyMVC/Text.c @@ -203,6 +203,43 @@ START_TEST(bitmapIconsAdvanceWholeCells) { } END_TEST +START_TEST(transformFollowsTextAndEscapes) { + + Font *font = $$(Font, defaultFont); + + Text *text = $(alloc(Text), initWithText, "hello :heart: world", font); + ck_assert_int_eq(TextTransformNone, text->transform); + ck_assert_ptr_null(text->transformed); + + // Detached from a window, no icon resolves, so the escape is ordinary text + $(text, setTransform, TextTransformUppercase); + ck_assert_str_eq("hello :heart: world", text->text); + ck_assert_str_eq("HELLO :HEART: WORLD", text->transformed); + + $(text, setTransform, TextTransformCapitalize); + ck_assert_str_eq("Hello :heart: World", text->transformed); + + $(text, setText, "^1a b^^c"); + ck_assert_str_eq("^1A B^^c", text->transformed); + + $(text, setTransform, TextTransformLowercase); + ck_assert_str_eq("^1a b^^c", text->transformed); + + $(text, setTransform, TextTransformNone); + ck_assert_ptr_null(text->transformed); + + // Sizing follows the transform + Text *lower = $(alloc(Text), initWithText, "iiii", font); + Text *upper = $(alloc(Text), initWithText, "iiii", font); + $(upper, setTransform, TextTransformUppercase); + ck_assert_int_gt($(upper, naturalSize).w, $(lower, naturalSize).w); + + release(upper); + release(lower); + release(text); + +} END_TEST + int main(int argc, char **argv) { TCase *tcase = tcase_create("Text"); @@ -212,6 +249,7 @@ int main(int argc, char **argv) { tcase_add_test(tcase, hasColorEscapes); tcase_add_test(tcase, stripColorEscapes); tcase_add_test(tcase, escapesDoNotAffectProportionalSize); + tcase_add_test(tcase, transformFollowsTextAndEscapes); Suite *suite = suite_create("Text"); suite_add_tcase(suite, tcase); From 3338e211d2b2a763791a570291406ccbbceda660 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Sun, 6 Sep 2026 16:24:14 -0400 Subject: [PATCH 2/4] Address review of text-transform The transform binds through int locals like the other enum inlets, a style without text-transform resets to none rather than sticking, awakeWithDictionary invalidates after rebinding text, description shows the display string, and an icon escape no longer starts a word under capitalize. Co-Authored-By: Claude Fable 5.1 --- Sources/ObjectivelyMVC/Text.c | 35 ++++++++++++++++++++++++----------- Sources/ObjectivelyMVC/Text.h | 3 ++- Tests/ObjectivelyMVC/Text.c | 4 ++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index cab61315..59f785f4 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -51,10 +51,21 @@ SDL_Color TextEscapeColors[] = { { 0x80, 0x80, 0x80, 0xFF } // ^9 Grey }; +/** + * @brief Length of the color escape at `chars`, or 0 if there is none: `^N` selects a color, + * `^^` is a literal caret. + */ +static size_t colorEscapeLength(const char *chars) { + if (chars[0] == '^' && ((chars[1] >= '0' && chars[1] <= '9') || chars[1] == '^')) { + return 2; + } + return 0; +} + bool MVC_HasColorEscapes(const char *text) { for (const char *p = text ? strchr(text, '^') : NULL; p; p = strchr(p + 1, '^')) { - if ((p[1] >= '0' && p[1] <= '9') || p[1] == '^') { + if (colorEscapeLength(p)) { return true; } } @@ -314,7 +325,7 @@ static void applyTransform(Text *self) { bool wordStart = true; for (char *c = self->transformed; *c; c++) { - if (*c == '^' && ((c[1] >= '0' && c[1] <= '9') || c[1] == '^')) { + if (colorEscapeLength(c)) { c++; continue; } @@ -323,7 +334,6 @@ static void applyTransform(Text *self) { const size_t length = MVC_IconEscapeLength(c, icons, NULL); if (length) { c += length - 1; - wordStart = true; continue; } } @@ -504,7 +514,7 @@ static String *description(const Object *self) { String *description = str("%s@%p \"%s\" %s [%d, %d, %d, %d]", this->identifier ?: classnameof(self), self, - ((Text *) self)->text, + displayText((Text *) self) ?: "", classNames->chars, bounds.x, bounds.y, bounds.w, bounds.h); @@ -531,15 +541,15 @@ static void applyStyle(View *self, const Style *style) { invalidate(this); } - TextTransform transform = this->transform; + int transform = -1; const Inlet transformInlets[] = MakeInlets( MakeInlet("text-transform", InletTypeEnum, &transform, (ident) TextTransformNames) ); - if ($(self, bind, transformInlets, style->attributes)) { - $(this, setTransform, transform); - } + $(self, bind, transformInlets, style->attributes); + + $(this, setTransform, transform < 0 ? TextTransformNone : (TextTransform) transform); char *fontFamily = NULL; int fontSize = -1, fontStyle = -1; @@ -570,18 +580,21 @@ static void awakeWithDictionary(View *self, const Dictionary *dictionary) { Text *this = (Text *) self; + int transform = this->transform; + const Inlet inlets[] = MakeInlets( MakeInlet("color", InletTypeColor, &this->color, NULL), MakeInlet("lineWrap", InletTypeBool, &this->lineWrap, NULL), MakeInlet("text", InletTypeCharacters, &this->text, NULL), - MakeInlet("textTransform", InletTypeEnum, &this->transform, (ident) TextTransformNames) + MakeInlet("textTransform", InletTypeEnum, &transform, (ident) TextTransformNames) ); $(self, bind, inlets, dictionary); - applyTransform(this); + this->transform = (TextTransform) transform; - this->naturalSizeCache.isValid = false; + applyTransform(this); + invalidate(this); $(self, sizeToFit); } diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 88cae893..221b4654 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -209,7 +209,8 @@ struct Text { /** * @brief The case transform applied when drawing: the `text-transform` style attribute, or - * `textTransform` in JSON. + * `textTransform` in JSON. A computed style without `text-transform` resets it to none, as + * with `color`. * @remarks Do not set this property directly. * @see Text::setTransform(Text *, TextTransform) */ diff --git a/Tests/ObjectivelyMVC/Text.c b/Tests/ObjectivelyMVC/Text.c index 4a25b876..6401b661 100644 --- a/Tests/ObjectivelyMVC/Text.c +++ b/Tests/ObjectivelyMVC/Text.c @@ -219,6 +219,10 @@ START_TEST(transformFollowsTextAndEscapes) { $(text, setTransform, TextTransformCapitalize); ck_assert_str_eq("Hello :heart: World", text->transformed); + // An icon is a glyph, not a word break, so it does not capitalize what follows + $(text, setText, "a:heart:b"); + ck_assert_str_eq("A:heart:b", text->transformed); + $(text, setText, "^1a b^^c"); ck_assert_str_eq("^1A B^^c", text->transformed); From 2ebfdabe4cd15bb08faa5062fc236b7ed18df29a Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Sun, 6 Sep 2026 16:38:11 -0400 Subject: [PATCH 3/4] Update documentation for Text case transformation Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ObjectivelyMVC/Text.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.h b/Sources/ObjectivelyMVC/Text.h index 221b4654..280db155 100644 --- a/Sources/ObjectivelyMVC/Text.h +++ b/Sources/ObjectivelyMVC/Text.h @@ -61,8 +61,8 @@ OBJECTIVELYMVC_EXPORT char *MVC_StripColorEscapes(const char *text); /** * @brief Case transforms a Text applies when drawing, leaving Text::text as set. - * @details ASCII letters only; multi-byte UTF-8 sequences, color escapes and `:icon:` escapes - * pass through unchanged, so icon names keep their case. + * @details ASCII letters only; multi-byte UTF-8 sequences and color escapes pass through unchanged. + * Resolved `:icon:` escapes also pass through unchanged, so icon names keep their case. */ typedef enum { TextTransformNone, From c87acebe281ba8d7b97cd86b891ee60c9ec08a91 Mon Sep 17 00:00:00 2001 From: Jay Dolan Date: Sun, 6 Sep 2026 16:39:19 -0400 Subject: [PATCH 4/4] Refactor text transformation logic for characters Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/ObjectivelyMVC/Text.c | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/Sources/ObjectivelyMVC/Text.c b/Sources/ObjectivelyMVC/Text.c index 59f785f4..32ce9c45 100644 --- a/Sources/ObjectivelyMVC/Text.c +++ b/Sources/ObjectivelyMVC/Text.c @@ -338,22 +338,25 @@ static void applyTransform(Text *self) { } } - switch (self->transform) { - case TextTransformUppercase: - *c = (char) SDL_toupper(*c); - break; - case TextTransformLowercase: - *c = (char) SDL_tolower(*c); - break; - case TextTransformCapitalize: - *c = (char) (wordStart ? SDL_toupper(*c) : SDL_tolower(*c)); - break; - default: - break; + const unsigned char uc = (unsigned char) *c; + + if ((uc & 0x80u) == 0) { + switch (self->transform) { + case TextTransformUppercase: + *c = (char) SDL_toupper(uc); + break; + case TextTransformLowercase: + *c = (char) SDL_tolower(uc); + break; + case TextTransformCapitalize: + *c = (char) (wordStart ? SDL_toupper(uc) : SDL_tolower(uc)); + break; + default: + break; + } } - wordStart = SDL_isspace(*c) != 0; - } + wordStart = SDL_isspace((unsigned char) *c) != 0; } /**