mirror of
https://github.com/LCTT/TranslateProject.git
synced 2024-12-23 21:20:42 +08:00
parent
ad98336294
commit
d7a3266cb6
846
published/20240110 Crash Course On Using Textual.md
Normal file
846
published/20240110 Crash Course On Using Textual.md
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
[#]: subject: "Crash Course On Using Textual"
|
||||||
|
[#]: via: "https://fedoramagazine.org/crash-course-on-using-textual/"
|
||||||
|
[#]: author: "Jose Nunez https://fedoramagazine.org/author/josevnz/"
|
||||||
|
[#]: collector: "lujun9972/lctt-scripts-1700446145"
|
||||||
|
[#]: translator: "ChatGPT"
|
||||||
|
[#]: reviewer: "wxy"
|
||||||
|
[#]: publisher: "wxy"
|
||||||
|
[#]: url: "https://linux.cn/article-16557-1.html"
|
||||||
|
|
||||||
|
Textual:为 Python 增加漂亮的文本用户界面(TUI)
|
||||||
|
======
|
||||||
|
|
||||||
|
![][0]
|
||||||
|
|
||||||
|
### 快速入门使用 Textual
|
||||||
|
|
||||||
|
Python 在 Linux 上有像 [TkInter][4] 这样的优秀 GUI(图形用户界面)开发库,但如果你不能运行图形应用程序怎么办?
|
||||||
|
|
||||||
|
文本终端,并非只在 Linux 上有,而且 BSD 和其它的出色的类 Unix 操作系统上也有。如果你的代码是用 Python 编写的,你应该使用 [Textual][5] 来帮助你编写 TUI(文本用户界面)。在这个快速介绍中,我将向你展示两个你可以用 Textual 做的示例,并且介绍它未来可能的应用方向。
|
||||||
|
|
||||||
|
所以 Textual 是什么?
|
||||||
|
|
||||||
|
> Textual 是一个为 Python 构建的快速应用程序开发框架,由 Textualize.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.org 安装:
|
||||||
|
|
||||||
|
```
|
||||||
|
. ~/virtualenv/Textualize/bin/activate
|
||||||
|
pip install --upgrade KodegeekTextualize
|
||||||
|
```
|
||||||
|
|
||||||
|
### 我们的首个程序:日志浏览器
|
||||||
|
|
||||||
|
![][6]
|
||||||
|
|
||||||
|
这个 _日志浏览器_ 就是一款简单的应用,能执行用户 _[PATH][7]_ 路径上的一系列 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` 类。其中最重要的有 `compose` 与 `mount` 等方法。但在当前应用中,我们只实现了 [compose][8]。
|
||||||
|
2. 在 `compose` 方法中,你会返回一系列 <ruby>[组件][9]<rt>Widget</rt></ruby>,并按顺序添加到主屏幕中。每一个组件都有定制自身外观的选项。
|
||||||
|
3. 你可以设定单字母的 <ruby>[绑定][10]<rt>binding</rt></ruby>,比如此处我们设定了 `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 的简化版本,容易上手。
|
||||||
|
|
||||||
|
_这真是太棒了_,只需要用一哥独立的 [样式表][12],就可以轻松调整应用的样式。
|
||||||
|
|
||||||
|
好,我们现在来看看如何在新屏幕上展示结果。
|
||||||
|
|
||||||
|
#### 在新屏幕上展示结果
|
||||||
|
|
||||||
|
![][13]
|
||||||
|
|
||||||
|
以下是在新屏幕上处理输出的代码:
|
||||||
|
|
||||||
|
```
|
||||||
|
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. 我们使用 [asyncio][14] 运行命令,这样我们就能让 TUI 主工作线程在每个命令的结果出来时就及时更新内容。
|
||||||
|
5. 对于“工作线程”,请注意 `run_process` 方法上的 `@work(exclusive=False)` 注解,该方法用于运行命令并捕获 STDOUT + STDERR 输出。使用 [工作线程][15] 来管理并发并不复杂,尽管它们在手册中确实有专门的章节。这主要是因为运行的外部命令可能会执行很长时间。
|
||||||
|
6. 在 `run_process` 中,我们通过调用 `write` 以命令的输出内容来更新 `event_log`。
|
||||||
|
7. 最后,`on_button_pressed` 把我们带回到前一屏幕(从堆栈中移除屏幕)。
|
||||||
|
|
||||||
|
这个小应用向你展示了如何一份不到 200 行的代码来编写一个简单的前端,用来运行非 Python 代码。
|
||||||
|
|
||||||
|
现在我们来看一个更复杂的例子,这个例子用到了我们还未探索过的 Textual 的新特性。
|
||||||
|
|
||||||
|
### 示例二:展示赛事成绩的表格
|
||||||
|
|
||||||
|
![通过 Textual 创建的表格应用][16]
|
||||||
|
|
||||||
|
本示例将展示如何使用 `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` 方法中添加了 [表头][17],“命令面板” 就位于此处,我们的表格([DataTable][18])也在这里。表格数据在 `mount` 方法中填充。
|
||||||
|
2. 我们设定了预期的绑定(`BINDINGS`),并指定了外部的 CSS 文件来设置样式(`CSS_PATH`)。
|
||||||
|
3. 默认情况下,我们无需任何设置便能使用 [命令面板][19],但在此我们显式启用了它(`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)`)。
|
||||||
|
|
||||||
|
现在,我们详细地探讨一下如何显示选手的详细信息。
|
||||||
|
|
||||||
|
#### 利用多屏展示复杂视图
|
||||||
|
|
||||||
|
![][20]
|
||||||
|
|
||||||
|
当用户选择表格中的一行,`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 渲染的组件][21] 来展示内容。它的便利之处在于,它会为我们自动生成一个内容目录。
|
||||||
|
2. 当用户点击 “close” 后,方法 `on_button_pressed` 会引导应用回到原始屏幕。注解 `@on(Button.Pressed, "#close")` 用来接收按键被点击的事件。
|
||||||
|
|
||||||
|
最后,我们来详细讲解一下那个多功能的搜索栏(也叫做命令面板)。
|
||||||
|
|
||||||
|
#### 命令面板的搜索功能
|
||||||
|
|
||||||
|
![][22]
|
||||||
|
|
||||||
|
任何使用了表头的 Textual 应用都默认开启了 [命令面板][19]。有意思的是,你可以在 `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 应用进行 [调试][23] 相较而言更具挑战性。这是因为其中有一些操作可能是异步的,而在解决组件问题时设置断点可能颇为复杂。
|
||||||
|
|
||||||
|
根据具体情况,你可以使用一些工具。但首先,确保你已经安装了 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 应用编写 [单元测试][24],应该如何操作呢?
|
||||||
|
|
||||||
|
在 [官方文档][25] 展示了几种用于测试我们应用的方式。
|
||||||
|
|
||||||
|
我将采用 [unittest][24] 进行测试。为了处理异步例程,我们会需要特别的类 `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.toml][26] 文件是一个打包应用的良好起点,告诉你需要做什么。
|
||||||
|
|
||||||
|
```
|
||||||
|
[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 的部分方面。还有很多需要探索和学习的内容:
|
||||||
|
|
||||||
|
* 强烈建议你查看 [官方教程][27]。有大量的示例和指向参考 [API][28] 的链接。
|
||||||
|
* Textual 可以使用来自 [Rich][29] 项目的组件,这个项目是一切的起源。我认为其中一些甚至可能所有这些组件在某些时候都会合并到 Textual 中。Textual 框架对于使用高级 API 的复杂应用更能胜任,但 Rich 也有很多漂亮的功能。
|
||||||
|
* 创建你自己的组件!同样,在设计 TUI 时,[拿一张纸,画出你希望这些组件如何布局的][30],这会为你后期省去很多时间和麻烦。
|
||||||
|
* 调试 Python 应用可能会有点复杂。有时你可能需要 [混合使用不同的工具][31] 来找出应用的问题所在。
|
||||||
|
* 异步 IO 是一个复杂的话题,你应该 [阅读开发者文档][32] 来了解更多可能的选择。
|
||||||
|
* Textual 被其他项目所使用。其中一个非常易于使用的项目是 [Trogon][33]。[它会让你的 CLI 可以自我发现][34]。
|
||||||
|
* [Textual-web][35] 是个很有前景的项目,能让你在浏览器上运行 Textual 应用。尽管它不如 Textual 成熟,但它的进化速度非常快。
|
||||||
|
* 最后,[查看这些外部项目][36]。在项目组合中有许多有用的开源应用。
|
||||||
|
|
||||||
|
*(题图:DA/f11b0eb0-8e16-4cbe-986a-0fe978f6732a)*
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
via: https://fedoramagazine.org/crash-course-on-using-textual/
|
||||||
|
|
||||||
|
作者:[Jose Nunez][a]
|
||||||
|
选题:[lujun9972][b]
|
||||||
|
译者:[ChatGPT](https://linux.cn/lctt/ChatGPT)
|
||||||
|
校对:[wxy](https://github.com/wxy)
|
||||||
|
|
||||||
|
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出
|
||||||
|
|
||||||
|
[a]: https://fedoramagazine.org/author/josevnz/
|
||||||
|
[b]: https://github.com/lujun9972
|
||||||
|
[1]: https://fedoramagazine.org/wp-content/uploads/2024/01/textual_course-816x345.jpg
|
||||||
|
[2]: https://unsplash.com/@bernardhermant?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash
|
||||||
|
[3]: https://unsplash.com/photos/gray-and-clear-digital-wallpaper-VUBAE-Bmugk?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash
|
||||||
|
[4]: https://docs.python.org/3/library/tkinter.html
|
||||||
|
[5]: https://textual.textualize.io/
|
||||||
|
[6]: https://tutorials.kodegeek.com/Textualize/img/output_of_multiple_well_known_unix_commands_2023-12-28T19_13_32_605621.svg
|
||||||
|
[7]: https://manpages.org/bash
|
||||||
|
[8]: https://textual.textualize.io/tutorial/#composing-the-widgets
|
||||||
|
[9]: https://textual.textualize.io/widget_gallery/
|
||||||
|
[10]: https://textual.textualize.io/api/binding/
|
||||||
|
[11]: https://textual.textualize.io/guide/screens/
|
||||||
|
[12]: https://textual.textualize.io/guide/styles/
|
||||||
|
[13]: https://tutorials.kodegeek.com/Textualize/img/output_of_multiple_well_known_unix_commands_2023-12-28T19_13_40_503695.svg
|
||||||
|
[14]: https://docs.python.org/3/library/asyncio.html
|
||||||
|
[15]: https://textual.textualize.io/guide/workers/
|
||||||
|
[16]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_20_213933.svg
|
||||||
|
[17]: https://textual.textualize.io/widgets/header/
|
||||||
|
[18]: https://textual.textualize.io/widgets/data_table/#guide
|
||||||
|
[19]: https://textual.textualize.io/guide/command_palette/
|
||||||
|
[20]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_44_404837.svg
|
||||||
|
[21]: https://textual.textualize.io/widget_gallery/#markdownviewer
|
||||||
|
[22]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_55_822030.svg
|
||||||
|
[23]: https://github.com/josevnz/tutorials/blob/main/docs/PythonDebugger/README.md
|
||||||
|
[24]: https://docs.python.org/3/library/unittest.html
|
||||||
|
[25]: https://textual.textualize.io/guide/testing/
|
||||||
|
[26]: https://tutorials.kodegeek.com/Textualize/pyproject.toml
|
||||||
|
[27]: https://textual.textualize.io/tutorial/
|
||||||
|
[28]: https://textual.textualize.io/api/
|
||||||
|
[29]: https://github.com/Textualize/rich
|
||||||
|
[30]: https://textual.textualize.io/how-to/design-a-layout/
|
||||||
|
[31]: https://github.com/josevnz/DebuggingApplications/blob/main/StracePythonWireshark/README.md
|
||||||
|
[32]: https://docs.python.org/3/library/asyncio-dev.html
|
||||||
|
[33]: https://github.com/Textualize/trogon
|
||||||
|
[34]: https://github.com/josevnz/CLIWithClickAndTrogon/blob/main/README.md
|
||||||
|
[35]: https://github.com/Textualize/textual-web
|
||||||
|
[36]: https://www.textualize.io/projects/
|
||||||
|
[37]: https://github.com/josevnz
|
||||||
|
[0]: https://img.linux.net.cn/data/attachment/album/202401/15/110359fptrxpqffztniz45.jpg
|
@ -1,906 +0,0 @@
|
|||||||
[#]: subject: "Crash Course On Using Textual"
|
|
||||||
[#]: via: "https://fedoramagazine.org/crash-course-on-using-textual/"
|
|
||||||
[#]: author: "Jose Nunez https://fedoramagazine.org/author/josevnz/"
|
|
||||||
[#]: collector: "lujun9972/lctt-scripts-1700446145"
|
|
||||||
[#]: translator: " "
|
|
||||||
[#]: reviewer: " "
|
|
||||||
[#]: publisher: " "
|
|
||||||
[#]: url: " "
|
|
||||||
|
|
||||||
Crash Course On Using Textual
|
|
||||||
======
|
|
||||||
|
|
||||||
![][1]
|
|
||||||
|
|
||||||
Photo by [Bernard Hermant][2] on [Unsplash][3]
|
|
||||||
|
|
||||||
### Crash course on using Textual
|
|
||||||
|
|
||||||
Python on Linux has nice GUI (Graphic User Interface) development libraries like [TkInter][4], but what if you cannot run graphical applications?
|
|
||||||
|
|
||||||
Text terminals, are available on not just Linux but BSD and other great Unix-like operating systems. If you write code in Python, you should be using [Textual][5] to help you write TUI ( _Text User Interfaces_ ). In this quick introduction, I will show you two examples of what you can do with Textual and where you can go after that.
|
|
||||||
|
|
||||||
So what is Textual?
|
|
||||||
|
|
||||||
> Textual is a Rapid Application Development framework for Python, built by Textualize.io. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal or a web browser!
|
|
||||||
|
|
||||||
#### What you need to follow this tutorial
|
|
||||||
|
|
||||||
You will need the following:
|
|
||||||
|
|
||||||
1. Basic programming experience, preferable in Python.
|
|
||||||
2. Understanding basic object oriented concepts like classes and inheritance
|
|
||||||
3. A machine with Linux and Python 3.9+ installed
|
|
||||||
4. A good editor (Vim or PyCharm are good choices)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
I tried to keep the code simple, so you can follow it. Also, I strongly recommend you download the code or at least install the programs as explained next.
|
|
||||||
|
|
||||||
#### Installation
|
|
||||||
|
|
||||||
First create a virtual environment:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
python3 -m venv ~/virtualenv/Textualize
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Now you can either clone the Git repository and make an editable distribution:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
. ~/virtualenv/Textualize/bin/activate
|
|
||||||
pip install --upgrade pip
|
|
||||||
pip install --upgrade wheel
|
|
||||||
pip install --upgrade build
|
|
||||||
pip install --editable .
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Or just Install from Pypi.org:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
. ~/virtualenv/Textualize/bin/activate
|
|
||||||
pip install --upgrade KodegeekTextualize
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Our first application: A log scroller
|
|
||||||
|
|
||||||
![][6]
|
|
||||||
|
|
||||||
The _log scroller_ is a simple application that executes a list of UNIX commands that are on the _[PATH][7]_ and captures the output as they finish.
|
|
||||||
|
|
||||||
The resulting application code:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Let’s quickly dissect the code for the application:
|
|
||||||
|
|
||||||
1. An application extends the class _App_. It has several methods but the most important are _compose_ and _mount_. Only [compose][8] is implemented in this app.
|
|
||||||
2. In _compose_ , you yield back Widgets, and they get added in the same order to the main screen. Each [Widget][9] has options to customize their appearance.
|
|
||||||
3. You can define single letter [bindings][10], in this case the letter ‘q’ allows you to exit the application (see the function _action_quit_app_ and the BINDINGS list)
|
|
||||||
4. We display the list of commands to run using a _SelectionList widget_. You can then tell your application to capture what was selected by using the annotation *@on(SelectionList.SelectedChanged) *and the method _on_selection_.
|
|
||||||
5. It is important to react to a lack of selected elements, we disable or enable the ‘exec’ button depending on how many commands were selected to run.
|
|
||||||
6. A similar listener ( _@on(Button.Pressed)_ ) is used to execute the commands. We do that by pushing our selection to a [new screen][11] that handles the execution and collection of results.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Notice the _CSS_PATH = “os_app.tcss”_ variable? Textual allows you to control the appearance (colors, position, size) of individual or classes of widgets using CSS:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Screen {
|
|
||||||
layout: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
Header {
|
|
||||||
dock: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
Footer {
|
|
||||||
dock: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectionList {
|
|
||||||
padding: 1;
|
|
||||||
border: solid $accent;
|
|
||||||
width: 1fr;
|
|
||||||
height: 80%;
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
width: 1fr
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Quoting from the Textual website:
|
|
||||||
|
|
||||||
> The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn.
|
|
||||||
|
|
||||||
_This is great_ , as you can customize the appearance of your application using a separate [stylesheet][12] without too much effort.
|
|
||||||
|
|
||||||
Let’s now look at how to display the results on a separate screen.
|
|
||||||
|
|
||||||
##### Display results on a separate screen
|
|
||||||
|
|
||||||
![][13]
|
|
||||||
|
|
||||||
The code that handles the output on a separate screen is here:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
You will notice the following:
|
|
||||||
|
|
||||||
1. The _LogScreen_ class extends _ModalScreen_ which handles screens in modal mode.
|
|
||||||
2. The screen also has a compose method where we add widgets to show the contents of the Unix commands.
|
|
||||||
3. We have a new method called _mount_. Once you ‘ _compose_ ’ the widgets then you can run code to retrieve data and customize their appearance even further
|
|
||||||
4. To run the commands we use [asyncio][14], so we give the TUI main worker thread a chance to update the contents as soon as results for each command are known.
|
|
||||||
5. On the ‘ _workers_ ’ topic, please note the _@work(exclusive=False)_ annotation on the _run_process_ method used to run the commands and capture the STDOUT + STDERR output. Using [workers][15] to manage concurrency is not complicated, but they do have a dedicated section in the manual. This extra complexity arises because we are running external commands that may or may not take a long time to complete.
|
|
||||||
6. In _run_proces_ s we update the _event_log_ by calling _write_ with the contents of the command output.
|
|
||||||
7. Finally, the _on_button_pressed_ takes us back to the previous screen (pop the screen from the stack).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
This little app shows you how to write a simple front end to run non-python code, in less than 200 lines of code.
|
|
||||||
|
|
||||||
Now let’s move to a more complex example that uses new features of Textual we haven’t explored yet.
|
|
||||||
|
|
||||||
#### Second example: A table with race results
|
|
||||||
|
|
||||||
![Table application created via Textual][16]
|
|
||||||
|
|
||||||
This example shows you how to display race results in a table (Using a _DataTable_ widget). The application allows you to:
|
|
||||||
|
|
||||||
* Sort a table by column
|
|
||||||
* Select a row to show race details in a full window, using the same ‘ _push screen_ ’ technique we saw in the log scroll application.
|
|
||||||
* Search the table and show racer details or run other commands like exit the application.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Let’s see the application code then:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#!/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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
What is interesting here?:
|
|
||||||
|
|
||||||
1. compose adds the [header][17] where the ‘ _command palette_ ’ will live, as well our table ([DataTable][18]). The table gets populated in the mount method.
|
|
||||||
2. We have the expected bindings (BINDINGS) and external CSS for appearance (CSS_PATH)
|
|
||||||
3. By default, if we want to have the [command palette][19] we do nothing, but it is explicitly enabled here ( _ENABLE_COMMAND_PALETTE = True_ )
|
|
||||||
4. Our application has a custom search in the table contents. When the user types a name, a possible match is shown and the user clicks it to display the details for that racer. This requires telling the application that we have a custom provider ( _COMMANDS = App.COMMANDS | {CustomCo_ mmand}), which is the class _CustomCommand(Provider)_
|
|
||||||
5. If the user clicks a table header, the contents are sorted by that header. This is done using _on_header_clicked_ which is annotated with *@on(DataTable.HeaderSelected) *
|
|
||||||
6. Similarly, when a row is selected, the method _on_row_clicked_ is called thanks to the annotation _@on(DataTable.RowSelected)_. The method receives the selected row that is then used to push a new screen with details ( _class DetailScreen(ModalScreen)_ )
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Now let’s explore in detail how the racer details are shown
|
|
||||||
|
|
||||||
##### Using screens to show more complex views
|
|
||||||
|
|
||||||
![][20]
|
|
||||||
|
|
||||||
When the user selects a row, the method _on_row_clicked_ gets called. It receives an event of type _DataTable.RowSelected_. From there we construct an instance of class *DetailScreen(ModalScreen) *with the contents of the selected row:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
The responsibility of this class is very simple:
|
|
||||||
|
|
||||||
1. Method _compose_ takes the row and displays the content using a [widget that knows how to render Markdown][21]. Pretty neat as it creates a table of contents for us.
|
|
||||||
2. The method _on_button_pressed_ pops back the original screen once the user clicks ‘close’. (Annotation _@on(Button.Pressed, “#close”)_ takes care of receiving pressed events)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Now the last bit of the puzzle, which requires more explanation, the multipurpose search bar (known as command palette).
|
|
||||||
|
|
||||||
##### You can search too, using the command palette
|
|
||||||
|
|
||||||
![][22]
|
|
||||||
|
|
||||||
The [command palette][19] is enabled by default on every Textual application that uses a header. The fun part is that you can add your own commands in addition to the default commands, on class _CompetitorsApp_ :
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
COMMANDS = App.COMMANDS | {CustomCommand}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
And now the class that does all the heavy lifting, _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. Any class extending _Provider_ only needs to implement the method _search_. In our case we do also override the method _startup_ to get a reference to our application table (and its contents), using the _App.query(DataTable).first()_. _startup_ gets called only once in the lifetime of the instantiated class.
|
|
||||||
2. Inside the _search_ method we use the _Provider.matcher_ to do a fuzzy search on the second column (name) of each table row comparing with the query (which is the term passed by the user on the TUI). The _matcher.match(searchable)_ returns an integer score, where greater than zero indicates a match.
|
|
||||||
3. Inside _search_ if the score is greater than zero then it returns a _Hit_ object that tell the command palette if the search query was successful or not.
|
|
||||||
4. Each _Hit_ has the following information: score (used for sorting matches on the palette command), a highlighted search term, a reference to a callable (that’s it in our case a function that will push our table row to a new screen)
|
|
||||||
5. All the methods of the Provider class are async. This allows you to free the main worker thread and only return once the response is ready to be used (no frozen UI).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
With all that information we can now display the racer details.
|
|
||||||
|
|
||||||
While the framework is simple enough to follow there is also a lot of complexity on the messages passed back and forth between the components. Luckily for us Textual has a nice debugging framework that will help us understand what is going on behind scenes.
|
|
||||||
|
|
||||||
#### Troubleshooting a Textual application
|
|
||||||
|
|
||||||
[Debugging][23] a Python Textual application is a little bit more challenging. This is because some operations can be asynchronous and setting breakpoints may be cumbersome when troubleshooting widgets.
|
|
||||||
|
|
||||||
Depending on the situation, there are some tools you can use. But first make sure you have the textual dev tools:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
pip install textual-dev==1.3.0
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Make sure you are capturing the right keys
|
|
||||||
|
|
||||||
You are not sure what keys are being captured by a Textual application? Run the key app:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
textual keys
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
This lets you can press your key combinations and confirm what events are generated in Textual.
|
|
||||||
|
|
||||||
##### A picture is worth more than a thousand words
|
|
||||||
|
|
||||||
Say that you have a problem placing components on a layout, and you want to show others where you are stuck. Textual allows you to take a screenshot of your running application:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
textual run --screenshot 5 ./kodegeek_textualize/log_scroller.py
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
That’s how I created the images for this tutorial.
|
|
||||||
|
|
||||||
##### Capturing events and printing custom messages
|
|
||||||
|
|
||||||
Textual has a logger that is part of every instance of an Application:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
my_app = self.screen.app
|
|
||||||
my_app.log.info(f"Loaded provider: CustomCommand")
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
In order to see the messages, you first need to start a console:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
. ~/virtualenv/Textualize/bin/activate
|
|
||||||
textual console
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in another terminal run your application
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
. ~/virtualenv/Textualize/bin/activate
|
|
||||||
textual run --dev ./kodegeek_textualize/log_scroller.py
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
You will see now events and messages flowing into the terminal where the console is running:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
▌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
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Another advantage of running your application in developer mode is that if you change your CSS, the application will try to render again without a restart.
|
|
||||||
|
|
||||||
#### Writing unit tests
|
|
||||||
|
|
||||||
What if you want to write [unit tests][24] for your brand new Textual application?
|
|
||||||
|
|
||||||
The documentation [shows there are several ways][25] to test our application.
|
|
||||||
|
|
||||||
I will be using [unittest][24] for that. We will need the special class, class _unittest.IsolatedAsyncioTestCase_ , to handle our asyncio routines:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
What is happening in the method _test_log_scroller_ :
|
|
||||||
|
|
||||||
1. Get a Pilot instance using _app.run_test()_. Then click the main button to run the query with the default commands, and then wait until all the events are processes.
|
|
||||||
2. Next get the Log from the new screen we pushed and make sure we got some lines back, it is not empty
|
|
||||||
3. Then close the new screen and pop the old one back
|
|
||||||
4. Finally, press ‘q’ and exit the application
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
What about the test table, can it be tested?:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
If you run all the tests you will see something like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
(Textualize) [josevnz@dmaf5 Textualize]$ python -m unittest tests/*.py
|
|
||||||
..
|
|
||||||
----------------------------------------------------------------------
|
|
||||||
Ran 2 tests in 2.065s
|
|
||||||
|
|
||||||
OK
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
Not a bad way to test a TUI, is it?
|
|
||||||
|
|
||||||
#### Packaging a Textual application
|
|
||||||
|
|
||||||
Packaging is not much different than packaging a regular Python application. You need to remember that you need to include the CSS files that control the appearance of your application:
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
. ~/virtualenv/Textualize/bin/activate
|
|
||||||
python -m build
|
|
||||||
pip install dist/KodegeekTextualize-*-py3-none-any.whl
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
This tutorial [pyproject.toml][26] file is a good start that shows you what to do to package your application.
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
[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"]}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
#### What is next
|
|
||||||
|
|
||||||
This short tutorial only covers a few aspects of Textual. There is so much more to discover and learn:
|
|
||||||
|
|
||||||
* You should definitely take a look at the [official tutorial][27]. Lots of examples and pointers to the reference [API][28].
|
|
||||||
* Textual can use _widgets_ from the project that started it all, [Rich][29]. I think some, if not any of these components will get merged into Textual at some point. Textual framework is more capable for complex applications using a high level API, but Rich has lots of nice features.
|
|
||||||
* Make your own widgets! Also while designing the TUI, [grab a piece of paper and draw how you picture the components][30] should align together. It will save you time and headaches later.
|
|
||||||
* Debugging applications in Python can get complicated. Sometimes you may have to [mix different tools][31] to figure out what is wrong with an application.
|
|
||||||
* _Asyncio_ is a complex topic, you should [read the developer documentation][32] to see your alternatives.
|
|
||||||
* _Textual_ is used by other projects. One that is super easy to use is [Trogon][33]. It will [make your CLI self discoverable][34].
|
|
||||||
* [Textual-web][35] is a promising project that will allow you to run Textual applications on a browser. It is less mature than Textual but is evolving really fast.
|
|
||||||
* Finally, [check the external projects][36]. There are a lot of useful Open Source applications in the portfolio.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[][37]
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
via: https://fedoramagazine.org/crash-course-on-using-textual/
|
|
||||||
|
|
||||||
作者:[Jose Nunez][a]
|
|
||||||
选题:[lujun9972][b]
|
|
||||||
译者:[译者ID](https://github.com/译者ID)
|
|
||||||
校对:[校对者ID](https://github.com/校对者ID)
|
|
||||||
|
|
||||||
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出
|
|
||||||
|
|
||||||
[a]: https://fedoramagazine.org/author/josevnz/
|
|
||||||
[b]: https://github.com/lujun9972
|
|
||||||
[1]: https://fedoramagazine.org/wp-content/uploads/2024/01/textual_course-816x345.jpg
|
|
||||||
[2]: https://unsplash.com/@bernardhermant?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash
|
|
||||||
[3]: https://unsplash.com/photos/gray-and-clear-digital-wallpaper-VUBAE-Bmugk?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash
|
|
||||||
[4]: https://docs.python.org/3/library/tkinter.html
|
|
||||||
[5]: https://textual.textualize.io/
|
|
||||||
[6]: https://tutorials.kodegeek.com/Textualize/img/output_of_multiple_well_known_unix_commands_2023-12-28T19_13_32_605621.svg
|
|
||||||
[7]: https://manpages.org/bash
|
|
||||||
[8]: https://textual.textualize.io/tutorial/#composing-the-widgets
|
|
||||||
[9]: https://textual.textualize.io/widget_gallery/
|
|
||||||
[10]: https://textual.textualize.io/api/binding/
|
|
||||||
[11]: https://textual.textualize.io/guide/screens/
|
|
||||||
[12]: https://textual.textualize.io/guide/styles/
|
|
||||||
[13]: https://tutorials.kodegeek.com/Textualize/img/output_of_multiple_well_known_unix_commands_2023-12-28T19_13_40_503695.svg
|
|
||||||
[14]: https://docs.python.org/3/library/asyncio.html
|
|
||||||
[15]: https://textual.textualize.io/guide/workers/
|
|
||||||
[16]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_20_213933.svg
|
|
||||||
[17]: https://textual.textualize.io/widgets/header/
|
|
||||||
[18]: https://textual.textualize.io/widgets/data_table/#guide
|
|
||||||
[19]: https://textual.textualize.io/guide/command_palette/
|
|
||||||
[20]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_44_404837.svg
|
|
||||||
[21]: https://textual.textualize.io/widget_gallery/#markdownviewer
|
|
||||||
[22]: https://tutorials.kodegeek.com/Textualize/img/summary_2023-12-28T19_05_55_822030.svg
|
|
||||||
[23]: https://github.com/josevnz/tutorials/blob/main/docs/PythonDebugger/README.md
|
|
||||||
[24]: https://docs.python.org/3/library/unittest.html
|
|
||||||
[25]: https://textual.textualize.io/guide/testing/
|
|
||||||
[26]: https://tutorials.kodegeek.com/Textualize/pyproject.toml
|
|
||||||
[27]: https://textual.textualize.io/tutorial/
|
|
||||||
[28]: https://textual.textualize.io/api/
|
|
||||||
[29]: https://github.com/Textualize/rich
|
|
||||||
[30]: https://textual.textualize.io/how-to/design-a-layout/
|
|
||||||
[31]: https://github.com/josevnz/DebuggingApplications/blob/main/StracePythonWireshark/README.md
|
|
||||||
[32]: https://docs.python.org/3/library/asyncio-dev.html
|
|
||||||
[33]: https://github.com/Textualize/trogon
|
|
||||||
[34]: https://github.com/josevnz/CLIWithClickAndTrogon/blob/main/README.md
|
|
||||||
[35]: https://github.com/Textualize/textual-web
|
|
||||||
[36]: https://www.textualize.io/projects/
|
|
||||||
[37]: https://github.com/josevnz
|
|
Loading…
Reference in New Issue
Block a user