Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 128 additions & 9 deletions Sources/ObjectivelyMVC/Text.c
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,35 @@ 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;
}
}

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);
Expand Down Expand Up @@ -285,6 +303,62 @@ 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 (colorEscapeLength(c)) {
c++;
continue;
}

if (*c == ':') {
const size_t length = MVC_IconEscapeLength(c, icons, NULL);
if (length) {
c += length - 1;
continue;
}
}

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((unsigned char) *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,
Expand All @@ -299,10 +373,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.
*/
Expand Down Expand Up @@ -418,6 +500,7 @@ static void dealloc(Object *self) {
release(this->font);

free(this->text);
free(this->transformed);

super(Object, self, dealloc);
}
Expand All @@ -434,7 +517,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);

Expand All @@ -461,6 +544,16 @@ static void applyStyle(View *self, const Style *style) {
invalidate(this);
}

int transform = -1;

const Inlet transformInlets[] = MakeInlets(
MakeInlet("text-transform", InletTypeEnum, &transform, (ident) TextTransformNames)
);

$(self, bind, transformInlets, style->attributes);

$(this, setTransform, transform < 0 ? TextTransformNone : (TextTransform) transform);

char *fontFamily = NULL;
int fontSize = -1, fontStyle = -1;

Expand Down Expand Up @@ -490,15 +583,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("text", InletTypeCharacters, &this->text, NULL),
MakeInlet("textTransform", InletTypeEnum, &transform, (ident) TextTransformNames)
);

$(self, bind, inlets, dictionary);

this->naturalSizeCache.isValid = false;
this->transform = (TextTransform) transform;

applyTransform(this);
invalidate(this);

$(self, sizeToFit);
}
Expand Down Expand Up @@ -555,24 +654,26 @@ 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;
}

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
Expand All @@ -597,7 +698,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);
Expand Down Expand Up @@ -730,7 +831,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;
Expand Down Expand Up @@ -801,6 +902,7 @@ static void setText(Text *self, const char *text) {
self->text = NULL;
}

applyTransform(self);
invalidate(self);

$((View *) self, sizeToFit);
Expand Down Expand Up @@ -829,6 +931,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

/**
Expand All @@ -853,6 +971,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;
}

Expand Down
38 changes: 38 additions & 0 deletions Sources/ObjectivelyMVC/Text.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 and color escapes pass through unchanged.
* Resolved `:icon:` escapes also 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
Expand Down Expand Up @@ -193,6 +207,21 @@ struct Text {
*/
Texture *texture;

/**
* @brief The case transform applied when drawing: the `text-transform` style attribute, or
* `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)
*/
TextTransform transform;

/**
* @brief `text` with `transform` applied, or `NULL` when `transform` is `TextTransformNone`.
* @private
*/
char *transformed;

};

/**
Expand Down Expand Up @@ -261,6 +290,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);
42 changes: 42 additions & 0 deletions Tests/ObjectivelyMVC/Text.c
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,47 @@ 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);

// 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);

$(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");
Expand All @@ -212,6 +253,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);
Expand Down
Loading