Textual 官方教程:从零构建一个功能完整的秒表 TUI 应用

Textual 官方教程:从零构建一个功能完整的秒表 TUI 应用 Textual 官方教程从零构建一个功能完整的秒表 TUI 应用【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textual本篇教程基于 Textual 官方文档docs/tutorial.md及其配套示例代码docs/examples/tutorial/带你从零构建一个功能完整的秒表Stopwatch终端应用支持多个秒表并行计时、独立开始/停止/重置、动态添加与删除。读完本文你将掌握 Textual 的核心开发范式——App类与BINDINGS键位绑定、compose()组合式 UI 构建、Textual CSS 样式与动态 CSS 类、响应式reactive属性以及mount()/remove()动态增删控件这些能力足以支撑你开发任何复杂的 TUI 应用。认识目标应用与获取代码本教程要构建的秒表应用应具备以下能力展示一组秒表每个秒表都有开始Start、停止Stop、重置Reset按钮用户可随时添加或删除秒表。这虽然看起来简单却是一个完全功能化fully featured的应用——教程作者 Will McGuganRich 与 Textual 的创建者的原话是如果你想让人去构建东西就让它有趣起来If you want people to build things, make it fun.。运行应用后你会注意到右下角的^p palette提示这是 Textual 的命令面板Command Palette可以把它理解为专属于你应用的命令提示符。获取代码并运行请先确保已安装 Textual然后获取仓库并运行示例git clone https://github.com/Textualize/textual.git cd textual/docs/examples/tutorial python stopwatch.py运行后可以看到最终成品界面终端渲染。整个开发过程将从 6 个递进版本的示例文件逐步展开示例文件阶段内容stopwatch01.py最小 AppHeader Footer 主题切换stopwatch02.py自定义 WidgetStopwatch / TimeDisplaystopwatch03.py引入 Textual CSS 样式stopwatch04.py动态 CSS 类切换开始/停止状态stopwatch05.py响应式属性驱动时间显示stopwatch06.py按钮接线独立控制每个秒表stopwatch.py最终版动态添加/移除秒表类型提示速览教程示例代码大量使用 Python 类型提示type hints。Textual 官方强调类型提示完全是可选的示例里带上它们只是推荐做法你可以自行决定是否在自己的项目中使用。类型提示是一种表达数据、参数与返回值类型的方式它让 mypy 之类的静态检查工具能在代码运行前发现潜在 bug。语法非常简单def repeat(text: str, count: int) - str: Repeat a string a given number of times. return text * count参数类型跟在冒号后面text: str表示text需要字符串count: int表示count需要整数返回类型跟在-后面- str:表示该方法返回字符串。第一步认识 App 类构建 Textual 应用的第一步是导入并继承App类。下面是教程的起点 stopwatch01.pyfrom textual.app import App, ComposeResult from textual.widgets import Footer, Header class StopwatchApp(App): A Textual app to manage stopwatches. BINDINGS [(d, toggle_dark, Toggle dark mode)] def compose(self) - ComposeResult: Create child widgets for the app. yield Header() yield Footer() def action_toggle_dark(self) - None: An action to toggle dark mode. self.theme ( textual-dark if self.theme textual-light else textual-light ) if __name__ __main__: app StopwatchApp() app.run()运行python stopwatch01.py后按d键可在明暗主题之间切换按CtrlQ退出应用返回命令行。App 类逐行解析第一、二行导入App基类所有 Textual 应用的基类以及两个内置控件——Footer屏幕底部的按键栏与 Header屏幕顶部的标题栏。控件Widget是可复用的组件负责管理屏幕的一部分。BINDINGS一个元组列表将按键key映射绑定到应用的动作action。元组三个值依次为按键、动作名称、简短描述。这里把d键绑定到toggle_dark动作。完整细节见按键绑定指南。compose()在这里用控件构建用户界面。它可以返回控件列表但通常用yield逐个产出从而让该方法成为生成器代码中依次产出了Header()和Footer()实例。action_toggle_dark()定义了一个动作方法。动作方法以action_开头后接动作名称上面的BINDINGS会告诉 Textual 在用户按下d键时执行它。注意动作方法还直接操作了self.theme——在 Textual 中主题也是一个响应式属性赋值即可实时切换。详见动作指南。末尾三行创建应用实例并调用run()方法textual.app.App.runrun()会让终端进入应用模式并持续运行直到你按CtrlQ退出。这段代码放在__name__ __main__块中既可以用python stopwatch01.py直接运行也可以作为更大项目的一部分被导入。用控件设计 UITextual 内置了大量控件builtin widgets我们的应用需要新控件可以通过继承并组合内置控件来创建。动手之前先看一眼设计草图明确目标自定义控件Stopwatch我们需要一个由以下子控件组成的Stopwatch控件一个 Start 按钮一个 Stop 按钮一个 Reset 按钮一个时间显示器。先搭出骨架stopwatch02.pyfrom textual.app import App, ComposeResult from textual.containers import HorizontalGroup, VerticalScroll from textual.widgets import Button, Digits, Footer, Header class TimeDisplay(Digits): A widget to display elapsed time. class Stopwatch(HorizontalGroup): A stopwatch widget. def compose(self) - ComposeResult: Create child widgets of a stopwatch. yield Button(Start, idstart, variantsuccess) yield Button(Stop, idstop, varianterror) yield Button(Reset, idreset) yield TimeDisplay(00:00:00.00) class StopwatchApp(App): A Textual app to manage stopwatches. BINDINGS [(d, toggle_dark, Toggle dark mode)] def compose(self) - ComposeResult: Create child widgets for the app. yield Header() yield Footer() yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch()) def action_toggle_dark(self) - None: An action to toggle dark mode. self.theme ( textual-dark if self.theme textual-light else textual-light ) if __name__ __main__: app StopwatchApp() app.run()这段代码引入的新元素两个新控件Button 负责按钮Digits 负责时间显示两个容器控件HorizontalGroup与VerticalScroll都来自textual.containers模块——正如模块名所示容器containers就是装其他控件的控件用来定义界面整体布局TimeDisplay目前极简只是继承Digits而不增加任何功能后续会逐步充实Stopwatch继承HorizontalGroup容器类会把子控件排成水平一行其compose()产出的子控件正好对应设计草图上的各个部件。如果你要构建自己的自定义控件务必参考协调控件指南。Button 的构造参数Button构造函数第一个参数是要显示的标签Start/Stop/Reset此外部分按钮还设置了以下参数id一个标识符用于在代码中区分按钮并应用样式variant选择默认样式的字符串success让按钮变绿error让按钮变红。组合控件StopwatchApp.compose()中的新行产出了一个VerticalScroll当内容放不下时它负责滚动并自动处理滚动所需的按键绑定如↑、↓、Page Down、Page Up、Home、End等。当控件包含其他控件如VerticalScroll时通常直接以位置参数接收子控件yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch())这行代码创建了一个包含三个Stopwatch控件的VerticalScroll。未加样式的应用运行stopwatch02.py界面元素都在但看起来和草图相去甚远——因为我们还没有对新控件应用任何样式。编写 Textual CSS每个控件都有一个styles对象包含影响其外观的诸多属性。例如设置白色文字与蓝色背景self.styles.background blue self.styles.color white虽然完全可以用这种方式给应用设置所有样式但几乎没必要。Textual 支持 CSS层叠样式表——网页浏览器使用的技术。CSS 文件是由应用加载的数据文件内含要应用到控件上的样式信息。Textual 使用的 CSS 方言相比 Web CSS 大幅简化更容易学习。CSS 让你能快速迭代应用设计并支持实时编辑live-editing——编辑 CSS 无需重启应用即可看到变化在应用中加入 CSS 文件stopwatch03.py只需添加一行类变量class StopwatchApp(App): A Textual app to manage stopwatches. CSS_PATH stopwatch03.tcss BINDINGS [(d, toggle_dark, Toggle dark mode)] ...CSS_PATH告诉 Textual 在应用启动时加载对应文件stopwatch03.tcss。CSS 基础语法CSS 文件包含若干声明块declaration blocks。第一个声明块来自stopwatch03.tcssStopwatch { background: $boost; height: 5; margin: 1; min-width: 50; padding: 1; }第一行告诉 Textual 样式应作用于Stopwatch控件花括号内是样式本身。其视觉效果如下各条样式的含义background: $boost把背景色设为$boost。$前缀会从内置主题中选取预定义颜色还有其他指定颜色的方式如blue或rgb(20,46,210)height: 5将控件高度设为 5 行文本margin: 1在Stopwatch控件周围设置 1 格的边距在列表中的控件之间制造空隙min-width: 50将控件最小宽度设为 50 格padding: 1在子控件周围设置 1 格的内边距。stopwatch03.tcss其余的声明块TimeDisplay { text-align: center; color: $foreground-muted; height: 3; } Button { width: 16; } #start { dock: left; } #stop { dock: left; display: none; } #reset { dock: right; }TimeDisplay块text-align:让文本居中color:设置文字颜色height:设为 3 行Button块width:将按钮宽度设为 16 格字符宽度最后三个块格式略有不同当声明以#开头时样式将作用于具有匹配id属性的控件。我们在compose中给Button设置了 ID例如第一个按钮idstart对应 CSS 中的#startdock样式把控件对齐到指定边缘start 与 stop 按钮停靠左边缘reset 按钮停靠右边缘你可能注意到了#stop有display: none;——这告诉 Textual不要显示该按钮。原因很简单计时器未运行时不该出现 stop 按钮同理计时器运行时也不该显示 start 按钮。如何在动态界面中管理这些状态正是下一节的内容。动态 CSS用 CSS 类切换控件状态我们希望Stopwatch控件拥有两种状态默认状态显示 Start 与 Reset 按钮和started已开始状态显示 Stop 按钮。秒表启动后背景应变绿以表示当前处于激活状态。这可以通过 CSS类class实现。注意这里的 CSS 类与 Python 类不是一回事——CSS 类就像贴在控件上的标签用于修改其样式。一个控件可以拥有任意数量的 CSS 类通过增删类即可改变外观。对应新增 CSSstopwatch04.tcssStopwatch { background: $boost; height: 5; margin: 1; min-width: 50; padding: 1; } TimeDisplay { text-align: center; color: $foreground-muted; height: 3; } Button { width: 16; } #start { dock: left; } #stop { dock: left; display: none; } #reset { dock: right; } .started { background: $success-muted; color: $text; } .started TimeDisplay { color: $foreground; } .started #start { display: none } .started #stop { display: block } .started #reset { visibility: hidden }新增规则以.started为前缀。.点号表示.started指的是名为started的 CSS 类这些新样式只作用于拥有该 CSS 类的控件。其中一些新样式有多个用空格分隔的选择器。空格表示第二个选择器须是第一个选择器的子级才匹配。以这条为例.started #start { display: none }.started匹配任何带startedCSS 类的控件#start匹配 ID 为start的控件。用空格组合两者.started #start得到的新选择器只有当 start 按钮位于带started类的容器内部时才匹配。翻译成人话就是如果控件已经开始了就隐藏 start 按钮。注意#stop在此规则下display: block恢复显示而#reset用的是visibility: hidden仍占据布局空间仅不可见。操控 CSS 类修改控件的 CSS 类是一种便捷的视觉更新方式可以避免堆砌大量杂乱的显示相关代码。通过 add_class() 和 remove_class() 方法增删 CSS 类。下面这段代码stopwatch04.py把 started 状态与 Start/Stop 按钮连接起来class Stopwatch(HorizontalGroup): A stopwatch widget. def on_button_pressed(self, event: Button.Pressed) - None: Event handler called when a button is pressed. if event.button.id start: self.add_class(started) elif event.button.id stop: self.remove_class(started) ...on_button_pressed是一个事件处理器event handler。事件处理器是 Textual 响应事件按键、鼠标点击等时调用的方法命名规则是on_加事件名因此on_button_pressed处理按钮被按下事件。事件处理器的写法详见消息处理器指南。运行stopwatch04.py后点击第一个按钮即可在两个状态间切换当事件处理器给控件加上或去掉startedCSS 类时Textual 会重新应用 CSS 并更新视觉。响应式属性让数据变化自动驱动界面Textual 的一个核心理念是你很少需要显式更新控件的视觉。虽然可以调用 refresh() 刷新数据但 Textual 更推荐通过响应式reactive属性自动完成。响应式属性的用法与__init__中设置普通属性无异但 Textual 能检测到你对它的赋值此外还附带一些其他超能力。要添加响应式属性先导入reactive再在类作用域中创建实例from textual.reactive import reactive为秒表添加响应式属性来计算并显示已流逝时间stopwatch05.pyfrom time import monotonic from textual.app import App, ComposeResult from textual.containers import HorizontalGroup, VerticalScroll from textual.reactive import reactive from textual.widgets import Button, Digits, Footer, Header class TimeDisplay(Digits): A widget to display elapsed time. start_time reactive(monotonic) time reactive(0.0) def on_mount(self) - None: Event handler called when widget is added to the app. self.set_interval(1 / 60, self.update_time) def update_time(self) - None: Method to update the time to the current time. self.time monotonic() - self.start_time def watch_time(self, time: float) - None: Called when the time attribute changes. minutes, seconds divmod(time, 60) hours, minutes divmod(minutes, 60) self.update(f{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}) ...这里给TimeDisplay加了两个响应式属性start_time秒表启动的时间秒time要在Stopwatch控件中显示的时间。两个属性都会像在__init__中赋值一样出现在self上只要向其中任何一个写入新值控件就会自动更新。关于monotonic本例中它来自标准库time模块与time.time类似但系统时钟被修改时不会倒退适合做计时基准。reactive的第一个参数可以是属性的默认值也可以是返回默认值的可调用对象我们把start_time的默认值设为monotonic函数——当TimeDisplay加入应用时会调用它把属性初始化为当前时间time的默认值是简单浮点数0.0因此self.time初始化为0。on_mount是控件首次加入应用Textual 术语叫mounted即挂载时触发的事件处理器。其中调用 set_interval() 创建一个定时器每秒调用self.update_time60 次即每帧约 16.7ms。update_time计算自控件启动以来流逝的时间并赋值给self.time——这正引出响应式的一大超能力如果实现一个以watch_开头、后接响应式属性名的方法那么该属性被修改时这个方法会被自动调用。这类方法称为watch 方法。因为watch_time监视time属性所以每秒 60 次更新self.time时也会隐式调用watch_time它把流逝时间格式化成字符串并调用self.update更新显示。由于这一切都是自动发生的构造TimeDisplay时无需再传初始参数。接线按钮独立控制每个秒表要让每个秒表能独立开始、停止、重置只需给TimeDisplay再添加几个方法stopwatch06.pyclass TimeDisplay(Digits): A widget to display elapsed time. start_time reactive(monotonic) time reactive(0.0) total reactive(0.0) def on_mount(self) - None: Event handler called when widget is added to the app. self.update_timer self.set_interval(1 / 60, self.update_time, pauseTrue) def update_time(self) - None: Method to update time to current. self.time self.total (monotonic() - self.start_time) def watch_time(self, time: float) - None: Called when the time attribute changes. minutes, seconds divmod(time, 60) hours, minutes divmod(minutes, 60) self.update(f{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}) def start(self) - None: Method to start (or resume) time updating. self.start_time monotonic() self.update_timer.resume() def stop(self) - None: Method to stop the time display updating. self.update_timer.pause() self.total monotonic() - self.start_time self.time self.total def reset(self) - None: Method to reset the time display to zero. self.total 0 self.time 0对TimeDisplay的改动小结新增total响应式属性存储点击 start 与 stop 之间累计的总时长set_interval增加了pauseTrue参数让定时器以暂停模式启动定时器暂停期间不会运行直到调用 resume()——因为用户未点击 start 之前我们不想更新时间update_time现在把total加到当前计时上以计入之前多次 start/stop 之间的时间set_interval返回一个 Timer 对象我们保存它用于在秒表启动时resume定时器新增start()、stop()、reset()方法。与此同时Stopwatch上的on_button_pressed事件处理器也补全了逻辑def on_button_pressed(self, event: Button.Pressed) - None: Event handler called when a button is pressed. button_id event.button.id time_display self.query_one(TimeDisplay) if button_id start: time_display.start() self.add_class(started) elif button_id stop: time_display.stop() self.remove_class(started) elif button_id reset: time_display.reset()第一行取出被按下按钮的id属性据此决定如何响应第二行调用 query_one() 拿到TimeDisplay控件的引用调用与按钮匹配的TimeDisplay方法秒表启动时self.add_class(started)、停止时self.remove_class(started)从而通过 CSS 更新秒表视觉。运行stopwatch06.py现在可以独立操作各个秒表了。最后剩下的功能就是动态添加和移除秒表。动态控件运行时挂载与移除秒表应用在启动时通过compose创建控件但我们还需要在运行期间创建新控件、移除不再需要的控件。这通过 mount()添加控件与 remove()移除控件完成。最终版 stopwatch.pyfrom time import monotonic from textual.app import App, ComposeResult from textual.containers import HorizontalGroup, VerticalScroll from textual.reactive import reactive from textual.widgets import Button, Digits, Footer, Header class TimeDisplay(Digits): A widget to display elapsed time. start_time reactive(monotonic) time reactive(0.0) total reactive(0.0) def on_mount(self) - None: Event handler called when widget is added to the app. self.update_timer self.set_interval(1 / 60, self.update_time, pauseTrue) def update_time(self) - None: Method to update time to current. self.time self.total (monotonic() - self.start_time) def watch_time(self, time: float) - None: Called when the time attribute changes. minutes, seconds divmod(time, 60) hours, minutes divmod(minutes, 60) self.update(f{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}) def start(self) - None: Method to start (or resume) time updating. self.start_time monotonic() self.update_timer.resume() def stop(self): Method to stop the time display updating. self.update_timer.pause() self.total monotonic() - self.start_time self.time self.total def reset(self): Method to reset the time display to zero. self.total 0 self.time 0 class Stopwatch(HorizontalGroup): A stopwatch widget. def on_button_pressed(self, event: Button.Pressed) - None: Event handler called when a button is pressed. button_id event.button.id time_display self.query_one(TimeDisplay) if button_id start: time_display.start() self.add_class(started) elif button_id stop: time_display.stop() self.remove_class(started) elif button_id reset: time_display.reset() def compose(self) - ComposeResult: Create child widgets of a stopwatch. yield Button(Start, idstart, variantsuccess) yield Button(Stop, idstop, varianterror) yield Button(Reset, idreset) yield TimeDisplay() class StopwatchApp(App): A Textual app to manage stopwatches. CSS_PATH stopwatch.tcss BINDINGS [ (d, toggle_dark, Toggle dark mode), (a, add_stopwatch, Add), (r, remove_stopwatch, Remove), ] def compose(self) - ComposeResult: Called to add widgets to the app. yield Header() yield Footer() yield VerticalScroll(Stopwatch(), Stopwatch(), Stopwatch(), idtimers) def action_add_stopwatch(self) - None: An action to add a timer. new_stopwatch Stopwatch() self.query_one(#timers).mount(new_stopwatch) new_stopwatch.scroll_visible() def action_remove_stopwatch(self) - None: Called to remove a timer. timers self.query(Stopwatch) if timers: timers.last().remove() def action_toggle_dark(self) - None: An action to toggle dark mode. self.theme ( textual-dark if self.theme textual-light else textual-light ) if __name__ __main__: app StopwatchApp() app.run()主要变化StopwatchApp中的VerticalScroll获得了timers这个 ID新增action_add_stopwatch动作添加秒表新增action_remove_stopwatch动作移除秒表为这些动作新增了按键绑定a添加、r移除。逐动作解析action_add_stopwatch创建并挂载新秒表。注意其中用 CSS 选择器#timers调用 query_one() 按 ID 取到秒表容器。挂载完成后新秒表立即出现在终端里最后一行调用 scroll_visible() 滚动容器让新秒表如果超出可视区滚动到可见位置。action_remove_stopwatch用 CSS 选择器Stopwatch调用 query() 获取所有Stopwatch控件。如果存在秒表就调用 last() 取最后一个再调用 remove() 移除它。运行最终版stopwatch.py按a添加秒表、按r移除秒表、按d切换明暗主题。注意最终版 stopwatch.tcss 相比早期版本只多了一行layout: horizontal;它显式声明了容器内的水平布局。底层实现速览本教程用到的核心机制作为参考本教程涉及的几个关键 API 在源码中的位置App基类、run()、主题响应式属性src/textual/app.pycompose()生成器与挂载流程src/textual/widget.py、src/textual/message_pump.pyadd_class/remove_class/query_one/query等 DOM 查询与类操作方法src/textual/dom.pyreactive与watch_方法的实现被赋值时自动触发 watch 方法正是由这里的分派逻辑驱动的src/textual/reactive.pyset_interval定时器创建src/textual/message_pump.pyTimer类的pause/resume状态机src/textual/timer.py事件系统与on_消息处理器约定src/textual/message_pump.py、事件指南CSS 解析与选择器匹配src/textual/css/查询结果类型DOMQuery的last()/remove()见 src/textual/css/query.py。接下来学什么恭喜你完成了第一个 Textual 应用本教程覆盖了大量知识点。如果你更喜欢边写边学的风格可以自行修改stopwatch.py或翻阅仓库中的其他示例代码。想要系统掌握用 Textual 构建复杂 TUI 应用的完整细节请继续阅读官方指南。【免费下载链接】textualThe lean application framework for Python. Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal and a web browser.项目地址: https://gitcode.com/gh_mirrors/te/textual创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考