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:
parent
bb1319ec6e
commit
7b8fd66198
BIN
src/assets/curl-terminal.png
Normal file
BIN
src/assets/curl-terminal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.2 MiB |
@ -2,7 +2,7 @@
|
|||||||
title: "Building a Terminal UI for Your Website with Rust and Cloudflare Workers"
|
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."
|
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
|
pubDate: 2026-01-20
|
||||||
heroImage: "../../assets/workbench.avif"
|
heroImage: "../../assets/curl-terminal.png"
|
||||||
featured: true
|
featured: true
|
||||||
category: "Development"
|
category: "Development"
|
||||||
tags: ["Rust", "Cloudflare Workers", "WebAssembly", "Terminal UI", "Edge Computing"]
|
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 worker entry point
|
||||||
|
|
||||||
the cloudflare `worker` crate provides macros for handling requests:
|
the cloudflare `worker` crate provides macros for handling requests:
|
||||||
|
|||||||
@ -21,20 +21,25 @@ pub fn horizontal_line(width: usize) -> String {
|
|||||||
|
|
||||||
/// Render a box top border with optional title
|
/// Render a box top border with optional title
|
||||||
pub fn box_top(width: usize, title: Option<&str>) -> String {
|
pub fn box_top(width: usize, title: Option<&str>) -> String {
|
||||||
let inner_width = width - 2;
|
let inner_width = width - 2; // subtract corners
|
||||||
|
|
||||||
match title {
|
match title {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
let title_colored = color(&format!(" {} ", t), Colors::RED);
|
// Title format: ┌─ Title ───────┐
|
||||||
let title_len = t.len() + 2; // account for spaces
|
// We need: corner + dash + space + title + space + dashes + corner
|
||||||
let line_len = inner_width.saturating_sub(title_len);
|
let title_display = format!(" {} ", t);
|
||||||
dim(&format!(
|
let title_visible_len = title_display.len(); // all ASCII, so len() works
|
||||||
"{}{}{}{}",
|
// Subtract: 1 for the dash before title, title_visible_len for title
|
||||||
Box::TOP_LEFT,
|
let remaining_dashes = inner_width.saturating_sub(1 + title_visible_len);
|
||||||
Box::HORIZONTAL,
|
|
||||||
title_colored,
|
format!(
|
||||||
format!("{}{}", dim(&horizontal_line(line_len)), dim(&Box::TOP_RIGHT.to_string()))
|
"{}{}{}{}{}",
|
||||||
))
|
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!(
|
None => dim(&format!(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
@ -74,7 +79,8 @@ pub fn box_empty(width: usize) -> String {
|
|||||||
box_row("", width)
|
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 {
|
fn strip_ansi_len(s: &str) -> usize {
|
||||||
let mut len = 0;
|
let mut len = 0;
|
||||||
let mut in_escape = false;
|
let mut in_escape = false;
|
||||||
@ -87,13 +93,40 @@ fn strip_ansi_len(s: &str) -> usize {
|
|||||||
in_escape = false;
|
in_escape = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
len += 1;
|
len += char_width(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
len
|
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
|
/// Center text within a given width
|
||||||
pub fn center(text: &str, width: usize) -> String {
|
pub fn center(text: &str, width: usize) -> String {
|
||||||
let visible_len = strip_ansi_len(text);
|
let visible_len = strip_ansi_len(text);
|
||||||
|
|||||||
@ -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::content::{Experience, NavItem, SiteContent, Skill, EXPERIENCES, NAV_ITEMS, SKILLS};
|
||||||
use super::layout::{box_bottom, box_empty, box_row, box_top, center};
|
use super::layout::{box_bottom, box_empty, box_row, box_top, center};
|
||||||
|
|
||||||
const WIDTH: usize = 67;
|
const WIDTH: usize = 78;
|
||||||
|
|
||||||
/// ASCII art logo for "VISUAL ALCHEMIST"
|
|
||||||
const LOGO: &str = r#"██╗ ██╗██╗███████╗██╗ ██╗ █████╗ ██╗
|
|
||||||
██║ ██║██║██╔════╝██║ ██║██╔══██╗██║
|
|
||||||
██║ ██║██║███████╗██║ ██║███████║██║
|
|
||||||
╚██╗ ██╔╝██║╚════██║██║ ██║██╔══██║██║
|
|
||||||
╚████╔╝ ██║███████║╚██████╔╝██║ ██║███████╗
|
|
||||||
╚═══╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝"#;
|
|
||||||
|
|
||||||
/// Render the complete terminal UI
|
/// Render the complete terminal UI
|
||||||
pub fn render() -> String {
|
pub fn render() -> String {
|
||||||
@ -36,14 +28,11 @@ fn render_header() -> String {
|
|||||||
lines.push(box_top(WIDTH, None));
|
lines.push(box_top(WIDTH, None));
|
||||||
lines.push(box_empty(WIDTH));
|
lines.push(box_empty(WIDTH));
|
||||||
|
|
||||||
// Logo
|
// Title
|
||||||
for line in LOGO.lines() {
|
lines.push(box_row(
|
||||||
let centered = center(&color(line, Colors::RED), WIDTH - 4);
|
¢er(&bold_color("VISUAL ALCHEMIST", Colors::RED), WIDTH - 4),
|
||||||
lines.push(box_row(¢ered, WIDTH));
|
WIDTH,
|
||||||
}
|
));
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
lines.push(box_row(¢er(&bold_color("ALCHEMIST", Colors::RED), WIDTH - 4), WIDTH));
|
|
||||||
lines.push(box_empty(WIDTH));
|
lines.push(box_empty(WIDTH));
|
||||||
|
|
||||||
// Name and title
|
// Name and title
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user