├── .python-version ├── src └── textual_cookbook │ ├── __init__.py │ ├── recipes │ ├── architecture_patterns │ │ ├── config.ini │ │ ├── css_basics.py │ │ ├── abc_meta.py │ │ ├── app_dict.py │ │ ├── configparser_usage.py │ │ ├── logging_html.py │ │ ├── help_screen.py │ │ ├── screen_push_wait.py │ │ └── use_default_screen.py │ ├── styling_and_colors │ │ ├── logging_color.py │ │ ├── color_basic.py │ │ ├── collapsible_style.py │ │ ├── button_focus_highlight.py │ │ ├── button_markup.py │ │ └── checkbox_style.py │ ├── textual_api_usage │ │ ├── click_links.py │ │ ├── widget_loading.py │ │ ├── click_container.py │ │ ├── app_events_order.py │ │ ├── select_change_options.py │ │ ├── tree_node_select.py │ │ ├── message_control.py │ │ ├── reactivity_validation.py │ │ ├── workers_exclusive.py │ │ ├── signal_usage.py │ │ ├── markdownviewer_update.py │ │ ├── mouse_capture.py │ │ └── screens_dom.py │ ├── animation_effects │ │ ├── animate_offset.py │ │ ├── spinner_widget.py │ │ ├── animate_multiple.py │ │ ├── spinner_widget_loading.py │ │ ├── reveal_redacted.py │ │ └── context_menu.py │ ├── modifying_widgets │ │ ├── header_timezone.py │ │ ├── listview_tooltips.py │ │ ├── progress_colors.py │ │ ├── progress_bounce.py │ │ ├── textarea_submit.py │ │ ├── tabs_unclickable.py │ │ ├── datatable_expandcol.py │ │ ├── decline_failed_input.py │ │ ├── better_optionlist.py │ │ └── datatable_headersort.py │ └── tips_and_tricks │ │ ├── truecolor_test.py │ │ ├── transparent_toggle.py │ │ ├── container_disabled.py │ │ ├── tooltips_timer.py │ │ └── timers_in_workers.py │ ├── cli.py │ ├── common_bugs │ ├── bug_widget_css.py │ ├── bug_same_widget.py │ ├── bug_get_screen.py │ └── bug_query_screens.py │ ├── styles.tcss │ └── main.py ├── .github ├── CODEOWNERS ├── scripts │ ├── tag_release.py │ └── validate_main.sh └── workflows │ ├── ci-testing-reports.yml │ └── release.yml ├── .gitignore ├── docs ├── reports │ ├── note.md │ ├── dark_theme.css │ └── index.html └── index.html ├── LICENSE ├── CHANGELOG.md ├── justfile ├── pyproject.toml ├── tests └── test_recipes.py ├── README.md └── noxfile.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /src/textual_cookbook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @edward-jazzhands -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/config.ini: -------------------------------------------------------------------------------- 1 | # Sample .ini file for configuration 2 | 3 | [MAIN] 4 | my_string = Hello, World! 5 | my_integer = 42 6 | my_float = 3.14 7 | my_boolean = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Textual stuff 10 | snapshot_report.html 11 | error.* 12 | 13 | # Virtual environments 14 | .venv 15 | 16 | # tooling stuff 17 | *_cache/ 18 | .nox/ 19 | 20 | # Reports 21 | *-report.* 22 | *-summary.* 23 | docs/reports/*.json 24 | 25 | # other 26 | sandbox/ 27 | misc/ 28 | [Tt]humbs.db -------------------------------------------------------------------------------- /docs/reports/note.md: -------------------------------------------------------------------------------- 1 | Reports are placed into the `reports` branch of the repository when created or updated. 2 | 3 | The reports are served as web pages through Github Pages, which can be accessed at: 4 | 5 | https://ttygroup.github.io/textual-cookbook/reports/ 6 | 7 | To view the raw files directly, see the Reports Branch: 8 | 9 | https://github.com/ttygroup/textual-cookbook/tree/reports/docs/reports 10 | 11 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 |If you are not redirected automatically, click here.
15 | 16 | 17 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/logging_color.py: -------------------------------------------------------------------------------- 1 | """A very simple demonstration of colorirzed logging in Textual. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App 7 | from rich.text import Text 8 | 9 | class TextualApp(App[None]): 10 | 11 | def on_ready(self): 12 | 13 | self.log(Text.from_markup( 14 | "[bold][italic]Logger launched in on_ready method.[/italic] \n" 15 | "[green]Good Log message[/green] \n" 16 | "[blink red]Attention: something happened:" 17 | )) 18 | 19 | 20 | if __name__ == "__main__": 21 | app = TextualApp() 22 | app.run() 23 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/cli.py: -------------------------------------------------------------------------------- 1 | # stndlib 2 | from __future__ import annotations 3 | import click 4 | 5 | 6 | @click.command() 7 | @click.argument("recipe", type=str, default=None, required=False) 8 | @click.option( 9 | "--run", "-r", is_flag=True, default=False, help="Run recipe immediately" 10 | ) 11 | def cli( 12 | recipe: str | None, 13 | run: bool = False, 14 | ) -> None: 15 | """ 16 | Textual-Cookbook 17 | """ 18 | from textual_cookbook.main import CookBookApp 19 | 20 | CookBookApp(starting_recipe=recipe, run=run).run() 21 | 22 | 23 | 24 | def run() -> None: 25 | """Entry point for the application.""" 26 | cli() 27 | 28 | 29 | if __name__ == "__main__": 30 | cli() 31 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/click_links.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to use click links. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App, ComposeResult 7 | from textual.widgets import Label 8 | 9 | class TextualApp(App[None]): 10 | 11 | BINDINGS = [ 12 | ("f2", "show_note", "Show Notification"), 13 | ] 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Label("Play the [on green @click=app.bell]bells[/] today!") 17 | yield Label("Show a [@click=app.show_note]notification[/]!") 18 | 19 | def action_show_note(self) -> None: 20 | self.notify("Action activated!") 21 | 22 | 23 | if __name__ == "__main__": 24 | app = TextualApp() 25 | app.run() 26 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/color_basic.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates basic usage of colored text in a Static widget 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App 7 | from textual.widgets import Static 8 | 9 | class TextualApp(App[None]): 10 | 11 | CSS = """ 12 | Screen { align: center middle; } 13 | #my_static { 14 | background: $surface; 15 | content-align: center middle; 16 | width: auto; height: 3; 17 | padding: 1; 18 | } 19 | """ 20 | 21 | def compose(self): 22 | yield Static("[yellow]This[/yellow] is my [red]Static[/red]", id="my_static") 23 | # Note that Static has a `markup` argument which is True by default. 24 | 25 | 26 | if __name__ == "__main__": 27 | app = TextualApp() 28 | app.run() 29 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /.github/scripts/tag_release.py: -------------------------------------------------------------------------------- 1 | # tag_release.py 2 | import sys 3 | import subprocess 4 | import tomli # Or `tomli` for Python < 3.11 5 | 6 | # 1. Read the version from the single source of truth 7 | with open("pyproject.toml", "rb") as f: 8 | pyproject_data = tomli.load(f) 9 | version = pyproject_data["project"]["version"] 10 | 11 | # 2. Construct the tag and the git command 12 | tag = f"v{version}" 13 | print(f"Found version {version}. Creating tag: {tag}") 14 | 15 | # 3. Run the git command 16 | try: 17 | subprocess.run(["git", "tag", tag], check=True) 18 | except subprocess.CalledProcessError: 19 | print(f"Error: Could not create tag. Does the tag '{tag}' already exist?") 20 | sys.exit(1) 21 | except FileNotFoundError: 22 | print("Error: 'git' command not found. Is Git installed and in your PATH?") 23 | sys.exit(1) 24 | else: 25 | print(f"Successfully created tag '{tag}'.") 26 | sys.exit(0) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/animate_offset.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to animate a single widget's offset 2 | using Textual's animation capabilities. The widget will move horizontally 3 | when the button is pressed, and the animation will last for 1 second. 4 | 5 | Recipe by Edward Jazzhands""" 6 | 7 | import sys 8 | from textual.app import App, ComposeResult 9 | from textual.widgets import Static, Button 10 | from textual.geometry import Offset 11 | 12 | class TextualApp(App[None]): 13 | 14 | def compose(self) -> ComposeResult: 15 | yield Button("Animate Offset", id="animate_button") 16 | self.box = Static("Hello, World!") 17 | yield self.box 18 | 19 | def on_button_pressed(self) -> None: 20 | self.box.animate("offset", value=Offset(20, 0), duration=1.0) 21 | 22 | 23 | if __name__ == "__main__": 24 | app = TextualApp() 25 | app.run() 26 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/collapsible_style.py: -------------------------------------------------------------------------------- 1 | """This file shows how to change the color and style of the 2 | title/header of a Collapsible widget in Textual. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual.app import App 8 | from textual.widgets import Collapsible, Button 9 | 10 | 11 | class TextualApp(App[None]): 12 | 13 | CSS = """ 14 | Screen { align: center middle; } 15 | Collapsible { 16 | width: 50%; height: auto; 17 | CollapsibleTitle { 18 | background: transparent; 19 | color: blue; /* text color */ 20 | } 21 | } 22 | """ 23 | 24 | def compose(self): 25 | 26 | for _ in range(3): 27 | with Collapsible(): 28 | for _ in range(3): 29 | yield Button("Hello, Textual!") 30 | 31 | 32 | if __name__ == "__main__": 33 | app = TextualApp() 34 | app.run() 35 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/button_focus_highlight.py: -------------------------------------------------------------------------------- 1 | """This demonstrates how to remove the focus highlight from buttons in Textual. 2 | Note that the buttons are still focusable, but they will not show the 3 | default focus highlight. 4 | 5 | Recipe by Edward Jazzhands""" 6 | 7 | import sys 8 | from textual.app import App 9 | from textual.widgets import Button 10 | from textual.containers import Container 11 | 12 | class TextualApp(App[None]): 13 | 14 | CSS = """ 15 | #my_container { width: 1fr; height: 1fr; align: center middle; } 16 | .my_button {&:focus {text-style: none;}} 17 | """ 18 | 19 | def compose(self): 20 | 21 | with Container(id="my_container"): 22 | yield Button("Button1", classes="my_button") 23 | yield Button("Button2", classes="my_button") 24 | yield Button("Button3", classes="my_button") 25 | 26 | 27 | if __name__ == "__main__": 28 | app = TextualApp() 29 | app.run() 30 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/header_timezone.py: -------------------------------------------------------------------------------- 1 | """This demonstrates how to change the timezone of the header clock in Textual. 2 | This is performing a monkey patch. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from zoneinfo import ZoneInfo 8 | from datetime import datetime 9 | 10 | from textual.app import App, RenderResult 11 | from textual.widgets import Footer, Header 12 | from textual.widgets._header import HeaderClock 13 | from rich.text import Text 14 | 15 | def render(self) -> RenderResult: 16 | """Render the header clock. 17 | 18 | Returns: 19 | The rendered clock. 20 | """ 21 | return Text(datetime.now(ZoneInfo("UTC")).time().strftime(self.time_format)) 22 | 23 | HeaderClock.render = render 24 | 25 | class TextualApp(App[None]): 26 | 27 | def compose(self): 28 | 29 | yield Header(show_clock=True) 30 | yield Footer() 31 | 32 | 33 | if __name__ == "__main__": 34 | app = TextualApp() 35 | app.run() 36 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /.github/scripts/validate_main.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Check if on main branch 5 | CURRENT_BRANCH=$(git branch --show-current) 6 | if [ "$CURRENT_BRANCH" != "main" ]; then 7 | echo "Error: You are not on the main branch. Please switch to main." 8 | exit 1 9 | fi 10 | 11 | # Check for uncommitted changes 12 | if [ -n "$(git status --porcelain)" ]; then 13 | echo "Error: There are uncommitted changes. Please commit or stash them." 14 | exit 1 15 | fi 16 | 17 | # Fetch latest changes from remote 18 | git fetch 19 | 20 | # Check if local main is up to date with origin/main 21 | if ! git rev-parse origin/main > /dev/null 2>&1; then 22 | echo "Error: Remote branch origin/main does not exist. Please set up a remote tracking branch." 23 | exit 1 24 | fi 25 | LOCAL_HASH=$(git rev-parse main) 26 | REMOTE_HASH=$(git rev-parse origin/main) 27 | if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then 28 | echo "Error: Your local main branch is not up to date with origin/main. Please pull the latest changes." 29 | exit 1 30 | fi -------------------------------------------------------------------------------- /src/textual_cookbook/common_bugs/bug_widget_css.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how widgets do not have a `CSS` class attribute. 2 | It is fairly common to assume that they do, but they do not. 3 | The code below will not work as expected. 4 | Widgets must use the `DEFAULT_CSS` class attribute instead. 5 | 6 | Example by Edward Jazzhands, 2025""" 7 | 8 | from textual.app import App, ComposeResult 9 | from textual.widget import Widget 10 | from textual.widgets import Header, Static 11 | 12 | class MyContainer(Widget): 13 | 14 | CSS = """ # Widget.CSS does not exist, use DEFAULT_CSS instead 15 | #porque { 16 | border: round red; 17 | } 18 | """ 19 | 20 | def compose(self) -> ComposeResult: 21 | yield Static("Hello World", id="porque") 22 | 23 | 24 | class BorderTestApp(App): 25 | CSS = """ 26 | MyContainer { 27 | border: round blue; 28 | } 29 | """ 30 | 31 | def compose(self) -> ComposeResult: 32 | yield Header() 33 | yield MyContainer() 34 | 35 | 36 | if __name__ == "__main__": 37 | app = BorderTestApp() 38 | app.run() -------------------------------------------------------------------------------- /src/textual_cookbook/common_bugs/bug_same_widget.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how you cannot use the same widget in multiple places. 2 | The second Static `widget2` will not be displayed twice because it is already 3 | displayed in the first container. 4 | 5 | Example by Edward Jazzhands, 2025""" 6 | 7 | from textual.app import App 8 | from textual.widgets import Static, Footer 9 | from textual.containers import Container 10 | 11 | class TextualApp(App[None]): 12 | 13 | DEFAULT_CSS = """ 14 | #my_container { 15 | width: 1fr; height: 1fr; 16 | border: solid red; 17 | align: center middle; content-align: center middle; 18 | } 19 | Static { border: solid blue; width: auto;} 20 | """ 21 | 22 | def compose(self): 23 | 24 | widget = Static("Hello, Textual!") 25 | widget2 = Static("Hello, Textual! 2") 26 | 27 | with Container(id="my_container"): 28 | yield widget2 29 | with Container(id="my_container2"): 30 | yield widget 31 | yield widget2 32 | 33 | yield Footer() 34 | 35 | 36 | TextualApp().run() -------------------------------------------------------------------------------- /src/textual_cookbook/common_bugs/bug_get_screen.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates ? 2 | Example by Edward Jazzhands, 2025""" 3 | 4 | from textual import on 5 | from textual.app import App, ComposeResult 6 | from textual.widgets import Button 7 | from textual.screen import Screen 8 | from textual.message import Message 9 | 10 | class MainScreen(Screen[None]): 11 | 12 | class EventFoo(Message): 13 | pass 14 | 15 | def compose(self) -> ComposeResult: 16 | yield Button("Press Me", id="main_button") 17 | 18 | def on_button_pressed(self) -> None: 19 | self.post_message(self.EventFoo()) 20 | 21 | class TuiApp(App[None]): 22 | 23 | SCREENS = {"main": MainScreen} 24 | 25 | def on_mount(self) -> None: 26 | self.push_screen("main") 27 | 28 | @on(MainScreen.EventFoo) 29 | def workfoo(self) -> None: 30 | 31 | main_screen = self.get_screen("main", MainScreen) 32 | main_screen.query_one("#main_button", Button).label = "Success" 33 | self.notify(f"EventFoo received from {main_screen}") 34 | 35 | 36 | if __name__ == "__main__": 37 | app = TuiApp() 38 | app.run() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Textual Tool Yard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/listview_tooltips.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates how to create a ListView with tooltips for each item. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App, ComposeResult 7 | from textual.widgets import Footer, Label, ListItem, ListView 8 | 9 | 10 | class TextualApp(App[None]): 11 | 12 | CSS = """ 13 | Screen {align: center middle;} 14 | ListView { width: 30; height: auto; } 15 | Label { padding: 1 2; } 16 | """ 17 | 18 | def compose(self) -> ComposeResult: 19 | 20 | with ListView(): 21 | item1 = ListItem(Label("One")) 22 | item1.tooltip = "This is the first item" 23 | item2 = ListItem(Label("Two")) 24 | item2.tooltip = "This is the second item" 25 | item3 = ListItem(Label("Three")) 26 | item3.tooltip = "This is the third item" 27 | 28 | yield item1 29 | yield item2 30 | yield item3 31 | 32 | yield Footer() 33 | 34 | 35 | if __name__ == "__main__": 36 | app = TextualApp() 37 | app.run() 38 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/widget_loading.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how a widget can be initialized with a loading state 2 | and then updated after a delay (simulating the time taken to boot up the app). 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual.app import App 8 | from textual.widgets import Static, Footer 9 | from textual.containers import Container 10 | 11 | class TextualApp(App[None]): 12 | 13 | DEFAULT_CSS = """ 14 | #my_container { align: center middle; } 15 | #my_static { width: 40; height: 15; border: solid $primary; } 16 | """ 17 | 18 | def compose(self): 19 | 20 | self.my_static = Static("Hello, Textual! great day eh", id="my_static") 21 | self.my_static.loading = True 22 | with Container(id="my_container"): 23 | yield self.my_static 24 | yield Footer() 25 | 26 | def on_ready(self): 27 | self.set_timer(2, self.finished_loading) 28 | 29 | def finished_loading(self): 30 | self.my_static.loading = False 31 | 32 | 33 | if __name__ == "__main__": 34 | app = TextualApp() 35 | app.run() 36 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/button_markup.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates how to create a button with markup text. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App 7 | from textual.widgets import Footer, Button 8 | from textual.containers import Container 9 | from textual.content import Content 10 | 11 | class TextualApp(App[None]): 12 | 13 | CSS = """ 14 | #my_container { 15 | width: 1fr; height: 1fr; 16 | border: solid red; 17 | align: center middle; content-align: center middle; 18 | } 19 | /* This just removes the focus highlight from the button text: */ 20 | .buttons { &:focus {text-style: bold;} } 21 | """ 22 | 23 | def compose(self): 24 | 25 | with Container(id="my_container"): 26 | button = Button( 27 | Content.from_markup("[yellow]This[/yellow] is my [red]button"), 28 | classes="buttons" 29 | ) 30 | yield button 31 | yield Button("This is a normal button") 32 | yield Footer() 33 | 34 | 35 | if __name__ == "__main__": 36 | app = TextualApp() 37 | app.run() 38 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/click_container.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to use on_click to get the widget 2 | that was clicked within a Container in Textual. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual.containers import Container 8 | from textual.widgets import Static 9 | from textual.app import App 10 | from textual import events 11 | 12 | class PopularContainer(Container): 13 | 14 | def on_click(self, event: events.Click) -> None: 15 | 16 | self.notify(f"card: {self.id}") 17 | # card = event.control # <-- this would also work. 18 | # if card: 19 | # self.notify(f"card: {card.id}") 20 | 21 | class TextualApp(App[None]): 22 | 23 | DEFAULT_CSS = """ 24 | Screen { align: center middle; } 25 | #my_container { border: solid blue; width: 50%; height: 50%;} 26 | #popular_container { border: solid red; width: 50%;} 27 | """ 28 | 29 | def compose(self): 30 | 31 | with Container(id="my_container"): 32 | with PopularContainer(id="popular_container"): 33 | yield Static("Hello, Textual!", id="my_static") 34 | 35 | 36 | if __name__ == "__main__": 37 | app = TextualApp() 38 | app.run() 39 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/css_basics.py: -------------------------------------------------------------------------------- 1 | """This file is a very simple example of how to use Textual CSS to 2 | set the sizes and borders of widgets. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual import on 8 | from textual.app import App 9 | from textual.widgets import Button, RichLog, Footer 10 | from textual.containers import Container 11 | 12 | 13 | class TextualApp(App[None]): 14 | 15 | CSS = """ 16 | Screen { align: center middle; } 17 | RichLog { 18 | border: solid blue; 19 | width: 70%; 20 | height: 1fr; 21 | } 22 | #button_container { 23 | border: solid green; 24 | height: 15; 25 | } 26 | """ 27 | 28 | BINDINGS = [ 29 | ("g", "send_log_msg", "Send a Log Message"), 30 | ] 31 | 32 | def compose(self): 33 | 34 | yield RichLog() 35 | with Container(id="button_container"): 36 | yield Button("Click to send log msg", id="my_button") 37 | yield Footer() 38 | 39 | @on(Button.Pressed, "#my_button") 40 | def action_send_log_msg(self): 41 | self.query_one(RichLog).write("This is a log message") 42 | 43 | 44 | if __name__ == "__main__": 45 | app = TextualApp() 46 | app.run() 47 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/app_events_order.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates what order that events are fired in Textual. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App 7 | from textual.widgets import Footer 8 | class TextualApp(App): 9 | 10 | def counter(self) -> int: 11 | self._counter += 1 12 | return self._counter 13 | 14 | def __init__(self): # This is the very first thing to run. 15 | super().__init__() 16 | self.foo = "foo" 17 | self._counter = 0 18 | self.log("This log statement won't work. The logger isn't set up yet") 19 | 20 | def on_load(self, event): 21 | self.log(f"on_load: {self.counter()} | foo: {self.foo}") 22 | 23 | def compose(self): 24 | self.log(f"compose: {self.counter()})") #2 25 | 26 | yield Footer() 27 | 28 | def on_resize(self, event): 29 | self.log(f"on_resize: {self.counter()})") #3 and 5 30 | 31 | def on_mount(self, event): 32 | self.log(f"on_mount: {self.counter()})") #4 33 | 34 | def on_ready(self, event): 35 | self.log(f"on_ready: {self.counter()})") #6 36 | 37 | 38 | if __name__ == "__main__": 39 | app = TextualApp() 40 | app.run() 41 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/select_change_options.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to change the options of a Select widget in real-time. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | from textual.app import App 7 | from textual.widgets import Select, Button 8 | from textual.containers import Container 9 | 10 | class TextualApp(App[None]): 11 | 12 | DEFAULT_CSS = """ 13 | #my_container { width: 1fr; height: 1fr; align: center middle; } 14 | """ 15 | 16 | my_list = [ 17 | ("Option 1", "1"), 18 | ("Option 2", "2"), 19 | ("Option 3", "3"), 20 | ("Option 4", "4"), 21 | ("Option 5", "5"), 22 | ] 23 | my_list2 = [ 24 | ("Option 6", "6"), 25 | ("Option 7", "7"), 26 | ("Option 8", "8"), 27 | ("Option 9", "9"), 28 | ("Option 10", "10"), 29 | ] 30 | 31 | def compose(self): 32 | 33 | with Container(id="my_container"): 34 | self.my_select = Select(self.my_list) 35 | yield self.my_select 36 | yield Button("Change list") 37 | 38 | def on_button_pressed(self, event: Button.Pressed): 39 | self.my_select.set_options(self.my_list2) 40 | 41 | 42 | if __name__ == "__main__": 43 | app = TextualApp() 44 | app.run() 45 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /docs/reports/dark_theme.css: -------------------------------------------------------------------------------- 1 | /* 2 | Dark Mode Override for pytest-html 3 | */ 4 | 5 | body { 6 | background-color: #1e1e1e; 7 | color: #cccccc; 8 | } 9 | 10 | h1, h2 { color: #ffffff; } 11 | p { color: #e0e0e0; } 12 | a { color: #5d9cec; } 13 | 14 | #environment td, 15 | #results-table, 16 | #results-table th, 17 | #results-table td, 18 | .logwrapper .log, 19 | div.media { border-color: #444444; } 20 | 21 | #environment tr:nth-child(odd) { 22 | background-color: #2c2c2c; 23 | } 24 | 25 | #results-table { color: #cccccc;} 26 | #results-table th { color: #ffffff;} 27 | .logwrapper { background-color: #2c2c2c;} 28 | span.passed, .passed .col-result { color: #4CAF50; } 29 | 30 | .logwrapper .logexpander { 31 | background-color: #333333; 32 | border-color: #666666; 33 | color: #cccccc; 34 | } 35 | 36 | .logwrapper .logexpander:hover { 37 | color: #ffffff; 38 | border-color: #ffffff; 39 | } 40 | 41 | .logwrapper .log { 42 | background-color: #1a1a1a; 43 | color: #e0e0e0; 44 | } 45 | 46 | .collapsible td:not(.col-links):hover::after, 47 | #environment-header h2:hover::after, 48 | #environment-header.collapsed h2:hover::after { 49 | color: #777777; 50 | } 51 | 52 | .summary__reload__button:hover { background-color: #5cb85c; } 53 | .filters button, .collapse button { color: #cccccc; } 54 | .filters button:hover, .collapse button:hover { color: #ffffff; } -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/styling_and_colors/checkbox_style.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to modify the style of 2 | a Checkbox widget. It shows removing the label highlight 3 | when focused as well as changing the color of the check symbol 4 | in the 'on' state. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from textual.app import App 10 | from textual.widgets import Static, Footer, Checkbox, Button 11 | from textual.containers import Container 12 | 13 | class TextualApp(App[None]): 14 | 15 | CSS = """ 16 | Screen { align: center middle; } 17 | #my_container { width: auto; height: auto; border: solid red; 18 | align: center middle; content-align: center middle; } 19 | Checkbox { 20 | &.-on > .toggle--button { 21 | color: $text-success; 22 | background: green; 23 | } 24 | &:focus { 25 | & > .toggle--label { 26 | background: transparent; 27 | } 28 | } 29 | } 30 | """ 31 | 32 | def compose(self): 33 | 34 | with Container(id="my_container"): 35 | yield Static("Hello, Textual!") 36 | yield Checkbox("My Checkbox") 37 | yield Button() 38 | 39 | yield Footer() 40 | 41 | 42 | if __name__ == "__main__": 43 | app = TextualApp() 44 | app.run() 45 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/tips_and_tricks/truecolor_test.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how Textual can detect the color system 2 | in use and display a message accordingly. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual.app import App 8 | from textual.widgets import Static, Footer 9 | from textual.containers import Container 10 | 11 | class TextualApp(App[None]): 12 | 13 | DEFAULT_CSS = """ 14 | Screen { width: 1fr; height: 1fr;} 15 | #my_static { border: solid blue; width: auto;} 16 | """ 17 | 18 | def compose(self): 19 | 20 | with Container(id="my_container"): 21 | yield Static("Hello, Textual!", id="my_static") 22 | 23 | yield Footer() 24 | 25 | def on_ready(self): 26 | 27 | color_system = self.app.console.color_system 28 | 29 | if color_system == "256": 30 | self.notify("256 colors") 31 | elif color_system == "truecolor": 32 | self.notify("True color") 33 | elif color_system == "standard": 34 | self.notify("Standard color") 35 | else: 36 | self.notify("Unknown color system") 37 | 38 | # possibly set os.environ afterwards?? 39 | # import os 40 | # os.environ["COLORTERM"] = "truecolor" 41 | 42 | 43 | if __name__ == "__main__": 44 | app = TextualApp() 45 | app.run() 46 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/tree_node_select.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to use the `select_node` method 2 | of the Tree widget to programmatically select a node. It is not 3 | super obvious how to use it from the documentation. The method takes 4 | a `TreeNode` object as its argument. However, there is no obvious 5 | way to get the desired node from the Tree. This means you have to 6 | save a reference to any node that you might want to programmatically 7 | select, at the time the node is created and added to the tree. 8 | 9 | Recipe by Edward Jazzhands""" 10 | 11 | import sys 12 | from textual.app import App, ComposeResult 13 | from textual.widgets import Tree, Button 14 | 15 | 16 | class TextualApp(App[None]): 17 | 18 | def compose(self) -> ComposeResult: 19 | tree: Tree[str] = Tree("Dune") 20 | tree.root.expand() 21 | self.characters = tree.root.add("Characters", expand=True) 22 | self.characters.add_leaf("Paul") 23 | self.characters.add_leaf("Jessica") 24 | self.characters.add_leaf("Chani") 25 | yield tree 26 | yield Button("Select Characters Node") 27 | 28 | def on_button_pressed(self): 29 | tree: Tree[str] = self.query_one(Tree) 30 | tree.select_node(self.characters) 31 | 32 | 33 | if __name__ == "__main__": 34 | app = TextualApp() 35 | app.run() 36 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/tips_and_tricks/transparent_toggle.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to create a Textual application 2 | with a transparent background which can be toggled on and off. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual import on 8 | from textual.app import App 9 | from textual.screen import Screen 10 | from textual.widgets import Static, Footer, Button 11 | from textual.containers import Container 12 | 13 | 14 | class DummyScreen(Screen[None]): 15 | 16 | def on_mount(self) -> None: 17 | self.dismiss() 18 | 19 | 20 | class TextualApp(App[None]): 21 | 22 | CSS = """ 23 | Screen { align: center middle; } 24 | #my_static { border: solid blue; width: auto;} 25 | #main_container { width: 50%; height: 50%; border: solid red; 26 | align: center middle; } 27 | """ 28 | 29 | def compose(self): 30 | 31 | with Container(id="main_container"): 32 | yield Static("Hello, Textual", id="my_static") 33 | yield Button("Toggle Transparency", id="toggle_transparency") 34 | 35 | yield Footer() 36 | 37 | @on(Button.Pressed, "#toggle_transparency") 38 | def toggle_transparency(self) -> None: 39 | 40 | self.ansi_color = not self.ansi_color 41 | self.push_screen(DummyScreen()) 42 | 43 | 44 | if __name__ == "__main__": 45 | app = TextualApp() 46 | app.run() 47 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Textual Cookbook Changelog 2 | 3 | ## [0.5.0] 2025-08-16 4 | 5 | - Added new CLI launcher. It is now possible to enter recipes as an argument when launchin the cookbook, like so: 6 | 7 | ```bash 8 | uvx textual-cookbook spinner_widget 9 | ``` 10 | 11 | The above line will immediately select the spinner_widget recipe and display it in the code viewer. Furthermore you can also provide the -r or --run flag to immediately run the recipe: 12 | 13 | ```bash 14 | uvx textual-cookbook spinner_widget -r 15 | ``` 16 | 17 | The `just cook` command in the justfile was also modified to reflect this change, so you can now do: 18 | 19 | ```bash 20 | just cook spinner_widget -r 21 | ``` 22 | 23 | ## [0.4.0] 2025-08-16 24 | 25 | - Added 2 new recipes by NSPC911 (#15 by @NSPC911): 26 | - Modifying Widgets: decline_failed_input.py 27 | - Modifying Widgets: better_optionlist.py 28 | 29 | - Added 1 new recipe by David Fokkema (#16 by @davidfokkema): 30 | - Architecture Patterns: use_default_screen.py 31 | 32 | ## [0.3.0] 2025-08-16 33 | 34 | - Added 4 new recipes by NSPC911: 35 | - Animation Effects: shaking.py 36 | - Architecture patterns: screen_push_wait.py 37 | - Modifying Widgets: progress_colors.py 38 | - Textual API usage: workers_exclusive.py 39 | - Changed unicode characters in the recipe runner to normal emojis for better compatibility 40 | 41 | ## [0.2.0] 2025-08-15 42 | 43 | - Added context menu recipe in Animations section 44 | 45 | ## [0.1.0] 2025-08-09 46 | 47 | - First release of Textual-Cookbook on PyPI 48 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/progress_colors.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates making a progress bar with different colors 2 | for different states: indeterminate, complete, incomplete, and error. 3 | 4 | Recipe by NSPC911 5 | https://github.com/NSPC911""" 6 | 7 | import sys 8 | from textual.app import App, ComposeResult 9 | from textual.widgets import ProgressBar 10 | 11 | class TextualApp(App[None]): 12 | CSS = """ 13 | .bar { 14 | color: $warning 15 | } 16 | .bar--indeterminate { 17 | color: $accent 18 | } 19 | .bar--complete { 20 | color: $success; 21 | } 22 | .error .bar--complete, 23 | .error .bar--bar { 24 | color: $error; 25 | } 26 | """ 27 | def compose(self) -> ComposeResult: 28 | yield ProgressBar(total=None, id="indeterminate") 29 | yield ProgressBar(total=100, id="complete", classes="complete") 30 | yield ProgressBar(total=100, id="incomplete", classes="incomplete") 31 | yield ProgressBar(total=100, id="error", classes="error complete") 32 | yield ProgressBar(total=100, id="incompleteerror", classes="error incomplete") 33 | 34 | def on_mount(self) -> None: 35 | for widget in self.query("ProgressBar.incomplete"): 36 | widget.update(progress=50) 37 | for widget in self.query("ProgressBar.complete"): 38 | widget.update(progress=100) 39 | 40 | 41 | 42 | if __name__ == "__main__": 43 | app = TextualApp() 44 | app.run() 45 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/progress_bounce.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates a progress bar with a cool bouncing 2 | animation effect. 3 | 4 | Recipe by David Fokkema 5 | https://github.com/davidfokkema""" 6 | 7 | import sys 8 | import time 9 | 10 | from textual.app import App, ComposeResult 11 | from textual.widgets import ProgressBar 12 | 13 | 14 | class ElapsedTimeProgressBar(ProgressBar): 15 | _start_time: float = 0 16 | 17 | def __init__(self, *args, **kwargs): 18 | super().__init__(*args, **kwargs) 19 | self._start_time = time.monotonic() 20 | 21 | def update(self, *args, **kwargs) -> None: 22 | super().update(*args, **kwargs) 23 | if self.total is None: 24 | self._display_eta = int(time.monotonic() - self._start_time) 25 | 26 | 27 | class TextualApp(App[None]): 28 | def compose(self) -> ComposeResult: 29 | yield ElapsedTimeProgressBar() 30 | yield ProgressBar() 31 | 32 | def on_mount(self) -> None: 33 | self.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True) 34 | self.set_timer(5.0, self.start_progress) 35 | 36 | def start_progress(self) -> None: 37 | for bar in self.query(ProgressBar): 38 | bar.update(total=100) 39 | self.progress_timer.resume() 40 | 41 | def make_progress(self) -> None: 42 | for bar in self.query(ProgressBar): 43 | bar.advance(1) 44 | 45 | 46 | if __name__ == "__main__": 47 | app = TextualApp() 48 | app.run() 49 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/spinner_widget.py: -------------------------------------------------------------------------------- 1 | """This example shows how to use the rich library to display a spinner in Textual. 2 | The spinner object changes its rendering internally, but Textual needs to be manually updated 3 | to reflect that change. This is done by using `set_interval` to call the `update_spinner` method. 4 | 5 | Recipe by Edward Jazzhands""" 6 | 7 | from __future__ import annotations 8 | import sys 9 | from typing import Any, TYPE_CHECKING 10 | if TYPE_CHECKING: 11 | from rich.console import RenderableType 12 | from rich.spinner import Spinner 13 | 14 | 15 | from textual.app import App 16 | from textual.widgets import Static 17 | 18 | class SpinnerWidget(Static): 19 | 20 | def __init__(self, spinner: str, text: RenderableType, *args: Any, **kwargs: Any): 21 | super().__init__(*args, **kwargs) 22 | self._spinner = Spinner(spinner, text) 23 | 24 | def on_mount(self) -> None: 25 | self.set_interval(0.02, self.update_spinner) 26 | 27 | def update_spinner(self) -> None: 28 | self.update(self._spinner) 29 | 30 | class TextualApp(App[None]): 31 | 32 | CSS = """ 33 | SpinnerWidget {width: 1fr; height: 1fr; content-align: center middle;} 34 | """ 35 | 36 | def compose(self): 37 | 38 | yield SpinnerWidget("line", "Loading...") 39 | # A few common options: 40 | # arc, arrow, bouncingBall, boxBounce, dots, dots2 to dots12, line 41 | # python -m rich.spinner to see all options 42 | 43 | 44 | if __name__ == "__main__": 45 | app = TextualApp() 46 | app.run() 47 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/textarea_submit.py: -------------------------------------------------------------------------------- 1 | """This shows how to add a Submitted action to a TextArea widget which 2 | replicates the Submitted messge in the Input widget, and which can 3 | be triggered by pressing a key or combo (ctrl+s in this case). 4 | 5 | Recipe by Edward Jazzhands""" 6 | 7 | from __future__ import annotations 8 | import sys 9 | from textual import on 10 | from textual.app import App 11 | from textual.message import Message 12 | from textual.binding import Binding 13 | from textual.widgets import TextArea, Footer 14 | 15 | class MyTextArea(TextArea): 16 | 17 | class Submitted(Message): 18 | def __init__(self, textarea: MyTextArea, text: str): 19 | super().__init__() 20 | self.textarea = textarea 21 | self.text = text 22 | 23 | BINDINGS = [ 24 | Binding("ctrl+s", "submit", "Submit", show=True), 25 | ] 26 | 27 | def action_submit(self): 28 | self.post_message(self.Submitted(self, self.text)) 29 | 30 | 31 | 32 | class TextualApp(App[None]): 33 | 34 | CSS = """ 35 | Screen { align: center middle; } 36 | MyTextArea { border: solid blue; width: 50%; height: 50%; } 37 | """ 38 | 39 | def compose(self): 40 | yield MyTextArea() 41 | yield Footer() 42 | 43 | @on(MyTextArea.Submitted) 44 | def my_textarea_submitted(self, event: MyTextArea.Submitted): 45 | self.notify(f"TextArea submitted with text: {event.text}") 46 | # handle submit here 47 | 48 | 49 | if __name__ == "__main__": 50 | app = TextualApp() 51 | app.run() 52 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/abc_meta.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to create a Textual widget that is also 2 | an abstract base class (ABC) using a metaclass that combines 3 | the functionality of both `Widget` and `ABC`. This allows you to define 4 | abstract methods that must be implemented by any subclass of the widget. 5 | Note that I would not recommend doing this in general. It causes too 6 | many issues with the Textual framework, such as preventing workers 7 | from functioning correctly, and it can lead to unexpected behavior. 8 | But its useful to keep this example up just as a demonstration of 9 | how one would do it. 10 | 11 | Recipe by Edward Jazzhands""" 12 | 13 | import sys 14 | from abc import ABC, abstractmethod 15 | from textual.widget import Widget 16 | from textual.app import App, ComposeResult 17 | 18 | 19 | class ABCWidgetMeta(type(Widget), type(ABC)): 20 | pass 21 | 22 | 23 | class AbstractDataWidget(Widget, ABC, metaclass=ABCWidgetMeta): 24 | 25 | @abstractmethod 26 | def load_data(self) -> None: 27 | pass 28 | 29 | def on_mount(self) -> None: 30 | self.load_data() 31 | 32 | 33 | class UserListWidget(AbstractDataWidget): 34 | "Widget inheriting from AbstractDataWidget" 35 | 36 | # comment this method out to see the ABC error 37 | def load_data(self) -> None: 38 | self.notify("Data loaded from source") 39 | 40 | 41 | class TextualApp(App[None]): 42 | 43 | def compose(self) -> ComposeResult: 44 | yield UserListWidget() 45 | 46 | 47 | if __name__ == "__main__": 48 | app = TextualApp() 49 | app.run() 50 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/app_dict.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates how to set up and read a dictionary on the app class 2 | in order to share data between different components. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual import on 8 | from textual.app import App 9 | from textual.widgets import Button 10 | from textual.containers import Container 11 | 12 | class MyContainer(Container): 13 | 14 | DEFAULT_CSS = """ MyContainer {border: panel $primary;} """ 15 | 16 | def __init__(self, container_num:int): 17 | super().__init__() 18 | self.container_num = container_num 19 | self.border_title = f"MyContainer {container_num}" 20 | 21 | def compose(self): 22 | yield Button("Set app dict", id="button1") 23 | yield Button("Read app dict", id="button2") 24 | 25 | @on(Button.Pressed, "#button1") 26 | def button1(self): 27 | x = self.container_num 28 | my_dict = { 29 | f"key{x}": f"value{x}", 30 | f"key{x+1}": f"value{x+1}" 31 | } 32 | self.app.app_dict = my_dict # type: ignore 33 | self.notify(f"App dict set by container {self.container_num}") 34 | 35 | @on(Button.Pressed, "#button2") 36 | def button2(self): 37 | self.notify(str(self.app.app_dict)) # type: ignore 38 | self.log(self.app.app_dict) # type: ignore 39 | 40 | 41 | class TextualApp(App[None]): 42 | 43 | app_dict = {} 44 | 45 | def compose(self): 46 | yield MyContainer(1) 47 | yield MyContainer(3) 48 | 49 | 50 | if __name__ == "__main__": 51 | app = TextualApp() 52 | app.run() 53 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/configparser_usage.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to use the configparser module in a Textual application. 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | import sys 6 | import configparser 7 | from pathlib import Path 8 | from textual.app import App 9 | 10 | class TextualApp(App[None]): 11 | 12 | def __init__(self): 13 | super().__init__() 14 | 15 | config_path = Path(__file__).resolve().parent / "config.ini" 16 | if not config_path.exists(): 17 | raise FileNotFoundError("config.ini file not found.") 18 | 19 | self.config = configparser.ConfigParser() # Available globally as self.app.config 20 | self.config.read(config_path) 21 | 22 | ## Config settings ## 23 | self.my_string = self.config.get("MAIN", "my_string") 24 | self.my_boolean = self.config.getboolean("MAIN", "my_boolean") 25 | self.my_integer = self.config.getint("MAIN", "my_integer") 26 | self.my_float = self.config.getfloat("MAIN", "my_float") 27 | 28 | def on_mount(self) -> None: 29 | 30 | assert self.my_string == "Hello, World!" 31 | assert self.my_boolean is True 32 | assert self.my_integer == 42 33 | assert self.my_float == 3.14 34 | 35 | def on_ready(self) -> None: 36 | self.log("Configuration loaded successfully!") 37 | self.log(f"String: {self.my_string}") 38 | self.log(f"Boolean: {self.my_boolean}") 39 | self.log(f"Integer: {self.my_integer}") 40 | self.log(f"Float: {self.my_float}") 41 | 42 | 43 | 44 | if __name__ == "__main__": 45 | app = TextualApp() 46 | app.run() 47 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/message_control.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to add the `control` method in a Textual message 2 | to allow it to be specified in CSS selectors (query, @on, etc.). 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | 7 | from __future__ import annotations 8 | import sys 9 | from textual import on 10 | from textual.app import App 11 | from textual.widgets import Static, Footer, Button 12 | from textual.containers import Container 13 | from textual.message import Message 14 | 15 | 16 | class MyStatic(Static): 17 | 18 | class MessageFoo(Message): 19 | 20 | def __init__(self, sender: MyStatic): 21 | super().__init__() 22 | self.sender: MyStatic = sender 23 | 24 | @property 25 | def control(self) -> MyStatic: 26 | return self.sender 27 | 28 | def send_foo(self): 29 | self.post_message(self.MessageFoo(self)) 30 | 31 | 32 | class TextualApp(App[None]): 33 | 34 | DEFAULT_CSS = """ 35 | Screen { align: center middle; } 36 | #my_static { border: solid blue; width: auto;} 37 | """ 38 | 39 | def compose(self): 40 | 41 | with Container(id="my_container"): 42 | yield MyStatic("Hello, Textual!", id="my_static") 43 | yield Button("press me", id="my_button") 44 | 45 | yield Footer() 46 | 47 | def on_button_pressed(self): 48 | my_static = self.query_one(MyStatic) 49 | my_static.send_foo() 50 | 51 | @on(MyStatic.MessageFoo, "#my_static") 52 | def recieve_foo(self, message: MyStatic.MessageFoo): 53 | self.notify(f"Received MessageFoo from {message.sender}!") 54 | 55 | 56 | if __name__ == "__main__": 57 | app = TextualApp() 58 | app.run() 59 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/reactivity_validation.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates ? 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | 6 | import sys 7 | from textual.app import App, ComposeResult 8 | from textual.containers import Horizontal 9 | from textual.reactive import reactive 10 | from textual.widgets import Button, RichLog 11 | 12 | 13 | class TextualApp(App[None]): 14 | CSS = """ 15 | #buttons { 16 | dock: top; 17 | height: auto; 18 | } 19 | """ 20 | 21 | count = reactive(0, always_update=True) 22 | 23 | def validate_count(self, count: int) -> int: 24 | """Validate value.""" 25 | self.log(f"ValidatING {count=}") 26 | 27 | if count < 0: 28 | self.log("Count cannot be negative") 29 | count = 0 30 | elif count > 10: 31 | self.log("Count cannot be greater than 10") 32 | count = 10 33 | 34 | self.log(f"Validated {count=}") 35 | return count 36 | 37 | def watch_count(self, count: int) -> None: 38 | """Watch for changes to count.""" 39 | self.log(f"Count changed to {count=}") 40 | 41 | def compose(self) -> ComposeResult: 42 | yield Horizontal( 43 | Button("+1", id="plus", variant="success"), 44 | Button("-1", id="minus", variant="error"), 45 | id="buttons", 46 | ) 47 | yield RichLog(highlight=True) 48 | 49 | def on_button_pressed(self, event: Button.Pressed) -> None: 50 | if event.button.id == "plus": 51 | self.count += 1 52 | else: 53 | self.count -= 1 54 | self.query_one(RichLog).write(f"count = {self.count}") 55 | 56 | 57 | if __name__ == "__main__": 58 | app = TextualApp() 59 | app.run() 60 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/animate_multiple.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how Textual can animate multiple widgets at once. 2 | I'm honestly not sure exactly of how it handles it internally, but it 3 | seems to work fine when you simply run the animate method multiple times 4 | in a row. It will make them all run simultaneously. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from textual.app import App 10 | from textual.widgets import Static, Button 11 | from textual.containers import Container 12 | from textual.geometry import Offset 13 | 14 | class TextualApp(App[None]): 15 | 16 | CSS = """ 17 | #my_container { align: center middle; border: solid red;} 18 | #my_static1, #my_static2 { border: solid blue; width: auto;} 19 | """ 20 | duration = 1.0 21 | 22 | def compose(self): 23 | 24 | with Container(id="my_container"): 25 | 26 | self.static1 = Static("Static 1", id="my_static1") 27 | self.static2 = Static("Static 2", id="my_static2") 28 | yield self.static1 29 | yield self.static2 30 | yield Button("press me") 31 | 32 | def on_button_pressed(self): 33 | self.static1.animate( 34 | "offset", 35 | Offset(20, 0), 36 | duration=self.duration, 37 | ) 38 | self.static2.animate( 39 | "offset", 40 | Offset(20, 0), 41 | duration=self.duration, 42 | ) 43 | # Note how it uses `self.styles.animate` here instead of `self.animate`: 44 | self.static2.styles.animate( 45 | "opacity", 46 | 0.0, 47 | duration=self.duration, 48 | ) 49 | 50 | 51 | if __name__ == "__main__": 52 | app = TextualApp() 53 | app.run() 54 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/tips_and_tricks/container_disabled.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how you can utilize the `disabled` state of a container 2 | to make all of its widgets and contets greyed out and unresponsive. 3 | A container greys out all of its children when it is disabled. This can be useful 4 | for making sections of your UI that can be temporarily disabled. You can select 5 | a container with your mouse and press 'd' to disable it. 6 | 7 | Recipe by Edward Jazzhands""" 8 | 9 | import sys 10 | from textual.app import App 11 | from textual.widgets import Static, Footer, Button, Checkbox, Input 12 | from textual.containers import Container 13 | 14 | 15 | class SpecialContainer(Container): 16 | 17 | BINDINGS = [("d", "select", "Disable this container")] 18 | 19 | def compose(self): 20 | self.can_focus = True 21 | yield Static("Hello, Textual! great day eh") 22 | yield Button("A Button") 23 | yield Checkbox("A Checkbox") 24 | yield Input(placeholder="An Input") 25 | 26 | def action_select(self): 27 | self.notify(f"Disabled {self.id}") 28 | self.disabled = True 29 | 30 | 31 | class TextualApp(App[None]): 32 | 33 | DEFAULT_CSS = """ 34 | #main_container { 35 | align: center middle; 36 | layout: grid; 37 | grid-size: 3 2; 38 | } 39 | .inner-container { 40 | border: solid $primary; 41 | &:focus { border: solid $accent; } 42 | } 43 | """ 44 | 45 | def compose(self): 46 | 47 | with Container(id="main_container"): 48 | for i in range(6): 49 | yield SpecialContainer(id=f"container_{i}", classes="inner-container") 50 | yield Footer() 51 | 52 | 53 | if __name__ == "__main__": 54 | app = TextualApp() 55 | app.run() 56 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/workers_exclusive.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how the `exclusive` parameter of the `work` 2 | decorator can only be used to cancel/debounce workers that are normal 3 | async workers (non-threaded). The normal worker will be restarted 4 | each time the button is pressed and effectively cancel the currently 5 | running worker. But threaded workers will not be cancelled 6 | and will run to completion, even if the button is pressed multiple 7 | times (This is a limitation of threading in Python and not because of Textual). 8 | 9 | Recipe by NSPC911 10 | https://github.com/NSPC911""" 11 | 12 | 13 | from asyncio import sleep 14 | 15 | import sys 16 | from textual import work, on 17 | from textual.app import App, ComposeResult 18 | from textual.widgets import Button, RichLog 19 | from textual.containers import HorizontalGroup 20 | 21 | class TextualApp(App[None]): 22 | def compose(self) -> ComposeResult: 23 | self.richlog = RichLog() 24 | yield self.richlog 25 | with HorizontalGroup(): 26 | yield Button("Run worker", id="worker") 27 | yield Button("Run worker THREAD", id="thread") 28 | 29 | def on_button_pressed(self, event:Button.Pressed): 30 | self.richlog.write(event.button.id) 31 | 32 | @on(Button.Pressed, "#worker") 33 | @work(exclusive=True, thread=False) 34 | async def worker_runner(self, event:Button.Pressed): 35 | await sleep(1) # simulate process intensive thing 36 | self.richlog.write("Worker completed!") 37 | 38 | @on(Button.Pressed, "#thread") 39 | @work(exclusive=True, thread=True) 40 | def thread_runner(self, event:Button.Pressed): 41 | self.call_from_thread(sleep, 1) 42 | self.richlog.write("Thread completed!") 43 | 44 | 45 | if __name__ == "__main__": 46 | app = TextualApp() 47 | app.run() 48 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/tabs_unclickable.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to create a TabbedContent widget with 2 | tabs that cannot be clicked or interacted with by the user. 3 | They can only be changed programmatically, which is demonstrated 4 | by pressing the "Enter" key to switch to the next tab. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from textual.app import App, ComposeResult 10 | from textual.widgets import Placeholder, Footer, TabbedContent, TabPane 11 | from textual.containers import Container 12 | 13 | class TextualApp(App[None]): 14 | 15 | CSS = """ 16 | Tabs { 17 | &:disabled { 18 | .underline--bar { 19 | background: $foreground 30%; 20 | } 21 | & .-active { 22 | color: $block-cursor-foreground; 23 | background: $block-cursor-background; 24 | } 25 | } 26 | } 27 | """ 28 | 29 | BINDINGS = [ 30 | ("enter", "next_tab", "Next Tab"), 31 | ] 32 | 33 | current_tab = 1 34 | 35 | def compose(self) -> ComposeResult: 36 | with Container(id="main_container"): 37 | with TabbedContent() as TB: 38 | TB.disabled = True 39 | with TabPane("Tab 1", id="tab1"): 40 | yield Placeholder("Placeholder for Tab 1") 41 | with TabPane("Tab 2", id="tab2"): 42 | yield Placeholder("Placeholder for Tab 2") 43 | with TabPane("Tab 3", id="tab3"): 44 | yield Placeholder("Placeholder for Tab 3") 45 | yield Footer() 46 | 47 | def action_next_tab(self) -> None: 48 | next_tab = (self.current_tab + 1) if self.current_tab < 3 else 1 49 | self.current_tab = next_tab 50 | self.query_one(TabbedContent).active = f"tab{next_tab}" 51 | 52 | 53 | if __name__ == "__main__": 54 | app = TextualApp() 55 | app.run() 56 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/logging_html.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to use the rich traceback feature 2 | in a Textual application to log errors to a file and display them 3 | in HTML format. The application includes a button that, when pressed, 4 | will intentionally cause an error, triggering the logging mechanism. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from datetime import datetime 10 | from textual.app import App 11 | from textual.widgets import Static, Button 12 | from textual.containers import Container 13 | from rich.console import Console 14 | from rich.traceback import Traceback 15 | 16 | 17 | class TextualApp(App[None]): 18 | 19 | CSS = """ 20 | Screen { align: center middle; } 21 | #my_static { border: solid blue; width: auto;} 22 | """ 23 | 24 | def compose(self): 25 | 26 | with Container(id="my_container"): 27 | yield Static("Hello, Textual!", id="my_static") 28 | yield Button("Press me to cause a bug", classes="some-tcss-class") 29 | 30 | def on_button_pressed(self): 31 | self.query_one("#non_existent_widget") # This will raise an error 32 | 33 | def _handle_exception(self, error: Exception) -> None: 34 | 35 | self.record_log_files(error) 36 | super()._handle_exception(error) 37 | 38 | 39 | def record_log_files(self, e: Exception): 40 | 41 | with open("error.txt", "w") as log_txt_file: 42 | console = Console(file=log_txt_file, record=True, width=100) 43 | console.print(f"TextualDon Error Report: {datetime.now()}\n") 44 | traceback = Traceback.from_exception( 45 | type(e), 46 | e, 47 | e.__traceback__, 48 | show_locals=True 49 | ) 50 | console.print(traceback) 51 | console.save_html("error.html") 52 | 53 | 54 | if __name__ == "__main__": 55 | app = TextualApp() 56 | app.run() 57 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Install the package 2 | install: 3 | uv sync 4 | 5 | cook script='' flags='': 6 | uv run textual-cookbook {{script}} {{flags}} 7 | 8 | # Note this only runs the recipe runner itself in dev mode 9 | cook-dev: 10 | uv run textual run --dev src/textual_cookbook/main.py 11 | 12 | # Run the console 13 | console: 14 | uv run textual console -x EVENT -x SYSTEM 15 | 16 | # Run individual scripts in dev mode 17 | script-dev script: 18 | uv run textual run --dev src/textual_cookbook/recipes/{{script}} 19 | 20 | # Runs ruff, exits with 0 if no issues are found 21 | lint script: 22 | @uv run ruff check src/textual_cookbook/recipes/{{script}} 23 | 24 | # Runs mypy, exits with 0 if no issues are found 25 | typecheck script: 26 | @uv run mypy src/textual_cookbook/recipes/{{script}} 27 | @uv run basedpyright src/textual_cookbook/recipes/{{script}} 28 | 29 | # Runs black 30 | format script: 31 | @uv run black src/textual_cookbook/recipes/{{script}} 32 | 33 | # Runs pytest using whatever version of Textual is installed 34 | test: 35 | @uv run pytest tests -v 36 | 37 | # Run the Nox testing suite for comprehensive testing. 38 | # This will run pytest against all versions of Textual and Python 39 | # specified in the noxfile.py 40 | nox: 41 | nox 42 | 43 | # Remove all caches and temporary files 44 | clean: 45 | find . -name "*.pyc" -delete 46 | find . -name "*-report.*" -delete 47 | find . -name "error.*" -delete 48 | rm -rf .mypy_cache 49 | rm -rf .ruff_cache 50 | rm -rf .nox 51 | 52 | # Remove the virtual environment and lock file 53 | del-env: 54 | rm -rf .venv 55 | rm -rf uv.lock 56 | 57 | nuke: clean del-env 58 | @echo "All build artifacts and caches have been removed." 59 | 60 | # Removes all environment and build stuff 61 | reset: nuke install 62 | @echo "Environment reset." 63 | 64 | release: 65 | bash .github/scripts/validate_main.sh && \ 66 | uv run .github/scripts/tag_release.py && \ 67 | git push --tags 68 | 69 | sync-tags: 70 | git fetch --prune origin "+refs/tags/*:refs/tags/*" -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/signal_usage.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates the usage of Textual's secret 'Signal' class. 2 | This isn't a public API, but it is used internally by Textual. 3 | It allows widgets to communicate with each other without sending events 4 | or passing in direct references to anything. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from textual import on 10 | from textual.app import App, ComposeResult 11 | from textual.signal import Signal 12 | from textual.widget import Widget 13 | from textual.widgets import Static, Button, Footer 14 | 15 | 16 | class MyManager(Widget): 17 | 18 | def __init__(self): 19 | super().__init__() 20 | self.display = False 21 | 22 | self.my_special_num = 42 23 | self.my_signal: Signal[int] = Signal(self, "my_signal") 24 | 25 | def send_signal(self): 26 | self.my_signal.publish(self.my_special_num) 27 | 28 | 29 | class TextualApp(App[None]): 30 | 31 | CSS = """ 32 | Screen { align: center middle; } 33 | #my_static { border: solid blue; width: auto;} 34 | """ 35 | 36 | BINDINGS = [ 37 | ("b", "send_signal", "Send Signal"), 38 | ] 39 | 40 | def __init__(self): 41 | super().__init__() 42 | self.manager = MyManager() 43 | 44 | def compose(self) -> ComposeResult: 45 | self.manager = MyManager() 46 | yield self.manager 47 | 48 | yield Static("Press button or press b", id="my_static") 49 | yield Button("Send Signal", id="send_signal_button") 50 | yield Footer() 51 | 52 | def on_mount(self) -> None: 53 | self.manager.my_signal.subscribe(self, self.on_my_signal) 54 | 55 | @on(Button.Pressed, "#send_signal_button") 56 | def action_send_signal(self): 57 | self.manager.send_signal() 58 | 59 | def on_my_signal(self, value: int): 60 | self.notify(f"Received signal with value: {value}") 61 | 62 | 63 | if __name__ == "__main__": 64 | app = TextualApp() 65 | app.run() 66 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/common_bugs/bug_query_screens.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how you cannot query for screens directly using `query_one` or \ 2 | `query_all`. Instead, you should use the `get_screen` method to retrieve the screen instance. \ 3 | Attempting to query for the screen as shown below will cause the app to crash. \ 4 | Also note that the `get_default_screen` method is not needed if you are using \ 5 | `push_screen` to intialize with an installed screen (in SCREENS). This can cause issues \ 6 | because the default MainScreen instance will not be the same as the \ 7 | sinstalled screen in the app's screen stack.\ 8 | 9 | Example by Edward Jazzhands, 2025""" 10 | 11 | from textual.app import App, ComposeResult 12 | from textual.widgets import Static, Button 13 | from textual.screen import Screen 14 | 15 | class MainScreen(Screen[None]): 16 | 17 | def compose(self) -> ComposeResult: 18 | yield Static("Main Screen Content", id="main_static") 19 | yield Button("Press Me", id="main_button") 20 | 21 | def on_button_pressed(self, event: Button.Pressed) -> None: 22 | self.app.on_path_entered() 23 | 24 | class TuiApp(App[None]): 25 | 26 | SCREENS = {"main": MainScreen} 27 | 28 | # def get_default_screen(self): # This will cause problems. It is not needed. 29 | # return MainScreen() # if you use this instead of the push_screen method, you will see 30 | # # that main_screen is not the same as self.screen. 31 | def on_mount(self) -> None: 32 | 33 | self.push_screen("main") 34 | self.log(self.screen_stack) 35 | 36 | def on_path_entered(self) -> None: 37 | 38 | # main_screen = self.query_one(MainScreen) # this will not work. 39 | 40 | main_screen = self.get_screen("main") # <-- Do this instead 41 | self.notify(f"{main_screen is self.screen}") # This shows that main_screen is the same as self.screen 42 | 43 | main_screen.query_one("#main_static", Static).update("Path Entered!") 44 | 45 | 46 | if __name__ == "__main__": 47 | app = TuiApp() 48 | app.run() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "textual-cookbook" 3 | version = "0.5.1" 4 | description = "Textual Cookbook: Recipes for Textual Applications" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [ 8 | { name = "edward-jazzhands", email = "ed.jazzhands@gmail.com" } 9 | ] 10 | license = "MIT" 11 | license-files = ["LICEN[CS]E*"] 12 | keywords = ["python", "textual", "tui", "cookbook"] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | "Programming Language :: Python :: 3 :: Only", 23 | ] 24 | 25 | dependencies = [ 26 | "textual[syntax]>=5.3.0", 27 | "textual-pyfiglet>=1.1.0", 28 | "click>=8.2.1", 29 | ] 30 | 31 | [project.urls] 32 | Repository = "https://github.com/ttygroup/textual-cookbook" 33 | Changelog = "https://github.com/ttygroup/textual-cookbook/blob/master/CHANGELOG.md" 34 | 35 | [build-system] 36 | requires = ["uv_build>=0.8.0,<0.9"] 37 | build-backend = "uv_build" 38 | 39 | [project.scripts] 40 | textual-cookbook = "textual_cookbook.cli:run" 41 | 42 | ########################## 43 | # Dev Dependency Configs # 44 | ########################## 45 | 46 | 47 | [dependency-groups] 48 | dev = [ 49 | "basedpyright>=1.31.0", 50 | "black>=25.1.0", 51 | "mypy>=1.17.1", 52 | "pytest>=8.4.1", 53 | "pytest-asyncio>=1.1.0", 54 | "pytest-html>=4.1.1", 55 | "pytest-textual-snapshot>=1.1.0", 56 | "pytest-json-report>=1.5.0", 57 | "ruff>=0.12.7", 58 | "textual-dev>=1.7.0", 59 | "tomli>=2.2.1", 60 | ] 61 | 62 | [tool.pytest.ini_options] 63 | asyncio_mode = "auto" 64 | 65 | [tool.black] 66 | line-length = 110 67 | 68 | [tool.mypy] 69 | pretty = true 70 | # strict = true 71 | disallow_untyped_defs = true 72 | disallow_untyped_calls = true 73 | 74 | [tool.basedpyright] 75 | include = ["src"] 76 | typeCheckingMode = "strict" 77 | 78 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/markdownviewer_update.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how the content of the MarkdownViewer widget 2 | can be updated using the `document.update()` method. 3 | 4 | Recipe by Edward Jazzhands""" 5 | 6 | import sys 7 | from textual import on 8 | from textual.app import App, ComposeResult 9 | from textual.widgets import MarkdownViewer, Button 10 | 11 | MARKDOWN1 = """\ 12 | # Markdown Viewer 13 | 14 | Markdown syntax and extensions are supported. 15 | 16 | - Typography *emphasis*, **strong**, `inline code` etc. 17 | - Headers 18 | - Lists (bullet and ordered) 19 | - Syntax highlighted code blocks 20 | - Tables! 21 | 22 | # Header 2 23 | """ 24 | 25 | MARKDOWN2 = """\ 26 | # Header 1 27 | 28 | | Name | Type | Default | Description | 29 | | --------------- | ------ | ------- | ---------------------------------- | 30 | | `show_header` | `bool` | `True` | Show the table header | 31 | | `fixed_rows` | `int` | `0` | Number of fixed rows | 32 | | `fixed_columns` | `int` | `0` | Number of fixed columns | 33 | | `zebra_stripes` | `bool` | `False` | Display alternating colors on rows | 34 | | `header_height` | `int` | `1` | Height of header row | 35 | | `show_cursor` | `bool` | `True` | Show a cell cursor | 36 | 37 | # Header 2 38 | """ 39 | 40 | class TextualApp(App[None]): 41 | 42 | def compose(self) -> ComposeResult: 43 | 44 | self.markdown_viewer = MarkdownViewer(MARKDOWN1, id="markdown_viewer") 45 | yield self.markdown_viewer 46 | yield Button("Markdown 1", id="markdown1") 47 | yield Button("Markdown 2", id="markdown2") 48 | 49 | @on(Button.Pressed, selector="#markdown1") 50 | def markdown1_pressed(self) -> None: 51 | self.markdown_viewer.document.update(MARKDOWN1) 52 | 53 | @on(Button.Pressed, selector="#markdown2") 54 | def markdown2_pressed(self) -> None: 55 | self.markdown_viewer.document.update(MARKDOWN2) 56 | 57 | 58 | if __name__ == "__main__": 59 | app = TextualApp() 60 | app.run() 61 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/spinner_widget_loading.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to replace a widget's default "loading" 2 | animation with a custom spinner widget in Textual. 3 | The Spinner is using the rich.spinner module. You can replace the loading 4 | animation with any Textual widget. The SpinnerWidget is only one example. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | 9 | from __future__ import annotations 10 | import sys 11 | import asyncio 12 | from typing import Any, TYPE_CHECKING 13 | if TYPE_CHECKING: 14 | from rich.console import RenderableType 15 | 16 | from textual.app import App, ComposeResult 17 | from rich.spinner import Spinner 18 | from textual.widget import Widget 19 | from textual.widgets import Button, Static 20 | 21 | class SpinnerWidget(Static): 22 | 23 | DEFAULT_CSS = """ 24 | SpinnerWidget { 25 | content-align: center middle; 26 | width: 1fr; height: 1fr; 27 | } 28 | """ 29 | 30 | def __init__(self, spinner: str, text: RenderableType = "", *args: Any, **kwargs: Any): 31 | 32 | super().__init__(*args, **kwargs) 33 | self._spinner = Spinner(spinner, text) 34 | 35 | def on_mount(self) -> None: 36 | self.update_render = self.set_interval(1 / 60, self.update_spinner) 37 | 38 | def update_spinner(self) -> None: 39 | self.update(self._spinner) 40 | 41 | 42 | class CustomLoadingWidget(Static): 43 | 44 | def render(self): 45 | return "This is the normal / finished state." 46 | 47 | def get_loading_widget(self) -> Widget: 48 | return SpinnerWidget("bouncingBall", "Loading...") 49 | 50 | async def loading_progress(self): 51 | 52 | self.loading = True 53 | for _ in range(4): 54 | await asyncio.sleep(0.75) 55 | self.loading = False 56 | 57 | 58 | class TextualApp(App[None]): 59 | 60 | CSS = """ 61 | Screen { align: center middle; } 62 | CustomLoadingWidget { 63 | border: solid red; 64 | width: 50%; height: 5; 65 | content-align: center middle; 66 | } 67 | """ 68 | 69 | def compose(self) -> ComposeResult: 70 | yield CustomLoadingWidget() 71 | yield Button("Start", id="start_button") 72 | 73 | async def on_button_pressed(self): 74 | my_widget = self.query_one(CustomLoadingWidget) 75 | await my_widget.loading_progress() 76 | 77 | 78 | 79 | if __name__ == "__main__": 80 | app = TextualApp() 81 | app.run() 82 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/tips_and_tricks/tooltips_timer.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how to change the timeout for tooltips. 2 | This is achieved by monkey-patching the _handle_tooltip_timer method 3 | of the Screen class in Textual. Pyright / Mypy do not like this file. 4 | I'm not even gonna bother trying to type hint it (I can assure you it works). 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | from __future__ import annotations 9 | import sys 10 | 11 | from textual.app import App 12 | from textual.widgets import Static, Footer 13 | from textual.widget import Widget 14 | from textual.css.query import NoMatches 15 | from textual.containers import Container 16 | from textual.widgets._tooltip import Tooltip 17 | from textual.screen import Screen 18 | from rich.console import RenderableType 19 | 20 | 21 | def _handle_tooltip_timer(self, widget: Widget) -> None: 22 | 23 | try: 24 | tooltip = self.get_child_by_type(Tooltip) 25 | except NoMatches: 26 | pass 27 | else: 28 | tooltip_content: RenderableType | None = None 29 | for node in widget.ancestors_with_self: 30 | if not isinstance(node, Widget): 31 | break 32 | if node.tooltip is not None: 33 | tooltip_content = node.tooltip 34 | break 35 | 36 | if tooltip_content is None: 37 | tooltip.display = False 38 | else: 39 | tooltip.display = True 40 | tooltip.absolute_offset = self.app.mouse_position 41 | tooltip.update(tooltip_content) 42 | 43 | self.set_timer(2.0, lambda: setattr(tooltip, "display", False)) 44 | 45 | 46 | # MONKEY PATCH: 47 | Screen._handle_tooltip_timer = _handle_tooltip_timer 48 | 49 | 50 | class TextualApp(App[None]): 51 | 52 | DEFAULT_CSS = """ 53 | #my_container { align: center middle; } 54 | #my_static { border: solid blue; width: auto;} 55 | """ 56 | 57 | def compose(self): 58 | 59 | self.log("Composing the app") 60 | 61 | with Container(id="my_container"): 62 | mystatic = Static("Hello, Textual!", id="my_static") 63 | yield mystatic 64 | mystatic.tooltip = "This is a tooltip" 65 | 66 | yield Footer() 67 | 68 | 69 | if __name__ == "__main__": 70 | app = TextualApp() 71 | app.run() 72 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/help_screen.py: -------------------------------------------------------------------------------- 1 | """this file is a work in progress please ignore 2 | 3 | Recipe by Edward Jazzhands""" 4 | 5 | from __future__ import annotations 6 | import sys 7 | # import importlib.resources 8 | from pathlib import Path 9 | 10 | from textual.app import App, ComposeResult 11 | 12 | from textual.screen import ModalScreen 13 | from textual.widgets import Markdown 14 | from textual.containers import VerticalScroll 15 | from textual.binding import Binding 16 | from textual.widgets import Static, Footer 17 | 18 | 19 | class HelpScreen(ModalScreen[None]): 20 | 21 | BINDINGS = [ 22 | Binding("escape,enter", "close_screen", description="Close the help window.", show=True), 23 | ] 24 | 25 | def __init__(self, anchor: str | None = None) -> None: 26 | super().__init__() 27 | self.anchor_line = anchor 28 | 29 | def compose(self) -> ComposeResult: 30 | 31 | help_path = Path(__file__).parent / "help.md" 32 | if not help_path.exists(): 33 | raise FileNotFoundError(f"Help file not found at {help_path}") 34 | with help_path.open(encoding="utf-8") as f: 35 | self.help = f.read() 36 | 37 | with VerticalScroll(classes="screen_container help"): 38 | yield Markdown(self.help) 39 | 40 | def on_mount(self) -> None: 41 | self.query_one(VerticalScroll).focus() 42 | if self.anchor_line: 43 | found = self.query_one(Markdown).goto_anchor(self.anchor_line) 44 | if not found: 45 | self.log.error(f"Anchor '{self.anchor_line}' not found in help document.") 46 | 47 | def on_click(self) -> None: 48 | self.dismiss() 49 | 50 | def action_close_screen(self) -> None: 51 | self.dismiss() 52 | 53 | def go_to_anchor(self, anchor: str) -> None: 54 | """Scroll to a specific anchor in the help screen.""" 55 | 56 | class TextualApp(App[None]): 57 | 58 | CSS = """ 59 | Screen { align: center middle; } 60 | #my_static { border: solid blue; width: auto;} 61 | """ 62 | 63 | 64 | def compose(self): 65 | 66 | yield Static("Hello, Textual!", id="my_static") 67 | yield Footer() 68 | 69 | def action_binding(self): 70 | self.notify("You pressed the 'b' key!") 71 | 72 | 73 | if __name__ == "__main__": 74 | app = TextualApp() 75 | app.run() 76 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/datatable_expandcol.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to create a DataTable with a dynamic first column 2 | that expands to fill the available space, while keeping other columns at 3 | fixed widths. The table will also auto-adjust the first column's width 4 | when the terminal is resized. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | import sys 9 | from typing import Any 10 | from textual.widgets import DataTable 11 | from textual.widgets.data_table import ColumnKey 12 | from textual.app import App, ComposeResult 13 | from textual.containers import Container 14 | 15 | ROWS = [ 16 | ("Joseph Schooling", "Singapore", 50.39, 4), 17 | ("Michael Phelps", "United States", 51.14, 2), 18 | ("Chad le Clos", "South Africa", 51.14, 5), 19 | ("László Cseh", "Hungary", 51.14, 6), 20 | ("Li Zhuhao", "China", 51.26, 3), 21 | ("Mehdy Metella", "France", 51.58, 8), 22 | ("Tom Shields", "United States", 51.73, 7), 23 | ("Aleksandr Sadovnikov", "Russia", 51.84, 1), 24 | ("Darren Burns", "Scotland", 51.84, 10), 25 | ] 26 | 27 | class MyDataTable(DataTable[Any]): 28 | 29 | col1_minimum = 15 # dynamic column 30 | col2_width = 20 31 | col3_width = 15 32 | col4_width = 10 33 | other_cols_total = col2_width + col3_width + col4_width 34 | 35 | def on_mount(self) -> None: 36 | 37 | self.add_column("Name", width=self.col1_minimum, key="col1") 38 | self.add_column("Country", width=self.col2_width, key="col2") 39 | self.add_column("Time", width=self.col3_width, key="col3") 40 | self.add_column("Lane", width=self.col4_width, key="col4") 41 | self.add_rows(ROWS) 42 | 43 | def on_resize(self) -> None: 44 | 45 | # Account for padding on both sides of each column: 46 | total_cell_padding = self.cell_padding * (len(self.columns)*2) 47 | 48 | # Pretty obvious how this works I think: 49 | first_col_width = self.size.width - self.other_cols_total - total_cell_padding 50 | 51 | # Prevent column from being smaller than the chosen minimum: 52 | if first_col_width < self.col1_minimum: 53 | first_col_width = self.col1_minimum 54 | 55 | self.columns[ColumnKey("col1")].width = first_col_width 56 | self.refresh() 57 | 58 | 59 | class TextualApp(App[None]): 60 | 61 | def compose(self) -> ComposeResult: 62 | with Container(): 63 | yield MyDataTable() 64 | 65 | 66 | if __name__ == "__main__": 67 | app = TextualApp() 68 | app.run() 69 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /.github/workflows/ci-testing-reports.yml: -------------------------------------------------------------------------------- 1 | name: CI Checks and Reports Update 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | # Manual triggering is allowed as well 11 | workflow_dispatch: 12 | 13 | jobs: 14 | run-tests: 15 | name: Run Tests and Generate Reports 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version-file: '.python-version' 26 | 27 | - name: Setup uv 28 | uses: astral-sh/setup-uv@v6 29 | with: 30 | enable-cache: true 31 | 32 | - name: Setup Nox 33 | uses: wntrblm/nox@2025.05.01 34 | 35 | - name: Run Nox sessions 36 | run: nox 37 | 38 | - name: Upload test results 39 | uses: actions/upload-artifact@v4 40 | if: always() # Upload even if tests fail 41 | with: 42 | name: test-reports 43 | path: docs/reports/*-report.* 44 | retention-days: 14 45 | 46 | 47 | commit_reports: 48 | name: Commit and Push Reports 49 | # Only run on push to main branch OR manually triggered: 50 | if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' 51 | runs-on: ubuntu-latest 52 | needs: run-tests 53 | permissions: 54 | contents: write # Grant write access to repository contents for GITHUB_TOKEN 55 | 56 | steps: 57 | - name: Checkout code 58 | uses: actions/checkout@v4 59 | 60 | - name: Switch to reports branch 61 | run: | 62 | git fetch origin reports || true 63 | git checkout -B reports origin/reports || git checkout -B reports 64 | 65 | # Download the reports from the previous job 66 | - name: Download test reports 67 | uses: actions/download-artifact@v4 68 | with: 69 | name: test-reports 70 | path: docs/reports/ 71 | 72 | - name: Commit and push reports 73 | run: | 74 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 75 | git config --local user.name "github-actions[bot]" 76 | 77 | git add docs/reports/ 78 | 79 | if git diff --staged --quiet; then 80 | echo "No changes to commit" 81 | else 82 | git commit -m "Update test reports - $(date +'%Y-%m-%d %H:%M:%S')" 83 | # This will use GITHUB_TOKEN automatically provided by GitHub Actions 84 | git push --force-with-lease origin reports 85 | fi -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/reveal_redacted.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates how you could set redacted text and then reveal 2 | the text when hovered. 3 | The RedactedText widget has a reveal_range reactive attribute which is updated 4 | when some redacted text is hovered. This will prompt a "smart refresh" to update 5 | which ranges have the 'redacted' styling. 6 | The important part is the metadata (meta) applied to portions of the text. 7 | The mouse event handler checks the event.style.meta and updates the reveal_range 8 | reactive attribute accordingly. 9 | 10 | Recipe by Tom J Gooding""" 11 | 12 | # These two imports added by Edward Jazzhands: 13 | from __future__ import annotations # added for 3.9 compatibility 14 | import sys # added to conform to the recipe format 15 | 16 | from rich.text import Text 17 | from textual import events 18 | from textual.app import App, ComposeResult, RenderResult 19 | from textual.reactive import reactive 20 | from textual.widget import Widget 21 | 22 | 23 | class RedactedText(Widget): 24 | COMPONENT_CLASSES = {"redacted"} 25 | 26 | DEFAULT_CSS = """ 27 | RedactedText { 28 | width: auto; 29 | height: auto; 30 | 31 | .redacted { 32 | color: $foreground; 33 | background: $foreground; 34 | } 35 | } 36 | """ 37 | 38 | reveal_range: reactive[tuple[int, int] | None] = reactive(None) 39 | 40 | def render(self) -> RenderResult: 41 | redacted_style = self.get_component_rich_style("redacted") 42 | 43 | redacted_text = Text("Hello world foo bar") 44 | redact_ranges = [(6, 11), (16, 19)] 45 | 46 | for redact_range in redact_ranges: 47 | start, end = redact_range 48 | # Add meta info - see the mouse handler below 49 | redacted_text.apply_meta({"redacted": redact_range}, start, end) 50 | 51 | if self.reveal_range != redact_range: 52 | # Add a redacted style to portions of the text 53 | redacted_text.stylize(redacted_style, start, end) 54 | 55 | return redacted_text 56 | 57 | def on_mouse_move(self, event: events.MouseMove) -> None: 58 | meta = event.style.meta 59 | if "redacted" in meta: 60 | self.reveal_range = meta["redacted"] 61 | else: 62 | self.reveal_range = None 63 | 64 | def on_leave(self) -> None: 65 | self.reveal_range = None 66 | 67 | 68 | class TextualApp(App): 69 | CSS = """ 70 | Screen { 71 | align: center middle; 72 | } 73 | """ 74 | 75 | def compose(self) -> ComposeResult: 76 | yield RedactedText() 77 | 78 | 79 | if __name__ == "__main__": 80 | app = TextualApp() 81 | app.run() 82 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/mouse_capture.py: -------------------------------------------------------------------------------- 1 | """ 2 | This example demonstrates how to capture mouse events in Textual applications 3 | in order to get clean mouse capture behavior. 4 | This shows two different ways to capture mouse events: 5 | 1. Using `capture_mouse` and `release_mouse` methods. 6 | 2. Using a flag to track if the click started on the widget. 7 | The second method can be preferable in some cases, as it avoids the need to 8 | explicitly capture and release the mouse. 9 | You can also see that removing either of the `on_leave` event handlers 10 | will result in the mouse not being released when the mouse leaves the widget, 11 | and the mouse_up event will be triggered even if the mouse is not 12 | still over the widget. 13 | 14 | Recipe by Edward Jazzhands""" 15 | 16 | import sys 17 | from typing import Any 18 | from textual.app import App 19 | from textual import events 20 | from textual.widgets import Static 21 | 22 | 23 | class MyCapturer1(Static): 24 | 25 | def on_mouse_down(self, event: events.MouseDown) -> None: 26 | 27 | self.capture_mouse() 28 | self.add_class("pressed") 29 | 30 | def on_mouse_up(self, event: events.MouseUp) -> None: 31 | 32 | if self.app.mouse_captured == self: 33 | self.release_mouse() 34 | self.remove_class("pressed") 35 | self.notify("Mouse released") 36 | 37 | def on_leave(self) -> None: 38 | 39 | self.release_mouse() 40 | self.remove_class("pressed") 41 | 42 | 43 | class MyCapturer2(Static): 44 | 45 | def __init__(self, *args: Any, **kwargs: Any): 46 | super().__init__(*args, **kwargs) 47 | self.click_started_on = False 48 | 49 | def on_mouse_down(self, event: events.MouseDown) -> None: 50 | 51 | self.click_started_on = True 52 | self.add_class("pressed") 53 | 54 | def on_mouse_up(self) -> None: 55 | 56 | self.remove_class("pressed") 57 | if self.click_started_on: 58 | self.click_started_on = False 59 | self.notify("Mouse released") 60 | 61 | def on_leave(self) -> None: 62 | 63 | self.click_started_on = False 64 | self.remove_class("pressed") 65 | 66 | 67 | class TextualApp(App[None]): 68 | 69 | CSS = """ 70 | Screen { align: center middle; } 71 | #capture1 { border: solid blue; } 72 | #capture2 { border: solid red; } 73 | .capture { 74 | width: auto; 75 | &.pressed { background: $success; } 76 | } 77 | """ 78 | 79 | def compose(self): 80 | 81 | yield MyCapturer1("Capturer 1", id="capture1", classes="capture") 82 | yield MyCapturer2("Capturer 2", id="capture2", classes="capture") 83 | 84 | 85 | if __name__ == "__main__": 86 | app = TextualApp() 87 | app.run() 88 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /tests/test_recipes.py: -------------------------------------------------------------------------------- 1 | """Dynamic test function creator script for Textual Cookbook recipes 2 | 3 | This script dynamically creates test functions for each example 4 | found in the recipes directory. 5 | 6 | How it works: 7 | 1. It scans the `recipes` directory for Python files. 8 | 2. For each file, it attempts to load the module and find a subclass of `App` 9 | (the `get_app_class` function). 10 | 3. If a subclass of `App` cannot be found or loaded, the test will fail. 11 | 4. The script uses a function factory (`make_test`) to create a test 12 | function for each example, which is then registered in the global namespace. 13 | 5. Pytest will discover these functions and treat every example as a separate test case. 14 | """ 15 | 16 | from __future__ import annotations 17 | from pathlib import Path 18 | import importlib.util 19 | from typing import cast 20 | from textual.app import App 21 | import pytest 22 | 23 | 24 | RECIPES_DIR = Path(__file__).parent.parent / "src" / "textual_cookbook" / "recipes" 25 | 26 | 27 | def get_app_class(path: Path) -> type[App[None]] | None: 28 | 29 | module_name = f"{path.name}" 30 | 31 | ### ~ Stage 1: Load the module spec ~ ### 32 | try: 33 | spec = importlib.util.spec_from_file_location(module_name, path) 34 | except Exception: 35 | pytest.fail(f"Failed to load spec for app {module_name}", pytrace=False) 36 | else: 37 | if spec is None or spec.loader is None: 38 | pytest.fail(f"Failed to load spec for app {module_name}", pytrace=False) 39 | 40 | ### ~ Stage 2: Load module using the spec ~ ### 41 | try: 42 | 43 | module = importlib.util.module_from_spec(spec) 44 | spec.loader.exec_module(module) 45 | except Exception as e: 46 | # breakpoint() 47 | pytest.fail(f"Failed to load module for app {module_name} with error: {e}", pytrace=False) 48 | 49 | ### ~ Stage 3: Retrieve the app class from module ~ ### 50 | candidates = {name: obj for name, obj in module.__dict__.items()} 51 | AppClass = None 52 | try: 53 | for _name, obj in candidates.items(): 54 | if isinstance(obj, type): 55 | if issubclass(obj, App) and obj is not App: 56 | AppClass = cast(type[App[None]], obj) 57 | break 58 | except StopIteration: 59 | pytest.fail(f"Failed to find a valid App subclass in {module_name}", pytrace=False) 60 | except Exception: 61 | pytest.fail(f"Error while searching for App subclass in {module_name}", pytrace=False) 62 | 63 | if not issubclass(AppClass, App): # type: ignore (NOT UNNECESSARY) 64 | pytest.fail(f"Example {recipe_file.name} does not subclass App", pytrace=False) 65 | 66 | return AppClass 67 | 68 | 69 | def make_test(recipe_file: Path): 70 | "The test function factory" 71 | 72 | async def proto_test(): 73 | 74 | AppClass = None 75 | AppClass = get_app_class(recipe_file) 76 | assert AppClass is not None, f"(proto_test) App class not found in {recipe_file}" 77 | 78 | app = AppClass() 79 | async with app.run_test() as pilot: 80 | await pilot.pause() 81 | assert app.screen 82 | await pilot.exit(None) 83 | 84 | return proto_test 85 | 86 | 87 | for recipe_file in RECIPES_DIR.rglob("*.py"): 88 | 89 | proto_test = make_test(recipe_file) 90 | test_name = f"test_{recipe_file.stem}" 91 | proto_test.__name__ = test_name 92 | globals()[test_name] = proto_test 93 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/screen_push_wait.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates how the `push_screen_wait` method 2 | (A wrapper around `push_screen` with the `wait_for_dismiss` argument 3 | set to True) can be used to push a screen in the middle of a worker 4 | that will cause the worker to temporarily pause what it is doing. This 5 | is demonstrated in both a normal async worker and a threaded worker. 6 | 7 | Recipe by NSPC911 8 | https://github.com/NSPC911""" 9 | 10 | import sys 11 | from typing import Any 12 | import asyncio 13 | 14 | from textual import on, work 15 | from textual.app import App, ComposeResult 16 | from textual.containers import Grid, Container 17 | from textual.screen import ModalScreen 18 | from textual.widgets import Button, Label, ProgressBar 19 | 20 | class Dismissable(ModalScreen[None]): 21 | """Super simple screen that can be dismissed.""" 22 | 23 | DEFAULT_CSS = """ 24 | Dismissable { 25 | align: center middle 26 | } 27 | #dialog { 28 | grid-size: 1; 29 | grid-gutter: 1 2; 30 | grid-rows: 1fr 3; 31 | padding: 1 3; 32 | width: 50vw; 33 | max-height: 13; 34 | border: round $primary-lighten-3; 35 | column-span: 3 36 | } 37 | #message { 38 | height: 1fr; 39 | width: 1fr; 40 | content-align: center middle 41 | } 42 | Container { 43 | align: center middle 44 | } 45 | Button { 46 | width: 50% 47 | } 48 | """ 49 | 50 | def __init__(self, message: str, **kwargs: Any): 51 | super().__init__(**kwargs) 52 | self.message = message 53 | 54 | def compose(self) -> ComposeResult: 55 | with Grid(id="dialog"): 56 | yield Label(self.message, id="message") 57 | with Container(): 58 | yield Button("Ok", variant="primary", id="ok") 59 | 60 | def on_mount(self) -> None: 61 | self.query_one("#ok").focus() 62 | 63 | @on(Button.Pressed, "#ok") 64 | def on_button_pressed(self) -> None: 65 | """Handle button presses.""" 66 | self.dismiss() 67 | 68 | 69 | class TextualApp(App[None]): 70 | 71 | def compose(self) -> ComposeResult: 72 | yield Container() 73 | yield Button("test in normal worker", id="test") 74 | yield Button("test in threaded worker", id="test_threaded") 75 | 76 | @on(Button.Pressed, "#test") 77 | @work 78 | async def if_button_pressed(self, event: Button.Pressed) -> None: 79 | 80 | progress = ProgressBar(total=10) 81 | self.mount(progress) 82 | while progress.percentage != 1: 83 | progress.advance() 84 | await asyncio.sleep(0.5) 85 | if progress.percentage == 0.5: 86 | await self.push_screen_wait(Dismissable("hi")) 87 | 88 | @on(Button.Pressed, "#test_threaded") 89 | @work(thread=True) 90 | def if_button_pressed_thread(self) -> None: 91 | 92 | progress = ProgressBar(total=10) 93 | self.call_from_thread(self.mount, progress) 94 | while progress.percentage != 1: 95 | self.call_from_thread(progress.advance) 96 | self.call_from_thread(asyncio.sleep, 0.5) 97 | if progress.percentage == 0.5: 98 | self.call_from_thread(self.push_screen_wait, Dismissable("hi")) 99 | 100 | 101 | if __name__ == "__main__": 102 | app = TextualApp() 103 | app.run() 104 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/tips_and_tricks/timers_in_workers.py: -------------------------------------------------------------------------------- 1 | """This script demonstrates setting timers inside of workers. 2 | A timer schedules a job to run on the event loop, and is independent 3 | of the worker that created it. This demonstrates how in both cases 4 | the timer callback runs after the worker has completed, as well as 5 | how the thread worker must use call_from_thread to schedule timers. 6 | 7 | Recipe by Edward Jazzhands 8 | """ 9 | from __future__ import annotations 10 | import sys 11 | import asyncio 12 | import time 13 | from typing import cast 14 | from functools import partial 15 | 16 | from textual import work, on 17 | from textual.worker import Worker, get_current_worker # type: ignore ("Partially Unknown") 18 | from textual.app import App, ComposeResult 19 | from textual.containers import Horizontal, Vertical 20 | from textual.widgets import RichLog, Button 21 | 22 | 23 | class TextualApp(App[None]): 24 | 25 | CSS = """ 26 | Screen { align: center middle; } 27 | #log_container { 28 | width: 70%; 29 | min-width: 60; 30 | height: 90%; 31 | RichLog { border: heavy $primary; } 32 | } 33 | .buttonrow { 34 | height: auto; 35 | Button { margin: 0 2; } 36 | } 37 | """ 38 | 39 | def compose(self) -> ComposeResult: 40 | 41 | with Vertical(id="log_container"): 42 | with RichLog(id="log1", markup=True) as rlog: 43 | rlog.border_title = "Non-Threaded Worker Log" 44 | rlog.can_focus = False 45 | with Horizontal(classes="buttonrow"): 46 | yield Button("Start normal worker ", id="start_normal_worker") 47 | yield Button("Start thread worker", id="start_thread_worker") 48 | 49 | 50 | def update_log(self, message: str) -> None: 51 | 52 | log_widget = self.query_one(RichLog) 53 | log_widget.write(message) 54 | 55 | async def on_button_pressed(self, event: Button.Pressed) -> None: 56 | 57 | if event.button.id == "start_normal_worker": 58 | self.normal_worker() 59 | elif event.button.id == "start_thread_worker": 60 | self.threaded_worker() 61 | 62 | def worker_timer(self, set_by_worker: Worker[None], log_num: int) -> None: 63 | 64 | self.update_log( 65 | "[yellow]Timer callback[/yellow]: \n" 66 | f"[red]Set by[/red]: {set_by_worker.name} \n" 67 | f"[red]State[/red]: {set_by_worker.state} \n" 68 | f"[red]Is finished?[/red]: {set_by_worker.is_finished} \n" 69 | ) 70 | 71 | @work 72 | async def normal_worker(self) -> None: 73 | 74 | current_worker = cast(Worker[None], get_current_worker()) 75 | self.set_timer(2, partial(self.worker_timer, current_worker, 1)) 76 | await asyncio.sleep(1) 77 | 78 | @work(thread=True) 79 | def threaded_worker(self) -> None: 80 | 81 | current_worker = cast(Worker[None], get_current_worker()) 82 | 83 | # Threaded workers must use call_from_thread to schedule timers. 84 | # Calling set_timer by itself would raise an error. 85 | self.app.call_from_thread( 86 | self.set_timer, 87 | 2, partial(self.worker_timer, current_worker, 2) 88 | ) 89 | time.sleep(1) 90 | 91 | @on(Worker.StateChanged) 92 | def _worker_state_changed(self, event: Worker.StateChanged) -> None: 93 | 94 | worker = event.worker # type: ignore ("Unknown worker type") 95 | self.update_log(f"{worker.name} is {worker.state}") 96 | 97 | 98 | if __name__ == "__main__": 99 | app = TextualApp() 100 | app.run() 101 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/styles.tcss: -------------------------------------------------------------------------------- 1 | /* loading screen */ 2 | #spinner_container { 3 | align: center middle; 4 | &.fullscreen { height: 1fr; } 5 | &.inline { height: auto; } 6 | } 7 | SpinnerWidget { 8 | height: 1; 9 | width: auto; 10 | } 11 | 12 | /* main screen */ 13 | TableScreen { 14 | layout: horizontal; 15 | overflow-x: hidden; 16 | overflow-y: hidden; 17 | # background: transparent; 18 | # border-top: dashed $foreground 70% ; 19 | # border-bottom: dashed $foreground 70% ; 20 | # border: none; 21 | # min-height: 20; 22 | # max-height: 30; 23 | # height: 100%; 24 | #main_container { 25 | #header_container { 26 | height: 4; 27 | margin: 0 1; 28 | #header_text { 29 | height: 1; 30 | padding: 0 1; 31 | text-wrap: nowrap; 32 | } 33 | FigletWidget { width: 62; height: 2; } 34 | } 35 | #table_container { 36 | margin: 0 1; 37 | height: 1fr; 38 | width: 1fr; 39 | & > CustomDataTable { 40 | overflow-x: hidden; 41 | # width: auto; 42 | & > .datatable--header { background: $panel; } 43 | # & > .datatable--cursor { background: $surface; } 44 | & > .datatable--header-cursor { background: $panel; } 45 | & > .datatable--header-hover { background: $panel; } 46 | # & > .datatable--hover { background: $surface; } 47 | } 48 | } 49 | #bottom_container { 50 | margin: 0 1; 51 | height: 5; 52 | & > SummaryBar { 53 | height: 1; 54 | background: $panel-darken-1; 55 | #sum_label, #sum_filler { width: 1fr; } 56 | .sum_cell { padding: 0 1; } 57 | } 58 | & > #controls_bar { 59 | height: 1; 60 | padding: 0 1; 61 | background: $panel; 62 | } 63 | } 64 | } 65 | CodeContainer { 66 | # min-width: 74; 67 | Vertical { 68 | TextArea { 69 | border: none; 70 | padding: 1; 71 | } 72 | Horizontal { 73 | height: 1; 74 | background: $panel; 75 | Static { 76 | color: $success; 77 | width: auto; 78 | padding: 0 0 0 1; 79 | } 80 | Button { 81 | min-width: 7; 82 | dock: right; 83 | &:hover {background: $success;} 84 | } 85 | } 86 | } 87 | } 88 | } 89 | 90 | .button_container { 91 | width: auto; 92 | height: auto; 93 | & > Button { 94 | border: round $primary 50%; 95 | background: transparent; 96 | &:focus { 97 | text-style: none; 98 | border: round $primary; 99 | } 100 | &:hover { 101 | border: round $primary; 102 | } 103 | &.-active { 104 | background: transparent; 105 | border: round $accent; 106 | tint: $surface 0%; 107 | } 108 | } 109 | } 110 | .modal { width: 1fr; } 111 | 112 | DescriptionScreen, ErrorScreen { 113 | align: center middle; 114 | .description_container { 115 | width: 50%; 116 | max-width: 75; 117 | height: auto; 118 | padding: 1; 119 | border: hkey $primary; 120 | Markdown, Static { 121 | width: 1fr; 122 | height: auto; 123 | background: transparent; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # HOW THIS WORKFLOW WORKS 2 | # This workflow automates creating a GitHub Release and publishing the Python package to PyPI. 3 | 4 | # --- Manual Steps --- 5 | # 1. Edit `pyproject.toml` to set the new version (e.g., `version = "1.2.3"` or `version = "1.2.4-rc1"` for pre-releases). 6 | # 2. Commit the change and merge it to the `main` branch (From the feature branch or however you work). 7 | # 3. On your local machine, fetch and pull the latest changes from `main` to ensure you're up to date. 8 | # 4. Use the justfile: `just release` to trigger the process. 9 | 10 | # --- Automation --- 11 | # - runs .github/scripts/tag_release.py to create a new tag based on the version in `pyproject.toml`. 12 | # - Pushes the new tag to github which triggers this workflow file. 13 | # - Checks that the tag matches the version in `pyproject.toml`. 14 | # - Builds the sdist and wheel. 15 | # - Publishes the package to PyPI using trusted publishing. 16 | # - Reads the release notes from your CHANGELOG.md. 17 | # - Creates a new GitHub Release, marking it as a pre-release if necessary. 18 | 19 | 20 | name: Create Release and Publish to PyPI 21 | 22 | on: 23 | push: 24 | tags: 25 | - "v*" # Runs on any tag starting with "v", e.g., v1.2.3 or v1.2.3-rc1 26 | workflow_dispatch: 27 | inputs: 28 | tag_name: 29 | description: 'Tag to create release for (e.g., v1.2.3). Must start with "v".' 30 | required: true 31 | type: string 32 | 33 | jobs: 34 | build-and-publish: 35 | name: Build and Publish 36 | runs-on: ubuntu-latest 37 | permissions: 38 | id-token: write # Required for Trusted Publishing with PyPI (OIDC). 39 | contents: write # Required to create the GitHub Release. 40 | 41 | steps: 42 | # If manually triggered, checkout the specific tag. Otherwise, it checks out the tag that triggered the workflow. 43 | # Fetch all history for all tags so the changelog-reader can find previous tags. 44 | - name: Check out code 45 | uses: actions/checkout@v4 46 | with: 47 | ref: ${{ github.event.inputs.tag_name || github.ref }} 48 | fetch-depth: 0 49 | 50 | - name: Set up Python 51 | uses: actions/setup-python@v5 52 | with: 53 | python-version-file: '.python-version' 54 | 55 | - name: Install required python packages 56 | run: python -m pip install --upgrade build tomli 57 | 58 | # Use github.ref_name which reliably gives the tag name (e.g., "v1.2.3") 59 | # Create a step output named 'version' that contains the tag name without the 'v' 60 | - name: Verify tag matches pyproject.toml version 61 | run: | 62 | TAG_NAME="${{ github.ref_name }}" 63 | PYPROJECT_VERSION=$(python -c "import tomli; print(tomli.load(open('pyproject.toml', 'rb'))['project']['version'])") 64 | 65 | if [ "v$PYPROJECT_VERSION" != "$TAG_NAME" ]; then 66 | echo "Error: Tag '$TAG_NAME' does not match pyproject.toml version 'v$PYPROJECT_VERSION'" 67 | exit 1 68 | fi 69 | 70 | echo "Tag and pyproject.toml version match: $TAG_NAME" 71 | echo "version=${TAG_NAME#v}" >> $GITHUB_OUTPUT 72 | 73 | - name: Build package 74 | run: python -m build 75 | 76 | - name: Publish to PyPI 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | 79 | - name: Get Changelog Entry 80 | id: changelog_reader 81 | uses: mindsers/changelog-reader-action@v2 82 | with: 83 | validation_level: warn 84 | version: ${{ steps.version_check.outputs.version }} 85 | path: ./CHANGELOG.md 86 | 87 | - name: Create GitHub Release 88 | uses: ncipollo/release-action@v1 89 | with: 90 | # Use the tag name that triggered the workflow 91 | tag: ${{ github.ref_name }} 92 | # The release title will be, e.g., "Release v1.2.3" 93 | name: Release ${{ github.ref_name }} 94 | # The body of the release is the changelog entry from the previous step 95 | body: ${{ steps.changelog_reader.outputs.changes }} 96 | # Automatically mark as pre-release if the tag contains a hyphen (e.g., v1.2.3-rc1) 97 | prerelease: ${{ contains(github.ref_name, '-') }} 98 | # This allows the action to update a release if it already exists 99 | allowUpdates: true -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/architecture_patterns/use_default_screen.py: -------------------------------------------------------------------------------- 1 | """An example showing how to use a default main screen. 2 | 3 | Simple Textual apps place a lot of logic in the `App` class, but you can also 4 | use a `Screen` as the main screen. This example shows how to do that, and how to 5 | push modal screens from the main screen, and show a modal confirmation screen 6 | when quitting the app. You'll never leave the main screen until you quit the 7 | application, but you can still use the `App` class to manage a (few) 8 | application-wide bindings. 9 | 10 | Recipe by David Fokkema 11 | """ 12 | 13 | import sys 14 | 15 | from textual import on 16 | from textual.app import App, ComposeResult 17 | from textual.containers import HorizontalGroup, VerticalGroup 18 | from textual.screen import ModalScreen, Screen 19 | from textual.widgets import Button, Footer, Label 20 | 21 | 22 | class HelpModal(ModalScreen[None]): 23 | BINDINGS = [("escape", "dismiss", "Dismiss")] 24 | 25 | def compose(self) -> ComposeResult: 26 | yield Footer() 27 | yield Label("Just your regular help screen, dismiss with escape") 28 | 29 | 30 | class MainScreen(Screen[None]): 31 | BINDINGS = [ 32 | ("a", "add_item", "Add Item"), 33 | ("r", "remove_item", "Remove Item"), 34 | ("h", "show_help", "Help"), 35 | ] 36 | 37 | items: list[str] = [] 38 | 39 | def compose(self) -> ComposeResult: 40 | yield Footer() 41 | yield Label() 42 | 43 | def on_mount(self) -> None: 44 | self.update_label() 45 | 46 | def action_add_item(self) -> None: 47 | self.items.append("💡") 48 | self.update_label() 49 | 50 | def action_remove_item(self) -> None: 51 | if self.items: 52 | self.items.pop() 53 | self.update_label() 54 | 55 | def update_label(self) -> None: 56 | widget = self.query_one(Label) 57 | if self.items: 58 | widget.update("".join(self.items)) 59 | else: 60 | widget.update("Empty") 61 | 62 | def action_show_help(self) -> None: 63 | self.app.push_screen(HelpModal()) 64 | 65 | 66 | class ConfirmQuitScreen(ModalScreen[bool]): 67 | BINDINGS = [("escape", "dismiss", "Dismiss")] 68 | 69 | def compose(self) -> ComposeResult: 70 | yield Footer() 71 | with VerticalGroup(): 72 | yield Label("Are you sure you want to quit?") 73 | yield HorizontalGroup( 74 | Button("Yes", id="yes_button", variant="success"), 75 | Button("No", id="no_button", variant="error"), 76 | ) 77 | 78 | @on(Button.Pressed) 79 | def confirm(self, event: Button.Pressed) -> None: 80 | if event.button.id == "yes_button": 81 | self.dismiss(True) 82 | else: 83 | self.dismiss(False) 84 | 85 | 86 | class MainApp(App[None]): 87 | BINDINGS = [("q", "confirm_quit", "Quit")] 88 | 89 | CSS = """ 90 | MainScreen Label { 91 | width: 100%; 92 | text-align: center; 93 | padding-top: 5; 94 | } 95 | 96 | HelpModal { 97 | align: center middle; 98 | & Label { 99 | border: $primary hkey; 100 | background: $panel; 101 | padding: 1 2; 102 | margin-top: 3; 103 | } 104 | } 105 | 106 | ConfirmQuitScreen { 107 | align: center middle; 108 | & VerticalGroup { 109 | width: auto; 110 | 111 | & Label { 112 | width: 100%; 113 | text-align: center; 114 | } 115 | 116 | & HorizontalGroup { 117 | width: auto; 118 | 119 | & Button { 120 | margin: 1 2; 121 | } 122 | } 123 | } 124 | } 125 | """ 126 | 127 | def get_default_screen(self) -> Screen[None]: 128 | return MainScreen() 129 | 130 | def action_confirm_quit(self) -> None: 131 | def callback(is_confirmed: bool | None) -> None: 132 | if is_confirmed: 133 | self.exit() 134 | 135 | self.push_screen(ConfirmQuitScreen(), callback=callback) 136 | 137 | 138 | if __name__ == "__main__": 139 | app = MainApp() 140 | app.run() 141 | sys.exit(app.return_code) 142 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/decline_failed_input.py: -------------------------------------------------------------------------------- 1 | """This script shows a custom Input element, that can decline a value if it \ 2 | does not meet the validators' criterias (in this case, requires both a 'h'\ 3 | and 'i' to exist) for it to be allowed. 4 | 5 | Recipe by NSPC911 6 | https://github.com/NSPC911""" 7 | 8 | from asyncio import sleep 9 | import sys 10 | 11 | from textual import events, work 12 | from textual.app import App, ComposeResult 13 | from textual.containers import HorizontalGroup 14 | from textual.screen import ModalScreen 15 | from textual.validation import Function 16 | from textual.widgets import Input, Button 17 | 18 | 19 | class ModalInput(ModalScreen): 20 | def __init__( 21 | self, 22 | border_title: str, 23 | border_subtitle: str = "", 24 | **kwargs, 25 | ) -> None: 26 | super().__init__(**kwargs) 27 | self.border_title = border_title 28 | self.border_subtitle = border_subtitle 29 | self.validators = [ 30 | Function(lambda x: "h" in x, "The character 'h' isnt in the string!"), 31 | Function(lambda x: "i" in x, "The character 'i' isnt in the string!"), 32 | ] 33 | 34 | def compose(self) -> ComposeResult: 35 | with HorizontalGroup(): 36 | yield Input( 37 | id="input", 38 | compact=True, 39 | valid_empty=False, 40 | validators=self.validators, 41 | validate_on=["changed", "submitted"], 42 | ) 43 | 44 | @work(exclusive=True) 45 | async def on_input_changed(self, event: Input.Changed) -> None: 46 | if self.query_one(Input).is_valid: 47 | self.horizontal_group.classes = "valid" 48 | self.horizontal_group.border_subtitle = self.border_subtitle 49 | else: 50 | self.horizontal_group.classes = "invalid" 51 | try: 52 | self.horizontal_group.border_subtitle = str( 53 | event.validation_result.failure_descriptions[0] 54 | ) 55 | except AttributeError: 56 | # valid_empty = False 57 | self.horizontal_group.border_subtitle = "The word cannot be empty!" 58 | 59 | def on_mount(self) -> None: 60 | self.horizontal_group: HorizontalGroup = self.query_one(HorizontalGroup) 61 | inp: Input = self.query_one(Input) 62 | self.horizontal_group.border_title = self.border_title 63 | if self.border_subtitle != "": 64 | self.horizontal_group.border_subtitle = self.border_subtitle 65 | inp.focus() 66 | inp.validate(inp.value) 67 | self.on_input_changed(inp.Changed(inp, inp.value)) 68 | 69 | @work 70 | async def on_input_submitted(self, event: Input.Submitted) -> None: 71 | """Handle input submission.""" 72 | if not self.query_one(Input).is_valid: 73 | # shake 74 | for i in range(3): 75 | self.horizontal_group.styles.offset = (1, 0) 76 | await sleep(0.1) 77 | self.horizontal_group.styles.offset = (0, 0) 78 | await sleep(0.1) 79 | return 80 | self.dismiss(event.input.value) 81 | 82 | def on_key(self, event: events.Key) -> None: 83 | """Handle escape key to dismiss the dialog.""" 84 | if event.key == "escape": 85 | event.stop() 86 | self.dismiss("") 87 | 88 | class TextualApp(App): 89 | CSS = """ 90 | ModalInput { 91 | align: center middle; 92 | HorizontalGroup { 93 | border: round $border; 94 | width: 50vw; 95 | max-height: 3; 96 | padding: 0 1; 97 | background: transparent !important; 98 | &.invalid { 99 | border: round $error-lighten-3 100 | } 101 | &:light { 102 | border: round $error; 103 | } 104 | } 105 | Input { background: transparent !important } 106 | Label { 107 | height: 1; 108 | } 109 | } 110 | """ 111 | def compose(self) -> ComposeResult: 112 | yield Button("Open an input dialog!") 113 | def on_button_pressed(self, event: Button.Pressed) -> None: 114 | self.push_screen(ModalInput("What word do you start a greeting with?"), callback=self.notify) 115 | 116 | if __name__ == "__main__": 117 | app = TextualApp() 118 | app.run() 119 | sys.exit(app.return_code) 120 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/animation_effects/context_menu.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates how to create animating pop-up 2 | context menus. The menu will pop up whereever you click 3 | on the static banner in the center. 4 | The context menu itself is actually on a Modal Screen. This 5 | allows us to close it by clicking anywhere. 6 | 7 | Recipe by Edward Jazzhands""" 8 | 9 | # Python imports 10 | from __future__ import annotations 11 | from typing import TYPE_CHECKING 12 | import sys 13 | 14 | if TYPE_CHECKING: 15 | from textual.app import ComposeResult 16 | 17 | # Textual and rich imports 18 | from textual import work 19 | from textual.app import App, ComposeResult 20 | from textual.geometry import Offset 21 | from textual.screen import ModalScreen 22 | from textual.containers import Container 23 | from textual import events 24 | from textual.widgets import Static, Button 25 | 26 | 27 | class ContextMenu(ModalScreen[None]): 28 | 29 | BINDINGS = [ 30 | ("up", "app.focus_previous"), 31 | ("down", "app.focus_next"), 32 | ] 33 | 34 | CSS = """ 35 | ContextMenu { 36 | background: $background 0%; 37 | align: left top; /* This will set the starting coordinates to (0, 0) */ 38 | } /* Which we need for the absolute offset to work */ 39 | #menu_container { 40 | background: $surface; 41 | width: 10; 42 | border-left: wide $panel; 43 | border-right: wide $panel; 44 | &.bottom { border-top: hkey $panel; } 45 | &.top { border-bottom: hkey $panel; } 46 | & > Button { 47 | min-width: 8; 48 | &:hover { background: $panel-lighten-2; } 49 | &:focus { 50 | background: $panel-lighten-2; 51 | text-style: none; 52 | } 53 | } 54 | } 55 | """ 56 | 57 | def __init__(self, menu_offset: Offset) -> None: 58 | super().__init__() 59 | self.menu_offset = menu_offset 60 | 61 | def compose(self) -> ComposeResult: 62 | 63 | with Container(id="menu_container"): 64 | yield Button("Menu 1", id="menu1", compact=True) 65 | yield Button("Menu 2", id="menu2", compact=True) 66 | yield Button("Menu 3", id="menu3", compact=True) 67 | yield Button("Menu 4", id="menu4", compact=True) 68 | 69 | def on_mount(self) -> None: 70 | 71 | menu = self.query_one("#menu_container") 72 | # automatically set the height to the number of buttons: 73 | height = len(menu.children) 74 | menu.styles.height = height 75 | 76 | # Subtracting the menu's height will make it appear above 77 | # the mouse instead of below it 78 | y_offset = self.menu_offset.y - height 79 | menu.offset = Offset(self.menu_offset.x, y_offset) 80 | 81 | def on_mouse_up(self) -> None: 82 | 83 | # This allows us to close it by clicking anywhere 84 | self.dismiss(None) 85 | 86 | async def on_button_pressed(self, event: Button.Pressed) -> None: 87 | 88 | if event.button.id == "menu1": 89 | self.notify("Menu 1") 90 | elif event.button.id == "menu2": 91 | self.notify("Menu 2") 92 | elif event.button.id == "menu3": 93 | self.notify("Menu 3") 94 | elif event.button.id == "menu4": 95 | self.notify("Menu 4") 96 | 97 | self.dismiss() 98 | 99 | class MyStatic(Static): 100 | 101 | @work 102 | async def on_click(self, event: events.Click) -> None: 103 | menu_offset = event.screen_offset 104 | await self.app.push_screen_wait( 105 | ContextMenu(menu_offset=menu_offset) 106 | ) 107 | 108 | class TextualApp(App[None]): 109 | 110 | CSS = """ 111 | #my_container { 112 | align: center middle; 113 | border: solid red; 114 | } 115 | MyStatic { 116 | border: hkey blue; 117 | width: auto; 118 | &:hover { background: $boost; } 119 | } 120 | """ 121 | 122 | def compose(self): 123 | 124 | with Container(id="my_container"): 125 | yield MyStatic( 126 | "Click on me anywhere. " 127 | "Notice how the menu pops up where you click. \n" 128 | "You can also navigate the menu with up/down keys or tab.", 129 | id="main_button" 130 | ) 131 | 132 | 133 | if __name__ == "__main__": 134 | app = TextualApp() 135 | app.run() 136 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/better_optionlist.py: -------------------------------------------------------------------------------- 1 | """This file demonstrates an improved optionlist, where you can search for the available 2 | options and select them accordingly. The options are from 3 | `Starlight (Keep Me Afloat) - Martin Garrix` <3 4 | 5 | Recipe by NSPC911 6 | https://github.com/NSPC911""" 7 | 8 | import sys 9 | 10 | from textual import events 11 | from textual.app import App, ComposeResult 12 | from textual.containers import VerticalGroup 13 | from textual.screen import ModalScreen 14 | from textual.widgets import Button, Input, OptionList 15 | from textual.widgets.option_list import Option 16 | 17 | from textual.fuzzy import Matcher 18 | 19 | starlight = [ 20 | "I see you through the clouds", 21 | "I feel you anyways", 22 | "You never walk out", 23 | "Even on my darkest days", 24 | "Like a guardian, you're watching over me", 25 | "Through the depths of my own despair", 26 | "I look up to the sky and I find my peace", 27 | "I know you'll be there, so", 28 | "Starlight, won't you lead the way down this river?", 29 | "Keep me afloat, keep me afloat", 30 | "If I ever drift away, won't you be there?", 31 | "Keep me afloat, keep me afloat", 32 | ] 33 | 34 | 35 | class NarrowOptionsWithInput(ModalScreen): 36 | def __init__(self, options: list = [], placeholder: str = "Don't drop your jaw!", **kwargs) -> None: 37 | super().__init__(**kwargs) 38 | self.placeholder = placeholder 39 | self.options = options 40 | # when there are styled options available 41 | self._option_mapping = {} 42 | 43 | def compose(self) -> ComposeResult: 44 | with VerticalGroup(id="root"): 45 | yield Input(placeholder=self.placeholder) 46 | yield OptionList(*self.options) 47 | 48 | def on_mount(self) -> None: 49 | self.query_one(OptionList).can_focus = False 50 | self.query_one(Input).focus() 51 | 52 | def on_input_changed(self, event: Input.Changed) -> None: 53 | value = event.value 54 | optionlist: OptionList = self.query_one(OptionList) 55 | optionlist.clear_options() 56 | if event.value == "": 57 | optionlist.add_options(self.options) 58 | else: 59 | matcher = Matcher(value, match_style="underline") 60 | matches = [] 61 | self._option_mapping.clear() 62 | for option in self.options: 63 | prompt = option.prompt if isinstance(option, Option) else str(option) 64 | score = matcher.match(prompt) 65 | if score > 0: 66 | highlighted_content = matcher.highlight(prompt) 67 | option_obj = Option(highlighted_content) 68 | matches.append((score, option_obj, prompt)) 69 | if matches: 70 | matches.sort(reverse=True, key=lambda tup: tup[0]) 71 | for _, option_obj, original_prompt in matches: 72 | optionlist.add_option(option_obj) 73 | self._option_mapping[option_obj.prompt] = original_prompt 74 | else: 75 | nomatch = Option("--no matches--", disabled=True) 76 | optionlist.add_option(nomatch) 77 | self._option_mapping[nomatch.prompt] = None 78 | optionlist.highlighted = 0 79 | 80 | def on_input_submitted(self, event: Input.Submitted) -> None: 81 | optionlist = self.query_one(OptionList) 82 | if optionlist.highlighted is None: 83 | optionlist.highlighted = 0 84 | optionlist.action_select() 85 | 86 | def on_option_list_option_selected(self, event: OptionList.OptionSelected): 87 | # Map back to the original string (not the Content object) 88 | prompt = event.option.prompt 89 | result = self._option_mapping.get(prompt, prompt) 90 | self.dismiss(result) 91 | 92 | def on_key(self, event: events.Key) -> None: 93 | """Handle key presses.""" 94 | match event.key: 95 | case "escape": 96 | self.dismiss(None) 97 | case "down": 98 | zoxide_options = self.query_one(OptionList) 99 | if zoxide_options.options: 100 | zoxide_options.action_cursor_down() 101 | case "up": 102 | zoxide_options = self.query_one(OptionList) 103 | if zoxide_options.options: 104 | zoxide_options.action_cursor_up() 105 | case "tab": 106 | self.focus_next() 107 | case "shift+tab": 108 | self.focus_previous() 109 | 110 | 111 | class TextualApp(App): 112 | def compose(self) -> ComposeResult: 113 | yield Button("Show the improved optionlist") 114 | 115 | def on_button_pressed(self, event: Button.Pressed) -> None: 116 | self.push_screen(NarrowOptionsWithInput(starlight, "Starlight (Keep Me Afloat)"), lambda x: self.notify(str(x))) 117 | 118 | if __name__ == "__main__": 119 | app = TextualApp() 120 | app.run() 121 | sys.exit(app.return_code) 122 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/modifying_widgets/datatable_headersort.py: -------------------------------------------------------------------------------- 1 | """This example demonstrates how to create a sortable DataTable 2 | with headers that change their appearance based on the sorting state. 3 | The arrows in the header label will flip direction when the user clicks 4 | on a header to indicate the current sort order. 5 | 6 | Recipe by Edward Jazzhands""" 7 | 8 | # python standard lib 9 | from __future__ import annotations 10 | import sys 11 | from typing import Any 12 | from enum import Enum 13 | 14 | # Textual imports 15 | from textual import on 16 | from textual.app import App, ComposeResult 17 | from textual.widgets import DataTable 18 | from textual.widgets.data_table import Column, ColumnKey 19 | from textual.containers import Container 20 | from rich.text import Text 21 | 22 | 23 | ROWS = [ 24 | (4, "Joseph Schooling", "Singapore", 50.39), 25 | (2, "Michael Phelps", "United States", 51.14), 26 | (5, "Chad le Clos", "South Africa", 51.14), 27 | (6, "László Cseh", "Hungary", 51.14), 28 | (3, "Li Zhuhao", "China", 51.26), 29 | (8, "Mehdy Metella", "France", 51.58), 30 | (7, "Tom Shields", "United States", 51.73), 31 | (1, "Aleksandr Sadovnikov", "Russia", 51.84), 32 | (10, "Darren Burns", "Scotland", 51.84), 33 | ] 34 | 35 | class SortingStatus(Enum): 36 | UNSORTED = 0 # [-] unsorted 37 | ASCENDING = 1 # [↑] ascending (reverse = True) 38 | DESCENDING = 2 # [↓] descending (reverse = False) 39 | 40 | 41 | class TextualApp(App[None]): 42 | 43 | DEFAULT_CSS = """ 44 | #datatable_container { align: center middle; } 45 | DataTable { width: 60; height: auto; } 46 | """ 47 | 48 | sorting_statuses_dict: dict[str, SortingStatus] = { 49 | "lane": SortingStatus.UNSORTED, 50 | "swimmer": SortingStatus.UNSORTED, 51 | "country": SortingStatus.UNSORTED, 52 | "time": SortingStatus.UNSORTED, 53 | } 54 | 55 | def compose(self) -> ComposeResult: 56 | self.datatable = DataTable[Any]() 57 | with Container(id="datatable_container"): 58 | yield self.datatable 59 | 60 | def on_mount(self) -> None: 61 | 62 | self.datatable.add_column("lane [yellow]-[/]", key="lane") 63 | self.datatable.add_column("swimmer [yellow]-[/]", key="swimmer") 64 | self.datatable.add_column("country [yellow]-[/]", key="country") 65 | self.datatable.add_column("time [yellow]-[/]", key="time") 66 | self.datatable.add_rows(ROWS) 67 | 68 | # Retrieving the actual column from `DataTable.columns` is the trick 69 | # to making this work. This is different from `DataTable.get_column`, which 70 | # returns the values of the column, not the actual column object. 71 | # You need the column object to modify its label. 72 | 73 | column = self.datatable.columns[ColumnKey("time")] 74 | self.sort_column(column, column.key) 75 | 76 | @on(DataTable.HeaderSelected) 77 | def header_selected(self, event: DataTable.HeaderSelected) -> None: 78 | 79 | column = self.datatable.columns[event.column_key] 80 | self.sort_column(column, column.key) 81 | 82 | def sort_column(self, column: Column, column_key: ColumnKey) -> None: 83 | 84 | if column_key.value not in self.sorting_statuses_dict: 85 | raise ValueError( 86 | f"Unknown column key: {column_key.value}. " 87 | "This should never happen, please report this issue." 88 | ) 89 | 90 | key = column_key.value 91 | sort_status = self.sorting_statuses_dict 92 | table = self.datatable 93 | 94 | if sort_status[key] == SortingStatus.UNSORTED : 95 | # if column is unsorted, that means the user is switching which 96 | # column to sort. Start by resetting all columns to unsorted. 97 | for col_key in sort_status: 98 | sort_status[col_key] = SortingStatus.UNSORTED 99 | col_index = table.get_column_index(col_key) 100 | col = table.ordered_columns[col_index] 101 | col.label = Text.from_markup(f"{col_key} [yellow]-[/]") 102 | 103 | # Now set chosen column to ascending: 104 | sort_status[key] = SortingStatus.ASCENDING 105 | table.sort(column_key, reverse=True) 106 | column.label = Text.from_markup(f"{key} [yellow]↑[/]") 107 | 108 | # For the other two conditions, we just toggle ascending/descending 109 | elif sort_status[key] == SortingStatus.ASCENDING: 110 | sort_status[key] = SortingStatus.DESCENDING 111 | table.sort(key, reverse=False) 112 | column.label = Text.from_markup(f"{key} [yellow]↓[/]") 113 | elif sort_status[key] == SortingStatus.DESCENDING: 114 | sort_status[key] = SortingStatus.ASCENDING 115 | table.sort(column_key, reverse=True) 116 | column.label = Text.from_markup(f"{key} [yellow]↑[/]") 117 | else: 118 | raise ValueError( 119 | f"Sort status for {key} is '{sort_status[key]}' " 120 | "did not meet any expected values." 121 | ) 122 | 123 | 124 | if __name__ == "__main__": 125 | app = TextualApp() 126 | app.run() 127 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textual-Cookbook 2 | 3 | This repo contains a collection of examples and recipes for using the Textual framework to build terminal applications. Every example in this repository is runnable, and you can use them as a starting point for your own projects, or just learn how to do nifty things with Textual. 4 | 5 | The examples are currently organized into the following categories: 6 | 7 | - **Animation Effects**: Examples that demonstrate how to create various animation effects in Textual applications. 8 | - **Architecture Patterns**: Examples that showcase different architectural patterns for building Textual applications. 9 | - **Modifying Widgets**: Examples that show how to modify existing widgets or create new ones. 10 | - **Styling and Colors**: Examples that demonstrate how to style Textual applications and use colors effectively. 11 | - **Textual API Usage**: Examples that illustrate how to use the Textual API for various tasks. 12 | - **Tips and Tricks**: A collection of miscellaneous examples that don't fit into the other categories. 13 | 14 | All examples are tested with Nox against multiple verions of Textual (back to 1.0.0) in order to see how far back the examples work. Nearly all of them work all the way back to 1.0.0 with a few exceptions. To view the results of the tests, click the link below: 15 | 16 | ## [Textual Cookbook Test Results](https://ttygroup.github.io/textual-cookbook/reports/) 17 | 18 | ## Contributing / Submission Guide 19 | 20 | Recipes can be submitted as pull requests. If you have a recipe that you would like to contribute, please follow these steps: 21 | 22 | 1. Fork the repository and clone it to your local machine. 23 | 2. Create your new recipe in the appropriate directory. It must be inside one of the category directories. If you think a new category is needed please open an issue. 24 | 3. Follow the recipe guidelines below when writing your recipe. 25 | 4. Test your recipe inside of the cookbook runner to ensure that it displays properly in the runner and the recipe works as expected. 26 | 5. Run Pytest (or Nox) to ensure your recipe passes the test on at least the latest version of Textual. 27 | 6. Submit a pull request. 28 | 29 | You may also want to download the [`Just` command runner](https://just.systems/) to use the justfile. 30 | 31 | ## Recipe Guidelines 32 | 33 | When writing a recipe, please follow these guidelines: 34 | 35 | ### **Docstring at the top** must follow this format 36 | 37 | ```python 38 | """This is the description of the recipe. Please write a description 39 | that clearly explains what the recipe demonstrates. This description 40 | can be as long or as short as you think is necessary. If it is very 41 | long then the description screen will be scrollable. Some recipes 42 | have short descriptions, some have descriptions that are several 43 | paragraphs. This may be necessary if your recipe solves a complex 44 | problem that requires explanation. The most important thing is 45 | that it's obvious what this recipe does and what its purpose is. 46 | 47 | This could be a second paragraph if you like, etc. 48 | 49 | Recipe by Your Name Here""" 50 | ``` 51 | 52 | Note the `Recipe by Your Name Here` must not have anything else on the line, except for the triple quotes (""") at the end. The cookbook runner uses Regex to find this line and extract the author's name. If you do not follow this format, the cookbook runner will not be able to display your name correctly. 53 | 54 | Optionally you can add extra info on the next line below your name and move the ending triple quotes to the next line, like this: 55 | 56 | ```python 57 | """This is the description of the recipe. 58 | 59 | Recipe by Your Name Here 60 | 2025, https://your.website.here""" 61 | ``` 62 | 63 | ### **Main guard** must be present and follow this format 64 | 65 | It should look like this: 66 | 67 | ```python 68 | if __name__ == "__main__": 69 | app = YourRecipeApp() 70 | app.run() 71 | sys.exit(app.return_code) 72 | ``` 73 | 74 | Note this requires importing `sys` at the top of your recipe file. The cookbook runner uses the exit code to know if a recipe failed or not, and provide an error message if one does. However, Textual does not return an exit code by default so we need to return it explicitly in the main guard using `sys.exit`. 75 | 76 | Without this, if a recipe fails then the runner has no way of knowing. The recipe simply closes leaving the user wondering what happened. This isn't a huge deal for experienced Textual developers adding new recipes. However, the cookbook's intended audience is beginners and intermediate users who may be modifying existing recipes, or attempting to create their own. Without this error message, it may not be immediately obvious that their recipe has failed and that they can close the runner to view the traceback. If every recipe has this in the main guard, it means any recipe can be modified or copied as a starting point for a new recipe, and the user will always get an error message if something goes wrong. 77 | 78 | ### Type hints 79 | 80 | Type hints are not required, but they are encouraged. I'm not going to be enforcing type strictness at all. But please do include them if you can. 81 | 82 | MyPy and BasedPyright are included as dev dependencies and there's a shortcut to use them in the justfile. You can use it like this: 83 | 84 | ```bash 85 | just typecheck category/my_recipe.py 86 | ``` 87 | 88 | And that's everything. Pytest will run in CI to ensure your recipe passes before it is merged. If you have any questions, feel free to open an issue or ask in the Textual Discord server. 89 | 90 | ## License 91 | 92 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 93 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """Config file for Nox sessions 2 | By Edward Jazzhands - 2025 3 | reuse_existing_virtualenvs 4 | NOTE ABOUT NOX CONFIG: 5 | If you are doing dev work in some kind of niche environment such as a Docker 6 | container or on a server, you might not have symlinks available to you. 7 | In that case, you can set `nox.options.reuse_existing_virtualenvs = True 8 | 9 | Setting `nox.options.reuse_existing_virtualenvs` to True will make Nox 10 | reuse environments between runs, preventing however many GB of data from 11 | being written to your drive every time you run it. (Note: saves environments 12 | between runs of Nox, not between sessions of the same run). 13 | 14 | If you do not need to reuse existing virtual environments, you can set 15 | `nox.options.reuse_existing_virtualenvs = False` and `DELETE_VENV_ON_EXIT = True` 16 | to delete the virtual environments after each session. This will ensure that 17 | you do not have any leftover virtual environments taking up space on your drive. 18 | Nox would just delete them when starting a new session anyway. 19 | """ 20 | 21 | import nox 22 | import pathlib 23 | import shutil 24 | import json 25 | import glob 26 | import os 27 | 28 | # PYTHON_VERSIONS = ["3.9", "3.12"] 29 | PYTHON_VERSIONS = ["3.10"] 30 | # TEXTUAL_VERSIONS = [5.1, 3.0, 2.1, 1.0] 31 | TEXTUAL_VERSIONS = [5.3, 4.0, 3.7] 32 | 33 | ############## 34 | # NOX CONFIG # 35 | ############## 36 | 37 | nox.options.reuse_existing_virtualenvs = True 38 | nox.options.stop_on_first_error = False 39 | DELETE_VENV_ON_EXIT = False 40 | 41 | if nox.options.reuse_existing_virtualenvs and DELETE_VENV_ON_EXIT: 42 | raise ValueError( 43 | "You cannot set both `nox.options.reuse_existing_virtualenvs`" 44 | "and `DELETE_VENV_ON_EXIT` to True (Technically this would not cause " 45 | "an error, but it would be pointless)." 46 | ) 47 | 48 | nox_report_path = pathlib.Path("docs/reports") / "nox-report.md" 49 | nox_report_path.unlink(missing_ok=True) 50 | 51 | file_index_path = pathlib.Path("docs/reports") / "file_index.json" 52 | file_index_path.unlink(missing_ok=True) 53 | file_index: dict[str, str] = {} 54 | 55 | ################ 56 | # NOX SESSIONS # 57 | ################ 58 | 59 | 60 | @nox.session( 61 | venv_backend="uv", 62 | python=PYTHON_VERSIONS, 63 | ) 64 | @nox.parametrize("ver", TEXTUAL_VERSIONS) 65 | def tests(session: nox.Session, ver: int) -> None: 66 | 67 | session.run_install( 68 | "uv", 69 | "sync", 70 | "--quiet", 71 | "--reinstall", 72 | f"--python={session.virtualenv.location}", 73 | env={"UV_PROJECT_ENVIRONMENT": session.virtualenv.location}, 74 | external=True, 75 | ) 76 | 77 | # Running pip install after syncing will override any 78 | # packages that were installed by the sync command. 79 | # Calculate the next minor version for the upper bound 80 | major, minor = str(ver).split(".") 81 | next_minor = f"{major}.{int(minor)+1}" 82 | session.run_install( 83 | "uv", 84 | "pip", 85 | "install", 86 | f"textual>={ver},<{next_minor}.0", 87 | external=True, 88 | ) 89 | session.run("uv", "pip", "show", "textual") 90 | # EXPLANATION: This will install the latest patch release for the specified minor 91 | # version series (e.g., 5.1.x, 5.3.x, etc.) by using textual>={ver},<{next_minor}.0. 92 | # To test a new minor version, just add it to TEXTUAL_VERSIONS. 93 | # The last `uv pip show textual` is just for logging purposes. 94 | 95 | # These are all assuming you have corresponding 96 | # sections in your pyproject.toml for configuring each tool: 97 | # session.run("ruff", "check", "src") 98 | # session.run("mypy", "src") 99 | # session.run("basedpyright", "src") 100 | report_html = f"{session.name}-report.html" 101 | html_path = f"docs/reports/{report_html}" 102 | report_json = f"{session.name}-summary.json" 103 | json_path = f"docs/reports/{report_json}" 104 | 105 | try: 106 | session.run( 107 | "pytest", 108 | "tests", 109 | "-v", 110 | f"--html={html_path}", 111 | "--self-contained-html", 112 | "--css=docs/reports/dark_theme.css", 113 | "--json-report", 114 | "--json-report-summary", 115 | f"--json-report-file={json_path}", 116 | "--json-report-indent=4", 117 | ) 118 | except Exception: 119 | with nox_report_path.open("a") as report_file_handle: 120 | report_file_handle.write(f"## {session.name} - {ver} | Error Found\n\n") 121 | else: 122 | with nox_report_path.open("a") as report_file_handle: 123 | report_file_handle.write(f"## {session.name} - {ver} | All Success\n\n") 124 | 125 | # This creates a list of all generated report files 126 | # to access them from the reports website 127 | file_index[session.name] = { 128 | "json": report_json, 129 | "html": report_html, 130 | "textual": ver, 131 | } 132 | with open(file_index_path, "w") as f: 133 | json.dump(file_index, f, indent=4) 134 | 135 | # This code here will make Nox delete each session after it finishes. 136 | # This might be preferable to allowing it all to accumulate and then deleting 137 | # the folder afterwards (for example if testing would use dozens of GB of data and 138 | # you don't have the disk space to store it all temporarily). 139 | session_path = pathlib.Path(session.virtualenv.location) 140 | if session_path.exists() and session_path.is_dir() and DELETE_VENV_ON_EXIT: 141 | shutil.rmtree(session_path) 142 | -------------------------------------------------------------------------------- /src/textual_cookbook/recipes/textual_api_usage/screens_dom.py: -------------------------------------------------------------------------------- 1 | """This file is a fairly complex example that shows off how Textual Screens 2 | are handled by the App class and the DOM. It has 3 screens, which 3 | all inherit from MyScreenType just to reduce code duplication (They are 4 | all identical except for the text they display). 5 | The main screen has a RichLog widget that displays debug information 6 | when the user presses the "Print Debug" button (or the 'p' key). 7 | The screens are transparent Modal screens and they also have a button to 8 | print the debug information to the RichLog. You can see the info 9 | being printed to the RichLog on the main screen below (although the Modal 10 | Screen is the active screen on top). 11 | You will see that for the `self.children` attribute on the App class, 12 | there will only ever be one child, which is the currently active screen. 13 | The docstring for `self.children` says this: 14 | 15 | > A view onto the app's immediate children. 16 | > This attribute exists on all widgets. In the case of the App, it will only ever 17 | > contain a single child, which will be the currently active screen. 18 | 19 | You can see that when using the `self.query_children()` method, the currently 20 | active screen will NOT be included in the results, even though it is technically 21 | the only child of the App class (as shown by `self.children`). 22 | Textual (I believe) automatically makes queries on the main App class 23 | start the query on the currently active screen, as opposed to including it 24 | in the query results. 25 | 26 | Recipe by Edward Jazzhands""" 27 | 28 | 29 | import sys 30 | from textual import on 31 | from textual.app import App, ComposeResult 32 | from textual.screen import ModalScreen 33 | from textual.binding import Binding 34 | from textual.containers import Container, Horizontal 35 | from textual.widgets import Static, Button, Footer, RichLog 36 | 37 | 38 | class MyScreenType(ModalScreen[None]): 39 | 40 | def compose(self) -> ComposeResult: 41 | with Container(classes="screen_container"): 42 | yield Button("Print Debug", id="print_debug_button") 43 | yield Button("Close", id="close_button") 44 | yield Footer() 45 | 46 | @on(Button.Pressed, "#close_button") 47 | def close_button(self) -> None: 48 | self.dismiss() 49 | 50 | @on(Button.Pressed, "#print_debug_button") 51 | async def print_debug(self) -> None: 52 | await self.app.run_action("print_debug") 53 | 54 | 55 | class ScreenOne(MyScreenType): 56 | 57 | def on_mount(self) -> None: 58 | self.query_one(Container).mount( 59 | Static("This is Screen One", classes="statics"), before=0 60 | ) 61 | 62 | class ScreenTwo(MyScreenType): 63 | 64 | def on_mount(self) -> None: 65 | self.query_one(Container).mount( 66 | Static("This is Screen Two", classes="statics"), before=0 67 | ) 68 | 69 | 70 | class ScreenThree(MyScreenType): 71 | 72 | def on_mount(self) -> None: 73 | self.query_one(Container).mount( 74 | Static("This is Screen Three", classes="statics"), before=0 75 | ) 76 | 77 | class TextualApp(App[None]): 78 | 79 | CSS = """ 80 | ModalScreen { 81 | align: center middle; 82 | background: $background 30%; /* extra transparent for demo */ 83 | } 84 | .statics { padding: 1; } 85 | .left_container { align: center middle; width: 30; } 86 | .screen_container { border: solid $primary; width: auto; height: auto;} 87 | """ 88 | 89 | BINDINGS = [ 90 | Binding("up", "select('up')", description="Selection up", priority=True), 91 | Binding("down", "select('down')", description="Selection down", priority=True), 92 | Binding("p", "print_debug", description="Print debug information"), 93 | ] 94 | 95 | # Screens here count as installed screens 96 | SCREENS = { 97 | "screen_one": ScreenOne, 98 | "screen_two": ScreenTwo, 99 | "screen_three": ScreenThree, 100 | } 101 | 102 | def compose(self) -> ComposeResult: 103 | 104 | with Horizontal(): 105 | with Container(classes="left_container"): 106 | yield Button("Go to Screen One", id="screen_one_button", classes="main_button") 107 | yield Button("Go to Screen Two", id="screen_two_button", classes="main_button") 108 | yield Button("Go to Screen Three", id="screen_three_button", classes="main_button") 109 | yield Button("Print Debug", id="print_debug_button", classes="main_button") 110 | with Container(classes="right_container"): 111 | richlog = RichLog() 112 | richlog.can_focus = False 113 | yield richlog 114 | yield Footer() 115 | 116 | @on(Button.Pressed, ".main_button") 117 | def main_button_pressed(self, event: Button.Pressed) -> None: 118 | button_id = event.button.id 119 | if button_id == "print_debug_button": 120 | self.action_print_debug() 121 | return 122 | if button_id is not None: 123 | screen_name = button_id.replace("_button", "") 124 | self.query_one(RichLog).write(f"Opening screen: {screen_name}") 125 | self.push_screen(screen_name) 126 | 127 | def action_select(self, direction: str) -> None: 128 | """Handle up and down selection.""" 129 | if direction == "up": 130 | self.action_focus_previous() 131 | else: 132 | assert direction == "down" 133 | self.action_focus_next() 134 | 135 | def action_print_debug(self) -> None: 136 | """Print debug information to the RichLog.""" 137 | log = self.query_one(RichLog) 138 | log.write("Debug Information:") 139 | log.write(f"Current Screen: {self.screen}") 140 | log.write(f"Screen Stack: {self.screen_stack}") 141 | log.write(f"Direct children of App: {self.children}") 142 | my_query = self.query_children().results() 143 | for item in my_query: 144 | log.write(f"Query Result: {item} (Type: {type(item)})") 145 | 146 | 147 | if __name__ == "__main__": 148 | app = TextualApp() 149 | app.run() 150 | sys.exit(app.return_code) -------------------------------------------------------------------------------- /docs/reports/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |See how each example runs on different Textual versions
204 | 205 |206 | Note that Python 3.10 is used for all tests. 207 |
208 | 209 | 210 |