Textual:為 Python 增加漂亮的文本用戶界面(TUI)
Python 在 Linux 上有像 TkInter 這樣的優(yōu)秀 GUI(圖形用戶界面)開發(fā)庫,但如果你不能運(yùn)行圖形應(yīng)用程序怎么辦?
文本終端,并非只在 Linux 上有,而且 BSD 和其它的出色的類 Unix 操作系統(tǒng)上也有。如果你的代碼是用 Python 編寫的,你應(yīng)該使用 Textual 來幫助你編寫 TUI(文本用戶界面)。在這個快速介紹中,我將向你展示兩個你可以用 Textual 做的示例,并且介紹它未來可能的應(yīng)用方向。
所以 Textual 是什么?
Textual 是一個為 Python 構(gòu)建的快速應(yīng)用程序開發(fā)框架,由 Textualize.io 構(gòu)建。它可以讓你用簡單的 Python API 構(gòu)建復(fù)雜的用戶界面,并運(yùn)行在終端或網(wǎng)絡(luò)瀏覽器上!
你需要的跟進(jìn)這個教程的工具
你需要有以下條件:
- 具備基礎(chǔ)的編程經(jīng)驗,最好熟練使用 Python。
- 理解基礎(chǔ)的面向?qū)ο蟾拍?,比如類和繼承。
- 一臺安裝了 Linux 與 Python 3.9+ 的機(jī)器
- 一款好的編輯器(Vim 或者 PyCharm 是不錯的選擇)
我盡可能簡單化代碼,以便你能輕松理解。此外,我強(qiáng)烈建議你下載代碼,或至少按照接下來的說明安裝相關(guān)程序。
安裝步驟
首先創(chuàng)建一個虛擬環(huán)境:
python3 -m venv ~/virtualenv/Textualize
現(xiàn)在,你可以克隆 Git 倉庫并創(chuàng)建一個可以編輯的發(fā)布版本:
. ~/virtualenv/Textualize/bin/activate
pip install --upgrade pip
pip install --upgrade wheel
pip install --upgrade build
pip install --editable .
或者直接從 Pypi.org 安裝:
. ~/virtualenv/Textualize/bin/activate
pip install --upgrade KodegeekTextualize
我們的首個程序:日志瀏覽器
這個 日志瀏覽器 就是一款簡單的應(yīng)用,能執(zhí)行用戶 PATH
以下是該應(yīng)用的代碼:
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()
現(xiàn)在我們逐條梳理一下程序的代碼:
- 每個應(yīng)用都擴(kuò)展自
App
類。其中最重要的有compose
與mount
等方法。但在當(dāng)前應(yīng)用中,我們只實現(xiàn)了 compose。 - 在
compose
方法中,你會返回一系列 組件Widget,并按順序添加到主屏幕中。每一個組件都有定制自身外觀的選項。 - 你可以設(shè)定單字母的 綁定binding,比如此處我們設(shè)定了
q
鍵來退出應(yīng)用(參見action_quit_app
函數(shù)和BINDINGS
列表)。 - 利用
SelectionList
組件,我們展示了待運(yùn)行的命令列表。然后,你可以通過@on(SelectionList.SelectedChanged)
注解以及on_selection
方法告知應(yīng)用獲取所選的內(nèi)容。 - 對于無選定元素的應(yīng)對很重要,我們會根據(jù)運(yùn)行的命令數(shù)量來決定是否禁用 “exec” 按鈕。
- 我們使用類似的監(jiān)聽器(
@on(Button.Pressed)
)來執(zhí)行命令。我們做的就是將我們的選擇送到一個新的屏幕,該屏幕會負(fù)責(zé)執(zhí)行命令并收集結(jié)果。
你注意到 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 官方網(wǎng)站:
Textual 中使用的 CSS 是互聯(lián)網(wǎng)上常見 CSS 的簡化版本,容易上手。
這真是太棒了,只需要用一哥獨立的 樣式表,就可以輕松調(diào)整應(yīng)用的樣式。
好,我們現(xiàn)在來看看如何在新屏幕上展示結(jié)果。
在新屏幕上展示結(jié)果
以下是在新屏幕上處理輸出的代碼:
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()
你會注意到:
LogScreen
類擴(kuò)展自ModalScreen
類, 該類負(fù)責(zé)處理模態(tài)模式的屏幕。- 這個屏幕同樣有一個
compose
方法,我們在這里添加了組件以展示 Unix 命令的內(nèi)容。 - 我們創(chuàng)建了一個叫做
mount
的新方法。一旦你用compose
編排好組件,你就可以運(yùn)行代碼來獲取數(shù)據(jù),并再進(jìn)一步定制它們的外觀。 - 我們使用 asyncio 運(yùn)行命令,這樣我們就能讓 TUI 主工作線程在每個命令的結(jié)果出來時就及時更新內(nèi)容。
- 對于“工作線程”,請注意
run_process
方法上的@work(exclusive=False)
注解,該方法用于運(yùn)行命令并捕獲 STDOUT + STDERR 輸出。使用 工作線程 來管理并發(fā)并不復(fù)雜,盡管它們在手冊中確實有專門的章節(jié)。這主要是因為運(yùn)行的外部命令可能會執(zhí)行很長時間。 - 在
run_process
中,我們通過調(diào)用write
以命令的輸出內(nèi)容來更新event_log
。 - 最后,
on_button_pressed
把我們帶回到前一屏幕(從堆棧中移除屏幕)。
這個小應(yīng)用向你展示了如何一份不到 200 行的代碼來編寫一個簡單的前端,用來運(yùn)行非 Python 代碼。
現(xiàn)在我們來看一個更復(fù)雜的例子,這個例子用到了我們還未探索過的 Textual 的新特性。
示例二:展示賽事成績的表格
通過 Textual 創(chuàng)建的表格應(yīng)用
通過 Textual 創(chuàng)建的表格應(yīng)用
本示例將展示如何使用 DataTable
組件在表格中展示賽事成績。你能通過這個應(yīng)用實現(xiàn):
- 通過列來排序表格
- 選擇表格中的行,完整窗口展示賽事細(xì)節(jié),我們將使用我們在日志瀏覽器中看到的 “推送屏幕” 技巧。
- 能進(jìn)行表格搜索,查看選手詳情,或執(zhí)行其他操作如退出應(yīng)用。
下面,我們來看看應(yīng)用代碼:
#!/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()
有哪些部分值得我們關(guān)注呢?
compose
方法中添加了 表頭,“命令面板” 就位于此處,我們的表格(DataTable)也在這里。表格數(shù)據(jù)在mount
方法中填充。- 我們設(shè)定了預(yù)期的綁定(
BINDINGS
),并指定了外部的 CSS 文件來設(shè)置樣式(CSS_PATH
)。 - 默認(rèn)情況下,我們無需任何設(shè)置便能使用 命令面板,但在此我們顯式啟用了它(
ENABLE_COMMAND_PALETTE = True
)。 - 我們的應(yīng)用有一個自定義表格搜索功能。當(dāng)用戶輸入一名選手的名字后,應(yīng)用會顯示可能的匹配項,用戶可以點擊匹配項查看該選手的詳細(xì)信息。這需要告訴應(yīng)用我們有一個定制的命令提供者(
COMMANDS = App.COMMANDS | {CustomCo_ mmand}
),即類CustomCommand(Provider)
。 - 如果用戶點擊了表頭,表格內(nèi)容會按照該列進(jìn)行排序。這是通過
on_header_clicked
方法實現(xiàn)的,該方法上具有@on(DataTable.HeaderSelected)
注解。 - 類似地,當(dāng)選中表格中的一行時,
on_row_clicked
方法會被調(diào)用,這得益于它擁有@on(DataTable.RowSelected)
注解。當(dāng)方法接受選中的行后,它會推送一個新的屏幕,顯示選中行的詳細(xì)信息(class DetailScreen(ModalScreen)
)。
現(xiàn)在,我們詳細(xì)地探討一下如何顯示選手的詳細(xì)信息。
利用多屏展示復(fù)雜視圖
當(dāng)用戶選擇表格中的一行,on_row_clicked
方法就會被調(diào)用。它收到的是一個 DataTable.RowSelected
類型的事件。從這里我們會用選中的行的內(nèi)容構(gòu)建一個 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()
這個類的職責(zé)很直接:
compose
方法取得此行數(shù)據(jù),并利用一個 支持 Markdown 渲染的組件 來展示內(nèi)容。它的便利之處在于,它會為我們自動生成一個內(nèi)容目錄。- 當(dāng)用戶點擊 “close” 后,方法
on_button_pressed
會引導(dǎo)應(yīng)用回到原始屏幕。注解@on(Button.Pressed, "#close")
用來接收按鍵被點擊的事件。
最后,我們來詳細(xì)講解一下那個多功能的搜索欄(也叫做命令面板)。
命令面板的搜索功能
任何使用了表頭的 Textual 應(yīng)用都默認(rèn)開啟了 命令面板。有意思的是,你可以在 CompetitorsApp
類中添加自定義的命令,這會增加到默認(rèn)命令集之上:
COMMANDS = App.COMMANDS | {CustomCommand}
然后是執(zhí)行大部分任務(wù)的 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)
- 所有繼承自
Provider
的類需實現(xiàn)search
方法。在我們的例子中,我們還覆蓋了startup
方法,為了獲取到我們應(yīng)用表格(和其內(nèi)容)的引用,這里使用到了App.query(DataTable).first()
。在類的生命周期中,startup
方法只會被調(diào)用一次。 - 在
search
方法內(nèi),我們使用Provider.matcher
對每個表格行的第二列(即名字)進(jìn)行模糊搜索,以與用戶在 TUI 中輸入的詞條進(jìn)行比較。matcher.match(searchable)
返回一個整型的評分,大于零說明匹配成功。 - 在
search
方法中,如果評分大于零,則返回一個Hit
對象,以告知命令面板搜索查詢是否成功。 - 每個
Hit
都有以下信息:評分(用于在命令面板中對匹配項排序)、高亮顯示的搜索詞、一個可調(diào)用對象的引用(在我們的案例中,它是一個可以將表格行推送到新屏幕的函數(shù))。 Provider
類的所有方法都是異步的。這使你能釋放主線程,只有當(dāng)響應(yīng)準(zhǔn)備好后才返回結(jié)果,這個過程不會凍結(jié)用戶界面。
理解了這些信息,我們就可以現(xiàn)在展示賽手的詳細(xì)信息了。
盡管這個架構(gòu)的追蹤功能相對直觀,但是組件間傳遞的消息復(fù)雜性不可忽視。幸運(yùn)的是,Textual 提供了有效的調(diào)試工具幫助我們理解背后的工作原理。
Textual 應(yīng)用的問題排查
對于 Python 的 Textual 應(yīng)用進(jìn)行 調(diào)試 相較而言更具挑戰(zhàn)性。這是因為其中有一些操作可能是異步的,而在解決組件問題時設(shè)置斷點可能頗為復(fù)雜。
根據(jù)具體情況,你可以使用一些工具。但首先,確保你已經(jīng)安裝了 textual 的開發(fā)工具:
pip install textual-dev==1.3.0
確保你能捕捉到正確的按鍵
不確定 Textual 應(yīng)用是否能捕捉到你的按鍵操作?運(yùn)行 keys 應(yīng)用:
textual keys
這讓你能夠驗證一下你的按鍵組合,并確認(rèn)在 Textual 中產(chǎn)生了哪些事件。
圖片比千言萬語更直觀
如果說你在布局設(shè)計上遇到了問題,想向他人展示你當(dāng)前的困境,Textual 為你的運(yùn)行應(yīng)用提供了截圖功能:
textual run --screenshot 5 ./kodegeek_textualize/log_scroller.py
就像你所看到的,我是通過這種方式為這篇教程創(chuàng)建了插圖。
捕獲事件并輸出定制消息
在 Textual 中,每一個應(yīng)用實例都有一個日志記錄器,可以使用如下方式訪問:
my_app = self.screen.app
my_app.log.info(f"Loaded provider: CustomCommand")
想要查看這些消息,首先需要開啟一個控制臺:
. ~/virtualenv/Textualize/bin/activate
textual console
然后在另一個終端運(yùn)行你的應(yīng)用程序:
. ~/virtualenv/Textualize/bin/activate
textual run --dev ./kodegeek_textualize/log_scroller.py
在運(yùn)行控制臺的終端中,你可以看到實時的事件和消息輸出:
▌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
此外,以開發(fā)者模式運(yùn)行的另一大好處是,如果你更改了 CSS,應(yīng)用會嘗試重新渲染,而無需重啟程序。
如何編寫單元測試
為你全新開發(fā)的 Textual 應(yīng)用編寫 單元測試,應(yīng)該如何操作呢?
在 官方文檔 展示了幾種用于測試我們應(yīng)用的方式。
我將采用 unittest 進(jìn)行測試。為了處理異步例程,我們會需要特別的類 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()
現(xiàn)在讓我們詳細(xì)看看 test_log_scroller
方法中的操作步驟:
- 通過
app.run_test()
獲取一個Pilot
實例。然后點擊主按鈕,運(yùn)行包含默認(rèn)指令的查詢,隨后等待所有事件的處理。 - 從我們新推送出的屏幕中獲取
Log
,確保我們已獲得幾行返回的內(nèi)容,即它并非空的。 - 關(guān)閉新屏幕并重新呈現(xiàn)舊屏幕。
- 最后,按下
q
,退出應(yīng)用。
可以測試表格嗎?
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()
如果你運(yùn)行所有的測試,你將看到如下類似的輸出:
(Textualize) [josevnz@dmaf5 Textualize]$ python -m unittest tests/*.py
..
----------------------------------------------------------------------
Ran 2 tests in 2.065s
OK
這是測試 TUI 的一個不錯的方式,對吧?
打包 Textual 應(yīng)用
打包 Textual 應(yīng)用與打包常規(guī) Python 應(yīng)用并沒有太大區(qū)別。你需要記住,需要包含那些控制應(yīng)用外觀的 CSS 文件:
. ~/virtualenv/Textualize/bin/activate
python -m build
pip install dist/KodegeekTextualize-*-py3-none-any.whl
這個教程的 pyproject.toml 文件是一個打包應(yīng)用的良好起點,告訴你需要做什么。
[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 = "kodegeek.com@protonmail.com"},
]
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 的部分方面。還有很多需要探索和學(xué)習(xí)的內(nèi)容:
- 強(qiáng)烈建議你查看 官方教程。有大量的示例和指向參考 API 的鏈接。
- Textual 可以使用來自 Rich 項目的組件,這個項目是一切的起源。我認(rèn)為其中一些甚至可能所有這些組件在某些時候都會合并到 Textual 中。Textual 框架對于使用高級 API 的復(fù)雜應(yīng)用更能勝任,但 Rich 也有很多漂亮的功能。
- 創(chuàng)建你自己的組件!同樣,在設(shè)計 TUI 時,拿一張紙,畫出你希望這些組件如何布局的,這會為你后期省去很多時間和麻煩。
- 調(diào)試 Python 應(yīng)用可能會有點復(fù)雜。有時你可能需要 混合使用不同的工具 來找出應(yīng)用的問題所在。
- 異步 IO 是一個復(fù)雜的話題,你應(yīng)該 閱讀開發(fā)者文檔 來了解更多可能的選擇。
- Textual 被其他項目所使用。其中一個非常易于使用的項目是 Trogon。它會讓你的 CLI 可以自我發(fā)現(xiàn)。
- Textual-web 是個很有前景的項目,能讓你在瀏覽器上運(yùn)行 Textual 應(yīng)用。盡管它不如 Textual 成熟,但它的進(jìn)化速度非常快。
- 最后,查看這些外部項目。在項目組合中有許多有用的開源應(yīng)用。