fix(terminal): simplify layout and fix box alignment

- remove ASCII art logo, use styled text header instead
- fix box_top width calculation for titled boxes
- add note about character width gotchas to blog post
- add new hero image for terminal UI blog post
This commit is contained in:
Nicholai Vogel 2026-01-20 06:58:20 -07:00
parent 3a5e5b05bf
commit 81fe8f0d2b
4 changed files with 59 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

View File

@ -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:

View File

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

View File

@ -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(&centered, WIDTH));
}
// Subtitle
lines.push(box_row(&center(&bold_color("ALCHEMIST", Colors::RED), WIDTH - 4), WIDTH));
// Title
lines.push(box_row(
&center(&bold_color("VISUAL ALCHEMIST", Colors::RED), WIDTH - 4),
WIDTH,
));
lines.push(box_empty(WIDTH));
// Name and title