🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

基於 Dart 的 Terminal UI ,pixel_prompt 這個 TUI 庫了解下

image

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

image

是的,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 碼都遵循一個通用格式:

  • 轉義字元 (ESC):所有命令都以一個特殊的“轉義”字元開始,在 PixelPrompt 的代碼中通常表示為 \x1B
  • 控制序列引導符 (CSI):緊跟在 ESC 後面的是一個左方括號 [ESC[ 的組合被稱為 CSI (Control Sequence Introducer)
  • 參數 (Parameters):在 CSI 和結束符之間,可以有一個或多個由分號 ; 分隔的數字,這些數字是命令的具體參數。
  • 結束符 (Final Byte):一個字母,用來定義這個命令的類型。

比如在 PixelPrompt 代碼中的例子:

  • 移動光標

    • 格式:\x1B[<行號>;<列號>H
    • PixelPromptCanvasBufferrendermoveCursorTo 方法中大量使用它,來精確地將光標定位到要更新的字元格
    • 例如 \x1B[10;20H 的意思是:“把光標移動到第 10 行,第 20 列”
  • 設定圖形樣式 :

    • 格式:\x1B[<參數>m
    • 用來改變顏色和樣式的命令,PixelPromptTextComponentStylegetStyleAnsi() 方法中生成這些代碼。
    • 顏色\x1B[31m 設定前景色為紅色(30-37 是標準前景色),\x1B[42m 設定背景色為綠色(40-47 是標準背景色)
    • 樣式\x1B[1m 設定為粗體
    • 24位真彩色\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 設定為亮黃色

image

而後續去掉 [2J 後,可以看到命令就沒有清屏,而是只執行了光標移動和文本輸出。

而在佈局支持上,PixelPrompt 自定義了一個 LayoutEngine,它負責計算和定位所有元件在終端螢幕上的位置和大小,目前它提供了類似於 Flutter 的 RowColumn 元件,用於水平方向和垂直方向的佈局,這些佈局元件會根據其子元件的大小和指定的 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 上吧!”

UI 藍圖

List<PositionedComponentInstance> 作為 LayoutEngine 的最終產物,可以把它理解為一份極其詳細的 “UI 施工藍圖”,其中:

  • componentInstance: 要畫什麼?例如具體的某個元件實例,比如一個 TextComponent 實例或一個 ButtonComponent 實例
  • rect: 要畫在哪裡,畫多大? 一個 Rect 物件,精確地定義了這個元件在終端螢幕上的矩形區域,包含了它的左上角 x, y 坐標以及它的 widthheight

所以,這個 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) 的區域裡
  • ...”

CanvasBuffer

最終,“藍圖”需要透過內部的 AppInstanceCanvasBuffer 協同工作進行轉移渲染。

首先,App 的實例 (AppInstance) 在拿到 LayoutEngine 給出的這份“施工藍圖” (List<PositionedComponentInstance>) 之後,它會開始遍歷這個列表,然後調用對應 componentInstance 自身的 render 方法,並把兩個關鍵參數傳給它:

  • CanvasBuffer 物件:這就是我們之前提到的那個內存中的虛擬螢幕
  • rect 物件:這就是藍圖中為這個元件分配好的矩形區域

接著就是在內存裡的虛擬螢幕(CanvasBuffer)進行繪製,比如每個元件實例都收到了自己的施工任務後,就會在自己的 render 方法內部,根據自己的內容(比如 TextComponent 的文本)和被分配的 rect,計算出應該在 CanvasBuffer 的哪些單元格裡填上什麼內容。

然後,它會調用 buffer.drawAt()buffer.drawChar() 方法,把自己的字元、顏色和樣式信息“畫”到內存中的 CanvasBuffer 裡。例如,TextComponentrender 方法可能會進行:

rect 是從 (x:5, y:2) 開始,文本是 'Hello',所以對應是 buffer.drawAt(5, 2, "Hello", ...)。”

而之所以會有 CanvasBuffer ,主要是為了實現高效的內容同步,例如:

  • CanvasBuffer 的基石是兩個二維列表(List<List<BufferCell>),它們充當了雙緩衝

    • _screenBuffer: 代表當前幀要繪製的內容。所有 draw 操作都會更新這個緩衝區。
    • _previousFrame: 儲存上一幀已經繪製到終端的內容。
  • 差分渲染,由 render() 方法完成。它的目標是用最少的操作來更新終端螢幕,從而實現高性能和無閃爍的刷新,核心思想是,它只更新從上一幀到當前幀發生變化的單元格。

ANSI

在所有元件都畫完之後,主程式會調用 canvasBuffer.render() 方法,這個方法會遍歷內存中的 _screenBuffer比較每個單元格與 _previousFrame 中對應單元格的差異,對有差異的單元格生成 ANSI 指令,將所有這些指令和字元拼接成一個巨大的字串,最後透過 stdout.write() 將這個字串一次性輸出到終端,從而呈現出 UI ,例如在終端最終透過相應用戶輸入,渲染出對應的效果:

image
image

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

image

可以看到,pixel_prompt 將 Dart 帶到了一個新的小眾領域,也在桌面領域補全了 Dart 的小短板,當然大多數時候你可能並不會有到,但這不乏為一個有趣的嘗試。

當然,目前 pixel_prompt 還處於實驗性階段,所以 API 尚不穩定 ,另外關於菜單 (menus)、表格 (tables) 和多行文本輸入區 (textfield area)這些還處於實現階段,也暫不支持滾動視圖 ,一些高級功能例如可視化調試器也還在完善,但總體來說,pixel_prompt 還是屬於一個非常有意思的專案。


原文出處:https://juejin.cn/post/7553547794457346087


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬9   ❤️4
557
🥈
我愛JS
📝3   💬9   ❤️7
190
🥉
AppleLily
📝1   💬4   ❤️1
68
#4
💬1  
3
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付