commit b89c1315f79c1617cb8966de023a11167d3371c8
parent 9b28e0a9c18e8d9817376f48a8772269e75b2c98
Author: Chris Bracken <chris@bracken.jp>
Date: Sat, 21 Feb 2026 08:25:35 +0900
gemini: add print_utf8_padded
Gemini doesn't have table or alignment support, so instead we hack our
own formatting code together in the hopes that anyone who cares enough
to browse source in gemini is doing it in a browser using a monospace
font.
Diffstat:
5 files changed, 179 insertions(+), 14 deletions(-)
diff --git a/src/format.c b/src/format.c
@@ -177,10 +177,11 @@ void print_gopher_link(FILE* out, const char* str) {
}
}
-void print_gopher_link_padded(FILE* out,
- const char* str,
- size_t width,
- char pad_char) {
+static void print_padded_internal(FILE* out,
+ const char* str,
+ size_t width,
+ char pad_char,
+ bool gopher_markup) {
assert(out != NULL);
assert(str != NULL);
@@ -223,8 +224,29 @@ void print_gopher_link_padded(FILE* out,
int w = wcwidth(wc);
size_t char_width = (w < 0) ? 0 : w;
+ // Gopher-specific adjustments for character width.
+ if (gopher_markup) {
+ if (wc == L'|') {
+ char_width = 1;
+ } else if (wc == L'\t') {
+ // Tab expansion width is handled in the printing logic below.
+ char_width = 0;
+ const char* tptr = ptr + bytes;
+ for (size_t i = 0; i < 8 && display_width + char_width < width; i++) {
+ char_width++;
+ // If this isn't the end of the string, we might need an ellipsis.
+ if (display_width + char_width == width && tptr < end) {
+ break;
+ }
+ }
+ } else if (wc == L'\r' || wc == L'\n') {
+ char_width = 0;
+ }
+ }
+
if (display_width + char_width > width ||
- (ptr + bytes < end && display_width + char_width == width)) {
+ (ptr + bytes < end && display_width + char_width == width &&
+ char_width > 0)) {
if (display_width < width) {
fprintf(out, "%s", kUtf8Ellipsis);
display_width++;
@@ -232,22 +254,20 @@ void print_gopher_link_padded(FILE* out,
break;
}
- if (wc == L'|') {
+ if (gopher_markup && wc == L'|') {
fprintf(out, "\\|");
display_width++;
last_char_width = 1;
- } else if (wc == L'\t') {
+ } else if (gopher_markup && wc == L'\t') {
for (size_t i = 0; i < 8 && display_width < width; i++) {
fprintf(out, " ");
display_width++;
}
last_char_width = 1;
- } else if (wc == L'\r' || wc == L'\n') {
- // Ignore.
+ } else if (gopher_markup && (wc == L'\r' || wc == L'\n')) {
last_char_width = 0;
} else {
// Hack: handle zero-width joiner and variation selector.
- // wcwidth lacks awareness of complex emoji modifier sequences.
if (is_unicode_modifier(wc)) {
display_width -= last_char_width;
char_width = 0;
@@ -269,3 +289,17 @@ void print_gopher_link_padded(FILE* out,
}
}
}
+
+void print_gopher_link_padded(FILE* out,
+ const char* str,
+ size_t width,
+ char pad_char) {
+ print_padded_internal(out, str, width, pad_char, true);
+}
+
+void print_utf8_padded(FILE* out,
+ const char* str,
+ size_t width,
+ char pad_char) {
+ print_padded_internal(out, str, width, pad_char, false);
+}
diff --git a/src/format.h b/src/format.h
@@ -44,4 +44,9 @@ void print_gopher_link_padded(FILE* out,
size_t width,
char pad_char);
+void print_utf8_padded(FILE* out,
+ const char* str,
+ size_t width,
+ char pad_char);
+
#endif // GOUT_FORMAT_H_
diff --git a/src/format_tests.c b/src/format_tests.c
@@ -596,3 +596,126 @@ UTEST(print_gopher_link_padded, MultiColumnTruncation) {
free(buf);
}
}
+
+UTEST(print_utf8_padded, Basic) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ print_utf8_padded(out, "hello", 10, ' ');
+ fclose(out);
+
+ EXPECT_STREQ("hello ", buf);
+
+ free(buf);
+}
+
+UTEST(print_utf8_padded, MultiByte) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ /* "こんにちは" is 10 columns. */
+ print_utf8_padded(out, "こんにちは", 15, ' ');
+ fclose(out);
+
+ EXPECT_STREQ("こんにちは ", buf);
+
+ free(buf);
+}
+
+UTEST(print_utf8_padded, Truncation) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ /* "hello world" is 11 columns. Width 10. */
+ print_utf8_padded(out, "hello world", 10, ' ');
+ fclose(out);
+
+ EXPECT_STREQ("hello wor…", buf);
+
+ free(buf);
+}
+
+UTEST(print_utf8_padded, MultiColumnTruncation) {
+ {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ /* "あいう" (6 columns). Width 4. Should be "あ…" + 1 space. */
+ print_utf8_padded(out, "あいう", 4, ' ');
+ fclose(out);
+ EXPECT_STREQ("あ… ", buf);
+ free(buf);
+ }
+
+ {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ /* "あいう" (6 columns). Width 5. Should be "あい…" (5 columns). */
+ print_utf8_padded(out, "あいう", 5, ' ');
+ fclose(out);
+ EXPECT_STREQ("あい…", buf);
+ free(buf);
+ }
+}
+
+UTEST(print_utf8_padded, ComplexEmoji) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ /* UTF-8 encoding of Unicode extended grapheme cluster for woman health worker
+ with medium-dark skin tone. This emoji is a Unicode emoji ZWJ sequence
+ composed of 5 code points. */
+ const char* emoji = // 👩🏾⚕️
+ "\xF0\x9F\x91\xA9" // U+1F469: Woman 👩
+ "\xF0\x9F\x8F\xBE" // U+1F3FE: Emoji modifier Fitzpatrick type 5 🏾
+ "\xE2\x80\x8D" // U+200D: Zero-width joiner
+ "\xE2\x9A\x95" // U+2695: Staff of Aesculapius ⚕
+ "\xEF\xB8\x8F"; // U+FE0F: Variation selector 16 (colour emoji)
+ print_utf8_padded(out, emoji, 10, ' ');
+ fclose(out);
+
+ EXPECT_STREQ("👩🏾⚕️ ", buf);
+
+ free(buf);
+}
+
+UTEST(print_utf8_padded, NoPadChar) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ print_utf8_padded(out, "hello", 10, '\0');
+ fclose(out);
+
+ EXPECT_STREQ("hello", buf);
+
+ free(buf);
+}
+
+UTEST(print_utf8_padded, ZeroWidth) {
+ char* buf = NULL;
+ size_t size = 0;
+ FILE* out = open_memstream(&buf, &size);
+ ASSERT_NE(NULL, out);
+
+ print_utf8_padded(out, "hello", 0, ' ');
+ fclose(out);
+
+ EXPECT_STREQ("", buf);
+
+ free(buf);
+}
diff --git a/src/writer/gemini/commit.c b/src/writer/gemini/commit.c
@@ -174,7 +174,7 @@ static void gemini_commit_write_diffstat_row(GeminiCommit* commit,
if (r < 0 || (size_t)r >= sizeof(filename)) {
errx(1, "snprintf: filename truncated or error");
}
- fprintf(out, "%-35.35s", filename);
+ print_utf8_padded(out, filename, 35, ' ');
size_t changed = delta->addcount + delta->delcount;
fprintf(out, " | ");
diff --git a/src/writer/gemini/refs.c b/src/writer/gemini/refs.c
@@ -60,8 +60,10 @@ static void ensure_block_closed(GeminiRefs* refs) {
static void ensure_block_open(GeminiRefs* refs) {
if (!refs->in_block) {
fprintf(refs->out, "```\n");
- fprintf(refs->out, "%-32.32s %-16.16s %s\n", "Name", "Last commit date",
- "Author");
+ print_utf8_padded(refs->out, "Name", 32, ' ');
+ fprintf(refs->out, " ");
+ print_utf8_padded(refs->out, "Last commit date", 16, ' ');
+ fprintf(refs->out, " Author\n");
refs->in_block = true;
}
}
@@ -87,7 +89,8 @@ void gemini_refs_add_ref(GeminiRefs* refs, const GitReference* ref) {
}
GitCommit* commit = ref->commit;
- fprintf(out, "%-32.32s ", ref->shorthand);
+ print_utf8_padded(out, ref->shorthand, 32, ' ');
+ fprintf(out, " ");
print_time_short(out, commit->author_time);
fprintf(out, " %s\n", commit->author_name);
}