building-tui-apps

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Building 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

快速参考:各语言对应的库

LanguageFull TUI FrameworkSimple Interactive
Python
textual
(modern, reactive)
rich
(tables, progress, prompts)
TypeScript
ink
(React-like) or
blessed
inquirer
(prompts only)
C#
Terminal.Gui
(full widgets)
Spectre.Console
(tables, prompts)
语言全功能TUI框架轻量交互式工具
Python
textual
(现代、响应式)
rich
(表格、进度条、提示框)
TypeScript
ink
(类React) 或
blessed
inquirer
(仅提示框)
C#
Terminal.Gui
(全组件库)
Spectre.Console
(表格、提示框)

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:
  1. State changes → triggers re-render
  2. Events (keyboard, mouse, resize) → update state
  3. Widgets compose into layouts
┌─────────────────────────────────────────────────────────┐
│                        App                               │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐  │
│  │    State    │→ │   Widgets   │→ │     Render      │  │
│  │  (reactive) │  │ (compose)   │  │  (on change)    │  │
│  └─────────────┘  └─────────────┘  └─────────────────┘  │
│         ↑                                    │          │
│         └────────── Events ←─────────────────┘          │
└─────────────────────────────────────────────────────────┘
所有现代TUI框架都采用这种响应式模式:
  1. 状态变更 → 触发重新渲染
  2. 事件(键盘、鼠标、窗口大小调整)→ 更新状态
  3. 组件组合成布局

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
undefined

Textual - 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:
  1. Single source of truth - One place for each piece of state
  2. Unidirectional flow - Events → State → Render
  3. 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"];
}
规则:
  1. 单一数据源 - 每个状态仅在一处维护
  2. 单向数据流 - 事件 → 状态 → 渲染
  3. 响应式更新 - 使用响应式状态/useState,而非手动刷新

Performance

性能优化

Avoid Re-render Storms

避免重复渲染风暴

python
undefined
python
undefined

Bad - 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
undefined
self.items = new_items # 仅触发一次渲染
undefined

Virtualization for Large Lists

大数据列表的虚拟化

python
undefined
python
undefined

Textual 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
undefined
def render_visible(self): viewport_start = self.scroll_offset viewport_end = viewport_start + self.height visible_items = self.items[viewport_start:viewport_end] # 仅渲染visible_items
undefined

Debounce 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 = None
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):
        # 批量处理所有待更新内容
        self.process_batch(self._pending_updates)
        self._pending_updates = []
        self._update_timer = None

Keyboard Navigation

键盘导航

Standard Keybindings

标准快捷键

KeyAction
↑/↓
or
j/k
Navigate items
Enter
Select/confirm
Escape
Cancel/back
q
Quit
?
Help
/
Search
Tab
Next panel
按键操作
↑/↓
j/k
导航选项
Enter
选择/确认
Escape
取消/返回
q
退出
?
帮助
/
搜索
Tab
切换到下一个面板

Focus Management

焦点管理

python
undefined
python
undefined

Textual

Textual

class MyApp(App): def action_next_panel(self) -> None: self.screen.focus_next()
def action_prev_panel(self) -> None:
    self.screen.focus_previous()
undefined
class MyApp(App): def action_next_panel(self) -> None: self.screen.focus_next()
def action_prev_panel(self) -> None:
    self.screen.focus_previous()
undefined

Async 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

可访问性

  1. High contrast by default - Don't rely only on color
  2. Screen reader text - Provide text alternatives
  3. Keyboard-only navigation - Everything accessible via keyboard
python
undefined
  1. 默认高对比度 - 不要仅依赖颜色传递信息
  2. 屏幕阅读器文本 - 提供文本替代方案
  3. 纯键盘导航 - 所有功能都可通过键盘访问
python
undefined

Textual - 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")
undefined
yield Label("Error: File not found", id="error", classes="error")
undefined

Anti-Patterns

反模式

Anti-PatternProblemFix
Blocking main threadUI freezesUse workers/async
Manual screen clearFlickerUse framework's render
Global state mutationsRace conditionsUse reactive state
Not handling resizeBroken layoutTest with small terminals
Hardcoded dimensionsNot portableUse relative sizing (Dim.Fill, percentages)
No keyboard shortcutsMouse-dependentAdd BINDINGS/useInput
Polling in renderCPU spinUse 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

测试策略

  1. Snapshot tests - Compare rendered output
  2. Interaction tests - Simulate key presses, verify state
  3. State tests - Directly test state management logic
  4. Integration tests - Test with real backend (mocked API)
  1. 快照测试 - 对比渲染输出
  2. 交互测试 - 模拟按键,验证状态
  3. 状态测试 - 直接测试状态管理逻辑
  4. 集成测试 - 结合真实后端(模拟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.py
my_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