在上一篇探討 libghostty 1.0 的 C 函式庫設計修養 中,我們深入剖析了 Mitchell Hashimoto 如何透過嚴格的 C ABI、不透明控制代碼與自訂記憶體配置器,打造出能跨越作業系統與語言邊界的現代虛擬終端核心。在它的建置管線中,有一條非常引人注目的路徑:能夠直接輸出 wasm32-freestanding 的獨立 WebAssembly 產物。
這次我把 libghostty-vt 編譯成 WebAssembly,再搭配 WebGPU 繪製它輸出的終端狀態。GhosPTT 是這項實驗的原型專案;選擇 PTT 作為題材,是想觀察 Big5、ANSI 與字元寬度在這套組合下會遇到哪些整合挑戰。
若要真正驗證一個現代虛擬終端引擎(VT emulator)的實力,單純的 shell 提示字元往往不足以暴露出邊界問題。我們需要一個足夠複雜、充滿高密度字元排版、特殊轉義控制碼與歷史相容包袱的測試場景。對台灣的軟體開發者來說,最深植人心的經典終端應用,莫過於擁有三十年歷史的 PTT(批踢踢實業坊)——它融合了嚴苛的 80×24 ANSI 雙位元組色塊藝術、歷史遺留的雙色字編碼、東亞字元寬度不明確(East Asian Ambiguous) 問題,以及高頻率的畫面重繪,是檢驗 libghostty-vt 解析能力與狀態機嚴謹度的極佳試煉場。
在此必須先明確釐清專案定位:官方以 HTML5 打造的 term.ptt.cc 歷經多年淬鍊,具備成熟穩定的連線架構與普及的社群基礎;GhosPTT(專案原始碼收錄於 ghosptt)的初衷並非為了取代官方網頁版,而是一個純粹的技術 prototype 與開源展示,核心目標是探索:當我們將工業級的 C/Zig 終端核心(ghostty-vt.wasm)作為 headless engine 搬上現代 Web,能激盪出什麼樣的架構想像與技術可能?
透過 GhosPTT,我們嘗試以 libghostty-vt 為底層中樞,向上銜接 WebGPU 實例化繪製管線、以片元數學幾何避免依賴字型度量所造成的 ANSI 色塊接縫、疊加復古 CRT 著色器,並補齊 Big5-UAO 補完計畫支援、Telnet 閒置保活、響應式視窗縮放與貼圖燈箱導覽等現代 Web 互動。這篇文章將完整拆解 GhosPTT 的設計與實作細節,聊聊如何以現代圖形與 WebAssembly 技術,為老派終端核心打造一場具體且紮實的技術演示。
先玩再說:線上即時體驗
講再多架構理論,都不如直接在瀏覽器裡親自摸摸看。下方為透過 Cloudflare Workers 與 WebGPU / Canvas 2D 雙渲染引擎驅動的 GhosPTT 即時展示:
- 操作方式 - 點擊終端機畫面即可取得鍵盤焦點,可使用方向鍵與 Enter 瀏覽 PTT;若在行動裝置或想快速嘗試,亦可直接點擊頂部快捷按鈕(免打字登入
guest、方向鍵導航、切換 WebGPU / Canvas 2D 渲染器與 CRT 著色器開關)。 - 獨立視窗體驗 - 若需要更大的閱讀空間或習慣使用全螢幕快捷鍵,也可以直接開啟獨立頁面 ghosptt.simplypatrick.workers.dev。
架構全景:WASM 核心、通訊管線與雙渲染引擎
要在瀏覽器中以 libghostty-vt 驅動高密度的 PTT 串流展示,必須串聯網路通訊、字元編碼解碼、終端機狀態機與硬體加速繪圖四大層級:
整個資料流由左至右、由上而下清晰分工:
- 連線與邊緣代理 - 瀏覽器透過 WebSocket 連線至同源的
/bbs路徑。生產環境由部署於邊緣的 Cloudflare Workers 轉送 WebSocket 並注入合規的Origin: https://term.ptt.cc標頭;同時實作定期TELNET IAC NOP(0xFF 0xF1)心跳保活機制,降低長文閱覽時因長時間無資料傳輸帶來的連線逾時斷線風險;本機開發時則由 Vite plugin 直接以tls.connect連向ws.ptt.cc並指定ALPNProtocols: ['http/1.1'],跨越官方網關的來源與協定防護。 - 通訊協定與編碼解析管線 - PTT 伺服器傳回的二進位位元組串流首先進入狀態機過濾 Telnet IAC 指令,在收到
IAC DO NAWS時自動回覆 80×24 視窗大小。接著載入 256 KiB 二進位表支援 Big5-UAO 2.50(Unicode 補完計畫) 涵蓋簡體字、日文假名與特殊符號,並由PttBig5StreamDecoder處理雙色字(DBCS 中斷 ANSI)跨封包拼接與 Mode 2027 寬度對齊。 - WebAssembly 終端核心 - 乾淨的字串餵入 891 KiB 的
ghostty-vt.wasm。這正是本次技術展示的終端狀態核心:由libghostty-vt獨立維護 80×24 畫面網格、屬性矩陣、游標移動與文字選取,前端完全不需要自行手寫容易出錯的終端狀態機,體現了 headless 終端函式庫的模組化優勢。 - 硬體加速雙渲染引擎 - 前端從 WASM 提取畫面快照後,WebGPU 實例化繪製管線(Instanced Quad)以單一 draw call 完成背景色塊、動態 CJK 字形紋理圖集(atlas,基準尺寸為 2048×2048)與純數學幾何 ANSI 色塊運算,再透過第二階段片元著色器疊加復古 CRT 曲面、掃描線與鄰近像素光暈(不支援 WebGPU 的裝置則自動降級至 Canvas 2D),並具備 5:3 視窗自適應縮放。
- 現代 Web 互動增強層 - 畫面上即時識別出超連結,當滑鼠懸停於 Imgur 或圖片網址時彈出浮窗預覽,點擊進入全螢幕燈箱與文章相簿模式,並於燈箱開啟時讓出方向鍵操作;同時維護 UAO/Big5 逆向編碼表,確保中文、日文與符號剪貼簿貼上可正常送往 PTT。
WebAssembly 整合:透過型別清單減少 C ABI 硬編碼
在 WebAssembly 與 JavaScript / TypeScript 的互通實作中,最繁瑣且脆弱的環節莫過於結構體的記憶體對齊與欄位位移(field offset)。若手動在前端宣告 offset 數值,只要編譯器旗標或核心結構稍有調整,記憶體讀寫就會立刻錯位。
libghostty-vt 在這次整合中使用的一項設計,就在於它在唯讀資料段中內嵌了一份由編譯期反射生成的 JSON 型別清單。在 GhosPTT 啟動時(src/ghostty-wasm.ts),我們不需要依賴任何 Emscripten 或外部綁定生成器,直接以原生 WebAssembly API 載入模組:
export class GhosttyVtTerminal {
public static async create(wasmUrl = './ghostty-vt.wasm'): Promise<GhosttyVtTerminal> {
const response = await fetch(wasmUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status} loading ${wasmUrl}`);
}
const wasmBytes = await response.arrayBuffer();
const utf8Decoder = new TextDecoder('utf-8');
const wasmModule = await WebAssembly.instantiate(wasmBytes, {
env: {
log: (ptr: number, len: number) => {
const exp = wasmModule.instance.exports as GhosttyWasmExports;
const bytes = new Uint8Array(exp.memory.buffer, ptr, len);
console.log('[ghostty-vt]', utf8Decoder.decode(bytes));
},
},
});
const exports = wasmModule.instance.exports as GhosttyWasmExports;
// 從 WASM 唯讀資料段讀取 NUL 結尾的 JSON ABI 描述
const jsonPtr = exports.ghostty_type_json();
const memBytes = new Uint8Array(exports.memory.buffer, jsonPtr);
let nulIdx = 0;
while (memBytes[nulIdx] !== 0) nulIdx++;
const jsonStr = utf8Decoder.decode(memBytes.subarray(0, nulIdx));
const layout = JSON.parse(jsonStr) as GhosttyTypeLayout;
return new GhosttyVtTerminal(exports, layout);
}
}透過解析這份 JSON,多個核心結構體大小(GhosttyRenderStateColors.size、GhosttyPoint.size 等)與列舉值(GhosttyTerminalOption.values)在執行期取得。
值得指出的是,作為驗證性質的原型實作,GhosPTT 目前仍假設執行於 32 位元 WASM 環境(指標大小為 4 位元組),且在掃描底層資料時保留了部分手動位移與位元遮罩運算(例如在 src/ghostty-wasm.ts 中讀取 GhosttyPoint 標記、選取範圍位移,以及解析行儲存格的樣式旗標)。型別清單的實務價值,在於省去了手動維護大量易碎的 C 結構體綁定程式碼,在原型開發效率與底層讀取效能之間取得良好平衡。
持久控制代碼與共用 scratch 緩衝區
避免每影格重複跨越 WASM 邊界配置與釋放記憶體,降低配置器抖動與記憶體碎片。GhosPTT 在建構式中一次性配置所有持久控制代碼(opaque handle)與共用 scratch buffers:
// 1. 建立 80x24 終端實例
this.opaqueSlotPtr = exports.ghostty_wasm_alloc_opaque();
this.check(
exports.ghostty_terminal_new(0, this.opaqueSlotPtr, COLS, ROWS),
'ghostty_terminal_new'
);
this.termPtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);
// 2. 建立渲染狀態與行迭代器
this.check(
exports.ghostty_render_state_new(0, this.opaqueSlotPtr),
'ghostty_render_state_new'
);
this.renderStatePtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);
this.check(
exports.ghostty_render_state_row_iterator_new(0, this.opaqueSlotPtr),
'ghostty_render_state_row_iterator_new'
);
this.rowIterPtr = exports.ghostty_wasm_take_opaque(this.opaqueSlotPtr);
// ...(略去 row_cells 初始化)
// 3. 預先配置可重複使用的結構體緩衝區
this.colorsSize = layout.types.GhosttyRenderStateColors.size;
this.colorsPtr = exports.ghostty_wasm_alloc(this.colorsSize);
this.cursorSize = layout.types.GhosttyRenderStateCursor.size;
this.cursorPtr = exports.ghostty_wasm_alloc(this.cursorSize);
this.cellRawPtr = exports.ghostty_wasm_alloc(8);提取每一影格時,呼叫 ghostty_render_state_update 更新渲染狀態,再透過 ghostty_render_state_row_iterator_next 由上而下逐行掃描整個視區,依序讀取各列字元格資料,高效產生緊湊的渲染快照(snapshot)。
歷史遺產與標準相容:雙色字與字元寬度的調解之道
PTT 是基於 Big5 繁體中文編碼與 DEC VT100 / ANSI 控制碼運作的純文字系統。當我們使用嚴格遵循現代標準的 libghostty-vt 作為終端引擎時,正好能展示現代 headless 核心如何與充滿歷史包袱的真實串流優雅協調。
雙色字(DBCS Split ANSI Escape)
在傳統 BBS 上,ANSI 美工為了在單一中文字的左右兩半塗上不同色彩(例如半藍半紅),創造出極其特殊的排版技巧:把 ANSI 顏色控制碼 \x1b[...m 硬生生插在 Big5 雙位元組(前導位元組 0x81..0xFE 與接續位元組 0x40..0x7E, 0xA1..0xFE)的中間!
如果直接將 WebSocket 收到的二進位串流送進瀏覽器原生 TextDecoder('big5'),解碼器在讀取前導位元組後看到 0x1b,會判定前導位元組為不合法字元,直接輸出替代字元 \ufffd,畫面隨之錯位。
GhosPTT 在 src/big5.ts 中實作了專屬的狀態機解碼器 PttBig5StreamDecoder。它會在串流中攔截前導位元組,檢測後續是否存在 ANSI 轉義序列,並在跨轉義序列後準確捕獲接續位元組完成解碼:
// 2. Big5 前導位元組(0x81..0xFE)
if (b >= 0x81 && b <= 0xfe) {
let j = i + 1;
let interveningEscapes = '';
let incomplete = false;
// 收集夾在 Big5 前導與接續位元組中間的所有 ANSI 轉義控制碼
while (j < buf.length && buf[j] === 0x1b) {
const end = findAnsiEscapeEnd(buf, j);
if (end < 0) {
incomplete = true;
break;
}
for (let k = j; k < end; k++) {
interveningEscapes += String.fromCharCode(buf[k]);
}
j = end;
}
if (incomplete || j >= buf.length) {
// 跨 WebSocket 封包邊界:保留未完成位元組至下一訊框
break;
}
const trail = buf[j];
const isValidTrail =
(trail >= 0x40 && trail <= 0x7e) || (trail >= 0xa1 && trail <= 0xfe);
if (isValidTrail) {
let ch = '';
// 優先查詢 Big5-UAO 2.50 對應表
if (uaoB2U) {
const code = (b << 8) | trail;
const u = uaoB2U[code];
if (u !== 0) {
ch = String.fromCharCode(u);
}
}
// 未在 UAO 表中則降級至原生解碼器
if (!ch) {
pairBuf[0] = b;
pairBuf[1] = trail;
ch = this.pairDecoder.decode(pairBuf);
}
const cp = ch.codePointAt(0) ?? 0;
const w = this.codepointWidthFn(cp);
// 重新排列:先輸出轉義序列,再輸出中文字元,必要時附加寬度補償字元
out += interveningEscapes + ch + (w === 1 ? BIG5_WIDE_PAD : '');
i = j + 1;
} else {
out += interveningEscapes;
i = j;
}
continue;
}如此一來,既有的顏色樣式依然能夠依序生效,而雙位元組字元亦能完整復原。需要留意的是,由於中斷的轉義控制碼被提至字元前方生效,字元左右兩半目前會統一取用後段的 SGR 顏色,尚未做到單一字元逐半獨立著色,但已避免這類插入式 SGR 造成的字元解碼錯誤。
East Asian Ambiguous 寬度與 Mode 2027
另一個棘手問題是字元寬度。在 Big5 世界裡,所有雙位元組字元(包括色塊 █、▊、▄、幾何角 ◢、◣、符號 ★、線條 ─、│)皆一律佔據 2 個終端機欄位(24px 寬度)。
然而在現代 Unicode 標準中,這些符號被歸類為「東亞寬度不明確(East Asian Ambiguous)」,在多數標準終端模擬器中預設僅佔 1 個欄位(12px)。這會導致整張 ANSI art 嚴重錯位、向左坍塌。
為了解決這個問題,GhosPTT 結合了 Ghostty 預設建議啟用的 Mode 2027(複合字元模式 / Grapheme Clustering):
// 啟動終端時啟用 Mode 2027
this.writeVt('\x1b[?2027h');在 Mode 2027 下,Ghostty 會依據 Unicode 規範將基底字元與結合標記聚合成單一複合字元。我們在常數中定義了一個特殊的結合字元:
export const BIG5_WIDE_PAD = '\u0903';U+0903 是天城文 Visarga(間隔記號)。解碼器透過呼叫 Ghostty 匯出的 ghostty_unicode_codepoint_width(cp) 函式計算字元寬度;當判定某個雙位元組字元寬度為 1 時,便在其後方附加 BIG5_WIDE_PAD。在 Mode 2027 下,Ghostty 會將兩者聚合成佔據 2 欄位寬度的複合圖元。這是原型階段為了因應目前 Ghostty 寬度計算邏輯所採取的相容策略,使雙位元組 ANSI 排版維持正確寬度;而在渲染輸出、字形紋理查詢與剪貼簿複製時,再透過正規表達式剔除該字元。
Big5-UAO 2.50 擴充字集支援
PTT 雖以傳統 Big5 為基礎,但在實際文章與推文中,使用者頻繁使用不在標準 Big5 範圍內的字元,例如簡體字(如「国」0x83f6)、日文假名(如「ぁ」0xc6e7)或特殊符號(如愛心「♥」0x9dde)。這些字元屬於 PTT 官方支援的 Big5-UAO(Unicode 補完計畫 2.50) 擴充字集。
WHATWG Big5 與 PTT 使用的 UAO 對照表並不完全相同;部分位元組可能解碼失敗輸出替代字元,也可能被解成另一個有效字元(例如 0x9dde 在 UAO 中代表「♥」,但在 WHATWG Big5 中則對應「鱅」)。因此原型優先使用 PTT 的對照表進行解碼。
為支援 UAO 字元,GhosPTT 實作了 UAO 2.50 查表機制:
- 透過獨立腳本(
scripts/build-uao.mjs)直接從ptt/pttbbs官方儲存庫提取table.go,預先產出兩張各含 65,536 個uint16的對照表(b2u與u2b),合併為 262,144 位元組(256 KiB)的二進位檔public/uao250.bin。 - 應用啟動時由
initBig5Uao('./uao250.bin')載入記憶體。解碼串流時,PttBig5StreamDecoder優先查詢uaoB2U;若不在 UAO 表中,才降級交由原生TextDecoder('big5')處理。 - 解碼後的 UAO 特殊字元同樣會進入
ghostty_unicode_codepoint_width(cp)寬度驗證。例如當「♥」由 Ghostty 判定為寬度 1 時,解碼器依然會自動附加BIG5_WIDE_PAD,依目前 Ghostty 行為在 Mode 2027 下補足兩欄寬度,維持 BBS 排版幾何。
Telnet IAC NOP 閒置保活機制
GhosPTT 的生產環境由 Cloudflare Workers 轉送 WebSocket 連線至 PTT 網關。在 BBS 閱覽長篇好文或推文時,使用者往往超過數分鐘未敲擊鍵盤,長時間缺乏雙向資料傳輸容易增加邊緣網路逾時斷線的風險。
RFC 854 定義了 TELNET IAC NOP(0xFF 0xF1,No Operation)指令。PTT 的 BBS 伺服器在接收到 NOP 時會默默予以忽略,既不會改變終端游標位置,也不會更動畫面緩衝區內容。
GhosPTT 在瀏覽器端(src/ptt-client.ts)與 Cloudflare Worker 代理(worker/index.ts)分別加入了應用層保活機制:每 10 秒檢查一次傳輸狀態,當偵測到連線閒置達 30 秒以上時,便在 WebSocket 資料通道中傳送 TELNET_IAC_NOP 位元組,降低長時間閱讀時的閒置斷線機率。
WebGPU 硬體加速與無縫 ANSI 色塊渲染
終端機看似只有文字,但在高 DPI 螢幕與高更新率下,每秒繪製大量的字形與色塊對 CPU 是不小的負擔。GhosPTT 實作了專屬的 WebGPU 渲染管線(src/webgpu-renderer.ts)。
Instanced Quad 實例化管線
在 80×24 視窗中,總共有 1,920 個字元格。加上背景矩形、文字前景色與選取區域,GhosPTT 配置了一個最多容納 8,192 個實例的實例資料緩衝區。每個 instance 佔用 16 個 float(64 位元組,符合 16 位元組對齊規範):
struct InstanceInput {
@location(0) rect: vec4<f32>, // x, y, w, h(物理像素)
@location(1) uvRect: vec4<f32>, // u0, v0, u1, v1
@location(2) color: vec4<f32>, // r, g, b, a
@location(3) kind: f32, // 0 = Solid, 1 = AtlasGlyph, 2 = CustomBlock
@location(4) codepoint: f32, // 自訂幾何圖元 Unicode 碼點
};在 WebGPU 渲染管線中,文字與背景色塊繪製集中在單一終端網格 pass(執行 pass.draw(6, instanceCount));若使用者開啟復古 CRT 效果,管線則會將網格結果先渲染至中介紋理,再透過第二個全螢幕後製 pass(pass.draw(6, 1))疊加著色器特效。中英文字形快取於動態紋理圖集(texture atlas,基準尺寸為 2048×2048,並依視窗縮放、DPR 與 GPU 紋理限制調整),並搭配最近鄰採樣(nearest-neighbor)與物理像素對齊,即便在 Retina 螢幕上文字依然銳利清晰。
為了改善深色背景上細抗鋸齒邊緣容易發虛的問題,片元著色器中特別加入了字幹補暗(stem darkening)伽瑪校正補償:
// 修正深色背景下的透明字形遮罩邊緣
let crispAlpha = pow(sample.a, 0.72) * in.color.a;
return vec4<f32>(in.color.rgb, crispAlpha);片元幾何消除 ANSI 色塊接縫(Procedural Custom Blocks)
在許多基於 Canvas 2D 或 DOM 的終端渲染中,常見的微小瑕疵是 ANSI 色塊繪畫的接縫。許多字型所設計的色塊字元(▁–█、▉–▏、▐、▔、▕)或角落三角形(◢、◣、◤、◥),在字型度量(metrics)上往往無法精確填滿整個字元格,造成色塊之間出現微小的一像素縫隙。
GhosPTT 對支援的色塊碼點改用幾何覆蓋計算,完全不依賴字型繪製色塊,改在 WGSL 片元著色器中進行純數學幾何分析:
fn evalCustomBlock(cp: u32, p: vec2<f32>) -> f32 {
// 下方 1/8 .. 8/8 色塊:U+2581 (▁) .. U+2588 (█)
if (cp >= 0x2581u && cp <= 0x2588u) {
let eighths = f32(cp - 0x2580u) / 8.0;
return select(0.0, 1.0, p.y >= (1.0 - eighths));
}
// 左側 7/8 .. 1/8 色塊:U+2589 (▉) .. U+258F (▏)
if (cp >= 0x2589u && cp <= 0x258Fu) {
let eighths = f32(8u - (cp - 0x2588u)) / 8.0;
return select(0.0, 1.0, p.x <= eighths);
}
// 右半色塊:U+2590 (▐)
if (cp == 0x2590u) {
return select(0.0, 1.0, p.x >= 0.5);
}
// 上方 1/8 色塊:U+2594 (▔)
if (cp == 0x2594u) {
return select(0.0, 1.0, p.y <= 0.125);
}
// 右側 1/8 色塊:U+2595 (▕)
if (cp == 0x2595u) {
return select(0.0, 1.0, p.x >= 0.875);
}
// 角落三角形搭配平滑抗鋸齒(實際邊界過渡寬度隨 quad 尺寸與 DPR 改變):U+25E2 (◢), U+25E3 (◣), U+25E4 (◤), U+25E5 (◥)
let aa = 0.025;
if (cp == 0x25E2u) {
// ◢ 右下角:x + y >= 1.0
return smoothstep(1.0 - aa, 1.0 + aa, p.x + p.y);
}
if (cp == 0x25E3u) {
// ◣ 左下角:y >= x
return smoothstep(-aa, aa, p.y - p.x);
}
if (cp == 0x25E4u) {
// ◤ 左上角:x + y <= 1.0
return 1.0 - smoothstep(1.0 - aa, 1.0 + aa, p.x + p.y);
}
if (cp == 0x25E5u) {
// ◥ 右上角:x >= y
return smoothstep(-aa, aa, p.x - p.y);
}
return 0.0;
}透過在片元著色器中計算幾何覆蓋,色塊邊緣能緊貼字元格邊界,避免傳統字型渲染在拼接時容易出現的接縫細紋。除了 WebGPU,Canvas 2D 降級路徑(src/renderer.ts 中的 drawCustomBlockGlyph)也同樣採用幾何路徑(fillRect 與 Path2D)繪製色塊,維持乾淨的繪製體驗。
復古 CRT 著色器
為重現舊時代映像管螢幕的氛圍,渲染管線設計了可隨時切換的後製處理 pass(fs_post)。
fn warpCrtUv(uv: vec2<f32>) -> vec2<f32> {
let centered = uv * 2.0 - 1.0;
let offset = centered.yx / vec2<f32>(6.5, 5.5);
let warped = centered + centered * offset * offset;
return warped * 0.5 + 0.5;
}透過 warpCrtUv 模擬球面桶狀畸變(barrel distortion),並在採樣時引入空間上的橫向 RGB 色差(chromatic aberration)、正弦掃描線(scanlines)、鄰近像素光暈(local glow)與周邊暗角(vignette)。需要說明的是,此著色器著重於重現單格空間上的映像管幾何感與掃描紋理,並未額外模擬時間維度上的螢光粉動態殘影(temporal persistence)。
現代 Web 體驗注入經典 BBS
除了底層終端渲染的高規格,GhosPTT 在前端操作體驗上也融入了現代化設計。
懸停縮圖預覽與貼文相簿導覽
在 PTT 看文時,文章與推文裡常充滿各類圖片連結(特別是 Imgur)。傳統上使用者必須逐一點開網址在新分頁查看,體驗破碎。
GhosPTT 實作了全自動的 URL 掃描與圖片正規化(src/image-preview.ts):
- 懸停即時預覽 (popover preview) - 滑鼠移到圖片網址上方時,自動抓取並顯示懸停縮圖卡片,不干擾終端閱讀。
- 全螢幕燈箱模式 (lightbox viewer) - 點擊網址直接展開高畫質燈箱。
- 貼文相簿模式 (post gallery navigation) - 閱讀文章時,GhosPTT 會在翻頁過程中持續累計曾出現在終端畫面上的圖片網址。使用者只要進入任一張燈箱,就能用鍵盤左右鍵(
←/→)像幻燈片一樣連續翻閱已瀏覽的所有圖片;當畫面偵測到回到主功能表或看板列表的特定提示字串時,便會自動重置相簿清單,關閉燈箱時亦無縫返還終端機鍵盤焦點。
響應式等比例縮放與燈箱鍵盤控制
PTT 終端機受限於 80×24 字元格的嚴格佈局(960×576 邏輯像素)。在行動裝置螢幕、高 DPR 筆電或 4K 螢幕上,若是固定像素寬高,容易造成畫面超出邊界或文字縮得過小。
GhosPTT 在 src/main.ts 中透過 ResizeObserver 監聽主畫面容器(.terminal-stage),動態計算出維持 5:3 等比例的最大縮放係數(scale),並將 Canvas 物理尺寸同步調整為 960 * scale * dpr 與 576 * scale * dpr。實體尺寸會四捨五入取整數;WebGPU 後端另依 GPU texture limit 限制有效像素密度,避免大型視窗或高 DPR 超出裝置能力。同時在 src/terminal-layout.ts 中提供幾何對齊與座標轉換函式:
export function clientPointToCell(canvas: HTMLCanvasElement, x: number, y: number) {
const rect = canvas.getBoundingClientRect();
return {
col: Math.max(0, Math.min(COLS - 1, Math.floor((x - rect.left) * COLS / (rect.width || 1)))),
row: Math.max(0, Math.min(ROWS - 1, Math.floor((y - rect.top) * ROWS / (rect.height || 1)))),
};
}透過 clientPointToCell 座標轉換,滑鼠點擊與 URL 懸停偵測能在一般縮放與 DPR 變動下精確映射至 80×24 的終端行列。
此外,當使用者開啟圖片全螢幕燈箱時,終端隱藏輸入區(#imeInput)的 keydown 處理函式會檢查燈箱開啟狀態,若為燈箱模式則呼叫 preventDefault() 並提前返回,不再向 PTT 送出按鍵碼,讓方向鍵(← / →)與 Esc 專注於燈箱圖片切換與關閉。
逆向編碼表支援 UAO 中日文與符號貼上
PTT 僅接受 Big5 位元組輸入。為了讓使用者能順利貼上剪貼簿內容或透過輸入法輸入,GhosPTT 實作了雙層編碼查表機制(src/big5.ts):
- 優先查詢 UAO 2.50 逆向表 - 當已載入
uao250.bin時,系統直接透過uaoU2B查詢 Unicode 字元對應的 Big5 位元組。這使得貼上的文字即使包含簡體字、日文假名或特殊符號,也能正確編碼送出。 - 原生 Big5 查表降級 - 若未載入 UAO 表或字元不在 UAO 範圍內,則延遲初始化(lazy initialization)瀏覽器原生
TextDecoder('big5')建立的 Big5 對應表進行補底。不在支援字集內的字元則直接忽略。
export function encodeStringToBig5(str: string): Uint8Array {
const normalized = str.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
const out: number[] = [];
for (const ch of normalized) {
const cp = ch.codePointAt(0);
if (cp === undefined) continue;
if (cp < 0x80) {
out.push(cp);
continue;
}
if (uaoU2B && cp <= 0xffff) {
const b5 = uaoU2B[cp];
if (b5 > 0xff) {
out.push((b5 >> 8) & 0xff, b5 & 0xff);
continue;
} else if (b5 > 0) {
out.push(b5);
continue;
}
}
const big5 = getUnicodeToBig5Map().get(ch);
if (big5 !== undefined) {
out.push((big5 >> 8) & 0xff, big5 & 0xff);
}
}
return new Uint8Array(out);
}當使用者貼上或輸入字串時,系統會先將換行符號(\r\n 或 \n)依照 Telnet BBS 協定慣例正規化為單一 \r,接著將 Unicode 字元即時轉碼為 Big5 位元組送往 PTT,讓中日文與符號輸入流暢自然。
結語:以經典舞台驗證現代終端核心的整合潛力
GhosPTT 最初的原動力,是為了驗證 Mitchell Hashimoto 所設計的 libghostty 函式庫在 WebAssembly 環境下的整合能力。透過 PTT 這個充滿歷史特色、排版規則嚴苛的真實展示場景,我們實證了 libghostty-vt 作為 headless 終端核心的優勢:它能在瀏覽器中維持精確的虛擬終端狀態,並給予前端繪圖管線(WebGPU、自訂著色器、CRT 後製)高度的發揮彈性。
這項原型展示了現代終端技術跳脫單一獨立應用的可能性:不需要重新撰寫易錯的終端狀態機,就能將專業級的虛擬終端解析能力嵌入專屬的前端介面中。
如果你也對 libghostty 的跨平台能力或 WebAssembly 整合感到好奇,歡迎參考 GhosPTT 的開源實作,一同探索現代終端引擎的更多技術可能性。
相關參考資料
- 專案原始碼 -
github.com/p47t/ghosptt - libghostty-vt 1.0 發布說明 - Mitchell Hashimoto: Libghostty 1.0
- Ghostty 終端機專案 - ghostty.org /
ghostty-org/ghostty - pttbbs 伺服器原始碼 -
ptt/pttbbs與 UAO 對照表 (table.go) - W3C WebGPU 與 WGSL 規範 - W3C WebGPU 與 W3C WebGPU Shading Language (WGSL)
- 字元標準與編碼規格 - Unicode Standard Annex #11 (East Asian Width) 與 WHATWG Encoding Standard (Big5)
- Telnet 協定標準 - RFC 854 (Telnet Protocol Specification) 與 RFC 1073 (Telnet Window Size Option)