Textual:為 Python 增加漂亮的文本用戶界面(TUI)

2024年2月6日 22点热度 0人点赞

導讀:如果你的代碼是用 Python 編寫的,你應該使用 Textual 來幫助你編寫 TUI(文本用戶界面)。

快速入門使用 Textual

Python 在 Linux 上有像 TkInterdocs.python.org 這樣的優秀 GUI(圖形用戶界面)開發庫,但如果你不能運行圖形應用程序怎麼辦?

文本終端,並非隻在 Linux 上有,而且 BSD 和其它的出色的類 Unix 操作系統上也有。如果你的代碼是用 Python 編寫的,你應該使用
Textualtextual.textualize.io 來幫助你編寫 TUI(文本用戶界面)。在這個快速介紹中,我將向你展示兩個你可以用 Textual 做的示例,並且介紹它未來可能的應用方向。

所以 Textual 是什麼?

Textual 是一個為 Python 構建的快速應用程序開發框架,由
Textualize.ioTextualize.io 構建。它可以讓你用簡單的 Python API 構建復雜的用戶界面,並運行在終端或網絡瀏覽器上!

你需要的跟進這個教程的工具

你需要有以下條件:

1. 具備基礎的編程經驗,最好熟練使用 Python。

2. 理解基礎的面向對象概念,比如類和繼承。

3. 一臺安裝了 Linux 與 Python 3.9 的機器

4. 一款好的編輯器(Vim 或者 PyCharm 是不錯的選擇)

我盡可能簡單化代碼,以便你能輕松理解。此外,我強烈建議你下載代碼,或至少按照接下來的說明安裝相關程序。

安裝步驟

首先創建一個虛擬環境:

python3 -m venv ~/virtualenv/Textualize

現在,你可以克隆 Git 倉庫並創建一個可以編輯的發佈版本:

. ~/virtualenv/Textualize/bin/activate
pip install --upgrade pip
pip install --upgrade wheel
pip install --upgrade build
pip install --editable .

或者直接從 Pypi.orgPypi.org 安裝:

. ~/virtualenv/Textualize/bin/activate
pip install --upgrade KodegeekTextualize

我們的首個程序:日志瀏覽器

這個 日志瀏覽器 就是一款簡單的應用,能執行用戶 PATHmanpages.org 路徑上的一系列 UNIX 命令,並在任務執行完畢後捕獲輸出。

以下是該應用的代碼:

import shutil
from textual import on
from textual.app import ComposeResult, App
from textual.widgets import Footer, Header, Button, SelectionList
from textual.widgets.selection_list import Selection
from textual.screen import ModalScreen
# Operating system commands are hardcoded
OS_COMMANDS = {
    "LSHW": ["lshw", "-json", "-sanitize", "-notime", "-quiet"],
    "LSCPU": ["lscpu", "--all", "--extended", "--json"],
    "LSMEM": ["lsmem", "--json", "--all", "--output-all"],
    "NUMASTAT": ["numastat", "-z"]
}
class LogScreen(ModalScreen):
    # ... Code of the full separate screen omitted, will be explained next
    def __init__(self, name = None, ident = None, classes = None, selections = None):
        super().__init__(name, ident, classes)
        pass
class OsApp(App):
    BINDINGS = [
        ("q", "quit_app", "Quit"),
    ]
    CSS_PATH = "os_app.tcss"
    ENABLE_COMMAND_PALETTE = False  # Do not need the command palette
    def action_quit_app(self):
        self.exit(0)
    def compose(self) -> ComposeResult:
        # Create a list of commands, valid commands are assumed to be on the PATH variable.
        selections = [Selection(name.title(), ' '.join(cmd), True) for name, cmd in OS_COMMANDS.items() if shutil.which(cmd[0].strip())]
        yield Header(show_clock=False)
        sel_list = SelectionList(*selections, id='cmds')
        sel_list.tooltip = "Select one more more command to execute"
        yield sel_list
        yield Button(f"Execute {len(selections)} commands", id="exec", variant="primary")
        yield Footer()
    @on(SelectionList.SelectedChanged)
    def on_selection(self, event: SelectionList.SelectedChanged) -> None:
        button = self.query_one("#exec", Button)
        selections = len(event.selection_list.selected)
        if selections:
            button.disabled = False
        else:
            button.disabled = True
        button.label = f"Execute {selections} commands"
    @on(Button.Pressed)
    def on_button_click(self):
        selection_list = self.query_one('#cmds', SelectionList)
        selections = selection_list.selected
        log_screen = LogScreen(selections=selections)
        self.push_screen(log_screen)
def main():
    app = OsApp()
    app.title = f"Output of multiple well known UNIX commands".title()
    app.sub_title = f"{len(OS_COMMANDS)} commands available"
    app.run()
if __name__ == "__main__":
    main()

現在我們逐條梳理一下程序的代碼:

1. 每個應用都擴展自 App 類。其中最重要的有 composemount 等方法。但在當前應用中,我們隻實現了
composetextual.textualize.io。

2. 在 compose 方法中,你會返回一系列 組件textual.textualize.io(Widget),並按順序添加到主屏幕中。每一個組件都有定制自身外觀的選項。

3. 你可以設定單字母的 綁定textual.textualize.io(binding),比如此處我們設定了 q 鍵來退出應用(參見 action_quit_app 函數和 BINDINGS 列表)。

4. 利用 SelectionList 組件,我們展示了待運行的命令列表。然後,你可以通過 @on(
SelectionList.SelectedChanged)
註解以及 on_selection 方法告知應用獲取所選的內容。

5. 對於無選定元素的應對很重要,我們會根據運行的命令數量來決定是否禁用 “exec” 按鈕。

6. 我們使用類似的監聽器( @on(Button.Pressed) )來執行命令。我們做的就是將我們的選擇送到一個新的屏幕,該屏幕會負責執行命令並收集結果。

你註意到 CSS_PATH = "os_app.tcss" 這個變量了嗎?Textual 允許你使用 CSS 來控制單個或多個組件的外觀(色彩、位置、尺寸):

Screen {
        layout: vertical;
}
Header {
        dock: top;
}
Footer {
        dock: bottom;
}
SelectionList {
        padding: 1;
        border: solid $accent;
        width: 1fr;
        height: 80%;
}
Button {
        width: 1fr
}

引自 Textual 官方網站:

Textual 中使用的 CSS 是互聯網上常見 CSS 的簡化版本,容易上手。

這真是太棒了,隻需要用一個獨立的 樣式表textual.textualize.io,就可以輕松調整應用的樣式。

好,我們現在來看看如何在新屏幕上展示結果。

在新屏幕上展示結果

以下是在新屏幕上處理輸出的代碼:

import asyncio
from typing import List
from textual import on, work
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import Button, Label, Log
from textual.worker import Worker
from textual.app import ComposeResult
class LogScreen(ModalScreen):
    count = reactive(0)
    MAX_LINES = 10_000
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "log_screen.tcss"
    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            selections: List = None
    ):
        super().__init__(name, ident, classes)
        self.selections = selections
    def compose(self) -> ComposeResult:
        yield Label(f"Running {len(self.selections)} commands")
        event_log = Log(
            id='event_log',
            max_lines=LogScreen.MAX_LINES,
            highlight=True
        )
        event_log.loading = True
        yield event_log
        button = Button("Close", id="close", variant="success")
        button.disabled = True
        yield button
    async def on_mount(self) -> None:
        event_log = self.query_one('#event_log', Log)
        event_log.loading = False
        event_log.clear()
        lst = '\n'.join(self.selections)
        event_log.write(f"Preparing:\n{lst}")
        event_log.write("\n")
        for command in self.selections:
            self.count  = 1
            self.run_process(cmd=command)
    def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        if self.count == 0:
            button = self.query_one('#close', Button)
            button.disabled = False
        self.log(event)
    @work(exclusive=False)
    async def run_process(self, cmd: str) -> None:
        event_log = self.query_one('#event_log', Log)
        event_log.write_line(f"Running: {cmd}")
        # Combine STDOUT and STDERR output
        proc = await asyncio.create_subprocess_shell(
            cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT
        )
        stdout, _ = await proc.communicate()
        if proc.returncode != 0:
            raise ValueError(f"'{cmd}' finished with errors ({proc.returncode})")
        stdout = stdout.decode(encoding='utf-8', errors='replace')
        if stdout:
            event_log.write(f'\nOutput of "{cmd}":\n')
            event_log.write(stdout)
        self.count -= 1
    @on(Button.Pressed, "#close")
    def on_button_pressed(self, _) -> None:
        self.app.pop_screen()

你會註意到:

1. LogScreen 類擴展自 ModalScreen 類, 該類負責處理模態模式的屏幕。

2. 這個屏幕同樣有一個 compose 方法,我們在這裡添加了組件以展示 Unix 命令的內容。

3. 我們創建了一個叫做 mount 的新方法。一旦你用 compose 編排好組件,你就可以運行代碼來獲取數據,並再進一步定制它們的外觀。

4. 我們使用 asynciodocs.python.org 運行命令,這樣我們就能讓 TUI 主工作線程在每個命令的結果出來時就及時更新內容。

5. 對於“工作線程”,請註意 run_process 方法上的 @work(exclusive=False) 註解,該方法用於運行命令並捕獲 STDOUT STDERR 輸出。使用 工作線程textual.textualize.io 來管理並發並不復雜,盡管它們在手冊中確實有專門的章節。這主要是因為運行的外部命令可能會執行很長時間。

6. 在 run_process 中,我們通過調用 write 以命令的輸出內容來更新 event_log

7. 最後,on_button_pressed 把我們帶回到前一屏幕(從堆棧中移除屏幕)。

這個小應用向你展示了如何一份不到 200 行的代碼來編寫一個簡單的前端,用來運行非 Python 代碼。

現在我們來看一個更復雜的例子,這個例子用到了我們還未探索過的 Textual 的新特性。

示例二:展示賽事成績的表格

通過 Textual 創建的表格應用

本示例將展示如何使用 DataTable 組件在表格中展示賽事成績。你能通過這個應用實現:

◈ 通過列來排序表格

◈ 選擇表格中的行,完整窗口展示賽事細節,我們將使用我們在日志瀏覽器中看到的 “推送屏幕” 技巧。

◈ 能進行表格搜索,查看選手詳情,或執行其他操作如退出應用。

下面,我們來看看應用代碼:

#!/usr/bin/env python
"""
Author: Jose Vicente Nunez
"""
from typing import Any, List
from rich.style import Style
from textual import on
from textual.app import ComposeResult, App
from textual.command import Provider
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Footer, Header
MY_DATA = [
    ("level", "name", "gender", "country", "age"),
    ("Green", "Wai", "M", "MYS", 22),
    ("Red", "Ryoji", "M", "JPN", 30),
    ("Purple", "Fabio", "M", "ITA", 99),
    ("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "details_screen.tcss"
    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        # Rest of screen code will be show later
class CustomCommand(Provider):
    def __init__(self, screen: Screen[Any], match_style: Style | None = None):
        super().__init__(screen, match_style)
        self.table = None
        # Rest of provider code will be show later
class CompetitorsApp(App):
    BINDINGS = [
        ("q", "quit_app", "Quit"),
    ]
    CSS_PATH = "competitors_app.tcss"
    # Enable the command palette, to add our custom filter commands
    ENABLE_COMMAND_PALETTE = True
    # Add the default commands and the TablePopulateProvider to get a row directly by name
    COMMANDS = App.COMMANDS | {CustomCommand}
    def action_quit_app(self):
        self.exit(0)
    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        table = DataTable(id=f'competitors_table')
        table.cursor_type = 'row'
        table.zebra_stripes = True
        table.loading = True
        yield table
        yield Footer()
    def on_mount(self) -> None:
        table = self.get_widget_by_id(f'competitors_table', expect_type=DataTable)
        columns = [x.title() for x in MY_DATA[0]]
        table.add_columns(*columns)
        table.add_rows(MY_DATA[1:])
        table.loading = False
        table.tooltip = "Select a row to get more details"
    @on(DataTable.HeaderSelected)
    def on_header_clicked(self, event: DataTable.HeaderSelected):
        table = event.data_table
        table.sort(event.column_key)
    @on(DataTable.RowSelected)
    def on_row_clicked(self, event: DataTable.RowSelected) -> None:
        table = event.data_table
        row = table.get_row(event.row_key)
        runner_detail = DetailScreen(row=row)
        self.show_detail(runner_detail)
    def show_detail(self, detailScreen: DetailScreen):
        self.push_screen(detailScreen)
def main():
    app = CompetitorsApp()
    app.title = f"Summary".title()
    app.sub_title = f"{len(MY_DATA)} users"
    app.run()
if __name__ == "__main__":
    main()

有哪些部分值得我們關註呢?

1. compose 方法中添加了 表頭textual.textualize.io,“命令面板” 就位於此處,我們的表格(
DataTabletextual.textualize.io)也在這裡。表格數據在
mount 方法中填充。

2. 我們設定了預期的綁定(BINDINGS),並指定了外部的 CSS 文件來設置樣式(CSS_PATH)。

3. 默認情況下,我們無需任何設置便能使用 命令面板textual.textualize.io,但在此我們顯式啟用了它(ENABLE_COMMAND_PALETTE = True)。

4. 我們的應用有一個自定義表格搜索功能。當用戶輸入一名選手的名字後,應用會顯示可能的匹配項,用戶可以點擊匹配項查看該選手的詳細信息。這需要告訴應用我們有一個定制的命令提供者(COMMANDS = App.COMMANDS | {CustomCo_ mmand}),即類 CustomCommand(Provider)

5. 如果用戶點擊了表頭,表格內容會按照該列進行排序。這是通過 on_header_clicked 方法實現的,該方法上具有 @on(DataTable.HeaderSelected) 註解。

6. 類似地,當選中表格中的一行時, on_row_clicked 方法會被調用,這得益於它擁有 @on(DataTable.RowSelected) 註解。當方法接受選中的行後,它會推送一個新的屏幕,顯示選中行的詳細信息(class DetailScreen(ModalScreen))。

現在,我們詳細地探討一下如何顯示選手的詳細信息。

利用多屏展示復雜視圖

當用戶選擇表格中的一行,on_row_clicked 方法就會被調用。它收到的是一個 DataTable.RowSelected 類型的事件。從這裡我們會用選中的行的內容構建一個 DetailScreen(ModalScreen) 類的實例:

from typing import Any, List
from textual import on
from textual.app import ComposeResult
from textual.screen import ModalScreen
from textual.widgets import Button, MarkdownViewer
MY_DATA = [
    ("level", "name", "gender", "country", "age"),
    ("Green", "Wai", "M", "MYS", 22),
    ("Red", "Ryoji", "M", "JPN", 30),
    ("Purple", "Fabio", "M", "ITA", 99),
    ("Blue", "Manuela", "F", "VEN", 25)
]
class DetailScreen(ModalScreen):
    ENABLE_COMMAND_PALETTE = False
    CSS_PATH = "details_screen.tcss"
    def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        self.row: List[Any] = row
    def compose(self) -> ComposeResult:
        self.log.info(f"Details: {self.row}")
        columns = MY_DATA[0]
        row_markdown = "\n"
        for i in range(0, len(columns)):
            row_markdown  = f"* **{columns[i].title()}:** {self.row[i]}\n"
        yield MarkdownViewer(f"""## User details:
        {row_markdown}
        """)
        button = Button("Close", variant="primary", id="close")
        button.tooltip = "Go back to main screen"
        yield button
    @on(Button.Pressed, "#close")
    def on_button_pressed(self, _) -> None:
        self.app.pop_screen()

這個類的職責很直接:

1. compose 方法取得此行數據,並利用一個 支持 Markdown 渲染的組件textual.textualize.io 來展示內容。它的便利之處在於,它會為我們自動生成一個內容目錄。

2. 當用戶點擊 “close” 後,方法 on_button_pressed 會引導應用回到原始屏幕。註解 @on(Button.Pressed, "#close") 用來接收按鍵被點擊的事件。

最後,我們來詳細講解一下那個多功能的搜索欄(也叫做命令面板)。

命令面板的搜索功能

任何使用了表頭的 Textual 應用都默認開啟了 命令面板textual.textualize.io。有意思的是,你可以在 CompetitorsApp 類中添加自定義的命令,這會增加到默認命令集之上:

COMMANDS = App.COMMANDS | {CustomCommand}

然後是執行大部分任務的 CustomCommand(Provider) 類:

from functools import partial
from typing import Any, List
from rich.style import Style
from textual.command import Provider, Hit
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable
from textual.app import App
class CustomCommand(Provider):
    def __init__(self, screen: Screen[Any], match_style: Style | None = None):
        super().__init__(screen, match_style)
        self.table = None
    async def startup(self) -> None:
        my_app = self.app
        my_app.log.info(f"Loaded provider: CustomCommand")
        self.table = my_app.query(DataTable).first()
    async def search(self, query: str) -> Hit:
        matcher = self.matcher(query)
        my_app = self.screen.app
        assert isinstance(my_app, CompetitorsApp)
        my_app.log.info(f"Got query: {query}")
        for row_key in self.table.rows:
            row = self.table.get_row(row_key)
            my_app.log.info(f"Searching {row}")
            searchable = row[1]
            score = matcher.match(searchable)
            if score > 0:
                runner_detail = DetailScreen(row=row)
                yield Hit(
                    score,
                    matcher.highlight(f"{searchable}"),
                    partial(my_app.show_detail, runner_detail),
                    help=f"Show details about {searchable}"
                )
class DetailScreen(ModalScreen):
        def __init__(
            self,
            name: str | None = None,
            ident: str | None = None,
            classes: str | None = None,
            row: List[Any] | None = None,
    ):
        super().__init__(name, ident, classes)
        # Code of this class explained on the previous section
class CompetitorsApp(App):
    # Add the default commands and the TablePopulateProvider to get a row directly by name
    COMMANDS = App.COMMANDS | {CustomCommand}
    # Most of the code shown before, only displaying relevant code
    def show_detail(self, detailScreen: DetailScreen):
        self.push_screen(detailScreen)

1. 所有繼承自 Provider 的類需實現 search 方法。在我們的例子中,我們還覆蓋了 startup 方法,為了獲取到我們應用表格(和其內容)的引用,這裡使用到了 App.query(DataTable).first()。在類的生命周期中, startup 方法隻會被調用一次。

2. 在 search 方法內,我們使用 Provider.matcher 對每個表格行的第二列(即名字)進行模糊搜索,以與用戶在 TUI 中輸入的詞條進行比較。matcher.match(searchable) 返回一個整型的評分,大於零說明匹配成功。

3. 在 search 方法中,如果評分大於零,則返回一個 Hit 對象,以告知命令面板搜索查詢是否成功。

4. 每個 Hit 都有以下信息:評分(用於在命令面板中對匹配項排序)、高亮顯示的搜索詞、一個可調用對象的引用(在我們的案例中,它是一個可以將表格行推送到新屏幕的函數)。

5. Provider 類的所有方法都是異步的。這使你能釋放主線程,隻有當響應準備好後才返回結果,這個過程不會凍結用戶界面。

理解了這些信息,我們就可以現在展示賽手的詳細信息了。

盡管這個架構的追蹤功能相對直觀,但是組件間傳遞的消息復雜性不可忽視。幸運的是,Textual 提供了有效的調試工具幫助我們理解背後的工作原理。

Textual 應用的問題排查

對於 Python 的 Textual 應用進行 調試github.com 相較而言更具挑戰性。這是因為其中有一些操作可能是異步的,而在解決組件問題時設置斷點可能頗為復雜。

根據具體情況,你可以使用一些工具。但首先,確保你已經安裝了 textual 的開發工具:

pip install textual-dev==1.3.0

確保你能捕捉到正確的按鍵

不確定 Textual 應用是否能捕捉到你的按鍵操作?運行 keys 應用:

textual keys

這讓你能夠驗證一下你的按鍵組合,並確認在 Textual 中產生了哪些事件。

圖片比千言萬語更直觀

如果說你在佈局設計上遇到了問題,想向他人展示你當前的困境,Textual 為你的運行應用提供了截圖功能:

textual run --screenshot 5 ./kodegeek_textualize/log_scroller.py

就像你所看到的,我是通過這種方式為這篇教程創建了插圖。

捕獲事件並輸出定制消息

在 Textual 中,每一個應用實例都有一個日志記錄器,可以使用如下方式訪問:

my_app = self.screen.app
my_app.log.info(f"Loaded provider: CustomCommand")

想要查看這些消息,首先需要開啟一個控制臺:

. ~/virtualenv/Textualize/bin/activate
textual console

然後在另一個終端運行你的應用程序:

. ~/virtualenv/Textualize/bin/activate
textual run --dev ./kodegeek_textualize/log_scroller.py

在運行控制臺的終端中,你可以看到實時的事件和消息輸出:

▌Textual Development Console v0.46.0
▌Run a Textual app with textual run --dev my_app.py to connect.
▌Press Ctrl C to quit.
─────────────────────────────────────────────────────────────────────────────── Client '127.0.0.1' connected ────────────────────────────────────────────────────────────────────────────────
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2188
Connected to devtools ( ws://127.0.0.1:8081 )
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2192
---
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2194
driver=<class 'textual.drivers.linux_driver.LinuxDriver'>
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2195
loop=<_UnixSelectorEventLoop running=True closed=False debug=False>
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2196
features=frozenset({'debug', 'devtools'})
[20:29:43] SYSTEM                                                                                                                                                                 app.py:2228
STARTED FileMonitor({PosixPath('/home/josevnz/TextualizeTutorial/docs/Textualize/kodegeek_textualize/os_app.tcss')})
[20:29:43] EVENT

此外,以開發者模式運行的另一大好處是,如果你更改了 CSS,應用會嘗試重新渲染,而無需重啟程序。

如何編寫單元測試

為你全新開發的 Textual 應用編寫 單元測試docs.python.org,應該如何操作呢?

在 官方文檔textual.textualize.io 展示了幾種用於測試我們應用的方式。

我將采用 unittestdocs.python.org 進行測試。為了處理異步例程,我們會需要特別的類
unittest.IsolatedAsyncioTestCase

import unittest
from textual.widgets import Log, Button
from kodegeek_textualize.log_scroller import OsApp
class LogScrollerTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_log_scroller(self):
        app = OsApp()
        self.assertIsNotNone(app)
        async with app.run_test() as pilot:
            # Execute the default commands
            await pilot.click(Button)
            await pilot.pause()
            event_log = app.screen.query(Log).first()  # We pushed the screen, query nodes from there
            self.assertTrue(event_log.lines)
            await pilot.click("#close")  # Close the new screen, pop the original one
            await pilot.press("q")  # Quit the app by pressing q
if __name__ == '__main__':
    unittest.main()

現在讓我們詳細看看 test_log_scroller 方法中的操作步驟:

1. 通過 app.run_test() 獲取一個 Pilot 實例。然後點擊主按鈕,運行包含默認指令的查詢,隨後等待所有事件的處理。

2. 從我們新推送出的屏幕中獲取 Log,確保我們已獲得幾行返回的內容,即它並非空的。

3. 關閉新屏幕並重新呈現舊屏幕。

4. 最後,按下 q,退出應用。

可以測試表格嗎?

import unittest
from textual.widgets import DataTable, MarkdownViewer
from kodegeek_textualize.table_with_detail_screen import CompetitorsApp
class TableWithDetailTestCase(unittest.IsolatedAsyncioTestCase):
    async def test_app(self):
        app = CompetitorsApp()
        self.assertIsNotNone(app)
        async with app.run_test() as pilot:
            """
            Test the command palette
            """
            await pilot.press("ctrl \\")
            for char in "manuela".split():
                await pilot.press(char)
            await pilot.press("enter")
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer.document)
            await pilot.click("#close")  # Close the new screen, pop the original one
            """
            Test the table
            """
            table = app.screen.query(DataTable).first()
            coordinate = table.cursor_coordinate
            self.assertTrue(table.is_valid_coordinate(coordinate))
            await pilot.press("enter")
            await pilot.pause()
            markdown_viewer = app.screen.query(MarkdownViewer).first()
            self.assertTrue(markdown_viewer)
            # Quit the app by pressing q
            await pilot.press("q")
if __name__ == '__main__':
    unittest.main()

如果你運行所有的測試,你將看到如下類似的輸出:

(Textualize) [josevnz@dmaf5 Textualize]$ python -m unittest tests/*.py
..
----------------------------------------------------------------------
Ran 2 tests in 2.065s
OK

這是測試 TUI 的一個不錯的方式,對吧?

打包 Textual 應用

打包 Textual 應用與打包常規 Python 應用並沒有太大區別。你需要記住,需要包含那些控制應用外觀的 CSS 文件:

. ~/virtualenv/Textualize/bin/activate
python -m build
pip install dist/KodegeekTextualize-*-py3-none-any.whl

這個教程的
pyproject.tomltutorials.kodegeek.com 文件是一個打包應用的良好起點,告訴你需要做什麼。

[build-system]
requires = [
    "setuptools >= 67.8.0",
    "wheel>=0.42.0",
    "build>=1.0.3",
    "twine>=4.0.2",
    "textual-dev>=1.2.1"
]
build-backend = "setuptools.build_meta"
[project]
name = "KodegeekTextualize"
version = "0.0.3"
authors = [
    {name = "Jose Vicente Nunez", email = "[email protected]"},
]
description = "Collection of scripts that show how to use several features of textualize"
readme = "README.md"
requires-python = ">=3.9"
keywords = ["running", "race"]
classifiers = [
    "Environment :: Console",
    "Development Status :: 4 - Beta",
    "Programming Language :: Python :: 3",
    "Intended Audience :: End Users/Desktop",
    "Topic :: Utilities"
]
dynamic = ["dependencies"]
[project.scripts]
log_scroller = "kodegeek_textualize.log_scroller:main"
table_detail = "kodegeek_textualize.table_with_detail_screen:main"
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
exclude = ["test*"]
[tool.setuptools.package-data]
kodegeek_textualize = ["*.txt", "*.tcss", "*.csv"]
img = ["*.svg"]
[tool.setuptools.dynamic]
dependencies = {file = ["requirements.txt"]}

未來計劃

這個簡短的教程隻覆蓋了 Textual 的部分方面。還有很多需要探索和學習的內容:

◈ 強烈建議你查看 官方教程textual.textualize.io。有大量的示例和指向參考 APItextual.textualize.io 的鏈接。

◈ Textual 可以使用來自 Richgithub.com 項目的組件,這個項目是一切的起源。我認為其中一些甚至可能所有這些組件在某些時候都會合並到 Textual 中。Textual 框架對於使用高級 API 的復雜應用更能勝任,但 Rich 也有很多漂亮的功能。

◈ 創建你自己的組件!同樣,在設計 TUI 時,拿一張紙,畫出你希望這些組件如何佈局的textual.textualize.io,這會為你後期省去很多時間和麻煩。

◈ 調試 Python 應用可能會有點復雜。有時你可能需要 混合使用不同的工具github.com 來找出應用的問題所在。

◈ 異步 IO 是一個復雜的話題,你應該 閱讀開發者文檔docs.python.org 來了解更多可能的選擇。

◈ Textual 被其他項目所使用。其中一個非常易於使用的項目是 Trogongithub.com。它會讓你的 CLI 可以自我發現github.com。

◈ Textual-webgithub.com 是個很有前景的項目,能讓你在瀏覽器上運行 Textual 應用。盡管它不如 Textual 成熟,但它的進化速度非常快。

◈ 最後,查看這些外部項目www.textualize.io。在項目組合中有許多有用的開源應用。


via: https://fedoramagazine.org/crash-course-on-using-textual/