diff --git a/src/assets/curl-terminal.png b/src/assets/curl-terminal.png new file mode 100644 index 0000000..80b3076 Binary files /dev/null and b/src/assets/curl-terminal.png differ diff --git a/src/content/blog/terminal-ui-cloudflare-rust.mdx b/src/content/blog/terminal-ui-cloudflare-rust.mdx index 528c35a..43765fd 100644 --- a/src/content/blog/terminal-ui-cloudflare-rust.mdx +++ b/src/content/blog/terminal-ui-cloudflare-rust.mdx @@ -2,7 +2,7 @@ title: "Building a Terminal UI for Your Website with Rust and Cloudflare Workers" description: "How we built a curl-able ANSI terminal interface for nicholai.work using Rust compiled to WebAssembly, running on Cloudflare's edge network. Browsers get the normal site, terminals get ASCII art." pubDate: 2026-01-20 -heroImage: "../../assets/workbench.avif" +heroImage: "../../assets/curl-terminal.png" featured: true category: "Development" tags: ["Rust", "Cloudflare Workers", "WebAssembly", "Terminal UI", "Edge Computing"] @@ -117,6 +117,12 @@ combine these with color codes and you get clean, bordered sections: └──────────────────────────────────────────────┘ ``` +### a note on character widths + +we learned this the hard way: **full-block characters (`█`) and double-line box drawing (`╔`, `║`, `═`) have inconsistent display widths across terminals**. some terminals render them as double-width, others as single-width. this causes alignment issues. + +the solution: use single-line box drawing characters (`┌`, `─`, `│`) for borders - they're reliably single-width everywhere. if you want ASCII art logos, half-block characters (`▄`, `▀`) are also safe. we ended up ditching the fancy ASCII art logo entirely and going with styled text, which works perfectly across all terminals. + ## the worker entry point the cloudflare `worker` crate provides macros for handling requests: diff --git a/worker/src/terminal/layout.rs b/worker/src/terminal/layout.rs index 0dd1275..7a47fe0 100644 --- a/worker/src/terminal/layout.rs +++ b/worker/src/terminal/layout.rs @@ -21,20 +21,25 @@ pub fn horizontal_line(width: usize) -> String { /// Render a box top border with optional title pub fn box_top(width: usize, title: Option<&str>) -> String { - let inner_width = width - 2; + let inner_width = width - 2; // subtract corners match title { Some(t) => { - let title_colored = color(&format!(" {} ", t), Colors::RED); - let title_len = t.len() + 2; // account for spaces - let line_len = inner_width.saturating_sub(title_len); - dim(&format!( - "{}{}{}{}", - Box::TOP_LEFT, - Box::HORIZONTAL, - title_colored, - format!("{}{}", dim(&horizontal_line(line_len)), dim(&Box::TOP_RIGHT.to_string())) - )) + // Title format: ┌─ Title ───────┐ + // We need: corner + dash + space + title + space + dashes + corner + let title_display = format!(" {} ", t); + let title_visible_len = title_display.len(); // all ASCII, so len() works + // Subtract: 1 for the dash before title, title_visible_len for title + let remaining_dashes = inner_width.saturating_sub(1 + title_visible_len); + + format!( + "{}{}{}{}{}", + dim(&Box::TOP_LEFT.to_string()), + dim(&Box::HORIZONTAL.to_string()), + color(&title_display, Colors::RED), + dim(&horizontal_line(remaining_dashes)), + dim(&Box::TOP_RIGHT.to_string()) + ) } None => dim(&format!( "{}{}{}", @@ -74,7 +79,8 @@ pub fn box_empty(width: usize) -> String { box_row("", width) } -/// Strip ANSI codes and return visible length +/// Strip ANSI codes and return visible display width +/// Accounts for double-width unicode characters (block chars, CJK, etc) fn strip_ansi_len(s: &str) -> usize { let mut len = 0; let mut in_escape = false; @@ -87,13 +93,40 @@ fn strip_ansi_len(s: &str) -> usize { in_escape = false; } } else { - len += 1; + len += char_width(c); } } len } +/// Get the display width of a character +/// Half-blocks are single-width, full-blocks vary by terminal +pub fn char_width(c: char) -> usize { + match c { + // Half-block characters - reliably single-width + '▀' | '▄' | '▌' | '▐' => 1, + // Single-line box drawing (what we use for borders) - single width + '┌' | '┐' | '└' | '┘' | '│' | '─' | '├' | '┤' | '┬' | '┴' | '┼' => 1, + // Most other characters are single width + _ => 1, + } +} + +/// Get display width of a string (accounting for ANSI codes and double-width chars) +pub fn display_width(s: &str) -> usize { + strip_ansi_len(s) +} + +/// Pad a string with spaces on the right to reach target display width +pub fn pad_to_width(text: &str, target_width: usize) -> String { + let current_width = display_width(text); + if current_width >= target_width { + return text.to_string(); + } + format!("{}{}", text, " ".repeat(target_width - current_width)) +} + /// Center text within a given width pub fn center(text: &str, width: usize) -> String { let visible_len = strip_ansi_len(text); diff --git a/worker/src/terminal/renderer.rs b/worker/src/terminal/renderer.rs index 3a36a7e..c471971 100644 --- a/worker/src/terminal/renderer.rs +++ b/worker/src/terminal/renderer.rs @@ -2,15 +2,7 @@ use super::colors::{bold_color, color, dim, Colors}; use super::content::{Experience, NavItem, SiteContent, Skill, EXPERIENCES, NAV_ITEMS, SKILLS}; use super::layout::{box_bottom, box_empty, box_row, box_top, center}; -const WIDTH: usize = 67; - -/// ASCII art logo for "VISUAL ALCHEMIST" -const LOGO: &str = r#"██╗ ██╗██╗███████╗██╗ ██╗ █████╗ ██╗ -██║ ██║██║██╔════╝██║ ██║██╔══██╗██║ -██║ ██║██║███████╗██║ ██║███████║██║ -╚██╗ ██╔╝██║╚════██║██║ ██║██╔══██║██║ - ╚████╔╝ ██║███████║╚██████╔╝██║ ██║███████╗ - ╚═══╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝"#; +const WIDTH: usize = 78; /// Render the complete terminal UI pub fn render() -> String { @@ -36,14 +28,11 @@ fn render_header() -> String { lines.push(box_top(WIDTH, None)); lines.push(box_empty(WIDTH)); - // Logo - for line in LOGO.lines() { - let centered = center(&color(line, Colors::RED), WIDTH - 4); - lines.push(box_row(¢ered, WIDTH)); - } - - // Subtitle - lines.push(box_row(¢er(&bold_color("ALCHEMIST", Colors::RED), WIDTH - 4), WIDTH)); + // Title + lines.push(box_row( + ¢er(&bold_color("VISUAL ALCHEMIST", Colors::RED), WIDTH - 4), + WIDTH, + )); lines.push(box_empty(WIDTH)); // Name and title