今天介紹一個特殊的 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[<行號>;<列號>H
PixelPrompt
在 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 還是屬於一個非常有意思的專案。