featured.svg

在上一篇探討 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 即時展示:

 Tip
  • 操作方式 - 點擊終端機畫面即可取得鍵盤焦點,可使用方向鍵與 Enter 瀏覽 PTT;若在行動裝置或想快速嘗試,亦可直接點擊頂部快捷按鈕(免打字登入 guest、方向鍵導航、切換 WebGPU / Canvas 2D 渲染器與 CRT 著色器開關)。
  • 獨立視窗體驗 - 若需要更大的閱讀空間或習慣使用全螢幕快捷鍵,也可以直接開啟獨立頁面 ghosptt.simplypatrick.workers.dev。

架構全景:WASM 核心、通訊管線與雙渲染引擎

要在瀏覽器中以 libghostty-vt 驅動高密度的 PTT 串流展示,必須串聯網路通訊、字元編碼解碼、終端機狀態機與硬體加速繪圖四大層級:

architecture.svg

整個資料流由左至右、由上而下清晰分工:

  1. 連線與邊緣代理 - 瀏覽器透過 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'],跨越官方網關的來源與協定防護。
  2. 通訊協定與編碼解析管線 - PTT 伺服器傳回的二進位位元組串流首先進入狀態機過濾 Telnet IAC 指令,在收到 IAC DO NAWS 時自動回覆 80×24 視窗大小。接著載入 256 KiB 二進位表支援 Big5-UAO 2.50(Unicode 補完計畫) 涵蓋簡體字、日文假名與特殊符號,並由 PttBig5StreamDecoder 處理雙色字(DBCS 中斷 ANSI)跨封包拼接與 Mode 2027 寬度對齊。
  3. WebAssembly 終端核心 - 乾淨的字串餵入 891 KiB 的 ghostty-vt.wasm。這正是本次技術展示的終端狀態核心:由 libghostty-vt 獨立維護 80×24 畫面網格、屬性矩陣、游標移動與文字選取,前端完全不需要自行手寫容易出錯的終端狀態機,體現了 headless 終端函式庫的模組化優勢。
  4. 硬體加速雙渲染引擎 - 前端從 WASM 提取畫面快照後,WebGPU 實例化繪製管線(Instanced Quad)以單一 draw call 完成背景色塊、動態 CJK 字形紋理圖集(atlas,基準尺寸為 2048×2048)與純數學幾何 ANSI 色塊運算,再透過第二階段片元著色器疊加復古 CRT 曲面、掃描線與鄰近像素光暈(不支援 WebGPU 的裝置則自動降級至 Canvas 2D),並具備 5:3 視窗自適應縮放。
  5. 現代 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):

  1. 優先查詢 UAO 2.50 逆向表 - 當已載入 uao250.bin 時,系統直接透過 uaoU2B 查詢 Unicode 字元對應的 Big5 位元組。這使得貼上的文字即使包含簡體字、日文假名或特殊符號,也能正確編碼送出。
  2. 原生 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 的開源實作,一同探索現代終端引擎的更多技術可能性。

相關參考資料