building-tui-apps
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseBuilding TUI Applications
构建TUI应用
Overview
概述
TUIs are reactive terminal interfaces. Unlike CLIs (single operation → exit), TUIs maintain state, handle events, and update displays continuously. Think of them as web apps for the terminal.
TUI是响应式终端界面。与CLI(单次操作→退出)不同,TUI会维持状态、处理事件并持续更新显示。可以将其视为终端版的Web应用。
When to Use TUI
何时使用TUI
dot
digraph decision {
rankdir=TB;
"Need persistent display?" [shape=diamond];
"Multiple views/panels?" [shape=diamond];
"Real-time updates?" [shape=diamond];
"CLI with progress" [shape=box, style=filled, fillcolor=lightblue];
"Full TUI" [shape=box, style=filled, fillcolor=lightgreen];
"CLI" [shape=box, style=filled, fillcolor=lightyellow];
"Need persistent display?" -> "CLI" [label="no"];
"Need persistent display?" -> "Multiple views/panels?" [label="yes"];
"Multiple views/panels?" -> "Full TUI" [label="yes"];
"Multiple views/panels?" -> "Real-time updates?" [label="no"];
"Real-time updates?" -> "Full TUI" [label="yes"];
"Real-time updates?" -> "CLI with progress" [label="no"];
}TUI is right when: Dashboard monitoring, file browsers, log viewers, interactive data exploration, multi-step wizards with navigation
CLI is better when: Single operation, piping output, scripting, simple progress display
dot
digraph decision {
rankdir=TB;
"Need persistent display?" [shape=diamond];
"Multiple views/panels?" [shape=diamond];
"Real-time updates?" [shape=diamond];
"CLI with progress" [shape=box, style=filled, fillcolor=lightblue];
"Full TUI" [shape=box, style=filled, fillcolor=lightgreen];
"CLI" [shape=box, style=filled, fillcolor=lightyellow];
"Need persistent display?" -> "CLI" [label="no"];
"Need persistent display?" -> "Multiple views/panels?" [label="yes"];
"Multiple views/panels?" -> "Full TUI" [label="yes"];
"Multiple views/panels?" -> "Real-time updates?" [label="no"];
"Real-time updates?" -> "Full TUI" [label="yes"];
"Real-time updates?" -> "CLI with progress" [label="no"];
}适合使用TUI的场景: 仪表盘监控、文件浏览器、日志查看器、交互式数据探索、带导航的多步骤向导
更适合使用CLI的场景: 单次操作、管道输出、脚本编写、简单进度显示
Quick Reference: Libraries by Language
快速参考:各语言对应的库
| Language | Full TUI Framework | Simple Interactive |
|---|---|---|
| Python | | |
| TypeScript | | |
| C# | | |
| 语言 | 全功能TUI框架 | 轻量交互式工具 |
|---|---|---|
| Python | | |
| TypeScript | | |
| C# | | |
Library Selection Flowchart
库选择流程图
dot
digraph library {
rankdir=TB;
"Need full-screen app?" [shape=diamond];
"Python or TS?" [shape=diamond];
"C#?" [shape=diamond];
"Modern reactive?" [shape=diamond];
"textual" [shape=box, style=filled, fillcolor=lightgreen];
"ink" [shape=box, style=filled, fillcolor=lightblue];
"blessed" [shape=box, style=filled, fillcolor=lightblue];
"Terminal.Gui" [shape=box, style=filled, fillcolor=lightyellow];
"rich/Spectre" [shape=box, style=filled, fillcolor=lightgray];
"Need full-screen app?" -> "Python or TS?" [label="yes"];
"Need full-screen app?" -> "rich/Spectre" [label="no, just prompts/tables"];
"Python or TS?" -> "textual" [label="Python"];
"Python or TS?" -> "Modern reactive?" [label="TypeScript"];
"Modern reactive?" -> "ink" [label="yes, React-like"];
"Modern reactive?" -> "blessed" [label="no, traditional"];
"Python or TS?" -> "C#?" [label="neither"];
"C#?" -> "Terminal.Gui" [label="yes"];
}dot
digraph library {
rankdir=TB;
"Need full-screen app?" [shape=diamond];
"Python or TS?" [shape=diamond];
"C#?" [shape=diamond];
"Modern reactive?" [shape=diamond];
"textual" [shape=box, style=filled, fillcolor=lightgreen];
"ink" [shape=box, style=filled, fillcolor=lightblue];
"blessed" [shape=box, style=filled, fillcolor=lightblue];
"Terminal.Gui" [shape=box, style=filled, fillcolor=lightyellow];
"rich/Spectre" [shape=box, style=filled, fillcolor=lightgray];
"Need full-screen app?" -> "Python or TS?" [label="yes"];
"Need full-screen app?" -> "rich/Spectre" [label="no, just prompts/tables"];
"Python or TS?" -> "textual" [label="Python"];
"Python or TS?" -> "Modern reactive?" [label="TypeScript"];
"Modern reactive?" -> "ink" [label="yes, React-like"];
"Modern reactive?" -> "blessed" [label="no, traditional"];
"Python or TS?" -> "C#?" [label="neither"];
"C#?" -> "Terminal.Gui" [label="yes"];
}Core Architecture Pattern
核心架构模式
┌─────────────────────────────────────────────────────────┐
│ App │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ State │→ │ Widgets │→ │ Render │ │
│ │ (reactive) │ │ (compose) │ │ (on change) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ ↑ │ │
│ └────────── Events ←─────────────────┘ │
└─────────────────────────────────────────────────────────┘All modern TUI frameworks use this reactive pattern:
- State changes → triggers re-render
- Events (keyboard, mouse, resize) → update state
- Widgets compose into layouts
┌─────────────────────────────────────────────────────────┐
│ App │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
│ │ State │→ │ Widgets │→ │ Render │ │
│ │ (reactive) │ │ (compose) │ │ (on change) │ │
│ └─────────────┘ └─────────────┘ └─────────────────┘ │
│ ↑ │ │
│ └────────── Events ←─────────────────┘ │
└─────────────────────────────────────────────────────────┘所有现代TUI框架都采用这种响应式模式:
- 状态变更 → 触发重新渲染
- 事件(键盘、鼠标、窗口大小调整)→ 更新状态
- 组件组合成布局
Python: Textual
Python: Textual
Basic Structure
基础结构
python
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Static
from textual.reactive import reactive
from textual.containers import Horizontal, Vertical
class DashboardApp(App):
"""Main TUI application."""
CSS = """
#sidebar { width: 30; }
#main { width: 1fr; }
"""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("enter", "select", "Select"),
]
# Reactive state - changes trigger UI updates
selected_id: reactive[str | None] = reactive(None)
items: reactive[list] = reactive([])
def compose(self) -> ComposeResult:
"""Build the UI tree."""
yield Header()
with Horizontal():
yield DataTable(id="table")
yield Static(id="detail")
yield Footer()
def on_mount(self) -> None:
"""Called when app starts."""
self.load_data()
def watch_selected_id(self, new_id: str | None) -> None:
"""Called automatically when selected_id changes."""
self.update_detail_panel(new_id)
def action_refresh(self) -> None:
"""Handle 'r' key."""
self.load_data()
async def load_data(self) -> None:
"""Load data without blocking UI."""
self.items = await self.fetch_items()python
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Static
from textual.reactive import reactive
from textual.containers import Horizontal, Vertical
class DashboardApp(App):
"""Main TUI application."""
CSS = """
#sidebar { width: 30; }
#main { width: 1fr; }
"""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("enter", "select", "Select"),
]
# Reactive state - changes trigger UI updates
selected_id: reactive[str | None] = reactive(None)
items: reactive[list] = reactive([])
def compose(self) -> ComposeResult:
"""Build the UI tree."""
yield Header()
with Horizontal():
yield DataTable(id="table")
yield Static(id="detail")
yield Footer()
def on_mount(self) -> None:
"""Called when app starts."""
self.load_data()
def watch_selected_id(self, new_id: str | None) -> None:
"""Called automatically when selected_id changes."""
self.update_detail_panel(new_id)
def action_refresh(self) -> None:
"""Handle 'r' key."""
self.load_data()
async def load_data(self) -> None:
"""Load data without blocking UI."""
self.items = await self.fetch_items()Key Patterns
关键模式
Workers for async operations:
python
from textual.worker import Worker
class MyApp(App):
@work(exclusive=True)
async def fetch_data(self) -> None:
"""Run in background, won't block UI."""
result = await api.get_items()
self.items = result
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion."""
if event.state == WorkerState.SUCCESS:
self.refresh_table()Custom widgets:
python
from textual.widget import Widget
from textual.message import Message
class NoticeCard(Widget):
"""Custom widget with message passing."""
class Selected(Message):
def __init__(self, notice_id: str) -> None:
self.notice_id = notice_id
super().__init__()
def on_click(self) -> None:
self.post_message(self.Selected(self.notice_id))异步操作的Worker机制:
python
from textual.worker import Worker
class MyApp(App):
@work(exclusive=True)
async def fetch_data(self) -> None:
"""Run in background, won't block UI."""
result = await api.get_items()
self.items = result
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion."""
if event.state == WorkerState.SUCCESS:
self.refresh_table()自定义组件:
python
from textual.widget import Widget
from textual.message import Message
class NoticeCard(Widget):
"""Custom widget with message passing."""
class Selected(Message):
def __init__(self, notice_id: str) -> None:
self.notice_id = notice_id
super().__init__()
def on_click(self) -> None:
self.post_message(self.Selected(self.notice_id))TypeScript: Ink
TypeScript: Ink
Basic Structure
基础结构
tsx
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
const Dashboard = () => {
const [items, setItems] = useState<Item[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const { exit } = useApp();
// Handle keyboard input
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setSelectedIndex(i => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex(i => Math.min(items.length - 1, i + 1));
if (key.return) handleSelect(items[selectedIndex]);
});
// Load data on mount
useEffect(() => {
loadItems().then(setItems);
}, []);
return (
<Box flexDirection="column">
<Box borderStyle="single" padding={1}>
<Text bold>Dashboard</Text>
</Box>
<Box flexDirection="row">
<ItemList items={items} selected={selectedIndex} />
<DetailPanel item={items[selectedIndex]} />
</Box>
</Box>
);
};
render(<Dashboard />);tsx
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
const Dashboard = () => {
const [items, setItems] = useState<Item[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const { exit } = useApp();
// Handle keyboard input
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setSelectedIndex(i => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex(i => Math.min(items.length - 1, i + 1));
if (key.return) handleSelect(items[selectedIndex]);
});
// Load data on mount
useEffect(() => {
loadItems().then(setItems);
}, []);
return (
<Box flexDirection="column">
<Box borderStyle="single" padding={1}>
<Text bold>Dashboard</Text>
</Box>
<Box flexDirection="row">
<ItemList items={items} selected={selectedIndex} />
<DetailPanel item={items[selectedIndex]} />
</Box>
</Box>
);
};
render(<Dashboard />);Key Patterns
关键模式
Reactive updates:
tsx
import { useEffect, useState } from 'react';
const LiveStatus = () => {
const [status, setStatus] = useState('loading');
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchStatus();
setStatus(data);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Text color={status === 'ok' ? 'green' : 'red'}>{status}</Text>;
};响应式更新:
tsx
import { useEffect, useState } from 'react';
const LiveStatus = () => {
const [status, setStatus] = useState('loading');
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchStatus();
setStatus(data);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Text color={status === 'ok' ? 'green' : 'red'}>{status}</Text>;
};C#: Terminal.Gui
C#: Terminal.Gui
Basic Structure
基础结构
csharp
using Terminal.Gui;
class Program
{
static void Main()
{
Application.Init();
var top = Application.Top;
var win = new Window("Dashboard")
{
X = 0, Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill()
};
var listView = new ListView(items)
{
X = 0, Y = 0,
Width = Dim.Percent(30),
Height = Dim.Fill()
};
var detailView = new TextView()
{
X = Pos.Right(listView) + 1,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
listView.SelectedItemChanged += (args) => {
detailView.Text = GetDetails(items[listView.SelectedItem]);
};
win.Add(listView, detailView);
top.Add(win);
Application.Run();
Application.Shutdown();
}
}csharp
using Terminal.Gui;
class Program
{
static void Main()
{
Application.Init();
var top = Application.Top;
var win = new Window("Dashboard")
{
X = 0, Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill()
};
var listView = new ListView(items)
{
X = 0, Y = 0,
Width = Dim.Percent(30),
Height = Dim.Fill()
};
var detailView = new TextView()
{
X = Pos.Right(listView) + 1,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
listView.SelectedItemChanged += (args) => {
detailView.Text = GetDetails(items[listView.SelectedItem]);
};
win.Add(listView, detailView);
top.Add(win);
Application.Run();
Application.Shutdown();
}
}Layout Patterns
布局模式
Responsive Layout
响应式布局
Handle terminal resize gracefully:
python
undefined优雅处理终端窗口大小调整:
python
undefinedTextual - automatic with CSS
Textual - 通过CSS自动实现
CSS = """
#sidebar {
width: 30;
}
@media (width < 80) {
#sidebar { display: none; }
}
"""
```typescript
// Ink - useStdout hook
import { useStdout } from 'ink';
const ResponsiveLayout = () => {
const { stdout } = useStdout();
const width = stdout.columns;
return (
<Box flexDirection={width < 80 ? 'column' : 'row'}>
{width >= 80 && <Sidebar />}
<MainContent />
</Box>
);
};CSS = """
#sidebar {
width: 30;
}
@media (width < 80) {
#sidebar { display: none; }
}
"""
```typescript
// Ink - 使用useStdout钩子
import { useStdout } from 'ink';
const ResponsiveLayout = () => {
const { stdout } = useStdout();
const width = stdout.columns;
return (
<Box flexDirection={width < 80 ? 'column' : 'row'}>
{width >= 80 && <Sidebar />}
<MainContent />
</Box>
);
};Common Layouts
常见布局
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ Header │ │ Sidebar │ Main │
├──────────┬─────────────────────┤ │ │ │
│ Sidebar │ Main │ │ ────── │ │
│ │ │ │ Item 1 │ Detail View │
│ Nav │ Content │ │ Item 2 │ │
│ │ │ │ Item 3 │ │
├──────────┴─────────────────────┤ │ │ │
│ Footer │ └─────────────┴──────────────────┘
└────────────────────────────────┘
Master-Detail Sidebar + Content┌────────────────────────────────┐ ┌────────────────────────────────┐
│ Header │ │ Sidebar │ Main │
├──────────┬─────────────────────┤ │ │ │
│ Sidebar │ Main │ │ ────── │ │
│ │ │ │ Item 1 │ Detail View │
│ Nav │ Content │ │ Item 2 │ │
│ │ │ │ Item 3 │ │
├──────────┴─────────────────────┤ │ │ │
│ Footer │ └─────────────┴──────────────────┘
└────────────────────────────────┘
主从布局 侧边栏+内容布局State Management
状态管理
dot
digraph state {
rankdir=LR;
"User Input" [shape=ellipse];
"Event Handler" [shape=box];
"State Update" [shape=box];
"Re-render" [shape=box];
"Display" [shape=ellipse];
"User Input" -> "Event Handler";
"Event Handler" -> "State Update";
"State Update" -> "Re-render";
"Re-render" -> "Display";
"Display" -> "User Input" [style=dashed, label="next input"];
}Rules:
- Single source of truth - One place for each piece of state
- Unidirectional flow - Events → State → Render
- Reactive updates - Use reactive/useState, not manual refresh
dot
digraph state {
rankdir=LR;
"User Input" [shape=ellipse];
"Event Handler" [shape=box];
"State Update" [shape=box];
"Re-render" [shape=box];
"Display" [shape=ellipse];
"User Input" -> "Event Handler";
"Event Handler" -> "State Update";
"State Update" -> "Re-render";
"Re-render" -> "Display";
"Display" -> "User Input" [style=dashed, label="next input"];
}规则:
- 单一数据源 - 每个状态仅在一处维护
- 单向数据流 - 事件 → 状态 → 渲染
- 响应式更新 - 使用响应式状态/useState,而非手动刷新
Performance
性能优化
Avoid Re-render Storms
避免重复渲染风暴
python
undefinedpython
undefinedBad - triggers re-render per item
错误示例 - 每个元素添加都会触发渲染
for item in items:
self.items.append(item) # Each append triggers render!
for item in items:
self.items.append(item) # 每次追加都会触发渲染!
Good - single update
正确示例 - 单次更新
self.items = new_items # One render
undefinedself.items = new_items # 仅触发一次渲染
undefinedVirtualization for Large Lists
大数据列表的虚拟化
python
undefinedpython
undefinedTextual DataTable handles this automatically
Textual的DataTable会自动处理
For custom widgets, only render visible items
自定义组件仅渲染可见项
def render_visible(self):
viewport_start = self.scroll_offset
viewport_end = viewport_start + self.height
visible_items = self.items[viewport_start:viewport_end]
# Only render visible_items
undefineddef render_visible(self):
viewport_start = self.scroll_offset
viewport_end = viewport_start + self.height
visible_items = self.items[viewport_start:viewport_end]
# 仅渲染visible_items
undefinedDebounce Rapid Updates
高频更新防抖
python
from textual.timer import Timer
class LiveDashboard(App):
def __init__(self):
self._pending_updates = []
self._update_timer: Timer | None = None
def queue_update(self, data):
self._pending_updates.append(data)
if not self._update_timer:
self._update_timer = self.set_timer(0.1, self._flush_updates)
def _flush_updates(self):
# Process all pending updates at once
self.process_batch(self._pending_updates)
self._pending_updates = []
self._update_timer = Nonepython
from textual.timer import Timer
class LiveDashboard(App):
def __init__(self):
self._pending_updates = []
self._update_timer: Timer | None = None
def queue_update(self, data):
self._pending_updates.append(data)
if not self._update_timer:
self._update_timer = self.set_timer(0.1, self._flush_updates)
def _flush_updates(self):
# 批量处理所有待更新内容
self.process_batch(self._pending_updates)
self._pending_updates = []
self._update_timer = NoneKeyboard Navigation
键盘导航
Standard Keybindings
标准快捷键
| Key | Action |
|---|---|
| Navigate items |
| Select/confirm |
| Cancel/back |
| Quit |
| Help |
| Search |
| Next panel |
| 按键 | 操作 |
|---|---|
| 导航选项 |
| 选择/确认 |
| 取消/返回 |
| 退出 |
| 帮助 |
| 搜索 |
| 切换到下一个面板 |
Focus Management
焦点管理
python
undefinedpython
undefinedTextual
Textual
class MyApp(App):
def action_next_panel(self) -> None:
self.screen.focus_next()
def action_prev_panel(self) -> None:
self.screen.focus_previous()undefinedclass MyApp(App):
def action_next_panel(self) -> None:
self.screen.focus_next()
def action_prev_panel(self) -> None:
self.screen.focus_previous()undefinedAsync Operations: The Worker Pattern
异步操作:Worker模式
Critical rule: Never block the main thread. TUIs freeze if you make synchronous network/file calls.
核心规则: 永远不要阻塞主线程。如果执行同步网络/文件调用,TUI会冻结。
Python Textual Workers
Python Textual Workers
python
from textual.app import App
from textual.worker import Worker, WorkerState
class DashboardApp(App):
def on_mount(self) -> None:
# Start worker - doesn't block UI
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
"""Runs in background thread."""
result = await api.get_items() # Network call
self.items = result # Update state when done
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.ERROR:
self.show_error(str(event.worker.error))python
from textual.app import App
from textual.worker import Worker, WorkerState
class DashboardApp(App):
def on_mount(self) -> None:
# 启动Worker - 不会阻塞UI
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
"""在后台线程运行。"""
result = await api.get_items() # 网络请求
self.items = result # 完成后更新状态
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.ERROR:
self.show_error(str(event.worker.error))TypeScript Ink
TypeScript Ink
tsx
const Dashboard = () => {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Async in useEffect - doesn't block render
(async () => {
const result = await fetchData();
setData(result);
setLoading(false);
})();
}, []);
if (loading) return <Text>Loading...</Text>;
return <DataView data={data} />;
};tsx
const Dashboard = () => {
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// useEffect中的异步操作 - 不会阻塞渲染
(async () => {
const result = await fetchData();
setData(result);
setLoading(false);
})();
}, []);
if (loading) return <Text>Loading...</Text>;
return <DataView data={data} />;
};C# Terminal.Gui
C# Terminal.Gui
csharp
// Use Application.MainLoop.Invoke for thread-safe UI updates
Task.Run(async () => {
var data = await FetchDataAsync();
Application.MainLoop.Invoke(() => {
listView.SetSource(data); // Update UI on main thread
});
});csharp
// 使用Application.MainLoop.Invoke实现线程安全的UI更新
Task.Run(async () => {
var data = await FetchDataAsync();
Application.MainLoop.Invoke(() => {
listView.SetSource(data); // 在主线程更新UI
});
});Accessibility
可访问性
- High contrast by default - Don't rely only on color
- Screen reader text - Provide text alternatives
- Keyboard-only navigation - Everything accessible via keyboard
python
undefined- 默认高对比度 - 不要仅依赖颜色传递信息
- 屏幕阅读器文本 - 提供文本替代方案
- 纯键盘导航 - 所有功能都可通过键盘访问
python
undefinedTextual - use semantic widgets
Textual - 使用语义化组件
from textual.widgets import Button, Label
from textual.widgets import Button, Label
Bad - visual only
错误示例 - 仅视觉展示
yield Static("[bold red]Error![/]")
yield Static("[bold red]Error![/]")
Good - semantic + visual
正确示例 - 语义化+视觉展示
yield Label("Error: File not found", id="error", classes="error")
undefinedyield Label("Error: File not found", id="error", classes="error")
undefinedAnti-Patterns
反模式
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Blocking main thread | UI freezes | Use workers/async |
| Manual screen clear | Flicker | Use framework's render |
| Global state mutations | Race conditions | Use reactive state |
| Not handling resize | Broken layout | Test with small terminals |
| Hardcoded dimensions | Not portable | Use relative sizing (Dim.Fill, percentages) |
| No keyboard shortcuts | Mouse-dependent | Add BINDINGS/useInput |
| Polling in render | CPU spin | Use timers, events |
| 反模式 | 问题 | 修复方案 |
|---|---|---|
| 阻塞主线程 | UI冻结 | 使用Worker/异步操作 |
| 手动清屏 | 闪烁 | 使用框架自带的渲染机制 |
| 全局状态突变 | 竞态条件 | 使用响应式状态 |
| 不处理窗口大小调整 | 布局错乱 | 在小终端窗口测试 |
| 硬编码尺寸 | 不可移植 | 使用相对尺寸(Dim.Fill、百分比) |
| 无键盘快捷键 | 依赖鼠标 | 添加BINDINGS/useInput |
| 渲染时轮询 | CPU空转 | 使用定时器、事件驱动 |
Testing TUI Apps
TUI应用测试
Python with Textual
Python Textual测试
python
from textual.testing import AppTest
async def test_dashboard():
async with AppTest(DashboardApp()) as app:
# Wait for mount
await app.wait_for_loaded()
# Check initial state
table = app.query_one("#table", DataTable)
assert table.row_count > 0
# Simulate key press
await app.press("down")
await app.press("enter")
# Check result
detail = app.query_one("#detail", Static)
assert "selected" in detail.render()python
from textual.testing import AppTest
async def test_dashboard():
async with AppTest(DashboardApp()) as app:
# 等待应用加载完成
await app.wait_for_loaded()
# 检查初始状态
table = app.query_one("#table", DataTable)
assert table.row_count > 0
# 模拟按键操作
await app.press("down")
await app.press("enter")
# 检查结果
detail = app.query_one("#detail", Static)
assert "selected" in detail.render()Testing Strategies
测试策略
- Snapshot tests - Compare rendered output
- Interaction tests - Simulate key presses, verify state
- State tests - Directly test state management logic
- Integration tests - Test with real backend (mocked API)
- 快照测试 - 对比渲染输出
- 交互测试 - 模拟按键,验证状态
- 状态测试 - 直接测试状态管理逻辑
- 集成测试 - 结合真实后端(模拟API)测试
File Structure
文件结构
my_tui/
├── app.py # Main App class
├── screens/ # Full-screen views
│ ├── main.py
│ └── detail.py
├── widgets/ # Reusable components
│ ├── sidebar.py
│ └── status_bar.py
├── state/ # State management
│ └── store.py
├── api/ # Backend communication
│ └── client.py
├── styles.css # Textual CSS (if using)
└── tests/
└── test_app.pymy_tui/
├── app.py # 主App类
├── screens/ # 全屏视图
│ ├── main.py
│ └── detail.py
├── widgets/ # 可复用组件
│ ├── sidebar.py
│ └── status_bar.py
├── state/ # 状态管理
│ └── store.py
├── api/ # 后端通信
│ └── client.py
├── styles.css # Textual样式文件(如果使用)
└── tests/
└── test_app.py