今天介紹一個特殊的 Dart 開源庫 pixel_prompt ,PixelPrompt 是 Dart 的終端 UI (Terminal UI TUI) 框架 ,它屬於參考了 Flutter 的響應式 UI 實現,利用 Dart 實現的聲明式 TUI :

是的,PixelPrompt 和 Flutter 沒有直接關係,是一個純 Dart 實現,實現的 UI 也是運行在終端的 TUI ,而非 App UI ,如果非要說的話,類似你現在用的 Claude Code 或者 Gemini Cli 上呈現的某些 “UI” 。
PixelPrompt將 Dart 聲明性 UI 樣式引入到了 Terminal ,讓開發者可以使用佈局、狀態元件和鍵盤/滑鼠事件來構建互動式、樣式化的終端應用。
既然是一個終端 UI 框架,那麼 PixelPrompt 的實現就不是我們常規認真的“像素 UI”,PixelPrompt 的渲染不是直接基於像素,而是基於字元單元格 (Character Cells - BufferCell) ,這也是它和一般應用 UI 實現的區別。
在 PixelPrompt 的內部, UI 是由一個個“元件 (Component)”構成,這在概念上和 Flutter 的 Widget 非常相似,專案定義了 Component 抽象類作為所有 UI 元素的基類,它有兩種核心元件類型:
BuildableComponent: 類似於 Flutter 的 StatelessWidget,用於構建靜態、無狀態的 UI 部分,它透過一個 build 方法返回一組子元件。StatefulComponent: 類似於 Flutter 的 StatefulWidget,用於需要維護和更新內部狀態的動態 UI 部分,它透過 createState 方法創建一個 ComponentState 物件來管理狀態,當狀態改變時,可以調用 setState 來觸發 UI 重繪。這就很 Flutter 了。
當然,在渲染機制上它會使用一個名為 CanvasBuffer 的類,這個類在內存中維護一個二維網格(List<List<BufferCell>),代表終端螢幕的每一個字元位置:
BufferCell 儲存了該位置要顯示的字元、前景色、背景色和字型樣式(如粗體、斜體)render 方法,將自己的內容(字元和樣式)繪製到 CanvasBuffer 的指定區域RenderManager 會高效地將 CanvasBuffer 中的內容與前一幀進行比較,只將有變化的部分透過 ANSI 轉義序列 (ANSI escape codes) 輸出到終端,從而更新螢幕顯示、移動光標和改變顏色在這裡有一個重點:ANSI , TUI 程式之所以能在 macOS、Linux 和 Windows 的終端裡運行,關鍵在於一個通用的標準:ANSI 轉義序列 (ANSI escape codes)。
在 TUI 領域裡並不會為每個操作系統編寫特定的圖形代碼,這裡可以將 ANSI 轉義序列想像成一種終端世界的“通用語言”,它是一些特殊的文本命令,當終端程式(如 iTerm2, Windows Terminal)接收到這些命令時,它不會把它們當成普通字元顯示出來,而是會執行相應的操作,比如:
所以在 TUI 領域,渲染 = 把 UI 變成 ANSI ,絕大多數 ANSI 碼都遵循一個通用格式:
PixelPrompt 的代碼中通常表示為 \x1B[,ESC 和 [ 的組合被稱為 CSI (Control Sequence Introducer); 分隔的數字,這些數字是命令的具體參數。比如在 PixelPrompt 代碼中的例子:
移動光標:
\x1B[<行號>;<列號>HPixelPrompt 在 CanvasBuffer 的 render 和 moveCursorTo 方法中大量使用它,來精確地將光標定位到要更新的字元格\x1B[10;20H 的意思是:“把光標移動到第 10 行,第 20 列”設定圖形樣式 :
\x1B[<參數>m,PixelPrompt 在 TextComponentStyle 的 getStyleAnsi() 方法中生成這些代碼。\x1B[31m 設定前景色為紅色(30-37 是標準前景色),\x1B[42m 設定背景色為綠色(40-47 是標準背景色)\x1B[1m 設定為粗體\x1B[38;2;<r>;<g>;<b>m 設定前景色的 RGB 值,如 \x1B[38;2;255;100;50m。\x1B[0m 清除之前所有的顏色和樣式設定,恢復到終端的預設狀態舉個例子,比如在 window 裡輸入 Write-Host "$([char]27)[2J$([char]27)[5;10H$([char]27)[93mThis is a complex" ,如下圖所示,可以看到終端被清屏,不過你的輸出文本的顏色和光標位置都發生了變化,因為:
[2J 清空整個螢幕[5;10H 移動到第5行第10列[93m 設定為亮黃色而後續去掉
[2J後,可以看到命令就沒有清屏,而是只執行了光標移動和文本輸出。
而在佈局支持上,PixelPrompt 自定義了一個 LayoutEngine,它負責計算和定位所有元件在終端螢幕上的位置和大小,目前它提供了類似於 Flutter 的 Row 和 Column 元件,用於水平方向和垂直方向的佈局,這些佈局元件會根據其子元件的大小和指定的 childGap(間距)來計算自身的尺寸:
整個佈局過程是遞歸實現,主要是從根元件
App開始,引擎會遍歷整個元件樹,測量(measure)每個元件的尺寸,並為它們分配一個矩形區域(Rect)用於渲染。
詳細來說,LayoutEngine 主要是針對元件進行佈局整理,方便後續轉移渲染:
在開始測量時,它首先會調用根元件的 measure 方法,詢問在給定的最大可用空間 (maxSize) 下,整個應用大概需要多大的空間,然後它會以根元件和其計算出的邊界 (rootBounds) 為起點,調用 _layoutRecursiveCompute 方法,開始遞歸地為每一個子元件分配位置。
_layoutRecursiveCompute 方法是佈局的核心,它自上而下 (Top-Down) 地為元件樹中的每一個節點分配一個精確的矩形區域 (Rect),實際上就是在為一個子元件完成測量和定位之後,引擎會以這個子元件和它剛剛被分配到的 Rect 為參數,遞歸地調用 _layoutRecursiveCompute 方法,從而開始對這個子元件的下一層子孫進行佈局。
當 compute 方法的遞歸過程全部結束後,它會返回一個 List<PositionedComponentInstance>,其中 PositionedComponentInstance 是一個簡單的數據結構,它將一個元件實例 (componentInstance) 和它最終被計算出的位置與尺寸 (rect) 綁定在一起。
這個列表就是整個 UI 的控件級別的最終佈局藍圖,前面我們講到的渲染系統 (AppInstance 中的 render 方法) 會接收這個列表,遍歷它,並告訴每個元件:“好了,你的位置和大小已經確定了(就是這個 Rect),現在請在這個區域內把自己畫到 CanvasBuffer 上吧!”
List<PositionedComponentInstance> 作為 LayoutEngine 的最終產物,可以把它理解為一份極其詳細的 “UI 施工藍圖”,其中:
componentInstance: 要畫什麼?例如具體的某個元件實例,比如一個 TextComponent 實例或一個 ButtonComponent 實例rect: 要畫在哪裡,畫多大? 一個 Rect 物件,精確地定義了這個元件在終端螢幕上的矩形區域,包含了它的左上角 x, y 坐標以及它的 width 和 height所以,這個 UI 藍圖列表的含義可以理解為:
“渲染系統,請按照這個列表進行施工:
- 把這個文本元件畫在
(x:5, y:2, width:10, height:1)的區域裡- 接著,把這個按鈕元件畫在
(x:5, y:4, width:15, height:3)的區域裡- 再接著,把那個容器元件畫在
(x:0, y:0, width:30, height:10)的區域裡- ...”
最終,“藍圖”需要透過內部的 AppInstance 和 CanvasBuffer 協同工作進行轉移渲染。
首先,App 的實例 (AppInstance) 在拿到 LayoutEngine 給出的這份“施工藍圖” (List<PositionedComponentInstance>) 之後,它會開始遍歷這個列表,然後調用對應 componentInstance 自身的 render 方法,並把兩個關鍵參數傳給它:
CanvasBuffer 物件:這就是我們之前提到的那個內存中的虛擬螢幕。rect 物件:這就是藍圖中為這個元件分配好的矩形區域。接著就是在內存裡的虛擬螢幕(CanvasBuffer)進行繪製,比如每個元件實例都收到了自己的施工任務後,就會在自己的 render 方法內部,根據自己的內容(比如 TextComponent 的文本)和被分配的 rect,計算出應該在 CanvasBuffer 的哪些單元格裡填上什麼內容。
然後,它會調用 buffer.drawAt() 或 buffer.drawChar() 方法,把自己的字元、顏色和樣式信息“畫”到內存中的 CanvasBuffer 裡。例如,TextComponent 的 render 方法可能會進行:
“
rect是從(x:5, y:2)開始,文本是 'Hello',所以對應是buffer.drawAt(5, 2, "Hello", ...)。”
而之所以會有 CanvasBuffer ,主要是為了實現高效的內容同步,例如:
CanvasBuffer 的基石是兩個二維列表(List<List<BufferCell>),它們充當了雙緩衝:
_screenBuffer: 代表當前幀要繪製的內容。所有 draw 操作都會更新這個緩衝區。_previousFrame: 儲存上一幀已經繪製到終端的內容。差分渲染,由 render() 方法完成。它的目標是用最少的操作來更新終端螢幕,從而實現高性能和無閃爍的刷新,核心思想是,它只更新從上一幀到當前幀發生變化的單元格。
在所有元件都畫完之後,主程式會調用 canvasBuffer.render() 方法,這個方法會遍歷內存中的 _screenBuffer,比較每個單元格與 _previousFrame 中對應單元格的差異,對有差異的單元格生成 ANSI 指令,將所有這些指令和字元拼接成一個巨大的字串,最後透過 stdout.write() 將這個字串一次性輸出到終端,從而呈現出 UI ,例如在終端最終透過相應用戶輸入,渲染出對應的效果:


整個過程總結如下圖所示:

可以看到,pixel_prompt 將 Dart 帶到了一個新的小眾領域,也在桌面領域補全了 Dart 的小短板,當然大多數時候你可能並不會有到,但這不乏為一個有趣的嘗試。
當然,目前 pixel_prompt 還處於實驗性階段,所以 API 尚不穩定 ,另外關於菜單 (menus)、表格 (tables) 和多行文本輸入區 (textfield area)這些還處於實現階段,也暫不支持滾動視圖 ,一些高級功能例如可視化調試器也還在完善,但總體來說,pixel_prompt 還是屬於一個非常有意思的專案。