“界面是用户的游戏世界” —— 用 Textual 打造精美终端 UI
Textual 是一个用 Python 构建终端用户界面(TUI)的框架。
类比理解:
| Web 开发 | Textual TUI | 说明 |
|---|---|---|
| HTML | Widgets | 界面组件 |
| CSS | CSS | 样式定义 |
| JavaScript | Python | 交互逻辑 |
| DOM | Widget Tree | 组件树 |
| React/Vue | Textual App | 应用框架 |
传统终端库(curses):
❌ API 老旧,难以使用
❌ 跨平台兼容性差
❌ 不支持现代 UI 特性
Textual:
✅ 现代化设计
✅ 支持事件驱动
✅ 丰富的组件库
✅ 自动处理终端差异
✅ 类似 Web 开发体验
# 已通过 uv 安装
uv add textual
# 验证安装
uv run python -c "import textual; print(textual.__version__)"
App (应用)
└── Screen (屏幕)
└── Widget (组件基类)
├── Static (静态内容)
│ ├── Label (文本标签)
│ └── Button (按钮)
├── Input (输入框)
├── ListView (列表视图)
└── ... (更多组件)
from textual.app import ComposeResult
from textual.widgets import Static, Label
class MyWidget(Static):
"""自定义组件。"""
def __init__(self):
"""1. 初始化(创建实例)"""
super().__init__()
self.data = None
def compose(self) -> ComposeResult:
"""2. 组合子组件(构建 UI)"""
yield Label("Hello")
yield Button("Click me")
def on_mount(self) -> None:
"""3. 挂载后(组件已添加到屏幕)"""
# 可以在这里进行异步操作
self.fetch_data()
def on_unmount(self) -> None:
"""4. 卸载时(组件即将移除)"""
# 清理资源
pass
# 2048 游戏的组件树
Game2048App
├── Header
├── GameScreen (Vertical)
│ ├── Label (标题 "🎮 2048")
│ ├── Label (操作提示)
│ ├── ScoreWidget (Horizontal)
│ │ ├── Label (Score)
│ │ ├── Label (Moves)
│ │ └── Label (Max)
│ └── GridWidget
│ ├── Horizontal (row 0)
│ │ ├── TileWidget (tile-0-0)
│ │ ├── TileWidget (tile-0-1)
│ │ ├── TileWidget (tile-0-2)
│ │ └── TileWidget (tile-0-3)
│ ├── Horizontal (row 1)
│ │ └── ...
│ └── ...
└── Footer
class TileWidget(Static):
"""单个方块组件。"""
# CSS 样式(内联定义)
DEFAULT_CSS = """
TileWidget {
width: 1fr;
height: 100%;
content-align: center middle;
background: $surface;
border: solid $primary-background;
margin: 0 1;
text-style: bold;
}
TileWidget.tile-empty {
background: $surface-darken-1;
color: $text-muted;
}
"""
def __init__(self, value: int, row: int, col: int):
"""初始化方块。"""
# 生成唯一 ID,方便后续查询
super().__init__(id=f"tile-{row}-{col}")
self.value = value
self.row = row
self.col = col
self._update_classes()
def _update_classes(self) -> None:
"""根据值更新 CSS 类。"""
self.remove_class("tile-empty")
if self.value == 0:
self.add_class("tile-empty")
def update_value(self, value: int) -> None:
"""更新方块的值。"""
self.value = value
self._update_classes()
self.refresh() # 触发重新渲染
def render(self) -> str:
"""渲染方块内容。"""
return str(self.value) if self.value != 0 else ""
关键知识点:
| 概念 | 说明 | 示例 |
|---|---|---|
DEFAULT_CSS |
组件默认样式 | 内联 CSS 字符串 |
id |
组件唯一标识 | id=f"tile-{row}-{col}" |
classes |
CSS 类名 | add_class("tile-empty") |
render() |
自定义渲染 | 返回要显示的字符串 |
refresh() |
触发重绘 | 更新 UI 显示 |
class GridWidget(Static):
"""4x4 棋盘组件。"""
DEFAULT_CSS = """
GridWidget {
width: 100%;
height: 18; /* 固定高度,确保 4 行能显示 */
background: $surface-darken-2;
border: solid $primary;
padding: 1;
}
.tile-row {
height: 4;
width: 100%;
}
"""
def __init__(self, game: Game):
super().__init__()
self.game = game
self.tile_widgets: list[list[TileWidget]] = []
def compose(self) -> ComposeResult:
"""组合棋盘组件。"""
self.tile_widgets = []
for row in range(GRID_SIZE):
row_widgets = []
for col in range(GRID_SIZE):
# 创建方块组件
tile = TileWidget(
value=self.game.grid.cells[row][col],
row=row,
col=col
)
row_widgets.append(tile)
self.tile_widgets.append(row_widgets)
# 每行用一个 Horizontal 容器
yield Horizontal(*row_widgets, classes="tile-row")
def refresh_grid(self) -> None:
"""刷新整个棋盘。"""
for row in range(GRID_SIZE):
for col in range(GRID_SIZE):
value = self.game.grid.cells[row][col]
tile = self.tile_widgets[row][col]
tile.update_value(value)
self.refresh()
设计要点:
self.tile_widgets 保存所有 TileWidget 引用,方便后续更新compose() 中一次性创建所有子组件refresh_grid() 只更新值,不重新创建组件class ScoreWidget(Static):
"""分数显示组件。"""
DEFAULT_CSS = """
ScoreWidget {
width: 100%;
height: auto;
background: $primary-background;
padding: 1 2;
margin: 1 0;
}
#score-label {
text-style: bold;
color: $text;
}
#moves-label {
color: $text-muted;
}
#max-tile-label {
color: $warning;
}
"""
def __init__(self, game: Game):
super().__init__()
self.game = game
# 保存子组件引用
self.score_label: Label | None = None
self.moves_label: Label | None = None
self.max_label: Label | None = None
def compose(self) -> ComposeResult:
"""组合分数显示。"""
self.score_label = Label(f"Score: {self.game.score}", id="score-label")
self.moves_label = Label(f"Moves: {self.game.moves}", id="moves-label")
self.max_label = Label(f"Max: {self.game.grid.get_max_tile()}", id="max-tile-label")
yield Horizontal(self.score_label, self.moves_label, self.max_label)
def update(self) -> None:
"""更新分数显示。"""
if self.score_label:
self.score_label.update(f"Score: {self.game.score}")
if self.moves_label:
self.moves_label.update(f"Moves: {self.game.moves}")
if self.max_label:
self.max_label.update(f"Max: {self.game.grid.get_max_tile()}")
/* 选择器 */
WidgetType { /* 类型选择器 */
property: value;
}
#id-name { /* ID 选择器 */
property: value;
}
.class-name { /* 类选择器 */
property: value;
}
Parent > Child { /* 子选择器 */
property: value;
}
/* 常用属性 */
Widget {
/* 尺寸 */
width: 100%;
height: auto;
max-height: 20;
/* 布局 */
display: flex;
flex-direction: column;
/* 间距 */
padding: 1 2; /* 上下 1,左右 2 */
margin: 1 0;
/* 对齐 */
content-align: center middle; /* 内容居中 */
text-align: center; /* 文本居中 */
/* 样式 */
background: $surface;
border: solid $primary;
color: $text;
text-style: bold;
/* 变量(主题色) */
/* $surface, $primary, $text, $warning, $error 等 */
}
Textual 提供了一套主题色变量:
$surface - 背景色
$surface-darken-1 - 深一度的背景
$surface-lighten-1 - 浅一度的背景
$primary - 主色调
$primary-background - 主色背景
$secondary - 次色调
$text - 文本颜色
$text-muted - 弱化文本
$warning - 警告色(黄色)
$error - 错误色(红色)
$success - 成功色(绿色)
# 为不同值的方块定义不同颜色
TILE_STYLES = {
2: {"bg": "#eee4da", "fg": "#776e65"},
4: {"bg": "#ede0c8", "fg": "#776e65"},
8: {"bg": "#f2b179", "fg": "#f9f6f2"},
16: {"bg": "#f59563", "fg": "#f9f6f2"},
32: {"bg": "#f67c5f", "fg": "#f9f6f2"},
64: {"bg": "#f65e3b", "fg": "#f9f6f2"},
128: {"bg": "#edcf72", "fg": "#f9f6f2"},
256: {"bg": "#edcc61", "fg": "#f9f6f2"},
512: {"bg": "#edc850", "fg": "#f9f6f2"},
1024: {"bg": "#edc53f", "fg": "#f9f6f2"},
2048: {"bg": "#edc22e", "fg": "#f9f6f2"},
}
# 在 TileWidget 中应用
def _update_classes(self) -> None:
# 移除所有值相关的类
for value in TILE_STYLES.keys():
self.remove_class(f"tile-{value}")
# 添加当前值的类
if self.value in TILE_STYLES:
self.add_class(f"tile-{self.value}")
/* CSS 中定义不同值的样式 */
.tile-2 { background: #eee4da; }
.tile-4 { background: #ede0c8; }
.tile-8 { background: #f2b179; }
/* ... */
Textual 中的常见事件:
| 事件 | 触发时机 | 处理方法 |
|---|---|---|
on_mount |
组件挂载到屏幕 | def on_mount(self) |
on_unmount |
组件从屏幕移除 | def on_unmount(self) |
on_key |
按键按下 | def on_key(self, event) |
on_click |
鼠标点击 | def on_click(self, event) |
on_button_pressed |
按钮按下 | def on_button_pressed(self, event) |
on_input_changed |
输入框内容变化 | def on_input_changed(self, event) |
from textual.binding import Binding
class Game2048App(App):
"""主应用。"""
# 定义键盘绑定
BINDINGS = [
# (按键,方法名,描述)
Binding("up", "move_up", "向上移动"),
Binding("down", "move_down", "向下移动"),
Binding("left", "move_left", "向左移动"),
Binding("right", "move_right", "向右移动"),
# WASD 备用
Binding("w", "move_up", "W", show=False),
Binding("a", "move_left", "A", show=False),
Binding("s", "move_down", "S", show=False),
Binding("d", "move_right", "D", show=False),
# 游戏控制
Binding("r", "restart", "重新开始"),
Binding("q", "quit", "退出"),
# 帮助
Binding("question_mark", "show_help", "帮助"),
]
# action_ 前缀的方法会被绑定调用
def action_move_up(self):
self._attempt_move("up")
def action_restart(self):
self.game.reset()
self.refresh_display()
self.notify("Game restarted!", title="🔄")
def action_quit(self):
self.exit()
用户按下 ← 键
↓
Textual 事件系统捕获
↓
匹配 BINDINGS 中的 "left"
↓
调用 action_move_left() 方法
↓
执行游戏逻辑
↓
更新 UI 显示
from textual.screen import ModalScreen
from textual.widgets import Button, Label
class GameOverModal(ModalScreen):
"""游戏结束模态框。"""
BINDINGS = [
Binding("enter", "restart", "重新开始"),
Binding("escape", "quit", "关闭"),
]
DEFAULT_CSS = """
GameOverModal {
align: center middle; /* 居中显示 */
}
#game-over-container {
width: 50;
height: auto;
background: $surface;
border: solid $error;
padding: 2 4;
}
"""
def __init__(self, game: Game):
super().__init__()
self.game = game
def compose(self) -> ComposeResult:
state_text = "🎉 You Win!" if self.game.state == GameState.WON else "💀 Game Over"
with Vertical(id="game-over-container"):
yield Label(state_text, id="game-over-title")
yield Label(f"Final Score: {self.game.score}", id="game-over-score")
yield Button("Play Again", id="restart-btn", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "restart-btn":
self.dismiss(True) # 关闭并返回 True
def action_restart(self):
self.dismiss(True)
使用模态框:
# 在主应用中
def _show_game_over(self) -> None:
def callback(restart: bool) -> None:
if restart:
self.game.reset()
self.refresh_display()
# push_screen 打开模态框,callback 接收返回值
self.push_screen(GameOverModal(self.game), callback)
# ❌ 错误:UI 直接修改游戏数据
class TileWidget:
def on_click(self):
self.value = 2048 # 直接修改,绕过游戏逻辑
# ✅ 正确:UI 只反映游戏状态
class TileWidget:
def update_value(self, value: int):
self.value = value
self.refresh()
# 游戏逻辑修改数据
game.move("left") # 修改 grid.cells
# UI 被动更新
grid_widget.refresh_grid() # 从 game.grid 读取新状态
# ❌ 低效:每次移动都刷新
def move(self, direction):
self.game.move(direction)
self.score_widget.update() # 刷新分数
self.grid_widget.refresh_grid() # 刷新棋盘
# 多次刷新 = 多次重绘
# ✅ 高效:批量更新
def move(self, direction):
self.game.move(direction)
self.refresh_display() # 一次刷新所有
def refresh_display(self):
with self.batch_update(): # 批量更新上下文
self.score_widget.update()
self.grid_widget.refresh_grid()
from textual.animation import animate
class TileWidget(Static):
def slide_to(self, new_row: int, new_col: int):
"""滑动到新位置(带动画)。"""
# 计算目标位置
target_x = new_col * TILE_WIDTH
target_y = new_row * TILE_HEIGHT
# 动画移动
animate(
self,
"offset",
Offset(target_x, target_y),
duration=0.1,
easing="in_out_quad"
)
class Game2048App(App):
THEMES = {
"default": {...},
"dark": {...},
"light": {...},
}
def action_toggle_theme(self):
"""切换主题。"""
current = self.theme
next_theme = "dark" if current == "default" else "default"
self.theme = next_theme
self.notify(f"Theme: {next_theme}")
class MergeParticle(Static):
"""合并时的粒子效果。"""
DEFAULT_CSS = """
MergeParticle {
position: absolute;
width: 4;
height: 2;
background: $warning;
}
"""
def on_mount(self):
# 向上飘动并消失
self.animate("opacity", 0, duration=0.5)
self.animate("offset", self.offset + Offset(0, -2), duration=0.5)
class GridWidget(Static):
"""响应式棋盘。"""
def on_resize(self, event):
"""窗口大小变化时调整。"""
if event.size.width < 60:
# 小屏幕:缩小字体
self.add_class("small")
else:
self.remove_class("small")
A: 检查清单:
yield 出来了?refresh() 了吗?A:
# 启用 Textual 调试模式
# 运行:textual run app.py
# 添加日志
def on_key(self, event):
self.notify(f"Key pressed: {event.key}")
# 查看组件树
# 按 Ctrl+P 打开组件检查器(调试模式)
A: Textual 自动处理大部分情况。如需自定义:
def on_resize(self, event):
# event.size.width, event.size.height
self.refresh_layout()
下一章: 第 6 章 应用入口与事件处理
🐧 好的 UI 让游戏更有趣!