mcpField
T
textual

textual

github.com/johnlarkin1开发21256 次安装13 周前更新
代码仓库

关于 textual

textual 是用于构建终端应用的 Python 框架,面向开发者。它采用 App、Screen、Widget 分层结构,并提供响应式状态、事件消息、CSS 布局和异步任务等机制。

主要功能

  1. 01App、Screen、Widget 架构
  2. 02响应式状态与事件消息
  3. 03可定制 Widget
  4. 04CSS 布局与样式
  5. 05异步任务与应用测试

适用场景

  1. 01构建交互式终端应用
  2. 02创建可复用的自定义 Widget
  3. 03测试终端应用中的交互行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/johnlarkin1/claude-code-extensions --skill textual

安装后在 Claude Code 中输入/textual确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
css.md
examples.md
widgets.md

详细介绍

Textual TUI 框架

使用 Textual 采用受 Web 启发的架构构建终端应用:App → Screen → Widget。

快速开始

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static

class MyApp(App):
    CSS_PATH = "styles.tcss"
    BINDINGS = [("q", "quit", "Quit"), ("d", "toggle_dark", "Dark Mode")]

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Hello, World!")
        yield Footer()

    def action_toggle_dark(self) -> None:
        self.theme = "textual-dark" if self.theme == "textual-light" else "textual-light"

if __name__ == "__main__":
    MyApp().run()

核心概念

Widget 生命周期
  1. __init__() → compose() → on_mount() → on_show()/on_hide() → on_unmount()
响应式
from textual.reactive import reactive, var

class MyWidget(Widget):
    count = reactive(0)  # Triggers refresh on change
    internal = var("")   # No automatic refresh

    def watch_count(self, new_value: int) -> None:
        """Called when count changes."""
        self.styles.background = "green" if new_value > 0 else "red"

    def validate_count(self, value: int) -> int:
        """Constrain values."""
        return max(0, min(100, value))
事件和消息
from textual import on
from textual.message import Message

class MyWidget(Widget):
    class Selected(Message):
        def __init__(self, value: str) -> None:
            self.value = value
            super().__init__()

    def on_click(self) -> None:
        self.post_message(self.Selected("item"))

class MyApp(App):
    # Handler naming: on_<widget>_<message>
    def on_button_pressed(self, event: Button.Pressed) -> None:
        self.log(f"Button {event.button.id} pressed")

    @on(Button.Pressed, "#submit")  # CSS selector filtering
    def handle_submit(self) -> None:
        pass
数据流
  • 属性向下传递:父级直接设置子级属性
  • 消息向上传递:子级通过 post_message() 向父级发送消息

屏幕

from textual.screen import Screen

class WelcomeScreen(Screen):
    BINDINGS = [("escape", "app.pop_screen", "Back")]

    def compose(self) -> ComposeResult:
        yield Static("Welcome!")
        yield Button("Continue", id="continue")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "continue":
            self.app.push_screen("main")

class MyApp(App):
    SCREENS = {"welcome": WelcomeScreen, "main": MainScreen}

    def on_mount(self) -> None:
        self.push_screen("welcome")

自定义 Widget

简单 Widget
class Greeting(Widget):
    def render(self) -> RenderResult:
        return "Hello, [bold]World[/bold]!"
复合 Widget
class LabeledButton(Widget):
    DEFAULT_CSS = """
    LabeledButton { layout: horizontal; height: auto; }
    LabeledButton Label { width: 1fr; }
    """

    def __init__(self, label: str, button_text: str) -> None:
        self.label_text = label
        self.button_text = button_text
        super().__init__()

    def compose(self) -> ComposeResult:
        yield Label(self.label_text)
        yield Button(self.button_text)
可聚焦 Widget
class Counter(Widget):
    can_focus = True
    BINDINGS = [("up", "increment", "+"), ("down", "decrement", "-")]
    count = reactive(0)

    def action_increment(self) -> None:
        self.count += 1

布局模式

容器
from textual.containers import Horizontal, Vertical, Grid, VerticalScroll

def compose(self) -> ComposeResult:
    with Vertical():
        with Horizontal():
            yield Button("Left")
            yield Button("Right")
        with VerticalScroll():
            for i in range(100):
                yield Label(f"Item {i}")
Grid CSS
Grid {
    layout: grid;
    grid-size: 3 2;           /* columns rows */
    grid-columns: 1fr 2fr 1fr;
    grid-gutter: 1 2;
}
#wide { column-span: 2; }
停靠
#header { dock: top; height: 3; }
#sidebar { dock: left; width: 25; }
#footer { dock: bottom; height: 1; }

Worker(异步)

from textual import work

class MyApp(App):
    @work(exclusive=True)  # Cancels previous
    async def fetch_data(self, url: str) -> None:
        async with httpx.AsyncClient() as client:
            response = await client.get(url)
            self.query_one("#result").update(response.text)

    @work(thread=True)  # For sync APIs
    def sync_operation(self) -> None:
        result = blocking_call()
        self.call_from_thread(self.update_ui, result)

测试

async def test_app():
    app = MyApp()
    async with app.run_test() as pilot:
        await pilot.press("enter")
        await pilot.click("#button")
        await pilot.pause()  # Wait for messages
        assert app.query_one("#status").render() == "Done"

常见操作

# Query widgets
self.query_one("#id")
self.query_one(Button)
self.query(".class")

# CSS classes
widget.add_class("active")
widget.toggle_class("visible")
widget.set_class(condition, "active")

# Visibility
widget.display = True/False

# Mount/remove
self.mount(NewWidget())
widget.remove()

# Timers
self.set_interval(1.0, callback)
self.set_timer(5.0, callback)

# Exit
self.exit(return_code=0)

参考资料

评论

支持 Markdown