From 40c8f53cc34937b18beabfc7dbeabb8f28a2080f Mon Sep 17 00:00:00 2001 From: Michael Tao Date: Fri, 10 Apr 2026 17:43:18 -0400 Subject: [PATCH] feat: add terminal image output utilities (Kitty + half-block truecolor) Add balsa::terminal module with three emission methods: - emit_kitty(): Kitty graphics protocol (Kitty/Ghostty/WezTerm) - emit_halfblock(): Unicode half-block chars with 24-bit ANSI truecolor - emit_auto(): auto-detect and pick the best method Includes base64 encoder for Kitty protocol payload and TERM_PROGRAM-based capability detection. --- core/include/balsa/terminal/image_output.hpp | 51 ++++++ core/meson.build | 6 +- core/src/terminal/image_output.cpp | 166 +++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 core/include/balsa/terminal/image_output.hpp create mode 100644 core/src/terminal/image_output.cpp diff --git a/core/include/balsa/terminal/image_output.hpp b/core/include/balsa/terminal/image_output.hpp new file mode 100644 index 0000000..e6894d8 --- /dev/null +++ b/core/include/balsa/terminal/image_output.hpp @@ -0,0 +1,51 @@ +#pragma once + +/// @file image_output.hpp +/// @brief Terminal image output via Kitty graphics protocol or half-block +/// truecolor characters. + +#include +#include +#include +#include +#include + +namespace balsa::terminal { + +/// Detect whether the current terminal likely supports the Kitty graphics +/// protocol (Kitty, Ghostty, WezTerm). +auto detect_kitty_support() -> bool; + +/// Emit an RGBA8 image via the Kitty graphics protocol. +/// @param width Image width in pixels. +/// @param height Image height in pixels. +/// @param rgba8 Pixel data: width*height*4 bytes, row-major RGBA8. +/// @param fp Output file (default: stdout). +void emit_kitty(size_t width, + size_t height, + std::span rgba8, + FILE *fp = stdout); + +/// Emit an RGBA8 image using Unicode half-block characters (U+2580 "▀") +/// with 24-bit ANSI truecolor escape sequences. +/// @param width Image width in pixels. +/// @param height Image height in pixels. +/// @param rgba8 Pixel data: width*height*4 bytes, row-major RGBA8. +/// @param fp Output file (default: stdout). +void emit_halfblock(size_t width, + size_t height, + std::span rgba8, + FILE *fp = stdout); + +/// Auto-detect terminal capabilities and emit the image using the best +/// available method (Kitty if supported, otherwise half-block). +/// @param width Image width in pixels. +/// @param height Image height in pixels. +/// @param rgba8 Pixel data: width*height*4 bytes, row-major RGBA8. +/// @param fp Output file (default: stdout). +void emit_auto(size_t width, + size_t height, + std::span rgba8, + FILE *fp = stdout); + +} // namespace balsa::terminal diff --git a/core/meson.build b/core/meson.build index e29614b..6226ca3 100644 --- a/core/meson.build +++ b/core/meson.build @@ -12,11 +12,15 @@ filesystem_headers = [ 'include/balsa/filesystem/prepend_to_filename.hpp', ] +terminal_sources = [ + 'src/terminal/image_output.cpp', +] + core_sources = [ 'src/logging/stopwatch.cpp', 'src/logging/json_sink.cpp', 'src/types/get_type_name.cpp', - ] + filesystem_sources + ] + filesystem_sources + terminal_sources core_headers = [ 'include/eigen/types.hpp' diff --git a/core/src/terminal/image_output.cpp b/core/src/terminal/image_output.cpp new file mode 100644 index 0000000..a2ac29d --- /dev/null +++ b/core/src/terminal/image_output.cpp @@ -0,0 +1,166 @@ +#include "balsa/terminal/image_output.hpp" + +#include +#include +#include +#include +#include + +namespace balsa::terminal { + +// ── Base64 encoder (RFC 4648) ────────────────────────────────────────────── + +namespace detail { + + auto base64_encode(std::span data) -> std::string { + static constexpr char table[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::string out; + out.reserve(((data.size() + 2) / 3) * 4); + + size_t i = 0; + for (; i + 2 < data.size(); i += 3) { + uint32_t triple = (uint32_t(data[i]) << 16) + | (uint32_t(data[i + 1]) << 8) + | uint32_t(data[i + 2]); + out.push_back(table[(triple >> 18) & 0x3F]); + out.push_back(table[(triple >> 12) & 0x3F]); + out.push_back(table[(triple >> 6) & 0x3F]); + out.push_back(table[triple & 0x3F]); + } + if (i + 1 == data.size()) { + uint32_t val = uint32_t(data[i]) << 16; + out.push_back(table[(val >> 18) & 0x3F]); + out.push_back(table[(val >> 12) & 0x3F]); + out.push_back('='); + out.push_back('='); + } else if (i + 2 == data.size()) { + uint32_t val = + (uint32_t(data[i]) << 16) | (uint32_t(data[i + 1]) << 8); + out.push_back(table[(val >> 18) & 0x3F]); + out.push_back(table[(val >> 12) & 0x3F]); + out.push_back(table[(val >> 6) & 0x3F]); + out.push_back('='); + } + return out; + } + +} // namespace detail + +// ── Detection ────────────────────────────────────────────────────────────── + +auto detect_kitty_support() -> bool { + // Kitty graphics protocol is supported by Kitty, Ghostty, and WezTerm. + const char *term_program = std::getenv("TERM_PROGRAM"); + if (term_program) { + std::string tp(term_program); + if (tp == "ghostty" || tp == "WezTerm") { return true; } + } + // Also check TERM for kitty (kitty sets TERM=xterm-kitty) + const char *term = std::getenv("TERM"); + if (term) { + std::string t(term); + if (t.find("kitty") != std::string::npos) { return true; } + } + return false; +} + +// ── Kitty graphics protocol ──────────────────────────────────────────────── + +void emit_kitty(size_t width, + size_t height, + std::span rgba8, + FILE *fp) { + // Encode the raw RGBA pixel data as base64. + auto b64 = detail::base64_encode(rgba8); + + // Kitty protocol: chunk the payload into pieces of up to 4096 bytes. + // First chunk carries the image metadata; subsequent chunks carry + // only the continuation flag (m=1) or final flag (m=0). + constexpr size_t chunk_size = 4096; + size_t offset = 0; + bool first = true; + + while (offset < b64.size()) { + size_t remaining = b64.size() - offset; + size_t n = std::min(remaining, chunk_size); + bool more = (offset + n < b64.size()); + + if (first) { + // f=32 : 32-bit RGBA pixels + // s=W : width in pixels + // v=H : height in pixels + // a=T : action = transmit and display + // t=d : transmission = direct (inline data) + // m=1/0: more chunks / last chunk + std::fprintf(fp, + "\033_Gf=32,s=%zu,v=%zu,a=T,t=d,m=%d;", + width, + height, + more ? 1 : 0); + first = false; + } else { + std::fprintf(fp, "\033_Gm=%d;", more ? 1 : 0); + } + std::fwrite(b64.data() + offset, 1, n, fp); + std::fprintf(fp, "\033\\"); + offset += n; + } + + // Newline after the image so subsequent text appears below. + std::fprintf(fp, "\n"); + std::fflush(fp); +} + +// ── Half-block truecolor ─────────────────────────────────────────────────── + +void emit_halfblock(size_t width, + size_t height, + std::span rgba8, + FILE *fp) { + // Process rows in pairs. For each pair, the top pixel is the foreground + // color and the bottom pixel is the background color of a "▀" (U+2580). + // If height is odd, the last row gets a black bottom pixel. + + auto px = [&](size_t x, size_t y) -> std::array { + if (y >= height) { return {0, 0, 0}; } + size_t idx = (y * width + x) * 4; + return {rgba8[idx], rgba8[idx + 1], rgba8[idx + 2]}; + }; + + for (size_t y = 0; y < height; y += 2) { + for (size_t x = 0; x < width; ++x) { + auto [tr, tg, tb] = px(x, y); + auto [br, bg, bb] = px(x, y + 1); + // ESC[38;2;r;g;bm = set foreground (top pixel) + // ESC[48;2;r;g;bm = set background (bottom pixel) + std::fprintf(fp, + "\033[38;2;%u;%u;%u;48;2;%u;%u;%um\xe2\x96\x80", + tr, + tg, + tb, + br, + bg, + bb); + } + // Reset attributes and newline. + std::fprintf(fp, "\033[0m\n"); + } + std::fflush(fp); +} + +// ── Auto-detect ──────────────────────────────────────────────────────────── + +void emit_auto(size_t width, + size_t height, + std::span rgba8, + FILE *fp) { + if (detect_kitty_support()) { + emit_kitty(width, height, rgba8, fp); + } else { + emit_halfblock(width, height, rgba8, fp); + } +} + +} // namespace balsa::terminal