diff --git a/api/command/index.html b/api/command/index.html index d70e7cdfeb..22b9a52c59 100644 --- a/api/command/index.html +++ b/api/command/index.html @@ -9163,8 +9163,8 @@
async
abstractmethod
+ async
¶async
abstractmethod
+ async
¶Tip
See the navigation links in the header or side-bar.
Click (top left) on mobile.
"},{"location":"#welcome","title":"Welcome","text":"Welcome to the Textual framework documentation.
Get started or go straight to the Tutorial
"},{"location":"#what-is-textual","title":"What is Textual?","text":"Textual is a Rapid Application Development framework for Python, built by Textualize.io.
Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal or a web browser!
Rapid development
Uses your existing Python skills to build beautiful user interfaces.
Low requirements
Run Textual on a single board computer if you want to.
Cross platform
Textual runs just about everywhere.
Remote
Textual apps can run over SSH.
CLI Integration
Textual apps can be launched and run from the command prompt.
Open Source
Textual is licensed under MIT.
CalculatorApp \u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513 \u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b \u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0C\u00a0\u00a0+/-\u00a0\u00a0%\u00a0\u00a0\u00f7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a07\u00a0\u00a08\u00a0\u00a09\u00a0\u00a0\u00d7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a04\u00a0\u00a05\u00a0\u00a06\u00a0\u00a0-\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a01\u00a0\u00a02\u00a0\u00a03\u00a0\u00a0+\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a00\u00a0\u00a0.\u00a0\u00a0=\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
PrideApp
StopwatchApp \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
CombiningLayoutsExample \u2b58CombiningLayoutsExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502HorizontallyPositionedChildrenHere\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a00\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a01\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a02\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2586\u2586\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502Vertical\u00a0layout,\u00a0child\u00a03\u2502\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502Thispanelis\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a05\u2502\u2502usinggrid\u00a0layout!\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a06\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"FAQ/","title":"FAQ","text":""},{"location":"FAQ/#frequently-asked-questions","title":"Frequently Asked Questions","text":"Welcome to the Textual FAQ. Here we try and answer any question that comes up frequently. If you can't find what you are looking for here, see our other help channels.
"},{"location":"FAQ/#does-textual-support-images","title":"Does Textual support images?","text":"Textual doesn't have built-in support for images yet, but it is on the Roadmap.
See also the rich-pixels project for a Rich renderable for images that works with Textual.
"},{"location":"FAQ/#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp","title":"How can I fix ImportError cannot import name ComposeResult from textual.app ?","text":"You likely have an older version of Textual. You can install the latest version by adding the -U
switch which will force pip to upgrade.
The following should do it:
pip install textual-dev -U\n
"},{"location":"FAQ/#how-can-i-select-and-copy-text-in-a-textual-app","title":"How can I select and copy text in a Textual app?","text":"Running a Textual app puts your terminal in to application mode which disables clicking and dragging to select text. Most terminal emulators offer a modifier key which you can hold while you click and drag to restore the behavior you may expect from the command line. The exact modifier key depends on the terminal and platform you are running on.
Refer to the documentation for your terminal emulator, if it is not listed above.
"},{"location":"FAQ/#how-can-i-set-a-translucent-app-background","title":"How can I set a translucent app background?","text":"Some terminal emulators have a translucent background feature which allows the desktop underneath to be partially visible.
This feature is unlikely to work with Textual, as the translucency effect requires the use of ANSI background colors, which Textual doesn't use. Textual uses 16.7 million colors where available which enables consistent colors across all platforms and additional effects which aren't possible with ANSI colors.
For more information on ANSI colors in Textual, see Why no Ansi Themes?.
"},{"location":"FAQ/#how-do-i-center-a-widget-in-a-screen","title":"How do I center a widget in a screen?","text":"Tip
See How To Center Things in the Textual documentation for a more comprensive answer to this question.
To center a widget within a container use align
. But remember that align
works on the children of a container, it isn't something you use on the child you want centered.
For example, here's an app that shows a Button
in the middle of a Screen
:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass ButtonApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"PUSH ME!\")\nif __name__ == \"__main__\":\nButtonApp().run()\n
If you use the above on multiple widgets, you'll find they appear to \"left-align\" in the center of the screen, like this:
+-----+\n| |\n+-----+\n\n+---------+\n| |\n+---------+\n\n+---------------+\n| |\n+---------------+\n
If you want them more like this:
+-----+\n | |\n +-----+\n\n +---------+\n | |\n +---------+\n\n+---------------+\n| |\n+---------------+\n
the best approach is to wrap each widget in a Center
container that individually centers it. For example:
from textual.app import App, ComposeResult\nfrom textual.containers import Center\nfrom textual.widgets import Button\nclass ButtonApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Center(Button(\"PUSH ME!\"))\nyield Center(Button(\"AND ME!\"))\nyield Center(Button(\"ALSO PLEASE PUSH ME!\"))\nyield Center(Button(\"HEY ME ALSO!!\"))\nif __name__ == \"__main__\":\nButtonApp().run()\n
"},{"location":"FAQ/#how-do-i-fix-workerdeclarationerror","title":"How do I fix WorkerDeclarationError?","text":"Textual version 0.31.0 requires that you set thread=True
on the @work
decorator if you want to run a threaded worker.
If you want a threaded worker, you would declare it in the following way:
@work(thread=True)\ndef run_in_background():\n...\n
If you don't want a threaded worker, you should make your work function async
:
@work()\nasync def run_in_background():\n...\n
This change was made because it was too easy to accidentally create a threaded worker, which may produce unexpected results.
"},{"location":"FAQ/#how-do-i-pass-arguments-to-an-app","title":"How do I pass arguments to an app?","text":"When creating your App
class, override __init__
as you would when inheriting normally. For example:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass Greetings(App[None]):\ndef __init__(self, greeting: str=\"Hello\", to_greet: str=\"World\") -> None:\nself.greeting = greeting\nself.to_greet = to_greet\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Static(f\"{self.greeting}, {self.to_greet}\")\n
Then the app can be run, passing in various arguments; for example:
# Running with default arguments.\nGreetings().run()\n# Running with a keyword argument.\nGreetings(to_greet=\"davep\").run()\n# Running with both positional arguments.\nGreetings(\"Well hello\", \"there\").run()\n
"},{"location":"FAQ/#no-widget-called-textlog","title":"No widget called TextLog","text":"The TextLog
widget was renamed to RichLog
in Textual 0.32.0. You will need to replace all references to TextLog
in your code, with RichLog
. Most IDEs will have a search and replace function which will help you do this.
Here's how you should import RichLog:
from textual.widgets import RichLog\n
"},{"location":"FAQ/#why-do-some-key-combinations-never-make-it-to-my-app","title":"Why do some key combinations never make it to my app?","text":"Textual can only ever support key combinations that are passed on by your terminal application. Which keys get passed on can differ from terminal to terminal, and from operating system to operating system.
Because of this it's best to stick to key combinations that are known to be universally-supported; these include the likes of:
When creating bindings for your application we recommend picking keys and key combinations from the above.
Keys that aren't normally passed through by terminals include Cmd and Option on macOS, and the Windows key on Windows.
If you need to test what key combinations work in different environments you can try them out with textual keys
.
You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters. For instance, you may find it displays misaligned blocks and lines like this:
You can (mostly) fix this by opening settings -> profiles > Text tab, and changing the font settings. We have found that Menlo Regular font, with a character spacing of 1 and line spacing of 0.805 produces reasonable results. If you want to use another font, you may have to tweak the line spacing until you get good results.
With these changes, Textual apps render more as intended:
Even with this fix, Terminal.app has a few limitations. It is limited to 256 colors, and can be a little slow compared to more modern alternatives. Fortunately there are a number of free terminal emulators for macOS which produces high quality results.
We recommend any of the following terminals:
Textual will not generate escape sequences for the 16 themeable ANSI colors.
This is an intentional design decision we took for for the following reasons:
Textual has a design system which guarantees apps will be readable on all platforms and terminals, and produces better results than ANSI colors.
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
"},{"location":"FAQ/#why-doesnt-the-datatable-scroll-programmatically","title":"Why doesn't theDataTable
scroll programmatically?","text":"If scrolling in your DataTable
is apparently broken, it may be because your DataTable
is using the default value of height: auto
. This means that the table will be sized to fit its rows without scrolling, which may cause the container (typically the screen) to scroll. If you would like the table itself to scroll, set the height to something other than auto
, like 100%
.
NOTE: As of Textual v0.31.0 the max-height
of a DataTable
is set to 100%
, this will mean that the above is no longer the default experience.
Generated by FAQtory
"},{"location":"getting_started/","title":"Getting started","text":"All you need to get started building Textual apps.
"},{"location":"getting_started/#requirements","title":"Requirements","text":"Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
Your platform
"},{"location":"getting_started/#linux-all-distros","title":"Linux (all distros)","text":"All Linux distros come with a terminal emulator that can run Textual apps.
"},{"location":"getting_started/#macos","title":"macOS","text":"The default terminal app is limited to 256 colors. We recommend installing a newer terminal such as iterm2, Kitty, or WezTerm.
"},{"location":"getting_started/#windows","title":"Windows","text":"The new Windows Terminal runs Textual apps beautifully.
"},{"location":"getting_started/#installation","title":"Installation","text":"Here's how to install Textual.
"},{"location":"getting_started/#from-pypi","title":"From PyPI","text":"You can install Textual via PyPI, with the following command:
pip install textual\n
If you plan on developing Textual apps, you should also install textual developer tools:
pip install textual-dev\n
"},{"location":"getting_started/#from-conda-forge","title":"From conda-forge","text":"Textual is also available on conda-forge. The preferred package manager for conda-forge is currently micromamba:
micromamba install -c conda-forge textual\n
And for the textual developer tools:
micromamba install -c conda-forge textual-dev\n
"},{"location":"getting_started/#textual-cli","title":"Textual CLI","text":"If you installed the developer tools you should have access to the textual
command. There are a number of sub-commands available which will aid you in building Textual apps. Run the following for a list of the available commands:
textual --help\n
See devtools for more about the textual
command.
Once you have Textual installed, run the following to get an impression of what it can do:
python -m textual\n
If Textual is installed you should see the following:
Textual\u00a0Demo \u2b58Textual\u00a0Demo TOP\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Widgets\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Textual\u00a0widgets\u00a0are\u00a0powerful\u00a0interactive\u00a0components.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Widgets Build\u00a0your\u00a0own\u00a0or\u00a0use\u00a0the\u00a0builtin\u00a0widgets.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0Input\u00a0Text\u00a0/\u00a0Password\u00a0input.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Rich\u00a0content\u00a0\u2022\u00a0Button\u00a0Clickable\u00a0button\u00a0with\u00a0a\u00a0number\u00a0of\u00a0styles.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0Switch\u00a0A\u00a0switch\u00a0to\u00a0toggle\u00a0between\u00a0states.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2583\u2583 \u00a0\u2022\u00a0DataTable\u00a0A\u00a0spreadsheet-like\u00a0widget\u00a0for\u00a0navigating\u00a0data.\u00a0Cells\u00a0may\u00a0contain\u00a0text\u00a0or\u00a0Rich\u00a0 renderables.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 CSS\u00a0\u2022\u00a0Tree\u00a0An\u00a0generic\u00a0tree\u00a0with\u00a0expandable\u00a0nodes.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0DirectoryTree\u00a0A\u00a0tree\u00a0of\u00a0file\u00a0and\u00a0folders.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0...\u00a0many\u00a0more\u00a0planned\u00a0... \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a \u258eUsername\u258awill\u258e\u258a \u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a \u258e\u258a\u2585\u2585 \u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a \u258ePassword\u258aPassword\u258e\u258a \u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a \u258e\u258a \u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258a \u258e\u00a0Login\u00a0\u258a \u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Foo\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Bar\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Baz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Foo\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(0,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(1,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(2,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(3,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(4,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(5,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(6,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(7,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(8,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(9,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2582\u2582 \u00a0Cell\u00a0(10,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(11,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(12,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(13,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u258f \u00a0CTRL+B\u00a0\u00a0Sidebar\u00a0\u00a0CTRL+T\u00a0\u00a0Toggle\u00a0Dark\u00a0mode\u00a0\u00a0CTRL+S\u00a0\u00a0Screenshot\u00a0\u00a0F1\u00a0\u00a0Notes\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0
"},{"location":"getting_started/#examples","title":"Examples","text":"The Textual repository comes with a number of example apps. To try out the examples, first clone the Textual repository:
HTTPSSSHGitHub CLIgit clone https://github.com/Textualize/textual.git\n
git clone git@github.com:Textualize/textual.git\n
gh repo clone Textualize/textual\n
With the repository cloned, navigate to the /examples/
directory where you will find a number of Python files you can run from the command line:
cd textual/examples/\npython code_browser.py ../\n
"},{"location":"getting_started/#need-help","title":"Need help?","text":"See the help page for how to get help with Textual, or to report bugs.
"},{"location":"help/","title":"Help","text":"If you need help with any aspect of Textual, let us know! We would be happy to hear from you.
"},{"location":"help/#bugs-and-feature-requests","title":"Bugs and feature requests","text":"Report bugs via GitHub on the Textual issues page. You can also post feature requests via GitHub issues, but see the roadmap first.
"},{"location":"help/#help-with-using-textual","title":"Help with using Textual","text":"You can seek help with using Textual in the discussion area on GitHub.
"},{"location":"help/#discord-server","title":"Discord Server","text":"For more realtime feedback or chat, join our discord server to connect with the Textual community.
"},{"location":"roadmap/","title":"Roadmap","text":"We (textualize.io) are actively building and maintaining Textual.
We have many new features in the pipeline. This page will keep track of that work.
"},{"location":"roadmap/#features","title":"Features","text":"High-level features we plan on implementing.
Widgets are key to making user-friendly interfaces. The builtin widgets should cover many common (and some uncommon) use-cases. The following is a list of the widgets we have built or are planning to build.
attrs
objectsPyDantic
objectsWelcome to the Textual Tutorial!
By the end of this page you should have a solid understanding of app development with Textual.
Quote
If you want people to build things, make it fun.
\u2014 Will McGugan (creator of Rich and Textual)
"},{"location":"tutorial/#video-series","title":"Video series","text":"This tutorial has an accompanying video series which covers the same content.
"},{"location":"tutorial/#stopwatch-application","title":"Stopwatch Application","text":"We're going to build a stopwatch application. This application should show a list of stopwatches with buttons to start, stop, and reset the stopwatches. We also want the user to be able to add and remove stopwatches as required.
This will be a simple yet fully featured app \u2014 you could distribute this app if you wanted to!
Here's what the finished app will look like:
stopwatch.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:01.99 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:09.51 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:06.04 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
"},{"location":"tutorial/#try-it-out","title":"Try it out!","text":"The following is not a screenshot, but a fully interactive Textual app running in your browser.
Try in Textual-web
See textual-web if you are interested in publishing your apps on the web.
"},{"location":"tutorial/#get-the-code","title":"Get the code","text":"If you want to try the finished Stopwatch app and follow along with the code, first make sure you have Textual installed then check out the Textual repository:
HTTPSSSHGitHub CLIgit clone https://github.com/Textualize/textual.git\n
git clone git@github.com:Textualize/textual.git\n
gh repo clone Textualize/textual\n
With the repository cloned, navigate to docs/examples/tutorial
and run stopwatch.py
.
cd textual/docs/examples/tutorial\npython stopwatch.py\n
"},{"location":"tutorial/#type-hints-in-brief","title":"Type hints (in brief)","text":"Tip
Type hints are entirely optional in Textual. We've included them in the example code but it's up to you whether you add them to your own projects.
We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, it's a way to express the types of your data, parameters, and return values. Type hinting allows tools like mypy to catch bugs before your code runs.
The following function contains type hints:
def repeat(text: str, count: int) -> str:\n\"\"\"Repeat a string a given number of times.\"\"\"\nreturn text * count\n
Parameter types follow a colon. So text: str
indicates that text
requires a string and count: int
means that count
requires an integer.
Return types follow ->
. So -> str:
indicates this method returns a string.
The first step in building a Textual app is to import and extend the App
class. Here's a basic app class we will use as a starting point for the stopwatch app.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
If you run this code, you should see something like the following:
stopwatch01.py \u2b58StopwatchApp \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
Hit the D key to toggle between light and dark mode.
stopwatch01.py \u2b58StopwatchApp \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
Hit Ctrl+C to exit the app and return to the command prompt.
"},{"location":"tutorial/#a-closer-look-at-the-app-class","title":"A closer look at the App class","text":"Let's examine stopwatch01.py
in more detail.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The first line imports the Textual App
class, which we will use as the base class for our App. The second line imports two builtin widgets: Footer
which shows a bar at the bottom of the screen with bound keys, and Header
which shows a title at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial.
The following lines define the app itself:
stopwatch01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The App class is where most of the logic of Textual apps is written. It is responsible for loading configuration, setting up widgets, handling keys, and more.
Here's what the above app defines:
BINDINGS
is a list of tuples that maps (or binds) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the D key on to the \"toggle_dark\" action. See key bindings in the guide for details.
compose()
is where we construct a user interface with widgets. The compose()
method may return a list of widgets, but it is generally easier to yield them (making this method a generator). In the example code we yield an instance of each of the widget classes we imported, i.e. Header()
and Footer()
.
action_toggle_dark()
defines an action method. Actions are methods beginning with action_
followed by the name of the action. The BINDINGS
list above tells Textual to run this action when the user hits the D key. See actions in the guide for details.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The final three lines create an instance of the app and calls the run() method which puts your terminal in to application mode and runs the app until you exit with Ctrl+C. This happens within a __name__ == \"__main__\"
block so we could run the app with python stopwatch01.py
or import it as part of a larger project.
Textual comes with a number of builtin widgets, like Header and Footer, which are versatile and re-usable. We will need to build some custom widgets for the stopwatch. Before we dive in to that, let's first sketch a design for the app \u2014 so we know what we're aiming for.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVcXGlP40pcdTAwMTb93r9cdTAwMDIxX2akxq/2paXRqFx1MDAwM4R9XHUwMDBiW8PMXHUwMDEzMrFDXGbxguNA4Kn/+5SddOwktuM4S/tFLVx1MDAxYVxcjuu66tx77lJVf33Z2NhcZj48c/PbxqbZb+pcdTAwMWTL8PX3za/h9TfT71quo5pQ9HfX7fnN6M52XHUwMDEweN1vf/xh6/6LXHUwMDE5eFx1MDAxZL1pam9Wt6d3ukHPsFxcrenaf1iBaXf/XHUwMDEz/jzVbfPfnmtcdTAwMWKBr8WdbJmGXHUwMDE1uP6gL7Nj2qZcdTAwMTN01dP/q/7e2Pgr+pmQzjebge48dczoXHUwMDBiUVMsIFx1MDAwNmzy6qnrRMJcbi5cdTAwMDWFXGaR0VxyVndHdVx1MDAxN5iGam0pkc24Jby0+aizxo/G/VPr6uX83Hu/ueqSPo97bVmdzmXw0Ymk6rrqZeK2buC7L+atZVx1MDAwNG3VXG4nrmd9y3d7T23H7HbHvuN6etNcbj7Ca1x1MDAwMIyuXHUwMDBlhuDbRnylXHUwMDFmTlx1MDAxMCdcdTAwMWFkXHUwMDAwUoboqCH8KpJcXCOEYpi4Plx1MDAxMGbb7ajBV8L8XHUwMDAzRJ9YnEe9+fKkZHKM+Fx1MDAxZVx1MDAwMpqAJlx1MDAwNuF9+IqqQ1xyy4lcdTAwMGXapvXUXHUwMDBlXHUwMDA2gmtcdTAwMWNcbp7o24yGXHUwMDFkXCLEOUFcYqNRS9ijd2BEXGL4c3Lg2rrvXHJcdTAwMDdos1x1MDAxYv6RkDZcdTAwMTR0d1x1MDAxMj5JXGIlpvag2/C2m5+HRzdcdTAwMTf1t7uL8114vnc4etZcdTAwMTjedN933zdHLT+Hv8Wi9TxDXHUwMDFmgFxiMlx1MDAwNiRcdTAwMTaSXHUwMDEwgmNcdTAwMWN2LOdFNTq9Tie+5jZfYtxFV39+LYF3gmBcdTAwMTbeJVx1MDAxNYJQSorj3XrEnty37P7JsSlcdTAwMWT97NV7/VGvON5cdTAwMDXRXHUwMDA0wXxcdTAwMWPsXHUwMDE4YlxyYyqT18uAvaVTRNE02JWGTWOcsSlwc8FcdTAwMDRUUCDrXHUwMDAy9y/MXHUwMDA0Zj9cdTAwMThH82CG67KPb+3+/bFcclx1MDAwZnZunFx1MDAwN3v/sL01XHUwMDE3tlx1MDAxOVx1MDAwNVxigWVhe0zOYmZcdTAwMWMqXHUwMDExXHUwMDE4R1x1MDAxY4nCuE5/63RcXLf1Zrvnm1VAtkxDNlZ4X1x1MDAxY9mBrztdT/dcdTAwMTWaUtBNU9CN8LTpJlAqky9WgO5lXHUwMDAyMJ5n11x0Lq3PcKhcdTAwMTNcdTAwMGZcYq/WddvqfIxNVYRMJell4CZcdTAwMDXVu6bqMcIhXHUwMDFmu/d7x3pcbpG72VTvYPpjoFx1MDAwZSzl6oxusC3DSFx1MDAxYfOmXHUwMDEyQFfP9Fx1MDAwZopcdTAwMThh17eeLEfvXFwl5SvPXHUwMDFmjMlM/lAug0SCo8J65lx1MDAxY2/3t06R6Fx1MDAxY8BG42brXHUwMDAzXFw1/I9q81x1MDAwN2NEXHUwMDAzWIJJd4lgpPRcZoGF/aWmaVx1MDAxMEMvSiFiSskgR8q1XHUwMDEyZG1cdTAwMTQymMpeXHUwMDFmn+zsideL9zuj9tK6sE9/bDfS/aNIU2Jcbvma/thZzJTeYXFmXHUwMDEyWPmXWMaTtVwiZqJcdOM4yUycXHUwMDAzyVxiT8zsLI3JXHUwMDFm5ooyk7JcdTAwMTnpOkOp8rpcdTAwMTbXmaWQXHUwMDEzXHUwMDA3XGIoXHUwMDFkS7jhqyenXHUwMDEyXHUwMDE4XFyMnFx1MDAxYWbXXGbWyk4zTPwkO1xyXHUwMDA0LE9PMGFcdTAwMTEntI1wXGKQXHUwMDE0rHh4c/zs9smja183ru5Ojp73jWtcdTAwMTI0q01PhCtVo2Lc34v8QMQ1oMhhPKYuXHUwMDEz4kSfND3DXHUwMDFhnExcdTAwMTiMXHUwMDE0XHUwMDBlU02OO6dDveMq5FRcdTAwMTaArkDtyvHKx0n/ljx8Xlx1MDAwN96xW/NZv+18st1cbkY8kmRcdTAwMDFcdTAwMWQyjpVZ4ax4wJP+0lx1MDAxNadcdTAwMTVcdTAwMTVIZGBd+WJ0knCWzitcdTAwMDRPwzyFV6hcdTAwMDQq5GGr8MeqXHUwMDEz9Fx1MDAwMPAt/Mc1XHUwMDA018ouMyz0JLskxSzPMVhmptCgQFxicVxuOSusende//LkZGf39uiqVoeg3Ttv+8e/k2RwkZQxXHUwMDA1krOpnDGBQmOccrGqIIgypnHO8Vx1MDAxOJOMJY1cdTAwMDVcdTAwMWRPZY/CXCJcdTAwMDRcdTAwMTX/XHUwMDEwsVYtXHUwMDE0XHUwMDEwYonm0MLyoKRcdTAwMTRngpJcdTAwMTPJkVx1MDAwNKQ4XHUwMDFmyGOhO/unXHKf1M97bbttXl1cdTAwMWZUvZAhqFx1MDAwNlx1MDAwNaWEXHQ+XHUwMDE5mlx1MDAxM00uI7ubVcoomt1ccrP8gvLEq6wlNP++3Xpr2O32XHUwMDE2Mz6fejY4erg74uOuz3JD8/RcdTAwMGWr50IpK5KlM0x5XHUwMDBmXHUwMDAwwDlcXKj8Ua6oXHUwMDBipaxCltJQppElKM1yYnMsXHUwMDAwXHUwMDEwfFx1MDAxNTW/6vhQl4Hurzc2n2HlpzPHoYBcdTAwMGL4TSiborCQiOE5XHUwMDEyYW7t5NW8QPzItnuNeqv/dnyB33+vuvFcIsE5XHUwMDAxqZkwXCKVT1xuVqxspVx1MDAwMnRKwuhFrrve/iDfL8+O/Ju9k7bVMPZcdTAwMGZcdTAwMDS+P2uvkrTSO6xcdTAwMWVpIZpZgFGBP8CcUFA8xZU/zFx1MDAxNVUjXHUwMDE192eoXHUwMDExpVx1MDAxYV9xOrlY2C9cdFx1MDAxMpJSuM5s8lx1MDAxMIFwXHUwMDBlXHUwMDA0LsZYw3BcdTAwMWFo41x1MDAwM7py3pph+zOi/kjM8uyFWKazKFx1MDAxOEZcdTAwMDBcdTAwMTFQ3Fl8RkivfaKzXHUwMDFh3vGI34JXd82b/d9cdTAwMTlfkdlBP9Mog1x1MDAxNOGpJVx1MDAwNsroaVx1MDAwYkf8Lf1cdTAwMTFcdTAwMDCaXHUwMDE28XNccipdXHUwMDFh0/WR4il9R1igsPeoXHUwMDBmOKmHXGJcdTAwMTBlXHUwMDEwMV1fOXRcdTAwMTbP6PpcdTAwMTXbu7o/OFx1MDAwMSe39nZw+eRcdTAwMWScXs63WkxIXHRji7NcIp6BIDvBLJByJOZYXHUwMDE3mf7O6XBv+m63u9XWg2b794NewEzQh1x1MDAwZVx1MDAxM4aMrDTJTCmfXHUwMDA2fUqAxCRcdTAwMDJUKcl6k8xzXHUwMDAzcTG62Td1I0lcdTAwMWJrYJpcdTAwMTl2epJphlx1MDAxMpYnXHUwMDE5XG4zSVx1MDAwNlwiyKOca2Gtc+y3k8tcdTAwMTf48Irk+71OwFx1MDAxYjx9LpVZRkvSNzpb34CGXGKcjIRcIq9cdTAwMTAqfWNkXCJEKUE0XGKJR5OlXHUwMDExXHKZVjWcsohcciNcdTAwMTLSUWVcYuU2QPrdzsOWj/pta5/dP+3hXHUwMDA3ez5CkYrd4/dZVeDCM0vzkGEogUCgePyf/ta/nVJcbkCcZkJcXPlRy4D4XGZGSYF5SvxcdTAwMTKuhyFcdTAwMDCtmVDmXHUwMDA14mKEUnfdYM2EMsMmT1x1MDAxMspQwkVcYiUn56ZAiOZSujP60NxvdY1cdTAwMWYt1Kyf39qn/aB5U/FaJdOYXHUwMDEwJKVYSTHXplLf1ShWXCLMJcOYraBamc4xw1x1MDAxMsZcdTAwMWU7O3uxrf7lNjk6291BXHUwMDFl+Vx1MDAxMOk5t1x1MDAxMntcXFx1MDAxMGF8PbVQxrM3XHUwMDAzIE64ilx1MDAwN+fINNvPp/a2d3x0dF4zOvt35lPt8sWpei2Ua5hcdTAwMTE0UYn/XHUwMDE42H9ccirVXzhgX7RcdTAwMTgqiFRTQcSai6E7XHI9eDyy2s9cdTAwMTdcdTAwMTf711x1MDAwZl3y2q5jP1x1MDAxZOPF8soreuwsry+9w3lcdTAwMTRS4DCHumqvj+akq7lcdTAwMDBcdTAwMTBcdFx1MDAwNouHNPnDXFzZXCIrytRGTjW2XHUwMDA0bVxcSpVcdTAwMTVcYkokoGtdXHUwMDAxXVx1MDAwMoaL+Xzrr7LO4I9lV1lcdMkmP4wogpTC4jt0asfXzkO7Z+yC163vV/XWNdjln5WvXHUwMDBmQY1yXHRTNjVTJrRVr1x1MDAwYi1XZoWIXHUwMDAxiClfRVxuL4+4LpqH6PFwz3Vfnlxm9/Ni79Opi93F+XDpj53Fh+lcdTAwMWRWj1x1MDAwZrHIXFw8ijBcdTAwMDVcXMGmeFx1MDAxOSl/lKuqnSxTOznRhFgxXHUwMDE5XHUwMDE2q98qXHUwMDE2RGqq1rxVdc1cXPi76rczSKV0/TYz84hpls5cdTAwMTGBJZtrb3jt7HnLe39/dPmLrd95dYs9YiND59aSVZ/tgEpGNSogSFnmR2G4LUjmXHUwMDFm84FcdNZJ2W2rXHUwMDAwpejbXHUwMDE0/SGiXGaCSM5D1TNcdTAwMWW59Cdr37dcdTAwMGbPXHUwMDFmxPfDmjh8faa017r/sbRMXG6lXHUwMDAwr6/sXHUwMDE2blx1MDAxOH9cdTAwMWbPnSdthFx1MDAxOPvCyFx1MDAwNHTMVpBjIcY2yY+bh7FcdTAwMTdJ270+XHUwMDEwJtdcdTAwMTJcZlx1MDAwNjWNfrOPP1FcdTAwMDAkmPB5zvvJn+Zq2lx1MDAwMlxuNFxmXHUwMDA1Y1x1MDAwMmMsMJw0XHUwMDA3QoOYhVx1MDAwYiZouFtyNTaBizBcdTAwMDWFZbhcdTAwMWSR8OSBXHUwMDAx8cJcdTAwMGWoQYSEomGGpVx1MDAwMvykvVAuNCfhSq/57UUkZNn6hGCiVH2iXHUwMDFiRnU1yzEs50k1xibj1/lVXHUwMDA3XHUwMDA1yCXS1WYvlHJcdTAwMGJoTKrRXHUwMDEzMFxc8IeT5yGFY6F7USSiodCLYThcdTAwMWNGQeTwhpHp2jRcdTAwMWQjlmn8NfRusO3atlx1MDAxNahcdTAwMDE4dy0nmLwjeqPvoZ61TX1Kb9WTk22TXG7phU9cdTAwMWO3zvFvXHUwMDFiMWSjP0a///k19e6tTDxFrZNQilx1MDAxZvcl+f/cplx1MDAwNKHszJZEUir9wsUzW/lMVFFTwjV1V9pcdTAwMGVMisP1zFxmQ45BaFNXYkaksmScyF9cdTAwMWaeslSGKFx0XHUwMDE5XHUwMDAyXHUwMDEw8TCyXHUwMDAyaOq4XGbVICVgskTOa1x1MDAxMTtCldqW8vOXbUeApiw9UapcdTAwMDEg41xckZ9I3DQwI0IjXHUwMDAyRJ5jvv3IkiW/aDgmXHUwMDBilzzccqIgxaREdFpcdTAwMTaENUSHO1x1MDAwZqek+VvZrEzwhp8p2M5pszKTXHUwMDBmXHS+nNzwhChnhOHiqcGjz7u911Zw8456jkktv9WA9duKWywsNJZusZQnpFx0wfLPR1gsXHUwMDEwimWO3ZxYvtFSPlx1MDAwZVx1MDAxMFwiZM1lsVx1MDAwNVx1MDAwMpbcQGhVXHUwMDAx1mqPY1x1MDAxNJSBeUrVS1xusP7n/DOyUKbxr9RYK5G6mi9cdTAwMWazcLSVlKyct4SzXHUwMDE3okjCwpNw5ti1kj/7XHUwMDE1NT1UxV2/llx1MDAxMidXqlx1MDAwZk6skJpiWIakcth5cqf2Mk1cdTAwMTBcdTAwMTOaXHUwMDE4nLhcdTAwMWFcdTAwMTGPiD2QODOjUVxuOFx1MDAwZuXkTFwiknagXHUwMDA1XHUwMDA2UGBaJlVT5dArn87G3Fx1MDAxNFxiiVQ2XHUwMDFhcCYoJ3FcYj1yU5SXMtixkO8x/X0jrkwkhZ9pXGbN6b7k1jd5zuJcdTAwMWWoaF1IJIpnc9+E3fQ+3G6999yRXHUwMDA3dv2cg+1SlmSdp6lRXHJcdTAwMDCkQszpbC5ccjclrGg5W9FcdTAwMDNcYjFV1mE1S0fzPILT21OvfaVcdTAwMDb70rm+wVcnwdbxoZ3uXHUwMDExzFPIXFz6Y2dcdTAwMTUy0zss7r4oJlxy/cd5dlx1MDAwMeYqY1YskdyJMJX+4FhKguY4ai1/mCu6sEe56pmayFXYXFyBo1x1MDAxM1x1MDAxMMRcZofnXHUwMDExrfPgm1x1MDAxMlx1MDAxMFxczINe/7GGM3gj71jDL0Pl3dQ97zJQ4zZyStTUWMbw5eOx2nyzzPda9kl8X4a6XHUwMDFiKolcdTAwMTlOzF8/v/z8P1xiXCJcdTAwMWT6In0= StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started)Reset"},{"location":"tutorial/#custom-widgets","title":"Custom widgets","text":"We need a Stopwatch
widget composed of the following child widgets:
Textual has a builtin Button
widget which takes care of the first three components. All we need to build is the time display widget which will show the elapsed time and the stopwatch widget itself.
Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.
stopwatch02.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
We've imported two new widgets in this code: Button
, which creates a clickable button, and Static
which is a base class for a simple control. We've also imported ScrollableContainer
from textual.containers
which (as the name suggests) is a Widget
which contains other widgets.
We've defined an empty TimeDisplay
widget by extending Static
. We will flesh this out later.
The Stopwatch widget class also extends Static
. This class has a compose()
method which yields child widgets, consisting of three Button
objects and a single TimeDisplay
object. These widgets will form the stopwatch in our sketch.
The Button constructor takes a label to be displayed in the button (\"Start\"
, \"Stop\"
, or \"Reset\"
). Additionally, some of the buttons set the following parameters:
id
is an identifier we can use to tell the buttons apart in code and apply styles. More on that later.variant
is a string which selects a default style. The \"success\" variant makes the button green, and the \"error\" variant makes it red.To add widgets to our application we first need to yield them from the app's compose()
method:
The new line in StopwatchApp.compose()
yields a single ScrollableContainer
object which will create a scrolling list of stopwatches. When classes contain other widgets (like ScrollableContainer
) they will typically accept their child widgets as positional arguments. We want to start the app with three stopwatches, so we construct three Stopwatch
instances and pass them to the container's constructor.
Let's see what happens when we run stopwatch02.py
.
stopwatch02.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 00:00:00.00 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2586\u2586 \u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 00:00:00.00 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container but it doesn't look like the sketch. This is because we have yet to apply any styles to our new widgets.
"},{"location":"tutorial/#writing-textual-css","title":"Writing Textual CSS","text":"Every widget has a styles
object with a number of attributes that impact how the widget will appear. Here's how you might set white text and a blue background for a widget:
self.styles.background = \"blue\"\nself.styles.color = \"white\"\n
While it's possible to set all styles for an app this way, it is rarely necessary. Textual has support for CSS (Cascading Style Sheets), a technology used by web browsers. CSS files are data files loaded by your app which contain information about styles to apply to your widgets.
Info
The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn.
CSS makes it easy to iterate on the design of your app and enables live-editing \u2014 you can edit CSS and see the changes without restarting the app!
Let's add a CSS file to our application.
stopwatch03.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch03.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Adding the CSS_PATH
class variable tells Textual to load the following file when the app starts:
Stopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\nTimeDisplay {\ncontent-align: center middle;\ntext-opacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n
If we run the app now, it will look very different.
stopwatch03.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
This app looks much more like our sketch. Let's look at how Textual uses stopwatch03.tcss
to apply styles.
CSS files contain a number of declaration blocks. Here's the first such block from stopwatch03.tcss
again:
Stopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\n
The first line tells Textual that the styles should apply to the Stopwatch
widget. The lines between the curly brackets contain the styles themselves.
Here's how this CSS code changes how the Stopwatch
widget is displayed.
layout: horizontal
aligns child widgets horizontally from left to right.background: $boost
sets the background color to $boost
. The $
prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as \"blue\"
or rgb(20,46,210)
.height: 5
sets the height of our widget to 5 lines of text.margin: 1
sets a margin of 1 cell around the Stopwatch
widget to create a little space between widgets in the list.min-width: 50
sets the minimum width of our widget to 50 cells.padding: 1
sets a padding of 1 cell around the child widgets.Here's the rest of stopwatch03.tcss
which contains further declaration blocks:
TimeDisplay {\ncontent-align: center middle;\nopacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n
The TimeDisplay
block aligns text to the center (content-align
), fades it slightly (opacity
), and sets its height (height
) to 3 lines.
The Button
block sets the width (width
) of buttons to 16 cells (character widths).
The last 3 blocks have a slightly different format. When the declaration begins with a #
then the styles will be applied to widgets with a matching \"id\" attribute. We've set an ID on the Button
widgets we yielded in compose
. For instance the first button has id=\"start\"
which matches #start
in the CSS.
The buttons have a dock
style which aligns the widget to a given edge. The start and stop buttons are docked to the left edge, while the reset button is docked to the right edge.
You may have noticed that the stop button (#stop
in the CSS) has display: none;
. This tells Textual to not show the button. We do this because we don't want to display the stop button when the timer is not running. Similarly, we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section.
We want our Stopwatch
widget to have two states: a default state with a Start and Reset button; and a started state with a Stop button. When a stopwatch is started it should also have a green background and bold text.
We can accomplish this with a CSS class. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
Here's the new CSS:
stopwatch04.tcssStopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\nTimeDisplay {\ncontent-align: center middle;\ntext-opacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n.started {\ntext-style: bold;\nbackground: $success;\ncolor: $text;\n}\n.started TimeDisplay {\ntext-opacity: 100%;\n}\n.started #start {\ndisplay: none\n}\n.started #stop {\ndisplay: block\n}\n.started #reset {\nvisibility: hidden\n}\n
These new rules are prefixed with .started
. The .
indicates that .started
refers to a CSS class called \"started\". The new styles will be applied only to widgets that have this CSS class.
Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles:
.started #start {\ndisplay: none\n}\n
The .started
selector matches any widget with a \"started\"
CSS class. While #start
matches a child widget with an ID of \"start\"
. So it matches the Start button only for Stopwatches in a started state.
The rule is \"display: none\"
which tells Textual to hide the button.
Modifying a widget's CSS classes is a convenient way to update visuals without introducing a lot of messy display related code.
You can add and remove CSS classes with the add_class() and remove_class() methods. We will use these methods to connect the started state to the Start / Stop buttons.
The following code will start or stop the stopwatches in response to clicking a button.
stopwatch04.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nif event.button.id == \"start\":\nself.add_class(\"started\")\nelif event.button.id == \"stop\":\nself.remove_class(\"started\")\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The on_button_pressed
method is an event handler. Event handlers are methods called by Textual in response to an event such as a key press, mouse click, etc. Event handlers begin with on_
followed by the name of the event they will handle. Hence on_button_pressed
will handle the button pressed event.
If you run stopwatch04.py
now you will be able to toggle between the two states by clicking the first button:
stopwatch04.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
"},{"location":"tutorial/#reactive-attributes","title":"Reactive attributes","text":"A recurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call refresh() to display new data. However, Textual prefers to do this automatically via reactive attributes.
You can declare a reactive attribute with reactive. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
stopwatch05.pyfrom time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.set_interval(1 / 60, self.update_time)\ndef update_time(self) -> None:\n\"\"\"Method to update the time to the current time.\"\"\"\nself.time = monotonic() - self.start_time\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nif event.button.id == \"start\":\nself.add_class(\"started\")\nelif event.button.id == \"stop\":\nself.remove_class(\"started\")\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
We have added two reactive attributes to the TimeDisplay
widget: start_time
will contain the time (in seconds) the stopwatch was started, and time
will contain the time to be displayed on the Stopwatch
.
Both attributes will be available on self
as if you had assigned them in __init__
. If you write to either of these attributes the widget will update automatically.
Info
The monotonic
function in this example is imported from the standard library time
module. It is similar to time.time
but won't go backwards if the system clock is changed.
The first argument to reactive
may be a default value for the attribute or a callable that returns a default value. We set the default for start_time
to the monotonic
function which will be called to initialize the attribute with the current time when the TimeDisplay
is added to the app. The time
attribute has a simple float as the default, so self.time
will be initialized to 0
.
The on_mount
method is an event handler called when the widget is first added to the application (or mounted in Textual terminology). In this method we call set_interval() to create a timer which calls self.update_time
sixty times a second. This update_time
method calculates the time elapsed since the widget started and assigns it to self.time
\u2014 which brings us to one of Reactive's super-powers.
If you implement a method that begins with watch_
followed by the name of a reactive attribute, then the method will be called when the attribute is modified. Such methods are known as watch methods.
Because watch_time
watches the time
attribute, when we update self.time
60 times a second we also implicitly call watch_time
which converts the elapsed time to a string and updates the widget with a call to self.update
. Because this happens automatically, we don't need to pass in an initial argument to TimeDisplay
.
The end result is that the Stopwatch
widgets show the time elapsed since the widget was created:
stopwatch05.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.03\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.03\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.03\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
We've seen how we can update widgets with a timer, but we still need to wire up the buttons so we can operate stopwatches independently.
"},{"location":"tutorial/#wiring-buttons","title":"Wiring buttons","text":"We need to be able to start, stop, and reset each stopwatch independently. We can do this by adding a few more methods to the TimeDisplay
class.
from time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\ndef start(self) -> None:\n\"\"\"Method to start (or resume) time updating.\"\"\"\nself.start_time = monotonic()\nself.update_timer.resume()\ndef stop(self) -> None:\n\"\"\"Method to stop the time display updating.\"\"\"\nself.update_timer.pause()\nself.total += monotonic() - self.start_time\nself.time = self.total\ndef reset(self) -> None:\n\"\"\"Method to reset the time display to zero.\"\"\"\nself.total = 0\nself.time = 0\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Called to add widgets to the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Here's a summary of the changes made to TimeDisplay
.
total
reactive attribute to store the total time elapsed between clicking the start and stop buttons.set_interval
has grown a pause=True
argument which starts the timer in pause mode (when a timer is paused it won't run until resume() is called). This is because we don't want the time to update until the user hits the start button.update_time
method now adds total
to the current time to account for the time between any previous clicks of the start and stop buttons.set_interval
which returns a Timer object. We will use this later to resume the timer when we start the Stopwatch.start()
, stop()
, and reset()
methods.In addition, the on_button_pressed
method on Stopwatch
has grown some code to manage the time display when the user clicks a button. Let's look at that in detail:
def on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\n
This code supplies missing features and makes our app useful. We've made the following changes.
id
attribute of the button that was pressed. We can use this to decide what to do in response.query_one
to get a reference to the TimeDisplay
widget.TimeDisplay
that matches the pressed button.\"started\"
class when the Stopwatch is started (self.add_class(\"started\")
), and remove it (self.remove_class(\"started\")
) when it is stopped. This will update the Stopwatch visuals via CSS.If you run stopwatch06.py
you will be able to use the stopwatches independently.
stopwatch06.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:04.01 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.
"},{"location":"tutorial/#dynamic-widgets","title":"Dynamic widgets","text":"The Stopwatch app creates widgets when it starts via the compose
method. We will also need to create new widgets while the app is running, and remove widgets we no longer need. We can do this by calling mount() to add a widget, and remove() to remove a widget.
Let's use these methods to implement adding and removing stopwatches to our app.
stopwatch.pyfrom time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\ndef start(self) -> None:\n\"\"\"Method to start (or resume) time updating.\"\"\"\nself.start_time = monotonic()\nself.update_timer.resume()\ndef stop(self):\n\"\"\"Method to stop the time display updating.\"\"\"\nself.update_timer.pause()\nself.total += monotonic() - self.start_time\nself.time = self.total\ndef reset(self):\n\"\"\"Method to reset the time display to zero.\"\"\"\nself.total = 0\nself.time = 0\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch.tcss\"\nBINDINGS = [\n(\"d\", \"toggle_dark\", \"Toggle dark mode\"),\n(\"a\", \"add_stopwatch\", \"Add\"),\n(\"r\", \"remove_stopwatch\", \"Remove\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Called to add widgets to the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch(), id=\"timers\")\ndef action_add_stopwatch(self) -> None:\n\"\"\"An action to add a timer.\"\"\"\nnew_stopwatch = Stopwatch()\nself.query_one(\"#timers\").mount(new_stopwatch)\nnew_stopwatch.scroll_visible()\ndef action_remove_stopwatch(self) -> None:\n\"\"\"Called to remove a timer.\"\"\"\ntimers = self.query(\"Stopwatch\")\nif timers:\ntimers.last().remove()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Here's a summary of the changes:
ScrollableContainer
object in StopWatchApp
grew a \"timers\"
ID.action_add_stopwatch
to add a new stopwatch.action_remove_stopwatch
to remove a stopwatch.The action_add_stopwatch
method creates and mounts a new stopwatch. Note the call to query_one() with a CSS selector of \"#timers\"
which gets the timer's container via its ID. Once mounted, the new Stopwatch will appear in the terminal. That last line in action_add_stopwatch
calls scroll_visible() which will scroll the container to make the new Stopwatch
visible (if required).
The action_remove_stopwatch
function calls query() with a CSS selector of \"Stopwatch\"
which gets all the Stopwatch
widgets. If there are stopwatches then the action calls last() to get the last stopwatch, and remove() to remove it.
If you run stopwatch.py
now you can add a new stopwatch with the A key and remove a stopwatch with R.
stopwatch.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
"},{"location":"tutorial/#what-next","title":"What next?","text":"Congratulations on building your first Textual application! This tutorial has covered a lot of ground. If you are the type that prefers to learn a framework by coding, feel free. You could tweak stopwatch.py
or look through the examples.
Read the guide for the full details on how to build sophisticated TUI applications with Textual.
"},{"location":"widget_gallery/","title":"Widgets","text":"Welcome to the Textual widget gallery.
We have many more widgets planned, or you can build your own.
Info
Textual is a TUI framework. Everything below runs in the terminal.
"},{"location":"widget_gallery/#button","title":"Button","text":"A simple button with a variety of semantic styles.
Button reference
ButtonsApp Standard\u00a0ButtonsDisabled\u00a0Buttons \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Default\u00a0\u00a0Default\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Primary!\u00a0\u00a0Primary!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Success!\u00a0\u00a0Success!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Warning!\u00a0\u00a0Warning!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Error!\u00a0\u00a0Error!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"widget_gallery/#checkbox","title":"Checkbox","text":"A classic checkbox control.
Checkbox reference
CheckboxApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Arrakis\u00a0\ud83d\ude13\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Caladan\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Chusuk\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGiedi\u00a0Prime\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGinaz\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Grumman\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2583\u2583 \u258a\u2590X\u258cKaitain\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e
"},{"location":"widget_gallery/#collapsible","title":"Collapsible","text":"Content that may be toggled on and off by clicking a title.
Collapsible reference
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
"},{"location":"widget_gallery/#contentswitcher","title":"ContentSwitcher","text":"A widget for containing and switching display between multiple child widgets.
ContentSwitcher reference
"},{"location":"widget_gallery/#datatable","title":"DataTable","text":"A powerful data table, with configurable cursors.
DataTable reference
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
"},{"location":"widget_gallery/#digits","title":"Digits","text":"Display numbers in tall characters.
Digits reference
DigitApp \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2551\u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u00a0\u2513\u00a0\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2578\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u250f\u2501\u2513\u257a\u2501\u2513\u2551 \u2551\u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u00a0\u2503\u00a0\u00a0\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b\u00a0\u2523\u2501\u2513\u2517\u2501\u2513\u00a0\u2501\u252b\u00a0\u2517\u2501\u2513\u2523\u2501\u252b\u2517\u2501\u252b\u00a0\u00a0\u2503\u2551 \u2551\u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u253b\u2578,\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578,\u2517\u2501\u251b\u257a\u2501\u251b\u257a\u2501\u251b,\u257a\u2501\u251b\u2517\u2501\u251b\u257a\u2501\u251b\u00a0\u00a0\u2579\u2551 \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d
"},{"location":"widget_gallery/#directorytree","title":"DirectoryTree","text":"A tree view of files and folders.
DirectoryTree reference
DirectoryTreeApp \ud83d\udcc2\u00a0. \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.faq \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.git \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.github \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.ipynb_checkpoints \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.mypy_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.pytest_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.screenshot_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.vscode \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0__pycache__ \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0dist\u2581\u2581 \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0docs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0examples \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0imgs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0notes \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0questions \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0reference \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0sandbox \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0site \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0src \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tests \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tools \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0.coverage
"},{"location":"widget_gallery/#footer","title":"Footer","text":"A footer to display and interact with key bindings.
Footer reference
FooterApp \u00a0Q\u00a0\u00a0Quit\u00a0the\u00a0app\u00a0\u00a0?\u00a0\u00a0Show\u00a0help\u00a0screen\u00a0\u00a0DELETE\u00a0\u00a0Delete\u00a0the\u00a0thing\u00a0
"},{"location":"widget_gallery/#header","title":"Header","text":"A header to display the app's title and subtitle.
Header reference
HeaderApp \u2b58HeaderApp
"},{"location":"widget_gallery/#input","title":"Input","text":"A control to enter text.
Input reference
InputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aDarren\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aLast\u00a0Name\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#label","title":"Label","text":"A simple text label.
Label reference
"},{"location":"widget_gallery/#listview","title":"ListView","text":"Display a list of items (items may be other widgets).
ListView reference
ListViewExample One Two Three
"},{"location":"widget_gallery/#loadingindicator","title":"LoadingIndicator","text":"Display an animation while data is loading.
LoadingIndicator reference
LoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf
"},{"location":"widget_gallery/#log","title":"Log","text":"Display and update lines of text (such as from a file).
Log reference
LogApp And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2584\u2584 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
"},{"location":"widget_gallery/#markdownviewer","title":"MarkdownViewer","text":"Display and interact with a Markdown document (adds a table of contents and browser-like navigation to Markdown).
MarkdownViewer reference
MarkdownExampleApp \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u25bc\u00a0\u2160\u00a0Markdown\u00a0Viewer\u258a\u258e\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Features\u258a\u258eMarkdown\u00a0Viewer\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Tables\u258a\u258e\u258a \u2514\u2500\u2500\u00a0\u2161\u00a0Code\u00a0Blocks\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aThis\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0MarkdownViewer\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aMarkdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u258a \u258a\u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u258a\u25cf\u00a0Headers \u258a\u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u258a\u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u258a\u25cf\u00a0Tables! \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tables\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aTables\u00a0are\u00a0displayed\u00a0in\u00a0a\u00a0DataTable\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258eName\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0TypeDefaultDescription\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a \u258a\u258eshow_headerboolTrueShow\u00a0the\u00a0table\u00a0header\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_rowsint0Number\u00a0of\u00a0fixed\u00a0rows\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_columnsint0Number\u00a0of\u00a0fixed\u00a0columns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258ezebra_stripesboolFalseDisplay\u00a0alternating\u00a0colors\u00a0on\u00a0rows\u258a \u258a\u258eheader_heightint1Height\u00a0of\u00a0header\u00a0row\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258eshow_cursorboolTrueShow\u00a0a\u00a0cell\u00a0cursor\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a
"},{"location":"widget_gallery/#markdown","title":"Markdown","text":"Display a markdown document.
Markdown reference
MarkdownExampleApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eMarkdown\u00a0Document\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 This\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0Markdown\u00a0widget. \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Markdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u25cf\u00a0Headers \u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u25cf\u00a0Tables!
"},{"location":"widget_gallery/#optionlist","title":"OptionList","text":"Display a vertical list of options (options may be Rich renderables).
OptionList reference
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aGemenon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aPicon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aSagittaron\u2584\u2584\u258e \u258aScorpia\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#placeholder","title":"Placeholder","text":"Display placeholder content while you are designing a UI.
Placeholder reference
PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula. Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0 vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedconsectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0 lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 sapien\u00a0sapien\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0
"},{"location":"widget_gallery/#pretty","title":"Pretty","text":"Display a pretty-formatted Rich renderable.
Pretty reference
PrettyExample { 'title':\u00a0'Back\u00a0to\u00a0the\u00a0Future', 'releaseYear':\u00a01985, 'director':\u00a0'Robert\u00a0Zemeckis', 'genre':\u00a0'Adventure,\u00a0Comedy,\u00a0Sci-Fi', 'cast':\u00a0[ {'actor':\u00a0'Michael\u00a0J.\u00a0Fox',\u00a0'character':\u00a0'Marty\u00a0McFly'}, {'actor':\u00a0'Christopher\u00a0Lloyd',\u00a0'character':\u00a0'Dr.\u00a0Emmett\u00a0Brown'} ] }
"},{"location":"widget_gallery/#progressbar","title":"ProgressBar","text":"A configurable progress bar with ETA and percentage complete.
ProgressBar reference
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250150% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$50\u00a0received!
"},{"location":"widget_gallery/#radiobutton","title":"RadioButton","text":"A simple radio button.
RadioButton reference
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#radioset","title":"RadioSet","text":"A collection of radio buttons, that enforces uniqueness.
RadioSet reference
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e\u258a\u2590\u25cf\u258c\u00a0Amanda\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e\u258a\u2590\u25cf\u258c\u00a0Connor\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e\u258a\u2590\u25cf\u258c\u00a0Duncan\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e\u258a\u2590\u25cf\u258c\u00a0Heather\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e\u258a\u2590\u25cf\u258c\u00a0Joe\u00a0Dawson\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e\u258a\u2590\u25cf\u258c\u00a0Kurgan,\u00a0The\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e\u258a\u2590\u25cf\u258c\u00a0Methos\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e\u258a\u2590\u25cf\u258c\u00a0Rachel\u00a0Ellenstein\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e\u258a\u2590\u25cf\u258c\u00a0Ram\u00edrez\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#richlog","title":"RichLog","text":"Display and update text in a scrolling panel.
RichLog reference
RichLogApp \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=next(iter_values) \u2502\u00a0\u00a0\u00a0exceptStopIteration: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0return \u2502\u00a0\u00a0\u00a0first=True\u2585\u2585 \u2502\u00a0\u00a0\u00a0forvalueiniter_values: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldfirst,False,previous_value \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0first=False \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=value \u2502\u00a0\u00a0\u00a0yieldfirst,True,previous_value \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503lane\u2503swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503time\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25024\u00a0\u00a0\u00a0\u2502Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u2502Singapore\u00a0\u00a0\u00a0\u00a0\u250250.39\u2502 \u25022\u00a0\u00a0\u00a0\u2502Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.14\u2502 \u25025\u00a0\u00a0\u00a0\u2502Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502South\u00a0Africa\u00a0\u250251.14\u2502 \u25026\u00a0\u00a0\u00a0\u2502L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.14\u2502 \u25023\u00a0\u00a0\u00a0\u2502Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.26\u2502 \u25028\u00a0\u00a0\u00a0\u2502Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.58\u2502 \u25027\u00a0\u00a0\u00a0\u2502Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.73\u2502 \u25021\u00a0\u00a0\u00a0\u2502Aleksandr\u00a0Sadovnikov\u2502Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.84\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 Write\u00a0text\u00a0or\u00a0any\u00a0Rich\u00a0renderable! Key(key='H',\u00a0character='H',\u00a0name='upper_h',\u00a0is_printable=True) Key(key='i',\u00a0character='i',\u00a0name='i',\u00a0is_printable=True)
"},{"location":"widget_gallery/#rule","title":"Rule","text":"A rule widget to separate content, similar to a <hr>
HTML tag.
Rule reference
HorizontalRulesApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0solid\u00a0(default)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0heavy\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0thick\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0dashed\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0double\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ascii\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 ----------------------------------------------------------------
"},{"location":"widget_gallery/#select","title":"Select","text":"Select from a number of possible options.
Select reference
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#selectionlist","title":"SelectionList","text":"Select multiple values from a list of options.
SelectionList reference
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"widget_gallery/#sparkline","title":"Sparkline","text":"Display numerical data.
Sparkline reference
SparklineSummaryFunctionApp \u2582\u2584\u2582\u2584\u2583\u2583\u2586\u2585\u2583\u2582\u2583\u2582\u2583\u2582\u2584\u2587\u2583\u2583\u2587\u2585\u2584\u2583\u2584\u2584\u2583\u2582\u2583\u2582\u2583\u2584\u2584\u2588\u2586\u2582\u2583\u2583\u2585\u2583\u2583\u2584\u2583\u2587\u2583\u2583\u2583\u2584\u2584\u2586\u2583\u2583\u2585\u2582\u2585\u2583\u2584\u2583\u2583\u2584\u2583\u2585\u2586\u2582\u2582\u2583\u2586\u2582\u2583\u2584\u2585\u2584\u2583\u2584\u2584\u2581\u2583\u2582 \u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2582\u2582\u2582\u2582\u2582\u2582\u2581\u2581\u2581\u2581\u2581\u2582\u2581\u2582\u2582\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2581\u2581\u2581\u2581\u2582\u2582\u2582\u2581\u2582\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"widget_gallery/#static","title":"Static","text":"Displays simple static content. Typically used as a base class.
Static reference
"},{"location":"widget_gallery/#switch","title":"Switch","text":"An on / off control, inspired by toggle buttons.
Switch reference
SwitchApp Example\u00a0switches \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e off:\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e on:\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e focused:\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e custom:\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#tabs","title":"Tabs","text":"A row of tabs you can select with the mouse or navigate with keys.
Tabs reference
TabsApp \u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0HalleckBaron\u00a0Vladimir \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0
"},{"location":"widget_gallery/#tabbedcontent","title":"TabbedContent","text":"A Combination of Tabs and ContentSwitcher to navigate static content.
TabbedContent reference
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
"},{"location":"widget_gallery/#textarea","title":"TextArea","text":"A multi-line text area which supports syntax highlighting various languages.
TextArea reference
TextAreaExample 1\u00a0\u00a0defhello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0print(\"hello\"+\u00a0name)\u00a0\u00a0\u00a0 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
"},{"location":"widget_gallery/#tree","title":"Tree","text":"A tree control with expandable nodes.
Tree reference
TreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
"},{"location":"api/","title":"API","text":"This is a API-level reference to the Textual API. Click the links to your left (or in the burger menu) to open a reference for each module.
If you are new to Textual, you may want to read the tutorial or guide first.
"},{"location":"api/app/","title":"App","text":"Here you will find the App class, which is the base class for Textual apps.
See app basics for how to build Textual apps.
"},{"location":"api/app/#textual.app.AutopilotCallbackType","title":"AutopilotCallbackTypemodule-attribute
","text":"AutopilotCallbackType: TypeAlias = (\n\"Callable[[Pilot[object]], Coroutine[Any, Any, None]]\"\n)\n
Signature for valid callbacks that can be used to control apps.
"},{"location":"api/app/#textual.app.CSSPathType","title":"CSSPathTypemodule-attribute
","text":"CSSPathType = Union[\nstr, PurePath, List[Union[str, PurePath]]\n]\n
Valid ways of specifying paths to CSS files.
"},{"location":"api/app/#textual.app.ActionError","title":"ActionErrorclass
","text":" Bases: Exception
Base class for exceptions relating to actions.
"},{"location":"api/app/#textual.app.ActiveModeError","title":"ActiveModeErrorclass
","text":" Bases: ModeError
Raised when attempting to remove the currently active mode.
"},{"location":"api/app/#textual.app.App","title":"Appclass
","text":"def __init__(\nself, driver_class=None, css_path=None, watch_css=False\n):\n
Bases: Generic[ReturnType]
, DOMNode
The base class for Textual Applications.
Parameters Name Type Description Defaultdriver_class
Type[Driver] | None
Driver class or None
to auto-detect. This will be used by some Textual tools.
None
css_path
CSSPathType | None
Path to CSS or None
to use the CSS_PATH
class variable. To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
None
watch_css
bool
Reload CSS if the files changed. This is set automatically if you are using textual run
with the dev
switch.
False
Raises Type Description CssPathError
When the supplied CSS path(s) are an unexpected type.
"},{"location":"api/app/#textual.app.App.AUTO_FOCUS","title":"AUTO_FOCUSclass-attribute
","text":"AUTO_FOCUS: str | None = '*'\n
A selector to determine what to focus automatically when a screen is activated.
The widget focused is the first that matches the given CSS selector. Setting to None
or \"\"
disables auto focus.
class-attribute
","text":"COMMANDS: set[type[Provider]] = {SystemCommands}\n
Command providers used by the command palette.
Should be a set of command.Provider classes.
"},{"location":"api/app/#textual.app.App.CSS","title":"CSSclass-attribute
","text":"CSS: str = ''\n
Inline CSS, useful for quick scripts. This is loaded after CSS_PATH, and therefore takes priority in the event of a specificity clash.
"},{"location":"api/app/#textual.app.App.CSS_PATH","title":"CSS_PATHclass-attribute
","text":"CSS_PATH: CSSPathType | None = None\n
File paths to load CSS from.
"},{"location":"api/app/#textual.app.App.ENABLE_COMMAND_PALETTE","title":"ENABLE_COMMAND_PALETTEclass-attribute
","text":"ENABLE_COMMAND_PALETTE: bool = True\n
Should the command palette be enabled for the application?
"},{"location":"api/app/#textual.app.App.MODES","title":"MODESclass-attribute
","text":"MODES: dict[str, str | Screen | Callable[[], Screen]] = {}\n
Modes associated with the app and their base screens.
The base screen is the screen at the bottom of the mode stack. You can think of it as the default screen for that stack. The base screens can be names of screens listed in SCREENS, Screen
instances, or callables that return screens.
class HelpScreen(Screen[None]):\n...\nclass MainAppScreen(Screen[None]):\n...\nclass MyApp(App[None]):\nMODES = {\n\"default\": \"main\",\n\"help\": HelpScreen,\n}\nSCREENS = {\n\"main\": MainAppScreen,\n}\n...\n
"},{"location":"api/app/#textual.app.App.SCREENS","title":"SCREENS class-attribute
","text":"SCREENS: dict[str, Screen | Callable[[], Screen]] = {}\n
Screens associated with the app for the lifetime of the app.
"},{"location":"api/app/#textual.app.App.SUB_TITLE","title":"SUB_TITLEclass-attribute
instance-attribute
","text":"SUB_TITLE: str | None = None\n
A class variable to set the default sub-title for the application.
To update the sub-title while the app is running, you can set the sub_title attribute. See also the Screen.SUB_TITLE
attribute.
class-attribute
instance-attribute
","text":"TITLE: str | None = None\n
A class variable to set the default title for the application.
To update the title while the app is running, you can set the title attribute. See also the Screen.TITLE
attribute.
property
","text":"children: Sequence['Widget']\n
A view onto the app's immediate children.
This attribute exists on all widgets. In the case of the App, it will only ever contain a single child, which will be the currently active screen.
Returns Type DescriptionSequence['Widget']
A sequence of widgets.
"},{"location":"api/app/#textual.app.App.current_mode","title":"current_modeproperty
","text":"current_mode: str\n
The name of the currently active mode.
"},{"location":"api/app/#textual.app.App.cursor_position","title":"cursor_positioninstance-attribute
","text":"cursor_position = Offset(0, 0)\n
The position of the terminal cursor in screen-space.
This can be set by widgets and is useful for controlling the positioning of OS IME and emoji popup menus.
"},{"location":"api/app/#textual.app.App.dark","title":"darkclass-attribute
instance-attribute
","text":"dark: Reactive[bool] = Reactive(True, compute=False)\n
Use a dark theme if True
, otherwise use a light theme.
Modify this attribute to switch between light and dark themes.
Exampleself.app.dark = not self.app.dark # Toggle dark mode\n
"},{"location":"api/app/#textual.app.App.debug","title":"debug property
","text":"debug: bool\n
Is debug mode enabled?
"},{"location":"api/app/#textual.app.App.focused","title":"focusedproperty
","text":"focused: Widget | None\n
The widget that is focused on the currently active screen, or None
.
Focused widgets receive keyboard input.
Returns Type DescriptionWidget | None
The currently focused widget, or None
if nothing is focused.
property
","text":"is_headless: bool\n
Is the driver running in 'headless' mode?
Headless mode is used when running tests with run_test.
"},{"location":"api/app/#textual.app.App.log","title":"logproperty
","text":"log: Logger\n
The textual logger.
Exampleself.log(\"Hello, World!\")\nself.log(self.tree)\n
Returns Type Description Logger
A Textual logger.
"},{"location":"api/app/#textual.app.App.namespace_bindings","title":"namespace_bindingsproperty
","text":"namespace_bindings: dict[str, tuple[DOMNode, Binding]]\n
Get currently active bindings.
If no widget is focused, then app-level bindings are returned. If a widget is focused, then any bindings present in the active screen and app are merged and returned.
This property may be used to inspect current bindings.
Returns Type Descriptiondict[str, tuple[DOMNode, Binding]]
A mapping of keys onto pairs of nodes and bindings.
"},{"location":"api/app/#textual.app.App.return_code","title":"return_codeproperty
","text":"return_code: int | None\n
The return code with which the app exited.
Non-zero codes indicate errors. A value of 1 means the app exited with a fatal error. If the app wasn't exited yet, this will be None
.
The return code can be used to exit the process via sys.exit
.
my_app.run()\nsys.exit(my_app.return_code)\n
"},{"location":"api/app/#textual.app.App.return_value","title":"return_value property
","text":"return_value: ReturnType | None\n
The return value of the app, or None
if it has not yet been set.
The return value is set when calling exit.
"},{"location":"api/app/#textual.app.App.screen","title":"screenproperty
","text":"screen: Screen[object]\n
The current active screen.
Returns Type DescriptionScreen[object]
The currently active (visible) screen.
Raises Type DescriptionScreenStackError
If there are no screens on the stack.
"},{"location":"api/app/#textual.app.App.screen_stack","title":"screen_stackproperty
","text":"screen_stack: Sequence[Screen]\n
A snapshot of the current screen stack.
Returns Type DescriptionSequence[Screen]
A snapshot of the current state of the screen stack.
"},{"location":"api/app/#textual.app.App.scroll_sensitivity_x","title":"scroll_sensitivity_xinstance-attribute
","text":"scroll_sensitivity_x: float = 4.0\n
Number of columns to scroll in the X direction with wheel or trackpad.
"},{"location":"api/app/#textual.app.App.scroll_sensitivity_y","title":"scroll_sensitivity_yinstance-attribute
","text":"scroll_sensitivity_y: float = 2.0\n
Number of lines to scroll in the Y direction with wheel or trackpad.
"},{"location":"api/app/#textual.app.App.size","title":"sizeproperty
","text":"size: Size\n
The size of the terminal.
Returns Type DescriptionSize
Size of the terminal.
"},{"location":"api/app/#textual.app.App.sub_title","title":"sub_titleclass-attribute
instance-attribute
","text":"sub_title: Reactive[str] = (\nself.SUB_TITLE if self.SUB_TITLE is not None else \"\"\n)\n
The sub-title for the application.
The initial value for sub_title
will be set to the SUB_TITLE
class variable if it exists, or an empty string if it doesn't.
Sub-titles are typically used to show the high-level state of the app, such as the current mode, or path to the file being worked on.
Assign a new value to this attribute to change the sub-title. The new value is always converted to string.
"},{"location":"api/app/#textual.app.App.title","title":"titleclass-attribute
instance-attribute
","text":"title: Reactive[str] = (\nself.TITLE\nif self.TITLE is not None\nelse f\"{self.__class__.__name__}\"\n)\n
The title for the application.
The initial value for title
will be set to the TITLE
class variable if it exists, or the name of the app if it doesn't.
Assign a new value to this attribute to change the title. The new value is always converted to string.
"},{"location":"api/app/#textual.app.App.use_command_palette","title":"use_command_paletteinstance-attribute
","text":"use_command_palette: bool = self.ENABLE_COMMAND_PALETTE\n
A flag to say if the application should use the command palette.
If set to False
any call to action_command_palette
will be ignored.
property
","text":"workers: WorkerManager\n
The worker manager.
Returns Type DescriptionWorkerManager
An object to manage workers.
"},{"location":"api/app/#textual.app.App.action_add_class","title":"action_add_classasync
","text":"def action_add_class(self, selector, class_name):\n
An action to add a CSS class to the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to add the class to.
requiredclass_name
str
The class to add to the selected widget.
required"},{"location":"api/app/#textual.app.App.action_back","title":"action_backasync
","text":"def action_back(self):\n
An action to go back to the previous screen (pop the current screen).
NoteIf there is no screen to go back to, this is a non-operation (in other words it's safe to call even if there are no other screens on the stack.)
"},{"location":"api/app/#textual.app.App.action_bell","title":"action_bellasync
","text":"def action_bell(self):\n
An action to play the terminal 'bell'.
"},{"location":"api/app/#textual.app.App.action_check_bindings","title":"action_check_bindingsasync
","text":"def action_check_bindings(self, key):\n
An action to handle a key press using the binding system.
Parameters Name Type Description Defaultkey
str
The key to process.
required"},{"location":"api/app/#textual.app.App.action_command_palette","title":"action_command_palettemethod
","text":"def action_command_palette(self):\n
Show the Textual command palette.
"},{"location":"api/app/#textual.app.App.action_focus","title":"action_focusasync
","text":"def action_focus(self, widget_id):\n
An action to focus the given widget.
Parameters Name Type Description Defaultwidget_id
str
ID of widget to focus.
required"},{"location":"api/app/#textual.app.App.action_focus_next","title":"action_focus_nextmethod
","text":"def action_focus_next(self):\n
An action to focus the next widget.
"},{"location":"api/app/#textual.app.App.action_focus_previous","title":"action_focus_previousmethod
","text":"def action_focus_previous(self):\n
An action to focus the previous widget.
"},{"location":"api/app/#textual.app.App.action_pop_screen","title":"action_pop_screenasync
","text":"def action_pop_screen(self):\n
An action to remove the topmost screen and makes the new topmost screen active.
"},{"location":"api/app/#textual.app.App.action_push_screen","title":"action_push_screenasync
","text":"def action_push_screen(self, screen):\n
An action to push a new screen on to the stack and make it active.
Parameters Name Type Description Defaultscreen
str
Name of the screen.
required"},{"location":"api/app/#textual.app.App.action_quit","title":"action_quitasync
","text":"def action_quit(self):\n
An action to quit the app as soon as possible.
"},{"location":"api/app/#textual.app.App.action_remove_class","title":"action_remove_classasync
","text":"def action_remove_class(self, selector, class_name):\n
An action to remove a CSS class from the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to remove the class from.
requiredclass_name
str
The class to remove from the selected widget.
required"},{"location":"api/app/#textual.app.App.action_screenshot","title":"action_screenshotmethod
","text":"def action_screenshot(self, filename=None, path='./'):\n
This action will save an SVG file containing the current contents of the screen.
Parameters Name Type Description Defaultfilename
str | None
Filename of screenshot, or None to auto-generate.
None
path
str
Path to directory. Defaults to current working directory.
'./'
"},{"location":"api/app/#textual.app.App.action_switch_mode","title":"action_switch_mode async
","text":"def action_switch_mode(self, mode):\n
An action that switches to the given mode..
"},{"location":"api/app/#textual.app.App.action_switch_screen","title":"action_switch_screenasync
","text":"def action_switch_screen(self, screen):\n
An action to switch screens.
Parameters Name Type Description Defaultscreen
str
Name of the screen.
required"},{"location":"api/app/#textual.app.App.action_toggle_class","title":"action_toggle_classasync
","text":"def action_toggle_class(self, selector, class_name):\n
An action to toggle a CSS class on the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to toggle the class on.
requiredclass_name
str
The class to toggle on the selected widget.
required"},{"location":"api/app/#textual.app.App.action_toggle_dark","title":"action_toggle_darkmethod
","text":"def action_toggle_dark(self):\n
An action to toggle dark mode.
"},{"location":"api/app/#textual.app.App.add_mode","title":"add_modemethod
","text":"def add_mode(self, mode, base_screen):\n
Adds a mode and its corresponding base screen to the app.
Parameters Name Type Description Defaultmode
str
The new mode.
requiredbase_screen
str | Screen | Callable[[], Screen]
The base screen associated with the given mode.
required Raises Type DescriptionInvalidModeError
If the name of the mode is not valid/duplicated.
"},{"location":"api/app/#textual.app.App.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
See the guide for how to use the animation system.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/app/#textual.app.App.batch_update","title":"batch_update method
","text":"def batch_update(self):\n
A context manager to suspend all repaints until the end of the batch.
"},{"location":"api/app/#textual.app.App.begin_capture_print","title":"begin_capture_printmethod
","text":"def begin_capture_print(self, target, stdout=True, stderr=True):\n
Capture content that is printed (or written to stdout / stderr).
If printing is captured, the target
will be sent an events.Print message.
target
MessageTarget
The widget where print content will be sent.
requiredstdout
bool
Capture stdout.
True
stderr
bool
Capture stderr.
True
"},{"location":"api/app/#textual.app.App.bell","title":"bell method
","text":"def bell(self):\n
Play the console 'bell'.
For terminals that support a bell, this typically makes a notification or error sound. Some terminals may make no sound or display a visual bell indicator, depending on configuration.
"},{"location":"api/app/#textual.app.App.bind","title":"bindmethod
","text":"def bind(\nself,\nkeys,\naction,\n*,\ndescription=\"\",\nshow=True,\nkey_display=None\n):\n
Bind a key to an action.
Parameters Name Type Description Defaultkeys
str
A comma separated list of keys, i.e.
requiredaction
str
Action to bind to.
requireddescription
str
Short description of action.
''
show
bool
Show key in UI.
True
key_display
str | None
Replacement text for key, or None to use default.
None
"},{"location":"api/app/#textual.app.App.call_from_thread","title":"call_from_thread method
","text":"def call_from_thread(self, callback, *args, **kwargs):\n
Run a callable from another thread, and return the result.
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods or set attributes on Textual objects from a thread, you may get unpredictable results.
This method will ensure that your code runs within the correct context.
Tip
Consider using post_message which is also thread-safe.
Parameters Name Type Description Defaultcallback
Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]]
A callable to run.
required*args
object
Arguments to the callback.
()
**kwargs
object
Keyword arguments for the callback.
{}
Raises Type Description RuntimeError
If the app isn't running or if this method is called from the same thread where the app is running.
Returns Type DescriptionCallThreadReturnType
The result of the callback.
"},{"location":"api/app/#textual.app.App.capture_mouse","title":"capture_mousemethod
","text":"def capture_mouse(self, widget):\n
Send all mouse events to the given widget or disable mouse capture.
Parameters Name Type Description Defaultwidget
Widget | None
If a widget, capture mouse event, or None
to end mouse capture.
async
","text":"def check_bindings(self, key, priority=False):\n
Handle a key press.
This method is used internally by the bindings system, but may be called directly if you wish to simulate a key being pressed.
Parameters Name Type Description Defaultkey
str
A key.
requiredpriority
bool
If True
check from App
down, otherwise from focused up.
False
Returns Type Description bool
True if the key was handled by a binding, otherwise False
"},{"location":"api/app/#textual.app.App.clear_notifications","title":"clear_notificationsmethod
","text":"def clear_notifications(self):\n
Clear all the current notifications.
"},{"location":"api/app/#textual.app.App.compose","title":"composemethod
","text":"def compose(self):\n
Yield child widgets for a container.
This method should be implemented in a subclass.
"},{"location":"api/app/#textual.app.App.end_capture_print","title":"end_capture_printmethod
","text":"def end_capture_print(self, target):\n
End capturing of prints.
Parameters Name Type Description Defaulttarget
MessageTarget
The widget that was capturing prints.
required"},{"location":"api/app/#textual.app.App.exit","title":"exitmethod
","text":"def exit(self, result=None, return_code=0, message=None):\n
Exit the app, and return the supplied result.
Parameters Name Type Description Defaultresult
ReturnType | None
Return value.
None
return_code
int
The return code. Use non-zero values for error codes.
0
message
RenderableType | None
Optional message to display on exit.
None
"},{"location":"api/app/#textual.app.App.export_screenshot","title":"export_screenshot method
","text":"def export_screenshot(self, *, title=None):\n
Export an SVG screenshot of the current screen.
See also save_screenshot which writes the screenshot to a file.
Parameters Name Type Description Defaulttitle
str | None
The title of the exported screenshot or None to use app title.
None
"},{"location":"api/app/#textual.app.App.get_child_by_id","title":"get_child_by_id method
","text":"def get_child_by_id(self, id, expect_type=None):\n
Get the first child (immediate descendent) of this DOMNode with the given ID.
Parameters Name Type Description Defaultid
str
The ID of the node to search for.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or use None
to apply no type restriction.
None
Returns Type Description ExpectType | Widget
The first child of this node with the specified ID.
Raises Type DescriptionNoMatches
If no children could be found for this ID.
WrongType
If the wrong type was found.
"},{"location":"api/app/#textual.app.App.get_child_by_type","title":"get_child_by_typemethod
","text":"def get_child_by_type(self, expect_type):\n
Get a child of a give type.
Parameters Name Type Description Defaultexpect_type
type[ExpectType]
The type of the expected child.
required Raises Type DescriptionNoMatches
If no valid child is found.
Returns Type DescriptionExpectType
A widget.
"},{"location":"api/app/#textual.app.App.get_css_variables","title":"get_css_variablesmethod
","text":"def get_css_variables(self):\n
Get a mapping of variables used to pre-populate CSS.
May be implemented in a subclass to add new CSS variables.
Returns Type Descriptiondict[str, str]
A mapping of variable name to value.
"},{"location":"api/app/#textual.app.App.get_driver_class","title":"get_driver_classmethod
","text":"def get_driver_class(self):\n
Get a driver class for this platform.
This method is called by the constructor, and unlikely to be required when building a Textual app.
Returns Type DescriptionType[Driver]
A Driver class which manages input and display.
"},{"location":"api/app/#textual.app.App.get_key_display","title":"get_key_displaymethod
","text":"def get_key_display(self, key):\n
For a given key, return how it should be displayed in an app (e.g. in the Footer widget). By key, we refer to the string used in the \"key\" argument for a Binding instance. By overriding this method, you can ensure that keys are displayed consistently throughout your app, without needing to add a key_display to every binding.
Parameters Name Type Description Defaultkey
str
The binding key string.
required Returns Type Descriptionstr
The display string for the input key.
"},{"location":"api/app/#textual.app.App.get_screen","title":"get_screenmethod
","text":"def get_screen(self, screen):\n
Get an installed screen.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
KeyError
If the named screen doesn't exist.
Returns Type DescriptionScreen
A screen instance.
"},{"location":"api/app/#textual.app.App.get_widget_at","title":"get_widget_atmethod
","text":"def get_widget_at(self, x, y):\n
Get the widget under the given coordinates.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptiontuple[Widget, Region]
The widget and the widget's screen region.
"},{"location":"api/app/#textual.app.App.get_widget_by_id","title":"get_widget_by_idmethod
","text":"def get_widget_by_id(self, id, expect_type=None):\n
Get the first descendant widget with the given ID.
Performs a breadth-first search rooted at the current screen. It will not return the Screen if that matches the ID. To get the screen, use self.screen
.
id
str
The ID to search for in the subtree
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type. Defaults to None.
None
Returns Type Description ExpectType | Widget
The first descendant encountered with this ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID
WrongType
if the wrong type was found.
"},{"location":"api/app/#textual.app.App.install_screen","title":"install_screenmethod
","text":"def install_screen(self, screen, name):\n
Install a screen.
Installing a screen prevents Textual from destroying it when it is no longer on the screen stack. Note that you don't need to install a screen to use it. See push_screen or switch_screen to make a new screen current.
Parameters Name Type Description Defaultscreen
Screen
Screen to install.
requiredname
str
Unique name to identify the screen.
required Raises Type DescriptionScreenError
If the screen can't be installed.
Returns Type DescriptionNone
An awaitable that awaits the mounting of the screen and its children.
"},{"location":"api/app/#textual.app.App.is_mounted","title":"is_mountedmethod
","text":"def is_mounted(self, widget):\n
Check if a widget is mounted.
Parameters Name Type Description Defaultwidget
Widget
A widget.
required Returns Type Descriptionbool
True of the widget is mounted.
"},{"location":"api/app/#textual.app.App.is_screen_installed","title":"is_screen_installedmethod
","text":"def is_screen_installed(self, screen):\n
Check if a given screen has been installed.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
bool
True if the screen is currently installed,
"},{"location":"api/app/#textual.app.App.mount","title":"mountmethod
","text":"def mount(self, *widgets, before=None, after=None):\n
Mount the given widgets relative to the app's screen.
Parameters Name Type Description Default*widgets
Widget
The widget(s) to mount.
()
before
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def mount_all(self, widgets, *, before=None, after=None):\n
Mount widgets from an iterable.
Parameters Name Type Description Defaultwidgets
Iterable[Widget]
An iterable of widgets.
requiredbefore
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def notify(\nself,\nmessage,\n*,\ntitle=\"\",\nseverity=\"information\",\ntimeout=Notification.timeout\n):\n
Create a notification.
Tip
This method is thread-safe.
Parameters Name Type Description Defaultmessage
str
The message for the notification.
requiredtitle
str
The title for the notification.
''
severity
SeverityLevel
The severity of the notification.
'information'
timeout
float
The timeout for the notification.
Notification.timeout
The notify
method is used to create an application-wide notification, shown in a Toast
, normally originating in the bottom right corner of the display.
Notifications can have the following severity levels:
information
warning
error
The default is information
.
# Show an information notification.\nself.notify(\"It's an older code, sir, but it checks out.\")\n# Show a warning. Note that Textual's notification system allows\n# for the use of Rich console markup.\nself.notify(\n\"Now witness the firepower of this fully \"\n\"[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!\",\ntitle=\"Possible trap detected\",\nseverity=\"warning\",\n)\n# Show an error. Set a longer timeout so it's noticed.\nself.notify(\"It's a trap!\", severity=\"error\", timeout=10)\n# Show an information notification, but without any sort of title.\nself.notify(\"It's against my programming to impersonate a deity.\", title=\"\")\n
"},{"location":"api/app/#textual.app.App.panic","title":"panic method
","text":"def panic(self, *renderables):\n
Exits the app and display error message(s).
Used in response to unexpected errors. For a more graceful exit, see the exit method.
Parameters Name Type Description Default*renderables
RenderableType
Text or Rich renderable(s) to display on exit.
()
"},{"location":"api/app/#textual.app.App.pop_screen","title":"pop_screen method
","text":"def pop_screen(self):\n
Pop the current screen from the stack, and switch to the previous screen.
Returns Type DescriptionScreen[object]
The screen that was replaced.
"},{"location":"api/app/#textual.app.App.post_display_hook","title":"post_display_hookmethod
","text":"def post_display_hook(self):\n
Called immediately after a display is done. Used in tests.
"},{"location":"api/app/#textual.app.App.push_screen","title":"push_screenmethod
","text":"def push_screen(\nself, screen, callback=None, wait_for_dismiss=False\n):\n
Push a new screen on the screen stack, making it the current screen.
Parameters Name Type Description Defaultscreen
Screen[ScreenResultType] | str
A Screen instance or the name of an installed screen.
requiredcallback
ScreenResultCallbackType[ScreenResultType] | None
An optional callback function that will be called if the screen is dismissed with a result.
None
wait_for_dismiss
bool
If True
, awaiting this method will return the dismiss value from the screen. When set to False
, awaiting this method will wait for the screen to be mounted. Note that wait_for_dismiss
should only be set to True
when running in a worker.
False
Raises Type Description NoActiveWorker
If using wait_for_dismiss
outside of a worker.
AwaitMount | asyncio.Future[ScreenResultType]
An optional awaitable that awaits the mounting of the screen and its children, or an asyncio Future to await the result of the screen.
"},{"location":"api/app/#textual.app.App.refresh_css","title":"refresh_cssmethod
","text":"def refresh_css(self, animate=True):\n
Refresh CSS.
Parameters Name Type Description Defaultanimate
bool
Also execute CSS animations.
True
"},{"location":"api/app/#textual.app.App.remove_mode","title":"remove_mode method
","text":"def remove_mode(self, mode):\n
Removes a mode from the app.
Screens that are running in the stack of that mode are scheduled for pruning.
Parameters Name Type Description Defaultmode
str
The mode to remove. It can't be the active mode.
required Raises Type DescriptionActiveModeError
If trying to remove the active mode.
UnknownModeError
If trying to remove an unknown mode.
"},{"location":"api/app/#textual.app.App.run","title":"runmethod
","text":"def run(self, *, headless=False, size=None, auto_pilot=None):\n
Run the app.
Parameters Name Type Description Defaultheadless
bool
Run in headless mode (no output).
False
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
None
auto_pilot
AutopilotCallbackType | None
An auto pilot coroutine.
None
Returns Type Description ReturnType | None
App return value.
"},{"location":"api/app/#textual.app.App.run_action","title":"run_actionasync
","text":"def run_action(self, action, default_namespace=None):\n
Perform an action.
Actions are typically associated with key bindings, where you wouldn't need to call this method manually.
Parameters Name Type Description Defaultaction
str | ActionParseResult
Action encoded in a string.
requireddefault_namespace
object | None
Namespace to use if not provided in the action, or None to use app.
None
Returns Type Description bool
True if the event has been handled.
"},{"location":"api/app/#textual.app.App.run_async","title":"run_asyncasync
","text":"def run_async(\nself, *, headless=False, size=None, auto_pilot=None\n):\n
Run the app asynchronously.
Parameters Name Type Description Defaultheadless
bool
Run in headless mode (no output).
False
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
None
auto_pilot
AutopilotCallbackType | None
An auto pilot coroutine.
None
Returns Type Description ReturnType | None
App return value.
"},{"location":"api/app/#textual.app.App.run_test","title":"run_testasync
","text":"def run_test(\nself,\n*,\nheadless=True,\nsize=(80, 24),\ntooltips=False,\nnotifications=False,\nmessage_hook=None\n):\n
An asynchronous context manager for testing apps.
Tip
See the guide for testing Textual apps.
Use this to run your app in \"headless\" mode (no output) and drive the app via a Pilot object.
Exampleasync with app.run_test() as pilot:\nawait pilot.click(\"#Button.ok\")\nassert ...\n
Parameters Name Type Description Default headless
bool
Run in headless mode (no output or input).
True
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
(80, 24)
tooltips
bool
Enable tooltips when testing.
False
notifications
bool
Enable notifications when testing.
False
message_hook
Callable[[Message], None] | None
An optional callback that will be called each time any message arrives at any message pump in the app.
None
"},{"location":"api/app/#textual.app.App.save_screenshot","title":"save_screenshot method
","text":"def save_screenshot(\nself, filename=None, path=\"./\", time_format=None\n):\n
Save an SVG screenshot of the current screen.
Parameters Name Type Description Defaultfilename
str | None
Filename of SVG screenshot, or None to auto-generate a filename with the date and time.
None
path
str
Path to directory for output. Defaults to current working directory.
'./'
time_format
str | None
Date and time format to use if filename is None. Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
None
Returns Type Description str
Filename of screenshot.
"},{"location":"api/app/#textual.app.App.set_focus","title":"set_focusmethod
","text":"def set_focus(self, widget, scroll_visible=True):\n
Focus (or unfocus) a widget. A focused widget will receive key events first.
Parameters Name Type Description Defaultwidget
Widget | None
Widget to focus.
requiredscroll_visible
bool
Scroll widget in to view.
True
"},{"location":"api/app/#textual.app.App.stop_animation","title":"stop_animation async
","text":"def stop_animation(self, attribute, complete=True):\n
Stop an animation on an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute whose animation should be stopped.
requiredcomplete
bool
Should the animation be set to its final value?
True
Note If there is no animation scheduled or running, this is a no-op.
"},{"location":"api/app/#textual.app.App.switch_mode","title":"switch_modemethod
","text":"def switch_mode(self, mode):\n
Switch to a given mode.
Parameters Name Type Description Defaultmode
str
The mode to switch to.
required Raises Type DescriptionUnknownModeError
If trying to switch to an unknown mode.
"},{"location":"api/app/#textual.app.App.switch_screen","title":"switch_screenmethod
","text":"def switch_screen(self, screen):\n
Switch to another screen by replacing the top of the screen stack with a new screen.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
method
","text":"def uninstall_screen(self, screen):\n
Uninstall a screen.
If the screen was not previously installed then this method is a null-op. Uninstalling a screen allows Textual to delete it when it is popped or switched. Note that uninstalling a screen is only required if you have previously installed it with install_screen. Textual will also uninstall screens automatically on exit.
Parameters Name Type Description Defaultscreen
Screen | str
The screen to uninstall or the name of a installed screen.
required Returns Type Descriptionstr | None
The name of the screen that was uninstalled, or None if no screen was uninstalled.
"},{"location":"api/app/#textual.app.App.update_styles","title":"update_stylesmethod
","text":"def update_styles(self, node):\n
Immediately update the styles of this node and all descendant nodes.
Should be called whenever CSS classes / pseudo classes change. For example, when you hover over a button, the :hover pseudo class will be added, and this method is called to apply the corresponding :hover styles.
"},{"location":"api/app/#textual.app.App.validate_sub_title","title":"validate_sub_titlemethod
","text":"def validate_sub_title(self, sub_title):\n
Make sure the sub-title is set to a string.
"},{"location":"api/app/#textual.app.App.validate_title","title":"validate_titlemethod
","text":"def validate_title(self, title):\n
Make sure the title is set to a string.
"},{"location":"api/app/#textual.app.App.watch_dark","title":"watch_darkmethod
","text":"def watch_dark(self, dark):\n
Watches the dark bool.
This method handles the transition between light and dark mode when you change the dark attribute.
"},{"location":"api/app/#textual.app.AppError","title":"AppErrorclass
","text":" Bases: Exception
Base class for general App related exceptions.
"},{"location":"api/app/#textual.app.InvalidModeError","title":"InvalidModeErrorclass
","text":" Bases: ModeError
Raised if there is an issue with a mode name.
"},{"location":"api/app/#textual.app.ModeError","title":"ModeErrorclass
","text":" Bases: Exception
Base class for exceptions related to modes.
"},{"location":"api/app/#textual.app.ScreenError","title":"ScreenErrorclass
","text":" Bases: Exception
Base class for exceptions that relate to screens.
"},{"location":"api/app/#textual.app.ScreenStackError","title":"ScreenStackErrorclass
","text":" Bases: ScreenError
Raised when trying to manipulate the screen stack incorrectly.
"},{"location":"api/app/#textual.app.UnknownModeError","title":"UnknownModeErrorclass
","text":" Bases: ModeError
Raised when attempting to use a mode that is not known.
"},{"location":"api/await_remove/","title":"Await remove","text":"An optionally awaitable object returned by methods that remove widgets.
"},{"location":"api/await_remove/#textual.await_remove.AwaitRemove","title":"AwaitRemoveclass
","text":"def __init__(self, finished_flag, task):\n
An awaitable returned by a method that removes DOM nodes.
Returned by Widget.remove and DOMQuery.remove.
Parameters Name Type Description Defaultfinished_flag
Event
The asyncio event to wait on.
requiredtask
Task
The task which does the remove (required to keep a reference).
required"},{"location":"api/binding/","title":"Binding","text":"A binding maps a key press on to an action.
See bindings in the guide for details.
"},{"location":"api/binding/#textual.binding.Binding","title":"Bindingclass
","text":"The configuration of a key binding.
"},{"location":"api/binding/#textual.binding.Binding.action","title":"actioninstance-attribute
","text":"action: str\n
Action to bind to.
"},{"location":"api/binding/#textual.binding.Binding.description","title":"descriptionclass-attribute
instance-attribute
","text":"description: str = ''\n
Description of action.
"},{"location":"api/binding/#textual.binding.Binding.key","title":"keyinstance-attribute
","text":"key: str\n
Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.
"},{"location":"api/binding/#textual.binding.Binding.key_display","title":"key_displayclass-attribute
instance-attribute
","text":"key_display: str | None = None\n
How the key should be shown in footer.
"},{"location":"api/binding/#textual.binding.Binding.priority","title":"priorityclass-attribute
instance-attribute
","text":"priority: bool = False\n
Enable priority binding for this key.
"},{"location":"api/binding/#textual.binding.Binding.show","title":"showclass-attribute
instance-attribute
","text":"show: bool = True\n
Show the action in Footer, or False to hide.
"},{"location":"api/binding/#textual.binding.BindingError","title":"BindingErrorclass
","text":" Bases: Exception
A binding related error.
"},{"location":"api/binding/#textual.binding.InvalidBinding","title":"InvalidBindingclass
","text":" Bases: Exception
Binding key is in an invalid format.
"},{"location":"api/binding/#textual.binding.NoBinding","title":"NoBindingclass
","text":" Bases: Exception
A binding was not found.
"},{"location":"api/color/","title":"Color","text":"This module contains a powerful Color class which Textual uses to manipulate colors.
"},{"location":"api/color/#textual.color--named-colors","title":"Named colors","text":"The following named colors are used by the parse method.
colors \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Name\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503hex\u00a0\u00a0\u00a0\u00a0\u2503RGB\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Color\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u2502\"aliceblue\"\u2502#F0F8FF\u2502rgb(240,\u00a0248,\u00a0255)\u2502\u2502 \u2502\"ansi_black\"\u2502#000000\u2502rgb(0,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_blue\"\u2502#000080\u2502rgb(0,\u00a00,\u00a0128)\u2502\u2502 \u2502\"ansi_bright_black\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"ansi_bright_blue\"\u2502#0000FF\u2502rgb(0,\u00a00,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_cyan\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_green\"\u2502#00FF00\u2502rgb(0,\u00a0255,\u00a00)\u2502\u2502 \u2502\"ansi_bright_magenta\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_red\"\u2502#FF0000\u2502rgb(255,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_bright_white\"\u2502#FFFFFF\u2502rgb(255,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_yellow\"\u2502#FFFF00\u2502rgb(255,\u00a0255,\u00a00)\u2502\u2502 \u2502\"ansi_cyan\"\u2502#008080\u2502rgb(0,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"ansi_green\"\u2502#008000\u2502rgb(0,\u00a0128,\u00a00)\u2502\u2502 \u2502\"ansi_magenta\"\u2502#800080\u2502rgb(128,\u00a00,\u00a0128)\u2502\u2502 \u2502\"ansi_red\"\u2502#800000\u2502rgb(128,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_white\"\u2502#C0C0C0\u2502rgb(192,\u00a0192,\u00a0192)\u2502\u2502 \u2502\"ansi_yellow\"\u2502#808000\u2502rgb(128,\u00a0128,\u00a00)\u2502\u2502 \u2502\"antiquewhite\"\u2502#FAEBD7\u2502rgb(250,\u00a0235,\u00a0215)\u2502\u2502 \u2502\"aqua\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"aquamarine\"\u2502#7FFFD4\u2502rgb(127,\u00a0255,\u00a0212)\u2502\u2502 \u2502\"azure\"\u2502#F0FFFF\u2502rgb(240,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"beige\"\u2502#F5F5DC\u2502rgb(245,\u00a0245,\u00a0220)\u2502\u2502 \u2502\"bisque\"\u2502#FFE4C4\u2502rgb(255,\u00a0228,\u00a0196)\u2502\u2502 \u2502\"black\"\u2502#000000\u2502rgb(0,\u00a00,\u00a00)\u2502\u2502 \u2502\"blanchedalmond\"\u2502#FFEBCD\u2502rgb(255,\u00a0235,\u00a0205)\u2502\u2502 \u2502\"blue\"\u2502#0000FF\u2502rgb(0,\u00a00,\u00a0255)\u2502\u2502 \u2502\"blueviolet\"\u2502#8A2BE2\u2502rgb(138,\u00a043,\u00a0226)\u2502\u2502 \u2502\"brown\"\u2502#A52A2A\u2502rgb(165,\u00a042,\u00a042)\u2502\u2502 \u2502\"burlywood\"\u2502#DEB887\u2502rgb(222,\u00a0184,\u00a0135)\u2502\u2502 \u2502\"cadetblue\"\u2502#5F9EA0\u2502rgb(95,\u00a0158,\u00a0160)\u2502\u2502 \u2502\"chartreuse\"\u2502#7FFF00\u2502rgb(127,\u00a0255,\u00a00)\u2502\u2502 \u2502\"chocolate\"\u2502#D2691E\u2502rgb(210,\u00a0105,\u00a030)\u2502\u2502 \u2502\"coral\"\u2502#FF7F50\u2502rgb(255,\u00a0127,\u00a080)\u2502\u2502 \u2502\"cornflowerblue\"\u2502#6495ED\u2502rgb(100,\u00a0149,\u00a0237)\u2502\u2502 \u2502\"cornsilk\"\u2502#FFF8DC\u2502rgb(255,\u00a0248,\u00a0220)\u2502\u2502 \u2502\"crimson\"\u2502#DC143C\u2502rgb(220,\u00a020,\u00a060)\u2502\u2502 \u2502\"cyan\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"darkblue\"\u2502#00008B\u2502rgb(0,\u00a00,\u00a0139)\u2502\u2502 \u2502\"darkcyan\"\u2502#008B8B\u2502rgb(0,\u00a0139,\u00a0139)\u2502\u2502 \u2502\"darkgoldenrod\"\u2502#B8860B\u2502rgb(184,\u00a0134,\u00a011)\u2502\u2502 \u2502\"darkgray\"\u2502#A9A9A9\u2502rgb(169,\u00a0169,\u00a0169)\u2502\u2502 \u2502\"darkgreen\"\u2502#006400\u2502rgb(0,\u00a0100,\u00a00)\u2502\u2502 \u2502\"darkgrey\"\u2502#A9A9A9\u2502rgb(169,\u00a0169,\u00a0169)\u2502\u2502 \u2502\"darkkhaki\"\u2502#BDB76B\u2502rgb(189,\u00a0183,\u00a0107)\u2502\u2502 \u2502\"darkmagenta\"\u2502#8B008B\u2502rgb(139,\u00a00,\u00a0139)\u2502\u2502 \u2502\"darkolivegreen\"\u2502#556B2F\u2502rgb(85,\u00a0107,\u00a047)\u2502\u2502 \u2502\"darkorange\"\u2502#FF8C00\u2502rgb(255,\u00a0140,\u00a00)\u2502\u2502 \u2502\"darkorchid\"\u2502#9932CC\u2502rgb(153,\u00a050,\u00a0204)\u2502\u2502 \u2502\"darkred\"\u2502#8B0000\u2502rgb(139,\u00a00,\u00a00)\u2502\u2502 \u2502\"darksalmon\"\u2502#E9967A\u2502rgb(233,\u00a0150,\u00a0122)\u2502\u2502 \u2502\"darkseagreen\"\u2502#8FBC8F\u2502rgb(143,\u00a0188,\u00a0143)\u2502\u2502 \u2502\"darkslateblue\"\u2502#483D8B\u2502rgb(72,\u00a061,\u00a0139)\u2502\u2502 \u2502\"darkslategray\"\u2502#2F4F4F\u2502rgb(47,\u00a079,\u00a079)\u2502\u2502 \u2502\"darkslategrey\"\u2502#2F4F4F\u2502rgb(47,\u00a079,\u00a079)\u2502\u2502 \u2502\"darkturquoise\"\u2502#00CED1\u2502rgb(0,\u00a0206,\u00a0209)\u2502\u2502 \u2502\"darkviolet\"\u2502#9400D3\u2502rgb(148,\u00a00,\u00a0211)\u2502\u2502 \u2502\"deeppink\"\u2502#FF1493\u2502rgb(255,\u00a020,\u00a0147)\u2502\u2502 \u2502\"deepskyblue\"\u2502#00BFFF\u2502rgb(0,\u00a0191,\u00a0255)\u2502\u2502 \u2502\"dimgray\"\u2502#696969\u2502rgb(105,\u00a0105,\u00a0105)\u2502\u2502 \u2502\"dimgrey\"\u2502#696969\u2502rgb(105,\u00a0105,\u00a0105)\u2502\u2502 \u2502\"dodgerblue\"\u2502#1E90FF\u2502rgb(30,\u00a0144,\u00a0255)\u2502\u2502 \u2502\"firebrick\"\u2502#B22222\u2502rgb(178,\u00a034,\u00a034)\u2502\u2502 \u2502\"floralwhite\"\u2502#FFFAF0\u2502rgb(255,\u00a0250,\u00a0240)\u2502\u2502 \u2502\"forestgreen\"\u2502#228B22\u2502rgb(34,\u00a0139,\u00a034)\u2502\u2502 \u2502\"fuchsia\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"gainsboro\"\u2502#DCDCDC\u2502rgb(220,\u00a0220,\u00a0220)\u2502\u2502 \u2502\"ghostwhite\"\u2502#F8F8FF\u2502rgb(248,\u00a0248,\u00a0255)\u2502\u2502 \u2502\"gold\"\u2502#FFD700\u2502rgb(255,\u00a0215,\u00a00)\u2502\u2502 \u2502\"goldenrod\"\u2502#DAA520\u2502rgb(218,\u00a0165,\u00a032)\u2502\u2502 \u2502\"gray\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"green\"\u2502#008000\u2502rgb(0,\u00a0128,\u00a00)\u2502\u2502 \u2502\"greenyellow\"\u2502#ADFF2F\u2502rgb(173,\u00a0255,\u00a047)\u2502\u2502 \u2502\"grey\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"honeydew\"\u2502#F0FFF0\u2502rgb(240,\u00a0255,\u00a0240)\u2502\u2502 \u2502\"hotpink\"\u2502#FF69B4\u2502rgb(255,\u00a0105,\u00a0180)\u2502\u2502 \u2502\"indianred\"\u2502#CD5C5C\u2502rgb(205,\u00a092,\u00a092)\u2502\u2502 \u2502\"indigo\"\u2502#4B0082\u2502rgb(75,\u00a00,\u00a0130)\u2502\u2502 \u2502\"ivory\"\u2502#FFFFF0\u2502rgb(255,\u00a0255,\u00a0240)\u2502\u2502 \u2502\"khaki\"\u2502#F0E68C\u2502rgb(240,\u00a0230,\u00a0140)\u2502\u2502 \u2502\"lavender\"\u2502#E6E6FA\u2502rgb(230,\u00a0230,\u00a0250)\u2502\u2502 \u2502\"lavenderblush\"\u2502#FFF0F5\u2502rgb(255,\u00a0240,\u00a0245)\u2502\u2502 \u2502\"lawngreen\"\u2502#7CFC00\u2502rgb(124,\u00a0252,\u00a00)\u2502\u2502 \u2502\"lemonchiffon\"\u2502#FFFACD\u2502rgb(255,\u00a0250,\u00a0205)\u2502\u2502 \u2502\"lightblue\"\u2502#ADD8E6\u2502rgb(173,\u00a0216,\u00a0230)\u2502\u2502 \u2502\"lightcoral\"\u2502#F08080\u2502rgb(240,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"lightcyan\"\u2502#E0FFFF\u2502rgb(224,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"lightgoldenrodyellow\"\u2502#FAFAD2\u2502rgb(250,\u00a0250,\u00a0210)\u2502\u2502 \u2502\"lightgray\"\u2502#D3D3D3\u2502rgb(211,\u00a0211,\u00a0211)\u2502\u2502 \u2502\"lightgreen\"\u2502#90EE90\u2502rgb(144,\u00a0238,\u00a0144)\u2502\u2502 \u2502\"lightgrey\"\u2502#D3D3D3\u2502rgb(211,\u00a0211,\u00a0211)\u2502\u2502 \u2502\"lightpink\"\u2502#FFB6C1\u2502rgb(255,\u00a0182,\u00a0193)\u2502\u2502 \u2502\"lightsalmon\"\u2502#FFA07A\u2502rgb(255,\u00a0160,\u00a0122)\u2502\u2502 \u2502\"lightseagreen\"\u2502#20B2AA\u2502rgb(32,\u00a0178,\u00a0170)\u2502\u2502 \u2502\"lightskyblue\"\u2502#87CEFA\u2502rgb(135,\u00a0206,\u00a0250)\u2502\u2502 \u2502\"lightslategray\"\u2502#778899\u2502rgb(119,\u00a0136,\u00a0153)\u2502\u2502 \u2502\"lightslategrey\"\u2502#778899\u2502rgb(119,\u00a0136,\u00a0153)\u2502\u2502 \u2502\"lightsteelblue\"\u2502#B0C4DE\u2502rgb(176,\u00a0196,\u00a0222)\u2502\u2502 \u2502\"lightyellow\"\u2502#FFFFE0\u2502rgb(255,\u00a0255,\u00a0224)\u2502\u2502 \u2502\"lime\"\u2502#00FF00\u2502rgb(0,\u00a0255,\u00a00)\u2502\u2502 \u2502\"limegreen\"\u2502#32CD32\u2502rgb(50,\u00a0205,\u00a050)\u2502\u2502 \u2502\"linen\"\u2502#FAF0E6\u2502rgb(250,\u00a0240,\u00a0230)\u2502\u2502 \u2502\"magenta\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"maroon\"\u2502#800000\u2502rgb(128,\u00a00,\u00a00)\u2502\u2502 \u2502\"mediumaquamarine\"\u2502#66CDAA\u2502rgb(102,\u00a0205,\u00a0170)\u2502\u2502 \u2502\"mediumblue\"\u2502#0000CD\u2502rgb(0,\u00a00,\u00a0205)\u2502\u2502 \u2502\"mediumorchid\"\u2502#BA55D3\u2502rgb(186,\u00a085,\u00a0211)\u2502\u2502 \u2502\"mediumpurple\"\u2502#9370DB\u2502rgb(147,\u00a0112,\u00a0219)\u2502\u2502 \u2502\"mediumseagreen\"\u2502#3CB371\u2502rgb(60,\u00a0179,\u00a0113)\u2502\u2502 \u2502\"mediumslateblue\"\u2502#7B68EE\u2502rgb(123,\u00a0104,\u00a0238)\u2502\u2502 \u2502\"mediumspringgreen\"\u2502#00FA9A\u2502rgb(0,\u00a0250,\u00a0154)\u2502\u2502 \u2502\"mediumturquoise\"\u2502#48D1CC\u2502rgb(72,\u00a0209,\u00a0204)\u2502\u2502 \u2502\"mediumvioletred\"\u2502#C71585\u2502rgb(199,\u00a021,\u00a0133)\u2502\u2502 \u2502\"midnightblue\"\u2502#191970\u2502rgb(25,\u00a025,\u00a0112)\u2502\u2502 \u2502\"mintcream\"\u2502#F5FFFA\u2502rgb(245,\u00a0255,\u00a0250)\u2502\u2502 \u2502\"mistyrose\"\u2502#FFE4E1\u2502rgb(255,\u00a0228,\u00a0225)\u2502\u2502 \u2502\"moccasin\"\u2502#FFE4B5\u2502rgb(255,\u00a0228,\u00a0181)\u2502\u2502 \u2502\"navajowhite\"\u2502#FFDEAD\u2502rgb(255,\u00a0222,\u00a0173)\u2502\u2502 \u2502\"navy\"\u2502#000080\u2502rgb(0,\u00a00,\u00a0128)\u2502\u2502 \u2502\"oldlace\"\u2502#FDF5E6\u2502rgb(253,\u00a0245,\u00a0230)\u2502\u2502 \u2502\"olive\"\u2502#808000\u2502rgb(128,\u00a0128,\u00a00)\u2502\u2502 \u2502\"olivedrab\"\u2502#6B8E23\u2502rgb(107,\u00a0142,\u00a035)\u2502\u2502 \u2502\"orange\"\u2502#FFA500\u2502rgb(255,\u00a0165,\u00a00)\u2502\u2502 \u2502\"orangered\"\u2502#FF4500\u2502rgb(255,\u00a069,\u00a00)\u2502\u2502 \u2502\"orchid\"\u2502#DA70D6\u2502rgb(218,\u00a0112,\u00a0214)\u2502\u2502 \u2502\"palegoldenrod\"\u2502#EEE8AA\u2502rgb(238,\u00a0232,\u00a0170)\u2502\u2502 \u2502\"palegreen\"\u2502#98FB98\u2502rgb(152,\u00a0251,\u00a0152)\u2502\u2502 \u2502\"paleturquoise\"\u2502#AFEEEE\u2502rgb(175,\u00a0238,\u00a0238)\u2502\u2502 \u2502\"palevioletred\"\u2502#DB7093\u2502rgb(219,\u00a0112,\u00a0147)\u2502\u2502 \u2502\"papayawhip\"\u2502#FFEFD5\u2502rgb(255,\u00a0239,\u00a0213)\u2502\u2502 \u2502\"peachpuff\"\u2502#FFDAB9\u2502rgb(255,\u00a0218,\u00a0185)\u2502\u2502 \u2502\"peru\"\u2502#CD853F\u2502rgb(205,\u00a0133,\u00a063)\u2502\u2502 \u2502\"pink\"\u2502#FFC0CB\u2502rgb(255,\u00a0192,\u00a0203)\u2502\u2502 \u2502\"plum\"\u2502#DDA0DD\u2502rgb(221,\u00a0160,\u00a0221)\u2502\u2502 \u2502\"powderblue\"\u2502#B0E0E6\u2502rgb(176,\u00a0224,\u00a0230)\u2502\u2502 \u2502\"purple\"\u2502#800080\u2502rgb(128,\u00a00,\u00a0128)\u2502\u2502 \u2502\"rebeccapurple\"\u2502#663399\u2502rgb(102,\u00a051,\u00a0153)\u2502\u2502 \u2502\"red\"\u2502#FF0000\u2502rgb(255,\u00a00,\u00a00)\u2502\u2502 \u2502\"rosybrown\"\u2502#BC8F8F\u2502rgb(188,\u00a0143,\u00a0143)\u2502\u2502 \u2502\"royalblue\"\u2502#4169E1\u2502rgb(65,\u00a0105,\u00a0225)\u2502\u2502 \u2502\"saddlebrown\"\u2502#8B4513\u2502rgb(139,\u00a069,\u00a019)\u2502\u2502 \u2502\"salmon\"\u2502#FA8072\u2502rgb(250,\u00a0128,\u00a0114)\u2502\u2502 \u2502\"sandybrown\"\u2502#F4A460\u2502rgb(244,\u00a0164,\u00a096)\u2502\u2502 \u2502\"seagreen\"\u2502#2E8B57\u2502rgb(46,\u00a0139,\u00a087)\u2502\u2502 \u2502\"seashell\"\u2502#FFF5EE\u2502rgb(255,\u00a0245,\u00a0238)\u2502\u2502 \u2502\"sienna\"\u2502#A0522D\u2502rgb(160,\u00a082,\u00a045)\u2502\u2502 \u2502\"silver\"\u2502#C0C0C0\u2502rgb(192,\u00a0192,\u00a0192)\u2502\u2502 \u2502\"skyblue\"\u2502#87CEEB\u2502rgb(135,\u00a0206,\u00a0235)\u2502\u2502 \u2502\"slateblue\"\u2502#6A5ACD\u2502rgb(106,\u00a090,\u00a0205)\u2502\u2502 \u2502\"slategray\"\u2502#708090\u2502rgb(112,\u00a0128,\u00a0144)\u2502\u2502 \u2502\"slategrey\"\u2502#708090\u2502rgb(112,\u00a0128,\u00a0144)\u2502\u2502 \u2502\"snow\"\u2502#FFFAFA\u2502rgb(255,\u00a0250,\u00a0250)\u2502\u2502 \u2502\"springgreen\"\u2502#00FF7F\u2502rgb(0,\u00a0255,\u00a0127)\u2502\u2502 \u2502\"steelblue\"\u2502#4682B4\u2502rgb(70,\u00a0130,\u00a0180)\u2502\u2502 \u2502\"tan\"\u2502#D2B48C\u2502rgb(210,\u00a0180,\u00a0140)\u2502\u2502 \u2502\"teal\"\u2502#008080\u2502rgb(0,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"thistle\"\u2502#D8BFD8\u2502rgb(216,\u00a0191,\u00a0216)\u2502\u2502 \u2502\"tomato\"\u2502#FF6347\u2502rgb(255,\u00a099,\u00a071)\u2502\u2502 \u2502\"turquoise\"\u2502#40E0D0\u2502rgb(64,\u00a0224,\u00a0208)\u2502\u2502 \u2502\"violet\"\u2502#EE82EE\u2502rgb(238,\u00a0130,\u00a0238)\u2502\u2502 \u2502\"wheat\"\u2502#F5DEB3\u2502rgb(245,\u00a0222,\u00a0179)\u2502\u2502 \u2502\"white\"\u2502#FFFFFF\u2502rgb(255,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"whitesmoke\"\u2502#F5F5F5\u2502rgb(245,\u00a0245,\u00a0245)\u2502\u2502 \u2502\"yellow\"\u2502#FFFF00\u2502rgb(255,\u00a0255,\u00a00)\u2502\u2502 \u2502\"yellowgreen\"\u2502#9ACD32\u2502rgb(154,\u00a0205,\u00a050)\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"api/color/#textual.color.BLACK","title":"BLACKmodule-attribute
","text":"BLACK: Final = Color(0, 0, 0)\n
A constant for pure black.
"},{"location":"api/color/#textual.color.WHITE","title":"WHITEmodule-attribute
","text":"WHITE: Final = Color(255, 255, 255)\n
A constant for pure white.
"},{"location":"api/color/#textual.color.Color","title":"Colorclass
","text":" Bases: NamedTuple
A class to represent a color.
Colors are stored as three values representing the degree of red, green, and blue in a color, and a fourth \"alpha\" value which defines where the color lies on a gradient of opaque to transparent.
Example>>> from textual.color import Color\n>>> color = Color.parse(\"red\")\n>>> color\nColor(255, 0, 0)\n>>> color.darken(0.5)\nColor(98, 0, 0)\n>>> color + Color.parse(\"green\")\nColor(0, 128, 0)\n>>> color_with_alpha = Color(100, 50, 25, 0.5)\n>>> color_with_alpha\nColor(100, 50, 25, a=0.5)\n>>> color + color_with_alpha\nColor(177, 25, 12)\n
"},{"location":"api/color/#textual.color.Color.a","title":"a class-attribute
instance-attribute
","text":"a: float = 1.0\n
Alpha (opacity) component in range 0 to 1.
"},{"location":"api/color/#textual.color.Color.b","title":"binstance-attribute
","text":"b: int\n
Blue component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.brightness","title":"brightnessproperty
","text":"brightness: float\n
The human perceptual brightness.
A value of 1 is returned for pure white, and 0 for pure black. Other colors lie on a gradient between the two extremes.
"},{"location":"api/color/#textual.color.Color.clamped","title":"clampedproperty
","text":"clamped: Color\n
A clamped color (this color with all values in expected range).
"},{"location":"api/color/#textual.color.Color.css","title":"cssproperty
","text":"css: str\n
The color in CSS RGB or RGBA form.
For example, \"rgb(10,20,30)\"
for an RGB color, or \"rgb(50,70,80,0.5)\"
for an RGBA color.
instance-attribute
","text":"g: int\n
Green component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.hex","title":"hexproperty
","text":"hex: str\n
The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA.
For example, \"#46b3de\"
for an RGB color, or \"#3342457f\"
for a color with alpha.
property
","text":"hex6: str\n
The color in CSS hex form, with 6 digits for RGB. Alpha is ignored.
For example, \"#46b3de\"
.
property
","text":"hsl: HSL\n
This color in HSL format.
HSL color is an alternative way of representing a color, which can be used in certain color calculations.
Returns Type DescriptionHSL
Color encoded in HSL format.
"},{"location":"api/color/#textual.color.Color.inverse","title":"inverseproperty
","text":"inverse: Color\n
The inverse of this color.
Returns Type DescriptionColor
Inverse color.
"},{"location":"api/color/#textual.color.Color.is_transparent","title":"is_transparentproperty
","text":"is_transparent: bool\n
Is the color transparent (i.e. has 0 alpha)?
"},{"location":"api/color/#textual.color.Color.monochrome","title":"monochromeproperty
","text":"monochrome: Color\n
A monochrome version of this color.
Returns Type DescriptionColor
The monochrome (black and white) version of this color.
"},{"location":"api/color/#textual.color.Color.normalized","title":"normalizedproperty
","text":"normalized: tuple[float, float, float]\n
A tuple of the color components normalized to between 0 and 1.
Returns Type Descriptiontuple[float, float, float]
Normalized components.
"},{"location":"api/color/#textual.color.Color.r","title":"rinstance-attribute
","text":"r: int\n
Red component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.rgb","title":"rgbproperty
","text":"rgb: tuple[int, int, int]\n
The red, green, and blue color components as a tuple of ints.
"},{"location":"api/color/#textual.color.Color.rich_color","title":"rich_colorproperty
","text":"rich_color: RichColor\n
This color encoded in Rich's Color class.
Returns Type DescriptionRichColor
A color object as used by Rich.
"},{"location":"api/color/#textual.color.Color.blend","title":"blendcached
","text":"def blend(self, destination, factor, alpha=None):\n
Generate a new color between two colors.
This method calculates a new color on a gradient. The position on the gradient is given by factor
, which is a float between 0 and 1, where 0 is the original color, and 1 is the destination
color. A value of gradient
between the two extremes produces a color somewhere between the two end points.
destination
Color
Another color.
requiredfactor
float
A blend factor, 0 -> 1.
requiredalpha
float | None
New alpha for result.
None
Returns Type Description Color
A new color.
"},{"location":"api/color/#textual.color.Color.darken","title":"darkencached
","text":"def darken(self, amount, alpha=None):\n
Darken the color by a given amount.
Parameters Name Type Description Defaultamount
float
Value between 0-1 to reduce luminance by.
requiredalpha
float | None
Alpha component for new color or None to copy alpha.
None
Returns Type Description Color
New color.
"},{"location":"api/color/#textual.color.Color.from_hsl","title":"from_hslclassmethod
","text":"def from_hsl(cls, h, s, l):\n
Create a color from HLS components.
Parameters Name Type Description Defaulth
float
Hue.
requiredl
float
Lightness.
requireds
float
Saturation.
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.Color.from_rich_color","title":"from_rich_colorclassmethod
","text":"def from_rich_color(cls, rich_color):\n
Create a new color from Rich's Color class.
Parameters Name Type Description Defaultrich_color
RichColor
An instance of Rich color.
required Returns Type DescriptionColor
A new Color instance.
"},{"location":"api/color/#textual.color.Color.get_contrast_text","title":"get_contrast_textcached
","text":"def get_contrast_text(self, alpha=0.95):\n
Get a light or dark color that best contrasts this color, for use with text.
Parameters Name Type Description Defaultalpha
float
An alpha value to apply to the result.
0.95
Returns Type Description Color
A new color, either an off-white or off-black.
"},{"location":"api/color/#textual.color.Color.lighten","title":"lightenmethod
","text":"def lighten(self, amount, alpha=None):\n
Lighten the color by a given amount.
Parameters Name Type Description Defaultamount
float
Value between 0-1 to increase luminance by.
requiredalpha
float | None
Alpha component for new color or None to copy alpha.
None
Returns Type Description Color
New color.
"},{"location":"api/color/#textual.color.Color.multiply_alpha","title":"multiply_alphamethod
","text":"def multiply_alpha(self, alpha):\n
Create a new color, multiplying the alpha by a constant.
Parameters Name Type Description Defaultalpha
float
A value to multiple the alpha by (expected to be in the range 0 to 1).
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.Color.parse","title":"parsecached
classmethod
","text":"def parse(cls, color_text):\n
Parse a string containing a named color or CSS-style color.
Colors may be parsed from the following formats:
Text beginning with a #
is parsed as a hexadecimal color code, where R, G, B, and A must be hexadecimal digits (0-9A-F):
#RGB
#RGBA
#RRGGBB
#RRGGBBAA
Alternatively, RGB colors can also be specified in the format that follows, where R, G, and B must be numbers between 0 and 255 and A must be a value between 0 and 1:
rgb(R,G,B)
rgb(R,G,B,A)
The HSL model can also be used, with a syntax similar to the above, if H is a value between 0 and 360, S and L are percentages, and A is a value between 0 and 1:
hsl(H,S,L)
hsla(H,S,L,A)
Any other formats will raise a ColorParseError
.
color_text
str | Color
Text with a valid color format. Color objects will be returned unmodified.
required Raises Type DescriptionColorParseError
If the color is not encoded correctly.
Returns Type DescriptionColor
Instance encoding the color specified by the argument.
"},{"location":"api/color/#textual.color.Color.with_alpha","title":"with_alphamethod
","text":"def with_alpha(self, alpha):\n
Create a new color with the given alpha.
Parameters Name Type Description Defaultalpha
float
New value for alpha.
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.ColorParseError","title":"ColorParseErrorclass
","text":"def __init__(self, message, suggested_color=None):\n
Bases: Exception
A color failed to parse.
Parameters Name Type Description Defaultmessage
str
The error message
requiredsuggested_color
str | None
A close color we can suggest.
None
"},{"location":"api/color/#textual.color.Gradient","title":"Gradient class
","text":"def __init__(self, *stops):\n
Defines a color gradient.
A gradient is defined by a sequence of \"stops\" consisting of a float and a color. The stop indicate the color at that point on a spectrum between 0 and 1.
Parameters Name Type Description Defaultstops
tuple[float, Color]
A colors stop.
()
Raises Type Description ValueError
If any stops are missing (must be at least a stop for 0 and 1).
"},{"location":"api/color/#textual.color.Gradient.get_color","title":"get_colormethod
","text":"def get_color(self, position):\n
Get a color from the gradient at a position between 0 and 1.
Positions that are between stops will return a blended color.
Parameters Name Type Description Defaultposition
float
A number between 0 and 1, where 0 is the first stop, and 1 is the last.
required Returns Type DescriptionColor
A color.
"},{"location":"api/color/#textual.color.HSL","title":"HSLclass
","text":" Bases: NamedTuple
A color in HLS (Hue, Saturation, Lightness) format.
"},{"location":"api/color/#textual.color.HSL.css","title":"cssproperty
","text":"css: str\n
HSL in css format.
"},{"location":"api/color/#textual.color.HSL.h","title":"hinstance-attribute
","text":"h: float\n
Hue in range 0 to 1.
"},{"location":"api/color/#textual.color.HSL.l","title":"linstance-attribute
","text":"l: float\n
Lightness in range 0 to 1.
"},{"location":"api/color/#textual.color.HSL.s","title":"sinstance-attribute
","text":"s: float\n
Saturation in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV","title":"HSVclass
","text":" Bases: NamedTuple
A color in HSV (Hue, Saturation, Value) format.
"},{"location":"api/color/#textual.color.HSV.h","title":"hinstance-attribute
","text":"h: float\n
Hue in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV.s","title":"sinstance-attribute
","text":"s: float\n
Saturation in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV.v","title":"vinstance-attribute
","text":"v: float\n
Value un range 0 to 1.
"},{"location":"api/color/#textual.color.Lab","title":"Labclass
","text":" Bases: NamedTuple
A color in CIE-L*ab format.
"},{"location":"api/color/#textual.color.Lab.L","title":"Linstance-attribute
","text":"L: float\n
Lightness in range 0 to 100.
"},{"location":"api/color/#textual.color.Lab.a","title":"ainstance-attribute
","text":"a: float\n
A axis in range -127 to 128.
"},{"location":"api/color/#textual.color.Lab.b","title":"binstance-attribute
","text":"b: float\n
B axis in range -127 to 128.
"},{"location":"api/color/#textual.color.lab_to_rgb","title":"lab_to_rgbfunction
","text":"def lab_to_rgb(lab, alpha=1.0):\n
Convert a CIE-L*ab color to RGB.
Uses the standard RGB color space with a D65/2\u2070 standard illuminant. Conversion passes through the XYZ color space. Cf. http://www.easyrgb.com/en/math.php.
"},{"location":"api/color/#textual.color.rgb_to_lab","title":"rgb_to_labfunction
","text":"def rgb_to_lab(rgb):\n
Convert an RGB color to the CIE-L*ab format.
Uses the standard RGB color space with a D65/2\u2070 standard illuminant. Conversion passes through the XYZ color space. Cf. http://www.easyrgb.com/en/math.php.
"},{"location":"api/command/","title":"Command","text":"The Textual command palette.
See the guide on the Command Palette for full details.
"},{"location":"api/command/#textual.command.Hits","title":"Hitsmodule-attribute
","text":"Hits: TypeAlias = AsyncIterator[Hit]\n
Return type for the command provider's search
method.
class
","text":"def __init__(self, prompt, command, id=None, disabled=False):\n
Bases: Option
Class that holds a command in the CommandList
.
prompt
RenderableType
The prompt for the option.
requiredcommand
Hit
The details of the command associated with the option.
requiredid
str | None
The optional ID for the option.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"api/command/#textual.command.Command.command","title":"command instance-attribute
","text":"command = command\n
The details of the command associated with the option.
"},{"location":"api/command/#textual.command.CommandInput","title":"CommandInputclass
","text":" Bases: Input
The command palette input control.
"},{"location":"api/command/#textual.command.CommandList","title":"CommandListclass
","text":" Bases: OptionList
The command palette command list.
"},{"location":"api/command/#textual.command.CommandPalette","title":"CommandPaletteclass
","text":"def __init__(self):\n
Bases: ModalScreen[CallbackType]
The Textual command palette.
"},{"location":"api/command/#textual.command.CommandPalette.BINDINGS","title":"BINDINGSclass-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"ctrl+end, shift+end\",\n\"command_list('last')\",\nshow=False,\n),\nBinding(\n\"ctrl+home, shift+home\",\n\"command_list('first')\",\nshow=False,\n),\nBinding(\"down\", \"cursor_down\", show=False),\nBinding(\"escape\", \"escape\", \"Exit the command palette\"),\nBinding(\n\"pagedown\", \"command_list('page_down')\", show=False\n),\nBinding(\n\"pageup\", \"command_list('page_up')\", show=False\n),\nBinding(\"up\", \"command_list('cursor_up')\", show=False),\n]\n
Key(s) Description ctrl+end, shift+end Jump to the last available commands. ctrl+home, shift+home Jump to the first available commands. down Navigate down through the available commands. escape Exit the command palette. pagedown Navigate down a page through the available commands. pageup Navigate up a page through the available commands. up Navigate up through the available commands."},{"location":"api/command/#textual.command.CommandPalette.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"command-palette--help-text\",\n\"command-palette--highlight\",\n}\n
Class Description command-palette--help-text
Targets the help text of a matched command. command-palette--highlight
Targets the highlights of a matched command."},{"location":"api/command/#textual.command.CommandPalette.run_on_select","title":"run_on_select class-attribute
","text":"run_on_select: bool = True\n
A flag to say if a command should be run when selected by the user.
If True
then when a user hits Enter
on a command match in the result list, or if they click on one with the mouse, the command will be selected and run. If set to False
the input will be filled with the command and then Enter
should be pressed on the keyboard or the 'go' button should be pressed.
staticmethod
","text":"def is_open(app):\n
Is the command palette current open?
Parameters Name Type Description Defaultapp
App
The app to test.
required Returns Type Descriptionbool
True
if the command palette is currently open, False
if not.
method
","text":"def on_mount(self, _):\n
Capture the calling screen.
"},{"location":"api/command/#textual.command.CommandPalette.on_unmount","title":"on_unmountasync
","text":"def on_unmount(self):\n
Shutdown providers when command palette is closed.
"},{"location":"api/command/#textual.command.Hit","title":"Hitclass
","text":"Holds the details of a single command search hit.
"},{"location":"api/command/#textual.command.Hit.command","title":"commandinstance-attribute
","text":"command: IgnoreReturnCallbackType\n
The function to call when the command is chosen.
"},{"location":"api/command/#textual.command.Hit.help","title":"helpclass-attribute
instance-attribute
","text":"help: str | None = None\n
Optional help text for the command.
"},{"location":"api/command/#textual.command.Hit.match_display","title":"match_displayinstance-attribute
","text":"match_display: RenderableType\n
A string or Rich renderable representation of the hit.
"},{"location":"api/command/#textual.command.Hit.score","title":"scoreinstance-attribute
","text":"score: float\n
The score of the command hit.
The value should be between 0 (no match) and 1 (complete match).
"},{"location":"api/command/#textual.command.Hit.text","title":"textclass-attribute
instance-attribute
","text":"text: str | None = None\n
The command text associated with the hit, as plain text.
If match_display
is not simple text, this attribute should be provided by the Provider object.
class
","text":"def __init__(\nself, query, *, match_style=None, case_sensitive=False\n):\n
A fuzzy matcher.
Parameters Name Type Description Defaultquery
str
A query as typed in by the user.
requiredmatch_style
Style | None
The style to use to highlight matched portions of a string.
None
case_sensitive
bool
Should matching be case sensitive?
False
"},{"location":"api/command/#textual.fuzzy.Matcher.case_sensitive","title":"case_sensitive property
","text":"case_sensitive: bool\n
Is this matcher case sensitive?
"},{"location":"api/command/#textual.fuzzy.Matcher.match_style","title":"match_styleproperty
","text":"match_style: Style\n
The style that will be used to highlight hits in the matched text.
"},{"location":"api/command/#textual.fuzzy.Matcher.query","title":"queryproperty
","text":"query: str\n
The query string to look for.
"},{"location":"api/command/#textual.fuzzy.Matcher.query_pattern","title":"query_patternproperty
","text":"query_pattern: str\n
The regular expression pattern built from the query.
"},{"location":"api/command/#textual.fuzzy.Matcher.highlight","title":"highlightmethod
","text":"def highlight(self, candidate):\n
Highlight the candidate with the fuzzy match.
Parameters Name Type Description Defaultcandidate
str
The candidate string to match against the query.
required Returns Type DescriptionText
A [rich.text.Text][Text
] object with highlighted matches.
method
","text":"def match(self, candidate):\n
Match the candidate against the query.
Parameters Name Type Description Defaultcandidate
str
Candidate string to match against the query.
required Returns Type Descriptionfloat
Strength of the match from 0 to 1.
"},{"location":"api/command/#textual.command.Provider","title":"Providerclass
","text":"def __init__(self, screen, match_style=None):\n
Bases: ABC
Base class for command palette command providers.
To create new command provider, inherit from this class and implement search
.
screen
Screen[Any]
A reference to the active screen.
required"},{"location":"api/command/#textual.command.Provider.app","title":"appproperty
","text":"app: App[object]\n
A reference to the application.
"},{"location":"api/command/#textual.command.Provider.focused","title":"focusedproperty
","text":"focused: Widget | None\n
The currently-focused widget in the currently-active screen in the application.
If no widget has focus this will be None
.
property
","text":"match_style: Style | None\n
The preferred style to use when highlighting matching portions of the match_display
.
property
","text":"screen: Screen[object]\n
The currently-active screen in the application.
"},{"location":"api/command/#textual.command.Provider.matcher","title":"matchermethod
","text":"def matcher(self, user_input, case_sensitive=False):\n
Create a fuzzy matcher for the given user input.
Parameters Name Type Description Defaultuser_input
str
The text that the user has input.
requiredcase_sensitive
bool
Should matching be case sensitive?
False
Returns Type Description Matcher
A fuzzy matcher object for matching against candidate hits.
"},{"location":"api/command/#textual.command.Provider.search","title":"searchasync
abstractmethod
","text":"def search(self, query):\n
A request to search for commands relevant to the given query.
Parameters Name Type Description Defaultquery
str
The user input to be matched.
requiredYields:
Type DescriptionHits
Instances of Hit
.
async
","text":"def shutdown(self):\n
Called when the Provider is shutdown.
Use this method to perform an cleanup, if required.
"},{"location":"api/command/#textual.command.Provider.startup","title":"startupasync
","text":"def startup(self):\n
Called after the Provider is initialized, but before any calls to search
.
class
","text":" Bases: Static
Widget for displaying a search icon before the command input.
"},{"location":"api/command/#textual.command.SearchIcon.icon","title":"iconclass-attribute
instance-attribute
","text":"icon: var[str] = var(\nEmoji.replace(\":magnifying_glass_tilted_right:\")\n)\n
The icon to display.
"},{"location":"api/containers/","title":"Containers","text":"Container widgets for quick styling.
With the exception of Center
and Middle
containers will fill all of the space in the parent widget.
class
","text":" Bases: Widget
A container which aligns children on the X axis.
"},{"location":"api/containers/#textual.containers.Container","title":"Containerclass
","text":" Bases: Widget
Simple container widget, with vertical layout.
"},{"location":"api/containers/#textual.containers.Grid","title":"Gridclass
","text":" Bases: Widget
A container with grid layout.
"},{"location":"api/containers/#textual.containers.Horizontal","title":"Horizontalclass
","text":" Bases: Widget
A container with horizontal layout and no scrollbars.
"},{"location":"api/containers/#textual.containers.HorizontalScroll","title":"HorizontalScrollclass
","text":" Bases: ScrollableContainer
A container with horizontal layout and an automatic scrollbar on the Y axis.
"},{"location":"api/containers/#textual.containers.Middle","title":"Middleclass
","text":" Bases: Widget
A container which aligns children on the Y axis.
"},{"location":"api/containers/#textual.containers.ScrollableContainer","title":"ScrollableContainerclass
","text":" Bases: Widget
A scrollable container with vertical layout, and auto scrollbars on both axis.
"},{"location":"api/containers/#textual.containers.ScrollableContainer.BINDINGS","title":"BINDINGSclass-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"up\", \"scroll_up\", \"Scroll Up\", show=False),\nBinding(\n\"down\", \"scroll_down\", \"Scroll Down\", show=False\n),\nBinding(\"left\", \"scroll_left\", \"Scroll Up\", show=False),\nBinding(\n\"right\", \"scroll_right\", \"Scroll Right\", show=False\n),\nBinding(\n\"home\", \"scroll_home\", \"Scroll Home\", show=False\n),\nBinding(\"end\", \"scroll_end\", \"Scroll End\", show=False),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\n]\n
Keyboard bindings for scrollable containers.
Key(s) Description up Scroll up, if vertical scrolling is available. down Scroll down, if vertical scrolling is available. left Scroll left, if horizontal scrolling is available. right Scroll right, if horizontal scrolling is available. home Scroll to the home position, if scrolling is available. end Scroll to the end position, if scrolling is available. pageup Scroll up one page, if vertical scrolling is available. pagedown Scroll down one page, if vertical scrolling is available."},{"location":"api/containers/#textual.containers.Vertical","title":"Verticalclass
","text":" Bases: Widget
A container with vertical layout and no scrollbars.
"},{"location":"api/containers/#textual.containers.VerticalScroll","title":"VerticalScrollclass
","text":" Bases: ScrollableContainer
A container with vertical layout and an automatic scrollbar on the Y axis.
"},{"location":"api/content_switcher/","title":"Content switcher","text":""},{"location":"api/content_switcher/#textual.widgets.ContentSwitcher","title":"textual.widgets.ContentSwitcherclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\ninitial=None\n):\n
Bases: Container
A widget for switching between different children.
NoteAll child widgets that are to be switched between need a unique ID. Children that have no ID will be hidden and ignored.
Parameters Name Type Description Default*children
Widget
The widgets to switch between.
()
name
str | None
The name of the content switcher.
None
id
str | None
The ID of the content switcher in the DOM.
None
classes
str | None
The CSS classes of the content switcher.
None
disabled
bool
Whether the content switcher is disabled or not.
False
initial
str | None
The ID of the initial widget to show, None
or empty string for the first tab.
None
Note If initial
is not supplied no children will be shown to start with.
class-attribute
instance-attribute
","text":"current: reactive[str | None] = reactive[Optional[str]](\nNone, init=False\n)\n
The ID of the currently-displayed widget.
If set to None
then no widget is visible.
If set to an unknown ID, this will result in NoMatches
being raised.
property
","text":"visible_content: Widget | None\n
A reference to the currently-visible widget.
None
if nothing is visible.
method
","text":"def watch_current(self, old, new):\n
React to the current visible child choice being changed.
Parameters Name Type Description Defaultold
str | None
The old widget ID (or None
if there was no widget).
new
str | None
The new widget ID (or None
if nothing should be shown).
A class to store a coordinate, used by the DataTable.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate","title":"Coordinateclass
","text":" Bases: NamedTuple
An object representing a row/column coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.column","title":"columninstance-attribute
","text":"column: int\n
The column of the coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.row","title":"rowinstance-attribute
","text":"row: int\n
The row of the coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.down","title":"downmethod
","text":"def down(self):\n
Get the coordinate below.
Returns Type DescriptionCoordinate
The coordinate below.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.left","title":"leftmethod
","text":"def left(self):\n
Get the coordinate to the left.
Returns Type DescriptionCoordinate
The coordinate to the left.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.right","title":"rightmethod
","text":"def right(self):\n
Get the coordinate to the right.
Returns Type DescriptionCoordinate
The coordinate to the right.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.up","title":"upmethod
","text":"def up(self):\n
Get the coordinate above.
Returns Type DescriptionCoordinate
The coordinate above.
"},{"location":"api/dom_node/","title":"Dom node","text":"A DOMNode is a base class for any object within the Textual Document Object Model, which includes all Widgets, Screens, and Apps.
"},{"location":"api/dom_node/#textual.dom.WalkMethod","title":"WalkMethodmodule-attribute
","text":"WalkMethod: TypeAlias = Literal['depth', 'breadth']\n
Valid walking methods for the DOMNode.walk_children
method.
class
","text":" Bases: Exception
Exception raised if you supply a id
attribute or class name in the wrong format.
class
","text":" Bases: Exception
Base exception class for errors relating to the DOM.
"},{"location":"api/dom_node/#textual.dom.DOMNode","title":"DOMNodeclass
","text":"def __init__(self, *, name=None, id=None, classes=None):\n
Bases: MessagePump
The base class for object that can be in the Textual DOM (App and Widget)
"},{"location":"api/dom_node/#textual.dom.DOMNode.SCOPED_CSS","title":"SCOPED_CSSclass-attribute
","text":"SCOPED_CSS: bool = True\n
Should default css be limited to the widget type?
"},{"location":"api/dom_node/#textual.dom.DOMNode.ancestors","title":"ancestorsproperty
","text":"ancestors: list[DOMNode]\n
A list of ancestor nodes Nodes by tracing ancestors all the way back to App.
Returns Type Descriptionlist[DOMNode]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.ancestors_with_self","title":"ancestors_with_selfproperty
","text":"ancestors_with_self: list[DOMNode]\n
A list of Nodes by tracing a path all the way back to App.
NoteThis is inclusive of self
.
list[DOMNode]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.auto_refresh","title":"auto_refreshwritable
property
","text":"auto_refresh: float | None\n
Number of seconds between automatic refresh, or None
for no automatic refresh.
property
","text":"background_colors: tuple[Color, Color]\n
The background color and the color of the parent's background.
Returns Type Descriptiontuple[Color, Color]
(<background color>, <color>)
property
","text":"children: Sequence['Widget']\n
A view on to the children.
Returns Type DescriptionSequence['Widget']
The node's children.
"},{"location":"api/dom_node/#textual.dom.DOMNode.classes","title":"classesclass-attribute
instance-attribute
","text":"classes = _ClassesDescriptor()\n
CSS class names for this node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.colors","title":"colorsproperty
","text":"colors: tuple[Color, Color, Color, Color]\n
The widget's background and foreground colors, and the parent's background and foreground colors.
Returns Type Descriptiontuple[Color, Color, Color, Color]
(<parent background>, <parent color>, <background>, <color>)
property
","text":"css_identifier: str\n
A CSS selector that identifies this DOM node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_identifier_styled","title":"css_identifier_styledproperty
","text":"css_identifier_styled: Text\n
A syntax highlighted CSS identifier.
Returns Type DescriptionText
A Rich Text object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_path_nodes","title":"css_path_nodesproperty
","text":"css_path_nodes: list[DOMNode]\n
A list of nodes from the App to this node, forming a \"path\".
Returns Type Descriptionlist[DOMNode]
A list of nodes, where the first item is the App, and the last is this node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_tree","title":"css_treeproperty
","text":"css_tree: Tree\n
A Rich tree to display the DOM, annotated with the node's CSS.
Log this to visualize your app in the textual console.
Exampleself.log(self.css_tree)\n
Returns Type Description Tree
A Tree renderable.
"},{"location":"api/dom_node/#textual.dom.DOMNode.display","title":"displaywritable
property
","text":"display: bool\n
Should the DOM node be displayed?
May be set to a boolean to show or hide the node, or to any valid value for the display
rule.
my_widget.display = False # Hide my_widget\n
"},{"location":"api/dom_node/#textual.dom.DOMNode.displayed_children","title":"displayed_children property
","text":"displayed_children: list[Widget]\n
The child nodes which will be displayed.
Returns Type Descriptionlist[Widget]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.id","title":"idwritable
property
","text":"id: str | None\n
The ID of this node, or None if the node has no ID.
"},{"location":"api/dom_node/#textual.dom.DOMNode.is_modal","title":"is_modalproperty
","text":"is_modal: bool\n
Is the node a modal?
"},{"location":"api/dom_node/#textual.dom.DOMNode.name","title":"nameproperty
","text":"name: str | None\n
The name of the node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.parent","title":"parentproperty
","text":"parent: DOMNode | None\n
The parent node.
All nodes have parent once added to the DOM, with the exception of the App which is the root node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.pseudo_classes","title":"pseudo_classesproperty
","text":"pseudo_classes: frozenset[str]\n
A (frozen) set of all pseudo classes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.rich_style","title":"rich_styleproperty
","text":"rich_style: Style\n
Get a Rich Style object for this DOMNode.
Returns Type DescriptionStyle
A Rich style.
"},{"location":"api/dom_node/#textual.dom.DOMNode.screen","title":"screenproperty
","text":"screen: 'Screen[object]'\n
The screen containing this node.
Returns Type Description'Screen[object]'
A screen object.
Raises Type DescriptionNoScreen
If this node isn't mounted (and has no screen).
"},{"location":"api/dom_node/#textual.dom.DOMNode.text_style","title":"text_styleproperty
","text":"text_style: Style\n
Get the text style object.
A widget's style is influenced by its parent. for instance if a parent is bold, then the child will also be bold.
Returns Type DescriptionStyle
A Rich Style.
"},{"location":"api/dom_node/#textual.dom.DOMNode.tree","title":"treeproperty
","text":"tree: Tree\n
A Rich tree to display the DOM.
Log this to visualize your app in the textual console.
Exampleself.log(self.tree)\n
Returns Type Description Tree
A Tree renderable.
"},{"location":"api/dom_node/#textual.dom.DOMNode.visible","title":"visiblewritable
property
","text":"visible: bool\n
Is this widget visible in the DOM?
If a widget hasn't had its visibility set explicitly, then it inherits it from its DOM ancestors.
This may be set explicitly to override inherited values. The valid values include the valid values for the visibility
rule and the booleans True
or False
, to set the widget to be visible or invisible, respectively.
When a node is invisible, Textual will reserve space for it, but won't display anything.
"},{"location":"api/dom_node/#textual.dom.DOMNode.workers","title":"workersproperty
","text":"workers: WorkerManager\n
The app's worker manager. Shortcut for self.app.workers
.
method
","text":"def add_class(self, *class_names):\n
Add class names to this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to add.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.get_component_styles","title":"get_component_stylesmethod
","text":"def get_component_styles(self, name):\n
Get a \"component\" styles object (must be defined in COMPONENT_CLASSES classvar).
Parameters Name Type Description Defaultname
str
Name of the component.
required Raises Type DescriptionKeyError
If the component class doesn't exist.
Returns Type DescriptionRenderStyles
A Styles object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.get_pseudo_classes","title":"get_pseudo_classesmethod
","text":"def get_pseudo_classes(self):\n
Get any pseudo classes applicable to this Node, e.g. hover, focus.
Returns Type DescriptionIterable[str]
Iterable of strings, such as a generator.
"},{"location":"api/dom_node/#textual.dom.DOMNode.has_class","title":"has_classmethod
","text":"def has_class(self, *class_names):\n
Check if the Node has all the given class names.
Parameters Name Type Description Default*class_names
str
CSS class names to check.
()
Returns Type Description bool
True
if the node has all the given class names, otherwise False
.
method
","text":"def has_pseudo_class(self, *class_names):\n
Check for pseudo classes (such as hover, focus etc)
Parameters Name Type Description Default*class_names
str
The pseudo classes to check for.
()
Returns Type Description bool
True
if the DOM node has those pseudo classes, False
if not.
method
","text":"def notify_style_update(self):\n
Called after styles are updated.
Implement this in a subclass if you want to clear any cached data when the CSS is reloaded.
"},{"location":"api/dom_node/#textual.dom.DOMNode.query","title":"querymethod
","text":"def query(self, selector=None):\n
Get a DOM query matching a selector.
Parameters Name Type Description Defaultselector
str | type[QueryType] | None
A CSS selector or None
for all nodes.
None
Returns Type Description DOMQuery[Widget] | DOMQuery[QueryType]
A query object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.query_one","title":"query_onemethod
","text":"def query_one(self, selector, expect_type=None):\n
Get a single Widget matching the given selector or selector type.
Parameters Name Type Description Defaultselector
str | type[QueryType]
A selector.
requiredexpect_type
type[QueryType] | None
Require the object be of the supplied type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If no node matches the query.
TooManyMatches
If there is more than one matching node in the query.
Returns Type DescriptionQueryType | Widget
A widget matching the selector.
"},{"location":"api/dom_node/#textual.dom.DOMNode.remove_class","title":"remove_classmethod
","text":"def remove_class(self, *class_names):\n
Remove class names from this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to remove.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.reset_styles","title":"reset_stylesmethod
","text":"def reset_styles(self):\n
Reset styles back to their initial state.
"},{"location":"api/dom_node/#textual.dom.DOMNode.run_worker","title":"run_workermethod
","text":"def run_worker(\nself,\nwork,\nname=\"\",\ngroup=\"default\",\ndescription=\"\",\nexit_on_error=True,\nstart=True,\nexclusive=False,\nthread=False,\n):\n
Run work in a worker.
A worker runs a function, coroutine, or awaitable, in the background as an async task or as a thread.
Parameters Name Type Description Defaultwork
WorkType[ResultType]
A function, async function, or an awaitable object to run in a worker.
requiredname
str | None
A short string to identify the worker (in logs and debugging).
''
group
str
A short string to identify a group of workers.
'default'
description
str
A longer string to store longer information on the worker.
''
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
start
bool
Start the worker immediately.
True
exclusive
bool
Cancel all workers in the same group.
False
thread
bool
Mark the worker as a thread worker.
False
Returns Type Description Worker[ResultType]
New Worker instance.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_class","title":"set_classmethod
","text":"def set_class(self, add, *class_names):\n
Add or remove class(es) based on a condition.
Parameters Name Type Description Defaultadd
bool
Add the classes if True, otherwise remove them.
required Returns Type DescriptionSelf
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_classes","title":"set_classesmethod
","text":"def set_classes(self, classes):\n
Replace all classes.
Parameters Name Type Description Defaultclasses
str | Iterable[str]
A string containing space separated classes, or an iterable of class names.
required Returns Type DescriptionSelf
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_styles","title":"set_stylesmethod
","text":"def set_styles(self, css=None, **update_styles):\n
Set custom styles on this object.
Parameters Name Type Description Defaultcss
str | None
Styles in CSS format.
None
**update_styles
Keyword arguments map style names on to style.
{}
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.toggle_class","title":"toggle_classmethod
","text":"def toggle_class(self, *class_names):\n
Toggle class names on this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to toggle.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.walk_children","title":"walk_childrenmethod
","text":"def walk_children(\nself,\nfilter_type=None,\n*,\nwith_self=False,\nmethod=\"depth\",\nreverse=False\n):\n
Walk the subtree rooted at this node, and return every descendant encountered in a list.
Parameters Name Type Description Defaultfilter_type
type[WalkType] | None
Filter only this type, or None for no filter.
None
with_self
bool
Also yield self in addition to descendants.
False
method
WalkMethod
One of \"depth\" or \"breadth\".
'depth'
reverse
bool
Reverse the order (bottom up).
False
Returns Type Description list[DOMNode] | list[WalkType]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.watch","title":"watchmethod
","text":"def watch(self, obj, attribute_name, callback, init=True):\n
Watches for modifications to reactive attributes on another object.
ExampleHere's how you could detect when the app changes from dark to light mode (and vice versa).
def on_dark_change(old_value:bool, new_value:bool):\n# Called when app.dark changes.\nprint(\"App.dark when from {old_value} to {new_value}\")\nself.watch(self.app, \"dark\", self.on_dark_change, init=False)\n
Parameters Name Type Description Default obj
DOMNode
Object containing attribute to watch.
requiredattribute_name
str
Attribute to watch.
requiredcallback
WatchCallbackType
A callback to run when attribute changes.
requiredinit
bool
Check watchers on first call.
True
"},{"location":"api/dom_node/#textual.dom.NoScreen","title":"NoScreen class
","text":" Bases: DOMError
Raised when the node has no associated screen.
"},{"location":"api/dom_node/#textual.dom.check_identifiers","title":"check_identifiersfunction
","text":"def check_identifiers(description, *names):\n
Validate identifier and raise an error if it fails.
Parameters Name Type Description Defaultdescription
str
Description of where identifier is used for error message.
required*names
str
Identifiers to check.
()
"},{"location":"api/errors/","title":"Errors","text":"General exception classes.
"},{"location":"api/errors/#textual.errors.DuplicateKeyHandlers","title":"DuplicateKeyHandlersclass
","text":" Bases: TextualError
More than one handler for a single key press.
For example, if the handlers key_ctrl_i
and key_tab
were defined on the same widget, then this error would be raised.
class
","text":" Bases: TextualError
Specified widget was not found.
"},{"location":"api/errors/#textual.errors.RenderError","title":"RenderErrorclass
","text":" Bases: TextualError
An object could not be rendered.
"},{"location":"api/errors/#textual.errors.TextualError","title":"TextualErrorclass
","text":" Bases: Exception
Base class for Textual errors.
"},{"location":"api/events/","title":"Events","text":"Builtin events sent by Textual.
Events may be marked as \"Bubbles\" and \"Verbose\". See the events guide for an explanation of bubbling. Verbose events are excluded from the textual console, unless you explicitly request them with the -v
switch as follows:
textual console -v\n
"},{"location":"api/events/#textual.events.Blur","title":"Blur class
","text":" Bases: Event
Sent when a widget is blurred (un-focussed).
class
","text":" Bases: MouseEvent
Sent when a widget is clicked.
class
","text":" Bases: Event
Sent to a widget to request it to compose and mount children.
class
","text":" Bases: Event
Sent when a child widget is blurred.
property
","text":"control: Widget\n
The widget that was blurred (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was blurred.
"},{"location":"api/events/#textual.events.DescendantFocus","title":"DescendantFocusclass
","text":" Bases: Event
Sent when a child widget is focussed.
property
","text":"control: Widget\n
The widget that was focused (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was focused.
"},{"location":"api/events/#textual.events.Enter","title":"Enterclass
","text":" Bases: Event
Sent when the mouse is moved over a widget.
class
","text":" Bases: Message
The base class for all events.
"},{"location":"api/events/#textual.events.Focus","title":"Focusclass
","text":" Bases: Event
Sent when a widget is focussed.
class
","text":" Bases: Event
Sent when a widget has been hidden.
A widget may be hidden by setting its visible
flag to False
, if it is no longer in a layout, or if it has been offset beyond the edges of the terminal.
class
","text":" Bases: Event
Sent when there are no more items in the message queue.
This is a pseudo-event in that it is created by the Textual system and doesn't go through the usual message queue.
class
","text":" Bases: Event
Base class for input events.
"},{"location":"api/events/#textual.events.Key","title":"Keyclass
","text":"def __init__(self, key, character):\n
Bases: InputEvent
Sent when the user hits a key on the keyboard.
key
str
The key that was pressed.
requiredcharacter
str | None
A printable character or None
if it is not printable.
aliases
list[str]
The aliases for the key, including the key itself.
"},{"location":"api/events/#textual.events.Key.is_printable","title":"is_printableproperty
","text":"is_printable: bool\n
Check if the key is printable (produces a unicode character).
Returns Type Descriptionbool
True if the key is printable.
"},{"location":"api/events/#textual.events.Key.name","title":"nameproperty
","text":"name: str\n
Name of a key suitable for use as a Python identifier.
"},{"location":"api/events/#textual.events.Key.name_aliases","title":"name_aliasesproperty
","text":"name_aliases: list[str]\n
The corresponding name for every alias in aliases
list.
class
","text":" Bases: Event
Sent when the mouse is moved away from a widget.
class
","text":" Bases: Event
Sent when the App is running but before the terminal is in application mode.
Use this event to run any set up that doesn't require any visuals such as loading configuration and binding keys.
class
","text":" Bases: Event
Sent when a widget is mounted and may receive messages.
class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Sent when the mouse has been captured.
When a mouse has been captured, all further mouse events will be sent to the capturing widget.
Parameters Name Type Description Defaultmouse_position
Offset
The position of the mouse when captured.
required"},{"location":"api/events/#textual.events.MouseDown","title":"MouseDownclass
","text":" Bases: MouseEvent
Sent when a mouse button is pressed.
class
","text":"def __init__(\nself,\nx,\ny,\ndelta_x,\ndelta_y,\nbutton,\nshift,\nmeta,\nctrl,\nscreen_x=None,\nscreen_y=None,\nstyle=None,\n):\n
Bases: InputEvent
Sent in response to a mouse event.
x
int
The relative x coordinate.
requiredy
int
The relative y coordinate.
requireddelta_x
int
Change in x since the last message.
requireddelta_y
int
Change in y since the last message.
requiredbutton
int
Indexed of the pressed button.
requiredshift
bool
True if the shift key is pressed.
requiredmeta
bool
True if the meta key is pressed.
requiredctrl
bool
True if the ctrl key is pressed.
requiredscreen_x
int | None
The absolute x coordinate.
None
screen_y
int | None
The absolute y coordinate.
None
style
Style | None
The Rich Style under the mouse cursor.
None
"},{"location":"api/events/#textual.events.MouseEvent.delta","title":"delta property
","text":"delta: Offset\n
Mouse coordinate delta (change since last event).
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.offset","title":"offsetproperty
","text":"offset: Offset\n
The mouse coordinate as an offset.
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.screen_offset","title":"screen_offsetproperty
","text":"screen_offset: Offset\n
Mouse coordinate relative to the screen.
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.style","title":"stylewritable
property
","text":"style: Style\n
The (Rich) Style under the cursor.
"},{"location":"api/events/#textual.events.MouseEvent.get_content_offset","title":"get_content_offsetmethod
","text":"def get_content_offset(self, widget):\n
Get offset within a widget's content area, or None if offset is not in content (i.e. padding or border).
Parameters Name Type Description Defaultwidget
Widget
Widget receiving the event.
required Returns Type DescriptionOffset | None
An offset where the origin is at the top left of the content area.
"},{"location":"api/events/#textual.events.MouseEvent.get_content_offset_capture","title":"get_content_offset_capturemethod
","text":"def get_content_offset_capture(self, widget):\n
Get offset from a widget's content area.
This method works even if the offset is outside the widget content region.
Parameters Name Type Description Defaultwidget
Widget
Widget receiving the event.
required Returns Type DescriptionOffset
An offset where the origin is at the top left of the content area.
"},{"location":"api/events/#textual.events.MouseMove","title":"MouseMoveclass
","text":" Bases: MouseEvent
Sent when the mouse cursor moves.
class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Mouse has been released.
mouse_position
Offset
The position of the mouse when released.
required"},{"location":"api/events/#textual.events.MouseScrollDown","title":"MouseScrollDownclass
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled down.
class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled up.
class
","text":" Bases: MouseEvent
Sent when a mouse button is released.
class
","text":"def __init__(self, text):\n
Bases: Event
Event containing text that was pasted into the Textual application. This event will only appear when running in a terminal emulator that supports bracketed paste mode. Textual will enable bracketed pastes when an app starts, and disable it when the app shuts down.
text
str
The text that has been pasted.
required"},{"location":"api/events/#textual.events.Print","title":"Printclass
","text":"def __init__(self, text, stderr=False):\n
Bases: Event
Sent to a widget that is capturing prints.
text
str
Text that was printed.
requiredstderr
bool
True if the print was to stderr, or False for stdout.
False
"},{"location":"api/events/#textual.events.Ready","title":"Ready class
","text":" Bases: Event
Sent to the app when the DOM is ready.
class
","text":"def __init__(self, size, virtual_size, container_size=None):\n
Bases: Event
Sent when the app or widget has been resized.
size
Size
The new size of the Widget.
requiredvirtual_size
Size
The virtual size (scrollable size) of the Widget.
requiredcontainer_size
Size | None
The size of the Widget's container widget.
None
"},{"location":"api/events/#textual.events.ScreenResume","title":"ScreenResume class
","text":" Bases: Event
Sent to screen that has been made active.
class
","text":" Bases: Event
Sent to screen when it is no longer active.
class
","text":" Bases: Event
Sent when a widget has become visible.
class
","text":"def __init__(self, timer, time, count=0, callback=None):\n
Bases: Event
Sent in response to a timer.
class
","text":" Bases: Event
Sent when a widget is unmounted and may not longer receive messages.
Filter classes.
Note
Filters are used internally, and not recommended for use by Textual app developers.
Filters are used internally to process terminal output after it has been rendered. Currently this is used internally to convert the application to monochrome, when the NO_COLOR env var is set.
In the future, this system will be used to implement accessibility features.
"},{"location":"api/filter/#textual.filter.NO_DIM","title":"NO_DIMmodule-attribute
","text":"NO_DIM = Style(dim=False)\n
A Style to set dim to False.
"},{"location":"api/filter/#textual.filter.ANSIToTruecolor","title":"ANSIToTruecolorclass
","text":"def __init__(self, terminal_theme):\n
Bases: LineFilter
Convert ANSI colors to their truecolor equivalents.
Parameters Name Type Description Defaultterminal_theme
TerminalTheme
A rich terminal theme.
required"},{"location":"api/filter/#textual.filter.ANSIToTruecolor.apply","title":"applymethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.ANSIToTruecolor.truecolor_style","title":"truecolor_stylecached
","text":"def truecolor_style(self, style):\n
Replace system colors with truecolor equivalent.
Parameters Name Type Description Defaultstyle
Style
Style to apply truecolor filter to.
required Returns Type DescriptionStyle
New style.
"},{"location":"api/filter/#textual.filter.DimFilter","title":"DimFilterclass
","text":"def __init__(self, dim_factor=0.5):\n
Bases: LineFilter
Replace dim attributes with modified colors.
Parameters Name Type Description Defaultdim_factor
float
The factor to dim by; 0 is 100% background (i.e. invisible), 1.0 is no change.
0.5
"},{"location":"api/filter/#textual.filter.DimFilter.apply","title":"apply method
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.LineFilter","title":"LineFilterclass
","text":" Bases: ABC
Base class for a line filter.
"},{"location":"api/filter/#textual.filter.LineFilter.apply","title":"applyabstractmethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.Monochrome","title":"Monochromeclass
","text":" Bases: LineFilter
Convert all colors to monochrome.
"},{"location":"api/filter/#textual.filter.Monochrome.apply","title":"applymethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.dim_color","title":"dim_colorcached
","text":"def dim_color(background, color, factor):\n
Dim a color by blending towards the background
Parameters Name Type Description Defaultbackground
RichColor
background color.
requiredcolor
RichColor
Foreground color.
requiredfactor
float
Blend factor
required Returns Type DescriptionRichColor
New dimmer color.
"},{"location":"api/filter/#textual.filter.dim_style","title":"dim_stylecached
","text":"def dim_style(style, background, factor):\n
Replace dim attribute with a dim color.
Parameters Name Type Description Defaultstyle
Style
Style to dim.
requiredfactor
float
Blend factor.
required Returns Type DescriptionStyle
New dimmed style.
"},{"location":"api/filter/#textual.filter.monochrome_style","title":"monochrome_stylecached
","text":"def monochrome_style(style):\n
Convert colors in a style to monochrome.
Parameters Name Type Description Defaultstyle
Style
A Rich Style.
required Returns Type DescriptionStyle
A new Rich style.
"},{"location":"api/fuzzy_matcher/","title":"Fuzzy matcher","text":"Fuzzy matcher.
This class is used by the command palette to match search terms.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher","title":"Matcherclass
","text":"def __init__(\nself, query, *, match_style=None, case_sensitive=False\n):\n
A fuzzy matcher.
Parameters Name Type Description Defaultquery
str
A query as typed in by the user.
requiredmatch_style
Style | None
The style to use to highlight matched portions of a string.
None
case_sensitive
bool
Should matching be case sensitive?
False
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.case_sensitive","title":"case_sensitive property
","text":"case_sensitive: bool\n
Is this matcher case sensitive?
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.match_style","title":"match_styleproperty
","text":"match_style: Style\n
The style that will be used to highlight hits in the matched text.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.query","title":"queryproperty
","text":"query: str\n
The query string to look for.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.query_pattern","title":"query_patternproperty
","text":"query_pattern: str\n
The regular expression pattern built from the query.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.highlight","title":"highlightmethod
","text":"def highlight(self, candidate):\n
Highlight the candidate with the fuzzy match.
Parameters Name Type Description Defaultcandidate
str
The candidate string to match against the query.
required Returns Type DescriptionText
A [rich.text.Text][Text
] object with highlighted matches.
method
","text":"def match(self, candidate):\n
Match the candidate against the query.
Parameters Name Type Description Defaultcandidate
str
Candidate string to match against the query.
required Returns Type Descriptionfloat
Strength of the match from 0 to 1.
"},{"location":"api/geometry/","title":"Geometry","text":"Functions and classes to manage terminal geometry (anything involving coordinates or dimensions).
"},{"location":"api/geometry/#textual.geometry.NULL_OFFSET","title":"NULL_OFFSETmodule-attribute
","text":"NULL_OFFSET: Final = Offset(0, 0)\n
An offset constant for (0, 0).
"},{"location":"api/geometry/#textual.geometry.NULL_REGION","title":"NULL_REGIONmodule-attribute
","text":"NULL_REGION: Final = Region(0, 0, 0, 0)\n
A Region constant for a null region (at the origin, with both width and height set to zero).
"},{"location":"api/geometry/#textual.geometry.NULL_SPACING","title":"NULL_SPACINGmodule-attribute
","text":"NULL_SPACING: Final = Spacing(0, 0, 0, 0)\n
A Spacing constant for no space.
"},{"location":"api/geometry/#textual.geometry.SpacingDimensions","title":"SpacingDimensionsmodule-attribute
","text":"SpacingDimensions: TypeAlias = Union[\nint,\nTuple[int],\nTuple[int, int],\nTuple[int, int, int, int],\n]\n
The valid ways in which you can specify spacing.
"},{"location":"api/geometry/#textual.geometry.Offset","title":"Offsetclass
","text":" Bases: NamedTuple
A cell offset defined by x and y coordinates.
Offsets are typically relative to the top left of the terminal or other container.
Textual prefers the names x
and y
, but you could consider x
to be the column and y
to be the row.
Offsets support addition, subtraction, multiplication, and negation.
Example>>> from textual.geometry import Offset\n>>> offset = Offset(3, 2)\n>>> offset\nOffset(x=3, y=2)\n>>> offset += Offset(10, 0)\n>>> offset\nOffset(x=13, y=2)\n>>> -offset\nOffset(x=-13, y=-2)\n
"},{"location":"api/geometry/#textual.geometry.Offset.clamped","title":"clamped property
","text":"clamped: Offset\n
This offset with x
and y
restricted to values above zero.
property
","text":"is_origin: bool\n
Is the offset at (0, 0)?
"},{"location":"api/geometry/#textual.geometry.Offset.x","title":"xclass-attribute
instance-attribute
","text":"x: int = 0\n
Offset in the x-axis (horizontal)
"},{"location":"api/geometry/#textual.geometry.Offset.y","title":"yclass-attribute
instance-attribute
","text":"y: int = 0\n
Offset in the y-axis (vertical)
"},{"location":"api/geometry/#textual.geometry.Offset.blend","title":"blendmethod
","text":"def blend(self, destination, factor):\n
Calculate a new offset on a line between this offset and a destination offset.
Parameters Name Type Description Defaultdestination
Offset
Point where factor would be 1.0.
requiredfactor
float
A value between 0 and 1.0.
required Returns Type DescriptionOffset
A new point on a line between self and destination.
"},{"location":"api/geometry/#textual.geometry.Offset.get_distance_to","title":"get_distance_tomethod
","text":"def get_distance_to(self, other):\n
Get the distance to another offset.
Parameters Name Type Description Defaultother
Offset
An offset.
required Returns Type Descriptionfloat
Distance to other offset.
"},{"location":"api/geometry/#textual.geometry.Region","title":"Regionclass
","text":" Bases: NamedTuple
Defines a rectangular region.
A Region consists of a coordinate (x and y) and dimensions (width and height).
(x, y)\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u25b2\n \u2502 \u2502 \u2502\n \u2502 \u2502 \u2502\n \u2502 \u2502 height\n \u2502 \u2502 \u2502\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25bc\n \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500 width \u2500\u2500\u2500\u2500\u2500\u2500\u25b6\n
Example >>> from textual.geometry import Region\n>>> region = Region(4, 5, 20, 10)\n>>> region\nRegion(x=4, y=5, width=20, height=10)\n>>> region.area\n200\n>>> region.size\nSize(width=20, height=10)\n>>> region.offset\nOffset(x=4, y=5)\n>>> region.contains(1, 2)\nFalse\n>>> region.contains(10, 8)\nTrue\n
"},{"location":"api/geometry/#textual.geometry.Region.area","title":"area property
","text":"area: int\n
The area under the region.
"},{"location":"api/geometry/#textual.geometry.Region.bottom","title":"bottomproperty
","text":"bottom: int\n
Maximum Y value (non inclusive).
"},{"location":"api/geometry/#textual.geometry.Region.bottom_left","title":"bottom_leftproperty
","text":"bottom_left: Offset\n
Bottom left offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.bottom_right","title":"bottom_rightproperty
","text":"bottom_right: Offset\n
Bottom right offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.center","title":"centerproperty
","text":"center: tuple[float, float]\n
The center of the region.
Note, that this does not return an Offset
, because the center may not be an integer coordinate.
tuple[float, float]
Tuple of floats.
"},{"location":"api/geometry/#textual.geometry.Region.column_range","title":"column_rangeproperty
","text":"column_range: range\n
A range object for X coordinates.
"},{"location":"api/geometry/#textual.geometry.Region.column_span","title":"column_spanproperty
","text":"column_span: tuple[int, int]\n
A pair of integers for the start and end columns (x coordinates) in this region.
The end value is exclusive.
"},{"location":"api/geometry/#textual.geometry.Region.corners","title":"cornersproperty
","text":"corners: tuple[int, int, int, int]\n
The top left and bottom right coordinates as a tuple of four integers.
"},{"location":"api/geometry/#textual.geometry.Region.height","title":"heightclass-attribute
instance-attribute
","text":"height: int = 0\n
The height of the region.
"},{"location":"api/geometry/#textual.geometry.Region.line_range","title":"line_rangeproperty
","text":"line_range: range\n
A range object for Y coordinates.
"},{"location":"api/geometry/#textual.geometry.Region.line_span","title":"line_spanproperty
","text":"line_span: tuple[int, int]\n
A pair of integers for the start and end lines (y coordinates) in this region.
The end value is exclusive.
"},{"location":"api/geometry/#textual.geometry.Region.offset","title":"offsetproperty
","text":"offset: Offset\n
The top left corner of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.reset_offset","title":"reset_offsetproperty
","text":"reset_offset: Region\n
An region of the same size at (0, 0).
Returns Type DescriptionRegion
A region at the origin.
"},{"location":"api/geometry/#textual.geometry.Region.right","title":"rightproperty
","text":"right: int\n
Maximum X value (non inclusive).
"},{"location":"api/geometry/#textual.geometry.Region.size","title":"sizeproperty
","text":"size: Size\n
Get the size of the region.
"},{"location":"api/geometry/#textual.geometry.Region.top_right","title":"top_rightproperty
","text":"top_right: Offset\n
Top right offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.width","title":"widthclass-attribute
instance-attribute
","text":"width: int = 0\n
The width of the region.
"},{"location":"api/geometry/#textual.geometry.Region.x","title":"xclass-attribute
instance-attribute
","text":"x: int = 0\n
Offset in the x-axis (horizontal).
"},{"location":"api/geometry/#textual.geometry.Region.y","title":"yclass-attribute
instance-attribute
","text":"y: int = 0\n
Offset in the y-axis (vertical).
"},{"location":"api/geometry/#textual.geometry.Region.at_offset","title":"at_offsetmethod
","text":"def at_offset(self, offset):\n
Get a new Region with the same size at a given offset.
Parameters Name Type Description Defaultoffset
tuple[int, int]
An offset.
required Returns Type DescriptionRegion
New Region with adjusted offset.
"},{"location":"api/geometry/#textual.geometry.Region.clip","title":"clipmethod
","text":"def clip(self, width, height):\n
Clip this region to fit within width, height.
Parameters Name Type Description Defaultwidth
int
Width of bounds.
requiredheight
int
Height of bounds.
required Returns Type DescriptionRegion
Clipped region.
"},{"location":"api/geometry/#textual.geometry.Region.clip_size","title":"clip_sizemethod
","text":"def clip_size(self, size):\n
Clip the size to fit within minimum values.
Parameters Name Type Description Defaultsize
tuple[int, int]
Maximum width and height.
required Returns Type DescriptionRegion
No region, not bigger than size.
"},{"location":"api/geometry/#textual.geometry.Region.contains","title":"containsmethod
","text":"def contains(self, x, y):\n
Check if a point is in the region.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Region.contains_point","title":"contains_pointmethod
","text":"def contains_point(self, point):\n
Check if a point is in the region.
Parameters Name Type Description Defaultpoint
tuple[int, int]
A tuple of x and y coordinates.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Region.contains_region","title":"contains_regioncached
","text":"def contains_region(self, other):\n
Check if a region is entirely contained within this region.
Parameters Name Type Description Defaultother
Region
A region.
required Returns Type Descriptionbool
True if the other region fits perfectly within this region.
"},{"location":"api/geometry/#textual.geometry.Region.crop_size","title":"crop_sizemethod
","text":"def crop_size(self, size):\n
Get a region with the same offset, with a size no larger than size
.
size
tuple[int, int]
Maximum width and height (WIDTH, HEIGHT).
required Returns Type DescriptionRegion
New region that could fit within size
.
method
","text":"def expand(self, size):\n
Increase the size of the region by adding a border.
Parameters Name Type Description Defaultsize
tuple[int, int]
Additional width and height.
required Returns Type DescriptionRegion
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.from_corners","title":"from_cornersclassmethod
","text":"def from_corners(cls, x1, y1, x2, y2):\n
Construct a Region form the top left and bottom right corners.
Parameters Name Type Description Defaultx1
int
Top left x.
requiredy1
int
Top left y.
requiredx2
int
Bottom right x.
requiredy2
int
Bottom right y.
required Returns Type DescriptionRegion
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.from_offset","title":"from_offsetclassmethod
","text":"def from_offset(cls, offset, size):\n
Create a region from offset and size.
Parameters Name Type Description Defaultoffset
tuple[int, int]
Offset (top left point).
requiredsize
tuple[int, int]
Dimensions of region.
required Returns Type DescriptionRegion
A region instance.
"},{"location":"api/geometry/#textual.geometry.Region.from_union","title":"from_unionclassmethod
","text":"def from_union(cls, regions):\n
Create a Region from the union of other regions.
Parameters Name Type Description Defaultregions
Collection[Region]
One or more regions.
required Returns Type DescriptionRegion
A Region that encloses all other regions.
"},{"location":"api/geometry/#textual.geometry.Region.get_scroll_to_visible","title":"get_scroll_to_visibleclassmethod
","text":"def get_scroll_to_visible(\ncls, window_region, region, *, top=False\n):\n
Calculate the smallest offset required to translate a window so that it contains another region.
This method is used to calculate the required offset to scroll something in to view.
Parameters Name Type Description Defaultwindow_region
Region
The window region.
requiredregion
Region
The region to move inside the window.
requiredtop
bool
Get offset to top of window.
False
Returns Type Description Offset
An offset required to add to region to move it inside window_region.
"},{"location":"api/geometry/#textual.geometry.Region.grow","title":"growcached
","text":"def grow(self, margin):\n
Grow a region by adding spacing.
Parameters Name Type Description Defaultmargin
tuple[int, int, int, int]
Grow space by (<top>, <right>, <bottom>, <left>)
.
Region
New region.
"},{"location":"api/geometry/#textual.geometry.Region.inflect","title":"inflectmethod
","text":"def inflect(self, x_axis=+1, y_axis=+1, margin=None):\n
Inflect a region around one or both axis.
The x_axis
and y_axis
parameters define which direction to move the region. A positive value will move the region right or down, a negative value will move the region left or up. A value of 0
will leave that axis unmodified.
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2502\n\u2551 \u2551\n\u2551 Self \u2551 \u2502\n\u2551 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u2502\n\n\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u253c \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\n\n \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 \u2502\n \u2502 \u2502 Result \u2502\n \u2502 \u2502\n \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default x_axis
int
+1 to inflect in the positive direction, -1 to inflect in the negative direction.
+1
y_axis
int
+1 to inflect in the positive direction, -1 to inflect in the negative direction.
+1
margin
Spacing | None
Additional margin.
None
Returns Type Description Region
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.intersection","title":"intersectioncached
","text":"def intersection(self, region):\n
Get the overlapping portion of the two regions.
Parameters Name Type Description Defaultregion
Region
A region that overlaps this region.
required Returns Type DescriptionRegion
A new region that covers when the two regions overlap.
"},{"location":"api/geometry/#textual.geometry.Region.overlaps","title":"overlapscached
","text":"def overlaps(self, other):\n
Check if another region overlaps this region.
Parameters Name Type Description Defaultother
Region
A Region.
required Returns Type Descriptionbool
True if other region shares any cells with this region.
"},{"location":"api/geometry/#textual.geometry.Region.shrink","title":"shrinkcached
","text":"def shrink(self, margin):\n
Shrink a region by subtracting spacing.
Parameters Name Type Description Defaultmargin
tuple[int, int, int, int]
Shrink space by (<top>, <right>, <bottom>, <left>)
.
Region
The new, smaller region.
"},{"location":"api/geometry/#textual.geometry.Region.split","title":"splitcached
","text":"def split(self, cut_x, cut_y):\n
Split a region in to 4 from given x and y offsets (cuts).
cut_x \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 \u2502 \u2502 \u2502\n \u2502 0 \u2502 \u2502 1 \u2502\n \u2502 \u2502 \u2502 \u2502\ncut_y \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 2 \u2502 \u2502 3 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut_x
int
Offset from self.x where the cut should be made. If negative, the cut is taken from the right edge.
requiredcut_y
int
Offset from self.y where the cut should be made. If negative, the cut is taken from the lower edge.
required Returns Type Descriptiontuple[Region, Region, Region, Region]
Four new regions which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.split_horizontal","title":"split_horizontalcached
","text":"def split_horizontal(self, cut):\n
Split a region in to two, from a given y offset.
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 0 \u2502\n \u2502 \u2502\n cut \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 1 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut
int
An offset from self.y where the cut should be made. May be negative, for the offset to start from the lower edge.
required Returns Type Descriptiontuple[Region, Region]
Two regions, which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.split_vertical","title":"split_verticalcached
","text":"def split_vertical(self, cut):\n
Split a region in to two, from a given x offset.
cut \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2510\n \u2502 0 \u2502\u2502 1 \u2502\n \u2502 \u2502\u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut
int
An offset from self.x where the cut should be made. If cut is negative, it is taken from the right edge.
required Returns Type Descriptiontuple[Region, Region]
Two regions, which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.translate","title":"translatecached
","text":"def translate(self, offset):\n
Move the offset of the Region.
Parameters Name Type Description Defaultoffset
tuple[int, int]
Offset to add to region.
required Returns Type DescriptionRegion
A new region shifted by (x, y)
"},{"location":"api/geometry/#textual.geometry.Region.translate_inside","title":"translate_insidemethod
","text":"def translate_inside(self, container, x_axis=True, y_axis=True):\n
Translate this region, so it fits within a container.
This will ensure that there is as little overlap as possible. The top left of the returned region is guaranteed to be within the container.
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 container \u2502 \u2502 container \u2502\n\u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2502 \u2500\u2500\u25b6 \u2502 \u2502 return \u2502\n\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2510 \u2502 \u2502 \u2502\n\u2502 \u2502 self \u2502 \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 \u2514\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default container
Region
A container region.
requiredx_axis
bool
Allow translation of X axis.
True
y_axis
bool
Allow translation of Y axis.
True
Returns Type Description Region
A new region with same dimensions that fits with inside container.
"},{"location":"api/geometry/#textual.geometry.Region.union","title":"unioncached
","text":"def union(self, region):\n
Get the smallest region that contains both regions.
Parameters Name Type Description Defaultregion
Region
Another region.
required Returns Type DescriptionRegion
An optimally sized region to cover both regions.
"},{"location":"api/geometry/#textual.geometry.Size","title":"Sizeclass
","text":" Bases: NamedTuple
The dimensions (width and height) of a rectangular region.
Example>>> from textual.geometry import Size\n>>> size = Size(2, 3)\n>>> size\nSize(width=2, height=3)\n>>> size.area\n6\n>>> size + Size(10, 20)\nSize(width=12, height=23)\n
"},{"location":"api/geometry/#textual.geometry.Size.area","title":"area property
","text":"area: int\n
The area occupied by a region of this size.
"},{"location":"api/geometry/#textual.geometry.Size.height","title":"heightclass-attribute
instance-attribute
","text":"height: int = 0\n
The height in cells.
"},{"location":"api/geometry/#textual.geometry.Size.line_range","title":"line_rangeproperty
","text":"line_range: range\n
A range object that covers values between 0 and height
.
property
","text":"region: Region\n
A region of the same size, at the origin.
"},{"location":"api/geometry/#textual.geometry.Size.width","title":"widthclass-attribute
instance-attribute
","text":"width: int = 0\n
The width in cells.
"},{"location":"api/geometry/#textual.geometry.Size.contains","title":"containsmethod
","text":"def contains(self, x, y):\n
Check if a point is in area defined by the size.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Size.contains_point","title":"contains_pointmethod
","text":"def contains_point(self, point):\n
Check if a point is in the area defined by the size.
Parameters Name Type Description Defaultpoint
tuple[int, int]
A tuple of x and y coordinates.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Spacing","title":"Spacingclass
","text":" Bases: NamedTuple
The spacing around a renderable, such as padding and border.
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
\u250c \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\u25b2\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2510\n \u2502 top\n\u2502 \u250f\u2501\u2501\u2501\u2501\u2501\u25bc\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2502\n \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u25b6\u2503 \u2503\u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6\n\u2502 left \u2503 \u2503 right \u2502\n \u2503 \u2503\n\u2502 \u2517\u2501\u2501\u2501\u2501\u2501\u25b2\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2502\n \u2502 bottom\n\u2514 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\u25bc\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2518\n
Example >>> from textual.geometry import Region, Spacing\n>>> region = Region(2, 3, 20, 10)\n>>> spacing = Spacing(1, 2, 3, 4)\n>>> region.grow(spacing)\nRegion(x=-2, y=2, width=26, height=14)\n>>> region.shrink(spacing)\nRegion(x=6, y=4, width=14, height=6)\n>>> spacing.css\n'1 2 3 4'\n
"},{"location":"api/geometry/#textual.geometry.Spacing.bottom","title":"bottom class-attribute
instance-attribute
","text":"bottom: int = 0\n
Space from the bottom of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.bottom_right","title":"bottom_rightproperty
","text":"bottom_right: tuple[int, int]\n
A pair of integers for the right, and bottom space.
"},{"location":"api/geometry/#textual.geometry.Spacing.css","title":"cssproperty
","text":"css: str\n
A string containing the spacing in CSS format.
For example: \"1\" or \"2 4\" or \"4 2 8 2\".
"},{"location":"api/geometry/#textual.geometry.Spacing.height","title":"heightproperty
","text":"height: int\n
Total space in the y axis.
"},{"location":"api/geometry/#textual.geometry.Spacing.left","title":"leftclass-attribute
instance-attribute
","text":"left: int = 0\n
Space from the left of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.right","title":"rightclass-attribute
instance-attribute
","text":"right: int = 0\n
Space from the right of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.top","title":"topclass-attribute
instance-attribute
","text":"top: int = 0\n
Space from the top of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.top_left","title":"top_leftproperty
","text":"top_left: tuple[int, int]\n
A pair of integers for the left, and top space.
"},{"location":"api/geometry/#textual.geometry.Spacing.totals","title":"totalsproperty
","text":"totals: tuple[int, int]\n
A pair of integers for the total horizontal and vertical space.
"},{"location":"api/geometry/#textual.geometry.Spacing.width","title":"widthproperty
","text":"width: int\n
Total space in the x axis.
"},{"location":"api/geometry/#textual.geometry.Spacing.all","title":"allclassmethod
","text":"def all(cls, amount):\n
Construct a Spacing with a given amount of spacing on all edges.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to all edges
required Returns Type DescriptionSpacing
Spacing(amount, amount, amount, amount)
method
","text":"def grow_maximum(self, other):\n
Grow spacing with a maximum.
Parameters Name Type Description Defaultother
Spacing
Spacing object.
required Returns Type DescriptionSpacing
New spacing where the values are maximum of the two values.
"},{"location":"api/geometry/#textual.geometry.Spacing.horizontal","title":"horizontalclassmethod
","text":"def horizontal(cls, amount):\n
Construct a Spacing with a given amount of spacing on horizontal edges, and no vertical spacing.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to horizontal edges
required Returns Type DescriptionSpacing
Spacing(0, amount, 0, amount)
classmethod
","text":"def unpack(cls, pad):\n
Unpack padding specified in CSS style.
Parameters Name Type Description Defaultpad
SpacingDimensions
An integer, or tuple of 1, 2, or 4 integers.
required Raises Type DescriptionValueError
If pad
is an invalid value.
Spacing
New Spacing object.
"},{"location":"api/geometry/#textual.geometry.Spacing.vertical","title":"verticalclassmethod
","text":"def vertical(cls, amount):\n
Construct a Spacing with a given amount of spacing on vertical edges, and no horizontal spacing.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to vertical edges
required Returns Type DescriptionSpacing
Spacing(amount, 0, amount, 0)
function
","text":"def clamp(value, minimum, maximum):\n
Adjust a value so it is not less than a minimum and not greater than a maximum value.
Parameters Name Type Description Defaultvalue
T
A value.
requiredminimum
T
Minimum value.
requiredmaximum
T
Maximum value.
required Returns Type DescriptionT
New value that is not less than the minimum or greater than the maximum.
"},{"location":"api/logger/","title":"Logger","text":"A logger class that logs to the Textual console.
"},{"location":"api/logger/#textual.Logger","title":"textual.Loggerclass
","text":"def __init__(\nself,\nlog_callable,\ngroup=LogGroup.INFO,\nverbosity=LogVerbosity.NORMAL,\n):\n
A Textual logger.
"},{"location":"api/logger/#textual.Logger.debug","title":"debugproperty
","text":"debug: Logger\n
Logs debug messages.
"},{"location":"api/logger/#textual.Logger.error","title":"errorproperty
","text":"error: Logger\n
Logs errors.
"},{"location":"api/logger/#textual.Logger.event","title":"eventproperty
","text":"event: Logger\n
Logs events.
"},{"location":"api/logger/#textual.Logger.info","title":"infoproperty
","text":"info: Logger\n
Logs information.
"},{"location":"api/logger/#textual.Logger.logging","title":"loggingproperty
","text":"logging: Logger\n
Logs from stdlib logging module.
"},{"location":"api/logger/#textual.Logger.system","title":"systemproperty
","text":"system: Logger\n
Logs system information.
"},{"location":"api/logger/#textual.Logger.verbose","title":"verboseproperty
","text":"verbose: Logger\n
A verbose logger.
"},{"location":"api/logger/#textual.Logger.warning","title":"warningproperty
","text":"warning: Logger\n
Logs warnings.
"},{"location":"api/logger/#textual.Logger.worker","title":"workerproperty
","text":"worker: Logger\n
Logs worker information.
"},{"location":"api/logger/#textual.Logger.verbosity","title":"verbositymethod
","text":"def verbosity(self, verbose):\n
Get a new logger with selective verbosity.
Parameters Name Type Description Defaultverbose
bool
True to use HIGH verbosity, otherwise NORMAL.
required Returns Type DescriptionLogger
New logger.
"},{"location":"api/logging/","title":"Logging","text":"A Textual Logging handler.
If there is an active Textual app, then log messages will go via the app (and logged via textual console).
If there is no active app, then log messages will go to stderr or stdout, depending on configuration.
"},{"location":"api/logging/#textual.logging.TextualHandler","title":"TextualHandlerclass
","text":"def __init__(self, stderr=True, stdout=False):\n
Bases: Handler
A Logging handler for Textual apps.
Parameters Name Type Description Defaultstderr
bool
Log to stderr when there is no active app.
True
stdout
bool
Log to stdout when there is not active app.
False
"},{"location":"api/logging/#textual.logging.TextualHandler.emit","title":"emit method
","text":"def emit(self, record):\n
Invoked by logging.
"},{"location":"api/map_geometry/","title":"Map geometry","text":"A data structure returned by screen.find_widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry","title":"textual._compositor.MapGeometryclass
","text":" Bases: NamedTuple
Defines the absolute location of a Widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.clip","title":"clipinstance-attribute
","text":"clip: Region\n
A region to clip the widget by (if a Widget is within a container).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.container_size","title":"container_sizeinstance-attribute
","text":"container_size: Size\n
The container size (area not occupied by scrollbars).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.dock_gutter","title":"dock_gutterinstance-attribute
","text":"dock_gutter: Spacing\n
Space from the container reserved by docked widgets.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.order","title":"orderinstance-attribute
","text":"order: tuple[tuple[int, int, int], ...]\n
Tuple of tuples defining the painting order of the widget.
Each successive triple represents painting order information with regards to ancestors in the DOM hierarchy and the last triple provides painting order information for this specific widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.region","title":"regioninstance-attribute
","text":"region: Region\n
The (screen) region occupied by the widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.virtual_region","title":"virtual_regioninstance-attribute
","text":"virtual_region: Region\n
The region relative to the container (but not necessarily visible).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.virtual_size","title":"virtual_sizeinstance-attribute
","text":"virtual_size: Size\n
The virtual size (scrollable area) of a widget if it is a container.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.visible_region","title":"visible_regionproperty
","text":"visible_region: Region\n
The Widget region after clipping.
"},{"location":"api/message/","title":"Message","text":"The base class for all messages (including events).
"},{"location":"api/message/#textual.message.Message","title":"Messageclass
","text":"def __init__(self):\n
Base class for a message.
"},{"location":"api/message/#textual.message.Message.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
","text":"ALLOW_SELECTOR_MATCH: set[str] = set()\n
Additional attributes that can be used with the on
decorator.
These attributes must be widgets.
"},{"location":"api/message/#textual.message.Message.control","title":"controlproperty
","text":"control: Widget | None\n
The widget associated with this message, or None by default.
"},{"location":"api/message/#textual.message.Message.handler_name","title":"handler_nameclass-attribute
","text":"handler_name: str\n
Name of the default message handler.
"},{"location":"api/message/#textual.message.Message.is_forwarded","title":"is_forwardedproperty
","text":"is_forwarded: bool\n
Has the message been forwarded?
"},{"location":"api/message/#textual.message.Message.prevent_default","title":"prevent_defaultmethod
","text":"def prevent_default(self, prevent=True):\n
Suppress the default action(s). This will prevent handlers in any base classes from being called.
Parameters Name Type Description Defaultprevent
bool
True if the default action should be suppressed, or False if the default actions should be performed.
True
"},{"location":"api/message/#textual.message.Message.stop","title":"stop method
","text":"def stop(self, stop=True):\n
Stop propagation of the message to parent.
Parameters Name Type Description Defaultstop
bool
The stop flag.
True
"},{"location":"api/message_pump/","title":"Message pump","text":"A MessagePump
is a base class for any object which processes messages, which includes Widget, Screen, and App.
Tip
Most of the method here are useful in general app development.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump","title":"MessagePumpclass
","text":"def __init__(self, parent=None):\n
Base class which supplies a message pump.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.app","title":"appproperty
","text":"app: 'App[object]'\n
Get the current app.
Returns Type Description'App[object]'
The current app.
Raises Type DescriptionNoActiveAppError
if no active app could be found for the current asyncio context
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.has_parent","title":"has_parentproperty
","text":"has_parent: bool\n
Does this object have a parent?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_attached","title":"is_attachedproperty
","text":"is_attached: bool\n
Is the node attached to the app via the DOM?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_parent_active","title":"is_parent_activeproperty
","text":"is_parent_active: bool\n
Is the parent active?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_running","title":"is_runningproperty
","text":"is_running: bool\n
Is the message pump running (potentially processing messages)?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.log","title":"logproperty
","text":"log: Logger\n
Get a logger for this object.
Returns Type DescriptionLogger
A logger.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.call_after_refresh","title":"call_after_refreshmethod
","text":"def call_after_refresh(self, callback, *args, **kwargs):\n
Schedule a callback to run after all messages are processed and the screen has been refreshed. Positional and keyword arguments are passed to the callable.
Parameters Name Type Description Defaultcallback
Callback
A callable.
required Returns Type Descriptionbool
True
if the callback was scheduled, or False
if the callback could not be scheduled (may occur if the message pump was closed or closing).
method
","text":"def call_later(self, callback, *args, **kwargs):\n
Schedule a callback to run after all messages are processed in this object. Positional and keywords arguments are passed to the callable.
Parameters Name Type Description Defaultcallback
Callback
Callable to call next.
required*args
Any
Positional arguments to pass to the callable.
()
**kwargs
Any
Keyword arguments to pass to the callable.
{}
Returns Type Description bool
True
if the callback was scheduled, or False
if the callback could not be scheduled (may occur if the message pump was closed or closing).
method
","text":"def call_next(self, callback, *args, **kwargs):\n
Schedule a callback to run immediately after processing the current message.
Parameters Name Type Description Defaultcallback
Callback
Callable to run after current event.
required*args
Any
Positional arguments to pass to the callable.
()
**kwargs
Any
Keyword arguments to pass to the callable.
{}
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.check_idle","title":"check_idle method
","text":"def check_idle(self):\n
Prompt the message pump to call idle if the queue is empty.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.check_message_enabled","title":"check_message_enabledmethod
","text":"def check_message_enabled(self, message):\n
Check if a given message is enabled (allowed to be sent).
Parameters Name Type Description Defaultmessage
Message
A message object.
required Returns Type Descriptionbool
True
if the message will be sent, or False
if it is disabled.
method
","text":"def disable_messages(self, *messages):\n
Disable message types from being processed.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.dispatch_key","title":"dispatch_keyasync
","text":"def dispatch_key(self, event):\n
Dispatch a key event to method.
This method will call the method named 'key_' if it exists. Some keys have aliases. The first alias found will be invoked if it exists. If multiple handlers exist that match the key, an exception is raised. Parameters Name Type Description Default event
events.Key
A key event.
required Returns Type Descriptionbool
True if key was handled, otherwise False.
Raises Type DescriptionDuplicateKeyHandlers
When there's more than 1 handler that could handle this key.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.enable_messages","title":"enable_messagesmethod
","text":"def enable_messages(self, *messages):\n
Enable processing of messages types.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.on_event","title":"on_eventasync
","text":"def on_event(self, event):\n
Called to process an event.
Parameters Name Type Description Defaultevent
events.Event
An Event object.
required"},{"location":"api/message_pump/#textual.message_pump.MessagePump.post_message","title":"post_messagemethod
","text":"def post_message(self, message):\n
Posts a message on to this widget's queue.
Parameters Name Type Description Defaultmessage
Message
A message (including Event).
required Returns Type Descriptionbool
True
if the messages was processed, False
if it wasn't.
method
","text":"def prevent(self, *message_types):\n
A context manager to temporarily prevent the given message types from being posted.
Exampleinput = self.query_one(Input)\nwith self.prevent(Input.Changed):\ninput.value = \"foo\"\n
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.set_interval","title":"set_interval method
","text":"def set_interval(\nself,\ninterval,\ncallback=None,\n*,\nname=None,\nrepeat=0,\npause=False\n):\n
Call a function at periodic intervals.
Parameters Name Type Description Defaultinterval
float
Time between calls.
requiredcallback
TimerCallback | None
Function to call.
None
name
str | None
Name of the timer object.
None
repeat
int
Number of times to repeat the call or 0 for continuous.
0
pause
bool
Start the timer paused.
False
Returns Type Description Timer
A timer object.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.set_timer","title":"set_timermethod
","text":"def set_timer(\nself, delay, callback=None, *, name=None, pause=False\n):\n
Make a function call after a delay.
Parameters Name Type Description Defaultdelay
float
Time to wait before invoking callback.
requiredcallback
TimerCallback | None
Callback to call after time has expired.
None
name
str | None
Name of the timer (for debug).
None
pause
bool
Start timer paused.
False
Returns Type Description Timer
A timer object.
"},{"location":"api/on/","title":"On","text":"Decorator to declare that the method is a message handler.
The decorator accepts an optional CSS selector that will be matched against a widget exposed by a control
attribute on the message.
# Handle the press of buttons with ID \"#quit\".\n@on(Button.Pressed, \"#quit\")\ndef quit_button(self) -> None:\nself.app.quit()\n
Keyword arguments can be used to match additional selectors for attributes listed in ALLOW_SELECTOR_MATCH
.
# Handle the activation of the tab \"#home\" within the `TabbedContent` \"#tabs\".\n@on(TabbedContent.TabActivated, \"#tabs\", tab=\"#home\")\ndef switch_to_home(self) -> None:\nself.log(\"Switching back to the home tab.\")\n...\n
Parameters Name Type Description Default message_type
type[Message]
The message type (i.e. the class).
requiredselector
str | None
An optional selector. If supplied, the handler will only be called if selector
matches the widget from the control
attribute of the message.
None
**kwargs
str
Additional selectors for other attributes of the message.
{}
"},{"location":"api/pilot/","title":"Pilot","text":"The pilot object is used by App.run_test to programmatically operate an app.
See the guide on how to test Textual apps.
"},{"location":"api/pilot/#textual.pilot.OutOfBounds","title":"OutOfBoundsclass
","text":" Bases: Exception
Raised when the pilot mouse target is outside of the (visible) screen.
"},{"location":"api/pilot/#textual.pilot.Pilot","title":"Pilotclass
","text":"def __init__(self, app):\n
Bases: Generic[ReturnType]
Pilot object to drive an app.
"},{"location":"api/pilot/#textual.pilot.Pilot.app","title":"appproperty
","text":"app: App[ReturnType]\n
"},{"location":"api/pilot/#textual.pilot.Pilot.click","title":"click async
","text":"def click(\nself,\nselector=None,\noffset=(0, 0),\nshift=False,\nmeta=False,\ncontrol=False,\n):\n
Simulate clicking with the mouse at a specified position.
The final position to be clicked is computed based on the selector provided and the offset specified and it must be within the visible area of the screen.
Parameters Name Type Description Defaultselector
type[Widget] | str | None
A selector to specify a widget that should be used as the reference for the click offset. If this is not specified, the offset is interpreted relative to the screen. You can use this parameter to try to click on a specific widget. However, if the widget is currently hidden or obscured by another widget, the click may not land on the widget you specified.
None
offset
tuple[int, int]
The offset to click. The offset is relative to the selector provided or to the screen, if no selector is provided.
(0, 0)
shift
bool
Click with the shift key held down.
False
meta
bool
Click with the meta key held down.
False
control
bool
Click with the control key held down.
False
Raises Type Description OutOfBounds
If the position to be clicked is outside of the (visible) screen.
Returns Type Descriptionbool
True if no selector was specified or if the click landed on the selected widget, False otherwise.
"},{"location":"api/pilot/#textual.pilot.Pilot.exit","title":"exitasync
","text":"def exit(self, result):\n
Exit the app with the given result.
Parameters Name Type Description Defaultresult
ReturnType
The app result returned by run
or run_async
.
async
","text":"def hover(self, selector=None, offset=(0, 0)):\n
Simulate hovering with the mouse cursor at a specified position.
The final position to be hovered is computed based on the selector provided and the offset specified and it must be within the visible area of the screen.
Parameters Name Type Description Defaultselector
type[Widget] | str | None | None
A selector to specify a widget that should be used as the reference for the hover offset. If this is not specified, the offset is interpreted relative to the screen. You can use this parameter to try to hover a specific widget. However, if the widget is currently hidden or obscured by another widget, the hover may not land on the widget you specified.
None
offset
tuple[int, int]
The offset to hover. The offset is relative to the selector provided or to the screen, if no selector is provided.
(0, 0)
Raises Type Description OutOfBounds
If the position to be hovered is outside of the (visible) screen.
Returns Type Descriptionbool
True if no selector was specified or if the hover landed on the selected widget, False otherwise.
"},{"location":"api/pilot/#textual.pilot.Pilot.pause","title":"pauseasync
","text":"def pause(self, delay=None):\n
Insert a pause.
Parameters Name Type Description Defaultdelay
float | None
Seconds to pause, or None to wait for cpu idle.
None
"},{"location":"api/pilot/#textual.pilot.Pilot.press","title":"press async
","text":"def press(self, *keys):\n
Simulate key-presses.
Parameters Name Type Description Default*keys
str
Keys to press.
()
"},{"location":"api/pilot/#textual.pilot.Pilot.wait_for_animation","title":"wait_for_animation async
","text":"def wait_for_animation(self):\n
Wait for any current animation to complete.
"},{"location":"api/pilot/#textual.pilot.Pilot.wait_for_scheduled_animations","title":"wait_for_scheduled_animationsasync
","text":"def wait_for_scheduled_animations(self):\n
Wait for any current and scheduled animations to complete.
"},{"location":"api/pilot/#textual.pilot.WaitForScreenTimeout","title":"WaitForScreenTimeoutclass
","text":" Bases: Exception
Exception raised if messages aren't being processed quickly enough.
If this occurs, the most likely explanation is some kind of deadlock in the app code.
"},{"location":"api/query/","title":"Query","text":"A DOMQuery is a set of DOM nodes returned by query.
The set of nodes may be further refined with filter and exclude. Additional methods apply actions to all nodes in the query.
Info
If this sounds like JQuery, a (once) popular JS library, it is no coincidence.
"},{"location":"api/query/#textual.css.query.ExpectType","title":"ExpectTypemodule-attribute
","text":"ExpectType = TypeVar('ExpectType')\n
Type variable used to further restrict queries.
"},{"location":"api/query/#textual.css.query.QueryType","title":"QueryTypemodule-attribute
","text":"QueryType = TypeVar('QueryType', bound='Widget')\n
Type variable used to type generic queries.
"},{"location":"api/query/#textual.css.query.DOMQuery","title":"DOMQueryclass
","text":"def __init__(\nself, node, *, filter=None, exclude=None, parent=None\n):\n
Bases: Generic[QueryType]
Warning
You won't need to construct this manually, as DOMQuery
objects are returned by query.
node
DOMNode
A DOM node.
requiredfilter
str | None
Query to filter children in the node.
None
exclude
str | None
Query to exclude children in the node.
None
parent
DOMQuery | None
The parent query, if this is the result of filtering another query.
None
Raises Type Description InvalidQueryFormat
If the format of the query is invalid.
"},{"location":"api/query/#textual.css.query.DOMQuery.node","title":"nodeproperty
","text":"node: DOMNode\n
The node being queried.
"},{"location":"api/query/#textual.css.query.DOMQuery.nodes","title":"nodesproperty
","text":"nodes: list[QueryType]\n
Lazily evaluate nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.add_class","title":"add_classmethod
","text":"def add_class(self, *class_names):\n
Add the given class name(s) to nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.exclude","title":"excludemethod
","text":"def exclude(self, selector):\n
Exclude nodes that match a given selector.
Parameters Name Type Description Defaultselector
str
A CSS selector.
required Returns Type DescriptionDOMQuery[QueryType]
New DOM query.
"},{"location":"api/query/#textual.css.query.DOMQuery.filter","title":"filtermethod
","text":"def filter(self, selector):\n
Filter this set by the given CSS selector.
Parameters Name Type Description Defaultselector
str
A CSS selector.
required Returns Type DescriptionDOMQuery[QueryType]
New DOM Query.
"},{"location":"api/query/#textual.css.query.DOMQuery.first","title":"firstmethod
","text":"def first(self, expect_type=None):\n
Get the first matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If there are no matching nodes in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.last","title":"lastmethod
","text":"def last(self, expect_type=None):\n
Get the last matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If there are no matching nodes in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.only_one","title":"only_onemethod
","text":"def only_one(self, expect_type=None):\n
Get the only matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If no node matches the query.
TooManyMatches
If there is more than one matching node in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.refresh","title":"refreshmethod
","text":"def refresh(self, *, repaint=True, layout=False):\n
Refresh matched nodes.
Parameters Name Type Description Defaultrepaint
bool
Repaint node(s).
True
layout
bool
Layout node(s).
False
Returns Type Description DOMQuery[QueryType]
Query for chaining.
"},{"location":"api/query/#textual.css.query.DOMQuery.remove","title":"removemethod
","text":"def remove(self):\n
Remove matched nodes from the DOM.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the widgets to be removed.
"},{"location":"api/query/#textual.css.query.DOMQuery.remove_class","title":"remove_classmethod
","text":"def remove_class(self, *class_names):\n
Remove the given class names from the nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.results","title":"resultsmethod
","text":"def results(self, filter_type=None):\n
Get query results, optionally filtered by a given type.
Parameters Name Type Description Defaultfilter_type
type[ExpectType] | None
A Widget class to filter results, or None for no filter.
None
Yields:
Type DescriptionQueryType | ExpectType
Iterator[Widget | ExpectType]: An iterator of Widget instances.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_class","title":"set_classmethod
","text":"def set_class(self, add, *class_names):\n
Set the given class name(s) according to a condition.
Parameters Name Type Description Defaultadd
bool
Add the classes if True, otherwise remove them.
required Returns Type DescriptionDOMQuery[QueryType]
Self.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_classes","title":"set_classesmethod
","text":"def set_classes(self, classes):\n
Set the classes on nodes to exactly the given set.
Parameters Name Type Description Defaultclasses
str | Iterable[str]
A string of space separated classes, or an iterable of class names.
required Returns Type DescriptionDOMQuery[QueryType]
Self.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_styles","title":"set_stylesmethod
","text":"def set_styles(self, css=None, **update_styles):\n
Set styles on matched nodes.
Parameters Name Type Description Defaultcss
str | None
CSS declarations to parser, or None.
None
"},{"location":"api/query/#textual.css.query.DOMQuery.toggle_class","title":"toggle_class method
","text":"def toggle_class(self, *class_names):\n
Toggle the given class names from matched nodes.
"},{"location":"api/query/#textual.css.query.InvalidQueryFormat","title":"InvalidQueryFormatclass
","text":" Bases: QueryError
Query did not parse correctly.
"},{"location":"api/query/#textual.css.query.NoMatches","title":"NoMatchesclass
","text":" Bases: QueryError
No nodes matched the query.
"},{"location":"api/query/#textual.css.query.QueryError","title":"QueryErrorclass
","text":" Bases: Exception
Base class for a query related error.
"},{"location":"api/query/#textual.css.query.TooManyMatches","title":"TooManyMatchesclass
","text":" Bases: QueryError
Too many nodes matched the query.
"},{"location":"api/query/#textual.css.query.WrongType","title":"WrongTypeclass
","text":" Bases: QueryError
Query result was not of the correct type.
"},{"location":"api/reactive/","title":"Reactive","text":"The Reactive
class implements reactivity.
class
","text":"def __init__(\nself,\ndefault,\n*,\nlayout=False,\nrepaint=True,\ninit=False,\nalways_update=False,\ncompute=True\n):\n
Bases: Generic[ReactiveType]
Reactive descriptor.
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredlayout
bool
Perform a layout on change.
False
repaint
bool
Perform a repaint on change.
True
init
bool
Call watchers on initialize (post mount).
False
always_update
bool
Call watchers even when the new value equals the old value.
False
compute
bool
Run compute methods when attribute is changed.
True
"},{"location":"api/reactive/#textual.reactive.TooManyComputesError","title":"TooManyComputesError class
","text":" Bases: Exception
Raised when an attribute has public and private compute methods.
"},{"location":"api/reactive/#textual.reactive.reactive","title":"reactiveclass
","text":"def __init__(\nself,\ndefault,\n*,\nlayout=False,\nrepaint=True,\ninit=True,\nalways_update=False\n):\n
Bases: Reactive[ReactiveType]
Create a reactive attribute.
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredlayout
bool
Perform a layout on change.
False
repaint
bool
Perform a repaint on change.
True
init
bool
Call watchers on initialize (post mount).
True
always_update
bool
Call watchers even when the new value equals the old value.
False
"},{"location":"api/reactive/#textual.reactive.var","title":"var class
","text":"def __init__(self, default, init=True, always_update=False):\n
Bases: Reactive[ReactiveType]
Create a reactive attribute (with no auto-refresh).
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredinit
bool
Call watchers on initialize (post mount).
True
always_update
bool
Call watchers even when the new value equals the old value.
False
"},{"location":"api/screen/","title":"Screen","text":"The Screen
class is a special widget which represents the content in the terminal. See Screens for details.
module-attribute
","text":"ScreenResultCallbackType = Union[\nCallable[[ScreenResultType], None],\nCallable[[ScreenResultType], Awaitable[None]],\n]\n
Type of a screen result callback function.
"},{"location":"api/screen/#textual.screen.ScreenResultType","title":"ScreenResultTypemodule-attribute
","text":"ScreenResultType = TypeVar('ScreenResultType')\n
The result type of a screen.
"},{"location":"api/screen/#textual.screen.ModalScreen","title":"ModalScreenclass
","text":"def __init__(self, name=None, id=None, classes=None):\n
Bases: Screen[ScreenResultType]
A screen with bindings that take precedence over the App's key bindings.
The default styling of a modal screen will dim the screen underneath.
"},{"location":"api/screen/#textual.screen.ResultCallback","title":"ResultCallbackclass
","text":"def __init__(self, requester, callback, future=None):\n
Bases: Generic[ScreenResultType]
Holds the details of a callback.
Parameters Name Type Description Defaultrequester
MessagePump
The object making a request for the callback.
requiredcallback
ScreenResultCallbackType[ScreenResultType] | None
The callback function.
requiredfuture
asyncio.Future[ScreenResultType] | None
A Future to hold the result.
None
"},{"location":"api/screen/#textual.screen.ResultCallback.callback","title":"callback instance-attribute
","text":"callback: ScreenResultCallbackType | None = callback\n
The callback function.
"},{"location":"api/screen/#textual.screen.ResultCallback.future","title":"futureinstance-attribute
","text":"future = future\n
A future for the result
"},{"location":"api/screen/#textual.screen.ResultCallback.requester","title":"requesterinstance-attribute
","text":"requester = requester\n
The object in the DOM that requested the callback.
"},{"location":"api/screen/#textual.screen.Screen","title":"Screenclass
","text":"def __init__(self, name=None, id=None, classes=None):\n
Bases: Generic[ScreenResultType]
, Widget
The base class for screens.
Parameters Name Type Description Defaultname
str | None
The name of the screen.
None
id
str | None
The ID of the screen in the DOM.
None
classes
str | None
The CSS classes for the screen.
None
"},{"location":"api/screen/#textual.screen.Screen.AUTO_FOCUS","title":"AUTO_FOCUS class-attribute
","text":"AUTO_FOCUS: str | None = None\n
A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given CSS selector. Set to None
to inherit the value from the screen's app. Set to \"\"
to disable auto focus.
class-attribute
","text":"COMMANDS: set[type[Provider]] = set()\n
Command providers used by the command palette, associated with the screen.
Should be a set of command.Provider
classes.
class-attribute
","text":"CSS: str = ''\n
Inline CSS, useful for quick scripts. Rules here take priority over CSS_PATH.
NoteThis CSS applies to the whole app.
"},{"location":"api/screen/#textual.screen.Screen.CSS_PATH","title":"CSS_PATHclass-attribute
","text":"CSS_PATH: CSSPathType | None = None\n
File paths to load CSS from.
NoteThis CSS applies to the whole app.
"},{"location":"api/screen/#textual.screen.Screen.SUB_TITLE","title":"SUB_TITLEclass-attribute
","text":"SUB_TITLE: str | None = None\n
A class variable to set the default sub-title for the screen.
This overrides the app sub-title. To update the sub-title while the screen is running, you can set the sub_title attribute.
"},{"location":"api/screen/#textual.screen.Screen.TITLE","title":"TITLEclass-attribute
","text":"TITLE: str | None = None\n
A class variable to set the default title for the screen.
This overrides the app title. To update the title while the screen is running, you can set the title attribute.
"},{"location":"api/screen/#textual.screen.Screen.focus_chain","title":"focus_chainproperty
","text":"focus_chain: list[Widget]\n
A list of widgets that may receive focus, in focus order.
"},{"location":"api/screen/#textual.screen.Screen.focused","title":"focusedclass-attribute
instance-attribute
","text":"focused: Reactive[Widget | None] = Reactive(None)\n
The focused widget or None
for no focus.
property
","text":"is_current: bool\n
Is the screen current (i.e. visible to user)?
"},{"location":"api/screen/#textual.screen.Screen.is_modal","title":"is_modalproperty
","text":"is_modal: bool\n
Is the screen modal?
"},{"location":"api/screen/#textual.screen.Screen.layers","title":"layersproperty
","text":"layers: tuple[str, ...]\n
Layers from parent.
Returns Type Descriptiontuple[str, ...]
Tuple of layer names.
"},{"location":"api/screen/#textual.screen.Screen.stack_updates","title":"stack_updatesclass-attribute
instance-attribute
","text":"stack_updates: Reactive[int] = Reactive(0, repaint=False)\n
An integer that updates when the screen is resumed.
"},{"location":"api/screen/#textual.screen.Screen.sub_title","title":"sub_titleclass-attribute
instance-attribute
","text":"sub_title: Reactive[str | None] = self.SUB_TITLE\n
Screen sub-title to override the app sub-title.
"},{"location":"api/screen/#textual.screen.Screen.title","title":"titleclass-attribute
instance-attribute
","text":"title: Reactive[str | None] = self.TITLE\n
Screen title to override the app title.
"},{"location":"api/screen/#textual.screen.Screen.action_dismiss","title":"action_dismissmethod
","text":"def action_dismiss(self, result=_NoResult):\n
A wrapper around dismiss
that can be called as an action.
result
ScreenResultType | Type[_NoResult]
The optional result to be passed to the result callback.
_NoResult
"},{"location":"api/screen/#textual.screen.Screen.can_view","title":"can_view method
","text":"def can_view(self, widget):\n
Check if a given widget is in the current view (scrollable area).
Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible.
Parameters Name Type Description Defaultwidget
Widget
A widget that is a descendant of self.
required Returns Type Descriptionbool
True if the entire widget is in view, False if it is partially visible or not in view.
"},{"location":"api/screen/#textual.screen.Screen.dismiss","title":"dismissmethod
","text":"def dismiss(self, result=_NoResult):\n
Dismiss the screen, optionally with a result.
If result
is provided and a callback was set when the screen was pushed, then the callback will be invoked with result
.
result
ScreenResultType | Type[_NoResult]
The optional result to be passed to the result callback.
_NoResult
Raises Type Description ScreenStackError
If trying to dismiss a screen that is not at the top of the stack.
"},{"location":"api/screen/#textual.screen.Screen.find_widget","title":"find_widgetmethod
","text":"def find_widget(self, widget):\n
Get the screen region of a Widget.
Parameters Name Type Description Defaultwidget
Widget
A Widget within the composition.
required Returns Type DescriptionMapGeometry
Region relative to screen.
Raises Type DescriptionNoWidget
If the widget could not be found in this screen.
"},{"location":"api/screen/#textual.screen.Screen.focus_next","title":"focus_nextmethod
","text":"def focus_next(self, selector='*'):\n
Focus the next widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget. If no focusable widget matches the given CSS selector, focus is set to None
.
selector
str | type[QueryType]
CSS selector to filter what nodes can be focused.
'*'
Returns Type Description Widget | None
Newly focused widget, or None for no focus. If the return is not None
, then it is guaranteed that the widget returned matches the CSS selectors given in the argument.
method
","text":"def focus_previous(self, selector='*'):\n
Focus the previous widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget. If no focusable widget matches the given CSS selector, focus is set to None
.
selector
str | type[QueryType]
CSS selector to filter what nodes can be focused.
'*'
Returns Type Description Widget | None
Newly focused widget, or None for no focus. If the return is not None
, then it is guaranteed that the widget returned matches the CSS selectors given in the argument.
method
","text":"def get_offset(self, widget):\n
Get the absolute offset of a given Widget.
Parameters Name Type Description Defaultwidget
Widget
A widget
required Returns Type DescriptionOffset
The widget's offset relative to the top left of the terminal.
"},{"location":"api/screen/#textual.screen.Screen.get_style_at","title":"get_style_atmethod
","text":"def get_style_at(self, x, y):\n
Get the style under a given coordinate.
Parameters Name Type Description Defaultx
int
X Coordinate.
requiredy
int
Y Coordinate.
required Returns Type DescriptionStyle
Rich Style object.
"},{"location":"api/screen/#textual.screen.Screen.get_widget_at","title":"get_widget_atmethod
","text":"def get_widget_at(self, x, y):\n
Get the widget at a given coordinate.
Parameters Name Type Description Defaultx
int
X Coordinate.
requiredy
int
Y Coordinate.
required Returns Type Descriptiontuple[Widget, Region]
Widget and screen region.
"},{"location":"api/screen/#textual.screen.Screen.get_widgets_at","title":"get_widgets_atmethod
","text":"def get_widgets_at(self, x, y):\n
Get all widgets under a given coordinate.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type DescriptionIterable[tuple[Widget, Region]]
Sequence of (WIDGET, REGION) tuples.
"},{"location":"api/screen/#textual.screen.Screen.set_focus","title":"set_focusmethod
","text":"def set_focus(self, widget, scroll_visible=True):\n
Focus (or un-focus) a widget. A focused widget will receive key events first.
Parameters Name Type Description Defaultwidget
Widget | None
Widget to focus, or None to un-focus.
requiredscroll_visible
bool
Scroll widget in to view.
True
"},{"location":"api/screen/#textual.screen.Screen.validate_sub_title","title":"validate_sub_title method
","text":"def validate_sub_title(self, sub_title):\n
Ensure the sub-title is a string or None
.
method
","text":"def validate_title(self, title):\n
Ensure the title is a string or None
.
ScrollView
is a base class for line api widgets.
class
","text":" Bases: ScrollableContainer
A base class for a Widget that handles its own scrolling (i.e. doesn't rely on the compositor to render children).
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.is_scrollable","title":"is_scrollableproperty
","text":"is_scrollable: bool\n
Always scrollable.
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.refresh_lines","title":"refresh_linesmethod
","text":"def refresh_lines(self, y_start, line_count=1):\n
Refresh one or more lines.
Parameters Name Type Description Defaulty_start
int
First line to refresh.
requiredline_count
int
Total number of lines to refresh.
1
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.scroll_to","title":"scroll_to method
","text":"def scroll_to(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to a given (absolute) coordinate, optionally animating.
Parameters Name Type Description Defaultx
float | None
X coordinate (column) to scroll to, or None
for no change.
None
y
float | None
Y coordinate (row) to scroll to, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/scrollbar/","title":"Scrollbar","text":"Implements the scrollbar-related widgets for internal use.
You will not need to use the widgets defined in this module.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar","title":"ScrollBarclass
","text":"def __init__(self, vertical=True, name=None, *, thickness=1):\n
Bases: Widget
class-attribute
","text":"renderer: Type[ScrollBarRender] = ScrollBarRender\n
The class used for rendering scrollbars. This can be overridden and set to a ScrollBarRender-derived class in order to delegate all scrollbar rendering to that class. E.g.:
class MyScrollBarRender(ScrollBarRender): ...\n\napp = MyApp()\nScrollBar.renderer = MyScrollBarRender\napp.run()\n
Because this variable is accessed through specific instances (rather than through the class ScrollBar itself) it is also possible to set this on specific scrollbar instance to change only that instance:
my_widget.horizontal_scrollbar.renderer = MyScrollBarRender\n
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_grab","title":"action_grab method
","text":"def action_grab(self):\n
Begin capturing the mouse cursor.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_scroll_down","title":"action_scroll_downmethod
","text":"def action_scroll_down(self):\n
Scroll vertical scrollbars down, horizontal scrollbars right.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_scroll_up","title":"action_scroll_upmethod
","text":"def action_scroll_up(self):\n
Scroll vertical scrollbars up, horizontal scrollbars left.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBarCorner","title":"ScrollBarCornerclass
","text":"def __init__(self, name=None):\n
Bases: Widget
Widget which fills the gap between horizontal and vertical scrollbars, should they both be present.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollDown","title":"ScrollDownclass
","text":" Bases: ScrollMessage
Message sent when clicking below handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollLeft","title":"ScrollLeftclass
","text":" Bases: ScrollMessage
Message sent when clicking above handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollMessage","title":"ScrollMessageclass
","text":" Bases: Message
Base class for all scrollbar messages.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollRight","title":"ScrollRightclass
","text":" Bases: ScrollMessage
Message sent when clicking below handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollTo","title":"ScrollToclass
","text":"def __init__(self, x=None, y=None, animate=True):\n
Bases: ScrollMessage
Message sent when click and dragging handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollUp","title":"ScrollUpclass
","text":" Bases: ScrollMessage
Message sent when clicking above handle.
"},{"location":"api/strip/","title":"Strip","text":"A Strip contains the result of rendering a widget. See line API for how to use Strips.
"},{"location":"api/strip/#textual.strip.Strip","title":"Stripclass
","text":"def __init__(self, segments, cell_length=None):\n
Represents a 'strip' (horizontal line) of a Textual Widget.
A Strip is like an immutable list of Segments. The immutability allows for effective caching.
Parameters Name Type Description Defaultsegments
Iterable[Segment]
An iterable of segments.
requiredcell_length
int | None
The cell length if known, or None to calculate on demand.
None
"},{"location":"api/strip/#textual.strip.Strip.cell_length","title":"cell_length property
","text":"cell_length: int\n
Get the number of cells required to render this object.
"},{"location":"api/strip/#textual.strip.Strip.link_ids","title":"link_idsproperty
","text":"link_ids: set[str]\n
A set of the link ids in this Strip.
"},{"location":"api/strip/#textual.strip.Strip.text","title":"textproperty
","text":"text: str\n
Segment text.
"},{"location":"api/strip/#textual.strip.Strip.adjust_cell_length","title":"adjust_cell_lengthmethod
","text":"def adjust_cell_length(self, cell_length, style=None):\n
Adjust the cell length, possibly truncating or extending.
Parameters Name Type Description Defaultcell_length
int
New desired cell length.
requiredstyle
Style | None
Style when extending, or None
.
None
Returns Type Description Strip
A new strip with the supplied cell length.
"},{"location":"api/strip/#textual.strip.Strip.apply_filter","title":"apply_filtermethod
","text":"def apply_filter(self, filter, background):\n
Apply a filter to all segments in the strip.
Parameters Name Type Description Defaultfilter
LineFilter
A line filter object.
required Returns Type DescriptionStrip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.apply_style","title":"apply_stylemethod
","text":"def apply_style(self, style):\n
Apply a style to the Strip.
Parameters Name Type Description Defaultstyle
Style
A Rich style.
required Returns Type DescriptionStrip
A new strip.
"},{"location":"api/strip/#textual.strip.Strip.blank","title":"blankclassmethod
","text":"def blank(cls, cell_length, style=None):\n
Create a blank strip.
Parameters Name Type Description Defaultcell_length
int
Desired cell length.
requiredstyle
StyleType | None
Style of blank.
None
Returns Type Description Strip
New strip.
"},{"location":"api/strip/#textual.strip.Strip.crop","title":"cropmethod
","text":"def crop(self, start, end=None):\n
Crop a strip between two cell positions.
Parameters Name Type Description Defaultstart
int
The start cell position (inclusive).
requiredend
int | None
The end cell position (exclusive).
None
Returns Type Description Strip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.crop_extend","title":"crop_extendmethod
","text":"def crop_extend(self, start, end, style):\n
Crop between two points, extending the length if required.
Parameters Name Type Description Defaultstart
int
Start offset of crop.
requiredend
int
End offset of crop.
requiredstyle
Style | None
Style of additional padding.
required Returns Type DescriptionStrip
New cropped Strip.
"},{"location":"api/strip/#textual.strip.Strip.divide","title":"dividemethod
","text":"def divide(self, cuts):\n
Divide the strip in to multiple smaller strips by cutting at given (cell) indices.
Parameters Name Type Description Defaultcuts
Iterable[int]
An iterable of cell positions as ints.
required Returns Type DescriptionSequence[Strip]
A new list of strips.
"},{"location":"api/strip/#textual.strip.Strip.extend_cell_length","title":"extend_cell_lengthmethod
","text":"def extend_cell_length(self, cell_length, style=None):\n
Extend the cell length if it is less than the given value.
Parameters Name Type Description Defaultcell_length
int
Required minimum cell length.
requiredstyle
Style | None
Style for padding if the cell length is extended.
None
Returns Type Description Strip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.from_lines","title":"from_linesclassmethod
","text":"def from_lines(cls, lines, cell_length=None):\n
Convert lines (lists of segments) to a list of Strips.
Parameters Name Type Description Defaultlines
list[list[Segment]]
List of lines, where a line is a list of segments.
requiredcell_length
int | None
Cell length of lines (must be same) or None if not known.
None
Returns Type Description list[Strip]
List of strips.
"},{"location":"api/strip/#textual.strip.Strip.index_to_cell_position","title":"index_to_cell_positionmethod
","text":"def index_to_cell_position(self, index):\n
Given a character index, return the cell position of that character. This is the sum of the cell lengths of all the characters before the character at index
.
index
int
The index to convert.
required Returns Type Descriptionint
The cell position of the character at index
.
classmethod
","text":"def join(cls, strips):\n
Join a number of strips in to one.
Parameters Name Type Description Defaultstrips
Iterable[Strip | None]
An iterable of Strips.
required Returns Type DescriptionStrip
A new combined strip.
"},{"location":"api/strip/#textual.strip.Strip.simplify","title":"simplifymethod
","text":"def simplify(self):\n
Simplify the segments (join segments with same style)
Returns Type DescriptionStrip
New strip.
"},{"location":"api/strip/#textual.strip.Strip.style_links","title":"style_linksmethod
","text":"def style_links(self, link_id, link_style):\n
Apply a style to Segments with the given link_id.
Parameters Name Type Description Defaultlink_id
str
A link id.
requiredlink_style
Style
Style to apply.
required Returns Type DescriptionStrip
New strip (or same Strip if no changes).
"},{"location":"api/strip/#textual.strip.StripRenderable","title":"StripRenderableclass
","text":"def __init__(self, strips):\n
A renderable which renders a list of strips in to lines.
"},{"location":"api/strip/#textual.strip.get_line_length","title":"get_line_lengthfunction
","text":"def get_line_length(segments):\n
Get the line length (total length of all segments).
Parameters Name Type Description Defaultsegments
Iterable[Segment]
Iterable of segments.
required Returns Type Descriptionint
Length of line in cells.
"},{"location":"api/suggester/","title":"Suggester","text":"The Suggester
class is used by the Input widget.
class
","text":"def __init__(self, suggestions, *, case_sensitive=True):\n
Bases: Suggester
Give completion suggestions based on a fixed list of options.
Examplecountries = [\"England\", \"Scotland\", \"Portugal\", \"Spain\", \"France\"]\nclass MyApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield Input(suggester=SuggestFromList(countries, case_sensitive=False))\n
If the user types P inside the input widget, a completion suggestion for \"Portugal\"
appears.
suggestions
Iterable[str]
Valid suggestions sorted by decreasing priority.
requiredcase_sensitive
bool
Whether suggestions are computed in a case sensitive manner or not. The values provided in the argument suggestions
represent the canonical representation of the completions and they will be suggested with that same casing.
True
"},{"location":"api/suggester/#textual.suggester.SuggestFromList.get_suggestion","title":"get_suggestion async
","text":"def get_suggestion(self, value):\n
Gets a completion from the given possibilities.
Parameters Name Type Description Defaultvalue
str
The current value.
required Returns Type Descriptionstr | None
A valid completion suggestion or None
.
class
","text":"def __init__(self, *, use_cache=True, case_sensitive=False):\n
Bases: ABC
Defines how widgets generate completion suggestions.
To define a custom suggester, subclass Suggester
and implement the async method get_suggestion
. See SuggestFromList
for an example.
use_cache
bool
Whether to cache suggestion results.
True
case_sensitive
bool
Whether suggestions are case sensitive or not. If they are not, incoming values are casefolded before generating the suggestion.
False
"},{"location":"api/suggester/#textual.suggester.Suggester.cache","title":"cache instance-attribute
","text":"cache: LRUCache[str, str | None] | None = (\nLRUCache(1024) if use_cache else None\n)\n
Suggestion cache, if used.
"},{"location":"api/suggester/#textual.suggester.Suggester.get_suggestion","title":"get_suggestionasync
abstractmethod
","text":"def get_suggestion(self, value):\n
Try to get a completion suggestion for the given input value.
Custom suggesters should implement this method.
NoteThe value argument will be casefolded if self.case_sensitive
is False
.
If your implementation is not deterministic, you may need to disable caching.
Parameters Name Type Description Defaultvalue
str
The current value of the requester widget.
required Returns Type Descriptionstr | None
A valid suggestion or None
.
class
","text":" Bases: Message
Sent when a completion suggestion is ready.
"},{"location":"api/suggester/#textual.suggester.SuggestionReady.suggestion","title":"suggestioninstance-attribute
","text":"suggestion: str\n
The string suggestion.
"},{"location":"api/suggester/#textual.suggester.SuggestionReady.value","title":"valueinstance-attribute
","text":"value: str\n
The value to which the suggestion is for.
"},{"location":"api/system_commands_source/","title":"System commands source","text":"A command palette command provider for Textual system commands.
This is a simple command provider that makes the most obvious application actions available via the command palette.
"},{"location":"api/system_commands_source/#textual._system_commands.SystemCommands","title":"SystemCommandsclass
","text":" Bases: Provider
A source of command palette commands that run app-wide tasks.
Used by default in App.COMMANDS
.
async
","text":"def search(self, query):\n
Handle a request to search for system commands that match the query.
Parameters Name Type Description Defaultuser_input
The user input to be matched.
requiredYields:
Type DescriptionHits
Command hits for use in the command palette.
"},{"location":"api/timer/","title":"Timer","text":"Timer objects are created by set_interval or set_timer.
"},{"location":"api/timer/#textual.timer.TimerCallback","title":"TimerCallbackmodule-attribute
","text":"TimerCallback = Union[\nCallable[[], Awaitable[None]], Callable[[], None]\n]\n
Type of valid callbacks to be used with timers.
"},{"location":"api/timer/#textual.timer.Timer","title":"Timerclass
","text":"def __init__(\nself,\nevent_target,\ninterval,\n*,\nname=None,\ncallback=None,\nrepeat=None,\nskip=True,\npause=False\n):\n
A class to send timer-based events.
Parameters Name Type Description Defaultevent_target
MessageTarget
The object which will receive the timer events.
requiredinterval
float
The time between timer events, in seconds.
requiredname
str | None
A name to assign the event (for debugging).
None
callback
TimerCallback | None
A optional callback to invoke when the event is handled.
None
repeat
int | None
The number of times to repeat the timer, or None to repeat forever.
None
skip
bool
Enable skipping of scheduled events that couldn't be sent in time.
True
pause
bool
Start the timer paused.
False
"},{"location":"api/timer/#textual.timer.Timer.pause","title":"pause method
","text":"def pause(self):\n
Pause the timer.
A paused timer will not send events until it is resumed.
"},{"location":"api/timer/#textual.timer.Timer.reset","title":"resetmethod
","text":"def reset(self):\n
Reset the timer, so it starts from the beginning.
"},{"location":"api/timer/#textual.timer.Timer.resume","title":"resumemethod
","text":"def resume(self):\n
Resume a paused timer.
"},{"location":"api/timer/#textual.timer.Timer.stop","title":"stopmethod
","text":"def stop(self):\n
Stop the timer.
"},{"location":"api/types/","title":"Types","text":"Export some objects that are used by Textual and that help document other features.
"},{"location":"api/types/#textual.types.ActionParseResult","title":"ActionParseResultmodule-attribute
","text":"ActionParseResult: TypeAlias = \"tuple[str, tuple[Any, ...]]\"\n
An action is its name and the arbitrary tuple of its arguments.
"},{"location":"api/types/#textual.types.CSSPathType","title":"CSSPathTypemodule-attribute
","text":"CSSPathType: TypeAlias = Union[\nstr, PurePath, List[Union[str, PurePath]]\n]\n
Valid ways of specifying paths to CSS files.
"},{"location":"api/types/#textual.types.CallbackType","title":"CallbackTypemodule-attribute
","text":"CallbackType = Union[\nCallable[[], Awaitable[None]], Callable[[], None]\n]\n
Type used for arbitrary callables used in callbacks.
"},{"location":"api/types/#textual.types.CursorType","title":"CursorTypemodule-attribute
","text":"CursorType = Literal['cell', 'row', 'column', 'none']\n
The valid types of cursors for DataTable.cursor_type
.
module-attribute
","text":"EasingFunction = Callable[[float], float]\n
Signature for a function that parametrises animation speed.
An easing function must map the interval [0, 1] into the interval [0, 1].
"},{"location":"api/types/#textual.types.IgnoreReturnCallbackType","title":"IgnoreReturnCallbackTypemodule-attribute
","text":"IgnoreReturnCallbackType = Union[\nCallable[[], Awaitable[Any]], Callable[[], Any]\n]\n
A callback which ignores the return type.
"},{"location":"api/types/#textual.types.InputValidationOn","title":"InputValidationOnmodule-attribute
","text":"InputValidationOn = Literal['blur', 'changed', 'submitted']\n
Possible messages that trigger input validation.
"},{"location":"api/types/#textual.types.WatchCallbackType","title":"WatchCallbackTypemodule-attribute
","text":"WatchCallbackType = Union[\nCallable[[], Awaitable[None]],\nCallable[[Any], Awaitable[None]],\nCallable[[Any, Any], Awaitable[None]],\nCallable[[], None],\nCallable[[Any], None],\nCallable[[Any, Any], None],\n]\n
Type used for callbacks passed to the watch
method of widgets.
class
","text":" Bases: Protocol
Protocol for objects that can have their intrinsic values animated.
For example, the transition between two colors can be animated because the class Color
satisfies this protocol.
class
","text":" Bases: Exception
Raised when supplied CSS path(s) are invalid.
"},{"location":"api/types/#textual.types.MessageTarget","title":"MessageTargetclass
","text":" Bases: Protocol
Protocol that must be followed by objects that can receive messages.
"},{"location":"api/types/#textual.types.NoActiveAppError","title":"NoActiveAppErrorclass
","text":" Bases: RuntimeError
Runtime error raised if we try to retrieve the active app when there is none.
"},{"location":"api/types/#textual.types.RenderStyles","title":"RenderStylesclass
","text":"def __init__(self, node, base, inline_styles):\n
Bases: StylesBase
Presents a combined view of two Styles object: a base Styles and inline Styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.base","title":"baseproperty
","text":"base: Styles\n
Quick access to base (css) style.
"},{"location":"api/types/#textual.css.styles.RenderStyles.css","title":"cssproperty
","text":"css: str\n
Get the CSS for the combined styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.gutter","title":"gutterproperty
","text":"gutter: Spacing\n
Get space around widget.
Returns Type DescriptionSpacing
Space around widget content.
"},{"location":"api/types/#textual.css.styles.RenderStyles.inline","title":"inlineproperty
","text":"inline: Styles\n
Quick access to the inline styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.rich_style","title":"rich_styleproperty
","text":"rich_style: Style\n
Get a Rich style for this Styles object.
"},{"location":"api/types/#textual.css.styles.RenderStyles.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
str | float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation. Defaults to value
if not set.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/types/#textual.css.styles.RenderStyles.clear_rule","title":"clear_rule method
","text":"def clear_rule(self, rule_name):\n
Clear a rule (from inline).
"},{"location":"api/types/#textual.css.styles.RenderStyles.get_rules","title":"get_rulesmethod
","text":"def get_rules(self):\n
Get rules as a dictionary
"},{"location":"api/types/#textual.css.styles.RenderStyles.has_rule","title":"has_rulemethod
","text":"def has_rule(self, rule):\n
Check if a rule has been set.
"},{"location":"api/types/#textual.css.styles.RenderStyles.merge","title":"mergemethod
","text":"def merge(self, other):\n
Merge values from another Styles.
Parameters Name Type Description Defaultother
StylesBase
A Styles object.
required"},{"location":"api/types/#textual.css.styles.RenderStyles.reset","title":"resetmethod
","text":"def reset(self):\n
Reset the rules to initial state.
"},{"location":"api/types/#textual.types.UnusedParameter","title":"UnusedParameterclass
","text":"Helper type for a parameter that isn't specified in a method call.
"},{"location":"api/validation/","title":"Validation","text":"Framework for validating string values
"},{"location":"api/validation/#textual.validation.Failure","title":"Failureclass
","text":"Information about a validation failure.
"},{"location":"api/validation/#textual.validation.Failure.description","title":"descriptionclass-attribute
instance-attribute
","text":"description: str | None = None\n
An optional override for describing this failure. Takes precedence over any messages set in the Validator.
"},{"location":"api/validation/#textual.validation.Failure.validator","title":"validatorinstance-attribute
","text":"validator: Validator\n
The Validator which produced the failure.
"},{"location":"api/validation/#textual.validation.Failure.value","title":"valueclass-attribute
instance-attribute
","text":"value: str | None = None\n
The value which resulted in validation failing.
"},{"location":"api/validation/#textual.validation.Function","title":"Functionclass
","text":"def __init__(self, function, failure_description=None):\n
Bases: Validator
A flexible validator which allows you to provide custom validation logic.
"},{"location":"api/validation/#textual.validation.Function.function","title":"functioninstance-attribute
","text":"function = function\n
Function which takes the value to validate and returns True if valid, and False otherwise.
"},{"location":"api/validation/#textual.validation.Function.ReturnedFalse","title":"ReturnedFalseclass
","text":" Bases: Failure
Indicates validation failed because the supplied function returned False.
"},{"location":"api/validation/#textual.validation.Function.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Function.validate","title":"validatemethod
","text":"def validate(self, value):\n
Validate that the supplied function returns True.
Parameters Name Type Description Defaultvalue
str
The value to pass into the supplied function.
required Returns Type DescriptionValidationResult
A ValidationResult indicating success if the function returned True, and failure if the function return False.
"},{"location":"api/validation/#textual.validation.Integer","title":"Integerclass
","text":" Bases: Number
Validator which ensures the value is an integer which falls within a range.
"},{"location":"api/validation/#textual.validation.Integer.NotAnInteger","title":"NotAnIntegerclass
","text":" Bases: Failure
Indicates a failure due to the value not being a valid integer.
"},{"location":"api/validation/#textual.validation.Integer.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Integer.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value
is an integer, optionally within a range.
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Length","title":"Lengthclass
","text":"def __init__(\nself,\nminimum=None,\nmaximum=None,\nfailure_description=None,\n):\n
Bases: Validator
Validate that a string is within a range (inclusive).
"},{"location":"api/validation/#textual.validation.Length.maximum","title":"maximuminstance-attribute
","text":"maximum = maximum\n
The inclusive maximum length of the value, or None if unbounded.
"},{"location":"api/validation/#textual.validation.Length.minimum","title":"minimuminstance-attribute
","text":"minimum = minimum\n
The inclusive minimum length of the value, or None if unbounded.
"},{"location":"api/validation/#textual.validation.Length.Incorrect","title":"Incorrectclass
","text":" Bases: Failure
Indicates a failure due to the length of the value being outside the range.
"},{"location":"api/validation/#textual.validation.Length.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Length.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value falls within the maximum and minimum length constraints.
Parameters Name Type Description Defaultvalue
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Number","title":"Numberclass
","text":"def __init__(\nself,\nminimum=None,\nmaximum=None,\nfailure_description=None,\n):\n
Bases: Validator
Validator that ensures the value is a number, with an optional range check.
"},{"location":"api/validation/#textual.validation.Number.maximum","title":"maximuminstance-attribute
","text":"maximum = maximum\n
The maximum value of the number, inclusive. If None
, the maximum is unbounded.
instance-attribute
","text":"minimum = minimum\n
The minimum value of the number, inclusive. If None
, the minimum is unbounded.
class
","text":" Bases: Failure
Indicates a failure due to the value not being a valid number (decimal/integer, inc. scientific notation)
"},{"location":"api/validation/#textual.validation.Number.NotInRange","title":"NotInRangeclass
","text":" Bases: Failure
Indicates a failure due to the number not being within the range [minimum, maximum].
"},{"location":"api/validation/#textual.validation.Number.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Number.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value
is a valid number, optionally within a range.
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Regex","title":"Regexclass
","text":"def __init__(self, regex, flags=0, failure_description=None):\n
Bases: Validator
A validator that checks the value matches a regex (via re.fullmatch
).
instance-attribute
","text":"flags = flags\n
The flags to pass to re.fullmatch
.
instance-attribute
","text":"regex = regex\n
The regex which we'll validate is matched by the value.
"},{"location":"api/validation/#textual.validation.Regex.NoResults","title":"NoResultsclass
","text":" Bases: Failure
Indicates validation failed because the regex could not be found within the value string.
"},{"location":"api/validation/#textual.validation.Regex.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Regex.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that the value matches the regex.
Parameters Name Type Description Defaultvalue
str
The value that should match the regex.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.URL","title":"URLclass
","text":" Bases: Validator
Validator that checks if a URL is valid (ensuring a scheme is present).
"},{"location":"api/validation/#textual.validation.URL.InvalidURL","title":"InvalidURLclass
","text":" Bases: Failure
Indicates that the URL is not valid.
"},{"location":"api/validation/#textual.validation.URL.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.URL.validate","title":"validatemethod
","text":"def validate(self, value):\n
Validates that value
is a valid URL (contains a scheme).
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.ValidationResult","title":"ValidationResultclass
","text":"The result of calling a Validator.validate
method.
property
","text":"failure_descriptions: list[str]\n
Utility for extracting failure descriptions as strings.
Useful if you don't care about the additional metadata included in the Failure
objects.
list[str]
A list of the string descriptions explaining the failing validations.
"},{"location":"api/validation/#textual.validation.ValidationResult.failures","title":"failuresclass-attribute
instance-attribute
","text":"failures: Sequence[Failure] = field(default_factory=list)\n
A list of reasons why the value was invalid. Empty if valid=True
"},{"location":"api/validation/#textual.validation.ValidationResult.is_valid","title":"is_validproperty
","text":"is_valid: bool\n
True if the validation was successful.
"},{"location":"api/validation/#textual.validation.ValidationResult.failure","title":"failurestaticmethod
","text":"def failure(failures):\n
Construct a failure ValidationResult.
Parameters Name Type Description Defaultfailures
Sequence[Failure]
The failures.
required Returns Type DescriptionValidationResult
A failure ValidationResult.
"},{"location":"api/validation/#textual.validation.ValidationResult.merge","title":"mergestaticmethod
","text":"def merge(results):\n
Merge multiple ValidationResult objects into one.
Parameters Name Type Description Defaultresults
Sequence['ValidationResult']
List of ValidationResult objects to merge.
required Returns Type Description'ValidationResult'
Merged ValidationResult object.
"},{"location":"api/validation/#textual.validation.ValidationResult.success","title":"successstaticmethod
","text":"def success():\n
Construct a successful ValidationResult.
Returns Type DescriptionValidationResult
A successful ValidationResult.
"},{"location":"api/validation/#textual.validation.Validator","title":"Validatorclass
","text":"def __init__(self, failure_description=None):\n
Bases: ABC
Base class for the validation of string values.
Commonly used in conjunction with the Input
widget, which accepts a list of validators via its constructor. This validation framework can also be used to validate any 'stringly-typed' values (for example raw command line input from sys.args
).
To implement your own Validator
, subclass this class.
class Palindrome(Validator):\ndef validate(self, value: str) -> ValidationResult:\ndef is_palindrome(value: str) -> bool:\nreturn value == value[::-1]\nreturn self.success() if is_palindrome(value) else self.failure(\"Not palindrome!\")\n
"},{"location":"api/validation/#textual.validation.Validator.failure_description","title":"failure_description instance-attribute
","text":"failure_description = failure_description\n
A description of why the validation failed.
The description (intended to be user-facing) to attached to the Failure if the validation fails. This failure description is ultimately accessible at the time of validation failure via the Input.Changed
or Input.Submitted
event, and you can access it on your message handler (a method called, for example, on_input_changed
or a method decorated with @on(Input.Changed)
.
method
","text":"def describe_failure(self, failure):\n
Return a string description of the Failure.
Used to provide a more fine-grained description of the failure. A Validator could fail for multiple reasons, so this method could be used to provide a different reason for different types of failure.
Warning
This method is only called if no other description has been supplied. If you supply a description inside a call to self.failure(description=\"...\")
, or pass a description into the constructor of the validator, those will take priority, and this method won't be called.
failure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Validator.failure","title":"failuremethod
","text":"def failure(self, description=None, value=None, failures=None):\n
Shorthand for signaling validation failure.
You can return failure(...) from a Validator.validate
implementation to signal validation succeeded.
description
str | None
The failure description that will be used. When used in conjunction with the Input widget, this is the description that will ultimately be available inside the handler for Input.Changed
. If not supplied, the failure_description
from the Validator
will be used. If that is not supplied either, then the describe_failure
method on Validator
will be called.
None
value
str | None
The value that was considered invalid. This is optional, and only needs to be supplied if required in your Input.Changed
handler.
None
failures
Failure | Sequence[Failure] | None
The reasons the validator failed. If not supplied, a generic Failure
will be included in the ValidationResult returned from this function.
None
Returns Type Description ValidationResult
A ValidationResult representing failed validation, and containing the metadata supplied to this function.
"},{"location":"api/validation/#textual.validation.Validator.success","title":"successmethod
","text":"def success(self):\n
Shorthand for ValidationResult(True)
.
You can return success() from a Validator.validate
method implementation to signal that validation has succeeded.
ValidationResult
A ValidationResult indicating validation succeeded.
"},{"location":"api/validation/#textual.validation.Validator.validate","title":"validateabstractmethod
","text":"def validate(self, value):\n
Validate the value and return a ValidationResult describing the outcome of the validation.
Parameters Name Type Description Defaultvalue
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/walk/","title":"Walk","text":"Functions for walking the DOM.
Note
For most purposes you would be better off using query, which uses these functions internally.
"},{"location":"api/walk/#textual.walk.walk_breadth_first","title":"walk_breadth_firstfunction
","text":"def walk_breadth_first(\nroot, filter_type=None, *, with_root=True\n):\n
Walk the tree breadth first (children first).
Note
Avoid changing the DOM (mounting, removing etc.) while iterating with this function. Consider walk_children which doesn't have this limitation.
Parameters Name Type Description Defaultroot
DOMNode
The root note (starting point).
requiredfilter_type
type[WalkType] | None
Optional DOMNode subclass to filter by, or None
for no filter.
None
with_root
bool
Include the root in the walk.
True
Returns Type Description Iterable[DOMNode] | Iterable[WalkType]
An iterable of DOMNodes, or the type specified in filter_type
.
function
","text":"def walk_depth_first(root, filter_type=None, *, with_root=True):\n
Walk the tree depth first (parents first).
Note
Avoid changing the DOM (mounting, removing etc.) while iterating with this function. Consider walk_children which doesn't have this limitation.
Parameters Name Type Description Defaultroot
DOMNode
The root note (starting point).
requiredfilter_type
type[WalkType] | None
Optional DOMNode subclass to filter by, or None
for no filter.
None
with_root
bool
Include the root in the walk.
True
Returns Type Description Iterable[DOMNode] | Iterable[WalkType]
An iterable of DOMNodes, or the type specified in filter_type
.
The base class for widgets.
"},{"location":"api/widget/#textual.widget.AwaitMount","title":"AwaitMountclass
","text":"def __init__(self, parent, widgets):\n
An optional awaitable returned by mount and mount_all.
Exampleawait self.mount(Static(\"foo\"))\n
"},{"location":"api/widget/#textual.widget.MountError","title":"MountError class
","text":" Bases: WidgetError
Error raised when there was a problem with the mount request.
"},{"location":"api/widget/#textual.widget.PseudoClasses","title":"PseudoClassesclass
","text":" Bases: NamedTuple
Used for render/render_line based widgets that use caching. This structure can be used as a cache-key.
"},{"location":"api/widget/#textual.widget.PseudoClasses.enabled","title":"enabledinstance-attribute
","text":"enabled: bool\n
Is 'enabled' applied?
"},{"location":"api/widget/#textual.widget.PseudoClasses.focus","title":"focusinstance-attribute
","text":"focus: bool\n
Is 'focus' applied?
"},{"location":"api/widget/#textual.widget.PseudoClasses.hover","title":"hoverinstance-attribute
","text":"hover: bool\n
Is 'hover' applied?
"},{"location":"api/widget/#textual.widget.Widget","title":"Widgetclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: DOMNode
A Widget is the base class for Textual widgets.
See also static for starting point for your own widgets.
Parameters Name Type Description Default*children
Widget
Child widgets.
()
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"api/widget/#textual.widget.Widget.BORDER_SUBTITLE","title":"BORDER_SUBTITLE class-attribute
","text":"BORDER_SUBTITLE: str = ''\n
Initial value for border_subtitle attribute.
"},{"location":"api/widget/#textual.widget.Widget.BORDER_TITLE","title":"BORDER_TITLEclass-attribute
","text":"BORDER_TITLE: str = ''\n
Initial value for border_title attribute.
"},{"location":"api/widget/#textual.widget.Widget.allow_horizontal_scroll","title":"allow_horizontal_scrollproperty
","text":"allow_horizontal_scroll: bool\n
Check if horizontal scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"},{"location":"api/widget/#textual.widget.Widget.allow_vertical_scroll","title":"allow_vertical_scrollproperty
","text":"allow_vertical_scroll: bool\n
Check if vertical scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"},{"location":"api/widget/#textual.widget.Widget.auto_links","title":"auto_linksclass-attribute
instance-attribute
","text":"auto_links: Reactive[bool] = Reactive(True)\n
Widget will highlight links automatically.
"},{"location":"api/widget/#textual.widget.Widget.border_subtitle","title":"border_subtitleclass-attribute
instance-attribute
","text":"border_subtitle: str | Text | None = _BorderTitle()\n
A title to show in the bottom border (if there is one).
"},{"location":"api/widget/#textual.widget.Widget.border_title","title":"border_titleclass-attribute
instance-attribute
","text":"border_title: str | Text | None = _BorderTitle()\n
A title to show in the top border (if there is one).
"},{"location":"api/widget/#textual.widget.Widget.can_focus","title":"can_focusclass-attribute
instance-attribute
","text":"can_focus: bool = False\n
Widget may receive focus.
"},{"location":"api/widget/#textual.widget.Widget.can_focus_children","title":"can_focus_childrenclass-attribute
instance-attribute
","text":"can_focus_children: bool = True\n
Widget's children may receive focus.
"},{"location":"api/widget/#textual.widget.Widget.container_size","title":"container_sizeproperty
","text":"container_size: Size\n
The size of the container (parent widget).
Returns Type DescriptionSize
Container size.
"},{"location":"api/widget/#textual.widget.Widget.container_viewport","title":"container_viewportproperty
","text":"container_viewport: Region\n
The viewport region (parent window).
Returns Type DescriptionRegion
The region that contains this widget.
"},{"location":"api/widget/#textual.widget.Widget.content_offset","title":"content_offsetproperty
","text":"content_offset: Offset\n
An offset from the Widget origin where the content begins.
Returns Type DescriptionOffset
Offset from widget's origin.
"},{"location":"api/widget/#textual.widget.Widget.content_region","title":"content_regionproperty
","text":"content_region: Region\n
Gets an absolute region containing the content (minus padding and border).
Returns Type DescriptionRegion
Screen region that contains a widget's content.
"},{"location":"api/widget/#textual.widget.Widget.content_size","title":"content_sizeproperty
","text":"content_size: Size\n
The size of the content area.
Returns Type DescriptionSize
Content area size.
"},{"location":"api/widget/#textual.widget.Widget.disabled","title":"disabledclass-attribute
instance-attribute
","text":"disabled: Reactive[bool] = disabled\n
Is the widget disabled? Disabled widgets can not be interacted with, and are typically styled to look dimmer.
"},{"location":"api/widget/#textual.widget.Widget.dock_gutter","title":"dock_gutterproperty
","text":"dock_gutter: Spacing\n
Space allocated to docks in the parent.
Returns Type DescriptionSpacing
Space to be subtracted from scrollable area.
"},{"location":"api/widget/#textual.widget.Widget.expand","title":"expandclass-attribute
instance-attribute
","text":"expand: Reactive[bool] = Reactive(False)\n
Rich renderable may expand beyond optimal size.
"},{"location":"api/widget/#textual.widget.Widget.focusable","title":"focusableproperty
","text":"focusable: bool\n
Can this widget currently be focused?
"},{"location":"api/widget/#textual.widget.Widget.gutter","title":"gutterproperty
","text":"gutter: Spacing\n
Spacing for padding / border / scrollbars.
Returns Type DescriptionSpacing
Additional spacing around content area.
"},{"location":"api/widget/#textual.widget.Widget.has_focus","title":"has_focusclass-attribute
instance-attribute
","text":"has_focus: Reactive[bool] = Reactive(False, repaint=False)\n
Does this widget have focus? Read only.
"},{"location":"api/widget/#textual.widget.Widget.highlight_link_id","title":"highlight_link_idclass-attribute
instance-attribute
","text":"highlight_link_id: Reactive[str] = Reactive('')\n
The currently highlighted link id. Read only.
"},{"location":"api/widget/#textual.widget.Widget.horizontal_scrollbar","title":"horizontal_scrollbarproperty
","text":"horizontal_scrollbar: ScrollBar\n
The horizontal scrollbar.
NoteThis will create a scrollbar if one doesn't exist.
Returns Type DescriptionScrollBar
ScrollBar Widget.
"},{"location":"api/widget/#textual.widget.Widget.hover_style","title":"hover_styleclass-attribute
instance-attribute
","text":"hover_style: Reactive[Style] = Reactive(\nStyle, repaint=False\n)\n
The current hover style (style under the mouse cursor). Read only.
"},{"location":"api/widget/#textual.widget.Widget.is_container","title":"is_containerproperty
","text":"is_container: bool\n
Is this widget a container (contains other widgets)?
"},{"location":"api/widget/#textual.widget.Widget.is_horizontal_scroll_end","title":"is_horizontal_scroll_endproperty
","text":"is_horizontal_scroll_end: bool\n
Is the horizontal scroll position at the maximum?
"},{"location":"api/widget/#textual.widget.Widget.is_horizontal_scrollbar_grabbed","title":"is_horizontal_scrollbar_grabbedproperty
","text":"is_horizontal_scrollbar_grabbed: bool\n
Is the user dragging the vertical scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.is_scrollable","title":"is_scrollableproperty
","text":"is_scrollable: bool\n
Can this widget be scrolled?
"},{"location":"api/widget/#textual.widget.Widget.is_vertical_scroll_end","title":"is_vertical_scroll_endproperty
","text":"is_vertical_scroll_end: bool\n
Is the vertical scroll position at the maximum?
"},{"location":"api/widget/#textual.widget.Widget.is_vertical_scrollbar_grabbed","title":"is_vertical_scrollbar_grabbedproperty
","text":"is_vertical_scrollbar_grabbed: bool\n
Is the user dragging the vertical scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.layer","title":"layerproperty
","text":"layer: str\n
Get the name of this widgets layer.
Returns Type Descriptionstr
Name of layer.
"},{"location":"api/widget/#textual.widget.Widget.layers","title":"layersproperty
","text":"layers: tuple[str, ...]\n
Layers of from parent.
Returns Type Descriptiontuple[str, ...]
Tuple of layer names.
"},{"location":"api/widget/#textual.widget.Widget.link_hover_style","title":"link_hover_styleproperty
","text":"link_hover_style: Style\n
Style of links underneath the mouse cursor.
Returns Type DescriptionStyle
Rich Style.
"},{"location":"api/widget/#textual.widget.Widget.link_style","title":"link_styleproperty
","text":"link_style: Style\n
Style of links.
Returns Type DescriptionStyle
Rich style.
"},{"location":"api/widget/#textual.widget.Widget.max_scroll_x","title":"max_scroll_xproperty
","text":"max_scroll_x: int\n
The maximum value of scroll_x
.
property
","text":"max_scroll_y: int\n
The maximum value of scroll_y
.
class-attribute
instance-attribute
","text":"mouse_over: Reactive[bool] = Reactive(False, repaint=False)\n
Is the mouse over this widget? Read only.
"},{"location":"api/widget/#textual.widget.Widget.offset","title":"offsetwritable
property
","text":"offset: Offset\n
Widget offset from origin.
Returns Type DescriptionOffset
Relative offset.
"},{"location":"api/widget/#textual.widget.Widget.opacity","title":"opacityproperty
","text":"opacity: float\n
Total opacity of widget.
"},{"location":"api/widget/#textual.widget.Widget.outer_size","title":"outer_sizeproperty
","text":"outer_size: Size\n
The size of the widget (including padding and border).
Returns Type DescriptionSize
Outer size.
"},{"location":"api/widget/#textual.widget.Widget.region","title":"regionproperty
","text":"region: Region\n
The region occupied by this widget, relative to the Screen.
Raises Type DescriptionNoScreen
If there is no screen.
errors.NoWidget
If the widget is not on the screen.
Returns Type DescriptionRegion
Region within screen occupied by widget.
"},{"location":"api/widget/#textual.widget.Widget.scroll_offset","title":"scroll_offsetproperty
","text":"scroll_offset: Offset\n
Get the current scroll offset.
Returns Type DescriptionOffset
Offset a container has been scrolled by.
"},{"location":"api/widget/#textual.widget.Widget.scroll_x","title":"scroll_xclass-attribute
instance-attribute
","text":"scroll_x: Reactive[float] = Reactive(\n0.0, repaint=False, layout=False\n)\n
The scroll position on the X axis.
"},{"location":"api/widget/#textual.widget.Widget.scroll_y","title":"scroll_yclass-attribute
instance-attribute
","text":"scroll_y: Reactive[float] = Reactive(\n0.0, repaint=False, layout=False\n)\n
The scroll position on the Y axis.
"},{"location":"api/widget/#textual.widget.Widget.scrollable_content_region","title":"scrollable_content_regionproperty
","text":"scrollable_content_region: Region\n
Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars).
Returns Type DescriptionRegion
Screen region that contains a widget's content.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_corner","title":"scrollbar_cornerproperty
","text":"scrollbar_corner: ScrollBarCorner\n
The scrollbar corner.
NoteThis will create a scrollbar corner if one doesn't exist.
Returns Type DescriptionScrollBarCorner
ScrollBarCorner Widget.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_gutter","title":"scrollbar_gutterproperty
","text":"scrollbar_gutter: Spacing\n
Spacing required to fit scrollbar(s).
Returns Type DescriptionSpacing
Scrollbar gutter spacing.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_size_horizontal","title":"scrollbar_size_horizontalproperty
","text":"scrollbar_size_horizontal: int\n
Get the height used by the horizontal scrollbar.
Returns Type Descriptionint
Number of rows in the horizontal scrollbar.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_size_vertical","title":"scrollbar_size_verticalproperty
","text":"scrollbar_size_vertical: int\n
Get the width used by the vertical scrollbar.
Returns Type Descriptionint
Number of columns in the vertical scrollbar.
"},{"location":"api/widget/#textual.widget.Widget.scrollbars_enabled","title":"scrollbars_enabledproperty
","text":"scrollbars_enabled: tuple[bool, bool]\n
A tuple of booleans that indicate if scrollbars are enabled.
Returns Type Descriptiontuple[bool, bool]
A tuple of (, )"},{"location":"api/widget/#textual.widget.Widget.scrollbars_space","title":"scrollbars_space property
","text":"
scrollbars_space: tuple[int, int]\n
The number of cells occupied by scrollbars for width and height
"},{"location":"api/widget/#textual.widget.Widget.show_horizontal_scrollbar","title":"show_horizontal_scrollbarclass-attribute
instance-attribute
","text":"show_horizontal_scrollbar: Reactive[bool] = Reactive(\nFalse, layout=True\n)\n
Show a horizontal scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.show_vertical_scrollbar","title":"show_vertical_scrollbarclass-attribute
instance-attribute
","text":"show_vertical_scrollbar: Reactive[bool] = Reactive(\nFalse, layout=True\n)\n
Show a horizontal scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.shrink","title":"shrinkclass-attribute
instance-attribute
","text":"shrink: Reactive[bool] = Reactive(True)\n
Rich renderable may shrink below optimal size.
"},{"location":"api/widget/#textual.widget.Widget.siblings","title":"siblingsproperty
","text":"siblings: list[Widget]\n
Get the widget's siblings (self is removed from the return list).
Returns Type Descriptionlist[Widget]
A list of siblings.
"},{"location":"api/widget/#textual.widget.Widget.size","title":"sizeproperty
","text":"size: Size\n
The size of the content area.
Returns Type DescriptionSize
Content area size.
"},{"location":"api/widget/#textual.widget.Widget.tooltip","title":"tooltipwritable
property
","text":"tooltip: RenderableType | None\n
Tooltip for the widget, or None
for no tooltip.
property
","text":"vertical_scrollbar: ScrollBar\n
The vertical scrollbar (create if necessary).
NoteThis will create a scrollbar if one doesn't exist.
Returns Type DescriptionScrollBar
ScrollBar Widget.
"},{"location":"api/widget/#textual.widget.Widget.virtual_region","title":"virtual_regionproperty
","text":"virtual_region: Region\n
The widget region relative to it's container (which may not be visible, depending on scroll offset).
Returns Type DescriptionRegion
The virtual region.
"},{"location":"api/widget/#textual.widget.Widget.virtual_region_with_margin","title":"virtual_region_with_marginproperty
","text":"virtual_region_with_margin: Region\n
The widget region relative to its container (including margin), which may not be visible, depending on the scroll offset.
Returns Type DescriptionRegion
The virtual region of the Widget, inclusive of its margin.
"},{"location":"api/widget/#textual.widget.Widget.virtual_size","title":"virtual_sizeclass-attribute
instance-attribute
","text":"virtual_size: Reactive[Size] = Reactive(\nSize(0, 0), layout=True\n)\n
The virtual (scrollable) size of the widget.
"},{"location":"api/widget/#textual.widget.Widget.visible_siblings","title":"visible_siblingsproperty
","text":"visible_siblings: list[Widget]\n
A list of siblings which will be shown.
Returns Type Descriptionlist[Widget]
List of siblings.
"},{"location":"api/widget/#textual.widget.Widget.window_region","title":"window_regionproperty
","text":"window_region: Region\n
The region within the scrollable area that is currently visible.
Returns Type DescriptionRegion
New region.
"},{"location":"api/widget/#textual.widget.Widget.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation. Defaults to value
if not set.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.begin_capture_print","title":"begin_capture_print method
","text":"def begin_capture_print(self, stdout=True, stderr=True):\n
Capture text from print statements (or writes to stdout / stderr).
If printing is captured, the widget will be sent an events.Print message.
Call end_capture_print to disable print capture.
Parameters Name Type Description Defaultstdout
bool
Capture stdout.
True
stderr
bool
Capture stderr.
True
"},{"location":"api/widget/#textual.widget.Widget.blur","title":"blur method
","text":"def blur(self):\n
Blur (un-focus) the widget.
Focus will be moved to the next available widget in the focus chain..
Returns Type DescriptionSelf
The Widget
instance.
method
","text":"def can_view(self, widget):\n
Check if a given widget is in the current view (scrollable area).
Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible.
Parameters Name Type Description Defaultwidget
Widget
A widget that is a descendant of self.
required Returns Type Descriptionbool
True if the entire widget is in view, False if it is partially visible or not in view.
"},{"location":"api/widget/#textual.widget.Widget.capture_mouse","title":"capture_mousemethod
","text":"def capture_mouse(self, capture=True):\n
Capture (or release) the mouse.
When captured, mouse events will go to this widget even when the pointer is not directly over the widget.
Parameters Name Type Description Defaultcapture
bool
True to capture or False to release.
True
"},{"location":"api/widget/#textual.widget.Widget.check_message_enabled","title":"check_message_enabled method
","text":"def check_message_enabled(self, message):\n
Check if a given message is enabled (allowed to be sent).
Parameters Name Type Description Defaultmessage
Message
A message object
required Returns Type Descriptionbool
True
if the message will be sent, or False
if it is disabled.
method
","text":"def compose(self):\n
Called by Textual to create child widgets.
Extend this to build a UI.
Exampledef compose(self) -> ComposeResult:\nyield Header()\nyield Container(\nTree(), Viewer()\n)\nyield Footer()\n
"},{"location":"api/widget/#textual.widget.Widget.end_capture_print","title":"end_capture_print method
","text":"def end_capture_print(self):\n
End print capture (set with capture_print).
"},{"location":"api/widget/#textual.widget.Widget.focus","title":"focusmethod
","text":"def focus(self, scroll_visible=True):\n
Give focus to this widget.
Parameters Name Type Description Defaultscroll_visible
bool
Scroll parent to make this widget visible.
True
Returns Type Description Self
The Widget
instance.
method
","text":"def get_child_by_id(self, id, expect_type=None):\n
Return the first child (immediate descendent) of this node with the given ID.
Parameters Name Type Description Defaultid
str
The ID of the child.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type.
None
Returns Type Description ExpectType | Widget
The first child of this node with the ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID
WrongType
if the wrong type was found.
"},{"location":"api/widget/#textual.widget.Widget.get_child_by_type","title":"get_child_by_typemethod
","text":"def get_child_by_type(self, expect_type):\n
Get a child of a give type.
Parameters Name Type Description Defaultexpect_type
type[ExpectType]
The type of the expected child.
required Raises Type DescriptionNoMatches
If no valid child is found.
Returns Type DescriptionExpectType
A widget.
"},{"location":"api/widget/#textual.widget.Widget.get_component_rich_style","title":"get_component_rich_stylemethod
","text":"def get_component_rich_style(self, name, *, partial=False):\n
Get a Rich style for a component.
Parameters Name Type Description Defaultname
str
Name of component.
requiredpartial
bool
Return a partial style (not combined with parent).
False
Returns Type Description Style
A Rich style object.
"},{"location":"api/widget/#textual.widget.Widget.get_content_height","title":"get_content_heightmethod
","text":"def get_content_height(self, container, viewport, width):\n
Called by Textual to get the height of the content area. May be overridden in a subclass.
Parameters Name Type Description Defaultcontainer
Size
Size of the container (immediate parent) widget.
requiredviewport
Size
Size of the viewport.
requiredwidth
int
Width of renderable.
required Returns Type Descriptionint
The height of the content.
"},{"location":"api/widget/#textual.widget.Widget.get_content_width","title":"get_content_widthmethod
","text":"def get_content_width(self, container, viewport):\n
Called by textual to get the width of the content area. May be overridden in a subclass.
Parameters Name Type Description Defaultcontainer
Size
Size of the container (immediate parent) widget.
requiredviewport
Size
Size of the viewport.
required Returns Type Descriptionint
The optimal width of the content.
"},{"location":"api/widget/#textual.widget.Widget.get_pseudo_class_state","title":"get_pseudo_class_statemethod
","text":"def get_pseudo_class_state(self):\n
Get an object describing whether each pseudo class is present on this object or not.
Returns Type DescriptionPseudoClasses
A PseudoClasses object describing the pseudo classes that are present.
"},{"location":"api/widget/#textual.widget.Widget.get_pseudo_classes","title":"get_pseudo_classesmethod
","text":"def get_pseudo_classes(self):\n
Pseudo classes for a widget.
Returns Type DescriptionIterable[str]
Names of the pseudo classes.
"},{"location":"api/widget/#textual.widget.Widget.get_style_at","title":"get_style_atmethod
","text":"def get_style_at(self, x, y):\n
Get the Rich style in a widget at a given relative offset.
Parameters Name Type Description Defaultx
int
X coordinate relative to the widget.
requiredy
int
Y coordinate relative to the widget.
required Returns Type DescriptionStyle
A rich Style object.
"},{"location":"api/widget/#textual.widget.Widget.get_widget_by_id","title":"get_widget_by_idmethod
","text":"def get_widget_by_id(self, id, expect_type=None):\n
Return the first descendant widget with the given ID.
Performs a depth-first search rooted at this widget.
Parameters Name Type Description Defaultid
str
The ID to search for in the subtree.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type.
None
Returns Type Description ExpectType | Widget
The first descendant encountered with this ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID.
WrongType
if the wrong type was found.
"},{"location":"api/widget/#textual.widget.Widget.mount","title":"mountmethod
","text":"def mount(self, *widgets, before=None, after=None):\n
Mount widgets below this widget (making this widget a container).
Parameters Name Type Description Default*widgets
Widget
The widget(s) to mount.
()
before
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def mount_all(self, widgets, *, before=None, after=None):\n
Mount widgets from an iterable.
Parameters Name Type Description Defaultwidgets
Iterable[Widget]
An iterable of widgets.
requiredbefore
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def move_child(self, child, before=None, after=None):\n
Move a child widget within its parent's list of children.
Parameters Name Type Description Defaultchild
int | Widget
The child widget to move.
requiredbefore
int | Widget | None
Child widget or location index to move before.
None
after
int | Widget | None
Child widget or location index to move after.
None
Raises Type Description WidgetError
If there is a problem with the child or target.
NoteOnly one of before
or after
can be provided. If neither or both are provided a WidgetError
will be raised.
method
","text":"def notify(\nself,\nmessage,\n*,\ntitle=\"\",\nseverity=\"information\",\ntimeout=Notification.timeout\n):\n
Create a notification.
Tip
This method is thread-safe.
Parameters Name Type Description Defaultmessage
str
The message for the notification.
requiredtitle
str
The title for the notification.
''
severity
SeverityLevel
The severity of the notification.
'information'
timeout
float
The timeout for the notification.
Notification.timeout
See App.notify
for the full documentation for this method.
method
","text":"def post_message(self, message):\n
Post a message to this widget.
Parameters Name Type Description Defaultmessage
Message
Message to post.
required Returns Type Descriptionbool
True if the message was posted, False if this widget was closed / closing.
"},{"location":"api/widget/#textual.widget.Widget.post_render","title":"post_rendermethod
","text":"def post_render(self, renderable):\n
Applies style attributes to the default renderable.
Returns Type DescriptionConsoleRenderable
A new renderable.
"},{"location":"api/widget/#textual.widget.Widget.refresh","title":"refreshmethod
","text":"def refresh(self, *regions, repaint=True, layout=False):\n
Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the next idle event. Only one refresh will be done even if this method is called multiple times.
By default this method will cause the content of the widget to refresh, but not change its size. You can also set layout=True
to perform a layout.
Warning
It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will do this automatically.
Parameters Name Type Description Default*regions
Region
Additional screen regions to mark as dirty.
()
repaint
bool
Repaint the widget (will call render() again).
True
layout
bool
Also layout widgets in the view.
False
Returns Type Description Self
The Widget
instance.
method
","text":"def release_mouse(self):\n
Release the mouse.
Mouse events will only be sent when the mouse is over the widget.
"},{"location":"api/widget/#textual.widget.Widget.remove","title":"removemethod
","text":"def remove(self):\n
Remove the Widget from the DOM (effectively deleting it).
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the widget to be removed.
"},{"location":"api/widget/#textual.widget.Widget.remove_children","title":"remove_childrenmethod
","text":"def remove_children(self):\n
Remove all children of this Widget from the DOM.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the children to be removed.
"},{"location":"api/widget/#textual.widget.Widget.render","title":"rendermethod
","text":"def render(self):\n
Get renderable for widget.
Returns Type DescriptionRenderableType
Any renderable.
"},{"location":"api/widget/#textual.widget.Widget.render_line","title":"render_linemethod
","text":"def render_line(self, y):\n
Render a line of content.
Parameters Name Type Description Defaulty
int
Y Coordinate of line.
required Returns Type DescriptionStrip
A rendered line.
"},{"location":"api/widget/#textual.widget.Widget.render_lines","title":"render_linesmethod
","text":"def render_lines(self, crop):\n
Render the widget in to lines.
Parameters Name Type Description Defaultcrop
Region
Region within visible area to render.
required Returns Type Descriptionlist[Strip]
A list of list of segments.
"},{"location":"api/widget/#textual.widget.Widget.render_str","title":"render_strmethod
","text":"def render_str(self, text_content):\n
Convert str in to a Text object.
If you pass in an existing Text object it will be returned unaltered.
Parameters Name Type Description Defaulttext_content
str | Text
Text or str.
required Returns Type DescriptionText
A text object.
"},{"location":"api/widget/#textual.widget.Widget.run_action","title":"run_actionasync
","text":"def run_action(self, action):\n
Perform a given action, with this widget as the default namespace.
Parameters Name Type Description Defaultaction
str
Action encoded as a string.
required"},{"location":"api/widget/#textual.widget.Widget.scroll_down","title":"scroll_downmethod
","text":"def scroll_down(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one line down.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_end","title":"scroll_end method
","text":"def scroll_end(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to the end of the container.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_home","title":"scroll_home method
","text":"def scroll_home(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to home position.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_left","title":"scroll_left method
","text":"def scroll_left(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one cell left.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_down","title":"scroll_page_down method
","text":"def scroll_page_down(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page down.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_left","title":"scroll_page_left method
","text":"def scroll_page_left(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page left.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_right","title":"scroll_page_right method
","text":"def scroll_page_right(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page right.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_up","title":"scroll_page_up method
","text":"def scroll_page_up(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page up.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_relative","title":"scroll_relative method
","text":"def scroll_relative(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll relative to current position.
Parameters Name Type Description Defaultx
float | None
X distance (columns) to scroll, or None
for no change.
None
y
float | None
Y distance (rows) to scroll, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
. Or None
to use duration
.
None
duration
float | None
Duration of animation, if animate is True
and speed is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_right","title":"scroll_right method
","text":"def scroll_right(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one cell right.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_to","title":"scroll_to method
","text":"def scroll_to(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to a given (absolute) coordinate, optionally animating.
Parameters Name Type Description Defaultx
float | None
X coordinate (column) to scroll to, or None
for no change.
None
y
float | None
Y coordinate (row) to scroll to, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Note The call to scroll is made after the next refresh.
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_center","title":"scroll_to_centermethod
","text":"def scroll_to_center(\nself,\nwidget,\nanimate=True,\n*,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\norigin_visible=True,\non_complete=None\n):\n
Scroll this widget to the center of self.
The center of the widget will be scrolled to the center of the container.
Parameters Name Type Description Defaultwidget
Widget
The widget to scroll to the center of self.
requiredanimate
bool
Whether to animate the scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
origin_visible
bool
Ensure that the top left corner of the widget remains visible after the scroll.
True
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_region","title":"scroll_to_region method
","text":"def scroll_to_region(\nself,\nregion,\n*,\nspacing=None,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\ncenter=False,\ntop=False,\norigin_visible=True,\nforce=False,\non_complete=None\n):\n
Scrolls a given region in to view, if required.
This method will scroll the least distance required to move region
fully within the scrollable area.
region
Region
A region that should be visible.
requiredspacing
Spacing | None
Optional spacing around the region.
None
animate
bool
True
to animate, or False
to jump.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
top
bool
Scroll region
to top of container.
False
origin_visible
bool
Ensure that the top left of the widget is within the window.
True
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Returns Type Description Offset
The distance that was scrolled.
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_widget","title":"scroll_to_widgetmethod
","text":"def scroll_to_widget(\nself,\nwidget,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\ncenter=False,\ntop=False,\norigin_visible=True,\nforce=False,\non_complete=None\n):\n
Scroll scrolling to bring a widget in to view.
Parameters Name Type Description Defaultwidget
Widget
A descendant widget.
requiredanimate
bool
True
to animate, or False
to jump.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
top
bool
Scroll widget to top of container.
False
origin_visible
bool
Ensure that the top left of the widget is within the window.
True
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Returns Type Description bool
True
if any scrolling has occurred in any descendant, otherwise False
.
method
","text":"def scroll_up(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one line up.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_visible","title":"scroll_visible method
","text":"def scroll_visible(\nself,\nanimate=True,\n*,\nspeed=None,\nduration=None,\ntop=False,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll the container to make this widget visible.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
top
bool
Scroll to top of container.
False
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.stop_animation","title":"stop_animation async
","text":"def stop_animation(self, attribute, complete=True):\n
Stop an animation on an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute whose animation should be stopped.
requiredcomplete
bool
Should the animation be set to its final value?
True
Note If there is no animation scheduled or running, this is a no-op.
"},{"location":"api/widget/#textual.widget.Widget.watch_disabled","title":"watch_disabledmethod
","text":"def watch_disabled(self):\n
Update the styles of the widget and its children when disabled is toggled.
"},{"location":"api/widget/#textual.widget.Widget.watch_has_focus","title":"watch_has_focusmethod
","text":"def watch_has_focus(self, value):\n
Update from CSS if has focus state changes.
"},{"location":"api/widget/#textual.widget.Widget.watch_mouse_over","title":"watch_mouse_overmethod
","text":"def watch_mouse_over(self, value):\n
Update from CSS if mouse over state changes.
"},{"location":"api/widget/#textual.widget.WidgetError","title":"WidgetErrorclass
","text":" Bases: Exception
Base widget error.
"},{"location":"api/work/","title":"Work","text":"A decorator used to create workers.
Parameters Name Type Description Defaultmethod
Callable[FactoryParamSpec, ReturnType] | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] | None
A function or coroutine.
None
name
str
A short string to identify the worker (in logs and debugging).
''
group
str
A short string to identify a group of workers.
'default'
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
exclusive
bool
Cancel all workers in the same group.
False
description
str | None
Readable description of the worker for debugging purposes. By default, it uses a string representation of the decorated method and its arguments.
None
thread
bool
Mark the method as a thread worker.
False
"},{"location":"api/worker/","title":"Worker","text":"A class to manage concurrent work.
"},{"location":"api/worker/#textual.worker.WorkType","title":"WorkTypemodule-attribute
","text":"WorkType: TypeAlias = Union[\nCallable[[], Coroutine[None, None, ResultType]],\nCallable[[], ResultType],\nAwaitable[ResultType],\n]\n
Type used for workers.
"},{"location":"api/worker/#textual.worker.active_worker","title":"active_workermodule-attribute
","text":"active_worker: ContextVar[Worker] = ContextVar(\n\"active_worker\"\n)\n
Currently active worker context var.
"},{"location":"api/worker/#textual.worker.DeadlockError","title":"DeadlockErrorclass
","text":" Bases: WorkerError
The operation would result in a deadlock.
"},{"location":"api/worker/#textual.worker.NoActiveWorker","title":"NoActiveWorkerclass
","text":" Bases: Exception
There is no active worker.
"},{"location":"api/worker/#textual.worker.Worker","title":"Workerclass
","text":"def __init__(\nself,\nnode,\nwork=None,\n*,\nname=\"\",\ngroup=\"default\",\ndescription=\"\",\nexit_on_error=True,\nthread=False\n):\n
Bases: Generic[ResultType]
A class to manage concurrent work (either a task or a thread).
Parameters Name Type Description Defaultnode
DOMNode
The widget, screen, or App that initiated the work.
requiredwork
WorkType | None
A callable, coroutine, or other awaitable object to run in the worker.
None
name
str
Name of the worker (short string to help identify when debugging).
''
group
str
The worker group.
'default'
description
str
Description of the worker (longer string with more details).
''
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
thread
bool
Mark the worker as a thread worker.
False
"},{"location":"api/worker/#textual.worker.Worker.completed_steps","title":"completed_steps property
","text":"completed_steps: int\n
The number of completed steps.
"},{"location":"api/worker/#textual.worker.Worker.error","title":"errorproperty
","text":"error: BaseException | None\n
The exception raised by the worker, or None
if there was no error.
property
","text":"is_cancelled: bool\n
Has the work been cancelled?
Note that cancelled work may still be running.
"},{"location":"api/worker/#textual.worker.Worker.is_finished","title":"is_finishedproperty
","text":"is_finished: bool\n
Has the task finished (cancelled, error, or success)?
"},{"location":"api/worker/#textual.worker.Worker.is_running","title":"is_runningproperty
","text":"is_running: bool\n
Is the task running?
"},{"location":"api/worker/#textual.worker.Worker.node","title":"nodeproperty
","text":"node: DOMNode\n
The node where this worker was run from.
"},{"location":"api/worker/#textual.worker.Worker.progress","title":"progressproperty
","text":"progress: float\n
Progress as a percentage.
If the total steps is None, then this will return 0. The percentage will be clamped between 0 and 100.
"},{"location":"api/worker/#textual.worker.Worker.result","title":"resultproperty
","text":"result: ResultType | None\n
The result of the worker, or None
if there is no result.
writable
property
","text":"state: WorkerState\n
The current state of the worker.
"},{"location":"api/worker/#textual.worker.Worker.total_steps","title":"total_stepsproperty
","text":"total_steps: int | None\n
The number of total steps, or None if indeterminate.
"},{"location":"api/worker/#textual.worker.Worker.StateChanged","title":"StateChangedclass
","text":"def __init__(self, worker, state):\n
Bases: Message
The worker state changed.
Parameters Name Type Description Defaultworker
Worker
The worker object.
requiredstate
WorkerState
New state.
required"},{"location":"api/worker/#textual.worker.Worker.advance","title":"advancemethod
","text":"def advance(self, steps=1):\n
Advance the number of completed steps.
Parameters Name Type Description Defaultsteps
int
Number of steps to advance.
1
"},{"location":"api/worker/#textual.worker.Worker.cancel","title":"cancel method
","text":"def cancel(self):\n
Cancel the task.
"},{"location":"api/worker/#textual.worker.Worker.run","title":"runasync
","text":"def run(self):\n
Run the work.
Implement this method in a subclass, or pass a callable to the constructor.
Returns Type DescriptionResultType
Return value of the work.
"},{"location":"api/worker/#textual.worker.Worker.update","title":"updatemethod
","text":"def update(self, completed_steps=None, total_steps=-1):\n
Update the number of completed steps.
Parameters Name Type Description Defaultcompleted_steps
int | None
The number of completed seps, or None
to not change.
None
total_steps
int | None
The total number of steps, None
for indeterminate, or -1 to leave unchanged.
-1
"},{"location":"api/worker/#textual.worker.Worker.wait","title":"wait async
","text":"def wait(self):\n
Wait for the work to complete.
Raises Type DescriptionWorkerFailed
If the Worker raised an exception.
WorkerCancelled
If the Worker was cancelled before it completed.
Returns Type DescriptionResultType
The return value of the work.
"},{"location":"api/worker/#textual.worker.WorkerCancelled","title":"WorkerCancelledclass
","text":" Bases: WorkerError
The worker was cancelled and did not complete.
"},{"location":"api/worker/#textual.worker.WorkerError","title":"WorkerErrorclass
","text":" Bases: Exception
A worker related error.
"},{"location":"api/worker/#textual.worker.WorkerFailed","title":"WorkerFailedclass
","text":"def __init__(self, error):\n
Bases: WorkerError
The worker raised an exception and did not complete.
"},{"location":"api/worker/#textual.worker.WorkerState","title":"WorkerStateclass
","text":" Bases: enum.Enum
A description of the worker's current state.
"},{"location":"api/worker/#textual.worker.WorkerState.CANCELLED","title":"CANCELLEDclass-attribute
instance-attribute
","text":"CANCELLED = 3\n
Worker is not running, and was cancelled.
"},{"location":"api/worker/#textual.worker.WorkerState.ERROR","title":"ERRORclass-attribute
instance-attribute
","text":"ERROR = 4\n
Worker is not running, and exited with an error.
"},{"location":"api/worker/#textual.worker.WorkerState.PENDING","title":"PENDINGclass-attribute
instance-attribute
","text":"PENDING = 1\n
Worker is initialized, but not running.
"},{"location":"api/worker/#textual.worker.WorkerState.RUNNING","title":"RUNNINGclass-attribute
instance-attribute
","text":"RUNNING = 2\n
Worker is running.
"},{"location":"api/worker/#textual.worker.WorkerState.SUCCESS","title":"SUCCESSclass-attribute
instance-attribute
","text":"SUCCESS = 5\n
Worker is not running, and completed successfully.
"},{"location":"api/worker/#textual.worker.get_current_worker","title":"get_current_workerfunction
","text":"def get_current_worker():\n
Get the currently active worker.
Raises Type DescriptionNoActiveWorker
If there is no active worker.
Returns Type DescriptionWorker
A Worker instance.
"},{"location":"api/worker_manager/","title":"Worker manager","text":"A class to manage workers for an app.
You access this object via App.workers or Widget.workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager","title":"WorkerManagerclass
","text":"def __init__(self, app):\n
An object to manager a number of workers.
You will not have to construct this class manually, as widgets, screens, and apps have a worker manager accessibly via a workers
attribute.
app
App
An App instance.
required"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.add_worker","title":"add_workermethod
","text":"def add_worker(self, worker, start=True, exclusive=True):\n
Add a new worker.
Parameters Name Type Description Defaultworker
Worker
A Worker instance.
requiredstart
bool
Start the worker if True, otherwise the worker must be started manually.
True
exclusive
bool
Cancel all workers in the same group as worker
.
True
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_all","title":"cancel_all method
","text":"def cancel_all(self):\n
Cancel all workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_group","title":"cancel_groupmethod
","text":"def cancel_group(self, node, group):\n
Cancel a single group.
Parameters Name Type Description Defaultnode
DOMNode
Worker DOM node.
requiredgroup
str
A group name.
required Returns Type Descriptionlist[Worker]
A list of workers that were cancelled.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_node","title":"cancel_nodemethod
","text":"def cancel_node(self, node):\n
Cancel all workers associated with a given node
Parameters Name Type Description Defaultnode
DOMNode
A DOM node (widget, screen, or App).
required Returns Type Descriptionlist[Worker]
List of cancelled workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.start_all","title":"start_allmethod
","text":"def start_all(self):\n
Start all the workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.wait_for_complete","title":"wait_for_completeasync
","text":"def wait_for_complete(self, workers=None):\n
Wait for workers to complete.
Parameters Name Type Description Defaultworkers
Iterable[Worker] | None
An iterable of workers or None to wait for all workers in the manager.
None
"},{"location":"blog/","title":"Textual Blog","text":""},{"location":"blog/2023/03/15/no-async-async-with-python/","title":"No-async async with Python","text":"A (reasonable) criticism of async is that it tends to proliferate in your code. In order to await
something, your functions must be async
all the way up the call-stack. This tends to result in you making things async
just to support that one call that needs it or, worse, adding async
just-in-case. Given that going from def
to async def
is a breaking change there is a strong incentive to go straight there.
Before you know it, you have adopted a policy of \"async all the things\".
Textual is an async framework, but doesn't require the app developer to use the async
and await
keywords (but you can if you need to). This post is about how Textual accomplishes this async-agnosticism.
Info
See this example from the docs for an async-less Textual app.
"},{"location":"blog/2023/03/15/no-async-async-with-python/#an-apology","title":"An apology","text":"But first, an apology! In a previous post I said Textual \"doesn't do any IO of its own\". This is not accurate. Textual responds to keys and mouse events (Input) and writes content to the terminal (Output).
Although Textual clearly does do IO, it uses asyncio
mainly for concurrency. It allows each widget to update its part of the screen independently from the rest of the app.
The first no-async async technique is the \"Await me maybe\" pattern, a term first coined by Simon Willison. This is particularly applicable to callbacks (or in Textual terms, message handlers).
The await_me_maybe
function below can run a callback that is either a plain old function or a coroutine (async def
). It does this by awaiting the result of the callback if it is awaitable, or simply returning the result if it is not.
import asyncio\nimport inspect\ndef plain_old_function():\nreturn \"Plain old function\"\nasync def async_function():\nreturn \"Async function\"\nasync def await_me_maybe(callback):\nresult = callback()\nif inspect.isawaitable(result):\nreturn await result\nreturn result\nasync def run_framework():\nprint(\nawait await_me_maybe(plain_old_function)\n)\nprint(\nawait await_me_maybe(async_function)\n)\nif __name__ == \"__main__\":\nasyncio.run(run_framework())\n
"},{"location":"blog/2023/03/15/no-async-async-with-python/#optionally-awaitable","title":"Optionally awaitable","text":"The \"await me maybe\" pattern is great when an async framework calls the app's code. The app developer can choose to write async code or not. Things get a little more complicated when the app wants to call the framework's API. If the API has asynced all the things, then it would force the app to do the same.
Textual's API consists of regular methods for the most part, but there are a few methods which are optionally awaitable. These are not coroutines (which must be awaited to do anything).
In practice, this means that those API calls initiate something which will complete a short time later. If you discard the return value then it won't prevent it from working. You only need to await
if you want to know when it has finished.
The mount
method is one such method. Calling it will add a widget to the screen:
def on_key(self):\n# Add MyWidget to the screen\nself.mount(MyWidget(\"Hello, World!\"))\n
In this example we don't care that the widget hasn't been mounted immediately, only that it will be soon.
Note
Textual awaits the result of mount after the message handler, so even if you don't explicitly await it, it will have been completed by the time the next message handler runs.
We might care if we want to mount a widget then make some changes to it. By making the handler async
and awaiting the result of mount, we can be sure that the widget has been initialized before we update it:
async def on_key(self):\n# Add MyWidget to the screen\nawait self.mount(MyWidget(\"Hello, World!\"))\n# add a border\nself.query_one(MyWidget).styles.border = (\"heavy\", \"red\")\n
Incidentally, I found there were very few examples of writing awaitable objects in Python. So here is the code for AwaitMount
which is returned by the mount
method:
class AwaitMount:\n\"\"\"An awaitable returned by mount() and mount_all().\"\"\"\ndef __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:\nself._parent = parent\nself._widgets = widgets\nasync def __call__(self) -> None:\n\"\"\"Allows awaiting via a call operation.\"\"\"\nawait self\ndef __await__(self) -> Generator[None, None, None]:\nasync def await_mount() -> None:\nif self._widgets:\naws = [\ncreate_task(widget._mounted_event.wait(), name=\"await mount\")\nfor widget in self._widgets\n]\nif aws:\nawait wait(aws)\nself._parent.refresh(layout=True)\nreturn await_mount().__await__()\n
"},{"location":"blog/2023/03/15/no-async-async-with-python/#summing-up","title":"Summing up","text":"Textual did initially \"async all the things\", which you might see if you find some old Textual code. Now async is optional.
This is not because I dislike async. I'm a fan! But it does place a small burden on the developer (more to type and think about). With the current API you generally don't need to write coroutines, or remember to await things. But async is there if you need it.
We're finding that Textual is increasingly becoming a UI to things which are naturally concurrent, so async was a good move. Concurrency can be a tricky subject, so we're planning some API magic to take the pain out of running tasks, threads, and processes. Stay tuned!
Join us on our Discord server if you want to talk about these things with the Textualize developers.
"},{"location":"blog/2022/12/08/be-the-keymaster/","title":"Be the Keymaster!","text":""},{"location":"blog/2022/12/08/be-the-keymaster/#that-didnt-go-to-plan","title":"That didn't go to plan","text":"So... yeah... the blog. When I wrote my previous (and first) post I had wanted to try and do a post towards the end of each week, highlighting what I'd done on the \"dogfooding\" front. Life kinda had other plans. Not in a terrible way, but it turns out that getting both flu and Covid jabs (AKA \"jags\" as they tend to say in my adopted home) on the same day doesn't really agree with me too well.
I have been working, but there's been some odd moments in the past week and a bit and, last week, once I got to the end, I was glad for it to end. So no blog post happened.
Anyway...
"},{"location":"blog/2022/12/08/be-the-keymaster/#what-have-i-been-up-to","title":"What have I been up to?","text":"While mostly sat feeling sorry for myself on my sofa, I have been coding. Rather than list all the different things here in detail, I'll quickly mention them with links to where to find them and play with them if you want:
"},{"location":"blog/2022/12/08/be-the-keymaster/#fivepyfive","title":"FivePyFive","text":"While my Textual 5x5 puzzle is one of the examples in the Textual repo, I wanted to make it more widely available so people can download it with pip
or pipx
. See over on PyPi and see if you can solve it. ;-)
I wanted to put together a very small example of how someone may put together a third party widget library, and in doing so selected what I thought was going to be a mostly-useless example: a wrapper around a text-based QR code generator website. Weirdly I've had a couple of people express a need for QR codes in the terminal since publishing that!
"},{"location":"blog/2022/12/08/be-the-keymaster/#pispy","title":"PISpy","text":"PISpy is a very simple terminal-based client for the PyPi API. Mostly it provides a hypertext interface to Python package details, letting you look up a package and then follow its dependency links. It's very simple at the moment, but I think more fun things can be done with this.
"},{"location":"blog/2022/12/08/be-the-keymaster/#oidia","title":"OIDIA","text":"I'm a big fan of the use of streak-tracking in one form or another. Personally I use a streak-tracking app for keeping tabs of all sorts of good (and bad) habits, and as a heavy user of all things Apple I make a lot of use of the Fitness rings, etc. So I got to thinking it might be fun to do a really simple, no shaming, no counting, just recording, steak app for the Terminal. OIDIA is the result.
As of the time of writing I only finished the first version of this yesterday evening, so there are plenty of rough edges; but having got it to a point where it performed the basic tasks I wanted from it, that seemed like a good time to publish.
Expect to see this getting more updates and polish.
"},{"location":"blog/2022/12/08/be-the-keymaster/#wait-what-about-this-keymaster-thing","title":"Wait, what about this Keymaster thing?","text":"Ahh, yes, about that... So one of the handy things I'm finding about Textual is its key binding system. The more I build Textual apps, the more I appreciate the bindings, how they can be associated with specific widgets, the use of actions (which can be used from other places too), etc.
But... (there's always a \"but\" right -- I mean, there'd be no blog post to be had here otherwise).
The terminal doesn't have access to all the key combinations you may want to use, and also, because some keys can't necessarily be \"typed\", at least not easily (think about it: there's no F1 character, you have to type F1
), many keys and key combinations need to be bound with specific names.
So there's two problems here: how do I discover what keys even turn up in my application, and when they do, what should I call them when I pass them to Binding
?
That felt like a \"well Dave just build an app for it!\" problem. So I did:
If you're building apps with Textual and you want to discover what keys turn up from your terminal and are available to your application, you can:
$ pipx install textual-keys\n
and then just run textual-keys
and start mashing the keyboard to find out.
There's a good chance that this app, or at least a version of it, will make it into Textual itself (very likely as one of the devtools). But for now it's just an easy install away.
I think there's a call to be made here too: have you built anything to help speed up how you work with Textual, or just make the development experience \"just so\"? If so, do let us know, and come yell about it on the #show-and-tell
channel in our Discord server.
I spent some time optimizing Textual on Windows recently, and discovered something which may be of interest to anyone working with async code on that platform.
Animation, scrolling, and fading had always been unsatisfactory on Windows. Textual was usable, but the lag when scrolling made apps feel far less snappy that other platforms. On macOS and Linux, scrolling is fast enough that it feels close to a native app, not something running in a terminal. Yet the Windows experience never improved, even as Textual got faster with each release.
I had chalked this up to Windows Terminal being slow to render updates. After all, the classic Windows terminal was (and still is) glacially slow. Perhaps Microsoft just weren't focusing on performance.
In retrospect, that was highly improbable. Like all modern terminals, Windows Terminal uses the GPU to render updates. Even without focussing on performance, it should be fast.
I figured I'd give it one last attempt to speed up Textual on Windows. If I failed, Windows would forever be a third-class platform for Textual apps.
It turned out that it was nothing to do with performance, per se. The issue was with a single asyncio function: asyncio.sleep
.
Textual has a Timer
class which creates events at regular intervals. It powers the JS-like set_interval
and set_timer
functions. It is also used internally to do animation (such as smooth scrolling). This Timer class calls asyncio.sleep
to wait the time between one event and the next.
On macOS and Linux, calling asynco.sleep
is fairly accurate. If you call sleep(3.14)
, it will return within 1% of 3.14 seconds. This is not the case for Windows, which for historical reasons uses a timer with a granularity of 15 milliseconds. The upshot is that sleep times will be rounded up to the nearest multiple of 15 milliseconds.
This limit appears to hold true for all async primitives on Windows. If you wait for something with a timeout, it will return on a multiple of 15 milliseconds. Fortunately there is work in the CPython pipeline to make this more accurate. Thanks to Steve Dower for pointing this out.
This lack of accuracy in the timer meant that timer events were created at a far slower rate than intended. Animation was slower because Textual was waiting too long between updates.
Once I had figured that out, I needed an alternative to asyncio.sleep
for Textual's Timer class. And I found one. The following version of sleep
is accurate to well within 1%:
from time import sleep as time_sleep\nfrom asyncio import get_running_loop\nasync def sleep(sleep_for: float) -> None:\n\"\"\"An asyncio sleep.\n On Windows this achieves a better granularity than asyncio.sleep\n Args:\n sleep_for (float): Seconds to sleep for.\n \"\"\" \nawait get_running_loop().run_in_executor(None, time_sleep, sleep_for)\n
That is a drop-in replacement for sleep on Windows. With it, Textual runs a lot smoother. Easily on par with macOS and Linux.
It's not quite perfect. There is a little tearing during full \"screen\" updates, but performance is decent all round. I suspect when this bug is fixed (big thanks to Paul Moore for looking in to that), and Microsoft implements this protocol then Textual on Windows will be A+.
This Windows improvement will be in v0.9.0 of Textual, which will be released in a few days.
"},{"location":"blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/","title":"The Heisenbug lurking in your async code","text":"I'm taking a brief break from blogging about Textual to bring you this brief PSA for Python developers who work with async code. I wanted to expand a little on this tweet.
If you have ever used asyncio.create_task
you may have created a bug for yourself that is challenging (read almost impossible) to reproduce. If it occurs, your code will likely fail in unpredictable ways.
The root cause of this Heisenbug is that if you don't hold a reference to the task object returned by create_task
then the task may disappear without warning when Python runs garbage collection. In other words, the code in your task will stop running with no obvious indication why.
This behavior is well documented, as you can see from this excerpt (emphasis mine):
But who reads all the docs? And who has perfect recall if they do? A search on GitHub indicates that there are a lot of projects where this bug is waiting for just the right moment to ruin somebody's day.
I suspect the reason this mistake is so common is that tasks are a lot like threads (conceptually at least). With threads you can just launch them and forget. Unless you mark them as \"daemon\" threads they will exist for the lifetime of your app. Not so with Tasks.
The solution recommended in the docs is to keep a reference to the task for as long as you need it to live. On modern Python you could use TaskGroups which will keep references to your tasks. As long as all the tasks you spin up are in TaskGroups, you should be fine.
"},{"location":"blog/2023/03/08/overhead-of-python-asyncio-tasks/","title":"Overhead of Python Asyncio tasks","text":"Every widget in Textual, be it a button, tree view, or a text input, runs an asyncio task. There is even a task for scrollbar corners (the little space formed when horizontal and vertical scrollbars meet).
Info
It may be IO that gives AsyncIO its name, but Textual doesn't do any IO of its own. Those tasks are used to power message queues, so that widgets (UI components) can do whatever they do at their own pace.
Its fair to say that Textual apps launch a lot of tasks. Which is why when I was trying to optimize startup (for apps with 1000s of widgets) I suspected it was task related.
I needed to know how much of an overhead it was to launch tasks. Tasks are lighter weight than threads, but how much lighter? The only way to know for certain was to profile.
The following code launches a load of do nothing tasks, then waits for them to shut down. This would give me an idea of how performant create_task
is, and also a baseline for optimizations. I would know the absolute limit of any optimizations I make.
from asyncio import create_task, wait, run\nfrom time import process_time as time\nasync def time_tasks(count=100) -> float:\n\"\"\"Time creating and destroying tasks.\"\"\"\nasync def nop_task() -> None:\n\"\"\"Do nothing task.\"\"\"\npass\nstart = time()\ntasks = [create_task(nop_task()) for _ in range(count)]\nawait wait(tasks)\nelapsed = time() - start\nreturn elapsed\nfor count in range(100_000, 1000_000 + 1, 100_000):\ncreate_time = run(time_tasks(count))\ncreate_per_second = 1 / (create_time / count)\nprint(f\"{count:,} tasks \\t {create_per_second:0,.0f} tasks per/s\")\n
And here is the output:
100,000 tasks 280,003 tasks per/s\n200,000 tasks 255,275 tasks per/s\n300,000 tasks 248,713 tasks per/s\n400,000 tasks 248,383 tasks per/s\n500,000 tasks 241,624 tasks per/s\n600,000 tasks 260,660 tasks per/s\n700,000 tasks 244,510 tasks per/s\n800,000 tasks 247,455 tasks per/s\n900,000 tasks 242,744 tasks per/s\n1,000,000 tasks 259,715 tasks per/s\n
Info
Running on an M1 MacBook Pro.
This tells me I can create, run, and shutdown 260K tasks per second.
That's fast.
Clearly create_task
is as close as you get to free in the Python world, and I would need to look elsewhere for optimizations. Turns out Textual spends far more time processing CSS rules than creating tasks (obvious in retrospect). I've noticed some big wins there, so the next version of Textual will be faster to start apps with a metric tonne of widgets.
But I still need to know what to do with those scrollbar corners. A task for two characters. I don't even...
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/","title":"A year of building for the terminal","text":"I joined Textualize back in January 2022, and since then have been hard at work with the team on both Rich and Textual. Over the course of the year, I\u2019ve been able to work on a lot of really cool things. In this post, I\u2019ll review a subset of the more interesting and visual stuff I\u2019ve built. If you\u2019re into terminals and command line tooling, you\u2019ll hopefully see at least one thing of interest!
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#a-file-manager-powered-by-textual","title":"A file manager powered by Textual","text":"I\u2019ve been slowly developing a file manager as a \u201cdogfooding\u201d project for Textual. It takes inspiration from tools such as Ranger and Midnight Commander.
As of December 2022, it lets you browse your file system, filtering, multi-selection, creating and deleting files/directories, opening files in your $EDITOR
and more.
I\u2019m happy with how far this project has come \u2014 I think it\u2019s a good example of the type of powerful application that can be built with Textual with relatively little code. I\u2019ve been able to focus on features, instead of worrying about terminal emulator implementation details.
The project is available on GitHub.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#better-diffs-in-the-terminal","title":"Better diffs in the terminal","text":"Diffs in the terminal are often difficult to read at a glance. I wanted to see how close I could get to achieving a diff display of a quality similar to that found in the GitHub UI.
To attempt this, I built a tool called Dunk. It\u2019s a command line program which you can pipe your git diff
output into, and it\u2019ll convert it into something which I find much more readable.
Although I\u2019m not particularly proud of the code - there are a lot of \u201chacks\u201d going on, but I\u2019m proud of the result. If anything, it shows what can be achieved for tools like this.
For many diffs, the difference between running git diff
and git diff | dunk | less -R
is night and day.
It\u2019d be interesting to revisit this at some point. It has its issues, but I\u2019d love to see how it can be used alongside Textual to build a terminal-based diff/merge tool. Perhaps it could be combined with\u2026
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#code-editor-floating-gutter","title":"Code editor floating gutter","text":"This is a common feature in text editors and IDEs: when you scroll to the right, you should still be able to see what line you\u2019re on. Out of interest, I tried to recreate the effect in the terminal using Textual.
Textual CSS offers a dock
property which allows you to attach a widget to an edge of its parent. By creating a widget that contains a vertical list of numbers and setting the dock
property to left
, we can create a floating gutter effect. Then, we just need to keep the scroll_y
in sync between the gutter and the content to ensure the line numbers stay aligned.
While working on Shira (a proof-of-concept, terminal-based Python object explorer), I wrote some autocompleting dropdown functionality.
Textual forgoes the z-index concept from browser CSS and instead uses a \u201cnamed layer\u201d system. Using the layers
property you can defined an ordered list of named layers, and using the layer
property, you can assign a descendant widget to one of those layers.
By creating a new layer above all others and assigning a widget to that layer, we can ensure that widget is painted above everything else.
In order to determine where to place the dropdown, we can track the current value in the dropdown by watch
ing the reactive input \u201cvalue\u201d inside the Input widget. This method will be called every time the value
of the Input changes, and we can use this hook to amend the position of our dropdown position to accommodate for the length of the input value.
I\u2019ve now extracted this into a separate library called textual-autocomplete.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#tabs-with-animated-underline","title":"Tabs with animated underline","text":"The aim here was to create a tab widget with underlines that animates smoothly as another tab is selected.
The difficulty with implementing something like this is that we don\u2019t have pixel-perfect resolution when animating - a terminal window is just a big grid of fixed-width character cells.
However, when animating things in a terminal, we can often achieve better granularity using Unicode related tricks. In this case, instead of shifting the bar along one whole cell, we adjust the endings of the bar to be a character which takes up half of a cell.
The exact characters that form the bar are \"\u257a\", \"\u2501\" and \"\u2578\". When the bar sits perfectly within cell boundaries, every character is \u201c\u2501\u201d. As it travels over a cell boundary, the left and right ends of the bar are updated to \"\u257a\" and \"\u2578\" respectively.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#snapshot-testing-for-terminal-apps","title":"Snapshot testing for terminal apps","text":"One of the great features we added to Rich this year was the ability to export console contents to an SVG. This feature was later exposed to Textual, allowing users to capture screenshots of their running Textual apps. Ultimately, I ended up creating a tool for snapshot testing in the Textual codebase.
Snapshot testing is used to ensure that Textual output doesn\u2019t unexpectedly change. On disk, we store what we expect the output to look like. Then, when we run our unit tests, we get immediately alerted if the output has changed.
This essentially automates the process of manually spinning up several apps and inspecting them for unexpected visual changes. It\u2019s great for catching subtle regressions!
In Textual, each CSS property has its own canonical example and an associated snapshot test. If we accidentally break a property in a way that affects the visual output, the chances of it sneaking into a release are greatly reduced, because the corresponding snapshot test will fail.
As part of this work, I built a web interface for comparing snapshots with test output. There\u2019s even a little toggle which highlights the differences, since they\u2019re sometimes rather subtle.
Since the terminal output shown in the video above is just an SVG image, I was able to add the \"Show difference\" functionality by overlaying the two images and applying a single CSS property: mix-blend-mode: difference;
.
The snapshot testing functionality itself is implemented as a pytest plugin, and it builds on top of a snapshot testing framework called syrupy.
It's quite likely that this will eventually be exposed to end-users of Textual.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#demonstrating-animation","title":"Demonstrating animation","text":"I built an example app to demonstrate how to animate in Textual and the available easing functions.
The smoothness here is achieved using tricks similar to those used in the tabs I discussed earlier. In fact, the bar that animates in the video above is the same Rich renderable that is used by Textual's scrollbars.
You can play with this app by running textual easing
. Please use animation sparingly.
When developing terminal based applications, performing simple debugging using print
can be difficult, since the terminal is in application mode.
A project I worked on earlier in the year to improve the situation was the Textual developer console, which you can launch with textual console
.
Then, by running a Textual application with the --dev
flag, all standard output will be redirected to it. This means you can use the builtin print
function and still immediately see the output. Textual itself also writes information to this console, giving insight into the messages that are flowing through an application.
Cells in the terminal are roughly two times taller than they are wide. This means, that two horizontally adjacent cells form an approximate square.
Using this fact, I wrote a simple library based on Rich and PIL which can convert an image file into terminal output. You can find the library, rich-pixels
, on GitHub.
It\u2019s particularly good for displaying simple pixel art images. The SVG image below is also a good example of the SVG export functionality I touched on earlier.
RichSince the library generates an object which is renderable using Rich, these can easily be embedded inside Textual applications.
Here's an example of that in a scrapped \"Pok\u00e9dex\" app I threw together:
This is a rather naive approach to the problem... but I did it for fun!
Other methods for displaying images in the terminal include:
That was a whirlwind tour of just some of the projects I tackled in 2022. If you found it interesting, be sure to follow me on Twitter. I don't post often, but when I do, it's usually about things similar to those I've discussed here.
"},{"location":"blog/2022/11/06/new-blog/","title":"New Blog","text":"Welcome to the first post on the Textual blog.
I plan on using this as a place to make announcements regarding new releases of Textual, and any other relevant news.
The first piece of news is that we've reorganized this site a little. The Events, Styles, and Widgets references are now under \"Reference\", and what used to be under \"Reference\" is now \"API\" which contains API-level documentation. I hope that's a little clearer than it used to be!
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/","title":"So you're looking for a wee bit of Textual help...","text":""},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#introduction","title":"Introduction","text":"Quote
Patience, Highlander. You have done well. But it'll take time. You are generations being born and dying. You are at one with all living things. Each man's thoughts and dreams are yours to know. You have power beyond imagination. Use it well, my friend. Don't lose your head.
Juan S\u00e1nchez Villalobos Ram\u00edrez, Chief metallurgist to King Charles V of Spain
As of the time of writing, I'm a couple or so days off having been with Textualize for 3 months. It's been fun, and educational, and every bit as engaging as I'd hoped, and more. One thing I hadn't quite prepared for though, but which I really love, is how so many other people are learning Textual along with me.
Even in those three months the library has changed and expanded quite a lot, and it continues to do so. Meanwhile, more people are turning up and using the framework; you can see this online in social media, blogs and of course in the ever-growing list of projects on GitHub which depend on Textual.
This inevitably means there's a lot of people getting to grips with a new tool, and one that is still a bit of a moving target. This in turn means lots of people are coming to us to get help.
As I've watched this happen I've noticed a few patterns emerging. Some of these good or neutral, some... let's just say not really beneficial to those seeking the help, or to those trying to provide the help. So I wanted to write a little bit about the different ways you can get help with Textual and your Textual-based projects, and to also try and encourage people to take the most helpful and positive approach to getting that help.
Now, before I go on, I want to make something very clear: I'm writing this as an individual. This is my own personal view, and my own advice from me to anyone who wishes to take it. It's not Textual (the project) or Textualize (the company) policy, rules or guidelines. This is just some ageing hacker's take on how best to go about asking for help, informed by years of asking for and also providing help in email, on Usenet, on forums, etc.
Or, put another way: if what you read in here seems sensible to you, I figure we'll likely have already hit it off over on GitHub or in the Discord server. ;-)
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#where-to-go-for-help","title":"Where to go for help","text":"At this point this is almost a bit of an FAQ itself, so I thought I'd address it here: where's the best place to ask for help about Textual, and what's the difference between GitHub Issues, Discussions and our Discord server?
I'd suggest thinking of them like this:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#discord","title":"Discord","text":"You have a question, or need help with something, and perhaps you could do with a reply as soon as possible. But, and this is the really important part, it doesn't matter if you don't get a response. If you're in this situation then the Discord server is possibly a good place to start. If you're lucky someone will be hanging about who can help out.
I can't speak for anyone else, but keep this in mind: when I look in on Discord I tend not to go scrolling back much to see if anything has been missed. If something catches my eye, I'll try and reply, but if it doesn't... well, it's mostly an instant chat thing so I don't dive too deeply back in time.
Going from Discord to a GitHub issue
As a slight aside here: sometimes people will pop up in Discord, ask a question about something that turns out looking like a bug, and that's the last we hear of it. Please, please, please, if this happens, the most helpful thing you can do is go raise an issue for us. It'll help us to keep track of problems, it'll help get your problem fixed, it'll mean everyone benefits.
My own advice would be to treat Discord as an ephemeral resource. It happens in the moment but fades away pretty quickly. It's like knocking on a friend's door to see if they're in. If they're not in, you might leave them a note, which is sort of like going to...
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#github","title":"GitHub","text":"On the other hand, if you have a question or need some help or something where you want to stand a good chance of the Textual developers (amongst others) seeing it and responding, I'd recommend that GitHub is the place to go. Dropping something into the discussions there, or leaving an issue, ensures it'll get seen. It won't get lost.
As for which you should use -- a discussion or an issue -- I'd suggest this: if you need help with something, or you want to check your understanding of something, or you just want to be sure something is a problem before taking it further, a discussion might be the best thing. On the other hand, if you've got a clear bug or feature request on your hands, an issue makes a lot of sense.
Don't worry if you're not sure which camp your question or whatever falls into though; go with what you think is right. There's no harm done either way (I may move an issue to a discussion first before replying, if it's really just a request for help -- but that's mostly so everyone can benefit from finding it in the right place later on down the line).
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#the-dos-and-donts-of-getting-help","title":"The dos and don'ts of getting help","text":"Now on to the fun part. This is where I get a bit preachy. Ish. Kinda. A little bit. Again, please remember, this isn't a set of rules, this isn't a set of official guidelines, this is just a bunch of \"if you want my advice, and I know you didn't ask but you've read this far so you actually sort of did don't say I didn't warn you!\" waffle.
This isn't going to be an exhaustive collection, far from it. But I feel these are some important highlights.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#do","title":"Do...","text":"When looking for help, in any of the locations mentioned above, I'd totally encourage:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#be-clear-and-detailed","title":"Be clear and detailed","text":"Too much detail is almost always way better than not enough. \"My program didn't run\", often even with some of the code supplied, is so much harder to help than \"I ran this code I'm posting here, and I expected this particular outcome, and I expected it because I'd read this particular thing in the docs and had comprehended it to mean this, but instead the outcome was this exception here, and I'm a bit stuck -- can someone offer some pointers?\"
The former approach means there often ends up having to be a back and forth which can last a long time, and which can sometimes be frustrating for the person asking. Manage frustration: be clear, tell us everything you can.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#say-what-resources-youve-used-already","title":"Say what resources you've used already","text":"If you've read the potions of the documentation that relate to what you're trying to do, it's going to be really helpful if you say so. If you don't, it might be assumed you haven't and you may end up being pointed at them.
So, please, if you've checked the documentation, looked in the FAQ, done a search of past issues or discussions or perhaps even done a search on the Discord server... please say so.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#be-polite","title":"Be polite","text":"This one can go a long way when looking for help. Look, I get it, programming is bloody frustrating at times. We've all rage-quit some code at some point, I'm sure. It's likely going to be your moment of greatest frustration when you go looking for help. But if you turn up looking for help acting all grumpy and stuff it's not going to come over well. Folk are less likely to be motivated to lend a hand to someone who seems rather annoyed.
If you throw in a please and thank-you here and there that makes it all the better.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#fully-consider-the-replies","title":"Fully consider the replies","text":"You could find yourself getting a reply that you're sure won't help at all. That's fair. But be sure to fully consider it first. Perhaps you missed the obvious along the way and this is 100% the course correction you'd unknowingly come looking for in the first place. Sure, the person replying might have totally misunderstood what was being asked, or might be giving a wrong answer (it me! I've totally done that and will again!), but even then a reply along the lines of \"I'm not sure that's what I'm looking for, because...\" gets everyone to the solution faster than \"lol nah\".
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#entertain-what-might-seem-like-odd-questions","title":"Entertain what might seem like odd questions","text":"Aye, I get it, being asked questions when you're looking for an answer can be a bit frustrating. But if you find yourself on the receiving end of a small series of questions about your question, keep this in mind: Textual is still rather new and still developing and it's possible that what you're trying to do isn't the correct way to do that thing. To the person looking to help you it may seem to them you have an XY problem.
Entertaining those questions might just get you to the real solution to your problem.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#allow-for-language-differences","title":"Allow for language differences","text":"You don't need me to tell you that a project such as Textual has a global audience. With that rather obvious fact comes the other fact that we don't all share the same first language. So, please, as much as possible, try and allow for that. If someone is trying to help you out, and they make it clear they're struggling to follow you, keep this in mind.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#acknowledge-the-answer","title":"Acknowledge the answer","text":"I suppose this is a variation on \"be polite\" (really, a thanks can go a long way), but there's more to this than a friendly acknowledgement. If someone has gone to the trouble of offering some help, it's helpful to everyone who comes after you to acknowledge if it worked or not. That way a future help-seeker will know if the answer they're reading stands a chance of being the right one.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#accept-that-textual-is-zero-point-software-right-now","title":"Accept that Textual is zero-point software (right now)","text":"Of course the aim is to have every release of Textual be stable and useful, but things will break. So, please, do keep in mind things like:
Of course it can be a bit frustrating a times, but overall the aim is to have the best framework possible in the long run.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#dont","title":"Don't...","text":"Okay, now for a bit of old-hacker finger-wagging. Here's a few things I'd personally discourage:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#lack-patience","title":"Lack patience","text":"Sure, it can be annoying. You're in your flow, you've got a neat idea for a thing you want to build, you're stuck on one particular thing and you really need help right now! Thing is, that's unlikely to happen. Badgering individuals, or a whole resource, to reply right now, or complaining that it's been $TIME_PERIOD
since you asked and nobody has replied... that's just going to make people less likely to reply.
This one often goes hand in hand with the \"lack patience\" thing: Be it asking on Discord, or in GitHub issues, discussions or even PRs, unnecessarily tagging individuals is a bit rude. Speaking for myself and only myself: I love helping folk with Textual. If I could help everyone all the time the moment they have a problem, I would. But it doesn't work like that. There's any number of reasons I might not be responding to a particular request, including but not limited to (here I'm talking personally because I don't want to speak for anyone else, but I'm sure I'm not alone here):
You get the idea though. So while I'm off having a well-rounded life, it's not good to get unnecessarily intrusive alerts to something that either a) doesn't actually directly involve me or b) could wait.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#seek-personal-support","title":"Seek personal support","text":"Again, I'm going to speak totally for myself here, but I also feel the general case is polite for all: there's a lot of good support resources available already; sending DMs on Discord or Twitter or in the Fediverse, looking for direct personal support, isn't really the best way to get help. Using the public/collective resources is absolutely the best way to get that help. Why's it a bad idea to dive into DMs? Here's some reasons I think it's not a good idea:
I suppose this should really be phrased as a do rather than a don't, as here I want to encourage something positive. A few times I've helped people out who have been very apologetic about their questions being \"noob\" questions, or about how they're fairly new to Python, or programming in general. Really, please, don't feel the need to apologise and don't be ashamed of where you're at.
If you've asked something that's obviously answered in the documentation, that's not a problem; you'll likely get pointed at the docs and it's what happens next that's the key bit. If the attitude is \"oh, cool, that's exactly what I needed to be reading, thanks!\" that's a really positive thing. The only time it's a problem is when there's a real reluctance to use the available resources. We've all seen that person somewhere at some point, right? ;-)
Not knowing things is totally cool.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#conclusion","title":"Conclusion","text":"So, that's my waffle over. As I said at the start: this is my own personal thoughts on how to get help with Textual, both as someone whose job it is to work on Textual and help people with Textual, and also as a FOSS advocate and supporter who can normally be found helping Textual users when he's not \"on the clock\" too.
What I've written here isn't exhaustive. Neither is it novel. Plenty has been written on the general subject in the past, and I'm sure more will be written on the subject in the future. I do, however, feel that these are the most common things I notice. I'd say those dos and don'ts cover 90% of \"can I get some help?\" interactions; perhaps closer to 99%.
Finally, and I think this is the most important thing to remember, the next time you are battling some issue while working with Textual: don't lose your head!
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/","title":"On dog food, the (original) Metaverse, and (not) being bored","text":""},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#introduction","title":"Introduction","text":"Quote
Cutler, armed with a schedule, was urging the team to \"eat its own dog food\". Part macho stunt and part common sense, the \"dog food diet\" was the cornerstone of Cutler\u2019s philosophy.
G. Pascal Zachary \u2014 Show-Stopper!
I can't remember exactly when it was -- it was likely late in 1994 or some time in 1995 -- when I first came across the concept of, or rather the name for the concept of, \"eating your own dog food\". The idea and the name played a huge part in the book Show-Stopper! by G. Pascal Zachary. The idea wasn't new to me of course; I'd been writing code for over a decade by then and plenty of times I'd built things and then used those things to do things, but it was fascinating to a mostly-self-taught 20-something me to be reading this (excellent -- go read it if you care about the history of your craft) book and to see the idea written down and named.
While Textualize isn't (thankfully -- really, I do recommend reading the book) anything like working on the team building Windows NT, the idea of taking a little time out from working on Textual, and instead work with Textual, makes a lot of sense. It's far too easy to get focused on adding things and improving things and tweaking things while losing sight of the fact that people will want to build with your product.
So you can imagine how pleased I was when Will announced that he wanted all of us to spend a couple or so weeks building something with Textual. I had, of course, already written one small application with the library, and had plans for another (in part it's how I ended up working here), but I'd yet to really dive in and try and build something more involved.
Giving it some thought: I wasn't entirely sure what I wanted to build though. I do want to use Textual to build a brand new terminal-based Norton Guide reader (not my first, not by a long way) but I felt that was possibly a bit too niche, and actually could take a bit too long anyway. Maybe not, it remains to be seen.
Eventually I decided on this approach: try and do a quick prototype of some daft idea each day or each couple of days, do that for a week or so, and then finally try and settle down on something less trivial. This approach should work well in that it'll help introduce me to more of Textual, help try out a few different parts of the library, and also hopefully discover some real pain-points with working with it and highlight a list of issues we should address -- as seen from the perspective of a developer working with the library.
So, here I am, at the end of week one. What I want to try and do is briefly (yes yes, I know, this introduction is the antithesis of brief) talk about what I built and perhaps try and highlight some lessons learnt, highlight some patterns I think are useful, and generally do an end-of-week version of a TIL. TWIL?
Yeah. I guess this is a TWIL.
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#gridinfo","title":"gridinfo","text":"I started the week by digging out a quick hack I'd done a couple of weeks earlier, with a view to cleaning it up. It started out as a fun attempt to do something with Rich Pixels while also making a terminal-based take on slstats.el
. I'm actually pleased with the result and how quickly it came together.
The point of the application itself is to show some general information about the current state of the Second Life grid (hello to any fellow residents of the original Metaverse!), and to also provide a simple region lookup screen that, using Rich Pixels, will display the object map (albeit in pretty low resolution -- but that's the fun of this!).
So the opening screen looks like this:
and a lookup of a region looks like this:
Here's a wee video of the whole thing in action:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#worth-a-highlight","title":"Worth a highlight","text":"Here's a couple of things from the code that I think are worth a highlight, as things to consider when building Textual apps:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#dont-use-the-default-screen","title":"Don't use the default screen","text":"Use of the default Screen
that's provided by the App
is handy enough, but I feel any non-trivial application should really put as much code as possible in screens that relate to key \"work\". Here's the entirety of my application code:
class GridInfo( App[ None ] ):\n\"\"\"TUI app for showing information about the Second Life grid.\"\"\"\nCSS_PATH = \"gridinfo.css\"\n\"\"\"The name of the CSS file for the app.\"\"\"\nTITLE = \"Grid Information\"\n\"\"\"str: The title of the application.\"\"\"\nSCREENS = {\n\"main\": Main,\n\"region\": RegionInfo\n}\n\"\"\"The collection of application screens.\"\"\"\ndef on_mount( self ) -> None:\n\"\"\"Set up the application on startup.\"\"\"\nself.push_screen( \"main\" )\n
You'll notice there's no work done in the app, other than to declare the screens, and to set the main
screen running when the app is mounted.
on_mount
","text":"My initial version of the application had it loading up the data from the Second Life and GridSurvey APIs in Main.on_mount
. This obviously wasn't a great idea as it made the startup appear slow. That's when I realised just how handy call_after_refresh
is. This meant I could show some placeholder information and then fire off the requests (3 of them: one to get the main grid information, one to get the grid concurrency data, and one to get the grid size data), keeping the application looking active and updating the display when the replies came in.
While building this app I think there was only really the one pain-point, and I suspect it's mostly more on me than on Textual itself: getting a good layout and playing whack-a-mole with CSS. I suspect this is going to be down to getting more and more familiar with CSS and the terminal (which is different from laying things out for the web), while also practising with various layout schemes -- which is where the revamped Placeholder
class is going to be really useful.
The next application was initially going to be a very quick hack, but actually turned into a less-trivial build than I'd initially envisaged; not in a negative way though. The more I played with it the more I explored and I feel that this ended up being my first really good exploration of some useful (personal -- your kilometerage may vary) patterns and approaches when working with Textual.
The application itself is a terminal client for the Bored-API. I had initially intended to roll my own code for working with the API, but I noticed that someone had done a nice library for it and it seemed silly to not build on that. Not needing to faff with that, I could concentrate on the application itself.
At first I was just going to let the user click away at a button that showed a random activity, but this quickly morphed into a \"why don't I make this into a sort of TODO list builder app, where you can add things to do when you are bored, and delete things you don't care for or have done\" approach.
Here's a view of the main screen:
and here's a view of the filter pop-over:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#worth-a-highlight_1","title":"Worth a highlight","text":""},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#dont-put-all-your-bindings-in-one-place","title":"Don't put all yourBINDINGS
in one place","text":"This came about from me overloading the use of the escape
key. I wanted it to work more or less like this:
It was easy enough to do, and I had an action in the Main
screen that escape
was bound to (again, in the Main
screen) that did all this logic with some if
/elif
work but it didn't feel elegant. Moreover, it meant that the Footer
always displayed the same description for the key.
That's when I realised that it made way more sense to have a Binding
for escape
in every widget that was the actual context for escape's use. So I went from one top-level binding to...
...\nclass Activity( Widget ):\n\"\"\"A widget that holds and displays a suggested activity.\"\"\"\nBINDINGS = [\n...\nBinding( \"escape\", \"deselect\", \"Switch to Types\" )\n]\n...\nclass Filters( Vertical ):\n\"\"\"Filtering sidebar.\"\"\"\nBINDINGS = [\nBinding( \"escape\", \"close\", \"Close Filters\" )\n]\n...\nclass Main( Screen ):\n\"\"\"The main application screen.\"\"\"\nBINDINGS = [\nBinding( \"escape\", \"quit\", \"Close\" )\n]\n\"\"\"The bindings for the main screen.\"\"\"\n
This was so much cleaner and I got better Footer
descriptions too. I'm going to be leaning hard on this approach from now on.
Until I wrote this application I hadn't really had a need to define or use my own Message
s. During work on this I realised how handy they really are. In the code I have an Activity
widget which takes care of the job of moving itself amongst its siblings if the user asks to move an activity up or down. When this happens I also want the Main
screen to save the activities to the filesystem as things have changed.
Thing is: I don't want the screen to know what an Activity
is capable of and I don't want an Activity
to know what the screen is capable of; especially the latter as I really don't want a child of a screen to know what the screen can do (in this case \"save stuff\").
This is where messages come in. Using a message I could just set things up so that the Activity
could shout out \"HEY I JUST DID A THING THAT CHANGES ME\" and not care who is listening and not care what they do with that information.
So, thanks to this bit of code in my Activity
widget...
class Moved( Message ):\n\"\"\"A message to indicate that an activity has moved.\"\"\"\ndef action_move_up( self ) -> None:\n\"\"\"Move this activity up one place in the list.\"\"\"\nif self.parent is not None and not self.is_first:\nparent = cast( Widget, self.parent )\nparent.move_child(\nself, before=parent.children.index( self ) - 1\n)\nself.emit_no_wait( self.Moved( self ) )\nself.scroll_visible( top=True )\n
...the Main
screen can do this:
def on_activity_moved( self, _: Activity.Moved ) -> None:\n\"\"\"React to an activity being moved.\"\"\"\nself.save_activity_list()\n
Warning
The code above used emit_no_wait
. Since this blog post was first published that method has been removed from Textual. You should use post_message_no_wait
or post_message
instead now.
On top of the issues of getting to know terminal-based-CSS that I mentioned earlier:
Input
widgets. They too are on the roadmap but I managed to cobble together fairly good working versions for my purposes. In doing so though I did further highlight that the reactive attribute facility needs a wee bit more attention as I ran into some (already-known) bugs. Thankfully in my case it was a very easy workaround.The first week of \"dogfooding\" has been fun and I'm more convinced than ever that it's an excellent exercise for Textualize to engage in. I didn't quite manage my plan of \"one silly trivial prototype per day\", which means I've ended up with two (well technically one and a half I guess given that gridinfo
already existed as a prototype) applications rather than four. I'm okay with that. I got a lot of utility out of this.
Now to look at the list of ideas I have going and think about what I'll kick next week off with...
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/","title":"What I learned from my first non-trivial PR","text":"PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0 ligula.\u00a0Nullam\u00a0imperdiet\u00a0sem\u00a0tellus, sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0\u2586\u2586consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0\u2586\u2586 Sed\u00a0lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 lacinia,\u00a0sapien\u00a0sapien\u00a0congue\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quisIt's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius. It is my second day at Textualize and I just got into the office. I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office. As I sit down, I turn myself in my chair to face my boss and colleagues to ask \u201cSo, what should I do today?\u201d. I was not expecting Will's answer, but the challenge excited me:
\u201cI thought I'll just throw you in the deep end and have you write some code.\u201d
What happened next was that I spent two days working on PR #1229 to add a new widget to the Textual code base. At the time of writing, the pull request has not been merged yet. Well, to be honest with you, it hasn't even been reviewed by anyone... But that won't stop me from blogging about some of the things I learned while creating this PR.
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#the-placeholder-widget","title":"The placeholder widget","text":"This PR adds a widget called Placeholder
to Textual. As per the documentation, this widget \u201cis meant to have no complex functionality. Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.\u201d
The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready. The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0 ligula.\u00a0Nullam\u00a0imperdiet\u00a0sem\u00a0tellus, sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0\u2586\u2586consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0\u2586\u2586 Sed\u00a0lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 lacinia,\u00a0sapien\u00a0sapien\u00a0congue\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quisThe top left and top right widgets have custom labels. Immediately under the top right placeholder, you can see some placeholders identified as #p3
, #p4
, and #p5
. Those are the IDs of the respective placeholders. Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company? The answer is simple: just copy and paste code! But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets. For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in _button.py. By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
For example, a widget can have a class attribute called DEFAULT_CSS
that specifies the default CSS for that widget. I learned this just from staring at the code for the button widget.
Studying the code base will also reveal the standards that are in place. For example, I learned that for a widget with variants (like the button with its \u201csuccess\u201d and \u201cerror\u201d variants), the widget gets a CSS class with the name of the variant prefixed by a dash. You can learn this by looking at the method Button.watch_variant
:
class Button(Static, can_focus=True):\n# ...\ndef watch_variant(self, old_variant: str, variant: str):\nself.remove_class(f\"-{old_variant}\")\nself.add_class(f\"-{variant}\")\n
In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#handling-the-placeholder-variant","title":"Handling the placeholder variant","text":"A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button. For the placeholder widget, we want the variant to determine what information the placeholder shows. The original GitHub issue mentions 5 variants for the placeholder:
The variant can be assigned when the placeholder is first instantiated, for example, Placeholder(\"css\")
would create a placeholder that shows its own CSS. However, we also want to have an on_click
handler that cycles through all the possible variants. I was getting ready to reinvent the wheel when I remembered that the standard module itertools
has a lovely tool that does exactly what I needed! Thus, all I needed to do was create a new cycle
through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
class Placeholder(Static):\ndef __init__(\nself,\nvariant: PlaceholderVariant = \"default\",\n*,\nlabel: str | None = None,\nname: str | None = None,\nid: str | None = None,\nclasses: str | None = None,\n) -> None:\n# ...\nself.variant = self.validate_variant(variant)\n# Set a cycle through the variants with the correct starting point.\nself._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)\nwhile next(self._variants_cycle) != self.variant:\npass\ndef on_click(self) -> None:\n\"\"\"Click handler to cycle through the placeholder variants.\"\"\"\nself.cycle_variant()\ndef cycle_variant(self) -> None:\n\"\"\"Get the next variant in the cycle.\"\"\"\nself.variant = next(self._variants_cycle)\n
I am just happy that I had the insight to add this little while
loop when a placeholder is instantiated:
from itertools import cycle\n# ...\nclass Placeholder(Static):\n# ...\ndef __init__(...):\n# ...\nself._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)\nwhile next(self._variants_cycle) != self.variant:\npass\n
Can you see what would be wrong if this loop wasn't there?
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#updating-the-render-of-the-placeholder-on-variant-change","title":"Updating the render of the placeholder on variant change","text":"If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes. Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was... Defer the problem to another method:
class Placeholder(Static):\n# ...\nvariant = reactive(\"default\")\n# ...\ndef watch_variant(\nself, old_variant: PlaceholderVariant, variant: PlaceholderVariant\n) -> None:\nself.validate_variant(variant)\nself.remove_class(f\"-{old_variant}\")\nself.add_class(f\"-{variant}\")\nself.call_variant_update() # <-- let this method do the heavy lifting!\n
Doing this properly required some thinking. Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this. I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
if variant == \"default\":\n# render the default placeholder\nelif variant == \"size\":\n# render the placeholder with its size\nelif variant == \"state\":\n# render the state of the placeholder\nelif variant == \"css\":\n# render the placeholder with its CSS rules\nelif variant == \"text\":\n# render the placeholder with some text inside\n
However, I am a fan of using the built-in getattr
and I thought of creating a rendering method for each different variant. Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call. This means that the method Placeholder.call_variant_update
is just this:
class Placeholder(Static):\n# ...\ndef call_variant_update(self) -> None:\n\"\"\"Calls the appropriate method to update the render of the placeholder.\"\"\"\nupdate_variant_method = getattr(self, f\"_update_{self.variant}_variant\")\nupdate_variant_method()\n
If self.variant
is, say, \"size\"
, then update_variant_method
refers to _update_size_variant
:
class Placeholder(Static):\n# ...\ndef _update_size_variant(self) -> None:\n\"\"\"Update the placeholder with the size of the placeholder.\"\"\"\nwidth, height = self.size\nself._placeholder_label.update(f\"[b]{width} x {height}[/b]\")\n
This variant \"size\"
also interacts with resizing events, so we have to watch out for those:
class Placeholder(Static):\n# ...\ndef on_resize(self, event: events.Resize) -> None:\n\"\"\"Update the placeholder \"size\" variant with the new placeholder size.\"\"\"\nif self.variant == \"size\":\nself._update_size_variant()\n
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#deleting-code-is-a-hurtful-blessing","title":"Deleting code is a (hurtful) blessing","text":"To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
After careful consideration and after coming up with the getattr
mechanism to update the display of the placeholder according to the active variant, I started showing the \u201cfinal\u201d product to Will and my other colleagues. Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state. This means that I had to delete part of my code even before it saw the light of day.
On the one hand, deleting those chunks of code made me a bit sad. After all, I had spent quite some time thinking about how to best implement that functionality! But then, it was time to write documentation and tests, and I verified that the best code is the code that you don't even write! The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base. On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now. Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!
"},{"location":"blog/2023/07/29/pull-requests-are-cake-or-puppies/","title":"Pull Requests are cake or puppies","text":"Broadly speaking, there are two types of contributions you can make to an Open Source project.
The first type is typically a bug fix, but could also be a documentation update, linting fix, or other change which doesn't impact core functionality. Such a contribution is like cake. It's a simple, delicious, gift to the project.
The second type of contribution often comes in the form of a new feature. This contribution likely represents a greater investment of time and effort than a bug fix. It is still a gift to the project, but this contribution is not cake.
A feature PR has far more in common with a puppy. The maintainer(s) may really like the feature but hesitate to merge all the same. They may even reject the contribution entirely. This is because a feature PR requires an ongoing burden to maintain. In the same way that a puppy needs food and walkies, a new feature will require updates and fixes long after the original contribution. Even if it is an amazing feature, the maintainer may not want to commit to that ongoing work.
The chances of a feature being merged can depend on the maturity of the project. At the beginning of a project, a maintainer may be delighted with a new feature contribution. After all, having others join you to build something is the joy of Open Source. And yet when a project gets more mature there may be a growing resistance to adding new features, and a greater risk that a feature PR is rejected or sits unappreciated in the PR queue.
So how should a contributor avoid this? If there is any doubt, it's best to propose the feature to the maintainers before undertaking the work. In all likelihood they will be happy for your contribution, just be prepared for them to say \"thanks but no thanks\". Don't take it as a rejection of your gift: it's just that the maintainer can't commit to taking on a puppy.
There are other ways to contribute code to a project that don't require the code to be merged in to the core. You could publish your change as a third party library. Take it from me: maintainers love it when their project spawns an ecosystem. You could also blog about how you solved your problem without an update to the core project. Having a resource that can be googled for, or a maintainer can direct people to, can be a huge help.
What prompted me to think about this is that my two main projects, Rich and Textual, are at quite different stages in their lifetime. Rich is relatively mature, and I'm unlikely to accept a puppy. If you can achieve what you need without adding to the core library, I am probably going to decline a new feature. Textual is younger and still accepting puppies \u2014 in addition to stick insects, gerbils, capybaras and giraffes.
Tip
If you are maintainer, and you do have to close a feature PR, feel free to link to this post.
Join us on the Discord Server if you want to discuss puppies and other creatures.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/","title":"Textual 0.11.0 adds a beautiful Markdown widget","text":"We released Textual 0.10.0 25 days ago, which is a little longer than our usual release cycle. What have we been up to?
The headline feature of this release is the enhanced Markdown support. Here's a screenshot of an example:
MarkdownApp \u258bHeader\u00a0level\u00a06\u00a0content. \u25bc\u00a0\u2160\u00a0Textual\u00a0Markdown\u00a0Browser\u00a0-\u00a0Demo\u258b \u251c\u2500\u2500\u00a0\u25bc\u00a0\u2161\u00a0Headers\u258b\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2162\u00a0This\u00a0is\u00a0H3\u258b\u258e\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2163\u00a0This\u00a0is\u00a0H4\u258b\u258eTypography\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2164\u00a0This\u00a0is\u00a0H5\u258b\u258e\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u2165\u00a0This\u00a0is\u00a0H6\u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u251c\u2500\u2500\u00a0\u25bc\u00a0\u2161\u00a0Typography\u258bThe\u00a0usual\u00a0Markdown\u00a0typography\u00a0is\u00a0supported.\u00a0The\u00a0exact\u00a0output\u00a0depends\u00a0on\u00a0 \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Emphasis\u258byour\u00a0terminal,\u00a0although\u00a0most\u00a0are\u00a0fairly\u00a0consistent.\u2581\u2581 \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Strong\u258b \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Strikethrough\u258bEmphasis \u2502\u00a0\u00a0\u00a0\u2517\u2501\u2501\u00a0\u2162\u00a0Inline\u00a0code\u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u251c\u2500\u2500\u00a0\u2161\u00a0Fences\u258bEmphasis\u00a0is\u00a0rendered\u00a0with\u00a0*asterisks*,\u00a0and\u00a0looks\u00a0like\u00a0this; \u251c\u2500\u2500\u00a0\u2161\u00a0Quote\u258b \u2514\u2500\u2500\u00a0\u2161\u00a0Tables\u258bStrong \u258b\u2594\u2594\u2594\u2594\u2594\u2594 \u258bUse\u00a0two\u00a0asterisks\u00a0to\u00a0indicate\u00a0strong\u00a0which\u00a0renders\u00a0in\u00a0bold,\u00a0e.g.\u00a0 \u258b**strong**\u00a0render\u00a0strong. \u258b \u258bStrikethrough \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bTwo\u00a0tildes\u00a0indicates\u00a0strikethrough,\u00a0e.g.\u00a0~~cross\u00a0out~~\u00a0render\u00a0cross\u00a0out. \u258b\u2582\u2582 \u258bInline\u00a0code \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bInline\u00a0code\u00a0is\u00a0indicated\u00a0by\u00a0backticks.\u00a0e.g.\u00a0import\u00a0this. \u258b \u258b\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258b\u258e\u258b \u258b\u258eFences\u258b \u258b\u258e\u258b \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bFenced\u00a0code\u00a0blocks\u00a0are\u00a0introduced\u00a0with\u00a0three\u00a0back-ticks\u00a0and\u00a0the\u00a0optional\u00a0 \u258bparser.\u00a0Here\u00a0we\u00a0are\u00a0rendering\u00a0the\u00a0code\u00a0in\u00a0a\u00a0sub-widget\u00a0with\u00a0syntax\u00a0 \u258bhighlighting\u00a0and\u00a0indent\u00a0guides. \u258b \u258bIn\u00a0the\u00a0future\u00a0I\u00a0think\u00a0we\u00a0could\u00a0add\u00a0controls\u00a0to\u00a0export\u00a0the\u00a0code,\u00a0copy\u00a0to\u00a0 \u258bthe\u00a0clipboard.\u00a0Heck,\u00a0even\u00a0run\u00a0it\u00a0and\u00a0show\u00a0the\u00a0output? \u258b \u258b \u258b@lru_cache(maxsize=1024) \u258bdefsplit(self,cut_x:int,cut_y:int)->tuple[Region,Region,Regi \u258b\u2502\u00a0\u00a0\u00a0\"\"\"Split\u00a0a\u00a0region\u00a0in\u00a0to\u00a04\u00a0from\u00a0given\u00a0x\u00a0and\u00a0y\u00a0offsets\u00a0(cuts). \u00a0T\u00a0\u00a0TOC\u00a0\u00a0B\u00a0\u00a0Back\u00a0\u00a0F\u00a0\u00a0Forward\u00a0Tip
You can generate these SVG screenshots for your app with textual run my_app.py --screenshot 5
which will export a screenshot after 5 seconds.
There are actually 2 new widgets: Markdown for a simple Markdown document, and MarkdownViewer which adds browser-like navigation and a table of contents.
Textual has had support for Markdown since day one by embedding a Rich Markdown object -- which still gives decent results! This new widget adds dynamic controls such as scrollable code fences and tables, in addition to working links.
In future releases we plan on adding more Markdown extensions, and the ability to easily embed custom widgets within the document. I'm sure there are plenty of interesting applications that could be powered by dynamically generated Markdown documents.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#datatable-improvements","title":"DataTable improvements","text":"There has been a lot of work on the DataTable API. We've added the ability to sort the data, which required that we introduce the concept of row and column keys. You can now reference rows / columns / cells by their coordinate or by row / column key.
Additionally there are new update_cell and update_cell_at methods to update cells after the data has been populated. Future releases will have more methods to manipulate table data, which will make it a very general purpose (and powerful) widget.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#tree-control","title":"Tree control","text":"The Tree widget has grown a few methods to programmatically expand, collapse and toggle tree nodes.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#breaking-changes","title":"Breaking changes","text":"There are a few breaking changes in this release. These are mostly naming and import related, which should be easy to fix if you are affected. Here's a few notable examples:
Checkbox
has been renamed to Switch
. This is because we plan to introduce complimentary Checkbox
and RadioButton
widgets in a future release, but we loved the look of Switches too much to drop them.emit
and emit_no_wait
methods. These methods posted message to the parent widget, but we found that made it problematic to subclass widgets. In almost all situations you want to replace these with self.post_message
(or self.post_message_no_wait
).Be sure to check the CHANGELOG for the full details on potential breaking changes.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#join-us","title":"Join us!","text":"We're having fun on our Discord server. Join us there to talk to Textualize developers and share ideas.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/","title":"Textual 0.12.0 adds syntactical sugar and batch updates","text":"It's been just 9 days since the previous release, but we have a few interesting enhancements to the Textual API to talk about.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#better-compose","title":"Better compose","text":"We've added a little syntactical sugar to Textual's compose
methods, which aids both readability and editability (that might not be a word).
First, let's look at the old way of building compose methods. This snippet is taken from the textual colors
command.
for color_name in ColorSystem.COLOR_NAMES:\nitems: list[Widget] = [ColorLabel(f'\"{color_name}\"')]\nfor level in LEVELS:\ncolor = f\"{color_name}-{level}\" if level else color_name\nitem = ColorItem(\nColorBar(f\"${color}\", classes=\"text label\"),\nColorBar(\"$text-muted\", classes=\"muted\"),\nColorBar(\"$text-disabled\", classes=\"disabled\"),\nclasses=color,\n)\nitems.append(item)\nyield ColorGroup(*items, id=f\"group-{color_name}\")\n
This code composes the following color swatches:
ColorsApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 primary \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b\u2581\u2581 secondary\u258e\"primary\"\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b background\u258e$primary-darken-3$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b primary-background\u258e$primary-darken-2$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b secondary-background\u258e$primary-darken-1$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b surface\u258e$primary$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b panel\u258e$primary-lighten-1$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b boost\u258e$primary-lighten-2$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b warning\u258e$primary-lighten-3$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b error\u258e\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 success \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b accent\u258e\"secondary\"\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u258e\u258b \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0Tip
You can see this by running textual colors
from the command line.
The old way was not all that bad, but it did make it hard to see the structure of your app at-a-glance, and editing compose methods always felt a little laborious.
Here's the new syntax, which uses context managers to add children to containers:
for color_name in ColorSystem.COLOR_NAMES:\nwith ColorGroup(id=f\"group-{color_name}\"):\nyield Label(f'\"{color_name}\"')\nfor level in LEVELS:\ncolor = f\"{color_name}-{level}\" if level else color_name\nwith ColorItem(classes=color):\nyield ColorBar(f\"${color}\", classes=\"text label\")\nyield ColorBar(\"$text-muted\", classes=\"muted\")\nyield ColorBar(\"$text-disabled\", classes=\"disabled\")\n
The context manager approach generally results in fewer lines of code, and presents attributes on the same line as containers themselves. Additionally, adding widgets to a container can be as simple is indenting them.
You can still construct widgets and containers with positional arguments, but this new syntax is preferred. It's not documented yet, but you can start using it now. We will be updating our examples in the next few weeks.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#batch-updates","title":"Batch updates","text":"Textual is smart about performing updates to the screen. When you make a change that might repaint the screen, those changes don't happen immediately. Textual makes a note of them, and repaints the screen a short time later (around a 1/60th of a second). Multiple updates are combined so that Textual does less work overall, and there is none of the flicker you might get with multiple repaints.
Although this works very well, it is possible to introduce a little flicker if you make changes across multiple widgets. And especially if you add or remove many widgets at once. To combat this we have added a batch_update context manager which tells Textual to disable screen updates until the end of the with block.
The new Markdown widget uses this context manager when it updates its content. Here's the code:
with self.app.batch_update():\nawait self.query(\"MarkdownBlock\").remove()\nawait self.mount_all(output)\n
Without the batch update there are a few frames where the old markdown blocks are removed and the new blocks are added (which would be perceived as a brief flicker). With the update, the update appears instant.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#disabled-widgets","title":"Disabled widgets","text":"A few widgets (such as Button) had a disabled
attribute which would fade the widget a little and make it unselectable. We've extended this to all widgets. Although it is particularly applicable to input controls, anything may be disabled. Disabling a container makes its children disabled, so you could use this for disabling a form, for example.
Tip
Disabled widgets may be styled with the :disabled
CSS pseudo-selector.
Also in this release is another context manager, which will disable specified Message types. This doesn't come up as a requirement very often, but it can be very useful when it does. This one is documented, see Preventing events for details.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#full-changelog","title":"Full changelog","text":"As always see the release page for additional changes and bug fixes.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#join-us","title":"Join us!","text":"We're having fun on our Discord server. Join us there to talk to Textualize developers and share ideas.
"},{"location":"blog/2023/03/09/textual-0140-shakes-up-posting-messages/","title":"Textual 0.14.0 shakes up posting messages","text":"Textual version 0.14.0 has landed just a week after 0.13.0.
Note
We like fast releases for Textual. Fast releases means quicker feedback, which means better code.
What's new?
We did a little shake-up of posting messages which will simplify building widgets. But this does mean a few breaking changes.
There are two methods in Textual to post messages: post_message
and post_message_no_wait
. The former was asynchronous (you needed to await
it), and the latter was a regular method call. These two methods have been replaced with a single post_message
method.
To upgrade your project to Textual 0.14.0, you will need to do the following:
await
keywords from any calls to post_message
.post_message_no_wait
with post_message
.Additionally, we've simplified constructing messages classes. Previously all messages required a sender
argument, which had to be manually set. This was a clear violation of our \"no boilerplate\" policy, and has been dropped. There is still a sender
property on messages / events, but it is set automatically.
So prior to 0.14.0 you might have posted messages like the following:
await self.post_message(self.Changed(self, item=self.item))\n
You can now replace it with this simpler function call:
self.post_message(self.Change(item=self.item))\n
This also means that you will need to drop the sender from any custom messages you have created.
If this was code pre-0.14.0:
class MyWidget(Widget):\nclass Changed(Message):\n\"\"\"My widget change event.\"\"\"\ndef __init__(self, sender:MessageTarget, item_index:int) -> None:\nself.item_index = item_index\nsuper().__init__(sender)\n
You would need to make the following change (dropping sender
).
class MyWidget(Widget):\nclass Changed(Message):\n\"\"\"My widget change event.\"\"\"\ndef __init__(self, item_index:int) -> None:\nself.item_index = item_index\nsuper().__init__()\n
If you have any problems upgrading, join our Discord server, we would be happy to help.
See the release notes for the full details on this update.
"},{"location":"blog/2023/03/13/textual-0150-adds-a-tabs-widget/","title":"Textual 0.15.0 adds a tabs widget","text":"We've just pushed Textual 0.15.0, only 4 days after the previous version. That's a little faster than our typical release cadence of 1 to 2 weeks.
What's new in this release?
The highlight of this release is a new Tabs widget to display tabs which can be navigated much like tabs in a browser. Here's a screenshot:
TabsApp Paul\u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0Halleck \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0In a future release, this will be combined with the ContentSwitcher widget to create a traditional tabbed dialog. Although Tabs is still useful as a standalone widgets.
Tip
I like to tweet progress with widgets on Twitter. See the #textualtabs hashtag which documents progress on this widget.
Also in this release is a new LoadingIndicator widget to display a simple animation while waiting for data. Here's a screenshot:
LoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cfAs always, see the release notes for the full details on this update.
If you want to talk about these widgets, or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/","title":"Textual 0.16.0 adds TabbedContent and border titles","text":"Textual 0.16.0 lands 9 days after the previous release. We have some new features to show you.
There are two highlights in this release. In no particular order, the first is TabbedContent which uses a row of tabs to navigate content. You will have likely encountered this UI in the desktop and web. I think in Windows they are known as \"Tabbed Dialogs\".
This widget combines existing Tabs and ContentSwitcher widgets and adds an expressive interface for composing. Here's a trivial example to use content tabs to navigate a set of three markdown documents:
def compose(self) -> ComposeResult:\nwith TabbedContent(\"Leto\", \"Jessica\", \"Paul\"):\nyield Markdown(LETO)\nyield Markdown(JESSICA)\nyield Markdown(PAUL)\n
Here's an example of the UI you can create with this widget (note the nesting)!
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/#border-titles","title":"Border titles","text":"The second highlight is a frequently requested feature (FRF?). Widgets now have the two new string properties, border_title
and border_subtitle
, which will be displayed within the widget's border.
You can set the alignment of these titles via border-title-align
and border-subtitle-align
. Titles may contain Console Markup, so you can add additional color and style to the labels.
Here's an example of a widget with a title:
BorderApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 ascii \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 none \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550double\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 hidden\u2551\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551I\u00a0must\u00a0not\u00a0fear.\u2551 blank\u2551Fear\u00a0is\u00a0the\u00a0mind-killer.\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2551 round\u2551I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551path.\u2551 solid\u2586\u2586\u2551Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551remain.\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551\u2551 double\u2551\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 dashed \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 heavy \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581BTW the above is a command you can run to see the various border styles you can apply to widgets.
textual borders\n
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/#container-changes","title":"Container changes","text":"Breaking change
If you have an app that uses any container classes, you should read this section.
We've made a change to containers in this release. Previously all containers had auto scrollbars, which means that any container would scroll if its children didn't fit. With nested layouts, it could be tricky to understand exactly which containers were scrolling. In 0.16.0 we split containers in to scrolling and non-scrolling versions. So Horizontal
will now not scroll by default, but HorizontalScroll
will have automatic scrollbars.
As always, see the release notes for the full details on this update.
If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/","title":"Textual 0.17.0 adds translucent screens and Option List","text":"This is a surprisingly large release, given it has been just 7 days since the last version (and we were down a developer for most of that time).
What's new in this release?
There are two new notable features I want to cover. The first is a compositor effect.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#translucent-screens","title":"Translucent screens","text":"Textual has a concept of \"screens\" which you can think of as independent UI modes, each with their own user interface and logic. The App class keeps a stack of these screens so you can switch to a new screen and later return to the previous screen.
Screens
See the guide to learn more about the screens API.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXOtT20hcdTAwMTL/nr+C4r7sVcWz0z3vrbq6XHUwMDAyXHUwMDEyXHUwMDEyQnhsyObB3VZK2MLW4ddaMsZs5X+/XHUwMDFlhSDJQorBxnHiXHUwMDBmXHUwMDE4a+RRa+bX3b9+yH8/2djYTKbDcPO3jc3wqlx1MDAxOXSj1iiYbD71xy/DUVx1MDAxY1xy+jSE6ed4MFx1MDAxZTXTMztJMox/+/XXXjC6XGKTYTdohuwyisdBN07GrWjAmoPer1FcdTAwMTL24n/7v4dBL/zXcNBrJSOWXaRcdTAwMTG2omQw+nKtsFx1MDAxYvbCflx1MDAxMtPs/6HPXHUwMDFiXHUwMDFif6d/c9K1oqA36LfS09OBnHhazFx1MDAxZT1cdTAwMWP0U1FBXHUwMDBiLZW28vaEKH5GXHUwMDE3S8JcdTAwMTaNnpPAYTbiXHUwMDBmbcrRNNFcdTAwMDae897Fx119cmhcdTAwMGZOxyfZVc+jbvckmXZTmeJcdTAwMDHdSjZcdTAwMTYno8FF+D5qJVx1MDAxZH/pmeNV31x1MDAxYVxyxu1OP4zjwndcdTAwMDbDoFx1MDAxOSVTOqb47cGg306nyI5cXNGnXHUwMDA2cs6M0Vx1MDAxNqTiXHUwMDEy6G7V7fiXXHRcdTAwMDQz1lx1MDAxOFx1MDAwNUJcdTAwMWEhXHUwMDA1qFx1MDAxOcl2XHUwMDA2XdpcdTAwMDeS7Fx1MDAxZjx9ZbKdXHUwMDA1zYs2XHTYb2XngFxugrPz7JzJzf1Kp5i0Uphs+k5cdTAwMTi1O4nfIauZNcBdfjRcdTAwMGXTTXCgpJNaZlvkrzjca6Vg+HN2XHUwMDE1O8FoeLNam7H/kJPWXHUwMDBi+nxcdTAwMTZJeTTl9lm8grPdXHUwMDEwYKe1v/3X85NcdTAwMDP5+2CrfztXXHUwMDAxesFoNJhs3o58vvkvXHUwMDEzbTxsXHUwMDA1X1x1MDAxMFx1MDAwNVpLa43TXHUwMDEyTVx1MDAwNspu1L+gwf64282OXHKaXHUwMDE3XHUwMDE5XGLTo5+f3lx1MDAxYvp0mSroo+OO0KD03NBcdTAwMGbHU3ux39vnfHz+ctLeiyb6hfue0Fx1MDAwN/5N7IPTTFx1MDAxOSNRc1x1MDAwZVx1MDAwMoyyXHUwMDA17EuBXGalQYKeddo4vlx1MDAxOPbPgzPO1Vx1MDAxMrGPQipwlq9cdTAwMTb7vd45n2zx5NlhNFxmwz9eXHUwMDFlbb86iJeEfVx1MDAwYlxccG6Whf0kvEruXHUwMDAyvkVdXHUwMDA1fFx1MDAxMNZx5NLh3Mh/d941l1fDy5fT3taHwfjj8PiF2F1v5CMqprRBXHUwMDA0dEZ6XHUwMDBiWlx1MDAwML7lwMhcdTAwMDRJclxi1iHkrMBDcG+c4udYxj1wW1x1MDAwNryBWZhrgdL7pp/IxDtcdTAwMGJK2PvAPEPToJ+cRNepjbaFo7tBL+pOXHUwMDBikEjxT1x1MDAwMp40R2HY34D/9n/pRK1W2P9nfsfikK7vJ9TFb251o7bXls1ueF5UoyRcIlx1MDAxZXY7nFxmcmvcJElcdTAwMDKabrTXmr2jwShqR/2g+7Zaqlpt/rLMd6gzUVx1MDAxM5w9nNNnIHojxPz6XFy/8/fQZ5zF5uPps3HMSFx1MDAwMGm4pXdbVGfjJFx1MDAwM81cdTAwMWRcdTAwMWGU6EjjXHUwMDFmRZ1cdTAwMWQyrogwS2MsR9TuXHUwMDBl5XZMIzk6KVFcdTAwMDHXOlx1MDAwM/BXlyaFv4VcdTAwMDeoeipkjao/ijLGSTBKtqN+K+q3aTCzXCJfQ5K9OVx1MDAxY0Sqvs2xl5IzhVxcS8FpI5UgL1x1MDAwNLmT2sHQLyFcdTAwMDMgTqLJZKNVTtibXHUwMDEzPt9cblx1MDAxNfZb31x1MDAxNqk+UMmJ1OBcZml5nPV7pjhqilx1MDAxM0pCSaZcdTAwMDVIXHUwMDA3nITiTlmnSlJ1gzjZXHUwMDE59HpRQmt/PIj6yexcdTAwMWGni7nldbxcdTAwMTNcdTAwMDYl40F3lVx1MDAxZps1XHUwMDA2Qz9j0aZn/21k2pJ+uP3/z6d3nt2ohHI6WkJxNt+T/PtcdTAwMDNcdTAwMTi5sJWGXGYtqVx1MDAwNlx1MDAxMZdM8b/JyM9G2NpcdTAwMGKuw/1nW1x1MDAwN89f9LWNIVhvXlwinGOOTFx1MDAxNVxiNN5y68xcdTAwMTJ8XHRGLeNcdTAwMDLAOeK+ZCaEmJHsIcGo1suj5ESVkKJcYs5cdTAwMWaBrNRYMECFuIqIkYLtSkeLZLhcdTAwMDVwNb+jPdzpXHUwMDFjXGZfXHUwMDFjvdtcdTAwMWRcdTAwMWNfj9xhcjQ+XHUwMDFmivVcdTAwMDao5IJcdJ9cdFGWdFWqYrJEXG7NuOPGXHUwMDEyQqVcdTAwMDZcXFxmnsuOXHUwMDE3SVx1MDAxZaE4mTS7fHDWMenXZ8rufYyvOsPx+MNWdPomuVx1MDAxOMllXHUwMDA1jIQ3yHG7x4O+XHUwMDE1qlxu+k6AUkS65jfN76eHb9o9t3+0r/76NH32abtzKI+WivxWXHUwMDEwd8IlQ98xolx1MDAxZYJcdTAwMTONXHUwMDA0YpGuXHUwMDAwfaGQXHUwMDExKZFcdTAwMDY5cDBcXC9GMi02XHUwMDFkhGqZ6CdcdTAwMTNpSTS+4nSJXHLDl+PX19fds1P96VxcJJGYnvL50P+0bt5Y7U+v4+3jyejgdO/Vofj0x9SeLWHe8+H7ydvG5Ni+v+7Fp1x1MDAxN83wI3ZcdTAwMGaWMC9cdTAwMWab//VO3mLyprl9XHUwMDE1NT/uvlx1MDAxMc1oWfE0J+S5pTnAqrSRrktcdTAwMWKRWjiKvOzcNuAsnuKOONpcdTAwMDXV3u6Mmq33h+r6dL3DTGlcdTAwMWSzSkvNOZleNZMuXHUwMDA1QaNOcrKERJ3JXHUwMDE3zlxudj9cdTAwMTNA6npcdTAwMTbewc1ErqZxq/myrO9CWqIrhj+Ct6tBolx1MDAwMan1fZCYbXiW2Vx1MDAxMVx1MDAxNOWlgYfnwDZcdTAwMTdJXHUwMDE38jzZVb7meYLhkFxyXHUwMDA3w09xmln55e4sj9CF7z12lqckU63qVeZ4dHVkROxXXHUwMDEz71Zyft2rN57L0L1H8L8gmERUXHUwMDE0ZiOZOzFTq7CGIVx1MDAxN9pcYqPJXHUwMDE3w2Ip2yrd40RvXHUwMDAxkVgwXHUwMDAx06E1QpZ1XHUwMDExuPGCmpRvXHUwMDAyXHUwMDE5XHUwMDA0V9ZNQjUpTC7JvopEXHUwMDBmcFqcx0z01NO6jUJWxVEsXHUwMDBmjmyoRE2rmUsx3CRVXHUwMDE0U1x1MDAxNFx1MDAwMyuOdFx1MDAwMtpvZnqKt/EjZVtqQJWOl/GUTfkk/35vo2JyRYVZo0JwXHUwMDAx8iD3yFx1MDAxYtczp/U0Ko5LZi1ZXGZDsaySZtaoXGJmXHUwMDA0V4pWnlxmXHUwMDBizEZcdTAwMWLLMipoOWghfSmfLpIrRuVsimWSglx1MDAwZetcdTAwMWNcdTAwMTiCXG6WykSgjZXOc5PV2lx1MDAxNKCYXCJbte9oU4DRXHUwMDBl0PIopykqJlx1MDAxZVJOXHUwMDFlXHUwMDAzZ8QmrNTkQoThXFyan9SmVEPKv1x1MDAxYWU0LcuiXHUwMDE08l+zXHUwMDE5XFyurZS+nDm3SalPnaynSdHKMCO55lx1MDAxMiVIk+tk8d/XXHUwMDAyXHUwMDE4+X3iMd6x4YItXHUwMDE1VSaFdMFcdTAwMTkjyWhJWnXSiez+b02KU1xmpXbKcsFccqrcrtxYXHUwMDE0dPRdhfpcdTAwMDFcdTAwMDHEQiSFK5fJ8nCDMqt8P4FaN6r3NVx1MDAxZC5t6T3Vuq5XSldTXHUwMDA1Tv5cdTAwMTM06vmpgn6m8FX0cnLZfvfhQrevT49+j79rn+C31ZrIMzBcbj6QTCatr5lpXHUwMDE5UUAkTSluXHUwMDFkUSaXL8ivR2VGeTOvVptcZlhdJ59cdTAwMTbVXlx1MDAwN4Rwvk1s/ui4cbU/tn+1wsvOycdLXHUwMDExTl87PNhZe3Rq5uNcdTAwMDdB2DMobLFwSG6XkS9cIuRcbq2EQbdcdTAwMTA6l16YcUIgUTfzgHB4kdR0I57s7CB2I9lcdTAwMTZcdTAwMWZa4z+O48nFslKyVlpuga9cdTAwMDD7NuegS1xy3IAkjJyfcMX93dFe7+L5azHF8L1otTvDg9Z6Q79cdTAwMDHWMrCKmKXTzmlcdTAwMGVcdTAwMDXoXHUwMDBiKYlcZiuL3KLv6l1cYvlfyjLLLElcdTAwMTJHXHUwMDA0XHUwMDEy7CcpypxtPT9/XHUwMDFmbPHD9rv9t1x1MDAwN83rQZDE46X1xqK0uDSNqlxmYWpcbp2gfHVbajN/W3h92Wd9I1x1MDAxOFxupYVcdTAwMDRSXHUwMDFiXHUwMDAxekafiOiQWfHxi6b9WKzOWVx1MDAxZMBcdTAwMThFXHUwMDExlKPolYiLJZ9VVizincxnZZRcIq/mfLmjpF5cdTAwMTTqy2Jss5pcdTAwMTCG1P1BNZBl50Q448AlxXBKXHUwMDE5jeA4N3d2r/neNk3RKsWlZJBuTvjZklwijWpQfVx1MDAxOS7hKZvxSf79vnVTqWD26C071cjR3ec5k6vXoVx1MDAxY530XHUwMDFiz/u6od1+6/CV6thcboPSXHSanfEoXFxcdTAwMDNcdTAwMWZN+GJcdTAwMDZ89yTB0T/jUEy0XHUwMDFhJ1x1MDAxOHF0X1x1MDAwZVx1MDAxMMpavlD1Jlx1MDAxOVx1MDAwNf14XHUwMDE4jEhd7jAsucxpTdO9XHUwMDE0qJw1YsWM9DGfLfHZXHUwMDAy41bedI9r2XSPizfdc1fdXHJBtsOSf7zHk5P1O38vtV5dP0SDXHUwMDAySlx1MDAwNspYIynYXHUwMDE3zuqZxnuLjLyhptPQmVxccWXpWlxyilx0XHUwMDAwKYiekW1x7q5cdTAwMTJcblx1MDAwMpM+XHLCLYVMoPJcdTAwMWRaN3TBu1x1MDAwN4fwkCzJXCJ0gTyzclx1MDAwZtHLOelCvcvYKDa7k+8z5CONTOvo5bIscEaLJITl3JdR9Ndi5D1cdTAwMWLw61x1MDAxZpcsUFx1MDAxOFx1MDAxMCCNpZ3zXHUwMDE5XHUwMDAyJY0uyWRcdTAwMTjSgCR648jGXHUwMDE5xJJMP1x1MDAxMk+pXHUwMDA2s381yjheXHUwMDE2TVx1MDAxMZWJXHUwMDA0XHUwMDA0n2+mUHV+nlwiPlxcyNa1fHn54tnhm9Z04sK+qirdrFx1MDAwZk9cdTAwMDHBmSFoI7FD32ePRYOGqH1cdTAwMDXRKCAuY6VSXHUwMDBipVx1MDAxM75BVO5o8ypcdTAwMTNcdTAwMTWyXHUwMDFjUlx0IdWPw1Se1s37mFx1MDAxOVx1MDAwNLK0Wq+eXHUwMDAxXHTiXHUwMDFhl1FcdTAwMWOddcN1okBcdTAwMDWxXHUwMDFlxoFcdTAwMTRWXHUwMDA2NuAsoZK4/vxN4fVbv65cdTAwMTTIP/IglFTeLKBRYqYtXHUwMDFjkFx0Ylx1MDAxNVx1MDAxNoUherTYXHUwMDAzO7X2XHUwMDAyXHUwMDFkOUokj01Ow3IpsyvdWlx1MDAwZu3du/TJXHUwMDAwXHS0MVjOl/jSXHUwMDAxRaSr7iF5sFrOSYDqfVGRXHUwMDAwgdCeXHUwMDA3Ulx1MDAxNFxuXHUwMDE2yeHlclx1MDAwNF9cdTAwMTmQZNJvJaBcdTAwMTJcdTAwMWOMeegziPW59qJUXFxcbmPJISljtNEu94NcdTAwMWa3YllmNHFcdTAwMDPaWue4MkKWpPqRSFAlnP2rXHUwMDA05CUxIFGdqCFhUEtcdTAwMGbXue3ZyfXlKzWNj6ZHL+LB5OPWZft479O6MyCK1ohcdTAwMDGh4MSCjK9ZXHUwMDE0XHUwMDEzNUJcdTAwMThGXHUwMDExgnRElFxibvlfUPlOmVx1MDAxYSBcdTAwMWHmIFx1MDAxN1x1MDAxNfzotUPHXHUwMDA1t8LlbmiFmZo15Cm4OE8xrlqvwVx1MDAxN1xyrTXzN6/Ub/2a8lx1MDAxNFxuKsH/mFx1MDAwZjk3w41cdTAwMTC5eCFtXHUwMDEwXHUwMDAwzdAnckxcdTAwMWHai8VcdTAwMWXdrNVr7dtotPDP9/tcdTAwMDdcdTAwMDfhXHUwMDBlLdeGOeu7XHUwMDE2NddWlVx1MDAxYdP8U2zKoV3l7yQspJVz0pR6h7FRKOs4RS9OXHUwMDFiXHUwMDA1WphcXGfyRpZcdTAwMTORXGLCao1cdTAwMWH8XHUwMDEzYuWfJJiLpNT3wszIRFxcSCvyyEJq8lx1MDAxZqIkXHUwMDEzkmfx7Wv+4XY0d7X0/0hcdTAwMTSlXHUwMDEyyOlgXHUwMDExwlVcdTAwMDTlyc3s/jGhk4TwdrtcdTAwMTVcdTAwMDTpqHVjyrNb3LyMwsn2XT056cvbx3QxvVx1MDAxNVxu/Y3+/fnJ5/9cdTAwMDPV4pXXIn0= Screen 1(hidden)app.pop_screen()Screen 2(hidden)Screen 3(visible)Screen 2(visible)
Screens can be used to build modal dialogs by pushing a screen with controls / buttons, and popping the screen when the user has finished with it. The problem with this approach is that there was nothing to indicate to the user that the original screen was still there, and could be returned to.
In this release we have added alpha support to the Screen's background color which allows the screen underneath to show through, typically blended with a little color. Applying this to a screen makes it clear than the user can return to the previous screen when they have finished interacting with the modal.
Here's how you can enable this effect with CSS:
DialogScreen {\nalign: center middle;\nbackground: $primary 30%;\n}\n
Setting the background to $primary
will make the background blue (with the default theme). The addition of 30%
sets the alpha so that it will be blended with the background. Here's the kind of effect this creates:
There are 4 screens in the above screenshot, one for the base screen and one for each of the three dialogs. Note how each screen modifies the color of the screen below, but leaves everything visible.
See the docs on screen opacity if you want to add this to your apps.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#option-list","title":"Option list","text":"Textual has had a ListView widget for a while, which is an excellent way of navigating a list of items (actually other widgets). In this release we've added an OptionList which is similar in appearance, but uses the line api under the hood. The Line API makes it more efficient when you approach thousands of items.
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258aGemenon\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258aPicon\u258e \u258aSagittaron\u258e \u258aScorpia\u258e \u258aTauron\u258e \u258aVirgon\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
The Options List accepts Rich renderable, which means that anything Rich can render may be displayed in a list. Here's an Option List of tables:
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aerilon\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Demeter\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25021.2\u00a0Billion\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Gaoth\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aquaria\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2582\u2582\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hermes\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250275,000\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502None\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Canceron\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hephaestus\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25026.7\u00a0Billion\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hades\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Caprica\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
We plan to build on the OptionList
widget to implement drop-downs, menus, check lists, etc. But it is still very useful as it is, and you can add it to apps now.
There are a number of fixes regarding refreshing in this release. If you had issues with parts of the screen not updating, the new version should resolve it.
There's also a new logging handler, and a \"thick\" border type.
See release notes for the full details.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#next-week","title":"Next week","text":"Next week we plan to take a break from building Textual to building apps with Textual. We do this now and again to give us an opportunity to step back and understand things from the perspective of a developer using Textual. We will hopefully have something interesting to show from the exercise, and new Open Source apps to share.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/04/04/textual-0180-adds-api-for-managing-concurrent-workers/","title":"Textual 0.18.0 adds API for managing concurrent workers","text":"Less than a week since the last release, and we have a new API to show you.
This release adds a new Worker API designed to manage concurrency, both asyncio tasks and threads.
An API to manage concurrency may seem like a strange addition to a library for building user interfaces, but on reflection it makes a lot of sense. People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running into predictable issues. These aren't specifically Textual problems, but rather general problems related to async tasks and threads. It's not enough for us to point users at the asyncio docs, we needed a better answer.
The new run_worker
method provides an easy way of launching \"Workers\" (a wrapper over async tasks and threads) which also manages their lifetime.
One of the challenges I've found with tasks and threads is ensuring that they are shut down in an orderly manner. Interestingly enough, Textual already implemented an orderly shutdown procedure to close the tasks that power widgets: children are shut down before parents, all the way up to the App (the root node). The new API piggybacks on to that existing mechanism to ensure that worker tasks are also shut down in the same order.
Tip
You won't need to worry about this gnarly issue with the new Worker API.
I'm particularly pleased with the new @work
decorator which can turn a coroutine OR a regular function into a Textual Worker object, by scheduling it as either an asyncio task or a thread. I suspect this will solve 90% of the concurrency issues we see with Textual apps.
See the Worker API for the details.
"},{"location":"blog/2023/04/04/textual-0180-adds-api-for-managing-concurrent-workers/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/05/03/textual-0230-improves-message-handling/","title":"Textual 0.23.0 improves message handling","text":"It's been a busy couple of weeks at Textualize. We've been building apps with Textual, as part of our dog-fooding week. The first app, Frogmouth, was released at the weekend and already has 1K GitHub stars! Expect two more such apps this month.
Frogmouth /Users/willmcgugan/projects/textual/FAQ.md ContentsLocalBookmarksHistory\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u258e\u258a \u258eHow\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u258a \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e\u258a \u2503\u25bc\u00a0\u2160\u00a0Frequently\u00a0Asked\u00a0Questions\u2503\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Does\u00a0Textual\u00a0support\u00a0images?\u2503When\u00a0creating\u00a0your\u00a0App\u00a0class,\u00a0override\u00a0__init__\u00a0as\u00a0you\u00a0would\u00a0wheninheriting\u00a0normally.\u00a0For\u00a0example: \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0fix\u00a0ImportError\u00a0cannot\u00a0i\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0select\u00a0and\u00a0copy\u00a0text\u00a0in\u00a0\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0set\u00a0a\u00a0translucent\u00a0app\u00a0ba\u2503fromtextual.appimportApp,ComposeResult \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0center\u00a0a\u00a0widget\u00a0in\u00a0a\u00a0scre\u2503fromtextual.widgetsimportStatic \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0do\u00a0some\u00a0key\u00a0combinations\u00a0never\u2503classGreetings(App[None]): \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0look\u00a0good\u00a0on\u00a0m\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2514\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0support\u00a0ANSI\u00a0t\u2503\u2502\u00a0\u00a0\u00a0def__init__(self,greeting:str=\"Hello\",to_greet:str=\"World\")->None: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.greeting=greeting \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.to_greet=to_greet \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0super().__init__() \u2503\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2503\u2502\u00a0\u00a0\u00a0defcompose(self)->ComposeResult: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldStatic(f\"{self.greeting},\u00a0{self.to_greet}\") \u2503\u2503 \u2503\u2503 \u2503\u2503Then\u00a0the\u00a0app\u00a0can\u00a0be\u00a0run,\u00a0passing\u00a0in\u00a0various\u00a0arguments;\u00a0for\u00a0example: \u2503\u2503\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0default\u00a0arguments. \u2503\u2503Greetings().run() \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0a\u00a0keyword\u00a0arguyment. \u2503\u2503Greetings(to_greet=\"davep\").run()\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0both\u00a0positional\u00a0arguments. \u2503\u2503Greetings(\"Well\u00a0hello\",\"there\").run() \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2503\u2589\u2503\u258e\u258a \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u00a0F1\u00a0\u00a0Help\u00a0\u00a0F2\u00a0\u00a0About\u00a0\u00a0CTRL+N\u00a0\u00a0Navigation\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0Tip
Join our mailing list if you would like to be the first to hear about our apps.
We haven't stopped developing Textual in that time. Today we released version 0.23.0 which has a really interesting API update I'd like to introduce.
Textual widgets can send messages to each other. To respond to those messages, you implement a message handler with a naming convention. For instance, the Button widget sends a Pressed
event. To handle that event, you implement a method called on_button_pressed
.
Simple enough, but handler methods are called to handle pressed events from all Buttons. To manage multiple buttons you typically had to write a large if
statement to wire up each button to the code it should run. It didn't take many Buttons before the handler became hard to follow.
Version 0.23.0 introduces the @on
decorator which allows you to dispatch events based on the widget that initiated them.
This is probably best explained in code. The following two listings respond to buttons being pressed. The first uses a single message handler, the second uses the decorator approach:
on_decorator01.pyon_decorator02.pyOutput on_decorator01.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (1)!\n\"\"\"Handle all button pressed events.\"\"\"\nif event.button.id == \"bell\":\nself.bell()\nelif event.button.has_class(\"toggle\", \"dark\"):\nself.dark = not self.dark\nelif event.button.id == \"quit\":\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\n@on(Button.Pressed, \"#bell\") # (1)!\ndef play_bell(self):\n\"\"\"Called when the bell button is pressed.\"\"\"\nself.bell()\n@on(Button.Pressed, \".toggle.dark\") # (2)!\ndef toggle_dark(self):\n\"\"\"Called when the 'toggle dark' button is pressed.\"\"\"\nself.dark = not self.dark\n@on(Button.Pressed, \"#quit\") # (3)!\ndef quit(self):\n\"\"\"Called when the quit button is pressed.\"\"\"\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
#
to match the id)OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
The decorator dispatches events based on a CSS selector. This means that you could have a handler per button, or a handler for buttons with a shared class, or parent.
We think this is a very flexible mechanism that will help keep code readable and maintainable.
"},{"location":"blog/2023/05/03/textual-0230-improves-message-handling/#why-didnt-we-do-this-earlier","title":"Why didn't we do this earlier?","text":"It's a reasonable question to ask: why didn't we implement this in an earlier version? We were certainly aware there was a deficiency in the API.
The truth is simply that we didn't have an elegant solution in mind until recently. The @on
decorator is, I believe, an elegant and powerful mechanism for dispatching handlers. It might seem obvious in hindsight, but it took many iterations and brainstorming in the office to come up with it!
If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/","title":"Textual 0.24.0 adds a Select control","text":"Coming just 5 days after the last release, we have version 0.24.0 which we are crowning the King of Textual releases. At least until it is deposed by version 0.25.0.
The highlight of this release is the new Select widget: a very familiar control from the web and desktop worlds. Here's a screenshot and code:
Output (expanded)select_widget.pyselect.cssSelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Select\nLINES = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\"\"\".splitlines()\nclass SelectApp(App):\nCSS_PATH = \"select.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Select((line, line) for line in LINES)\n@on(Select.Changed)\ndef select_changed(self, event: Select.Changed) -> None:\nself.title = str(event.value)\nif __name__ == \"__main__\":\napp = SelectApp()\napp.run()\n
\n
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#new-styles","title":"New styles","text":"This one required new functionality in Textual itself. The \"pull-down\" overlay with options presented a difficulty with the previous API. The overlay needed to appear over any content below it. This is possible (using layers), but there was no simple way of positioning it directly under the parent widget.
We solved this with a new \"overlay\" concept, which can considered a special layer for user interactions like this Select, but also pop-up menus, tooltips, etc. Widgets styled to use the overlay appear in their natural place in the \"document\", but on top of everything else.
A second problem we tackled was ensuring that an overlay widget was never clipped. This was also solved with a new rule called \"constrain\". Applying constrain
to a widget will keep the widget within the bounds of the screen. In the case of Select
, if you expand the options while at the bottom of the screen, then the overlay will be moved up so that you can see all the options.
These new rules are currently undocumented as they are still subject to change, but you can see them in the Select source if you are interested.
In a future release these will be finalized and you can confidently use them in your own projects.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#fixes-for-the-on-decorator","title":"Fixes for the @on decorator","text":"The new @on
decorator is proving popular. To recap, it is a more declarative and finely grained way of dispatching messages. Here's a snippet from the calculator example which uses @on
:
@on(Button.Pressed, \"#plus,#minus,#divide,#multiply\")\ndef pressed_op(self, event: Button.Pressed) -> None:\n\"\"\"Pressed one of the arithmetic operations.\"\"\"\nself.right = Decimal(self.value or \"0\")\nself._do_math()\nassert event.button.id is not None\nself.operator = event.button.id\n
The decorator arranges for the method to be called when any of the four math operation buttons are pressed.
In 0.24.0 we've fixed some missing attributes which prevented the decorator from working with some messages. We've also extended the decorator to use keywords arguments, so it will match attributes other than control
.
There is a surprising number of fixes in this release for just 5 days. See CHANGELOG.md for details.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/","title":"Textual adds Sparklines, Selection list, Input validation, and tool tips","text":"It's been 12 days since the last Textual release, which is longer than our usual release cycle of a week.
We've been a little distracted with our \"dogfood\" projects: Frogmouth and Trogon. Both of which hit 1000 Github stars in 24 hours. We will be maintaining / updating those, but it is business as usual for this Textual release (and it's a big one). We have such sights to show you.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#sparkline-widget","title":"Sparkline widget","text":"A Sparkline is essentially a mini-plot. Just detailed enough to keep an eye on time-series data.
SparklineColorsApp \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582Colors are configurable, and all it takes is a call to set_interval
to make it animate.
Next up is the SelectionList widget. Essentially a scrolling list of checkboxes. Lots of use cases for this one.
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#tooltips","title":"Tooltips","text":"We've added tooltips to Textual widgets.
The API couldn't be simpler: simply assign a string to the tooltip
property on any widget. This string will be displayed after 300ms when you hover over the widget.
As always, you can configure how the tooltips will be displayed with CSS.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#input-updates","title":"Input updates","text":"We have some quality of life improvements for the Input widget.
You can now use a simple declarative API to validating input.
InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258afoo\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e ['Must\u00a0be\u00a0a\u00a0valid\u00a0number.',\u00a0'Value\u00a0is\u00a0not\u00a0even.',\u00a0\"That's\u00a0not\u00a0a\u00a0palindrome\u00a0:/\"]Also in this release is a suggestion API, which will suggest auto completions as you type. Hit right to accept the suggestion.
Here's a screenshot:
FruitsApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258astrawberry\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258eYou could use this API to offer suggestions from a fixed list, or even pull the data from a network request.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#join-us","title":"Join us","text":"Development on Textual is fast. We're very responsive to issues and feature requests.
If you have any suggestions, jump on our Discord server and you may see your feature in the next release!
"},{"location":"blog/2023/07/03/textual-0290-refactors-dev-tools/","title":"Textual 0.29.0 refactors dev tools","text":"It's been a slow week or two at Textualize, with Textual devs taking well-earned annual leave, but we still managed to get a new version out.
Version 0.29.0 has shipped with a number of fixes (see the release notes for details), but I'd like to use this post to explain a change we made to how Textual developer tools are distributed.
Previously if you installed textual[dev]
you would get the Textual dev tools plus the library itself. If you were distributing Textual apps and didn't need the developer tools you could drop the [dev]
.
We did this because the less dependencies a package has, the fewer installation issues you can expect to get in the future. And Textual is surprisingly lean if you only need to run apps, and not build them.
Alas, this wasn't quite as elegant solution as we hoped. The dependencies defined in extras wouldn't install commands, so textual
was bundled with the core library. This meant that if you installed the Textual package without the [dev]
you would still get the textual
command on your path but it wouldn't run.
We solved this by creating two packages: textual
contains the core library (with minimal dependencies) and textual-dev
contains the developer tools. If you are building Textual apps, you should install both as follows:
pip install textual textual-dev\n
That's the only difference. If you run in to any issues feel free to ask on the Discord server!
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/","title":"Textual 0.30.0 adds desktop-style notifications","text":"We have a new release of Textual to talk about, but before that I'd like to cover a little Textual news.
By sheer coincidence we reached 20,000 stars on GitHub today. Now stars don't mean all that much (at least until we can spend them on coffee), but its nice to know that twenty thousand developers thought Textual was interesting enough to hit the \u2605 button. Thank you!
In other news: we moved office. We are now a stone's throw away from Edinburgh Castle. The office is around three times as big as the old place, which means we have room for wide standup desks and dual monitors. But more importantly we have room for new employees. Don't send your CVs just yet, but we hope to grow the team before the end of the year.
Exciting times.
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/#new-release","title":"New Release","text":"And now, for the main feature. Version 0.30 adds a new notification system. Similar to desktop notifications, it displays a small window with a title and message (called a toast) for a pre-defined number of seconds.
Notifications are great for short timely messages to add supplementary information for the user. Here it is in action:
The API is super simple. To display a notification, call notify()
with a message and an optional title.
def on_mount(self) -> None:\nself.notify(\"Hello, from Textual!\", title=\"Welcome\")\n
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/#textualize-video-channel","title":"Textualize Video Channel","text":"In case you missed it; Textualize now has a YouTube channel. Our very own Rodrigo has recorded a video tutorial series on how to build Textual apps. Check it out!
We will be adding more videos in the near future, covering anything from beginner to advanced topics.
Don't worry if you prefer reading to watching videos. We will be adding plenty more content to the Textual docs in the near future. Watch this space.
As always, if you want to discuss anything with the Textual developers, join us on the Discord server.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/","title":"Textual 0.38.0 adds a syntax aware TextArea","text":"This is the second big feature release this month after last week's command palette.
The TextArea has finally landed. I know a lot of folk have been waiting for this one. Textual's TextArea is a fully-featured widget for editing code, with syntax highlighting and line numbers. It is highly configurable, and looks great.
Darren Burns (the author of this widget) has penned a terrific write-up on the TextArea. See Things I learned while building Textual's TextArea for some of the challenges he faced.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#scoped-css","title":"Scoped CSS","text":"Another notable feature added in 0.38.0 is scoped CSS. A common gotcha in building Textual widgets is that you could write CSS that impacted styles outside of that widget.
Consider the following widget:
class MyWidget(Widget):\nDEFAULT_CSS = \"\"\"\n MyWidget {\n height: auto;\n border: magenta;\n }\n Label {\n border: solid green;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"foo\")\nyield Label(\"bar\")\n
The author has intended to style the labels in that widget by adding a green border. This does work for the widget in question, but (prior to 0.38.0) the Label
rule would style all Labels (including any outside of the widget) \u2014 which was probably not intended.
With version 0.38.0, the CSS is scoped so that only the widget's labels will be styled. This is almost always what you want, which is why it is enabled by default. If you do want to style something outside of the widget you can set SCOPED_CSS=False
(as a classvar).
We've also made a slight quality of life improvement to the CSS, by adding :light
and :dark
pseudo selectors. This allows you to change styles depending on whether you have dark mode enabled or not.
This was possible before, just a little verbose. Here's how you would do it in 0.37.0:
App.-dark-mode MyWidget Label {\n...\n}\n
In 0.38.0 it's a little more concise and readable:
MyWidget:dark Label {\n...\n}\n
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#testing-guide","title":"Testing guide","text":"Not strictly part of the release, but we've added a guide on testing Textual apps.
As you may know, we are on a mission to make TUIs a serious proposition for critical apps, which makes testing essential. We've extracted and documented our internal testing tools, including our snapshot tests pytest plugin pytest-textual-snapshot.
This gives devs powerful tools to ensure the quality of their apps. Let us know your thoughts on that!
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#release-notes","title":"Release notes","text":"See the release page for the full details on this release.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#whats-next","title":"What's next?","text":"There's lots of features planned over the next few months. One feature I am particularly excited by is a widget to generate plots by wrapping the awesome Plotext library. Check out some early work on this feature:
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#join-us","title":"Join us","text":"Join our Discord server if you want to discuss Textual with the Textualize devs, or the community.
"},{"location":"blog/2022/11/08/version-040/","title":"Version 0.4.0","text":"We've released version 0.4.0 of Textual.
As this is the first post tagged with release
let me first explain where the blog fits in with releases. We plan on doing a post for every note-worthy release. Which likely means all but the most trivial updates (typos just aren't that interesting). Blog posts will be supplementary to release notes which you will find on the Textual repository.
Blog posts will give a little more background for the highlights in a release, and a rationale for changes and new additions. We embrace building in public, which means that we would like you to be as up-to-date with new developments as if you were sitting in our office. It's a small office, and you might not be a fan of the Scottish weather (it's dreich), but you can at least be here virtually.
Release 0.4.0 follows 0.3.0, released on October 31st. Here are the highlights of the update.
"},{"location":"blog/2022/11/08/version-040/#updated-mount-method","title":"Updated Mount Method","text":"The mount method has seen some work. We've dropped the ability to assign an id
via keyword attributes, which wasn't terribly useful. Now, an id
must be assigned via the constructor.
The mount method has also grown before
and after
parameters which tell Textual where to add a new Widget (the default was to add it to the end). Here are a few examples:
# Mount at the start\nself.mount(Button(id=\"Buy Coffee\"), before=0)\n# Mount after a selector\nself.mount(Static(\"Password is incorrect\"), after=\"Dialog Input.-error\")\n# Mount after a specific widget\ntweet = self.query_one(\"Tweet\")\nself.mount(Static(\"Consider switching to Mastodon\"), after=tweet)\n
Textual needs much of the same kind of operations as the JS API exposed by the browser. But we are determined to make this way more intuitive. The new mount method is a step towards that.
"},{"location":"blog/2022/11/08/version-040/#faster-updates","title":"Faster Updates","text":"Textual now writes to stdout in a thread. The upshot of this is that Textual can work on the next update before the terminal has displayed the previous frame.
This means smoother updates all round! You may notice this when scrolling and animating, but even if you don't, you will have more CPU cycles to play with in your Textual app.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2ca3Oa3Fx1MDAxNsff91NkPG8r3fdLZ86cybW5t401SXPmmVx1MDAwZUFUXCKK4WIunX73Z0FsXHUwMDAwI2pcdTAwMTKDTPLCKlx1MDAxYvZebNaP/1prQ39/WFurhXdDu/Z5rWbfWqbrtHzzpvYx3j6y/cDxXHUwMDA20ESS34FcdTAwMTf5VrJnN1xmh8HnT5/6pt+zw6FrWrYxcoLIdIMwajmeYXn9T05o94P/xZ/HZt/+79Drt0LfSFx1MDAwN6nbLSf0/IexbNfu24MwgN7/XHUwMDBmv9fWfiefXHUwMDE563zbXG7NQce1k1x1MDAwM5Km1ECK1eTWY2+QXHUwMDE4SzDDVFx1MDAxMk7Q41x1MDAxZU6wXHUwMDA144V2XHUwMDBimttgs522xJtq55fW8dVgOzo971xuWd857UaDzct02Lbjuo3wzn2YXG7T6kZ+xqgg9L2efea0wi6044ntj8dcdTAwMDVcdTAwMWXMQnqU70Wd7sBcdTAwMGWC3DHe0LSc8C7ehlLzXHUwMDFmZuHzWrrlXHUwMDE2filuKClcdTAwMTVcdTAwMTVCYc6kXHUwMDE0j63J8ZxcdTAwMWGEU41cdTAwMDThXGL+Ju3a9FxcuFx1MDAxNGDXf6hgbUumll2aVq9cdTAwMDPmXHJa6T6EqEtbKJXudfP3fJkyiJSUUclcdTAwMWZcdTAwMWK7ttPphtDKkKGlXCJgI5KYXHUwMDExRVMr7ORqxC2USMZcdTAwMWVcdTAwMWLioYd7rcQx/pmczK7pXHUwMDBmx5NWXHUwMDBi4lx1MDAxZlx1MDAxObNji7cnvSrrWZlcdTAwMGJ+2T5qXHUwMDFjOMdcciFcdTAwMWPinp9uXHUwMDFmXHUwMDA218HPx75yblx1MDAxONq3Ye2x4c/H6d3m9v646IBpt+Nv6Vx0R8OW+eCwWFxiqWGKJaY09Vx1MDAwMNdcdTAwMTn0oHFcdTAwMTC5brrNs3qpj3/I2PtMuDQthotKXHUwMDA03pa5YPPgut9ccr5aR0PyvVlf92+PLbp/9l1UXHUwMDFkrjp4q0GRXHUwMDE2XHUwMDA0cy01opzk8FwiXGJcdTAwMWKEUnBuJqTChFx1MDAxN+LF27Rlsdl42YJphabhRVxiM4RmkjMmgFx1MDAxZq5ewFx1MDAxONxcdTAwMGaVwHBcdTAwMDaqXFzIml/I/p55MrpAw9b1tT/sbl37J29cdNn0XHUwMDAxn1x1MDAwM1x1MDAxOUwkUrxcdTAwMTTIXHUwMDE4xkWQYSm4RIhxsTBkw1x1MDAxZt++7l1cdTAwMWW1aGRcdTAwMWTgId9wej/vulWHjFx0XHUwMDBljGFcdTAwMDJcdTAwMTOviKSgXCJcdTAwMTNcdTAwMTImXGbMlVx1MDAxNFx1MDAxOCeuzYohW6mGYaQg2lx1MDAxMFx1MDAxNJNy+dq17qXd22t2b2lvyLqX9uHoXHUwMDFifku+plx1MDAwZlhRXHUwMDExY1pcdTAwMTbxJZhcIkhgtLiG3e2qKPq1dVx1MDAxZlx1MDAxYzXNkfn91t05cdpVx4tQaVx1MDAwMFVcdTAwMTju/pJzzSfo0spA0FwiXHUwMDE555RLnLnZVE/CXGLinCnKsChcdTAwMTexg63v6Ng+6nxcdTAwMGbCbvO62d7mqH/2lohNXHUwMDFmcHWI5ezMJoikXGIuTFx1MDAxOIeQnujFxWt2oFBRuupCXHUwMDE4TIBzM1xuai2QyONFkDZcYokjyPiCKKqXXHUwMDExIT6FKyOZjzRlg9ExPlx1MDAwMJVcdTAwMTBYv4VCLTOaSi+5N1xiXHUwMDFizn2ScaDc1lx1MDAxZLPvuHe5q5b4aOxGyfjZeVxmbFx1MDAxODNxSpXbe911OrFcdTAwMTfXLDhcdTAwMGLbzzl46Fim+7hD32m1svpigVx0JvTp7y2S23i+03FcdTAwMDam+yNv4YuIK6x3MKmpQGRxMZud/FZcdTAwMTQ3TJmBsODgUFxcQFx1MDAwMqry+Vx1MDAxOFx1MDAxNpCPMVxu07BENXuKWyZcdTAwMGKcgVx1MDAxYqaEXHUwMDBirbF8XHUwMDAztVrmnf91uJ35Tsm0zSnTTdL2YOCLYCvOzTjHXHUwMDFhJluphXGbXHUwMDFkQ1RcdTAwMTQ3iqnBXHUwMDE1Y5prXHUwMDA0mZGarH5cYlx1MDAwM0muYFx1MDAxZYA1ls18ViFuXHUwMDEwNCrMdbniVjJtK1x1MDAxMLc5SU8p4iYgZ6BU4cWDydlZcUVx44BcdTAwMWKjWsPpclx1MDAxOVdCJsSNXHUwMDE5XHUwMDE4JE/qh1xmSqo3wW1BcVx1MDAwMyvB0pJDyXevbXNcbngv0LbZ1ZHMZE4gJ1x0QpqpZ1x1MDAwNJT7I3SAdjZa/kE/XHUwMDFhnUdHfp1wXXXkMKKGXHUwMDAy5WJcYkI1hjjOIcewNFx1MDAwNChcdTAwMWZCY5ErJG7FpUfQZ1x1MDAwMVx1MDAwMW/JdVx1MDAxMeF87TaO2DpB9+bJSe8wwvhm9Jq6yFx1MDAxYnU7r9wyfcC02/G3alQ0OS1cXJbjRGhCsmvAc2Wy8+XQPJDfnOZF65c2zZsrc++u6szWMUlcdTAwMTa9OTi8oFwiXHUwMDBlTfPUXG6QUak4pH/JqncxtasvaWKCQEhFdpGnXHUwMDE0dCN2u3s9ijbcXHUwMDAzT9zwweHVcc/uvVx1MDAxZd2ldztcdTAwMGbd6Vx1MDAwM1ZcdTAwMTVdrYvQJZpoXHIh7uJye9A7adCGa91ftK9+7Gy2T5v3vdOqo0spMTAnXHUwMDAwLlx1MDAxMVxmXCJZzfLkYmFcYlx1MDAxZcdcdTAwMWRcdTAwMGbAZG51lVx1MDAxMlxcolC85l82tPvWyW5k4u0uP6xcdTAwMWaMblx1MDAwNlx1MDAwM1Cr6PXQLr3bedBOXHUwMDFmsKLQSsSLoMVcdTAwMWHFSal+XHUwMDA2tdH2VXR4tyt/ycPG9lx1MDAxNW9IsnfxperUYsRcZsqFwpJSXHUwMDExL2boPLWgt5CfYyrwPGorILiSY1x1MDAxNa9Olczu1sXXy0NN7ky457Gj4O6871xmL1/P7tK7ncfu9Fx1MDAwMVfHbmHtVlx1MDAxND9YoyA4XHUwMDA0aJ+R286OayqKbZ0jXHUwMDAzUYRcdTAwMTVcdTAwMDI448WSiYdDmcRcdTAwMDZcdTAwMTNSUPqQ3L7Rasli9du4sqWIxO+7orSC+u2cXHUwMDE0b5n1W4xcdTAwMGLjWy21ZOCKiyM3u1xuUFHkMMeGjlx1MDAxN/0gxGVxOWmCOEhcXLlcdTAwMDYh1VgnXG62yvXJeMVcdTAwMDRcdKrouyau/Fx1MDAxYe6cOuhcdTAwMTLXJ8lcZt5cdTAwMTSDm756xuM3syOJqvKmkEFcdTAwMTDDXHUwMDFh4jnIx8hEOlx0XHUwMDAyJyghT+PF8vVNQ85cdTAwMGKRqX7XXHUwMDBmXHUwMDAzrEDe5iRUS5U3XZxcdFIpiFr8VaPZKXdFYaMxbCBtTGGqp1x1MDAxNG/i7IwhJMRY2+Sb4Lbg+iRcdTAwMDfkJc247jvErXxtm1N0XFyitlx1MDAxNT5cdTAwMGKAXHUwMDA1XCKCaJEpy8yj7eJLdK+Q02tcXHX3XHUwMDFhO1dnW0PvdLfqtGFFXGZcdTAwMTRnZVx1MDAwNCdcdTAwMGZcdTAwMDPofCippVx1MDAwMUmdYip+qlx1MDAxM9LZV7FWXFwoXHUwMDE1U2osT2mjXHUwMDA0bn/wUX4kSXRptG3Ybc9/XHUwMDFlbq7dXHUwMDBlZ8BcdTAwMTZ6w1wi0nJnMYnV2JJcdTAwMTdxVfg6XHUwMDA0ZkzHIUqmejePK9usb1x1MDAwNH3xrd+MzlG9j++3tjevK8+VIIZcdTAwMDBqXG64ojxeoVx1MDAxMFxuKVx1MDAxND9BOut1o9eRlcn9ZpAlKKVSKVa+jpVI1no7J0qrXHUwMDAz68GQmVxcmb7v3UxNxlDhujxcdTAwMDQjSHKK9OJoscb53k7n+HJ9py72/P3dU7y19cKHaSafynzDV424QWAvTjWjkIzhiXSMXCJlcFx1MDAwMOLvXHUwMDAzbK9Lx4rJgsFccqD872NymE5J0LChMFx1MDAwNKtiSuSIOFFcdTAwMWGJXHUwMDE3vIueWFg2cUFo+uGGM2g5g87kIfagVdDimkG46fX7TlxiZnzznEE4uUfS73rs7V3bfEJcdTAwMGX0nG2rhb4zsYI2jDvNL4uk39ZSz0l+PH7/5+PUvVx0llx1MDAwNlI4juhxPreI/+rI0EhcdTAwMDGAmPB5PVx1MDAxNTtH0lPqXHUwMDE3aUdcdTAwMWay/z5Xb0XxS/SIKU3gfNLLPrdGc/Szuf+r88VcdTAwMTXO6c9r+/asqe2rqt9cdTAwMTXqXFxcdTAwMTlcdTAwMTBcdTAwMWRcblx1MDAxZf+PXHUwMDFjQiOevytAKG9gzcZcdTAwMDXRV69CzLgtLCS4mFx0grR8529t/HD6lYhkXHUwMDEzO1x1MDAxZbj6MEa2Zlx1MDAwZYeNMC7SfFx1MDAxZVNcdTAwMDZcdTAwMTfAaY1PMe2tNnLsm40pXHUwMDFl0E7+4l5cdTAwMTNWYyrsePp///nw51+TJY25In0= UpdateWriteUpdateWriteUpdateWriteUpdateWriteBeforeAfterTime"},{"location":"blog/2022/11/08/version-040/#multiple-css-paths","title":"Multiple CSS Paths","text":"Up to version 0.3.0, Textual would only read a single CSS file set in the CSS_PATH
class variable. You can now supply a list of paths if you have more than one CSS file.
This change was prompted by tuilwindcss which brings a TailwindCSS like approach to building Textual Widgets. Also check out calmcode.io by the same author, which is an amazing resource.
"},{"location":"blog/2022/12/11/version-060/","title":"Textual 0.6.0 adds a treemendous new widget","text":"A new release of Textual lands 3 weeks after the previous release -- and it's a big one.
Information
If you're new here, Textual is TUI framework for Python.
"},{"location":"blog/2022/12/11/version-060/#tree-control","title":"Tree Control","text":"The headline feature of version 0.6.0 is a new tree control built from the ground-up. The previous Tree control suffered from an overly complex API and wasn't scalable (scrolling slowed down with 1000s of nodes).
This new version has a simpler API and is highly scalable (no slowdown with larger trees). There are also a number of visual enhancements in this version.
Here's a very simple example:
Outputtree.pyTreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
from textual.app import App, ComposeResult\nfrom textual.widgets import Tree\nclass TreeApp(App):\ndef compose(self) -> ComposeResult:\ntree: Tree[dict] = Tree(\"Dune\")\ntree.root.expand()\ncharacters = tree.root.add(\"Characters\", expand=True)\ncharacters.add_leaf(\"Paul\")\ncharacters.add_leaf(\"Jessica\")\ncharacters.add_leaf(\"Chani\")\nyield tree\nif __name__ == \"__main__\":\napp = TreeApp()\napp.run()\n
Here's the tree control being used to navigate some JSON (json_tree.py in the examples directory).
I'm biased of course, but I think this terminal based tree control is more usable (and even prettier) than just about anything I've seen on the web or desktop. So much of computing tends to organize itself in to a tree that I think this widget will find a lot of uses.
The Tree control forms the foundation of the DirectoryTree widget, which has also been updated. Here it is used in the code_browser.py example:
"},{"location":"blog/2022/12/11/version-060/#list-view","title":"List View","text":"We have a new ListView control to navigate and select items in a list. Items can be widgets themselves, which makes this a great platform for building more sophisticated controls.
Outputlist_view.pylist_view.cssListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
\n
"},{"location":"blog/2022/12/11/version-060/#placeholder","title":"Placeholder","text":"The Placeholder widget was broken since the big CSS update. We've brought it back and given it a bit of a polish.
Use this widget in place of custom widgets you have yet to build when designing your UI. The colors are automatically cycled to differentiate one placeholder from the next. You can click a placeholder to cycle between its ID, size, and lorem ipsum text.
Outputplaceholder.pyplaceholder.cssPlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3 #p5Placeholder Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0 Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0 33\u00a0x\u00a011nec\u00a0libero\u00a0quis\u00a0gravida.\u00a034\u00a0x\u00a011 Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0 Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0 sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0 amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0 adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0 imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibus50\u00a0x\u00a011 sit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sed lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapien congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0 vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0 mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis. Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0 adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0 imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibusimperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibus sit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedsit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sed lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapienlacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapien congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0 vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0 mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis.mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis.
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass PlaceholderApp(App):\nCSS_PATH = \"placeholder.tcss\"\ndef compose(self) -> ComposeResult:\nyield VerticalScroll(\nContainer(\nPlaceholder(\"This is a custom label for p1.\", id=\"p1\"),\nPlaceholder(\"Placeholder p2 here!\", id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(),\nHorizontal(\nPlaceholder(variant=\"size\", id=\"col1\"),\nPlaceholder(variant=\"text\", id=\"col2\"),\nPlaceholder(variant=\"size\", id=\"col3\"),\nid=\"c1\",\n),\nid=\"bot\",\n),\nContainer(\nPlaceholder(variant=\"text\", id=\"left\"),\nPlaceholder(variant=\"size\", id=\"topright\"),\nPlaceholder(variant=\"text\", id=\"botright\"),\nid=\"top\",\n),\nid=\"content\",\n)\nif __name__ == \"__main__\":\napp = PlaceholderApp()\napp.run()\n
\n
"},{"location":"blog/2022/12/11/version-060/#fixes","title":"Fixes","text":"As always, there are a number of fixes in this release. Mostly related to layout. See CHANGELOG.md for the details.
"},{"location":"blog/2022/12/11/version-060/#whats-next","title":"What's next?","text":"The next release will focus on pain points we discovered while in a dog-fooding phase (see the DevLog for details on what Textual devs have been building).
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/","title":"Textual 0.37.0 adds a command palette","text":"Textual version 0.37.0 has landed! The highlight of this release is the new command palette.
A command palette gives users quick access to features in your app. If you hit ctrl+backslash in a Textual app, it will bring up the command palette where you can start typing commands. The commands are matched with a fuzzy search, so you only need to type two or three characters to get to any command.
Here's a video of it in action:
Adding your own commands to the command palette is a piece of cake. Here's the (command) Provider class used in the example above:
class ColorCommands(Provider):\n\"\"\"A command provider to select colors.\"\"\"\nasync def search(self, query: str) -> Hits:\n\"\"\"Called for each key.\"\"\"\nmatcher = self.matcher(query)\nfor color in COLOR_NAME_TO_RGB.keys():\nscore = matcher.match(color)\nif score > 0:\nyield Hit(\nscore,\nmatcher.highlight(color),\npartial(self.app.post_message, SwitchColor(color)),\n)\n
And here is how you add a provider to your app:
class ColorApp(App):\n\"\"\"Experiment with the command palette.\"\"\"\nCOMMANDS = App.COMMANDS | {ColorCommands}\n
We're excited about this feature because it is a step towards bringing a common user interface to Textual apps.
Quote
It's a Textual app. I know this.
\u2014 You, maybe.
The goal is to be able to build apps that may look quite different, but take no time to learn, because once you learn how to use one Textual app, you can use them all.
See the Guide for details on how to work with the command palette.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#what-else","title":"What else?","text":"Also in 0.37.0 we have a new Collapsible widget, which is a great way of adding content while avoiding a cluttered screen.
And of course, bug fixes and other updates. See the release page for the full details.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#whats-next","title":"What's next?","text":"Coming very soon, is a new TextEditor widget. This is a super powerful widget to enter arbitrary text, with beautiful syntax highlighting for a number of languages. We're expecting that to land next week. Watch this space, or join the Discord server if you want to be the first to try it out.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#join-us","title":"Join us","text":"Join our Discord server if you want to discuss Textual with the Textualize devs, or the community.
"},{"location":"blog/2022/12/07/letting-your-cook-multitask-while-bringing-water-to-a-boil/","title":"Letting your cook multitask while bringing water to a boil","text":"Whenever you are cooking a time-consuming meal, you want to multitask as much as possible. For example, you do not want to stand still while you wait for a pot of water to start boiling. Similarly, you want your applications to remain responsive (i.e., you want the cook to \u201cmultitask\u201d) while they do some time-consuming operations in the background (e.g., while the water heats up).
The animation below shows an example of an application that remains responsive (colours on the left still change on click) even while doing a bunch of time-consuming operations (shown on the right).
In this blog post, I will teach you how to multitask like a good cook.
"},{"location":"blog/2022/12/07/letting-your-cook-multitask-while-bringing-water-to-a-boil/#wasting-time-staring-at-pots","title":"Wasting time staring at pots","text":"There is no point in me presenting a solution to a problem if you don't understand the problem I am trying to solve. Suppose we have an application that needs to display a huge amount of data that needs to be read and parsed from a file. The first time I had to do something like this, I ended up writing an application that \u201cblocked\u201d. This means that while the application was reading and parsing the data, nothing else worked.
To exemplify this type of scenario, I created a simple application that spends five seconds preparing some data. After the data is ready, we display a Label
on the right that says that the data has been loaded. On the left, the app has a big rectangle (a custom widget called ColourChanger
) that you can click and that changes background colours randomly.
When you start the application, you can click the rectangle on the left to change the background colour of the ColourChanger
, as the animation below shows:
However, as soon as you press l
to trigger the data loading process, clicking the ColourChanger
widget doesn't do anything. The app doesn't respond because it is busy working on the data. This is the code of the app so you can try it yourself:
import time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget): # (1)!\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")] # (2)!\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None: # (3)!\ntime.sleep(5) # (4)!\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\"))\nMyApp().run()\n
ColourChanger
changes colours, randomly, when clicked.l
that runs an action that we know will take some time (for example, reading and parsing a huge file).action_load
is responsible for starting our time-consuming task and then reporting back.I think it is easy to understand why the widget ColourChanger
stops working when we hit the time.sleep
call if we consider the cooking analogy I have written about before in my blog. In short, Python behaves like a lone cook in a kitchen:
Things like \u201cchopping up vegetables\u201d and \u201cseasoning a salad\u201d are blocking, i.e., they need the cook's time and attention. In the app that I showed above, the call to time.sleep
is blocking, so the cook can't go and do anything else until the time interval elapses.
It makes a lot of sense to think that a cook would multitask in their kitchen, but Python isn't like a smart cook. Python is like a very dumb cook who only ever does one thing at a time and waits until each thing is completely done before doing the next thing. So, by default, Python would act like a cook who fills up a pan with water, starts heating the water, and then stands there staring at the water until it starts boiling instead of doing something else. It is by using the module asyncio
from the standard library that our cook learns to do other tasks while awaiting the completion of the things they already started doing.
Textual is an async framework, which means it knows how to interoperate with the module asyncio
and this will be the solution to our problem. By using asyncio
with the tasks we want to run in the background, we will let the application remain responsive while we load and parse the data we need, or while we crunch the numbers we need to crunch, or while we connect to some slow API over the Internet, or whatever it is you want to do.
The module asyncio
uses the keyword async
to know which functions can be run asynchronously. In other words, you use the keyword async
to identify functions that contain tasks that would otherwise force the cook to waste time. (Functions with the keyword async
are called coroutines.)
The module asyncio
also introduces a function asyncio.create_task
that you can use to run coroutines concurrently. So, if we create a coroutine that is in charge of doing the time-consuming operation and then run it with asyncio.create_task
, we are well on our way to fix our issues.
However, the keyword async
and asyncio.create_task
alone aren't enough. Consider this modification of the previous app, where the method action_load
now uses asyncio.create_task
to run a coroutine who does the sleeping:
import asyncio\nimport time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget):\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")]\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None: # (1)!\nasyncio.create_task(self._do_long_operation()) # (2)!\nasync def _do_long_operation(self) -> None: # (3)!\ntime.sleep(5)\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\"))\nMyApp().run()\n
action_load
now defers the heavy lifting to another method we created.asyncio.create_task
because it is a coroutine._do_long_operation
has the keyword async
, so it is a coroutine.This modified app also works but it suffers from the same issue as the one before! The keyword async
tells Python that there will be things inside that function that can be awaited by the cook. That is, the function will do some time-consuming operation that doesn't require the cook's attention. However, we need to tell Python which time-consuming operation doesn't require the cook's attention, i.e., which time-consuming operation can be awaited, with the keyword await
.
Whenever we want to use the keyword await
, we need to do it with objects that are compatible with it. For many things, that means using specialised libraries:
time.sleep
, one can use await asyncio.sleep
;requests
to make Internet requests, use aiohttp
; oraiofiles
.To fix the last example application, all we need to do is replace the call to time.sleep
with a call to asyncio.sleep
and then use the keyword await
to signal Python that we can be doing something else while we sleep. The animation below shows that we can still change colours while the application is completing the time-consuming operation.
import asyncio\nimport time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget):\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")]\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None:\nasyncio.create_task(self._do_long_operation())\nasync def _do_long_operation(self) -> None:\nself.query_one(\"#log\").mount(Label(\"Starting \u23f3\")) # (1)!\nawait asyncio.sleep(5) # (2)!\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\")) # (3)!\nMyApp().run()\n
await
the time-consuming operation so that the application remains responsive.Because our time-consuming operation runs concurrently, everything else in the application still works while we await for the time-consuming operation to finish. In particular, we can keep changing colours (like the animation above showed) but we can also keep activating the binding with the key l
to start multiple instances of the same time-consuming operation! The animation below shows just this:
Warning
The animation GIFs in this blog post show low-quality colours in an attempt to reduce the size of the media files you have to download to be able to read this blog post. If you run Textual locally you will see beautiful colours \u2728
"},{"location":"blog/2023/07/27/using-rich-inspect-to-interrogate-python-objects/","title":"Using Rich Inspect to interrogate Python objects","text":"The Rich library has a few functions that are admittedly a little out of scope for a terminal color library. One such function is inspect
which is so useful you may want to pip install rich
just for this feature.
The easiest way to describe inspect
is that it is Python's builtin help()
but easier on the eye (and with a few more features). If you invoke it with any object, inspect
will display a nicely formatted report on that object \u2014 which makes it great for interrogating objects from the REPL. Here's an example:
>>> from rich import inspect\n>>> text_file = open(\"foo.txt\", \"w\")\n>>> inspect(text_file)\n
Here we're inspecting a file object, but it could be literally anything. You will see the following output in the terminal:
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256fBy default, inspect
will generate a data-oriented summary with a text representation of the object and its data attributes. You can also add methods=True
to show all the methods in the public API. Here's an example:
>>> inspect(text_file, methods=True)\n
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2502close\u00a0=def\u00a0close():Flush\u00a0and\u00a0close\u00a0the\u00a0IO\u00a0object.\u2502 \u2502detach\u00a0=def\u00a0detach():Separate\u00a0the\u00a0underlying\u00a0buffer\u00a0from\u00a0the\u00a0TextIOBase\u00a0and\u00a0return\u00a0it.\u2502 \u2502fileno\u00a0=def\u00a0fileno():Returns\u00a0underlying\u00a0file\u00a0descriptor\u00a0if\u00a0one\u00a0exists.\u2502 \u2502flush\u00a0=def\u00a0flush():Flush\u00a0write\u00a0buffers,\u00a0if\u00a0applicable.\u2502 \u2502isatty\u00a0=def\u00a0isatty():Return\u00a0whether\u00a0this\u00a0is\u00a0an\u00a0'interactive'\u00a0stream.\u2502 \u2502read\u00a0=def\u00a0read(size=-1,\u00a0/):Read\u00a0at\u00a0most\u00a0n\u00a0characters\u00a0from\u00a0stream.\u2502 \u2502readable\u00a0=def\u00a0readable():Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0reading.\u2502 \u2502readline\u00a0=def\u00a0readline(size=-1,\u00a0/):Read\u00a0until\u00a0newline\u00a0or\u00a0EOF.\u2502 \u2502readlines\u00a0=def\u00a0readlines(hint=-1,\u00a0/):Return\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0from\u00a0the\u00a0stream.\u2502 \u2502reconfigure\u00a0=def\u00a0reconfigure(*,\u00a0encoding=None,\u00a0errors=None,\u00a0newline=None,\u00a0line_buffering=None,\u00a0\u2502 \u2502write_through=None):Reconfigure\u00a0the\u00a0text\u00a0stream\u00a0with\u00a0new\u00a0parameters.\u2502 \u2502seek\u00a0=def\u00a0seek(cookie,\u00a0whence=0,\u00a0/):Change\u00a0stream\u00a0position.\u2502 \u2502seekable\u00a0=def\u00a0seekable():Return\u00a0whether\u00a0object\u00a0supports\u00a0random\u00a0access.\u2502 \u2502tell\u00a0=def\u00a0tell():Return\u00a0current\u00a0stream\u00a0position.\u2502 \u2502truncate\u00a0=def\u00a0truncate(pos=None,\u00a0/):Truncate\u00a0file\u00a0to\u00a0size\u00a0bytes.\u2502 \u2502writable\u00a0=def\u00a0writable():Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0writing.\u2502 \u2502write\u00a0=def\u00a0write(text,\u00a0/):\u2502 \u2502Write\u00a0string\u00a0to\u00a0stream.\u2502 \u2502Returns\u00a0the\u00a0number\u00a0of\u00a0characters\u00a0written\u00a0(which\u00a0is\u00a0always\u00a0equal\u00a0to\u2502 \u2502the\u00a0length\u00a0of\u00a0the\u00a0string).\u2502 \u2502writelines\u00a0=def\u00a0writelines(lines,\u00a0/):Write\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0to\u00a0stream.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f The documentation is summarized by default to avoid generating verbose reports. If you want to see the full unabbreviated help you can add help=True
:
>>> inspect(text_file, methods=True, help=True)\n
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502encoding\u00a0gives\u00a0the\u00a0name\u00a0of\u00a0the\u00a0encoding\u00a0that\u00a0the\u00a0stream\u00a0will\u00a0be\u2502 \u2502decoded\u00a0or\u00a0encoded\u00a0with.\u00a0It\u00a0defaults\u00a0to\u00a0locale.getencoding().\u2502 \u2502\u2502 \u2502errors\u00a0determines\u00a0the\u00a0strictness\u00a0of\u00a0encoding\u00a0and\u00a0decoding\u00a0(see\u2502 \u2502help(codecs.Codec)\u00a0or\u00a0the\u00a0documentation\u00a0for\u00a0codecs.register)\u00a0and\u2502 \u2502defaults\u00a0to\u00a0\"strict\".\u2502 \u2502\u2502 \u2502newline\u00a0controls\u00a0how\u00a0line\u00a0endings\u00a0are\u00a0handled.\u00a0It\u00a0can\u00a0be\u00a0None,\u00a0'',\u2502 \u2502'\\n',\u00a0'\\r',\u00a0and\u00a0'\\r\\n'.\u00a0\u00a0It\u00a0works\u00a0as\u00a0follows:\u2502 \u2502\u2502 \u2502*\u00a0On\u00a0input,\u00a0if\u00a0newline\u00a0is\u00a0None,\u00a0universal\u00a0newlines\u00a0mode\u00a0is\u2502 \u2502\u00a0\u00a0enabled.\u00a0Lines\u00a0in\u00a0the\u00a0input\u00a0can\u00a0end\u00a0in\u00a0'\\n',\u00a0'\\r',\u00a0or\u00a0'\\r\\n',\u00a0and\u2502 \u2502\u00a0\u00a0these\u00a0are\u00a0translated\u00a0into\u00a0'\\n'\u00a0before\u00a0being\u00a0returned\u00a0to\u00a0the\u2502 \u2502\u00a0\u00a0caller.\u00a0If\u00a0it\u00a0is\u00a0'',\u00a0universal\u00a0newline\u00a0mode\u00a0is\u00a0enabled,\u00a0but\u00a0line\u2502 \u2502\u00a0\u00a0endings\u00a0are\u00a0returned\u00a0to\u00a0the\u00a0caller\u00a0untranslated.\u00a0If\u00a0it\u00a0has\u00a0any\u00a0of\u2502 \u2502\u00a0\u00a0the\u00a0other\u00a0legal\u00a0values,\u00a0input\u00a0lines\u00a0are\u00a0only\u00a0terminated\u00a0by\u00a0the\u00a0given\u2502 \u2502\u00a0\u00a0string,\u00a0and\u00a0the\u00a0line\u00a0ending\u00a0is\u00a0returned\u00a0to\u00a0the\u00a0caller\u00a0untranslated.\u2502 \u2502\u2502 \u2502*\u00a0On\u00a0output,\u00a0if\u00a0newline\u00a0is\u00a0None,\u00a0any\u00a0'\\n'\u00a0characters\u00a0written\u00a0are\u2502 \u2502\u00a0\u00a0translated\u00a0to\u00a0the\u00a0system\u00a0default\u00a0line\u00a0separator,\u00a0os.linesep.\u00a0If\u2502 \u2502\u00a0\u00a0newline\u00a0is\u00a0''\u00a0or\u00a0'\\n',\u00a0no\u00a0translation\u00a0takes\u00a0place.\u00a0If\u00a0newline\u00a0is\u00a0any\u2502 \u2502\u00a0\u00a0of\u00a0the\u00a0other\u00a0legal\u00a0values,\u00a0any\u00a0'\\n'\u00a0characters\u00a0written\u00a0are\u00a0translated\u2502 \u2502\u00a0\u00a0to\u00a0the\u00a0given\u00a0string.\u2502 \u2502\u2502 \u2502If\u00a0line_buffering\u00a0is\u00a0True,\u00a0a\u00a0call\u00a0to\u00a0flush\u00a0is\u00a0implied\u00a0when\u00a0a\u00a0call\u00a0to\u2502 \u2502write\u00a0contains\u00a0a\u00a0newline\u00a0character.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2502close\u00a0=def\u00a0close():\u2502 \u2502Flush\u00a0and\u00a0close\u00a0the\u00a0IO\u00a0object.\u2502 \u2502\u2502 \u2502This\u00a0method\u00a0has\u00a0no\u00a0effect\u00a0if\u00a0the\u00a0file\u00a0is\u00a0already\u00a0closed.\u2502 \u2502detach\u00a0=def\u00a0detach():\u2502 \u2502Separate\u00a0the\u00a0underlying\u00a0buffer\u00a0from\u00a0the\u00a0TextIOBase\u00a0and\u00a0return\u00a0it.\u2502 \u2502\u2502 \u2502After\u00a0the\u00a0underlying\u00a0buffer\u00a0has\u00a0been\u00a0detached,\u00a0the\u00a0TextIO\u00a0is\u00a0in\u00a0an\u2502 \u2502unusable\u00a0state.\u2502 \u2502fileno\u00a0=def\u00a0fileno():\u2502 \u2502Returns\u00a0underlying\u00a0file\u00a0descriptor\u00a0if\u00a0one\u00a0exists.\u2502 \u2502\u2502 \u2502OSError\u00a0is\u00a0raised\u00a0if\u00a0the\u00a0IO\u00a0object\u00a0does\u00a0not\u00a0use\u00a0a\u00a0file\u00a0descriptor.\u2502 \u2502flush\u00a0=def\u00a0flush():\u2502 \u2502Flush\u00a0write\u00a0buffers,\u00a0if\u00a0applicable.\u2502 \u2502\u2502 \u2502This\u00a0is\u00a0not\u00a0implemented\u00a0for\u00a0read-only\u00a0and\u00a0non-blocking\u00a0streams.\u2502 \u2502isatty\u00a0=def\u00a0isatty():\u2502 \u2502Return\u00a0whether\u00a0this\u00a0is\u00a0an\u00a0'interactive'\u00a0stream.\u2502 \u2502\u2502 \u2502Return\u00a0False\u00a0if\u00a0it\u00a0can't\u00a0be\u00a0determined.\u2502 \u2502read\u00a0=def\u00a0read(size=-1,\u00a0/):\u2502 \u2502Read\u00a0at\u00a0most\u00a0n\u00a0characters\u00a0from\u00a0stream.\u2502 \u2502\u2502 \u2502Read\u00a0from\u00a0underlying\u00a0buffer\u00a0until\u00a0we\u00a0have\u00a0n\u00a0characters\u00a0or\u00a0we\u00a0hit\u00a0EOF.\u2502 \u2502If\u00a0n\u00a0is\u00a0negative\u00a0or\u00a0omitted,\u00a0read\u00a0until\u00a0EOF.\u2502 \u2502readable\u00a0=def\u00a0readable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0reading.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0read()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502readline\u00a0=def\u00a0readline(size=-1,\u00a0/):\u2502 \u2502Read\u00a0until\u00a0newline\u00a0or\u00a0EOF.\u2502 \u2502\u2502 \u2502Returns\u00a0an\u00a0empty\u00a0string\u00a0if\u00a0EOF\u00a0is\u00a0hit\u00a0immediately.\u2502 \u2502readlines\u00a0=def\u00a0readlines(hint=-1,\u00a0/):\u2502 \u2502Return\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0from\u00a0the\u00a0stream.\u2502 \u2502\u2502 \u2502hint\u00a0can\u00a0be\u00a0specified\u00a0to\u00a0control\u00a0the\u00a0number\u00a0of\u00a0lines\u00a0read:\u00a0no\u00a0more\u2502 \u2502lines\u00a0will\u00a0be\u00a0read\u00a0if\u00a0the\u00a0total\u00a0size\u00a0(in\u00a0bytes/characters)\u00a0of\u00a0all\u2502 \u2502lines\u00a0so\u00a0far\u00a0exceeds\u00a0hint.\u2502 \u2502reconfigure\u00a0=def\u00a0reconfigure(*,\u00a0encoding=None,\u00a0errors=None,\u00a0newline=None,\u00a0line_buffering=None,\u00a0\u2502 \u2502write_through=None):\u2502 \u2502Reconfigure\u00a0the\u00a0text\u00a0stream\u00a0with\u00a0new\u00a0parameters.\u2502 \u2502\u2502 \u2502This\u00a0also\u00a0does\u00a0an\u00a0implicit\u00a0stream\u00a0flush.\u2502 \u2502seek\u00a0=def\u00a0seek(cookie,\u00a0whence=0,\u00a0/):\u2502 \u2502Change\u00a0stream\u00a0position.\u2502 \u2502\u2502 \u2502Change\u00a0the\u00a0stream\u00a0position\u00a0to\u00a0the\u00a0given\u00a0byte\u00a0offset.\u00a0The\u00a0offset\u00a0is\u2502 \u2502interpreted\u00a0relative\u00a0to\u00a0the\u00a0position\u00a0indicated\u00a0by\u00a0whence.\u00a0\u00a0Values\u2502 \u2502for\u00a0whence\u00a0are:\u2502 \u2502\u2502 \u2502*\u00a00\u00a0--\u00a0start\u00a0of\u00a0stream\u00a0(the\u00a0default);\u00a0offset\u00a0should\u00a0be\u00a0zero\u00a0or\u00a0positive\u2502 \u2502*\u00a01\u00a0--\u00a0current\u00a0stream\u00a0position;\u00a0offset\u00a0may\u00a0be\u00a0negative\u2502 \u2502*\u00a02\u00a0--\u00a0end\u00a0of\u00a0stream;\u00a0offset\u00a0is\u00a0usually\u00a0negative\u2502 \u2502\u2502 \u2502Return\u00a0the\u00a0new\u00a0absolute\u00a0position.\u2502 \u2502seekable\u00a0=def\u00a0seekable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0supports\u00a0random\u00a0access.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0seek(),\u00a0tell()\u00a0and\u00a0truncate()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502This\u00a0method\u00a0may\u00a0need\u00a0to\u00a0do\u00a0a\u00a0test\u00a0seek().\u2502 \u2502tell\u00a0=def\u00a0tell():Return\u00a0current\u00a0stream\u00a0position.\u2502 \u2502truncate\u00a0=def\u00a0truncate(pos=None,\u00a0/):\u2502 \u2502Truncate\u00a0file\u00a0to\u00a0size\u00a0bytes.\u2502 \u2502\u2502 \u2502File\u00a0pointer\u00a0is\u00a0left\u00a0unchanged.\u00a0\u00a0Size\u00a0defaults\u00a0to\u00a0the\u00a0current\u00a0IO\u2502 \u2502position\u00a0as\u00a0reported\u00a0by\u00a0tell().\u00a0\u00a0Returns\u00a0the\u00a0new\u00a0size.\u2502 \u2502writable\u00a0=def\u00a0writable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0writing.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0write()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502write\u00a0=def\u00a0write(text,\u00a0/):\u2502 \u2502Write\u00a0string\u00a0to\u00a0stream.\u2502 \u2502Returns\u00a0the\u00a0number\u00a0of\u00a0characters\u00a0written\u00a0(which\u00a0is\u00a0always\u00a0equal\u00a0to\u2502 \u2502the\u00a0length\u00a0of\u00a0the\u00a0string).\u2502 \u2502writelines\u00a0=def\u00a0writelines(lines,\u00a0/):\u2502 \u2502Write\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0to\u00a0stream.\u2502 \u2502\u2502 \u2502Line\u00a0separators\u00a0are\u00a0not\u00a0added,\u00a0so\u00a0it\u00a0is\u00a0usual\u00a0for\u00a0each\u00a0of\u00a0the\u2502 \u2502lines\u00a0provided\u00a0to\u00a0have\u00a0a\u00a0line\u00a0separator\u00a0at\u00a0the\u00a0end.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f There are a few more arguments to refine the level of detail you need (private methods, dunder attributes etc). You can see the full range of options with this delightful little incantation:
>>> inspect(inspect)\n
If you are interested in Rich or Textual, join our Discord server!
"},{"location":"blog/2023/07/27/using-rich-inspect-to-interrogate-python-objects/#addendum","title":"Addendum","text":"Here's how to have inspect
always available without an explicit import:
Put this in your pythonrc file: pic.twitter.com/pXTi69ykZL
\u2014 Tushar Sadhwani (@sadhlife) July 27, 2023"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/","title":"Spinners and progress bars in Textual","text":"One of the things I love about mathematics is that you can solve a problem just by guessing the correct answer. That is a perfectly valid strategy for solving a problem. The only thing you need to do after guessing the answer is to prove that your guess is correct.
I used this strategy, to some success, to display spinners and indeterminate progress bars from Rich in Textual.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#display-an-indeterminate-progress-bar-in-textual","title":"Display an indeterminate progress bar in Textual","text":"I have been playing around with Textual and recently I decided I needed an indeterminate progress bar to show that some data was loading. Textual is likely to get progress bars in the future, but I don't want to wait for the future! I want my progress bars now! Textual builds on top of Rich, so if Rich has progress bars, I reckoned I could use them in my Textual apps.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#progress-bars-in-rich","title":"Progress bars in Rich","text":"Creating a progress bar in Rich is as easy as opening up the documentation for Progress
and copying & pasting the code.
import time\nfrom rich.progress import track\nfor _ in track(range(20), description=\"Processing...\"):\ntime.sleep(0.5) # Simulate work being done\n
The function track
provides a very convenient interface for creating progress bars that keep track of a well-specified number of steps. In the example above, we were keeping track of some task that was going to take 20 steps to complete. (For example, if we had to process a list with 20 elements.) However, I am looking for indeterminate progress bars.
Scrolling further down the documentation for rich.progress
I found what I was looking for:
import time\nfrom rich.progress import Progress\nwith Progress() as progress:\n_ = progress.add_task(\"Loading...\", total=None) # (1)!\nwhile True:\ntime.sleep(0.01)\n
total=None
is what makes it an indeterminate progress bar.So, putting an indeterminate progress bar on the screen is easy. Now, I only needed to glue that together with the little I know about Textual to put an indeterminate progress bar in a Textual app.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#guessing-what-is-what-and-what-goes-where","title":"Guessing what is what and what goes where","text":"What I want is to have an indeterminate progress bar inside my Textual app. Something that looks like this:
The GIF above shows just the progress bar. Obviously, the end goal is to have the progress bar be part of a Textual app that does something.
So, when I set out to do this, my first thought went to the stopwatch app in the Textual tutorial because it has a widget that updates automatically, the TimeDisplay
. Below you can find the essential part of the code for the TimeDisplay
widget and a small animation of it updating when the stopwatch is started.
TimeDisplay
widgetOutput from time import monotonic\nfrom textual.reactive import reactive\nfrom textual.widgets import Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\n
The reason the time display updates magically is due to the three methods that I highlighted in the code above:
on_mount
is called when the TimeDisplay
widget is mounted on the app and, in it, we use the method set_interval
to let Textual know that every 1 / 60
seconds we would like to call the method update_time
. (In other words, we would like update_time
to be called 60 times per second.)update_time
(which is called automatically a bunch of times per second) will update the reactive attribute time
. When this attribute update happens, the method watch_time
kicks in.watch_time
is a watcher method and gets called whenever the attribute self.time
is assigned to. So, if the method update_time
is called a bunch of times per second, the watcher method watch_time
is also called a bunch of times per second. In it, we create a nice representation of the time that has elapsed and we use the method update
to update the time that is being displayed.I thought it would be reasonable if a similar mechanism needed to be in place for my progress bar, but then I realised that the progress bar seems to update itself... Looking at the indeterminate progress bar example from before, the only thing going on was that we used time.sleep
to stop our program for a bit. We didn't do anything to update the progress bar... Look:
with Progress() as progress:\n_ = progress.add_task(\"Loading...\", total=None) # (1)!\nwhile True:\ntime.sleep(0.01)\n
After pondering about this for a bit, I realised I would not need a watcher method for anything. The watcher method would only make sense if I needed to update an attribute related to some sort of artificial progress, but that clearly isn't needed to get the bar going...
At some point, I realised that the object progress
is the object of interest. At first, I thought progress.add_task
would return the progress bar, but it actually returns the integer ID of the task added, so the object of interest is progress
. Because I am doing nothing to update the bar explicitly, the object progress
must be updating itself.
The Textual documentation also says that we can build widgets from Rich renderables, so I concluded that if Progress
were a renderable, then I could inherit from Static
and use the method update
to update the widget with my instance of Progress
directly. I gave it a try and I put together this code:
from rich.progress import Progress, BarColumn\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass IndeterminateProgress(Static):\ndef __init__(self):\nsuper().__init__(\"\")\nself._bar = Progress(BarColumn()) # (1)!\nself._bar.add_task(\"\", total=None) # (2)!\ndef on_mount(self) -> None:\n# When the widget is mounted start updating the display regularly.\nself.update_render = self.set_interval(\n1 / 60, self.update_progress_bar\n) # (3)!\ndef update_progress_bar(self) -> None:\nself.update(self._bar) # (4)!\nclass MyApp(App):\ndef compose(self) -> ComposeResult:\nyield IndeterminateProgress()\nif __name__ == \"__main__\":\napp = MyApp()\napp.run()\n
Progress
that just cares about the bar itself (Rich progress bars can have a label, an indicator for the time left, etc).total=None
for the indeterminate progress bar.update_progress_bar
60 times per second.Static.update
with the Progress
object because self._bar
is a Rich renderable.And lo and behold, it worked:
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#proving-it-works","title":"Proving it works","text":"I finished writing this piece of code and I was ecstatic because it was working! After all, my Textual app starts and renders the progress bar. And so, I shared this simple app with someone who wanted to do a similar thing, but I was left with a bad taste in my mouth because I couldn't really connect all the dots and explain exactly why it worked.
Plot twist
By the end of the blog post, I will be much closer to a full explanation!
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#display-a-rich-spinner-in-a-textual-app","title":"Display a Rich spinner in a Textual app","text":"A day after creating my basic IndeterminateProgress
widget, I found someone that was trying to display a Rich spinner in a Textual app. Actually, it was someone that had filed an issue against Rich. They didn't ask \u201chow can I display a Rich spinner in a Textual app?\u201d, but they filed an alleged bug that crept up on them when they tried displaying a spinner in a Textual app.
When reading the issue I realised that displaying a Rich spinner looked very similar to displaying a Rich progress bar, so I made a tiny change to my code and tried to run it:
CodeSpinner runningfrom rich.spinner import Spinner\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass SpinnerWidget(Static):\ndef __init__(self):\nsuper().__init__(\"\")\nself._spinner = Spinner(\"moon\") # (1)!\ndef on_mount(self) -> None:\nself.update_render = self.set_interval(1 / 60, self.update_spinner)\ndef update_spinner(self) -> None:\nself.update(self._spinner)\nclass MyApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield SpinnerWidget()\nMyApp().run()\n
Progress
, we create an instance of Spinner
and save it so we can call self.update(self._spinner)
later on.After creating the progress bar and spinner widgets I thought of creating the little display that was shown at the beginning of the blog post:
When writing the code for this app, I realised both widgets had a lot of shared code and logic and I tried abstracting away their common functionality. That led to the code shown below (more or less) where I implemented the updating functionality in IntervalUpdater
and then let the IndeterminateProgressBar
and SpinnerWidget
instantiate the correct Rich renderable.
from rich.progress import Progress, BarColumn\nfrom rich.spinner import Spinner\nfrom textual.app import RenderableType\nfrom textual.widgets import Button, Static\nclass IntervalUpdater(Static):\n_renderable_object: RenderableType # (1)!\ndef update_rendering(self) -> None: # (2)!\nself.update(self._renderable_object)\ndef on_mount(self) -> None: # (3)!\nself.interval_update = self.set_interval(1 / 60, self.update_rendering)\nclass IndeterminateProgressBar(IntervalUpdater):\n\"\"\"Basic indeterminate progress bar widget based on rich.progress.Progress.\"\"\"\ndef __init__(self) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Progress(BarColumn()) # (4)!\nself._renderable_object.add_task(\"\", total=None)\nclass SpinnerWidget(IntervalUpdater):\n\"\"\"Basic spinner widget based on rich.spinner.Spinner.\"\"\"\ndef __init__(self, style: str) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Spinner(style) # (5)!\n
IntervalUpdate
should set the attribute _renderable_object
to the instance of the Rich renderable that we want to animate.update_rendering
and on_mount
are exactly the same as what we had before, both in the progress bar widget and in the spinner widget.update_rendering
and on_mount
are exactly the same as what we had before, both in the progress bar widget and in the spinner widget._renderable_object
to an instance of Progress
._renderable_object
to an instance of Spinner
.But I wanted something more! I wanted to make my app similar to the stopwatch app from the terminal and thus wanted to add a \u201cPause\u201d and a \u201cResume\u201d button. These buttons should, respectively, stop the progress bar and the spinner animations and resume them.
Below you can see the code I wrote and a short animation of the app working.
App codeCSSOutputfrom rich.progress import Progress, BarColumn\nfrom rich.spinner import Spinner\nfrom textual.app import App, ComposeResult, RenderableType\nfrom textual.containers import Grid, Horizontal, Vertical\nfrom textual.widgets import Button, Static\nclass IntervalUpdater(Static):\n_renderable_object: RenderableType\ndef update_rendering(self) -> None:\nself.update(self._renderable_object)\ndef on_mount(self) -> None:\nself.interval_update = self.set_interval(1 / 60, self.update_rendering)\ndef pause(self) -> None: # (1)!\nself.interval_update.pause()\ndef resume(self) -> None: # (2)!\nself.interval_update.resume()\nclass IndeterminateProgressBar(IntervalUpdater):\n\"\"\"Basic indeterminate progress bar widget based on rich.progress.Progress.\"\"\"\ndef __init__(self) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Progress(BarColumn())\nself._renderable_object.add_task(\"\", total=None)\nclass SpinnerWidget(IntervalUpdater):\n\"\"\"Basic spinner widget based on rich.spinner.Spinner.\"\"\"\ndef __init__(self, style: str) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Spinner(style)\nclass LiveDisplayApp(App[None]):\n\"\"\"App showcasing some widgets that update regularly.\"\"\"\nCSS_PATH = \"myapp.css\"\ndef compose(self) -> ComposeResult:\nyield Vertical(\nGrid(\nSpinnerWidget(\"moon\"),\nIndeterminateProgressBar(),\nSpinnerWidget(\"aesthetic\"),\nSpinnerWidget(\"bouncingBar\"),\nSpinnerWidget(\"earth\"),\nSpinnerWidget(\"dots8Bit\"),\n),\nHorizontal(\nButton(\"Pause\", id=\"pause\"), # (3)!\nButton(\"Resume\", id=\"resume\", disabled=True),\n),\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (4)!\npressed_id = event.button.id\nassert pressed_id is not None\nfor widget in self.query(IntervalUpdater):\ngetattr(widget, pressed_id)() # (5)!\nfor button in self.query(Button): # (6)!\nif button.id == pressed_id:\nbutton.disabled = True\nelse:\nbutton.disabled = False\nLiveDisplayApp().run()\n
pause
looks at the attribute interval_update
(returned by the method set_interval
) and tells it to stop calling the method update_rendering
60 times per second.resume
looks at the attribute interval_update
(returned by the method set_interval
) and tells it to resume calling the method update_rendering
60 times per second.on_button_pressed
will wait for button presses and will take care of pausing or resuming the animations.IntervalUpdater
in our app and use a little bit of introspection to call the correct method (pause
or resume
) in our widgets. Notice this was only possible because the buttons were assigned IDs that matched the names of the methods. (I love Python !)Screen {\nalign: center middle;\n}\nHorizontal {\nheight: 1fr;\nalign-horizontal: center;\n}\nButton {\nmargin: 0 3 0 3;\n}\nGrid {\nheight: 4fr;\nalign: center middle;\ngrid-size: 3 2;\ngrid-columns: 8;\ngrid-rows: 1;\ngrid-gutter: 1;\nborder: gray double;\n}\nIntervalUpdater {\ncontent-align: center middle;\n}\n
If you think this was a lot, take a couple of deep breaths before moving on.
The only issue with my app is that... it does not work! If you press the button to pause the animations, it looks like the widgets are paused. However, you can see that if I move my mouse over the paused widgets, they update:
Obviously, that caught me by surprise, in the sense that I expected it work. On the other hand, this isn't surprising. After all, I thought I had guessed how I could solve the problem of displaying these Rich renderables that update live and I thought I knew how to pause and resume their animations, but I hadn't convinced myself I knew exactly why it worked.
Warning
This goes to show that sometimes it is not the best idea to commit code that you wrote and that works if you don't know why it works. The code might seem to work and yet have deficiencies that will hurt you further down the road.
As it turns out, the reason why pausing is not working is that I did not grok why the rendering worked in the first place... So I had to go down that rabbit hole first.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#understanding-the-rich-rendering-magic","title":"Understanding the Rich rendering magic","text":""},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#how-staticupdate-works","title":"HowStatic.update
works","text":"The most basic way of creating a Textual widget is to inherit from Widget
and implement the method render
that just returns the thing that must be printed on the screen. Then, the widget Static
provides some functionality on top of that: the method update
.
The method Static.update(renderable)
is used to tell the widget in question that its method render
(called when the widget needs to be drawn) should just return renderable
. So, if the implementation of the method IntervalUpdater.update_rendering
(the method that gets called 60 times per second) is this:
class IntervalUpdater(Static):\n# ...\ndef update_rendering(self) -> None:\nself.update(self._renderable_object)\n
Then, we are essentially saying \u201chey, the thing in self._renderable_object
is what must be returned whenever Textual asks you to render yourself. So, this really proves that both Progress
and Spinner
from Rich are renderables. But what is more, this shows that my implementation of IntervalUpdater
can be simplified greatly! In fact, we can boil it down to just this:
class IntervalUpdater(Static):\n_renderable_object: RenderableType\ndef __init__(self, renderable_object: RenderableType) -> None: # (1)!\nsuper().__init__(renderable_object) # (2)!\ndef on_mount(self) -> None:\nself.interval_update = self.set_interval(1 / 60, self.refresh) # (3)!\n
IntervalUpdater
, now we give it the Rich renderable that we want displayed. If this Rich renderable is something that updates over time, then those changes will be reflected in the rendering.Static
with the renderable object itself, instead of initialising with the empty string \"\"
and then updating repeatedly.self.refresh
60 times per second. We don't need the auxiliary method update_rendering
because this widget (an instance of Static
) already knows what its renderable is.Once you understand the code above you will realise that the previous implementation of update_rendering
was actually doing superfluous work because the repeated calls to self.update
always had the exact same object. Again, we see strong evidence that the Rich progress bars and the spinners have the inherent ability to display a different representation of themselves as time goes by.
I kept seeing strong evidence that Rich spinners and Rich progress bars updated their own rendering but I still did not have actual proof. So, I went digging around to see how Spinner
was implemented and I found this code (from the file spinner.py
at the time of writing):
class Spinner:\n# ...\ndef __rich_console__(\nself, console: \"Console\", options: \"ConsoleOptions\"\n) -> \"RenderResult\":\nyield self.render(console.get_time()) # (1)!\n# ...\ndef render(self, time: float) -> \"RenderableType\": # (2)!\n# ...\nframe_no = ((time - self.start_time) * self.speed) / ( # (3)!\nself.interval / 1000.0\n) + self.frame_no_offset\n# ...\n# ...\n
__rich_console__
that is supposed to return the result of rendering the spinner. Instead, it defers its work to the method render
... However, to call the method render
, we need to pass the argument console.get_time()
, which the spinner uses to know in which state it is!render
takes a time
and returns a renderable!time
, and the time when the spinner started!The snippet of code shown above, from the implementation of Spinner
, explains why moving the mouse over a spinner (or a progress bar) that supposedly was paused makes it move. We no longer get repeated updates (60 times per second) because we told our app that we wanted to pause the result of set_interval
, so we no longer get automatic updates. However, moving the mouse over the spinners and the progress bar makes Textual want to re-render them and, when it does, it figures out that time was not frozen (obviously!) and so the spinners and the progress bar have a different frame to show.
To get a better feeling for this, do the following experiment:
textual console
in a terminal to open the Textual devtools console.print(\"Rendering from within spinner\")
to the beginning of the method Spinner.render
(from Rich).print(\"Rendering static\")
to the beginning of the method Static.render
(from Textual).Static.render
and another from the Spinner.render
.The result of steps 6 and 7 are shown below. Notice that, in the beginning of the animation, the screen on the right shows some prints but is quiet because no more prints are coming in. When the mouse enters the screen and starts going over widgets, the screen on the right gets new prints in pairs, first from Static.render
(which Textual calls to render the widget) and then from Spinner.render
because ultimately we need to know how the Spinner looks.
Now, at this point, I made another educated guess and deduced that progress bars work in the same way! I still have to prove it, and I guess I will do so in another blog post, coming soon, where our spinner and progress bar widgets can be properly paused!
I will see you soon
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/","title":"Stealing Open Source code from Textual","text":"I would like to talk about a serious issue in the Free and Open Source software world. Stealing code. You wouldn't steal a car would you?
But you should steal code from Open Source projects. Respect the license (you may need to give attribution) but stealing code is not like stealing a car. If I steal your car, I have deprived you of a car. If you steal my open source code, I haven't lost anything.
Warning
I'm not advocating for piracy. Open source code gives you explicit permission to use it.
From my point of view, I feel like code has greater value when it has been copied / modified in another project.
There are a number of files and modules in Textual that could either be lifted as is, or wouldn't require much work to extract. I'd like to cover a few here. You might find them useful in your next project.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#loop-first-last","title":"Loop first / last","text":"How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself needing this a lot, so I wrote some helpers in _loop.py.
I'm sure there is an equivalent implementation on PyPI, but steal this if you need it.
Here's an example of use:
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):\nyield move_to(x, y)\nyield from line\nif not last:\nyield new_line\n
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#lru-cache","title":"LRU Cache","text":"Python's lru_cache can be the one-liner that makes your code orders of magnitude faster. But it has a few gotchas.
The main issue is managing the lifetime of these caches. The decorator keeps a single global cache, which will keep a reference to every object in the function call. On an instance method that means you keep references to self
for the lifetime of your app.
For a more flexibility you can use the LRUCache implementation from Textual. This uses essentially the same algorithm as the stdlib decorator, but it is implemented as a container.
Here's a quick example of its use. It works like a dictionary until you reach a maximum size. After that, new elements will kick out the element that was used least recently.
>>> from textual._cache import LRUCache\n>>> cache = LRUCache(maxsize=3)\n>>> cache[\"foo\"] = 1\n>>> cache[\"bar\"] = 2\n>>> cache[\"baz\"] = 3\n>>> dict(cache)\n{'foo': 1, 'bar': 2, 'baz': 3}\n>>> cache[\"egg\"] = 4\n>>> dict(cache)\n{'bar': 2, 'baz': 3, 'egg': 4}\n
In Textual, we use a LRUCache to store the results of rendering content to the terminal. For example, in a datatable it is too costly to render everything up front. So Textual renders only the lines that are currently visible on the \"screen\". The cache ensures that scrolling only needs to render the newly exposed lines, and lines that haven't been displayed in a while are discarded to save memory.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#color","title":"Color","text":"Textual has a Color class which could be extracted in to a module of its own.
The Color class can parse colors encoded in a variety of HTML and CSS formats. Color object support a variety of methods and operators you can use to manipulate colors, in a fairly natural way.
Here's some examples in the REPL.
>>> from textual.color import Color\n>>> color = Color.parse(\"lime\")\n>>> color\nColor(0, 255, 0, a=1.0)\n>>> color.darken(0.8)\nColor(0, 45, 0, a=1.0)\n>>> color + Color.parse(\"red\").with_alpha(0.1)\nColor(25, 229, 0, a=1.0)\n>>> color = Color.parse(\"#12a30a\")\n>>> color\nColor(18, 163, 10, a=1.0)\n>>> color.css\n'rgb(18,163,10)'\n>>> color.hex\n'#12A30A'\n>>> color.monochrome\nColor(121, 121, 121, a=1.0)\n>>> color.monochrome.hex\n'#797979'\n>>> color.hsl\nHSL(h=0.3246187363834423, s=0.8843930635838151, l=0.33921568627450976)\n>>>\n
There are some very good color libraries in PyPI, which you should also consider using. But Textual's Color class is lean and performant, with no C dependencies.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#geometry","title":"Geometry","text":"This may be my favorite module in Textual: geometry.py.
The geometry module contains a number of classes responsible for storing and manipulating 2D geometry. There is an Offset
class which is a two dimensional point. A Region
class which is a rectangular region defined by a coordinate and dimensions. There is a Spacing
class which defines additional space around a region. And there is a Size
class which defines the dimensions of an area by its width and height.
These objects are used by Textual's layout engine and compositor, which makes them the oldest and most thoroughly tested part of the project.
There's a lot going on in this module, but the docstrings are quite detailed and have unicode art like this to help explain things.
cut_x \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 \u2502 \u2502 \u2502\n \u2502 0 \u2502 \u2502 1 \u2502\n \u2502 \u2502 \u2502 \u2502\n cut_y \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 2 \u2502 \u2502 3 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#you-should-steal-our-code","title":"You should steal our code","text":"There is a lot going on in the Textual Repository. Including a CSS parser, renderer, layout and compositing engine. All written in pure Python. Steal it with my blessing.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/","title":"Things I learned building a text editor for the terminal","text":"TextArea
is the latest widget to be added to Textual's growing collection. It provides a multi-line space to edit text, and features optional syntax highlighting for a selection of languages.
Adding a TextArea
to your Textual app is as simple as adding this to your compose
method:
yield TextArea()\n
Enabling syntax highlighting for a language is as simple as:
yield TextArea(language=\"python\")\n
Working on the TextArea
widget for Textual taught me a lot about Python and my general approach to software engineering. It gave me an appreciation for the subtle functionality behind the editors we use on a daily basis \u2014 features we may not even notice, despite some engineer spending hours perfecting it to provide a small boost to our development experience.
This post is a tour of some of these learnings.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#vertical-cursor-movement-is-more-than-just-cursor_row","title":"Vertical cursor movement is more than justcursor_row++
","text":"When you move the cursor vertically, you can't simply keep the same column index and clamp it within the line. Editors should maintain the visual column offset where possible, meaning they must account for double-width emoji (sigh \ud83d\ude14) and East-Asian characters.
Notice that although the cursor is on column 11 while on line 1, it lands on column 6 when it arrives at line 3. This is because the 6th character of line 3 visually aligns with the 11th character of line 1.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#edits-from-other-sources-may-move-my-cursor","title":"Edits from other sources may move my cursor","text":"There are two ways to interact with the TextArea
:
In the example below, Hello, world!\\n
is repeatedly inserted at the start of the document via the API. Notice that this updates the location of my cursor, ensuring that I don't lose my place.
This subtle feature should aid those implementing collaborative and multi-cursor editing.
This turned out to be one of the more complex features of the whole project, and went through several iterations before I was happy with the result.
Thankfully it resulted in some wonderful Tetris-esque whiteboards along the way!
A TetrisArea white-boarding session.
Sometimes stepping away from the screen and scribbling on a whiteboard with your colleagues (thanks Dave!) is what's needed to finally crack a tough problem.
Many thanks to David Brochart for sending me down this rabbit hole!
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#spending-a-few-minutes-running-a-profiler-can-be-really-beneficial","title":"Spending a few minutes running a profiler can be really beneficial","text":"While building the TextArea
widget I avoided heavy optimisation work that may have affected readability or maintainability.
However, I did run a profiler in an attempt to detect flawed assumptions or mistakes which were affecting the performance of my code.
I spent around 30 minutes profiling TextArea
using pyinstrument, and the result was a ~97% reduction in the time taken to handle a key press. What an amazing return on investment for such a minimal time commitment!
\"pyinstrument -r html\" produces this beautiful output.
pyinstrument unveiled two issues that were massively impacting performance.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#1-reparsing-highlighting-queries-on-each-key-press","title":"1. Reparsing highlighting queries on each key press","text":"I was constructing a tree-sitter Query
object on each key press, incorrectly assuming it was a low-overhead call. This query was completely static, so I moved it into the constructor ensuring the object was created only once. This reduced key processing time by around 94% - a substantial and very much noticeable improvement.
This seems obvious in hindsight, but the code in question was written earlier in the project and had been relegated in my mind to \"code that works correctly and will receive less attention from here on out\". pyinstrument quickly brought this code back to my attention and highlighted it as a glaring performance bug.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#2-namedtuples-are-slower-than-i-expected","title":"2. NamedTuples are slower than I expected","text":"In Python, NamedTuple
s are slow to create relative to tuple
s, and this cost was adding up inside an extremely hot loop which was instantiating a large number of them. pyinstrument revealed that a large portion of the time during syntax highlighting was spent inside NamedTuple.__new__
.
Here's a quick benchmark which constructs 10,000 NamedTuple
s:
\u276f hyperfine -w 2 'python sandbox/darren/make_namedtuples.py'\nBenchmark 1: python sandbox/darren/make_namedtuples.py\nTime (mean \u00b1 \u03c3): 15.9 ms \u00b1 0.5 ms [User: 12.8 ms, System: 2.5 ms]\nRange (min \u2026 max): 15.2 ms \u2026 18.4 ms 165 runs\n
Here's the same benchmark using tuple
instead:
\u276f hyperfine -w 2 'python sandbox/darren/make_tuples.py'\nBenchmark 1: python sandbox/darren/make_tuples.py\nTime (mean \u00b1 \u03c3): 9.3 ms \u00b1 0.5 ms [User: 6.8 ms, System: 2.0 ms]\nRange (min \u2026 max): 8.7 ms \u2026 12.3 ms 256 runs\n
Switching to tuple
resulted in another noticeable increase in responsiveness. Key-press handling time dropped by almost 50%! Unfortunately, this change does impact readability. However, the scope in which these tuples were used was very small, and so I felt it was a worthy trade-off.
In order to support syntax highlighting, we make use of the tree-sitter library, which maintains a syntax tree representing the structure of our document.
To perform highlighting, we follow these steps:
Cycling through a few of the builtin themes.
Another benefit that I didn't consider before working on this project is that tree-sitter parsers can also be used to highlight syntax errors in a document. This can be useful in some situations - for example, highlighting mismatched HTML closing tags:
Highlighting mismatched closing HTML tags in red.
Before building this widget, I was oblivious as to how we might approach syntax highlighting. Without tree-sitter's incremental parsing approach, I'm not sure reasonable performance would have been feasible.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#edits-are-replacements","title":"Edits are replacements","text":"All single-cursor edits can be distilled into a single behaviour: replace_range
. This replaces a range of characters with some text. We can use this one method to easily implement deletion, insertion, and replacement of text.
This greatly simplified my initial approach, which involved unique implementations for inserting and deleting.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#the-line-between-text-area-and-vscode-in-the-terminal","title":"The line between \"text area\" and \"VSCode in the terminal\"","text":"A project like this has no clear finish line. There are always new features, optimisations, and refactors waiting to be made.
So where do we draw the line?
We want to provide a widget which can act as both a basic multiline text area that anyone can drop into their app, yet powerful and extensible enough to act as the foundation for a Textual-powered text editor.
Yet, the more features we add, the more opinionated the widget becomes, and the less that users will feel like they can build it into their own thing. Finding the sweet spot between feature-rich and flexible is no easy task.
I don't think the answer is clear, and I don't believe it's possible to please everyone.
Regardless, I'm happy with where we've landed, and I'm really excited to see what people build using TextArea
in the future!
It's no surprise that a common question on the Textual Discord server is how to go about producing plots in the terminal. A popular solution that has been suggested is Plotext. While Plotext doesn't directly support Textual, it is easy to use with Rich and, because of this, we wanted to make it just as easy to use in your Textual applications.
With this in mind we've created textual-plotext
: a library that provides a widget for using Plotext plots in your app. In doing this we've tried our best to make it as similar as possible to using Plotext in a conventional Python script.
Take this code from the Plotext README:
import plotext as plt\ny = plt.sin() # sinusoidal test signal\nplt.scatter(y)\nplt.title(\"Scatter Plot\") # to apply a title\nplt.show() # to finally plot\n
The Textual equivalent of this (including everything needed to make this a fully-working Textual application) is:
from textual.app import App, ComposeResult\nfrom textual_plotext import PlotextPlot\nclass ScatterApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield PlotextPlot()\ndef on_mount(self) -> None:\nplt = self.query_one(PlotextPlot).plt\ny = plt.sin() # sinusoidal test signal\nplt.scatter(y)\nplt.title(\"Scatter Plot\") # to apply a title\nif __name__ == \"__main__\":\nScatterApp().run()\n
When run the result will look like this:
Aside from a couple of the more far-out plot types1 you should find that everything you can do with Plotext in a conventional script can also be done in a Textual application.
Here's a small selection of screenshots from a demo built into the library, each of the plots taken from the Plotext README:
A key design goal of this widget is that you can develop your plots so that the resulting code looks very similar to that in the Plotext documentation. The core difference is that, where you'd normally import the plotext
module as plt
and then call functions via plt
, you instead use the plt
property made available by the widget.
You don't even need to call the build
or show
functions as textual-plotext
takes care of this for you. You can see this in action in the scatter code shown earlier.
Of course, moving any existing plotting code into your Textual app means you will need to think about how you get the data and when and where you build your plot. This might be where the Textual worker API becomes useful.
We've included a longer-form example application that shows off the glorious Scottish weather we enjoy here at Textual Towers, with an application that uses workers to pull down weather data from a year ago and plot it.
If you are an existing Plotext user who wants to turn your plots into full terminal applications, we think this will be very familiar and accessible. If you're a Textual user who wants to add plots to your application, we think Plotext is a great library for this.
If you have any questions about this, or anything else to do with Textual, feel free to come and join us on our Discord server or in our GitHub discussions.
Right now there's no animated gif or video support.\u00a0\u21a9
If you know us, you will know that we are the team behind Rich and Textual \u2014 two popular Python libraries that work magic in the terminal.
Note
Not to mention Rich-CLI, Trogon, and Frogmouth
Today we are adding one project more to that lineup: textual-web.
Textual Web takes a Textual-powered TUI and turns it in to a web application. Here's a video of that in action:
With the textual-web
command you can publish any Textual app on the web, making it available to anyone you send the URL to. This works without creating a socket server on your machine, so you won't have to configure firewalls and ports to share your applications.
We're excited about the possibilities here. Textual web apps are fast to spin up and tear down, and they can run just about anywhere that has an outgoing internet connection. They can be built by a single developer without any experience with a traditional web stack. All you need is proficiency in Python and a little time to read our lovely docs.
Future releases will expose more of the Web platform APIs to Textual apps, such as notifications and file system access. We plan to do this in a way that allows the same (Python) code to drive those features. For instance, a Textual app might save a file to disk in a terminal, but offer to download it in the browser.
Also in the pipeline is PWA support, so you can build terminal apps, web apps, and desktop apps with a single codebase.
Textual Web is currently in a public beta. Join our Discord server if you would like to help us test, or if you have any questions.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/","title":"To TUI or not to TUI","text":"Tech moves pretty fast. If you don\u2019t stop and look around once in a while, you could miss it. And yet some technology feels like it has been around forever.
Terminals are one of those forever-technologies.
My interest is in Text User Interfaces: interactive apps that run within a terminal. I spend lot of time thinking about where TUIs might fit within the tech ecosystem, and how much more they could be doing for developers. Hardly surprising, since that is what we do at Textualize.
Recently I had the opportunity to test how new TUI projects would be received. You can consider these to be \"testing the water\", and hopefully representative of TUI apps in general.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#the-projects","title":"The projects","text":"In April we took a break from building Textual, to building apps with Textual. We had three ideas to work on, and three devs to do the work. One idea we parked for later. The other two were so promising we devoted more time to them. Both projects took around three developer-weeks to build, which also included work on Textual itself and standard duties for responding to issues / community requests. We released them in May.
The first project was Frogmouth, a Markdown browser. I think this TUI does better than the equivalent web experience in many ways. The only notable missing feature is images, and that will happen before too long.
Here's a screenshot:
Frogmouth /Users/willmcgugan/projects/textual/FAQ.md ContentsLocalBookmarksHistory\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u258e\u258a \u258eHow\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u258a \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e\u258a \u2503\u25bc\u00a0\u2160\u00a0Frequently\u00a0Asked\u00a0Questions\u2503\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Does\u00a0Textual\u00a0support\u00a0images?\u2503When\u00a0creating\u00a0your\u00a0App\u00a0class,\u00a0override\u00a0__init__\u00a0as\u00a0you\u00a0would\u00a0wheninheriting\u00a0normally.\u00a0For\u00a0example: \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0fix\u00a0ImportError\u00a0cannot\u00a0i\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0select\u00a0and\u00a0copy\u00a0text\u00a0in\u00a0\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0set\u00a0a\u00a0translucent\u00a0app\u00a0ba\u2503fromtextual.appimportApp,ComposeResult \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0center\u00a0a\u00a0widget\u00a0in\u00a0a\u00a0scre\u2503fromtextual.widgetsimportStatic \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0do\u00a0some\u00a0key\u00a0combinations\u00a0never\u2503classGreetings(App[None]): \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0look\u00a0good\u00a0on\u00a0m\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2514\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0support\u00a0ANSI\u00a0t\u2503\u2502\u00a0\u00a0\u00a0def__init__(self,greeting:str=\"Hello\",to_greet:str=\"World\")->None: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.greeting=greeting \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.to_greet=to_greet \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0super().__init__() \u2503\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2503\u2502\u00a0\u00a0\u00a0defcompose(self)->ComposeResult: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldStatic(f\"{self.greeting},\u00a0{self.to_greet}\") \u2503\u2503 \u2503\u2503 \u2503\u2503Then\u00a0the\u00a0app\u00a0can\u00a0be\u00a0run,\u00a0passing\u00a0in\u00a0various\u00a0arguments;\u00a0for\u00a0example: \u2503\u2503\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0default\u00a0arguments. \u2503\u2503Greetings().run() \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0a\u00a0keyword\u00a0arguyment. \u2503\u2503Greetings(to_greet=\"davep\").run()\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0both\u00a0positional\u00a0arguments. \u2503\u2503Greetings(\"Well\u00a0hello\",\"there\").run() \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2503\u2589\u2503\u258e\u258a \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u00a0F1\u00a0\u00a0Help\u00a0\u00a0F2\u00a0\u00a0About\u00a0\u00a0CTRL+N\u00a0\u00a0Navigation\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0Info
Quick aside about these \"screenshots\", because its a common ask. They aren't true screenshots, but rather SVGs exported by Textual.
We posted Frogmouth on Hacker News and Reddit on a Sunday morning (US time). A day later, it had 1,000 stars and lots of positive feedback.
The second project was Trogon, a library this time. Trogon automatically creates a TUI for command line apps. Same deal: we released it on a Sunday morning, and it reached 1K stars even quicker than Frogmouth.
Trogon sqlite-utilstransform v3.31Transform\u00a0a\u00a0table\u00a0beyond\u00a0the\u00a0capabilities\u00a0of\u00a0ALTER\u00a0TABLE \u258a\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258e disable-wal\u258a\u258a\u258e\u258e drop-table\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e drop-view\u258a\u258e\u2587\u2587 dump\u258aOptions\u258e duplicate\u258a\u258e enable-counts\u258a--type\u00a0multiple\u00a0<text\u00a0choice>\u258e enable-fts\u258a\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258e enable-wal\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e extract\u258a\u2502\u258a\u258e\u2502\u258e\u2585\u2585 index-foreign-keys\u258a\u2502\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2502\u258e indexes\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e insert\u258a\u2502\u258aSelect\u25b2\u258e\u2502\u258e insert-files\u258a\u2502\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2502\u258e install\u258a\u2514\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2518\u258e memory\u258a\u258aSelect\u258e\u258e optimize\u258a\u258aINTEGER\u258e\u258e populate-fts\u258a\u258aTEXT\u258e\u258e query\u258a\u258aFLOAT\u258e\u258e rebuild-fts\u258a\u258a\u258aBLOB\u258e\u258e\u258e reset-counts\u258a\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e\u258e rows\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e schema\u258a+\u00a0value\u258e search\u258aDrop\u00a0this\u00a0column\u258e tables\u258a\u258e transform\u258a--rename\u00a0multiple\u00a0<text\u00a0text>\u258e triggers\u258a\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258e uninstall\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e upsert\u258a\u2502\u258a\u258e\u2502\u258e vacuum views$\u00a0sqlite-utils\u00a0transform \u00a0CTRL+R\u00a0\u00a0Close\u00a0&\u00a0Run\u00a0\u00a0CTRL+T\u00a0Focus\u00a0Command\u00a0Tree\u00a0\u00a0CTRL+O\u00a0\u00a0Command\u00a0Info\u00a0\u00a0CTRL+S\u00a0\u00a0Search\u00a0\u00a0F1\u00a0\u00a0About\u00a0Both of these projects are very young, but off to a great start. I'm looking forward to seeing how far we can taken them.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#wrapping-up","title":"Wrapping up","text":"With previous generations of software, TUIs have required a high degree of motivation to build. That has changed with the work that we (and others) have been doing. A TUI can be a powerful and maintainable piece of software which works as a standalone project, or as a value-add to an existing project.
As a forever-technology, a TUI is a safe bet.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#discord","title":"Discord","text":"Want to discuss this post with myself or other Textualize devs? Join our Discord server...
"},{"location":"css_types/","title":"CSS Types","text":"CSS types define the values that Textual CSS styles accept.
CSS types will be linked from within the styles reference in the \"Formal Syntax\" section of each style. The CSS types will be denoted by a keyword enclosed by angle brackets <
and >
.
For example, the style align-horizontal
references the CSS type <horizontal>
:
\nalign-horizontal: <horizontal>;\n"},{"location":"css_types/border/","title":"<border>","text":"
The <border>
CSS type represents a border style.
The <border>
type can take any of the following values:
ascii
A border with plus, hyphen, and vertical bar characters. blank
A blank border (reserves space for a border). dashed
Dashed line border. double
Double lined border. heavy
Heavy border. hidden
Alias for \"none\". hkey
Horizontal key-line border. inner
Thick solid border. none
Disabled border. outer
Solid border with additional space around content. round
Rounded corners. solid
Solid border. tall
Solid border with additional space top and bottom. thick
Border style that is consistently thick across edges. vkey
Vertical key-line border. wide
Solid border with additional space left and right."},{"location":"css_types/border/#border-command","title":"Border command","text":"The textual
CLI has a subcommand which will let you explore the various border types interactively, when applied to the CSS rule border
:
textual borders\n
"},{"location":"css_types/border/#examples","title":"Examples","text":""},{"location":"css_types/border/#css","title":"CSS","text":"#container {\nborder: heavy red;\n}\n#heading {\nborder-bottom: solid blue;\n}\n
"},{"location":"css_types/border/#python","title":"Python","text":"widget.styles.border = (\"heavy\", \"red\")\nwidget.styles.border_bottom = (\"solid\", \"blue\")\n
"},{"location":"css_types/color/","title":"<color>","text":"The <color>
CSS type represents a color.
Warning
Not to be confused with the color
CSS rule to set text color.
A <color>
should be in one of the formats explained in this section. A bullet point summary of the formats available follows:
red
);#F35573
);#F35573A0
);rgb(23, 78, 200)
);hsl(290, 70%, 80%)
);Textual's default themes also provide many CSS variables with colors that can be used out of the box.
"},{"location":"css_types/color/#named-colors","title":"Named colors","text":"A named color is a <name>
that Textual recognises. Below, you can find a (collapsed) list of all of the named colors that Textual recognises, along with their hexadecimal values, their RGB values, and a visual sample.
The hexadecimal RGB format starts with an octothorpe #
and is then followed by 3 or 6 hexadecimal digits: 0123456789ABCDEF
. Casing is ignored.
#RRGGBB
:RR
represents the red channel;GG
represents the green channel; andBB
represents the blue channel.#RGB
.In a 3 digit color, each channel is represented by a single digit which is duplicated when converting to the 6 digit format. For example, the color #A2F
is the same as #AA22FF
.
This is the same as the hex RGB value, but with an extra channel for the alpha component (that sets opacity).
#RRGGBBAA
, equivalent to the format #RRGGBB
with two extra digits for opacity.#RGBA
, equivalent to the format #RGB
with an extra digit for opacity.rgb
description","text":"The rgb
format description is a functional description of a color in the RGB color space. This description follows the format rgb(red, green, blue)
, where red
, green
, and blue
are decimal integers between 0 and 255. They represent the value of the channel with the same name.
For example, rgb(0, 255, 32)
is equivalent to #00FF20
.
rgba
description","text":"The rgba
format description is the same as the rgb
with an extra parameter for opacity, which should be a value between 0
and 1
.
For example, rgba(0, 255, 32, 0.5)
is the color rgb(0, 255, 32)
with 50% opacity.
hsl
description","text":"The hsl
format description is a functional description of a color in the HSL color space. This description follows the format hsl(hue, saturation, lightness)
, where
hue
is a float between 0 and 360;saturation
is a percentage between 0%
and 100%
; andlightness
is a percentage between 0%
and 100%
.For example, the color #00FF20
would be represented as hsl(128, 100%, 50%)
in the HSL color space.
hsla
description","text":"The hsla
format description is the same as the hsl
with an extra parameter for opacity, which should be a value between 0
and 1
.
For example, hsla(128, 100%, 50%, 0.5)
is the color hsl(128, 100%, 50%)
with 50% opacity.
Header {\nbackground: red; /* Color name */\n}\n.accent {\ncolor: $accent; /* Textual variable */\n}\n#footer {\ntint: hsl(300, 20%, 70%); /* HSL description */\n}\n
"},{"location":"css_types/color/#python","title":"Python","text":"In Python, rules that expect a <color>
can also accept an instance of the type Color
.
# Mimicking the CSS syntax\nwidget.styles.background = \"red\" # Color name\nwidget.styles.color = \"$accent\" # Textual variable\nwidget.styles.tint = \"hsl(300, 20%, 70%)\" # HSL description\nfrom textual.color import Color\n# Using a Color object directly...\ncolor = Color(16, 200, 45)\n# ... which can also parse the CSS syntax\ncolor = Color.parse(\"#A8F\")\n
"},{"location":"css_types/horizontal/","title":"<horizontal>","text":"The <horizontal>
CSS type represents a position along the horizontal axis.
The <horizontal>
type can take any of the following values:
center
Aligns in the center of the horizontal axis. left
(default) Aligns on the left of the horizontal axis. right
Aligns on the right of the horizontal axis."},{"location":"css_types/horizontal/#examples","title":"Examples","text":""},{"location":"css_types/horizontal/#css","title":"CSS","text":".container {\nalign-horizontal: right;\n}\n
"},{"location":"css_types/horizontal/#python","title":"Python","text":"widget.styles.align_horizontal = \"right\"\n
"},{"location":"css_types/integer/","title":"<integer>","text":"The <integer>
CSS type represents an integer number.
An <integer>
is any valid integer number like -10
or 42
.
Note
Some CSS rules may expect an <integer>
within certain bounds. If that is the case, it will be noted in that rule.
.classname {\noffset: 10 -20\n}\n
"},{"location":"css_types/integer/#python","title":"Python","text":"In Python, a rule that expects a CSS type <integer>
will expect a value of the type int
:
widget.styles.offset = (10, -20)\n
"},{"location":"css_types/name/","title":"<name>","text":"The <name>
type represents a sequence of characters that identifies something.
A <name>
is any non-empty sequence of characters:
a-z
, A-Z
, or underscore _
; anda-zA-Z
, digits 0-9
, underscores _
, and hiphens -
.Screen {\nlayers: onlyLetters Letters-and-hiphens _lead-under letters-1-digit;\n}\n
"},{"location":"css_types/name/#python","title":"Python","text":"widget.styles.layers = \"onlyLetters Letters-and-hiphens _lead-under letters-1-digit\"\n
"},{"location":"css_types/number/","title":"<number>","text":"The <number>
CSS type represents a real number, which can be an integer or a number with a decimal part (akin to a float
in Python).
A <number>
is an <integer>
, optionally followed by the decimal point .
and a decimal part composed of one or more digits.
Grid {\ngrid-size: 3 6 /* Integers are numbers */\n}\n.translucid {\nopacity: 0.5 /* Numbers can have a decimal part */\n}\n
"},{"location":"css_types/number/#python","title":"Python","text":"In Python, a rule that expects a CSS type <number>
will accept an int
or a float
:
widget.styles.grid_size = (3, 6) # Integers are numbers\nwidget.styles.opacity = 0.5 # Numbers can have a decimal part\n
"},{"location":"css_types/overflow/","title":"<overflow>","text":"The <overflow>
CSS type represents overflow modes.
The <overflow>
type can take any of the following values:
auto
Determine overflow mode automatically. hidden
Don't overflow. scroll
Allow overflowing."},{"location":"css_types/overflow/#examples","title":"Examples","text":""},{"location":"css_types/overflow/#css","title":"CSS","text":"#container {\noverflow-y: hidden; /* Don't overflow */\n}\n
"},{"location":"css_types/overflow/#python","title":"Python","text":"widget.styles.overflow_y = \"hidden\" # Don't overflow\n
"},{"location":"css_types/percentage/","title":"<percentage>","text":"The <percentage>
CSS type represents a percentage value. It is often used to represent values that are relative to the parent's values.
Warning
Not to be confused with the <scalar>
type.
A <percentage>
is a <number>
followed by the percent sign %
(without spaces). Some rules may clamp the values between 0%
and 100%
.
#footer {\n/* Integer followed by % */\ncolor: red 70%;\n/* The number can be negative/decimal, although that may not make sense */\noffset: -30% 12.5%;\n}\n
"},{"location":"css_types/percentage/#python","title":"Python","text":"# Integer followed by %\nwidget.styles.color = \"red 70%\"\n# The number can be negative/decimal, although that may not make sense\nwidget.styles.offset = (\"-30%\", \"12.5%\")\n
"},{"location":"css_types/scalar/","title":"<scalar>","text":"The <scalar>
CSS type represents a length. It can be a <number>
and a unit, or the special value auto
. It is used to represent lengths, for example in the width
and height
rules.
Warning
Not to be confused with the <number>
or <percentage>
types.
A <scalar>
can be any of the following:
10
);1fr
);50%
);25w
/75h
);25vw
/75vh
); orauto
to compute the optimal size to fit without scrolling.A complete reference table and detailed explanations follow. You can skip to the examples.
Unit symbol Unit Example Description\"\"
Cell 10
Number of cells (rows or columns). \"fr\"
Fraction 1fr
Specifies the proportion of space the widget should occupy. \"%\"
Percent 75%
Length relative to the container widget. \"w\"
Width 25w
Percentage relative to the width of the container widget. \"h\"
Height 75h
Percentage relative to the height of the container widget. \"vw\"
Viewport width 25vw
Percentage relative to the viewport width. \"vh\"
Viewport height 75vh
Percentage relative to the viewport height. - Auto auto
Tries to compute the optimal size to fit without scrolling."},{"location":"css_types/scalar/#cell","title":"Cell","text":"The number of cells is the only unit for a scalar that is absolute. This can be an integer or a float but floats are truncated to integers.
If used to specify a horizontal length, it corresponds to the number of columns. For example, in width: 15
, this sets the width of a widget to be equal to 15 cells, which translates to 15 columns.
If used to specify a vertical length, it corresponds to the number of lines. For example, in height: 10
, this sets the height of a widget to be equal to 10 cells, which translates to 10 lines.
The unit fraction is used to represent proportional sizes.
For example, if two widgets are side by side and one has width: 1fr
and the other has width: 3fr
, the second one will be three times as wide as the first one.
The percent unit matches a <percentage>
and is used to specify a total length relative to the space made available by the container widget.
If used to specify a horizontal length, it will be relative to the width of the container. For example, width: 50%
sets the width of a widget to 50% of the width of its container.
If used to specify a vertical length, it will be relative to the height of the container. For example, height: 50%
sets the height of a widget to 50% of the height of its container.
The width unit is similar to the percent unit, except it sets the percentage to be relative to the width of the container.
For example, width: 25w
sets the width of a widget to 25% of the width of its container and height: 25w
sets the height of a widget to 25% of the width of its container. So, if the container has a width of 100 cells, the width and the height of the child widget will be of 25 cells.
The height unit is similar to the percent unit, except it sets the percentage to be relative to the height of the container.
For example, height: 75h
sets the height of a widget to 75% of the height of its container and width: 75h
sets the width of a widget to 75% of the height of its container. So, if the container has a height of 100 cells, the width and the height of the child widget will be of 75 cells.
This is the same as the width unit, except that it is relative to the width of the viewport instead of the width of the immediate container. The width of the viewport is the width of the terminal minus the widths of widgets that are docked left or right.
For example, width: 25vw
will try to set the width of a widget to be 25% of the viewport width, regardless of the widths of its containers.
This is the same as the height unit, except that it is relative to the height of the viewport instead of the height of the immediate container. The height of the viewport is the height of the terminal minus the heights of widgets that are docked top or bottom.
For example, height: 75vh
will try to set the height of a widget to be 75% of the viewport height, regardless of the height of its containers.
This special value will try to calculate the optimal size to fit the contents of the widget without scrolling.
For example, if its container is big enough, a label with width: auto
will be just as wide as its text.
Horizontal {\nwidth: 60; /* 60 cells */\nheight: 1fr; /* proportional size of 1 */\n}\n
"},{"location":"css_types/scalar/#python","title":"Python","text":"widget.styles.width = 16 # Cell unit can be specified with an int/float\nwidget.styles.height = \"1fr\" # proportional size of 1\n
"},{"location":"css_types/text_align/","title":"<text-align>","text":"The <text-align>
CSS type represents alignments that can be applied to text.
Warning
Not to be confused with the text-align
CSS rule that sets the alignment of text in a widget.
A <text-align>
can be any of the following values:
center
Center alignment. end
Alias for right
. justify
Text is justified inside the widget. left
Left alignment. right
Right alignment. start
Alias for left
. Tip
The meanings of start
and end
will likely change when RTL languages become supported by Textual.
Label {\ntext-align: justify;\n}\n
"},{"location":"css_types/text_align/#python","title":"Python","text":"widget.styles.text_align = \"justify\"\n
"},{"location":"css_types/text_style/","title":"<text-style>","text":"The <text-style>
CSS type represents styles that can be applied to text.
Warning
Not to be confused with the text-style
CSS rule that sets the style of text in a widget.
A <text-style>
can be the value none
for plain text with no styling, or any space-separated combination of the following values:
bold
Bold text. italic
Italic text. reverse
Reverse video text (foreground and background colors reversed). strike
Strikethrough text. underline
Underline text."},{"location":"css_types/text_style/#examples","title":"Examples","text":""},{"location":"css_types/text_style/#css","title":"CSS","text":"#label1 {\n/* You can specify any value by itself. */\nrule: strike;\n}\n#label2 {\n/* You can also combine multiple values. */\nrule: strike bold italic reverse;\n}\n
"},{"location":"css_types/text_style/#python","title":"Python","text":"# You can specify any value by itself\nwidget.styles.text_style = \"strike\"\n# You can also combine multiple values\nwidget.styles.text_style = \"strike bold italic reverse\n
"},{"location":"css_types/vertical/","title":"<vertical>","text":"The <vertical>
CSS type represents a position along the vertical axis.
The <vertical>
type can take any of the following values:
bottom
Aligns at the bottom of the vertical axis. middle
Aligns in the middle of the vertical axis. top
(default) Aligns at the top of the vertical axis."},{"location":"css_types/vertical/#examples","title":"Examples","text":""},{"location":"css_types/vertical/#css","title":"CSS","text":".container {\nalign-vertical: top;\n}\n
"},{"location":"css_types/vertical/#python","title":"Python","text":"widget.styles.align_vertical = \"top\"\n
"},{"location":"events/","title":"Events","text":"A reference to Textual events.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"events/blur/","title":"Blur","text":"The Blur
event is sent to a widget when it loses focus.
No other attributes
"},{"location":"events/blur/#code","title":"Code","text":""},{"location":"events/blur/#textual.events.Blur","title":"textual.events.Blurclass
","text":" Bases: Event
Sent when a widget is blurred (un-focussed).
The Click
event is sent to a widget when the user clicks a mouse button.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/click/#code","title":"Code","text":""},{"location":"events/click/#textual.events.Click","title":"textual.events.Click class
","text":" Bases: MouseEvent
Sent when a widget is clicked.
The DescendantBlur
event is sent to a widget when one of its children loses focus.
No other attributes
"},{"location":"events/descendant_blur/#code","title":"Code","text":""},{"location":"events/descendant_blur/#textual.events.DescendantBlur","title":"textual.events.DescendantBlurclass
","text":" Bases: Event
Sent when a child widget is blurred.
property
","text":"control: Widget\n
The widget that was blurred (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was blurred.
"},{"location":"events/descendant_blur/#see-also","title":"See also","text":"The DescendantFocus
event is sent to a widget when one of its descendants receives focus.
No other attributes
"},{"location":"events/descendant_focus/#code","title":"Code","text":""},{"location":"events/descendant_focus/#textual.events.DescendantFocus","title":"textual.events.DescendantFocusclass
","text":" Bases: Event
Sent when a child widget is focussed.
property
","text":"control: Widget\n
The widget that was focused (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was focused.
"},{"location":"events/descendant_focus/#see-also","title":"See also","text":"The Enter
event is sent to a widget when the mouse pointer first moves over a widget.
No other attributes
"},{"location":"events/enter/#code","title":"Code","text":""},{"location":"events/enter/#textual.events.Enter","title":"textual.events.Enterclass
","text":" Bases: Event
Sent when the mouse is moved over a widget.
The Focus
event is sent to a widget when it receives input focus.
No other attributes
"},{"location":"events/focus/#code","title":"Code","text":""},{"location":"events/focus/#textual.events.Focus","title":"textual.events.Focusclass
","text":" Bases: Event
Sent when a widget is focussed.
The Hide
event is sent to a widget when it is hidden from view.
No additional attributes
"},{"location":"events/hide/#code","title":"Code","text":""},{"location":"events/hide/#textual.events.Hide","title":"textual.events.Hideclass
","text":" Bases: Event
Sent when a widget has been hidden.
A widget may be hidden by setting its visible
flag to False
, if it is no longer in a layout, or if it has been offset beyond the edges of the terminal.
The Key
event is sent to a widget when the user presses a key on the keyboard.
key
str Name of the key that was pressed. char
str or None The character that was pressed, or None it isn't printable."},{"location":"events/key/#code","title":"Code","text":""},{"location":"events/key/#textual.events.Key","title":"textual.events.Key class
","text":"def __init__(self, key, character):\n
Bases: InputEvent
Sent when the user hits a key on the keyboard.
key
str
The key that was pressed.
requiredcharacter
str | None
A printable character or None
if it is not printable.
aliases
list[str]
The aliases for the key, including the key itself.
"},{"location":"events/key/#textual.events.Key.is_printable","title":"is_printableproperty
","text":"is_printable: bool\n
Check if the key is printable (produces a unicode character).
Returns Type Descriptionbool
True if the key is printable.
"},{"location":"events/key/#textual.events.Key.name","title":"nameproperty
","text":"name: str\n
Name of a key suitable for use as a Python identifier.
"},{"location":"events/key/#textual.events.Key.name_aliases","title":"name_aliasesproperty
","text":"name_aliases: list[str]\n
The corresponding name for every alias in aliases
list.
The Leave
event is sent to a widget when the mouse pointer moves off a widget.
No other attributes
"},{"location":"events/leave/#code","title":"Code","text":""},{"location":"events/leave/#textual.events.Leave","title":"textual.events.Leaveclass
","text":" Bases: Event
Sent when the mouse is moved away from a widget.
The Load
event is sent to the app prior to switching the terminal to application mode.
The load event is typically used to do any setup actions required by the app that don't change the display.
No additional attributes
"},{"location":"events/load/#code","title":"Code","text":""},{"location":"events/load/#textual.events.Load","title":"textual.events.Loadclass
","text":" Bases: Event
Sent when the App is running but before the terminal is in application mode.
Use this event to run any set up that doesn't require any visuals such as loading configuration and binding keys.
The Mount
event is sent to a widget and Application when it is first mounted.
The mount event is typically used to set the initial state of a widget or to add new children widgets.
No additional attributes
"},{"location":"events/mount/#code","title":"Code","text":""},{"location":"events/mount/#textual.events.Mount","title":"textual.events.Mountclass
","text":" Bases: Event
Sent when a widget is mounted and may receive messages.
The MouseCapture
event is sent to a widget when it is capturing mouse events from outside of its borders on the screen.
mouse_position
Offset Mouse coordinates when the mouse was captured"},{"location":"events/mouse_capture/#code","title":"Code","text":""},{"location":"events/mouse_capture/#textual.events.MouseCapture","title":"textual.events.MouseCapture class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Sent when the mouse has been captured.
When a mouse has been captured, all further mouse events will be sent to the capturing widget.
Parameters Name Type Description Defaultmouse_position
Offset
The position of the mouse when captured.
required"},{"location":"events/mouse_down/","title":"MouseDown","text":"The MouseDown
event is sent to a widget when a mouse button is pressed.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_down/#code","title":"Code","text":""},{"location":"events/mouse_down/#textual.events.MouseDown","title":"textual.events.MouseDown class
","text":" Bases: MouseEvent
Sent when a mouse button is pressed.
The MouseMove
event is sent to a widget when the mouse pointer is moved over a widget.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_move/#code","title":"Code","text":""},{"location":"events/mouse_move/#textual.events.MouseMove","title":"textual.events.MouseMove class
","text":" Bases: MouseEvent
Sent when the mouse cursor moves.
The MouseRelease
event is sent to a widget when it is no longer receiving mouse events outside of its borders.
mouse_position
Offset Mouse coordinates when the mouse was released"},{"location":"events/mouse_release/#code","title":"Code","text":""},{"location":"events/mouse_release/#textual.events.MouseRelease","title":"textual.events.MouseRelease class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Mouse has been released.
mouse_position
Offset
The position of the mouse when released.
required"},{"location":"events/mouse_scroll_down/","title":"MouseScrollDown","text":"The MouseScrollDown
event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved down.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget"},{"location":"events/mouse_scroll_down/#code","title":"Code","text":""},{"location":"events/mouse_scroll_down/#textual.events.MouseScrollDown","title":"textual.events.MouseScrollDown class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled down.
The MouseScrollUp
event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved up.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget"},{"location":"events/mouse_scroll_up/#code","title":"Code","text":""},{"location":"events/mouse_scroll_up/#textual.events.MouseScrollUp","title":"textual.events.MouseScrollUp class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled up.
The MouseUp
event is sent to a widget when the user releases a mouse button.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_up/#code","title":"Code","text":""},{"location":"events/mouse_up/#textual.events.MouseUp","title":"textual.events.MouseUp class
","text":" Bases: MouseEvent
Sent when a mouse button is released.
The Paste
event is sent to a widget when the user pastes text.
text
str The text that was pasted"},{"location":"events/paste/#code","title":"Code","text":""},{"location":"events/paste/#textual.events.Paste","title":"textual.events.Paste class
","text":"def __init__(self, text):\n
Bases: Event
Event containing text that was pasted into the Textual application. This event will only appear when running in a terminal emulator that supports bracketed paste mode. Textual will enable bracketed pastes when an app starts, and disable it when the app shuts down.
text
str
The text that has been pasted.
required"},{"location":"events/resize/","title":"Resize","text":"The Resize
event is sent to a widget when its size changes and when it is first made visible.
size
Size The new size of the Widget virtual_size
Size The virtual size (scrollable area) of the Widget container_size
Size The size of the container (parent widget)"},{"location":"events/resize/#code","title":"Code","text":""},{"location":"events/resize/#textual.events.Resize","title":"textual.events.Resize class
","text":"def __init__(self, size, virtual_size, container_size=None):\n
Bases: Event
Sent when the app or widget has been resized.
size
Size
The new size of the Widget.
requiredvirtual_size
Size
The virtual size (scrollable size) of the Widget.
requiredcontainer_size
Size | None
The size of the Widget's container widget.
None
"},{"location":"events/screen_resume/","title":"ScreenResume","text":"The ScreenResume
event is sent to a Screen when it becomes current.
No other attributes
"},{"location":"events/screen_resume/#code","title":"Code","text":""},{"location":"events/screen_resume/#textual.events.ScreenResume","title":"textual.events.ScreenResumeclass
","text":" Bases: Event
Sent to screen that has been made active.
The ScreenSuspend
event is sent to a Screen when it is replaced by another screen.
No other attributes
"},{"location":"events/screen_suspend/#code","title":"Code","text":""},{"location":"events/screen_suspend/#textual.events.ScreenSuspend","title":"textual.events.ScreenSuspendclass
","text":" Bases: Event
Sent to screen when it is no longer active.
The Show
event is sent to a widget when it becomes visible.
No additional attributes
"},{"location":"events/show/#code","title":"Code","text":""},{"location":"events/show/#textual.events.Show","title":"textual.events.Showclass
","text":" Bases: Event
Sent when a widget has become visible.
These are the examples from the documentation, used to generate screenshots.
You can run them with the textual CLI.
For example:
textual run text_style.py\n
"},{"location":"guide/","title":"Guide","text":"Welcome to the Textual Guide! An in-depth reference on how to build apps with Textual.
"},{"location":"guide/#example-code","title":"Example code","text":"Most of the code in this guide is fully working\u2014you could cut and paste it if you wanted to.
Although it is probably easier to check out the Textual repository and navigate to the docs/examples/guide
directory and run the examples from there.
Textual uses CSS to apply style to widgets. If you have any exposure to web development you will have encountered CSS, but don't worry if you haven't: this chapter will get you up to speed.
VSCode User?
The official Textual CSS extension adds syntax highlighting for both external files and inline CSS.
"},{"location":"guide/CSS/#stylesheets","title":"Stylesheets","text":"CSS stands for Cascading Stylesheet. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies styles to widgets, but otherwise it is the same idea.
Let's look at some Textual CSS.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
This is an example of a CSS rule set. There may be many such sections in any given CSS file.
Let's break this CSS code down a bit.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
The first line is a selector which tells Textual which widget(s) to modify. In the above example, the styles will be applied to a widget defined by the Python class Header
.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
The lines inside the curly braces contains CSS rules, which consist of a rule name and rule value separated by a colon and ending in a semicolon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semicolons.
The first rule in the above example reads \"dock: top;\"
. The rule name is dock
which tells Textual to place the widget on an edge of the screen. The text after the colon is top
which tells Textual to dock to the top of the screen. Other valid values for dock
are \"right\", \"bottom\", or \"left\"; but \"top\" is most appropriate for a header.
The DOM, or Document Object Model, is a term borrowed from the web world. Textual doesn't use documents but the term has stuck. In Textual CSS, the DOM is an arrangement of widgets you can visualize as a tree-like structure.
Some widgets contain other widgets: for instance, a list control widget will likely also have item widgets, or a dialog widget may contain button widgets. These child widgets form the branches of the tree.
Let's look at a trivial Textual app.
dom1.pyOutputfrom textual.app import App\nclass ExampleApp(App):\npass\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
ExampleApp
This example creates an instance of ExampleApp
, which will implicitly create a Screen
object. In DOM terms, the Screen
is a child of ExampleApp
.
With the above example, the DOM will look like the following:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nM1Ya0/jOFx1MDAxNP3Or6i6X3YlXGKOY8fxSKtcdTAwMTXPpSywo1x1MDAwMVxyj9VcYrmJaT3Na1x1MDAxMpfHIP77XqdMXHUwMDFlbVxiZVx1MDAxN0ZEUZv4OtfX1+fc4+R+pdfr67tU9j/0+vLWXHUwMDE3oVxuMnHTXzXt1zLLVVx1MDAxMoNcdFx1MDAxN/d5Ms38oudY6zT/sL5cdTAwMWWJbFwidVx1MDAxYVxuX1rXKp+KMNfTQCWWn0TrSsso/8P8XHUwMDFliUj+niZRoDOrXHUwMDFhZE1cdTAwMDZKJ9lsLFx1MDAxOcpIxjpcdTAwMDfv/8B9r3df/NaiXHUwMDBilIiSOCi6XHUwMDE3hlp4njvfepTERaiUIVx1MDAwN3PqkbKDyrdhMC1cdTAwMDOwXkHAsrKYpn56sbN1pD45eiNcdTAwMWKyfNP5Nvy6d1WNeqXC8FjfhbM8XGJ/PM1kZc11lkzkqVxu9Fx1MDAxOOz2XFx7+VxcnkBcbqqnsmQ6XHUwMDFhxzLPXHUwMDFizySp8JW+M21cYpWtXCJcdTAwMWVcdTAwMTU+qpZbk1x1MDAwMeJamHmO5zpcdTAwMGV1XHUwMDEwqc23cECY5VLsXHUwMDEwh9G5mLaSXHUwMDEw1lx1MDAwMGL6XHUwMDA1XHUwMDE1R1x1MDAxNdVQ+JNcdTAwMTGEXHUwMDE2XHUwMDA3VVx1MDAxZlx1MDAwZvvcrs335sdMa1x1MDAwM46lXHUwMDFhjbVpxNjyXHUwMDEwcT1GZ75r+ZBF/m3P5pRcdTAwMTKMcWkxI6aDoFx1MDAwMMKX+fyNRZY+5qmfm5tatCbQnXlcdTAwMTTVkVRbY+dcIkv5vlx1MDAxYVxmvk7GfyX88CxcdTAwMWRcdTAwMGZcdTAwMGVLX1xy2Gl5q/ul4WG1y+2Ze1x1MDAxMm1cdTAwMGUv7evp9v6BPls7+8jRfrtbkWXJzfN+XHUwMDFiUawuO5HK7eNVlchpXHUwMDFhiFx1MDAxOfZt10XE5sjjXHUwMDBl4aU9VPFcdTAwMDSM8TRcZqu2xJ9UdFmpxbtA0kacdYba5CmG2thQXHUwMDE0XHUwMDEw4i1N0e7le69cdTAwMTSldidFObeAXG6GLP+HoTpcdTAwMTNxnopcZljQwlLWxlK+wErmeraDXFxcdTAwMWK9Piu7kMihOr1cdTAwMDSJ1YInsT5W31x1MDAwYjS5XHUwMDE2hWKEsIsw41x1MDAxY1HW6LUrXCJcdTAwMTXeNdawgCxEvnMrojSUXHUwMDFiafrrb/VcdTAwMTTnXHUwMDEyXCIpXFyTxjNcdTAwMWKhXHUwMDFhXHUwMDE5aPd9mJvMXHUwMDFhqNdcbkSu7Fx1MDAxMKkgXGJrXGL0IVx1MDAxMFx1MDAwMT6zwTKCk2RqpGJcdTAwMTGetMXZScZM+nqGxVx1MDAxNkZS+qRmYlx1MDAwNCDkUJXdpVx1MDAxOXn+PdGXXyfDk+PRwblzQsefkvPLd89IXHUwMDE3W8hlhHheXHUwMDFiI1x1MDAxZNuxXHUwMDEwI9h+U0pSukhJj0GlmFx1MDAxM+tHalx1MDAwMqRcdTAwMTHFXHUwMDFlcV+fml3KXHUwMDE27MfnQ0rOXHUwMDBmtlx1MDAwMrw33tldu9zDn9+jYM78nu5/vr45INuHXHUwMDA3XHUwMDE5XHL+vMNTTLbdV/CLT4PB3u7EP/Q2iH1cdTAwMTKFf+/EXHUwMDE3ozdcdTAwMTX49sS/QOCZkVZe7a/eSOBcdPXmW3+UXHUwMDEzwinUYUKX34J3o+3dVlx1MDAxM9ZZTVxisZhdaNzbXHUwMDE1XHUwMDEz0lJMsDNfREBcdTAwMWFhXHUwMDE3wp2fKu8vx2GbvGPUaO2Q82M/kzJ+SspZo/+rSfkzMjgv5WWMnZSbVZJcdTAwMTbOMfxcdTAwMTTlQCZAv+FcXF7Bu0vxO+Wc43BcdTAwMGJe7lx1MDAxMXNaOYdcdTAwMTm1XFzOjYJcdTAwMTNujjdjXHUwMDFlslxid5vkLlx06Fx1MDAxMIsz7FJcdTAwMTcvyLlcdTAwMDebXuDGf9loXHUwMDE3wf1sJuZaZHpTxYGKR2CslFxm2OhPzbhryEKO7VLCoVx1MDAxNlKOXHTyylmb6YnU7D0tXHUwMDAyckBcdTAwMWPYg1x1MDAxYYxWr5+98kNQ19b4sXMpqX1cdTAwMTlcdTAwMDfPXHUwMDA2hThUX8Tg1Vx1MDAwME7KmLdcdTAwMTBcdTAwMTW24LWh2HVcdTAwMTXfKmyHPVx1MDAxNVY7zVx1MDAxN8JcbkWut5IoUlx1MDAxYdL/MVGxnk9zkc9ccsPvsVx1MDAxNMG8XHUwMDE1plW3zVx1MDAxN4LUeGzu3KqrXsWU4qa8/rLa2nttXHUwMDExweaoYbfysFL/NzuQwmdfpOmxXHUwMDA2pJVrXHUwMDAwYFbBY+GuJta/VvJms+Xb0lVxmDRcdTAwMTYpNCVHmundP6w8/Fx1MDAwYlxiYlx1MDAxObwifQ== ExampleApp()Screen()This doesn't look much like a tree yet. Let's add a header and a footer to this application, which will create more branches of the tree:
dom2.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass ExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
ExampleApp \u2b58ExampleApp
With a header and a footer widget the DOM looks like this:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1aa0/bSFx1MDAxNP3Or0DZL7tS4877UWm1glx1MDAxNpZ3aWFcdTAwMDNlVVWuPSReP2s7QKj633dsgu28XHUwMDFjXHUwMDEzXHUwMDEyNpXWQiGZmdy5nrnnzLk3/r6xudlKXHUwMDA3kWq92WypO8v0XHUwMDFjOzZvW6+y9lx1MDAxYlx1MDAxNSdOXHUwMDE46C6Uf07CfmzlI3tpXHUwMDFhJW9ev/bN2FVp5JmWMm6cpG96Sdq3ndCwQv+1kyo/+SN7PTF99XtcdTAwMTT6dlx1MDAxYVx1MDAxYuUkbWU7aVx1MDAxOD/MpTzlqyBNtPW/9efNze/5a8U72zH9MLDz4XlHxT0hx1tPwiB3XHUwMDE1Ulx1MDAwNLEkklx1MDAxNFx1MDAwM5zknZ4sVbbuvdZcdTAwMGWrsidralxyTi//7Jn/qItLwY5Odq7hh17nvJz12vG8s3TgPayDafX6sSp7kzRcdTAwMGVddeHYaS+bfKy9+F5cdTAwMTLqJSi/XHUwMDE1h/1uL1BJMvKdMDItJ1x1MDAxZOg2XG6KRjPo5ibKlrtsXHUwMDAxIDVcdTAwMTBcdTAwMTdYMIwpXHUwMDA2RJS3m31fSINRhFx05nTMo7ehp3dAe/RcdTAwMGLIr9Knr6bldrVjgV2OXHUwMDExyJKwcre3j/dZma+nnG4vzVx1MDAxYVx1MDAxMTJcdTAwMDQgTHD6YLuyXHUwMDFhKl99yCDjknAhip5sxmjfzsPg8/jq9cw4XHUwMDFhrlIryT5UvM1cdTAwMWPdXHUwMDE5j6FqXHUwMDFjVXbY/bgtrlx1MDAwZTBoR9euc9x7f+6eqm+FrZGgS9Vd2io6fryqM1x1MDAwYjto76vf+3JLOsHX+Pro+vh+f3u6WTOOw9umdpfu7sjoV00nLM1cdTAwMGXflfvTj2wzXHUwMDFkbilcdTAwMDNcdTAwMDTpXHUwMDAwpITgot9zXHUwMDAyV3dcdTAwMDZ9zyvbQsstMbhR8XdcdTAwMDL5I35WYVx1MDAwZsFM2DMuJEKYoca4r1/mNcU9XHUwMDAydbiHXHUwMDA0XHUwMDFhlOZcdTAwMTB8XHUwMDBl7tPYXGaSyIw1tqZgn0/Dvlx1MDAxY8c6oYBwKjFaPtSXXHUwMDE5h+V2h0F65tw/xJJBNcNcdTAwMDHEXHUwMDAw4lJcdTAwMDLKR0btmr7jXHJGdjBcdTAwMGZY7fnOnelHntqKol9/q65worQnuWky8p0tz+lmgd2y9L2peCTmU0efm8VcdTAwMDDfsW2vXHUwMDEyf5Z2xNQ24/0mZ1hcdTAwMTg7XScwvfNpftZCMVZW+lx1MDAxMIpT8EhcdTAwMTmcjcd87Vx1MDAwNGON8eh9ROdcdTAwMDcn51dXXHUwMDFjfGA+3btcbkkvXXc8YmhcdTAwMDDGXHRcdTAwMTFiXHUwMDFhXHUwMDFlXHUwMDExpVx1MDAwNuBcdTAwMDTBlVx1MDAwMpLSSUBcbq55YkxcdTAwMDA8XHUwMDFlwlx1MDAxNHLOKV9cdTAwMDEy6061I6ftfzlcdTAwMWFcdTAwMWO+9c47g0/3XHUwMDFkeb69dbK+h/DFQefm9oi8Oz6Kqf3nXHUwMDAw9Vx1MDAxMXnHlmBcdTAwMTdd2Pt7u651LLZcYjz3vfc7wVV3XHR2l76880TD9Fx0XHUwMDFiekturtt+6rZcdTAwMDdcdTAwMWbIXHUwMDE3c0dYd+q47y9hXHUwMDE1tlx1MDAwZb+ddtPww5dTR4pcdTAwMDO307uEnzrN7DZcdTAwMTE5XHUwMDE4gVx1MDAxMjUrXHUwMDEyOYTNXHUwMDE2OZhcbkkolOWIeaRaXHUwMDFmXHUwMDE260qqrJZUXHUwMDA1MzhcdTAwMDTymclNPaeSKZyKSmHxyKVQICQpYCtIaJZcdTAwMTmI01RcdTAwMGVcdTAwMDIjrTWq5syKlVxuZilcdTAwMWE+Mn5pimaOXHUwMDFhXHUwMDE4VzSFj7WYe8D8XHUwMDE00HExXHUwMDEzc1x1MDAxMFx1MDAxMEq0lm1eUKg/ktZcdTAwMTNzXHUwMDE4XGJDUlx1MDAwMTiejjnIXHImZSZkiMyulSFcdTAwMGZcdTAwMThEslFwXHUwMDE3XHUwMDAwxMSQXHUwMDFjMcrQpKrRniGh4bhcdTAwMDBcdTAwMTJz71x1MDAxNkWiwIwvgsQkNeN021x0bCfo6s7yLNNotPrZvG1gXHUwMDAwrNVcdTAwMWGRmlxmqURcdTAwMDSI4raz2zOjbGNcckKylIdSJlx0YkRWRlxmS2x1XHUwMDE5wnBwcai2VGDPdVxuSE2/gOtcZkn/UV5ip/Bcblx1MDAxOTp7ytVnXlx1MDAwN4KYz3JrOswn3PLMJH1cdTAwMWL6vpPq5T9ccp0gXHUwMDFkX+Z8PbcyfPeUaY/36tuq9o1cdTAwMTNBlFlcdTAwMWNVsOW7zVx1MDAxMir5h+L951dTR7cnQzi7KsFbWtio/l8oXHUwMDA3g1x1MDAwMM1OwpD2g2KEmlx1MDAxN0VOXHUwMDBmXHUwMDA3b4OrvnQv/Y8n9uG9+5f7z81/y1061uaQXHUwMDE31OSFMIJcdTAwMDRcdMr1a4XNM1x1MDAwM4RcdTAwMTBDY4Q9dldy0v84XHUwMDE304QlKIa0dOhFUjHn6F03OtlNXHUwMDBmLv0t92T70N/12zPU9/+p2NPtrmh5l252XoY3fcKG3j4jw3skxdnHblx1MDAwNqfKT0ArysQkJuOtXHUwMDE1ZoX64GWoeXmrfvvWlVkhrmVWzlxmJiBmkIFVM2uzjFxmccZYRqsvmpA9OVx1MDAxZZ+XkO1pXHUwMDE1o+JcdTAwMTdOyOYog/GErPDxXHUwMDE50oaBurSMIy2NafNSiJfsXHUwMDFlmGfx9o06oJ3j+z3w7XhcdTAwMWKsO1x1MDAwMDFhXHUwMDA2gZzAXHUwMDFjX9mvXHUwMDFiY9KGXHUwMDFiWId8MWBdlI3kOkmQ1VrWiyib40+nZF/sWIdcdTAwMDNcdTAwMTd+ci/aN1F/XHUwMDAw/1c2y1I2K1ren8XsPME0fcKG3q60dI04rXLNilx1MDAwNFx1MDAxM6SYjTdcdTAwMTeEzTHQvoAnKKb6/VtXwqawlrC5NCjEWFx1MDAwZVx1MDAwNdVcblx0u2FcdDvLPzlcdTAwMDGlmy8jmJ5cdTAwMTiPz1x1MDAxM0y7YZi+uGCaozfGXHUwMDA1U+FjLfRmVrBcdTAwMTma/UicoEBcdTAwMTJcZnDzXHUwMDEydn32tq7QXHUwMDAzwFx1MDAxMFRcYplVQ1x1MDAwNVx1MDAxNmhcdTAwMDR5WEslwXV+wIfIw6uDXHUwMDFlXHUwMDAyhqSMS0klg1JCMYlEgVxyqZNIJFx1MDAxONY+M4nGgUmAhFx1MDAxNEm0XHUwMDAwMJ9R0F48k5ld0G5Q8C2PuWqlmVBcdTAwMDBcdTAwMDGlXHUwMDAy6pVgXGLDyqjH6jdFXHUwMDE0wmH2KTBcdTAwMWVcdTAwMGWYX89cdTAwMWXxqT61XHUwMDE59YkhoONcZnFJXHUwMDAw1uGEJnyCyOA6USZMZ8Ukq1x1MDAxM6BcdKd+qmr27GDOrokwLu1tVP8/mc8gwLNcdI3RXGbmXFw011x1MDAxMvXqal1cdFxyXHUwMDBiXHUwMDAzScFcdTAwMDVikmnJUFx1MDAxZVRcdTAwMGaEpqVcdTAwMDTCNPuVnFxiQenqXGJNYoNBiHQ8M4xw5cHeks6kgVx1MDAwNOeSUo6ZoHLy2V/B9Z2QhTLCZ/HZokJj2XxcdTAwMDZcZk1j2lx1MDAxYlx1MDAwMfR2MSRk+ZxiwVx1MDAxZNyA+TOTXHUwMDEwPGzognxWrzxGfKJASL1OTGtcdTAwMDRcbinhXHUwMDEzLlx0gzJ9XGZcdTAwMDGYXHUwMDFmm1iIn5rNZlx1MDAwNXJ2TYTwLCrbXHUwMDE4mm+ZUXSW6ngrtkKHtGNcdTAwMGbVaXmPrVx1MDAxYkfdbk95vP46vzLBl69mxkIqu9PvPzZ+/Fx1MDAwYlx0sVx1MDAwYuIifQ== ExampleApp()Screen()Header()Footer()Note
We've simplified the above example somewhat. Both the Header and Footer widgets contain children of their own. When building an app with pre-built widgets you rarely need to know how they are constructed unless you plan on changing the styles of individual components.
Both Header and Footer are children of the Screen object.
To further explore the DOM, we're going to build a simple dialog with a question and two buttons. To do this we're going to import and use a few more builtin widgets:
textual.layout.Container
For our top-level dialog.textual.layout.Horizontal
To arrange widgets left to right.textual.widgets.Static
For simple content.textual.widgets.Button
For a clickable button.from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal\nfrom textual.widgets import Button, Footer, Header, Static\nQUESTION = \"Do you want to learn about Textual CSS?\"\nclass ExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nyield Container(\nStatic(QUESTION, classes=\"question\"),\nHorizontal(\nButton(\"Yes\", variant=\"success\"),\nButton(\"No\", variant=\"error\"),\nclasses=\"buttons\",\n),\nid=\"dialog\",\n)\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example.
Here's the DOM created by the above code:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1d+1PiyFx1MDAxNv59/oop95e9VWO2+/T7Vt265Vx1MDAwYnVcdTAwMWSfOFx1MDAwYnrnllx1MDAxNSFcdTAwMDIjLyGIurX/+z1cdTAwMWSVXHUwMDA0Qlx1MDAwMihB2DvU7qhJ6Jx0n/Od7+tX/vz0+fOa/9j21v75ec17KLn1Wrnj9te+2OP3XqdbazXxXHUwMDE0XHUwMDA0f3dbvU4puLLq++3uP3/7reF2bj2/XXdLnnNf6/bcetfvlWstp9Rq/FbzvUb33/bfI7fh/avdapT9jlx1MDAxM95k3SvX/Fbn+V5e3Wt4Tb+Lpf9cdTAwMDf//vz5z+DfiHXlmttoNcvB5cGJiHlGjlx1MDAxZT1qNVx1MDAwM1NccuGCXHUwMDAzIzC4oNbdxpv5Xlx1MDAxOc/eoMFeeMZcdTAwMWVa29y+K3b2XHUwMDFlL5t5wXP7f0C+d212w7ve1Or1vP9Yf65cdTAwMDe3VO11vPBs1++0br1CrexX8TxcdTAwMWQ5Pvhet4VVXHUwMDEwfqvT6lWqTa/bXHUwMDFk+k6r7ZZq/iNcdTAwMWVcdTAwMTNkcNBtVoJcIsIjXHUwMDBmwVx1MDAwNeBQJkBcdTAwMTCquDBE8cHp4PvUYYRqo1x1MDAxOFBcdTAwMGVajtq11apjO6Bdv5DgXHUwMDEzWnbtlm4raF6zXHUwMDFjXqOhZGjkmfuvT6uZwzlcdTAwMDPA21x1MDAxOGZcYojBJVWvVqn69lx1MDAxYVx1MDAwMEdcdTAwMTMutVx1MDAxMs+3ipjiXHUwMDA1TUJcdTAwMDVQ4FLqsEmtXHUwMDAx7f1y4Fx1MDAxYv9cdTAwMWSt0qrbab9U3VrX/lx1MDAxMTHe2r0z6lhR54o0u5bnXHUwMDFi5sdlsVrq7JOTk8vCuVuQg7KGPNH3XHUwMDFl/LXBib++pFx1MDAxNXt4uv90kXso+p1tb72a71x1MDAxNlx1MDAxNfCb8cW6nU6rP225XHUwMDE5mTv3Yoeu/jLtXHLDYl9+XHUwMDBim73XLrvPwUulJFxco1trqfXgfL3WvMWTzV69XHUwMDFlXHUwMDFla5Vuw3j/XHUwMDE0sTeGMkN2RiGG6NGjr1x1MDAxMCOAXHUwMDE50IqEXHUwMDBlO1x0YtJreVkhRqVBXGZFXHUwMDA0XHUwMDAywoFTot6PMX7HbXbbblx1MDAwN1x1MDAwM3dcZs6oyThcdTAwMDNxXFxB41xmXHUwMDEwakK754Yr8/TO0Fx1MDAwYlpNP1978oKyXHUwMDFjQTUnIFx0KGOIUENX5dxGrf441LCBXHUwMDFio+VcdTAwMWLt9q//iFZ110NcdTAwMTOCMsXQxVx1MDAxYvVaxfr5Wlx0XHUwMDFmyutcZoWAX8OUPbigUSuX61x1MDAxMX8soVx1MDAwNS6W2dmfJn22OrVKrenWz4dcZkxccsmOV/KffXJMXFxcbkWS4pIqIyhorvTUgVx0rdO9XHUwMDFi/fitcX2+sd+oXHUwMDFlVcVV/2TZXHUwMDAzkyrHXHUwMDAwZ1x1MDAxNORzYFx1MDAwZcUloO8oYFxu/3/O/dmFpVx1MDAxOFx1MDAxM4daOVx1MDAxOKacKVx1MDAxMYtHrlxyIZpcbj3/eEzLcGfyxtvtq1x1MDAxZtWzwl3ha+OSi527+vLm+cLvf9z3v/Ltw69cdTAwMWRR3n2EXHUwMDFl8O2EhDxTuVAo7+/lbkuHeoPT80b9eKd5WZlDuVx1MDAxOVVvRsWq+9pe7m6HNjo7V+Vt/1x1MDAxZe6P6/OoXHUwMDA1Tjpt2rwvXHUwMDFkXHUwMDE1clx1MDAwN0e6sF1oXHUwMDFk+lx1MDAwN+8qd1x1MDAxMo9cdTAwMWFfQVOae/2jsXFSv5dU+OubZVM5OGo9PE5n7rLwMy7V6NFBXHUwMDFlkIgykis2fVx1MDAxZUh3tyXNXHUwMDAzKK1S8lx1MDAwMNdcdTAwMGXD9jCv/Cy7PMDH8TFcdTAwMTbDf6VBWDKTgc7Lmo9cdTAwMDFcdTAwMTk6msK/8qWO5zWTKJhcdTAwMWG6fm5cdTAwMTRsXHUwMDAyi1x1MDAxOaVgXHUwMDAzXHUwMDFiU1x1MDAwM+858MdEnoakwFx1MDAwM+CCXHUwMDEzXHUwMDFkYdyT4i49i35I3FEyMfCMcDTlhqlxgYcs1GFcXFx1MDAwZlximMgs8IjDjSQmyrVcdTAwMDbxx7hjXHUwMDE0SCEhTsRcdTAwMDQzmnEuYfZAXGasW3Qgdn2342/WmuVas4Inw3yGwVjq2fuuXHUwMDEzhzAqXHUwMDA1R3RRXHUwMDAyQZGE1W5cdTAwMWbPbdtmQ1x1MDAxMSlQR1xuIVxyXHUwMDA3yU3kipfeyDRF83LxILGuec3yRKOIUehPXG6lXHUwMDFj/idUqExcdTAwMDZWgYMyLyDMQe9cdTAwMThlKsms8VFcdTAwMWUzq+52/a1Wo1HzsfpPWrWmP1rNQX1u2PCuem559Cw+VvTcKFx1MDAwZbRticOkO/ztc1x1MDAxOCnBXHUwMDFmg9//+2Xs1etxXHUwMDE3tp+I84YlfIr+fJN0pJSz0cOv0MUlw3yKXjo1dDVcdTAwMGVcbnDyXGJ721vffpQ2L2BcdTAwMDOKXHUwMDA172Ohi09CLkaZI1x1MDAxOJJcdTAwMDFuOOJTRIpcdTAwMDVfZ9rRRFx1MDAxM2OoXHUwMDA0TcjSSEfJXHUwMDA1YGTDgnuIycPW9UFLXlx1MDAxZJT13f3ZUfuGnlx1MDAxZPKfynFeyjGj6l2tYucvSCdcdMfxXHUwMDBmXHUwMDEyXHUwMDE2+4qzXHUwMDFmLfAoiYDEXGJaY1wipVxiUtN3wKe33rKCtU5cdTAwMDNrRVx1MDAxY8OxNZShXHUwMDAxWC+BwFx1MDAwM6Q7iohcdTAwMGbocDczeOP7XHUwMDA03lx1MDAxZdJcIq+zYIE3gWuMXG68gY3v4Epa0KTo41x1MDAxMt2NSja9zDvYOqnkjqhqyZvzvavTi2qp+vWDx78mhlx1MDAxZlx1MDAxMlBkQ1x1MDAwNFx1MDAwNZ4kmqlcYvlcYr7OiaMkRiVjWiFtJ9nJvFx1MDAxOclcdTAwMTJcYoPijrxB3L2HK12RnD686lx1MDAxNVx1MDAwZp425FaDbPbORK/4kyvNiytlVL0/i/2QvvvxXHUwMDBmMjNcdTAwMDWbJem9jYIpZUZcdTAwMGZcdTAwMGYmQVx1MDAxMKlcdTAwMTjRfHpcdTAwMGWW3nxLmlx1MDAwNFx1MDAxOE1LXHUwMDAyXG5cdTAwMWNtXGZRQFnWSWA6XHUwMDBlXHUwMDA2XHUwMDA0oVx1MDAxZqniR3SyL46D5Votf+FcdTAwMWNsXHUwMDAyh1x1MDAxOeVgXHUwMDAzXHUwMDFiUyMvuZNd8sTIk8LYvs7pIy9dZC7p6JamXHUwMDBlhlx1MDAxNuGGcEKJNEORx4hxJDOCaFx1MDAwM1ooynl2kWdQaFx1MDAxOabBSKBcXI2LQ2rn63BGXHUwMDE4wlx1MDAwMGgqqVx1MDAxOY1LKvCo5lGVtrA+9zfFZXKf+1x1MDAxNH3SYcqLdoZzQTUxQiCY2s68cETyc9hDLzmAeVx1MDAxZLJk7OWCyX3uQ0alq6Vho7CtXHUwMDEwuLlcdTAwMTRcdTAwMWFcdTAwMTRmu7hNXHUwMDE0XHUwMDFjXHRCXGKtJeVcdTAwMTRcZo/ZtFJcdTAwMWTuid5sP3E/XHUwMDBly/tcdTAwMTT9OTOcof8ndr1TJrF6XHKfYT5lOmVbTkCTTDvAqMK4tLOhR1x1MDAwMY1Sh1wi2+DUgLQ/Mlx1MDAwNDTJXHUwMDFkaWPMoFSkhof9SpFZ29QhdpYrXHUwMDA1jf9Hc81gNpfSXGZcdTAwMTBkVlx1MDAxZtBe0YA41FBlQYrhXHUwMDAzXHUwMDAzeiRcdTAwMGKD41x1MDAwNVxyXHUwMDE0Nlx1MDAxMuproGiMXHUwMDE2KP4jV7xlsG7SXHUwMDE4XCJxbIojXHUwMDFjMHtcYkFcdTAwMDVXMZO0g4RcdTAwMDBcdTAwMTDFlE2ETOskk8ZcdTAwMTOYlYazRFdcdTAwMGVOxpx4XmiGzZ9cdTAwMDRmKFx1MDAwNdArpJl+XHUwMDFjMX0u1pJOgUA1ZFeYIEkzXFxhK1xmz1xyt8OMhklCMckwxSC7rmnpXHUwMDE4XHUwMDE0XtyuMEF6xWSYZFwi5Fxmg1xiXHUwMDA1kUJYZaBRxsWwjGDcc6pM+IyrimVvJmdAQFAk2kZgXHUwMDE2UiQ+e8Jgi2rkb1x1MDAxYUOKMKNeqcGM3Kx0ly/+vv/Acr9fX5y6hzfXh+R4N8EmgsmQXCJhMVx1MDAwMVx1MDAxZFHAY0ZR7mhO0dHAXHUwMDAwIKc0sNJwtp7ozvZcdTAwMTN35Fx1MDAxOfEstcNcdTAwMWaISOzroUhJXHUwMDE4g1kmdqW385KiXHUwMDFh5n5cdTAwMDdcdTAwMTBcbjhcdTAwMTOgsFx1MDAxMUKkeJ5cdTAwMWVhXHUwMDFjhjrBKNQoQI1cdTAwMWGxa55cZi1EzLDHP0wpr7jFgGk0KFSkXHUwMDBi6ep/8vvC2z2R3fLvKt8/qNxvbVx1MDAxZW/+7OqfV1d/RtW7WsVmNU9/tWrhXHUwMDFk0/RcdTAwMTPKnTQyMf5BpjT3x7fqU1x1MDAxOfLFYt4tnpeqV4WN+lnCmo2ZXHUwMDFh7eBcXO+c9HKHXHUwMDA3Z113vXrQudl4qO9OV+5rXpwjXHUwMDBiS02xyatJVeJyXHUwMDA14NoopF7TS4Z0d1vW5FxuLC25XG6kcotJrmJMco0ssHxNrlx1MDAxMrOrnbKbQXbNeiSFyqGjKSMpW69jXHUwMDFjv35v2lx1MDAxM7Xyv77bjVx1MDAxN+qtyve1783xIyxcdTAwMDKGylx1MDAxOVxmoNS9m2Hnn2l8ZVx1MDAwMmNcdTAwMWNcdTAwMWRfmWj5O6gwJ4mDL+jAXHUwMDAwis6wvcTR3vXm7Teyv5477uzU1pv89NvjzrJcdTAwMDer5Kg17LwzrpRcclZcdTAwMThcblZhuGNQh2CsZlx1MDAxZKxcdTAwMTFNXHUwMDFlMuF4b6TQilx0XHUwMDEzXHUwMDFkgF1cYlx1MDAxNW7vNftcdTAwMTe3x9c7ZVLNre+vXHUwMDFmk3rx8CdcdTAwMTWeXHUwMDE3XHUwMDE1zqh6V6vYrKjwatXC/KlwRuZOYtjjb5g9XHUwMDEzTi13v3/WKPTFjr7eJFx1MDAxN/6RPKrRq4TJ7TOVu9voXHUwMDFll76dNd2LgtgvbNx5lav1/nTlLlxyc+cscVwirFCEK1x1MDAxMd1cdTAwMTllXHUwMDEyXHUwMDE5SHe3pSVcdTAwMDNcIoVcZkhiXHUwMDFjvlx1MDAxODKgx5CBMczdjklcdTAwMTi7XHUwMDA2/O/M3PeQXHUwMDEwP1lcdTAwMGVcXP/VXHUwMDFlfybBpbrb7XpdZMLXPd9vNbuLJvFcdTAwMTPIbmyi+lxmXHUwMDBm8Vx1MDAwZT4vk/eM4VRwXCJcdTAwMDUjU4dweX/f39l+2uHXrFuu7l+oRv/SXfZcdTAwMTBcdTAwMTZ2N1x1MDAwMCUkl1JcdTAwMDCG8PB4nZLS4YBS1273xIBnuJXTlHzeLmoxoFx1MDAxNzyJfbt597he8lxi9b8+Ulwi+24+d978SefnReczqt7VKjYrOr9atZBcdTAwMTWdX61aqJvNLX29u8W3pPTy51x1MDAwN1x1MDAwZpVcXG5KevxG9TH+Qf6P2Hx0ksdo11x1MDAxZWFMaVxyYno6n+5cdTAwMTfLylx1MDAwNVx1MDAwNEvjXHUwMDAyii6KXHUwMDBijKPzKsZcdTAwMDVcZppIuFAruKp0eja/XHUwMDE5MN1cYlx0/r524SHx/fL8173bqblNXHUwMDFmKXG3Vyrh0yXzejVcXPiceP1cdTAwMDTSO8rr3/Y472D4KD6TwtpQ6+UgZpiR97Tlnp6ap7PKZvGylds4h0b+dNmjWlx1MDAxOe1IXHUwMDAzxE5cdTAwMDWFmEhXUjh2wi5+1FIwfCCUMY46fcG7QbKvxW5+s974arbyxe5cdTAwMWb8x5Ms/tzTY25cdTAwMTQ/o+pdrWKzovirVVx1MDAwYllR/NWqhflT/IzMnaRcdTAwMWPG33BKa98xvvDy28crh2h/cmxcdTAwMDVcdTAwMTNcdTAwMDXgTNLplUN6+y0px9CEpXFcZkVcdTAwMTbFMaZTXHUwMDBlVFx1MDAxOY5qLjqz6v9DOlx1MDAxY7XGUG1cdTAwMGZcdTAwMDOrs2jdMIFKT6FcdTAwMWImPUtqMKeLXHUwMDA2XHUwMDAxyX1cdTAwMDFcdTAwMDJQz3NcdTAwMDPTr0ms9o+L9fouK/hf20y15GX+zr1e9ohmmjtcdTAwMTiqWmKYXHUwMDA0fVx1MDAwMWIopIWRjkBtpbnKXFw2jFm6M2ZgwFx1MDAxMIFBRt+yd+l7ZMN957Kfq2z+qKpK+3ZrY+Obq9tnP2XDvGRDRtW7WsVmJVx1MDAxYlarXHUwMDE2spJccqtSXHUwMDBik3j4+Fx1MDAxYi4hX1x1MDAxNjqRL2thqN1cdTAwMGV++uSaXs1Lm1xcTVpylYQuKrnqMcl1XGZfJswgX1ZvWFx1MDAwNrs6dDnvu8hgXyaNn37byZ/vXHUwMDFmXHUwMDFmfbF/jM4+uet5Xb+WOokmXHUwMDFi0jyBSca283/rXHUwMDEzpcZ14uJ3mVx1MDAxONacXHUwMDFhLYDNsJAlfcXQkoZ1sP0/U6hcdTAwMTCQjGL0XHUwMDBl78sqXHUwMDE4c4BJxlXmKlx1MDAxOIyDXHUwMDE1zijeXGLsmmFcdTAwMTVcdTAwMGZyyZ3xu0RyorRcdTAwMWHaROpvseR9luXlQjBit8IhWIfcaFx1MDAxYbnqdTtcIq00XHUwMDExxK7GJnY19stcdTAwMDVcdEveh1x1MDAxZmOVlp0ne5L9XGZ8KCznU/Tn7JtcdTAwMDFFXHUwMDEy/ygz0NpcbrxcdTAwMTmW16RPiV5SXGKRXHUwMDAwXHUwMDBlXHUwMDE3WNtCWM+iw3tcdTAwMDFcdCZcdTAwMWNcdEoxXHUwMDA1zDBcdTAwMDZmxK45QlxikY7dt9A6ONGM6DFcdTAwMTBcIrD57Yt77IxcdTAwMDBjKJD4XHUwMDBiRYDYxXJcdTAwMWM+4IVcIktcdTAwMDEmxOFKcFCSSqVcdTAwMTFRTPw1XHUwMDFm2lx1MDAwMWk4J4Tad3wgXHUwMDE3TMeSJJPS59eOmKSEXCJUMlwiqNAmvpdcdTAwMTF37I5mTHGBXHUwMDE2XHUwMDBiyVTMpFVcdTAwMDKxZFe2n7hcdTAwMTPPXHUwMDBizCRJ3jaDM/vuVD3DsED6QMmSoplcdTAwMTLSXHUwMDAxu1x1MDAxZj/XytjN3IZljlx1MDAwMcegrmBcdTAwMTK4nYmhR+ya42ZAxNFcdTAwMDKBk1x1MDAwMlD7hoKw2kM+JFx1MDAxY5RiRiq7ZT5nXHUwMDEw31ODW9/h5i1vjV1mLJtcdTAwMDE4wM7+4kiJhGG2VaPvSVx1MDAxYeyJSKyu5VxiL1x1MDAwNONN67ehWfrowDBbXHUwMDAzKkBSXHUwMDEwOliCQlh8IzTlIFx1MDAwMCuqXHUwMDA3RGU1gSzRi4OTo/47L1x1MDAxOItsZVx1MDAxN+NklGKiYDO8TTF9ouiyolx1MDAxOJNcdTAwMGV7fte1XHUwMDA0VFx1MDAwNsNbPVtcdTAwMTSzkk4pad/LrNiIXfNDMbxcdTAwMTFcYlRnRnOmkVSNQzHlgFx1MDAwNo2pXHUwMDA1qN2bKbZcdTAwMDSKoZnYZvxvRsimXHUwMDA2seC9YkhcdTAwMDFcdTAwMDBZqaaMKjlmL0dKXHUwMDFkgdyIXGIuUPNgXHUwMDEye+N2s+lzI0esYsq+XCJcdTAwMDVcdTAwMThcdTAwMTNKo9aKb2kmXHUwMDFkXGZuJIlcdTAwMDSI3Ss0btMqYdl6ojPbT8yNk8Ds08tcctbcdtv2dnmD1kC/rpVf+lx1MDAwMMOnXFy7r3n9zXjc/XJcdTAwMTN8bMdXUJ9cdTAwMTaJPPusf/716a//XHUwMDAxsk3fXHUwMDAxIn0= App()Screen()Header()Footer()Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")Static( QUESTION, classes=\"questions\")Here's the output from this example:
ExampleApp \u2b58ExampleApp Do\u00a0you\u00a0want\u00a0to\u00a0learn\u00a0about\u00a0Textual\u00a0CSS? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
You may recognize some elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
"},{"location":"guide/CSS/#css-files","title":"CSS files","text":"To add a stylesheet set the CSS_PATH
classvar to a relative path:
Note
Textual CSS files are typically given the extension .tcss
to differentiate them from browser CSS (.css
).
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal\nfrom textual.widgets import Button, Footer, Header, Static\nQUESTION = \"Do you want to learn about Textual CSS?\"\nclass ExampleApp(App):\nCSS_PATH = \"dom4.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nyield Container(\nStatic(QUESTION, classes=\"question\"),\nHorizontal(\nButton(\"Yes\", variant=\"success\"),\nButton(\"No\", variant=\"error\"),\nclasses=\"buttons\",\n),\nid=\"dialog\",\n)\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
You may have noticed that some constructors have additional keyword arguments: id
and classes
. These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
Here's the CSS file we are applying:
dom4.tcss/* The top level dialog (a Container) */\n#dialog {\nheight: 100%;\nmargin: 4 8;\nbackground: $panel;\ncolor: $text;\nborder: tall $background;\npadding: 1 2;\n}\n/* The button class */\nButton {\nwidth: 1fr;\n}\n/* Matches the question text */\n.question {\ntext-style: bold;\nheight: 100%;\ncontent-align: center middle;\n}\n/* Matches the button container */\n.buttons {\nwidth: 100%;\nheight: auto;\ndock: bottom;\n}\n
The CSS contains a number of rule sets with a selector and a list of rules. You can also add comments with text between /*
and */
which will be ignored by Textual. Add comments to leave yourself reminders or to temporarily disable selectors.
With the CSS in place, the output looks very different:
ExampleApp \u2b58ExampleApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aDo\u00a0you\u00a0want\u00a0to\u00a0learn\u00a0about\u00a0Textual\u00a0CSS?\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0Yes\u00a0\u00a0No\u00a0\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/CSS/#using-multiple-css-files","title":"Using multiple CSS files","text":"You can also set the CSS_PATH
class variable to a list of paths. Textual will combine the rules from all of the supplied paths.
It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your .py
files?
A major advantage of CSS is that it separates how your app looks from how it works. Setting styles in Python can generate a lot of spaghetti code which can make it hard to see the important logic in your application.
A second advantage of CSS is that you can customize builtin and third-party widgets just as easily as you can your own app or widgets.
Finally, Textual CSS allows you to live edit the styles in your app. If you run your application with the following command, any changes you make to the CSS file will be instantly updated in the terminal:
textual run my_app.py --dev\n
Being able to iterate on the design without restarting the application makes it easier and faster to design beautiful interfaces.
"},{"location":"guide/CSS/#selectors","title":"Selectors","text":"A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to.
Selectors can target a kind of widget or a very specific widget. For instance, you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
Let's look at the selectors supported by Textual CSS.
"},{"location":"guide/CSS/#type-selector","title":"Type selector","text":"The type selector matches the name of the (Python) class. For example, the following widget can be matched with a Button
selector:
from textual.widgets import Static\nclass Button(Static):\npass\n
The following rule applies a border to this widget:
Button {\nborder: solid blue;\n}\n
The type selector will also match a widget's base classes. Consequently, a Static
selector will also style the button because the Button
Python class extends Static
.
Static {\nbackground: blue;\nborder: rounded white;\n}\n
Note
The fact that the type selector matches base classes is a departure from browser CSS which doesn't have the same concept.
You may have noticed that the border
rule exists in both Static and Button. When this happens, Textual will use the most recently defined sub-class within a list of bases. So Button wins over Static, and Static wins over Widget (the base class of all widgets). Hence if both rules were in a stylesheet, the buttons would be \"solid blue\" and not \"rounded white\".
Every Widget can have a single id
attribute, which is set via the constructor. The ID should be unique to its container.
Here's an example of a widget with an ID:
yield Button(id=\"next\")\n
You can match an ID with a selector starting with a hash (#
). Here is how you might draw a red outline around the above button:
#next {\noutline: red;\n}\n
A Widget's id
attribute can not be changed after the Widget has been constructed.
Every widget can have a number of class names applied. The term \"class\" here is borrowed from web CSS, and has a different meaning to a Python class. You can think of a CSS class as a tag of sorts. Widgets with the same tag will share styles.
CSS classes are set via the widget's classes
parameter in the constructor. Here's an example:
yield Button(classes=\"success\")\n
This button will have a single class called \"success\"
which we could target via CSS to make the button a particular color.
You may also set multiple classes separated by spaces. For instance, here is a button with both an error
class and a disabled
class:
yield Button(classes=\"error disabled\")\n
To match a Widget with a given class in CSS you can precede the class name with a dot (.
). Here's a rule with a class selector to match the \"success\"
class name:
.success {\nbackground: green;\ncolor: white;\n}\n
Note
You can apply a class name to any widget, which means that widgets of different types could share classes.
Class name selectors may be chained together by appending another full stop and class name. The selector will match a widget that has all of the class names set. For instance, the following sets a red background on widgets that have both error
and disabled
class names.
.error.disabled {\nbackground: darkred;\n}\n
Unlike the id
attribute, a widget's classes can be changed after the widget was created. Adding and removing CSS classes is the recommended way of changing the display while your app is running. There are a few methods you can use to manage CSS classes.
The universal selector is denoted by an asterisk and will match all widgets.
For example, the following will draw a red outline around all widgets:
* {\noutline: solid red;\n}\n
"},{"location":"guide/CSS/#pseudo-classes","title":"Pseudo classes","text":"Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the :hover
pseudo selector.
Button:hover {\nbackground: green;\n}\n
The background: green
is only applied to the Button underneath the mouse cursor. When you move the cursor away from the button it will return to its previous background color.
Here are some other pseudo classes:
:disabled
Matches widgets which are in a disabled state.:enabled
Matches widgets which are in an enabled state.:focus
Matches widgets which have input focus.:focus-within
Matches widgets with a focused child widget.:dark
Matches widgets in dark mode (where App.dark == True
).:light
Matches widgets in dark mode (where App.dark == False
).More sophisticated selectors can be created by combining simple selectors. The logic used to combine selectors is know as a combinator.
"},{"location":"guide/CSS/#descendant-combinator","title":"Descendant combinator","text":"If you separate two selectors with a space it will match widgets with the second selector that have an ancestor that matches the first selector.
Here's a section of DOM to illustrate this combinator:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPiSFx1MDAxMn7vX+HwPOzsRKOp+5iIiVxyfODbbjdu293bXHUwMDEzXHUwMDBljGSQXHUwMDExXHUwMDEylmSwPTH/favARlx1MDAwMiRZXHUwMDFjYmCnefAhQSmVyuP7KiuLPz9sbGyGz1x1MDAxZGvzt41N66lec2zTr/U2P+rjXctcdTAwMGZsz1WnUP//wHv06/13NsOwXHUwMDEz/Pbrr+2a37LCjlOrW0bXXHUwMDBlXHUwMDFla05cdTAwMTA+mrZn1L32r3ZotYP/6J+ntbb1e8drm6FvRFx1MDAxNylZplx1MDAxZHr+4FqWY7UtN1xm1Oj/Vf9vbPzZ/1x1MDAxOZPOt+phzW04Vv9cdTAwMDP9UzFcdTAwMDFcdTAwMTEg44dPPbcvLVx1MDAxNFx1MDAxY1x1MDAwYimAkMN32MGOumBomer0nVx1MDAxMtqKzpj9a1x1MDAwNVx1MDAwN7x5b1Pil1x1MDAxOWx/2nX2v5Ur0XXvbMephs/OQFx1MDAxN7V689GPSVx1MDAxNYS+17KubDNs6quPXHUwMDFkXHUwMDFmfi7wlFx1MDAxYaJP+d5jo+laQTDyXHUwMDE5r1Or2+GzPlx1MDAwNsDw6EBccr9tREee1H+UY4MxTlx1MDAwNGFcdTAwMDBxQfDwrP48ZtJcdTAwMDBEQCoxoOokXHUwMDE5k2vbc9SzUHL9ZDHOXHUwMDAxiCS7rdVbXHIlnmtcdTAwMGXfXHUwMDEz+jU36NR89cSi9/Xe7pix4bGmZTeaoTooRHQ9q693iFx1MDAwMFaCMFx1MDAxYd2FvkrnwOxcdTAwMWLBXHUwMDFm43pr1vzOq342XHUwMDAz/U9MQi3c7rhcdTAwMDXFrSj2bKl7V7rCsve1aVx1MDAxZcKb26dcdTAwMTPGYDBcdTAwMWNrxORC6yncXHUwMDFjnvjrY9aw7W9fji727ra64ZfLw9557+Wk0blNXHUwMDFltub7Xi/vuFeHl93eMdk5OfapufeMXHUwMDFlXHUwMDEx2WFcdTAwMGJcdTAwMThcdTAwMTddmVx1MDAwN/uVVv1ElFx0vGg7Z7vut8ZcdTAwMDLGLUi96zXs8ePOPpDXXHUwMDA3L0934qhSMZ9uat3eXHUwMDBm5S5m2H1rXHUwMDBmXHUwMDA33bpdvtm+XHUwMDBi7ytcdTAwMDe75fOr47mUO1wixce8N5JT3Obxdlx1MDAwZmBcdTAwMGK07zudg6Pj7svN+U6YT9zXv6JQ+Ngxa4OspYIsJJBcIiZcdTAwMDSPwq1juy110n10nOiYV29Fie5DTOCJXHUwMDE0O3L/I9lcdTAwMTWK8cNv2ZVzKiHjMMpZ7yXXbLNY2eQqspKrIFx1MDAwNl1OcqVcdMmV8vHkilR2RVx1MDAxY9LosS0sty7SXHUwMDFho4fuuWHVfunDNTZytFJr287zyHPrm6mSdFudrtmu5f/83dUnbPP375umXXO8xvfN7+6/41pcdTAwMGUsJY1cdTAwMWWeopFxyo7d0Ca+6Vh3o7ZcdTAwMWbaXG6oXHUwMDBlT7dt04xDz/rbtVx1MDAwZvJcdTAwMDBGz7dcdTAwMWK2W3Mu8kqe6abZSJhClOarkCtcdTAwMTAsOVx1MDAxMjy3s55fPDjWS3nr8XHr5MA5Obt2ryv3f6+z0nd9lVx1MDAwMYNCXHRcYodcdL5KMTOkgJwqV9W+SlN9XHUwMDE19F9z+KpcdTAwMDSTvirYhK9cdTAwMDKAMYNAXHUwMDE24KxZiem+etjYvfDc7Va9xfyn1sG31kNKYvpcdTAwMDGEp1x1MDAxZrcg9a7XsEVcdTAwMDHh9dLChd/c6d12rYtcdTAwMTBcdTAwMWY6XHUwMDEwiZfWeWc+xLqOWlg8blx1MDAxZoyLn1x1MDAxZkpfkajUQ9JcdKrW887Z9lx1MDAwM1/AuGZcdTAwMWJcXG57h597leujbafb8XdvXHUwMDFiuFCekaz4aNjXvzJcdTAwMTFcdTAwMThmJMr/RfFcdTAwMDGKU/lcdTAwMDCkilx1MDAwYlxiRkGUV9/DXHUwMDE42fa2qlx1MDAxOINmYVxmXG5ccrJcdTAwMWOMIVx1MDAxMjDGJFx1MDAxZpCAXHUwMDEzIFxiL2CubZHWOFx1MDAxZlx1MDAxZthXMPtFI2vnZ318XHUwMDAwretOLVxirEDh69vHMPTcYNnU4Fx1MDAxZFx1MDAwND1OXHKmuYlM581mXHRcXKbOlzPMXHUwMDA1Izj/bPmX8PzCe3q+2eptnZ1+uXj5/PKpzFL8d8xcdTAwMGb/LjpPODGApFxiXG7JuXJfQkb8lyNqKCpcdTAwMGJcdFx1MDAxM8rJXHRcdTAwMTfz+O9PXHUwMDEw3VxuoajxzFxmXHUwMDAxXHUwMDEyrKRcdTAwMDScocW7b1ZcdTAwMDZkrOneyy77elolnYY8wkfHj5X5gcBcdTAwMGaGUKh612vYolx1MDAxOMJ6aaEohrBeWnDk1ra43dsm24xZ1Yujp0alssK2sHyCkHwjOcWtXHUwMDFmnV6QzrF/+tXfvjWt44483vuyuELEUoiHiNWWx4lcdTAwMDeTXHUwMDEwXCKJRX7ikW1cdTAwMTcrWokgXFxmQlx1MDAxNypccrwo6DI99YBcdTAwMTFsfJvepLpARPCyS1x1MDAxMUulXHUwMDFlW31Y/vP3za+WwubJ9Fx1MDAwMpKRj1xy+UNd3Y7lz04w3oHf41x1MDAwNGNcXNRMR8wmXHUwMDExXCK2pGbMXHUwMDFiXHUwMDExXHUwMDA0XGZDOUVZkMPKWYvsud3mXvUlaODnXHUwMDEz379ebVx1MDAxNsEkMqBSXHUwMDAye3XF0UlcdTAwMDBNXCKo1GW41SBcdTAwMTFAeyElYMkk4iGQlaP2WZVcdTAwMWOet2jFNe/RzqfLXHUwMDFmJGJRJKIg9a7XsEWRiPXSQlEkYr20UFx1MDAxNIlYLy0svijyXHUwMDFlN0m+kWjY17/+flx1MDAwZSFlOodAurTBXHUwMDEwyT/5mf38VpRDMMmzgFx1MDAwYtVcZmNBwGVcdTAwMTFcdTAwMTSCS0VcdTAwMWZcYohhmv9nXG5x6i2bQbxcdTAwMDO9U1x1MDAxOcRA0kwvXHUwMDFjXHUwMDA0l1x1MDAwNDdkIL2EXGIh1oYp83thdm15NUuITFx1MDAxN1x0XHRjXHUwMDE0YFwigEQjPkhcdTAwMThVJ1x1MDAxMZVcdTAwMTJDKinjhfkgMDBcdTAwMDZUcixcdTAwMTCmXGIozdNJn2TSgMopKIVCxVx1MDAwZS7wuItcIlx1MDAwMDliKFZVyu2ifVlndVHC4UxcdTAwMGJcdTAwMGWDsOaHW7Zr2m5DnYwy3VsnSp51fX2nrj9cdTAwMDZ9LVx1MDAwMkY5xVxcqVx1MDAxMFx0XHUwMDE5X1x1MDAwM6p1UetoTmZgSVxiXHUwMDAxQFExiFV4fX3DMONuWq75vkzZXHUwMDA1xZhMJSVcdTAwMTTmXHUwMDAwqStcIlxuoZIv8rmhUMjAnDMpKEVcdTAwMTLolDAhlFNcdTAwMGLCba/dtkOl+0+e7YbjOu4rs6w9vWnVzPGz6qbi58ZDQkePOMpcIqO/Nlwin+n/M/z7j4/J7043Zv2aMONovFx1MDAwZvHfU0czXGJcdTAwMDFcdTAwMWU/PKynXHUwMDAySFx1MDAwNIQyesN70SxcdTAwMWK8rSqmYMTgXHUwMDAyMimFJJLEVozrzzNMXGYqMUOIUPVcdTAwMDY2LtdcdTAwMDIxXHUwMDA1xFx1MDAwNkZQXHUwMDFiPEbKolGk92h2XHUwMDA0XHUwMDE4XGZzhFx0xFx1MDAwNKugXHUwMDE1W58xXGJnXHUwMDAyS6DcgswwVzJXOJtcdTAwMTVx5FxmZ7lDXHUwMDA3MFxiplx1MDAxOCCgMCDAkkNcdTAwMWPzo9fIXHUwMDAxocH0Ulx1MDAxZklcdTAwMDBcdTAwMDE6XHUwMDE0z1x1MDAxNs6ywceoTFxiS8l1L1x1MDAxZmBcdTAwMDKDJJlUXHUwMDAwoFKrknKOXHUwMDEw4utcdTAwMWTO0m1ZvyaseFHRLD5tO4HNJCfKmfNcdTAwMDez7CrZqlx1MDAwNjMsXGaKXHUwMDEwg0JiXHUwMDE1zMZjXHUwMDE5M1x1MDAwMMVcdTAwMWF8MJX1i1x1MDAwYmVSKmvGkimszFWWTmr9XHUwMDEwKuhcIqFbXFxcdTAwMTGEKn5MrPyCXG6VIKpS/z81lJU0JiCSqidJmYLZQrCYXHUwMDE3vcVccmyoUKf8jFx1MDAwM4XFlabfkMGUwSy7XHUwMDE2MyqVTodSuVx1MDAxM0QqpElcdTAwMDQnhKJcdTAwMDZcdTAwMDRUhVfOgZZrUqR1XG5lpVRj1q9cdDOeMpRl1qlcdTAwMTQySVxyZyq39d08f9HYadbA2XFnL7Shf0rK7Z3wyjxdcapJdEfMSFxmQ0hcdTAwMThQQlx1MDAwMlx1MDAxOCpwcSpNoJKCXHUwMDFiiKtcdTAwMDTCJ0BcdTAwMTfGkGDFhJfcXHUwMDEw7jlP1XLrwobynpZcdTAwMWN0ePxcdTAwMTLUq/PPwJ6cXHUwMDFmvHytPF2H/o5ValaDa47I3T+wQFWQelx1MDAwYlx1MDAxYZZ37f3Kwy5s+7s35k7YRd0zZ1x1MDAxMVogwO9At1s/vapcdTAwMWOdiqudK+8kPFpd7d7et8ufnC6DNCxtmbJxdKqyWqHlg+RcdTAwMWLJKe5cdTAwMWM91pnjfjmzzmXpwWqJ3a8v95/cs8vSXU5reEtb70CkXGJHXHUwMDE3VO6gNH2elWFFXHUwMDFmXHUwMDE0ssjfXHUwMDBlmm1uq5r86Hjyo9JcdTAwMTBcbm5cdTAwMTSZ+khC6kNcdTAwMTPTpopaKFx1MDAxNlFExluk4UXPNypsIDByNKOwUa37luX+nFLS4CPvX1hJ41x1MDAxZJQ2XtJcdTAwMTjKmOljqYSZp1ZcdTAwMTSFXHUwMDAy+EDPUud2sexYtpouRlx0MahmWFx1MDAxYzLEYUxcdTAwMWT93Vx1MDAxMaA0KKSFXCJNXGJcciggZlSRKSooTZj0IypcZqQgT6hcdTAwMWVRX+5cdTAwMTkqjPOWL/gsjpiTJGd7wUZ8bo1KrtNcdTAwMDElkjCMXHUwMDEyKDIxXHUwMDAwXHUwMDA1XFxcdTAwMDfN1zNTUuMpSilMPVx1MDAwZu04XHUwMDA0ci6jRqS4KEA339F+c1x1MDAxZJZyQqJ1YsbptqtfMauNXHUwMDA2+lx1MDAxMP8929pNlE6KXHTlXGIjPsVcdTAwMWPf+YV74MnHo93OTfPo7vzm0uJcdTAwMGbWiscsjJShgdGwNNgoTShwQFx1MDAxMFx1MDAxNJz2o1x1MDAxMlx1MDAxYpNogVEr305pSGFcdTAwMDTlnXyG2upcXFx1MDAxYqV99tF1q/n5oXlwWeru032zu3OfXGZ+fyzcnH7cgtS7XsNcdTAwMTa2UdpaaaGgYYvacKEgcVx1MDAxN0/i393XLfFGcor7cIvLVVRmdyc7XHUwMDE31WvvM7l7MlvrNTeAkEhdtVx1MDAwMLFUp5mg+SdcdTAwMDey7WJVUVx1MDAwME1GXHUwMDAxglx1MDAxOHhJKCDflm5QXGJcdTAwMDSgXHUwMDEyqID+jaInXHUwMDBi5tzTLbBN67bmL3/nhkxUm2tTt1x1MDAxMdHngOtZJXmqWFx1MDAxOVx1MDAwNGCKXd26veolsvDR4fVpq1x0fdn4dmydpHhq3feCoNSshfXm3++tXHUwMDEwXHUwMDE5irlob1x1MDAwNWNe2f88YobgXHUwMDA1zjJwPumpXHSdVlxiXCLMgIRL3tCtVVx1MDAwNtf7N4dP5llcdTAwMGaf7l1cdTAwMTDzmJXdXHUwMDFmgH1RgL0g9a7XsEVcdTAwMDH29dJCUZ1W66WFojqtXG5cdTAwMTJ38ds1XHUwMDE0JO57tCX5gjmlnYO2ZI5bpdVnSlx1MDAwZt2DXHUwMDFitoPuXHUwMDFliLtXPkwhhStLh6RM7WdXUiBAOEf5XHUwMDBiOdl2saJ0XGLyTIBFoIGLXHUwMDA0WCxcdTAwMDFgTVIhLplUkFx1MDAxN69h2XTqfjB9bMAnvm9cdTAwMWW4QVhznGXzoHfYQkp7WKrgmb6Z3i+W3rQpOVx1MDAwMphROMWOdZnrOlbTNSlcdTAwMTaGXHUwMDE4W7XwVl3tN1x1MDAxY1x1MDAxNemXRK/PXHUwMDFjxIVJXHUwMDA3JdKgjI0tJVx1MDAxY+5Dj1VUXHUwMDE1s1CheVdcdTAwMWbP5KqLLqyWgMGERIxJXHUwMDE1WpmEOKr3bkTlTIIxlVGFL6WyOir/XHUwMDFhVThLSfajXzHLicb4XHUwMDEw/z11nIgtZlx1MDAxYe/DYlxcb6RI85c1s7HSaoZcdFx1MDAwMqhBqMBcdTAwMTKqZIpZbHnBoK2UXHUwMDE4sexe5LQm0F83JaTuXHUwMDFml0xFZ0FcdTAwMTJyO4OGnmIl8vVcdTAwMTX74ozXuVx1MDAxNEgp5ZLPsjn+Klx1MDAwN5DsucXRXHUwMDAwotuwXHUwMDAwJZjplUSQxL4oYFx1MDAxOEK4IbBKglx1MDAxMFxyNJnQK5BrjUZ2pt9cdTAwMThpd1VcdTAwMDJJSlx0XHUwMDEyglx1MDAwYoZcdTAwMTPaXSfbW9cpZmWYr35NXHUwMDE47pTxK70kk1qRwSp5UEbzdypcdTAwMWP4n+Wlc/FcYus9XHUwMDFmXHUwMDA1l1x1MDAwN95J2fs8+ywvXHUwMDFh97VcdTAwMWMhLIpN03RfIVx1MDAwMlxmoWyLxSdz+ztTXGJmXGIwWIU0T+D66a5GXHUwMDExRYm7aaHoPqOFm5MrxpBe76ZcdTAwMDRZ8qKM4jdxXFzewtC2traNsGlcdTAwMDVWXCKbiYHGadhM6HXSqMzIrYzzlrg4s2FcdTAwMGYq0796R1LdcVx1MDAxZW98fs99s1x1MDAxZvVS3He25knMXHUwMDEzvLTvvlx1MDAxMlx1MDAxOFhcbiwwXHUwMDAzXHUwMDFjXHUwMDAzmr6pRY6vycrwYa5cdTAwMThcdTAwMTFcdTAwMWWjRNH2MsRQaVx1MDAxMlx1MDAwMVxyyrnKp5N7WWi6XCJcdTAwMTBe9lZcdTAwMTZcdTAwMDUjjuxsMJLbXHUwMDExXHUwMDAwXHUwMDEyXHUwMDEzhiBVhEVSjmLvXHUwMDFh9ktKQvHMi0Fz90lqYaR6XoLrfnxAJUlo3uRcbvFcdTAwMTJFoF43ROVcdTAwMTMyrVx1MDAxM/BIMF79Kk3a7YIgXHUwMDA3lOlcdTAwMWRcIpwjpU+FP3NHLeubXHUwMDBm7ZOtW8e8XHUwMDExqGe13Fx1MDAxNv+yv1x1MDAxNqBcdTAwMDNcdTAwMTNcdTAwMTW1JjGHOmAw2bf1oiCHiEJNXHUwMDA25GBSXHUwMDExOimW/SVcdTAwMDCrNbE/XHUwMDFm4vjF9Nx/hb9svKV6O1hcdTAwMDXgkSDVbPhcdTAwMDPFZsTH9+NcdTAwMDVUXHUwMDA1T8R5fk/OfvCrjD9cdTAwMTAxMMaC6u2zIIjvXHUwMDEw1XdoXHUwMDE1YYnsr+wqXGJ/MGlwyoWKXHUwMDE5XHUwMDEyYUXdo4npqKjBjMGU31x1MDAwNLFcdTAwMTCMU5XIwLJbUVxuhlx1MDAxZtl5YWNkwlx1MDAwMynlYaQ3RVx1MDAxMIIgNDndIYz+ZMeM4CP3LIdcdTAwMTJF51lcYlx1MDAwMVwiXGbr73lcdTAwMTJsQlx1MDAxNjiQN0madYJcdTAwMWSpNqtfpaG5pmGOXHUwMDBmr1x1MDAwM2/WOp1qqGxrqH9lvrb5XHUwMDFhqaO72+zaVm8ryav6L1x1MDAxZFx1MDAwMPt61GHG0vf4519cdTAwMWb++lx1MDAxZow/wb0ifQ== Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button(\"Yes\")Button(\"No\")Screen()Container( id=\"sidebar\")Button( \"Install\")match these*don't* match thisLet's say we want to make the text of the buttons in the dialog bold, but we don't want to change the Button in the sidebar. We can do this with the following rule:
#dialog Button {\ntext-style: bold;\n}\n
The #dialog Button
selector matches all buttons that are below the widget with an ID of \"dialog\". No other buttons will be matched.
As with all selectors, you can combine as many as you wish. The following will match a Button
that is under a Horizontal
widget and under a widget with an id of \"dialog\"
:
#dialog Horizontal Button {\ntext-style: bold;\n}\n
"},{"location":"guide/CSS/#child-combinator","title":"Child combinator","text":"The child combinator is similar to the descendant combinator but will only match an immediate child. To create a child combinator, separate two selectors with a greater than symbol (>
). Any whitespace around the >
will be ignored.
Let's use this to match the Button in the sidebar given the following DOM:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPayFx1MDAxNv2eX+HyfMmrXHUwMDFhNL0vUzX1ylx1MDAxYl6It1x1MDAxOMdxXqamXHUwMDA0yFi2QFjIYDw1//3dxlx1MDAxOFx0kFx1MDAwNDiIQCaaqlx1MDAxOCTRurp9l3P6dvf8/W5jYzPstZzN3zc2naeq7bm1wO5u/mrOd5yg7fpNuET639v+Y1Dt33lcdTAwMWKGrfbvv/3WsIN7J2x5dtWxOm770fba4WPN9a2q3/jNXHKdRvu/5t9cdTAwMTO74fzR8lx1MDAxYrUwsKKHXHUwMDE0nJpcdTAwMWL6wcuzXHUwMDFjz2k4zbBccq3/XHUwMDBmvm9s/N3/NyZd4FRDu1n3nP5cdTAwMGb6l2JcdTAwMDJcdTAwMTLKx0+f+M2+tJgxxKhiWFx1MDAwZe9w27vwwNCpweVcdTAwMWJcdTAwMTDaia6YU5unT1x1MDAwZr3CcfehXFyq1iv+x6B81Ph4XHUwMDE1PffG9byLsOe96MKu3j5cdTAwMDYxqdph4N87V24tvDVPXHUwMDFmOz/8XdtcdTAwMDc1RL9cbvzH+m3TabdHfuO37KpcdTAwMWL24Fx1MDAxY0fDky9a+H0jOvNkblDIYkQxoZSgSpPhRfNrirhFJOiAIcSU0HxMqlx1MDAxZN+Dnlx1MDAwMKl+Qf0jkqtiV+/rIFxcsza8J1xm7Ga7ZVx1MDAwN9Bf0X3d1/dcdTAwMTVieO7Wceu3IZxUKnqe09e6VlJcdTAwMTKCWNQn5iGtw1rfXHUwMDAy/lx1MDAxY1farVx1MDAxZLRcdTAwMDbK2WybLzFcdTAwMDGNbHvj5lx1MDAxMzehWMdcdTAwMWU3gkcquttb+1x1MDAwNz1153/aqYf+zrCtXHUwMDExe1x1MDAwYp2ncHN44Z9fs5ptfLkslfdvtjvh5aej7nn3+bjeqiQ3a1x1MDAwN4HfnbXdq6NPne5cdTAwMDe2e/wh4LX9XHUwMDFleSRsVyygXXJVOzwo3leP1Vx1MDAxNsPlhne61/xSX0C7Oal3vZr98Lh7gPTnw+enXHUwMDFiVSpcdTAwMTZrT3/Zne5P5S6m2eYneVx1MDAxM1xcf9zzd0J9dl1raX5Fb1ZXucGjLu70xNlflza9Pqtf1vjV0dk3iTtcIsWvs75I1OzgU1x1MDAxNGFcdTAwMWZbNfslXHUwMDEzQuiG5KBcdTAwMDX8p/Twuuc27+Fi89HzonN+9T5Knu9i8k6k7Vx1MDAxMTlHMjZT46eHXHUwMDE5XHUwMDFicoMkSNCZXHUwMDEzdnb3rWrC5lx1MDAxOVx0myBLLidh84SEzaO8PEjYkKtcdTAwMDVcdTAwMTOYxSxjYVx1MDAxOXuRxlx1MDAxOPW531xmL9znvj2JkbNFu+F6vZFu61spSLpcdTAwMDOXbbfpXHUwMDA07782zVx1MDAwNbf2x9fNmmt7fv3r5tfmf+JqbjsgjWmek5F2tjy3bix803NuRk0/dFx1MDAwMftcdTAwMGUvN9xaLY5mq6/PPpxcdTAwMDWD+oFbd5u2V55V8kwvzVx1MDAwNtec41RX5eDHWjOGZ/bVx+1tzfZ6qHhyt9XcVzLsnlx1MDAwNEer7qtCWkQhRDGd9FUmqUVcdTAwMTlTXHUwMDA0XHUwMDEx3vfVXHUwMDFjnVWjSWdVYtxZmWZcdTAwMTBBOc7BV7Oy3e5D8S44Oz54OC/uljpN++7y41x1MDAxN/cnul5cdTAwMTS6zkm969VsXuh6vbRQXHUwMDBlbne7lY5TXHUwMDBl6ZGHiXq+P299+NdpIS8ycNCz5e1e62z3ktxXXHUwMDFmcIufXHUwMDFmXHUwMDFl11x1MDAxNtBuVVx1MDAxY5/xc9ooXHUwMDFl1IJS4aSwfVK8JavYa9NIRvJcdTAwMDOjZlx1MDAwN5++P8ngUoyfXHUwMDFlXCJcdTAwMTdcIjHVUszBMrL1vKLIRZJcZuSipKWWhFxcVFx1MDAwMnKZpFx1MDAxOVpSjaj6sVnGXHUwMDAxgPdng9e99+b8XHUwMDBiYK96drvttFx1MDAwMbVXXHUwMDFlw9BvtpdNOKbg8nHCMc9LZDpvNvdQKHVgXzGwXHUwMDEzhYme2YErXb3fKNRDXSn0XHUwMDBlnXapt394WllxXHUwMDA3ZmZcXJ9LKbg0fkFGPVhcYmJhLFx1MDAxOcKcI+M835t6SI6QknHGuFx1MDAxNOqx5fTY/vmhf1cv71x1MDAxNLYudz63y92Ugbaf1GP+dnNS73o1m1x1MDAxN/VYLy3kRT3WS1x1MDAwYp7e3lGV/Vx1MDAxZLYjhHNRLj3Vi8VcdTAwMTW2hbyYx8LFncY8klx1MDAxZlx1MDAxODU7+PT9mYfSLJV5IFwiIFczOfuYabaeV1x1MDAxNbjwLOCiuSWWXHUwMDAzXFySmEdsaPSVeSBcZlxmXGKxXHUwMDFjgMtcdTAwMTRrjFx1MDAwMau8mcd2XHUwMDFmlb//unntXHUwMDAwNE9mXHUwMDE3mI38bEhcdTAwMWaq8DpO8HZ+MVx1MDAwNXyP84txUTPdMJtDQNem+lwiVlxuXHUwMDBijsXsJOLx+aDTPH1+vmS03PN4VTS/PFx1MDAxN1fcXHUwMDE3wcgspFx1MDAxNXDrXHUwMDE3X1x1MDAxY+NcdTAwMTDYklpcIqXRanBcYkqxXHUwMDE2XFzFRiOWwiGuXHUwMDBlXHUwMDBmzz5cXD8/bFx1MDAxZlx1MDAxNC78QtdcdTAwMGWfPznHPznEojhETupdr2bz4lx1MDAxMOulhbw4xHppIS9cdTAwMGWxXlrIq9iycHGnUZPkXHUwMDA3Rs1cdTAwMGU+LVx1MDAxMlxmZmKiNGpCXHUwMDExkuOnI2pcIjlSfJ6iSLaeV1x1MDAxM1x1MDAwZUnEMuBcdTAwMTAwXHUwMDEzslx1MDAxYzg0XHUwMDFiM8GaKVx1MDAwMmLKXHUwMDFjJkuvIDU58ZfNTKYg+lRm8lwiaaZcdTAwMTO+hKxcdTAwMDQvlFx1MDAwNKU6IcdSKCb57E6YXVx0X00nXHUwMDA0hG9cdTAwMTFwQLAyjlx1MDAxMJd8xFx1MDAwYlx1MDAxOcKWkERx+MC0XCKxQu6i3Vx1MDAxMFlAOFx1MDAxMMZcXEumheI0NnQzdEuhISgwXCKgZ1x1MDAwNHiimvRSjLjCwHDo/F7aXHUwMDE3dtle2lx1MDAwZe0g3HabNbdZh4tRrntdjDPLPMS+X1dcdTAwMWbbfTVcIlx1MDAwMb1IJSeIKOgyLWN31e2W4XpcdTAwMTYolzGEjL4pkXhwwzDnbjrN2nSZskuVMZlcbiBcdTAwMTSViMBcdTAwMTNcdMdA9LlSfEIqYlEphVx1MDAwNsJJNIJAKyak8ux2uOM3XHUwMDFhblxiyj/z3WY4ruS+NreMt986dm38KrxV/Np4WGiZXHUwMDE2R/lp9Gkj8pv+l+HnP39NvjvdnM0xYchRe+/if+eOaJjg1MlcdTAwMTbgXHUwMDE1nDGsZ1x1MDAxZvHMhoUrXHUwMDFh0SS2OIGghSRYN4uiSP/XXHUwMDEyWVx1MDAxYZCVMkNcdTAwMWKaxIZDXHUwMDE3XHUwMDBlKzC1qFCKQWDlbGTSRzTogiymXHUwMDAwXHUwMDAyMZCTgj3EJn5cZuq4mijMwWt+rGg2c+RcdTAwMDD9UI6FMj5EXHUwMDA0pFx1MDAxZlx1MDAxNvOiQdzAkKKwXHUwMDE5NWaIIU5cdTAwMDR7YzTLhFx1MDAxZqMyXHUwMDExqjX4rEJIKFxuXHUwMDFkPSlcdTAwMTO4P9dcZnPCzVxuO1x1MDAxMHytg1m6LZtjwopcdTAwMTdcdTAwMTbLIGWkwjOALIRcbj7HvJPs8tuKXHUwMDA2M8aBIyHMTWKkMjZA/lx1MDAxMs2IxbAwI7VcXFx1MDAwYinHxVpcXDTTXHUwMDFh+phA0NRcdTAwMWNDOoutXHUwMDFhioJcdTAwMTmz4CqVcFx1MDAxZsZE0IlZZVx1MDAxOEJcdTAwMTlcdTAwMDNcdTAwMGbG/9ZoVjCggHFMXHUwMDAwalx1MDAxM1xiaYyihHBGLUhcXFx1MDAwMJOAXHUwMDFlQ1xu44K+LZ5lXHUwMDE3ekal4lx1MDAxYfA/1lx1MDAxMlx1MDAxM4hqmuBcdKG4XHUwMDA1uFx1MDAxYVwirJTIyDUp0jpFs0KqMZtjwoznjGaZRTBcdTAwMTnjJWNcdTAwMDGNaCyQmme1nfZKl6Wb+6fS9dVFrfaJ2Fx1MDAxN1x1MDAxZj6ueDhjZlx1MDAwNY8wuYJAzkYyXG4j/eFcYlx1MDAwMbpcdTAwMDeEKlx1MDAwMbVpXHUwMDA0d+RcdTAwMDfOYlWtKICBbPBoKidgXHUwMDE4QGZcdLQknmyWUlx1MDAwYrvrlu9cXO+ycdTuak4qXi0sep++fbD3+Pzw+br49DlcZnadwu1F+7MkbFx1MDAxMTP2161cdTAwMTaWk3pzalZ23IPiw1x1MDAxZW5cdTAwMDR7f9V2w1x1MDAwZemceovQXHUwMDAyQ0FcdTAwMGI3O9WTq2LpRF3tXvnHYWl1tVu5a2ydeVx1MDAxZIF5WNiu6XrpxH/qra64i197PlDD58NcdTAwMTLx2lx1MDAxN6p1etxmndZt6ZBfflO70yoryVxuipp9zY5cdTAwMGJEYpmJNq2yXHUwMDAySDiVNCDgYkQxOnuWzTaLXHUwMDE1zbJmtUl6ltXCgoSGXHUwMDAx5+mcsyxLyLIk0v1rdqVUamqw1uKza96VlVj9YEpl5aJcdTAwMWE4TvN9Sk1Fjty/sJrKXHUwMDE0jDheU1x1MDAxOcqY6XjpfF2lbyZBgD4gTdjs2z9lR87V9DzOsVx1MDAwNWRcdTAwMWNcXItcdTAwMTGqXGJcdTAwMWatpsB3S0klluB5XHUwMDE4XHUwMDAzi1SUaOCZSopYrXnoiIybMlx1MDAwMFx1MDAwN8ebwLtcdTAwMDQhRpjW4lxyeHeVmXq2O2zEx/i4llx1MDAwMuJcdTAwMTJcdTAwMDeWLijhMYo4oMTMQlx1MDAxY1RcZtpcdTAwMWJcXJmTn89R0Vx1MDAxMVx1MDAxOFx0XHUwMDA19JxhXHSBMqGgXHUwMDAzsiCgsohzbpKb1lx1MDAxM1wirVx1MDAxMz9PN15zxMw2auhd/O/bpqfSdGpcdTAwMGVsUYE7izm217huPV2497RyfcnK4fn+XHUwMDE26dyffl7x4EWJtKggXHUwMDFjSHpcdTAwMDJsMJvXYWVqwfqFnH/33eugQ1x1MDAxOFn+XCK34z1WPFdq9+amVun2yrf+VlhOISA/J6jO325O6l2vZnPbvW6ttJBTs7ntXpePuHmNIOQk7nn9oX7uXFw9XHUwMDEwLb94lyeNtt1yUsZRXHUwMDE2tdle4otEzb6Cg+89MEEoSV1cdTAwMDFDMCFCKI5nh1x1MDAxONn9t6JcdTAwMTCDkiyIQVx1MDAwMOsuXHRizLbfXHUwMDFlxpIjSlx1MDAwMX2v39DEN+6313ZrTsVcdTAwMGWWvf/FXHUwMDE04DzThnsjome66pRcdTAwMTVr6ZtjMsaAiDE++3DG9lHTx1xi6JdDjypP5Vx1MDAxMr7URSfFXcfcbtRZx6csvdVZMZrqrVhbXGZpRvCLt45OPmCCWIJcbo6Y+PbRjF8wqSgllEpwVZkweJGw5Vx1MDAwNSZcdTAwMTKx+Fx1MDAwNNalsIHzvaND9CRLN6dXLr66PdNfetf7P9nAothATupdr2bzYlx1MDAwM+ulhbyWq62XXHUwMDE28lqulpO4eW15sV6dtnxOlPxcIjOKe1rd3T65bqn60Ze7Y7vje0/bjM0m7uDTd+daXHUwMDE00dTldUT3t9mLIYVp2C3bLL5cdTAwMGLVmlx1MDAwMb1cdTAwMTGWhd6ktPCi0NuU0dxcdTAwMDT8lrC1uWRMXHUwMDAzXHUwMDA0zFx1MDAwMb+tXHUwMDBl01x1MDAxYSxaM+de+MrXzcNmO7Q9b9k8a1xuXHUwMDFkSVltlyp4ppOmXHUwMDE3jFx1MDAxOUl1Ulx1MDAwNCleUDVcdTAwMDfDyp5cdTAwMTKzmlx1MDAwM1winHFLMCp14oBcYuHCMsMgS3BSxixJiOCCJMzZYNriQjAkJkvFVCBwJPKWXUJ+iEpxwVxmWWlcIoSmXHUwMDEwZzWmOKk8yyjlmsoppeJR+deoYltIMlx1MDAxZnPEXGYnauNd/O/8MYOn1mk55cpUjGfP65m4bDUjXHUwMDA2w8Si4OuvQ6hcdCt2XHUwMDExNsaY91x1MDAxY1x1MDAxM2RcdTAwMTFuZvQogYmAXGJcdTAwMTCb2Fx1MDAxNa3XNZNcdTAwMTckVlx0U6qppGZcdTAwMDe2t+T7VVx1MDAwZVx1MDAxY9njl6OBXHUwMDAzXHUwMDExJFx1MDAxMWdUSCRcdTAwMTiOZlx1MDAxN1xmXHUwMDAzh7RcdTAwMTQliGIzXHUwMDE3XHUwMDA3joR1XHUwMDE3M001yc71XHUwMDFiI4uHQVx1MDAxY7PGiygzUYlINLlOd3K18DqFqnSrNUdkr3OGq3RcdTAwMWWSselcdTAwMTnHRFx1MDAxMqlm5yFS2yd35O7sYLdXrX/0xd72WUWkxKtcdTAwMTVcdTAwMTlDJlx1MDAxNFx1MDAwMI7Go9jhZb8z9mrd31TmyVx1MDAxODkmSdPeJiFcZlx1MDAwNtJcdTAwMDH2IN8y2+1bho5Xi2YnXHUwMDExm9lnt142a05gXGLLRnjrtjde9lx1MDAxZE+e6qpGfjz7VNfQb6WxmZHXXHUwMDFhpy7Jor1ccnxA0EhcdTAwMDVcdTAwMWbIJGQ5x+L67P5fSW+mWFlcdTAwMTC2zNbiXHUwMDE0Y6FGx1x1MDAxNFx1MDAwNMA9zNToXG6qMY92hJRv92hNLU0pXHUwMDE1ZjNcdTAwMWUmaGxCXnxcdTAwMTVX4lx1MDAxYS6zmo/QkZLdXHUwMDBmXHUwMDAxOLJzwijggNxcdTAwMGXAXHUwMDExXHUwMDExXGKMWkNATli4XHUwMDBl0VrNwlW+XHUwMDE5a1x1MDAxOHGkJmbSqlZcdTAwMDSeiVTiYliwMqzM/zvDbFx1MDAwMrXe2CPVes1ReDXcNOTxbtDupt1qXYRgZcNeXHUwMDAwQ3Zrg5BcdTAwMWS93GbHdbrbXHTuddM/TFx1MDAwNOyr0Vx1MDAwNFx1MDAxYce84t//vPvn/yxzRNUifQ== Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button(\"Yes\")Button(\"No\")Screen()Container( id=\"sidebar\")Button( \"Install\")Underline this buttonWe can use the following CSS to style all buttons which have a parent with an ID of sidebar
:
#sidebar > Button {\ntext-style: underline;\n}\n
"},{"location":"guide/CSS/#specificity","title":"Specificity","text":"It is possible that several selectors match a given widget. If the same style is applied by more than one selector then Textual needs a way to decide which rule wins. It does this by following these rules:
The selector with the most IDs wins. For instance #next
beats .button
and #dialog #next
beats #next
. If the selectors have the same number of IDs then move to the next rule.
The selector with the most class names wins. For instance .button.success
beats .success
. For the purposes of specificity, pseudo classes are treated the same as regular class names, so .button:hover
counts as 2 class names. If the selectors have the same number of class names then move to the next rule.
The selector with the most types wins. For instance Container Button
beats Button
.
The specificity rules are usually enough to fix any conflicts in your stylesheets. There is one last way of resolving conflicting selectors which applies to individual rules. If you add the text !important
to the end of a rule then it will \"win\" regardless of the specificity.
Warning
Use !important
sparingly (if at all) as it can make it difficult to modify your CSS in the future.
Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons:
Button:hover {\nbackground: blue !important;\n}\n
"},{"location":"guide/CSS/#css-variables","title":"CSS Variables","text":"You can define variables to reduce repetition and encourage consistency in your CSS. Variables in Textual CSS are prefixed with $
. Here's an example of how you might define a variable called $border
:
$border: wide green;\n
With our variable assigned, we can write $border
and it will be substituted with wide green
. Consider the following snippet:
#foo {\nborder: $border;\n}\n
This will be translated into:
#foo {\nborder: wide green;\n}\n
Variables allow us to define reusable styling in a single place. If we decide we want to change some aspect of our design in the future, we only have to update a single variable.
Note
Variables can only be used in the values of a CSS declaration. You cannot, for example, refer to a variable inside a selector.
Variables can refer to other variables. Let's say we define a variable $success: lime;
. Our $border
variable could then be updated to $border: wide $success;
, which will be translated to $border: wide lime;
.
Actions are allow-listed functions with a string syntax you can embed in links and bind to keys. In this chapter we will discuss how to create actions and how to run them.
"},{"location":"guide/actions/#action-methods","title":"Action methods","text":"Action methods are methods on your app or widgets prefixed with action_
. Aside from the prefix these are regular methods which you could call directly if you wished.
Information
Action methods may be coroutines (defined with the async
keyword).
Let's write an app with a simple action.
actions01.pyfrom textual.app import App\nfrom textual import events\nclass ActionsApp(App):\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\ndef on_key(self, event: events.Key) -> None:\nif event.key == \"r\":\nself.action_set_background(\"red\")\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
The action_set_background
method is an action which sets the background of the screen. The key handler above will call this action if you press the R key.
Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an action string. For instance, the string \"set_background('red')\"
is an action string which would call self.action_set_background('red')
.
The following example replaces the immediate call with a call to run_action() which parses an action string and dispatches it to the appropriate method.
actions02.pyfrom textual import events\nfrom textual.app import App\nclass ActionsApp(App):\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nasync def on_key(self, event: events.Key) -> None:\nif event.key == \"r\":\nawait self.run_action(\"set_background('red')\")\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
Note that the run_action()
method is a coroutine so on_key
needs to be prefixed with the async
keyword.
You will not typically need this in a real app as Textual will run actions in links or key bindings. Before we discuss these, let's have a closer look at the syntax for action strings.
"},{"location":"guide/actions/#syntax","title":"Syntax","text":"Action strings have a simple syntax, which for the most part replicates Python's function call syntax.
Important
As much as they look like Python code, Textual does not call Python's eval
function to compile action strings.
Action strings have the following format:
\"bell\"
will call action_bell()
.set_background(\"red\")
will call action_set_background(\"red\")
.If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other Python symbols.
Consequently \"set_background('blue')\"
is a valid action string, but \"set_background(new_color)\"
is not \u2014 because new_color
is a variable and not a literal.
Actions may be embedded as links within console markup. You can create such links with a @click
tag.
The following example mounts simple static text with embedded action links.
actions03.pyOutput actions03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('red')]Red[/]\n[@click=set_background('green')]Green[/]\n[@click=set_background('blue')]Blue[/]\n\"\"\"\nclass ActionsApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
ActionsApp Set\u00a0your\u00a0background Red Green Blue
When you click any of the links, Textual runs the \"set_background\"
action to change the background to the given color.
Textual will run actions bound to keys. The following example adds key bindings for the R, G, and B keys which call the \"set_background\"
action.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('red')]Red[/]\n[@click=set_background('green')]Green[/]\n[@click=set_background('blue')]Blue[/]\n\"\"\"\nclass ActionsApp(App):\nBINDINGS = [\n(\"r\", \"set_background('red')\", \"Red\"),\n(\"g\", \"set_background('green')\", \"Green\"),\n(\"b\", \"set_background('blue')\", \"Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
ActionsApp Set\u00a0your\u00a0background Red Green Blue
If you run this example, you can change the background by pressing keys in addition to clicking links.
See the previous section on input for more information on bindings.
"},{"location":"guide/actions/#namespaces","title":"Namespaces","text":"Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a custom widget it can have its own set of actions.
The following example defines a custom widget with its own set_background
action.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('cyan')]Cyan[/]\n[@click=set_background('magenta')]Magenta[/]\n[@click=set_background('yellow')]Yellow[/]\n\"\"\"\nclass ColorSwitcher(Static):\ndef action_set_background(self, color: str) -> None:\nself.styles.background = color\nclass ActionsApp(App):\nCSS_PATH = \"actions05.tcss\"\nBINDINGS = [\n(\"r\", \"set_background('red')\", \"Red\"),\n(\"g\", \"set_background('green')\", \"Green\"),\n(\"b\", \"set_background('blue')\", \"Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield ColorSwitcher(TEXT)\nyield ColorSwitcher(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
actions05.tcssScreen {\nlayout: grid;\ngrid-size: 1;\ngrid-gutter: 2 4;\ngrid-rows: 1fr;\n}\nColorSwitcher {\nheight: 100%;\nmargin: 2 4;\n}\n
There are two instances of the custom widget mounted. If you click the links in either of them it will changed the background for that widget only. The R, G, and B key bindings are set on the App so will set the background for the screen.
You can optionally prefix an action with a namespace, which tells Textual to run actions for a different object.
Textual supports the following action namespaces:
app
invokes actions on the App.screen
invokes actions on the screen.In the previous example if you wanted a link to set the background on the app rather than the widget, we could set a link to app.set_background('red')
.
Textual supports the following builtin actions which are defined on the app.
Ths chapter discusses how to use Textual's animation system to create visual effects such as movement, blending, and fading.
"},{"location":"guide/animation/#animating-styles","title":"Animating styles","text":"Textual's animator can change an attribute from one value to another in fixed increments over a period of time. You can apply animations to styles such as offset
to move widgets around the screen, and opacity
to create fading effects.
Apps and widgets both have an animate method which will animate properties on those objects. Additionally, styles
objects have an identical animate
method which will animate styles.
Let's look at an example of how we can animate the opacity of a widget to make it fade out. The following example app contains a single Static
widget which is immediately animated to an opacity of 0.0
(making it invisible) over a duration of two seconds.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass AnimationApp(App):\ndef compose(self) -> ComposeResult:\nself.box = Static(\"Hello, World!\")\nself.box.styles.background = \"red\"\nself.box.styles.color = \"black\"\nself.box.styles.padding = (1, 2)\nyield self.box\ndef on_mount(self):\nself.box.styles.animate(\"opacity\", value=0.0, duration=2.0)\nif __name__ == \"__main__\":\napp = AnimationApp()\napp.run()\n
The animator updates the value of the opacity
attribute on the styles
object in small increments over two seconds. Here's what the output will look like after each half a second.
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
"},{"location":"guide/animation/#duration-and-speed","title":"Duration and Speed","text":"When requesting an animation you can specify a duration or speed. The duration is how long the animation should take in seconds. The speed is how many units a value should change in one second. For instance, if you animate a value at 0 to 10 with a speed of 2, it will complete in 5 seconds.
"},{"location":"guide/animation/#easing-functions","title":"Easing functions","text":"The easing function determines the journey a value takes on its way to the target value. It could move at a constant pace, or it might start off slow then accelerate towards its final value. Textual supports a number of easing functions.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1XWVPjRlx1MDAxMH7nV1DeVyzmPrYqlYJcdTAwMDVcdTAwMTJcdTAwMDJLXHUwMDBlczib2ofBXHUwMDFh21x1MDAxM8uSkMZcdTAwMDaW4r+nJVx1MDAxM48vwrnUblX0YGumZ3q+6e6vu3Wztr7e8Ne5bbxfb9irjklcXFxcmMvGRjU/tkXpslx1MDAxNESkXHUwMDFll9mo6NQr+97n5fvNzaEpXHUwMDA21ueJ6dho7MqRSUo/il1cdTAwMTZ1suGm83ZY/lj9XHUwMDFlmaH9Ic+GsS+icEjTxs5nxeQsm9ihTX1cdNr/gvH6+k39O4POXHUwMDE0RTZcdTAwMDFWT1x1MDAwN3BKL05cdTAwMWVlaY1cdTAwMTMjrjSjWuDpXG5X7sBR3sYg7lx1MDAwMlxcXHUwMDFiJNVU42Tvl7TZ/nX76rh1fDDO+4j+dNVcdKd2XZK0/HUysYLp9EeFXHLS0lx1MDAxN9nAnrnY9/812sz8dF9syj5cdTAwMDCYiots1OuntqwuXHUwMDFmkGa56Th/XV9cdTAwMDNNZ03aq5WEmStcdTAwMThRwSMqXHUwMDE0XHUwMDE3XGZRQZhUU2m1n2lcdTAwMWNxXHUwMDAxcsyRRIxcdTAwMTK5gOxDloBcdTAwMWZcdTAwMDDZO1Q/XHUwMDAx2rnpXGZ6gC+Np2t8YdIyN1x1MDAwNXgrrLu8uzOONGZIXHUwMDEwPpX0rev1fWVcdTAwMGXCXCIlg6C0tVx1MDAxM7DCmiuhUZBUXHUwMDA35vtxXHUwMDFkXHUwMDBin1x1MDAxN43YN0V+Z6tGXHJsXHUwMDA2bDXcnVx0pLB5lMdm4nQsXHUwMDA0x1xcXHUwMDEzWtlqKk9cXDpcdTAwMDBhOkqSMJd1XHUwMDA2K+Kk9Kbw2y6NXdpb3GLT+Fx1MDAxZUliSv8hXHUwMDFiXHUwMDBlnVx1MDAwN1x1MDAxOL9lLvWLK2q9W1WQ962JV2ielS2yIa80XHUwMDA28lRPeFtcdTAwMGbRUlx1MDAwZqbvnzdWrm4uebGevXNg2L02+3+78UTa4pkwXFzkraCaIaxkYPZDvKUnh1x1MDAwN7y9d/7n3uFheaJHgra3+t9cdTAwMDFvRUQgXHUwMDE2pdRaXHUwMDAwbdlcdTAwMTJvMaFIYES1XHUwMDE0mi9cdTAwMDB7PdpShFwiKSmjclx1MDAwNXFxJCFtUEyWuEs5Ykpr9sbUhdAgwY7/U3dmwVxuR1ZPM/jwifRcdTAwMDXr2pXsRfL+skuYpkpjXHUwMDFj8utD9G3r38nu2cfty7//6F1cZoa7R1x1MDAwM3aQvDJ9y1xm+o7Xr7pUXHUwMDAwNSWjeJagNX2VjJCQXFxTRojmSC9cdTAwMDBcdTAwMGL0VYJY3XlcdH1pJKSCUsqpRFqH8lx1MDAxZqovXHUwMDAyMFx1MDAwNHMhJWGKXHUwMDEyvURmgpnAXHUwMDAyIf6mbFZcbmCrXHUwMDAw+Htj85zsVamMMYso4lpqSoRcdTAwMTYqNLHV06QogsZcdTAwMTZxJjWD5lx0yVx1MDAwN/VpXHUwMDEyKVwiJHTDoJJzzOb0YYVcIkisWFIou1x1MDAxMlx1MDAwYvVwqrkn6Gp1y/H2xMTj7ZVflXjEfVmnSjpcdTAwMDRcbkPoKlx1MDAxZUo6O+3Wyadd0vz482D3tDX+1L44z/Hzklx1MDAwZV5kx1dLOuDsiDBcdTAwMDZdPuJcdTAwMDRcbvB8q89cdHiRay5cdTAwMTTl0PD/R855acvAXHUwMDAyrJBklrt7wYggirFw2UcnlbJcdTAwMWE8M6lQKlmIlCcklW6W+pb7Ulx1MDAwN1x1MDAxNJqb3TNDl1xcz/mtjtHKUG5oZy1Z2rpsVlx1MDAxZjdza7dcdTAwMTLXS+uqarvzwe1cdTAwMWR8XHUwMDE1T8U+m7l3XHUwMDA3zjagrthfyjdZ4XouNcnxLI5nsYrcW8zhq1x1MDAxMVPIXG6Pp9Vps9y/lGdcdTAwMTe90+ukvfOlO+52bfNbp1x1MDAxNYH0x1x1MDAxNaZcXCqFkObznTiQKVx1MDAxMlxcK2jEXHUwMDE5NFLQkn81XnHyKF5BktaKMvyMYv1cdTAwMTJesSrLvlx1MDAxOa/GJlx1MDAxOX1cdTAwMTPEmlx1MDAwMJkwa+2uXCI2TJ63PNhcdTAwMDdcdTAwMTZMeFx1MDAwNi5w8d0lg7rG2NnL7eUoeNetn0przdaKXHUwMDE4tnLAze3a7T8uXFzYXHUwMDFjIn0= timevalueRun the following from the command prompt to preview them.
textual easing\n
You can specify which easing method to use via the easing
parameter on the animate
method. The default easing method is \"in_out_cubic\"
which accelerates and then decelerates to produce a pleasing organic motion.
Note
The textual easing
preview requires the textual-dev
package to be installed (using pip install textual-dev
).
You can pass a callable to the animator via the on_complete
parameter. Textual will run the callable when the animation has completed.
You can delay the start of an animation with the delay
parameter of the animate
method. This parameter accepts a float
value representing the number of seconds to delay the animation by. For example, self.box.styles.animate(\"opacity\", value=0.0, duration=2.0, delay=5.0)
delays the start of the animation by five seconds, meaning the animation will start after 5 seconds and complete 2 seconds after that.
In this chapter we will cover how to use Textual's App class to create an application. Just enough to get you up to speed. We will go in to more detail in the following chapters.
"},{"location":"guide/app/#the-app-class","title":"The App class","text":"The first step in building a Textual app is to import the App class and create a subclass. Let's look at the simplest app class:
from textual.app import App\nclass MyApp(App):\npass\n
"},{"location":"guide/app/#the-run-method","title":"The run method","text":"To run an app we create an instance and call run().
simple02.pyfrom textual.app import App\nclass MyApp(App):\npass\nif __name__ == \"__main__\":\napp = MyApp()\napp.run()\n
Apps don't get much simpler than this\u2014don't expect it to do much.
Tip
The __name__ == \"__main__\":
condition is true only if you run the file with python
command. This allows us to import app
without running the app immediately. It also allows the devtools run command to run the app in development mode. See the Python docs for more information.
If we run this app with python simple02.py
you will see a blank terminal, something like the following:
MyApp
When you call App.run() Textual puts the terminal in to a special state called application mode. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the screen).
If you hit Ctrl+C Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored.
Tip
A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the Option key. See the documentation for your terminal software for how to select text in application mode.
"},{"location":"guide/app/#events","title":"Events","text":"Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with on_
followed by the name of the event.
One such event is the mount event which is sent to an application after it enters application mode. You can respond to this event by defining a method called on_mount
.
Info
You may have noticed we use the term \"send\" and \"sent\" in relation to event handler methods in preference to \"calling\". This is because Textual uses a message passing system where events are passed (or sent) between components. See events for details.
Another such event is the key event which is sent when the user presses a key. The following example contains handlers for both those events:
event01.pyfrom textual.app import App\nfrom textual import events\nclass EventApp(App):\nCOLORS = [\n\"white\",\n\"maroon\",\n\"red\",\n\"purple\",\n\"fuchsia\",\n\"olive\",\n\"yellow\",\n\"navy\",\n\"teal\",\n\"aqua\",\n]\ndef on_mount(self) -> None:\nself.screen.styles.background = \"darkblue\"\ndef on_key(self, event: events.Key) -> None:\nif event.key.isdecimal():\nself.screen.styles.background = self.COLORS[int(event.key)]\nif __name__ == \"__main__\":\napp = EventApp()\napp.run()\n
The on_mount
handler sets the self.screen.styles.background
attribute to \"darkblue\"
which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
EventApp
The key event handler (on_key
) has an event
parameter which will receive a Key instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
Note
It is unusual (but not unprecedented) for a method's parameters to affect how it is called. Textual accomplishes this by inspecting the method prior to calling it.
Some events contain additional information you can inspect in the handler. The Key event has a key
attribute which is the name of the key that was pressed. The on_key
method above uses this attribute to change the background color if any of the keys from 0 to 9 are pressed.
Textual is powered by Python's asyncio framework which uses the async
and await
keywords.
Textual knows to await your event handlers if they are coroutines (i.e. prefixed with the async
keyword). Regular functions are generally fine unless you plan on integrating other async libraries (such as httpx for reading data from the internet).
Tip
For a friendly introduction to async programming in Python, see FastAPI's concurrent burgers article.
"},{"location":"guide/app/#widgets","title":"Widgets","text":"Widgets are self-contained components responsible for generating the output for a portion of the screen. Widgets respond to events in much the same way as the App. Most apps that do anything interesting will contain at least one (and probably many) widgets which together form a User Interface.
Widgets can be as simple as a piece of text, a button, or a fully-fledged component like a text editor or file browser (which may contain widgets of their own).
"},{"location":"guide/app/#composing","title":"Composing","text":"To add widgets to your app implement a compose()
method which should return an iterable of Widget
instances. A list would work, but it is convenient to yield widgets, making the method a generator.
The following example imports a builtin Welcome
widget and yields it from App.compose()
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Welcome\nclass WelcomeApp(App):\ndef compose(self) -> ComposeResult:\nyield Welcome()\ndef on_button_pressed(self) -> None:\nself.exit()\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
When you run this code, Textual will mount the Welcome
widget which contains Markdown content and a button:
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Notice the on_button_pressed
method which handles the Button.Pressed event sent by a button contained in the Welcome
widget. The handler calls App.exit() to exit the app.
While composing is the preferred way of adding widgets when your app starts it is sometimes necessary to add new widget(s) in response to events. You can do this by calling mount() which will add a new widget to the UI.
Here's an app which adds a welcome widget in response to any key press:
widgets02.pyfrom textual.app import App\nfrom textual.widgets import Welcome\nclass WelcomeApp(App):\ndef on_key(self) -> None:\nself.mount(Welcome())\ndef on_button_pressed(self) -> None:\nself.exit()\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
When you first run this you will get a blank screen. Press any key to add the welcome widget. You can even press a key multiple times to add several widgets.
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503\u2582\u2582 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0 \u258c\u00a0that\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0 \u258c\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn \u258c\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 \u258c\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#awaiting-mount","title":"Awaiting mount","text":"When you mount a widget, Textual will mount everything the widget composes. Textual guarantees that the mounting will be complete by the next message handler, but not immediately after the call to mount()
. This may be a problem if you want to make any changes to the widget in the same message handler.
Let's first illustrate the problem with an example. The following code will mount the Welcome widget in response to a key press. It will also attempt to modify the Button in the Welcome widget by changing its label from \"OK\" to \"YES!\".
from textual.app import App\nfrom textual.widgets import Button, Welcome\nclass WelcomeApp(App):\ndef on_key(self) -> None:\nself.mount(Welcome())\nself.query_one(Button).label = \"YES!\" # (1)!\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
query_one
method.If you run this example, you will find that Textual raises a NoMatches exception when you press a key. This is because the mount process has not yet completed when we attempt to change the button.
To solve this we can optionally await the result of mount()
, which requires we make the function async
. This guarantees that by the following line, the Button has been mounted, and we can change its label.
from textual.app import App\nfrom textual.widgets import Button, Welcome\nclass WelcomeApp(App):\nasync def on_key(self) -> None:\nawait self.mount(Welcome())\nself.query_one(Button).label = \"YES!\"\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
Here's the output. Note the changed button text:
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0YES!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#exiting","title":"Exiting","text":"An app will run until you call App.exit() which will exit application mode and the run method will return. If this is the last line in your code you will return to the command prompt.
The exit method will also accept an optional positional value to be returned by run()
. The following example uses this to return the id
(identifier) of a clicked button.
from textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
Running this app will give you the following:
QuestionApp Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Clicking either of those buttons will exit the app, and the run()
method will return either \"yes\"
or \"no\"
depending on button clicked.
You may have noticed that we subclassed App[str]
rather than the usual App
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
The addition of [str]
tells mypy that run()
is expected to return a string. It may also return None
if App.exit() is called without a return value, so the return type of run
will be str | None
. Replace the str
in [str]
with the type of the value you intend to call the exit method with.
Note
Type annotations are entirely optional (but recommended) with Textual.
"},{"location":"guide/app/#return-code","title":"Return code","text":"When you exit a Textual app with App.exit()
, you can optionally specify a return code with the return_code
parameter.
What are return codes?
Returns codes are a standard feature provided by your operating system. When any application exits it can return an integer to indicate if it was successful or not. A return code of 0
indicates success, any other value indicates that an error occurred. The exact meaning of a non-zero return code is application-dependant.
When a Textual app exits normally, the return code will be 0
. If there is an unhandled exception, Textual will set a return code of 1
. You may want to set a different value for the return code if there is error condition that you want to differentiate from an unhandled exception.
Here's an example of setting a return code for an error condition:
if critical_error:\nself.exit(return_code=4, message=\"Critical error occurred\")\n
The app's return code can be queried with app.return_code
, which will be None
if it hasn't been set, or an integer.
Textual won't explicitly exit the process. To exit the app with a return code, you should call sys.exit
. Here's how you might do that:
if __name__ == \"__main__\"\napp = MyApp()\napp.run()\nimport sys\nsys.exit(app.return_code or 0)\n
"},{"location":"guide/app/#css","title":"CSS","text":"Textual apps can reference CSS files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy).
Info
Textual apps typically use the extension .tcss
for external CSS files to differentiate them from browser (.css
) files.
The chapter on Textual CSS describes how to use CSS in detail. For now let's look at how your app references external CSS files.
The following example enables loading of CSS by adding a CSS_PATH
class variable:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button, Label\nclass QuestionApp(App[str]):\nCSS_PATH = \"question02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
Note
We also added an id
to the Label
, because we want to style it in the CSS.
If the path is relative (as it is above) then it is taken as relative to where the app is defined. Hence this example references \"question01.tcss\"
in the same directory as the Python code. Here is that CSS file:
Screen {\nlayout: grid;\ngrid-size: 2;\ngrid-gutter: 2;\npadding: 2;\n}\n#question {\nwidth: 100%;\nheight: 100%;\ncolumn-span: 2;\ncontent-align: center bottom;\ntext-style: bold;\n}\nButton {\nwidth: 100%;\n}\n
When \"question02.py\"
runs it will load \"question02.tcss\"
and update the app and widgets accordingly. Even though the code is almost identical to the previous sample, the app now looks quite different:
QuestionApp Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#classvar-css","title":"Classvar CSS","text":"While external CSS files are recommended for most applications, and enable some cool features like live editing, you can also specify the CSS directly within the Python code.
To do this set a CSS
class variable on the app to a string containing your CSS.
Here's the question app with classvar CSS:
question03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\nCSS = \"\"\"\n Screen {\n layout: grid;\n grid-size: 2;\n grid-gutter: 2;\n padding: 2;\n }\n #question {\n width: 100%;\n height: 100%;\n column-span: 2;\n content-align: center bottom;\n text-style: bold;\n }\n Button {\n width: 100%;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
"},{"location":"guide/app/#title-and-subtitle","title":"Title and subtitle","text":"Textual apps have a title
attribute which is typically the name of your application, and an optional sub_title
attribute which adds additional context (such as the file your are working on). By default, title
will be set to the name of your App class, and sub_title
is empty. You can change these defaults by defining TITLE
and SUB_TITLE
class variables. Here's an example of that:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button, Header, Label\nclass MyApp(App[str]):\nCSS_PATH = \"question02.tcss\"\nTITLE = \"A Question App\"\nSUB_TITLE = \"The most important question\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = MyApp()\nreply = app.run()\nprint(reply)\n
Note that the title and subtitle are displayed by the builtin Header widget at the top of the screen:
A\u00a0Question\u00a0App \u2b58A\u00a0Question\u00a0App\u00a0\u2014\u00a0The\u00a0most\u00a0important\u00a0question Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
You can also set the title attributes dynamically within a method of your app. The following example sets the title and subtitle in response to a key press:
question_title02.pyfrom textual.app import App, ComposeResult\nfrom textual.events import Key\nfrom textual.widgets import Button, Header, Label\nclass MyApp(App[str]):\nCSS_PATH = \"question02.tcss\"\nTITLE = \"A Question App\"\nSUB_TITLE = \"The most important question\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\ndef on_key(self, event: Key):\nself.title = event.key\nself.sub_title = f\"You just pressed {event.key}!\"\nif __name__ == \"__main__\":\napp = MyApp()\nreply = app.run()\nprint(reply)\n
If you run this app and press the T key, you should see the header update accordingly:
A\u00a0Question\u00a0App \u2b58t\u00a0\u2014\u00a0You\u00a0just\u00a0pressed\u00a0t! Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Info
Note that there is no need to explicitly refresh the screen when setting the title attributes. This is an example of reactivity, which we will cover later in the guide.
"},{"location":"guide/app/#whats-next","title":"What's next","text":"In the following chapter we will learn more about how to apply styles to your widgets and app.
"},{"location":"guide/command_palette/","title":"Command Palette","text":"Textual apps have a built-in command palette, which gives users a quick way to access certain functionality within your app.
In this chapter we will explain what a command palette is, how to use it, and how you can add your own commands.
"},{"location":"guide/command_palette/#launching-the-command-palette","title":"Launching the command palette","text":"Press Ctrl + \\
(ctrl and backslash) to invoke the command palette screen, which contains of a single input widget. Textual will suggest commands as you type in that input. Press Up or Down to select a command from the list, and Enter to invoke it.
Commands are looked up via a fuzzy search, which means Textual will show commands that match the keys you type in the same order, but not necessarily at the start of the command. For instance the \"Toggle light/dark mode\" command will be shown if you type \"to\" (for toggle), but you could also type \"dm\" (to match dark mode). This scheme allows the user to quickly get to a particular command with a minimum of key-presses.
Command PaletteCommand Palette after 't'Command Palette after 'td'ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0eCommand\u00a0Palette\u00a0Search... \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0et Toggle\u00a0light/dark\u00a0mode Toggle\u00a0the\u00a0application\u00a0between\u00a0light\u00a0and\u00a0dark\u00a0mode Quit\u00a0the\u00a0application Quit\u00a0the\u00a0application\u00a0as\u00a0soon\u00a0as\u00a0possible Ring\u00a0the\u00a0bell Ring\u00a0the\u00a0terminal's\u00a0'bell' \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0etd Toggle\u00a0light/dark\u00a0mode Toggle\u00a0the\u00a0application\u00a0between\u00a0light\u00a0and\u00a0dark\u00a0mode \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/command_palette/#default-commands","title":"Default commands","text":"Textual apps have the following commands enabled by default:
\"Toggle light/dark mode\"
This will toggle between light and dark mode, by setting App.dark
to either True
or False
.\"Quit the application\"
Quits the application. The equivalent of pressing ++ctrl+C++.\"Play the bell\"
Plays the terminal bell, by calling App.bell
.To add your own command(s) to the command palette, define a command.Provider
class then add it to the COMMANDS
class var on your App
class.
Let's look at a simple example which adds the ability to open Python files via the command palette.
The following example will display a blank screen initially, but if you bring up the command palette and start typing the name of a Python file, it will show the command to open it.
Tip
If you are running that example from the repository, you may want to add some additional Python files to see how the examples works with multiple files.
command01.pyfrom __future__ import annotations\nfrom functools import partial\nfrom pathlib import Path\nfrom rich.syntax import Syntax\nfrom textual.app import App, ComposeResult\nfrom textual.command import Hit, Hits, Provider\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Static\nclass PythonFileCommands(Provider):\n\"\"\"A command provider to open a Python file in the current working directory.\"\"\"\ndef read_files(self) -> list[Path]:\n\"\"\"Get a list of Python files in the current working directory.\"\"\"\nreturn list(Path(\"./\").glob(\"*.py\"))\nasync def startup(self) -> None: # (1)!\n\"\"\"Called once when the command palette is opened, prior to searching.\"\"\"\nworker = self.app.run_worker(self.read_files, thread=True)\nself.python_paths = await worker.wait()\nasync def search(self, query: str) -> Hits: # (2)!\n\"\"\"Search for Python files.\"\"\"\nmatcher = self.matcher(query) # (3)!\napp = self.app\nassert isinstance(app, ViewerApp)\nfor path in self.python_paths:\ncommand = f\"open {str(path)}\"\nscore = matcher.match(command) # (4)!\nif score > 0:\nyield Hit(\nscore,\nmatcher.highlight(command), # (5)!\npartial(app.open_file, path),\nhelp=\"Open this file in the viewer\",\n)\nclass ViewerApp(App):\n\"\"\"Demonstrate a command source.\"\"\"\nCOMMANDS = App.COMMANDS | {PythonFileCommands} # (6)!\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nyield Static(id=\"code\", expand=True)\ndef open_file(self, path: Path) -> None:\n\"\"\"Open and display a file with syntax highlighting.\"\"\"\nsyntax = Syntax.from_path(\nstr(path),\nline_numbers=True,\nword_wrap=False,\nindent_guides=True,\ntheme=\"github-dark\",\n)\nself.query_one(\"#code\", Static).update(syntax)\nif __name__ == \"__main__\":\napp = ViewerApp()\napp.run()\n
There are three methods you can override in a command provider: startup
, search
, and shutdown
. All of these methods should be coroutines (async def
). Only search
is required, the other methods are optional. Let's explore those methods in detail.
The startup
method is called when the command palette is opened. You can use this method as way of performing work that needs to be done prior to searching. In the example, we use this method to get the Python (.py) files in the current working directory.
The search
method is responsible for finding results (or hits) that match the user's input. This method should yield Hit
objects for any command that matches the query
argument.
Exactly how the matching is implemented is up to the author of the command provider, but we recommend using the builtin fuzzy matcher object, which you can get by calling matcher
. This object has a match()
method which compares the user's search term against the potential command and returns a score. A score of zero means no hit, and you can discard the potential command. A score of above zero indicates the confidence in the result, where 1 is an exact match, and anything lower indicates a less confident match.
The Hit
contains information about the score (used in ordering) and how the hit should be displayed, and an optional help string. It also contains a callback, which will be run if the user selects that command.
In the example above, the callback is a lambda which calls the open_file
method in the example app.
Note
Unlike most other places in Textual, errors in command provider will not exit the app. This is a deliberate design decision taken to prevent a single broken Provider
class from making the command palette unusable. Errors in command providers will be logged to the console.
The shutdown
method is called when the command palette is closed. You can use this as a hook to gracefully close any objects you created in startup
.
You can also associate commands with a screen by adding a COMMANDS
class var to your Screen class.
Commands defined on a screen are only considered when that screen is active. You can use this to implement commands that are specific to a particular screen, that wouldn't be applicable everywhere in the app.
"},{"location":"guide/command_palette/#disabling-the-command-palette","title":"Disabling the command palette","text":"The command palette is enabled by default. If you would prefer not to have the command palette, you can set ENABLE_COMMAND_PALETTE = False
on your app class.
Here's an app class with no command palette:
class NoPaletteApp(App):\nENABLE_COMMAND_PALETTE = False\n
"},{"location":"guide/design/","title":"Design System","text":"Textual's design system consists of a number of predefined colors and guidelines for how to use them in your app.
You don't have to follow these guidelines, but if you do, you will be able to mix builtin widgets with third party widgets and your own creations, without worrying about clashing colors.
Information
Textual's color system is based on Google's Material design system, modified to suit the terminal.
"},{"location":"guide/design/#designing-with-colors","title":"Designing with Colors","text":"Textual pre-defines a number of colors as CSS variables. For instance, the CSS variable $primary
is set to #004578
(the blue used in headers). You can use $primary
in place of the color in the background and color rules, or other any other rule that accepts a color.
Here's an example of CSS that uses color variables:
MyWidget {\nbackground: $primary;\ncolor: $text;\n}\n
Using variables rather than explicit colors allows Textual to apply color themes. Textual supplies a default light and dark theme, but in the future many more themes will be available.
"},{"location":"guide/design/#base-colors","title":"Base Colors","text":"There are 12 base colors defined in the color scheme. The following table lists each of the color names (as used in CSS) and a description of where to use them.
Color Description$primary
The primary color, can be considered the branding color. Typically used for titles, and backgrounds for strong emphasis. $secondary
An alternative branding color, used for similar purposes as $primary
, where an app needs to differentiate something from the primary color. $primary-background
The primary color applied to a background. On light mode this is the same as $primary
. In dark mode this is a dimmed version of $primary
. $secondary-background
The secondary color applied to a background. On light mode this is the same as $secondary
. In dark mode this is a dimmed version of $secondary
. $background
A color used for the background, where there is no content. $surface
The color underneath text. $panel
A color used to differentiate a part of the UI form the main content. Typically used for dialogs or sidebars. $boost
A color with alpha that can be used to create layers on a background. $warning
Indicates a warning. Text or background. $error
Indicates an error. Text or background. $success
Used to indicate success. Text or background. $accent
Used sparingly to draw attention to a part of the UI (typically borders around focused widgets)."},{"location":"guide/design/#shades","title":"Shades","text":"For every color, Textual generates 3 dark shades and 3 light shades.
-lighten-1
, -lighten-2
, or -lighten-3
to the color's variable name to get lighter shades (3 is the lightest).-darken-1
, -darken-2
, and -darken-3
to a color to get the darker shades (3 is the darkest).For example, $secondary-darken-1
is a slightly darkened $secondary
, and $error-lighten-3
is a very light version of the $error
color.
There are two color themes in Textual, a light mode and dark mode. You can switch between them by toggling the dark
attribute on the App class.
In dark mode $background
and $surface
are off-black. Dark mode also set $primary-background
and $secondary-background
to dark versions of $primary
and $secondary
.
The design system defines three CSS variables you should use for text color.
$text
sets the color of text in your app. Most text in your app should have this color.$text-muted
sets a slightly faded text color. Use this for text which has lower importance. For instance a sub-title or supplementary information.$text-disabled
sets faded out text which indicates it has been disabled. For instance, menu items which are not applicable and can't be clicked.You can set these colors via the color property. The design system uses auto
colors for text, which means that Textual will pick either white or black (whichever has better contrast).
Information
These text colors all have some alpha applied, so that even $text
isn't pure white or pure black. This is done because blending in a little of the background color produces text that is not so harsh on the eyes.
In a future version of Textual you will be able to modify theme colors directly, and allow users to configure preferred themes.
"},{"location":"guide/design/#color-preview","title":"Color Preview","text":"Run the following from the command line to preview the colors defined in the color system:
textual colors\n
"},{"location":"guide/design/#theme-reference","title":"Theme Reference","text":"Here's a list of the colors defined in the default light and dark themes.
Textual\u00a0Theme\u00a0Colors \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Light\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Dark\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-3\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-2\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-1\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-1\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-2\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-3\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-3\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-2\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-1\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-1\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-2\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-3\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0
"},{"location":"guide/devtools/","title":"Devtools","text":"Note
If you don't have the textual
command on your path, you may have forgotten to install the textual-dev
package.
See getting started for details.
Textual comes with a command line application of the same name. The textual
command is a super useful tool that will help you to build apps.
Take a moment to look through the available subcommands. There will be even more helpful tools here in the future.
textual --help\n
"},{"location":"guide/devtools/#run","title":"Run","text":"The run
sub-command runs Textual apps. If you supply a path to a Python file it will load and run the app.
textual run my_app.py\n
This is equivalent to running python my_app.py
from the command prompt, but will allow you to set various switches which can help you debug, such as --dev
which enable the Console.
See the run
subcommand's help for details:
textual run --help\n
You can also run Textual apps from a python import. The following command would import music.play
and run a Textual app in that module:
textual run music.play\n
This assumes you have a Textual app instance called app
in music.play
. If your app has a different name, you can append it after a colon:
textual run music.play:MusicPlayerApp\n
Note
This works for both Textual app instances and classes.
"},{"location":"guide/devtools/#running-from-commands","title":"Running from commands","text":"If your app is installed as a command line script, you can use the -c
switch to run it. For instance, the following will run the textual colors
command:
textual run -c textual colors\n
"},{"location":"guide/devtools/#live-editing","title":"Live editing","text":"If you combine the run
command with the --dev
switch your app will run in development mode.
textual run --dev my_app.py\n
One of the features of dev mode is live editing of CSS files: any changes to your CSS will be reflected in the terminal a few milliseconds later.
This is a great feature for iterating on your app's look and feel. Open the CSS in your editor and have your app running in a terminal. Edits to your CSS will appear almost immediately after you save.
"},{"location":"guide/devtools/#console","title":"Console","text":"When building a typical terminal application you are generally unable to use print
when debugging (or log to the console). This is because anything you write to standard output will overwrite application content. Textual has a solution to this in the form of a debug console which restores print
and adds a few additional features to help you debug.
To use the console, open up two terminal emulators. Run the following in one of the terminals:
textual console\n
You should see the Textual devtools welcome message:
textual\u00a0console \u258cTextual\u00a0Development\u00a0Console\u00a0v0.36.0 \u258cRun\u00a0a\u00a0Textual\u00a0app\u00a0with\u00a0textual\u00a0run\u00a0--dev\u00a0my_app.py\u00a0to\u00a0connect. \u258cPress\u00a0Ctrl+C\u00a0to\u00a0quit.
In the other console, run your application with textual run
and the --dev
switch:
textual run --dev my_app.py\n
Anything you print
from your application will be displayed in the console window. Textual will also write log messages to this window which may be helpful when debugging your application.
Textual writes log messages to inform you about certain events, such as when the user presses a key or clicks on the terminal. To avoid swamping you with too much information, some events are marked as \"verbose\" and will be excluded from the logs. If you want to see these log messages, you can add the -v
switch.
textual console -v\n
"},{"location":"guide/devtools/#decreasing-verbosity","title":"Decreasing verbosity","text":"Log messages are classififed in to groups, and the -x
flag can be used to exclude all message from a group. The groups are: EVENT
, DEBUG
, INFO
, WARNING
, ERROR
, PRINT
, SYSTEM
, and LOGGING
. The group a message belongs to is printed after its timestamp.
Multiple groups may be excluded, for example to exclude everything except warning, errors, and print
statements:
textual console -x SYSTEM -x EVENT -x DEBUG -x INFO\n
"},{"location":"guide/devtools/#custom-port","title":"Custom port","text":"You can use the option --port
to specify a custom port to run the console on, which comes in handy if you have other software running on the port that Textual uses by default:
textual console --port 7342\n
Then, use the command run
with the same --port
option:
textual run --dev --port 7342 my_app.py\n
"},{"location":"guide/devtools/#textual-log","title":"Textual log","text":"Use the log
function to pretty-print data structures and anything that Rich can display.
You can import the log function as follows:
from textual import log\n
Here's a few examples of writing to the console, with log
:
def on_mount(self) -> None:\nlog(\"Hello, World\") # simple string\nlog(locals()) # Log local variables\nlog(children=self.children, pi=3.141592) # key/values\nlog(self.tree) # Rich renderables\n
"},{"location":"guide/devtools/#log-method","title":"Log method","text":"There's a convenient shortcut to log
on the App
and Widget
objects. This is useful in event handlers. Here's an example:
from textual.app import App\nclass LogApp(App):\ndef on_load(self):\nself.log(\"In the log handler!\", pi=3.141529)\ndef on_mount(self):\nself.log(self.tree)\nif __name__ == \"__main__\":\nLogApp().run()\n
"},{"location":"guide/devtools/#logging-handler","title":"Logging handler","text":"Textual has a logging handler which will write anything logged via the builtin logging library to the devtools. This may be useful if you have a third-party library that uses the logging module, and you want to see those logs with Textual logs.
Note
The logging library works with strings only, so you won't be able to log Rich renderables such as self.tree
with the logging handler.
Here's an example of configuring logging to use the TextualHandler
.
import logging\nfrom textual.app import App\nfrom textual.logging import TextualHandler\nlogging.basicConfig(\nlevel=\"NOTSET\",\nhandlers=[TextualHandler()],\n)\nclass LogApp(App):\n\"\"\"Using logging with Textual.\"\"\"\ndef on_mount(self) -> None:\nlogging.debug(\"Logged via TextualHandler\")\nif __name__ == \"__main__\":\nLogApp().run()\n
"},{"location":"guide/events/","title":"Events and Messages","text":"We've used event handler methods in many of the examples in this guide. This chapter explores events and messages (see below) in more detail.
"},{"location":"guide/events/#messages","title":"Messages","text":"Events are a particular kind of message sent by Textual in response to input and other state changes. Events are reserved for use by Textual, but you can also create custom messages for the purpose of coordinating between widgets in your app.
More on that later, but for now keep in mind that events are also messages, and anything that is true of messages is true of events.
"},{"location":"guide/events/#message-queue","title":"Message Queue","text":"Every App and Widget object contains a message queue. You can think of a message queue as orders at a restaurant. The chef takes an order and makes the dish. Orders that arrive while the chef is cooking are placed in a line. When the chef has finished a dish they pick up the next order in the line.
Textual processes messages in the same way. Messages are picked off a queue and processed (cooked) by a handler method. This guarantees messages and events are processed even if your code can not handle them right away.
This processing of messages is done within an asyncio Task which is started when you mount the widget. The task monitors a queue for new messages and dispatches them to the appropriate handler when they arrive.
Tip
The FastAPI docs have an excellent introduction to Python async programming.
By way of an example, let's consider what happens if you were to type \"Text\" in to a Input
widget. When you hit the T key, Textual creates a key event and sends it to the widget's message queue. Ditto for E, X, and T.
The widget's task will pick the first message from the queue (a key event for the T key) and call the on_key
method with the event as the first argument. In other words it will call Input.on_key(event)
, which updates the display to show the new letter.
When the on_key
method returns, Textual will get the next event from the queue and repeat the process for the remaining keys. At some point the queue will be empty and the widget is said to be in an idle state.
Note
This example illustrates a point, but a typical app will be fast enough to have processed a key before the next event arrives. So it is unlikely you will have so many key events in the message queue.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPbOLb93r9cIpX+8l5Vi1xy3Fx1MDAwYlxcXHUwMDAwUzX1yrvlxI5cdTAwMTfF25splyxRiy1LskSvXf3f50JxLGohJVlL5ExUldgmaVx1MDAxMCbP3Vx1MDAwZi7++u3Dh4/RUzP8+I9cdTAwMGZcdTAwMWbDx0K+Vi228lx1MDAwZlx1MDAxZv/wx+/DVrvaqPMp6Pzcbty1XG6dKytR1Gz/488/b/Kt6zBq1vKFMLivtu/ytXZ0V6w2gkLj5s9qXHUwMDE03rT/z/+/l79cdP/ZbNxcdTAwMTSjVtC9SSYsVqNG69u9wlp4XHUwMDEz1qM2j/7//POHXHUwMDBmf3X+j82uVq2HnWs7R7tzU6D7j+416p15XCKR4tOye0G1vc53isJcIp8t8WzD7lx1MDAxOX/oY+FY1pu721x1MDAwN42709rGTqV1+bTXyHTvWqrWakfRU+3bQ8hcdTAwMTcqd63YnNpRq3FcdTAwMWSeVItR5fszi1x1MDAxZH/9vWK+XeFcdLyebjXuypV62PZ/u3w92mjmXHUwMDBi1ejJXHUwMDFmXHUwMDEz4vVovl7uXGbSPfLIP2VcdTAwMDBFYFx1MDAxZFx1MDAxOaW1QjSWXk/7XHUwMDAxMnxSKyc0WWONUYawb25rjVx1MDAxYb9cYp7b76Lz6U7uMl+4LvNcZuvF12uiVr7ebuZb/Lq61z18/6tcdTAwMDOSioxUyqFcdTAwMDDFd3u9pFx1MDAxMlbLlci/XHUwMDE2XHUwMDBiXHUwMDAxSJJSW5RGQXe27bDzZow2xlxup8zrXHQ/hWa22IHHv/tcdTAwMWZsJd9qvjy/j52pxqbvf9yIYav7y3fNYv5cdTAwMWJcdTAwMTAkXHUwMDExgjZcdTAwMWFcdTAwMTGlfT3PeLvmk/W7Wq17rFG4XHUwMDFlgp12lG9Fq9V6sVov9/9KWC8mnKnl29Fa4+amXHUwMDFh8TT2XHUwMDFi1XrUf0Vn3JVWq/FQXHTzxSEjJ55r+uG6wuQ/3e8+dOHT+eH1+3//MfTq5HfqP1x1MDAwM2+zO9xv8a9//zGZXFzHxbZPrqU1XHUwMDEy/VvrXHUwMDAyZJRgXHUwMDE3XHUwMDFmt+uHNbm/cqLXilLKp1P8urX0gk1cdTAwMTiAQaeVllx1MDAwNqxm+e5cdTAwMTVsXHUwMDE5KFx1MDAwM1JoaYGUk6J/arOTa1xmLFx0x4IpXHUwMDE0KiMsdoW2K9fGXHUwMDA1pNFJLdFcYsDYbL7LtZaSQHfVzy+5/oFynfxOO2f73+aEct1cblx1MDAwYtE3VFx1MDAwZlx1MDAxMW5cdTAwMGKq/+h34TYgyFkruiBcdTAwMTkl23pcdTAwMTez6vhuf0tcdTAwMWWXXHUwMDBln45WN6urXHUwMDBme2+TbdmPwe+/126w0zJbmy1VQGCsklpb/qe74uJHXHUwMDAwZ1x1MDAwMudcdTAwMDRcdTAwMTFYh85p0zeziUT7d1WgsKSVXHUwMDFhYrCpK1x1MDAxYa+SbFx1MDAwN0TXXHUwMDE5XCJWMNrNXnRfcfVXXGZ9L6/2+jS7cnZcdTAwMWGFZ1x1MDAwZW9Xwp2zbPSw1vxcdTAwMTiH6SveovAx+vh64u8/foZhe67+Y9xcdTAwMWJ2h/0uqDNUjqky3zPPmLhcdTAwMWIjksRdXHUwMDFhUsiuKcmx5T39MS+tvFx1MDAwYpcm71xiJlCzkvd0XHUwMDE3XVx1MDAwZpF4wH6JZ/3EXHUwMDEzVSrmc4wt8m3/w6KtdalRj46qz/7Rg+g5upm/qdY6XHUwMDBm+fVwXHUwMDA3qT7mvfdzXG4+hU//c1x1MDAxZD79819cdTAwMWbDf3383/izbYdcdTAwMWRcdTAwMDeV5+d6fnmlVi3XOyEjXHUwMDBmXHUwMDEwtnpQXHUwMDFmVTnGfb3gplosxq1ggWeU5zFb2XGsV6NVLVfr+VoudcJvt8RSOpckmyDY9+T34cZcdTAwMGagm0BQyX6y95VTKp5+NjuGtnaWXzalXHUwMDBljJLkUKFjV1x1MDAwN/pssVCBs8ZZJVxmsG+ippHN6W0xsVtG1mo5uWBOY4tLXHUwMDE2Tyh7sEVCXHUwMDFlZTZcdTAwMGLbUT17tT290fwvXHUwMDFmdpSJXHUwMDFmfsPlM/FSxtI1/TZeXHUwMDE44jBC4vg+ffpzXl49XCJS9VxiqkDNSo/MxMZrhWTi7+W/wcQ/LtrEjzCKI03845QmXHUwMDFlY2++XzSVcFx1MDAxY+lrM777fWLv7k5LVXyqf3Lbd+XqMUVN/Vx1MDAwZURTXHUwMDA11pJBpYXSZGLa6ttcYi4wXHUwMDE2vOdttXFcdTAwMTLtNLI5vY2X6KQlqWBcdTAwMGU58DT7tpYt3lZutrPQus3kSoXyVrNcdTAwMDJcdTAwMGbTm81fw85j2FG+w/BcdTAwMWIuoe9cdTAwMDAmMVx1MDAxZFxiXHUwMDAyyLDcqq5uXHUwMDFlpaDSn/PSKijWQGlcblxuXVx1MDAwMDNTULNwXHUwMDFlkDg45Hm8ISX4jp2HaNHOw1xiczvSeYimdFx1MDAxZYSEJNmUvqpsSajx81x1MDAwM6JcXCg+fd543nzOrZ6a1np0YrYuXHUwMDEzZLPQarTbmUo+KlQmrsXNXFw+Ob5cdKRcdTAwMDGSTrGEOid7xVNcdTAwMDdWXHUwMDBiw+60clx1MDAwNlx1MDAwNcr5pe/Yq1xiWFx1MDAxMtBnTaWMXHUwMDE1Sbt1OFx1MDAxMoFlh0YpQuzUXHUwMDA2+0WXWMU4K/Fcclx1MDAxOYRpRJescjSB6L5cdTAwMWS1XHUwMDA2XHUwMDEyXHUwMDEzzmA58lwimKB2/PDl7Ph+t+lobbN+vXffzl2VaDdcdTAwMDGzfbj7cWjFwFxuzViUgpW0xD6wXHUwMDAyaTYhPi5nXHUwMDE42XmCXHUwMDE1XHUwMDAzIUn74rU0zlxmMyw2IFx1MDAxMqhYj1x1MDAwMCFcdTAwMDNlXHUwMDAwrdJcIlnygcpyW5pUuIa1WrXZXHUwMDFlXG5W0olMXHUwMDA3p4W39+P7Puu3bq0sXHUwMDFh1fUoV6GV+pdqWTxfvVx1MDAwNauL83wk6MBcYumENlx1MDAwNlx1MDAxND952Vx1MDAwM1apXHUwMDAzjUaQY8xqXHUwMDA2K01Fcvi9lNegYVx1MDAxMKksMEpcdTAwMDBoR+xxgoonZ16hKiHQRMqxUlx1MDAxNYBcYjFcdTAwMTftO1RcdTAwMWRJY1x1MDAxNDtoPydUjUgsXHUwMDE2+EJcdTAwMDJpJeT4Sb6TxtZOY2vvYHf/5jh7lFx1MDAxN7lneX605GDV4K1vJzRX6ND2eekqQKk1Y4RcdTAwMDVcdTAwMTdcZsXM3dvAeimEnlx1MDAxYljRSkOCZe8nXHUwMDA1KyWnvdjYgEOYgGWSoULrRu+F1fPTs8v80VWrjodPS1x1MDAwZVYrXHUwMDAy0pbh4Yz3XHUwMDA2bS9WkVxyL1tV7SyHcYyT6VJeXHUwMDEyLtmvmlx1MDAxN1ZZliSHxPie9Wqqx0o6UbNqUFx1MDAxMlmnjFx1MDAxZmWd5MzVYSV/U9k938k837ab1y6iXHUwMDA0rL6V7ThztIKgXHUwMDAwpFJAXHUwMDA2vVbqJTFLclx1MDAwMTgvs6y1PMV5jlxcRyNcdTAwMDPHYZZX4GRUTD2+XHUwMDAyVstcdTAwMDDNNzWPXG6MjnHQvytX45xcdTAwMDWcS23l5cTQJOP9cVbVVm+fNr82M/fb59tfT7ZKZ/NMMlx1MDAwZb9hd9iX7354klx1MDAxMZO9bClYN1x1MDAxMlx1MDAxODF+TJj+mJc0x1xiUqZJmKFAWWG0L1xisveC81x1MDAwYlx1MDAwYpFGSlx1MDAxOFx1MDAwZZhcdTAwMDBcdTAwMDaLs07rd8hH8sszXHUwMDE4Y2jIXHUwMDAya1x1MDAwYqN7LkpJP+aGplx1MDAxYVH0XFz4mkmshaVe6E+WaEw3XHUwMDFh/YnG3Fx1MDAxNElFJWWSLJJcdTAwMDEhrFx1MDAxNuOHvMeZ6PK2urlXOz0pn12VVk4zN/nscjtmLFuBJasth6LaOWN7ef3SYoDs7CiUgFx1MDAxY/xOtVxc56VcdTAwMTY5xDEzgWGnT7BMKVx1MDAxZrfi8OSMXHUwMDEywnFIKzTH6U5cZjpmyidtOEpZbqlMxWryXHUwMDEylORo11x1MDAxYZRKqFx0XHUwMDAyiIddsV68P195PG89Xrebh1FUeWjN2Cmby9oy1tVWO4UgjZZcdTAwMWPi92BVkVx1MDAwZZBDXGb5Ld6FfiFasqVlUmhfTsS3xLy/1qB0zv1cdTAwMDRryyhZsI2w1lx1MDAxOFx1MDAwYuPzYS42L53UT7BfrFx1MDAxZcv1c7neLlx1MDAxZp4svVxcXHUwMDEz+Fx1MDAxNaPgWIU5xyFMb9LVSzU/dDZR5Dx+xfyoalx1MDAxOEjl2CfloE5rXHUwMDAwXHUwMDE4YoX8UiQgskpY9lx1MDAxNy1cckg1O1fOL2Waw+qUX0LtP5MuLEt4pZ2T/S9zQplcdTAwMWVBdVOJnqVcdTAwMTRoXHUwMDA1mHh9YZRor+7fXVx1MDAxY8r26flDTWy3rlx1MDAwZlx0sVx1MDAwMctcdTAwMWXlZVhjXHUwMDA2noPKVpmkYC+lN84z2lx1MDAwNb6K7UChXHUwMDE22s3Cu5yG6SZYs/ty5IKZboXWYeZi5XRtu1x1MDAwNNW7jWN+zc/H+9OTvH5ccjuPYUcloYbfsDvsd82yqIhcIpnpZm3/4S6bXHUwMDA2lNPG2lx02lWkPufl1U8yVT9ZXHUwMDFi0Kz00yyIbpI9VH415i39KH505uk90eRHWNu50+S1SF7B4pRcdTAwMTakXHUwMDEwx09LRe58y17WWpXN58+5q0+5i82tKImJsUyyyY4/kSSQVkiUvYkpPlx1MDAxMXSWt4DlmExN10dmetfBXGKNPnhcXLDnXHUwMDEwfn740l5dfb4o7aysRcdcdTAwMWKHub3bcHqj+WvYdzTsKIdk+Fxyl9AhUTpR6ZGViJMszE9/ysur8kSaynNcInCzUnkz8Ua01Vx1MDAwNMLJJc+4z9hcdTAwMWJZOO9+hP2eO+9cdTAwMWUokXfv0Fx1MDAxOHJajC+ZT1dP5d3jXG6gK2Tz+0dcctkw2fz7oN3rXHUwMDAwkP1cdTAwMDF2v1x1MDAxZItnf57SYEBWKOUkkmDJmF+eUmtcdTAwMTlcYoNgXHUwMDEwWVx1MDAxM0DM6YhcdTAwMTHvNV9iXHUwMDFjkTZcdTAwMWPokVx1MDAxOFJ+UKCs027BoYRcdTAwMDGn1Fx1MDAwNML7dty6WGewfthKUlx1MDAxYeI07pFd29a/NL9cdTAwMWWph3ZOZa5lTX6+XFy/uFh25r3HXHUwMDAwaWLAcrTvpO1cdTAwMDMsXHUwMDA0YKywXGZcdTAwMDGNen7VMq1cXKDQovJcdTAwMGLGKL6ONF7cdc5cdTAwMDFbXHUwMDEyy3hGUv1oRY7OeziSXHUwMDBiwapcdTAwMDJQM1slkkJcdTAwMGa1NpFcdTAwMWVcboJDPclcbmV8XHL7aaWZrZePi5vnj7uVtdrGp690t+QsXHUwMDA0NjGBdVx1MDAxNlx1MDAwMY10IJXp5TIri1x1MDAwMVx1MDAwZqJQa4NcdTAwMWNo4bRcXOZE4r0mzzpSgr84I4d0XHUwMDE2lFx1MDAxMFx1MDAxOKklq1/tXHUwMDFjuXjrk1dcdTAwMWFcdTAwMDKAdKhcdTAwMTesV1x1MDAxN4VVh8mpXHRcdTAwMTZRYmtcdTAwMDPjpybOro4q2Vx1MDAxM1co1Vx1MDAwYitcdTAwMDeZ3U27+1x1MDAxNZeed69cdTAwMDKPUCNAsONDivqwKlx1MDAwM9ZyymotSKrp8lx1MDAxMims+1x1MDAxOSCVpc5cdTAwMTH/XHUwMDE1uGD/fWFQdYlcdTAwMTU4VEJIXHUwMDAxMD7N8su5OVxcKa1cdTAwMWPU90pfi632Sq7cLN4tOVItXHUwMDA0WmhALaxfx1xyXHUwMDAzQFx1MDAwNb8kz1x1MDAxYlxcS1O2Yk4j3c9cdTAwMDCqRiqrLL2BKrM0QFx1MDAxZOGrJrZcdTAwMWSQmpBgolx1MDAxOOvgYCVcdTAwMTNlbr/uPt6b/U+nlXpNbL+xXHLpXCJJ9zbo8Pek0X49f1/HXCJCXHUwMDFikFGsblx1MDAxOcxg5Pz8VTRcdTAwMTSwl+GZhOBLwkNcdTAwMDCrPVx1MDAxMY1naDtsIVx1MDAxMFx1MDAwM/6qNJZxTKRcdTAwMTbMub+9P6jIkthcdTAwMTD7N7vV0+yXyu7Jp+5b6sHjJEnLmVx1MDAwZjsqaTn8ht1hX75cdTAwMWIlvHNPWpJJ5mVcdTAwMDJcdTAwMTkt0NnxbUz6Y17SrCWbj1S5VTogXCIgwYrMoJnjYlx1MDAxOVx1MDAxYSm38VYlL4LK8bFmV1xyXHUwMDE3767Hqkhv5PLLQFx1MDAxYcdxXHUwMDExsNUkNp+q56o0Mn9cdTAwMTh/zHNm84+wRlx1MDAwM2z+cIpcXKWRid1cdTAwMTaMXHUwMDA1/uBcdTAwMDTiWL86XHUwMDE3XHUwMDBlz8zuZeuomTvcKOWz0cZyu3ygfdHU8lx1MDAxN5IgXHUwMDA0qD5Z9JlcdTAwMThAJdnP8vHJVFx1MDAxZV9cbptcdTAwMWZcYtHPQrGDXHUwMDE505DxfI/hXHUwMDBiNMuqRf5ucNFcdTAwMWFcYt/01yyc6eCthptALlOhmsjmd4lcdTAwMGLAQLKDXHUwMDAziGp8mJrjzY2mOP1aKT2stHdcdTAwMTGuXHUwMDFmSup+6Vm/nZ1iiP/zvTbQ8S/0dlx1MDAwNnHCXHUwMDA1gFKC8ett3Vx1MDAxYzeUmFxym99cdTAwMGbgJP5cIv4uXHUwMDA38fdcdTAwMDex+V1ipYyjXG5jhJ2gJ8Xh5Vx1MDAxNT5cItjbp88n7UyYscfl+vrSyzVh4Fh/WVx1MDAwZehZtEH0eoNeqn3mRYBcdTAwMDZcdTAwMTNfXHUwMDA3O3suv7K+14+z7Fx1MDAxNzlcdTAwMTVPxCWS+c1ghVtpYJy8qS/FL6nunJsxnT/prXbOXHUwMDBlvM9cdMU6vVxmTsmdvKTfzcw5sFx1MDAxM3ScuXiWpcszudW42C192s/WcpWd99G7Vvt9kzjqY1x1MDAwZlxydN/CbemMX4PnwDdQ0qzyZuBhTkHLXHUwMDAz4djnXHUwMDA1WjAtb1/kLteal+p0o3L7eFx1MDAxODZcdTAwMGJPutTtstxcdTAwMDO5SbIwv4b9NeyoxNnwXHUwMDFidof9rlx1MDAwN2dofFJValwi209TYubMt4OUksZffJD+lJdWl7IvlKZLwVx1MDAxN8VnpUtnwvdcdTAwMTNcdTAwMWOzcyxml7xLV/fFv0++31xi12BcdTAwMDF8v+RilFG+i9dcdTAwMDR+XHUwMDBlbd5dXHUwMDE3zvbWqfglX4n29s/3dlx1MDAxM1uWLlx1MDAxN99cdTAwMGZE4FxmXGKjhe9MKmNdsTqRjPeE/Fx1MDAxZbdW+35mMD82rla+RYe0VnC8KmxM+mJ0P1xiXHUwMDFjz5M0XHREgYP1KIlcbv062Fx1MDAwNYuu8f06ZmVL0iuoyV3LJOtQ313WjV9B3dws2Mdy9sneX8rdtdXSefnpxC053Vx1MDAwZnyvZd+e1oKxMr4v6je8YmB8/zZcdTAwMGXBnW9cZjlHvFJcdTAwMDDI01CgXHUwMDFjuWHL6MFcdTAwMDZcdTAwMTZcdTAwMDU4w+FcdTAwMWNcdTAwMDdxclx1MDAxMK+ao1xiJFx0i6amWICZ4TWNmpKywMuT01x1MDAxY6hcdJrsrWx9Pmt8LbSL5nzncdvIjcuzXFz0XHUwMDE2tC6Qm4K+0Vwi61dcdTAwMDPot1x1MDAxMOjrXsp/f1x1MDAwMM5cbuf33o5b/1k32mV/XHUwMDA2pee/alx1MDAxYq9cdTAwMDHGqSmkpG88pFl76njm/rUntFwiYlxys3By6qKgSoksKraBrN7dXHUwMDA0u9qf4Vnt071dWa3uX1x1MDAxZdRcdTAwMWWbd4XjZpKnvixI1a7TXHUwMDE23PBLVtag6stoXHUwMDAyXHUwMDA2orPNXHUwMDE2P1x0RtN0O/KltdmdXHUwMDAxVFx02Vwiszgtmke1IKhKkbJTu2CsXHUwMDAyXHUwMDFim/FcdTAwMGJra7dnj2H2aKNw0FxcvTtcbqN2e+UmKVx1MDAwMb8sYLXsK7K2s8Yho9U521x1MDAwN1ZcYpRU7Eii9WCdikSV2md3XHUwMDA2YDVglVx1MDAxNvGC/ftcdTAwMDNrusuarFn5PVx1MDAwMitcdTAwMWRN41x1MDAwN1rR7v6FuSiXTu7yrvX5oZy5XFw3XHUwMDA3S0/665CHtGVb70j5/d178CqFsYHjqMY3aFx1MDAxMHK69oMjSH8qkMR4XHUwMDA08MyJYYtUtPRcdTAwMGK8wCm/XHUwMDE3sVSDi1RYu0jPXHUwMDFmgDm0hn45MTTJuHef39v7slx1MDAwZke3mYeHwu1D+2v1tjF97vK9XGY7KiU6/IbdYV++XHUwMDFipVx1MDAxM3BWOiFxa3KV2JBcdTAwMDVcdTAwMTjbXHUwMDFjSdP4XHUwMDBiK9Kf8pKmREHadG1gtd9Lxlx1MDAwN4ZcdTAwMWHQzrNcdTAwMTVcdTAwMWONVFx1MDAwN8O4hFx1MDAxY15r86adXHUwMDEyp7NYXHUwMDE4S5a/uS+wY1fW51x1MDAwZXhAXHUwMDE3V7UjuYSPXHUwMDBiJFx1MDAxM46wcoNkwsdp2IRp7F70q7xgXHUwMDAyPkflrPJ8eYqVK5cp5FpHn3dL9XJ5ud1JXHUwMDA2XHUwMDAzXHUwMDA3N563Yp3xLYJVv0D6XHUwMDEyhjWecKuJY/qp/MlcdTAwMTRCIYuWIa2VUsbK2HL0eEZJd1x1MDAxYTmBYdUh41x1MDAxYkl9zyhJQyB/SOwzs/XOibyj5Iy95qjUM9HHhmmzoj+3r0ru6GrroJJTh3hw/by29LSjXGaA8cuZlFx1MDAxM35xiO03XHUwMDFk6L03v+LL75dn58c7mlx0mZA63YTe5EdOyzqahPtcdTAwMWGbx0/NOvoxXFxCflx1MDAxZonuIN/MeUU3vlRHVysnbnPzZrN+9OX86Gz1dOP8aHXppdrIQHg2l187KGz/JvZcdTAwMWShdlxuXHRcdTAwMWRcdTAwMTA//DluxFwi/G5cdTAwMTVcdTAwMWOFalwi8LtFqGE2XGI5WGXH1SpU7Fx1MDAxYmhcdTAwMWOQa09qd7pni/Nfgv1cdTAwMDNcdTAwMDU7k/Ja/WfghU4o2iPK7GmbLfL9/Fx1MDAxMo2x5Xun0ag8rjdk5n5/7aZyer5xXHUwMDFh3W++jzK7XHRYvK1Fllx1MDAwZYfxXHUwMDFkKF9k3O/J6Jv+k1x1MDAxMobN99xk3PcpXHUwMDAxLYxfy2ZcdTAwMDTqYYxhwsDybKzwsZ52XHUwMDAzIaBf1sUgolx1MDAwNW9cdTAwMGVmJbpJXCLAt6PWqcSQXGL4Vfq93cavsl9X7E21JU736qtHxbtnVXu8v3xTgn2xcFx1MDAwNe3/Ut3pKY5cdTAwMDMmSTFcXK3SSrJTXHUwMDAwen6kLU9cdTAwMGLhgFx1MDAwNpTPXHUwMDEzW6GG0EJ8W1x1MDAxZOFcdTAwMTkqwneCwngg8lx1MDAxYVx1MDAxNfl9j1x1MDAwNIpcdTAwMDWnLCa1SKmATeus41x1MDAxMuvsfomCJ++MX1x1MDAwZrpoP118udtsVWBlz62V98rm4K603Fx1MDAwMTzrhYBcdTAwMTHg9SqrWCn7o1wi0lx1MDAwMVjR2fC4t0nYjNvqsL8qXHUwMDFjKE/NxyHrXHUwMDAxfTXIoNDaoensXHQ2WFxyQmfYu47npX4qoDqdXFxcdTAwMGLyPVx1MDAxNk18O4GRXHUwMDFkoGzt881u5ejE1Y+b+TD6VMmJJc808fNcdTAwMGV8Po3YzKPyPn0/Ulx1MDAxNYdcdTAwMDLWOWPQTFu4TGurMz1SwTdcdTAwMDNmQ/iTXCJVxnfY6ocqR8BW9qzaXHUwMDFkzbTbyVx1MDAxZEa3ollcXL9ccq9BrV/Jh+aSY9WpQGnyvo6RxJI7oFUx4JDUNyxjZ9HAdGo1rbHO1GBcdTAwMDVgpLr4XHUwMDFlQ+9cdTAwMGarI2ihKVxcZn5cIuArumOD9TjKStrP5chd01x1MDAxOYX526eVqkhcdTAwMDDrXHUwMDEy1dhd4DpcdTAwMTU1wSGnjL3rb5vag1xiUDFI+Jx05KZSrSNq7DqQ7GyA51OzN+KGRFhaXHUwMDA27KZ4rq5veyjt4EJr3+uCT8/DY305MbRsfV3BXHUwMDFjfcpcdTAwMWTa7fVjzK5uhZtccnszfTX8v3zYUbX74TfsXHUwMDBl+/LdwnRNYlx1MDAxZiCZzOf1IZiRQo+vZtJcdTAwMWbzslx1MDAxNu9BpatcdTAwMTlcdTAwMGWcXHUwMDE1XHUwMDA3rEJcdTAwMTjfXHUwMDFicY5qhkarmSHFe3ROk1Y/YvvQN+Vme1x1MDAxYlx1MDAwMVx0tvNGKfZcdTAwMDaI/1xi6rkqvXhcdTAwMWbFn/Scq/cj7OeQ6n00RfneQvJcdTAwMGVHXHUwMDE2pfKZj/EzrNnzfFttnn2l/f1wd0OYnZvPoV1uR1x1MDAxNYVcbsjvU+1cdTAwMTe/KG1s31xuQ41s+i1pY3zLrFx1MDAxZVx1MDAwZeaM+1x1MDAwMVx0XHUwMDAyo3zlnuLNe+OJKl+0l4o/woe6XHUwMDAzjqpB1Fx1MDAxNlx1MDAxMN5FUPXbi0X6mG82j1wiXHUwMDFlkk9/gy7Pulp8kdruMFx1MDAxZu+r4cPqsEC18/FcItBcdTAwMTFcdTAwMDBcdTAwMGay0M/5r79/+/s/t9XlXHUwMDAwIn0= events.Key(key=\"e\")events.Key(key=\"x\")events.Key(key=\"t\")Tevents.Key(key=\"x\")events.Key(key=\"t\")Teevents.Key(key=\"t\")TexText"},{"location":"guide/events/#default-behaviors","title":"Default behaviors","text":"You may be familiar with Python's super function to call a function defined in a base class. You will not have to use this in event handlers as Textual will automatically call handler methods defined in a widget's base class(es).
For instance, let's say we are building the classic game of Pong and we have written a Paddle
widget which extends Static. When a Key event arrives, Textual calls Paddle.on_key
(to respond to Left and Right keys), then Static.on_key
, and finally Widget.on_key
.
If you don't want this behavior you can call prevent_default() on the event object. This tells Textual not to call any more handlers on base classes.
Warning
You won't need prevent_default
very often. Be sure to know what your base classes do before calling it, or you risk disabling some core features builtin to Textual.
Messages have a bubble
attribute. If this is set to True
then events will be sent to a widget's parent after processing. Input events typically bubble so that a widget will have the opportunity to respond to input events if they aren't handled by their children.
The following diagram shows an (abbreviated) DOM for a UI with a container and two buttons. With the \"No\" button focused, it will receive the key event first.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1bbVPa2lx1MDAxNv7ur3C4X3pnarrfX85M54yioFSxVk+tPZ5xYlx1MDAxMiElJDRcdCDt9L/fXHUwMDE1UFx1MDAxMt5cdTAwMDIqcHBu80Eh2eysvfZ6nv2slZ2fW9vbhbjXclxuf2xcdTAwMTece8v0XFw7NLuFt8n5jlx1MDAxM0Zu4MMl0v9cdTAwMWVcdTAwMDXt0Oq3rMdxK/rj3bumXHUwMDE5Npy45ZmWY3TcqG16Udy23cCwguY7N3aa0Z/J36rZdN63gqZcdTAwMWSHRnqTXHUwMDFkx3bjIFx1MDAxY9zL8Zym48dcdTAwMTH0/jd8397+2f+bsc52zWbg2/3m/Vx1MDAwYql5nODxs9XA75uKOUKcMCT1sIVcdTAwMWLtw91ix4bLd2Cxk15JTlx1MDAxNXrRt6vuXHUwMDE34pu8yI/l/snt58reSXrbO9fzzuOeN3CEadXboZNejeIwaDiXrlx1MDAxZNeTu4+dXHUwMDFm/i5cbsBcdTAwMDfpr8KgXav7Tlx1MDAxNI38JmiZllx1MDAxYveSc1xiXHLPmn6t30d65j6ZIcVcciQ0JZprLDVVw6vJ77UhMeJcdTAwMTQrjSTmmo3bVVxmPJhcYrDrP6h/pJbdmlajXHUwMDA25vl22kZcdTAwMTFL48yYu4+jVdRgjFx1MDAxMlwiwVxmqlx1MDAxMeHDJnXHrdXjpFxyIYZCTCjJXHUwMDA3t8qY4vSnhGtFqVx1MDAxNul8JbdvXHUwMDFk2f3Q+GfcoXUzbD04rlx1MDAxMCVfMqYnVlx1MDAxZozHVTa2MpNOxFWl7Fx1MDAxZlQ+fyzvX1rfj3ul8/tw2NdIIMbOfVxcXHUwMDE4Xvj19ne3M7tcdTAwMWRp/XbRXHUwMDFiLmituL+6b1x1MDAxZvPDkPpnJdwrnXlfet3p1pphXHUwMDE4dDP9PnxKo6ndss1cdTAwMDEjYCEoSyiDS8SG1z3Xb8BFv+156bnAaqQkspUxeIK7RsafIS5G0fjZR+JcIohSXHUwMDA0WOApiOZcdTAwMTFX/vRtKnFplENcXFx1MDAwMlx1MDAxOVx1MDAwMlx1MDAxMyBcdTAwMGLBXHUwMDA2zPVcdTAwMTLiikPTj1pmXGJ8MIW85HzyXCJcdTAwMTNkRVx1MDAxMJFMJ3S2fLrKj05O5Vx1MDAxM6IzXHKCwI/P3Vx1MDAxZv2lUVx1MDAxOFx1MDAxYyuGiIBBaI24XHUwMDFjaVUym67XXHUwMDFimdd+XHUwMDE4g+W7rdab/2ZdXHUwMDFkOWDCYLlcdTAwMWRpvOu5tSTOXHUwMDBiXHUwMDE2XGbKXHRHIFx1MDAxMLsgXHUwMDA0hlxymq5te5lwtMBcdTAwMDJcdTAwMTP6XGaPXHUwMDE2WZOD0K25vuldjFx1MDAxOJhcdTAwMGLJXHUwMDAxJUzBpFwiszGJwfGYscyiNVx1MDAwZpP5JLWhmKRSXHUwMDFhhDIsZKJcdTAwMTVENjL6XHUwMDFkMGJwwCsgUilBJScrQyU1XHUwMDE0cCSTQlxugShXelxuKqk2XHUwMDE0xVopLjSGcJ5cdTAwMDAp5lxcwigofjpG+6Y+XHUwMDFio09bQTJ2mGG85/q269fgYrr0ParkRTDRR7HVTqzcXHUwMDAxhlx1MDAwNYBzjlxiU8BcdTAwMWNEZFx1MDAxYdXMVlx1MDAxMvRcdTAwMDZcdTAwMTeKSFx1MDAwNbHNlCZYPDRcdTAwMTiuwFx1MDAwNce355tUvPlcdTAwMDbS8NI8s8t2T/rt3a87++VZJjGsMdZIYyqJXHUwMDEyRLFcdKswg+mHmcNEXHUwMDEzglx1MDAwNPydMMszo7hcdTAwMTg0m25cZs7/XHUwMDE4uH487uS+N3dcdTAwMTO011x1MDAxZNNcdTAwMWW/XG7Dyl5cdTAwMWKnhVbS46h4TD9tp7Dpf1x1MDAxOX7+5+3U1juzozk5JuI47W8r+39cdTAwMTajhY5cdTAwMTVcdTAwMGbwPIXVXGK4eFx1MDAxNq1cdTAwMDGSXHRcdTAwMDaxsbjSyJ/nXHJlNaKUQSVcdTAwMTJIJ1x1MDAwYp5iZIzVNJBcdTAwMWWiSFx1MDAwM2q1TtbF1WlccpHOxZDGVKp8XHUwMDFleFx1MDAwYphVipHVZi2pkGp8iM5kpV5FOLBkLL+dWl9PXp5cXHjNXHUwMDFmn2/+wlx1MDAxZr2DYlxye3Xkur1ytJhcXM/t97LyudM9ZvsnxyG3yz3SJmxfLKFfcmlcdTAwMWZcdTAwMWSWXHUwMDFh1onaZfii6Z1cdTAwMWX4X2tL6HdF7n1d3TbE5VGn1PqEb9pRvdHhJXRXtf/vnPuCXGZ2vebOy+On33BBa0vlxoUon3+7u6yfdSp+3Tv+XHUwMDE27CzBXHUwMDBi91fki/zavjmpoHL5XHUwMDE2k2bN6Z0tqT7AJFx1MDAxMZCOrro+QIhW46dcdTAwMWZXbYq51ELRxXOR/LDY1FVb07xVXHUwMDFiUjJDrmnV5lNW7Uxq9LBqKyk4XHUwMDE4K+g6K1x1MDAwMkxohTR6QjxOr1xiLFpcdTAwMDEoPqbnb6795IJrv79OKvReULsuXFz704tcdTAwMDOZPHGkOOA5d6PR/6TSwFx1MDAxYy06Xlx1MDAxYZhr+fM1NkWKzkIrRlxcI0GzYTFcdTAwMGaunVx1MDAxYoJlLf7c2bPo0W3ZP2qflXv/Llxc+Ty0YkFcZqIxpDmSUsGoXHUwMDFhRSucMrhmSlx1MDAwMVx1MDAxMFx1MDAwNWFMrlx1MDAwZaxcdTAwMTkwpFx1MDAxMluMg5VgjDBkY5lq31o09sVheFOMnfaN3K1Wrq7qny5+XHUwMDFjfvitsZelsVfk3tfV7ao09uvywqo09uvygqf3iuq2XFxkRSGc84tcdTAwMGb3tVJpg72w/JRgXlx1MDAwNjN9IGm3XHUwMDBmn3JcdTAwMTSYwpqwpyiwXFyhMSsjoCSjcMc1XHUwMDA2wVRRgejiXHUwMDFhI3/+NlVjyHyNodelMdRcdTAwMTSNISc0htKYScroXG52NMxcdTAwMGJH/IRwfFlCsNeO48B/k5xcdTAwMWLo6uvClVx1MDAxM11cdTAwMTfeXHUwMDBlvnXM0DX9XHUwMDE4pHbUtixcdTAwMTjd7CxBjna+pCxhjphcdTAwMWXPXHUwMDEynjecXFxE56dcdTAwMGV49lNHjFx1MDAwNYQ8hPvimf7ljYjrxdZt1W+ffqrY9ztcdTAwMDeXZ/VNz/SpVlx1MDAwNoaoXHUwMDE1QkHSz4SczFx1MDAxZJCAbjjTjJOV7lx1MDAwNVgsecCISkQ5ZWtOXHUwMDFl0MfK4Un7qnL48axcdTAwMTL39lx1MDAwZXo8OPF/J1x1MDAwZstKXHUwMDFlVuTe19XtqpKH1+WFVSVcdTAwMGavy1x1MDAwYqtKXHUwMDFlXpdcdTAwMTde8DjhmTnJ9IGk3T58yntKwUEkp09cdTAwMTBWlpPgmTlcdFGCXHUwMDEzQcjie1x1MDAwYvKnb0O1XHUwMDBiQzRfu+i1aZdcdTAwMDWTXHUwMDEypSmiXHUwMDAyr0C6LDNcdTAwMWWXnpRUgylcIt5cdTAwMDG8huvOSOZo9Fx1MDAwNTKSeWPJXHUwMDA188z9j1jJmc9cdTAwMWMx0kJLxjPhPVx1MDAwZs75tLmhcKaKXHUwMDE5QiCtklx1MDAwMoLi2YpK/6Gjklx1MDAwNlx1MDAxNZJizYHeOFErrDFgXGaWXGJGXHUwMDE4p5RQwtkkulx1MDAwNTe45lopRGjyglx1MDAwN1x1MDAxZFx1MDAwNztnyXNi8ZyNRM/fXHUwMDAw+Vx1MDAwMrAvuFx1MDAwMXLh3YbIYJgqrFx1MDAwNeVIJvOVaTPYaUhcZlxmPoZlilxuLlx1MDAxNOTakztcclx1MDAxN9pcdTAwMDCZXHUwMDBm6lx1MDAxMZO4XHUwMDEwyVx1MDAwZUBcdTAwMDSrXHUwMDA1pJFcbk/YXHUwMDA0M4+0XHUwMDA0vIE9SMBcZuNcdJte0+7H2ZGcXHUwMDFjXHUwMDEzMZx2t5X9/1xmOpstTmBcdTAwMDKoktn8fVx1MDAxZZvlXHUwMDE3pjeVzSQxINDAXHUwMDEzXGYpxHXqj1x1MDAwMZkpQzCMOaeKXHUwMDBi/qJXw+ZQXHUwMDE5NVx1MDAxOMijxFx1MDAwNM4kKKUpVEZcZlx1MDAwMcJcdTAwMDR0iVJcdTAwMTIxltnv/Vh0oUpcYs1AvKyXzJgmXHUwMDE5sfQvktlcdTAwMGVQXHUwMDA3lVx1MDAwMCTMQEpKJsgkdYCjKbhZUVxuLCM0aM7n0Vl+1XTMqMQmXHUwMDA07UEkSEz0hFEw/UQgcCeiXHUwMDE45CdWr5rOdmaHc3JMXHUwMDA28lx1MDAxM1x0LbdcXCwzr7OOcVx1MDAxYeeYwaLCXHUwMDE3L1x1MDAxNvNcdTAwMWatfetL7XDvwlx1MDAxM7FC3S46vmttOqdcdEZcckWJgHDjXHUwMDEyKzZZK9ZUKFhQYGXNvlT2nPddmSWcO87YJKVcdTAwMTGR9pxcdTAwMTaKM1vQXHUwMDFlOEtCqqVhTp7xXGJoXHUwMDFlZ1xyw2pKyaLt69L9j6/V71cq+u53j3ixcVJ9eSWkKE87ptM+/G7+1Y0vT2k1qsYzXHUwMDFl+i6pXHUwMDEyMn0gabePiJrN36BcdTAwMDKUeMrjsFxccM6qhEiSkzrBIVx1MDAwNVx1MDAxNotcdTAwMDMzf/o2XHUwMDE2mCpcdTAwMGaYmsHStCRg5qpccsKnQJNMpEZcdTAwMThcdTAwMDNXcs7Uup/OPjFcdTAwMWPTWU9cdTAwMGIhmUeGI4WQdJCPhVx1MDAxMKeT2GR8cHpvXHUwMDFhTu/9deHiujDjXHUwMDA1Tj3y46W9wDlnkVx1MDAxOa92TDd4gMmtXHUwMDA3oFx1MDAxN8xW6zxcdTAwMDa/XHUwMDBlXHUwMDA1XGZMnWs/OCf1ZaHjOt29Kbx+1z+SXvs4T1x1MDAwMOUkXHUwMDEz9/PX1q//XHUwMDAxXHUwMDA3vCMgIn0= App()Container( id=\"dialog\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")events.Key(key=\"T\")After Textual calls Button.on_key
the event bubbles to the button's parent and will call Container.on_key
(if it exists).
As before, the event bubbles to its parent (the App class).
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT4lhcdTAwMTP+7q+w2C/zVlxy2XO/bNXWW4p3XHUwMDFkXHUwMDFkXHUwMDA1dZzXLStAhFxmgWBcdTAwMTK8zNb+97dcdTAwMGYqXHS3XHUwMDEwlTgwO1QpkFx1MDAxYzqdPt1Pnu7Tyd8rq6uF6KHrXHUwMDE0/lgtOPc123PrgX1X+Gi23zpB6PpcdTAwMWTYRfrfQ79cdTAwMTfU+iObUdRccv/4/fe2XHUwMDFktJyo69k1x7p1w57thVGv7vpWzW//7kZOO/yv+X9ot50/u367XHUwMDFlXHUwMDA1VnyQolN3Iz94PJbjOW2nXHUwMDEzhSD9f/B9dfXv/v+EdnXXbvuden94f0esXHUwMDFl13p066Hf6auKmWBMak3jXHUwMDExbrhcdTAwMDFHi5w67L5cdTAwMDaNnXiP2VRAZVxc/LZ3vr7+5UpUrtTa3XkobuPDXrueV45cdTAwMWW8R0PYtWYvcOK9YVx1MDAxNPgt59ytR01z9JHtg9+FPtgg/lXg91x1MDAxYc2OXHUwMDEzhkO/8bt2zY1cdTAwMWXMNoRcdTAwMDZb7U6jLyPeclx1MDAwZt+KmEhLIaQ5ZZxJitRgd19cdTAwMDDllmJKI0a1YFxu61x1MDAxMcVKvlx1MDAwNzNcdTAwMDGK/Yb6r1i1ql1rNUC/Tj1cdTAwMWWjSE3jxEnfPZ+uolx1MDAxNmOUXHUwMDEwSVx0WFx1MDAxY1x1MDAxMT5cdTAwMTjSdNxGMzJjXGJcdTAwMDE9mVCSP1x1MDAxZSphI6c/J1hJolx05yreY1x1MDAxNOju1vve8deoTZt20H2yXSE0X1x1MDAxMspcdTAwMWK9N0ddK+leiXnvKSEu9q/0fveb+vL15vtR8M0hXHUwMDAzWUO+XHUwMDE4OfdRYbDjn4+/xP4kYodGf8x6wKzaXHUwMDFlRrf35Vx1MDAwYreoP92cVnfOTlx1MDAxZZwvXHUwMDA3k7W1g8C/S8h9+lx1MDAxNPt+r1u3XHUwMDFmIVxmXHUwMDBiQVx1MDAxOeJEUarkYL/ndlqws9PzvHibX2vFqLeSUHhcZmyHzj+JtEhMRVrEXHUwMDE4w0igOOhnIW369C0u0pI0pFXCklx1MDAxY1x1MDAxM1x1MDAwMFn2ZqSNXHUwMDAyu1x1MDAxM3btXHUwMDAw4GtcdTAwMDLaytloS8bRXHUwMDE1SSFcYvxcIlZsbug6T/eMvcDvRGX3e9/FhMWxYohcYkTgmo64XHUwMDFjXHUwMDFhtWW3Xe9haGL7flxmmq91u1x1MDAxZv6TNHXogFxufZl8aPCa5zaMo1x1MDAxN2pwUk4wXHUwMDE0XHUwMDAzkVx1MDAwYtRlMKDt1utewlx1MDAxZmuggVxyMoPdLCzCXHUwMDBm3Ibbsb3KkIKpMfmIXHRcdTAwMTOCXHUwMDEyI6KmRaVcIkozJjDOXHUwMDFllKkotahBSYVFXGJFhHCFNNA9OVx1MDAxNJREUPBcdTAwMWNcdTAwMDAnJoFYYIJZflFpSawxVlxcXHRkXFyejFx1MDAwNyUjXHUwMDE2XHUwMDE1lFJcYlxcRVx1MDAxNUpQ0+dcdTAwMTiVmFx1MDAxMcpwwjczx2hf1dfG6Fx1MDAxMJq9IEbDyFx1MDAwZaJ1t1N3O1xy2Fx1MDAxOV/7nnl9lpjoR3GtZ7QsXCJcdTAwMGJcIlxcMiVcdTAwMTUgLONcdTAwMWMmViXGNeyusaOFsWCYXHUwMDFhsFVgcfI0YHBcdTAwMTUuOJ36bK22909Pd13S+7z9cNw624nOTsrlL5O0XHUwMDAypVx1MDAwMDypQDA/iiNcdTAwMDPwMeZcdTAwMGW00lx1MDAxNlKUXHUwMDBiialWiFA5ppRnh1HJb7fdXGKs/9l3O9GolfvmXFwz4d507ProXjip5L5RXFzoXHUwMDFhicNkN/60XHUwMDFhx03/y+DzX1x1MDAxZieOLk51Z/NcdTAwMWFz5FjcSvJ9XHUwMDFholx1MDAwNU4teoznXHSoRijlo5ufUY0gpYngmtLMsJY+y4tcbmuYUEsyXG54QJnJ31hsXHUwMDEyI4FiZmkuIaWD2Vx1MDAxMZrlSDZETPxcdTAwMDZApmKweEIuXHKhylx1MDAxMCM5kIs0Zu2fXHUwMDE3T2+6p9WOOjhu3n1cdTAwMGbsi1wiZ29PL7pF50SSXHLt7jKt9nuHLba9s5eNsKfKPd87u707YFx1MDAxYp9cdTAwMGVcdTAwMDJe334gPcI2xFx1MDAxY+SS8/ruzlar9kmtMVxcaXtHm52vjTnIzcm8yyW2Jc53b7e6J/iqXHUwMDE3Nlu3fFx1MDAwYl1cdTAwMWbW/3XGfUNcdTAwMGX7XHUwMDEzWSHqXu00Nq+0s1x1MDAxOVRxsX5cdTAwMWKG3l4wXHUwMDA3KyCnsvnJ82XjqHm/s394sulT725cdTAwMTGtO6tOMvmAsdhnejCVjDLNgcfH5Dmnelx1MDAwNrDsqSRcdTAwMDNcdTAwMGItXHT8yewkI93OXHUwMDBiSzKwSiVcdTAwMTlcdTAwMTRZ7H1IXHUwMDA2n0AyXHUwMDEySf5cdTAwMTPJgFx1MDAxY85cdTAwMTQxWDwv71DBeHRI/Fx1MDAwMoecXFzByFqxKD2XXHUwMDEzPlxcdsxcdTAwMGW3/uelWVx1MDAwM/H8xmXhsjO5mMHJkJxBrcJzrofd/0WljFx1MDAxOdR5tJQxU/PUSE3NXHQopmRauCpIXyVjQmaO1kP3XHUwMDA2bzU66/dcdTAwMDeoev39SFKvVKr/2GjlM4OVMG0pXHUwMDAyXHUwMDE5L1wiWFxuotVwsDJcdTAwMDU5XHUwMDE505JJzjWwcZFfsGo0XHUwMDFlrEqMXHUwMDA2K1NcdTAwMDIyYczeOSNwTnmVVuAlO8Xt1s3DWsA3rn5lXHUwMDA088pcYnIy73KJzSsjWC4r5JVcdTAwMTEsl1x1MDAxNTy9XlLV7Vx1MDAxMitcdOGUK/v3ja2teTD3nNTNK4FZlkmblb9MPmAs9unTXHUwMDBmz18oYdNbXySmlFx1MDAwYqqzM6J0Oy8sI2LpjEi+XHUwMDE3I1JcdTAwMTNcdTAwMTiRXHUwMDFjY0RKaES1UD91+rLei1wiv/PBbHvMXHUwMDAyLlx1MDAwYlx1MDAxN054Wfj4+O3WXHUwMDBlXFy7XHUwMDEzQWJcdTAwMTD2ajU4u+k5jVx1MDAxY1x1MDAxNj6nnGZcdTAwMDb3XHUwMDFmzWledzqpIT0j0Vx1MDAxMVPjmmjNMKTj2fss1Oej3fXjXHUwMDBi965q22F5u/LlU/PsfvHLXHUwMDEy1IJ4RVxcYoI1XHUwMDExmqmRuMaWwtysWULSQ4XKL64zZjpCaa6QeOe2tWtSo7h3Ujyon8pcdTAwMTLueC1ug9Bfmc6cMp2czLtcXGLzynSWy1xueWU6y2WFvDKd5bJCXis1y2KFWVx01ORcdTAwMDPGYp8+LUBcdTAwMDLFU1x1MDAxMijJJWGSZb93IN3Oi8q01FxmoiXei2hlS6BcdTAwMTjTjCgt/m1cdNShPyHhcFx1MDAwMF2C986eZiRcdTAwMTRcdTAwMTmyp1nnklx1MDAxYcxTO2FcdMJpy7mYMcp59rwpXHUwMDFk5Fx1MDAxNzWaibRcdTAwMTinlEqJXHUwMDA0I5QmXG5ccv1wRtzChGAlsemkppLnXHUwMDE3z1x1MDAxOFtCgFx1MDAxMkZcdTAwMWZcdTAwMDJQy8bDW3CLa66V6ZXUSGI6XHUwMDFh7Vx1MDAwMExCICXly6P99b2wL7/6JPSY1lx1MDAwYlx1MDAxYveRMkw5XHUwMDAxX6RiSmsrsTCYjVx1MDAxMDNCKMVoou8yS/fq0+DZnbCxTmBlXHUwMDBlXHQsolxmfFx1MDAwMsWoO9BcdCZcdTAwMTNpKbRcdTAwMDR9kIBJw9N0mlxmXHUwMDBmYzotUyPsdFc2rzEnjsWtJN9f3tqvp9d3KTiQubsxO56lV/1cdTAwMTdcdTAwMTXPqDbBgFx1MDAxOdicXHUwMDEygHBcdTAwMWRfQ1x1MDAxZvFMWFRcdTAwMGJcYiNNNVx1MDAxNXnecIOpXHUwMDA1XGZIK1x1MDAwNJPNpFBcdTAwMTN6+1x1MDAwNbGEpkhcdTAwMDBfUlx1MDAxMoGyXHR0fWIvXFxcIlx1MDAwZVx1MDAwMPOKXHUwMDA18Vx1MDAwNcWzXCKAXHUwMDA3NVxyOTA5nEgmkmD1iFx1MDAxZGA4XG5mU2ZBQlxinWgxylx0z4xORiWzJoBcdTAwMDG2cNySXHUwMDFjXHUwMDAzXHUwMDFhtYhAYFx1MDAxZkSxUlx1MDAxYatpOk0uXHUwMDE2LzWeXHUwMDE1pzuzeY278Vx1MDAwYlx1MDAxMS21uq3o9LtcYvshTjDJjmqVXHUwMDA3t1k+uHPuSifOt/Jaw1x0i9/2fyyq0VmgRihcdTAwMWVes1x1MDAxYcm4jPmxJNLgXHUwMDE5SvY0veZ2bVZcdTAwMTPONWdsXHUwMDFj0UhcIpmLK9sxTDzfj4SVuWmKklx1MDAxY+5HXHUwMDFh+NWEqkXt+vz4QNb3mq1cdTAwMWLvXHUwMDBivjj8fmJvbb29XHUwMDE4UpJHt7bT27mxT++i8yN6XHUwMDE4XHUwMDFlRvuTxb6odpOTunNcdTAwMTc7q3Yz+YCx2GdcdTAwMDBIudpcdTAwMDDyJlx1MDAxNk1yqt1IOb10g1x1MDAwMdZcdTAwMTBcdTAwMDeyllx1MDAxOUbSzbyoMFwiUmBcdTAwMDRi1sJcdFx1MDAxOHlcdTAwMTOKpDIjwifgXGJcdTAwMTlL5bBkXFzBgX5A4eZV1CdRuCFoaOugcFx1MDAxM5/Kc+HGuTU6WfvOw4eW8/DnZaFyWZhy67FcdTAwMWX68dxuPZ5xQVx1MDAxY63OTFb49Vd3nUjzx8KSYE2xTjzaY1ZYejdl9nnz/qHZ3tpu672d43DXX1/wsFx1MDAwNPSxuGKacHPNXHUwMDE0hI801Fx1MDAxM2KZZmVI/Vx1MDAxOXBmJN90N/LbL+8ms9LoVVx1MDAwYtdvubpfXHR0UdtZP946p3vVs/2Tln/8eWNxr+45qbssYmeRhslcdTAwMDfMqG2vWj/fYdXKzV6RrYn2UbVabspsc5aBjEhNaO5PRlE65W5lSLA4ZSp7TpM+fYuKejxcdTAwMTX1OLbk3FBvXHUwMDFldISYslx1MDAwMEf8XZ+EXHUwMDAy7kg5euuTUJaJjsy4gudNR8wjiaZGJqZKcYNcdTAwMGaZI3N9jbZcdTAwMTTfaWzfn5XOfMlC7yxoLXpcclx1MDAxNZtcdTAwMWGpXHUwMDEwXHUwMDFjklx1MDAwMY20XHUwMDEymlxyhSbm3JJcXHJwTZO64eRcIsyPICRcXDNO2KtcdTAwMTZ430JIPt/qYD3c6V03v29ffN3arGzc3PdcdTAwMTaXkOSk7nKJ3f3aq5xViN495Vx1MDAxN2frd07P35XeXCJcdTAwMWF3XHUwMDE2f5p8wFx1MDAxZs+fKFVEXHUwMDEynHsxXHUwMDA3J59cdTAwMDQ5XG7TXHUwMDE0XHUwMDAwQSRJ3CyUTp++RUVpjFNRWnGLzFxypedSz4G0kSMk87i5c55cdTAwMGW57Fxmalx1MDAwNueYXHUwMDAzg0p5ttz09WdJXHRcdTAwMTOcveDRcqkw9aKoJO9cdTAwMTaV5vZcItPywKnCiGAx0lx1MDAxZEeRtFx1MDAwNMdSIVxykavV9NuLXHUwMDFjIeVbgtI8fkxRhDnlXGaSK8YxmfC4XHUwMDA0XHUwMDAxWVx1MDAxNoP9XHUwMDE0eC2nVNCx9jmTnUlcdTAwMDRcdTAwMTDznlx1MDAxZDVPUfuq/rmMT5dLzzNWh5/jXHUwMDA2zF9rJMCI2DzCLNHN8bw4zCxBuVx1MDAxNppIhVx1MDAxOWj+3NPxwqfLpcfu6tCKNaJEKTgq4qbpR3MxrpayXHUwMDAwdDVkrlxcKUSVYGNaLdUqdJpP91x1MDAwN4y7cyxzJfn+Yr6RuJyNXHUwMDAwXHUwMDFiU5pcIk1Zdrpx4GyfXpVON4vnVcq/ke1cdTAwMTLZrp0sPLBJYVx1MDAxMbP2jzhcdTAwMDZGQYZxjUjz5DlOzJ2KXG6ihOXXJpgowMRkY6xvxviAIFi9a7VcdTAwMDbYXHUwMDE45TSvxaPxrt9qr1pNXHUwMDEygSS5UEOjs3byRn53XHUwMDFhr1x1MDAxODqLUVx1MDAxMvGkyWNsrTxFcMHudstcdTAwMTFYaFx1MDAwMHgwXHRu/ek0Y3mFW9e5W59QXHUwMDFhuO6/jNR+vJrIcMxcdTAwMTT8/c/KP/9cdTAwMDeoXHUwMDAzMlx1MDAwNiJ9 App()Container( id=\"dialog\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")events.Key(key=\"T\")events.Key(key=\"T\")events.Key(key=\"T\")bubbleThe App class is always the root of the DOM, so there is nowhere for the event to bubble to.
"},{"location":"guide/events/#stopping-bubbling","title":"Stopping bubbling","text":"Event handlers may stop this bubble behavior by calling the stop() method on the event or message. You might want to do this if a widget has responded to the event in an authoritative way. For instance when a text input widget responds to a key event it stops the bubbling so that the key doesn't also invoke a key binding.
"},{"location":"guide/events/#custom-messages","title":"Custom messages","text":"You can create custom messages for your application that may be used in the same way as events (recall that events are simply messages reserved for use by Textual).
The most common reason to do this is if you are building a custom widget and you need to inform a parent widget about a state change.
Let's look at an example which defines a custom message. The following example creates color buttons which\u2014when clicked\u2014send a custom message.
custom01.pyOutput custom01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.message import Message\nfrom textual.widgets import Static\nclass ColorButton(Static):\n\"\"\"A color button.\"\"\"\nclass Selected(Message):\n\"\"\"Color selected message.\"\"\"\ndef __init__(self, color: Color) -> None:\nself.color = color\nsuper().__init__()\ndef __init__(self, color: Color) -> None:\nself.color = color\nsuper().__init__()\ndef on_mount(self) -> None:\nself.styles.margin = (1, 2)\nself.styles.content_align = (\"center\", \"middle\")\nself.styles.background = Color.parse(\"#ffffff33\")\nself.styles.border = (\"tall\", self.color)\ndef on_click(self) -> None:\n# The post_message method sends an event to be handled in the DOM\nself.post_message(self.Selected(self.color))\ndef render(self) -> str:\nreturn str(self.color)\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nyield ColorButton(Color.parse(\"#008080\"))\nyield ColorButton(Color.parse(\"#808000\"))\nyield ColorButton(Color.parse(\"#E9967A\"))\nyield ColorButton(Color.parse(\"#121212\"))\ndef on_color_button_selected(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
ColorApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(0,\u00a0128,\u00a0128)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(128,\u00a0128,\u00a00)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(233,\u00a0150,\u00a0122)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(18,\u00a018,\u00a018)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the custom message class which extends Message. The constructor stores a color object which handler methods will be able to inspect.
The message class is defined within the widget class itself. This is not strictly required but recommended, for these reasons:
ColorButton
, you have access to the message class via ColorButton.Selected
.on_selected
, the handler name becomes on_color_button_selected
. This makes it less likely that your chosen name will clash with another message.To send a message call the post_message() method. This will place a message on the widget's message queue and run any message handlers.
It is common for widgets to send messages to themselves, and allow them to bubble. This is so a base class has an opportunity to handle the message. We do this in the example above, which means a subclass could add a on_color_button_selected
if it wanted to handle the message itself.
You can temporarily disable posting of messages of a particular type by calling prevent, which returns a context manager (used with Python's with
keyword). This is typically used when updating data in a child widget and you don't want to receive notifications that something has changed.
The following example will play the terminal bell as you type. It does this by handling Input.Changed and calling bell(). There is a Clear button which sets the input's value to an empty string. This would normally also result in a Input.Changed
event being sent (and the bell playing). Since we don't want the button to make a sound, the assignment to value
is wrapped within a prevent context manager.
Tip
In reality, playing the terminal bell as you type would be very irritating -- we don't recommend it!
prevent.pyOutput prevent.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Button, Input\nclass PreventApp(App):\n\"\"\"Demonstrates `prevent` context manager.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Input()\nyield Button(\"Clear\", id=\"clear\")\ndef on_button_pressed(self) -> None:\n\"\"\"Clear the text input.\"\"\"\ninput = self.query_one(Input)\nwith input.prevent(Input.Changed): # (1)!\ninput.value = \"\"\ndef on_input_changed(self) -> None:\n\"\"\"Called as the user types.\"\"\"\nself.bell() # (2)!\nif __name__ == \"__main__\":\napp = PreventApp()\napp.run()\n
PreventApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Clear\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/events/#message-handlers","title":"Message handlers","text":"Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.
"},{"location":"guide/events/#handler-naming","title":"Handler naming","text":"Textual uses the following scheme to map messages classes on to a Python method.
\"on_\"
.\"_\"
.Messages have a namespace if they are defined as a child class of a Widget. The namespace is the name of the parent class. For instance, the builtin Input
class defines it's Changed
message as follow:
class Input(Widget):\n...\nclass Changed(Message):\n\"\"\"Posted when the value changes.\"\"\"\n...\n
Because Changed
is a child class of Input
, its namespace will be \"input\" (and the handler name will be on_input_changed
). This allows you to have similarly named events, without clashing event handler names.
Tip
If you are ever in doubt about what the handler name should be for a given event, print the handler_name
class variable for your event class.
Here's how you would check the handler name for the Input.Changed
event:
>>> from textual.widgets import Input\n>>> Input.Changed.handler_name\n'on_input_changed'\n
"},{"location":"guide/events/#on-decorator","title":"On decorator","text":"In addition to the naming convention, message handlers may be created with the on
decorator, which turns a method into a handler for the given message or event.
For instance, the two methods declared below are equivalent:
@on(Button.Pressed)\ndef handle_button_pressed(self):\n...\ndef on_button_pressed(self):\n...\n
While this allows you to name your method handlers anything you want, the main advantage of the decorator approach over the naming convention is that you can specify which widget(s) you want to handle messages for.
Let's first explore where this can be useful. In the following example we have three buttons, each of which does something different; one plays the bell, one toggles dark mode, and the other quits the app.
on_decorator01.pyOutput on_decorator01.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (1)!\n\"\"\"Handle all button pressed events.\"\"\"\nif event.button.id == \"bell\":\nself.bell()\nelif event.button.has_class(\"toggle\", \"dark\"):\nself.dark = not self.dark\nelif event.button.id == \"quit\":\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Note how the message handler has a chained if
statement to match the action to the button. While this works just fine, it can be a little hard to follow when the number of buttons grows.
The on
decorator takes a CSS selector in addition to the event type which will be used to select which controls the handler should work with. We can use this to write a handler per control rather than manage them all in a single handler.
The following example uses the decorator approach to write individual message handlers for each of the three buttons:
on_decorator02.pyOutput on_decorator02.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\n@on(Button.Pressed, \"#bell\") # (1)!\ndef play_bell(self):\n\"\"\"Called when the bell button is pressed.\"\"\"\nself.bell()\n@on(Button.Pressed, \".toggle.dark\") # (2)!\ndef toggle_dark(self):\n\"\"\"Called when the 'toggle dark' button is pressed.\"\"\"\nself.dark = not self.dark\n@on(Button.Pressed, \"#quit\") # (3)!\ndef quit(self):\n\"\"\"Called when the quit button is pressed.\"\"\"\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
#
to match the id)OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
While there are a few more lines of code, it is clearer what will happen when you click any given button.
Note that the decorator requires that the message class has a control
attribute which should be the widget associated with the message. Messages from builtin controls will have this attribute, but you may need to add control
to any custom messages you write.
Note
If multiple decorated handlers match the message, then they will all be called in the order they are defined.
The naming convention handler will be called after any decorated handlers.
"},{"location":"guide/events/#applying-css-selectors-to-arbitrary-attributes","title":"Applying CSS selectors to arbitrary attributes","text":"The on
decorator also accepts selectors as keyword arguments that may be used to match other attributes in a Message, provided those attributes are in Message.ALLOW_SELECTOR_MATCH
.
The snippet below shows how to match the message TabbedContent.TabActivated
only when the tab with id home
was activated:
@on(TabbedContent.TabActivated, tab=\"#home\")\ndef home_tab(self) -> None:\nself.log(\"Switched back to home tab.\")\n...\n
"},{"location":"guide/events/#handler-arguments","title":"Handler arguments","text":"Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from custom01.py
above) contains a message
parameter. The body of the code makes use of the message to set a preset color.
def on_color_button_selected(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\n
A similar handler can be written using the decorator on
:
@on(ColorButton.Selected)\ndef animate_background_color(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\n
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
def on_color_button_selected(self) -> None:\nself.app.bell()\n
This pattern is a convenience that saves writing out a parameter that may not be used.
"},{"location":"guide/events/#async-handlers","title":"Async handlers","text":"Message handlers may be coroutines. If you prefix your handlers with the async
keyword, Textual will await
them. This lets your handler use the await
keyword for asynchronous APIs.
If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from its message queue until the handler has returned. This is rarely a problem in practice; as long as handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use.
Info
To re-use the chef analogy, if an order comes in for beef wellington (which takes a while to cook), orders may start to pile up and customers may have to wait for their meal. The solution would be to have another chef work on the wellington while the first chef picks up new orders.
Network access is a common cause of slow handlers. If you try to retrieve a file from the internet, the message handler may take anything up to a few seconds to return, which would prevent the widget or app from updating during that time. The solution is to launch a new asyncio task to do the network task in the background.
Let's look at an example which looks up word definitions from an api as you type.
Note
You will need to install httpx with pip install httpx
to run this example.
import asyncio\ntry:\nimport httpx\nexcept ImportError:\nraise ImportError(\"Please install httpx with 'pip install httpx' \")\nfrom rich.json import JSON\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass DictionaryApp(App):\n\"\"\"Searches a dictionary API as-you-type.\"\"\"\nCSS_PATH = \"dictionary.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Search for a word\")\nyield VerticalScroll(Static(id=\"results\"), id=\"results-container\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"A coroutine to handle a text changed message.\"\"\"\nif message.value:\n# Look up the word in the background\nasyncio.create_task(self.lookup_word(message.value))\nelse:\n# Clear the results\nself.query_one(\"#results\", Static).update()\nasync def lookup_word(self, word: str) -> None:\n\"\"\"Looks up a word.\"\"\"\nurl = f\"https://api.dictionaryapi.dev/api/v2/entries/en/{word}\"\nasync with httpx.AsyncClient() as client:\nresults = (await client.get(url)).text\nif word == self.query_one(Input).value:\nself.query_one(\"#results\", Static).update(JSON(results))\nif __name__ == \"__main__\":\napp = DictionaryApp()\napp.run()\n
dictionary.tcssScreen {\nbackground: $panel;\n}\nInput {\ndock: top;\nwidth: 100%;\nheight: 1;\npadding: 0 1;\nmargin: 1 1 0 1;\n}\n#results {\nwidth: auto;\nmin-height: 100%;\n}\n#results-container {\nbackground: $background 50%;\noverflow: auto;\nmargin: 1 2;\nheight: 100%;\n}\n
DictionaryApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSearch\u00a0for\u00a0a\u00a0word\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the highlighted line in the above code which calls asyncio.create_task
to run a coroutine in the background. Without this you would find typing in to the text box to be unresponsive.
This chapter will discuss how to make your app respond to input in the form of key presses and mouse actions.
Quote
More Input!
\u2014 Johnny Five
"},{"location":"guide/input/#keyboard-input","title":"Keyboard input","text":"The most fundamental way to receive input is via Key events which are sent to your app when the user presses a key. Let's write an app to show key events as you type.
key01.pyOutput key01.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\ndef on_key(self, event: events.Key) -> None:\nself.query_one(RichLog).write(event)\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
InputApp Key(key='T',\u00a0character='T',\u00a0name='upper_t',\u00a0is_printable=True) Key(key='e',\u00a0character='e',\u00a0name='e',\u00a0is_printable=True) Key(key='x',\u00a0character='x',\u00a0name='x',\u00a0is_printable=True) Key(key='t',\u00a0character='t',\u00a0name='t',\u00a0is_printable=True) Key(key='u',\u00a0character='u',\u00a0name='u',\u00a0is_printable=True) Key(key='a',\u00a0character='a',\u00a0name='a',\u00a0is_printable=True) Key(key='l',\u00a0character='l',\u00a0name='l',\u00a0is_printable=True) Key( key='exclamation_mark', character='!', name='exclamation_mark', is_printable=True )
When you press a key, the app will receive the event and write it to a RichLog widget. Try pressing a few keys to see what happens.
Tip
For a more feature rich version of this example, run textual keys
from the command line.
The key event contains the following attributes which your app can use to know how to respond.
"},{"location":"guide/input/#key","title":"key","text":"The key
attribute is a string which identifies the key that was pressed. The value of key
will be a single character for letters and numbers, or a longer identifier for other keys.
Some keys may be combined with the Shift key. In the case of letters, this will result in a capital letter as you might expect. For non-printable keys, the key
attribute will be prefixed with shift+
. For example, Shift+Home will produce an event with key=\"shift+home\"
.
Many keys can also be combined with Ctrl which will prefix the key with ctrl+
. For instance, Ctrl+P will produce an event with key=\"ctrl+p\"
.
Warning
Not all keys combinations are supported in terminals and some keys may be intercepted by your OS. If in doubt, run textual keys
from the command line.
If the key has an associated printable character, then character
will contain a string with a single Unicode character. If there is no printable character for the key (such as for function keys) then character
will be None
.
For example the P key will produce character=\"p\"
but F2 will produce character=None
.
The name
attribute is similar to key
but, unlike key
, is guaranteed to be valid within a Python function name. Textual derives name
from the key
attribute by lower casing it and replacing +
with _
. Upper case letters are prefixed with upper_
to distinguish them from lower case names.
For example, Ctrl+P produces name=\"ctrl_p\"
and Shift+P produces name=\"upper_p\"
.
The is_printable
attribute is a boolean which indicates if the key would typically result in something that could be used in an input widget. If is_printable
is False
then the key is a control code or function key that you wouldn't expect to produce anything in an input.
Some keys or combinations of keys can produce the same event. For instance, the Tab key is indistinguishable from Ctrl+I in the terminal. For such keys, Textual events will contain a list of the possible keys that may have produced this event. In the case of Tab, the aliases
attribute will contain [\"tab\", \"ctrl+i\"]
Textual offers a convenient way of handling specific keys. If you create a method beginning with key_
followed by the key name (the event's name
attribute), then that method will be called in response to the key press.
Let's add a key method to the example code.
key02.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\ndef on_key(self, event: events.Key) -> None:\nself.query_one(RichLog).write(event)\ndef key_space(self) -> None:\nself.bell()\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
Note the addition of a key_space
method which is called in response to the space key, and plays the terminal bell noise.
Note
Consider key methods to be a convenience for experimenting with Textual features. In nearly all cases, key bindings and actions are preferable.
"},{"location":"guide/input/#input-focus","title":"Input focus","text":"Only a single widget may receive key events at a time. The widget which is actively receiving key events is said to have input focus.
The following example shows how focus works in practice.
key03.pykey03.tcssOutput key03.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass KeyLogger(RichLog):\ndef on_key(self, event: events.Key) -> None:\nself.write(event)\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\nCSS_PATH = \"key03.tcss\"\ndef compose(self) -> ComposeResult:\nyield KeyLogger()\nyield KeyLogger()\nyield KeyLogger()\nyield KeyLogger()\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
key03.tcssScreen {\nlayout: grid;\ngrid-size: 2 2;\ngrid-columns: 1fr;\n}\nKeyLogger {\nborder: blank;\n}\nKeyLogger:hover {\nborder: wide $secondary;\n}\nKeyLogger:focus {\nborder: wide $accent;\n}\n
InputApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 name='o',\u258echaracter='d',\u258a is_printable=True\u258ename='d',\u258a )\u258eis_printable=True\u258a Key(\u258e)\u258a key='tab',\u258eKey(\u258a character='\\t',\u258ekey='exclamation_mark',\u258a name='tab',\u258echaracter='!',\u258a is_printable=False,\u2586\u2586\u258ename='exclamation_mark',\u2587\u2587\u258a aliases=['tab',\u00a0'ctrl+i']\u258eis_printable=True\u258a )\u258e)\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
The app splits the screen in to quarters, with a RichLog
widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that the widget has focus. Key events will be sent to the focused widget only.
Tip
the :focus
CSS pseudo-selector can be used to apply a style to the focused widget.
You can move focus by pressing the Tab key to focus the next widget. Pressing Shift+Tab moves the focus in the opposite direction.
"},{"location":"guide/input/#controlling-focus","title":"Controlling focus","text":"Textual will handle keyboard focus automatically, but you can tell Textual to focus a widget by calling the widget's focus() method.
"},{"location":"guide/input/#focus-events","title":"Focus events","text":"When a widget receives focus, it is sent a Focus event. When a widget loses focus it is sent a Blur event.
"},{"location":"guide/input/#bindings","title":"Bindings","text":"Keys may be associated with actions for a given widget. This association is known as a key binding.
To create bindings, add a BINDINGS
class variable to your app or widget. This should be a list of tuples of three strings. The first value is the key, the second is the action, the third value is a short human readable description.
The following example binds the keys R, G, and B to an action which adds a bar widget to the screen.
binding01.pybinding01.tcssOutput binding01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Footer, Static\nclass Bar(Static):\npass\nclass BindingApp(App):\nCSS_PATH = \"binding01.tcss\"\nBINDINGS = [\n(\"r\", \"add_bar('red')\", \"Add Red\"),\n(\"g\", \"add_bar('green')\", \"Add Green\"),\n(\"b\", \"add_bar('blue')\", \"Add Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield Footer()\ndef action_add_bar(self, color: str) -> None:\nbar = Bar(color)\nbar.styles.background = Color.parse(color).with_alpha(0.5)\nself.mount(bar)\nself.call_after_refresh(self.screen.scroll_end, animate=False)\nif __name__ == \"__main__\":\napp = BindingApp()\napp.run()\n
binding01.tcssBar {\nheight: 5;\ncontent-align: center middle;\ntext-style: bold;\nmargin: 1 2;\ncolor: $text;\n}\n
BindingApp red\u2582\u2582 green blue blue \u00a0R\u00a0\u00a0Add\u00a0Red\u00a0\u00a0G\u00a0\u00a0Add\u00a0Green\u00a0\u00a0B\u00a0\u00a0Add\u00a0Blue\u00a0
Note how the footer displays bindings and makes them clickable.
Tip
Multiple keys can be bound to a single action by comma-separating them. For example, (\"r,t\", \"add_bar('red')\", \"Add Red\")
means both R and T are bound to add_bar('red')
.
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a Binding instance which exposes a few more options.
"},{"location":"guide/input/#priority-bindings","title":"Priority bindings","text":"Individual bindings may be marked as a priority, which means they will be checked prior to the bindings of the focused widget. This feature is often used to create hot-keys on the app or screen. Such bindings can not be disabled by binding the same key on a widget.
You can create priority key bindings by setting priority=True
on the Binding object. Textual uses this feature to add a default binding for Ctrl+C so there is always a way to exit the app. Here's the bindings from the App base class. Note the first binding is set as a priority:
BINDINGS = [\nBinding(\"ctrl+c\", \"quit\", \"Quit\", show=False, priority=True),\nBinding(\"tab\", \"focus_next\", \"Focus Next\", show=False),\nBinding(\"shift+tab\", \"focus_previous\", \"Focus Previous\", show=False),\n]\n
"},{"location":"guide/input/#show-bindings","title":"Show bindings","text":"The footer widget can inspect bindings to display available keys. If you don't want a binding to display in the footer you can set show=False
. The default bindings on App do this so that the standard Ctrl+C, Tab and Shift+Tab bindings don't typically appear in the footer.
Textual will send events in response to mouse movement and mouse clicks. These events contain the coordinates of the mouse cursor relative to the terminal or widget.
Information
The trackpad (and possibly other pointer devices) are treated the same as the mouse in terminals.
Terminal coordinates are given by a pair values named x
and y
. The X coordinate is an offset in characters, extending from the left to the right of the screen. The Y coordinate is an offset in lines, extending from the top of the screen to the bottom.
Coordinates may be relative to the screen, so (0, 0)
would be the top left of the screen. Coordinates may also be relative to a widget, where (0, 0)
would be the top left of the widget itself.
When you move the mouse cursor over a widget it will receive MouseMove events which contain the coordinate of the mouse and information about what modifier keys (Ctrl, Shift etc) are held down.
The following example shows mouse movements being used to attach a widget to the mouse cursor.
mouse01.pymouse01.tcss mouse01.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widgets import RichLog, Static\nclass PlayArea(Container):\ndef on_mount(self) -> None:\nself.capture_mouse()\ndef on_mouse_move(self, event: events.MouseMove) -> None:\nself.screen.query_one(RichLog).write(event)\nself.query_one(Ball).offset = event.offset - (8, 2)\nclass Ball(Static):\npass\nclass MouseApp(App):\nCSS_PATH = \"mouse01.tcss\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\nyield PlayArea(Ball(\"Textual\"))\nif __name__ == \"__main__\":\napp = MouseApp()\napp.run()\n
mouse01.tcssScreen {\nlayers: log ball;\n}\nTextLog {\nlayer: log;\n}\nPlayArea {\nopacity: 0%;\nlayer: ball;\n}\nBall {\nlayer: ball;\nwidth: auto;\nheight: 1;\nbackground: $secondary;\nborder: tall $secondary;\ncolor: $background;\nbox-sizing: content-box;\ntext-style: bold;\npadding: 0 4;\n}\n
If you run mouse01.py
you should find that it logs the mouse move event, and keeps a widget pinned directly under the cursor.
The on_mouse_move
handler sets the offset style of the ball (a rectangular one) to match the mouse coordinates.
In the mouse01.py
example there was a call to capture_mouse()
in the mount handler. Textual will send mouse move events to the widget directly under the cursor. You can tell Textual to send all mouse events to a widget regardless of the position of the mouse cursor by calling capture_mouse.
Call release_mouse to restore the default behavior.
Warning
If you capture the mouse, be aware you might get negative mouse coordinates if the cursor is to the left of the widget.
Textual will send a MouseCapture event when the mouse is captured, and a MouseRelease event when it is released.
"},{"location":"guide/input/#enter-and-leave-events","title":"Enter and Leave events","text":"Textual will send a Enter event to a widget when the mouse cursor first moves over it, and a Leave event when the cursor moves off a widget.
"},{"location":"guide/input/#click-events","title":"Click events","text":"There are three events associated with clicking a button on your mouse. When the button is initially pressed, Textual sends a MouseDown event, followed by MouseUp when the button is released. Textual then sends a final Click event.
If you want your app to respond to a mouse click you should prefer the Click event (and not MouseDown or MouseUp). This is because a future version of Textual may support other pointing devices which don't have up and down states.
"},{"location":"guide/input/#scroll-events","title":"Scroll events","text":"Most mice have a scroll wheel which you can use to scroll the window underneath the cursor. Scrollable containers in Textual will handle these automatically, but you can handle MouseScrollDown and MouseScrollUp if you want build your own scrolling functionality.
Information
Terminal emulators will typically convert trackpad gestures in to scroll events.
"},{"location":"guide/layout/","title":"Layout","text":"In Textual, the layout defines how widgets will be arranged (or laid out) inside a container. Textual supports a number of layouts which can be set either via a widget's styles
object or via CSS. Layouts can be used for both high-level positioning of widgets on screen, and for positioning of nested widgets.
The vertical
layout arranges child widgets vertically, from top to bottom.
The example below demonstrates how children are arranged inside a container with the vertical
layout.
VerticalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Two\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Three\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass VerticalLayoutExample(App):\nCSS_PATH = \"vertical_layout.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = VerticalLayoutExample()\napp.run()\n
Screen {\nlayout: vertical;\n}\n.box {\nheight: 1fr;\nborder: solid green;\n}\n
Notice that the first widget yielded from the compose
method appears at the top of the display, the second widget appears below it, and so on. Inside vertical_layout.tcss
, we've assigned layout: vertical
to Screen
. Screen
is the parent container of the widgets yielded from the App.compose
method, and can be thought of as the terminal window itself.
Note
The layout: vertical
CSS isn't strictly necessary in this case, since Screens use a vertical
layout by default.
We've assigned each child .box
a height of 1fr
, which ensures they're each allocated an equal portion of the available height.
You might also have noticed that the child widgets are the same width as the screen, despite nothing in our CSS file suggesting this. This is because widgets expand to the width of their parent container (in this case, the Screen
).
Just like other styles, layout
can be adjusted at runtime by modifying the styles
of a Widget
instance:
widget.styles.layout = \"vertical\"\n
Using fr
units guarantees that the children fill the available height of the parent. However, if the total height of the children exceeds the available space, then Textual will automatically add a scrollbar to the parent Screen
.
Note
A scrollbar is added automatically because Screen
contains the declaration overflow-y: auto;
.
For example, if we swap out height: 1fr;
for height: 10;
in the example above, the child widgets become a fixed height of 10, and a scrollbar appears (assuming our terminal window is sufficiently small):
VerticalLayoutScrolledExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2582\u2582 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Two\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502
With the parent container in focus, we can use our mouse wheel, trackpad, or keyboard to scroll it.
"},{"location":"guide/layout/#horizontal","title":"Horizontal","text":"The horizontal
layout arranges child widgets horizontally, from left to right.
The example below shows how we can arrange widgets horizontally, with minimal changes to the vertical layout example above.
Outputhorizontal_layout.pyhorizontal_layout.tcssHorizontalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass HorizontalLayoutExample(App):\nCSS_PATH = \"horizontal_layout.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = HorizontalLayoutExample()\napp.run()\n
Screen {\nlayout: horizontal;\n}\n.box {\nheight: 100%;\nwidth: 1fr;\nborder: solid green;\n}\n
We've changed the layout
to horizontal
inside our CSS file. As a result, the widgets are now arranged from left to right instead of top to bottom.
We also adjusted the height of the child .box
widgets to 100%
. As mentioned earlier, widgets expand to fill the width of their parent container. They do not, however, expand to fill the container's height. Thus, we need explicitly assign height: 100%
to achieve this.
A consequence of this \"horizontal growth\" behaviour is that if we remove the width restriction from the above example (by deleting width: 1fr;
), each child widget will grow to fit the width of the screen, and only the first widget will be visible. The other two widgets in our layout are offscreen, to the right-hand side of the screen. In the case of horizontal
layout, Textual will not automatically add a scrollbar.
To enable horizontal scrolling, we can use the overflow-x: auto;
declaration:
HorizontalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u258a
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass HorizontalLayoutExample(App):\nCSS_PATH = \"horizontal_layout_overflow.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = HorizontalLayoutExample()\napp.run()\n
Screen {\nlayout: horizontal;\noverflow-x: auto;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
With overflow-x: auto;
, Textual automatically adds a horizontal scrollbar since the width of the children exceeds the available horizontal space in the parent container.
Textual comes with several \"container\" widgets. Among them, we have Vertical, Horizontal, and Grid which have the corresponding layout.
The example below shows how we can combine these containers to create a simple 2x2 grid. Inside a single Horizontal
container, we place two Vertical
containers. In other words, we have a single row containing two columns.
UtilityContainersExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502One\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Two\u2502\u2502Four\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVertical(\nStatic(\"One\"),\nStatic(\"Two\"),\nclasses=\"column\",\n),\nVertical(\nStatic(\"Three\"),\nStatic(\"Four\"),\nclasses=\"column\",\n),\n)\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Static {\ncontent-align: center middle;\nbackground: crimson;\nborder: solid darkred;\nheight: 1fr;\n}\n.column {\nwidth: 1fr;\n}\n
You may be tempted to use many levels of nested utility containers in order to build advanced, grid-like layouts. However, Textual comes with a more powerful mechanism for achieving this known as grid layout, which we'll discuss below.
"},{"location":"guide/layout/#composing-with-context-managers","title":"Composing with context managers","text":"In the previous section, we've shown how you add children to a container (such as Horizontal
and Vertical
) using positional arguments. It's fine to do it this way, but Textual offers a simplified syntax using context managers, which is generally easier to write and edit.
When composing a widget, you can introduce a container using Python's with
statement. Any widgets yielded within that block are added as a child of the container.
Let's update the utility containers example to use the context manager approach.
utility_containers_using_with.pyutility_containers.pyutility_containers.tcssOutputNote
This code uses context managers to compose widgets.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nwith Vertical(classes=\"column\"):\nyield Static(\"One\")\nyield Static(\"Two\")\nwith Vertical(classes=\"column\"):\nyield Static(\"Three\")\nyield Static(\"Four\")\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Note
This is the original code using positional arguments.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVertical(\nStatic(\"One\"),\nStatic(\"Two\"),\nclasses=\"column\",\n),\nVertical(\nStatic(\"Three\"),\nStatic(\"Four\"),\nclasses=\"column\",\n),\n)\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Static {\ncontent-align: center middle;\nbackground: crimson;\nborder: solid darkred;\nheight: 1fr;\n}\n.column {\nwidth: 1fr;\n}\n
UtilityContainersExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502One\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Two\u2502\u2502Four\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
Note how the end result is the same, but the code with context managers is a little easier to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
"},{"location":"guide/layout/#grid","title":"Grid","text":"The grid
layout arranges widgets within a grid. Widgets can span multiple rows and columns to create complex layouts. The diagram below hints at what can be achieved using layout: grid
.
Note
Grid layouts in Textual have little in common with browser-based CSS Grid.
To get started with grid layout, define the number of columns and rows in your grid with the grid-size
CSS property and set layout: grid
. Widgets are inserted into the \"cells\" of the grid from left-to-right and top-to-bottom order.
The following example creates a 3 x 2 grid and adds six widgets to it
Outputgrid_layout1.pygrid_layout1.tcssGridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout1.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3 2;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
If we were to yield a seventh widget from our compose
method, it would not be visible as the grid does not contain enough cells to accommodate it. We can tell Textual to add new rows on demand to fit the number of widgets, by omitting the number of rows from grid-size
. The following example creates a grid with three columns, with rows created on demand:
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Seven\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout2.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nyield Static(\"Seven\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Since we specified that our grid has three columns (grid-size: 3
), and we've yielded seven widgets in total, a third row has been created to accommodate the seventh widget.
Now that we know how to define a simple uniform grid, let's look at how we can customize it to create more complex layouts.
"},{"location":"guide/layout/#row-and-column-sizes","title":"Row and column sizes","text":"You can adjust the width of columns and the height of rows in your grid using the grid-columns
and grid-rows
properties. These properties can take multiple values, letting you specify dimensions on a column-by-column or row-by-row basis.
Continuing on from our earlier 3x2 example grid, let's adjust the width of the columns using grid-columns
. We'll make the first column take up half of the screen width, with the other two columns sharing the remaining space equally.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout3_row_col_adjust.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 2fr 1fr 1fr;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Since our grid-size
is 3 (meaning it has three columns), our grid-columns
declaration has three space-separated values. Each of these values sets the width of a column. The first value refers to the left-most column, the second value refers to the next column, and so on. In the example above, we've given the left-most column a width of 2fr
and the other columns widths of 1fr
. As a result, the first column is allocated twice the width of the other columns.
Similarly, we can adjust the height of a row using grid-rows
. In the following example, we use %
units to adjust the first row of our grid to 25%
height, and the second row to 75%
height (while retaining the grid-columns
change from above).
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout4_row_col_adjust.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 2fr 1fr 1fr;\ngrid-rows: 25% 75%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
If you don't specify enough values in a grid-columns
or grid-rows
declaration, the values you have provided will be \"repeated\". For example, if your grid has four columns (i.e. grid-size: 4;
), then grid-columns: 2 4;
is equivalent to grid-columns: 2 4 2 4;
. If it instead had three columns, then grid-columns: 2 4;
would be equivalent to grid-columns: 2 4 2;
.
The grid-columns
and grid-rows
rules can both accept a value of \"auto\" in place of any of the dimensions, which tells Textual to calculate an optimal size based on the content.
Let's modify the previous example to make the first column an auto
column.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502First\u00a0column\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout_auto.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"First column\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: auto 1fr 1fr;\ngrid-rows: 25% 75%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Notice how the first column is just wide enough to fit the content of each cell. The layout will adjust accordingly if you update the content for any widget in that column.
"},{"location":"guide/layout/#cell-spans","title":"Cell spans","text":"Cells may span multiple rows or columns, to create more interesting grid arrangements.
To make a single cell span multiple rows or columns in the grid, we need to be able to select it using CSS. To do this, we'll add an ID to the widget inside our compose
method so we can set the row-span
and column-span
properties using CSS.
Let's add an ID of #two
to the second widget yielded from compose
, and give it a column-span
of 2 to make that widget span two columns. We'll also add a slight tint using tint: magenta 40%;
to draw attention to it.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u00a0(column-span:\u00a02)\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Three\u2502\u2502Four\u2502\u2502Five\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Six\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout5_col_span.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two [b](column-span: 2)\", classes=\"box\", id=\"two\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n#two {\ncolumn-span: 2;\ntint: magenta 40%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Notice that the widget expands to fill columns to the right of its original position. Since #two
now spans two cells instead of one, all widgets that follow it are shifted along one cell in the grid to accommodate. As a result, the final widget wraps on to a new row at the bottom of the grid.
Note
In the example above, setting the column-span
of #two
to be 3 (instead of 2) would have the same effect, since there are only 2 columns available (including #two
's original column).
We can similarly adjust the row-span
of a cell to have it span multiple rows. This can be used in conjunction with column-span
, meaning one cell may span multiple rows and columns. The example below shows row-span
in action. We again target widget #two
in our CSS, and add a row-span: 2;
declaration to it.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u00a0(column-span:\u00a02\u00a0and\u00a0row-span:\u00a02)\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2502\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u2502\u2502 \u2502Three\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout6_row_span.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two [b](column-span: 2 and row-span: 2)\", classes=\"box\", id=\"two\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\napp = GridLayoutExample()\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n#two {\ncolumn-span: 2;\nrow-span: 2;\ntint: magenta 40%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Widget #two
now spans two columns and two rows, covering a total of four cells. Notice how the other cells are moved to accommodate this change. The widget that previously occupied a single cell now occupies four cells, thus displacing three cells to a new row.
The spacing between cells in the grid can be adjusted using the grid-gutter
CSS property. By default, cells have no gutter, meaning their edges touch each other. Gutter is applied across every cell in the grid, so grid-gutter
must be used on a widget with layout: grid
(not on a child/cell widget).
To illustrate gutter let's set our Screen
background color to lightgreen
, and the background color of the widgets we yield to darkmagenta
. Now if we add grid-gutter: 1;
to our grid, one cell of spacing appears between the cells and reveals the light green background of the Screen
.
GridLayoutExample OneTwoThree FourFiveSix
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout7_gutter.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-gutter: 1;\nbackground: lightgreen;\n}\n.box {\nbackground: darkmagenta;\nheight: 100%;\n}\n
Notice that gutter only applies between the cells in a grid, pushing them away from each other. It doesn't add any spacing between cells and the edges of the parent container.
Tip
You can also supply two values to the grid-gutter
property to set vertical and horizontal gutters respectively. Since terminal cells are typically two times taller than they are wide, it's common to set the horizontal gutter equal to double the vertical gutter (e.g. grid-gutter: 1 2;
) in order to achieve visually consistent spacing around grid cells.
Widgets may be docked. Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2aWXPbNlx1MDAxMIDf8ys0ymvM4D4y0+nItpKocWwnjuOj0+nQJCTSokiWpCxLXHUwMDE5//cuaVXURVx1MDAxZrKqqtGDLWFcdGBcdHy72Fx1MDAwNfDjVa1Wz4axqb+r1c2tY1x1MDAwN76b2IP6m7z8xiSpXHUwMDFmhSBcIsXvNOonTvGkl2Vx+u7t256ddE1cdTAwMTZcdTAwMDe2Y6xcdTAwMWI/7dtBmvVdP7KcqPfWz0wv/TX/e2j3zC9x1HOzxCo72TGun0XJfV8mMD1cdTAwMTNmKbT+O/yu1X5cdTAwMTR/p7RLjJPZYScwRYVCVCrIXHSbLz2MwkJZzDXHTCuMJk/46T70l1x1MDAxOVx1MDAxN8Rt0NmUkryovk9cdTAwMWKNLDiPm7vvveD4YtQ6b3mdstu2XHUwMDFmXHUwMDA0J9kwKNRykihNdzw7c7zyiTRLoq45893MyzWYK5/UTSNcdTAwMTiJslZcdTAwMTL1O15o0nSmTlx1MDAxNNuOn1xy8zJUvsL9SLyrlSW3+TBcYksppLimkk9cdTAwMDR5VVwiLc0wQ4LwOWX2olx1MDAwMOZcdTAwMDCUeY2KT6nOle10O6BT6E6eyVx1MDAxMjtMYzuBmSqfXHUwMDFijF+TaWFRqfRMJ57xO15cdTAwMDZSSpSl2LReqSkmQFLMXHUwMDE4RVxcTVx1MDAwNHmnccstWPhjfuw8O4nHY1RP81x1MDAxZlNcbue6NqdAKiv3Y9e+n28sXHUwMDA0YVx1MDAxYyOtJKJcdTAwMTN54IddXHUwMDEwhv0gKMtcIqdbXCJSlN69WYFNrGklm4wjXCI1k09nM+yfXHUwMDFj73qX3/rdXHUwMDEz3jvY/Vx1MDAxNCFf7lWwOcfXLJVkk1RKhlxiZUuoJJjNUbF2KplVgSRcdTAwMTFcdTAwMTYmQMIyKJHGXHUwMDFhXHUwMDFjXHUwMDA3+lx1MDAxZkNpgsCP0+VIXG5VhaQg4D84werJRLZUvG9uj6L9a9p2/mzEPlx1MDAxYuhwXHUwMDE1XCI35yeFtKhSQmI1RyRcdTAwMDVUheZcdTAwMTS/zE++btuccLJIIyaLxE94xNhic13f08hcdTAwMDVSQlx1MDAwYkV+Tlx1MDAxYVx0IVU0YiykJuA7no6jz0dnXel+uP14ddL63CW94Pw82G5cdTAwMWM1tjSli6s25ZbgL1xcslx1MDAwMcUrhPi6UCyCXGLNlPhJUZSiXG5FmFx1MDAxZKQwsPhkXHUwMDEym8R8O/R6XzzxvXf05f1+s5mNXHUwMDBltptEjKXF5Cx0Y1x1MDAxMl9cdTAwMWE7vsbkXG587rpA1FgqJqT8P6/QXHUwMDBmh41cdTAwMTC5VLpFSlx1MDAxOVx1MDAxM1x1MDAxMOc/PW68uTj8XHUwMDFj68ug2T/5yptcdTAwMTk7v4pdv1x1MDAwMkbPdrx+Yv57XHUwMDFjuVx1MDAwNjTIXFxKkVfldJ3rtFhGJdWWXHUwMDEwRcZ031x1MDAxMF2Ek1wiYtFZ5cZ0XG4miCZywys2jFx1MDAwNkViM3TKXHUwMDA3Mm4qXGJoJJ+R1Vx1MDAxY1x1MDAxZYlcdTAwMTZpXFz3XHUwMDAzX35cdTAwMTmq9vfWzaCRbjudXHUwMDA0c0stYJjXXHUwMDE11FKQ2NGXZjZjn7mMT8iYLYZcdTAwMTHmS1NcdTAwMWJJQCghZJRMXHUwMDE3n3lAMVx1MDAwNF2SK0o2SygkXHUwMDE3SMrNXHUwMDEwqpSsXCKU5IqoZ1x1MDAxMbpz9MGcfo/OzcDP1M7p8V+O23K2n1BlgUdcdTAwMTBswYFiziyBJCMzKdB6XHUwMDExJVpZfFVAXHUwMDA1olQwyjaLJ5jFxvBElVmPRlhTNZ1cdTAwMTY9Rif969PnVn/onO3tfrn0mmfNTvL5z+2nXHUwMDEzsu2lyzthzFKU6lx1MDAxN29ccj1Cp0RcdTAwMTDN4dVcdTAwMWMoQ+A/MaGbXHJAYc2hXHUwMDAyb4RQgmTlLlx1MDAxMURHVEJcdTAwMWH4jGyo8enyMOy6UUz6R3vu7c23bD+Mt1x1MDAxZFFOmSU5X7KjLoWl+Dr8JyHqyiwlXHUwMDE0wotcIrZcdTAwMThcdTAwMTOotVjkXHUwMDE0XHUwMDBibTGxdFx1MDAwZlx1MDAxM1x1MDAxMiRcblx1MDAxM1x1MDAwNPRveIlcdTAwMTeYboZQrFHl1rpSmmBKpnKox1x1MDAwMFx1MDAxNa33e8e/XHRcdTAwMTnstM2+STJFXHUwMDBm+61tXHUwMDA3NF/hXHRcdTAwMTWUqPlcdTAwMTiUQs4uOV5DkvSQXHUwMDBmXHUwMDA1K1x1MDAxOJ83XHUwMDE1XHItI1RbsJapXHUwMDEyYzpcdTAwMGYqpVx1MDAxNFDlerMnQDBykESvi1M7SaLBUi/KKlx1MDAxMWWUS4LwM1x1MDAwZSbbl1xyXHUwMDE571x1MDAwN5/E6FwiuKTy4243XHUwMDFjXHUwMDBlVkN0c8c/WECaRDBmRFx1MDAxMIWEwnKGU5afTT5cZil3NEPuqpDmm1xiXHUwMDEwXHUwMDAyc5jyonu2yChB2lr0n1x1MDAwMoPqXG6vcjJZKLdcdTAwMWGXlMKQPsd/TulhJ9muXHUwMDFmun7YXHUwMDAx4T+M1ian661cdTAwMDKig49fvUBcdTAwMGVcdGmedk4v5ECnX9HpRNdcdTAwMWOjyOnnWu4gXHUwMDBiwlxywlx1MDAxNbhcdTAwMTdCNSyDik891rHjYv4teZ/ojiV3XHUwMDEzfUzoltrMvoCdZntRr+dn8OrHkVx1MDAxZmbzT1x1MDAxNO/SyI3KM7Y7L4WWp2Xz1lx1MDAxN+ctlldcdPJP+a1W4ln8mHz/483Sp3cq+SmkXHUwMDA1OmVcdTAwMWKvpv9XOYvM3GZLfVx1MDAwNa5cZrgkg0BcdTAwMTRcdTAwMDLO0sE+5itcdTAwMWWe5i1dznCxnC1cdTAwMWNcdTAwMTQzXG6xOnn5WUi1k4BwfolbWPBcdFx1MDAxME9cdIhcdP+N449cdEM/pvB6kt+fYeveXHUwMDE0JpK7f4D8N1x1MDAxY047XG6zXHUwMDEzf1TsqKCZ0vd2z1x1MDAwZoYzXHUwMDE4XHUwMDE00OeXa4q2ajDwXHUwMDFkk01PV2qg61wiuVAzlVx1MDAxYYHfya2jXHUwMDFlmPas2WS+Y1x1MDAwN1x1MDAxM3FcdTAwMTZNXHKvXHUwMDAzStjQXFzSWnBcdTAwMWVR4nf80Fx1MDAwZb4tVWglw8XVW02QIVxuSVx1MDAxOFJPz5RcdTAwMGVcdTAwMDJzdINij35cdTAwMThdOFe/XVx1MDAwZkbBaMWt+s2t8lRLaz6NXHUwMDA3O7aUnj+/WfNcdTAwMDVcdTAwMGbMS4VcdTAwMWawXFyMXHUwMDE01lx1MDAxMHuwXHKb7uHxdYNeKXamlZdcXCeDXHUwMDBmo1x1MDAwMTtYm+lyjMRz9qteZrpcdTAwMDf2MOpnY0tJt8F25zRaMUTn1Vx1MDAxN7RcdTAwMTCh+DnXs1x1MDAxZZ7uLbVdxoVFXHUwMDE5yq/hIaXx1GWL+1x1MDAwMJ1Z+rG7g0q2+dXqRqykhbjGeqzA1MZTadJcdTAwMTBcdTAwMWFUXdeCcsGJ5qusyy9cdNVz81OrmN9TQ/VcdTAwMDdXgtlQXHUwMDFkVpj8SFxcUYihJFXlJJahOrG0QkyonzdWr+SokE4jVFx1MDAxNbK/XHUwMDFhN1634/gkg/meTFx1MDAwZiDlu2OfWb5h/cY3g91lR8vFJ3dJxSjnpm/y9/xx9+rub5B4Q/4ifQ== Docked widgetLayout widgetsTo dock a widget to an edge, add a dock: <EDGE>;
declaration to it, where <EDGE>
is one of top
, right
, bottom
, or left
. For example, a sidebar similar to that shown in the diagram above can be achieved using dock: left;
. The code below shows a simple sidebar implementation.
DockLayoutExample SidebarDocking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0\u2587\u2587 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout1_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
If we run the app above and scroll down, the body text will scroll but the sidebar does not (note the position of the scrollbar in the output shown above).
Docking multiple widgets to the same edge will result in overlap. The first widget yielded from compose
will appear below widgets yielded after it. Let's dock a second sidebar, #another-sidebar
, to the left of the screen. This new sidebar is double the width of the one previous one, and has a deeppink
background.
DockLayoutExample Sidebar1Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars. \u2587\u2587 Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout2_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar2\", id=\"another-sidebar\")\nyield Static(\"Sidebar1\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\napp = DockLayoutExample()\nif __name__ == \"__main__\":\napp.run()\n
#another-sidebar {\ndock: left;\nwidth: 30;\nheight: 100%;\nbackground: deeppink;\n}\n#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
Notice that the original sidebar (#sidebar
) appears on top of the newly docked widget. This is because #sidebar
was yielded after #another-sidebar
inside the compose
method.
Of course, we can also dock widgets to multiple edges within the same container. The built-in Header
widget contains some internal CSS which docks it to the top. We can yield it inside compose
, and without any additional CSS, we get a header fixed to the top of the screen.
DockLayoutExample Sidebar1DockLayoutExample Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout3_sidebar_header.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header(id=\"header\")\nyield Static(\"Sidebar1\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
If we wished for the sidebar to appear below the header, it'd simply be a case of yielding the sidebar before we yield the header.
"},{"location":"guide/layout/#layers","title":"Layers","text":"Textual has a concept of layers which gives you finely grained control over the order widgets are placed.
When drawing widgets, Textual will first draw on lower layers, working its way up to higher layers. As such, widgets on higher layers will be drawn on top of those on lower layers.
Layer names are defined with a layers
style on a container (parent) widget. Descendants of this widget can then be assigned to one of these layers using a layer
style.
The layers
style takes a space-separated list of layer names. The leftmost name is the lowest layer, and the rightmost is the highest layer. Therefore, if you assign a descendant to the rightmost layer name, it'll be drawn on the top layer and will be visible above all other descendants.
An example layers
declaration looks like: layers: one two three;
. To add a widget to the topmost layer in this case, you'd add a declaration of layer: three;
to it.
In the example below, #box1
is yielded before #box2
. Given our earlier discussion on yield order, you'd expect #box2
to appear on top. However, in this case, both #box1
and #box2
are assigned to layers which define the reverse order, so #box1
is on top of #box2
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"guide/layout/#offsets","title":"Offsets","text":"Widgets have a relative offset which is added to the widget's location, after its location has been determined via its parent's layout. This means that if a widget hasn't had its offset modified using CSS or Python code, it will have an offset of (0, 0)
.
The offset of a widget can be set using the offset
CSS property. offset
takes two values.
x
(horizontal) offset. Positive values will shift the widget to the right. Negative values will shift the widget to the left.y
(vertical) offset. Positive values will shift the widget down. Negative values will shift the widget up.The sections above show how the various layouts in Textual can be used to position widgets on screen. In a real application, you'll make use of several layouts.
The example below shows how an advanced layout can be built by combining the various techniques described on this page.
Outputcombining_layouts.pycombining_layouts.tcssCombiningLayoutsExample \u2b58CombiningLayoutsExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502HorizontallyPositionedChildrenHere\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a00\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a01\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2585\u2585\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a02\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\u2502\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502Thispanelis\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a03\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502usinggrid\u00a0layout!\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Header, Static\nclass CombiningLayoutsExample(App):\nCSS_PATH = \"combining_layouts.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Container(id=\"app-grid\"):\nwith VerticalScroll(id=\"left-pane\"):\nfor number in range(15):\nyield Static(f\"Vertical layout, child {number}\")\nwith Horizontal(id=\"top-right\"):\nyield Static(\"Horizontally\")\nyield Static(\"Positioned\")\nyield Static(\"Children\")\nyield Static(\"Here\")\nwith Container(id=\"bottom-right\"):\nyield Static(\"This\")\nyield Static(\"panel\")\nyield Static(\"is\")\nyield Static(\"using\")\nyield Static(\"grid layout!\", id=\"bottom-right-final\")\nif __name__ == \"__main__\":\napp = CombiningLayoutsExample()\napp.run()\n
#app-grid {\nlayout: grid;\ngrid-size: 2; /* two columns */\ngrid-columns: 1fr;\ngrid-rows: 1fr;\n}\n#left-pane > Static {\nbackground: $boost;\ncolor: auto;\nmargin-bottom: 1;\npadding: 1;\n}\n#left-pane {\nwidth: 100%;\nheight: 100%;\nrow-span: 2;\nbackground: $panel;\nborder: dodgerblue;\n}\n#top-right {\nheight: 100%;\nbackground: $panel;\nborder: mediumvioletred;\n}\n#top-right > Static {\nwidth: auto;\nheight: 100%;\nmargin-right: 1;\nbackground: $boost;\n}\n#bottom-right {\nheight: 100%;\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 1fr;\ngrid-rows: 1fr;\ngrid-gutter: 1;\nbackground: $panel;\nborder: greenyellow;\n}\n#bottom-right-final {\ncolumn-span: 2;\n}\n#bottom-right > Static {\nheight: 100%;\nbackground: $boost;\n}\n
Textual layouts make it easy to design and build real-life applications with relatively little code.
"},{"location":"guide/queries/","title":"DOM Queries","text":"In the previous chapter we introduced the DOM which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS selectors.
Selectors are a very useful idea and can do more that apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how!
"},{"location":"guide/queries/#query-one","title":"Query one","text":"The query_one method gets a single widget in an app or other widget. If you call it with a selector it will return the first matching widget.
Let's say we have a widget with an ID of send
and we want to get a reference to it in our app. We could do this with the following:
send_button = self.query_one(\"#send\")\n
If there is no widget with an ID of send
, Textual will raise a NoMatches exception. Otherwise it will return the matched widget.
You can also add a second parameter for the expected type.
send_button = self.query_one(\"#send\", Button)\n
If the matched widget is not a button (i.e. if isinstance(widget, Button)
equals False
), Textual will raise a WrongType exception.
Tip
The second parameter allows type-checkers like MyPy to know the exact return type. Without it, MyPy would only know the result of query_one
is a Widget (the base class).
Apps and widgets have a query method which finds (or queries) widgets. This method returns a DOMQuery object which is a list-like container of widgets.
If you call query
with no arguments, you will get back a DOMQuery
containing all widgets. This method is recursive, meaning it will also return child widgets (as many levels as required).
Here's how you might iterate over all the widgets in your app:
for widget in self.query():\nprint(widget)\n
Called on the app
, this will retrieve all widgets in the app. If you call the same method on a widget, it will return the children of that widget.
Note
All the query and related methods work on both App and Widget sub-classes.
"},{"location":"guide/queries/#query-selectors","title":"Query selectors","text":"You can call query
with a CSS selector. Let's look a few examples:
If we want to find all the button widgets, we could do something like the following:
for button in self.query(\"Button\"):\nprint(button)\n
Any selector that works in CSS will work with the query
method. For instance, if we want to find all the disabled buttons in a Dialog widget, we could do this:
for button in self.query(\"Dialog Button.disabled\"):\nprint(button)\n
Info
The selector Dialog Button.disabled
says find all the Button
with a CSS class of disabled
that are a child of a Dialog
widget.
Query objects have a results method which is an alternative way of iterating over widgets. If you supply a type (i.e. a Widget class) then this method will generate only objects of that type.
The following example queries for widgets with the disabled
CSS class and iterates over just the Button objects.
for button in self.query(\".disabled\").results(Button):\nprint(button)\n
Tip
This method allows type-checkers like MyPy to know the exact type of the object in the loop. Without it, MyPy would only know that button
is a Widget
(the base class).
We've seen that the query method returns a DOMQuery object you can iterate over in a for loop. Query objects behave like Python lists and support all of the same operations (such as query[0]
, len(query)
,reverse(query)
etc). They also have a number of other methods to simplify retrieving and modifying widgets.
The first and last methods return the first or last matching widget from the selector, respectively.
Here's how we might find the last button in an app:
last_button = self.query(\"Button\").last()\n
If there are no buttons, Textual will raise a NoMatches exception. Otherwise it will return a button widget.
Both first()
and last()
accept an expect_type
argument that should be the class of the widget you are expecting. Let's say we want to get the last widget with class .disabled
, and we want to check it really is a button. We could do this:
disabled_button = self.query(\".disabled\").last(Button)\n
The query selects all widgets with a disabled
CSS class. The last
method gets the last disabled widget and checks it is a Button
and not any other kind of widget.
If the last widget is not a button, Textual will raise a WrongType exception.
Tip
Specifying the expected type allows type-checkers like MyPy to know the exact return type.
"},{"location":"guide/queries/#filter","title":"Filter","text":"Query objects have a filter method which further refines a query. This method will return a new query object with widgets that match both the original query and the new selector
Let's say we have a query which gets all the buttons in an app, and we want a new query object with just the disabled buttons. We could write something like this:
# Get all the Buttons\nbuttons_query = self.query(\"Button\")\n# Buttons with 'disabled' CSS class\ndisabled_buttons = buttons_query.filter(\".disabled\")\n
Iterating over disabled_buttons
will give us all the disabled buttons.
Query objects have an exclude method which is the logical opposite of filter. The exclude
method removes any widgets from the query object which match a selector.
Here's how we could get all the buttons which don't have the disabled
class set.
# Get all the Buttons\nbuttons_query = self.query(\"Button\")\n# Remove all the Buttons with the 'disabled' CSS class\nenabled_buttons = buttons_query.exclude(\".disabled\")\n
"},{"location":"guide/queries/#loop-free-operations","title":"Loop-free operations","text":"Once you have a query object, you can loop over it to call methods on the matched widgets. Query objects also support a number of methods which make an update to every matched widget without an explicit loop.
For instance, let's say we want to disable all buttons in an app. We could do this by calling add_class() on a query object.
self.query(\"Button\").add_class(\"disabled\")\n
This single line is equivalent to the following:
for widget in self.query(\"Button\"):\nwidget.add_class(\"disabled\")\n
Here are the other loop-free methods on query objects:
Textual's reactive attributes are attributes with superpowers. In this chapter we will look at how reactive attributes can simplify your apps.
Quote
With great power comes great responsibility.
\u2014 Uncle Ben
"},{"location":"guide/reactivity/#reactive-attributes","title":"Reactive attributes","text":"Textual provides an alternative way of adding attributes to your widget or App, which doesn't require adding them to your class constructor (__init__
). To create these attributes import reactive from textual.reactive
, and assign them in the class scope.
The following code illustrates how to create reactive attributes:
from textual.reactive import reactive\nfrom textual.widget import Widget\nclass Reactive(Widget):\nname = reactive(\"Paul\") # (1)!\ncount = reactive(0) # (2)!\nis_cool = reactive(True) # (3)!\n
\"Paul\"
0
.True
.The reactive
constructor accepts a default value as the first positional argument.
Information
Textual uses Python's descriptor protocol to create reactive attributes, which is the same protocol used by the builtin property
decorator.
You can get and set these attributes in the same way as if you had assigned them in an __init__
method. For instance self.name = \"Jessica\"
, self.count += 1
, or print(self.is_cool)
.
You can also set the default to a function (or other callable). Textual will call this function to get the default value. The following code illustrates a reactive value which will be automatically assigned the current time when the widget is created:
from time import time\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nclass Timer(Widget):\nstart_time = reactive(time) # (1)!\n
time
function returns the current time in seconds.There is no need to specify a type hint if a reactive attribute has a default value, as type checkers will assume the attribute is the same type as the default.
You may want to add explicit type hints if the attribute type is a superset of the default type. For instance if you want to make an attribute optional. Here's how you would create a reactive string attribute which may be None
:
name: reactive[str | None] = reactive(\"Paul\")\n
"},{"location":"guide/reactivity/#smart-refresh","title":"Smart refresh","text":"The first superpower we will look at is \"smart refresh\". When you modify a reactive attribute, Textual will make note of the fact that it has changed and refresh automatically.
Information
If you modify multiple reactive attributes, Textual will only do a single refresh to minimize updates.
Let's look at an example which illustrates this. In the following app, the value of an input is used to update a \"Hello, World!\" type greeting.
refresh01.pyrefresh01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input\nclass Name(Widget):\n\"\"\"Generates a greeting.\"\"\"\nwho = reactive(\"name\")\ndef render(self) -> str:\nreturn f\"Hello, {self.who}!\"\nclass WatchApp(App):\nCSS_PATH = \"refresh01.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter your name\")\nyield Name()\ndef on_input_changed(self, event: Input.Changed) -> None:\nself.query_one(Name).who = event.value\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
Input {\ndock: top;\nmargin-top: 1;\n}\nName {\nheight: 100%;\ncontent-align: center middle;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aTextual\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e Hello,\u00a0Textual!
The Name
widget has a reactive who
attribute. When the app modifies that attribute, a refresh happens automatically.
Information
Textual will check if a value has really changed, so assigning the same value wont prompt an unnecessary refresh.
"},{"location":"guide/reactivity/#disabling-refresh","title":"Disabling refresh","text":"If you don't want an attribute to prompt a refresh or layout but you still want other reactive superpowers, you can use var to create an attribute. You can import var
from textual.reactive
.
The following code illustrates how you create non-refreshing reactive attributes.
class MyWidget(Widget):\ncount = var(0) # (1)!\n
self.count
wont cause a refresh or layout.The smart refresh feature will update the content area of a widget, but will not change its size. If modifying an attribute should change the size of the widget, you can set layout=True
on the reactive attribute. This ensures that your CSS layout will update accordingly.
The following example modifies \"refresh01.py\" so that the greeting has an automatic width.
refresh02.pyrefresh02.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input\nclass Name(Widget):\n\"\"\"Generates a greeting.\"\"\"\nwho = reactive(\"name\", layout=True) # (1)!\ndef render(self) -> str:\nreturn f\"Hello, {self.who}!\"\nclass WatchApp(App):\nCSS_PATH = \"refresh02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter your name\")\nyield Name()\ndef on_input_changed(self, event: Input.Changed) -> None:\nself.query_one(Name).who = event.value\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
Input {\ndock: top;\nmargin-top: 1;\n}\nName {\nwidth: auto;\nheight: auto;\nborder: heavy $secondary;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aname\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Hello,\u00a0name!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
If you type in to the input now, the greeting will expand to fit the content. If you were to set layout=False
on the reactive attribute, you should see that the box remains the same size when you type.
The next superpower we will look at is validation, which can check and potentially modify a value you assign to a reactive attribute.
If you add a method that begins with validate_
followed by the name of your attribute, it will be called when you assign a value to that attribute. This method should accept the incoming value as a positional argument, and return the value to set (which may be the same or a different value).
A common use for this is to restrict numbers to a given range. The following example keeps a count. There is a button to increase the count, and a button to decrease it. The validation ensures that the count will never go above 10 or below zero.
validate01.pyvalidate01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, RichLog\nclass ValidateApp(App):\nCSS_PATH = \"validate01.tcss\"\ncount = reactive(0)\ndef validate_count(self, count: int) -> int:\n\"\"\"Validate value.\"\"\"\nif count < 0:\ncount = 0\nelif count > 10:\ncount = 10\nreturn count\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nButton(\"+1\", id=\"plus\", variant=\"success\"),\nButton(\"-1\", id=\"minus\", variant=\"error\"),\nid=\"buttons\",\n)\nyield RichLog(highlight=True)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"plus\":\nself.count += 1\nelse:\nself.count -= 1\nself.query_one(RichLog).write(f\"{self.count=}\")\nif __name__ == \"__main__\":\napp = ValidateApp()\napp.run()\n
#buttons {\ndock: top;\nheight: auto;\n}\n
ValidateApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0+1\u00a0\u00a0-1\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
If you click the buttons in the above example it will show the current count. When self.count
is modified in the button handler, Textual runs validate_count
which performs the validation to limit the value of count.
Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch method names begin with watch_
followed by the name of the attribute, and should accept one or two arguments. If the method accepts a single argument, it will be called with the new assigned value. If the method accepts two positional arguments, it will be called with both the old value and the new value.
The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example \"darkorchid\"
or \"#52de44\"
.
from textual.app import App, ComposeResult\nfrom textual.color import Color, ColorParseError\nfrom textual.containers import Grid\nfrom textual.reactive import reactive\nfrom textual.widgets import Input, Static\nclass WatchApp(App):\nCSS_PATH = \"watch01.tcss\"\ncolor = reactive(Color.parse(\"transparent\")) # (1)!\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a color\")\nyield Grid(Static(id=\"old\"), Static(id=\"new\"), id=\"colors\")\ndef watch_color(self, old_color: Color, new_color: Color) -> None: # (2)!\nself.query_one(\"#old\").styles.background = old_color\nself.query_one(\"#new\").styles.background = new_color\ndef on_input_submitted(self, event: Input.Submitted) -> None:\ntry:\ninput_color = Color.parse(event.value)\nexcept ColorParseError:\npass\nelse:\nself.query_one(Input).value = \"\"\nself.color = input_color # (3)!\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
self.color
is changed.Input {\ndock: top;\nmargin-top: 1;\n}\n#colors {\ngrid-size: 2 1;\ngrid-gutter: 2 4;\ngrid-columns: 1fr;\nmargin: 0 1;\n}\n#old {\nheight: 100%;\nborder: wide $secondary;\n}\n#new {\nheight: 100%;\nborder: wide $secondary;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258adarkorchid\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
The color is parsed in on_input_submitted
and assigned to self.color
. Because color
is reactive, Textual also calls watch_color
with the old and new values.
Textual only calls watch methods if the value of a reactive attribute changes. If the newly assigned value is the same as the previous value, the watch method is not called. You can override this behaviour by passing always_update=True
to reactive
.
Compute methods are the final superpower offered by the reactive
descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with compute_
followed by the name of the reactive value.
You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes.
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes.
computed01.pycomputed01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Horizontal\nfrom textual.reactive import reactive\nfrom textual.widgets import Input, Static\nclass ComputedApp(App):\nCSS_PATH = \"computed01.tcss\"\nred = reactive(0)\ngreen = reactive(0)\nblue = reactive(0)\ncolor = reactive(Color.parse(\"transparent\"))\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nInput(\"0\", placeholder=\"Enter red 0-255\", id=\"red\"),\nInput(\"0\", placeholder=\"Enter green 0-255\", id=\"green\"),\nInput(\"0\", placeholder=\"Enter blue 0-255\", id=\"blue\"),\nid=\"color-inputs\",\n)\nyield Static(id=\"color\")\ndef compute_color(self) -> Color: # (1)!\nreturn Color(self.red, self.green, self.blue).clamped\ndef watch_color(self, color: Color) -> None: # (2)\nself.query_one(\"#color\").styles.background = color\ndef on_input_changed(self, event: Input.Changed) -> None:\ntry:\ncomponent = int(event.value)\nexcept ValueError:\nself.bell()\nelse:\nif event.input.id == \"red\":\nself.red = component\nelif event.input.id == \"green\":\nself.green = component\nelse:\nself.blue = component\nif __name__ == \"__main__\":\napp = ComputedApp()\napp.run()\n
compute_color
changes.#color-inputs {\ndock: top;\nheight: auto;\n}\nInput {\nwidth: 1fr;\n}\n#color {\nheight: 100%;\nborder: tall $secondary;\n}\n
ComputedApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a0\u258e\u258a0\u258e\u258a0\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the compute_color
method which combines the color components into a Color object. It will be recalculated when any of the red
, green
, or blue
attributes are modified.
When the result of compute_color
changes, Textual will also call watch_color
since color
still has the watch method superpower.
Note
Textual will first attempt to call the compute method for a reactive attribute, followed by the validate method, and finally the watch method.
Note
It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when any reactive attribute changes.
"},{"location":"guide/screens/","title":"Screens","text":"This chapter covers Textual's screen API. We will discuss how to create screens and switch between them.
"},{"location":"guide/screens/#what-is-a-screen","title":"What is a screen?","text":"Screens are containers for widgets that occupy the dimensions of your terminal. There can be many screens in a given app, but only one screen is active at a time.
Textual requires that there be at least one screen object and will create one implicitly in the App class. If you don't change the screen, any widgets you mount or compose will be added to this default screen.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nM1Ya0/jOFx1MDAxNP3Or6i6X3YlXGKOY8fxSKtcdTAwMTXPpSywo1x1MDAwMVxyj9VcYrmJaT3Na1x1MDAxMpfHIP77XqdMXHUwMDFlbVxiZVx1MDAxN0ZEUZv4OtfX1+fc4+R+pdfr67tU9j/0+vLWXHUwMDE3oVxuMnHTXzXt1zLLVVx1MDAxMoNcdFx1MDAxN/d5Ms38oudY6zT/sL5cdTAwMWWJbFwidVx1MDAxYVxuX1rXKp+KMNfTQCWWn0TrSsso/8P8XHUwMDFliUj+niZRoDOrXHUwMDFhZE1cdTAwMDZKJ9lsLFx1MDAxOcpIxjpcdTAwMDfv/8B9r3df/NaiXHUwMDBilIiSOCi6XHUwMDE3hlp4njvfepTERaiUIVx1MDAwN3PqkbKDyrdhMC1cdTAwMDOwXkHAsrKYpn56sbN1pD45eiNcdTAwMWKyfNP5Nvy6d1WNeqXC8FjfhbM8XGJ/PM1kZc11lkzkqVxu9Fx1MDAxOOz2XFx7+VxcnkBcbqqnsmQ6XHUwMDFhxzLPXHUwMDFizySp8JW+M21cYpWtXCJcdTAwMWVcdTAwMTU+qpZbk1x1MDAwMeJamHmO5zpcdTAwMGV1XHUwMDEwqc23cECY5VLsXHUwMDEwh9G5mLaSXHUwMDEw1lx1MDAwMGL6XHUwMDA1XHUwMDE1R1x1MDAxNdVQ+JNcdTAwMTGEXHUwMDE2XHUwMDA3VVx1MDAxZlx1MDAwZvvcrs335sdMa1x1MDAwM46lXHUwMDFhjbVpxNjyXHUwMDEwcT1GZ75r+ZBF/m3P5pRcdTAwMTKMcWkxI6aDoFx1MDAwMMKX+fyNRZY+5qmfm5tatCbQnXlcdTAwMTTVkVRbY+dcIkv5vlx1MDAxYVxmvk7GfyX88CxcdTAwMWRcdTAwMGZcdTAwMGVLX1xy2Gl5q/ul4WG1y+2Ze1x1MDAxMm1cdTAwMGUv7evp9v6BPls7+8jRfrtbkWXJzfN+XHUwMDFiUawuO5HK7eNVlchpXHUwMDFhiFx1MDAxOfZt10XE5sjjXHUwMDBl4aU9VPFcdTAwMDSM8TRcZqu2xJ9UdFmpxbtA0kacdYba5CmG2thQXHUwMDE0XHUwMDEw4i1N0e7le69cdTAwMTSldidFObeAXG6GLP+HoTpcdTAwMTNxnopcZljQwlLWxlK+wErmeraDXFxcdTAwMWK9Piu7kMihOr1cdTAwMDSJ1YInsT5W31x1MDAwYjS5XHUwMDE2hWKEsIsw41x1MDAxY1HW6LUrXCJcdTAwMTXeNdawgCxEvnMrojSUXHUwMDFiafrrb/VcdTAwMTTnXHUwMDEyXCIpXFyTxjNcdTAwMWKhXHUwMDFhXHUwMDE5aPd9mJvMXHUwMDFhqNdcbkSu7Fx1MDAxMKkgXGJrXGL0IVx1MDAxMFx1MDAwMT6zwTKCk2RqpGJcdTAwMTGetMXZScZM+nqGxVx1MDAxNkZS+qRmYlx1MDAwNCDkUJXdpVx1MDAxOXn+PdGXXyfDk+PRwblzQsefkvPLd89IXHUwMDE3W8hlhHheXHUwMDFiI1x1MDAxZNuxXHUwMDEwI9h+U0pSukhJj0GlmFx1MDAxM+tHalx1MDAwMqRcdTAwMTHFXHUwMDFlcV+fml3KXHUwMDE27MfnQ0rOXHUwMDBmtlx1MDAwMrw33tldu9zDn9+jYM78nu5/vr45INuHXHUwMDA3XHUwMDE5XHL+vMNTTLbdV/CLT4PB3u7EP/Q2iH1cdTAwMTKFf+/EXHUwMDE3ozdcdTAwMTX49sS/QOCZkVZe7a/eSOBcdPXmW3+UXHUwMDEzwinUYUKX34J3o+3dVlx1MDAxM9ZZTVxisZhdaNzbXHUwMDE1XHUwMDEz0lJMsDNfREBcdTAwMWFhXHUwMDE3wp2fKu8vx2GbvGPUaO2Q82M/kzJ+SspZo/+rSfkzMjgv5WWMnZSbVZJcdTAwMTbOMfxcdTAwMTTlQCZAv+FcXF7Bu0vxO+Wc43BcdTAwMGJe7lx1MDAxMXNaOYdcdTAwMTm1XFzOjYJcdTAwMTNujjdjXHUwMDFlslxid5vkLlx06Fx1MDAxMIsz7FJcdTAwMTcvyLlcdTAwMDebXuDGf9loXHUwMDE3wf1sJuZaZHpTxYGKR2CslFxm2OhPzbhryEKO7VLCoVx1MDAxNlKOXHTyylmb6YnU7D0tXHUwMDAyckBcdTAwMWPYg1x1MDAxYYxWr5+98kNQ19b4sXMpqX1cdTAwMTlcdTAwMDfPXHUwMDA2hThUX8Tg1Vx1MDAwME7KmLdcdTAwMTBcdTAwMTW24LWh2HVcdTAwMTXfKmyHPVx1MDAxNVY7zVx1MDAxN8JcbkWut5IoUlx1MDAxYdL/MVGxnk9zkc9ccsPvsVx1MDAxNMG8XHUwMDE1plW3zVx1MDAxN4LUeGzu3KqrXsWU4qa8/rLa2nttXHUwMDExweaoYbfysFL/NzuQwmdfpOmxXHUwMDA2pJVrXHUwMDAwYFbBY+GuJta/VvJms+Xb0lVxmDRcdTAwMTYpNCVHmundP6w8/Fx1MDAwYlxiYlx1MDAxObwifQ== ExampleApp()Screen()"},{"location":"guide/screens/#creating-a-screen","title":"Creating a screen","text":"You can create a screen by extending the Screen class which you can import from textual.screen
. The screen may be styled in the same way as other widgets, with the exception that you can't modify the screen's dimensions (as these will always be the size of your terminal).
Let's look at a simple example of writing a screen class to simulate Window's blue screen of death.
screen01.pyscreen01.tcssOutput screen01.pyfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Static\nERROR_TEXT = \"\"\"\nAn error has occurred. To continue:\nPress Enter to return to Windows, or\nPress CTRL+ALT+DEL to restart your computer. If you do this,\nyou will lose any unsaved information in all open applications.\nError: 0E : 016F : BFF9B3D4\n\"\"\"\nclass BSOD(Screen):\nBINDINGS = [(\"escape\", \"app.pop_screen\", \"Pop screen\")]\ndef compose(self) -> ComposeResult:\nyield Static(\" Windows \", id=\"title\")\nyield Static(ERROR_TEXT)\nyield Static(\"Press any key to continue [blink]_[/]\", id=\"any-key\")\nclass BSODApp(App):\nCSS_PATH = \"screen01.tcss\"\nSCREENS = {\"bsod\": BSOD()}\nBINDINGS = [(\"b\", \"push_screen('bsod')\", \"BSOD\")]\nif __name__ == \"__main__\":\napp = BSODApp()\napp.run()\n
screen01.tcssBSOD {\nalign: center middle;\nbackground: blue;\ncolor: white;\n}\nBSOD>Static {\nwidth: 70;\n}\n#title {\ncontent-align-horizontal: center;\ntext-style: reverse;\n}\n#any-key {\ncontent-align-horizontal: center;\n}\n
BSODApp \u00a0Windows\u00a0 An\u00a0error\u00a0has\u00a0occurred.\u00a0To\u00a0continue: Press\u00a0Enter\u00a0to\u00a0return\u00a0to\u00a0Windows,\u00a0or Press\u00a0CTRL+ALT+DEL\u00a0to\u00a0restart\u00a0your\u00a0computer.\u00a0If\u00a0you\u00a0do\u00a0this, you\u00a0will\u00a0lose\u00a0any\u00a0unsaved\u00a0information\u00a0in\u00a0all\u00a0open\u00a0applications. Error:\u00a00E\u00a0:\u00a0016F\u00a0:\u00a0BFF9B3D4 Press\u00a0any\u00a0key\u00a0to\u00a0continue\u00a0_
If you run this you will see an empty screen. Hit the B key to show a blue screen of death. Hit Esc to return to the default screen.
The BSOD
class above defines a screen with a key binding and compose method. These should be familiar as they work in the same way as apps.
The app class has a new SCREENS
class variable. Textual uses this class variable to associate a name with screen object (the name is used to reference screens in the screen API). Also in the app is a key binding associated with the action \"push_screen('bsod')\"
. The screen class has a similar action \"pop_screen\"
bound to the Esc key. We will cover these actions below.
You can associate a screen with a name by defining a SCREENS
class variable in your app, which should be a dict
that maps names on to Screen
objects. The name of the screen may be used interchangeably with screen objects in much of the screen API.
You can also install new named screens dynamically with the install_screen method. The following example installs the BSOD
screen in a mount handler rather than from the SCREENS
variable.
from textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Static\nERROR_TEXT = \"\"\"\nAn error has occurred. To continue:\nPress Enter to return to Windows, or\nPress CTRL+ALT+DEL to restart your computer. If you do this,\nyou will lose any unsaved information in all open applications.\nError: 0E : 016F : BFF9B3D4\n\"\"\"\nclass BSOD(Screen):\nBINDINGS = [(\"escape\", \"app.pop_screen\", \"Pop screen\")]\ndef compose(self) -> ComposeResult:\nyield Static(\" Windows \", id=\"title\")\nyield Static(ERROR_TEXT)\nyield Static(\"Press any key to continue [blink]_[/]\", id=\"any-key\")\nclass BSODApp(App):\nCSS_PATH = \"screen02.tcss\"\nBINDINGS = [(\"b\", \"push_screen('bsod')\", \"BSOD\")]\ndef on_mount(self) -> None:\nself.install_screen(BSOD(), name=\"bsod\")\nif __name__ == \"__main__\":\napp = BSODApp()\napp.run()\n
screen02.tcssBSOD {\nalign: center middle;\nbackground: blue;\ncolor: white;\n}\nBSOD>Static {\nwidth: 70;\n}\n#title {\ncontent-align-horizontal: center;\ntext-style: reverse;\n}\n#any-key {\ncontent-align-horizontal: center;\n}\n
BSODApp \u00a0Windows\u00a0 An\u00a0error\u00a0has\u00a0occurred.\u00a0To\u00a0continue: Press\u00a0Enter\u00a0to\u00a0return\u00a0to\u00a0Windows,\u00a0or Press\u00a0CTRL+ALT+DEL\u00a0to\u00a0restart\u00a0your\u00a0computer.\u00a0If\u00a0you\u00a0do\u00a0this, you\u00a0will\u00a0lose\u00a0any\u00a0unsaved\u00a0information\u00a0in\u00a0all\u00a0open\u00a0applications. Error:\u00a00E\u00a0:\u00a0016F\u00a0:\u00a0BFF9B3D4 Press\u00a0any\u00a0key\u00a0to\u00a0continue\u00a0_
Although both do the same thing, we recommend SCREENS
for screens that exist for the lifetime of your app.
Screens defined in SCREENS
or added with install_screen are installed screens. Textual will keep these screens in memory for the lifetime of your app.
If you have installed a screen, but you later want it to be removed and cleaned up, you can call uninstall_screen.
"},{"location":"guide/screens/#screen-stack","title":"Screen stack","text":"Textual apps keep a stack of screens. You can think of this screen stack as a stack of paper, where only the very top sheet is visible. If you remove the top sheet, the paper underneath becomes visible. Screens work in a similar way.
Note
You can also make parts of the top screen translucent, so that deeper screens show through. See Screen opacity.
The active screen (top of the stack) will render the screen and receive input events. The following API methods on the App class can manipulate this stack, and let you decide which screen the user can interact with.
"},{"location":"guide/screens/#push-screen","title":"Push screen","text":"The push_screen method puts a screen on top of the stack and makes that screen active. You can call this method with the name of an installed screen, or a screen object.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVcXG1z2khcdTAwMTL+nl/h8n3Zq1xu2nnr6Zmturpcbk5cdTAwMWN7XHUwMDEzx/Fb/HK35ZJBgNaAMFx1MDAxMrbxVv779chcdTAwMGVcdTAwMTIvXCJgMEtyVGyMRkitmaeffrpnJn+92tjYTPqdYPO3jc3gvuI3w2rXv9t87Y7fXHUwMDA23TiM2tQk0s9x1OtW0jNcdTAwMWJJ0ol/+/XXlt+9XHUwMDBlkk7Tr1x1MDAwNN5tXHUwMDE49/xmnPSqYeRVotavYVx1MDAxMrTif7vfn/xW8K9O1KomXS+7SSmohknUfbxX0FxmWkE7ienq/6HPXHUwMDFiXHUwMDFif6W/c9ZVQ79cdTAwMTW1q+npaUPOPLSjRz9F7dRUY5hgWqFcdTAwMWGcXHUwMDEwxm/pZklQpdZcdTAwMWFcdTAwMTlcdTAwMWNkLe7QJjQv9MPZWalafuj/WTqqmebJXHUwMDE3nt21XHUwMDE2NptHSb+Z2lx1MDAxNEf0KFlbnHSj6+A0rCZccmrlI8eLvtWNevVGO4jjoe9EXHUwMDFkv1x1MDAxMiZ9d4yxwVG/XU+vkVx1MDAxZLmnT4prjzFccmgtN1x1MDAwMCx7WPd9oYzHuFVWgFx1MDAxMlpxgSOGbUVNXHUwMDFhXHUwMDA2MuxcdTAwMWYsfWWmXfmV6zrZ165m53Dw/aua1tlZd09cdTAwMGasLHjKKIkwaGpcdTAwMDRhvZE4I4z2XGZyZvOtcZCOXHUwMDAyV1ZbhsCzXHUwMDE2d9PObjWFw1x1MDAxZqP92PC7naf+2ozdh5zBztZ3o1jK4yk30sFu9fz86Fg97Fx1MDAxZVx1MDAxZX5IToKqvdy6XHUwMDFmXFxrXGJ8frdcdTAwMWLdbVx1MDAwZVq+Pv2VmdbrVP1HTHGtlWVcdTAwMTJcZkeW9XQzbF9TY7vXbGbHosp1XHUwMDA2w/To19dzg19cdFVcdTAwMDR+Qo7Qmis5O/rvoHFwvV+PXHUwMDBmPnROXHK7XHUwMDEwd1dvP/TWXHUwMDFj/YJ5wjKu6FGNNiiH4c9Re1xujVx1MDAwNcGNsoh8MfjX/CvGYIng18BcdTAwMDRYo/lqwVx1MDAxZuvz8Kh8X98qb5v7crdzqe3t4VLAb1xmXHUwMDFhLkGwZYE/XHTuk0nIXHUwMDA3XHKFyDdGXHUwMDEx+Fx0XHUwMDBmMyNfto9iXHUwMDFktMzno3q3vHuO8mRfrDnvXHUwMDFiXHSe0VJoXHUwMDAzipxcdTAwMWPsXHUwMDEw8ClcdTAwMTB4TCgujaBcdTAwMWZccrBcdTAwMTDu0Vx1MDAwMquJcdxzZsZcdTAwMDGPfFxm5lx1MDAxNH6YlIrJn4fjhbZKSJhcdTAwMDPmXHUwMDE5mqJ2clx1MDAxND5cdTAwMDQpOVxmXHUwMDFk3fZbYbM/XHUwMDA0iVx1MDAxNP9k4FGlXHUwMDFiXHUwMDA07Vxy/t/2L42wWlxy2v/MXHUwMDBmWVx1MDAxY9D93Vx1MDAwNfXwN980w7rzls1mUFx1MDAxYnajJCQpNmhOolxcXHUwMDFmV8hcdTAwMTKfLtfdrY4+UdRccuth229cdTAwMWVcdTAwMTdb9Sxv1kZcdTAwMTR7M1xigUTgXHUwMDE5/L/nzVx1MDAxNyfbd35n//D+Zlx1MDAwN45bV+clXHUwMDE5Xa+5Nyu0XHUwMDFlSoZWUawg+squ4r5P4cVcdTAwMTDgkFx0ozSSWHpcdTAwMTFvXHUwMDE2MiPMgTfnjj15s2aS5FxymiyU/uAxy1x0Nupblj3pipxZbPxCiVN41VxmJjuzgKFvrsiZ81ZNdebHbp7gzVxc5eLCqDtTTFKkj0Xm8N9z5+kjP4c7i1Fsvpg7g9RcdTAwMWVcdTAwMThtKTZLhZSaXHKrUqU8y8iVXHUwMDA1Q2lAczli2HL8XHUwMDE50JOcXiR/XHUwMDE13Vx1MDAwYsxcdTAwMDT3RuY5cVxmkqhF0082bt9it6A2inXyXHUwMDE5+Vlq51x1MDAxNHf/nkPOk0Hl7PC7STlsV8N2nVx1MDAxYTMm+VZm2J0hRqQuXFzpOSuZp4FzSYNI6Vx1MDAwNVx09ixRdV3hd5zN0mOGRFx1MDAwZadMg34o6Xo64+vAqqBd/b5N0/OvIZtcdTAwMTgpXFxOXHRccpL2s1xcXHQ7Zlx1MDAxNNmkZZrzcCYkmT5mU9OPk62o1VxuXHUwMDEz6vrPUdhORrs47cs3zs1cdTAwMWKBP8ZcdTAwMWb0TPm2UT7ouCtcdTAwMGXTevbXRuYw6YfB33+8nnh2MZbda1xmxdnlXuXf52Yyul0hkZGsZsRcdTAwMDSQucz3iGy6XHUwMDFlXUtcIjPUtYpcdTAwMGLrKkdEMyp72DTLkOhRzoeugEN5iFUjdi2HxzR6hmv6p4GowfKMTFx1MDAwNzRmXGJcdTAwMDCaXHUwMDEyaVdH4qBzicZcdTAwMTONSUtcdTAwMDJcdTAwMTJgxSSGXCLPqMsnselp61x1MDAxMGFI5ViCXHUwMDEzYl1HWZM76YnEhCdcdTAwMTEoWilwWog/l8Sml1BzNpXIKGlpXFy4pZtcdTAwMDGiVuNGecagdIqBMkgk++GHZrFSIZTT1jFcdTAwMTTPSWNTXG6F0rDRo1x1MDAwM1wi05K7bFx1MDAxNmZPsPzfXHUwMDBm3vfffLx4+yG86PcvL9tcdTAwMGbNXHUwMDAztt5cdFx1MDAxNiFcdTAwMWY8XHUwMDA1oDSjIE3cnYmhtE5cdTAwMGXCQ8s1UlJPRJZcdTAwMTNsa1ImJ7BokCBeoExeXFzLI6fjdq5cIsdz8Vx0qjBjUOT3zpjZq3kx6NPLclx1MDAxZuO9m/2y6uzU7vq4t/bwtJ6SXHUwMDAyXFzh0lx1MDAwMojhQMuN8Fx1MDAxOKN2XHUwMDEyQYyyXG47qlx1MDAwMP7eOjblXGJcdTAwMDBMw1x1MDAwYpSxp1TgrFwiPWJXXHUwMDAwTi1kXHUwMDExOEFKrTnD2UXgVrVcdTAwMWQlW5c7+zbeerhcdTAwMGXOeVJcdTAwMGab61x1MDAwZU4hPFLXRlJ4XHUwMDAyru1wdaqktYdcdTAwMTTTSGExJPDgYjLQiIrlwVx1MDAxMqkzzcCZYSueYTRBsNP7+PDQvLrQlzWZhLJ/wXJCaCz/XHUwMDE4tHx9Pe26+/H+obi77iad29Ln2t71l/v34tNcdTAwMTKuW8Wbm+B3/Vx0opCfRNt7N3uldydLuO5N+fN+VZ2etu7Ptt9WoFx1MDAwYu/fXHUwMDA0/WVV4Y1GsHJZXHUwMDFjUFSelpJcdTAwMTdcdTAwMTFcdTAwMDBSVkHaWsqZXHTg0n93/mf/rnZcdTAwMTi/3T/dub1Oro72dtY7XHUwMDBipDOMR1x1MDAxOZ5kLolcdTAwMTJsZJa1RKmFJ4hcYkmju0lYu2BcIkhZ01UwQTxBbrZ74PpqzOGVm/cmmbdSqYRcYozPXHUwMDE1jbJcdTAwMTHPSsiUT3NDbKtJoXKj7dA5g4JyhrVvXHUwMDA1Zb/T8Tq9uHFcdTAwMTmnNdxfXHUwMDFl3+TksnKupL+KsnKhbVNdsbAkI6coRZe1XHUwMDAym10oTqe8Zbhi1Y9cdTAwMWLBsoOx9ijUSlxutIYh48PB2IBcdTAwMDecXHUwMDE5amTkhsYsNvFb5IncXHUwMDEzzsW04kZxd5dcdI7JuSRSsKBpRCwl72JsJom7iVx1MDAwMiZI187vqc8vy5BuJLvnWaCQs2Omssx0ibeRL8tYo5hQSlCqRfwhclXNp1xuXGJ4Wlxu5UaUXHUwMDEz/VEnPp1QUJVcdTAwMTl+ilx1MDAxZqg0UoyotHVcZkvZ9V7l3+emXHUwMDEzZVxuQztHkETtYlx1MDAwZXE/XeusKaFcdTAwMDBcdTAwMDePUkpcdEaCXCKVn027pISiSFkjXG5cdTAwMDDNgVx1MDAxOGexuapcIkJcdTAwMTFcdTAwMWVQnm+UIHfgUptcdFx1MDAxYZ9z7qFby6WsK7vz3ErHgdJHXHUwMDFhM3jWcqpF+IS6jWfG/I18UlwiQrGCuVVBjjGktpg765FQKHhcYkB3krbaMGV/UkIpRJR7jWNpWXxC2UAhnyhcdTAwMDZMXG4+R6F1eq63pnxcIjR6qJTRmlx1MDAxMiPQw3RcIoT1pFx1MDAxNVppXHUwMDFhXHUwMDE4o5larJJVLFAsY5xuT9lcbpOcTZj6pifxSEXRXHUwMDE1LKUsbv52lE9QXHUwMDExMii1y1x1MDAxYVZCJ0ipy0vOXHUwMDFhzSFPSJxJKy2iXHUwMDA2olxuXHUwMDFh1DE6sVx1MDAxZadcdTAwMGUkkyl40Kjit6nXn41OXG5cdTAwMDGVNo5BaU46mbbEu3ihK7FcdTAwMWKSaJqjMv7pTInTqFxcOjg9tlx1MDAwZqdYPWbnX2prXnwkXHTmKTBGK0U9j7lcdTAwMTn5lE8096hcdTAwMDeMJFVohFx1MDAxNHKx0sNLbHBAXHUwMDEy+EOZ2ErKj0dcdTAwMTf7b5p7R8FJr1x1MDAwM+XD6qfT3avP0bLKbujkRcbcL1h6l4XhVEmwXHUwMDFhQc++kKxxU2p3XHUwMDBmto7fn15Hl5dxq9xTbz+sO/qBMlx1MDAxZlx1MDAwNsg5PaxQo/tcdTAwMWIs86RbdUSpXHUwMDEx0zQq6zUvxLlgXHUwMDE0RC0+I4IuMDGElph4XHUwMDFlQf5cXHSiLlxctmyF0qTF5+Dmz7+fd9iX3bPem/P6Wb9S7d10+s8qRq1cdTAwMTSdgjRcdTAwMDJ1ttRcdTAwMWElz3XH41x1MDAwNZRcdTAwMDdcXKBcdTAwMTKkXHUwMDExXHUwMDAw0C6WOy59Yki4WXVjV7wrYYF5oe9cdTAwMTKzJI3CzNKIuWg+RPDiJMcyRVx1MDAwMlxi7ezbzirJznZ8dVx1MDAxYpr7sniItpvxzVFcdTAwMTfWPckxaDw3XHUwMDExQsLEas1gWJWUXHUwMDA0+Vx1MDAwNaBUaaqthF5s31lcdTAwMTHy81x1MDAxYlCmrNjnXGYkokSxYqSrOHp7v7d3wpPjg6D8caeCeLK/XHUwMDE0pFPmzjVH8ayqy1wiS/blWi7Zl1x1MDAwYi/ZXHUwMDA3LC6DOpaXgs1cdTAwMTHKplx1MDAwZvx6TnFazTyBXHUwMDAyJMFcbpRcdTAwMWTZRq09bi0pXHUwMDE5Zlx1MDAwNXk7vkwgk5LuopVA6TJcdTAwMWGYtFx1MDAxZMcqz1xiSomsUW5cdTAwMTNcdTAwMGVcdTAwMWL3dVx1MDAwMaBcZua3XHUwMDFmr2Kp69xxJ2fHTEWL6UFiI1+0oFx1MDAwNF1cdTAwMGLLSSy7moTiuZNcdTAwMDZLXVx1MDAwNWjDnO3I3L7EpzN+tqJFqVx1MDAxMFHuNYal7HKv8u9zq1x1MDAwM1moio3klKHjXHUwMDFje/mOXHUwMDBm3vdKeHi6/eXjx/pd/fL0T41FS01cdTAwMWJ+pdHrXHUwMDA266CL0Vx1MDAxNYRAWMNcdTAwMTQoPZK1XHUwMDAxqWYrgCSElJbU68uIg9zE1jRtXHUwMDAwlMhz58Gr1Vx1MDAwNrUusWjnXGaO3zW2XHUwMDBmv1x1MDAxY4ja4bugM5s2eD3tui9a9rDWTZCtTHM8bqldXHUwMDA3nfFkyfO0XHUwMDA1isL/ocWme+D4XHUwMDFjM6zTcTNcdTAwMTdcdTAwMWasUFxcaItcdTAwMWVBR9Cjulx1MDAxZPt2ZFx1MDAxYo1cdTAwMTJcdTAwMWVoXHUwMDA13M2aUFx1MDAxZbtYXHUwMDExpzBZXHUwMDAwj9PFXHLnkrJxJifJXHUwMDBiro1cdTAwMDeIRksrXHUwMDA1XHUwMDEy6Mc20qCbu2GYXHUwMDFisZXMiTzb8WaUXHUwMDE308PMxvCuXHUwMDE1t0NDS+pAt0JcdTAwMDAmLNrgdJLmMt1qwMBcdTAwMDD8rFx1MDAwMqNcdTAwMThT7lVcdTAwMWGH05xcdTAwMTKjkFM0K+RcdTAwMTTQbjGylLNrjOkxY105XHUwMDA1085329qY0E5VjXCKJVx1MDAwNWKYNcYoXHUwMDE0L7Qm2yjPpYaoLaOsg7p9nFLIXHUwMDBlt1KNJCjRXHUwMDFiSW4xXoejhEpxQlHWef9/nFwiaFx1MDAxOK2rXHUwMDE4XHUwMDE56lx1MDAwYs3HOUW6rXCIlIQq1EaQfpzOKUVWTZ9cdTAwMDJcdTAwMWOxijFlJLOKI1GZlVx1MDAxM5jOc5UwTckxxVx1MDAwNUvZxI+9Qa9cdTAwMTDP7lVcdTAwMWGHclx1MDAxMZ29erqDW/x6lFx1MDAxMO5cdTAwMDZcdTAwMDNC0Fx1MDAwZatPSjB7zM3bMLgrT5qRSV9OeKVcdTAwMWTquChwXHUwMDBm+9fXV1//XHUwMDA3mNhRliJ9 Screen 1(hidden)Screen 2 (visible)app.push_screen(screen3)Screen 3 (visible)hidden"},{"location":"guide/screens/#action","title":"Action","text":"You can also push screens with the \"app.push_screen\"
action, which requires the name of an installed screen.
The pop_screen method removes the top-most screen from the stack, and makes the new top screen active.
Note
The screen stack must always have at least one screen. If you attempt to remove the last screen, Textual will raise a ScreenStackError exception.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXOtT20hcdTAwMTL/nr+C4r7sVcWz0z3vrbq6XHUwMDAyXHUwMDEyXHUwMDEyQnhsyObB3VZK2MLW4ddaMsZs5X+/XHUwMDFlhSDJQorBxnHiXHUwMDBmXHUwMDE4a+RRa+bX3b9+yH8/2djYTKbDcPO3jc3wqlx1MDAxOXSj1iiYbD71xy/DUVx1MDAxY1xy+jSE6ed4MFx1MDAxZTXTMztJMox/+/XXXjC6XGKTYTdohuwyisdBN07GrWjAmoPer1FcdTAwMTL24n/7v4dBL/zXcNBrJSOWXaRcdTAwMTG2omQw+nKtsFx1MDAxYvbCflx1MDAxMtPs/6HPXHUwMDFiXHUwMDFif6d/c9K1oqA36LfS09OBnHhazFx1MDAxZT1cdTAwMWP0U1FBXHUwMDBiLZW28vaEKH5GXHUwMDE3S8JcdTAwMTaNnpPAYTbiXHUwMDBmbcrRNNFcdTAwMDae897Fx119cmhcdTAwMGZOxyfZVc+jbvckmXZTmeJcdTAwMDHdSjZcdTAwMTYno8FF+D5qJVx1MDAxZH/pmeNV31x1MDAxYVxyxu1OP4zjwndcdTAwMDbDoFx1MDAxOSVTOqb47cGg306nyI5cXNGnXHUwMDA2cs6M0Vx1MDAxNqTiXHUwMDEy6G7V7fiXXHRcdTAwMDQz1lx1MDAxOFx1MDAwNUJcdTAwMWEhXHUwMDA1qFx1MDAxOcl2XHUwMDA2XdpcdTAwMDeS7Fx1MDAxZjx9ZbKdXHUwMDA1zYs2XHTYb2XngFxugrPz7JzJzf1Kp5i0Uphs+k5cdTAwMTi1O4nfIauZNcBdfjRcdTAwMGXTTXCgpJNaZlvkrzjca6Vg+HN2XHUwMDE1O8FoeLNam7H/kJPWXHUwMDBi+nxcdTAwMTZJeTTl9lm8grPdXHUwMDEwYKe1v/3X85NcdTAwMDP5+2CrfztXXHUwMDAxesFoNJhs3o58vvkvXHUwMDEzbTxsXHUwMDA1X1x1MDAxMFx1MDAwNVpLa43TXHUwMDEyTVx1MDAwNspu1L+gwf64282OXHKaXHUwMDE3XHUwMDE5XGLTo5+f3lx1MDAxYvp0mSroo+OO0KD03NBcdTAwMGbHU3ux39vnfHz+ctLeiyb6hfue0Fx1MDAwN/5N7IPTTFx1MDAxOSNRc1x1MDAwZVx1MDAwMoyyXHUwMDA17EuBXGalQYKeddo4vlx1MDAxOPbPgzPO1Vx1MDAxMrGPQipwlq9cdTAwMTb7vd45n2zx5NlhNFxmwz9eXHUwMDFlbb86iJeEfVx1MDAwYlxccG6Whf0kvEruXHUwMDAyvkVdXHUwMDA1fFx1MDAxMNZx5NLh3Mh/d941l1fDy5fT3taHwfjj8PiF2F1v5CMqprRBXHUwMDA0dEZ6XHUwMDBiWlx1MDAwML7lwMhcdTAwMDRJclxi1iHkrMBDcG+c4udYxj1wW1x1MDAwNryBWZhrgdL7pp/IxDtcdTAwMGJK2PvAPEPToJ+cRNepjbaFo7tBL+pOXHUwMDBikEjxT1x1MDAwMp40R2HY34D/9n/pRK1W2P9nfsfikK7vJ9TFb251o7bXls1ueF5UoyRcIlx1MDAxZXY7nFxmcmvcJElcdTAwMDKabrTXmr2jwShqR/2g+7Zaqlpt/rLMd6gzUVx1MDAxM5w9nNNnIHojxPz6XFy/8/fQZ5zF5uPps3HMSFx1MDAwMGm4pXdbVGfjJFx1MDAwM81cdTAwMWRcdTAwMWGU6EjjXHUwMDFmRZ1cdTAwMWQyrogwS2MsR9TuXHUwMDBl5XZMIzk6KVFcdTAwMDHXOlx1MDAwM/BXlyaFv4VcdTAwMDeoeipkjao/ijLGSTBKtqN+K+q3aTCzXCJfQ5K9OVx1MDAxY0Sqvs2xl5IzhVxcS8FpI5UgL1x1MDAwNLmT2sHQLyFcdTAwMDMgTqLJZKNVTtibXHUwMDEzPt9cblx1MDAxNfZb31x1MDAxNqk+UMmJ1OBcZml5nPV7pjhqilx1MDAxM0pCSaZcdTAwMDVIXHUwMDA3nITiTlmnSlJ1gzjZXHUwMDE59HpRQmt/PIj6yexcdTAwMWGni7nldbxcdTAwMTNcdTAwMDYl40F3lVx1MDAxZps1XHUwMDA2Qz9j0aZn/21k2pJ+uP3/z6d3nt2ohHI6WkJxNt+T/PtcdTAwMDNcdTAwMTi5sJWGXGYtqVx1MDAwNlx1MDAxMZdM8b/JyM9G2NpcdTAwMGKuw/1nW1x1MDAwN89f9LWNIVhvXlwinGOOTFx1MDAxNVxiNN5y68xcdTAwMTJ8XHRGLeNcdTAwMDLAOeK+ZCaEmJHsIcGo1suj5ESVkKJcYs5cdTAwMWaBrNRYMECFuIqIkYLtSkeLZLhcdTAwMDVwNb+jPdzpXHUwMDFjXGZfXHUwMDFjvdtcdTAwMWRcdTAwMWNfj9xhcjQ+XHUwMDFmivVcdTAwMDao5IJcdJ9cdFGWdFWqYrJEXG7NuOPGXHUwMDEyQqVcdTAwMDZcXFxmnsuOXHUwMDE3SVx1MDAxZaE4mTS7fHDWMenXZ8rufYyvOsPx+MNWdPomuVx1MDAxOMllXHUwMDA1jIQ3yHG7x4O+XHUwMDE1qlxu+k6AUkS65jfN76eHb9o9t3+0r/76NH32abtzKI+WivxWXHUwMDEwd8IlQ98xolx1MDAxZYJcdTAwMTONXHUwMDA0YpGuXHUwMDAwfaGQXHUwMDExKZFcdTAwMDY5cDBcXC9GMi02XHUwMDFkhGqZ6CdcdTAwMTNpSTS+4nSJXHLDl+PX19fds1P96VxcJJGYnvL50P+0bt5Y7U+v4+3jyejgdO/Vofj0x9SeLWHe8+H7ydvG5Ni+v+7Fp1x1MDAxN83wI3ZcdTAwMGaWMC9cdTAwMWab//VO3mLyprl9XHUwMDE1NT/uvlx1MDAxMc1oWfE0J+S5pTnAqrSRrktcdTAwMWKRWjiKvOzcNuAsnuKOONpcdTAwMDXV3u6Mmq33h+r6dL3DTGlcdTAwMWSzSkvNOZleNZMuXHUwMDA1QaNOcrKERJ3JXHUwMDE3zlxudj9cdTAwMTNA6npcdTAwMTbewc1ErqZxq/myrO9CWqIrhj+Ct6tBolx1MDAwMan1fZCYbXiW2Vx1MDAxMVx1MDAxNOWlgYfnwDZcdTAwMTdJXHUwMDE38jzZVb7meYLhkFxyXHUwMDA3w09xmln55e4sj9CF7z12lqckU63qVeZ4dHVkROxXXHUwMDEz71Zyft2rN57L0L1H8L8gmERUXHUwMDE0ZiOZOzFTq7CGIVx1MDAxN9pcYqPJXHUwMDE3w2Ip2yrd40RvXHUwMDAxkVgwXHUwMDAx06E1QpZ1XHUwMDExuPGCmpRvXHUwMDAyXHUwMDE5XHUwMDA0V9ZNQjUpTC7JvopEXHUwMDBmcFqcx0z01NO6jUJWxVEsXHUwMDBmjmyoRE2rmUsx3CRVXHUwMDE0U1x1MDAxNFx1MDAwMyuOdFx1MDAwMtpvZnqKt/EjZVtqQJWOl/GUTfkk/35vo2JyRYVZo0JwXHUwMDAx8iD3yFx1MDAxYtczp/U0Ko5LZi1ZXGZDsaySZtaoXGJmXHUwMDA0V4pWnlxmXHUwMDBizEZcdTAwMWLLMipoOWghfSmfLpIrRuVsimWSglx1MDAwZetcdTAwMWNcdTAwMTiCXG6WykSgjZXOc5PV2lx1MDAxNKCYXCJbte9oU4DRXHUwMDBl0PIopykqJlx1MDAxZVJOXHUwMDFlXHUwMDAzZ8QmrNTkQoThXFyan9SmVEPKv1x1MDAxYWU0LcuiXHUwMDE08l+zXHUwMDE5XFyurZS+nDm3SalPnaynSdHKMCO55lx1MDAxMiVIk+tk8d/XXHUwMDAyXHUwMDE4+X3iMd6x4YItXHUwMDE1VSaFdMFcdTAwMTkjyWhJWnXSiez+b02KU1xmpXbKcsFccqrcrtxYXHUwMDE0dPRdhfpcdTAwMDFcdTAwMDHEQiSFK5fJ8nCDMqt8P4FaN6r3NVx1MDAxZC5t6T3Vuq5XSldTXHUwMDA1Tv5cdTAwMTM06vmpgn6m8FX0cnLZfvfhQrevT49+j79rn+C31ZrIMzBcbj6QTCatr5lpXHUwMDE5UUAkTSluXHUwMDFkUSaXL8ivR2VGeTOvVptcZlhdJ59cdTAwMTbVXlx1MDAwN4Rwvk1s/ui4cbU/tn+1wsvOycdLXHUwMDExTl87PNhZe3Rq5uNcdTAwMDdB2DMobLFwSG6XkS9cIuRcbq2EQbdcdTAwMTA6l16YcUIgUTfzgHB4kdR0I57s7CB2I9lcdTAwMTZcdTAwMWZa4z+O48nFslKyVlpuga9cdTAwMDD7NuegS1xy3IAkjJyfcMX93dFe7+L5azHF8L1otTvDg9Z6Q79cdTAwMDHWMrCKmKXTzmlcdTAwMGVcdTAwMDXoXHUwMDBiKYlcZiuL3KLv6l1cYvlfyjLLLElcdTAwMTJHXHUwMDA0XHUwMDEy7CcpypxtPT9/XHUwMDFmbPHD9rv9t1x1MDAwN83rQZDE46X1xqK0uDSNqlxmYWpcbp2gfHVbajN/W3h92Wd9I1x1MDAxOFxupYVcdTAwMDRSXHUwMDFiXHUwMDAxekafiOiQWfHxi6b9WKzOWVx1MDAxZMBcdTAwMThFXHUwMDExlKPolYiLJZ9VVizincxnZZRcIq/mfLmjpF5cdTAwMTTqy2Jss5pcdTAwMTCG1P1BNZBl50Q448AlxXBKXHUwMDE5jeA4N3d2r/neNk3RKsWlZJBuTvjZklwijWpQfVx1MDAxOS7hKZvxSf79vnVTqWD26C071cjR3ec5k6vXoVx1MDAxY530XHUwMDFiz/u6od1+6/CV6thcboPSXHSanfEoXFxcdTAwMDNcdTAwMWZN+GJcdTAwMDZ89yTB0T/jUEy0XHUwMDFhJ1x1MDAxOHF0X1x1MDAwZVx1MDAxMMpavlD1Jlx1MDAxOVx1MDAwNf14XHUwMDE4jEhd7jAsucxpTdO9XHUwMDE0qJw1YsWM9DGfLfHZXHUwMDAy41bedI9r2XSPizfdc1fdXHJBtsOSf7zHk5P1O38vtV5dP0SDXHUwMDAySlx1MDAwNspYIynYXHUwMDE3zuqZxnuLjLyhptPQmVxccWXpWlxyilx0XHUwMDAwKYiekW1x7q5cdTAwMTJcblx1MDAwMpM+XHLCLYVMoPJcdTAwMWRaN3TBu1x1MDAwN4fwkCzJXCJ0gTyzclx1MDAwZtHLOelCvcvYKDa7k+8z5CONTOvo5bIscEaLJITl3JdR9Ndi5D1cdTAwMWLw61x1MDAxZpcsUFx1MDAxOFx1MDAxMCCNpZ3zXHUwMDE5XHUwMDAyJY0uyWRcdTAwMTjSgCR648jGXHUwMDE5xJJMP1x1MDAxMk+pXHUwMDA2s381yjheXHUwMDE2TVx1MDAxMZWJXHUwMDA0XHUwMDA0n2+mUHV+nlwiPlxcyNa1fHn54tnhm9Z04sK+qirdrFx1MDAwZk9cdTAwMDHBmSFoI7FD32ePRYOGqH1cdTAwMDXRKCAuY6VSXHUwMDBipVx1MDAxM75BVO5o8ypcdTAwMTNcdTAwMTWyXHUwMDFjUlx0IdWPw1Se1s37mFx1MDAxOVx1MDAwNLK0Wq+eXHUwMDAxXHTiXHUwMDFhl1FcdTAwMWOddcN1okBcdTAwMDWxXHUwMDFlxoFcdTAwMTRWXHUwMDA2NuAsoZK4/vxN4fVbv65cdTAwMTTIP/IglFTeLKBRYqYtXHUwMDFjkFx0Ylx1MDAxNVx1MDAxNoUherTYXHUwMDAzO7X2XHUwMDAyXHUwMDFkOUokj01Ow3IpsyvdWlx1MDAwZu3du/TJXHUwMDAwXHS0MVjOl/jSXHUwMDAxRaSr7iF5sFrOSYDqfVGRXHUwMDAwgdCeXHUwMDA3Ulx1MDAxNFxuXHUwMDE2yeHlclx1MDAwNF9cdTAwMTmQZNJvJaBcdTAwMTJcdTAwMWOMeegziPW59qJUXFxcbmPJISljtNEu94NcdTAwMWa3YllmNHFcdTAwMDPaWue4MkKWpPqRSFAlnP2rXHUwMDA05CUxIFGdqCFhUEtcdTAwMGbXue3ZyfXlKzWNj6ZHL+LB5OPWZft479O6MyCK1ohcdTAwMDGh4MSCjK9ZXHUwMDE0XHUwMDEzNUJcdTAwMThGXHUwMDExgnRElFxibvlfUPlOmVx1MDAxYSBcdTAwMWHmIFx1MDAxN1x1MDAxNfzotUPHXHUwMDA1t8LlbmiFmZo15Cm4OE8xrlqvwVx1MDAxN1xyrTXzN6/Ub/2a8lx1MDAxNFxuKsH/mFx1MDAwZjk3w41cdTAwMTC5eCFtXHUwMDEwXHUwMDAwzdAnckxcdTAwMWHai8VcdTAwMWXdrNVr7dtotPDP9/tcdTAwMDdcdTAwMDfhXHUwMDBlLdeGOeu7XHUwMDE2NddWlVx1MDAxYdP8U2zKoV3l7yQspJVz0pR6h7FRKOs4RS9OXHUwMDFiXHUwMDA1WphcXGfyRpZcdTAwMTORXGLCao1cdTAwMWH8XHUwMDEzYuWfJJiLpNT3wszIRFxcSCvyyEJq8lx1MDAxZqIkXHUwMDEzkmfx7Wv+4XY0d7X0/0hcdTAwMTSlXHUwMDEyyOlgXHUwMDExwlVcdTAwMDTlyc3s/jGhk4TwdrtcdTAwMTVcdTAwMDTpqHVjyrNb3LyMwsn2XT056cvbx3QxvVx1MDAxNVxu/Y3+/fnJ5/9cdTAwMDPV4pXXIn0= Screen 1(hidden)app.pop_screen()Screen 2(hidden)Screen 3(visible)Screen 2(visible)When you pop a screen it will be removed and deleted unless it has been installed or there is another copy of the screen on the stack.
"},{"location":"guide/screens/#action_1","title":"Action","text":"You can also pop screens with the \"app.pop_screen\"
action.
The switch_screen method replaces the top of the stack with a new screen.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXG1T20hcdTAwMTL+nl9BcV/2qsLsTPe8btXVXHUwMDE1XHUwMDAxNlx1MDAwMVx1MDAxMpNccuH17opcdTAwMTK2bGsxsrFlXGZs5b9fjyCWLL9gg3DIKlXY1sjj1szTTz/dM8pfb1ZWVpPbTrj628pqeFNcclpRrVx1MDAxYlxmVt/689dht1x1MDAxN7VjaoL0c6/d71bTK5tJ0un99uuvl0H3XCJMOq2gXHUwMDFhsuuo11x1MDAwZlq9pF+L2qzavvw1SsLL3r/930pwXHUwMDE5/qvTvqwlXZb9yFpYi5J29/63wlZ4XHUwMDE5xkmPev9cdTAwMGZ9Xln5K/2bs65cdTAwMTZcdTAwMDWX7biWXp425MwzUDxbacepqWiElFxiKrsg6m3SjyVhjVrrZHCYtfhTq7fHrZPex6uT643+2ra4qrq7KKlmv1qPWq395LaV2tRr061kbb2k275cYo+iWtKkVlE4P+1b3Xa/0YzDXm/kO+1OUI2SWzqn+PBkXHUwMDEwN9IusjM39GlccpVgxmgrpOJSKG3VsN13IDUyY41RXHUwMDAypUGJQlx1MDAxNSzbaLdoXHUwMDFlyLJ/8PTIbDtcdTAwMGaqXHUwMDE3XHIyMK5l11xiXHUwMDE1XHUwMDA05/XsmsHD/UqnmLRcdTAwMTJN1n0zjFx1MDAxYc3Ez5DVzFx1MDAxYcFdvrVcdTAwMTemk1x1MDAwMCA5OpR22OB/sbNdS8Hwv+IoNoNu52G0Vnv+Q85ab+hWXHUwMDExSXk05eY5TCpR2N89QLt7uub0detcZuKPw75GoFx1MDAxN3S77cHqsOXbw7vMtH6nXHUwMDE23CNKaC2tdU5cIupsMltRfEGNcb/Vys61q1x1MDAxN1x1MDAxOVxi07Pf3i5cZn2Jalx1MDAxYfSFXHUwMDExllx1MDAxYm5cdTAwMTTOjf0/wi+wKW/OKp9cdTAwMGU+XHUwMDFjXHLuTreCne0vP1x1MDAxMvuCP1x1MDAwZX5pmDJGguZcXKAwelx1MDAwNPvogKHlXHUwMDFjXGJfTlx1MDAxYsefh/16cM65Klx1MDAxMftkmHBOw5LB/2Vf7Z00vsRna/31w62d/atccn5cdTAwMTmUXHUwMDAyfsdRcO2kKVx1MDAwYvxJeJNMQr6yelx1MDAxYfKNdMid4PNcdTAwMDM/6H95t3n2Z3vzc3x4dHP8YaNcIlx1MDAwZVx1MDAwZl478J1hloNBXHUwMDAx3FhrR4FvhGJIzi+ERUW+XHUwMDAxz8K9cYrXYVx1MDAxY/eC23HAXHUwMDFiUYS5UVxuLU1cdCxcdTAwMTflL0fxXHUwMDFl5dKAxlx1MDAwNVCeoalcdTAwMWQn+9FdmHLDyNnfg8uodTtcdTAwMDKJXHUwMDE0/mTgfrVcdTAwMWKG8Yr4b/xLM6rVwvif+Vx1MDAxOeuF9Pu+Qz36zfVW1PDOstpcbuujXpREpMOGzUk7N8ZVsiSg7rrbteJcdTAwMWS1u1EjioPW1+lWPc2ZXHUwMDAxi2eHYVxmOEVUoXF+bz4wx1f609ZBv9M53ZKbXHUwMDFmq+8/71x1MDAxZL52b7aOkXJDq1BobnKKNlxyYyiYoJNaodNKKlMwrFx1MDAxY29cdTAwMDbMOGTozblzXHUwMDBm3uxcZqCRSmZ38DeIWUpQzFi2N8PKL5Q2ReetcLI3g1x1MDAxYfnmkrw5b9VMb75cdTAwMWbmXHTuLCjgTPVnXHJcdTAwMTSXtMuJocf8efbML+DPxSD4gv5cZsYxdEpLZVx1MDAwNXJNideoQ2vrm41cdTAwMTDg6FJcdTAwMTC6YFo5XHUwMDFlrYk2hFBcdTAwMTKApsQ6nvHG0L9cdTAwMWRnXHUwMDE20GpDjsCdmaRRSVxccKKdJ0Tv1MxcdTAwMTn+PsMjlbJOLKJcInN2XHUwMDA03eRdXHUwMDE016K4QY1cdTAwMTmVfK8ybM9cdTAwMTElUlx1MDAxZq72vZWcXHTllPSJsybcWpVcdEs/XHUwMDE2QcdcdTAwMWLNQFhJk01aXmr/7+GKb0Ozwrj2uFGzM7CcUWucgeHcijTrp+R/kk2KQqexUjjpaO6tXHUwMDE5s6lcdTAwMTX0ko325WWU0Nh/bkdxUlx1MDAxY+N0MNe9ozfDYIxB6J7ybUVG6PhcdTAwMWVHiT17t5K5TPph+P5/bydePVx1MDAxNcv+XHUwMDE4Q3HW25v868JUZkFOT7BcdTAwMTUnW3CBPGO2XCJ9pUxmgUnUymjDSX7wQnXJSMtIXHUwMDBmWFx1MDAxNFx1MDAwMlx1MDAxMVxi/C9DZI5ZckJjuOFWTiQy5Vx1MDAxOGiN5H9cdTAwMDLISclZx5iMfIE6cFnDMojs6YnCnEQ2O3lcdTAwMWQhMs0taUhKXHRBccvR5i6651xmw1BrcjJCtsy50YIsNruGOmJcdTAwMTGXxPIkJo1cdTAwMTREU1x1MDAxM0js5+asabD1x9o4Ylx1MDAxN2StXHUwMDE5hUG0tnj2O29cdTAwMDHXXHUwMDE0yEDLjNlcdTAwMWXjrePT885e5Th+31x1MDAxZlxcRWu1TrR+dNF83Vx1MDAxOVx1MDAxNfi6oPWFXHUwMDExMFx1MDAwMimBzO72vihuXHUwMDE5+aZwzlx1MDAxMYNbmUsvn15cdTAwMTTXurzSoCNgSJJemdmlZVmPXHUwMDE1rrPg8nKFa6LGafjUToLBXFx6+Vx1MDAxODrXb+tcdTAwMDfBzqf1+LyyTTnCIN6CratS0VlcdTAwMGJ6zbBcXHgqYL5cXG2c4Vx1MDAwZYBcdTAwMTdcdTAwMTN+R0GXlI5cdTAwMDalwaJ2ZdStVYmVayFcdTAwMDWCUy+CzyFcdTAwMGJOqFx1MDAwMtTtYSVuXHUwMDFjbFx1MDAxY21tXZ9WKlx1MDAxZiuN3km/nCqAplx1MDAxMETiQC1cdTAwMDH9gtT4dFnprFEqP+WP0vOHs697h5+PzuqdXHUwMDAzPKq15Hn7Knnl9KzSlFx0lNdcdTAwMWFcblRu+S/twCGzaDSBTNNYQK75Kfi3UHVcIixz3caBXCI5vOxFS1x1MDAxYoZcdTAwMWb6XHUwMDFm7+5a56f6rI5JhLenfD70v53V77vbzul54/Bi1+3dbVx1MDAwZrqbUbR7Wi+h3+NrXHUwMDFi697B7mDQaZze1bu1qLr+R1x0/fbE+sGOXHUwMDFl3Fx1MDAwZWrrXCLYUdHazWkvLIdcdTAwMDW8KJDKubJYYFrJXHUwMDFi3dRcdTAwMDDo9Tl3Ml9Ee4xcdTAwMDFcdTAwMGX1p+jaXHUwMDFkh1eV3ubmn1j5uvm+rZ7CXHUwMDAwy0ssXHUwMDAxNVOCXCJcdTAwMWNcdTAwMTjKLa1Uo1x1MDAwYliCI6OMUyNpOGVcdTAwMTQ+s+ZccmDPw1x08kzpXHSppJxcdTAwMTDuaEpcdTAwMTRlTi9Q9J4lx5RxsFxiXHUwMDE0s1x1MDAxOc/K0mhcdTAwMTjZXHKgvVxmttqNXFwzLFJn+vd7kTrodFhvQJlb86yXVoZ/uX/BycXq3ELBMorVM6yb6Y/Ti9ZcdTAwMWPMNI90vvRHOkzM7ZCzma9cZod8XHRNqli6sMNcdH7kl06MeCRKzVxmSLBcXFhDI1JcdTAwMTRcdTAwMGLlOCQw37lUoFxyWIE4qdRjXHUwMDA1Q4da+1VlXHUwMDE0+dTtwVvRSikt8ieE5+dUeoBTQvlcdTAwMTRvnbPSM1vnreTrKs761X+Lylx1MDAxOcmlyVx1MDAxNVx1MDAxZVx1MDAxZVxuK4o5wf1FSFOluJFcdTAwMGZcdTAwMTdMKfWM3sVPVIKZjid/XHUwMDE0kZT19ib/+oRcdTAwMTWwnHuMhXdcdTAwMGUkLFx1MDAwNZ9f4M/WO6+TTYw0zO+70ki3i4iFXHUwMDA1MFx0TKVcdTAwMDOhTH7TVplUwpklia5IO5BzgsZcdFwi3zpcdTAwMDagUFJYXHUwMDE0xlx1MDAxYZlcdTAwMWKfXHUwMDA3Klx1MDAxMZo0XGKiWm7R2C836SetR5dNJWuCUTwgMVwiOWViXG5cdTAwMTByXHUwMDE33VOJZKTPNEpBI619reJvSiVrU1x1MDAwMeWPMSiVxiXcTS9cdTAwMTZcdTAwMTC1SePdaG4umZ3rvU4ukY5cdTAwMDaXK2JVXHUwMDFmzLRcdTAwMWRcdTAwMTUmNOSMa1x1MDAwZVx1MDAwNECjKVuXXHUwMDA1w8piXHUwMDEzpL7RXGItXHUwMDE1IV2bSUVcdTAwMDPHmfa0RmlcdTAwMDVcdTAwMDClNFx1MDAxM6SJcSgo21nyajrkPffHSlx1MDAxM6JcdTAwMTMgceaXRSjqar9vfTKlOO5cZo20X71cdTAwMWVfuv57UMpcZlD5Y1xmTlx1MDAwYnLKrJ3jZvqWO0cj75TUXHUwMDE57TxKKr/38fxz/Wj75FCfSLu373ai3dddgbSWM+VcdTAwMDR4tuZo5Gj5gSQzI6bn1ilcdTAwMDSH8nn7Z8tfXHUwMDFlks44Q1x1MDAxZbLkesSyloesnFx1MDAxZfJoSEg1gpj/mZ6dXHJcdTAwMGW7h4Ov8CFpVFx1MDAwNvWrvT+7W+9fOzolo2nXVlx1MDAwYidcdTAwMDGwXHUwMDEw8biXXCJCXHUwMDFiR6qMW8WfXHUwMDE38souj5NmJlx1MDAwNlEvUSxcdTAwMWJcdTAwMTLgXHUwMDEyq+NcdTAwMWLHXHUwMDFmZaRO63+Yg9v1rb3Eid8/XZRSbS7dpaZusJ7+iJyg8FwiXHSQXHUwMDBiXHUwMDE0t1x1MDAwME+qX9Q+bm13XHUwMDE0XHUwMDFjvW+a5ufaa5eQVvpcctbKbzJBa/Lrb2k6KvzOXHUwMDE3J8ibKFx1MDAxZlXuZfxcdCaloONcdTAwMWKshd/jrVx1MDAxNVx1MDAxN0t+XuLlcO6LY2ilWPrzXHUwMDEy+Cp3WOPzd1hLmPr4k/D79Ei32Pn3Jc6e+Ve5fOS0ZqBImVHex7WDwv5cdK2Ypmb/JIWw+efryvRnbVx1MDAxOGl30ipSS+B20sNQ1jFOXHUwMDEzXHUwMDAyXHUwMDAwXHUwMDBl1Eih68HXwVxuxV1+rW8ppeone+Oc+eDsXHUwMDEwMZJcdTAwMGZcbktJMeU3KFx1MDAxZFx0WpywXHUwMDA3XHUwMDEwaKa11ztaXHUwMDFhTjP+PVxyWnBb4mxcdTAwMTm4MrK5WlC+ZZVcdTAwMTbaot+GkD1dN7Rcblx1MDAxOKWupHGs81dKbse3fP9MmehULPujiOKsszf510VVicht8iqSXHUwMDE4aVx1MDAxMrA0vvPnoFti33aSRudT+6ZDaDpcYmrx2bspJNZcZqrNfjf88TpfkPDgQlxiIDCholxmf1ToXHUwMDFigcwh+ke+QPuc7zk8lnSDuNdcdLrkXHUwMDEwXHUwMDEztEkuxc20ybi058I/s/xcIlx1MDAwYuGztMmL7vuS4DRmkm9pT391w8v2dV7m/nBlktn0NF2SX+YperT0iSwp7Pk9evakL+TRS9zXXCItU9zHdK7AXHUwMDA3hsLzXHUwMDEyfl+LzzMoeZbGuGetos/0aFx1MDAwMaSB/Fx1MDAxNlNOXHUwMDFhSXOZ+48gsno1MFx1MDAwNc5vMjHoXHUwMDFmRCu6u0/wpXJmmdXq5/jjnOpkdqgoKFx1MDAwMUupo6LwbqSQOlx1MDAxYqJMnnCGXHUwMDE0XHUwMDE40Vx1MDAxMI+npamnqZPZu5hHXHUwMDE0XHUwMDEzd1xuaL6Ms1r6xYRxmyhqkDiRaK2jTFx1MDAwMIT6uZ/9mlx1MDAwZWV/rFx1MDAxNVE8TZ68efhcdTAwMDG/eWg/IcxccqeDYFx1MDAxZNVcdTAwMWWIPLvL1esoXHUwMDFjvJu0nzo9PEem4+mZKPT3+te3N9/+XHUwMDBmJe3LnyJ9 Screen 1(hidden)Screen 2 (visible)app.switch_screen(screen3)Screen 3 (visible)Screen 2 removedLike pop_screen, if the screen being replaced is not installed it will be removed and deleted.
"},{"location":"guide/screens/#action_2","title":"Action","text":"You can also switch screens with the \"app.switch_screen\"
action which accepts the name of the screen to switch to.
If a screen has a background color with an alpha component, then the background color will be blended with the screen beneath it. For example, if the top-most screen has a background set to rgba(0,0,255,0.5)
then anywhere in the screen not occupied with a widget will display the second screen from the top, tinted with 50% blue.
Note
Although parts of other screens may be made visible with background alpha, only the top-most is active (can respond to mouse and keyboard).
One use of background alpha is to style modal dialogs (see below).
"},{"location":"guide/screens/#modal-screens","title":"Modal screens","text":"Screens may be used to create modal dialogs, where the main interface is temporarily disabled (but still visible) while the user is entering information.
The following example pushes a screen when you hit the Q key to ask you if you really want to quit. From the quit screen you can click either Quit to exit the app immediately, or Cancel to dismiss the screen and return to the main screen.
OutputOutput (after pressing Q)modal01.pymodal01.tcssModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2585\u2585 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
ModalApp \u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588 \u2588\u2588 \u2588\u2588 \u2588Are\u00a0you\u00a0sure\u00a0you\u00a0want\u00a0to\u00a0quit?\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2588 \u2588\u00a0Quit\u00a0\u00a0Cancel\u00a0\u2588 \u2588\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2588 \u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588
modal01.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import Screen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(Screen):\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.app.exit()\nelse:\nself.app.pop_screen()\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\nself.push_screen(QuitScreen())\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
modal01.tcssQuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
Note the request_quit
action in the app which pushes a new instance of QuitScreen
. This makes the quit screen active. If you click Cancel, the quit screen calls pop_screen to return the default screen. This also removes and deletes the QuitScreen
object.
There are two flaws with this modal screen, which we can fix in the same way.
The first flaw is that the app adds a new quit screen every time you press Q, even when the quit screen is still visible. Consequently if you press Q three times, you will have to click Cancel three times to get back to the main screen. This is because bindings defined on App are always checked, and we call push_screen
for every press of Q.
The second flaw is that the modal dialog doesn't look modal. There is no indication that the main interface is still there, waiting to become active again.
We can solve both those issues by replacing our use of Screen with ModalScreen. This screen sub-class will prevent key bindings on the app from being processed. It also sets a background with a little alpha to allow the previous screen to show through.
Let's see what happens when we use ModalScreen
.
ModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2585\u2585 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
ModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0i\u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588 Where\u00a0the\u00a0\u2588\u2588st\u00a0not\u00a0f Fear\u00a0is\u00a0th\u2588\u2588 Fear\u00a0is\u00a0th\u2588Are\u00a0you\u00a0sure\u00a0you\u00a0want\u00a0to\u00a0quit?\u2588 I\u00a0will\u00a0fac\u2588\u2588 I\u00a0will\u00a0per\u2588\u2588\u2585\u2585 And\u00a0when\u00a0i\u2588\u2588 Where\u00a0the\u00a0\u2588\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2588st\u00a0not\u00a0f Fear\u00a0is\u00a0th\u2588\u00a0Quit\u00a0\u00a0Cancel\u00a0\u2588 Fear\u00a0is\u00a0th\u2588\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2588 I\u00a0will\u00a0fac\u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588 I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
modal02.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(ModalScreen):\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.app.exit()\nelse:\nself.app.pop_screen()\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\n\"\"\"Action to display the quit dialog.\"\"\"\nself.push_screen(QuitScreen())\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
modal01.tcssQuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
Now when we press Q, the dialog is displayed over the main screen. The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input.
"},{"location":"guide/screens/#returning-data-from-screens","title":"Returning data from screens","text":"It is a common requirement for screens to be able to return data. For instance, you may want a screen to show a dialog and have the result of that dialog processed after the screen has been popped.
To return data from a screen, call dismiss()
on the screen with the data you wish to return. This will pop the screen and invoke a callback set when the screen was pushed (with push_screen
).
Let's modify the previous example to use dismiss
rather than an explicit pop_screen
.
from textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(ModalScreen[bool]): # (1)!\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.dismiss(True)\nelse:\nself.dismiss(False)\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\n\"\"\"Action to display the quit dialog.\"\"\"\ndef check_quit(quit: bool) -> None:\n\"\"\"Called when QuitScreen is dismissed.\"\"\"\nif quit:\nself.exit()\nself.push_screen(QuitScreen(), check_quit)\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
[bool]
QuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
In the on_button_pressed
message handler we call dismiss
with a boolean that indicates if the user has chosen to quit the app. This boolean is passed to the check_quit
function we provided when QuitScreen
was pushed.
Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller. This makes it easier for the app to perform any cleanup actions prior to exiting, for example.
Returning data in this way can help keep your code manageable by making it easy to re-use your Screen
classes in other contexts.
You may have noticed in the previous example that we changed the base class to ModalScreen[bool]
. The addition of [bool]
adds typing information that tells the type checker to expect a boolean in the call to dismiss
, and that any callback set in push_screen
should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
Some apps may benefit from having multiple screen stacks, rather than just one. Consider an app with a dashboard screen, a settings screen, and a help screen. These are independent in the sense that we don't want to prevent the user from switching between them, even if there are one or more modal screens on the screen stack. But we may still want each individual screen to have a navigation stack where we can push and pop screens.
In Textual we can manage this with modes. A mode is simply a named screen stack, which we can switch between as required. When we switch modes, the topmost screen in the new mode becomes the active visible screen.
The following diagram illustrates such an app with modes. On startup the app switches to the \"dashboard\" mode which makes the top of the stack visible.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2aW0/bSFx1MDAxNMff+ylQ9mVXKu7cL5VWK6ClXHUwMDA1UmhJuZRtVTn2JPHGsY3tJEDFd99jw8ZcdTAwMTdcYiSkXHUwMDA0Km1cdTAwMWWCPWfsOTPz+885M+HHi5WVRnpcdTAwMWWZxuuVhjlzbN9zY3vceJmVj0yceGFcdTAwMDAmkt8n4TB28pq9NI2S169eXHLsuG/SyLdcdTAwMWRjjbxkaPtJOnS90HLCwSsvNYPkr+x711x1MDAxZZg/o3DgprFVNLJqXFwvXHLjq7aMb1x1MDAwNiZIXHUwMDEzePvfcL+y8iP/LnnnevYgXGbcvHpuKLmnab10N1xmclcx5YpcdTAwMTOJNZrU8JI30FpqXFwwd8BjU1iyosaRjYZ7zeh78+JTW9uO2t48+/ChaLbj+X4rPfdzp5JcdTAwMTD6UtiSNFx1MDAwZfvmyHPTXtZ2rXzaU3E47PZcdTAwMDKTJJVnwsh2vPRcdTAwMWPKeOG7XHUwMDFkdPNXXHUwMDE0JWdwtyotjVx00pxcdTAwMTFOlGKMTMxXz1OLXHUwMDEwwlx1MDAwNYyT0lxuXHUwMDA2pObYRujDPIBjv6H8U7jWtp1+XHUwMDE3/Fx1MDAwYtyiXHUwMDBl5rbd7lx1MDAxNHXG190lQlpMM1x1MDAwMe1ThbVWk1x1MDAxYT3jdXtp1jvOLCWxkFxiXHUwMDBiTqUo/DD5dGioXHUwMDAwb2BsYshcdTAwMWGPttyci2/lXHUwMDExXHUwMDBi3OtcdTAwMTFcdTAwMGKGvl/4m1x1MDAxOd6WWCqeXHUwMDE5Rq59NelYwDhQxbGkolx1MDAxOCnfXHUwMDBi+vXX+aHTLzjJSy9fzo0n43wqnoogISijbGY8t9+hQ9U8+dTc2Wz7zY3Drffn0f5T4onRvXxyXHUwMDBi5lRrwpiQmFAlK3zChFtcdTAwMDIhSiSlSmjJXHUwMDE2wrNjt1x1MDAxMeKPgyehjGOt0Fx1MDAxMvBkQiMk+Vx1MDAxMvBUpaGo4amFXCLgzVx1MDAxY3SK3ogo01x1MDAxMu6J8665ftzdx1x1MDAwM3fzmdOJLYyBTC44V1xmRlx1MDAxZtEqnlhCXHUwMDA1ilx1MDAxOPArXHUwMDA1XHUwMDExdLHlU1x1MDAxMUdj8yh8YkJcdTAwMTiGJYUsXHUwMDAxUIZcdTAwMTFF0NxcdTAwMTJcdTAwMDClkkxcdTAwMDM0XHUwMDBiaowjiWdcdTAwMDd0dEC3u1EoRu/eXHUwMDFlOKnauaCO/5SA0vv4lDhcdTAwMDOUQ1dcdTAwMDVDWlx1MDAxMF6hkyNkMWCTaSGZYrTu1nxwtlxya7vtXz22g4gxXHUwMDEzVC6DzVIwuFx1MDAxMduZZJrCnM1cZufnL/2NcNPZ+4L39b67ZtZcdTAwMDej95+fNZxUUEtcbqmk1Fx1MDAwMsM3rcGpLSWIJCBhzLhaiM2OK1xyZv+zOTObnN3BJkJKc0g8Z2bT/b56vLcrm+GgXHUwMDFk+73xXHUwMDA2e+tvnjw3Ni3IXCJplkUykucuvFx1MDAwNiu3lIZcdTAwMDSTMYlcdTAwMTFcdTAwMTdcdTAwMTVWmURgXHUwMDE08Fx1MDAxNIxccqRcdTAwMDJ0IViZI0znl89CfzasqTlLbyNcdTAwMTWXwlaNVMVhaYG1Q85cZqp/SFtvdk9OTtaJcS76slx1MDAxZnL1aVxuqD3b6VxyY/P0SShcdTAwMTOWXHUwMDEwkFxcMlx1MDAwNmkoIZKzXG6cXHUwMDEySVx1MDAwYlZRyHUgxSOYL5aCTovymCuLaq6xlkAmYvImnOXk91xuR4Ipw1x1MDAxY5V20o+II+TmisyTc1x1MDAxNtNcdTAwMWVcdTAwMDZpy7vIk0ZVKd20XHUwMDA3nn9embmcU1x1MDAxOKmvXHLXTnrt0I7dr41Gxbzme90gx810qkynnmP7XHUwMDEzc1x1MDAxYUaF1YHmbC8w8ZZbdzuMva5cdTAwMTfY/ue7m4ZcdTAwMWWb95OVwiolg207MZk1z4pcdTAwMWakQqBrarzAXGa2o4ry2eOFQVx0+uLt75zzXvfirFx1MDAxZq7udFx1MDAwZbaeVobsPlx1MDAxNSpMsr0ggTVcdTAwMDfyadiD10SoLYIo5lx1MDAxYWFcYjNqsXOKaVwiXHUwMDE0ylx1MDAxMopqXGJRXGJyJlXKSZ6NXGKh92iek4lFRdgzfrR8/dVbfVTpTVx1MDAwZoDZgVx1MDAxOFwipdbuXHUwMDEz3sH2If9cdTAwMThcdTAwMWSGp7J1vrHzXHUwMDBmYmunb5vPXFx4jHKLYlx1MDAwNblcdTAwMWJHnGOlqvuIXFx5XHUwMDA0Q1qnRCa/R1xuf0RZsIvWRCDCqS6r6dlIXHUwMDBmKTlXOrao9Fx1MDAxMpOmXtBNli+/21p+TFx0lnO0mzt5yLnpPLsl0Vx1MDAxMuHx+MBcdTAwMWZ8XGLGXFydXlx1MDAxY1x1MDAwZtvjvYeJkNTKXHUwMDFmL1x0JcRCXHUwMDAy4p5cdTAwMDagQVx1MDAwNJRU41x1MDAxZtHEXHUwMDAyocLOKtNcdTAwMDfEpukqVLLD21x1MDAwZlShVlkrXHUwMDEwZJDimpaOwO9cdTAwMTChUOAz4z9vS3RtKPgpze1ol1x1MDAxY21/POqf4vFWa20zSnbtuNjqVWCz4zhcdTAwMWM3JpbL66s7XHUwMDE0rjQofJ5fpVx1MDAxNlP4mpN6I7Py+8hLvLZv/liuyqe3/jOUfjX4t0mdsHrpROqwXHUwMDA3k0rL2bebd9PwJEqX91x0PYt0QnPKXHUwMDE51Vx1MDAxNFx1MDAxM1ngklx1MDAxZqxAMOZIXHSuXGLnTEl2x1HIXHUwMDAyQkdcdTAwMTZWXHUwMDFjgStMZb9OXHUwMDEzSW45XGZhzJKIXHUwMDExgYXQWGB9U/lCQFx1MDAwZlx1MDAxOCqWqvulX2j6P1bIdcnlg6LywzWbpHacrnuBXHUwMDBika7q2PU/RGzNXHUwMDEwTXKVO8PMy1VkIcmFJJpjXGJZTFx1MDAxNucm2cjYUbbJgSqUXG5CKMKSiJtdN4FbuFTthZ2kXHUwMDFi4WDgpdD/j6FcdTAwMTek9Vx1MDAxYXmH1jLh9Yx9Q//w5rKtrtAoe2N1+S2uVlxuhvObyfW3l7fWXr2Dr+xzg6zihS/Kf7M1O2+iYUdRK4WZn0xcdTAwMTSg5rnXS27Rz8bIM+P121x1MDAwZbDzT1x1MDAxNlxy8rHOllx1MDAwNpPjePni8l9cdTAwMDSHyVx1MDAxMCJ9 \"dashboard\"\"help\"\"settings\"Active (visible)If we later change the mode to \"settings\", the top of that mode's screen stack becomes visible.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2a2VLbSFx1MDAxNIbv81x1MDAxNJTnNii9L6mammJfMmxcdTAwMDFcdTAwMDJkkkrJUttWLEtCkrFJinef01xuYy1gNrNlfGHsPrL6tPr7z1wi8fPN3FxcKz9PTOv9XFzLjD03XGb81Fx1MDAxZLXe2vEzk2ZBXHUwMDFjgYlcdTAwMTTfs3iYesWRvTxPsvfv3lxy3LRv8iR0PeOcXHUwMDA12dBccrN86Fx1MDAwN7HjxYN3QW5cdTAwMDbZX/Z921x1MDAxZJg/k3jg56lTTjJv/CCP019zmdBcZkyUZ3D2f+D73NzP4r3inVx1MDAxZriDOPKLw1x1MDAwYkPpXHUwMDFlJbw5ulx1MDAxZEeFq5xcdIlcdTAwMTlcIlx1MDAxM3uQLcNcXLnxwdhcdTAwMDF/TWmxQ62j3Z01s7iXu9m8TpLTlW/JsEPLSTtBXHUwMDE47ufnYeFSXHUwMDE2w0pKW5ancd9cdTAwMWNcdTAwMDV+3lx1MDAwMytujE/7VVx1MDAxYVx1MDAwZru9yGRZ7Tdx4npBfm5cdTAwMTeHJoNu1C1OUY6M7Y80dbDWmGtcIpggQtOJ2f6eKe4wXCK1XHUwMDA2XHUwMDEzZVxcNdxaikPYXHUwMDAzcOtcdTAwMGZUvErH2q7X74J3kV9cdTAwMWWDueu2O+Uxo8vFXHUwMDEyIVx1MDAxZKaZUIxRXHUwMDA1zpSz9EzQ7eXWTc5cdTAwMWMlsZBcYlx1MDAwYk6lKP0wxWbAXHUwMDAy7Fx1MDAxOVx1MDAxOJtcdTAwMTjs5MmGXzDxtXq9XCL/8npFwzAs/bWGlSZHVZYq27y/sX7a3/Hzle1w72Rk0sWTxW97k3XVwHPTNFx1MDAxZbUmlovLT6VHw8R3f1x1MDAwMYXh4iuGXHUwMDA1oVKWSIZB1G86XHUwMDFixl6/ZLBcdTAwMTi9eHtv8JlcdTAwMTLTwFx1MDAwNyooJVx1MDAwMle24jb0+3LpQ1x1MDAxYeJvx6j9PTzoxt3R9+HWK0dcdTAwMWbYXHUwMDE2XHUwMDAwNiGMXHUwMDEy1Fx1MDAwMJ9cdEchLlx1MDAwNVx1MDAxMoLCzrCZyO+4bYT405BPQJewUejRyH9ccmxqoqexSTVBmHBy96jMR8nR1tjfpOM12U+Xu0ujT/HglaOpXHUwMDFkiLlSaURcdTAwMTmSVNbYpGClWDFFuCRIK8ZnglNcdTAwMTFPY/MkcGKQXHUwMDE2xoqQ/1x1MDAxN520kiWbJYOWmFx1MDAxM87Znek88Vx1MDAwZdaOt7zOp7PNY7W1tDFYWF7cfkk62W10akxcdTAwMWNCXHUwMDA0x5hSpKSqwVx0VDpcdTAwMWHUXHTVhKRcdTAwMTji60xstlxya/vt36RkuFx1MDAxMU0hlHpcdTAwMTY01TQ0MVKwYCwqaf82NlPlkf7ybrhcdTAwMWLujE829lx1MDAwZvn6SftF61mMboOTXHUwMDBiu+1cdTAwMWFTXHUwMDBiqNSkXHUwMDFlOpkmXHUwMDBlXHUwMDEyXG5ziVx1MDAxONdcdTAwMTQ1/bpnWvelwez3p1x1MDAxM1xuXHUwMDFjhckz0Mn59F6LXG6GIKKgO8OZeWp1NzvrbVxmP6ydjr6vXHUwMDA0KplfeXVwOohcdTAwMTIoppVgRFx1MDAwYlx1MDAwNVA2aJVcdTAwMGXCSENcdTAwMDcmXHUwMDEwpFx1MDAwZVanlUNzhlxixlx1MDAwMlxuXHUwMDFlTORsRSjzhOn8XHUwMDBmitDHpTU34/w6VGGSqYGUI4FcdTAwMTnn8u7dUZet7+xcdTAwMWRcdTAwMWStRHtYXHUwMDFk8uHpeEzaXHUwMDBiU1jtuV5vmJpcdTAwMTdP84QpXHUwMDA3NpxcdMKVRErX2Vx1MDAxNLZcYoXuiCPIKZg+UZ7HXFw5VHONtVx1MDAwNDBcdTAwMTGTV9mkvEkjwZTZPVwiz0GjJlCG03vQWG56XHUwMDFj5fvBXHUwMDBme+GJqo2uuoMgPK/tW4EpXFypLy3fzXrt2E39L61WzbxcdTAwMTBcdTAwMDZdS24rNJ060nngueHEnMdJafVgOjeITLrhN92O06BcdTAwMWJEbnhw89SwYrM+XHRcdTAwMTRO5W5a282MtdpcdTAwMDXyXHUwMDA3ibBcdTAwMWHymlwiXHUwMDE0UHpcIoird+9cdTAwMDPnPZxcdTAwMWZcdTAwMWZ8Xo/ib5tcdTAwMWZHW6uHy8qLXrlcYjFEXFyHKi6FgFpcdTAwMDZcdTAwMTFdr2ckkkWG4IgwTrSa7Vx1MDAwNt00XHUwMDE1XG7lXGJFNWPgXHUwMDAwZqpSlLxcdTAwMWFcdTAwMTVCI4Lv0/rNqsKeXHST51x1MDAxN2Bz1ifVnsDN0Yn2JKOSQ69992Ltx+r4cIVcdTAwMDdq6cTdPmDR2s5cdTAwMGZ/8PFltXd7LyEocygniDJbc6Ayxf2SXHUwMDFlNLpSI6lcdTAwMTDngqjZWompXHSQKEdcdTAwMGLoY1x1MDAwNII0o6tyejXaI5yx+9Rjs2ovM3lcdTAwMWVE3ez59XfdzE+pQYxlc7TMf1RqXHUwMDAxjcHd85//cXfps4/mN8+S4OPSh8V2XHUwMDEwz88/TIOkMf6EXHUwMDFhZNpB0MkjibWwbVJNhERxh2PMidJQqaLqfbcrKlSyw9tcdTAwMGZToYI4QDn0b1xiQ9yrdlx1MDAxZjeIUEDJXGZtwuP1RJeGa1x1MDAxZknhwd/n/vKmiFx1MDAwZldcdTAwMGU6cZxiubpQ3pmowXb/R1Ka2j/PpvBcdTAwMDUvXHUwMDBmzszzars552Oo+teFvk7WeuqTN4K0JETf48HbzTv/XCKqlreK2lxuXG60LyhcdTAwMTSuiuD6LWRcbjlPSlvTaY5cdTAwMTWYb3i+MYOoqVx1MDAwMz08gTDKtOQgVVROM1G11I5iXHUwMDFjXHUwMDBlXHUwMDAxzdubhldFblx1MDAxZlx1MDAwZlx1MDAxMlapXHJuV3kp3/9QIZcjXHUwMDE3XHUwMDBmLH5cdTAwMWYqzyx303wxiHxIanXHLv+lYuNcdTAwMGWJo1x1MDAxMLQ3tF5cIlx1MDAwNyvrk6BcblOoQVx1MDAxMKGVo7puYmOpXHUwMDAztVxmWFx1MDAxMId2XHUwMDFlXHUwMDEyXHUwMDE4u7J2XHUwMDEz+aVP9WW4Wb5cdTAwMTRcdTAwMGZcdTAwMDZBXHUwMDBlXHUwMDE3YDdcdTAwMGWivHlEsaJcdTAwMDUrvJ5xr6hcdTAwMWXOXFy1NVx1MDAxNZrYM9ZDbflprmS4+DL5/PXttUdPx8u+roBVnu5N9a+NzsVcdTAwMDQtN0n2c9j4yT5cdTAwMDFpgX9cdTAwMTlcXMtVts5cdTAwMDIzWrzuZnXxsnG/uNI2MJiCxos3XHUwMDE3/1x1MDAwMiHc1HkifQ== \"dashboard\"\"help\"\"settings\"ActiveTo add modes to your app, define a MODES
class variable in your App class which should be a dict
that maps the name of the mode on to either a screen object, a callable that returns a screen, or the name of an installed screen. However you specify it, the values in MODES
set the base screen for each mode's screen stack.
You can switch between these screens at any time by calling App.switch_mode
. When you switch to a new mode, the topmost screen in the new stack becomes visible. Any calls to App.push_screen
or App.pop_screen
will affect only the active mode.
Let's look at an example with modes:
modes01.pyOutputOutput (after pressing S)from textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Footer, Placeholder\nclass DashboardScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Dashboard Screen\")\nyield Footer()\nclass SettingsScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Settings Screen\")\nyield Footer()\nclass HelpScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Help Screen\")\nyield Footer()\nclass ModesApp(App):\nBINDINGS = [\n(\"d\", \"switch_mode('dashboard')\", \"Dashboard\"), # (1)!\n(\"s\", \"switch_mode('settings')\", \"Settings\"),\n(\"h\", \"switch_mode('help')\", \"Help\"),\n]\nMODES = {\n\"dashboard\": DashboardScreen, # (2)!\n\"settings\": SettingsScreen,\n\"help\": HelpScreen,\n}\ndef on_mount(self) -> None:\nself.switch_mode(\"dashboard\") # (3)!\nif __name__ == \"__main__\":\napp = ModesApp()\napp.run()\n
switch_mode
is a builtin action to switch modes.DashboardScreen
with the name \"dashboard\".ModesApp Dashboard\u00a0Screen \u00a0D\u00a0\u00a0Dashboard\u00a0\u00a0S\u00a0\u00a0Settings\u00a0\u00a0H\u00a0\u00a0Help\u00a0
ModesApp Settings\u00a0Screen \u00a0D\u00a0\u00a0Dashboard\u00a0\u00a0S\u00a0\u00a0Settings\u00a0\u00a0H\u00a0\u00a0Help\u00a0
Here we have defined three screens. One for a dashboard, one for settings, and one for help. We've bound keys to each of these screens, so the user can switch between the screens.
Pressing D, S, or H switches between these modes.
"},{"location":"guide/styles/","title":"Styles","text":"In this chapter we will explore how you can apply styles to your application to create beautiful user interfaces.
"},{"location":"guide/styles/#styles-object","title":"Styles object","text":"Every Textual widget class provides a styles
object which contains a number of attributes. These attributes tell Textual how the widget should be displayed. Setting any of these attributes will update the screen accordingly.
Note
These docs use the term screen to describe the contents of the terminal, which will typically be a window on your desktop.
Let's look at a simple example which sets styles on screen
(a special widget that represents the screen).
from textual.app import App\nclass ScreenApp(App):\ndef on_mount(self) -> None:\nself.screen.styles.background = \"darkblue\"\nself.screen.styles.border = (\"heavy\", \"white\")\nif __name__ == \"__main__\":\napp = ScreenApp()\napp.run()\n
The first line sets the background style to \"darkblue\"
which will change the background color to dark blue. There are a few other ways of setting color which we will explore later.
The second line sets border to a tuple of (\"heavy\", \"white\")
which tells Textual to draw a white border with a style of \"heavy\"
. Running this code will show the following:
ScreenApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
"},{"location":"guide/styles/#styling-widgets","title":"Styling widgets","text":"Setting styles on screen is useful, but to create most user interfaces we will also need to apply styles to other widgets.
The following example adds a static widget which we will apply some styles to:
widget.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass WidgetApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(\"Textual\")\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.border = (\"heavy\", \"white\")\nif __name__ == \"__main__\":\napp = WidgetApp()\napp.run()\n
The compose method stores a reference to the widget before yielding it. In the mount handler we use that reference to set the same styles on the widget as we did for the screen example. Here is the result:
WidgetApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Textual\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Widgets will occupy the full width of their container and as many lines as required to fit in the vertical direction.
Note how the combined height of the widget is three rows in the terminal. This is because a border adds two rows (and two columns). If you were to remove the line that sets the border style, the widget would occupy a single row.
Information
Widgets will wrap text by default. If you were to replace \"Textual\"
with a long paragraph of text, the widget will expand downwards to fit.
There are a number of style attributes which accept colors. The most commonly used are color which sets the default color of text on a widget, and background which sets the background color (beneath the text).
You can set a color value to one of a number of pre-defined color constants, such as \"crimson\"
, \"lime\"
, and \"palegreen\"
. You can find a full list in the Color API.
Here's how you would set the screen background to lime:
self.screen.styles.background = \"lime\"\n
In addition to color names, you can also use any of the following ways of expressing a color:
#
followed by three pairs of one or two hex digits; one for the red, green, and blue color components. For example, #f00
is an intense red color, and #9932CC
is dark orchid.rgb
followed by a tuple of three numbers in the range 0 to 255. For example rgb(255,0,0)
is intense red, and rgb(153,50,204)
is dark orchid.hsl
followed by a angle between 0 and 360 and two percentage values, representing Hue, Saturation and Lightness. For example hsl(0,100%,50%)
is intense red and hsl(280,60%,49%)
is dark orchid.The background and color styles also accept a Color object which can be used to create colors dynamically.
The following example adds three widgets and sets their color styles.
colors01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Static\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(\"Textual One\")\nyield self.widget1\nself.widget2 = Static(\"Textual Two\")\nyield self.widget2\nself.widget3 = Static(\"Textual Three\")\nyield self.widget3\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"#9932CC\"\nself.widget2.styles.background = \"hsl(150,42.9%,49.4%)\"\nself.widget2.styles.color = \"blue\"\nself.widget3.styles.background = Color(191, 78, 96)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
Here is the output:
ColorApp Textual\u00a0One Textual\u00a0Two Textual\u00a0Three
"},{"location":"guide/styles/#alpha","title":"Alpha","text":"Textual represents color internally as a tuple of three values for the red, green, and blue components.
Textual supports a common fourth value called alpha which can make a color translucent. If you set alpha on a background color, Textual will blend the background with the color beneath it. If you set alpha on the text color, then Textual will blend the text with the background color.
There are a few ways you can set alpha on a color in Textual.
\"#9932CC7f\"
is a dark orchid which is roughly 50% translucent.rgba
format, which is identical to rgb
with the additional of a fourth value that should be between 0 and 1, where 0 is invisible and 1 is opaque. For example \"rgba(192,78,96,0.5)\"
.a
parameter on a Color object. For example Color(192, 78, 96, a=0.5)
creates a translucent dark orchid.The following example shows what happens when you set alpha on background colors:
colors01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Static\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nself.widgets = [Static(\"\") for n in range(10)]\nyield from self.widgets\ndef on_mount(self) -> None:\nfor index, widget in enumerate(self.widgets, 1):\nalpha = index * 0.1\nwidget.update(f\"alpha={alpha:.1f}\")\nwidget.styles.background = Color(191, 78, 96, a=alpha)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
Notice that at an alpha of 0.1 the background almost matches the screen, but at 1.0 it is a solid color.
ColorApp alpha=0.1 alpha=0.2 alpha=0.3 alpha=0.4 alpha=0.5 alpha=0.6 alpha=0.7 alpha=0.8 alpha=0.9 alpha=1.0
"},{"location":"guide/styles/#dimensions","title":"Dimensions","text":"Widgets occupy a rectangular region of the screen, which may be as small as a single character or as large as the screen (potentially larger if scrolling is enabled).
"},{"location":"guide/styles/#box-model","title":"Box Model","text":"The following styles influence the dimensions of a widget.
Additionally, the margin style adds space around a widget's border, which isn't technically part of the widget, but provides visual separation between widgets.
Together these styles compose the widget's box model. The following diagram shows how these settings are combined:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT2txcdTAwMTb+3l/h+H4tu/t+6cyZM9VqrbfWevfMO51cYlx1MDAwMaJAaFx1MDAxMlx1MDAxNHyn//2sXHUwMDFklCSERFCw6Dn50EpcdTAwMTKSxd7redaz1r78825lZTVcdTAwMWF03dWPK6tuv+q0vFrg3K6+t+dv3CD0/Fx1MDAwZVxcovHn0O9cdTAwMDXV+M5mXHUwMDE0dcOPXHUwMDFmPrSd4NqNui2n6qJcdTAwMWIv7DmtMOrVPFx1MDAxZlX99lx1MDAwNy9y2+G/7b/7Ttv9V9dv16JcdTAwMDAlL6m4NS/yg+G73JbbdjtRXGJP/1x1MDAwZnxeWfkn/jdlXeBWI6fTaLnxXHUwMDE34kuJgZzT8bP7fic2llBFleCck9FcdTAwMWRe+Fx1MDAxOd5cdTAwMTe5NbhcXFx1MDAwN5vd5Io9tTo46Tt6Y//TbvubXHUwMDFmnISHXHUwMDFkv16tJa+te63WYTRoXHKbwqk2e0HKqDBcbvxr99SrRU379rHzo+/V/MhcdTAwMWEwulx1MDAxY/i9RrPjhmHmS37XqXrRwJ7DeHR22FxmXHUwMDFmV5IzfdtcdTAwMDZYXCIhpMGYXHUwMDEwybA0cnQ5flx1MDAwMMdIY2FcYiZGKEFcdTAwMTVcdTAwMWYzbd1vQW+AaX/h+Ehsu3Sq11xyMLBTXHUwMDFi3Vx1MDAxM1x1MDAwNU4n7DpcdTAwMDH0WXLf7f2PXHUwMDE22CCmNJVUXHUwMDBiplxyS35P0/VcdTAwMWHNyFx1MDAxYUtcdMJaXHUwMDE4psTwbYmxoVx1MDAxYndcZtjJXGI8JfmR1oTu11rsI3+Pt2vTXHS69823XHUwMDFh2lx1MDAwZinzreVcdTAwMWLjXHUwMDBllnayVN9/r+v+vt5vXa5HV0dH52dVXHUwMDE11ndGz8p4pFx1MDAxM1x1MDAwNP7t6ujK7/u/XHUwMDEy03rdmjP0MlwiJSOSSo0l06PrLa9zXHJcdTAwMTc7vVYrOedXr1x1MDAxM8eMz/5+/1x1MDAwNERIZVxuXHUwMDExYYji4Fx1MDAwNFRNjYjdra+tur9f3aq3XHUwMDA3P1x1MDAxYd7N7lx1MDAwMd1cZlx1MDAwYlx1MDAxMFx1MDAxMfqA75nxMPatx+DAXHUwMDFlRYNcdTAwMDI0XHUwMDE4TVx1MDAxOVx1MDAxNVx1MDAxYyshWFx1MDAxNlxylGokJSZKXHS4R6cvj6NB1FmtykvR8Fx1MDAxN69Kty7ySGBCIS1cdTAwMDSXWok8XGKoMIhcdTAwMWFcdTAwMDNewVx1MDAxOcNU5EFAXHUwMDE5XHUwMDEzklxirl9cdTAwMTZcdTAwMDTVb7vnzbOt49rAmE5vn3RVs+a9Qlx1MDAxMHBZXGZcdTAwMDImMeVUSjE1XGKud6+u2mHw6zRw+4HZONu46PHPT1x1MDAwYlx1MDAwYrRcYlx1MDAwNjUnbM43LFxiRpFSXFxIYaiSWpAsXHUwMDBlXHUwMDE0uKDWWlx1MDAxOUOoIIqwQlx1MDAxY7hSqedEXHUwMDA18O88XHUwMDA0SMq1XHUwMDFmiJ9pXGJh0DX8pZz+wZdcIrdcdTAwMWZlvXzY8TtcdTAwMTef9k5+bW1drG1dXHUwMDA0P2818/prTsrn309+7PDLd1x1MDAwM7355Yiz3Z1+c933mt+rXHUwMDFi11+WXHUwMDEzS5nfn5Z/KZCMwchIQjhcdTAwMDeKmlx1MDAxYUU3XHUwMDFlZnzT7Kvewefq9aU52D6W3+YsrmZcZiaPg0hcdTAwMWGDuLA/lEpmtKFcdTAwMTlcdTAwMTBcdMKQ1pJcdTAwMTgjtNRcdTAwMTBNXHUwMDE2pqwkzUOIilx1MDAxY4KwkFQxiHHzR9A8nTHpdL9cdTAwMTNcdTAwMWR6d7bdKc6c3XTaXmuQ6bfYS8HSPSdoeJ10W4YuvDMmd525+1PLa1g/Xm259ayDR1x1MDAxZWQjo8uRn/rlVXi7XHUwMDAzj1x1MDAwYr7Wxn+FXHUwMDFmePBmp3WUteRJ2GKiXHUwMDEwW1x1MDAwNGSaUcDabGpwXHLCzs7hQe1qw2l+3dq9lp2Dm7D5gplcdTAwMGJ+XCK6IERRK4K0XHLLXFxk0Fx1MDAwNalcdTAwMWJcdTAwMDLgYSpccmRcdTAwMTOMXHUwMDFhtTB4pXKicngpSFxcXGZ+YVXmqeb2TXV7/etGh31yu+etSPw8mmskSdTSosH73anVvE5jXHUwMDE50PtgytNCI1bjZ1x1MDAxZuBcdTAwMGLaXHUwMDFkXHUwMDEyXFwpplx1MDAwZo2Tdcayo1cwVVwiMFx1MDAxOSdIvZDA5Fx1MDAxM1x1MDAwNCZNvW9cYl9gXHUwMDFiyLYgUL98cFxcXHUwMDE0vlhcdTAwMGVf63BcdTAwMTmsWoGmciaDzExcdTAwMDZZXHUwMDE1vuVcdTAwMDYlMGt7tVo628pcIu2xJGlcdTAwMWN8XHUwMDE5O0tcdTAwMTFYnuhpXlx1MDAwNEPCpWCCSjJ9tWPnR6O5dbm5b1x1MDAwNs2rk8b+USDO+rdcdTAwMDU4rFx1MDAwNn5cdTAwMThWmk5UbVx1MDAxNmFxvNC2OJlcdTAwMWFcdTAwMTc9tFx1MDAxMYRgI1xyIYxnsEipRJBZXHRNjZREM1lcXFx1MDAwMpyi6FGKxcdcdTAwMGJcdTAwMWaGcCzzwVVcYlxyvVx1MDAwNVx1MDAwMvZlY+vF55/O2t2nauv48MugvXf16cfBXHUwMDE1ny62lmZ/e9v9XHLj17c3RXBUOe+37rw9df3HYnYpwIbvn1x1MDAwNC4qcVx1MDAxMbooMUopkGVTg6u8pWdcdTAwMDZXYSVl7uBcdTAwMTJGIU4heGCNtVx1MDAwMcfO5oBcdTAwMTSucmhcdGIgXHUwMDAzNGRxOSAjiHJcYqZcdTAwMWNiKSeQdubxxTRcdTAwMTJcdTAwMDRMZFx1MDAxNFBuMOXjKCNYWv+RqXxyapjFpr50XHUwMDEwXGYjJ4jWvE6s1D6mkPYwcjSMPj0xOOmJdXzt4cPTtmxf3ahcdTAwMTM/XHUwMDA1N1xim9Ve7Fx1MDAwMlxiY26w4Fx1MDAxYfqCXHUwMDFhbFL3NJxu3ESIKMNcdTAwMTVcdTAwMTAp3Mah3+/vXHUwMDE4XHUwMDAxftXt1Fx1MDAxZTepPJikTKpgRDU3jFxi8DBwMS1VziiKjFx1MDAwMXMgXHSC+5SUQuWMajlhtO63257Ved99r1x1MDAxM403cdyWnyzam66Tk8fwo9LXxmmha5+YpdPkr5VcdTAwMDQy8YfR33+/n3h3oSvbo5Lz4uRx79L/z6rZ7buK+Ixhrlx1MDAxOZMzpNzlLvdcInz2RN1O49ZXoFx1MDAxNojmNFW5XHUwMDE51rQ4XHUwMDAyNU9cdTAwMTlIXHUwMDA1cDSlx+yaY00rXHT1JUm35sImXHUwMDEwL1dcdTAwMTR+ti5YQPyeJSfI59xrflBLS/s/l3LfW/I0OWL5sVxivuCwilE9vdQv12fzXHUwMDE505k7cpUmXGJcdTAwMTRcYoGMmyvKx1Q+5DqIgFx1MDAxMiFcdTAwMDRcdTAwMTBDXHUwMDE0Xdw4P1x1MDAwNCwhpVxyVMDdXFzTXHTFaWB4XHUwMDA29zCOXHUwMDE52GnrezkpXCKYYoxi/lx1MDAwNGQvg1x1MDAxNFx1MDAxOVx1MDAwZp5zUFx1MDAwNMNYr5GWXHUwMDE0Q8tcdTAwMTgsQX/wtERJaVx1MDAwNoWNVkpyyZTQkHK9akFQ6FH2qOSdaUZFUMwpjJeMXHUwMDE0K2MoVnz6Ql75JJIlZlx1MDAxNWh6XHUwMDAzv5RA2/Ox/IZhXHUwMDA0XHQ7tFx1MDAxM8EgkPi4XfNkXHUwMDE1XHTv4LFcdTAwMTZcdTAwMDbP5olcZs7QitJcdTAwMTRslWCtJFxc5jNcdTAwMWNcdTAwMDHGgqh5ylx1MDAxONjrpJXyWWsp0oCO5EZcdTAwMDCSKFFcdTAwMDI4I2m8JPl55SxS5EH2yPvOjCxcdTAwMTJrplx0JKJTXHUwMDEyepxDpGBcdTAwMDY4LTUp6zFcdTAwMGVZ29w4ucWuUP0tsbl95ZxXfTlY9nFyYFxyXHUwMDA09IHt1Cqp+Vx1MDAxOIdAXHUwMDFlh1xmqFx1MDAxMiPhXHUwMDA2I1x1MDAwNF/kXHUwMDE0RESkMpPLj1x1MDAwNI1XJlx1MDAxZkhDXHUwMDEzhTGHZPOtkUb2YfPFcubaXFyBPKFcdTAwMTftMeq/OVx1MDAwMVfIYuBqJY2tqE9fXHUwMDEwYNu/blx1MDAwZqtcdTAwMDbj3unNSbOmyMHaN7b0wIWmVpBdXHUwMDFiqZSwXHUwMDAzXGJcdTAwMTngcq3tbF3OoNlcdTAwMDEgIDdcdTAwMTdcdTAwMDZcXFx1MDAwZbJXXHUwMDEwkVx1MDAxZVx1MDAxOVx1MDAxOOFcdTAwMTYjXHUwMDE2K8NcdMDlXHUwMDAwXFyRKVP8XHUwMDFmuH9cdTAwMTC4+V60RyXpwHlcdPd0oT1cdTAwMDddyCVcdTAwMTQ1eHrofu7XpXvxq7Le9DaOf7Y/fz6+/LGx9NBVXHUwMDE0KfBHyqmiXHUwMDEyXHUwMDA0XVx1MDAwNrqMcWQ4t9OIXHUwMDE1aFx1MDAxZbLIaoBRRnHGXGJjoKxcZp9Q14PcXHUwMDAxXHSMNYeeseV3kUoj7mc8cylcdTAwMTSh4pVcdTAwMDK5SJz7Z8e7J25w1T350Vx1MDAxZmzc7eytNUNZMFxugDlcdTAwMTaMQdBRiimuVaoqnoxNUFx1MDAwMklcdTAwMWFcdTAwMTNwp5mQ879cdTAwMTiFLFTHV4pdKr6c96Z50VxuKFg2fvqBV5TClNmumZpWSO07ub08qG5+ObxsrK/fnlx1MDAwN+J4b/lphSBbeLKDUYaQ8Wk9SiNlsJ3xg+2s2MVccndcdTAwMTJkbbAlXHUwMDA3zOFl6XZcdTAwMWbRiiHgKKBbiKCaKZErXHUwMDA2KEq4XHUwMDA2J3mlsv7ZpFx1MDAwMjKaQKbLJFx1MDAwNVxmYYEpyXOKRra4Q5WhmFx1MDAxMSHMXHUwMDFi5ZRid7LHuCPNyCdFI466eFx1MDAwMoWCfrEj0dOLlPJeX1Y24Vx1MDAxY0lNtDBKXGLI/rOFXHUwMDAxxlx1MDAwNbI+Z9dCcWpSq3bmXlx1MDAxN0hcdTAwMWVdMthoQDpcdTAwMTKhzVx1MDAwYk/wLY9cdTAwMTNcdTAwMTlPm2lcdTAwMTJSuawtfe6D2y+C5Z40iLk17LWUXHUwMDAz/KlBzHtLSlx0oajiYEwhIVx1MDAxMMUhrIKomX698vlp4/i6urfryruoe0hMv1x1MDAxMX3dWnZG4DZtXHUwMDExTFPQd5ZcdTAwMDGzaVx1MDAwYsVcdTAwMDYkrq38gPbA6ULiXHUwMDAyhlx1MDAxYqC9gfCp4cD8lEzQXHUwMDE3QE9cdTAwMTiDXHUwMDEw1Vx1MDAwMi5TjXW+XHUwMDAwXHUwMDAxeZfAdsnz65RcdTAwMThvrVx1MDAwMFHcq/ao5Dt0xkhfXHUwMDA07LQ8XHUwMDFkn5sgjLaZw/TDiL1m55Z4XHUwMDBl+bV5Snb5trk46vhF85CXXHUwMDA215pcdESltPPqMNCcyuZcclx1MDAxNEtEQIcyw5nGgJrFVVx1MDAxMjHSRmktXHUwMDA1SCwstZ5Uj+B2XHUwMDE3XHUwMDAyUIFK2GXimuSHXHUwMDExJaRcdTAwMGWcavXmZie8VlxcXHUwMDE3dao9Krn+nFx1MDAxMdbFXHUwMDA1gZJcdTAwMTVcdTAwMDbUzlx1MDAxMOCQXHUwMDExT4/s5vb15UG3Ujlccu+2XHUwMDBl1ytsoPYrRZOgl1x1MDAwNtmSaGR3XHUwMDE2XHUwMDAxXHUwMDE1z5igKrtMj1x1MDAxOMu5XHUwMDE4Wt72S3pa8twrXHUwMDAyTFwiXHUwMDEwXGbA21x1MDAwNOjFpEZdUyN8QENcdTAwMWOua8ZcdTAwMTRcdTAwMDVcdJFbZc4451pcdTAwMTP6SuN1UUngSNd37i62XHUwMDFiP9yzjbWdwDn9eXjzs6DOSOxcXFx1MDAwZYhJXHUwMDEyMmHDSXqyTVJntHOMsVx1MDAxMZgzm1x1MDAxMt3f8NaKXHUwMDAylUKXXHUwMDFhXs1509xoJb1mLbdxXHUwMDExwVSotPZ9jFakS25PXHUwMDBm1k7Xe7+O62FwuH6MXbr0tGIoMsDXkFx1MDAwYmBlXGZcdTAwMWKjXHUwMDE1bZBcItb9qFLMyOLlg8+mXHUwMDE1amfcXHUwMDAxfeF4jDNN+JlcdTAwMTFIwlx0RJ94sJHp9GLke8VAiMBcdTAwMDJj/r/KLFx1MDAxOMHPl1RBXG5cdTAwMGKQsqFxwvxcIoKInZwllMKEQ7fn1zG8XHJmKXYqe1Qm+NOM1FJUclQlK1wiXHK1vjLDfMbyvl9WXrFNXHUwMDBmylx1MDAxZNI7q81otuRIlERSc6OJsfNcdTAwMWQoW1x1MDAxY7GI5NFlu1xuQMakiZb6XHS88ZyiY7lcdTAwMTTN+NpMRcfyWFT63Fx1MDAwN8dfXHUwMDA02z2p6Dh03pRcdTAwMDP8qZrj0JCnaVxyXjxVgjOioEVnmJ1YvqvR0s5wZlxiYo3dQ4RplopLw0lOQiFiXGaVIDMoXHUwMDExophcdTAwMTBcdTAwMThnXHUwMDBlf1ZlXHUwMDAyXHUwMDEzO81cdTAwMTAzXGJcdTAwMGbAw8JM2nfEqlwiXHUwMDAxYcFcdTAwMTZBIUayXFxcbmOkgUeItzdZsUiClO8tMFx1MDAxMlx1MDAxN1x1MDAxMlx1MDAxOWm1uuTSjk7zlJhP0lx1MDAxZrtcdTAwMDCUXHUwMDAw30L+w6lcdTAwMDHflzlcdTAwMDXymnRGpcSn4ut5d5pRaFx1MDAxNOcwqmRcdTAwMDcjIZmgRE5PLOWb3CwtsVDEscF2koFcdTAwMTFybPKkXHUwMDA0satBXGZcdTAwMTOFXHUwMDAxPCUrKZ/PK5rH0/ol1liI9JKqpDKikVx1MDAwNPlHIKM1oI7YhFx1MDAwNVlgpmBY0SdswbBcZrxSuD6idG+sLDdASlwiuYYkRoNOJGpCZUTYtTKScFx1MDAwNUhcdTAwMTOg3lx1MDAxZiqNby2BqVx1MDAxNDuVPfLuNCOtlG7rYkjhts5cdTAwMTRDjMaE0umzmIprjs+jy7NQRc2Ts1x1MDAwZebru79cdTAwMGVcbqhlubZ10Vx1MDAxONJlu1kpt9BMVyaG27pcdTAwMThky3eGXHRNKFBNMcM8f1tcdTAwMTeCXHUwMDE4J0VcdTAwMGIrqKCIMUj9qWRcdTAwMDVcdTAwMTO1IeBcdTAwMWFcdTAwMGXukrTEsq/jLs1yXHUwMDE2ur+LgoQwtfXmtPu7vLt/6KrT7Vx1MDAxZUbwyFx1MDAxMSlCW3u1++wneczqjeferk3Y1bhcdTAwMWVcdTAwMWbW5LhcdTAwMTEsQlxc29L//H73+7/nXHUwMDBiXHUwMDAzXCIifQ== MarginPaddingContent areaBorderHeightWidth"},{"location":"guide/styles/#width-and-height","title":"Width and height","text":"Setting the width restricts the number of columns used by a widget, and setting the height restricts the number of rows. Let's look at an example which sets both dimensions.
dimensions01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.height = 10\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
This code produces the following result.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path.
Note how the text wraps in the widget, and is cropped because it doesn't fit in the space provided.
"},{"location":"guide/styles/#auto-dimensions","title":"Auto dimensions","text":"In practice, we generally want the size of a widget to adapt to its content, which we can do by setting a dimension to \"auto\"
.
Let's set the height to auto and see what happens.
dimensions02.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.height = \"auto\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
If you run this you will see the height of the widget now grows to accommodate the full text:
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0 will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
"},{"location":"guide/styles/#units","title":"Units","text":"Textual offers a few different units which allow you to specify dimensions relative to the screen or container. Relative units can better make use of available space if the user resizes the terminal.
%
) symbol and will set a dimension to a proportion of the widget's parent size. For instance, setting width to \"50%\"
will cause a widget to be half the width of its parent.vw
unit sets a dimension to a percentage of the terminal width, and vh
sets a dimension to a percentage of the terminal height.w
unit sets a dimension to a percentage of the available width (which may be smaller than the terminal size if the widget is within another widget).h
unit sets a dimension to a percentage of the available height.The following example demonstrates applying percentage units:
dimensions03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.height = \"80%\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
With the width set to \"50%\"
and the height set to \"80%\"
, the widget will keep those relative dimensions when resizing the terminal window:
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0 will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0 total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0 the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0 its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
"},{"location":"guide/styles/#fr-units","title":"FR units","text":"Percentage units can be problematic for some relative values. For instance, if we want to divide the screen into thirds, we would have to set a dimension to 33.3333333333%
which is awkward. Textual supports fr
units which are often better than percentage-based units for these situations.
When specifying fr
units for a given dimension, Textual will divide the available space by the sum of the fr
units on that dimension. That space will then be divided amongst the widgets as a proportion of their individual fr
values.
Let's look at an example. We will create two widgets, one with a height of \"2fr\"
and one with a height of \"1fr\"
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.height = \"2fr\"\nself.widget2.styles.height = \"1fr\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
The total fr
units for height is 3. The first widget will have a screen height of two thirds because its height style is set to 2fr
. The second widget's height style is 1fr
so its screen height will be one third. Here's what that looks like.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
"},{"location":"guide/styles/#maximum-and-minimums","title":"Maximum and minimums","text":"The same units may also be used to set limits on a dimension. The following styles set minimum and maximum sizes and can accept any of the values used in width and height.
Padding adds space around your content which can aid readability. Setting padding to an integer will add that number additional rows and columns around the content area. The following example sets padding to 2:
padding01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.padding = 2\nif __name__ == \"__main__\":\napp = PaddingApp()\napp.run()\n
Notice the additional space around the text:
PaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0 that\u00a0brings\u00a0total\u00a0 obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past, I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0 to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0 there\u00a0will\u00a0be\u00a0nothing.\u00a0 Only\u00a0I\u00a0will\u00a0remain.
You can also set padding to a tuple of two integers which will apply padding to the top/bottom and left/right edges. The following example sets padding to (2, 4)
which adds two rows to the top and bottom of the widget, and 4 columns to the left and right of the widget.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.padding = (2, 4)\nif __name__ == \"__main__\":\napp = PaddingApp()\napp.run()\n
Compare the output of this example to the previous example:
PaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0 mind-killer. Fear\u00a0is\u00a0the\u00a0 little-death\u00a0that\u00a0 brings\u00a0total\u00a0 obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0 pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0 past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0 inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0 path. Where\u00a0the\u00a0fear\u00a0has\u00a0 gone\u00a0there\u00a0will\u00a0be\u00a0 nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
You can also set padding to a tuple of four values which applies padding to each edge individually. The first value is the padding for the top of the widget, followed by the right of the widget, then bottom, then left.
"},{"location":"guide/styles/#border","title":"Border","text":"The border style draws a border around a widget. To add a border set styles.border
to a tuple of two values. The first value is the border type, which should be a string. The second value is the border color which will accept any value that works with color and background.
The following example adds a border around a widget:
border01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BorderApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Label(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.border = (\"heavy\", \"yellow\")\nif __name__ == \"__main__\":\napp = BorderApp()\napp.run()\n
Here is the result:
BorderApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2503nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
There are many other border types. Run the following from the command prompt to preview them.
textual borders\n
"},{"location":"guide/styles/#title-alignment","title":"Title alignment","text":"Widgets have two attributes, border_title
and border_subtitle
which (if set) will be displayed within the border. The border_title
attribute is displayed in the top border, and border_subtitle
is displayed in the bottom border.
There are two styles to set the alignment of these border labels, which may be set to \"left\", \"right\", or \"center\".
border-title-align
sets the alignment of the title, which defaults to \"left\".border-subtitle-align
sets the alignment of the subtitle, which defaults to \"right\".The following example sets both titles and changes the alignment of the title (top) to \"center\".
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BorderTitleApp(App[None]):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.border = (\"heavy\", \"yellow\")\nself.widget.border_title = \"Litany Against Fear\"\nself.widget.border_subtitle = \"by Frank Herbert, in \u201cDune\u201d\"\nself.widget.styles.border_title_align = \"center\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Note the addition of the titles and their alignments:
BorderTitleApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Litany\u00a0Against\u00a0Fear\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2503nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0by\u00a0Frank\u00a0Herbert,\u00a0in\u00a0\u201cDune\u201d\u00a0\u2501\u251b
"},{"location":"guide/styles/#outline","title":"Outline","text":"Outline is similar to border and is set in the same way. The difference is that outline will not change the size of the widget, and may overlap the content area. The following example sets an outline on a widget:
outline01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.outline = (\"heavy\", \"yellow\")\nif __name__ == \"__main__\":\napp = OutlineApp()\napp.run()\n
Notice how the outline overlaps the text in the widget.
OutlineApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503ear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503otal\u00a0obliteration.\u2503 \u2503\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503hrough\u00a0me.\u2503 \u2503nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503he\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Outline can be useful to emphasize a widget, but be mindful that it may obscure your content.
"},{"location":"guide/styles/#box-sizing","title":"Box sizing","text":"When you set padding or border it reduces the size of the widget's content area. In other words, setting padding or border won't change the width or height of the widget.
This is generally desirable when you arrange things on screen as you can add border or padding without breaking your layout. Occasionally though you may want to keep the size of the content area constant and grow the size of the widget to fit padding and border. The box-sizing style allows you to switch between these two modes.
If you set box_sizing
to \"content-box\"
then space required for padding and border will be added to the widget dimensions. The default value of box_sizing
is \"border-box\"
. Compare the box model diagram for content-box
to the to the box model for border-box
.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGlT28pcdTAwMTL9nl9Bcb/Gysz0bJ2qV6/Yl1x1MDAxMCAsXHTJq1spYVx1MDAwYizwXHUwMDE2W8bArfz31yNcdTAwMTZJliWwMY6TukpCsEaWRjN9Tp/uWf55s7CwXHUwMDE43XSCxfdcdTAwMGKLwXXVb4S1rj9YfOvOX1x1MDAwNd1e2G5RkYg/99r9bjW+slx1MDAxZUWd3vt375p+9zKIOlxyv1x1MDAxYXhXYa/vN3pRv1x1MDAxNra9arv5LoyCZu+/7ueu31xm/tNpN2tR10tcdTAwMWVSXHRqYdTu3j0raFx1MDAwNM2gXHUwMDE19eju/6PPXHUwMDBiXHUwMDBi/8Q/U7XrXHUwMDA21chvnTeC+Fx1MDAwYnFRUkGpzPDZ3XYrrqxUXFxLieyxPOyt0tOioEaFZ1TjIClxp1x1MDAxNtVJX+2Y48anXHUwMDBmK8tcdTAwMDfXjWZ40lo+Tlx1MDAxZXpcdTAwMTY2XHUwMDFhh9FN464h/Gq9301VqVx1MDAxN3Xbl8GXsFx1MDAxNtWpnFx1MDAwZp1//F6tXHUwMDFkuVxuPFx1MDAxNnfb/fN6K+j1Ml9qd/xqXHUwMDE43bhzLKn/XSO8X0jOXFzTp1xuXGJcdTAwMGa1NVxcXHUwMDFhRW9rRNIg7lx1MDAwNkJ5Qlx1MDAxYtDKMlx1MDAwYkYrXHUwMDA1Q1VbaTeoL6hqf7H4SOp26lcvz6mCrdrjNVHXb/U6fpd6LLlucP/SiqFcdTAwMDfGXG4trFx1MDAwMouQvE89XGLP65HrXHUwMDEzwT1mXHUwMDE1glF3T9NJbYK4Y1x1MDAwNENtuNaQvKWrQ2erXHUwMDE2m8jfw1xyW/e7nfv2W+y5XHUwMDBmqfq7qq9ccttX2sZSnf/j+uvG9tnJPr+48LdVyGorXHUwMDFmLlx1MDAwZVx1MDAxZe+VMUi/221cdTAwMGZcdTAwMTZcdTAwMWZLft7/llSt36n5d2bmXkRIxplcdTAwMTQqeaFG2Lqkwla/0UjOtauXiWXGZ3++nVx1MDAwMFx1MDAxMFx1MDAxYaFcdTAwMTBcdTAwMTCAXHUwMDFjJTPPR0TQ6e5o4P5t31x1MDAxZixvfP+6Jzu6WYCIXpvQPTZcdTAwMWWGvvVcdTAwMTRcdTAwMWPgKTSA8VxiXHUwMDAymlx1MDAxYq1RSc6MzKCBc/C44Fx1MDAxYYAjWSAyVYhcdTAwMDZ1XHUwMDA2taosRcNfsqqDM5VHXHUwMDAyKONZpSTBUuVBIFx1MDAxNHpcdTAwMDJcdLNMXHUwMDAyMDKMXHUwMDFjXGK4Je5ijIOdLVxiqns7X+snm8e1XHUwMDFixFZ/l3dMvVx1MDAxNv6GIJBWXHUwMDE3gYBcYlxuXGZcIpfPXHUwMDA2XHUwMDAxRqfB7sdGY1x1MDAxYsz+5kr9495BuLkxmVtcdTAwMTCFbsHv1afrXHUwMDE2XHUwMDEwPCEtKs6UloZZkcWBXHUwMDA2jyhcdTAwMThAKaG4JNdQiINAXHUwMDFi81x1MDAxMq+Q7vNHXGJwaYdtXHUwMDFlXHUwMDA01YLqNWOT34K1o5Poy97H9kmwXHUwMDFmNcLLz8fV76NNPlxurqOUxb8tu+3h0fopu8L95tGn6HJ5aXn1eFuL5yGp9L5Tr27m6rfPfeCvw32mnmmlamxcdTAwMTHkpSaq1TiG37tcdTAwMTZcdTAwMWSz/vXHSudQXpyurP6wrZrembJcdTAwMTJcdTAwMWPT8z2NeOdWUFgrNaAwRmJcdTAwMDbxgNZcdTAwMDPpXHUwMDA0IEeUWlxmU9H0ZKBcdTAwMTZ5vFx1MDAwYjVcZndcdTAwMGVcbiW5YvNcbjpvmsaYdHq7XHUwMDE1XHUwMDFkhrdBrFEzZ9f9Zti4yfRbbKVU049+9zxspduyXHUwMDE30DOD2Mdnrl5qhOfOjlx1MDAxN1x1MDAxYsFZ1sCjkFx1MDAwMqfH4qidevMqPd2n23W3asNv0e6G9GS/cZStyUTYXHUwMDAyI1xusSWsZEKq57vTpUF978dutHVzc1xm6+2br6dflr75M4yy2ITgXCLhiNJyi1JcdTAwMDCyrDtcdTAwMDVhXHR65G+F0lrSP/Nq6EpcdNoydGkjKVx1MDAxYzMptz9cdTAwMTNvurRdw9Wr3dMts1x1MDAwNlx1MDAxZjuDXHK5PMDOL1x1MDAxM5Avw+6+X6uFrfN5XHUwMDAw70NVJvOMglx1MDAwZp99QC93XHUwMDAyUY+RXCIpl1x1MDAxZvNcbl5yfSVaWFxi7YlcdTAwMTlpYTlCXHUwMDBii9Tz7tHLhdRcbq36g3wj5PC1QsVUq1x1MDAwNWorfzTIcDTIqvStoFtcdTAwMDKzZlirpVx1MDAwM8Ms0p5cbuiGwZepZylcdTAwMDLLY1IsXHUwMDE0qFxcOM/CpFx1MDAxNM9cdTAwMDZitX/bWPmxXHUwMDE0+T40XHUwMDBlj1pcdTAwMWbPw5NOrVx1MDAwMIjVbrvXq9T9qFovXHUwMDAyoyxcdTAwMDLj1FWqS9BQkIekQJXT5CqDRc6ZR1wilYFVxlpGTqxcdTAwMTCLz8jPlGLx6Vx1MDAxY1xyXHUwMDEyXHUwMDE56Lxv1Vx1MDAxNtEgn3F+ctnfq96eXW037GF7XHUwMDBm+X57U/k4hYCyxj5/qHZPbtesXHUwMDFhyNslXFw7XHK7dj5TPnfPXHUwMDFmXHUwMDA1rZLgT3AgzjdqXGYnV97UY2OrMOkzdWxxMmlkoLlLsFx1MDAxYiVTuVx1MDAxNHdcdTAwMDPJOVx1MDAxNVtcdTAwMGLUXHUwMDFhWnElhlE/PZVcbpw8LpJcdTAwMWIzXHUwMDFjJLd6xFBcdTAwMDBYj0JRbUFw61x1MDAwNLVcdTAwMWPGXHUwMDE5xalWKlx1MDAxNJMgLa7qrL1gL/K70XLYiqXa+1x1MDAxNNjIXHUwMDEzVvtxt3qMSWRKWmpdgVxmXHUwMDEznC2e+524kz1uUFx1MDAxYdTuMomJOFh4XHUwMDFjK7vzYitwc7Mp+ss7XHUwMDFi+uhi/Zj8WCNYekDnI+ZcdTAwMTeDVq20Slx1MDAxNeZRXGKHwFx1MDAxNZBp0N8kdnmslPBcdTAwMTCpOiCQrjNaK1NUqdFuKVepht+LVtrNZuiU3n47bEXDTVx1MDAxY7flklx1MDAwM3w98HP6mF4qXTbMXGZcdTAwMWR3xyyjJr8tJKCJPzz+/vfbkVdcdTAwMTeasjsqOStObvcm/f+4op2DKkxhc1x0zKF7XGZKXHUwMDFibSwzpbTJpLvlnlx1MDAxMZI4nNpfMMwltdBcdTAwMTOCsKKEJTzxYrXw4qRW0lx1MDAxYiVht+JEwZKzXHUwMDE5R90v0Fx1MDAwNq/gw8eJXG7yUfdyu1tLi/tfXHUwMDE3dN/XZDJJwpUsnJjA3aBcdTAwMWaF3qlBqqfwW67SpjNcdTAwMDY1deyCXHUwMDE0XHUwMDFlkMKnuJskPcEziVx1MDAwMe/kXGLzNLNGXHUwMDEzsIFcdTAwMWPecEJgeuAlXHUwMDE3p7R2ro34W1oxXCJBTSxPjKtBMpD0x/JcXFAuXGYyhlx1MDAwMn9TNTLsP59cdTAwMTJcdTAwMDWGoTVGS01q0VKclFx1MDAxM1x1MDAwNdazmjqOSJm5aTYyrWX+dE1QaFDuqORtaUxRUMwqXHUwMDE0y1x1MDAxNFx1MDAwNjqMXHUwMDE0PSGNP19cdTAwMTWUz3mZY1bhhFxyXHUwMDE0SiphWHaCh+SS7NJYRnJNkVx1MDAwNac4dvqsotHB06lngopMJf3TtGKssFx1MDAwNFx1MDAxMUZcdTAwMTdwqfNRXHUwMDBlt4bCXHUwMDAxnCRT/9vxXG7ziClcdTAwMTSBQ3CjiFaS5khcdTAwMDKgXCJcdTAwMWFcdTAwMTk9O+83p5FcIlx1MDAwYnJH3nbGpJFYNo1gXHUwMDExhOJMpDUomLYqueIpXHUwMDEy0cd0Tn7b3T9cdTAwMWaovY3o1lZcdTAwMDbt7mQkMruxcsmUZ610Q+HkwFxmXHUwMDFmykJq4Vx1MDAxMblQkVx1MDAxNKhcZvLXXHUwMDFizpPoWdTU4kZcbklcdTAwMDYgR1xm7zGPWSlcdTAwMDGsJDVlNaRH9Vx1MDAxZcSJdrGRSc0km1x0iVx1MDAxMM+mXHUwMDA2l16DRLI3my62M2VTXHUwMDA1dnGvuqMyokOnXHUwMDA0bVxyavjsI7RcdTAwMTGIbUhrPz/qODw+Xr3+hD++NU5cdTAwMDbtlVUxOPig+nNcdTAwMGZtLjwmjFFcdTAwMWFJXHUwMDA0gM7mQIH0gbGK2oFcdTAwMTSEpk+vlzKQpJBcdTAwMTVX6Vx1MDAxMYRcdTAwMTSkIVx1MDAxNo9mxLxcdTAwMThO6Fx1MDAxMnbWUFx1MDAxNoCQxDz/QvnhyPeiOypJXHUwMDA3Tkvbo8XhsylpT0EygHq+tF/fXGYvwP9yeHKw9v3G9PR688BuzT10XHUwMDAxPDBAYVxmMi7lkFems1x1MDAxZfltyzl55Hio/lx1MDAxNaW9Mlx1MDAwNlx1MDAxMJVcdTAwMDYg8YVyRPaPOF5cdTAwMTC9W+DGpfRVzidzelx1MDAwN6TXkDNW9lxc8pQpTWX8XCIjxPXF8vbpkeZXl37YPFx1MDAxZfRcdTAwMWHLXHUwMDExT09cdTAwMWRNZ1x1MDAxMShcdTAwMDJcdTAwMTbIKFZcdTAwMDOOViPafFx1MDAxZYFTME2UJ8m6XHUwMDE1oHrAU8FcYsZrksirav1KsU3FxTlzmlx1MDAxNq9wLorlvlx1MDAxMoxcdTAwMTGNyedrgsp1/fvR5dbFWuvbh481pj/rw09zPzVWUiylwFquSbNcIiP1lWVcdTAwMTZccp5QXHUwMDAy3ChcdTAwMGXxrFx1MDAxNq+3Ror8XHUwMDA2Z4JcZl4yN8/BwKj5fORzgOojjXGLXHUwMDE1VC5nXHUwMDAwZCDaoJpgqvyLiIXE6lxcXHUwMDEwXHUwMDBi8zhDNMqNgVx0hdqkoPSYndQgUbhoXHUwMDE2uFL4h7JKiTm5Y9iQxuSUosFJa1xus5DWcCZcdTAwMTlcdTAwMWIjXHRZ3uvzSihMeU5cdTAwMTRKaS1YJYaUikBPXHUwMDEzj1x1MDAxM4ujdvmU11MqKlx1MDAwMWTJuCRIt1x1MDAxYc6KXHRiipeMS5b7ioypjTVnqVxc3Jbe98HuS2hutuOdm3e9ljKAXzXeeV+TUkYoyjtcdTAwMTBcdTAwMWZcdTAwMTdSQiwySM2PkXhY2j483Fplllx1MDAxZKxcdTAwMWT8WGnsbKnjrVx0XHUwMDA3JmbHXHTIPa1Rc5JcdTAwMTkk45TKJlx1MDAxZVB6XHUwMDFjhdVCXHUwMDE40NrK11t/wz3rVsBcdTAwMDI9xFxyQOmU/EtcdTAwMTSG8khcdTAwMDVZXHUwMDA0i0ZKrUx+XHQquVxmdGNcdTAwMTMzjl6IU8FMgr4/PFx1MDAwZlEp7ta4ON+jYzr7XCJoW1W4+EdwXHUwMDA2XGJcIj2g/uTKOlx1MDAxZV2cXHUwMDFlX0U7387WQrH+5Wrtw+lg3pFNze2RXHUwMDE3Z8LSoVx1MDAxZIqz0HbJIGFcdTAwMTW5e+Pmmr4mtEFow9yQXHUwMDExcVxijExLMM+6OaBWaYoxOeaCh3heoeapqGI2uDYgXne88bfFdUGfxqW57lx1MDAxY1x1MDAxM9UlXHUwMDEzlFThsiDBlFx1MDAwMW7ZXHUwMDE4XHUwMDEzXGZbS2ebn7+sRlx1MDAxYqe3N7jx+Vx1MDAxY7aD02kvXGaa/nxp48JEQ3yqKebnILJcdTAwMTOUXGZRKvlrZrTlbjFcdTAwMWRcdTAwMGVVbHq4ttTLwjDDrTI8PTybXHUwMDFhMDBWulx1MDAwNFx1MDAxYVx1MDAwMFx1MDAxOIGc59bPc0GBl8suzTgrQO2X0lxi089cbny++bz27Wj95OKK0LpcdTAwMTNt3X5a2d0uSDdyQOGmlFx1MDAxOVCMJNaIqcxuzlx1MDAxOTlHaigmKTTmf2y6scik4sK8NU2NVqB4taFVaMmN4vPXXG53t0Cavf7tdme9YTe+Nb/uhLti3lnFLVx1MDAxNSbbc0pcZrQxmF1taKRHoolcdTAwMTlFP5hg/PXEgqWIg7ulZZLiLyPtiEQj97hcdTAwMTVcdTAwMWNccuNuzbKFXHUwMDE0wz2QilIxoGZMKsJcdTAwMWGchlp4MakwjyCkXGJJWjO00pr0eox7SqFWXHUwMDE0JFx1MDAwMJWhdpTMilx1MDAwN+X9p3FKoUG5o5KzpTEppSjZaIrXTYJbiWdcdTAwMTV//mSl8n6fU0LhXHUwMDE2PWDAOVx1MDAxMDzIv1x1MDAwZi3r0tTyUlPUrIxb7FaypdWLk42JPirbe4BcIiVyrVx1MDAwMFx1MDAxM0RcdTAwMTgvyTaWS9CMrY2VbSx3QqX3fTD8XHUwMDEypptttvHOeFNcdTAwMDbwq5KNd1x1MDAxNZlMY5BwK5RcdTAwMThWU4huxtjdq3yjprmdXHUwMDAzXHKeUlJQjOYmobCko+M5TtJlXCKZXHUwMDExQFElXHRcdTAwMTBePJxJN/Lli4YzyYJJSVx1MDAxMztcdItSKVx1MDAxY7U9ifBQkY7Qd9tUMsjHLmi15NJMsunjbzdcdLriVoFyt5OMdfNL2ajhS+2h5syCllTKpEzJ96yQXHUwMDE5vZdBToD8TjKjUmJUcXnensaUXHUwMDFhxdFLSnRcdTAwMGUxXHUwMDBiUiBFfTVcdTAwMDazlO+FM7fMXCI8a4lcdTAwMTZAo0tGXHLNnpTK01x1MDAxMq1LXHUwMDBmoYSS9ZYvJ1x1MDAxNivjdVx1MDAwMlx1MDAxNH0wRcH6qOjFepriSW7BXHUwMDA1llx1MDAxNvJrtrj7srRcdTAwMTNtJjtcdTAwMGa8Mpo9JDOaXkpcdTAwMTjLjeYmtfjoIVwioV5cIk/odlx1MDAwNFaotMoss8jQx+jNuHL08WfEL5Vio3JH3pzGpJXS7V+wZFx1MDAxNlx1MDAxNrqlMEaPseji5lxmev1PW8dw+vX6y/fT6569Piqa3jlX279I5lHrut0nQIGgTshcdTAwMGWluP15rZvFgi5nJYUuli4v3/+Fe6SUXGZcdTAwMTZtXHUwMDAwIzxcdTAwMDBhXHUwMDE41aFgpjZcdTAwMTlcdTAwMTEhkU8yU/vfnWCet1x1MDAxM8yb+5su+p3OYUS3fCRFauuwdlx1MDAxZv8kt1m8XG6DwfKInZrP4sNVOW5cdTAwMDSHkMC19D8/3/z8P1KdJ/cifQ== MarginPaddingContent areaBorderHeightWidth
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9vr+CYr/G2nn0vFJ161x1MDAxNs9cdTAwMTBcdTAwMTJcYpCsgdzaSim2sFx1MDAwNbJlZFx1MDAxMSBb+e+3RybWw5KwjU2c7CqpXHUwMDA0NLLU1vQ5fbrn8fdva2vr8f3AW3+5tu7dtdzAb0fu7fpcdTAwMGJ7/otcdTAwMTdccv2wj00s+X1cdTAwMTjeRK3kym5cdTAwMWNcdTAwMGaGL//4o+dGV148XGLclud88Yc3bjCMb9p+6LTC3lx1MDAxZn7s9Yb/tf9cdTAwMWW6Pe8/g7DXjiMnfUjDa/txXHUwMDE4jZ7lXHUwMDA1Xs/rx0O8+//w97W1v5N/M9ZFXit2+53ASz6QNKVcdTAwMDaCkMWzh2E/MVZIXHUwMDAyyihcdTAwMTi3+8NtfFrstbHxXHUwMDAyLfbSXHUwMDE2e2r9rue/8+NmcPL57IB5XHUwMDE3LvGvXHUwMDA2nfShXHUwMDE3flx1MDAxMLyP74PRi3Bb3ZsoY9IwjsIr79Rvx11sp4Xz48+1w9hcdTAwMWEwbo7Cm06371xyh7lcdTAwMGaFXHUwMDAzt+XH9/ZcdTAwMWMh47Ojl/ByLT1zh79x7nBKtNRCcVwiOOhxq/082GaiOVx1MDAxN1xmXHUwMDE4SC5cdTAwMGKGbYVcdTAwMDH2XHUwMDA0XHUwMDFh9jtJjtSyz27rqoPm9dvja+LI7Vx1MDAwZlx1MDAwN26E/ZVed/vwlVx1MDAwNTFcdTAwMGVXmkmmXHUwMDA114an36br+Z1ubI1h1CFaXHUwMDE4rsToaVx1MDAxOWu8pFskp5wrotm4wZoweN1O/OOv4lvtutHg4eWtXHUwMDBm7S9cdTAwMTnzreU7RefKOlim51xy8TZO7k7J3edBXHUwMDEzneY0eHfahPG9ct7oRlF4uz5u+fbwU2razaDtjnyMSslcdTAwMTlcdTAwMTB8+6D5uD3w+1fY2L9cdIL0XFzYukrdMjn77cVcdTAwMWNokIZVoYFqYjRcdTAwMTDK2NR4ODm97MZcdTAwMWLhtup97Hxccj5+eCvfvb2vwMMwRGzPjIbCp1x1MDAxZVx1MDAwM1x1MDAwM39cZlx1MDAwYpSgt1x1MDAwYsZcYjFcdTAwMDZ9jGb8yH5eoP9RySRBJzXEyKJdKVx1MDAxOMRcdTAwMDVvt6BcdTAwMTZcZr9DS3pcdTAwMTdiXHUwMDEyXGJcXChHXHUwMDBiXHUwMDAxUisxiVx1MDAwMSaMw4yRmiA0XHRcdTAwMTOTXHUwMDE44JxqLkHA82Kg9e7tefds78/2vTH9m0M6UN22/1x1MDAxM2JcdTAwMDA0VGKAUMJcdTAwMDDUXGYxYftcXJxcXDc9ev9n8+xEXHUwMDBm995cdTAwMDSXXHUwMDFmTueLXHSsXG5cdTAwMDVtd9hdbEyg6GTC8jBwXHLAqVB5XHUwMDFjKOooQzlcdTAwMDEjXGZjylTiwJNKPSUooH9PQoBmYtSDzzNcboDxKXv5szj94U5M3vpcdTAwMWRcdTAwMTF/jravTuLgXCLce/Wm3Olj7y7O+PyLutteXGZOXulX/a2d/YbYi09aXHUwMDAxOT6/m1x1MDAwZUtcdTAwMTX3zVnxYtov8uMgmrMzKyiVrkInSCRFaVx1MDAxNJlcdTAwMWGcXHUwMDA3XHUwMDFmVaB29oP30cX713s3w43b1n60YME2Y4iaQq8x6TCigVx1MDAxOcWI4kJCXHUwMDBlm/hcblx1MDAxY661tGKNXHUwMDBiMLxcdTAwMTKbT1x1MDAxNWySTUKTiVwiMlx1MDAwMcMkXG6yZUSjRTpj2ulhP37vf7XvnZHc2V2351x1MDAwN/e5fku81DqSXHUwMDFidfx+9lVcdTAwMGU9fOZIN+Wu3lxi/I714/XAu8g7eOxjfjNujsPMN2/h0128XfS6XfxcdTAwMTZh5OOT3eBD3pK5sMVcdTAwMTWtwlx1MDAxNkXppylcdTAwMTAxfeR7c35w+nZn+PntznHcUTuvOifdT3NGvrmyITJcdTAwMWa6qHFAXHUwMDAxXHUwMDEwXGZrhGNOkkeX4tIxkoNAiYgvg6uloSujMWrQRVxyXHUwMDEzXHUwMDEyreR68fCqXHUwMDBiUFFHXHUwMDFlNFx1MDAwM/r5LrzqkyDUp2Hv4vCHib2ngffIbbf9fmdcdTAwMTXQ+92U+UJj5itcdTAwMTfhK4iNXHUwMDE3POMmj8G3XtmsKnyphDrhisjF5OlZhCuUXGJXllx0xlx1MDAwZvhcdTAwMDVcblx1MDAxOMzR8X+d8MgnXHUwMDEwtoXNaNVcdTAwMWG+K7dcdTAwMWNmplx1MDAxY2Yt/JRcdTAwMTfVXHUwMDAwree329k0Lo+1x7KvXCL8cnbWYrA+gzSqXHUwMDEyiEQxRtksXHUwMDE5ZGfbb59s3HXce/ewXHUwMDE37t1cZm72gFRcdTAwMDCxXHUwMDE1hcNho+vGrW5cdTAwMTVcdTAwMTihXG6MXHUwMDBiXHUwMDE3qkkxXHUwMDA1OCgjXHUwMDA0l0LlXHUwMDBii4JqR6AyJJpQplxyiEooTlFLqYXi4/VcdTAwMTSDykaWXHUwMDA0V8JcdTAwMTlVmstnriY2L11/6+p6p8ng3WB/+PngVu2cPyn7XHUwMDFi3fe8/fVi19/a273YdU9PT10uP7Y+rWaFZvT8MmwhdqrAhXxcdTAwMGWU80xF7DFs1b/pmbFVWaFZOLZcdTAwMThcdTAwMDOHalx1MDAxNKuGIbKooDxcdTAwMDcujSrWgNFcdTAwMDQ1LJGELS9cdOTUYWAoXHUwMDAzRTlQLUuq9txcdTAwMDLdSI1g0txcdTAwMTBcdTAwMDZFnKH1wsBcXCliYulzx8Bh7Ebxpt9PpNrLXGbSMFx1MDAwZbZukk51XGJKXGZMXHUwMDEwNL5cXFsqTkG23nFcdTAwMDdJXHUwMDE3O1x1MDAxNN1VXHUwMDE5aS9cdTAwMDOTdtHaeExrXHUwMDE0w+KtY3bNdjY25T5rvTmmh/y8t/1cdTAwMWSaY8Cve/12rUlccuIwXHJcdTAwMDYlXHUwMDExJ1rjX6kmjGKOMWhcdTAwMGVnXHUwMDA2r1NSXG5VZVR5UJowKnCH8VbY6/lW51x1MDAxZIV+Py6+4uRdbli0dz13Qlx1MDAxZuOXyrZcdTAwMTVpYWDvmKfT9Ke1XHUwMDE0Mskv45//elF6daUn26Mx4cTp7X7L/j+raEfXXHUwMDE3xdNjsYCRx8pYPT2hlTvLs1x1MDAxMtqcwlx1MDAxZDRihitqhLKygFx1MDAxNataysGEXFxK7Fx1MDAxZMy6dbVaeHJVK33ZdXk31cJIhO3PI1xyllx1MDAxMMJnyVxuJvPuzTBqZ8X9j0u7XHUwMDFmLJlPkVBUlpVcYmbSKIXeP/2gab1IW8yA0cLRXHUwMDBiRDtSXHUwMDEwJE4g3GiTwedIjmAzZkVcdTAwMWP9TGnk0KWhXHUwMDE3g5yQ0lx1MDAwNjdkcNCspESNPM/xXHUwMDFhjpaCLVx1MDAxM9CJpNygnVx1MDAxNNRcdTAwMWMltVWQI8VcdTAwMDD6mCpQxGilJGDXXGKt02g0Vlx1MDAwNdrRkmEqRFxmkahUICtmfnVRUOlP9mhMutKMqqCaVLjkxdNjUsHwx7iZZWZS/fyUXHUwMDE1Jlx1MDAxNVx1MDAwMIZZu1accZpGkIRTXHUwMDE4qlWCnlx1MDAwYlRcdTAwMTCioLqU93ROkYZcdTAwMTJI1LN9Xiqcc6SiNNOIXHUwMDEwNFx1MDAwNclDTiQ50tY5XHUwMDA05mT/XHUwMDAwViHYb0YgNFx1MDAxOFVcdTAwMDJJJX1cdTAwMWJp/lNFXCLlk+h+clx1MDAxMqlyIHtMus6MJJJoplx1MDAxMlx1MDAwZdGiejyAII1cdTAwMGL0yOnHynfc+91B0CHNe9o6+nIs2NZ1pz9cdTAwMWaFPN9YOShw8ItcIj2D4sJQmtclglJcdTAwMDdTPWCUXCLhSrk8XVwiMN2XmOyXliCpU6xOfs8yXHUwMDEwPVxcSzPPdMbVJo38zVx1MDAxNovlXFzbQoFcXNKL9lx1MDAxOPffgoCLwKxcdTAwMDIuYlpcYok6e2rcfo1JTM8uL4/5K7155DF+pE/C1cetclx1MDAwNFKmRKFjU6g8bJUmXHUwMDBlalx1MDAwMi2JoFx1MDAxY4XZ8opcdTAwMDGA2ldQkVx1MDAxZFx1MDAxYlx1MDAxOKOWODyRhSWwTSZcdTAwMGZgXHUwMDE2NMeg3r+wTdpcdTAwMTZcbtvJXrRHI+3ARal2wyonp1FJmJI0XHUwMDFik1x1MDAxZkNu97T1seG7uumfiNthXHUwMDE0XGbgar9q4G91kGvszFHCXGZTzNhZNPmBXHTJXHUwMDE0akOpNGWYWGUnky9etVx1MDAxYrt4g9tcdTAwMDVcdTAwMDE2PYCSup5cdTAwMWShxORBg5DKlutFxpxcdTAwMTGUNbdF2J82XHUwMDAwl2f8XHUwMDA0iOBcXFx1MDAxYqFcdTAwMTRXoFVWi49HJ1BcdTAwMTNJ4Fx1MDAxOG64ydVcdTAwMDRyYl1cXEf87O71wac+XHUwMDA0fPPr8aG5fffpkbGJZXLIUmV8o9qlkuZJb1pcdTAwMTSvUKKqS4xIYVxcIK9MX2I87X9yNztcdTAwMWJcdTAwMDfXsblcdTAwMGVft44/6aG6Wn1iUY62Yy+Gg9CSZtZtJcSiqMOMXaYkUVx1MDAxNDC+vFx1MDAxMU9MXHUwMDE5gFx1MDAxM84lXHUwMDEwUJiz8ZJygKHoKlxuXHUwMDEzXHUwMDBlwTRXYrJcdTAwMTiAmFI8y0k/O62gMqaYvFwiWVwiLlxieiOdZFx1MDAxNe1IXHUwMDBlXHUwMDE4XHUwMDE4XGYjnFxuUVlH/EexSrU72aPoSDMyStWoo1bV1UXQXHUwMDA0I7NR009cdTAwMTUs769V51x1MDAxMyRcZoxvmml0VU15fqWX5MqRQlx1MDAwM1x1MDAwM8XxXmp583xTXHUwMDE41I43XHUwMDAyp9RcdTAwMGXaz05cdTAwMThPXHUwMDE5bqyPXHUwMDE0OVebaSZSvbStve93v19cdTAwMDbNzTWMuTfqtYxcdTAwMDP8qGHMXHUwMDA3S2pcdTAwMTmhquaQXHUwMDFioyzOq1wiRlx1MDAxYqpcdTAwMTlMP1x1MDAwZmEn2N30olx1MDAwZsHB9kWjcfl+b/NAdVc+d7GpXHUwMDBiQt1cYm2IMpLnU1x1MDAxN0GoYydvUmaFs1x1MDAxMMurOlx1MDAxMEdcdTAwMTLDkPIxg0LuZ7REYYBwXGJBMapcdTAwMDU2M010SfHQ2HnFdJ6lXHUwMDAxqyAyfrUqRHWv2qMx2aEzxvoqZGeHxlxuwFaCSsblXGZcdTAwMDOJW/os+qT3z/eGTdlvXHUwMDFmeTJqbp6tOq5BMocwzKE0SClcdTAwMDXPZ1x1MDAwZWCsLtVSXHUwMDAyaFBMXHUwMDE2J0gvXHUwMDEy11x1MDAxYWWVlkJJSqTWZSVcdLA7XHUwMDFjoFxmVIKJZMnVXHUwMDA0rDm26rmUwFNRndryL6rHXHUwMDE3VPapPVx1MDAxYVx1MDAxM905I6irS1x1MDAwMjWLXGYwvbKlRlx1MDAwZdOXXHUwMDA0ro/8+y3SNedcdTAwMWa67Y7vn53e7u6t/ChcdTAwMDFcdTAwMDNwXGaVRktjXGLDfLqwdYnmXHUwMDBlxUxTKWqzTbnEcVx1MDAwMlx1MDAwNKVDsEeYpkTR7Gq8zCCf5lx1MDAwMrBdc66YoXRiXHI7xYTDUFwi5plTuFxu0C4vNqKjUtSPJNlMXHUwMDA3aHZcdTAwMDZNWmy0U42JXHUwMDExXHUwMDA0OFx1MDAxM7Sy2rjt9ptnPGTn9+JcdTAwMGJcdTAwMWKqo2PYV41ftS7QqPSpUeuEOy2MWZioLFx1MDAwZVx1MDAxOCWTXYCmXHUwMDE3XGaXYuvd61x1MDAxYlxugygkV1+uXHUwMDA2zd1ccrq/6sRiXHUwMDE3XHUwMDAxK1wiXHUwMDExiShcdTAwMDeULTlcdTAwMTaIhTmMMrthXHUwMDEyXHUwMDAzu6JrebzC7OQ7u9hYKjvJSZcsXG62w1hcdTAwMTQoXHUwMDA2ICGl0lxcZ1x1MDAwMsN3ZtHG2PDzI1KBJTFcdTAwMGJxiCDSXHUwMDBls1x1MDAwMoKEI7WUzDGiXHUwMDBlZVxcM0zXXGJcdTAwMDVM6SqXM/yjmKXaqezRKPGnXHUwMDE5qaWq6qhqJFx1MDAwYrpcdTAwMGJcdTAwMTUzTEcq77FcdTAwMTXnXHUwMDE1JoQjNJN25Vx1MDAwMFxiY1xuq7ZAXHUwMDFhh2qFnopdw1x1MDAxOC9cdTAwMWG2wKJj+uC6oqO09Icq65lcdTAwMTc51IvRnKvNVHWsj0W19/3u98sgu7mqjiPnzTjAjyo6jlxmmU9qQMbFi8NcdTAwMTBUXHUwMDE4JG0mp6851m+atLKTnFxyKjmuXHTlRmCQyqcwXG6sXHUwMDEwwVx1MDAxNIeB3dOnZoJcIlx1MDAwN+7Ck2pcdTAwMTNcdTAwMDSNtSNRo9XYwpTtP8JcdTAwMWMjXHUwMDA0hlx1MDAwNTtcdTAwMDVcdTAwMGWN5ZM5XGbmWUJcdTAwMTIxzz5cXKugNGZbPGGXeVKN6krb7TUwnGaS/1x1MDAwN1xyYreLsVwiXVx1MDAwMrZcdTAwMTKAjIovSJDSrVxuJiTIzyQ0XHUwMDFhNU6VtE/604xKozqJ0ZVFT8G5xD9meq1Rv9nNXG5cdTAwMTNcdTAwMGKVnNi1lPhfcVxupeCONFx1MDAwNLVcYv4k6qZQPp1YNCQrXHUwMDAxJNGWXHUwMDE3oIRX7PIwRmzJSlx1MDAxOM3snKtcIq/gWck5Uz9gifiySiPUQaEnQWNcdTAwMTKjqZJUlVRGhGPXXHUwMDFlUVBcYlx1MDAxZCFFblx1MDAxZEWOPco325pgj18jgWlU+5Q9Jr1pRlap3dzFsMokxqC254ZPTyyt4y+dm6NBcDxodT9s0r1cdTAwMGZfL5tVeyyt2NYu0tFcbr8tui9nupDH2G1yXHI6LbW+bZipnjzx9K1dqMOBVi2sYII5ljUsXHUwMDAzlk/VtjsoamaL88+b4vy8W7zMQoe/fb9xctN1dzB4XHUwMDFm4y3HhIjv2m8/pD7pbda/+N7tZsmGyVx1MDAxN8lhTU5egsWHZ9/0399++/Z/3Vx1MDAxNVx1MDAwZVx1MDAwMiJ9 MarginPaddingContent areaBorderHeightWidth
The following example creates two widgets with a width of 30, a height of 6, and a border and padding of 1. The first widget has the default box_sizing
(\"border-box\"
). The second widget sets box_sizing
to \"content-box\"
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BoxSizing(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.width = 30\nself.widget2.styles.width = 30\nself.widget1.styles.height = 6\nself.widget2.styles.height = 6\nself.widget1.styles.border = (\"heavy\", \"white\")\nself.widget2.styles.border = (\"heavy\", \"white\")\nself.widget1.styles.padding = 1\nself.widget2.styles.padding = 1\nself.widget2.styles.box_sizing = \"content-box\"\nif __name__ == \"__main__\":\napp = BoxSizing()\napp.run()\n
The padding and border of the first widget is subtracted from the height leaving only 2 lines in the content area. The second widget also has a height of 6, but the padding and border adds additional height so that the content area remains 6 lines.
BoxSizing \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u2503 \u2503brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
"},{"location":"guide/styles/#margin","title":"Margin","text":"Margin is similar to padding in that it adds space, but unlike padding, margin is outside of the widget's border. It is used to add space between widgets.
The following example creates two widgets, each with a margin of 2.
margin01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass MarginApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.border = (\"heavy\", \"white\")\nself.widget2.styles.border = (\"heavy\", \"white\")\nself.widget1.styles.margin = 2\nself.widget2.styles.margin = 2\nif __name__ == \"__main__\":\napp = MarginApp()\napp.run()\n
Notice how each widget has an additional two rows and columns around the border.
MarginApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Note
In the above example both widgets have a margin of 2, but there are only 2 lines of space between the widgets. This is because margins of consecutive widgets overlap. In other words when there are two widgets next to each other Textual picks the greater of the two margins.
"},{"location":"guide/styles/#more-styles","title":"More styles","text":"We've covered the most fundamental styles used by Textual apps, but there are many more which you can use to customize many aspects of how your app looks. See the Styles reference for a comprehensive list.
In the next chapter we will discuss Textual CSS which is a powerful way of applying styles to widgets that keeps your code free of style attributes.
"},{"location":"guide/testing/","title":"Testing","text":"Code testing is an important part of software development. This chapter will cover how to write tests for your Textual apps.
"},{"location":"guide/testing/#what-is-testing","title":"What is testing?","text":"It is common to write tests alongside your app. A test is simply a function that confirms your app is working correctly.
Learn more about testing
We recommend Python Testing with pytest for a comprehensive guide to writing tests.
"},{"location":"guide/testing/#do-you-need-to-write-tests","title":"Do you need to write tests?","text":"The short answer is \"no\", you don't need to write tests.
In practice however, it is almost always a good idea to write tests. Writing code that is completely bug free is virtually impossible, even for experienced developers. If you want to have confidence that your application will run as you intended it to, then you should write tests. Your test code will help you find bugs early, and alert you if you accidentally break something in the future.
"},{"location":"guide/testing/#testing-frameworks-for-textual","title":"Testing frameworks for Textual","text":"Textual doesn't require any particular test framework. You can use any test framework you are familiar with, but we will be using pytest in this chapter.
"},{"location":"guide/testing/#testing-apps","title":"Testing apps","text":"You can often test Textual code in the same way as any other app, and use similar techniques. But when testing user interface interactions, you may need to use Textual's dedicated test features.
Let's write a simple Textual app so we can demonstrate how to test it. The following app shows three buttons labelled \"red\", \"green\", and \"blue\". Clicking one of those buttons or pressing a corresponding R, G, and B key will change the background color.
rgb.pyOutputfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Button, Footer\nclass RGBApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n Horizontal {\n width: auto;\n height: auto;\n }\n \"\"\"\nBINDINGS = [\n(\"r\", \"switch_color('red')\", \"Go Red\"),\n(\"g\", \"switch_color('green')\", \"Go Green\"),\n(\"b\", \"switch_color('blue')\", \"Go Blue\"),\n]\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nyield Button(\"Red\", id=\"red\")\nyield Button(\"Green\", id=\"green\")\nyield Button(\"Blue\", id=\"blue\")\nyield Footer()\n@on(Button.Pressed)\ndef pressed_button(self, event: Button.Pressed) -> None:\nassert event.button.id is not None\nself.action_switch_color(event.button.id)\ndef action_switch_color(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = RGBApp()\napp.run()\n
RGBApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Red\u00a0\u00a0Green\u00a0\u00a0Blue\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0R\u00a0\u00a0Go\u00a0Red\u00a0\u00a0G\u00a0\u00a0Go\u00a0Green\u00a0\u00a0B\u00a0\u00a0Go\u00a0Blue\u00a0
Although it is straightforward to test an app like this manually, it is not practical to click every button and hit every key in your app after changing a single line of code. Tests allow us to automate such testing so we can quickly simulate user interactions and check the result.
To test our simple app we will use the run_test()
method on the App
class. This replaces the usual call to run()
and will run the app in headless mode, which prevents Textual from updating the terminal but otherwise behaves as normal.
The run_test()
method is an async context manager which returns a Pilot
object. You can use this object to interact with the app as if you were operating it with a keyboard and mouse.
Let's look at the tests for the example above:
test_rgb.pyfrom rgb import RGBApp\nfrom textual.color import Color\nasync def test_keys(): # (1)!\n\"\"\"Test pressing keys has the desired result.\"\"\"\napp = RGBApp()\nasync with app.run_test() as pilot: # (2)!\n# Test pressing the R key\nawait pilot.press(\"r\") # (3)!\nassert app.screen.styles.background == Color.parse(\"red\") # (4)!\n# Test pressing the G key\nawait pilot.press(\"g\")\nassert app.screen.styles.background == Color.parse(\"green\")\n# Test pressing the B key\nawait pilot.press(\"b\")\nassert app.screen.styles.background == Color.parse(\"blue\")\n# Test pressing the X key\nawait pilot.press(\"x\")\n# No binding (so no change to the color)\nassert app.screen.styles.background == Color.parse(\"blue\")\nasync def test_buttons():\n\"\"\"Test pressing keys has the desired result.\"\"\"\napp = RGBApp()\nasync with app.run_test() as pilot:\n# Test clicking the \"red\" button\nawait pilot.click(\"#red\") # (5)!\nassert app.screen.styles.background == Color.parse(\"red\")\n# Test clicking the \"green\" button\nawait pilot.click(\"#green\")\nassert app.screen.styles.background == Color.parse(\"green\")\n# Test clicking the \"blue\" button\nawait pilot.click(\"#blue\")\nassert app.screen.styles.background == Color.parse(\"blue\")\n
run_test()
method requires that it run in a coroutine, so tests must use the async
keyword.id
of red
(the button labelled \"Red\").There are two tests defined in test_rgb.py
. The first to test keys and the second to test button clicks. Both tests first construct an instance of the app and then call run_test()
to get a Pilot object. The test_keys
function simulates key presses with Pilot.press
, and test_buttons
simulates button clicks with Pilot.click
.
After simulating a user interaction, Textual tests will typically check the state has been updated with an assert
statement. The pytest
module will record any failures of these assert statements as a test fail.
If you run the tests with pytest test_rgb.py
you should get 2 passes, which will confirm that the user will be able to click buttons or press the keys to change the background color.
If you later update this app, and accidentally break this functionality, one or more of your tests will fail. Knowing which test has failed will help you quickly track down where your code was broken.
"},{"location":"guide/testing/#simulating-key-presses","title":"Simulating key presses","text":"We've seen how the press
method simulates keys. You can also supply multiple keys to simulate the user typing in to the app. Here's an example of simulating the user typing the word \"hello\".
await pilot.press(\"h\", \"e\", \"l\", \"l\", \"o\")\n
Each string creates a single keypress. You can also use the name for non-printable keys (such as \"enter\") and the \"ctrl+\" modifier. These are the same identifiers as used for key events, which you can experiment with by running textual keys
.
You can simulate mouse clicks in a similar way with Pilot.click
. If you supply a CSS selector Textual will simulate clicking on the matching widget.
Note
If there is another widget in front of the widget you want to click, you may end up clicking the topmost widget rather than the widget indicated in the selector. This is generally what you want, because a real user would experience the same thing.
"},{"location":"guide/testing/#clicking-the-screen","title":"Clicking the screen","text":"If you don't supply a CSS selector, then the click will be relative to the screen. For example, the following simulates a click at (0, 0):
await pilot.click()\n
"},{"location":"guide/testing/#click-offsets","title":"Click offsets","text":"If you supply an offset
value, it will be added to the coordinates of the simulated click. For example the following line would simulate a click at the coordinates (10, 5).
await pilot.click(offset=(10, 5))\n
If you combine this with a selector, then the offset will be relative to the widget. Here's how you would click the line above a button.
await pilot.click(Button, offset(0, -1))\n
"},{"location":"guide/testing/#modifier-keys","title":"Modifier keys","text":"You can simulate clicks in combination with modifier keys, by setting the shift
, meta
, or control
parameters. Here's how you could simulate ctrl-clicking a widget with an ID of \"slider\":
await pilot.click(\"#slider\", control=True)\n
"},{"location":"guide/testing/#changing-the-screen-size","title":"Changing the screen size","text":"The default size of a simulated app is (80, 24). You may want to test what happens when the app has a different size. To do this, set the size
parameter of run_test
to a different size. For example, here is how you would simulate a terminal resized to 100 columns and 50 lines:
async with app.run_test(size=(100, 50)) as pilot:\n...\n
"},{"location":"guide/testing/#pausing-the-pilot","title":"Pausing the pilot","text":"Some actions in a Textual app won't change the state immediately. For instance, messages may take a moment to bubble from the widget that sent them. If you were to post a message and immediately assert
you may find that it fails because the message hasn't yet been processed.
You can generally solve this by calling pause()
which will wait for all pending messages to be processed. You can also supply a delay
parameter, which will insert a delay prior to waiting for pending messages.
Textual itself has a large battery of tests. If you are interested in how we write tests, see the tests/ directory in the Textual repository.
"},{"location":"guide/testing/#snapshot-testing","title":"Snapshot testing","text":"Snapshot testing is the process of recording the output of a test, and comparing it against the output from previous runs.
Textual uses snapshot testing internally to ensure that the builtin widgets look and function correctly in every release. We've made the pytest plugin we built available for public use.
The official Textual pytest plugin can help you catch otherwise difficult to detect visual changes in your app.
It works by generating an SVG screenshot (such as the images in these docs) from your app. If the screenshot changes in any test run, you will have the opportunity to visually compare the new output against previous runs.
"},{"location":"guide/testing/#installing-the-plugin","title":"Installing the plugin","text":"You can install pytest-textual-snapshot
using your favorite package manager (pip
, poetry
, etc.).
pip install pytest-textual-snapshot\n
"},{"location":"guide/testing/#creating-a-snapshot-test","title":"Creating a snapshot test","text":"With the package installed, you now have access to the snap_compare
pytest fixture.
Let's look at an example of how we'd create a snapshot test for the calculator app below.
CalculatorApp \u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513 \u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b \u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0C\u00a0\u00a0+/-\u00a0\u00a0%\u00a0\u00a0\u00f7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a07\u00a0\u00a08\u00a0\u00a09\u00a0\u00a0\u00d7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a04\u00a0\u00a05\u00a0\u00a06\u00a0\u00a0-\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a01\u00a0\u00a02\u00a0\u00a03\u00a0\u00a0+\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a00\u00a0\u00a0.\u00a0\u00a0=\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
First, we need to create a new test and specify the path to the Python file containing the app. This path should be relative to the location of the test.
def test_calculator(snap_compare):\nassert snap_compare(\"path/to/calculator.py\")\n
Let's run the test as normal using pytest
.
pytest\n
When this test runs for the first time, an SVG screenshot of the calculator app is generated, and the test will fail. Snapshot tests always fail on the first run, since there's no previous version to compare the snapshot to.
If you open the snapshot report in your browser, you'll see something like this:
Tip
You can usually open the link directly from the terminal, but some terminal emulators may require you to hold Ctrl or Cmd while clicking for links to work.
The report explains that there's \"No history for this test\". It's our job to validate that the initial snapshot looks correct before proceeding. Our calculator is rendering as we expect, so we'll save this snapshot:
pytest --snapshot-update\n
Warning
Only ever run pytest with --snapshot-update
if you're happy with how the output looks on the left hand side of the snapshot report. When using --snapshot-update
, you're saying \"I'm happy with all of the screenshots in the snapshot test report, and they will now represent the ground truth which all future runs will be compared against\". As such, you should only run pytest --snapshot-update
after running pytest
and confirming the output looks good.
Now that our snapshot is saved, if we run pytest
(with no arguments) again, the test will pass. This is because the screenshot taken during this test run matches the one we saved earlier.
The real power of snapshot testing comes from its ability to catch visual regressions which could otherwise easily be missed.
Imagine a new developer joins your team, and tries to make a few changes to the calculator. While making this change they accidentally break some styling which removes the orange coloring from the buttons on the right of the app. When they run pytest
, they're presented with a report which reveals the damage:
On the right, we can see our \"historical\" snapshot - this is the one we saved earlier. On the left is how our app is currently rendering - clearly not how we intended!
We can click the \"Show difference\" toggle at the top right of the diff to overlay the two versions:
This reveals another problem, which could easily be missed in a quick visual inspection - our new developer has also deleted the number 4!
Tip
Snapshot tests work well in CI on all supported operating systems, and the snapshot report is just an HTML file which can be exported as a build artifact.
"},{"location":"guide/testing/#pressing-keys","title":"Pressing keys","text":"You can simulate pressing keys before the snapshot is captured using the press
parameter.
def test_calculator_pressing_numbers(snap_compare):\nassert snap_compare(\"path/to/calculator.py\", press=[\"1\", \"2\", \"3\"])\n
"},{"location":"guide/testing/#changing-the-terminal-size","title":"Changing the terminal size","text":"To capture the snapshot with a different terminal size, pass a tuple (width, height)
as the terminal_size
parameter.
def test_calculator(snap_compare):\nassert snap_compare(\"path/to/calculator.py\", terminal_size=(50, 100))\n
"},{"location":"guide/testing/#running-setup-code","title":"Running setup code","text":"You can also run arbitrary code before the snapshot is captured using the run_before
parameter.
In this example, we use run_before
to hover the mouse cursor over the widget with ID number-5
before taking the snapshot.
def test_calculator_hover_number(snap_compare):\nasync def run_before(pilot) -> None:\nawait pilot.hover(\"#number-5\")\nassert snap_compare(\"path/to/calculator.py\", run_before=run_before)\n
For more information, visit the pytest-textual-snapshot
repo on GitHub.
In this chapter we will explore widgets in more detail, and how you can create custom widgets of your own.
"},{"location":"guide/widgets/#what-is-a-widget","title":"What is a widget?","text":"A widget is a component of your UI responsible for managing a rectangular region of the screen. Widgets may respond to events in much the same way as an app. In many respects, widgets are like mini-apps.
Information
Every widget runs in its own asyncio task.
"},{"location":"guide/widgets/#custom-widgets","title":"Custom widgets","text":"There is a growing collection of builtin widgets in Textual, but you can build entirely custom widgets that work in the same way.
The first step in building a widget is to import and extend a widget class. This can either be Widget which is the base class of all widgets, or one of its subclasses.
Let's create a simple custom widget to display a greeting.
hello01.pyfrom textual.app import App, ComposeResult, RenderResult\nfrom textual.widget import Widget\nclass Hello(Widget):\n\"\"\"Display a greeting.\"\"\"\ndef render(self) -> RenderResult:\nreturn \"Hello, [b]World[/b]!\"\nclass CustomApp(App):\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
The three highlighted lines define a custom widget class with just a render() method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later.
Note that the text contains tags in square brackets, i.e. [b]
. This is console markup which allows you to embed various styles within your content. If you run this you will find that World
is in bold.
CustomApp Hello,\u00a0World!
This (very simple) custom widget may be styled in the same way as builtin widgets, and targeted with CSS. Let's add some CSS to this app.
hello02.pyhello02.tcss hello02.pyfrom textual.app import App, ComposeResult, RenderResult\nfrom textual.widget import Widget\nclass Hello(Widget):\n\"\"\"Display a greeting.\"\"\"\ndef render(self) -> RenderResult:\nreturn \"Hello, [b]World[/b]!\"\nclass CustomApp(App):\nCSS_PATH = \"hello02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello02.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\ncolor: $text;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
The addition of the CSS has completely transformed our custom widget.
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHello,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/widgets/#static-widget","title":"Static widget","text":"While you can extend the Widget class, a subclass will typically be a better starting point. The Static class is a widget subclass which caches the result of render, and provides an update() method to update the content area.
Let's use Static to create a widget which cycles through \"hello\" in various languages.
hello03.pyhello03.tcssOutput hello03.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\ndef on_mount(self) -> None:\nself.next_word()\ndef on_click(self) -> None:\nself.next_word()\ndef next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"{hello}, [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello03.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello03.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note that there is no render()
method on this widget. The Static class is handling the render for us. Instead we call update()
when we want to update the content within the widget.
The next_word
method updates the greeting. We call this method from the mount handler to get the first word, and from a click handler to cycle through the greetings when we click the widget.
When building an app it is best to keep your CSS in an external file. This allows you to see all your CSS in one place, and to enable live editing. However if you intend to distribute a widget (via PyPI for instance) it can be convenient to bundle the code and CSS together. You can do this by adding a DEFAULT_CSS
class variable inside your widget class.
Textual's builtin widgets bundle CSS in this way, which is why you can see nicely styled widgets without having to copy any CSS code.
Here's the Hello example again, this time the widget has embedded default CSS:
hello04.pyhello04.tcssOutput hello04.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\nDEFAULT_CSS = \"\"\"\n Hello {\n width: 40;\n height: 9;\n padding: 1 2;\n background: $panel;\n border: $secondary tall;\n content-align: center middle;\n }\n \"\"\"\ndef on_mount(self) -> None:\nself.next_word()\ndef on_click(self) -> None:\nself.next_word()\ndef next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"{hello}, [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello04.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello04.tcssScreen {\nalign: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/widgets/#scoped-css","title":"Scoped CSS","text":"Default CSS is scoped by default. All this means is that CSS defined in DEFAULT_CSS
will affect the widget and potentially its children only. This is to prevent you from inadvertently breaking an unrelated widget.
You can disabled scoped CSS by setting the class var SCOPED_CSS
to False
.
CSS defined within DEFAULT_CSS
has an automatically lower specificity than CSS read from either the App's CSS
class variable or an external stylesheet. In practice this means that your app's CSS will take precedence over any CSS bundled with widgets.
Text in a widget may be marked up with links which perform an action when clicked. Links in console markup use the following format:
\"Click [@click='app.bell']Me[/]\"\n
The @click
tag introduces a click handler, which runs the app.bell
action.
Let's use markup links in the hello example so that the greeting becomes a link which updates the widget.
hello05.pyhello05.tcssOutput hello05.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\ndef on_mount(self) -> None:\nself.action_next_word()\ndef action_next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"[@click='next_word']{hello}[/], [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello05.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello05.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the next_word
action which updates the next word.
Every widget has a border_title
and border_subtitle
attribute. Setting border_title
will display text within the top border, and setting border_subtitle
will display text within the bottom border.
Note
Border titles will only display if the widget has a border enabled.
The default value for these attributes is empty string, which disables the title. You can change the default value for the title attributes with the BORDER_TITLE
and BORDER_SUBTITLE
class variables.
Let's demonstrate setting a title, both as a class variable and a instance variable:
hello06.pyhello06.tcssOutput hello06.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\nBORDER_TITLE = \"Hello Widget\" # (1)!\ndef on_mount(self) -> None:\nself.action_next_word()\nself.border_subtitle = \"Click for next hello\" # (2)!\ndef action_next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"[@click='next_word']{hello}[/], [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello05.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
title
attribute via class variable.subtitle
via an instance attribute.Screen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u00a0Hello\u00a0Widget\u00a0\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u00a0Click\u00a0for\u00a0next\u00a0hello\u00a0\u2581\u258e
Note that titles are limited to a single line of text. If the supplied text is too long to fit within the widget, it will be cropped (and an ellipsis added).
There are a number of styles that influence how titles are displayed (color and alignment). See the style reference for details.
"},{"location":"guide/widgets/#rich-renderables","title":"Rich renderables","text":"In previous examples we've set strings as content for Widgets. You can also use special objects called renderables for advanced visuals. You can use any renderable defined in Rich or third party libraries.
Lets make a widget that uses a Rich table for its content. The following app is a solution to the classic fizzbuzz problem often used to screen software engineers in job interviews. The problem is this: Count up from 1 to 100, when the number is divisible by 3, output \"fizz\"; when the number is divisible by 5, output \"buzz\"; and when the number is divisible by both 3 and 5 output \"fizzbuzz\".
This app will \"play\" fizz buzz by displaying a table of the first 15 numbers and columns for fizz and buzz.
fizzbuzz01.pyfizzbuzz01.tcssOutput fizzbuzz01.pyfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass FizzBuzz(Static):\ndef on_mount(self) -> None:\ntable = Table(\"Number\", \"Fizz?\", \"Buzz?\")\nfor n in range(1, 16):\nfizz = not n % 3\nbuzz = not n % 5\ntable.add_row(\nstr(n),\n\"fizz\" if fizz else \"\",\n\"buzz\" if buzz else \"\",\n)\nself.update(table)\nclass FizzBuzzApp(App):\nCSS_PATH = \"fizzbuzz01.tcss\"\ndef compose(self) -> ComposeResult:\nyield FizzBuzz()\nif __name__ == \"__main__\":\napp = FizzBuzzApp()\napp.run()\n
fizzbuzz01.tcssScreen {\nalign: center middle;\n}\nFizzBuzz {\nwidth: auto;\nheight: auto;\nbackground: $primary;\ncolor: $text;\n}\n
FizzBuzzApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Number\u2503Fizz?\u2503Buzz?\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25021\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25022\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25023\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u25024\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25025\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u2502 \u25026\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u25027\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25028\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25029\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u250210\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u2502 \u250211\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250212\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u250213\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250214\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250215\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502buzz\u00a0\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"guide/widgets/#content-size","title":"Content size","text":"Textual will auto-detect the dimensions of the content area from rich renderables if width or height is set to auto
. You can override auto dimensions by implementing get_content_width() or get_content_height().
Let's modify the default width for the fizzbuzz example. By default, the table will be just wide enough to fix the columns. Let's force it to be 50 characters wide.
fizzbuzz02.pyfizzbuzz02.tcssOutput fizzbuzz02.pyfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Size\nfrom textual.widgets import Static\nclass FizzBuzz(Static):\ndef on_mount(self) -> None:\ntable = Table(\"Number\", \"Fizz?\", \"Buzz?\", expand=True)\nfor n in range(1, 16):\nfizz = not n % 3\nbuzz = not n % 5\ntable.add_row(\nstr(n),\n\"fizz\" if fizz else \"\",\n\"buzz\" if buzz else \"\",\n)\nself.update(table)\ndef get_content_width(self, container: Size, viewport: Size) -> int:\n\"\"\"Force content width size.\"\"\"\nreturn 50\nclass FizzBuzzApp(App):\nCSS_PATH = \"fizzbuzz02.tcss\"\ndef compose(self) -> ComposeResult:\nyield FizzBuzz()\nif __name__ == \"__main__\":\napp = FizzBuzzApp()\napp.run()\n
fizzbuzz02.tcssScreen {\nalign: center middle;\n}\nFizzBuzz {\nwidth: auto;\nheight: auto;\nbackground: $primary;\ncolor: $text;\n}\n
FizzBuzzApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Number\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Fizz?\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Buzz?\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25021\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25022\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25023\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u25024\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25025\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u25026\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u25027\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25028\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25029\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u250210\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u250211\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250212\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u250213\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250214\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250215\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
Note that we've added expand=True
to tell the Table
to expand beyond the optimal width, so that it fills the 50 characters returned by get_content_width
.
Widgets can have tooltips which is content displayed when the user hovers the mouse over the widget. You can use tooltips to add supplementary information or help messages.
Tip
It is best not to rely on tooltips for essential information. Some users prefer to use the keyboard exclusively and may never see tooltips.
To add a tooltip, assign to the widget's tooltip
property. You can set text or any other Rich renderable.
The following example adds a tooltip to a button:
tooltip01.pyOutput (before hover)Output (after hover) tooltip01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\"\"\"\nclass TooltipApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"Click me\", variant=\"success\")\ndef on_mount(self) -> None:\nself.query_one(Button).tooltip = TEXT\nif __name__ == \"__main__\":\napp = TooltipApp()\napp.run()\n
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Click\u00a0me\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
"},{"location":"guide/widgets/#customizing-the-tooltip","title":"Customizing the tooltip","text":"If you don't like the default look of the tooltips, you can customize them to your liking with CSS. Add a rule to your CSS that targets Tooltip
. Here's an example:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\"\"\"\nclass TooltipApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n Tooltip {\n padding: 2 4;\n background: $primary;\n color: auto 90%;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"Click me\", variant=\"success\")\ndef on_mount(self) -> None:\nself.query_one(Button).tooltip = TEXT\nif __name__ == \"__main__\":\napp = TooltipApp()\napp.run()\n
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Click\u00a0me\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
"},{"location":"guide/widgets/#line-api","title":"Line API","text":"A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size. If a widget is large enough to require scrolling, or updates frequently, then this redrawing can make your app feel less responsive. Textual offers an alternative API which reduces the amount of work required to refresh a widget, and makes it possible to update portions of a widget (as small as a single character) without a full redraw. This is known as the line API.
Note
The Line API requires a little more work that typical Rich renderables, but can produce powerful widgets such as the builtin DataTable which can handle thousands or even millions of rows.
"},{"location":"guide/widgets/#render-line-method","title":"Render Line method","text":"To build a widget with the line API, implement a render_line
method rather than a render
method. The render_line
method takes a single integer argument y
which is an offset from the top of the widget, and should return a Strip object containing that line's content. Textual will call this method as required to get content for every row of characters in the widget.
Let's look at an example before we go in to the details. The following Textual app implements a widget with the line API that renders a checkerboard pattern. This might form the basis of a chess / checkers game. Here's the code:
checker01.pyOutput checker01.pyfrom rich.segment import Segment\nfrom rich.style import Style\nfrom textual.app import App, ComposeResult\nfrom textual.strip import Strip\nfrom textual.widget import Widget\nclass CheckerBoard(Widget):\n\"\"\"Render an 8x8 checkerboard.\"\"\"\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nrow_index = y // 4 # A checkerboard square consists of 4 rows\nif row_index >= 8: # Generate blank lines when we reach the end\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2 # Used to alternate the starting square on each row\nwhite = Style.parse(\"on white\") # Get a style object for a white background\nblack = Style.parse(\"on black\") # Get a style object for a black background\n# Generate a list of segments with alternating black and white space characters\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(8)\n]\nstrip = Strip(segments, 8 * 8)\nreturn strip\nclass BoardApp(App):\n\"\"\"A simple app to show our widget.\"\"\"\ndef compose(self) -> ComposeResult:\nyield CheckerBoard()\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp
The render_line
method above calculates a Strip
for every row of characters in the widget. Each strip contains alternating black and white space characters which form the squares in the checkerboard.
You may have noticed that the checkerboard widget makes use of some objects we haven't covered before. Let's explore those.
"},{"location":"guide/widgets/#segment-and-style","title":"Segment and Style","text":"A Segment is a class borrowed from the Rich project. It is small object (actually a named tuple) which bundles a string to be displayed and a Style which tells Textual how the text should look (color, bold, italic etc).
Let's look at a simple segment which would produce the text \"Hello, World!\" in bold.
greeting = Segment(\"Hello, World!\", Style(bold=True))\n
This would create the following object:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1aW1PbRlx1MDAxOH3nVzDuSztcdTAwMTM2e790ptOhJFx1MDAxNEIuUEJoaTuMsNa2gixcdTAwMTlJ5pJO/nu/XHUwMDE1RpIl7CBjUqd+XHUwMDAwe3el/Xb3nPNdpH/W1tc72c3Idn5cXO/Y665cdTAwMTdcdTAwMDZ+4l11nrn2S5ukQVx1MDAxY0FcdTAwMTfNf6fxOOnmI1x1MDAwN1k2Sn98/nzoJec2XHUwMDFihV7XossgXHUwMDFke2Gajf0gRt14+DzI7DD92f1961xy7U+jeOhnXHQqJ9mwfpDFye1cXDa0Q1x1MDAxYmUp3P1P+L2+/k/+t2JdYruZXHUwMDE39UObX5B3lVx1MDAwNirJ661v4yg3lkhFhGRcdTAwMWHLYkSQvoD5MutDd1x1MDAwZmy2ZY9r6vAjuvlhK+7ffPQvtlx1MDAwZcJNSo7CP8ppe0FcdTAwMThcdTAwMWVmN+HtVnjdwTipXHUwMDE4lWZJfG6PXHUwMDAzP1x1MDAxYrjZa+3Fdb6XXHUwMDBlwICiO4nH/UFkU7dcdTAwMDO4aI1HXjfIbtyNcNl6u1xy1XHX7pBcZkZSca41Zsatueh11zNKXHUwMDExx0ZLyZRcdTAwMTCMypphW3FcYmdcdTAwMDGGfYfzT2nZmdc974N5kV+MyVx1MDAxMi9KR15cdTAwMDInVo67miyZXHUwMDBiiqQglCqMXHKunszAXHUwMDA2/UHmjosgho1SynDMsJCiNMbmp0JcZnN2XHUwMDEyo4tcdTAwMWVnwmjXz1x1MDAxMfJ3ddtcIn+ybXeQKUHDJi2fy8W48S/rYKtcdTAwMDKugoN3e0dXxmNDtn/iXHUwMDFk7uyeblx1MDAxZqTvVLHgKXR6SVx1MDAxMl91ip7Pk2+loeOR791cIlx1MDAwZVx1MDAwMCk4XHUwMDExWlFDTdFcdTAwMWZcdTAwMDbROXRG4zAs2+LueVx00rXKStqxg1a2scZcdTAwMGVcdTAwMDBcdTAwMGLRilx1MDAxOf5gclxcnrw7vd5S9rV93dvjsYjt/lx0WYxcdTAwMWN0XHUwMDE2OdJcdTAwMTgk4n5ukFx1MDAwNblhkFwiXFxzXHUwMDAw1TQtXHUwMDE4QVhSMZNcclpSa7qLs4FiiaQhXHUwMDFjJuHGfVSTXHUwMDBlgiFtpm2744HEmlxuzivn80Q8mIdUXHUwMDAxu8SWhdTMXmf3gnQmRlx1MDAwNTVcZnSLPlxcwI9Aen57b7btx4R9XHUwMDE0Nt7ZXHUwMDBlg8GSXHUwMDA1fOlcdTAwMThlRFwirakmtFx1MDAwNlH+dFpNKtJbwJHyOlxmXHKmXHUwMDA0XHUwMDBiptqgcFxuXHUwMDFmrWT3evx7cPkq+nBp9/ax2Lu+Or5cdTAwMWPsLUl2OWfaXHUwMDE40lx1MDAwMswlauIoO1xmPtmc1FOt294wXGLzoyqac5SDgX91dmxcdTAwMTjGz9aP4yT0/+pUjyq1MHvtdu66zTDoO0Z0QtubpkpcdTAwMTZA8FR0Z/Go7O2CXHUwMDFkXHUwMDFl3C7Z9evriZOgXHUwMDFmRF74fpZNi3tcdTAwMTbGVL31jrWGXHUwMDEwIK3hXHUwMDBmZ+3u3ubbbSrVxYuD991cdTAwMWXTn3x/J5jB2lx1MDAxYfv+K79cIihGVIBCmqZjwVxiT7XWucsk73XVIzxcdTAwMGJhSNx5ldaeRTpBxZqV6/m/XHUwMDA1WMYwgkvFelwit0VmM4AoyjGRpEVwdfM2ftU/O9u/XHUwMDEx4uJ0g1x1MDAxZI9e71x1MDAxZmyuuuNcdTAwMTJMI6yVXHUwMDEyhlPnKNg0XHUwMDExuECSc8HInCjr0X5Mmyb4m35MSaG5Ulx1MDAxNU16Sj/2q/fJo3tcdTAwMDO6uSuOduTxsWTnLy+Wlj5cdTAwMTjNOG2B7sf5sVx1MDAxYz3fn8Wh/9P7ZGx/WFx1MDAwNT/WsGmxuLNidZ3AnEHKXHUwMDAwaWhcdTAwMTmafonAL0/UwcvRfnK+8bt9dbQpe1x1MDAxYofh6apcdTAwMTOYUYYoJNzwMVxuXHUwMDEy93K57nouOVx1MDAwMvpSXHUwMDE3PHHYjbp3XVwii+k9Low2XFyXkka6QK6N61rNaFRcdTAwMTBcdTAwMTBF9lQsJlNsdFx1MDAwNvZcdTAwMTNrsyDqo2kyVChcXIH616DwtEGL8Vx1MDAxN8/kL/gjXHR7rMpcdTAwMDFfou9V/8Ppm1x1MDAxNyPpXHUwMDFmvtl68UZEoThcdTAwMWOfrDp9hYJA0HBiZO6AWZ2+XG5RIDV0XHUwMDAwezWresVl87dCyTn8hWReSY1L2H+zTphcdTAwMTCBK1x1MDAxOfRXo2+aY2mV+Htr0VxcXHUwMDAy327vfUlkpVxmWvfAWlx1MDAwYlx1MDAwMVx1MDAxOU5cdTAwMGJcdTAwMGY8X69XlMJMXHUwMDBiJFx1MDAwNGR04Fx1MDAwZaRcdTAwMDF/PFx1MDAxZENzSZCmmFNtnthcdTAwMDVcdTAwMWKkQElcXKFEaqOwvqc8pDRSICXmLlx1MDAxY6hcdTAwMThzV7yXcFx1MDAwM6ZcdTAwMWVXu6eTlsVqlos71jTzkuyXIPJcdTAwMDHW04ZNnlHtPiDQy7ncXHUwMDFkOys3MMJcXFx1MDAxMVc9g3CSUWp4+WjG7Y03cqtFkuWlv8aqbeR/2Zr5XHUwMDA1z4o1YFxmwVx1MDAwNsPeMEyoNIRy1TCGMMjn8pJGw5rQS7OteDhcZjLY7v04iLL6tub7t+m4PrBeQ0BgNdW+uiiM3Fx1MDAxZKc1vfy2XpIm/1F8//vZvaNnYtl9Nlx1MDAxYTAub7dW/d9azqiaWclcdTAwMDYh4EJcYqVcdTAwMWUuZ/P914rKmWRcdTAwMWNcdTAwMTmqMVMu6IDMoaFmsFx1MDAwNdJhzEUkrGbX8tRcZuJcIsyNUkZcdTAwMGLIb4SsJP6lnCmEXHUwMDFkXHUwMDE1INOrXHUwMDE5M1EzJjXRXHUwMDFjyzZFg2XL2eLZ/lx1MDAwM+VsfuBblzNcdTAwMDaUYiBoXHUwMDEwVzqK8cq4W1x1MDAwNVx1MDAxMYgzdr+CPEjP5tfBpvWMXG7KNVx1MDAwMVx1MDAwNitOsKaGNawhXHUwMDAykVx1MDAxOer6LenZxmw45911JLdcdTAwMTS0WVx0lmGi3lokWFxmpuK0zZO5M9zt9a8hXHUwMDFhXHUwMDFkXHUwMDFmXnw8+qN38mv8epFcdTAwMWH/XCJitth7XHUwMDE1Tq5cdTAwMDTPdUxgXHUwMDAzwVlZpHA3oJQh8KJGXHUwMDEy6TA/O7tcdTAwMDLn7/H5YvZdr9drqph6UFWEQOKHtWStdGrxvOqJS/dSV6pQXyuvWqWMasFcXIpSQurNRfRcdTAwMDFbXHUwMDBiQCUtkqn5p7ySj+S4XHUwMDExiCqmlVCUS1x1MDAxMMKSkTldhUBaQSxuXHUwMDA0eHZiKq/GLI2wXHUwMDEwP3NcZiqtJOdcdTAwMWFCoEq4Vz6ZI65mg4VDPDZcdTAwMTJX9GzygFx1MDAwZbSVske+XHUwMDAw9aioQ2JcdTAwMTeutnlFqW3UMd9cdTAwMWJMuXnKMVx1MDAwM99GKTbCbUwz5lBIuUhAaYWpXHUwMDE0RuNcdTAwMDVjj/lv/9ViXHUwMDBmLMDlau5cdTAwMWVwSaFpwyhcdTAwMDKBMNPgrImGMFx1MDAxMoY0n5h+SyHIbGS7T1x1MDAwM9OzXCKQtclcdTAwMDRcdTAwMWRvNDrMXHUwMDAwdMVxXHUwMDAwylx1MDAwM39cIuDlKjuXgb365X76OVx1MDAwNq5N9tNcdJLNmfB57fO/MTl+mCJ9 \"Hello, World\"Style(bold=True)greeting.textgreeting.stylegreetingBoth Rich and Textual work with segments to generate content. A Textual app is the result of combining hundreds, or perhaps thousands, of segments,
"},{"location":"guide/widgets/#strips","title":"Strips","text":"A Strip is a container for a number of segments covering a single line (or row) in the Widget. A Strip will contain at least one segment, but often many more.
A Strip
is constructed from a list of Segment
objects. Here's now you might construct a strip that displays the text \"Hello, World!\", but with the second word in bold:
segments = [\nSegment(\"Hello, \"),\nSegment(\"World\", Style(bold=True)),\nSegment(\"!\")\n]\nstrip = Strip(segments)\n
The first and third Segment
omit a style, which results in the widget's default style being used. The second segment has a style object which applies bold to the text \"World\". If this were part of a widget it would produce the text: Hello, World!
The Strip
constructor has an optional second parameter, which should be the cell length of the strip. The strip above has a length of 13, so we could have constructed it like this:
strip = Strip(segments, 13)\n
Note that the cell length parameter is not the total number of characters in the string. It is the number of terminal \"cells\". Some characters (such as Asian language characters and certain emoji) take up the space of two Western alphabet characters. If you don't know in advance the number of cells your segments will occupy, it is best to omit the length parameter so that Textual calculates it automatically.
"},{"location":"guide/widgets/#component-classes","title":"Component classes","text":"When applying styles to widgets we can use CSS to select the child widgets. Widgets rendered with the line API don't have children per-se, but we can still use CSS to apply styles to parts of our widget by defining component classes. Component classes are associated with a widget by defining a COMPONENT_CLASSES
class variable which should be a set
of strings containing CSS class names.
In the checkerboard example above we hard-coded the color of the squares to \"white\" and \"black\". But what if we want to create a checkerboard with different colors? We can do this by defining two component classes, one for the \"white\" squares and one for the \"dark\" squares. This will allow us to change the colors with CSS.
The following example replaces our hard-coded colors with component classes.
checker02.pyOutput checker02.pyfrom rich.segment import Segment\nfrom textual.app import App, ComposeResult\nfrom textual.strip import Strip\nfrom textual.widget import Widget\nclass CheckerBoard(Widget):\n\"\"\"Render an 8x8 checkerboard.\"\"\"\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard .checkerboard--black-square {\n background: #004578;\n }\n \"\"\"\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nrow_index = y // 4 # four lines per row\nif row_index >= 8:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(8)\n]\nstrip = Strip(segments, 8 * 8)\nreturn strip\nclass BoardApp(App):\n\"\"\"A simple app to show our widget.\"\"\"\ndef compose(self) -> ComposeResult:\nyield CheckerBoard()\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp
The COMPONENT_CLASSES
class variable above adds two class names: checkerboard--white-square
and checkerboard--black-square
. These are set in the DEFAULT_CSS
but can modified in the app's CSS
class variable or external CSS.
Tip
Component classes typically begin with the name of the widget followed by two hyphens. This is a convention to avoid potential name clashes.
The render_line
method calls get_component_rich_style to get Style
objects from the CSS, which we apply to the segments to create a more colorful looking checkerboard.
A Line API widget can be made to scroll by extending the ScrollView class (rather than Widget
). The ScrollView
class will do most of the work, but we will need to manage the following details:
ScrollView
class requires a virtual size, which is the size of the scrollable content and should be set via the virtual_size
property. If this is larger than the widget then Textual will add scrollbars.render_line
method to generate strips for the visible area of the widget, taking into account the current position of the scrollbars.Let's add scrolling to our checkerboard example. A standard 8 x 8 board isn't sufficient to demonstrate scrolling so we will make the size of the board configurable and set it to 100 x 100, for a total of 10,000 squares.
checker03.pyOutput checker03.pyfrom __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Size\nfrom textual.strip import Strip\nfrom textual.scroll_view import ScrollView\nfrom rich.segment import Segment\nclass CheckerBoard(ScrollView):\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard .checkerboard--black-square {\n background: #004578;\n }\n \"\"\"\ndef __init__(self, board_size: int) -> None:\nsuper().__init__()\nself.board_size = board_size\n# Each square is 4 rows and 8 columns\nself.virtual_size = Size(board_size * 8, board_size * 4)\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nscroll_x, scroll_y = self.scroll_offset # The current scroll position\ny += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!\nrow_index = y // 4 # four lines per row\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\nif row_index >= self.board_size:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(self.board_size)\n]\nstrip = Strip(segments, self.board_size * 8)\n# Crop the strip so that is covers the visible area\nstrip = strip.crop(scroll_x, scroll_x + self.size.width)\nreturn strip\nclass BoardApp(App):\ndef compose(self) -> ComposeResult:\nyield CheckerBoard(100)\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp \u2585\u2585 \u258b
The virtual size is set in the constructor to match the total size of the board, which will enable scrollbars (unless you have your terminal zoomed out very far). You can update the virtual_size
attribute dynamically as required, but our checkerboard isn't going to change size so we only need to set it once.
The render_line
method gets the scroll offset which is an Offset containing the current position of the scrollbars. We add scroll_offset.y
to the y
argument because y
is relative to the top of the widget, and we need a Y coordinate relative to the scrollable content.
We also need to compensate for the position of the horizontal scrollbar. This is done in the call to strip.crop
which crops the strip to the visible area between scroll_x
and scroll_x + self.size.width
.
Tip
Strip objects are immutable, so methods will return a new Strip rather than modifying the original.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaXNcIkmS/d6/QlbzcZuccI97zMbWdKFcdTAwMTPd6GB3TMYpkFKAOCSktv7v645UXCI5XHUwMDEyoYvKYtVtqiqSI4h87v6eh4fHX38sLf3oPDbLP/619KPcK+bDWqmVf/jxJz9+X261a406XcL+v9uNbqvYf2a102m2//XPf97mWzflTjPMXHUwMDE3y8F9rd3Nh+1Ot1RrXHUwMDA0xcbtP2ud8m37v/n3Xv62/O9m47bUaVx1MDAwNYNcdTAwMGZJlUu1TqP1/FnlsHxbrnfa9O7/Q/9eWvqr/zsyula52MnXr8Jy/1x1MDAwNf1Lg1x1MDAwMVx1MDAxYWVHXHUwMDFm3WvU+4NcdTAwMDVcdTAwMTRotdBavj6j1l6jz+uUS3S5QmMuXHUwMDBmrvBDP453K1AvX/dcdTAwMGWLK6nWib/e1M3LjcHHVmpheNx5XGafp1wiX6x2W5FBtTutxk35rFbqVPnTR1x1MDAxZX99XbtBszB4VavRvarWy+320GtcdTAwMWHNfLHWeaTHpHh98HlcdTAwMTL+tTR4pEf/SjlcZjRcdTAwMDJ4kFx1MDAwZZXE14v8auVcXKC0tVJIROE1yJFhrTZCulx1MDAxMzSsf4j+z2BghXzx5opGVy9ccp6D6FxuZTN4zsPLlzUmXHUwMDEwylqtUGlnhFevz6iWa1fVXHUwMDBlPcVi4KQ2wlx1MDAxOIHCOTdcdTAwMThnu9y/XHUwMDFmXGJSKbruXHUwMDA3N5Q/vrlV6mPjP9FcdTAwMTmrl15mrN5ccsPBiPnCelx1MDAwNE+D13SbpfzzbVx1MDAwN2O1Rk9DoLl5vVx1MDAxZdbqN6NvXHUwMDE3Noo3XHUwMDAzpPRcdTAwMWb9+89cdTAwMGZAlO5KXHUwMDFjRK1cdTAwMDaNXHUwMDAy3OxcYl3LVm6ue8t7d1x1MDAxOXl2eFxcObnPZdeWXHUwMDEzjlBtXHUwMDAy0MpcdTAwMWGp0ErrnDfjXHUwMDE4ldJrXHRcdTAwMDRTacEkXHUwMDE2ozR85b0wesEgaoWLgyhISSZcdORDZsZo+WRzm1x1MDAwNl8olNTRQWbL7excdTAwMWPcqIRjNFx1MDAwNTJcdTAwMTDgnfZgwVx1MDAxOedgXHUwMDE4pNrIQFx1MDAxOSfoXHUwMDEyoFOj40pcdTAwMGVEQUvp6D9YOIjqOIhaa5SQxsLMXGI93y/s7VxcaFdoXHUwMDFjXHUwMDFj3ubP7zeJ37R/LUJBvOlGVWC1oWl3XHUwMDA2vFFgh1x1MDAwMGqkXHUwMDBmiFx1MDAwM3hPXHUwMDFlSmilMbFcYkWwVtFQXHUwMDE3XHUwMDBloVx1MDAxMEtFUSjiNqD97IH+NpXLy3RxOXucq96U16pcdTAwMDfrXHUwMDFiISTdiTpcYoiFKidccjqg71xmw1x1MDAxMFVcIiBcdTAwMGaqtFx1MDAwMkVE1erEQlx1MDAxNCyRXHUwMDE0TTFg0bio9bFcXFx1MDAxNFx1MDAxY01cdTAwMTdFNz27XHUwMDFi9TZvdNi4vu5cdTAwMTazcFx1MDAxOXZLjX27l3CMXHUwMDAy6sBcdTAwMWKhKcxcdTAwMTNcdTAwMDR8RFx1MDAxMD2HeU1cXNRcYutQ9mNKciHafy25lPm5UT9cdTAwMWa5JHxcdTAwMWNEtfDOXHUwMDAy+plcdTAwMDHaetquLtv1PVxym5uynFp7Slx1MDAxZlRPk1x1MDAxZefpzlKgJ1x1MDAwNmdR05/aXHJLesJtgI6UvDGoPGKSqahw4KxcdTAwMTBcdTAwMGJcdTAwMDdRXHUwMDE5n3RykmKbfIdcdTAwMTNdllx1MDAwNd+53Xpyjerm8Xr2XGbyp/WkK/pcdTAwMTRgIDmvRLLeoSBHaUYwqomMXG5E68lFXHUwMDE5n9ysXHUwMDEzgDWk6oWaX6SfXHUwMDBmRq0wcVx1MDAxOPXgPOiogHhcdTAwMGKjK141fKdzs1Jo3+CtKUH7ot5MOEbBXHUwMDEzXHUwMDA2lVx1MDAwNVx1MDAwMUpcdTAwMDLFe1x1MDAxY470ylx1MDAxOYKGoD+EQYr3oJKLUUnwcdZEXHUwMDAy42JglD4pXHUwMDBlo8zMlNJmdozuhL3qWlXmMlx1MDAxZFFcdTAwMGXXzcNj2OydJFx1MDAxY6NSyoCYptBcdTAwMTTKhTM4mrwniFxuY+mOWCFRJDgxSjZGMUBoOb/M6JzcqME4iFwiKE30Rjo9M0b1zXpYKFXu08f3WpRy5+FlfT1MOEbRqEA68j9cdTAwMWWU0TKShntcdTAwMTZMXHUwMDEwOMKuQk5MYYKdKPlcdTAwMTJA4qNcdTAwMGKHUFx1MDAxZJu7d2hQ0q2ZPeu0eqMuOnBdusruXHUwMDE1W6dcdTAwMGYtma3m80lcdTAwMTdM7EWV8kQzXHRcdTAwMDFcdTAwMDCRtYyfWSdA4ywvjoIzXHSGqJVcdTAwMTL418JBVMk4iKKj2dL+XHUwMDFkq0v6+mJze9dm95dVuYHb+6enzZxNOkTBm4Cw4STxXHUwMDFhXHUwMDAxUo5hXHUwMDE0XHUwMDAzLSjWkGJcImbuklx1MDAwYlGyMbpjOMdF+jlRUVx1MDAxZFx1MDAwYlFtke5cdTAwMWH42SU9bi7bo3C7lrtfbV5f7eNtRVxc51x1MDAxMlx1MDAxZeaVkIEha1x1MDAxNN4rXHUwMDBmVo2GeVwiXHUwMDAxSMhcdTAwMTDSeUy0oDfaWWW1XTQn6lxcbF6UvIvxRkaXSN+C6F7n8r6mbnqnzZW73HLnMXN8VF3/XHUwMDFkIGqEJS6qnZMjqXvOOVx1MDAxMVd1gqhcdTAwMDCr/eSKJS6FsVx1MDAxNtTCIVTGMlFcdTAwMDM0a1rI2X3oxe7DRqHaq4Rdd7ZV92erjfr+RdLDPNHMwFiHJJPAOI3KjkDUXHUwMDA2XHUwMDAyXHUwMDFk31x1MDAxOGe4jiixXHUwMDEwReEleVHQi5ZysjLWiyryoUJqNbtcdTAwMTNcci+ecvumqFx1MDAwZm/NQ72zv3N73To/TLhcdTAwMTNNOZJD9D3BSclcdJtcdTAwMTGEelx1MDAxMVgwSoNcdTAwMDaLTiVXLCktkWJeRFksXHUwMDA2QJ1cdTAwMTdxXHUwMDAwXHUwMDA1Vlx1MDAxMHTv9OxcYr16KKZPa21wl9nH7VpVmePc+i9Ois5Q6GRJXGZJ+qaEQZr3KD6eMVxugdJkrFZcdTAwMDOp/YiLSlx1MDAxYUbpXCLxXHUwMDE05Vx1MDAxNm3908ZjlL+wgWhZxZvVou2KKp6c7Oid5mrT5DLd6nphM+lOXHUwMDE0REA6wztcdTAwMDOoud4uUpPAb+BcYj2kokjqk2Am8CS3jMRcdTAwMTlnhV84JlxusVFcdTAwMWWFXHUwMDE2Xoj3rCx5XHUwMDExVjvdwoYt75ZqXCJzXuymdlx1MDAxYlx0Ryj5UGk8ek1cXFR7gcNy3itcdTAwMTF4hSRDXHUwMDE0XG6Z4Fx1MDAxMlx1MDAxMpLxxEXkwiXtnYivXHUwMDE1JXySdphcdTAwMWSehUZcdTAwMTWuWts7t08rXHUwMDA3t1f3ze398/VMwuGZsi6w1pIs1I6IJrnRXHUwMDExfMpAopTOoqbJwORWiqIgLi1cdTAwMTDEwvFQXHUwMDFiW2+PLJSMtbPT0OPMRerk5nxv67540d3aL4dcdTAwMGZcdTAwMGb791x0hyigXHJcdTAwMTR7SFCS5LBcdTAwMWGN8Fx1MDAwNGAgnU86RHlcdTAwMGLJLbe3aLVfQFx1MDAxN+ohNtukpLXo4Vx1MDAxZEp+fcs9XFyd7tQz9+XD7PLKSemyW096OpRcdTAwMDHKy1x1MDAxMp5cdTAwMWOQ5FxyIUNcdTAwMDAl7qlcdTAwMDMhNTlZrZXxNsGLSob/035cdTAwMDG1fHwps1x1MDAwNE9cIvZcdTAwMWRL8/dcdTAwMWJcdTAwMGad86Otq8xarry6fX2xXazmz1x1MDAxM1x1MDAwZdFcdTAwMTS5UMF1MsJpY6xcdTAwMWYpZSaM2sAp+lx1MDAxMUT0SEol14tcdTAwMDJdsDSChcuHmtgw7zQ4XHUwMDE33Y79pk7avYHbTmpzt3l3XHUwMDEwbuyk6j19eZD0ZFx1MDAxM3jL2+eFQCSkRpcwnnNNXHUwMDE4KND9XHJ3hGKdXFylXHUwMDA0QCYk1OIlRH188VxiOO2dXHUwMDE3XHUwMDA2Zlx1MDAwZvS5Wlx1MDAxNcqNVLpWz2RrOrO50fBcdTAwMGa/WMvPUuBkXHUwMDAyQqDXllx1MDAxM0p+dFWJMCqJXHIxepBcdTAwMTdtXHUwMDEyi1FjUJOkc4vmRJ2MdaIgOIXt5DtcbpzOT1x1MDAwZotccn9YujqrXHUwMDFkZU+0bj21Vs5cdTAwMTJcdTAwMWXo0fDKJil2pYiWXHUwMDEy3Vx1MDAxY07ZO7qsSMqT4JdcdTAwMWGT7EXRWuJcIkYvmlpyJjbhpFhcdTAwMWPCe7YsZc+3m1x1MDAxYrWtjaNMa2tXn3SOz07bSW+TI6VcdTAwMGVcdTAwMWNcYqKhxEhp1ofVkldcdTAwMTA4a/t1I+RpXHUwMDEznFx1MDAxMVx1MDAwNcnKwZk59iCZV1x1MDAwNV5spT2QNDBWKJw9zvuV8/rpffWyU8BcdTAwMTW3WXkqrJVcdTAwMWbuXHUwMDEyXHUwMDBlUfAuIDFvhffaaFx1MDAwMGlGMKpcdTAwMDKKn2iIqYJEmVxcQe/J0Kywi0dFMX7fp1x1MDAxMZr3jNvZNydcdTAwMWbs7dhuSp7ByeN1/cGmsVPYLCdcdTAwMWOiSlx1MDAxMDxIXHTT/UUhhVx1MDAxZNlcckJyMTCaVD1xVMNro8mFqDZcbr3Ri1bH7OPT9oCO63tRzVx1MDAwZdH1Qlhv3e1cdTAwMTfSXHUwMDBltleObjZuXHUwMDFl1sKjxEOUXHUwMDAzPVFRYclcdTAwMGaBXHUwMDE4dqL9tKi3gvyr8kbK5Fx1MDAxNo9cdTAwMDCS/Vxit4iCPt6LUtBcdTAwMTDc9WB2Lrpmd+5ztfW1p/AqbNbq4DauNm5cdTAwMTNcdTAwMGVR8p+BIMmOdPspkFx1MDAwYmdHMcqro16C1nR7olQvcSBVvDuVSfOCgVSa+I54zit05Edmz91X7/Nb6V1cdTAwMTf2Llxua2m7nXdHO+W4XHUwMDFhp1x1MDAxMbBcckN0tFx1MDAxZfP1VaV8u1p+XHUwMDA3Ru0sbUVZXHRpZTn1NJq6573JXHUwMDA2eEcl31x1MDAxOTDuUyVOnVa+3m7mW4SBcZxqrVx1MDAwMmOs05ZmXurIkskrTlx0oUG/o5TWRJ9hUs8xxWu5qL5cdTAwMGWnL1x1MDAxN1x1MDAwNsCK3O+Gvdjcv8Dl5ulx+7RSa/m6yVxmmndccqEw32o1XHUwMDFlfrxe+fvPae+bOd+9XHUwMDE2+TOzVz0uioNmoVx1MDAxM+7ordne9+VvybAucDZ+M4vgLXJcdTAwMTbf0c5v+bKS71xcXHUwMDFk7Fx1MDAxZjdSVVkopo+PN1RcXD5iqnmNrj99azM/bblcdTAwMTZNXHUwMDExTyalN8yjjXRcdTAwMDFcdTAwMDV+0CwriG/Hl2HTs/LqjVx1MDAwMFCpVMatSipHI1BI4ZY+30VcdTAwMTbTX62KXCJT4HkvXHUwMDAzyU2DUX3+umHVaMlcdTAwMWJWv45FT7Wq89LpKt5V7vaq+96Yxoo5yJz8juhcdTAwMTdcdTAwMTCf6rA0o85F6/LfQr8tbu7Ui4VO53Hn3pnlp9Ku6vaSvqShdSB5r1x1MDAwNVx1MDAxMoSUVsRyhlxyQEEgyd+DI1x1MDAwNjy0pTtpXGbIglRCLdy6MFxijMcoXHRJ40HNzn+2lrP3XHUwMDBm4U1W5S5219IrWbVij4pJhyh4XHUwMDE1oNFWcl9cdTAwMGIyWTWGUFx1MDAwMVx1MDAwNlx1MDAwNXlp3tGdZI7OXHUwMDA1XHUwMDE4Xi9cdTAwMWNH91x1MDAxMYowuqbRryrRZvZUhzvdyJz27uzmyurxzr06XHUwMDBm5ermL27DNsvCMFx1MDAwNqSYvSdcdTAwMDLurbejPYFcdTAwMTVcdTAwMDaSN1x1MDAxYzpP0TvJzVm80t5TXHUwMDE0WDg3qlxcrFx1MDAxYjVWSVx1MDAxMlc4e6TvPS7fhPlwfTt31MvlTn0h3HWtxPNcXCG04jVGr7mkeyTOXHUwMDEz0SWKS1x1MDAxNJIgqpz93I7YXHUwMDE4omtcdTAwMDOtaFx1MDAxMJKbwGk1QT7CM9tGYl7cKFx1MDAwNqL7dV52XHUwMDFiXG6Dyir4ukW3aURcdTAwMTex97jTbsPxg6rD9uPBtj+utWcjulPl429EoMthWGu2J9tcdTAwMTTxxjibQsKIXHUwMDA2Zdzs4nE1tfm0dbyXlb1cdTAwMDPZvs4101x1MDAwZvthXFxcdTAwMDJxqlHNz+1cdTAwMWKiXHUwMDFlfI5cdTAwMDa3XHUwMDFmXHUwMDEz0c6Fz05fXHUwMDEzMUHJus1+cot5Ja9R47hNsXS0xtHryb+p6IlcdTAwMWSvNuUoKqDtb9Hs96yXoyZcdTAwMDVcdTAwMWVcdTAwMTVyt/D5iMdP2FSSsK+mtPXkXHLsxsvZXHUwMDE3yVx1MDAxZkXtonq/bVx1MDAwMVx1MDAxZXZ6XHUwMDE1n013u+eYbOhbctVAmDFeSEUqeVQ2qn5iXVvlUVxu87l64kq+QN7ke9BvKfJcdTAwMTn9lfnIZMBTx+f1nOZ2rO9pUrPSOzg9Or9q1zOt+onRqdst7dLJxqc3XHUwMDAxeUyJwnpBXHUwMDE4XHUwMDFjQadcZlx1MDAxY0hcdTAwMDOWa+GI9Hxcbp2AXHUwMDA15ybw8a9AJyrlXHUwMDAwo0dcdTAwMDH8Nuh8K/NcdTAwMTa79qiNJOeJcnbJeLeVrm0yc7BcdTAwMTdP9e4y3jzI9C9u8zVcdTAwMDMhN4RBL/uiUWvrRurd+SwuuiOSdJxcIsKb3I3rXHUwMDAwaHjBZ+G6KHlcdTAwMTWfeFPsRH20XHUwMDEx9FtcdTAwMThN27ua9duVxure6nlnuXZb3ln7xZ27Z0trXHUwMDEwj9GOlCNnf4drNblHXHLvXHUwMDE1XCKhJrj1QoJrNWl00jrxO0b56aduxm9sXHUwMDAzT1x1MDAxOFx1MDAxNVx1MDAxNFneUU58mOnKXZu9W1x1MDAxM+srOzv32Vx1MDAxNYzd2fblYV58XGKh2lx1MDAwNki+UlxuirROR49cdTAwMTjtl8FpRVxi9U5JQb/d51LDxXJJlfLjXHUwMDAwlcR1pVx1MDAxNUSB0ViylkhkXHUwMDFiJDYwMF4765ltSHKYalx1MDAxNKLIXUeV/Uo3+nJholxuM2m9d5KRT1ukXdcvU3p143LvcDZcdTAwMTX257T33czVTHhSf+q08+Ji9XBv/ab31PgqdUdwILo+XHUwMDFm51x1MDAxZp/aIOZsXHUwMDE0yvdcdTAwMWPEuFJO5eHo2nWaZ49yZb1X2c2sJZ6gXHUwMDEwOVx0LFx1MDAxYc3HL1x07q0w3v1JXGLD59xcdTAwMTlF/jW5PfRcXL9kxSyc85+6Ic9xu1x1MDAwZfOOKuiLsL0s072101x1MDAwYpHN71x1MDAxYtvJp5dlsp0/qdxcdTAwMDClXHUwMDA04tDcjlSPIJS9v6BcdTAwMTBoiK55Jz/ZcFxcXHUwMDE1TbkyIVx0XHUwMDAx3lx1MDAwN9JbXHUwMDAwj9yHSkyo3yDvT+BFQzpUOOWjNVx1MDAwNT8hXG5cXH6iXHUwMDE27oCmaD/xXHUwMDExiEq2SVx1MDAxZF1cZn1cdTAwMTOhd729w4vl/HZnZ2VHnl2J1XVI/uI16lx1MDAwMKxcInhwP3mMXHUwMDFlY/HcidRcdTAwMDSSXHUwMDBiMFx1MDAxNOk8XHUwMDFmLeBJmlx1MDAxN1x1MDAwNU6kWLt4zSHIaOMwalx1MDAxNUU4mozZI31ntbZXrbWrdrmi0o3Udc2ellx1MDAwYknHKFx1MDAxYVx1MDAxOWiB5JpcZoAzY/2cTUBcdTAwMTSagihywjfBzXKl5lx1MDAxM7VcZs4tXHUwMDEzXHUwMDExab/4rdkygFg2yrkh3nw/O0Srayd+67GweplXXHUwMDA3T83zfPmweFx1MDAxOXcqeEJcdTAwMDK9dC6g6GmRmDc5Slx1MDAxY85DXHUwMDE4TVx1MDAxMVihUJI7zmvxPTJcdTAwMGZkoIhE8mJcdTAwMDFZgvGRj1x1MDAxOVx1MDAwNHqv+Cwzy1x1MDAwZV8hUZLxxTbB8mHo1d8p875Vjs3TXHUwMDAyplfBWURcdTAwMDPvqDG6gHSx2Mvu11x1MDAwZdPiuuuf0ie4XHUwMDFld1x1MDAxY2lC1jPYXHUwMDA0LHeC9rxcdTAwMWRFXHUwMDFhXHUwMDFjWXAjscY9KZSxRDC9/tySRizXhcCQtCBkU5RcdTAwMTAmmltcdTAwMWGsaSA3vFx1MDAxNMJb0d8zM8Z1uaU198iYz3Lz91pcdTAwMDBxh1xiw/2kXHUwMDA1dMq9zkTw2/hcXFx1MDAwNDvDfmO5mbF/WMrc7V9cdTAwMWTpjebVQ+8207kut+1jsrfAXHUwMDEw5lx1MDAwM97agqiJXHUwMDE1Kj16yKly/eI7XHUwMDE0ktgpfvKs6Im1S6gn8JHoyVx1MDAxNi+bWYWWnjuKzVx1MDAwNdrftbnlNH1oiqliPsy1ZK+3msvmunj8y4LGXHUwMDAwmY1657j21K8uckOPpvO3tfBxXGJdfVuiXHUwMDAx3tdanW4+vGzTXHUwMDBig5c7XHUwMDE3uf3tMlxyoP+Oeuily2Htik3vR1iuXGbbZKdGXHUwMDEz83q504is4Vx1MDAxNGkoeXq71lZp9Cs1WrWrWj1cdTAwMWaeTFx1MDAxOdZU3/A80Vx1MDAxM5yDjO9cdTAwMWLG68rkqmdcdTAwMTfY01x1MDAwMfVcdTAwMGLKXHUwMDFh3/RcZlrYgMNcdJLxK1x1MDAxN21o1H+51IHU5Fx1MDAxYVR/Z4P83Fx1MDAwMtXkosZAcDrfSevpfy0mXHUwMDE0NVwi14EpxedcdTAwMWIrjUpGT1x1MDAxNXshhY6rXHUwMDAwXFy0XHUwMDE3cZKES2TW8q3OSq1eqtWv6OLAdfwoP3/01lxmXHUwMDExpm+zxS6PMiVcdTAwMDLPRf2aj6ZWpP5cdTAwMDbHXFzwXHUwMDE05JvPtFuS6lx1MDAwM+2lN8R7lHt5xqtcdTAwMGb7Ua6X3lx1MDAxZdT0nZ/RQUFAZkM3RIO1Rlx1MDAxYTM+Jkk0qK+6pNbWXHUwMDFib8aGXHUwMDE05tud1cbtba1DU3/QqNU7o1Pcn8tltupqOT/mL+grRa+Nmn+T33E4Plxm/rY0sJH+P17//p8/Jz47XHUwMDE1i+H+1XH4XHUwMDBl3vCP6J/vdl3g4lx1MDAwZsxcdTAwMTQ0rzp6XHUwMDE4+Fu+a3rQSqTvXHUwMDAyXGacMoq4hTPK4OjiOlx1MDAwMZ9cdTAwMTNcIkKTojTuk1x1MDAxNXRcdTAwMTNJTUBKQTjuXHUwMDEyQoJcdTAwMTYnkXn0JvBoXHUwMDE0N1j2Q2vJr9VcdTAwMWbgSVx1MDAxMkj8f+a6ROCclyTDvFx1MDAwM7qFkSbdr16CXHUwMDBi6lxy3TuCOadcdTAwMDOMn+64hr/F7+Q/YnHEP2NcYnqn94hcdTAwMTNFXHUwMDE4aVx1MDAxYzbqPKRA8lTvKM5pZFx1MDAxZs9ubaPcLGbO2/uq53Xq9EOFXHUwMDBm89NEzlx1MDAwNzTd5LWVdlx1MDAwZYRcdTAwMTjO2fJOzUDxjk3pnJRcbj/XXmWy+1AzaVwiXHUwMDEwXHUwMDEyrHZfeLTJK4K+dmP+VE10eUfM/na1dLV2SVx1MDAxYXPz5GB/q1hdXHUwMDA0TfR8N5MmiZ5H9TFagT5eXHUwMDExkSP22pnZN8xOx1NcdTAwMTJphVTE5/hcdTAwMDRuzV1cdTAwMDEs4qhnQKJcdTAwMWRcdTAwMTSUXHUwMDE09y6Unz1cdTAwMTJpomdcdTAwMDBhXHUwMDAyR1x1MDAxZu1IgFx1MDAxMmsk/TXuKIhCoyT2Yzw3K3U+kjP5mUyxzlxuObfVxm/lXHUwMDE101x1MDAwM8wrZfBcdTAwMDF6a9FYllx1MDAxZdxcdTAwMDd7smhcdTAwMTKcXHUwMDA146JcdTAwMWPBZbdK/oypXy2Jnlx1MDAwN4UuXHUwMDAwzjtznLGSXHUwMDBmMpqsmoRcdTAwMTe8QEKciIZlzLhO+51ITTyE+Sc1jt530pp474XxR7p5x4fcvKPubHrUSqL3sjJwJHZI+TG43UhCXHUwMDA3+KhccuuE1EZ5bsvyXHLbVEFDXHUwMDAwXHUwMDE0XCJcYsaOTFxm3KR1voC7oDrSqJJbbMrIdtSfzotgQaFmfodf/1r3XHUwMDE1cU5AhFx1MDAxNEi1XCJcdTAwMGIk7uw35lC48Vx1MDAxZlqFkmjhc9Ob6f7r91VG8Vh6vjxcdTAwMDajd3qROHFkpm3MRXDeR1Olb/ZHrW489MJcXObx8WlVy+WM0vXK2q/0XCL+LS+S0nyomXL9XHUwMDAzd4zGkcMktFS8nmRRXHUwMDBiukNexi+WXHUwMDE2PeYx/43iqH/ajv9KdTStfppl+mBcZt+tM0hJVIJ2sdVcYsPLRqXSLidi6WXCqD5cdTAwMTapdXzrNG1cdTAwMDRZdZS8vmVj03dFJzFSp6znXHUwMDEzqsnXW66vJa8/ZGOqf4C1JetcdTAwMTJGO4VTemd+2MhcdTAwMDBVYJXnXHUwMDE0XHUwMDFjSC8hsmFrXHUwMDEwqo1cZri3XHUwMDA3elSWrHIsg8l1Q4pcdTAwMGL95tLyXHUwMDA0rDPqK0L1aFB7O4hPb1oywuWl53RcdTAwMWVqcpXCXHJK/pZcdTAwMDbJTU/S0mrF7W608vL3ZvuxQOpfXHUwMDFkhdBcdTAwMTdF6eh+37H6fetcdTAwMWRzg9mj9NHZzX796v60dt29yOS1PCnBdpjsKI3OkGrkQ1x1MDAxNLRA6e1o3SmRJ6GF8WSeXHUwMDE433huXHUwMDA29zG1rSmYXHRcdTAwMWK2JoRqkoOMdfi6XHUwMDFh/VdcdTAwMWP9ilx1MDAxNjFzTDg+Lv176SXiPiaBXHUwMDAyXGaN52PB30yR6aRrjFx1MDAxNPJcdTAwMWR1XHUwMDE3U+91XCJNXHUwMDE3hFxulCNpY6Xpn1x1MDAxYTmyOYyrZT2FXGLe4m7UlMqLT1uv8oHzvHvGcz9cdTAwMTIhJ3BcdTAwMDBSYVx1MDAwZcBcdHR82ICa0Fx1MDAwMseK/smyZi6rmGA1XHUwMDA1ky/gXHUwMDAwcZF+eihYiq5iUpwnXHTCfcOF9laPXHUwMDA3elBcdTAwMDFdI3fBrWelNm682OFr2Vx1MDAwN9FGQYzCsjDiqr5J3IN71HE9oHRa0vB/b+5cdTAwMTFcdTAwMGJg/kmNYfeLyIeRscVcdTAwMTc0XHUwMDBloKDrZ+9cdTAwMGaDXHUwMDA3qdZaeLjse2F2I1u73c6Bc8l2YOSZXHUwMDAydl7kw4zhxZAh/+UlXHUwMDA2SE5cXDllLZeifJ//slx1MDAxM1x1MDAxY9Yk9tE/1O1cdTAwMGKXQ6aRj0/sXHUwMDBlf5t8XHUwMDEwnfaRpv7fTT56g2DfS1x1MDAwMvlcdTAwMThcdTAwMWHPx8hcdTAwMDeXp8VcdTAwMTkvzbAw7zr+aPrNTqTxXHUwMDAy9yVBpygmeTJgXHUwMDFjyT2QTlx1MDAwYlx1MDAxMNGT/Vx1MDAxYe99fO1DibSHqHzCeJ1cdTAwMGZQsCZUxnKX7HFTNuTdjdJeKvLfWsFY41x1MDAwN7DAgfUri8a/xfhm5Fx1MDAxZdNDQSSGXHUwMDBiJTiuXHRyr9xBYFx1MDAwMlx1MDAxM+CVTor0XHUwMDFj4ZWy3HOByPHH2Mf0ziZcdTAwMDOyM2FcdTAwMTTc5Vx1MDAwMFx1MDAwNaBzgLxs/nuTjTjA8k9qXGarX8Q1tI7lXHUwMDFhUvSLe9/RwnzdbKz2uqc5dyGOb25qVX1cdTAwMWFmk801NK9cdTAwMDKR7XnBp11ZO9xcdTAwMWKaZjvgynVNXCKK7suUXHUwMDBl+5+lXHUwMDFhOKk63Y4lRHmbL1x1MDAxOdxcdTAwMTdcdTAwMWVcdTAwMGU8jWp8b8NcdTAwMThUVnxIaX2aaiz91//Wn5dcdTAwMWGmV1mZ4beZJ/uYNMRcdTAwMGZcdTAwMTJcdTAwMTLw8VvUKOKAtO85pWA6Jlx1MDAxMmni3lx1MDAwN55z4ZJcdTAwMWKB2JGaK1wiXCKBRd7VzkdcdTAwMTR4XHUwMDFiT0g+rSbIv5NIXHUwMDA3329RJGDSKqTl4lx1MDAxNW208dKSjejx7Shk/PRiO5+OfsinWOiP2OiMjGR6wFiKZlx1MDAxZZDPylx1MDAwND6uwFC8x/GdXHUwMDFmKuBSW3IqgMhV31x1MDAxZt2MMnVcdTAwMGL+0JCI8lx1MDAxMNEl5Vx1MDAwZp6X7u3YkFxcoPlwXGbNjVx1MDAwZpif/N51V6l4XHUwMDA09y+PgfeLKFxuQPwxcyRcInjh/Fx1MDAxZOdg1dxGXHUwMDBmM+kw18hgunS0JY5cbvvbiXZg4HSgNVx1MDAxZj/FvJDsYPR4XHUwMDAwXHUwMDEzOEt6SkpyXHUwMDBicko70lx1MDAxOc7BmurB7IQ2uZFcXPvPTlx1MDAxZlxc1+Hn2C/p06slkfa/QyxiXHUwMDEwKX6yiJVGvlVabjYn0oXI28yDLryO5dmo/nix21x1MDAxZvlm87hDs/Tq42j+a6WXrzp4x1x1MDAxZve18sPK5OV8XtH/48VQ2VwiynxcdTAwMWL++vuPv/9cdTAwMGZcYuGAVyJ9 virtual_size.heightvirtual_size.widthself.scroll_offsety = scroll_yx = scroll_xx = scroll_x +self.size.widthBoardApp"},{"location":"guide/widgets/#region-updates","title":"Region updates","text":"The Line API makes it possible to refresh parts of a widget, as small as a single character. Refreshing smaller regions makes updates more efficient, and keeps your widget feeling responsive.
To demonstrate this we will update the checkerboard to highlight the square under the mouse pointer. Here's the code:
checker04.pyOutput checker04.pyfrom __future__ import annotations\nfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Offset, Region, Size\nfrom textual.reactive import var\nfrom textual.strip import Strip\nfrom textual.scroll_view import ScrollView\nfrom rich.segment import Segment\nfrom rich.style import Style\nclass CheckerBoard(ScrollView):\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n\"checkerboard--cursor-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard > .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard > .checkerboard--black-square {\n background: #004578;\n }\n CheckerBoard > .checkerboard--cursor-square {\n background: darkred;\n }\n \"\"\"\ncursor_square = var(Offset(0, 0))\ndef __init__(self, board_size: int) -> None:\nsuper().__init__()\nself.board_size = board_size\n# Each square is 4 rows and 8 columns\nself.virtual_size = Size(board_size * 8, board_size * 4)\ndef on_mouse_move(self, event: events.MouseMove) -> None:\n\"\"\"Called when the user moves the mouse over the widget.\"\"\"\nmouse_position = event.offset + self.scroll_offset\nself.cursor_square = Offset(mouse_position.x // 8, mouse_position.y // 4)\ndef watch_cursor_square(\nself, previous_square: Offset, cursor_square: Offset\n) -> None:\n\"\"\"Called when the cursor square changes.\"\"\"\ndef get_square_region(square_offset: Offset) -> Region:\n\"\"\"Get region relative to widget from square coordinate.\"\"\"\nx, y = square_offset\nregion = Region(x * 8, y * 4, 8, 4)\n# Move the region in to the widgets frame of reference\nregion = region.translate(-self.scroll_offset)\nreturn region\n# Refresh the previous cursor square\nself.refresh(get_square_region(previous_square))\n# Refresh the new cursor square\nself.refresh(get_square_region(cursor_square))\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nscroll_x, scroll_y = self.scroll_offset # The current scroll position\ny += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!\nrow_index = y // 4 # four lines per row\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\ncursor = self.get_component_rich_style(\"checkerboard--cursor-square\")\nif row_index >= self.board_size:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\ndef get_square_style(column: int, row: int) -> Style:\n\"\"\"Get the cursor style at the given position on the checkerboard.\"\"\"\nif self.cursor_square == Offset(column, row):\nsquare_style = cursor\nelse:\nsquare_style = black if (column + is_odd) % 2 else white\nreturn square_style\nsegments = [\nSegment(\" \" * 8, get_square_style(column, row_index))\nfor column in range(self.board_size)\n]\nstrip = Strip(segments, self.board_size * 8)\n# Crop the strip so that is covers the visible area\nstrip = strip.crop(scroll_x, scroll_x + self.size.width)\nreturn strip\nclass BoardApp(App):\ndef compose(self) -> ComposeResult:\nyield CheckerBoard(100)\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp \u2585\u2585 \u258b
We've added a style to the checkerboard which is the color of the highlighted square, with a default of \"darkred\". We will need this when we come to render the highlighted square.
We've also added a reactive variable called cursor_square
which will hold the coordinate of the square underneath the mouse. Note that we have used var which gives us reactive superpowers but won't automatically refresh the whole widget, because we want to update only the squares under the cursor.
The on_mouse_move
handler takes the mouse coordinates from the MouseMove object and calculates the coordinate of the square underneath the mouse. There's a little math here, so let's break it down.
self.scroll_offset
to event.offset
.If the cursor square coordinate calculated in on_mouse_move
changes, Textual will call watch_cursor_square
with the previous coordinate and new coordinate of the square. This method works out the regions of the widget to update and essentially does the reverse of the steps we took to go from mouse coordinates to square coordinates. The get_square_region
function calculates a Region object for each square and uses them as a positional argument in a call to refresh. Passing Region objects to refresh
tells Textual to update only the cells underneath those regions, and not the entire widget.
Note
Textual is smart about performing updates. If you refresh multiple regions, Textual will combine them into as few non-overlapping regions as possible.
The final step is to update the render_line
method to use the cursor style when rendering the square underneath the mouse.
You should find that if you move the mouse over the widget now, it will highlight the square underneath the mouse pointer in red.
"},{"location":"guide/widgets/#line-api-examples","title":"Line API examples","text":"The following builtin widgets use the Line API. If you are building advanced widgets, it may be worth looking through the code for inspiration!
Widgets may be combined to create new widgets with additional features. Such widgets are known as compound widgets. The stopwatch in the tutorial is an example of a compound widget.
A compound widget can be used like any other widget. The only thing that differs is that when you build a compound widget, you write a compose()
method which yields child widgets, rather than implement a render
or render_line
method.
The following is an example of a compound widget.
compound01.pyOutput compound01.pyfrom textual.app import App, ComposeResult\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label\nclass InputWithLabel(Widget):\n\"\"\"An input with a label.\"\"\"\nDEFAULT_CSS = \"\"\"\n InputWithLabel {\n layout: horizontal;\n height: auto;\n }\n InputWithLabel Label {\n padding: 1;\n width: 12;\n text-align: right;\n }\n InputWithLabel Input {\n width: 1fr;\n }\n \"\"\"\ndef __init__(self, input_label: str) -> None:\nself.input_label = input_label\nsuper().__init__()\ndef compose(self) -> ComposeResult: # (1)!\nyield Label(self.input_label)\nyield Input()\nclass CompoundApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n InputWithLabel {\n width: 80%;\n margin: 1;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield InputWithLabel(\"First Name\")\nyield InputWithLabel(\"Last Name\")\nyield InputWithLabel(\"Email\")\nif __name__ == \"__main__\":\napp = CompoundApp()\napp.run()\n
compose
method makes this widget a compound widget.CompoundApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e First\u00a0Name\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u00a0Last\u00a0Name\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u00a0\u00a0\u00a0\u00a0\u00a0Email\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
The InputWithLabel
class bundles an Input with a Label to create a new widget that displays a right-aligned label next to an input control. You can re-use this InputWithLabel
class anywhere in a Textual app, including in other widgets.
Widgets rarely exist in isolation, and often need to communicate or exchange data with other parts of your app. This is not difficult to do, but there is a risk that widgets can become dependant on each other, making it impossible to reuse a widget without copying a lot of dependant code.
In this section we will show how to design and build a fully-working app, while keeping widgets reusable.
"},{"location":"guide/widgets/#designing-the-app","title":"Designing the app","text":"We are going to build a byte editor which allows you to enter a number in both decimal and binary. You could use this as a teaching aid for binary numbers.
Here's a sketch of what the app should ultimately look like:
Tip
There are plenty of resources on the web, such as this excellent video from Khan Academy if you want to brush up on binary numbers.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaU/jyFx1MDAxNv3ev1x1MDAwMjFf3pMmnlpvVY309MTSQCCsYe2nXHUwMDExMomTmCTOYidcdTAwMDRG/d/frUBcdTAwMTNncchcdTAwMDKM0400PVx1MDAxMNtJueqcU+de36r8/WVtbT16bHrrf66te72CW/OLbfdh/Xf7etdrh34jwEOs/3fY6LRcdTAwMGL9MytR1Fxm//zjj7rbrnpRs+ZcdTAwMTY8p+uHXHUwMDFkt1x1MDAxNkadot9wXG6N+lx1MDAxZn7k1cP/2n+P3Lr3n2ajXozazuBDMl7Rj1x1MDAxYe3nz/JqXt1cdTAwMGKiXHUwMDEw3/1/+Pfa2t/9f2Ota3uFyFxyyjWvf0H/UKyBko++etRcYvqNpVx1MDAwNDQ1lHP6eoZcdTAwMWZu4+dFXlx1MDAxMVx1MDAwZpewzd7giH1pXHUwMDFkzppcdTAwMTdnwVOnSW865dxWZn9jc/dm8LElv1bLR4+1frNcbu1GXHUwMDE4ZipuVKhcZs5cYqN2o+pd+cWoYlsw8vrrtWFcdTAwMDN7YnBVu9EpV1x1MDAwMi9cZoeuaTTdglx1MDAxZj3amySvLz53xJ9rg1d6+Jek2lFEU05cdTAwMTRcdTAwMDHDxetBe7XQ4GggWjNcdTAwMTCEMdAjrdpq1HAwsFW/kf7PoF13bqFaxsZcdTAwMDXFwTmiXHUwMDAwXklcdTAwMGXOeXi5VymoQzhjnFHNKSHy9YyK55crkb01RVx1MDAxZInNUJJcdTAwMTEmqVx1MDAxMmzQXHUwMDEyrz8mXHUwMDE0NKGcXHUwMDExMlx1MDAxOFT7+c1ssY+Pv+I9XHUwMDE2XHUwMDE0X3os6NRqgybbXHUwMDAzX2OYXHUwMDFhXFzTaVx1MDAxNt3o5WOUUoZQrYlcdTAwMTn0Rs1cdTAwMGaqo29Xa1x1MDAxNKpcdTAwMDO09F/9/vtcdTAwMDIwpYQn41RcdTAwMTNcclx1MDAwMsDMjtM9dpJxm72oVT047Fx1MDAxYz1cdTAwMTGze8C/Lo5T9k44xWF/XHUwMDEzqMrBW+VcZlxiUcA4XGYjVVx1MDAxYYdcdTAwMDHiR1HBXHJ2i1pcdTAwMDaqUdtccsKm20YkTIKrcLBcdTAwMTlcXHBcIimlXHUwMDEz0MqBOJpcdTAwMTIjXHUwMDA0cI5cdTAwMDRjMIpWMJJIzpn8NLCaT1x1MDAwMWucmKNY5VpQI1x1MDAwNZiZsZrb2bvZolx1MDAwZlutWq8rRHGDkqfrIFx1MDAwMasjePsnUUqIXHUwMDAxhVomULFGQKpcdTAwMWSthcbxXHUwMDAw0Fxu4CNBylx1MDAxZEJBMi1cdTAwMTVVRulxlDLtXHUwMDAwXHUwMDEwjlx1MDAxYU9cdTAwMThwS6xRlCrNSJ9Sq4dSr1bzm+FEjIJcdTAwMTKJXHUwMDE41YIowbWSM2PU69ZPN77dXHUwMDFmXFzlstmb6kaudbW3XV5cdTAwMDSj7zXjz4BRXHUwMDFjeUMkXHUwMDEwoVx1MDAxMa7EsGGQXHUwMDAygpRLg8rGjIXOUnN+yZVMsnF8Uu5YRyFccuCEzpBcdTAwMTJiXHUwMDFjoJQ5XHUwMDEyQFx1MDAxOJRQwlAshVx1MDAxYVx1MDAwNShcdTAwMTeUMWpibfxcdTAwMTlcdTAwMDCqmE5cdTAwMDIo+jBcdTAwMWMxodTM+DSsV7nNRlx1MDAwN7nuUfd8v51vcdVopFx1MDAxY59SXCJcdTAwMDJcdTAwMDFcdTAwMDEqXHRcdTAwMDe0pSP4VFx1MDAwZVCBRGVUSFx1MDAwNVIsXHTQO3ScXHUwMDFmXHUwMDA1UIZyYlx1MDAxOFx1MDAwMbWCXHUwMDEz/TSE6uRpXlKMXCJcdTAwMDSfXHUwMDE5oHv5ulxmy2euOLmqXHUwMDFmh8F5buN6p5NygGqOYVx1MDAxMZM4W0itXHUwMDE4lyNcdTAwMDBcdTAwMDVcdTAwMDdRY7hmiFWtR83HfPik7E5r+Dh8XHUwMDEynFx1MDAwNriWn1x1MDAxNzV9jlx1MDAxMdUyXHUwMDExoFx1MDAxOERcbowk+excdTAwMTBccjJnV0FwuXta7mZ36MZGePh0t5duiCqG8ZA0SmBgLVx1MDAxMYxmXGKiUjJcdTAwMDSwZIDgXHUwMDExeFAuXHUwMDA10VKpNFx0n1x1MDAxM1x1MDAwMFx1MDAxOWPKj1x1MDAxOVxccpzCcVx1MDAxMp9cdTAwMDN/P5AwwFx1MDAwMn955XsyLF+vXHUwMDE5XFxcdTAwMWTDUuT1XHUwMDA2Jjo28s2Th92jTKV0obY297fJ6WbLO9Drr+d9/33y2z5f/PW47rmt/Xz1kGarXHUwMDE33lGzenFcdTAwMTJcdTAwMGV/yo/Pd9vtxkPsfV9+S+KSxrBSsHgotSSXhu4/RiNhXHUwMDEyaaRBgVx1MDAxYVxuYd+i0eTOnEyjiluodNpe+omk3o9IU1x1MDAwMzrGx+nExuiEXHUwMDAxNjZCXHUwMDEzmc6QbTDWjSDK+099S0uGXt1x637tcWi4+uDE/lkz8e5cdTAwMGI9/LxnJFx1MDAwZZ25UfPLXHUwMDE2uus1rzSM6cgvuLXXw3W/WIzPXHUwMDFkXHUwMDA1/HBcdTAwMTffsZ2dRfNcdTAwMWJtv+xcdTAwMDdu7XzQtsUnK5qciTZCXHUwMDEzYSPSmVnmV0/Oj2vlzMP+9l6tK+tHJrpkqWeZXHUwMDExyDLGNNpcdTAwMTFcZnHYsOVHmXE4xnnKXHUwMDE4jFx1MDAwYthcdTAwMDfm9lx1MDAwNHdsXHUwMDFhXHUwMDFjuKVcdTAwMTBIOSm7x5hcdTAwMDPKSGyyTeRQYsbzJngpXHUwMDFlg3nC0oUmtXR4LWqSXHUwMDEzKqibIONcdTAwMDH6W/DduoON6FwiMsXb++vTXFzwcHK2XHUwMDE1h9o/9Vx1MDAxY+VtXGJrKlx1MDAxZI6ux3CDs7PUZlx1MDAwNMLCMTgmXHUwMDE4XHUwMDFhUUYwMljKcVx0UiBSTZokXHUwMDFj++Rcbj9cdTAwMDQ7XWpKJ6CXOlJcdTAwMTJF7dRtjb9cdTAwMWVcdTAwMDUvN4JK8vGGLFx1MDAxZNhlsXFcdTAwMWRPWHOFgVx1MDAxM5394cp+b/eCXHUwMDA2N1t1cXpPj8J6JnNZ99OuvUZcdTAwMDFcblx1MDAxYdpw9DFCXHUwMDFiMVx1MDAwZVxcITHg11x1MDAwMiNdJZeKZj9Fe5F7XHUwMDEy47t/XHUwMDE2v/Fu/1D8isRAVzFcdTAwMTQhXHUwMDE5XHUwMDFmz7fgXHUwMDFifVx1MDAxNU2/c1jyXHUwMDAytl1RXHUwMDE5kTO79HZcdTAwMTW0XHUwMDE3J1x1MDAxOFx1MDAwN4HKudFcdTAwMTKNOGUjXHUwMDEwXHUwMDA2h0hcdTAwMDGaXHUwMDE5gtJrlkHwh0qvfZzLmFwiep5s4TtGw88oyOe/XHUwMDFkdO/Pe3dunlSOXHUwMDAyUz2JVP3dwlb0d7FHt1x1MDAxZkpccpmYRqdAUTw4mUPaN/d6XHUwMDA10T5Sgp1vfDvdr9Jet1dLu7RjbzuUY8dcdTAwMTNcdTAwMDFExaPCV1ttn1NcdTAwMTP0JahasFxmMT5B2pmy8bdcdTAwMTK/iLRzQVx1MDAxM/HLJEhOyVx1MDAxY88p71xcssd446RVydz1mt6J37qst1ZB2y2G0XegfFNcdTAwMTR3zSeEhuhaKMNAg/LlQsPfNGjPTEi2v4e4o61cdTAwMDai8YRfXHUwMDA0vclVS1x1MDAxOP9cdTAwMTjCXHUwMDE5mT1cdTAwMDHf2rlcdTAwMTf+dnBcdTAwMWJcXJU293dP+d7N8U1SIUhqxFx1MDAxNyiaXHUwMDEyXGb4lFbow1x1MDAwMMRcYnCFI21lndBcblx1MDAwNZGnPadhn2VRXHUwMDFlL877qeGLTE9cdTAwMTRfyYnSqFx1MDAwNLPj9+yqVbx42pG128JZVVx1MDAxNKNm7f6gsFxu4lx1MDAwYlQ7XHUwMDE0XHJcdTAwMWJojFx1MDAwZlxy3vN4bKhcdTAwMTDZwI1SiObUii9lXHUwMDFh8Dao+UXgy03yXHUwMDAzepuZxLBRzVx1MDAxZVx1MDAxNz5cXFb35bedyq6f9erB8VngPVx1MDAxY6Q+rYF65lx1MDAxMCptvVxmXGLsj9G0XHUwMDA2c9D14pQsXHUwMDE1pfFAIJ3ySzlGK4LJX1x1MDAwNcBCJeY1NKP2QVx1MDAwMZ1dfm9Ubkfz4lG1ZIr5Mjts1VtVs1x1MDAxMvKr7MNHNPpoXCK0UWN5XHLEsCRGM2LQbi1ZaPKh8quFXCJGxiOan1x1MDAxYr06Oatsi4Ml9sbsqYf7w1x1MDAxM9K4PLy99MONh43QXFxH+fOktFxcauRXceUwjNgwNiNcdTAwMDTFS41AlztSoCnGIaGGkbRnle1iIFxmt3+Z3IOEZP+A3Vx1MDAwNMJQmD33kMtcdTAwMTauvcr1TeusXrjNPmRcdTAwMGav+fHxKuivXHUwMDA1sTRacS2YfVx1MDAxNqTHQUxt9lx1MDAwMf1cdTAwMTSVii9cdTAwMDPij9VfyVx1MDAxOcM2/ir6K6eU+INcdTAwMTbGMJgjdVbee3wqXHUwMDExt37VaoS3hzdcdTAwMWI8W8Omp1xcf7VE4bOVSURb68BGrUNfXHUwMDE2gSphUcGXeibyXHT6y4kkiH4+T5nqXG7jXHUwMDE3ZGL2gffLis1cdTAwMWNld1ftzVMhzEGvfXfwKFx1MDAxYrVcdTAwMWJcdTAwMWFcdTAwMTb2V0F90Tw4VKBNsE/0uIxVwP2AMChbXHUwMDFiheBcdTAwMDK0walVX4bmXFxY+/CLgJcmi68xVGCwMrv3velcdTAwMTVOdffkXHUwMDAyrlx1MDAwZnfunlqd/cvk1Vx1MDAwManRXjSLqL3CYM8zNbR+91V7cUKWiFrN4s44ndqL5uHZ//xcIvCNXHUwMDE1q43AXHUwMDE3XHUwMDE1XHUwMDAwea7nSPxcdTAwMTa7VffxdrtcdTAwMDS3j3fu08XxiV9/zK6C9lwiT1x1MDAxZEJcdTAwMThcdTAwMDDKL1omOlx1MDAxZb4pZp8/UvtoWaU48Vx1MDAwYii9gPj+eZxvUq0+Jcm6S23yXGKHi8yM3MKW2cs8VkpcdTAwMTc7ep9lg9Pc0Vx1MDAwZZUrgVxcw1x1MDAxY3RcdTAwMGKEXG4lXHUwMDEwejCCXFxcdTAwMDKOXHUwMDEyNppcdTAwMDctUZ2TXHUwMDBi9lx1MDAxMTeuWFx1MDAxMLmxRN1cdTAwMDCqZFx1MDAxNJv9dYHA4lx1MDAwNYbLXHUwMDE2679cdTAwMDJ3Ql1P7mJcdTAwMGZuu7RLOlx1MDAwN/eiXFwq34WN2FQ6XHUwMDA0sbnrelx1MDAwNKNcdTAwMWFiOYPFllx1MDAwMsQqUN5YXG4wNC6DlVx1MDAwMGLoxFlXXHUwMDAyRI1m0jKAoVx1MDAxYlx1MDAxOK35J9NL/pN4XG7JZUmKISbto6qZaXr+UKrlNk0+XGLK7u5Fdvss4zG6XHUwMDEyNLXPXHUwMDBlKWFcdTAwMTjWac3VyMNxXCLt7MPxP1xmX5VKtkjLsHSSXHUwMDFiXHUwMDFhI6mdgDBcdTAwMTD5+PU0n09cIppcdTAwMDZcdTAwMTLRxUhERTKLXHUwMDA0XHUwMDAyRtrtNWZmUY5+bfaK1b2C2JK1m8JJt83yuVVgkVbELoanXHUwMDA2JzOiOZ1AI0BJsfGzkmy0XZ862VEmXHJX8bTxz8Mkllx1MDAwNiaxXHUwMDA1mVx1MDAwNIllhuhcdTAwMTjR/+NcdHpmJu2dbXSzl5fhrp/fyarr6Lq3ebGzXG5MwlDGYVLYXHUwMDFiRnuoKYwxSYKtz5bckPhjoHdl0qRcYmeMSZRcdTAwMTOpOPC5kqGrwiSRXHUwMDA2JolcdTAwMDWZlFxcs6BcZlx1MDAwN61cdTAwMDWfPf66XHUwMDBmz13aOFx1MDAwN5/Ip8fbsOh+O6+vRNZcdTAwMTZcZjhcdTAwMDLjL4M84dyIcVwiXHUwMDAxKENcdTAwMTkwW8r9z1x1MDAxMkmjs/uUzS8+n0gyXHJEkouau+RtXHUwMDA3XGLaXGKGnTk7k85uy3tQb3qV7Y2n3YjSXG5/MKvBJC5cdTAwMWRFtX28rFx1MDAxNWej1T9cdTAwMDRcdTAwMWPWL4RcIooyPeVcdTAwMTHexzOJc6FcdTAwMTVTn7BR3OdcdTAwMTNcdNJAJFiQSDzR22HQIKyhmWNKOi+b/MmFyLiFfWRJJLdcdTAwMWW71XBcdTAwMTWIJFx1MDAxNbo3YiTBXHUwMDE5WFx1MDAwYi1GyjhwSuKcUlx1MDAxMFx1MDAxY12VgOQyjqWIRGYhkt1yz65cbvhcdTAwMTlnJJVcdTAwMDZcIqlcdTAwMDWJZJL34Fx1MDAxNVx1MDAxYeNaXHUwMDEy31XyLVwi8XLQpHukWij1NiumSup1fytpOUuqiKQpdZRdUkw44lxcqdFcdTAwMTlJONTuzStcZmg2bUnLx6dcdTAwMWJcZlx1MDAxMcauqvlcdTAwMTmJxNNAJL4gkZLTdlx1MDAxMuyihDm2XG7ptIS/V2td3T1G3V3voFx1MDAwYse3Okqg0WLbslx1MDAxNd2w4r3zdlLaIVx1MDAwNqdcdTAwMTljjJB0JDrCOdlcdTAwMTFcdTAwMDKhi7GRYYpM2XyVgyhcdTAwMTXUdFxuTdyYTc6U9pbcSPGu+1a+XHUwMDFjWIGd0pZjZzZodqJ//Ttcclx1MDAxY/3RlKlMfe7QXHRUTa5BXHUwMDEz9lx1MDAwYlx1MDAwM0Do2esgplx1MDAwZnA6d1CU4IDk6Fx0cUJDYzi8fkhcYuZQTe3G2VxiMymSXHUwMDBi2Fx1MDAxNyYqY1x1MDAwZfpVQFx1MDAxOVDcME5iNVx1MDAxOINcdTAwMWQ+ibGb4jNb1Gp3XHUwMDAxZWPfi1x1MDAwMFx1MDAxYVBr9Fxc+/ksOlx1MDAxMVwi1WQ8mzJcdTAwMDfVwshtR5t+UPSD8vpQXHUwMDA1xsu3fGRnkPw+OVx1MDAwYlx1MDAxZNtK4lx1MDAxMG1QSXH0XHUwMDAwhUzSwY5Ltlx1MDAwYtymXHUwMDFkYUdcdKLQunGFWGaoxi9nXGaqPLyg+Hajpm9cdTAwMTVcdTAwMTdrVIZYzFx1MDAxOIyUXHUwMDE5XG68xMbJsUZRh1GluMBwXHUwMDA1zyGcwVijam5cdTAwMThtNdA0Rtj5J1xyP4hGO7nfm1x1MDAxYpbYXHUwMDE1z1x1MDAxZJNcdLyp+LFRXHUwMDA1aNp3XHUwMDFjluzBb2tcdTAwMDOW9P94/f2v3yeenVxmYvszXHUwMDBl38H7fYn/f26bkVx1MDAxOPditCdcdTAwMTiXc+z+ynVR+81vN6Ql/bPcXHUwMDE27+RPL5LyRynRLiPAkYaAfC4vieVl7PVcdTAwMWGYY5fzckSixDebkjlaVLtgtlx1MDAwNKyiknE6X1n34i4jXVx1MDAxYtss5zLyXHUwMDBmPkaE6bBcdTAwMTmvbVnMZ0Dy5pdGg/0yhtlr3adcdTAwMGZxOrmqwG4wK5GLz1x1MDAxYt5cdTAwMGZzVWpHKk5sZa4ynCfv0bMwVzGmt3swo1ZcdTAwMTiceDSZQF1cdTAwMDNcdTAwMGVcdTAwMTdcdTAwMTLDfi2pxKhg/NuXXHUwMDA04OVcdTAwMTBflPuRNmNhrs1oM6ZL/vCMjlx1MDAwZVx1MDAwMnWMK+A4hDqW9Xid0rmDc5/hKLtcbm9cdTAwMDAnw8VsxvTd2WKNsjWxXFzbXHUwMDFkXHUwMDE57JdcZlHB2ViTKDpcdTAwMTFA8ae2JsNcdTAwMTiQK20yXHUwMDEyXHUwMDExbH8yY+B9L4+RuMaXXHQjQZHZM1x1MDAxObXC3XaVX3tX8p7C42XuW/787C7dskWtdzOGXHUwMDExm1BcdTAwMTdGjqyNXHUwMDA0YVVNc1uCi1xu9/6qJSdsLz8xXHKo3u/LYaaZi4+srkVlofEt8j/aXFzk3Duvllx1MDAwZW/xoylcdTAwMGJaiymlTVx1MDAwNsNcdTAwMTJcdTAwMTLfjuvNXCLBqSOcVpIyW9XE7ZbwXHUwMDA02Ti8f1x1MDAwZqC5QFVcdTAwMTRMKrvNLUwphl+UpoY7dr9jw6xcIlxuPcFbyH5cdTAwMGLt14xxobiKnfLDW6CEXGJit8H4XHUwMDE0b7Ew1Wb0XHUwMDE207V+LZ7CwFHhxm6/ZPc+l3w8WWB3PVWKgTJcdTAwMDKHj6nFjMX0pVx1MDAxZSNuXHUwMDA3m4KjiZG60XbhsFx1MDAxZW+UcLTRXHUwMDAynaxcdTAwMTREYYipV9pbZJJcdTAwMTBsf8awm2Qtvry8/7rbbOYjRNzrcCCW/eKLQlx1MDAwZm5yvet7XHUwMDBmm5NJZnn25aU7rep4/SVL3798/z/RhT5iIn0= 901245673Input()Switch()Label()There are three types of built-in widget in the sketch, namely (Input, Label, and Switch). Rather than manage these as a single collection of widgets, we can arrange them in to logical groups with compound widgets. This will make our app easier to work with.
Try in Textual-web "},{"location":"guide/widgets/#identifying-components","title":"Identifying components","text":"We will divide this UI into three compound widgets:
BitSwitch
for a switch with a numeric label.ByteInput
which contains 8 BitSwitch
widgets.ByteEditor
which contains a ByteInput
and an Input to show the decimal value.This is not the only way we could implement our design with compound widgets. So why these three widgets? As a rule of thumb, a widget should handle one piece of data, which is why we have an independent widget for a bit, a byte, and the decimal value.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaVNcdTAwMWJLsv3uX+HwfJlcdTAwMTdx6VtZlbXdiFx1MDAxN1x1MDAxMyDAYEBcYiPWXHUwMDE3XHUwMDEzjpbUWkBcdTAwMWLqXHUwMDE2ICb831+WzKDW0kJcdTAwMGLybVx1MDAwMSYw9KKurjqZeTIrK+s/nz5//lx1MDAxMvXawZe/Pn9cdFx1MDAxZYt+vVbq+Fx1MDAwZl/+cMfvg05YazXpXHUwMDE07/9cdTAwMWS2up1i/8pqXHUwMDE0tcO//vyz4Xdug6hd94uBd19cdTAwMGK7fj2MuqVayyu2XHUwMDFhf9aioFx1MDAxMf7L/cz6jeB/261GKep4g4dsXHUwMDA0pVrU6vx6VlBcdTAwMGZcdTAwMWFBM1xu6dP/j/7+/Pk//Z+x1nWCYuQ3K/Wgf0P/VKyBio9cdTAwMWXNtpr9xlx1MDAwMkMtwGqAlytq4TY9L1xuSnS6TG1cdTAwMGVcdTAwMDZn3KEvT9XLm28n33vfOrlCrprJt2VcdTAwMTFLg8eWa/X6adSr95tV7LTCcKPqR8Xq4Iow6rRug4taKaq6XHUwMDE2jFx1MDAxY3+5N2xRT1xm7uq0upVqM1xiw6F7Wm2/WIt67iXZy8FfXHUwMDFk8dfnwZFH+ktZjzPNJVx1MDAwN8tBoVQvZ93tdNBDg6BcdTAwMTQ3hiGzI83KtOo0XHUwMDFh1Kx/sP7XoGFcdTAwMDW/eFuh1jVLg2uwqIKyXHUwMDFjXFzz8PyyXHUwMDEywWOCc8HBXGJgTL5cXFFccmqVauRcdTAwMWGiwZPGcC0541x1MDAxMjRcdTAwMGXGLlxm+oNcdTAwMDKSziM1cXC3e357v9RcdTAwMDfIv+Nd1iw9d1mzW69cdTAwMGaa7E7sxEA1uKfbLvm/xlx1MDAxZZTWRiDn2sKgXHL1WvN29OPqreLtXHUwMDAwLv2jP/9YXHUwMDAwp1x1MDAwNEZMXHUwMDA0KpeaXHUwMDAzXHUwMDA3XHUwMDEwM1x1MDAwM/VwL7NxcJcrlc+yqnfUqu9njoVcXFx1MDAxY6j8jYBKw/5cdTAwMWFStWeVRNRouZExXHUwMDAw9G9X6Fx1MDAxOSOBsGxBW7RmXHUwMDE5pEZcdTAwMWS/XHUwMDE5tv1cdTAwMGVcdTAwMDFhXHUwMDEyWtFTglx1MDAwYlx1MDAxNExcdTAwMDLAXHUwMDA0sFxuxTxcdTAwMDPMXCIqISRcdTAwMTiuxsDKjdBKk5Z5Z2DVXHUwMDEyXHUwMDEysVxuKNFqasrMWL3LXHUwMDE2dq5cdTAwMThuPcHm4VZWl0s3XHUwMDE3359cdTAwMTKwOoK3v1x1MDAxMaWSXHUwMDE5bYVS1oBRYyils6TMNKCyTOtcdTAwMTWiVHhcZpQkUaGnWW3GYcqNp1x1MDAxNFx1MDAxM2hcdTAwMTTjSlx1MDAxMKTHYGolkKDhOqrUoF6vtcOJXHUwMDE4VUYkYdRcdTAwMDJaxpTUM0P01lx1MDAwNPzkoFx1MDAwMPmbrd7RiVx0ermjy5NFIPpWXHUwMDE2/3WIaushXHUwMDExXHUwMDFjXHUwMDFhc6GsiGnK/u1cdTAwMWE9lFx1MDAxNmjotbJWxPpqXHUwMDExk1/2JXGLcXiCoDZwLq1cInvOkSz3XHUwMDA0m889qVx1MDAxNFpSoYxcdTAwMGLBUY/iXHUwMDEzJFx1MDAxMVx1MDAwMuBkXHUwMDAy31x1MDAxNUC10ElcdTAwMDBcdTAwMDVcdTAwMDKv5ZbPXHUwMDBl0KenzUaNXHUwMDFkXHUwMDFlbaszWVx1MDAwNX9v92t19yzdXHUwMDAwXHUwMDA1pjwynMqANcYy4GJcdTAwMDSiwuNkPi0zilx1MDAwYkviuiREXHUwMDBiRDlXXHUwMDA1UU28lIGAd4ZQm2jmOelcdTAwMTR6aTE7QkWb1W52oqPsTvXmQF1cdTAwMWbnXHUwMDFizcJWylx1MDAxMSrQXHUwMDEzXG40k2RnLfkvOIJQwlx1MDAwNYGT/Fx1MDAxOYtkXcUo/5hcdTAwMGahwFx1MDAwYsaoVSGUXHUwMDEzOrVUxM7WXHUwMDBmoq84TixcdKRgiXxLLYWcXHUwMDE5pfXNzon6epCv6G+t81x1MDAwNopM/qmeSzdKqas9TV6xXHUwMDAy8kaI5ckhkHJpyLcnT8pcdTAwMWHpPEi1XHUwMDE0Rsvl8iSATkBkrM//a8aJf1x1MDAxMvNcItdtXHUwMDBlXHUwMDA0/lx1MDAxN1x1MDAwYlx1MDAwMzSI5yM/k4H5cs/g7lx1MDAxOJqi4HHApGNDn+/ulvf38o9cdTAwMGbly1x1MDAxM7v1vWefXHUwMDBluzdfXq77+fzbb0P9UDtjgJc2XHTvhlx1MDAwNpuTzzl7mGDyO0+Ge9UvVrudIPWAV+LtXHUwMDAwP9X5ilx1MDAxMZaBtzVcdTAwMGV74thcdTAwMTaRpzRcYjBcdTAwMTjrVjM6rT31jTtcdTAwMWI6uus3avXe0HD1sUn989nGuy9cZuh5fcVrhq7crNcqzb52XHLKw5COakW//nK6USuV4kq+SFx1MDAwZvfpXHUwMDEzO/uz6OZWp1apNf16ftC2JazKlLCxZuQsky2dnfucibNN0auD3GiI7c7DztNcdTAwMDO/a6ZdzFx1MDAwNFwiiVx1MDAxOSlsziwoXHUwMDE0w6E4XHUwMDE0xrMo+yFcdTAwMDUgeK8uXHUwMDEyh8LTTFklXGaToKScXHUwMDE0i+PcU9pKbUnyXHJcdTAwMDNmx2NxKFx1MDAxNbXXmnnEcCHrk1x1MDAwZVrEWWL4XHUwMDAzhJRcdTAwMDaQ3P6ZXHUwMDAxfHC2KcOL6Nthpn2OXHUwMDA15V/dV6r3f/+8x1xmIFbSM8BcdTAwMTVcdTAwMTB7N9rYYVuByDyJZCVcYlrWWFx1MDAwMtdS7FxiWZFJPclQeIzcXFxcdTAwMTIoJS11PUxcdTAwMDAweORBanJ6ibxcdTAwMGIjzSh+OWlcdTAwMWOpXHUwMDE5zuNhrjV8XHUwMDEzWVx1MDAwZVjqSnLI2OzwbTX09fZ2ttjL3NbuNkVcdTAwMThcXFx1MDAxZUo/7fpXcu2Ri0lcdTAwMWVcdTAwMWYpNOByXHUwMDFjuoygXHUwMDA0XHUwMDFjOCNcdTAwMDeUL1x1MDAxNcH7XHUwMDFkXG6YMcmN4UZ/XHUwMDEwXHUwMDA0y+T5PMWJunJtZvdLg+2Tk30ondejnVx1MDAxZvLxurXFi9dmXHUwMDFkXHUwMDE0sETjcSFQgVx1MDAxMJprMCMoXHUwMDA2wo22StF1xDFSq37pXGZcdTAwMTNcdTAwMTaIQ3xcdTAwMTD0qsTgtJFSxVxy6WvQLdT3KmdcdTAwMDeb96fFQGm1n+ncdjN3aVe+XHUwMDAwzCNqa1x1MDAxOTjSKKRcdTAwMWVcdTAwMGVOo7BcdTAwMWVjKDRKXHUwMDAxXHUwMDFh5OqczDdSvmQwrTBcXHxcdTAwMTD4XG6ZXHUwMDE4XHUwMDE0JFx1MDAxMWdO+9qZXHUwMDAxvHt4e3pZzp0/tJqN/ePK9SPb7lx1MDAxZa+D7nUgNlx1MDAwNFFitprQg6PK17lwmoNVTFxuS65cdTAwMWMug+J/XHUwMDE4ZVx1MDAwMjshfv1cdTAwMTbql8yHm1x1MDAwMjRcdTAwMWaE/VxuTI4+SO2yo+RcdTAwMWNJa2fnu5eH29vlXHUwMDAzfVx1MDAxNlRcIlZu4o/N9CtgRbzBcMVcdTAwMTjXqK1cdTAwMWVGLpFfXHUwMDEwXHUwMDFjmJFutmOFXHRcdTAwMTZvo35dhlx1MDAxMCNcdTAwMDb/QbQvxnyVUfKgLLWC4eza965Z7+BBuVuA4kE2f3GQffpRTprZTpf2VS7zhli+1eSxas3GMcystYJ4XHUwMDA0MSrUy8VcdTAwMWVWqX1dviZIXHUwMDBlXHUwMDFmxHNDlpzdpvv5fHaOXHUwMDE5xd5pMTRFznd2s51K5nLv5OvGblLKcGq0L+fWI8IghZtHUVxc4Fxidok5SKZcdTAwMTDRzX2DhKWYw+/gv1x1MDAwMP08zY+igE1y7EGgy5+Nx9deQzDkq6adLVx1MDAxN24u9+5cdTAwMWHb19nKg9pPQdL7bCgmssQ5aWF0UbIxXHUwMDEwI1x1MDAxZFdSo1Nvy2VvrFL/KlwiOvTNPlxi+0WTXHUwMDFj+yW/jaivkLOjt3rQyYeNw/bOWXi3u5FcdTAwMGZt8VtcdTAwMTZTr3+N9lxiXHUwMDEw5LZcdFx1MDAxN26B8ehcdTAwMDO4XHUwMDE4XGZotDruLKRU+yrSOZqA9EHUr9TJXHUwMDA0wjDUXHUwMDEwp1Kv4fe+yO5OK7WwsbeFm9cnd5FfbZXXQvtcdTAwMWFDXHUwMDA0V1x1MDAxYmktM1x1MDAxNuOZXHUwMDE4LyBcdTAwMTY0NuS/uUVYfLnMpJXyX+E+wX6U2K9MzpyHflx1MDAxN7mMlpnxe+37aifb/da5rCt21oyAIT9Ku/5Fhlx1MDAxZVfoXCKmjjnwUeZgPcFcdNZklC1PfeqDXHUwMDEyaDR5mlx1MDAxZlx1MDAwNL5KJUZcdTAwMWaASa5cdTAwMTXOXHUwMDEzfrjZle3vR+qSseuef3WV2z82lfN1UL9cdTAwMGXC5KGRLTJWS8PNuPrlVmtcIsdcdTAwMDC4ZHL9SpWvZkyTz/1RtK/iidqXM6Vccqp4mspr8O12evuHXHUwMDE13Fx1MDAxMVet4F5vZXgzb3qp176qr32ZtVx1MDAxY7hcdTAwMTAxr/1F+2olhVx1MDAxNkwxxLRcdTAwMDd/wdL7WMnYXHUwMDA3XHSfUZcl6l9LbJBjXFzdvFx1MDAwNuDHvdtKeHPh8+vHXFw1v3lzXHUwMDFmXT9l10L/9leBgjbKXG5Ffc/lOIpcdTAwMTGFW+5O/2C5OYzVTr4xg1x1MDAwMqx8P5mTSan1XHUwMDAwidDlXGbBXHUwMDE5ojnm3W7bX4PM1VnQ/V45yD49Ni/CcmEt5o0lck9cdTAwMGLnsVx1MDAxMWqJXHUwMDAxj0RcdTAwMWaAk/7lXGKSVLNEmMJcdTAwMWRcdTAwMDRcblx1MDAxZlx1MDAxN0RubLRcdTAwMDdQZaPYXHUwMDA06Vx1MDAwMtBcdTAwMTg3mqvMrid/NVx1MDAwNpHFsutjXHUwMDFm8Ep2/VDPXHKS63HowlmT66NWOymzfuhcdTAwMDVG0+jZ9Cz6JFHSyYtbXHUwMDA10VFrxFx1MDAxY9lvW6J9b3PhUfugoDG30WlcdTAwMWSZncw6SFx1MDAxMlx1MDAxYfQss8LNYVx1MDAwYsVwNInTZVx1MDAxOXEpXWcoMlx1MDAxMlOWYS8jSpNoy7gkUWPcYtx5WMq6XGJcdTAwMTKkQZBgMUFcdTAwMDKZKEnkyoJi8VmtV1NB7ptf4ewqd1x1MDAwMUenbV3M5i+KrcZaXGJcdTAwMTKxXHUwMDE0cNEnRiBlio+LkbHOPLtcXDzF/1aLhIKcXHUwMDEyXHUwMDFiX4L7flx1MDAwNImnQZD4goKkk1x1MDAxN1xuK7DkW1x1MDAwM5+d3PXC6sPj7db53fX33Yvz5tXF1d7xWqSlXGJcdTAwMDaelsI4v1x1MDAwNInLjYpcdTAwMTLJmZGMPG9cdTAwMTJcdTAwMDNjkr2SpSRpklx1MDAxYjIuSeSSXHUwMDE4l1x1MDAxZP5cdTAwMWW5XHUwMDFkpkGScEFJSk4vUORcdTAwMGVYXHUwMDA1c1QuMVdcdTAwMDf4taDOiz/OXHUwMDFh55nocKdcXM111kGQOCrPOs+USVdcdTAwMDVCglx1MDAxYZMkblx1MDAwMazi2lx1MDAxNV5YlZs0kyhx5Vx1MDAxMu90LMfs/UiSTIMkyUXJnUmSJFJ+Uiszz1wiocvDo4ctc7b740T29i5yZYjqtbVcdTAwMTAlsjVcdTAwMWVcdTAwMTlcdTAwMWNcdTAwMDZE4MhcdTAwMWRcdTAwMDJtxkTJoGJcdTAwMDY12Wlr2GrcpFx1MDAxOa1cdTAwMTJcdTAwMTlPRoZxrnDYusiSSoMsqVx1MDAwNWVcdJP5XHUwMDFk00R7mJhjyX6mlM1sh+a8cZMrXHUwMDA03ZNWbvPworJcdTAwMTayxJXHXFzNLGt5v0TTqCwxz0pE1NZVfF2VKLFcdTAwMTlFXHRcdTAwMTmq91x1MDAxObzTaVx1MDAxMCW9mChxlrz+RLmiySDZ7GZcdH50wzB6aLDj8z14ZFmf5UpJ1S9SJUpCcY9cdTAwMTO3XHUwMDAzMJqTXHUwMDBlMSPRO2Y90iuuQFx1MDAxZFx00rRVKKuPOmhUXHUwMDFhLYjfXHUwMDEzdEAyw/DbJEmkQZLEXHUwMDEyxWRscja2XHUwMDE0ynKNZnbD1Ny/vTu4lFulzrm82snrS773mH/jXHUwMDE5/ZJcdTAwMWZWg1x1MDAwNFFiXHUwMDBiiZJcdTAwMDTjITdMMlx1MDAxN1x1MDAwZZdsJOrAhMcsXG6ppNJkqKfU4Sha7nN/qihNndN3XHUwMDE1pN1cdTAwMDNcdTAwMTQ3rmrNpNJlWnmayKawQjBqT9xxe/amXHUwMDE4J1x1MDAwZahQvZ3dej4xUrLsOdRkbOPh4TJ7XFyyt5uV3Yub3o5ccl/ebFxihn6n03pIYc0ySF6PS+4zMVx1MDAwMFx1MDAwMDV7XHUwMDE0+yravc0yntssfisp2/leOdkpJk1cdTAwMDelR1x1MDAwNJRcdTAwMTSeXHUwMDA1REt9bp1cdTAwMWIzJFx1MDAwMsJcdTAwMTJt45qTOpBcdTAwMTIlT15Rs6xcdTAwMDRALD9jSt0yJVG4+mlvZ1HWXHUwMDA345Ps1exF0bZq0elDjVx1MDAxOMw//2ey5VqsOtqilivenKnC+6tjJ0hvPFx1MDAwM2tsa1x1MDAwMtMvkzBHStr0oU5pSppC6TF62X6VXHUwMDEylzI5Yr9cXD14QSBcdTAwMTP0rfiUelx1MDAxMEvbL+Zxi1x1MDAxMo0w9CBcdTAwMTNPm1x1MDAxZlxis/Ikama50UowXHUwMDFlr/k6qMVJhpjFd5RIZVHCMPI70VatWao1K1+Gsoqet4XZn8Eg9CW22O1rbk+7jG3JXHUwMDE5aMuJdsjYRVx1MDAxNb9Nl1x1MDAxOI+4LVxyN/F+RaOtn89cdTAwMGbSloJm6fVcdTAwMTZNZ2lDLVx1MDAwMueZ0oOk1Vx1MDAxY21sqeVLk8Ajb11Zolx1MDAxY9KQbVx1MDAwMDueS1X3wyjTajRqXHUwMDEx9XyuVWtGoz3c78pNJ+LVwFx1MDAxZlNcdTAwMWH0UvFzo7qg7T5xWIlcdTAwMGZ++zyQlf5cdTAwMWYvv//7j4lXbyRj2H2No3fwgZ/i/y9W2FFPKSymjVuKPUf91Ivi9cHTRifs7FfK/KZ519RVm5RcdTAwMWSeXHUwMDFlJmKkJ1x1MDAxNaGNSctcdTAwMTSPr2v5xUS0p6VcdTAwMDRgVkvNpuSkXHUwMDAwKUO/sLguI5XpKdRu6p5+XGJpJySqXHUwMDE4XCLj5NhKrV1cdTAwMDaeVLFuet69QjO0Kp4/uUqeclx1MDAxNvpPcHxRr1x1MDAwNyivRKF1nrHifM24uJ1qzSWNylx1MDAxY8vbj/Ld7t2PTDPfXHQvXHUwMDFl7cVcdTAwMTM8QEGnXlx1MDAwMiR6iEBcdTAwMDZbWaflcZiLS4PkXHUwMDAxXHUwMDEy+pnb1YdjMlx1MDAxN19WXHUwMDAyJm4sNKGGsHSRUivecCZ8fUC+JFx1MDAxOe9FwX6z3Y1SQsZjzVmMjHNIXFzexFx00W5XojnSWKZcdTAwMGZ1Ssk4MOOJX8XXmDDkTVx1MDAwZlenkFp5XHUwMDE2hVx1MDAwMSO1ZdM2XHRbVnyJqlCPc86MsYbHp/hfhNlcdTAwMTJ5c3tHWGO4dcHkMdnWxEJcdTAwMTU19H1Q8en2IEZ8N4j5up3C0FxyoUFr9ThcdTAwMTfXnuHSXG43TSU1qlx1MDAxNyY4J1x1MDAxOZ/O0obdXHUwMDAz0vi/Qn9cdTAwMTJVrNrugIuvNfVOXHUwMDA0rPvaXHUwMDE4w+qbXHUwMDEyb56Y6kBcdTAwMWTNNeBcdTAwMWMxhLMrv36+fdPbPtBfe49cdTAwMGa7ufvd3lsvKp7GOlx1MDAxNtySS3jo0qxcdFmCSzWyuSFnrqKv1lx1MDAwMiXTgvh3Mu2QRYustFx1MDAwNPFcdTAwMTbcI34vgLqd+t5MmGJcdTAwMTJgPCW0KzFM9IjJ8Z04ibZcdTAwMGJt4uuaVslJXG7lw9ZtXHUwMDA3mtunnfLpcclcdTAwMDS3N42t9Vwi3skxcCBcdTAwMTJKqnCeXHUwMDE4+OZjq7jTuS40N+Do/vvtzs2VLrdTL1x1MDAwMZxxr79lXHUwMDAwMuVKQlxme56ESE/2tylSbqNcIm2SXHUwMDBim/CC8UWwXHUwMDA08eaTplUnXHUwMDEwb7fRXHUwMDE1zLVpwHvB+PK8e6e/jXN6iPdLe1x1MDAxNmPekFxck95tXHUwMDFlIIycI+11+linlHiTufBcdTAwMTi3RJtcdTAwMTVze9uO7NiHzPFcdTAwMDdcdTAwMGJCMUcweHJCxNLia0hRKCPIXFxcdTAwMTJNIco2gXlL44JLxlx1MDAwNbpcdTAwMTUqsmJcdTAwMTOkW1omh2pcdTAwMDGvM/Weblx1MDAxMoapN2Ouklx1MDAxNtFu5crzgIs7j1Nd8MiddDXPXHUwMDA1uVOAOFx1MDAxZXaeiX5PJ2tD9Jsxji40LJjWNGh2kFx1MDAxZftOXGJ4MnDd1zhkk1x1MDAxOPin5yd88dvt04iA9tL/XHUwMDA04VrpWV1cdTAwMGZe88t9LXjYmryjm9vU7dNzhzrFXHUwMDEz9Fx1MDAxN3D//PTz/1x1MDAwMcOHY8gifQ== 901245673BitSwitch()ByteInput()ByteEditor()In the following code we will implement the three widgets. There will be no functionality yet, but it should look like our design.
byte01.pyOutput byte01.pyfrom __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258abyte\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the compose()
methods of each of the widgets.
The BitSwitch
yields a Label which displays the bit number, and a Switch control for that bit. The default CSS for BitSwitch
aligns its children vertically, and sets the label's text-align to center.
The ByteInput
yields 8 BitSwitch
widgets and arranges them horizontally. It also adds a focus-within
style in its CSS to draw an accent border when any of the switches are focused.
The ByteEditor
yields a ByteInput
and an Input
control. The default CSS stacks the two controls on top of each other to divide the screen in to two parts.
With these three widgets, the DOM for our app will look like this:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1daVPjSNL+3r+CYL/0Row1WXfVREy8wdnc0OZo4O1ccsLYXHUwMDAyXHUwMDBijG1kmWtj/vtmXHUwMDE5XHUwMDFhSdZh2fiiPUPTLclyuiqfzCezslL//bKwsFx1MDAxODy33cW/XHUwMDE2XHUwMDE23adqpeHV/Mrj4lx1MDAxZvb4g+t3vFZcdTAwMTNP0d6/O62uX+1dWVx1MDAwZoJ2568//7yr+Ldu0G5Uqq7z4HW6lUYn6Na8llNt3f3pXHUwMDA17l3n/+yfe5U79+92665cdTAwMTb4TvghJbfmXHUwMDA1Lf/1s9yGe+c2g1x1MDAwZd79//HfXHUwMDBiXHUwMDBi/+39XHUwMDE5kc53q0Gled1we2/onVxuXHUwMDA1VET0XHUwMDFm3Ws1e8JSXHUwMDBlVFx1MDAxOcPDXHUwMDBivM4qflxc4Nbw7Fx1MDAxNYrshmfsocUr9nK7d9N9rJtm6eB43Vx1MDAwZs5L5+vhp155jcZh8Nx4XHUwMDFkiUq13vUjMnVcdTAwMDK/dev+8GpB/dfARY6/v6/TwkFcYt/lt7rX9abbsd+fvFx1MDAxZm21K1UveLbHXHUwMDAw3o++XHUwMDBlwl9cdTAwMGLhkSd7hWFcdTAwMGVQQY0xRIDR6v1s7/1cblx1MDAxY6GZltQoKpRhfXKttFx1MDAxYThcdTAwMTMo17/EXHUwMDE1q1V5KNllpXp7jeI1a+/XXHUwMDA0fqXZaVd8nK/wuse3b0xEONB117uuXHUwMDA3eFCa8PPc3rhcdTAwMTOliFx1MDAwNk01fz9jP6W9WeupwH+iI9OsvY1Ms9tohILZXHUwMDEza/1qXHUwMDEzVZ2Y+lx1MDAwNO5TKG1ksluVdndcdTAwMWJMUN7erizz48btaaVZXny/7p8/0m/7+ubO3vdye31cdTAwMWb2dlx1MDAxZb7V+MWe/H7sPsU/5dfnV3y/9Vj0vjv+wVx1MDAwZn61U/7O+ePekeeXvNXvlWL3fftbOIDddq3yqutEKlxyXFyDUSZcdTAwMDKXhte87Vx1MDAxZttGq3pcdTAwMWLC40tE4Fx1MDAwNCxj41x1MDAxYUGkzEakXHUwMDAwbrSRQlx1MDAxNkZk+iSNhEgyNURSMI6IIDJcdTAwMDKC3lxyXGadXHUwMDE2JClNQpKKXHUwMDA0JKlihlx1MDAxMEXk2CCZoYVKXHUwMDBiqUBLoYbQwnC2W83g0Hvp2XaIXHUwMDFkXa/ceY3n2IT11Fx1MDAxM1x1MDAwN2j5OXDXev7m67+jI9lx8ZPtrYiOvWep4V1bNV6s4ndx/ZiGXHUwMDA3XHUwMDFlurD3XHUwMDBi7rxaLeqUqihIXHUwMDA17+lvXHUwMDE2cSYt37v2mpXGUZqcucB7XHUwMDA1flxu8lxiRIal31x1MDAxOVx1MDAxMqI1IyxyxSDs5du4ecWe0Fx1MDAwZYBUgilKXHUwMDE1NSBj2KNcXDqCXHUwMDE4oVx1MDAwNbUjolQ2+KD3XHUwMDFhXHUwMDFkfEY5QDgxXHUwMDA0lOT4k0SiXHUwMDEwXHUwMDBl2lx1MDAwN4WAXHUwMDEwXHUwMDFhgCnSXHUwMDBmTHtKgpHDuMrQqfxSXHUwMDE4+nbknynDtVx1MDAxM1T8YNlr1rzmdVxcsDfOV1x1MDAwNCf2O1fa1q04hlx0nFPKcUg0ZyxyxVWr2rXfo1x1MDAwNFx1MDAwZcd5XHUwMDE30nAj0c1IymTiy7vN2mChLq7u/aP9l2Zru37hi43qQ1N8f0xcbmVcdTAwMWOCdp5cdTAwMWHG0KArSXm6UIJcdTAwMDHHadRIzigorlx1MDAxMjI1Kp1gpXV351x1MDAwNTj2XHUwMDA3La9cdTAwMTn0j3FvMJcs8utupdZ/XHUwMDE2v1P0XFy/iWjbO8apUvi3hVx1MDAxMEG9f7z//T9/pF5dylJs+0qodHi3L9HfWbYtl+tTKmX/4Xf7XHUwMDA2SC6oXHUwMDA2XHUwMDE1erxB9i1/jmdi39Qg86aII1x1MDAwNdVUcEMojm+c7DNcdTAwMDKOMlx1MDAwMq1cdTAwMWLHWUDjJ/rkXHUwMDFhn3UjOiQ171x1MDAwNi1cIv4vXHUwMDBihryTcsZDXHUwMDFmO1x1MDAxYrJ/6W8/updcdTAwMWKb9eP6snfmnrfXl1fOZk32L7rPOytwt/tcdTAwMWPcd8pAXHUwMDFloHJTL81zXHUwMDEwMZI/XHUwMDE4KYigVOssqGtmJEFcdTAwMTdQnMmkz/6cI13mI11MXHLpTCWRLpNIXHUwMDA3gaKCoWND+qRjiEi4MyCGWPnF7L/+bNpcdTAwMTNV9NdcdTAwMWS38/fPxaDV/rn4s5lcdTAwMWVZXGJcdTAwMWW703vg0HCvgtHjilx1MDAwMW6rP64oIPtcdTAwMDc8slxmeVg/TFx0Rr+AlEdcdTAwMTfH6crhnqqdbHzb89ae2lxcb7i+u+TNe/6NXHUwMDEx9MlcdTAwMThYIf9AXHUwMDAyQkGyXHUwMDE4UjlnjqaMIDUhXHUwMDFjgOpMoH442jeQXHUwMDA0qkpE+5Io4JJFvtdsXFzyzfpccl8+u1/deKhcXH6/a+12N48vr4q6uJuLk12x3azed4+f6jdUXHUwMDFk3d22XHUwMDFlxuA6XHUwMDBmbsr153OvfHrbeVhT61stf3XDXHUwMDFiw323r6ube+tcdTAwMGZPasv9UTnaa+/drWP8MS6XzFx1MDAxNGOhXk3KJUtNM7HOuOGaUiiO9fTpn6lPLoB1XHS5WEd6zlwiWFdcdTAwMTPDuklL7CV8sjXBlKE846Pf8+OUbcJss9nuXHUwMDA2WXm9XGbv+9G83lx1MDAwMCeVltf7JebojpYh581cdTAwMDKfMMC1UkNk9nboXHUwMDBm/2bHXdl9WuvSMrku7Z+Wm/PuZylTXHUwMDBlXHUwMDE4xJbgXHUwMDFh/zPxxJ5cdTAwMDTiXGKNJopcdTAwMTjFpFx1MDAxMlx1MDAxM3SzJMXNykTujjD0sdJwOevQl2JQeVjdO395emr5vPHy/HJ/dTRrP3tx841cdTAwMWVtPK2xTX/l4rl+u+5cdTAwMDdwPob78v3DXHUwMDA3s7NcdTAwMTI86lx1MDAxMu8snWnXK2k1Lj+rqWZcIlT4XHT5WcZ19pI2hnpcbqO+SEZ4XHUwMDEw1tOnf879LOU8XHUwMDBm65Q6MFx1MDAxNayblKx90s1SQimyXHUwMDAxXHUwMDBl41vSXHUwMDFlp1x1MDAxNn7QzXrB4aNcdTAwMTdU619hun52gJNK+NmInFx1MDAwYlx1MDAxZlx0aVx1MDAxNWR6WoywMJzT0lx1MDAxNF/AfvK8yr1/e3dSWq+wXHUwMDE3ft899PZmvIgmXHUwMDA3pp7AXHUwMDExxiiOP4wzwXhcZn2cI8lVlFx1MDAwMVd4XHRcdTAwMDOSzXI/nHoyPIk/naS5XHUwMDA06ZEydIy5p9FcXO3a/snN7qM8XT2g66XT1WD7eUOuTSFcdTAwMWI82HVNKWurc7CjqUKFkUOkg9KHc86xI3KxI+LY6WfP41xcnyFJ6CSztkhmmcJIVY4vXHUwMDFiND9cdTAwMTHia9j1mvbslSrWW42a6//9c/FcdTAwMTKDss60XHUwMDEzt1x1MDAwM1xcQb9DKyR9LkwzXHUwMDBiRVx1MDAxNCGZMLWL2ZrpyIrdwHXUXFzDNadcZpNI5lx1MDAxMFsqXHUwMDAwXHUwMDA2tZ/RXHUwMDE4TJm2oSaA0JZeXG5cdTAwMWVZdlx1MDAxZTdOwaFcdTAwMTRxITV+XHUwMDE0YsPoXHUwMDE0jyeYXHUwMDAzQjFcdTAwMDCNYT5cdTAwMThcdTAwMTFBwHuih4PNtqthKrg+XaVIwaJcZulQw7mixtbgXHRJZFpVXHUwMDA2OFx1MDAxOEpJW4kqUG6NLMYkvnyhSpF8VL9cdTAwMGJFcJ654UJcdTAwMTBbmWKkyKhfXHUwMDAxblBcdTAwMWKkoTjjSlFJXHUwMDEyUn2qWpFs9bavpGKHN/xcdTAwMTL9PbSFo1xcZK5cdTAwMWZcdTAwMTODNIUoPVx1MDAwNIvPT4PMqYljWjvSXHUwMDE45F2ao1wi0ThcdTAwMTVBaDiK4yBQsGUz0ULtcVx1MDAxN8JRXHUwMDA3yZ8gqM5MUqHCiVx0iVx0d4iU1GhGgWiTXFy00ohcdTAwMGIzY/uGcpFIiD9++5afWY7bXHRcdTAwMDaaIZxcdTAwMTjYKmI0/ilW0NixJoxwUFx1MDAxY936aOYtP1xuj8uEQ8RcYrJeXCJ6dc9JmexKqVwi1iRTYVx1MDAxZFx1MDAxNv/cxi1Tse0rodJDmrb8JFx1MDAwNc+u9MVcdTAwMTkgXHUwMDE0lVx1MDAxNYrnXGJ3L1a+lcs3zD+puZetnSXxtNahc15cdTAwMWbDwDjoVDRDXHUwMDAzJ1x1MDAwNbKPOIMj3DGMgFx1MDAxMpzgXHUwMDBmzsLkXCKtVINcdTAwMTZJirwlXHRcdTAwMTFcdTAwMTNcdTAwMWMjv1kvXHUwMDA3bJ2s3DWvK8es/qw3ZGX95P6YXHUwMDA2s15cdTAwMWX/XHUwMDFkKtY4z2RcdTAwMWNI5jgyXHUwMDEyXHUwMDE4XCL3kT5Nc1x1MDAwZUkh8iDJtMOnXHUwMDAzSV0obY9cdTAwMDabXHUwMDAzj/Ki3yf1XHUwMDExVn1NN2k/wJNkXHUwMDE3p4285yW6kbBcdTAwMGZ2XHUwMDFj41x1MDAwZWLZbWHU5du3OeX5XHUwMDFjtFx1MDAwM5pcYs21pHh5nOczbVx1MDAxYyEw1jWogISTSEZy/LlcZoZ2TuGAI8pcdOEqJVx1MDAwNckxOqfCbv/UWuNcdTAwMTU6uVx1MDAxYk1cdTAwMGKD0IzuZfpccnNcdTAwMTn5QInzalx1MDAwMkJyYFx1MDAwMlVcdTAwMTlcdTAwMDNpmuTVysGJ14ZzrnrUO5k0KMT1XHUwMDBix1x1MDAxZuBgyI7ScMlQ+YxkJpbOeFx1MDAxNVxuldIuliE1XHUwMDA2IGj4PzfXz9Zt+0pq9ZBsP9u+oYPKsm+Ca/u/LJ6qzedZc2rfcMxcdTAwMWSltVwiXHUwMDFjObTk0X3mr1v6LOtAdkVcdTAwMDBpXHUwMDA3V5NcXFSh1JGglOQ2KWzT5CnMn6HeS6KYZFRcdTAwMTiI7C59s29cXKJcdTAwMWVcdTAwMTk6VLHApzNvhTf1UVx1MDAwMTiQhNg9myotJ1xuXHUwMDBlx1x1MDAxM1xubZvhQFxig1x1MDAxMXf0XHI0uL8kslx1MDAxYslcdTAwMDBQjVx1MDAxOLHqlCqSoIg80au8xVA8ZZPhZ7Jt2VptX1x0fVx1MDAxZdKy5Zc1yki7ikSaXHUwMDE2Z5zhNFx1MDAxNN/Rx5fXuydrm62V04p7UX95MK2no/05XHUwMDBmmohgjpBMqN6ym2AsXHUwMDFlNSlGXHUwMDFjXHUwMDA13O5lRf+OqjY541asrlEjhVRy9u076lx1MDAwZlx1MDAxYvewXq67bOX2qnbaofWDy4PfvfwwXHUwMDFhNE+q/FBFdnb2QVx1MDAxMqmcXHUwMDEyisjiXHUwMDAxVfoszTlcIqXMRaSgXHUwMDBlnVxuXCLT1oKTaVxmLa2QZjo1/kPrYDjVo6QxdiqXbuPrz0X4ufjvhakmMlx1MDAwNriS/kRGXFzQXHUwMDBmOEStMkszKJJcdTAwMWZi2DBcdTAwMWLqXHUwMDBlzrfcs+f7Yyit7m6elPeuyrf1kzmHXHUwMDFm1cg4rLZJXHRUXHRcdTAwMTJcdTAwMDdcdTAwMWa6Q0OQiFCMXHUwMDAzlJi5N1Q4I2Cj4Fx1MDAxOXvD/bVcdTAwMDefL61cdTAwMWZcdTAwMWRstq+6Tb3JO+V9PVx1MDAxZl7LkGhcdTAwMTOWSXktozKz78hsXHUwMDE1V2irXHUwMDBiwyZ9NOdcdTAwMWI2XGYsj8yCXHL6LD5cctjIlK5cdTAwMTApLsvG8kzy6dTLXHUwMDBmq4BcdTAwMWZzWW9F6FN2V1x1MDAwM1xmfb+7XG6FzEVbdlbKiEw3hbGxJU5miEYsuWx8XrNSXG5pIJpcdTAwMTZGXHUwMDE1XHUwMDExOtqAoVcnrLjDNZ5lXHUwMDAwljH3yzU+xFx06ShJXGIyM2X7f6mUjaE2O8mRI1wiU1x1MDAwNIPxNEvm3Fx1MDAxNcG36qhlmEF9jaJ6tI0tXHUwMDA1k1LD1LIwu5hiXHUwMDA3lnDbtimSXHUwMDEyXHRrWVxmgM1cXDGMXHUwMDA2XHUwMDAwVT7x5Vx1MDAwYuWl8olmXFwornBcdTAwMTJccqBMVEguaVIocIxdf+VS4JByopNCfabMVClTue0rqdbh/b5Ef49QPZhTXsOBMsX0XHUwMDEwW93z+dW82jejUd9Q4yjYoSe0b1x1MDAwZp7CIJlcdTAwMTnCJVx1MDAxOE3BTM7AcVxmXHUwMDA3mN1Qj26FoUFlKVEx5460+1x1MDAwNFx1MDAxNcFYWEqW4OnUNiuj3JjZXHUwMDE2XHUwMDEwXCJcdTAwMTOZZNa9sIGzffKQMFx1MDAxYZtXt/tyeKxz3ZstYY5cdTAwMTFcYitbISGYXHUwMDFk99FcZlxcPjWJXHQlXGJcdTAwMTFcdTAwMDS0XaCgXGIxkpRcdFx1MDAxY2XLqI0t39WA4n1q+5at2r2z/Uo9pHlcdTAwMWKQeYfsblx1MDAxZb2F5KF6hZ7cXHUwMDFmXFysLYlD+dgodVx1MDAwZk42Lk7rL4ejmbjpdVx1MDAxNEDn4dhcdTAwMGVcdTAwMTmcXHUwMDBiTanS8ZhcdFx1MDAwN8hRXGZcdTAwMDRcdTAwMDBnyI50dtA0rY5cdTAwMDJcdTAwMTKhoW2XlbGFTe9cdTAwMWE1VLKhc7ZVW/Jqa+Z4/9ErLZeO5OXZ9lx1MDAxNDrh5N634ZLzXHUwMDFk0Tl9MFtcdTAwMDcvXHUwMDE1v96+WSndjeG+3aPmcX1rp7y0XHUwMDEx1O9fSof+j63zsW3L1EyISMg8qeSINJlduoSxLbmH6VxunDr5c05muGF5SKfMoVNBetp+5pS2PcCR3JNxds1cdTAwMWOnXHUwMDBlhnP9sX5cdTAwMDJquqWJXHUwMDAzXFxUdj9cdTAwMDE1cpqEyuwulkTbsihFSPE8Sb7pnFfoMeIwRjFcdTAwMWVlxvbcVvE4QlDuYEBLMNzoXHUwMDE5oslVJ2JcdTAwMDTg2MpcdTAwMGWjgCkqIa2Fllx1MDAwMlx1MDAwNykos5V0XHUwMDFhf6KlRG9xXHUwMDA0Tlx1MDAxYTBOh+rqM/Y4QlxiSkfKaI57I1x1MDAxMjhUXCJZx+jbprq0MskwQjjEXHUwMDFlN4RcdG2f/ZBk7IWiiHz0xqNcYklt/Vx1MDAwZVx1MDAxN7ZcZl0xlVx1MDAxMEk7XHUwMDEyY1x1MDAxZmVcdTAwMWK5aUWJTM7GZ1xuXCKy9dq+XHUwMDEyXHUwMDFhPdYgQqvMVkVWJ1RsZ/Ug81Zb3VIrRy+Px7v3z3urm9v+9f7jjDtcdTAwMTVcclxcdeFUOVxmXGZD48VRoYyMW7de9Vx1MDAwZdo1MDaLRcRcdTAwMDStW7FcdTAwMTiCXG6NTidatDCbXHUwMDEwgtzfXHUwMDFjlLdWa+X25Vn1uHPaZJuXT7831Udzwya+XHUwMDBiXHTNWnZBXHUwMDFkKG2fVjBE5jJ9muZcdTAwMWOSnDg6XHUwMDA3krZ8ZyqQTOvAkkL2kVx1MDAxYtk1hOl0YFx1MDAxOVpcdTAwMGI/RvZ/lcWoqdfvXGZwJln1O+qj9TsmO9TWUvPeWlFh+Fx1MDAxZEO9Jsjyj4fjhzOoPnWPbneuunNcdTAwMGU/ZF9cdTAwMGUyfeRV1G5cdTAwMDeH/vpcdTAwMWS7tIaUXHUwMDEx9dDk9Medkju0JVx1MDAxMchcdTAwMTWVnnVKbf3+XHUwMDA2bpmskcf79o/d5TX329Nz4c5hXHUwMDEz9Vu2gymfuN+yj8fJ9FtcdTAwMTTokHVv6cM558DR1NHZwFx1MDAxMeDoKVx1MDAwMKdYXHUwMDAxXHUwMDBmwUBdXHUwMDE5JulUMlRDq+DHnNZsKnhcdTAwMDbY+vFX8GSXeWtkioxcdTAwMDEp3lx1MDAxZiefkc9pZsp2wLG8XHUwMDBiXHUwMDE0aIK0MN5cdTAwMDNMKoaAY1x1MDAxYc+B4Vx1MDAxYbKbzH54gdtcdTAwMTJSYXu54MdQalJcdTAwMTDIpW0mQoxcIr0nN5DEU1W17fIuh2s2Pe60lEGNXHUwMDE56jFcdTAwMDSRXHUwMDExLZSWKpxcdTAwMDOybaeEwbhcXFx1MDAwMtJdXCIjnfhcdTAwMTdcIkvJtm8oKIlRkaIjPykun2jGheJ2kLTtXGbJKfL+lDV34iiGsVx0Z0Joxpn55Fx1MDAxZHIyVdu+XHUwMDEySlx1MDAxZN7uS/T38Gl3XHUwMDE2cZ79xcD24YHUXGaxZzafXc2rbVx1MDAxM9ox9slrRCvOXHUwMDE19Fx1MDAxN+9oRzImeoVcdTAwMWaCkcl14kBcdTAwMDVcdTAwMDD7uC6tKUFcdTAwMWIlU9a/mHGQ/YHtQsqZ3Vx1MDAxY9tv26Tdi4k2epZcdTAwMWJmR6cg47ZtNn6SYG2IsrUhXHUwMDEwZmBDK8JcdTAwMWRj60okXHUwMDA2o4xSKZJcdTAwMGacLGTa8ilJXFwmxlx0t9V46E2FTjFsXHUwMDE4XHUwMDE0okhUo1x1MDAwMUT+ysTnzrlnqrV9JVx1MDAxNHpIu5ZcdTAwMTUjXHSZmWy3T3lcdTAwMTmi39fz877ZbW3uXpOT/c7SycqZ9+1ged5NXHUwMDFhU8ThvTJPaqjdnFx1MDAxZDdpgCaNgCUhhGnOJ/dQbZPymHuRiJC4ffpcdTAwMTmHKVx1MDAwNUh2q/pIzGukR2o7jvOz+ZUsXHUwMDA0db+7INNX8SNcdTAwMTMwXFyYXHUwMDE0tNpZMVLsXHUwMDBi9Vx1MDAwN0T9Qr1C7MtcdTAwMWKEXHUwMDE3K+32YYCj9m7ucD682ttXXHUwMDBmb7z44LmPy0mN+NdV72Xv2oOtxYjbczT/fPnnf+6uXHUwMDA3ViJ9 ByteEditor()Container( classes=\"top\")ByteInput()BitSwitch(0)Input( placeholder=\"bytes\")Container()Label(\"0\") Switch() BitSwitch(7)Label(\"7\") Switch() ...(1 thru 6)Now that we have the design in place, we can implement the behavior.
"},{"location":"guide/widgets/#data-flow","title":"Data flow","text":"We want to ensure that our widgets are re-usable, which we can do by following the guideline of \"attributes down, messages up\". This means that a widget can update a child by setting its attributes or calling its methods, but widgets should only ever send messages to their parent (or other ancestors).
Info
This pattern of only setting attributes in one direction and using messages for the opposite direction is known as uni-directional data flow.
In practice, this means that to update a child widget you get a reference to it and use it like any other Python object. Here's an example of an action that updates a child widget:
def action_set_true(self):\nself.query_one(Switch).value = 1\n
If a child needs to update a parent, it should send a message with post_message.
Here's an example of posting message:
def on_click(self):\nself.post_message(MyWidget.Change(active=True))\n
Note that attributes down and messages up means that you can't modify widgets on the same level directly. If you want to modify a sibling, you will need to send a message to the parent, and the parent would make the changes.
The following diagram illustrates this concept:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1bbU/byFx1MDAxNv7eX4HYL3uljTtvZ14qXV1cdTAwMTHabmm7QFx1MDAwYrS3vVpVJjHEJYmzjlx1MDAwM6Wr/vd9xkDsxHFcYmkoUF1LpYk9XHUwMDFlXHUwMDFmz5znec45M/n70draenY+iNafrK1HX1phN26n4dn6b/78aZRcdTAwMGXjpI9LXCL/PkxGaStv2cmywfDJ48e9MD2JskE3bEXBaTxcdTAwMWOF3WE2asdJ0Ep6j+Ms6lxy/+P/boe96N+DpNfO0qB4SCNqx1mSXjwr6ka9qJ9cctH7//B9be3v/G/JujRqZWH/uFx1MDAxYuU35Jdcblx1MDAwM6W2bPr0dtLPreWGXHUwMDE5LomsXHUwMDFht4iHT/HALGrj8lx1MDAxMYyOiiv+1Hp7I1x1MDAxOVxmXlx1MDAxZf4uP71qXHUwMDFldk5cdTAwMWJCfNz5XFw89yjudvey8+7FWIStzigtWTXM0uQkelx1MDAxZrezztXQlc6P71x1MDAxYiZcdTAwMTiG4q40XHUwMDE5XHUwMDFkd/rR0I9cdTAwMDBcdTAwMWafTVx1MDAwNmErzs79OVa84MUwPFkrznzBN6VYwPFHSSdcdTAwMWOXjunxZd9cdTAwMDHOXHUwMDA2TFx1MDAxMmdCa+Gc4VOGbSZdTFx1MDAwNlxm+4WOZLulXG7TXHUwMDBlw9bJMezrt8dtsjTsXHUwMDBmXHUwMDA3YYopK9qdXb4yJ1x1MDAxYmjmpOKKlDFE41x1MDAxNp0oPu5kaGJN4Egz6yRcdO6kdoUxUT4rVlx1MDAxYiVcdTAwMWOZYvi8XHUwMDA1g6127iF/loet375cdTAwMWO2/qjbLYz2XHUwMDE3nk17VdmzJrwri75cdTAwMTRvUvKEdCdcdTAwMWWdb1Bz1Njdf7v59Vlj820nXlx1MDAxZrf79tvsbi9ubkafs99Pn37gpn9+4I6V2Op3pp5y9fwwTZOzRftcdTAwMWQ4+vzcbDe/dj+yNOp8lVx1MDAwM2lcdTAwMGZW0O+zt9m2ftE8dPZsZ1s8c3uvKVmFve+fvZJvXHUwMDA3XHUwMDFmtYuy5qe9/us/WKPXWazfy0/FhI9cdTAwMDbt8Fx1MDAwMrhcXFx1MDAxYsuEs0qRLlxcvVx1MDAxYvdPpn2hm7ROXG6sPypcdTAwMTlcXGGZXHQ/KFx1MDAxM4w1NH16TDDWOSGVYHZhgpntVktcdTAwMTHMNI5vkWBcZlx1MDAwYpSyhitNmoQuXtffr7hccpzj3Fx0xom70mCsml9cXNH1mFCo8IBLXHUwMDA2IW1cdTAwMTiXJTO+l0BW6YPFTCf9bC/+6lx1MDAwN1vIwFx1MDAxOKOc0lJcdTAwMTitmJto9Dzsxd3zibnLfVx1MDAxNYO1m4/Tr/8qj+gwglx1MDAxMb5XZSfab3TjY+/P6y3cXHUwMDEypVx1MDAxM66exZDmcYPDJMuSXtGgXHUwMDA1I0L0mW4tXCKRSVx1MDAxYVx1MDAxZsf9sLs/beNcXPTN1XjFLK+FoDRcbjOxOFx1MDAwMF80P785y87Tk8M/PvRenGyop6/l2d1cdTAwMDLQXFyHP2FUoJ0w0mjOlZN8UuDJWK//Vlx1MDAwYoDTcuFqXHUwMDAxyPLjblx1MDAwNVx1MDAxZfZJRCpOr1xmn8tcdPxr2j7/LyUv9ejAfvncO2Bb7tPxzyrwbz5svd9I3iE0fNVP4iMxXHUwMDE4ZsasSIitI0xp4di3JMSIXG51XHUwMDFkXHUwMDBiQJtcdTAwMDRBf1x1MDAxNmaB2ZN/v1lAQmeVM2BDKa21JZnwt2tGXHUwMDAxlFx1MDAxY3PBXHUwMDE4Mabp1kiglEDMUWEr4Gyal7Kz25NheKDmdyfDm5242/7BKnyNjE2r8JWJ3yHC4PNaXHUwMDExhi44y1x1MDAxMFx1MDAwMS5cZkDqf+2evmxcdTAwMWOIvZPPO/uHZ+HWwba551x1MDAwMNRcdTAwMDJcdTAwMTCDe8DhhFx1MDAxNFx1MDAxNVx1MDAxMTZwXHUwMDFmidRVO01GqlvD30pEWCHFdkqy1cFzOVx1MDAxNY7C072NdP9Mmv2/tt8kfGh3mns/QNV+wnSYXHUwMDE0XHUwMDE38tZVXHUwMDE42VUtXHIwqaWU7lx1MDAwNvW22dN/z2lA+3zXh+NETDNpJnhAM1x1MDAxNVx1MDAxOMRDllx1MDAxOKJcdTAwMTI9p9r2Y3TYOKRcdTAwMDZCKLUyoK/SXHUwMDA3XHUwMDFmvFx1MDAwZV+jY0vp8Fx1MDAwNfZngE9oUa/BllxmyF6oxVPh+UnMfS12W1x1MDAxNzhcco+2UivLidRcdTAwMDT8oGtcdTAwMTBH4sxoq1x1MDAxOLPThlx1MDAxNfDjVqvw8DuKUSpAXHUwMDFjYJhcdTAwMDLtcTzRVNFoRIDQXHUwMDE00TozTlx1MDAxMKeKXGZjvlx1MDAxMFCQpZtUu1x1MDAwYmG58lx1MDAxOHF55ttyoF26hjXMwjRrxv123D+eNOxyVWeRilGO69bIW9lggVx1MDAxMFx1MDAxY1x1MDAxY8KJWbJWXCKyLzU7XHUwMDBlXHUwMDA3efhcdTAwMTNoLlx1MDAxZIfN1nKjuKy8fdRvX2/V/FxietIqXHUwMDEwkZPge8hcdTAwMWLhqFx1MDAxYeVcdTAwMDKtlOKwyFx1MDAxMUlesahcdTAwMWJcdTAwMGWzzaTXizNcZv1uXHUwMDEy97PpIc7HcsMjv1x1MDAxM4Xt6at4o/K1aYpcdTAwMTj4XHUwMDFlJ6Ow4tNagaD8y/jzn7/NbN2QNmCOw3G5VVx1MDAwZTGk1OX7XHUwMDE1hoOUVYj3cZVLc21/tUjxR1x1MDAwNSNFd4/K/9+YLJWoXHJUyFxi6Vx1MDAwNWbxOGV+pLlcdTAwMWGqbCfeOVx1MDAxNudKupYqSVx1MDAwNcz4ckH+skpPZiyai0AppoEgXHUwMDAxPiUzZdgqM1x1MDAxNlxuYIxcdTAwMTKet5mRZlx1MDAwNlfCXHJcZlx1MDAxN4asYX6lUlbL+iSVsfpuqXLp+GZBqrxcdClcdJJcZrNmpDbMKWZLsLpkJcFcdTAwMDLIoCGlreRcdTAwMDaR6XJMOT/GKVx1MDAxOcWCXHUwMDFj4dxIJpnmXG6onknfXHUwMDFhyavQxLWDUfSgybLetf1RcepcdTAwMWKSW1x1MDAxZbzO4DZuarnNK1x1MDAxMIaWXHUwMDE3VYHruK3RODxcdTAwMTl0e413nz703n462uc7NnyzXHUwMDFjt01cdTAwMTc9bi9cZoRPXHUwMDA3ym92IORcYkK5qWJcZiSHXHSGLFxmLlxi4qintpZcdTAwMTOhXGKXpzZJXHUwMDAxQWl8XHUwMDFjKiGYppSAjalccnpqXHUwMDEwqXCutJLWQVx1MDAwMitxoECw6inmzrhccjKAaEtcdTAwMTdcdTAwMTO4PLdNY7HmyopRPnFtxfFQ7Vx1MDAxY/ujOrsrXHUwMDAyuWD1q57EuGWalVxue9eh/JXZ33p3tN//a3evXHUwMDExd3Y/7vY2yzi+pyjnXGahKHNcdTAwMWF6XCJcdTAwMWSjyVJcdTAwMGI5XHUwMDFl+HqTXHUwMDE2mlx05Ma3XHUwMDA3cul11GJCuJRCilx1MDAxOeFcdTAwMGLoXGJcdTAwMTk4poWk1UaUmlxcVVxckUEohqt3inFokSpM+z/Gr47aXHUwMDE59kdlbm9cYvD6XHUwMDE0xcnps+NcdTAwMTRFwFuM0IunKPOXju9pNUdqXHUwMDFiKM9kklx1MDAxY1x1MDAxM0pNXHUwMDE2c0grjDyzSmryy1x1MDAxOfVrKpE25nsyXHUwMDE0K1x1MDAwMzyFM1x1MDAwYiVcdTAwMTZOmFx1MDAxOWsqXHUwMDA2TSxcdTAwMTfqosTNbaWY44zz+UkxXHUwMDEwP2Ep5yb5XHT32039tjHpk49S4lbkXHUwMDAy0pfymN9GZ1x1MDAwNMPs28rbL5Sg3KjAhEArz4c1hEXzqk0skPA0MLVcdTAwMGaJyIxcdTAwMGJcdTAwMTJcdTAwMGYzP/HFdMF93VxuaVwiXHUwMDFj1JXvbihcdTAwMTko5vNIvKt23OrruqvFSd5dXHUwMDA1XCKrXCJKcHLtJkxcdTAwMTCHVCRcdTAwMTYve89fhbunREkqr0JcdTAwMTJcdTAwMTG3xu+PniBK5D+B4yRcdTAwMTTmXHUwMDA1woHxqGXK7y17I71Hwmu19lm8sULPqnv7QoBcdTAwMTVcdTAwMDC+c1x1MDAwNKtLw3RcdTAwMTlcZjGEyoIxW9j5XHUwMDEzXHUwMDE2c1x1MDAxNqYlsJIzoCUlXHL+Icx1pVxm44qXMMPgLtKCIVx1MDAxM1x1MDAxMXrJqvdccmo5/udcdTAwMDJKYKqM407MsIhcdTAwMDXgUWNcdTAwMTCngDyMIFMx6SExpVx1MDAxNFx1MDAwMahcdTAwMTDqIJj0xepcdGqTKlBcdTAwMDZcdTAwMTFcYsf0cKmEvK63epj4o1x1MDAwMpBVXHUwMDExJWZrzs9hpDQgS7Z4TDl/I8Q9pUptKPDcXCKl31x1MDAxZlOiwjykJFx1MDAxYvhanZXI11x1MDAxOVx1MDAxOODWQkpHgfNLQlpcdTAwMTE8h5XixTFPalx1MDAwM6+CjVx1MDAxMpE+VNNUat7cec1G9Fvc/Fx1MDAxM/LkTerLXG40o1x1MDAwNVx1MDAwNtRxSZzPiCldQIjsuEDCRuAtW2Wl1caULIBNiLBcdTAwMWPYm/zCs6pcdTAwMTbiPVU643dDglx1MDAxMrhGXvOgqbIhXHUwMDFj0mXn02XwpCPi5dtcdTAwMWKKw/MtoCfhv1baa8myUYuV/GpcdTAwMDUmN2TLur1Mtv6HPbBcdTAwMDQxp6XC7a+jyk7nTTM92n16MNJnh9REYrT//PzeUyVcdTAwMDZcdTAwMWUyZIB0+G15xfTqh4O+oCm130RcYlx1MDAwN741ruRyxi97RKVMzpGpSFx1MDAwZeYuLLm1zUxcYscs5Hupgnh5M1x1MDAxM5s4O2f3Ulx1MDAwZuaGx9Fw7dfRYPZcdTAwMWUmXrOHqVx1MDAxYlx1MDAxZE369+RcdTAwMGWmLFx1MDAxOdRtX5p4mem9SpNcdTAwMDYthTBobFx1MDAxZMSQYlvos1i8gp18jJ/2qdGmdCS2XHUwMDA2zVx1MDAxZGaH2+9cdTAwMWVcdTAwMDLCXHUwMDA09EprST66nMrbiFx1MDAwN2SsX1xi9Vx1MDAwYqeO32LeZtRCXHUwMDEw035DkrP6x0DMr1UuVY9eXG5iYZal8eEo8z7dTs7691x1MDAwMmZVoy6g9uhSLtfDwWAvw7iNg1x1MDAxNcxI3L58+aLr9dM4OmtWveKXo/zwvebw9UCJ8jjx26Nv/1x1MDAwMC2LavoifQ== Parent()Child()Child()messages (up)attributes (down)"},{"location":"guide/widgets/#messages-up","title":"Messages up","text":"Let's extend the ByteEditor
so that clicking any of the 8 BitSwitch
widgets updates the decimal value. To do this we will add a custom message to BitSwitch
that we catch in the ByteEditor
.
from __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.message import Message\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\nclass BitChanged(Message):\n\"\"\"Sent when the 'bit' changes.\"\"\"\ndef __init__(self, bit: int, value: bool) -> None:\nsuper().__init__()\nself.bit = bit\nself.value = value\nvalue = reactive(0) # (1)!\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\ndef on_switch_changed(self, event: Switch.Changed) -> None: # (2)!\n\"\"\"When the switch changes, notify the parent via a message.\"\"\"\nevent.stop() # (3)!\nself.value = event.value # (4)!\nself.post_message(self.BitChanged(self.bit, event.value))\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\ndef on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:\n\"\"\"When a switch changes, update the value.\"\"\"\nvalue = 0\nfor switch in self.query(BitSwitch):\nvalue |= switch.value << switch.bit\nself.query_one(Input).value = str(value)\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
Switch
widgets, when it changes state.ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a32\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2503\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2503 \u2503\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u2503 \u2503\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
BitSwitch
widget now has an on_switch_changed
method which will handle a Switch.Changed
message, sent when the user clicks a switch. We use this to store the new value of the bit, and sent a new custom message, BitSwitch.BitChanged
.ByteEditor
widget handles the BitSwitch.Changed
message by calculating the decimal value and setting it on the input.The following is a (simplified) DOM diagram to show how the new message is processed:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPiSNL+7l9BeL/MXHUwMDFiMdbUlXVMxMaGb7vb99l4e8MhQLZlZKBBPjfmv29cdTAwMTZ2I4FcdTAwMGVcdTAwMDTGgHteOtrYkiilqvLJfDKrsvjvQqm0XHUwMDE4Pre8xT9Li95T1Vxy/FrbfVxc/N1cdTAwMWV/8NpcdTAwMWS/2cBTrPt3p3nfrnavvFx0w1bnzz/+uHPbdS9sXHUwMDA1btVzXHUwMDFl/M69XHUwMDFidML7mt90qs27P/zQu+v8y/7cc++8f7aad7Ww7UQ3WfJqfthsv97LXHUwMDBivDuvXHUwMDExdrD1f+PfpdJ/uz9j0rW9aug2rlx1MDAwM6/7ge6pmIBcdTAwMWH44OG9ZqMrrSGEK0JcdTAwMDTtXeB31vB+oVfDs1cos1x1MDAxN52pdVx1MDAxZs9cdTAwMTfl/ZPjQ7ax/0Rf/MOL5tI2j2575Vx1MDAwN8Fx+Fx1MDAxY7x2hVu9uW/HhOqE7WbdO/dr4c3PnotcdTAwMWTvfa7TxF6IPtVu3l/fNLyO7YBI0GbLrfrhsz1GSO/oay/8WYqOPOFfXHUwMDAwwpHAiFRGXHUwMDFhQ0x05+7nJXWEUExSpoXgQOSAYKvNXHUwMDAwx1x1MDAwMlx1MDAwNftcdTAwMDdcXPFaVUSiVdxq/Vx1MDAxYeVr1HrXhG230Wm5bVx1MDAxY7House3R6Y8uvWN51/fhHhQRlx1MDAwZtXxulx1MDAxZFx1MDAwZlx1MDAxYSRKZFTvhL1Ja7vW1YH/xHumUXvrmcZ9XHUwMDEwRHLZXHUwMDEz64N6XHUwMDEz150+/Vx0vadI2NhgP1x1MDAxZl3723opuPpa11+vvJ1rslx1MDAxNJ4v9q776/f0Zl8/zF42audnXHUwMDE3x0vN3X3plVx1MDAxZla/3Kq1/rv8vL/bbjdcdTAwMWZj7b79XHUwMDE2Peh9q+a+6iSVSlx1MDAxM6FcdDdMR4NcdTAwMWP4jfpgXHUwMDFmXHUwMDA0zWo9UuOFmMBcdPz0PX9cdTAwMWM6hoos6FDNmEFRiC6MnfTuXHUwMDFjXHUwMDBiO3R62FE0XHUwMDBmO5o51ExcdTAwMDU7RiehQ80gdKhcdTAwMDZcdTAwMDZcXFGYXHUwMDE4dLK0kDJKiVx1MDAwMjKKXHUwMDE2RmPdbITH/ktXkWTf0Vxy985cdTAwMGae+4arq57YPSvPobfedVxmv/1fvFx1MDAxZjtcdTAwMWXeudtcdTAwMTTv+8xy4F9bNV6s4rN47T5cclx1MDAwZn30Nb1cdTAwMGLu/Fot7j2qKIiLbba3i1x1MDAxOP1m27/2XHUwMDFibnCSJmcu8HJcdTAwMWRcdTAwMTdHdGWhj1FcdTAwMDaGoz6qwujTle3d1fKjvOzcbLeCSiB2XaPn3nNJ7lx1MDAxMK0oVcSAVMz0oY9p6aAx1Fx1MDAxYzVeXHUwMDAyZVx1MDAwND5cZn2o60U8XHUwMDE32lx1MDAwMkEkxaGZsetqy/UtVl7+tvTilqv1tc7S4flyY1xuriu3XbF3V79cclx1MDAxZaTim+et2y/N8mXdrU+g3VBcdTAwMDSbT4dXXHUwMDBmXHUwMDE1oYOV2vPm84/bJz1cdFerNChD0PhHivVBrpZrk8lSKTIhTlx1MDAwNep3YbCnXHUwMDBm/ydwtdlg50Q6MFx1MDAxZLBcdTAwMWKRxHqKq1XGXGJ0+zGv81x1MDAwYvlaPzx+9MPqzW9qur52iJtK+NqYnKV8Z/tcbv007IEhmdijVCilmCpcdTAwMWVcIuZbzznFnqTSQTZhXHKe6uKv39Ey6ShcdTAwMTDCWkJqiFx1MDAwMJ2JPdJ9jY894qBcdTAwMDWQXHUwMDFhKbVcdTAwMDEgRpuUiFx1MDAxMYgjXHUwMDE41VQrJVx1MDAwNIYgbFx1MDAxMJqGI2Y4k6NcdTAwMDSQkV/5qTHs7chfo1x1MDAwM/ZniEbHXHUwMDAxbCd02+GK36j5jet+wd5SIUVYqX1mt4XXXHSHXHUwMDEzjFx1MDAwN1xml0pII6L+7Fx1MDAxYYHqfafb6Tiw1t1cdJAouFx1MDAwNtCJZ/dcdTAwMWG14TLlo7cnk8ZBRlx1MDAwMov/0LlyXHUwMDAxXCJdJlxmuqjSXG7sf1x1MDAxY26iXHUwMDEyQlx1MDAwNW4nXFxt3t35Ifb9QdNvhIN93O3MZVx1MDAwYv1cdTAwMWLPrVxynsWHip9cdTAwMWK0XHUwMDExLdtiP1x1MDAwYot+K0VcdTAwMTDq/tH7/T+/p1+dqdn2ldDpqLmF+PtYoVx1MDAwNDbHXHUwMDA2XHUwMDBm9yxcdTAwMWNcdTAwMTJcdTAwMGJcdTAwMWNwXHUwMDE5c7fDLFxc5UTedbbWn/ZcdTAwMGVEsFX/8m19tbV1PFtcdTAwMGKnhpJcdTAwMGJcdTAwMDKOUlx1MDAwNrhcdTAwMDTgQotcdTAwMDFcdTAwMGKHps1yXHUwMDBmSz9cYmp/pJFcdTAwMTM3cKmRXHUwMDA0RMfeTFx1MDAxOGPaUFxudNY5sCevfHZ5e7N50ZKrrfrB8u7LVc2fXHUwMDAy4Z9cdTAwMTdijujIzoFcdTAwMTmUwlx1MDAxMCOL58DSu3POoYPYYFx1MDAxMXRcdTAwMDaAw2FKwIk501x1MDAxY1ZutFx1MDAxMFIjaZtcdTAwMWEpXHUwMDFmy8ePRcp33IpcdTAwMTf89n1RfV9EsjtNVj7E4lx1MDAwZrLyfkHf4bdoXHUwMDBlM1x1MDAwNyqBXGJcdTAwMTGLm4eBr3a6t7dzclY7P0TQ3Vx1MDAxY79UV59cdTAwMGXUnINPcqTehHGMM20kovvzz1x1MDAwMmk7kUxQJJyKXGIjs4PiKfktqlx1MDAwNMbnJJ7OmI3jkpvkaUM9fttvXHUwMDFmeU+rXHUwMDE3661V9+jub5RRXHUwMDEyLCejhOacauQ8xTNK6d0559hcdTAwMDHlsGzscDot7EiThE7SdXHKXGaGXHUwMDA1ZnpzN9NzXW9Jmim7rSFcdTAwMDZ/0G1FQuZcIi4zkcQgliZcdTAwMWGAnNboy/goUVY+eZ7XPFx1MDAxMoZZVDFUY2lcdTAwMTBYnPZBjlx1MDAwMzhcdTAwMWPhyKnmiEnGP1xmcYDYNpJcdTAwMTjOXGbjUsTuXHUwMDE0+S7pUINcdTAwMDNiU+tKMMVcdTAwMDfxSFx1MDAxObOeN25cdTAwMTimm0ZcdTAwMWHLWcS6tFBcdTAwMWGpcMpcdTAwMDZcdTAwMDNSMFJcdTAwMDB6d8K0jl3xM2WzZONcdTAwMDTCKJGK2excdTAwMWJRSiZcdTAwMWW+UFx1MDAxZSmfb8aEXHUwMDAywOFlaDhcdTAwMTUnPDJH/UJcdE1cdTAwMTWnwIBcdTAwMThhM2BcdJk+U1x1MDAxYWkpU7XtK6HUUXNcdTAwMGLx99FtXHUwMDFidlxcpm0jXGJ5SiFS02G2LZ9fzatt48xBViVcdTAwMTRcdTAwMWE3XG5aRqHuq20zXHUwMDBl2jeBJyQ3mis1INhkjVx1MDAxYva3wpDbKIRaynSVQLyitlM0XHUwMDFlXHUwMDA2JY5f8zPBRJTQXHUwMDA0Ufr/1s2aeodcdTAwMTMp0WppJVx1MDAxNGozxC6JMtJcdTAwMWOQMnNjKHInKWQyXHUwMDFmXci45bOSmHFcdTAwMTNGXHUwMDAxt/8p1TLVuKFInFx1MDAxYqIpJ8SumDNJe/uZbFumYttXUqVHtG25qVx1MDAwNqpoJndD5ibRmlIovtqGtOvbX7dcdTAwMGWDXHUwMDEzctmRrdN6eHrS+jL3XHUwMDA2zlx1MDAxOIcx27WMaWl0xO67k4CGOVRqVDc8XHUwMDBm6G6z7VtFMVxyufbt7Vx1MDAxYSGS1o2ylGhcdFx1MDAxMvZcdTAwMGJVRFxuytHQvst+0eH2q/eZkfJcdTAwMGbaf1bNaq3R2Vx1MDAxNPvqWMOB6bxcdTAwMTROnMPJ9cph58vt3XVjjSw/XHR2tr4rP1f+gVwiWcxcdTAwMDJcdTAwMTRGQtTOXHUwMDAwysJ4Su/NOceTXCJcIlx1MDAxN096cnjKT92RlPCHJ/JcdTAwMGZMXCKbVFx1MDAwMqa3noWNoITRWMfyXHUwMDBm1EHDzImWTKBKkdhUTV8+oj+9sNhcdTAwMGL1ndVcdTAwMWJcdTAwMWM5r/bb94Y9++BcdTAwMDb33j9P2vfe90b6qlx1MDAxNy77WurlIVx1MDAwMu+qXHUwMDFmXHUwMDA0I6UphjiL9DRFruzjcXyusyk+XGJcdTAwMGV8hEKJfFx1MDAwYjZcdTAwMTnA1tzOjTdRxGqGPk5TXHUwMDEwXHUwMDEyQCPp6l+CJoh0XHUwMDE05UA018hR9MchXHUwMDE2hIO3oFx1MDAxOHNR5OiKcEhcdTAwMDJcdTAwMThcdTAwMTRyViosYUJahOBcdTAwMWTEM0ib1Fx1MDAwND5cbp4nTvC54eojXHR++NU/OHKXbzfPt063j2twpra93TiZjqgyM2huKTfUXHUwMDE4ZVikyz3GXHJcdTAwMGXaarxCXHUwMDAypZxRNuZcIph8OPeLpDhcdTAwMTeW7uJQXHUwMDAz4TIhXHUwMDEyd5jgikpk91xmLTP/7LmLTL1+PT2o0pNk+JzFqOLgenpAXHUwMDFlaVx1MDAwN7+wfSt7QeXg7GqLPD7Cycry/ZHoXFzuz/1yeux/ooFcYlx1MDAwNmBcZiH9XHUwMDE5XGbQxulcdTAwMDaTimMsqSE7gfH+1fSRsYrsWYKQaHTsXFxyMevF9GflzfJcdTAwMTKtrHjV1epcdTAwMGZf3vDypbgqSuXp4cF6cNsgq/7h3kZwtXfyrHaCidSBWVx1MDAxNtWN/j+aynNmMpFDQWpcdTAwMDPoXHUwMDE0WWHopHfnnHP510qUXGI6/VxcXlx1MDAxMuOg3ZpcdTAwMDJ0XG6WgVx1MDAxMYw7qITY/MtHcvlRtTCVy89/XHUwMDFk2Fx1MDAxMJv/QXVggtHMRTCAPotJw4tcdTAwMDfSO/ri6kbpx1v/oHy+fPz88Lgmzubeb0nmXHUwMDEwZsBcdTAwMTBKpCZU94FcdTAwMGa7XHUwMDAw/Vx1MDAxNmBcdTAwMTQtKFKKWFx1MDAxMDOrKjAlJGKPTbAyZDzH1VDrbnAsXq7Kl1qrs8ZdsIy+5+NcdTAwMWRXbruHpzWobZRltVx1MDAxNXYuqD5mlaO9/Vx0tNtoP/JNODva6ZgnODhZXHRX7vjW53K0gsVcdTAwMTZ/JCpRrOtcdTAwMTkpaZY+/PPuaFx1MDAxNcnDumCOnlxu1otcdTAwMTWBcTvNSmBKa3am7GdnVVx1MDAwMzbER31EXHKYkCxz+sdmgjhBT1tcdTAwMTh4+aZzToEnKXGEsrVCXGZcdTAwMTksVf25L1x0zFx1MDAwMZDKXHUwMDA2yoxzPSjX5Ga3hWNX7DFcdTAwMDWKo1x0gFx1MDAxNFx1MDAxOErqoFx1MDAxOVSaaWWUMDpJf1FAjpxcdTAwMWNmVlx1MDAwMNZFq2CxXGJ78qmvfDpaXHUwMDFhLO/SXHUwMDFjiNLG1lGp+Oqdn2ViXHUwMDE4Llx1MDAxOIGBg1x1MDAwMmCCQ+LZXHUwMDBi5b7yoVvKLO/CuybzcdpB6ElDqVx1MDAxNFx1MDAwNohmn3tyO0uv7Suh0VFjXHUwMDBi8ffxXHUwMDAyXGIhs6e2hUShsG8jRVx1MDAxZGbc7k9b6ujBe3jY2Hrcba1cdLPjXHUwMDFkzdi4XHUwMDE1qP7iaNuwbzU3nLBcdTAwMDFSgdbOXHUwMDAxQrStuELkfmB1a9FF9MxWwaNGzDqA8I8qa1crtdtccvdCfDup1Fx1MDAwM1J396dA9OeGkEM2dFBPXHUwMDEwWECL04L03pxz5FDlmGzkSDkl5Fx1MDAxNKv+UoxcdTAwMDDGSXJyXHUwMDE54/mh47Or/lx1MDAxYWLxP6z6XHUwMDBiVOZcblx1MDAxMqQo1Fx1MDAxOGGKXHUwMDA3w99u7veO/fLVKWeN3VbraYtvm4c5XHUwMDA3XHUwMDFm+mZHcIyDpTJMx6ffX91cdTAwMTaSKGq3YEPk0fhsznTcVjLvhUxcdTAwMDOMgZnvfnS6slG5fdg/uEJd9Y/LnFc3Ksd/pzySjFx1MDAxOH5yMaOmwG1qpTB00rtzzqFcdTAwMDPaMdnQkeDAVKBTrPSLMqa7ZWi/4nzNbGq/htj7Sdd+cWmyIVx1MDAwN5JQu1a1eJCVz53nNYNEmMNcdTAwMTlGuJJcdTAwMTDDgffvXHUwMDE0oFxmdaRcdTAwMTZcdTAwMTjjaoVRp1x1MDAxOZw+mmBcdTAwMDbJOCCMUZJwgy5Jpm0hhFx1MDAwMbe0Y4ZgQHljXHUwMDBiP3r7b0hQo/ixT5c+KpyqsWVd0ih0XHUwMDFjSPPRoEZ7kpSyqsN44tFcdTAwMGJlj/KJZim7qFx1MDAwYjiFXHUwMDE0ofqqw0RCps+UPVrKVGr7Sqhz1NxC/H10s2ZU5s5BjFFcdTAwMWPzUTh4PrWaV6vGucPRbFx1MDAxMSYpKvfAKm5lwDFCITaowivIx+XFke1TxolmVlx1MDAxOMVT4mFcdTAwMDFcdTAwMGW39Y1GIErBJEsmUH2UlCNFx7+uWXst51x1MDAxMnainiB2XGKw5PJLaic9bEZcXOFVaEjUmCVf+WSklF3QlVx1MDAxNEk7SGmFIYBcdTAwMDZQXHUwMDEzwz73itAspbavQXVcdTAwMWXRpuV/K1x1MDAwMIPsJW2ca6kkXHUwMDFiIUI65N5cdTAwMGVpiy1cdTAwMTnenZZbT5VvlVx1MDAxM3ExW8Mmhld7Scfmy4TQXHUwMDAwXHUwMDE4gPTvjqFcdTAwMTh3hMUqdoPdXHUwMDA0M5utuZJ6opZr1/7hSWE3i0+r9lx1MDAxMilcdTAwMGLaIEHHMEzjwtD3bun4YdVe9y7dXGb8h/37L/5hsKb3g43640rRjFx1MDAwM/9xcv+1/nxSP/56015fuffPrlduP1fGgdHsxdVUgFGCY1RQXHUwMDE4T+ndOd94wuA9XHUwMDBmT5xOXHUwMDBlT/nJurTNLpLVXpTYRJ31NCMgasYph7HLvXpLROak4muIv8hcXOEyftFXvjfkMnPdXHUwMDE59jVcdTAwMTOKj7DHobtz0Vxcalxcr9HTXHUwMDAzXb/wNm+bZ8/e3LN8Y3fO0EaBVlQgw+pDL1x1MDAxOEQvVcyWR1x1MDAxYVx1MDAxZV/sPlx1MDAxYm8ojWGESP2+vVx1MDAxYj7MXHUwMDFidiqHoVe+8E7MQV2dtFdum5Xdtb+TN2RcInu/XfRcdTAwMTNcdTAwMTKdoSjuXHLTu3POXHUwMDAxZd1hXHUwMDFloMzkXHUwMDAwNVx0d8hcYjVcdTAwMTQ0+URcdTAwMGI5f1x1MDAxOW84xGF8gDfMzHdRqTPT+Fx1MDAwNlx1MDAxOLWhd/F8V74pm9dcImgllUOUNFx1MDAxYdWKoaPpX1xiqqh0tOXz1NhccnNIzjZH7922wFx1MDAxMVozSbjmWktqTErRoOSv+11cdJTHriZMbuJmP6pcdTAwMTSwWVVBTyXplc8nS32Jc8LRJNtcdEfVXVwiK2M5l7dcdTAwMTSTdIBLpYjm3e9bgTHXgubDutS3XHUwMDE2XHUwMDE0PVx1MDAwMZPC1lxc21R+MuslXHUwMDFjprlB3VVgR1MlJ1x1MDAxOD5T1mspW7W7p1x1MDAxM1pcdTAwMWQ1uFx1MDAxMH9cdTAwMWZzp6PY5PlgSlx1MDAxZs2fVYziu1Tm19/PhJvAcFx1MDAwM8fsvrCCXCI/wchnoJZTULtcdTAwMDeE5Izh8NiKz5x9Yaex0Vx1MDAxMVxiYlBcdTAwMGJgXrn+16PDcufMO79i4erezsHDt6WD2nzsczTq3lx1MDAxM2NxfVtlm8n1XHUwMDE5t7KoXHUwMDExguf07px3PKlcXDzpXHTiaVx1MDAxMlx1MDAxYlx1MDAxZFEgqFx1MDAxZDJe8vPRO1x1MDAxZI3l/H+9nY6GeItJ73SUidlcdTAwMWNcdTAwMGaImJVSk1x1MDAxMVxuXCI6wU1l47D98uOyeaafVn/slDfXZuxcdTAwMDJcdTAwMGKUWVxu4oDgXHUwMDE4fVx1MDAxYrtiU/av61x1MDAxNkY51FZZaqT5hKpY0elkfSCPfcFNXHUwMDBmsbEvOowqqi3t0yNtVjo+Ylx1MDAwMVx1MDAxOYJ6L2JcdTAwMGKvj1t2Sq+KXuogXHTtlPq1vnSHT+Nee6kojX0/8ygoXHKbrSyI9j3lIFx1MDAxZVx1MDAwYko6XHUwMDE2JmVOkZKynnyETVx1MDAwZfjT5Y/VJ7qp1lx1MDAxZlr8eV08Xla/VudcdTAwMWWRhDukXHUwMDFidFx1MDAxM40/419waVx1MDAxYjDMfoFcdTAwMTVgPIbRpCTZlVx1MDAxNkUy0NmAjH+bXrSwJPn1VGg60EqS6UxcdTAwMWVJSsW7PWjxumen1Esxldpep9W0ul557io9hrylRFx1MDAwMup7I2yW/LBTXHUwMDFhJCZxhzpdqL7/IV5RvPBGsVx1MDAxN91W6zjEcelFXHUwMDE3OOJ+7a1zI1FcdTAwMTZcdTAwMWZ873ElReWuui/batcyWFx1MDAxMHrdUOWvhb/+XHUwMDA3KtdcdTAwMDdDIn0= ByteEditor()BitSwitch(7)Label(\"7\") Switch() Switch.Changed( value=True)ByteEditor()BitSwitch(7)Label(\"7\") Switch() BitSwitch.Changed( value=True)BitSwitch.Changed( value=True)Switch.Changed( value=True)A. Switch sends Switch.Changed messageB. BitSwitch responds by sending BitSwitch.Changedto its parent"},{"location":"guide/widgets/#attributes-down","title":"Attributes down","text":"We also want the switches to update if the user edits the decimal value.
Since the switches are children of ByteEditor
we can update them by setting their attributes directly. This is an example of \"attributes down\".
from __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.geometry import clamp\nfrom textual.message import Message\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\nclass BitChanged(Message):\n\"\"\"Sent when the 'bit' changes.\"\"\"\ndef __init__(self, bit: int, value: bool) -> None:\nsuper().__init__()\nself.bit = bit\nself.value = value\nvalue = reactive(0)\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\ndef watch_value(self, value: bool) -> None: # (1)!\n\"\"\"When the value changes we want to set the switch accordingly.\"\"\"\nself.query_one(Switch).value = value\ndef on_switch_changed(self, event: Switch.Changed) -> None:\n\"\"\"When the switch changes, notify the parent via a message.\"\"\"\nevent.stop()\nself.value = event.value\nself.post_message(self.BitChanged(self.bit, event.value))\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\nvalue = reactive(0)\ndef validate_value(self, value: int) -> int: # (2)!\n\"\"\"Ensure value is between 0 and 255.\"\"\"\nreturn clamp(value, 0, 255)\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\ndef on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:\n\"\"\"When a switch changes, update the value.\"\"\"\nvalue = 0\nfor switch in self.query(BitSwitch):\nvalue |= switch.value << switch.bit\nself.query_one(Input).value = str(value)\ndef on_input_changed(self, event: Input.Changed) -> None: # (3)!\n\"\"\"When the text changes, set the value of the byte.\"\"\"\ntry:\nself.value = int(event.value or \"0\")\nexcept ValueError:\npass\ndef watch_value(self, value: int) -> None: # (4)!\n\"\"\"When self.value changes, update switches.\"\"\"\nfor switch in self.query(BitSwitch):\nwith switch.prevent(BitSwitch.BitChanged): # (5)!\nswitch.value = bool(value & (1 << switch.bit)) # (6)!\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
BitSwitch
's value changed, we want to update the builtin Switch
to match.Input.Changed
event when the user modified the value in the input.ByteEditor
value changes, update all the switches to match.BitChanged
message from being sent.switch
is a child, we can set its attributes directly.ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a100\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Changed
event, which we handle with on_input_changed
by setting self.value
, which is a reactive value we added to ByteEditor
.watch_value
which sets the value of each of the eight switches. Because we are working with children of the ByteEditor
, we can set attributes directly without going via a message.In this chapter we will explore the topic of concurrency and how to use Textual's Worker API to make it easier.
The Worker API was added in version 0.18.0
"},{"location":"guide/workers/#concurrency","title":"Concurrency","text":"There are many interesting uses for Textual which require reading data from an internet service. When an app requests data from the network it is important that it doesn't prevent the user interface from updating. In other words, the requests should be concurrent (happen at the same time) as the UI updates.
Managing this concurrency is a tricky topic, in any language or framework. Even for experienced developers, there are gotchas which could make your app lock up or behave oddly. Textual's Worker API makes concurrency far less error prone and easier to reason about.
"},{"location":"guide/workers/#workers_1","title":"Workers","text":"Before we go into detail, let's see an example that demonstrates a common pitfall for apps that make network requests.
The following app uses httpx to get the current weather for any given city, by making a request to wttr.in.
weather01.pyweather.tcssOutput weather01.pyimport httpx\nfrom rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nawait self.update_weather(message.value)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
weather.tcssInput {\ndock: top;\nwidth: 100%;\n}\n#weather-container {\nwidth: 100%;\nheight: 1fr;\nalign: center middle;\noverflow: auto;\n}\n#weather {\nwidth: auto;\nheight: auto;\n}\n
WeatherApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aEnter\u00a0a\u00a0City\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
If you were to run this app, you should see weather information update as you type. But you may find that the input is not as responsive as usual, with a noticeable delay between pressing a key and seeing it echoed in screen. This is because we are making a request to the weather API within a message handler, and the app will not be able to process other messages until the request has completed (which may be anything from a few hundred milliseconds to several seconds later).
To resolve this we can use the run_worker method which runs the update_weather
coroutine (async def
function) in the background. Here's the code:
import httpx\nfrom rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.run_worker(self.update_weather(message.value), exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
This one line change will make typing as responsive as you would expect from any app.
The run_worker
method schedules a new worker to run update_weather
, and returns a Worker object. This happens almost immediately, so it won't prevent other messages from being processed. The update_weather
function is now running concurrently, and will finish a second or two later.
Tip
The Worker object has a few useful methods on it, but you can often ignore it as we did in weather02.py
.
The call to run_worker
also sets exclusive=True
which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. For instance, if you start typing \"Paris\", you may get the response for \"Pari\" after the response for \"Paris\", which could show the wrong weather information. The exclusive
flag tells Textual to cancel all previous workers before starting the new one.
An alternative to calling run_worker
manually is the work decorator, which automatically generates a worker from the decorated method.
Let's use this decorator in our weather app:
weather03.pyimport httpx\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
The addition of @work(exclusive=True)
converts the update_weather
coroutine into a regular function which when called will create and start a worker. Note that even though update_weather
is an async def
function, the decorator means that we don't need to use the await
keyword when calling it.
Tip
The decorator takes the same arguments as run_worker
.
When you run a worker, the return value of the function won't be available until the work has completed. You can check the return value of a worker with the worker.result
attribute which will initially be None
, but will be replaced with the return value of the function when it completes.
If you need the return value you can call worker.wait which is a coroutine that will wait for the work to complete. But note that if you do this in a message handler it will also prevent the widget from updating until the worker returns. Often a better approach is to handle worker events which will notify your app when a worker completes, and the return value is available without waiting.
"},{"location":"guide/workers/#cancelling-workers","title":"Cancelling workers","text":"You can cancel a worker at any time before it is finished by calling Worker.cancel. This will raise a CancelledError within the coroutine, and should cause it to exit prematurely.
"},{"location":"guide/workers/#worker-errors","title":"Worker errors","text":"The default behavior when a worker encounters an exception is to exit the app and display the traceback in the terminal. You can also create workers which will not immediately exit on exception, by setting exit_on_error=False
on the call to run_worker
or the @work
decorator.
Workers are managed by a single WorkerManager instance, which you can access via app.workers
. This is a container-like object which you iterate over to see your active workers.
Workers are tied to the DOM node (widget, screen, or app) where they are created. This means that if you remove the widget or pop the screen where they are created, then the tasks will be cleaned up automatically. Similarly if you exit the app, any running tasks will be cancelled.
Worker objects have a state
attribute which will contain a WorkerState enumeration that indicates what the worker is doing at any given time. The state
attribute will contain one of the following values:
worker.error
will contain the exception. SUCCESS The worker completed successful, and worker.result
will contain the return value. Workers start with a PENDING
state, then go to RUNNING
. From there, they will go to CANCELLED
, ERROR
or SUCCESS
.
When a worker changes state, it sends a Worker.StateChanged event to the widget where the worker was created. You can handle this message by defining an on_worker_state_changed
event handler. For instance, here is how we might log the state of the worker that updates the weather:
import httpx\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nfrom textual.worker import Worker\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\ndef on_worker_state_changed(self, event: Worker.StateChanged) -> None:\n\"\"\"Called when the worker state changes.\"\"\"\nself.log(event)\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
If you run the above code with textual
you should see the worker lifetime events logged in the Textual console.
textual run weather04.py --dev\n
"},{"location":"guide/workers/#thread-workers","title":"Thread workers","text":"In previous examples we used run_worker
or the work
decorator in conjunction with coroutines. This works well if you are using an async API like httpx
, but if your API doesn't support async you may need to use threads.
What are threads?
Threads are a form of concurrency supplied by your Operating System. Threads allow your code to run more than a single function simultaneously.
You can create threads by setting thread=True
on the run_worker
method or the work
decorator. The API for thread workers is identical to async workers, but there are a few differences you need to be aware of when writing code for thread workers.
The first difference is that you should avoid calling methods on your UI directly, or setting reactive variables. You can work around this with the App.call_from_thread method which schedules a call in the main thread.
The second difference is that you can't cancel threads in the same way as coroutines, but you can manually check if the worker was cancelled.
Let's demonstrate thread workers by replacing httpx
with urllib.request
(in the standard library). The urllib
module is not async aware, so we will need to use threads:
from urllib.request import Request, urlopen\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nfrom textual.worker import Worker, get_current_worker\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True, thread=True)\ndef update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nworker = get_current_worker()\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nrequest = Request(url)\nrequest.add_header(\"User-agent\", \"CURL\")\nresponse_text = urlopen(request).read().decode(\"utf-8\")\nweather = Text.from_ansi(response_text)\nif not worker.is_cancelled:\nself.call_from_thread(weather_widget.update, weather)\nelse:\n# No city, so just blank out the weather\nif not worker.is_cancelled:\nself.call_from_thread(weather_widget.update, \"\")\ndef on_worker_state_changed(self, event: Worker.StateChanged) -> None:\n\"\"\"Called when the worker state changes.\"\"\"\nself.log(event)\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
In this example, the update_weather
is not asynchronous (i.e. a regular function). The @work
decorator has thread=True
which makes it a thread worker. Note the use of get_current_worker which the function uses to check if it has been cancelled or not.
Important
Textual will raise an exception if you add the work
decorator to a regular function without thread=True
.
Most Textual functions are not thread-safe which means you will need to use call_from_thread
to run them from a thread worker. An exception would be post_message which is thread-safe. If your worker needs to make multiple updates to the UI, it is a good idea to send custom messages and let the message handler update the state of the UI.
Welcome to the How To section.
Here you will find How To articles which cover various topics at a higher level than the Guide or Reference. We will be adding more articles in the future. If there is anything you would like to see covered, open an issue in the Textual repository!
"},{"location":"how-to/center-things/","title":"Center things","text":"If you have ever needed to center something in a web page, you will be glad to know it is much easier in Textual.
This article discusses a few different ways in which things can be centered, and the differences between them.
"},{"location":"how-to/center-things/#aligning-widgets","title":"Aligning widgets","text":"The align rule will center a widget relative to one or both edges. This rule is applied to a container, and will impact how the container's children are arranged. Let's see this in practice with a trivial app containing a Static widget:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's the output:
CenterApp Hello,\u00a0World!
The container of the widget is the screen, which has the align: center middle;
rule applied. The center
part tells Textual to align in the horizontal direction, and middle
tells Textual to align in the vertical direction.
The output may surprise you. The text appears to be aligned in the middle (i.e. vertical edge), but left aligned on the horizontal. This isn't a bug \u2014 I promise. Let's make a small change to reveal what is happening here. In the next example, we will add a background and a border to our text:
Tip
Adding a border is a very good way of visualizing layout issues, if something isn't behaving as you would expect.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\", id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
The static widget will now have a blue background and white border:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHello,\u00a0World!\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note the static widget is as wide as the screen. Since the widget is as wide as its container, there is no room for it to move in the horizontal direction.
Info
The align
rule applies to widgets, not the text.
In order to see the center
alignment, we will have to make the widget smaller than the width of the screen. Let's set the width of the Static widget to auto
, which will make the widget just wide enough to fit the content:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\", id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this now, you should see the widget is aligned on both axis:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHello,\u00a0World!\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#aligning-text","title":"Aligning text","text":"In addition to aligning widgets, you may also want to align text. In order to demonstrate the difference, lets update the example with some longer text. We will also set the width of the widget to something smaller, to force the text to wrap.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's what it looks like with longer text:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eCould\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258eterminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u258a \u258eclassified\u00a0address.\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note how the widget is centered, but the text within it is flushed to the left edge. Left aligned text is the default, but you can also center the text with the text-align rule. Let's center align the longer text by setting this rule:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n text-align: center;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this, you will see that each line of text is individually centered:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
You can also use text-align
to right align text or justify the text (align to both edges).
There is one last rule that can help us center things. The content-align rule aligns content within a widget. It treats the text as a rectangular region and positions it relative to the space inside a widget's border.
In order to see why we might need this rule, we need to make the Static widget larger than required to fit the text. Let's set the height of the Static widget to 9 to give the content room to move:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n height: 9;\n text-align: center;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's what it looks like with the larger widget:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Textual aligns a widget's content to the top border by default, which is why the space is below the text. We can tell Textual to align the content to the center by setting content-align: center middle
;
Note
Strictly speaking, we only need to align the content vertically here (there is no room to move the content left or right) So we could have done content-align-vertical: middle;
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n height: 9;\n text-align: center;\n content-align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this now, the content will be centered within the widget:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u258a \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#aligning-multiple-widgets","title":"Aligning multiple widgets","text":"It's just as easy to align multiple widgets as it is a single widget. Applying align: center middle;
to the parent widget (screen or other container) will align all its children.
Let's create an example with two widgets. The following code adds two widgets with auto dimensions:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"How about a nice game\", classes=\"words\")\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
This produces the following output:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
We can center both those widgets by applying the align
rule as before:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"How about a nice game\", classes=\"words\")\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's the output:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note how the widgets are aligned as if they are a single group. In other words, their position relative to each other didn't change, just their position relative to the screen.
If you do want to center each widget independently, you can place each widget inside its own container, and set align
for those containers. Textual has a builtin Center
container for just this purpose.
Let's wrap our two widgets in a Center
container:
from textual.app import App, ComposeResult\nfrom textual.containers import Center\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nyield Static(\"How about a nice game\", classes=\"words\")\nwith Center():\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this, you will see that the widgets are centered relative to each other, not just the screen:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#summary","title":"Summary","text":"Keep the following in mind when you want to center content in Textual:
align
rule is applied to the parent of the widget you want to center (i.e. the widget's container).text-align
rule aligns text on a line by line basis.content-align
rule aligns content within a widget.Center
container if you want to align multiple widgets relative to each other.If you need further help, we are here to help.
"},{"location":"how-to/design-a-layout/","title":"Design a Layout","text":"This article discusses an approach you can take when designing the layout for your applications.
Textual's layout system is flexible enough to accommodate just about any application design you could conceive of, but it may be hard to know where to start. We will go through a few tips which will help you get over the initial hurdle of designing an application layout.
"},{"location":"how-to/design-a-layout/#tip-1-make-a-sketch","title":"Tip 1. Make a sketch","text":"The initial design of your application is best done with a sketch. You could use a drawing package such as Excalidraw for your sketch, but pen and paper is equally as good.
Start by drawing a rectangle to represent a blank terminal, then draw a rectangle for each element in your application. Annotate each of the rectangles with the content they will contain, and note wether they will scroll (and in what direction).
For the purposes of this article we are going to design a layout for a Twitter or Mastodon client, which will have a header / footer and a number of columns.
Note
The approach we are discussing here is applicable even if the app you want to build looks nothing like our sketch!
Here's our sketch:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daW9cdTAwMWK7kv1+f0WQ92VcdTAwMDa46ktcdTAwMTbJXCJ5gcHAi1x1MDAxY1mOl8iKt8GDoaUtyda+eHvIf5+ivKjVUsstW7JbuTaQxGktzWafOudcdTAwMTRZTf7njy9fvvbv2v7Xv7989W9LhXqt3C3cfP3TXHUwMDFkv/a7vVqrSS/B8P+91qBbXHUwMDFhvrPa77d7f//1V6PQvfL77Xqh5HvXtd6gUO/1XHUwMDA35VrLK7VcdTAwMWF/1fp+o/e/7u+9QsP/n3arUe53vdFJUn651m91XHUwMDFmzuXX/Ybf7Pfo2/+P/v/ly3+Gf1x1MDAwN1rX9Uv9QrNS94dcdTAwMWZcdTAwMTi+NGogZ0aFXHUwMDBm77Waw9ZyLoxGxVxyPL+j1tukXHUwMDEz9v0yvXxBjfZHr7hDX1x1MDAwZnL9dT+3q/ay56WbXCK2djHdXHUwMDFkjM57UavXXHUwMDBm+3f1YbtK3Vavl6pcdTAwMTb6peroXHUwMDFkvX63deVcdTAwMWbXyv3qU/dcdTAwMDWOP3+216KuXHUwMDE4farbXHUwMDFhVKpNv+d6gT9cdTAwMWZttVx1MDAwYqVa/254lez56ENX/P1ldOSW/pdC4Vx0LY1VqLW1KtAr7lx1MDAwYkAoXHUwMDBmhFx1MDAxNKhcdTAwMDVarahfQi3baNXpjlDL/sWGP6O2XHUwMDE1XHUwMDBipatcbjWwWX5+T79baPbahS7dt9H7blx1MDAxZa9ZSemhcCdjinM+akfVr1WqfXqHQOZcdTAwMTnOrJQohLs9OGqMP7w1XHUwMDFjjNCopVx1MDAxNs+vuCa0t8tDnPw72HHN8mPHNVx1MDAwN/X6qNXuhXRcdTAwMDBbo89cZtrlwlx1MDAwM1x1MDAwMjhcdTAwMWFcdIpJ1Fxio1x1MDAwZanXmlfhr6u3Slcj0Fxmj/7681x1MDAxNXDVKlx1MDAxYa1WcVx1MDAwNIlcdTAwMThcdTAwMWKsuVLn5367sL63s3+Lza2fXHUwMDA3nZ3tTlx1MDAwNFhDgPtImFx1MDAxYcGVtIpxJjhBIIRT6Vx1MDAxOaUlgcJcdTAwMDJKi8vEqfBcdTAwMThHXHUwMDA1dD6urTaTQFx1MDAwNeMhMiFccjJAQaCeXHUwMDAwKt0zJYGuZvVw6tfrtXZvKkpxXHUwMDA2p1x1MDAxYZCcI1cmNkzbP86OS+lstssvc9f5+3TqrtG8fVxyTPn7wVRxz1xuhdTxQljOQIbolDjOMCMl11x1MDAwZcqgw1x1MDAxMTRcdTAwMTdM/3VRUKBgXHUwMDEyolxceJJcdTAwMDEoi0D/SGPkJEY5eFxuKVKISFx1MDAxOVxiXHUwMDAxUocxyql9ilNcdTAwMWL5b1x1MDAwNVItbFx1MDAxNEi1QXfNXHUwMDEwX/dP1jrpu8ZB7qC4tnXT2upvna3jTcIxXG7KY9TdyLnhlsBcdTAwMDEhiFxuT1x1MDAxObRkXG4kUZhV8o1cdTAwMTAtMqaWXHUwMDA1Uc1cdTAwMTXpgeC/XHUwMDE5Qq2IQijnjJSFS81iQ7TeSbdTV1x1MDAxNexs1upXWLrrb53UXHUwMDBlk1xyUVx1MDAxMljgXHUwMDAyQWjBmWRWh1x1MDAxMFxujmQl3Vx1MDAxNVxyxLVv41BcdTAwMGVFY3BZXHUwMDAwpcsgqkcuV1DoZ+dPKlx1MDAxMqPu1iihXHUwMDAzb3hcdKLr37ZcbrsldbLV2V1vnt3+OL6pXHUwMDFjNJJcctFcdTAwMTTlXCKk4lZcdTAwMTFFcnBJyThGXHUwMDExXHQ+hFxmTTTKJcOlYFRJ5oym4Vx1MDAxNCnaQiBOXHUwMDAyOZMnNaekytphO0CFIUquTJGLNYrNXHUwMDAx0SewjOBcIlx1MDAxZY/8ikbu82dGn1x1MDAwZcCt79+O3HZcdTAwMDBcdTAwMWPd9mG1cbB9tj/YK+VzlbXWgVx1MDAxOdx9fX7fr8ffosJCXGImjTLWLiosxtpcdTAwMTmMXGJcdTAwMTNcdTAwMTVcdTAwMTCczs5dvqBiR8T0i55cdTAwMWVcdTAwMTHVQqk66PpcdTAwMWZcdTAwMWZcdTAwMTNcXCO5W+JKQIK+YTLE21x1MDAxYTxcdTAwMDdB4nNcdTAwMTlcbpnXXHUwMDA0XHUwMDA1xVx1MDAxZbuYYn4p8oBcdTAwMTPaXHUwMDE5p1x1MDAwZSeRmJafTVx1MDAwNoFcdTAwMDWuyZ3PXHUwMDE1XHUwMDA0r+Pp11x1MDAwMHJ041vN/mHt3vU9sLGjW4VGrX43du+GSKWuyviFst/9OnZ8rV6rONR+LVF7gy9cdTAwMTJ2+7VSof78hkatXFxcdTAwMGVqQIlOVqg1/e52XHUwMDFj7m51a5Vas1DPR7SFrt3PPOupXHUwMDE3uDPFQs93r1x1MDAwZVx1MDAwM+j1Qlx1MDAwNVx1MDAxOJmTkns0QltcdTAwMTN/5GRtc6O+M2Bddn/XuK/YfL5/lc68LizfcexEXHUwMDE5j5NRkoxsfUhcdTAwMDPcN1xihp7ipGQ8XHUwMDE4XHUwMDE3XHUwMDBiXHUwMDFlNeGKuEFScvEkiKPrXHUwMDFmhSX5KbCGXHUwMDFisnXKqCA/PLkp0IZy6sDgz1x1MDAxMt2UJt5cdTAwMGVkP0t1U1x1MDAwMapcZouHM/zW5fmxUdqpbbT2sr3NXGZ2N1x1MDAwZTK57av9evpV43vvOXBcIjy6t+hcdTAwMTJwMis6hFBuKCdcdTAwMTX0gmIhYLzGTalCoXgh5TTPzz1GSIUpg89Selx1MDAxMjSzj01cYjbxXHUwMDExnUJcdTAwMWKlmVx0XGZcYr5cdTAwMTWcrzNM2Xbnvt45Ov/x48f5Sf3i6vgwfXxcdTAwMWMwTH9O/9qHXHUwMDBm51t7lUIu5Z9095pH9lx1MDAxMCv1y+1cdTAwMWbjZ3k6f6Hbbd3MYcQ4XHUwMDAwhZRQi4qoSCNcdTAwMTY5vkMmjGhcdTAwMGWtiZ+aTO/MhFx1MDAxYrFcdTAwMTRcdTAwMTGpIOtDySBlXHUwMDA1aMLhJJin6bBloFxirnYh2cm0eJLW46QsmnJBy6ZGVcDEPI2MazJvxr5cdTAwMDPFXHUwMDBiSpjIi1x1MDAwNYj1dUaM49jRXHUwMDE5Rix/4/v9JfmwXHUwMDE3SD/sw0JNiWfD5Fx1MDAxYmyYgPDR5/FcdTAwMDLBXGYjb1x1MDAxMt+GVXnaNrYvdzqp6lx1MDAwNuxfyrPTn0onX+BcZnJcdTAwMDGGh+1cdTAwMTdcdTAwMWGPRIVcdTAwMDJEaGCaiVCbPsUteOu3mtlccr9/36hWz5pq/7qU7drcSXxcdTAwMTFcdTAwMDLFKVx1MDAwMdNq+aNcdTAwMDFcdTAwMDFWXGKrkLbcXHUwMDE4w+dcdTAwMTggm37VK6BCJD+KSWHleHYxxL1hXHUwMDFlJSXyzXnHylx1MDAwYpCYXHUwMDAzjMlcdTAwMTWgXHUwMDE3SPnDXHUwMDA1yLLw0ZEtVJJroUT8uen1k5udY5VLnzXRXFzdf9va6ejNXtJcdTAwMTWISN5cdTAwMDBcdC03XHUwMDFjJ4dcdTAwMDEkMOp24iVOIYtGvNFcdTAwMTT+5jqUXHUwMDFmXFyv5fa+X/LU/u3deW692Pl29X1OXHUwMDFkUjJQXHUwMDE4sixcdTAwMWTSkb5cdTAwMGKYkFxcQrBW5iXYT7/qxOuQ8JRigCBcdTAwMTXnlIKG0iHiYU9qXHUwMDEwlFx1MDAwYjFBXHUwMDE27Y1cdTAwMDbsU4xcdTAwMTIgRi/w80eLkTA8fPQpKlxySM1cdTAwMDH1aIzrpaBs9b5cdTAwMWSet9bWe2epn+1jW7lnJ7yQdC1SXHUwMDFlkTjprlx1MDAxNVO1SLteXHUwMDE3Slx1MDAxYm6QXHTFlydGU8ah5cTAc1JU5yB9WG9uXHUwMDFmVlx1MDAwN/5ajde+dUSnXdqZU3U0LF91Zk26kOxcdTAwMDCoOZKf6Vx1MDAxN71cdTAwMDKig0hcdTAwMTIvNJsqOkYsXHUwMDE04Z+qk1x1MDAwMNV5gYk/WnU4RqpcdTAwMGVcdTAwMDHEWUGr45codPZcdTAwMGWvXHUwMDFi6Hc6WXPon9rDwkb7eDPZsmOYp13dNVk9YVx0fCM6f5hlolBcdTAwMTGCXCKTQmk8Yl9cdTAwMTOSXHUwMDAwpuhPKywzXHUwMDFlc1Vj04ORXGLa01x1MDAxOJ6MfZz+ZHTcXG7BR/q0xFwiXHUwMDA1VFxc2PcpJlx1MDAwM1x1MDAxYpmkcCGlq+Ww8f3Qjl83379l6rtcdTAwMTfqqr2pdLfWbq4nXHUwMDFimK6aXGaBOdplXHUwMDE2UYcqXHUwMDFlldJcdTAwMWWlxXRYg5VcdTAwMTbeVvFooGS5P1x0zH9ENVmnWMzk8T6T7V3ft3WuslNax/R8XHUwMDBlytBcdTAwMWTgi4qLyLxcdTAwMWRlVEhoXHUwMDAzwDmzc1D11ItOuIPiWntcdTAwMTKtq8IgymMqVE2mUHiMoXtcdTAwMDe5TXzjXHUwMDEwcmQxmSTAWyvoNCBcZlx1MDAwNNK2hFx1MDAxNJMtyD/FLybbarX6Sysme4G6w1x1MDAwNirclqVcdTAwMTeTXHUwMDA1XHUwMDA3JENRKSkkiacxvk5cdTAwMWSfyduLTnFXZHbSm5mN3Lp/1j9KejFcdTAwMTnnnPyJtGjMw2zl6GtcdTAwMWVqySxFpXJPOHF8c43nSlaUTdelx6Gajla63vJ57fREXFyKrl/cuD1fWHVccmk2W5gwzTZsMnp2k9hcdTAwMWHcJEL86fxcdTAwMDNd6N521i94tre/0Thkx1x1MDAwN+e902RcdTAwMWI2QrdngFx1MDAxMftCuJZ5XHUwMDE4XHUwMDA2wDyrjEW7iDD47SdT1vKnu9vbP1x1MDAwZnY2+1x1MDAxOdk46lx1MDAxZmyd5W3cirXs9o+LU6wp1chu9ZrXR5XCzdXu4irW3NTg0s1cdTAwMWXMKP90s0RcdTAwMWGZjVx1MDAxZE7TezPpbo9SXHUwMDFjrYiOXHUwMDE5Ob6JXHUwMDA0SFxi4ZGlUlxcPZYwvy2cPlx1MDAwN8timr1lXHUwMDBllr3A+lx1MDAxZj1YJmT0xKlcIoiilvGHsEV3/fJ252fZ7MHZcVVV8VStdZOvcKRvJOVcdTAwMTiWsGFEalI4So0oXHUwMDAzs1x1MDAwYnjA7TdcdTAwMTe4/PptrWvWXHUwMDBlb1tcdTAwMWIoikff7zavXHUwMDBi+3M9wyZcdTAwMTRcYlxc+qhDcGooLEQgmUBleXzUT7/qxFx1MDAwYlx1MDAxMXpcdTAwMTZdMkdJ/6RcdTAwMTBcdTAwMTlcdTAwMTJcIvc4r3vY9+3FmqsqRK/AY3KF6Fx1MDAwNXL+cCGy0c8zUL5H2TaK+KtcdTAwMDGcsfNO6qJvMsfVtZ3Uxam6zlx1MDAwNzLQhCqR8lx1MDAxNFx1MDAwNzR0qUJPKJFcdTAwMDTwXGZl92RcdTAwMWQ1uHj5VKJcdTAwMTlKxLLp0rbMs61Byp5cdTAwMWOcXHUwMDE2JWtcdTAwMTVcdTAwMDbzjX9LYiZcdTAwMTbE/1KUKLpCXHUwMDA2NWHBTVx1MDAwYsVcdTAwMDb99IteXHUwMDAxIZJuXHUwMDEwmaDjVlx1MDAxYVx1MDAxOFx1MDAxN1wiKZTHkFx1MDAwMGdcZqi3V2uuqlx1MDAxMP1WXHUwMDE50Vx1MDAwYtz80UIkZ1RQk1WSXHUwMDA20cZcdTAwMTeiXvNO+er7fjs/yFxmWje8t7X5bTfpQiQ98r9aXHUwMDAyQ+VcbmdHXGb0ULQmSIiY0kYwMz5h9Y8uWjtcdTAwMTPrezc3crN4spMvpG4vq7uVg0pcdTAwMDIlR+jIKVeyIHS/jOXxNWf6Va+A5lxi4chcdTAwMWXc8LWRIatl1EJcdTAwMTG+4qozz9pcdTAwMDDJVZ1cdTAwMTeI+KNVh7xeVFSCMlxiwOJPNG01N3c7W6ksbMP5oLnm64vNb1FcdTAwMDNcdTAwMTJcdNFcdTAwMWNQzFOMXHUwMDFiTVx1MDAxZW+yXGJCXHUwMDAwp9RILWpJnZUuWVx1MDAxYq7Ywd6rZE1gdMlcdTAwMWFcdTAwMThB3oDJ+KVcdTAwMDCbOyeH7eyp3fyW/nFweqK+i3w/asWOxJRcdTAwMDLQZXqKYFx1MDAwMUrgtFx1MDAxMWLOPFSWhJPDm59r/u0qXHUwMDAxdvs3p8Wj01x1MDAxY5rMnbzoraW3blpnXHUwMDBim7WUYFx1MDAwMlx1MDAxNfhLrVx1MDAwNFx1MDAxMJFcdTAwMTUxnJO2U0Ns/JVrjptnxVRd18o1xnJZOEinipuphFx1MDAxM7QxRNBaI2pwSz2HaopcdTAwMDE8gr5WXGa5q/JXn/Mks1KFa57ezd3traV+yPtTuGfFjctKMW4hwPFdeW3ztsVcdTAwMGYzuWo5n8FBrnmbW1xcIYCVbOkpyMxVXHUwMDAztNKcXHUwMDFiXHUwMDFi/2nN6b2Z8Fx1MDAxNITMtSeFVdZcdTAwMTlszoJcdTAwMTHzUFx04HJw7Vx1MDAxYziGXHUwMDE20/hMQVY1XHUwMDA1eYH2PzpcdTAwMDVcdTAwMTFcInLgXHUwMDBiKE92i1xixlx1MDAxZvdK93aE/H7q31xcXHUwMDFmd1x1MDAwYmv3ftpcdTAwMWVVo8aiXHUwMDEzI3HaXHUwMDE1u1lheFjEXHUwMDFlSlx1MDAwMcAjkUelXHUwMDFmpkTDXHL7lLjg/b+6YoNGZuOuVsZu75JlZfv84GLO0TD3Z9lSXHUwMDA0PPpcdTAwMDFcdTAwMDRAVFx1MDAwNIb4XHUwMDBmIEy/6MQrkXElaVx1MDAxYbmRjPAvxlFvSIhIXHUwMDE4mISQ7/vUoVXVoVx1MDAxN7j5w3XIRJaJUrRcbuJnmGNcdJutVvU8ZVtHfnX/JNPM1lxuulHNJl+IXGJtQqCSROdhIZIgPWRMuVx1MDAxZFjQ2OC04KdcdTAwMTJNKtH29fqV8Sv5Sr3Fi6Zlv12y9LzzMu+iRFx1MDAxOF1cdTAwMDAjlVuxiFx1MDAwNYpcdTAwMDVegv30q15cdTAwMDEpcvVWyEBcdTAwMWJcdTAwMTGWXCIp0DPcjXxcdG5ccshFbIjxKUVcdTAwMWYrRS+w80dLkdSRUoSKvJLSc9Sk2SNbNlBLr/XyV6XizV1e/cgmfCVcdTAwMDEw6GlGcWCUskqHXHUwMDAzUitPUpxQsqRcdTAwMDAkLi8jWqlCgFx1MDAxMzi9zJTklrxs71c3dnZz+6U7lUDBXHUwMDEx0etkSPdwXHUwMDFiSX98vZl+0SugN1x1MDAxY6zbeskyJoIrRD/UXHUwMDAx6EXi+1NwXHUwMDEyIDgvkPBHXHUwMDBiXHUwMDBlRG+AqrVGXHUwMDAwruOnPt30UZrt7O83hTpJy8Za5uw+9zPZguOqnIGSXG5BXHUwMDA0NNysYywgXHUwMDA1uMdcdTAwMTJcdTAwMDJjcG9cbsjVL1x1MDAwM1x1MDAxMDqQXGIvtVxmQEfv+oRCgWVyjpVr9lmrv5nL+oY31/X6xe4mXjRcdTAwMTO/qix6buEowp52i5mFXHUwMDE28qNcdTAwMWXwrFvv+nFcdTAwMTW0t42SRVx1MDAwMVO6labdhlNuq1x1MDAwM/c1OFx0T5Bu116SXHUwMDExPVxcx1x1MDAwMyZcdTAwMGJcdTAwMDDeXHUwMDE3o1x1MDAxMoRcXPoq5DNYU7n1XHUwMDAyOJujiv460ylmXHUwMDFh9udm+eLozFx1MDAxN1x1MDAwNzlTgGbSnVxmXHUwMDExJlx1MDAwMVx1MDAxMNFwI1x1MDAxMVhgfYSHrSS14yxOyTORhjViedtGk5eR5ExcdTAwMTRcYlx1MDAwYqFAmbGUXGY1yY1wwLKt+9KLUlx1MDAxNuSV5linpnbrlyO8Ut2/6M9wSv1WO8omjTV3Yjma8VMuYjWa6MdjXCLHxITbbFCjiV8nUDXr+3fn1fIgla1s5Vx1MDAxYq3LuzxL/OxcZoWFR1xcL4XVhlEqMl58hpx0h6SXQKeMU55/dFxc58hcdTAwMTZcdTAwMTbyV2dcXF9cdTAwMWNuX36HUjrTb3/G9Vx1MDAwN8b1QzdPM5Qztlx1MDAxMVVuu2vkMv7I2mxCT2pkg/bc9ijKKKVItsdcdTAwMWQloPBcdTAwMDSS41x1MDAxNNxcIuCbxrpnXHUwMDA2tmWeNW5FVMtcdTAwMTRcdTAwMTnHXHUwMDEx31x1MDAwNi0loqA2MOOe0MNcdEvJiXtcdTAwMTSToOdcdTAwMTmMmFxcXGZcdTAwMTFcdTAwMWWPzFhcZnFG5Zog9tOv2my01y90++u1ZrnWrIw3zH849XZcZl84jOjSwLWSeaApYXLzXHUwMDE0bjhSju6s65hC2/G2Ry6c2lxmlj9MsE1cXLrfLL/cpNn11IEmpZhnXHUwMDE0pztkhit6XHUwMDE4hWKiTZR5o9vcW5I9dONQdqJN9UKvv9FqNGp96vmDVq3ZXHUwMDBm9/CwK9dcXNBX/cJcdTAwMDTh0DVcdTAwMDVfXHUwMDBis0PbfeM4yY9++zKKnuF/nn//959T352KwrX7mUD06Nv+XGL+Ozet2ehHq1xmUr9Solx1MDAxY3/4ZracJZXVXHUwMDE0o1x1MDAwNJQpUIbwL9n4g1x1MDAxY1xi3ONusFx1MDAwMJlcdTAwMTaISyyWt8JDbYZLW4xtXGY4mj/gXHUwMDFlXHUwMDEzlFxmW0roXHRcdTAwMWWT01x0XHUwMDFjKWWiTJnNY15cdTAwMTbOasxcYnzVY78xWW22KVx1MDAxZadcdTAwMTBiNNdXXGJARKLZiO6fOcR40m1cdTAwMDIurGTuwVFjZvPa+HWsXHUwMDE0u0yH1/C1XHRkzckukfM1NnIyUlx0NG5p8/iL1lx1MDAxY1X29zZQ9vzaXHUwMDFl+Nnvt53Dw42bpJNLSqJLd4hBrEIlJIyzXHUwMDBiV+5JXHUwMDFkrVxyMolumONNw3Czn8Ux2uNCXHUwMDE5lNHZ0MScXHK3lFxyKVx1MDAwN4x3SYfesMftn7O+91xyK1x1MDAxMc783iU+2OD2one9/8b0Lf5cdTAwMTRcdTAwMTahaNBo9r7811N29qVX6rbq9f9+35QuRjNcdTAwMTYxpVx1MDAxNemHzIxcdTAwMDc9lFtseq5nzWdcdTAwMDM6oZwljEdGXHUwMDEzrHFcdTAwMWJccihcdTAwMDXjXHUwMDAzOI5HXHUwMDE0XHUwMDEzRltcdTAwMTKS5Y7MkunVJM1cbshccmshg9tCjCa3QHiGh2r+nthcdTAwMGKcnVx1MDAxYptSeG8/5MLY7dm3RD80W1x1MDAxN8ezPNBkcyVKpa1Ukk2meZy6XFxcInPVkbONUFRrZu92PO7OXHUwMDE4Uk5jQIOWXG5IlfREczQhkfwsvW4lXHUwMDEycuVEo1bKg0VcdTAwMDPa/Vx1MDAwNKE8p1x1MDAwMYukM2TRdCY510zMs13BbFx1MDAxZE0onVnjaW5cdGSEXCJcdCZUmsxccvfc7CRcYiWXTGfaeJRmSLe/MFx1MDAxMavk09hMoEfo0I55hzvdXHUwMDA0nkp95LRhnVx1MDAwMVx1MDAwNFx1MDAxN194f05T1Ly5dtpYXHUwMDFhp1x1MDAxMYtI4G6tU1wiXG6plbTTSMRyx2jSXHUwMDBlf8zkMFEsZpu9LO5cdTAwMTjPckKVXHUwMDE0YIxcdTAwMDX6VUzSLHiUhJEyuM50T2/halx1MDAxM1skst3PJKZcdTAwMTdGbjxyXHUwMDEynVTOclxu9vjcNtvLJ5TbXGI4XHUwMDFlKkBKLCWA0eOT6NxQz1x1MDAxM945d0umkl1b3pg8R+5m891CXHUwMDBlklx1MDAwMlLBlEJcdTAwMGbKOSlYJcWhXHUwMDFlrvRcdTAwMTBMdlx1MDAxZshNKtRqrlxc8/d1a47ZyPRcZvdop0ZJXHUwMDE2XHUwMDFjXHUwMDEwfnJrZFx1MDAxOEjFXHTsZlg680rPNvsp33FmQ+ZG+MDtduVOOTko71x1MDAxNlx1MDAxNzFuSS73yFx1MDAwMzVcdHGiUatEbdHAXHUwMDFlvjpcdTAwMDHpObkter3nSNvGnfZqPUel88Fpo8GuQF9tnaX3i93SbvbnbtRcIueJoTaNnuSkXHUwMDE3XHUwMDA0fjfXKMeX70CiNiNcZqM/Llx1MDAwZmVLXHUwMDFjN1NIpt1cdTAwMWHlRuhcZuppS9hMKatcdTAwMTSMzLVcclxc5YruXvNYRvDWcaj4ZVx1MDAwNNVWt3bvxoueRn7ed/xpxumXWl5cdTAwMTCoXHUwMDFiXHUwMDBll1xyKcvA7VpcdTAwMTY73mfDIaHxzqX2iF6F21x1MDAwN5TcitLjhdSopVx1MDAwN4xcdTAwMWOkojcuN+Ct52qDrZ3Ykz7gZKwnnVx1MDAwMNqIkSdtXHUwMDE5XHSHlfOE/6In4ubedWpeKzNbVr6EJuKoP9zjXCJ0f8mowlx1MDAxNN/gJvNcdTAwMWZ2d32li5m5a9uYi2Hu4Vx1MDAxOMVcdTAwMDRnROtcdTAwMDKnjIOBoFx1MDAxYqxcdTAwMWTna5fIMTZ5L1bKxURh2v2kJuBcdTAwMWNlYv54PMHXQrt92Ce4Pd9cdTAwMGXCd638yPWjq/x6XfNv1qfUi19cZn+cXGZccvvTUZI/jIFff/z6f3dfuVAifQ== HeaderTweetTweetTweetTweetFooterTweetTweetTweetTweetTweetTweetTweetTweetFixedFixedColumns (vertical scroll)horizontal scrollIt's rough, but it's all we need.
Try in Textual-web "},{"location":"how-to/design-a-layout/#tip-2-work-outside-in","title":"Tip 2. Work outside in","text":"Like a sculpture with a block of marble, it is best to work from the outside towards the center. If your design has fixed elements (like a header, footer, or sidebar), start with those first.
In our sketch we have a header and footer. Since these are the outermost widgets, we will begin by adding them.
Tip
Textual has builtin Header and Footer widgets which you could use in a real application.
The following example defines an app, a screen, and our header and footer widgets. Since we're starting from scratch and don't have any functionality for our widgets, we are going to use the Placeholder widget to help us visualize our design.
In a real app, we would replace these placeholders with more useful content.
layout01.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder): # (1)!\npass\nclass Footer(Placeholder): # (2)!\npass\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\") # (3)!\nyield Footer(id=\"Footer\") # (4)!\nclass LayoutApp(App):\ndef on_mount(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header
"},{"location":"how-to/design-a-layout/#tip-3-apply-docks","title":"Tip 3. Apply docks","text":"This app works, but the header and footer don't behave as expected. We want both of these widgets to be fixed to an edge of the screen and limited in height. In Textual this is known as docking which you can apply with the dock rule.
We will dock the header and footer to the top and bottom edges of the screen respectively, by adding a little CSS to the widget classes:
layout02.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Footer
The DEFAULT_CSS
class variable is used to set CSS directly in Python code. We could define these in an external CSS file, but writing the CSS inline like this can be convenient if it isn't too complex.
When you dock a widget, it reduces the available area for other widgets. This means that Textual will automatically compensate for the 6 additional lines reserved for the header and footer.
"},{"location":"how-to/design-a-layout/#tip-4-use-fr-units-for-flexible-things","title":"Tip 4. Use FR Units for flexible things","text":"After we've added the header and footer, we want the remaining space to be used for the main interface, which will contain the columns in the sketch. This area is flexible (will change according to the size of the terminal), so how do we ensure that it takes up precisely the space needed?
The simplest way is to use fr units. By setting both the width and height to 1fr
, we are telling Textual to divide the space equally amongst the remaining widgets. There is only a single widget, so that widget will fill all of the remaining space.
Let's make that change.
layout03.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass ColumnsContainer(Placeholder):\nDEFAULT_CSS = \"\"\"\n ColumnsContainer {\n width: 1fr;\n height: 1fr;\n border: solid white;\n }\n \"\"\" # (1)!\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nyield ColumnsContainer(id=\"Columns\")\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
1fr
. We also add a border just to illustrate the dimensions better.LayoutApp #Header \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502#Columns\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 #Footer
As you can see, the central Columns area will resize with the terminal window.
"},{"location":"how-to/design-a-layout/#tip-5-use-containers","title":"Tip 5. Use containers","text":"Before we add content to the Columns area, we have an opportunity to simplify. Rather than extend Placeholder
for our ColumnsContainer
widget, we can use one of the builtin containers. A container is simply a widget designed to contain other widgets. Containers are styled with fr
units to fill the remaining space so we won't need to add any more CSS.
Let's replace the ColumnsContainer
class in the previous example with a HorizontalScroll
container, which also adds an automatic horizontal scrollbar.
from textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nyield HorizontalScroll() # (1)!\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Footer
The container will appear as blank space until we add some widgets to it.
Let's add the columns to the HorizontalScroll
. A column is itself a container which will have a vertical scrollbar, so we will define our Column
by subclassing VerticalScroll
. In a real app, these columns will likely be added dynamically from some kind of configuration, but let's add 4 to visualize the layout.
We will also define a Tweet
placeholder and add a few to each column.
from textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll, VerticalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass Tweet(Placeholder):\npass\nclass Column(VerticalScroll):\ndef compose(self) -> ComposeResult:\nfor tweet_no in range(1, 20):\nyield Tweet(id=f\"Tweet{tweet_no}\")\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nwith HorizontalScroll():\nyield Column()\nyield Column()\nyield Column()\nyield Column()\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Tweet1#Tweet1#Tweet1#Tweet1 #Footer
Note from the output that each Column
takes a quarter of the screen width. This happens because Column
extends a container which has a width of 1fr
.
It makes more sense for a column in a Twitter / Mastodon client to use a fixed width. Let's set the width of the columns to 32.
We also want to reduce the height of each \"tweet\". In the real app, you might set the height to \"auto\" so it fits the content, but lets set it to 5 lines for now.
Here's the final example and a reminder of the sketch.
layout06.pyOutputSketchfrom textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll, VerticalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass Tweet(Placeholder):\nDEFAULT_CSS = \"\"\"\n Tweet {\n height: 5;\n width: 1fr;\n border: tall $background;\n }\n \"\"\"\nclass Column(VerticalScroll):\nDEFAULT_CSS = \"\"\"\n Column {\n height: 1fr;\n width: 32;\n margin: 0 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor tweet_no in range(1, 20):\nyield Tweet(id=f\"Tweet{tweet_no}\")\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nwith HorizontalScroll():\nyield Column()\nyield Column()\nyield Column()\nyield Column()\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet1\u258e\u258a#Tweet1\u258e\u258a#Tweet1\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u2583\u2583\u258a\u258e\u2583\u2583\u258a\u258e \u258a#Tweet2\u258e\u258a#Tweet2\u258e\u258a#Tweet2\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet3\u258e\u258a#Tweet3\u258e\u258a#Tweet3\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet4\u258e\u258a#Tweet4\u258e\u258a#Tweet4\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet5\u258e\u258a#Tweet5\u258e\u258a#Tweet5\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258c #Footer
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daW9cdTAwMWK7kv1+f0WQ92VcdTAwMDa46ktcdTAwMTbJXCJ5gcHAi1x1MDAxY1mOl8iKt8GDoaUtyda+eHvIf5+ivKjVUsstW7JbuTaQxGktzWafOudcdTAwMTRZTf7njy9fvvbv2v7Xv7989W9LhXqt3C3cfP3TXHUwMDFkv/a7vVqrSS/B8P+91qBbXHUwMDFhvrPa77d7f//1V6PQvfL77Xqh5HvXtd6gUO/1XHUwMDA35VrLK7VcdTAwMWF/1fp+o/e/7u+9QsP/n3arUe53vdFJUn651m91XHUwMDFmzuXX/Ybf7Pfo2/+P/v/ly3+Gf1x1MDAwN1rX9Uv9QrNS94dcdTAwMWZcdTAwMTi+NGogZ0aFXHUwMDBm77Waw9ZyLoxGxVxyPL+j1tukXHUwMDEz9v0yvXxBjfZHr7hDX1x1MDAwZnL9dT+3q/ay56WbXCK2djHdXHUwMDFkjM57UavXXHUwMDBm+3f1YbtK3Vavl6pcdTAwMTb6peroXHUwMDFkvX63deVcdTAwMWbXyv3qU/dcdTAwMDWOP3+216KuXHUwMDE4farbXHUwMDFhVKpNv+d6gT9cdTAwMWZttVx1MDAwYqVa/254lez56ENX/P1ldOSW/pdC4Vx0LY1VqLW1KtAr7lx1MDAwYkAoXHUwMDBmhFx1MDAxNKhcdTAwMDVarahfQi3baNXpjlDL/sWGP6O2XHUwMDE1XHUwMDBipatcbjWwWX5+T79baPbahS7dt9H7blx1MDAxZa9ZSemhcCdjinM+akfVr1WqfXqHQOZcdTAwMTnOrJQohLs9OGqMP7w1XHUwMDFjjNCopVx1MDAxNs+vuCa0t8tDnPw72HHN8mPHNVx1MDAwN/X6qNXuhXRcdTAwMDBbo89cZtrlwlx1MDAwM1x1MDAwMjhcdTAwMWFcdIpJ1Fxio1x1MDAwZanXmlfhr6u3Slcj0Fxmj/7681x1MDAxNXDVKlx1MDAxYa1WcVx1MDAwNIlcdTAwMThcdTAwMWKsuVLn5367sL63s3+Lza2fXHUwMDA3nZ3tTlx1MDAwNFhDgPtImFx1MDAxYcGVtIpxJjhBIIRT6Vx1MDAxOaUlgcJcdTAwMDJKi8vEqfBcdTAwMThHXHUwMDA1dD6urTaTQFx1MDAwNeMhMiFccjJAQaCeXHUwMDAwKt0zJYGuZvVw6tfrtXZvKkpxXHUwMDA2p1x1MDAxYZCcI1cmNkzbP86OS+lstssvc9f5+3TqrtG8fVxyTPn7wVRxz1xuhdTxQljOQIbolDjOMCMl11x1MDAwZcqgw1x1MDAxMTRcdTAwMTdM/3VRUKBgXHUwMDEyolxceJJcdTAwMDEoi0D/SGPkJEY5eFxuKVKISFx1MDAxOVxiXHUwMDAxUocxyql9ilNcdTAwMWL5b1x1MDAwNVItbFx1MDAxNEi1QXfNXHUwMDEwX/dP1jrpu8ZB7qC4tnXT2upvna3jTcIxXG7KY9TdyLnhlsBcdTAwMDEhiFxuT1x1MDAxObRkXG4kUZhV8o1cdTAwMTAtMqaWXHUwMDA1Uc1cdTAwMTXpgeC/XHUwMDE5Qq2IQijnjJSFS81iQ7TeSbdTV1x1MDAxNexs1upXWLrrb53UXHUwMDBlk1xyUVx1MDAxMljgXHUwMDAyQWjBmWRWh1x1MDAxMFxujmQl3Vx1MDAxNVxyxLVv41BcdTAwMGVFY3BZXHUwMDAwpcsgqkcuV1DoZ+dPKlx1MDAxMqPu1iihXHUwMDAzb3hcdKLr37ZcbrsldbLV2V1vnt3+OL6pXHUwMDFjNJJcctFcdTAwMTTlXCKk4lZcdTAwMTFFcnBJyThGXHUwMDExXHQ+hFxmTTTKJcOlYFRJ5oym4Vx1MDAxNCnaQiBOXHUwMDAyOZMnNaekytphO0CFIUquTJGLNYrNXHUwMDAx0SewjOBcIlx1MDAxZY/8ikbu82dGn1x1MDAwZcCt79+O3HZcdTAwMDBcdTAwMWPd9mG1cbB9tj/YK+VzlbXWgVx1MDAxOdx9fX7fr8ffosJCXGImjTLWLiosxtpcdTAwMTmMXGJcdTAwMTNcdTAwMTVcdTAwMTCczs5dvqBiR8T0i55cdTAwMWVcdTAwMTHVQqk66PpcdTAwMWZcdTAwMWZcdTAwMTNcXCO5W+JKQIK+YTLE21x1MDAxYTxcdTAwMDdB4nNcdTAwMTlcbpnXXHUwMDA0XHUwMDA1xVx1MDAxZbuYYn4p8oBcdTAwMTPaXHUwMDE5p1x1MDAwZSeRmJafTVx1MDAwNoFcdTAwMDWuyZ3PXHUwMDE1XHUwMDA0r+Pp11x1MDAwMHJ041vN/mHt3vU9sLGjW4VGrX43du+GSKWuyviFst/9OnZ8rV6rONR+LVF7gy9cdTAwMTJ2+7VSof78hkatXFxcdTAwMGVqQIlOVqg1/e52XHUwMDFj7m51a5Vas1DPR7SFrt3PPOupXHUwMDE3uDPFQs93r1x1MDAwZVx1MDAwM+j1Qlx1MDAwNVx1MDAxOJmTkns0QltcdTAwMTN/5GRtc6O+M2Bddn/XuK/YfL5/lc68LizfcexEXHUwMDE5j5NRkoxsfUhcdTAwMDPcN1xihp7ipGQ8XHUwMDE4XHUwMDE3XHUwMDBiXHUwMDFlNeGKuEFScvEkiKPrXHUwMDFmhSX5KbCGXHUwMDFisnXKqCA/PLkp0IZy6sDgz1x1MDAxMt2UJt5cdTAwMGVkP0t1U1x1MDAwMapcZouHM/zW5fmxUdqpbbT2sr3NXGZ2N1x1MDAwZTK57av9evpV43vvOXBcIjy6t+hcdTAwMTJwMis6hFBuKCdcdTAwMTX0gmIhYLzGTalCoXgh5TTPzz1GSIUpg89Selx1MDAxMjSzj01cYjbxXHUwMDExnUJcdTAwMWKlmVx0XGZcYr5cdTAwMTWcrzNM2Xbnvt45Ov/x48f5Sf3i6vgwfXxcdTAwMWMwTH9O/9qHXHUwMDBm51t7lUIu5Z9095pH9lx1MDAxMCv1y+1cdTAwMWbjZ3k6f6Hbbd3MYcQ4XHUwMDAwhZRQi4qoSCNcdTAwMTY5vkMmjGhcdTAwMGWtiZ+aTO/MhFx1MDAxYrFcdTAwMTRcdTAwMTGpIOtDySBlXHUwMDA1aMLhJJin6bBloFxirnYh2cm0eJLW46QsmnJBy6ZGVcDEPI2MazJvxr5cdTAwMDPFXHUwMDBiSpjIi1x1MDAwNYj1dUaM49jRXHUwMDE5Rix/4/v9JfmwXHUwMDE3SD/sw0JNiWfD5Fx1MDAxYmyYgPDR5/FcdTAwMDLBXGYjb1x1MDAxMt+GVXnaNrYvdzqp6lx1MDAwNuxfyrPTn0onX+BcZnJcdTAwMDGGh+1cdTAwMTdcdTAwMWGPRIVcdTAwMDJEaGCaiVCbPsUteOu3mtlccr9/36hWz5pq/7qU7drcSXxcdTAwMTFcdTAwMDLFKVx1MDAwMdNq+aNcdTAwMDFcdTAwMDFWXGKrkLbcXHUwMDE4w+dcdTAwMTggm37VK6BCJD+KSWHleHYxxL1hXHUwMDFlJSXyzXnHylx1MDAwYpCYXHUwMDAzjMlcdTAwMTWgXHUwMDE3SPnDXHUwMDA1yLLw0ZEtVJJroUT8uen1k5udY5VLnzXRXFzdf9va6ejNXtJcdTAwMTWISN5cdTAwMDBcdC03XHUwMDFjJ4dcdTAwMDEkMOp24iVOIYtGvNFcdTAwMTT+5jqUXHUwMDFmXFyv5fa+X/LU/u3deW692Pl29X1OXHUwMDFkUjJQXHUwMDE4sixcdTAwMWTSkb5cdTAwMGKYkFxcQrBW5iXYT7/qxOuQ8JRigCBcdTAwMTXnlIKG0iHiYU9qXHUwMDEwlFx1MDAwYjFBXHUwMDE27Y1cdTAwMDbsU4xcdTAwMTIgRi/w80eLkTA8fPQpKlxySM1cdTAwMDH1aIzrpaBs9b5cdTAwMWSet9bWe2epn+1jW7lnJ7yQdC1SXHUwMDFlkTjprlx1MDAxNVO1SLteXHUwMDE3Slx1MDAxYm6QXHTFlydGU8ah5cTAc1JU5yB9WG9uXHUwMDFmVlx1MDAwN/5ajde+dUSnXdqZU3U0LF91Zk26kOxcdTAwMDCoOZKf6Vx1MDAxN71cdTAwMDKig0hcdTAwMTIvNJsqOkYsXHUwMDE04Z+qk1x1MDAwMNV5gYk/WnU4RqpcdTAwMGVcdTAwMDHEWUGr45codPZcdTAwMGWvXHUwMDFi6Hc6WXPon9rDwkb7eDPZsmOYp13dNVk9YVx0fCM6f5hlolBcdTAwMTGCXCKTQmk8Yl9cdTAwMTOSXHUwMDAwpuhPKywzXHUwMDFlc1Vj04ORXGLa01x1MDAxOJ6MfZz+ZHTcXG7BR/q0xFwiXHUwMDA1VFxc2PcpJlx1MDAwM1x1MDAxYpmkcCGlq+Ww8f3Qjl83379l6rtcdTAwMTfqqr2pdLfWbq4nXHUwMDFimK6aXGaBOdplXHUwMDE2UYcqXHUwMDFlldJcdTAwMWWlxXRYg5VcdTAwMTbeVvFooGS5P1x0zH9ENVmnWMzk8T6T7V3ft3WuslNax/R8XHUwMDBlytBcdTAwMWTgi4qLyLxcdTAwMWRlVEhoXHUwMDAzwDmzc1D11ItOuIPiWntcdTAwMTKtq8IgymMqVE2mUHiMoXtcdTAwMDe5TXzjXHUwMDEwcmQxmSTAWyvoNCBcZlx1MDAwNNK2hFx1MDAxNJMtyD/FLybbarX6Sysme4G6w1x1MDAwNirclqVcdTAwMTeTXHUwMDA1XHUwMDA3JENRKSkkiacxvk5cdTAwMWSfyduLTnFXZHbSm5mN3Lp/1j9KejFcdTAwMTnnnPyJtGjMw2zl6GtcdTAwMWVqySxFpXJPOHF8c43nSlaUTdelx6Gajla63vJ57fREXFyKrl/cuD1fWHVccmk2W5gwzTZsMnp2k9hcdTAwMWHcJEL86fxcdTAwMDNd6N521i94tre/0Thkx1x1MDAwN+e902RcdTAwMWI2QrdngFx1MDAxMftCuJZ5XHUwMDE4XHUwMDA2wDyrjEW7iDD47SdT1vKnu9vbP1x1MDAwZnY2+1x1MDAxOdk46lx1MDAxZmyd5W3cirXs9o+LU6wp1chu9ZrXR5XCzdXu4irW3NTg0s1cdTAwMWXMKP90s0RcdTAwMWGZjVx1MDAxZE7TezPpbo9SXHUwMDFjrYiOXHUwMDE5Ob6JXHUwMDA0SFxi4ZGlUlxcPZYwvy2cPlx1MDAwN8timr1lXHUwMDBllr3A+lx1MDAxZj1YJmT0xKlcIoiilvGHsEV3/fJ252fZ7MHZcVVV8VStdZOvcKRvJOVcdTAwMTiWsGFEalI4So0oXHUwMDAzs1x1MDAwYnjA7TdcdTAwMTe4/PptrWvWXHUwMDBlb1tcdTAwMWIoikff7zavXHUwMDBi+3M9wyZcdTAwMTRcYlxc+qhDcGooLEQgmUBleXzUT7/qxFx1MDAwYlx1MDAxMXpcdTAwMTZdMkdJ/6RcdTAwMTBcdTAwMTlcdTAwMTJcIvc4r3vY9+3FmqsqRK/AY3KF6Fx1MDAwNXL+cCGy0c8zUL5H2TaK+KtcdTAwMDGcsfNO6qJvMsfVtZ3Uxam6zlx1MDAwNzLQhCqR8lx1MDAxNFx1MDAwNzR0qUJPKJFcdTAwMDTwXGZl92RcdTAwMWQ1uHj5VKJcdTAwMTlKxLLp0rbMs61Byp5cdTAwMWOcXHUwMDE2JWtcdTAwMTVcdTAwMDbzjX9LYiZcdTAwMTbE/1KUKLpCXHUwMDA2NWHBTVx1MDAwYsVcdTAwMDb99IteXHUwMDAxIZJuXHUwMDEwmaDjVlx1MDAxYVx1MDAxOFx1MDAxN1wiKZTHkFx1MDAwMGdcZqi3V2uuqlx1MDAxMP1WXHUwMDE50Vx1MDAwYtz80UIkZ1RQk1WSXHUwMDA20cZcdTAwMTeiXvNO+er7fjs/yFxmWje8t7X5bTfpQiQ98r9aXHUwMDAyQ+VcbmdHXGb0ULQmSIiY0kYwMz5h9Y8uWjtcdTAwMTPrezc3crN4spMvpG4vq7uVg0pcdTAwMDIlR+jIKVeyIHS/jOXxNWf6Va+A5lxi4chcdTAwMWXc8LWRIatl1EJcdTAwMTG+4qozz9pcdTAwMDDJVZ1cdTAwMTeI+KNVh7xeVFSCMlxiwOJPNG01N3c7W6ksbMP5oLnm64vNb1FcdTAwMDNcdTAwMTJcdNFcdTAwMWNQzFOMXHUwMDFiTVx1MDAxZW+yXGJCXHUwMDAwp9RILWpJnZUuWVx1MDAxYq7Ywd6rZE1gdMlcdTAwMWFcdTAwMThB3oDJ+KVcdTAwMDCbOyeH7eyp3fyW/nFweqK+i3w/asWOxJRcdTAwMDLQZXqKYFx1MDAwMUrgtFx1MDAxMWLOPFSWhJPDm59r/u0qXHUwMDAxdvs3p8Wj01x1MDAxY5rMnbzoraW3blpnXHUwMDBim7WUYFx1MDAwMlx1MDAxNfhLrVx1MDAwNFx1MDAxMJFcdTAwMTUxnJO2U0Ns/JVrjptnxVRd18o1xnJZOEinipuphFx1MDAxM7QxRNBaI2pwSz2HaopcdTAwMDE8gr5WXGa5q/JXn/Mks1KFa57ezd3traV+yPtTuGfFjctKMW4hwPFdeW3ztsVcdTAwMGYzuWo5n8FBrnmbW1xcIYCVbOkpyMxVXHUwMDAztNKcXHUwMDFiXHUwMDFi/2nN6b2Z8Fx1MDAxNITMtSeFVdZcdTAwMTlszoJcdTAwMTHzUFx04HJw7Vx1MDAxYziGXHUwMDE20/hMQVY1XHUwMDA1eYH2PzpcdTAwMDVcdTAwMTFcInLgXHUwMDBiKE92i1xixlx1MDAxZvdK93aE/H7q31xcXHUwMDFmd1x1MDAwYmv3ftpcdTAwMWVVo8aiXHUwMDEzI3HaXHUwMDE1u1lheFjEXHUwMDFlSlx1MDAwMcAjkUelXHUwMDFmpkTDXHL7lLjg/b+6YoNGZuOuVsZu75JlZfv84GLO0TD3Z9lSXHUwMDA0PPpcdTAwMDFcdTAwMDRAVFx1MDAwNIb4XHUwMDBmIEy/6MQrkXElaVx1MDAxYbmRjPAvxlFvSIhIXHUwMDE4mISQ7/vUoVXVoVx1MDAxN7j5w3XIRJaJUrRcbuJnmGNcdJutVvU8ZVtHfnX/JNPM1lxuulHNJl+IXGJtQqCSROdhIZIgPWRMuVx1MDAxZFjQ2OC04KdcdTAwMTJNKtH29fqV8Sv5Sr3Fi6Zlv12y9LzzMu+iRFx1MDAxOF1cdTAwMDAjlVuxiFx1MDAwNYpcdTAwMDVegv30q15cdTAwMDEpcvVWyEBcdTAwMWJcdTAwMTGWXCIp0DPcjXxcdG5ccshFbIjxKUVcdTAwMWYrRS+w80dLkdSRUoSKvJLSc9Sk2SNbNlBLr/XyV6XizV1e/cgmfCVcdTAwMDEw6GlGcWCUskqHXHUwMDAzUitPUpxQsqRcdTAwMDAkLi8jWqlCgFx1MDAxMzi9zJTklrxs71c3dnZz+6U7lUDBXHUwMDEx0etkSPdwXHUwMDFiSX98vZl+0SugN1x1MDAxY6zbeskyJoIrRD/UXHUwMDAx6EXi+1NwXHUwMDEyIDgvkPBHXHUwMDBiXHUwMDBlRG+AqrVGXHUwMDAwruOnPt30UZrt7O83hTpJy8Za5uw+9zPZguOqnIGSXG5BXHUwMDA0NNysYywgXHUwMDA1uMdcdTAwMTJcdTAwMDJjcG9cbsjVL1x1MDAwM1x1MDAxMDqQXGIvtVxmQEfv+oRCgWVyjpVr9lmrv5nL+oY31/X6xe4mXjRcdTAwMTO/qix6buEowp52i5mFXHUwMDE28qNcdTAwMWXwrFvv+nFcdTAwMTW0t42SRVx1MDAwMVO6labdhlNuq1x1MDAwM/c1OFx0T5Bu116SXHUwMDExPVxcx1x1MDAwMyZcdTAwMGJcdTAwMDDeXHUwMDE3o1x1MDAxMoRcXPoq5DNYU7n1XHUwMDAyOJujiv460ylmXHUwMDFh9udm+eLozFx1MDAxN1x1MDAwNzlTgGbSnVxmXHUwMDExJlx1MDAwMVx1MDAxMNFwI1x1MDAxMVhgfYSHrSS14yxOyTORhjViedtGk5eR5ExcdTAwMTRcYlx1MDAwYqFAmbGUXGY1yY1wwLKt+9KLUlx1MDAxNuSV5linpnbrlyO8Ut2/6M9wSv1WO8omjTV3Yjma8VMuYjWa6MdjXCLHxITbbFCjiV8nUDXr+3fn1fIgla1s5Vx1MDAxYq3LuzxL/OxcZoWFR1xcL4XVhlEqMl58hpx0h6SXQKeMU55/dFxc58hcdTAwMTZcdTAwMTbyV2dcXF9cdTAwMWNuX36HUjrTb3/G9Vx1MDAwN8b1QzdPM5Qztlx1MDAxMVVuu2vkMv7I2mxCT2pkg/bc9ijKKKVItsdcdTAwMWQloPBcdTAwMDSS41x1MDAxNNxcIuCbxrpnXHUwMDA2tmWeNW5FVMtcdTAwMTRcdTAwMTnHXHUwMDEx31x1MDAwNi0loqA2MOOe0MNcdEvJiXtcdTAwMTSToOdcdTAwMTmMmFxcXGZcdTAwMTFcdTAwMWWPzFhcZnFG5Zog9tOv2my01y90++u1ZrnWrIw3zH849XZcZl84jOjSwLWSeaApYXLzXHUwMDE0bjhSju6s65hC2/G2Ry6c2lxmlj9MsE1cXLrfLL/cpNn11IEmpZhnXHUwMDE0pztkhit6XHUwMDE4hWKiTZR5o9vcW5I9dONQdqJN9UKvv9FqNGp96vmDVq3ZXHUwMDBm9/CwK9dcXNBX/cJcdTAwMDTh0DVcdTAwMDVfXHUwMDBis0PbfeM4yY9++zKKnuF/nn//959T352KwrX7mUD06Nv+XGL+Ozet2ehHq1xmUr9Solx1MDAxY3/4ZracJZXVXHUwMDE0o1x1MDAwNJQpUIbwL9n4g1x1MDAxY1xi3ONusFx1MDAwMJlcdTAwMTaISyyWt8JDbYZLW4xtXGY4mj/gXHUwMDFlXHUwMDEzlFxmW0roXHRcdTAwMWWT01x0XHUwMDFjKWWiTJnNY15cdTAwMTbOasxcYnzVY78xWW22KVx1MDAxZadcdTAwMTBiNNdXXGJARKLZiO6fOcR40m1cdTAwMDIurGTuwVFjZvPa+HWsXHUwMDE0u0yH1/C1XHRkzckukfM1NnIyUlx0NG5p8/iL1lx1MDAxY1X29zZQ9vzaXHUwMDFl+Nnvt53Dw42bpJNLSqJLd4hBrEIlJIyzXHUwMDBiV+5JXHUwMDFkrVxyMolumONNw3Czn8Ux2uNCXHUwMDE5lNHZ0MScXHK3lFxyKVx1MDAwN4x3SYfesMftn7O+91xyK1x1MDAxMc783iU+2OD2one9/8b0Lf5cdTAwMTRcdTAwMTahaNBo9r7811N29qVX6rbq9f9+35QuRjNcdTAwMTYxpVx1MDAxNemHzIxcdTAwMDc9lFtseq5nzWdcdTAwMDM6oZwljEdGXHUwMDEzrHFcdTAwMWJccihcdTAwMDXjXHUwMDAzOI5HXHUwMDE0XHUwMDEzRltcdTAwMTKS5Y7MkunVJM1cbshccmshg9tCjCa3QHiGh2r+nthcdTAwMGKcnVx1MDAxYptSeG8/5MLY7dm3RD80W1x1MDAxN8ezPNBkcyVKpa1Ukk2meZy6XFxcInPVkbONUFRrZu92PO7OXHUwMDE4Uk5jQIOWXG5IlfREczQhkfwsvW4lXHUwMDEycuVEo1bKg0VcdTAwMDPa/Vx1MDAwNKE8p1x1MDAwMYukM2TRdCY510zMs13BbFx1MDAxZE0onVnjaW5cdGSEXCJcdCZUmsxccvfc7CRcYiWXTGfaeJRmSLe/MFx1MDAxMavk09hMoEfo0I55hzvdXHUwMDA0nkp95LRhnVx1MDAwMVx1MDAwNFx1MDAxN194f05T1Ly5dtpYXHUwMDFhp1x1MDAxMYtI4G6tU1wiXG6plbTTSMRyx2jSXHUwMDBlf8zkMFEsZpu9LO5cdTAwMTjPckKVXHUwMDE0YIxcdTAwMDX6VUzSLHiUhJEyuM50T2/halx1MDAxM1skst3PJKZcdTAwMTdGbjxyXHUwMDEynVTOclxu9vjcNtvLJ5TbXGI4XHUwMDFlKkBKLCWA0eOT6NxQz1x1MDAxM945d0umkl1b3pg8R+5m891CXHUwMDBlklx1MDAwMlLBlEJcdTAwMGbKOSlYJcWhXHUwMDFlrvRcdTAwMTBMdlx1MDAxZshNKtRqrlxc8/d1a47ZyPRcZvdop0ZJXHUwMDE2XHUwMDFjXHUwMDEwfnJrZFx1MDAxOEjFXHTsZlg680rPNvsp33FmQ+ZG+MDtduVOOTko71x1MDAxNlx1MDAxNzFuSS73yFx1MDAwMzVcdHGiUatEbdHAXHUwMDFlvjpcdTAwMDHpObkter3nSNvGnfZqPUel88Fpo8GuQF9tnaX3i93SbvbnbtRcIueJoTaNnuSkXHUwMDE3XHUwMDA0fjfXKMeX70CiNiNcZqM/Llx1MDAwZmVLXHUwMDFjN1NIpt1cdTAwMWHlRuhcZuppS9hMKatcdTAwMTSMzLVcclxc5YruXvNYRvDWcaj4ZVx1MDAwNNVWt3bvxoueRn7ed/xpxumXWl5cdTAwMTCoXHUwMDFiXHUwMDBll1xyKcvA7VpcdTAwMTY73mfDIaHxzqX2iF6F21x1MDAwN5TcitLjhdSopVx1MDAwN4xcdTAwMWOkojcuN+Ct52qDrZ3Ykz7gZKwnnVx1MDAwMNqIkSdtXHUwMDE5XHSHlfOE/6In4ubedWpeKzNbVr6EJuKoP9zjXCJ0f8mowlx1MDAxNN/gJvNcdTAwMWZ2d32li5m5a9uYi2Hu4Vx1MDAxOMVcdTAwMDRnROtcdTAwMDKnjIOBoFx1MDAxYqxcdTAwMWTna5fIMTZ5L1bKxURh2v2kJuBcdTAwMWNlYv54PMHXQrt92Ce4Pd9cdTAwMGXCd638yPWjq/x6XfNv1qfUi19cZn+cXGZccvvTUZI/jIFff/z6f3dfuVAifQ== HeaderTweetTweetTweetTweetFooterTweetTweetTweetTweetTweetTweetTweetTweetFixedFixedColumns (vertical scroll)horizontal scroll
A layout like this is a great starting point. In a real app, you would start replacing each of the placeholders with builtin or custom widgets.
"},{"location":"how-to/design-a-layout/#summary","title":"Summary","text":"Layout is the first thing you will tackle when building a Textual app. The following tips will help you get started.
fr
for flexible space within layouts.If you need further help, we are here to help.
"},{"location":"reference/","title":"Reference","text":"Welcome to the Textual Reference.
CSS Types
CSS Types are the data types that CSS styles accept in their rules.
CSS Types Reference
Events
Events are how Textual communicates with your application.
Events Reference
Styles
All the styles you can use to take your Textual app to the next level.
Styles Reference
Widgets
How to use the many widgets builtin to Textual.
Widgets Reference
A reference to Widget styles.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"styles/align/","title":"Align","text":"The align
style aligns children within a container.
\nalign: <horizontal> <vertical>;\n\nalign-horizontal: <horizontal>;\nalign-vertical: <vertical>;\n
The align
style takes a <horizontal>
followed by a <vertical>
.
You can also set the alignment for each axis individually with align-horizontal
and align-vertical
.
This example contains a simple app with two labels centered on the screen with align: center middle;
:
AlignApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503Vertical\u00a0alignment\u00a0with\u00a0Textual\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503Take\u00a0note,\u00a0browsers.\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Label\nclass AlignApp(App):\ndef compose(self):\nyield Label(\"Vertical alignment with [b]Textual[/]\", classes=\"box\")\nyield Label(\"Take note, browsers.\", classes=\"box\")\napp = AlignApp(css_path=\"align.tcss\")\n
Screen {\nalign: center middle;\n}\n.box {\nwidth: 40;\nheight: 5;\nmargin: 1;\npadding: 1;\nbackground: green;\ncolor: white 90%;\nborder: heavy white;\n}\n
"},{"location":"styles/align/#all-alignments","title":"All alignments","text":"The next example shows a 3 by 3 grid of containers with text labels. Each label has been aligned differently inside its container, and its text shows its align: ...
value.
AlignAllApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502left\u00a0top\u2502\u2502center\u00a0top\u2502\u2502right\u00a0top\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502left\u00a0middle\u2502\u2502center\u00a0middle\u2502\u2502right\u00a0middle\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502left\u00a0bottom\u2502\u2502center\u00a0bottom\u2502\u2502right\u00a0bottom\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass AlignAllApp(App):\n\"\"\"App that illustrates all alignments.\"\"\"\nCSS_PATH = \"align_all.tcss\"\ndef compose(self) -> ComposeResult:\nyield Container(Label(\"left top\"), id=\"left-top\")\nyield Container(Label(\"center top\"), id=\"center-top\")\nyield Container(Label(\"right top\"), id=\"right-top\")\nyield Container(Label(\"left middle\"), id=\"left-middle\")\nyield Container(Label(\"center middle\"), id=\"center-middle\")\nyield Container(Label(\"right middle\"), id=\"right-middle\")\nyield Container(Label(\"left bottom\"), id=\"left-bottom\")\nyield Container(Label(\"center bottom\"), id=\"center-bottom\")\nyield Container(Label(\"right bottom\"), id=\"right-bottom\")\n
#left-top {\n/* align: left top; this is the default value and is implied. */\n}\n#center-top {\nalign: center top;\n}\n#right-top {\nalign: right top;\n}\n#left-middle {\nalign: left middle;\n}\n#center-middle {\nalign: center middle;\n}\n#right-middle {\nalign: right middle;\n}\n#left-bottom {\nalign: left bottom;\n}\n#center-bottom {\nalign: center bottom;\n}\n#right-bottom {\nalign: right bottom;\n}\nScreen {\nlayout: grid;\ngrid-size: 3 3;\ngrid-gutter: 1;\n}\nContainer {\nbackground: $boost;\nborder: solid gray;\nheight: 100%;\n}\nLabel {\nwidth: auto;\nheight: 1;\nbackground: $accent;\n}\n
"},{"location":"styles/align/#css","title":"CSS","text":"/* Align child widgets to the center. */\nalign: center middle;\n/* Align child widget to the top right */\nalign: right top;\n/* Change the horizontal alignment of the children of a widget */\nalign-horizontal: right;\n/* Change the vertical alignment of the children of a widget */\nalign-vertical: middle;\n
"},{"location":"styles/align/#python","title":"Python","text":"# Align child widgets to the center\nwidget.styles.align = (\"center\", \"middle\")\n# Align child widgets to the top right\nwidget.styles.align = (\"right\", \"top\")\n# Change the horizontal alignment of the children of a widget\nwidget.styles.align_horizontal = \"right\"\n# Change the vertical alignment of the children of a widget\nwidget.styles.align_vertical = \"middle\"\n
"},{"location":"styles/align/#see-also","title":"See also","text":"content-align
to set the alignment of content inside a widget.text-align
to set the alignment of text in a widget.The background
style sets the background color of a widget.
\nbackground: <color> [<percentage>];\n
The background
style requires a <color>
optionally followed by <percentage>
to specify the color's opacity (clamped between 0%
and 100%
).
This example creates three widgets and applies a different background to each.
Outputbackground.pybackground.tcssBackgroundApp Widget\u00a01 Widget\u00a02 Widget\u00a03
from textual.app import App\nfrom textual.widgets import Label\nclass BackgroundApp(App):\ndef compose(self):\nyield Label(\"Widget 1\", id=\"static1\")\nyield Label(\"Widget 2\", id=\"static2\")\nyield Label(\"Widget 3\", id=\"static3\")\napp = BackgroundApp(css_path=\"background.tcss\")\n
Label {\nwidth: 100%;\nheight: 1fr;\ncontent-align: center middle;\ncolor: white;\n}\n#static1 {\nbackground: red;\n}\n#static2 {\nbackground: rgb(0, 255, 0);\n}\n#static3 {\nbackground: hsl(240, 100%, 50%);\n}\n
"},{"location":"styles/background/#different-opacity-settings","title":"Different opacity settings","text":"The next example creates ten widgets laid out side by side to show the effect of setting different percentages for the background color's opacity.
Outputbackground_transparency.pybackground_transparency.tcssBackgroundTransparencyApp 10%20%30%40%50%60%70%80%90%100%
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass BackgroundTransparencyApp(App):\n\"\"\"Simple app to exemplify different transparency settings.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"10%\", id=\"t10\")\nyield Static(\"20%\", id=\"t20\")\nyield Static(\"30%\", id=\"t30\")\nyield Static(\"40%\", id=\"t40\")\nyield Static(\"50%\", id=\"t50\")\nyield Static(\"60%\", id=\"t60\")\nyield Static(\"70%\", id=\"t70\")\nyield Static(\"80%\", id=\"t80\")\nyield Static(\"90%\", id=\"t90\")\nyield Static(\"100%\", id=\"t100\")\napp = BackgroundTransparencyApp(css_path=\"background_transparency.tcss\")\n
#t10 {\nbackground: red 10%;\n}\n#t20 {\nbackground: red 20%;\n}\n#t30 {\nbackground: red 30%;\n}\n#t40 {\nbackground: red 40%;\n}\n#t50 {\nbackground: red 50%;\n}\n#t60 {\nbackground: red 60%;\n}\n#t70 {\nbackground: red 70%;\n}\n#t80 {\nbackground: red 80%;\n}\n#t90 {\nbackground: red 90%;\n}\n#t100 {\nbackground: red 100%;\n}\nScreen {\nlayout: horizontal;\n}\nStatic {\nheight: 100%;\nwidth: 1fr;\ncontent-align: center middle;\n}\n
"},{"location":"styles/background/#css","title":"CSS","text":"/* Blue background */\nbackground: blue;\n/* 20% red background */\nbackground: red 20%;\n/* RGB color */\nbackground: rgb(100, 120, 200);\n/* HSL color */\nbackground: hsl(290, 70%, 80%);\n
"},{"location":"styles/background/#python","title":"Python","text":"You can use the same syntax as CSS, or explicitly set a Color
object for finer-grained control.
# Set blue background\nwidget.styles.background = \"blue\"\n# Set through HSL model\nwidget.styles.background = \"hsl(351,32%,89%)\"\nfrom textual.color import Color\n# Set with a color object by parsing a string\nwidget.styles.background = Color.parse(\"pink\")\nwidget.styles.background = Color.parse(\"#FF00FF\")\n# Set with a color object instantiated directly\nwidget.styles.background = Color(120, 60, 100)\n
"},{"location":"styles/background/#see-also","title":"See also","text":"color
to set the color of text in a widget.The border
style enables the drawing of a box around a widget.
A border style may also be applied to individual edges with border-top
, border-right
, border-bottom
, and border-left
.
Note
border
and outline
cannot coexist in the same edge of a widget.
\nborder: [<border>] [<color>] [<percentage>];\n\nborder-top: [<border>] [<color>] [<percentage>];\nborder-right: [<border>] [<color> [<percentage>]];\nborder-bottom: [<border>] [<color> [<percentage>]];\nborder-left: [<border>] [<color> [<percentage>]];\n
In CSS, the border is set with a border style and a color. Both are optional. An optional percentage may be added to blend the border with the background color.
In Python, the border is set with a tuple of border style and a color.
"},{"location":"styles/border/#border-command","title":"Border command","text":"The textual
CLI has a subcommand which will let you explore the various border types interactively:
textual borders\n
Alternatively, you can see the examples below.
"},{"location":"styles/border/#examples","title":"Examples","text":""},{"location":"styles/border/#basic-usage","title":"Basic usage","text":"This examples shows three widgets with different border styles.
Outputborder.pyborder.tcssBorderApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0border\u00a0is\u00a0solid\u00a0red\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0border\u00a0is\u00a0dashed\u00a0green\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258aMy\u00a0border\u00a0is\u00a0tall\u00a0blue\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderApp(App):\ndef compose(self):\nyield Label(\"My border is solid red\", id=\"label1\")\nyield Label(\"My border is dashed green\", id=\"label2\")\nyield Label(\"My border is tall blue\", id=\"label3\")\napp = BorderApp(css_path=\"border.tcss\")\n
#label1 {\nbackground: red 20%;\ncolor: red;\nborder: solid red;\n}\n#label2 {\nbackground: green 20%;\ncolor: green;\nborder: dashed green;\n}\n#label3 {\nbackground: blue 20%;\ncolor: blue;\nborder: tall blue;\n}\nScreen {\nbackground: white;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border/#all-border-types","title":"All border types","text":"The next example shows a grid with all the available border types.
Outputborder_all.pyborder_all.tcssAllBordersApp +------------------+\u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 |ascii|blank\u254fdashed\u254f +------------------+\u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2551double\u2551\u2503heavy\u2503hidden/none \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2597\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2596\u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c hkey\u2590inner\u258c\u258couter\u2590 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u259d\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2598\u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u2502round\u2502\u2502solid\u2502\u258atall\u258e \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588\u258f\u2595\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2588thick\u2588\u258fvkey\u2595\u258ewide\u258a \u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588\u258f\u2595\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass AllBordersApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"ascii\", id=\"ascii\"),\nLabel(\"blank\", id=\"blank\"),\nLabel(\"dashed\", id=\"dashed\"),\nLabel(\"double\", id=\"double\"),\nLabel(\"heavy\", id=\"heavy\"),\nLabel(\"hidden/none\", id=\"hidden\"),\nLabel(\"hkey\", id=\"hkey\"),\nLabel(\"inner\", id=\"inner\"),\nLabel(\"outer\", id=\"outer\"),\nLabel(\"round\", id=\"round\"),\nLabel(\"solid\", id=\"solid\"),\nLabel(\"tall\", id=\"tall\"),\nLabel(\"thick\", id=\"thick\"),\nLabel(\"vkey\", id=\"vkey\"),\nLabel(\"wide\", id=\"wide\"),\n)\napp = AllBordersApp(css_path=\"border_all.tcss\")\n
#ascii {\nborder: ascii $accent;\n}\n#blank {\nborder: blank $accent;\n}\n#dashed {\nborder: dashed $accent;\n}\n#double {\nborder: double $accent;\n}\n#heavy {\nborder: heavy $accent;\n}\n#hidden {\nborder: hidden $accent;\n}\n#hkey {\nborder: hkey $accent;\n}\n#inner {\nborder: inner $accent;\n}\n#outer {\nborder: outer $accent;\n}\n#round {\nborder: round $accent;\n}\n#solid {\nborder: solid $accent;\n}\n#tall {\nborder: tall $accent;\n}\n#thick {\nborder: thick $accent;\n}\n#vkey {\nborder: vkey $accent;\n}\n#wide {\nborder: wide $accent;\n}\nGrid {\ngrid-size: 3 5;\nalign: center middle;\ngrid-gutter: 1 2;\n}\nLabel {\nwidth: 20;\nheight: 3;\ncontent-align: center middle;\n}\n
"},{"location":"styles/border/#borders-and-outlines","title":"Borders and outlines","text":"The next example makes the difference between border
and outline
clearer by having three labels side-by-side. They contain the same text, have the same width and height, and are styled exactly the same up to their border
and outline
styles.
This example also shows that a widget cannot contain both a border
and an outline
:
OutlineBorderApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502ear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path\u2502 \u2502here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502I\u00a0must\u00a0not\u00a0fear.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineBorderApp(App):\ndef compose(self):\nyield Label(TEXT, classes=\"outline\")\nyield Label(TEXT, classes=\"border\")\nyield Label(TEXT, classes=\"outline border\")\napp = OutlineBorderApp(css_path=\"outline_vs_border.tcss\")\n
Label {\nheight: 8;\n}\n.outline {\noutline: $error round;\n}\n.border {\nborder: $success heavy;\n}\n
"},{"location":"styles/border/#css","title":"CSS","text":"/* Set a heavy white border */\nborder: heavy white;\n/* Set a red border on the left */\nborder-left: outer red;\n/* Set a rounded orange border, 50% opacity. */\nborder: round orange 50%;\n
"},{"location":"styles/border/#python","title":"Python","text":"# Set a heavy white border\nwidget.styles.border = (\"heavy\", \"white\")\n# Set a red border on the left\nwidget.styles.border_left = (\"outer\", \"red\")\n
"},{"location":"styles/border/#see-also","title":"See also","text":"box-sizing
to specify how to account for the border in a widget's dimensions.outline
to add an outline around the content of a widget.border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-align
style sets the horizontal alignment for the border subtitle.
\nborder-subtitle-align: <horizontal>;\n
The border-subtitle-align
style takes a <horizontal>
that determines where the border subtitle is aligned along the top edge of the border. This means that the border corners are always visible.
The default alignment is right
.
This example shows three labels, each with a different border subtitle alignment:
Outputborder_subtitle_align.pyborder_subtitle_align.tcssBorderSubtitleAlignApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0subtitle\u00a0is\u00a0on\u00a0the\u00a0left.\u2502 \u2502\u2502 \u2514\u2500\u00a0<\u00a0Left\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0subtitle\u00a0is\u00a0centered\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u00a0Centered!\u00a0\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258aMy\u00a0subtitle\u00a0is\u00a0on\u00a0the\u00a0right\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u00a0Right\u00a0>\u00a0\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderSubtitleAlignApp(App):\ndef compose(self):\nlbl = Label(\"My subtitle is on the left.\", id=\"label1\")\nlbl.border_subtitle = \"< Left\"\nyield lbl\nlbl = Label(\"My subtitle is centered\", id=\"label2\")\nlbl.border_subtitle = \"Centered!\"\nyield lbl\nlbl = Label(\"My subtitle is on the right\", id=\"label3\")\nlbl.border_subtitle = \"Right >\"\nyield lbl\napp = BorderSubtitleAlignApp(css_path=\"border_subtitle_align.tcss\")\n
#label1 {\nborder: solid $secondary;\nborder-subtitle-align: left;\n}\n#label2 {\nborder: dashed $secondary;\nborder-subtitle-align: center;\n}\n#label3 {\nborder: tall $secondary;\nborder-subtitle-align: right;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border_subtitle_align/#complete-usage-reference","title":"Complete usage reference","text":"This example shows all border title and subtitle alignments, together with some examples of how (sub)titles can have custom markup. Open the code tabs to see the details of the code examples.
Outputborder_sub_title_align_all.pyborder_sub_title_align_all.tcssBorderSubTitleAlignAll \u258fBorder\u00a0title\u2595\u256d\u2500Lef\u2026\u2500\u256e\u2581\u2581\u2581\u2581\u2581Left\u2581\u2581\u2581\u2581\u2581 \u258fThis\u00a0is\u00a0the\u00a0story\u00a0of\u2595\u2502a\u00a0Python\u2502\u258edeveloper\u00a0that\u258a \u258fBorder\u00a0subtitle\u2595\u2570\u2500Cen\u2026\u2500\u256f\u2594\u2594\u2594\u2594\u2594@@@\u2594\u2594\u2594\u2594\u2594\u2594 +--------------+\u2500Title\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 |had\u00a0to\u00a0fill\u00a0up|nine\u00a0labelsand\u00a0ended\u00a0up\u00a0redoing\u00a0it +-Left-------+\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500Subtitle\u2500 \u2500Title,\u00a0but\u00a0really\u00a0looo\u2026\u2500 \u2500Title,\u00a0but\u00a0r\u2026\u2500\u2500Title,\u00a0but\u00a0reall\u2026\u2500 because\u00a0the\u00a0first\u00a0tryhad\u00a0some\u00a0labelsthat\u00a0were\u00a0too\u00a0long. \u2500Subtitle,\u00a0bu\u2026\u2500\u2500Subtitle,\u00a0but\u00a0re\u2026\u2500 \u2500Subtitle,\u00a0but\u00a0really\u00a0l\u2026\u2500
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Label\ndef make_label_container( # (11)!\ntext: str, id: str, border_title: str, border_subtitle: str\n) -> Container:\nlbl = Label(text, id=id)\nlbl.border_title = border_title\nlbl.border_subtitle = border_subtitle\nreturn Container(lbl)\nclass BorderSubTitleAlignAll(App[None]):\ndef compose(self):\nwith Grid():\nyield make_label_container( # (1)!\n\"This is the story of\",\n\"lbl1\",\n\"[b]Border [i]title[/i][/]\",\n\"[u][r]Border[/r] subtitle[/]\",\n)\nyield make_label_container( # (2)!\n\"a Python\",\n\"lbl2\",\n\"[b red]Left, but it's loooooooooooong\",\n\"[reverse]Center, but it's loooooooooooong\",\n)\nyield make_label_container( # (3)!\n\"developer that\",\n\"lbl3\",\n\"[b i on purple]Left[/]\",\n\"[r u white on black]@@@[/]\",\n)\nyield make_label_container(\n\"had to fill up\",\n\"lbl4\",\n\"\", # (4)!\n\"[link=https://textual.textualize.io]Left[/]\", # (5)!\n)\nyield make_label_container( # (6)!\n\"nine labels\", \"lbl5\", \"Title\", \"Subtitle\"\n)\nyield make_label_container( # (7)!\n\"and ended up redoing it\",\n\"lbl6\",\n\"Title\",\n\"Subtitle\",\n)\nyield make_label_container( # (8)!\n\"because the first try\",\n\"lbl7\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (9)!\n\"had some labels\",\n\"lbl8\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (10)!\n\"that were too long.\",\n\"lbl9\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\napp = BorderSubTitleAlignAll(css_path=\"border_sub_title_align_all.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Grid {\ngrid-size: 3 3;\nalign: center middle;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\nalign: center middle;\n}\n#lbl1 { /* (1)! */\nborder: vkey $secondary;\n}\n#lbl2 { /* (2)! */\nborder: round $secondary;\nborder-title-align: right;\nborder-subtitle-align: right;\n}\n#lbl3 {\nborder: wide $secondary;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl4 {\nborder: ascii $success;\nborder-title-align: center; /* (3)! */\nborder-subtitle-align: left;\n}\n#lbl5 { /* (4)! */\n/* No border = no (sub)title. */\nborder: none $success;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl6 { /* (5)! */\nborder-top: solid $success;\nborder-bottom: solid $success;\n}\n#lbl7 { /* (6)! */\nborder-top: solid $error;\nborder-bottom: solid $error;\npadding: 1 2;\nborder-subtitle-align: left;\n}\n#lbl8 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl9 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: right;\n}\n
left
and the default alignment for the subtitle is right
.none
/hidden
, the (sub)title is not shown.border-subtitle-align: left;\nborder-subtitle-align: center;\nborder-subtitle-align: right;\n
"},{"location":"styles/border_subtitle_align/#python","title":"Python","text":"widget.styles.border_subtitle_align = \"left\"\nwidget.styles.border_subtitle_align = \"center\"\nwidget.styles.border_subtitle_align = \"right\"\n
"},{"location":"styles/border_subtitle_align/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-background
style sets the background color of the border_subtitle.
\nborder-subtitle-background: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_subtitle_background/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_background/#css","title":"CSS","text":"border-subtitle-background: blue;\n
"},{"location":"styles/border_subtitle_background/#python","title":"Python","text":"widget.styles.border_subtitle_background = \"blue\"\n
"},{"location":"styles/border_subtitle_background/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-color
style sets the color of the border_subtitle.
\nborder-subtitle-color: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_subtitle_color/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_color/#css","title":"CSS","text":"border-subtitle-color: red;\n
"},{"location":"styles/border_subtitle_color/#python","title":"Python","text":"widget.styles.border_subtitle_color = \"red\"\n
"},{"location":"styles/border_subtitle_color/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-style
style sets the text style of the border_subtitle.
\nborder-subtitle-style: <text-style>;\n"},{"location":"styles/border_subtitle_style/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_style/#css","title":"CSS","text":"border-subtitle-style: bold underline;\n
"},{"location":"styles/border_subtitle_style/#python","title":"Python","text":"widget.styles.border_subtitle_style = \"bold underline\"\n
"},{"location":"styles/border_subtitle_style/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-align
style sets the horizontal alignment for the border title.
\nborder-title-align: <horizontal>;\n
The border-title-align
style takes a <horizontal>
that determines where the border title is aligned along the top edge of the border. This means that the border corners are always visible.
The default alignment is left
.
This example shows three labels, each with a different border title alignment:
Outputborder_title_align.pyborder_title_align.tcssBorderTitleAlignApp \u250c\u2500\u00a0<\u00a0Left\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0title\u00a0is\u00a0on\u00a0the\u00a0left.\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u00a0Centered!\u00a0\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0title\u00a0is\u00a0centered\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u00a0Right\u00a0>\u00a0\u2594\u258e \u258a\u258e \u258aMy\u00a0title\u00a0is\u00a0on\u00a0the\u00a0right\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderTitleAlignApp(App):\ndef compose(self):\nlbl = Label(\"My title is on the left.\", id=\"label1\")\nlbl.border_title = \"< Left\"\nyield lbl\nlbl = Label(\"My title is centered\", id=\"label2\")\nlbl.border_title = \"Centered!\"\nyield lbl\nlbl = Label(\"My title is on the right\", id=\"label3\")\nlbl.border_title = \"Right >\"\nyield lbl\napp = BorderTitleAlignApp(css_path=\"border_title_align.tcss\")\n
#label1 {\nborder: solid $secondary;\nborder-title-align: left;\n}\n#label2 {\nborder: dashed $secondary;\nborder-title-align: center;\n}\n#label3 {\nborder: tall $secondary;\nborder-title-align: right;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border_title_align/#complete-usage-reference","title":"Complete usage reference","text":"This example shows all border title and subtitle alignments, together with some examples of how (sub)titles can have custom markup. Open the code tabs to see the details of the code examples.
Outputborder_sub_title_align_all.pyborder_sub_title_align_all.tcssBorderSubTitleAlignAll \u258fBorder\u00a0title\u2595\u256d\u2500Lef\u2026\u2500\u256e\u2581\u2581\u2581\u2581\u2581Left\u2581\u2581\u2581\u2581\u2581 \u258fThis\u00a0is\u00a0the\u00a0story\u00a0of\u2595\u2502a\u00a0Python\u2502\u258edeveloper\u00a0that\u258a \u258fBorder\u00a0subtitle\u2595\u2570\u2500Cen\u2026\u2500\u256f\u2594\u2594\u2594\u2594\u2594@@@\u2594\u2594\u2594\u2594\u2594\u2594 +--------------+\u2500Title\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 |had\u00a0to\u00a0fill\u00a0up|nine\u00a0labelsand\u00a0ended\u00a0up\u00a0redoing\u00a0it +-Left-------+\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500Subtitle\u2500 \u2500Title,\u00a0but\u00a0really\u00a0looo\u2026\u2500 \u2500Title,\u00a0but\u00a0r\u2026\u2500\u2500Title,\u00a0but\u00a0reall\u2026\u2500 because\u00a0the\u00a0first\u00a0tryhad\u00a0some\u00a0labelsthat\u00a0were\u00a0too\u00a0long. \u2500Subtitle,\u00a0bu\u2026\u2500\u2500Subtitle,\u00a0but\u00a0re\u2026\u2500 \u2500Subtitle,\u00a0but\u00a0really\u00a0l\u2026\u2500
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Label\ndef make_label_container( # (11)!\ntext: str, id: str, border_title: str, border_subtitle: str\n) -> Container:\nlbl = Label(text, id=id)\nlbl.border_title = border_title\nlbl.border_subtitle = border_subtitle\nreturn Container(lbl)\nclass BorderSubTitleAlignAll(App[None]):\ndef compose(self):\nwith Grid():\nyield make_label_container( # (1)!\n\"This is the story of\",\n\"lbl1\",\n\"[b]Border [i]title[/i][/]\",\n\"[u][r]Border[/r] subtitle[/]\",\n)\nyield make_label_container( # (2)!\n\"a Python\",\n\"lbl2\",\n\"[b red]Left, but it's loooooooooooong\",\n\"[reverse]Center, but it's loooooooooooong\",\n)\nyield make_label_container( # (3)!\n\"developer that\",\n\"lbl3\",\n\"[b i on purple]Left[/]\",\n\"[r u white on black]@@@[/]\",\n)\nyield make_label_container(\n\"had to fill up\",\n\"lbl4\",\n\"\", # (4)!\n\"[link=https://textual.textualize.io]Left[/]\", # (5)!\n)\nyield make_label_container( # (6)!\n\"nine labels\", \"lbl5\", \"Title\", \"Subtitle\"\n)\nyield make_label_container( # (7)!\n\"and ended up redoing it\",\n\"lbl6\",\n\"Title\",\n\"Subtitle\",\n)\nyield make_label_container( # (8)!\n\"because the first try\",\n\"lbl7\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (9)!\n\"had some labels\",\n\"lbl8\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (10)!\n\"that were too long.\",\n\"lbl9\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\napp = BorderSubTitleAlignAll(css_path=\"border_sub_title_align_all.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Grid {\ngrid-size: 3 3;\nalign: center middle;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\nalign: center middle;\n}\n#lbl1 { /* (1)! */\nborder: vkey $secondary;\n}\n#lbl2 { /* (2)! */\nborder: round $secondary;\nborder-title-align: right;\nborder-subtitle-align: right;\n}\n#lbl3 {\nborder: wide $secondary;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl4 {\nborder: ascii $success;\nborder-title-align: center; /* (3)! */\nborder-subtitle-align: left;\n}\n#lbl5 { /* (4)! */\n/* No border = no (sub)title. */\nborder: none $success;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl6 { /* (5)! */\nborder-top: solid $success;\nborder-bottom: solid $success;\n}\n#lbl7 { /* (6)! */\nborder-top: solid $error;\nborder-bottom: solid $error;\npadding: 1 2;\nborder-subtitle-align: left;\n}\n#lbl8 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl9 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: right;\n}\n
left
and the default alignment for the subtitle is right
.none
/hidden
, the (sub)title is not shown.border-title-align: left;\nborder-title-align: center;\nborder-title-align: right;\n
"},{"location":"styles/border_title_align/#python","title":"Python","text":"widget.styles.border_title_align = \"left\"\nwidget.styles.border_title_align = \"center\"\nwidget.styles.border_title_align = \"right\"\n
"},{"location":"styles/border_title_align/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-background
style sets the background color of the border_title.
\nborder-title-background: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_title_background/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_background/#css","title":"CSS","text":"border-title-background: blue;\n
"},{"location":"styles/border_title_background/#python","title":"Python","text":"widget.styles.border_title_background = \"blue\"\n
"},{"location":"styles/border_title_background/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-color
style sets the color of the border_title.
\nborder-title-color: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_title_color/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_color/#css","title":"CSS","text":"border-title-color: red;\n
"},{"location":"styles/border_title_color/#python","title":"Python","text":"widget.styles.border_title_color = \"red\"\n
"},{"location":"styles/border_title_color/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-style
style sets the text style of the border_title.
\nborder-title-style: <text-style>;\n"},{"location":"styles/border_title_style/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_style/#css","title":"CSS","text":"border-title-style: bold underline;\n
"},{"location":"styles/border_title_style/#python","title":"Python","text":"widget.styles.border_title_style = \"bold underline\"\n
"},{"location":"styles/border_title_style/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The box-sizing
style determines how the width and height of a widget are calculated.
box-sizing: border-box | content-box;\n
"},{"location":"styles/box_sizing/#values","title":"Values","text":"Value Description border-box
(default) Padding and border are included in the width and height. If you add padding and/or border the widget will not change in size, but you will have less space for content. content-box
Padding and border will increase the size of the widget, leaving the content area unaffected."},{"location":"styles/box_sizing/#example","title":"Example","text":"Both widgets in this example have the same height (5). The top widget has box-sizing: border-box
which means that padding and border reduce the space for content. The bottom widget has box-sizing: content-box
which increases the size of the widget to compensate for padding and border.
BoxSizingApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eI'm\u00a0using\u00a0border-box!\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eI'm\u00a0using\u00a0content-box!\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Static\nclass BoxSizingApp(App):\ndef compose(self):\nyield Static(\"I'm using border-box!\", id=\"static1\")\nyield Static(\"I'm using content-box!\", id=\"static2\")\napp = BoxSizingApp(css_path=\"box_sizing.tcss\")\n
#static1 {\nbox-sizing: border-box;\n}\n#static2 {\nbox-sizing: content-box;\n}\nScreen {\nbackground: white;\ncolor: black;\n}\nApp Static {\nbackground: blue 20%;\nheight: 5;\nmargin: 2;\npadding: 1;\nborder: wide black;\n}\n
"},{"location":"styles/box_sizing/#css","title":"CSS","text":"/* Set box sizing to border-box (default) */\nbox-sizing: border-box;\n/* Set box sizing to content-box */\nbox-sizing: content-box;\n
"},{"location":"styles/box_sizing/#python","title":"Python","text":"# Set box sizing to border-box (default)\nwidget.box_sizing = \"border-box\"\n# Set box sizing to content-box\nwidget.box_sizing = \"content-box\"\n
"},{"location":"styles/box_sizing/#see-also","title":"See also","text":"border
to add a border around a widget.padding
to add spacing around the content of a widget.The color
style sets the text color of a widget.
\ncolor: (<color> | auto) [<percentage>];\n
The color
style requires a <color>
followed by an optional <percentage>
to specify the color's opacity.
You can also use the special value of \"auto\"
in place of a color. This tells Textual to automatically select either white or black text for best contrast against the background.
This example sets a different text color for each of three different widgets.
Outputcolor.pycolor.tcssColorApp I'm\u00a0red! I'm\u00a0rgb(0,\u00a0255,\u00a00)! I'm\u00a0hsl(240,\u00a0100%,\u00a050%)!
from textual.app import App\nfrom textual.widgets import Label\nclass ColorApp(App):\ndef compose(self):\nyield Label(\"I'm red!\", id=\"label1\")\nyield Label(\"I'm rgb(0, 255, 0)!\", id=\"label2\")\nyield Label(\"I'm hsl(240, 100%, 50%)!\", id=\"label3\")\napp = ColorApp(css_path=\"color.tcss\")\n
Label {\nheight: 1fr;\ncontent-align: center middle;\nwidth: 100%;\n}\n#label1 {\ncolor: red;\n}\n#label2 {\ncolor: rgb(0, 255, 0);\n}\n#label3 {\ncolor: hsl(240, 100%, 50%);\n}\n
"},{"location":"styles/color/#auto","title":"Auto","text":"The next example shows how auto
chooses between a lighter or a darker text color to increase the contrast and improve readability.
ColorApp The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog!
from textual.app import App\nfrom textual.widgets import Label\nclass ColorApp(App):\ndef compose(self):\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl1\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl2\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl3\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl4\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl5\")\napp = ColorApp(css_path=\"color_auto.tcss\")\n
Label {\ncolor: auto 80%;\ncontent-align: center middle;\nheight: 1fr;\nwidth: 100%;\n}\n#lbl1 {\nbackground: red 80%;\n}\n#lbl2 {\nbackground: yellow 80%;\n}\n#lbl3 {\nbackground: blue 80%;\n}\n#lbl4 {\nbackground: pink 80%;\n}\n#lbl5 {\nbackground: green 80%;\n}\n
"},{"location":"styles/color/#css","title":"CSS","text":"/* Blue text */\ncolor: blue;\n/* 20% red text */\ncolor: red 20%;\n/* RGB color */\ncolor: rgb(100, 120, 200);\n/* Automatically choose color with suitable contrast for readability */\ncolor: auto;\n
"},{"location":"styles/color/#python","title":"Python","text":"You can use the same syntax as CSS, or explicitly set a Color
object.
# Set blue text\nwidget.styles.color = \"blue\"\nfrom textual.color import Color\n# Set with a color object\nwidget.styles.color = Color.parse(\"pink\")\n
"},{"location":"styles/color/#see-also","title":"See also","text":"background
to set the background color in a widget.The content-align
style aligns content inside a widget.
\ncontent-align: <horizontal> <vertical>;\n\ncontent-align-horizontal: <horizontal>;\ncontent-align-vertical: <vertical>;\n
The content-align
style takes a <horizontal>
followed by a <vertical>
.
You can specify the alignment of content on both the horizontal and vertical axes at the same time, or on each of the axis separately. To specify content alignment on a single axis, use the respective style and type:
content-align-horizontal
takes a <horizontal>
and does alignment along the horizontal axis; andcontent-align-vertical
takes a <vertical>
and does alignment along the vertical axis.This first example shows three labels stacked vertically, each with different content alignments.
Outputcontent_align.pycontent_align.tcssContentAlignApp With\u00a0content-align\u00a0you\u00a0can... ...Easily\u00a0align\u00a0content... ...Horizontally\u00a0and\u00a0vertically!
from textual.app import App\nfrom textual.widgets import Label\nclass ContentAlignApp(App):\ndef compose(self):\nyield Label(\"With [i]content-align[/] you can...\", id=\"box1\")\nyield Label(\"...[b]Easily align content[/]...\", id=\"box2\")\nyield Label(\"...Horizontally [i]and[/] vertically!\", id=\"box3\")\napp = ContentAlignApp(css_path=\"content_align.tcss\")\n
#box1 {\ncontent-align: left top;\nbackground: red;\n}\n#box2 {\ncontent-align-horizontal: center;\ncontent-align-vertical: middle;\nbackground: green;\n}\n#box3 {\ncontent-align: right bottom;\nbackground: blue;\n}\nLabel {\nwidth: 100%;\nheight: 1fr;\npadding: 1;\ncolor: white;\n}\n
"},{"location":"styles/content_align/#all-content-alignments","title":"All content alignments","text":"The next example shows a 3 by 3 grid of labels. Each label has its text aligned differently.
Outputcontent_align_all.pycontent_align_all.tcssAllContentAlignApp left\u00a0topcenter\u00a0topright\u00a0top left\u00a0middlecenter\u00a0middleright\u00a0middle left\u00a0bottomcenter\u00a0bottomright\u00a0bottom
from textual.app import App\nfrom textual.widgets import Label\nclass AllContentAlignApp(App):\ndef compose(self):\nyield Label(\"left top\", id=\"left-top\")\nyield Label(\"center top\", id=\"center-top\")\nyield Label(\"right top\", id=\"right-top\")\nyield Label(\"left middle\", id=\"left-middle\")\nyield Label(\"center middle\", id=\"center-middle\")\nyield Label(\"right middle\", id=\"right-middle\")\nyield Label(\"left bottom\", id=\"left-bottom\")\nyield Label(\"center bottom\", id=\"center-bottom\")\nyield Label(\"right bottom\", id=\"right-bottom\")\napp = AllContentAlignApp(css_path=\"content_align_all.tcss\")\n
#left-top {\n/* content-align: left top; this is the default implied value. */\n}\n#center-top {\ncontent-align: center top;\n}\n#right-top {\ncontent-align: right top;\n}\n#left-middle {\ncontent-align: left middle;\n}\n#center-middle {\ncontent-align: center middle;\n}\n#right-middle {\ncontent-align: right middle;\n}\n#left-bottom {\ncontent-align: left bottom;\n}\n#center-bottom {\ncontent-align: center bottom;\n}\n#right-bottom {\ncontent-align: right bottom;\n}\nScreen {\nlayout: grid;\ngrid-size: 3 3;\ngrid-gutter: 1;\n}\nLabel {\nwidth: 100%;\nheight: 100%;\nbackground: $primary;\n}\n
"},{"location":"styles/content_align/#css","title":"CSS","text":"/* Align content in the very center of a widget */\ncontent-align: center middle;\n/* Align content at the top right of a widget */\ncontent-align: right top;\n/* Change the horizontal alignment of the content of a widget */\ncontent-align-horizontal: right;\n/* Change the vertical alignment of the content of a widget */\ncontent-align-vertical: middle;\n
"},{"location":"styles/content_align/#python","title":"Python","text":"# Align content in the very center of a widget\nwidget.styles.content_align = (\"center\", \"middle\")\n# Align content at the top right of a widget\nwidget.styles.content_align = (\"right\", \"top\")\n# Change the horizontal alignment of the content of a widget\nwidget.styles.content_align_horizontal = \"right\"\n# Change the vertical alignment of the content of a widget\nwidget.styles.content_align_vertical = \"middle\"\n
"},{"location":"styles/content_align/#see-also","title":"See also","text":"align
to set the alignment of children widgets inside a container.text-align
to set the alignment of text in a widget.The display
style defines whether a widget is displayed or not.
display: block | none;\n
"},{"location":"styles/display/#values","title":"Values","text":"Value Description block
(default) Display the widget as normal. none
The widget is not displayed and space will no longer be reserved for it."},{"location":"styles/display/#example","title":"Example","text":"Note that the second widget is hidden by adding the \"remove\"
class which sets the display style to none
.
DisplayApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a01\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a03\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Static\nclass DisplayApp(App):\ndef compose(self):\nyield Static(\"Widget 1\")\nyield Static(\"Widget 2\", classes=\"remove\")\nyield Static(\"Widget 3\")\napp = DisplayApp(css_path=\"display.tcss\")\n
Screen {\nbackground: green;\n}\nStatic {\nheight: 5;\nbackground: white;\ncolor: blue;\nborder: heavy blue;\n}\nStatic.remove {\ndisplay: none;\n}\n
"},{"location":"styles/display/#css","title":"CSS","text":"/* Widget is shown */\ndisplay: block;\n/* Widget is not shown */\ndisplay: none;\n
"},{"location":"styles/display/#python","title":"Python","text":"# Hide the widget\nself.styles.display = \"none\"\n# Show the widget again\nself.styles.display = \"block\"\n
There is also a shortcut to show / hide a widget. The display
property on Widget
may be set to True
or False
to show or hide the widget.
# Hide the widget\nwidget.display = False\n# Show the widget\nwidget.display = True\n
"},{"location":"styles/display/#see-also","title":"See also","text":"visibility
to specify whether a widget is visible or not.The dock
style is used to fix a widget to the edge of a container (which may be the entire terminal window).
dock: bottom | left | right | top;\n
The option chosen determines the edge to which the widget is docked.
"},{"location":"styles/dock/#examples","title":"Examples","text":""},{"location":"styles/dock/#basic-usage","title":"Basic usage","text":"The example below shows a left
docked sidebar. Notice that even though the content is scrolled, the sidebar remains fixed.
DockLayoutExample SidebarDocking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0\u2587\u2587 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout1_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
"},{"location":"styles/dock/#advanced-usage","title":"Advanced usage","text":"The second example shows how one can use full-width or full-height containers to dock labels to the edges of a larger container. The labels will remain in that position (docked) even if the container they are in scrolls horizontally and/or vertically.
Outputdock_all.pydock_all.tcssDockAllApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502top\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502leftright\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502bottom\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass DockAllApp(App):\ndef compose(self):\nyield Container(\nContainer(Label(\"left\"), id=\"left\"),\nContainer(Label(\"top\"), id=\"top\"),\nContainer(Label(\"right\"), id=\"right\"),\nContainer(Label(\"bottom\"), id=\"bottom\"),\nid=\"big_container\",\n)\napp = DockAllApp(css_path=\"dock_all.tcss\")\n
#left {\ndock: left;\nheight: 100%;\nwidth: auto;\nalign-vertical: middle;\n}\n#top {\ndock: top;\nheight: auto;\nwidth: 100%;\nalign-horizontal: center;\n}\n#right {\ndock: right;\nheight: 100%;\nwidth: auto;\nalign-vertical: middle;\n}\n#bottom {\ndock: bottom;\nheight: auto;\nwidth: 100%;\nalign-horizontal: center;\n}\nScreen {\nalign: center middle;\n}\n#big_container {\nwidth: 75%;\nheight: 75%;\nborder: round white;\n}\n
"},{"location":"styles/dock/#css","title":"CSS","text":"dock: bottom; /* Docks on the bottom edge of the parent container. */\ndock: left; /* Docks on the left edge of the parent container. */\ndock: right; /* Docks on the right edge of the parent container. */\ndock: top; /* Docks on the top edge of the parent container. */\n
"},{"location":"styles/dock/#python","title":"Python","text":"widget.styles.dock = \"bottom\" # Dock bottom.\nwidget.styles.dock = \"left\" # Dock left.\nwidget.styles.dock = \"right\" # Dock right.\nwidget.styles.dock = \"top\" # Dock top.\n
"},{"location":"styles/dock/#see-also","title":"See also","text":"The height
style sets a widget's height.
\nheight: <scalar>;\n
The height
style needs a <scalar>
to determine the vertical length of the widget. By default, it sets the height of the content area, but if box-sizing
is set to border-box
it sets the height of the border area.
This examples creates a widget with a height of 50% of the screen.
Outputheight.pyheight.tcssHeightApp Widget
from textual.app import App\nfrom textual.widget import Widget\nclass HeightApp(App):\ndef compose(self):\nyield Widget()\napp = HeightApp(css_path=\"height.tcss\")\n
Screen > Widget {\nbackground: green;\nheight: 50%;\ncolor: white;\n}\n
"},{"location":"styles/height/#all-height-formats","title":"All height formats","text":"The next example creates a series of wide widgets with heights set with different units. Open the CSS file tab to see the comments that explain how each height is computed. (The output includes a vertical ruler on the right to make it easier to check the height of each widget.)
Outputheight_comparison.pyheight_comparison.tcssHeightComparisonApp #cells\u00b7 \u00b7 \u00b7 #percent\u00b7 \u2022 \u00b7 #w\u00b7 \u00b7 \u00b7 \u2022 #h\u00b7 \u00b7 \u00b7 \u00b7 #vw\u2022 \u00b7 \u00b7 \u00b7 #vh\u00b7 \u2022 #auto\u00b7 #fr1\u00b7 #fr2\u00b7 \u00b7
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Label, Placeholder, Static\nclass Ruler(Static):\ndef compose(self):\nruler_text = \"\u00b7\\n\u00b7\\n\u00b7\\n\u00b7\\n\u2022\\n\" * 100\nyield Label(ruler_text)\nclass HeightComparisonApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(id=\"cells\"), # (1)!\nPlaceholder(id=\"percent\"),\nPlaceholder(id=\"w\"),\nPlaceholder(id=\"h\"),\nPlaceholder(id=\"vw\"),\nPlaceholder(id=\"vh\"),\nPlaceholder(id=\"auto\"),\nPlaceholder(id=\"fr1\"),\nPlaceholder(id=\"fr2\"),\n)\nyield Ruler()\napp = HeightComparisonApp(css_path=\"height_comparison.tcss\")\n
#cells {\nheight: 2; /* (1)! */\n}\n#percent {\nheight: 12.5%; /* (2)! */\n}\n#w {\nheight: 5w; /* (3)! */\n}\n#h {\nheight: 12.5h; /* (4)! */\n}\n#vw {\nheight: 6.25vw; /* (5)! */\n}\n#vh {\nheight: 12.5vh; /* (6)! */\n}\n#auto {\nheight: auto; /* (7)! */\n}\n#fr1 {\nheight: 1fr; /* (8)! */\n}\n#fr2 {\nheight: 2fr; /* (9)! */\n}\nScreen {\nlayers: ruler;\noverflow: hidden;\n}\nRuler {\nlayer: ruler;\ndock: right;\nwidth: 1;\nbackground: $accent;\n}\n
VerticalScroll
container. Because it expands to fit all of the terminal, the width of the VerticalScroll
is 80 and 5% of 80 is 4.VerticalScroll
container. Because it expands to fit all of the terminal, the height of the VerticalScroll
is 24 and 12.5% of 24 is 3.1fr
, which means this placeholder will have half the height of a placeholder with 2fr
.2fr
, which means this placeholder will have twice the height of a placeholder with 1fr
./* Explicit cell height */\nheight: 10;\n/* Percentage height */\nheight: 50%;\n/* Automatic height */\nheight: auto\n
"},{"location":"styles/height/#python","title":"Python","text":"self.styles.height = 10 # Explicit cell height can be an int\nself.styles.height = \"50%\"\nself.styles.height = \"auto\"\n
"},{"location":"styles/height/#see-also","title":"See also","text":"max-height
and min-height
to limit the height of a widget.width
to set the width of a widget.The layer
style defines the layer a widget belongs to.
\nlayer: <name>;\n
The layer
style accepts a <name>
that defines the layer this widget belongs to. This <name>
must correspond to a <name>
that has been defined in a layers
style by an ancestor of this widget.
More information on layers can be found in the guide.
Warning
Using a <name>
that hasn't been defined in a layers
declaration of an ancestor of this widget has no effect.
In the example below, #box1
is yielded before #box2
. However, since #box1
is on the higher layer, it is drawn on top of #box2
.
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"styles/layer/#css","title":"CSS","text":"/* Draw the widget on the layer called 'below' */\nlayer: below;\n
"},{"location":"styles/layer/#python","title":"Python","text":"# Draw the widget on the layer called 'below'\nwidget.styles.layer = \"below\"\n
"},{"location":"styles/layer/#see-also","title":"See also","text":"layers
to define an ordered set of layers.The layers
style allows you to define an ordered set of layers.
\nlayers: <name>+;\n
The layers
style accepts one or more <name>
that define the layers that the widget is aware of, and the order in which they will be painted on the screen.
The values used here can later be referenced using the layer
property. The layers defined first in the list are drawn under the layers that are defined later in the list.
More information on layers can be found in the guide.
"},{"location":"styles/layers/#example","title":"Example","text":"In the example below, #box1
is yielded before #box2
. However, since #box1
is on the higher layer, it is drawn on top of #box2
.
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"styles/layers/#css","title":"CSS","text":"/* Bottom layer is called 'below', layer above it is called 'above' */\nlayers: below above;\n
"},{"location":"styles/layers/#python","title":"Python","text":"# Bottom layer is called 'below', layer above it is called 'above'\nwidget.style.layers = (\"below\", \"above\")\n
"},{"location":"styles/layers/#see-also","title":"See also","text":"layer
to set the layer a widget belongs to.The layout
style defines how a widget arranges its children.
\nlayout: grid | horizontal | vertical;\n
The layout
style takes an option that defines how child widgets will be arranged, as per the table shown below.
grid
Child widgets will be arranged in a grid. horizontal
Child widgets will be arranged along the horizontal axis, from left to right. vertical
(default) Child widgets will be arranged along the vertical axis, from top to bottom. See the layout guide for more information.
"},{"location":"styles/layout/#example","title":"Example","text":"Note how the layout
style affects the arrangement of widgets in the example below. To learn more about the grid layout, you can see the layout guide or the grid reference.
LayoutApp Layout Is Vertical LayoutIsHorizontal
from textual.app import App\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass LayoutApp(App):\ndef compose(self):\nyield Container(\nLabel(\"Layout\"),\nLabel(\"Is\"),\nLabel(\"Vertical\"),\nid=\"vertical-layout\",\n)\nyield Container(\nLabel(\"Layout\"),\nLabel(\"Is\"),\nLabel(\"Horizontal\"),\nid=\"horizontal-layout\",\n)\napp = LayoutApp(css_path=\"layout.tcss\")\n
#vertical-layout {\nlayout: vertical;\nbackground: darkmagenta;\nheight: auto;\n}\n#horizontal-layout {\nlayout: horizontal;\nbackground: darkcyan;\nheight: auto;\n}\nLabel {\nmargin: 1;\nwidth: 12;\ncolor: black;\nbackground: yellowgreen;\n}\n
"},{"location":"styles/layout/#css","title":"CSS","text":"layout: horizontal;\n
"},{"location":"styles/layout/#python","title":"Python","text":"widget.styles.layout = \"horizontal\"\n
"},{"location":"styles/layout/#see-also","title":"See also","text":"The margin
style specifies spacing around a widget.
\nmargin: <integer>\n # one value for all edges\n | <integer> <integer>\n # top/bot left/right\n | <integer> <integer> <integer> <integer>;\n # top right bot left\n\nmargin-top: <integer>;\nmargin-right: <integer>;\nmargin-bottom: <integer>;\nmargin-left: <integer>;\n
The margin
specifies spacing around the four edges of the widget equal to the <integer>
specified. The number of values given defines what edges get what margin:
<integer>
sets the same margin for the four edges of the widget;<integer>
set margin for top/bottom and left/right edges, respectively.<integer>
set margin for the top, right, bottom, and left edges, respectively.Tip
To remember the order of the edges affected by the rule margin
when it has 4 values, think of a clock. Its hand starts at the top and the goes clockwise: top, right, bottom, left.
Alternatively, margin can be set for each edge individually through the styles margin-top
, margin-right
, margin-bottom
, and margin-left
, respectively.
In the example below we add a large margin to a label, which makes it move away from the top-left corner of the screen.
Outputmargin.pymargin.tcssMarginApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258a \u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0\u258a \u258eits\u00a0path.\u258a \u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a \u258eremain.\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass MarginApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = MarginApp(css_path=\"margin.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\n}\nLabel {\nmargin: 4 8;\nbackground: blue 20%;\nborder: blue wide;\nwidth: 100%;\n}\n
"},{"location":"styles/margin/#all-margin-settings","title":"All margin settings","text":"The next example shows a grid. In each cell, we have a placeholder that has its margins set in different ways.
Outputmargin_all.pymargin_all.tcssMarginAllApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502margin\u2502\u2502margin:\u00a01\u00a0\u2502 \u2502no\u00a0margin\u2502\u2502margin:\u00a01\u2502\u2502:\u00a01\u00a05\u2502\u25021\u00a02\u00a06\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502margin-bottom:\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502margin-right:\u00a0\u2502\u2502\u2502\u2502margin-left:\u00a03\u2502 \u2502\u2502\u25023\u2502\u2502\u2502\u2502\u2502 \u2502margin-top:\u00a04\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Placeholder\nclass MarginAllApp(App):\ndef compose(self):\nyield Grid(\nContainer(Placeholder(\"no margin\", id=\"p1\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1\", id=\"p2\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1 5\", id=\"p3\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1 1 2 6\", id=\"p4\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-top: 4\", id=\"p5\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-right: 3\", id=\"p6\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-bottom: 4\", id=\"p7\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-left: 3\", id=\"p8\"), classes=\"bordered\"),\n)\napp = MarginAllApp(css_path=\"margin_all.tcss\")\n
Screen {\nbackground: $background;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nwidth: 100%;\nheight: 100%;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\n}\n.bordered {\nborder: white round;\n}\n#p1 {\n/* default is no margin */\n}\n#p2 {\nmargin: 1;\n}\n#p3 {\nmargin: 1 5;\n}\n#p4 {\nmargin: 1 1 2 6;\n}\n#p5 {\nmargin-top: 4;\n}\n#p6 {\nmargin-right: 3;\n}\n#p7 {\nmargin-bottom: 4;\n}\n#p8 {\nmargin-left: 3;\n}\n
"},{"location":"styles/margin/#css","title":"CSS","text":"/* Set margin of 1 around all edges */\nmargin: 1;\n/* Set margin of 2 on the top and bottom edges, and 4 on the left and right */\nmargin: 2 4;\n/* Set margin of 1 on the top, 2 on the right,\n3 on the bottom, and 4 on the left */\nmargin: 1 2 3 4;\nmargin-top: 1;\nmargin-right: 2;\nmargin-bottom: 3;\nmargin-left: 4;\n
"},{"location":"styles/margin/#python","title":"Python","text":"Python does not provide the properties margin-top
, margin-right
, margin-bottom
, and margin-left
. However, you can set the margin to a single integer, a tuple of 2 integers, or a tuple of 4 integers:
# Set margin of 1 around all edges\nwidget.styles.margin = 1\n# Set margin of 2 on the top and bottom edges, and 4 on the left and right\nwidget.styles.margin = (2, 4)\n# Set margin of 1 on top, 2 on the right, 3 on the bottom, and 4 on the left\nwidget.styles.margin = (1, 2, 3, 4)\n
"},{"location":"styles/margin/#see-also","title":"See also","text":"padding
to add spacing around the content of a widget.The max-height
style sets a maximum height for a widget.
\nmax-height: <scalar>;\n
The max-height
style accepts a <scalar>
that defines an upper bound for the height
of a widget. That is, the height of a widget is never allowed to exceed max-height
.
The example below shows some placeholders that were defined to span vertically from the top edge of the terminal to the bottom edge. Then, we set max-height
individually on each placeholder.
MaxHeightApp max-height:\u00a010w max-height:\u00a010 max-height:\u00a050% max-height:\u00a0999
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Placeholder\nclass MaxHeightApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(\"max-height: 10w\", id=\"p1\"),\nPlaceholder(\"max-height: 999\", id=\"p2\"),\nPlaceholder(\"max-height: 50%\", id=\"p3\"),\nPlaceholder(\"max-height: 10\", id=\"p4\"),\n)\napp = MaxHeightApp(css_path=\"max_height.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\n}\nPlaceholder {\nheight: 100%;\nwidth: 1fr;\n}\n#p1 {\nmax-height: 10w;\n}\n#p2 {\nmax-height: 999; /* (1)! */\n}\n#p3 {\nmax-height: 50%;\n}\n#p4 {\nmax-height: 10;\n}\n
/* Set the maximum height to 10 rows */\nmax-height: 10;\n/* Set the maximum height to 25% of the viewport height */\nmax-height: 25vh;\n
"},{"location":"styles/max_height/#python","title":"Python","text":"# Set the maximum height to 10 rows\nwidget.styles.max_height = 10\n# Set the maximum height to 25% of the viewport height\nwidget.styles.max_height = \"25vh\"\n
"},{"location":"styles/max_height/#see-also","title":"See also","text":"min-height
to set a lower bound on the height of a widget.height
to set the height of a widget.The max-width
style sets a maximum width for a widget.
\nmax-width: <scalar>;\n
The max-width
style accepts a <scalar>
that defines an upper bound for the width
of a widget. That is, the width of a widget is never allowed to exceed max-width
.
The example below shows some placeholders that were defined to span horizontally from the left edge of the terminal to the right edge. Then, we set max-width
individually on each placeholder.
MaxWidthApp max-width:\u00a0 50h max-width:\u00a0999 max-width:\u00a050% max-width:\u00a030
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Placeholder\nclass MaxWidthApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(\"max-width: 50h\", id=\"p1\"),\nPlaceholder(\"max-width: 999\", id=\"p2\"),\nPlaceholder(\"max-width: 50%\", id=\"p3\"),\nPlaceholder(\"max-width: 30\", id=\"p4\"),\n)\napp = MaxWidthApp(css_path=\"max_width.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\n}\nPlaceholder {\nwidth: 100%;\nheight: 1fr;\n}\n#p1 {\nmax-width: 50h;\n}\n#p2 {\nmax-width: 999; /* (1)! */\n}\n#p3 {\nmax-width: 50%;\n}\n#p4 {\nmax-width: 30;\n}\n
/* Set the maximum width to 10 rows */\nmax-width: 10;\n/* Set the maximum width to 25% of the viewport width */\nmax-width: 25vw;\n
"},{"location":"styles/max_width/#python","title":"Python","text":"# Set the maximum width to 10 rows\nwidget.styles.max_width = 10\n# Set the maximum width to 25% of the viewport width\nwidget.styles.max_width = \"25vw\"\n
"},{"location":"styles/max_width/#see-also","title":"See also","text":"min-width
to set a lower bound on the width of a widget.width
to set the width of a widget.The min-height
style sets a minimum height for a widget.
\nmin-height: <scalar>;\n
The min-height
style accepts a <scalar>
that defines a lower bound for the height
of a widget. That is, the height of a widget is never allowed to be under min-height
.
The example below shows some placeholders with their height set to 50%
. Then, we set min-height
individually on each placeholder.
MinHeightApp min-height:\u00a025% min-height:\u00a075% min-height:\u00a030 min-height:\u00a040w \u2583\u2583
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Placeholder\nclass MinHeightApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(\"min-height: 25%\", id=\"p1\"),\nPlaceholder(\"min-height: 75%\", id=\"p2\"),\nPlaceholder(\"min-height: 30\", id=\"p3\"),\nPlaceholder(\"min-height: 40w\", id=\"p4\"),\n)\napp = MinHeightApp(css_path=\"min_height.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\noverflow-y: auto;\n}\nPlaceholder {\nwidth: 1fr;\nheight: 50%;\n}\n#p1 {\nmin-height: 25%; /* (1)! */\n}\n#p2 {\nmin-height: 75%;\n}\n#p3 {\nmin-height: 30;\n}\n#p4 {\nmin-height: 40w;\n}\n
/* Set the minimum height to 10 rows */\nmin-height: 10;\n/* Set the minimum height to 25% of the viewport height */\nmin-height: 25vh;\n
"},{"location":"styles/min_height/#python","title":"Python","text":"# Set the minimum height to 10 rows\nwidget.styles.min_height = 10\n# Set the minimum height to 25% of the viewport height\nwidget.styles.min_height = \"25vh\"\n
"},{"location":"styles/min_height/#see-also","title":"See also","text":"max-height
to set an upper bound on the height of a widget.height
to set the height of a widget.The min-width
style sets a minimum width for a widget.
\nmin-width: <scalar>;\n
The min-width
style accepts a <scalar>
that defines a lower bound for the width
of a widget. That is, the width of a widget is never allowed to be under min-width
.
The example below shows some placeholders with their width set to 50%
. Then, we set min-width
individually on each placeholder.
MinWidthApp min-width:\u00a025% min-width:\u00a075% min-width:\u00a0100 min-width:\u00a0400h
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Placeholder\nclass MinWidthApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(\"min-width: 25%\", id=\"p1\"),\nPlaceholder(\"min-width: 75%\", id=\"p2\"),\nPlaceholder(\"min-width: 100\", id=\"p3\"),\nPlaceholder(\"min-width: 400h\", id=\"p4\"),\n)\napp = MinWidthApp(css_path=\"min_width.tcss\")\n
VerticalScroll {\nheight: 100%;\nwidth: 100%;\noverflow-x: auto;\n}\nPlaceholder {\nheight: 1fr;\nwidth: 50%;\n}\n#p1 {\nmin-width: 25%;\n/* (1)! */\n}\n#p2 {\nmin-width: 75%;\n}\n#p3 {\nmin-width: 100;\n}\n#p4 {\nmin-width: 400h;\n}\n
/* Set the minimum width to 10 rows */\nmin-width: 10;\n/* Set the minimum width to 25% of the viewport width */\nmin-width: 25vw;\n
"},{"location":"styles/min_width/#python","title":"Python","text":"# Set the minimum width to 10 rows\nwidget.styles.min_width = 10\n# Set the minimum width to 25% of the viewport width\nwidget.styles.min_width = \"25vw\"\n
"},{"location":"styles/min_width/#see-also","title":"See also","text":"max-width
to set an upper bound on the width of a widget.width
to set the width of a widget.The offset
style defines an offset for the position of the widget.
\noffset: <scalar> <scalar>;\n\noffset-x: <scalar>;\noffset-y: <scalar>\n
The two <scalar>
in the offset
define, respectively, the offsets in the horizontal and vertical axes for the widget.
To specify an offset along a single axis, you can use offset-x
and offset-y
.
In this example, we have 3 widgets with differing offsets.
Outputoffset.pyoffset.tcssOffsetApp \u258c\u2590 \u258cChani\u00a0(offset\u00a00\u00a0\u2590 \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c\u258c-3)\u2590 \u258c\u2590\u258c\u2590 \u258c\u2590\u258c\u2590 \u258c\u2590\u258c\u2590 \u258cPaul\u00a0(offset\u00a08\u00a02)\u2590\u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u258c\u2590 \u258c\u2590 \u258c\u2590 \u258c\u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u258c\u2590 \u258c\u2590 \u258c\u2590 \u258cDuncan\u00a0(offset\u00a04\u00a0\u2590 \u258c10)\u2590 \u258c\u2590 \u258c\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f
from textual.app import App\nfrom textual.widgets import Label\nclass OffsetApp(App):\ndef compose(self):\nyield Label(\"Paul (offset 8 2)\", classes=\"paul\")\nyield Label(\"Duncan (offset 4 10)\", classes=\"duncan\")\nyield Label(\"Chani (offset 0 -3)\", classes=\"chani\")\napp = OffsetApp(css_path=\"offset.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\nlayout: horizontal;\n}\nLabel {\nwidth: 20;\nheight: 10;\ncontent-align: center middle;\n}\n.paul {\noffset: 8 2;\nbackground: red 20%;\nborder: outer red;\ncolor: red;\n}\n.duncan {\noffset: 4 10;\nbackground: green 20%;\nborder: outer green;\ncolor: green;\n}\n.chani {\noffset: 0 -3;\nbackground: blue 20%;\nborder: outer blue;\ncolor: blue;\n}\n
"},{"location":"styles/offset/#css","title":"CSS","text":"/* Move the widget 8 cells in the x direction and 2 in the y direction */\noffset: 8 2;\n/* Move the widget 4 cells in the x direction\noffset-x: 4;\n/* Move the widget -3 cells in the y direction\noffset-y: -3;\n
"},{"location":"styles/offset/#python","title":"Python","text":"You cannot change programmatically the offset for a single axis. You have to set the two axes at the same time.
# Move the widget 2 cells in the x direction, and 4 in the y direction.\nwidget.styles.offset = (2, 4)\n
"},{"location":"styles/offset/#see-also","title":"See also","text":"The opacity
style sets the opacity of a widget.
While terminals are not capable of true opacity, Textual can create an approximation by blending widgets with their background color.
"},{"location":"styles/opacity/#syntax","title":"Syntax","text":"\nopacity: <number> | <percentage>;\n
The opacity of a widget can be set as a <number>
or a <percentage>
. If given as a number, then opacity
should be a value between 0 and 1, where 0 is the background color and 1 is fully opaque. If given as a percentage, 0% is the background color and 100% is fully opaque.
Typically, if you set this value it would be somewhere between the two extremes. For instance, setting the opacity of a widget to 70%
will make it appear dimmer than surrounding widgets, which could be used to display a disabled state.
This example shows, from top to bottom, increasing opacity values for a label with a border and some text. When the opacity is zero, all we see is the (black) background.
Outputopacity.pyopacity.tcssOpacityApp \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258copacity:\u00a00%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a025%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a050%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a075%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a0100%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f
from textual.app import App\nfrom textual.widgets import Label\nclass OpacityApp(App):\ndef compose(self):\nyield Label(\"opacity: 0%\", id=\"zero-opacity\")\nyield Label(\"opacity: 25%\", id=\"quarter-opacity\")\nyield Label(\"opacity: 50%\", id=\"half-opacity\")\nyield Label(\"opacity: 75%\", id=\"three-quarter-opacity\")\nyield Label(\"opacity: 100%\", id=\"full-opacity\")\napp = OpacityApp(css_path=\"opacity.tcss\")\n
#zero-opacity {\nopacity: 0%;\n}\n#quarter-opacity {\nopacity: 25%;\n}\n#half-opacity {\nopacity: 50%;\n}\n#three-quarter-opacity {\nopacity: 75%;\n}\n#full-opacity {\nopacity: 100%;\n}\nScreen {\nbackground: black;\n}\nLabel {\nwidth: 100%;\nheight: 1fr;\nborder: outer dodgerblue;\nbackground: lightseagreen;\ncontent-align: center middle;\ntext-style: bold;\n}\n
"},{"location":"styles/opacity/#css","title":"CSS","text":"/* Fade the widget to 50% against its parent's background */\nopacity: 50%;\n
"},{"location":"styles/opacity/#python","title":"Python","text":"# Fade the widget to 50% against its parent's background\nwidget.styles.opacity = \"50%\"\n
"},{"location":"styles/opacity/#see-also","title":"See also","text":"text-opacity
to blend the color of a widget's content with its background color.The outline
style enables the drawing of a box around the content of a widget, which means the outline is drawn over the content area.
Note
border
and outline
cannot coexist in the same edge of a widget.
\noutline: [<border>] [<color>];\n\noutline-top: [<border>] [<color>];\noutline-right: [<border>] [<color>];\noutline-bottom: [<border>] [<color>];\noutline-left: [<border>] [<color>];\n
The style outline
accepts an optional <border>
that sets the visual style of the widget outline and an optional <color>
to set the color of the outline.
Unlike the style border
, the frame of the outline is drawn over the content area of the widget. This rule can be useful to add temporary emphasis on the content of a widget, if you want to draw the user's attention to it.
The textual
CLI has a subcommand which will let you explore the various border types interactively, when applied to the CSS rule border
:
textual borders\n
"},{"location":"styles/outline/#examples","title":"Examples","text":""},{"location":"styles/outline/#basic-usage","title":"Basic usage","text":"This example shows a widget with an outline. Note how the outline occludes the text area.
Outputoutline.pyoutline.tcssOutlineApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u258a \u258e\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258e\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258a \u258end\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u258a \u258eath.\u258a \u258ehere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = OutlineApp(css_path=\"outline.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\n}\nLabel {\nmargin: 4 8;\nbackground: green 20%;\noutline: wide green;\nwidth: 100%;\n}\n
"},{"location":"styles/outline/#all-outline-types","title":"All outline types","text":"The next example shows a grid with all the available outline types.
Outputoutline_all.pyoutline_all.tcssAllOutlinesApp +------------------+\u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 |ascii|blank\u254fdashed\u254f +------------------+\u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2551double\u2551\u2503heavy\u2503hidden/none \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2597\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2596 hkey\u2590inner\u258cnone \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u259d\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2598 \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u258couter\u2590\u2502round\u2502\u2502solid\u2502 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258f\u2595\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258atall\u258e\u258fvkey\u2595\u258ewide\u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258f\u2595\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass AllOutlinesApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"ascii\", id=\"ascii\"),\nLabel(\"blank\", id=\"blank\"),\nLabel(\"dashed\", id=\"dashed\"),\nLabel(\"double\", id=\"double\"),\nLabel(\"heavy\", id=\"heavy\"),\nLabel(\"hidden/none\", id=\"hidden\"),\nLabel(\"hkey\", id=\"hkey\"),\nLabel(\"inner\", id=\"inner\"),\nLabel(\"none\", id=\"none\"),\nLabel(\"outer\", id=\"outer\"),\nLabel(\"round\", id=\"round\"),\nLabel(\"solid\", id=\"solid\"),\nLabel(\"tall\", id=\"tall\"),\nLabel(\"vkey\", id=\"vkey\"),\nLabel(\"wide\", id=\"wide\"),\n)\napp = AllOutlinesApp(css_path=\"outline_all.tcss\")\n
#ascii {\noutline: ascii $accent;\n}\n#blank {\noutline: blank $accent;\n}\n#dashed {\noutline: dashed $accent;\n}\n#double {\noutline: double $accent;\n}\n#heavy {\noutline: heavy $accent;\n}\n#hidden {\noutline: hidden $accent;\n}\n#hkey {\noutline: hkey $accent;\n}\n#inner {\noutline: inner $accent;\n}\n#none {\noutline: none $accent;\n}\n#outer {\noutline: outer $accent;\n}\n#round {\noutline: round $accent;\n}\n#solid {\noutline: solid $accent;\n}\n#tall {\noutline: tall $accent;\n}\n#vkey {\noutline: vkey $accent;\n}\n#wide {\noutline: wide $accent;\n}\nGrid {\ngrid-size: 3 5;\nalign: center middle;\ngrid-gutter: 1 2;\n}\nLabel {\nwidth: 20;\nheight: 3;\ncontent-align: center middle;\n}\n
"},{"location":"styles/outline/#borders-and-outlines","title":"Borders and outlines","text":"The next example makes the difference between border
and outline
clearer by having three labels side-by-side. They contain the same text, have the same width and height, and are styled exactly the same up to their border
and outline
styles.
This example also shows that a widget cannot contain both a border
and an outline
:
OutlineBorderApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502ear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path\u2502 \u2502here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502I\u00a0must\u00a0not\u00a0fear.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineBorderApp(App):\ndef compose(self):\nyield Label(TEXT, classes=\"outline\")\nyield Label(TEXT, classes=\"border\")\nyield Label(TEXT, classes=\"outline border\")\napp = OutlineBorderApp(css_path=\"outline_vs_border.tcss\")\n
Label {\nheight: 8;\n}\n.outline {\noutline: $error round;\n}\n.border {\nborder: $success heavy;\n}\n
"},{"location":"styles/outline/#css","title":"CSS","text":"/* Set a heavy white outline */\noutline:heavy white;\n/* set a red outline on the left */\noutline-left:outer red;\n
"},{"location":"styles/outline/#python","title":"Python","text":"# Set a heavy white outline\nwidget.outline = (\"heavy\", \"white\")\n# Set a red outline on the left\nwidget.outline_left = (\"outer\", \"red\")\n
"},{"location":"styles/outline/#see-also","title":"See also","text":"border
to add a border around a widget.The overflow
style specifies if and when scrollbars should be displayed.
\noverflow: <overflow> <overflow>;\n\noverflow-x: <overflow>;\noverflow-y: <overflow>;\n
The style overflow
accepts two values that determine when to display scrollbars in a container widget. The two values set the overflow for the horizontal and vertical axes, respectively.
Overflow may also be set individually for each axis:
overflow-x
sets the overflow for the horizontal axis; andoverflow-y
sets the overflow for the vertical axis.The default setting for containers is overflow: auto auto
.
Warning
Some built-in containers like Horizontal
and VerticalScroll
override these defaults.
Here we split the screen into left and right sections, each with three vertically scrolling widgets that do not fit into the height of the terminal.
The left side has overflow-y: auto
(the default) and will automatically show a scrollbar. The right side has overflow-y: hidden
which will prevent a scrollbar from being shown.
OverflowApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eI\u00a0must\u00a0not\u00a0fear.\u258a\u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a\u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a\u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a \u258ebrings\u00a0total\u00a0obliteration.\u258a\u258ebrings\u00a0total\u00a0obliteration.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a\u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u258a\u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0\u258a \u258eand\u00a0through\u00a0me.\u258a\u258eand\u00a0through\u00a0me.\u258a \u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0\u258a\u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0\u258a \u258ewill\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0\u258a\u258eturn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0\u258a \u258eits\u00a0path.\u258a\u2581\u2581\u258epath.\u258a \u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0\u258a\u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u258a \u258ewill\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a\u258ebe\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u258a \u258eremain.\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eI\u00a0must\u00a0not\u00a0fear.\u258a\u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a\u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a\u258ebrings\u00a0total\u00a0obliteration.\u258a \u258ebrings\u00a0total\u00a0obliteration.\u258a\u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a\u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u258a\u258eand\u00a0through\u00a0me.\u258a
from textual.app import App\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OverflowApp(App):\ndef compose(self):\nyield Horizontal(\nVerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id=\"left\"),\nVerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id=\"right\"),\n)\napp = OverflowApp(css_path=\"overflow.tcss\")\n
Screen {\nbackground: $background;\ncolor: black;\n}\nVerticalScroll {\nwidth: 1fr;\n}\nStatic {\nmargin: 1 2;\nbackground: green 80%;\nborder: green wide;\ncolor: white 90%;\nheight: auto;\n}\n#right {\noverflow-y: hidden;\n}\n
"},{"location":"styles/overflow/#css","title":"CSS","text":"/* Automatic scrollbars on both axes (the default) */\noverflow: auto auto;\n/* Hide the vertical scrollbar */\noverflow-y: hidden;\n/* Always show the horizontal scrollbar */\noverflow-x: scroll;\n
"},{"location":"styles/overflow/#python","title":"Python","text":"Overflow cannot be programmatically set for both axes at the same time.
# Hide the vertical scrollbar\nwidget.styles.overflow_y = \"hidden\"\n# Always show the horizontal scrollbar\nwidget.styles.overflow_x = \"scroll\"\n
"},{"location":"styles/padding/","title":"Padding","text":"The padding
style specifies spacing around the content of a widget.
\npadding: <integer> # one value for all edges\n | <integer> <integer>\n # top/bot left/right\n | <integer> <integer> <integer> <integer>;\n # top right bot left\n\npadding-top: <integer>;\npadding-right: <integer>;\npadding-bottom: <integer>;\npadding-left: <integer>;\n
The padding
specifies spacing around the content of a widget, thus this spacing is added inside the widget. The values of the <integer>
determine how much spacing is added and the number of values define what edges get what padding:
<integer>
sets the same padding for the four edges of the widget;<integer>
set padding for top/bottom and left/right edges, respectively.<integer>
set padding for the top, right, bottom, and left edges, respectively.Tip
To remember the order of the edges affected by the rule padding
when it has 4 values, think of a clock. Its hand starts at the top and then goes clockwise: top, right, bottom, left.
Alternatively, padding can be set for each edge individually through the rules padding-top
, padding-right
, padding-bottom
, and padding-left
, respectively.
This example adds padding around some text.
Outputpadding.pypadding.tcssPaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0 path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = PaddingApp(css_path=\"padding.tcss\")\n
Screen {\nbackground: white;\ncolor: blue;\n}\nLabel {\npadding: 4 8;\nbackground: blue 20%;\nwidth: 100%;\n}\n
"},{"location":"styles/padding/#all-padding-settings","title":"All padding settings","text":"The next example shows a grid. In each cell, we have a placeholder that has its padding set in different ways. The effect of each padding setting is noticeable in the colored background around the text of each placeholder.
Outputpadding_all.pypadding_all.tcssPaddingAllApp no\u00a0padding padding:\u00a01padding:padding:\u00a01\u00a01 1\u00a052\u00a06 padding-right:\u00a03padding-bottom:\u00a04padding-left:\u00a03 padding-top:\u00a04
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Placeholder\nclass PaddingAllApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(\"no padding\", id=\"p1\"),\nPlaceholder(\"padding: 1\", id=\"p2\"),\nPlaceholder(\"padding: 1 5\", id=\"p3\"),\nPlaceholder(\"padding: 1 1 2 6\", id=\"p4\"),\nPlaceholder(\"padding-top: 4\", id=\"p5\"),\nPlaceholder(\"padding-right: 3\", id=\"p6\"),\nPlaceholder(\"padding-bottom: 4\", id=\"p7\"),\nPlaceholder(\"padding-left: 3\", id=\"p8\"),\n)\napp = PaddingAllApp(css_path=\"padding_all.tcss\")\n
Screen {\nbackground: $background;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nwidth: auto;\nheight: auto;\n}\n#p1 {\n/* default is no padding */\n}\n#p2 {\npadding: 1;\n}\n#p3 {\npadding: 1 5;\n}\n#p4 {\npadding: 1 1 2 6;\n}\n#p5 {\npadding-top: 4;\n}\n#p6 {\npadding-right: 3;\n}\n#p7 {\npadding-bottom: 4;\n}\n#p8 {\npadding-left: 3;\n}\n
"},{"location":"styles/padding/#css","title":"CSS","text":"/* Set padding of 1 around all edges */\npadding: 1;\n/* Set padding of 2 on the top and bottom edges, and 4 on the left and right */\npadding: 2 4;\n/* Set padding of 1 on the top, 2 on the right,\n3 on the bottom, and 4 on the left */\npadding: 1 2 3 4;\npadding-top: 1;\npadding-right: 2;\npadding-bottom: 3;\npadding-left: 4;\n
"},{"location":"styles/padding/#python","title":"Python","text":"In Python, you cannot set any of the individual padding
styles padding-top
, padding-right
, padding-bottom
, and padding-left
.
However, you can set padding to a single integer, a tuple of 2 integers, or a tuple of 4 integers:
# Set padding of 1 around all edges\nwidget.styles.padding = 1\n# Set padding of 2 on the top and bottom edges, and 4 on the left and right\nwidget.styles.padding = (2, 4)\n# Set padding of 1 on top, 2 on the right, 3 on the bottom, and 4 on the left\nwidget.styles.padding = (1, 2, 3, 4)\n
"},{"location":"styles/padding/#see-also","title":"See also","text":"box-sizing
to specify how to account for padding in a widget's dimensions.padding
to add spacing around a widget.The scrollbar-gutter
style allows reserving space for a vertical scrollbar.
\nscrollbar-gutter: auto | stable;\n"},{"location":"styles/scrollbar_gutter/#values","title":"Values","text":"Value Description
auto
(default) No space is reserved for a vertical scrollbar. stable
Space is reserved for a vertical scrollbar. Setting the value to stable
prevents unwanted layout changes when the scrollbar becomes visible, whereas the default value of auto
means that the layout of your application is recomputed when a vertical scrollbar becomes needed.
In the example below, notice the gap reserved for the scrollbar on the right side of the terminal window.
Outputscrollbar_gutter.pyscrollbar_gutter.tcssScrollbarGutterApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
from textual.app import App\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass ScrollbarGutterApp(App):\ndef compose(self):\nyield Static(TEXT, id=\"text-box\")\napp = ScrollbarGutterApp(css_path=\"scrollbar_gutter.tcss\")\n
Screen {\nscrollbar-gutter: stable;\n}\n#text-box {\ncolor: floralwhite;\nbackground: darkmagenta;\n}\n
"},{"location":"styles/scrollbar_gutter/#css","title":"CSS","text":"scrollbar-gutter: auto; /* Don't reserve space for a vertical scrollbar. */\nscrollbar-gutter: stable; /* Reserve space for a vertical scrollbar. */\n
"},{"location":"styles/scrollbar_gutter/#python","title":"Python","text":"self.styles.scrollbar_gutter = \"auto\" # Don't reserve space for a vertical scrollbar.\nself.styles.scrollbar_gutter = \"stable\" # Reserve space for a vertical scrollbar.\n
"},{"location":"styles/scrollbar_size/","title":"Scrollbar-size","text":"The scrollbar-size
style defines the width of the scrollbars.
\nscrollbar-size: <integer> <integer>;\n # horizontal vertical\n\nscrollbar-size-horizontal: <integer>;\nscrollbar-size-vertical: <integer>;\n
The scrollbar-size
style takes two <integer>
to set the horizontal and vertical scrollbar sizes, respectively. This customisable size is the width of the scrollbar, given that its length will always be 100% of the container.
The scrollbar widths may also be set individually with scrollbar-size-horizontal
and scrollbar-size-vertical
.
In this example we modify the size of the widget's scrollbar to be much larger than usual.
Outputscrollbar_size.pyscrollbar_size.tcssScrollbarApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2581\u2581\u2581\u2581 I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
from textual.app import App\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield ScrollableContainer(Label(TEXT * 5), classes=\"panel\")\napp = ScrollbarApp(css_path=\"scrollbar_size.tcss\")\n
Screen {\nbackground: white;\ncolor: blue 80%;\nlayout: horizontal;\n}\nLabel {\npadding: 1 2;\nwidth: 200;\n}\n.panel {\nscrollbar-size: 10 4;\npadding: 1 2;\n}\n
"},{"location":"styles/scrollbar_size/#scrollbar-sizes-comparison","title":"Scrollbar sizes comparison","text":"In the next example we show three containers with differently sized scrollbars.
Tip
If you want to hide the scrollbar but still allow the container to scroll using the mousewheel or keyboard, you can set the scrollbar size to 0
.
ScrollbarApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0pastAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0tWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0thWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0t I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2587I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2587\u2587 I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0pastAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0tWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0thWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0t I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.\u2582I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 \u258fAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u258f \u258fWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0th\u258f \u258fI\u00a0must\u00a0not\u00a0fear.\u258f \u258fFear\u00a0is\u00a0the\u00a0mind-killer.\u258f \u258f\u2589\u258f
from textual.app import App\nfrom textual.containers import Horizontal, ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield Horizontal(\nScrollableContainer(Label(TEXT * 5), id=\"v1\"),\nScrollableContainer(Label(TEXT * 5), id=\"v2\"),\nScrollableContainer(Label(TEXT * 5), id=\"v3\"),\n)\napp = ScrollbarApp(css_path=\"scrollbar_size2.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
ScrollableContainer {\nwidth: 1fr;\n}\n#v1 {\nscrollbar-size: 5 1;\nbackground: red 20%;\n}\n#v2 {\nscrollbar-size-vertical: 1;\nbackground: green 20%;\n}\n#v3 {\nscrollbar-size-horizontal: 5;\nbackground: blue 20%;\n}\n
"},{"location":"styles/scrollbar_size/#css","title":"CSS","text":"/* Set horizontal scrollbar to 10, and vertical scrollbar to 4 */\nscrollbar-size: 10 4;\n/* Set horizontal scrollbar to 10 */\nscrollbar-size-horizontal: 10;\n/* Set vertical scrollbar to 4 */\nscrollbar-size-vertical: 4;\n
"},{"location":"styles/scrollbar_size/#python","title":"Python","text":"The style scrollbar-size
has no Python equivalent. The scrollbar sizes must be set independently:
# Set horizontal scrollbar to 10:\nwidget.styles.scrollbar_size_horizontal = 10\n# Set vertical scrollbar to 4:\nwidget.styles.scrollbar_size_vertical = 4\n
"},{"location":"styles/text_align/","title":"Text-align","text":"The text-align
style sets the text alignment in a widget.
\ntext-align: <text-align>;\n
The text-align
style accepts a value of the type <text-align>
that defines how text is aligned inside the widget.
The default value is start
.
This example shows, from top to bottom: left
, center
, right
, and justify
text alignments.
TextAlign Left\u00a0alignedCenter\u00a0aligned I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0 mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 little-death\u00a0that\u00a0brings\u00a0total\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u00a0\u00a0 obliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0Iobliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0I will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u00a0\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0through\u00a0me.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Right\u00a0alignedJustified \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0theI\u00a0\u00a0must\u00a0\u00a0not\u00a0\u00a0fear.\u00a0\u00a0Fear\u00a0\u00a0\u00a0is\u00a0\u00a0\u00a0the \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0themind-killer.\u00a0\u00a0\u00a0\u00a0\u00a0Fear\u00a0\u00a0\u00a0\u00a0\u00a0is\u00a0\u00a0\u00a0\u00a0\u00a0the \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0little-death\u00a0that\u00a0brings\u00a0totallittle-death\u00a0\u00a0\u00a0that\u00a0\u00a0\u00a0brings\u00a0\u00a0\u00a0total obliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0Iobliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0I \u00a0\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0andwill\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0\u00a0me\u00a0\u00a0and \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0through\u00a0me.through\u00a0me.
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nTEXT = (\n\"I must not fear. Fear is the mind-killer. Fear is the little-death that \"\n\"brings total obliteration. I will face my fear. I will permit it to pass over \"\n\"me and through me.\"\n)\nclass TextAlign(App):\ndef compose(self):\nyield Grid(\nLabel(\"[b]Left aligned[/]\\n\" + TEXT, id=\"one\"),\nLabel(\"[b]Center aligned[/]\\n\" + TEXT, id=\"two\"),\nLabel(\"[b]Right aligned[/]\\n\" + TEXT, id=\"three\"),\nLabel(\"[b]Justified[/]\\n\" + TEXT, id=\"four\"),\n)\napp = TextAlign(css_path=\"text_align.tcss\")\n
#one {\ntext-align: left;\nbackground: lightblue;\n}\n#two {\ntext-align: center;\nbackground: indianred;\n}\n#three {\ntext-align: right;\nbackground: palegreen;\n}\n#four {\ntext-align: justify;\nbackground: palevioletred;\n}\nLabel {\npadding: 1 2;\nheight: 100%;\ncolor: auto;\n}\nGrid {\ngrid-size: 2 2;\n}\n
"},{"location":"styles/text_align/#css","title":"CSS","text":"/* Set text in the widget to be right aligned */\ntext-align: right;\n
"},{"location":"styles/text_align/#python","title":"Python","text":"# Set text in the widget to be right aligned\nwidget.styles.text_align = \"right\"\n
"},{"location":"styles/text_align/#see-also","title":"See also","text":"align
to set the alignment of children widgets inside a container.content-align
to set the alignment of content inside a widget.The text-opacity
style blends the foreground color (i.e. text) with the background color.
\ntext-opacity: <number> | <percentage>;\n
The text opacity of a widget can be set as a <number>
or a <percentage>
. If given as a number, then text-opacity
should be a value between 0 and 1, where 0 makes the foreground color match the background (effectively making text invisible) and 1 will display text as normal. If given as a percentage, 0% will result in invisible text, and 100% will display fully opaque text.
Typically, if you set this value it would be somewhere between the two extremes. For instance, setting text-opacity
to 70%
would result in slightly faded text. Setting it to 0.3
would result in very dim text.
Warning
Be careful not to set text opacity so low as to make it hard to read.
"},{"location":"styles/text_opacity/#example","title":"Example","text":"This example shows, from top to bottom, increasing text-opacity
values.
TextOpacityApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a025%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a050%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a075%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a0100%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0
from textual.app import App\nfrom textual.widgets import Label\nclass TextOpacityApp(App):\ndef compose(self):\nyield Label(\"text-opacity: 0%\", id=\"zero-opacity\")\nyield Label(\"text-opacity: 25%\", id=\"quarter-opacity\")\nyield Label(\"text-opacity: 50%\", id=\"half-opacity\")\nyield Label(\"text-opacity: 75%\", id=\"three-quarter-opacity\")\nyield Label(\"text-opacity: 100%\", id=\"full-opacity\")\napp = TextOpacityApp(css_path=\"text_opacity.tcss\")\n
#zero-opacity {\ntext-opacity: 0%;\n}\n#quarter-opacity {\ntext-opacity: 25%;\n}\n#half-opacity {\ntext-opacity: 50%;\n}\n#three-quarter-opacity {\ntext-opacity: 75%;\n}\n#full-opacity {\ntext-opacity: 100%;\n}\nLabel {\nheight: 1fr;\nwidth: 100%;\ntext-align: center;\ntext-style: bold;\n}\n
"},{"location":"styles/text_opacity/#css","title":"CSS","text":"/* Set the text to be \"half-faded\" against the background of the widget */\ntext-opacity: 50%;\n
"},{"location":"styles/text_opacity/#python","title":"Python","text":"# Set the text to be \"half-faded\" against the background of the widget\nwidget.styles.text_opacity = \"50%\"\n
"},{"location":"styles/text_opacity/#see-also","title":"See also","text":"opacity
to specify the opacity of a whole widget.The text-style
style sets the style for the text in a widget.
\ntext-style: <text-style>;\n
text-style
will take all the values specified and will apply that styling combination to the text in the widget.
Each of the three text panels has a different text style, respectively bold
, italic
, and reverse
, from left to right.
TextStyleApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0 that\u00a0brings\u00a0total\u00a0that\u00a0brings\u00a0total\u00a0that\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 over\u00a0me\u00a0and\u00a0through\u00a0me.over\u00a0me\u00a0and\u00a0through\u00a0me.over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0 I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0 to\u00a0see\u00a0its\u00a0path.to\u00a0see\u00a0its\u00a0path.to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0 there\u00a0will\u00a0be\u00a0nothing.\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Onlythere\u00a0will\u00a0be\u00a0nothing.\u00a0Only Only\u00a0I\u00a0will\u00a0remain.I\u00a0will\u00a0remain.I\u00a0will\u00a0remain.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass TextStyleApp(App):\ndef compose(self):\nyield Label(TEXT, id=\"lbl1\")\nyield Label(TEXT, id=\"lbl2\")\nyield Label(TEXT, id=\"lbl3\")\napp = TextStyleApp(css_path=\"text_style.tcss\")\n
Screen {\nlayout: horizontal;\n}\nLabel {\nwidth: 1fr;\n}\n#lbl1 {\nbackground: red 30%;\ntext-style: bold;\n}\n#lbl2 {\nbackground: green 30%;\ntext-style: italic;\n}\n#lbl3 {\nbackground: blue 30%;\ntext-style: reverse;\n}\n
"},{"location":"styles/text_style/#all-text-styles","title":"All text styles","text":"The next example shows all different text styles on their own, as well as some combinations of styles in a single widget.
Outputtext_style_all.pytext_style_all.tcssAllTextStyleApp nonebolditalicreverse I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 mind-killer.mind-killer.mind-killer.mind-killer. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 little-death\u00a0thatlittle-death\u00a0that\u00a0little-death\u00a0thatlittle-death\u00a0that\u00a0 brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0 fear.fear.fear.fear. strikeunderlinebold\u00a0italicreverse\u00a0strike I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 mind-killer.mind-killer.mind-killer.mind-killer. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 little-death\u00a0thatlittle-death\u00a0that\u00a0little-death\u00a0thatlittle-death\u00a0that\u00a0 brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0 fear.fear.fear.fear. I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass AllTextStyleApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"none\\n\" + TEXT, id=\"lbl1\"),\nLabel(\"bold\\n\" + TEXT, id=\"lbl2\"),\nLabel(\"italic\\n\" + TEXT, id=\"lbl3\"),\nLabel(\"reverse\\n\" + TEXT, id=\"lbl4\"),\nLabel(\"strike\\n\" + TEXT, id=\"lbl5\"),\nLabel(\"underline\\n\" + TEXT, id=\"lbl6\"),\nLabel(\"bold italic\\n\" + TEXT, id=\"lbl7\"),\nLabel(\"reverse strike\\n\" + TEXT, id=\"lbl8\"),\n)\napp = AllTextStyleApp(css_path=\"text_style_all.tcss\")\n
#lbl1 {\ntext-style: none;\n}\n#lbl2 {\ntext-style: bold;\n}\n#lbl3 {\ntext-style: italic;\n}\n#lbl4 {\ntext-style: reverse;\n}\n#lbl5 {\ntext-style: strike;\n}\n#lbl6 {\ntext-style: underline;\n}\n#lbl7 {\ntext-style: bold italic;\n}\n#lbl8 {\ntext-style: reverse strike;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\nmargin: 1 2;\nheight: 100%;\n}\nLabel {\nheight: 100%;\n}\n
"},{"location":"styles/text_style/#css","title":"CSS","text":"text-style: italic;\n
"},{"location":"styles/text_style/#python","title":"Python","text":"widget.styles.text_style = \"italic\"\n
"},{"location":"styles/tint/","title":"Tint","text":"The tint
style blends a color with the whole widget.
\ntint: <color> [<percentage>];\n
The tint style blends a <color>
with the widget. The color should likely have an alpha component (specified directly in the color used or by the optional <percentage>
), otherwise the end result will obscure the widget content.
This examples shows a green tint with gradually increasing alpha.
Outputtint.pytint.tcssTintApp tint:\u00a0green\u00a00%; tint:\u00a0green\u00a010%; tint:\u00a0green\u00a020%; tint:\u00a0green\u00a030%; tint:\u00a0green\u00a040%; tint:\u00a0green\u00a050%; \u2584\u2584 tint:\u00a0green\u00a060%; tint:\u00a0green\u00a070%;
from textual.app import App\nfrom textual.color import Color\nfrom textual.widgets import Label\nclass TintApp(App):\ndef compose(self):\ncolor = Color.parse(\"green\")\nfor tint_alpha in range(0, 101, 10):\nwidget = Label(f\"tint: green {tint_alpha}%;\")\nwidget.styles.tint = color.with_alpha(tint_alpha / 100) # (1)!\nyield widget\napp = TintApp(css_path=\"tint.tcss\")\n
Color
instance with varying levels of opacity, set through the method with_alpha.Label {\nheight: 3;\nwidth: 100%;\ntext-style: bold;\nbackground: white;\ncolor: black;\ncontent-align: center middle;\n}\n
"},{"location":"styles/tint/#css","title":"CSS","text":"/* A red tint (could indicate an error) */\ntint: red 20%;\n/* A green tint */\ntint: rgba(0, 200, 0, 0.3);\n
"},{"location":"styles/tint/#python","title":"Python","text":"# A red tint\nfrom textual.color import Color\nwidget.styles.tint = Color.parse(\"red\").with_alpha(0.2);\n# A green tint\nwidget.styles.tint = \"rgba(0, 200, 0, 0.3)\"\n
"},{"location":"styles/visibility/","title":"Visibility","text":"The visibility
style determines whether a widget is visible or not.
\nvisibility: hidden | visible;\n
visibility
takes one of two values to set the visibility of a widget.
hidden
The widget will be invisible. visible
(default) The widget will be displayed as normal."},{"location":"styles/visibility/#visibility-inheritance","title":"Visibility inheritance","text":"Note
Children of an invisible container can be visible.
By default, children inherit the visibility of their parents. So, if a container is set to be invisible, its children widgets will also be invisible by default. However, those widgets can be made visible if their visibility is explicitly set to visibility: visible
. This is shown in the second example below.
Note that the second widget is hidden while leaving a space where it would have been rendered.
Outputvisibility.pyvisibility.tcssVisibilityApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a01\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a03\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Label\nclass VisibilityApp(App):\ndef compose(self):\nyield Label(\"Widget 1\")\nyield Label(\"Widget 2\", classes=\"invisible\")\nyield Label(\"Widget 3\")\napp = VisibilityApp(css_path=\"visibility.tcss\")\n
Screen {\nbackground: green;\n}\nLabel {\nheight: 5;\nwidth: 100%;\nbackground: white;\ncolor: blue;\nborder: heavy blue;\n}\nLabel.invisible {\nvisibility: hidden;\n}\n
"},{"location":"styles/visibility/#overriding-container-visibility","title":"Overriding container visibility","text":"The next example shows the interaction of the visibility
style with invisible containers that have visible children. The app below has three rows with a Horizontal
container per row and three placeholders per row. The containers all have a white background, and then:
VisibilityContainersApp PlaceholderPlaceholderPlaceholder PlaceholderPlaceholderPlaceholder
from textual.app import App\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass VisibilityContainersApp(App):\ndef compose(self):\nyield VerticalScroll(\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"top\",\n),\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"middle\",\n),\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"bot\",\n),\n)\napp = VisibilityContainersApp(css_path=\"visibility_containers.tcss\")\n
Horizontal {\npadding: 1 2; /* (1)! */\nbackground: white;\nheight: 1fr;\n}\n#top {} /* (2)! */\n#middle { /* (3)! */\nvisibility: hidden;\n}\n#bot { /* (4)! */\nvisibility: hidden;\n}\n#bot > Placeholder { /* (5)! */\nvisibility: visible;\n}\nPlaceholder {\nwidth: 1fr;\n}\n
Horizontal
is visible.Horizontal
is visible by default, and so are its children.Horizontal
is made invisible and its children will inherit that setting.Horizontal
is made invisible.../* Widget is invisible */\nvisibility: hidden;\n/* Widget is visible */\nvisibility: visible;\n
"},{"location":"styles/visibility/#python","title":"Python","text":"# Widget is invisible\nself.styles.visibility = \"hidden\"\n# Widget is visible\nself.styles.visibility = \"visible\"\n
There is also a shortcut to set a Widget's visibility. The visible
property on Widget
may be set to True
or False
.
# Make a widget invisible\nwidget.visible = False\n# Make the widget visible again\nwidget.visible = True\n
"},{"location":"styles/visibility/#see-also","title":"See also","text":"display
to specify whether a widget is displayed or not.The width
style sets a widget's width.
\nwidth: <scalar>;\n
The style width
needs a <scalar>
to determine the horizontal length of the width. By default, it sets the width of the content area, but if box-sizing
is set to border-box
it sets the width of the border area.
This example adds a widget with 50% width of the screen.
Outputwidth.pywidth.tcssWidthApp Widget
from textual.app import App\nfrom textual.widget import Widget\nclass WidthApp(App):\ndef compose(self):\nyield Widget()\napp = WidthApp(css_path=\"width.tcss\")\n
Screen > Widget {\nbackground: green;\nwidth: 50%;\ncolor: white;\n}\n
"},{"location":"styles/width/#all-width-formats","title":"All width formats","text":"Outputwidth_comparison.pywidth_comparison.tcss WidthComparisonApp #cells#percent#w#h#vw#vh#auto#fr1#fr3 \u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Label, Placeholder, Static\nclass Ruler(Static):\ndef compose(self):\nruler_text = \"\u00b7\u00b7\u00b7\u00b7\u2022\" * 100\nyield Label(ruler_text)\nclass WidthComparisonApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(id=\"cells\"), # (1)!\nPlaceholder(id=\"percent\"),\nPlaceholder(id=\"w\"),\nPlaceholder(id=\"h\"),\nPlaceholder(id=\"vw\"),\nPlaceholder(id=\"vh\"),\nPlaceholder(id=\"auto\"),\nPlaceholder(id=\"fr1\"),\nPlaceholder(id=\"fr3\"),\n)\nyield Ruler()\napp = WidthComparisonApp(css_path=\"width_comparison.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
#cells {\nwidth: 9; /* (1)! */\n}\n#percent {\nwidth: 12.5%; /* (2)! */\n}\n#w {\nwidth: 10w; /* (3)! */\n}\n#h {\nwidth: 25h; /* (4)! */\n}\n#vw {\nwidth: 15vw; /* (5)! */\n}\n#vh {\nwidth: 25vh; /* (6)! */\n}\n#auto {\nwidth: auto; /* (7)! */\n}\n#fr1 {\nwidth: 1fr; /* (8)! */\n}\n#fr3 {\nwidth: 3fr; /* (9)! */\n}\nScreen {\nlayers: ruler;\n}\nRuler {\nlayer: ruler;\ndock: bottom;\noverflow: hidden;\nheight: 1;\nbackground: $accent;\n}\n
Horizontal
container. Because it expands to fit all of the terminal, the width of the Horizontal
is 80 and 10% of 80 is 8.Horizontal
container. Because it expands to fit all of the terminal, the height of the Horizontal
is 24 and 25% of 24 is 6.\"#auto\"
, the placeholder has its width set to 5.1fr
, which means this placeholder will have a third of the width of a placeholder with 3fr
.3fr
, which means this placeholder will have triple the width of a placeholder with 1fr
./* Explicit cell width */\nwidth: 10;\n/* Percentage width */\nwidth: 50%;\n/* Automatic width */\nwidth: auto;\n
"},{"location":"styles/width/#python","title":"Python","text":"widget.styles.width = 10\nwidget.styles.width = \"50%\nwidget.styles.width = \"auto\"\n
"},{"location":"styles/width/#see-also","title":"See also","text":"max-width
and min-width
to limit the width of a widget.height
to set the height of a widget.There are a number of styles relating to the Textual grid
layout.
For an in-depth look at the grid layout, visit the grid guide.
Property Descriptioncolumn-span
Number of columns a cell spans. grid-columns
Width of grid columns. grid-gutter
Spacing between grid cells. grid-rows
Height of grid rows. grid-size
Number of columns and rows in the grid layout. row-span
Number of rows a cell spans."},{"location":"styles/grid/#syntax","title":"Syntax","text":"\ncolumn-span: <integer>;\n\ngrid-columns: <scalar>+;\n\ngrid-gutter: <scalar> [<scalar>];\n\ngrid-rows: <scalar>+;\n\ngrid-size: <integer> [<integer>];\n\nrow-span: <integer>;\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/grid/#example","title":"Example","text":"The example below shows all the styles above in action. The grid-size: 3 4;
declaration sets the grid to 3 columns and 4 rows. The first cell of the grid, tinted magenta, shows a cell spanning multiple rows and columns. The spacing between grid cells is defined by the grid-gutter
style.
GridApp Grid\u00a0cell\u00a01Grid\u00a0cell\u00a02 row-span:\u00a03; column-span:\u00a02; Grid\u00a0cell\u00a03 Grid\u00a0cell\u00a04 Grid\u00a0cell\u00a05Grid\u00a0cell\u00a06Grid\u00a0cell\u00a07
from textual.app import App\nfrom textual.widgets import Static\nclass GridApp(App):\ndef compose(self):\nyield Static(\"Grid cell 1\\n\\nrow-span: 3;\\ncolumn-span: 2;\", id=\"static1\")\nyield Static(\"Grid cell 2\", id=\"static2\")\nyield Static(\"Grid cell 3\", id=\"static3\")\nyield Static(\"Grid cell 4\", id=\"static4\")\nyield Static(\"Grid cell 5\", id=\"static5\")\nyield Static(\"Grid cell 6\", id=\"static6\")\nyield Static(\"Grid cell 7\", id=\"static7\")\napp = GridApp(css_path=\"grid.tcss\")\n
Screen {\nlayout: grid;\ngrid-size: 3 4;\ngrid-rows: 1fr;\ngrid-columns: 1fr;\ngrid-gutter: 1;\n}\nStatic {\ncolor: auto;\nbackground: lightblue;\nheight: 100%;\npadding: 1 2;\n}\n#static1 {\ntint: magenta 40%;\nrow-span: 3;\ncolumn-span: 2;\n}\n
Warning
The styles listed on this page will only work when the layout is grid
.
The column-span
style specifies how many columns a widget will span in a grid layout.
Note
This style only affects widgets that are direct children of a widget with layout: grid
.
\ncolumn-span: <integer>;\n
The column-span
style accepts a single non-negative <integer>
that quantifies how many columns the given widget spans.
The example below shows a 4 by 4 grid where many placeholders span over several columns.
Outputcolumn_span.pycolumn_span.tcssMyApp #p1 #p2#p3 #p4#p5 #p6#p7
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Placeholder\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(id=\"p1\"),\nPlaceholder(id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(id=\"p6\"),\nPlaceholder(id=\"p7\"),\n)\napp = MyApp(css_path=\"column_span.tcss\")\n
#p1 {\ncolumn-span: 4;\n}\n#p2 {\ncolumn-span: 3;\n}\n#p3 {\ncolumn-span: 1; /* Didn't need to be set explicitly. */\n}\n#p4 {\ncolumn-span: 2;\n}\n#p5 {\ncolumn-span: 2;\n}\n#p6 {\n/* Default value is 1. */\n}\n#p7 {\ncolumn-span: 3;\n}\nGrid {\ngrid-size: 4 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nheight: 100%;\n}\n
"},{"location":"styles/grid/column_span/#css","title":"CSS","text":"column-span: 3;\n
"},{"location":"styles/grid/column_span/#python","title":"Python","text":"widget.styles.column_span = 3\n
"},{"location":"styles/grid/column_span/#see-also","title":"See also","text":"row-span
to specify how many rows a widget spans.The grid-columns
style allows to define the width of the columns of the grid.
Note
This style only affects widgets with layout: grid
.
\ngrid-columns: <scalar>+;\n
The grid-columns
style takes one or more <scalar>
that specify the length of the columns of the grid.
If there are more columns in the grid than scalars specified in grid-columns
, they are reused cyclically. If the number of <scalar>
is in excess, the excess is ignored.
The example below shows a grid with 10 labels laid out in a grid with 2 rows and 5 columns.
We set grid-columns: 1fr 16 2fr
. Because there are more rows than scalars in the style definition, the scalars will be reused:
1fr
;16
; and2fr
.MyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u2502width\u00a0=\u00a016\u2502\u25022fr\u2502\u25021fr\u2502\u2502width\u00a0=\u00a016\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u2502width\u00a0=\u00a016\u2502\u25022fr\u2502\u25021fr\u2502\u2502width\u00a0=\u00a016\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"2fr\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"2fr\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\n)\napp = MyApp(css_path=\"grid_columns.tcss\")\n
Grid {\ngrid-size: 5 2;\ngrid-columns: 1fr 16 2fr;\n}\nLabel {\nborder: round white;\ncontent-align-horizontal: center;\nwidth: 100%;\nheight: 100%;\n}\n
"},{"location":"styles/grid/grid_columns/#css","title":"CSS","text":"/* Set all columns to have 50% width */\ngrid-columns: 50%;\n/* Every other column is twice as wide as the first one */\ngrid-columns: 1fr 2fr;\n
"},{"location":"styles/grid/grid_columns/#python","title":"Python","text":"grid.styles.grid_columns = \"50%\"\ngrid.styles.grid_columns = \"1fr 2fr\"\n
"},{"location":"styles/grid/grid_columns/#see-also","title":"See also","text":"grid-rows
to specify the height of the grid rows.The grid-gutter
style sets the size of the gutter in the grid layout. That is, it sets the space between adjacent cells in the grid.
Gutter is only applied between the edges of cells. No spacing is added between the edges of the cells and the edges of the container.
Note
This style only affects widgets with layout: grid
.
\ngrid-gutter: <integer> [<integer>];\n
The grid-gutter
style takes one or two <integer>
that set the length of the gutter along the vertical and horizontal axes. If only one <integer>
is supplied, it sets the vertical and horizontal gutters. If two are supplied, they set the vertical and horizontal gutters, respectively.
The example below employs a common trick to apply visually consistent spacing around all grid cells.
Outputgrid_gutter.pygrid_gutter.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25025\u2502\u25026\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25027\u2502\u25028\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\nLabel(\"6\"),\nLabel(\"7\"),\nLabel(\"8\"),\n)\napp = MyApp(css_path=\"grid_gutter.tcss\")\n
Grid {\ngrid-size: 2 4;\ngrid-gutter: 1 2; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
/* Set vertical and horizontal gutters to be the same */\ngrid-gutter: 5;\n/* Set vertical and horizontal gutters separately */\ngrid-gutter: 1 2;\n
"},{"location":"styles/grid/grid_gutter/#python","title":"Python","text":"Vertical and horizontal gutters correspond to different Python properties, so they must be set separately:
widget.styles.grid_gutter_vertical = \"1\"\nwidget.styles.grid_gutter_horizontal = \"2\"\n
"},{"location":"styles/grid/grid_rows/","title":"Grid-rows","text":"The grid-rows
style allows to define the height of the rows of the grid.
Note
This style only affects widgets with layout: grid
.
\ngrid-rows: <scalar>+;\n
The grid-rows
style takes one or more <scalar>
that specify the length of the rows of the grid.
If there are more rows in the grid than scalars specified in grid-rows
, they are reused cyclically. If the number of <scalar>
is in excess, the excess is ignored.
The example below shows a grid with 10 labels laid out in a grid with 5 rows and 2 columns.
We set grid-rows: 1fr 6 25%
. Because there are more rows than scalars in the style definition, the scalars will be reused:
1fr
;6
; and25%
.MyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u25021fr\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502height\u00a0=\u00a06\u2502\u2502height\u00a0=\u00a06\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u250225%\u2502\u250225%\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u25021fr\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502height\u00a0=\u00a06\u2502\u2502height\u00a0=\u00a06\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1fr\"),\nLabel(\"1fr\"),\nLabel(\"height = 6\"),\nLabel(\"height = 6\"),\nLabel(\"25%\"),\nLabel(\"25%\"),\nLabel(\"1fr\"),\nLabel(\"1fr\"),\nLabel(\"height = 6\"),\nLabel(\"height = 6\"),\n)\napp = MyApp(css_path=\"grid_rows.tcss\")\n
Grid {\ngrid-size: 2 5;\ngrid-rows: 1fr 6 25%;\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
"},{"location":"styles/grid/grid_rows/#css","title":"CSS","text":"/* Set all rows to have 50% height */\ngrid-rows: 50%;\n/* Every other row is twice as tall as the first one */\ngrid-rows: 1fr 2fr;\n
"},{"location":"styles/grid/grid_rows/#python","title":"Python","text":"grid.styles.grid_rows = \"50%\"\ngrid.styles.grid_rows = \"1fr 2fr\"\n
"},{"location":"styles/grid/grid_rows/#see-also","title":"See also","text":"grid-columns
to specify the width of the grid columns.The grid-size
style sets the number of columns and rows in a grid layout.
The number of rows can be left unspecified and it will be computed automatically.
Note
This style only affects widgets with layout: grid
.
\ngrid-size: <integer> [<integer>];\n
The grid-size
style takes one or two non-negative <integer>
. The first defines how many columns there are in the grid. If present, the second one sets the number of rows \u2013 regardless of the number of children of the grid \u2013, otherwise the number of rows is computed automatically.
In the first example, we create a grid with 2 columns and 5 rows, although we do not have enough labels to fill in the whole grid:
Outputgrid_size_both.pygrid_size_both.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502 \u25025\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\n)\napp = MyApp(css_path=\"grid_size_both.tcss\")\n
Grid {\ngrid-size: 2 4; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
In the second example, we create a grid with 2 columns and however many rows are needed to display all of the grid children:
Outputgrid_size_columns.pygrid_size_columns.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502 \u2502\u2502 \u25025\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\n)\napp = MyApp(css_path=\"grid_size_columns.tcss\")\n
Grid {\ngrid-size: 2; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
/* Grid with 3 rows and 5 columns */\ngrid-size: 3 5;\n/* Grid with 4 columns and as many rows as needed */\ngrid-size: 4;\n
"},{"location":"styles/grid/grid_size/#python","title":"Python","text":"To programmatically change the grid size, the number of rows and columns must be specified separately:
widget.styles.grid_size_rows = 3\nwidget.styles.grid_size_columns = 6\n
"},{"location":"styles/grid/row_span/","title":"Row-span","text":"The row-span
style specifies how many rows a widget will span in a grid layout.
Note
This style only affects widgets that are direct children of a widget with layout: grid
.
\nrow-span: <integer>;\n
The row-span
style accepts a single non-negative <integer>
that quantifies how many rows the given widget spans.
The example below shows a 4 by 4 grid where many placeholders span over several rows.
Notice that grid cells are filled from left to right, top to bottom. After placing the placeholders #p1
, #p2
, #p3
, and #p4
, the next available cell is in the second row, fourth column, which is where the top of #p5
is.
MyApp #p4 #p3 #p2 #p1 #p5 #p6 #p7
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Placeholder\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(id=\"p1\"),\nPlaceholder(id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(id=\"p6\"),\nPlaceholder(id=\"p7\"),\n)\napp = MyApp(css_path=\"row_span.tcss\")\n
#p1 {\nrow-span: 4;\n}\n#p2 {\nrow-span: 3;\n}\n#p3 {\nrow-span: 2;\n}\n#p4 {\nrow-span: 1; /* Didn't need to be set explicitly. */\n}\n#p5 {\nrow-span: 3;\n}\n#p6 {\nrow-span: 2;\n}\n#p7 {\n/* Default value is 1. */\n}\nGrid {\ngrid-size: 4 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nheight: 100%;\n}\n
"},{"location":"styles/grid/row_span/#css","title":"CSS","text":"row-span: 3\n
"},{"location":"styles/grid/row_span/#python","title":"Python","text":"widget.styles.row_span = 3\n
"},{"location":"styles/grid/row_span/#see-also","title":"See also","text":"column-span
to specify how many columns a widget spans.Textual supports the concept of inline \"links\" embedded in text which trigger an action when pressed. There are a number of styles which influence the appearance of these links within a widget.
Note
These CSS rules only target Textual action links. Internet hyperlinks are not affected by these styles.
Property Descriptionlink-background
The background color of the link text. link-color
The color of the link text. link-hover-background
The background color of the link text when the cursor is over it. link-hover-color
The color of the link text when the cursor is over it. link-hover-style
The style of the link text when the cursor is over it. link-style
The style of the link text (e.g. underline)."},{"location":"styles/links/#syntax","title":"Syntax","text":"\nlink-background: <color> [<percentage>];\n\nlink-color: <color> [<percentage>];\n\nlink-style: <text-style>;\n\nlink-hover-background: <color> [<percentage>];\n\nlink-hover-color: <color> [<percentage>];\n\nlink-hover-style: <text-style>;\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/links/#example","title":"Example","text":"In the example below, the first label illustrates default link styling. The second label uses CSS to customize the link color, background, and style.
Outputlinks.pylinks.tcssLinksApp Here\u00a0is\u00a0a\u00a0link\u00a0which\u00a0you\u00a0can\u00a0click! Here\u00a0is\u00a0a\u00a0link\u00a0which\u00a0you\u00a0can\u00a0click!
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nHere is a [@click='app.bell']link[/] which you can click!\n\"\"\"\nclass LinksApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\nyield Static(TEXT, id=\"custom\")\napp = LinksApp(css_path=\"links.tcss\")\n
#custom {\nlink-color: black 90%;\nlink-background: dodgerblue;\nlink-style: bold italic underline;\n}\n
"},{"location":"styles/links/#additional-notes","title":"Additional Notes","text":"The link-background
style sets the background color of the link.
Note
link-background
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-background: <color> [<percentage>];\n
link-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of text enclosed in Textual action links.
The example below shows some links with their background color changed. It also shows that link-background
does not affect hyperlinks.
LinkBackgroundApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkBackgroundApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkBackgroundApp(css_path=\"link_background.tcss\")\n
link-background
rule.link-background
.link-background
.link-background
.#lbl1, #lbl2 {\nlink-background: red; /* (1)! */\n}\n#lbl3 {\nlink-background: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-background: $accent;\n}\n
link-background: red 70%;\nlink-background: $accent;\n
"},{"location":"styles/links/link_background/#python","title":"Python","text":"widget.styles.link_background = \"red 70%\"\nwidget.styles.link_background = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_background = Color(100, 30, 173)\n
"},{"location":"styles/links/link_background/#see-also","title":"See also","text":"link-color
to set the color of link text.The link-color
style sets the color of the link text.
Note
link-color
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-color: <color> [<percentage>];\n
link-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of text enclosed in Textual action links.
The example below shows some links with their color changed. It also shows that link-color
does not affect hyperlinks.
LinkColorApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkColorApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkColorApp(css_path=\"link_color.tcss\")\n
link-color
rule.link-color
.link-color
.link-color
.#lbl1, #lbl2 {\nlink-color: red; /* (1)! */\n}\n#lbl3 {\nlink-color: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-color: $accent;\n}\n
link-color: red 70%;\nlink-color: $accent;\n
"},{"location":"styles/links/link_color/#python","title":"Python","text":"widget.styles.link_color = \"red 70%\"\nwidget.styles.link_color = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_color = Color(100, 30, 173)\n
"},{"location":"styles/links/link_color/#see-also","title":"See also","text":"link-background
to set the background color of link text.The link-hover-background
style sets the background color of the link when the mouse cursor is over the link.
Note
link-hover-background
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-background: <color> [<percentage>];\n
link-hover-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of text enclosed in Textual action links when the mouse pointer is over it.
If not provided, a Textual action link will have link-hover-background
set to $accent
.
The example below shows some links that have their background colour changed when the mouse moves over it and it shows that there is a default color for link-hover-background
.
It also shows that link-hover-background
does not affect hyperlinks.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_background.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverBackgroundApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverBackgroundApp(css_path=\"link_hover_background.tcss\")\n
link-hover-background
rule.link-hover-background
.link-hover-background
.link-hover-background
.#lbl1, #lbl2 {\nlink-hover-background: red; /* (1)! */\n}\n#lbl3 {\nlink-hover-background: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\n/* Empty to show the default hover background */ /* (2)! */\n}\n
link-hover-background: red 70%;\nlink-hover-background: $accent;\n
"},{"location":"styles/links/link_hover_background/#python","title":"Python","text":"widget.styles.link_hover_background = \"red 70%\"\nwidget.styles.link_hover_background = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_hover_background = Color(100, 30, 173)\n
"},{"location":"styles/links/link_hover_background/#see-also","title":"See also","text":"link-background
to set the background color of link text.The link-hover-color
style sets the color of the link text when the mouse cursor is over the link.
Note
link-hover-color
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-color: <color> [<percentage>];\n
link-hover-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of text enclosed in Textual action links when the mouse pointer is over it.
If not provided, a Textual action link will have link-hover-color
set to white
.
The example below shows some links that have their colour changed when the mouse moves over it. It also shows that link-hover-color
does not affect hyperlinks.
Note
The background color also changes when the mouse moves over the links because that is the default behavior. That can be customised by setting link-hover-background
but we haven't done so in this example.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_color.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverColorApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverColorApp(css_path=\"link_hover_color.tcss\")\n
link-hover-color
rule.link-hover-color
.link-hover-color
.link-hover-color
.#lbl1, #lbl2 {\nlink-hover-color: red; /* (1)! */\n}\n#lbl3 {\nlink-hover-color: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-hover-color: black;\n}\n
link-hover-color: red 70%;\nlink-hover-color: black;\n
"},{"location":"styles/links/link_hover_color/#python","title":"Python","text":"widget.styles.link_hover_color = \"red 70%\"\nwidget.styles.link_hover_color = \"black\"\n# You can also use a `Color` object directly:\nwidget.styles.link_hover_color = Color(100, 30, 173)\n
"},{"location":"styles/links/link_hover_color/#see-also","title":"See also","text":"link-color
to set the color of link text.The link-hover-style
style sets the text style for the link text when the mouse cursor is over the link.
Note
link-hover-style
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-style: <text-style>;\n
link-hover-style
applies its <text-style>
to the text of Textual action links when the mouse pointer is over them.
If not provided, a Textual action link will have link-hover-style
set to bold
.
The example below shows some links that have their colour changed when the mouse moves over it. It also shows that link-hover-style
does not affect hyperlinks.
Note
The background color also changes when the mouse moves over the links because that is the default behavior. That can be customised by setting link-hover-background
but we haven't done so in this example.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_style.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverStyleApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverStyleApp(css_path=\"link_hover_style.tcss\")\n
link-hover-style
rule.link-hover-style
.link-hover-style
.link-hover-style
.#lbl1, #lbl2 {\nlink-hover-style: bold italic; /* (1)! */\n}\n#lbl3 {\nlink-hover-style: reverse strike;\n}\n#lbl4 {\nlink-hover-style: bold;\n}\n
link-hover-style: bold;\nlink-hover-style: bold italic reverse;\n
"},{"location":"styles/links/link_hover_style/#python","title":"Python","text":"widget.styles.link_hover_style = \"bold\"\nwidget.styles.link_hover_style = \"bold italic reverse\"\n
"},{"location":"styles/links/link_hover_style/#see-also","title":"See also","text":"link-style
to set the style of link text.text-style
to set the style of text in a widget.The link-style
style sets the text style for the link text.
Note
link-style
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-style: <text-style>;\n
link-style
will take all the values specified and will apply that styling to text that is enclosed by a Textual action link.
If not provided, a Textual action link will have link-style
set to underline
.
The example below shows some links with different styles applied to their text. It also shows that link-style
does not affect hyperlinks.
LinkStyleApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkStyleApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkStyleApp(css_path=\"link_style.tcss\")\n
link-style
rule.link-style
.link-style
.link-style
.#lbl1, #lbl2 {\nlink-style: bold italic; /* (1)! */\n}\n#lbl3 {\nlink-style: reverse strike;\n}\n#lbl4 {\nlink-style: bold;\n}\n
link-style: bold;\nlink-style: bold italic reverse;\n
"},{"location":"styles/links/link_style/#python","title":"Python","text":"widget.styles.link_style = \"bold\"\nwidget.styles.link_style = \"bold italic reverse\"\n
"},{"location":"styles/links/link_style/#see-also","title":"See also","text":"text-style
to set the style of text in a widget.There are a number of styles to set the colors used in Textual scrollbars. You won't typically need to do this, as the default themes have carefully chosen colors, but you can if you want to.
Style Applies toscrollbar-background
Scrollbar background. scrollbar-background-active
Scrollbar background when the thumb is being dragged. scrollbar-background-hover
Scrollbar background when the mouse is hovering over it. scrollbar-color
Scrollbar \"thumb\" (movable part). scrollbar-color-active
Scrollbar thumb when it is active (being dragged). scrollbar-color-hover
Scrollbar thumb when the mouse is hovering over it. scrollbar-corner-color
The gap between the horizontal and vertical scrollbars."},{"location":"styles/scrollbar_colors/#syntax","title":"Syntax","text":"\nscrollbar-background: <color> [<percentage>];\n\nscrollbar-background-active: <color> [<percentage>];\n\nscrollbar-background-hover: <color> [<percentage>];\n\nscrollbar-color: <color> [<percentage>];\n\nscrollbar-color-active: <color> [<percentage>];\n\nscrollbar-color-hover: <color> [<percentage>];\n\nscrollbar-corner-color: <color> [<percentage>];\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/scrollbar_colors/#example","title":"Example","text":"This example shows two panels that contain oversized text. The right panel sets scrollbar-background
, scrollbar-color
, and scrollbar-corner-color
, and the left panel shows the default colors for comparison.
ScrollbarApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turnAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn see\u00a0its\u00a0path.see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 will\u00a0remain.will\u00a0remain. I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turnAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn see\u00a0its\u00a0path.\u2583\u2583see\u00a0its\u00a0path.\u2583\u2583 Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 will\u00a0remain.will\u00a0remain. I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t \u258d\u258d
from textual.app import App\nfrom textual.containers import Horizontal, ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield Horizontal(\nScrollableContainer(Label(TEXT * 10)),\nScrollableContainer(Label(TEXT * 10), classes=\"right\"),\n)\napp = ScrollbarApp(css_path=\"scrollbars.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Label {\nwidth: 150%;\nheight: 150%;\n}\n.right {\nscrollbar-background: red;\nscrollbar-color: green;\nscrollbar-corner-color: blue;\n}\nHorizontal > ScrollableContainer {\nwidth: 50%;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/","title":"Scrollbar-background","text":"The scrollbar-background
style sets the background color of the scrollbar.
\nscrollbar-background: <color> [<percentage>];\n
scrollbar-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#css","title":"CSS","text":"scrollbar-backround: blue;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#python","title":"Python","text":"widget.styles.scrollbar_background = \"blue\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#see-also","title":"See also","text":"scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color
to set the color of scrollbars.scrollbar-corner-color
to set the color of the corner where horizontal and vertical scrollbars meet.The scrollbar-background-active
style sets the background color of the scrollbar when the thumb is being dragged.
\nscrollbar-background-active: <color> [<percentage>];\n
scrollbar-background-active
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar when its thumb is being dragged.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#css","title":"CSS","text":"scrollbar-backround-active: red;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#python","title":"Python","text":"widget.styles.scrollbar_background_active = \"red\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.The scrollbar-background-hover
style sets the background color of the scrollbar when the cursor is over it.
\nscrollbar-background-hover: <color> [<percentage>];\n
scrollbar-background-hover
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar when the cursor is over it.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#css","title":"CSS","text":"scrollbar-background-hover: purple;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#python","title":"Python","text":"widget.styles.scrollbar_background_hover = \"purple\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#see-also","title":"See also","text":""},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#see-also_1","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.The scrollbar-color
style sets the color of the scrollbar.
\nscrollbar-color: <color> [<percentage>];\n
scrollbar-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#css","title":"CSS","text":"scrollbar-color: cyan;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#python","title":"Python","text":"widget.styles.scrollbar_color = \"cyan\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.scrollbar-corner-color
to set the color of the corner where horizontal and vertical scrollbars meet.The scrollbar-color-active
style sets the color of the scrollbar when the thumb is being dragged.
\nscrollbar-color-active: <color> [<percentage>];\n
scrollbar-color-active
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar when its thumb is being dragged.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#css","title":"CSS","text":"scrollbar-color-active: yellow;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#python","title":"Python","text":"widget.styles.scrollbar_color_active = \"yellow\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#see-also","title":"See also","text":"scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-color
to set the color of scrollbars.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.The scrollbar-color-hover
style sets the color of the scrollbar when the cursor is over it.
\nscrollbar-color-hover: <color> [<percentage>];\n
scrollbar-color-hover
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar when the cursor is over it.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#css","title":"CSS","text":"scrollbar-color-hover: pink;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#python","title":"Python","text":"widget.styles.scrollbar_color_hover = \"pink\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#see-also","title":"See also","text":"scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color
to set the color of scrollbars.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.The scrollbar-corner-color
style sets the color of the gap between the horizontal and vertical scrollbars.
\nscrollbar-corner-color: <color> [<percentage>];\n
scrollbar-corner-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of the gap between the horizontal and vertical scrollbars of a widget.
The example below sets the scrollbar corner (bottom-right corner of the screen) to white.
Outputscrollbar_corner_color.pyscrollbar_corner_color.tcssScrollbarCornerColorApp I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarCornerColorApp(App):\ndef compose(self):\nyield Label(TEXT.replace(\"\\n\", \" \") + \"\\n\" + TEXT * 10)\napp = ScrollbarCornerColorApp(css_path=\"scrollbar_corner_color.tcss\")\n
Screen {\noverflow: auto auto;\nscrollbar-corner-color: white;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#css","title":"CSS","text":"scrollbar-corner-color: white;\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#python","title":"Python","text":"widget.styles.scrollbar_corner_color = \"white\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-color
to set the color of scrollbars.A reference to the builtin widgets.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"widgets/button/","title":"Button","text":"A simple button widget which can be pressed using a mouse click or by pressing Enter when it has focus.
The example below shows each button variant, and its disabled equivalent. Clicking any of the non-disabled buttons in the example app below will result in the app exiting and the details of the selected button being printed to the console.
Outputbutton.pybutton.tcssButtonsApp Standard\u00a0ButtonsDisabled\u00a0Buttons \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Default\u00a0\u00a0Default\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Primary!\u00a0\u00a0Primary!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Success!\u00a0\u00a0Success!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Warning!\u00a0\u00a0Warning!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Error!\u00a0\u00a0Error!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Button, Static\nclass ButtonsApp(App[str]):\nCSS_PATH = \"button.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVerticalScroll(\nStatic(\"Standard Buttons\", classes=\"header\"),\nButton(\"Default\"),\nButton(\"Primary!\", variant=\"primary\"),\nButton.success(\"Success!\"),\nButton.warning(\"Warning!\"),\nButton.error(\"Error!\"),\n),\nVerticalScroll(\nStatic(\"Disabled Buttons\", classes=\"header\"),\nButton(\"Default\", disabled=True),\nButton(\"Primary!\", variant=\"primary\", disabled=True),\nButton.success(\"Success!\", disabled=True),\nButton.warning(\"Warning!\", disabled=True),\nButton.error(\"Error!\", disabled=True),\n),\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(str(event.button))\nif __name__ == \"__main__\":\napp = ButtonsApp()\nprint(app.run())\n
Button {\nmargin: 1 2;\n}\nHorizontal > VerticalScroll {\nwidth: 24;\n}\n.header {\nmargin: 1 0 0 2;\ntext-style: bold;\n}\n
"},{"location":"widgets/button/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description label
str
\"\"
The text that appears inside the button. variant
ButtonVariant
\"default\"
Semantic styling variant. One of default
, primary
, success
, warning
, error
. disabled
bool
False
Whether the button is disabled or not. Disabled buttons cannot be focused or clicked, and are styled in a way that suggests this."},{"location":"widgets/button/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/button/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/button/#additional-notes","title":"Additional Notes","text":"Button
has the height
set to 3 lines and a min-width
of 16 columns. To create a button with zero visible padding, you will need to change these values and also remove the border with border: none;
.class
","text":"def __init__(\nself,\nlabel=None,\nvariant=\"default\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Static
A simple clickable button.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
variant
ButtonVariant
The variant of the button.
'default'
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
"},{"location":"widgets/button/#textual.widgets._button.Button.ACTIVE_EFFECT_DURATION","title":"ACTIVE_EFFECT_DURATION class-attribute
instance-attribute
","text":"ACTIVE_EFFECT_DURATION = 0.3\n
When buttons are clicked they get the -active
class for this duration (in seconds)
class-attribute
instance-attribute
","text":"label: reactive[TextType] = self.validate_label(label)\n
The text label that appears within the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.variant","title":"variantclass-attribute
instance-attribute
","text":"variant = self.validate_variant(variant)\n
The variant name for the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.Pressed","title":"Pressedclass
","text":"def __init__(self, button):\n
Bases: Message
Event sent when a Button
is pressed.
Can be handled using on_button_pressed
in a subclass of Button
or in a parent widget in the DOM.
instance-attribute
","text":"button: Button = button\n
The button that was pressed.
"},{"location":"widgets/button/#textual.widgets._button.Button.Pressed.control","title":"controlproperty
","text":"control: Button\n
An alias for Pressed.button.
This will be the same value as Pressed.button.
"},{"location":"widgets/button/#textual.widgets._button.Button.action_press","title":"action_pressmethod
","text":"def action_press(self):\n
Activate a press of the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.error","title":"errorclassmethod
","text":"def error(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating an error Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'error' variant.
method
","text":"def press(self):\n
Respond to a button press.
Returns Type DescriptionSelf
The button instance.
"},{"location":"widgets/button/#textual.widgets._button.Button.success","title":"successclassmethod
","text":"def success(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating a success Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'success' variant.
method
","text":"def validate_label(self, label):\n
Parse markup for self.label
"},{"location":"widgets/button/#textual.widgets._button.Button.warning","title":"warningclassmethod
","text":"def warning(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating a warning Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'warning' variant.
module-attribute
","text":"ButtonVariant = Literal[\n\"default\", \"primary\", \"success\", \"warning\", \"error\"\n]\n
The names of the valid button variants.
These are the variants that can be used with a Button
.
Added in version 0.13.0
A simple checkbox widget which stores a boolean value.
The example below shows check boxes in various states.
Outputcheckbox.pycheckbox.tcssCheckboxApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Arrakis\u00a0\ud83d\ude13\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Caladan\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Chusuk\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGiedi\u00a0Prime\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGinaz\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Grumman\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2583\u2583 \u258a\u2590X\u258cKaitain\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Checkbox\nclass CheckboxApp(App[None]):\nCSS_PATH = \"checkbox.tcss\"\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nyield Checkbox(\"Arrakis :sweat:\")\nyield Checkbox(\"Caladan\")\nyield Checkbox(\"Chusuk\")\nyield Checkbox(\"[b]Giedi Prime[/b]\")\nyield Checkbox(\"[magenta]Ginaz[/]\")\nyield Checkbox(\"Grumman\", True)\nyield Checkbox(\"Kaitain\", id=\"initial_focus\")\nyield Checkbox(\"Novebruns\", True)\ndef on_mount(self):\nself.query_one(\"#initial_focus\", Checkbox).focus()\nif __name__ == \"__main__\":\nCheckboxApp().run()\n
Screen {\nalign: center middle;\n}\nVerticalScroll {\nwidth: auto;\nheight: auto;\nbackground: $boost;\npadding: 2;\n}\n
"},{"location":"widgets/checkbox/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the checkbox."},{"location":"widgets/checkbox/#messages","title":"Messages","text":"The checkbox widget defines the following bindings:
Key(s) Description enter, space Toggle the value."},{"location":"widgets/checkbox/#component-classes","title":"Component Classes","text":"The checkbox widget inherits the following component classes:
Class Descriptiontoggle--button
Targets the toggle button itself. toggle--label
Targets the text label of the toggle button."},{"location":"widgets/checkbox/#textual.widgets.Checkbox","title":"textual.widgets.Checkbox class
","text":" Bases: ToggleButton
A check box widget that represents a boolean value.
"},{"location":"widgets/checkbox/#textual.widgets._checkbox.Checkbox.Changed","title":"Changedclass
","text":" Bases: ToggleButton.Changed
Posted when the value of the checkbox changes.
This message can be handled using an on_checkbox_changed
method.
property
","text":"checkbox: Checkbox\n
The checkbox that was changed.
"},{"location":"widgets/checkbox/#textual.widgets._checkbox.Checkbox.Changed.control","title":"controlproperty
","text":"control: Checkbox\n
An alias for Changed.checkbox.
"},{"location":"widgets/collapsible/","title":"Collapsible","text":"Added in version 0.37
A container with a title that can be used to show (expand) or hide (collapse) content, either by clicking or focusing and pressing Enter.
You can add content to a Collapsible widget either by passing in children to the constructor, or with a context manager (with
statement).
Here is an example of using the constructor to add content:
def compose(self) -> ComposeResult:\nyield Collapsible(Label(\"Hello, world.\"))\n
Here's how the to use it with the context manager:
def compose(self) -> ComposeResult:\nwith Collapsible():\nyield Label(\"Hello, world.\")\n
The second form is generally preferred, but the end result is the same.
"},{"location":"widgets/collapsible/#title","title":"Title","text":"The default title \"Toggle\" can be customized by setting the title
parameter of the constructor:
def compose(self) -> ComposeResult:\nwith Collapsible(title=\"An interesting story.\"):\nyield Label(\"Interesting but verbose story.\")\n
"},{"location":"widgets/collapsible/#initial-state","title":"Initial State","text":"The initial state of the Collapsible
widget can be customized via the collapsed
parameter of the constructor:
def compose(self) -> ComposeResult:\nwith Collapsible(title=\"Contents 1\", collapsed=False):\nyield Label(\"Hello, world.\")\nwith Collapsible(title=\"Contents 2\", collapsed=True): # Default.\nyield Label(\"Hello, world.\")\n
"},{"location":"widgets/collapsible/#collapseexpand-symbols","title":"Collapse/Expand Symbols","text":"The symbols used to show the collapsed / expanded state can be customized by setting the parameters collapsed_symbol
and expanded_symbol
:
def compose(self) -> ComposeResult:\nwith Collapsible(collapsed_symbol=\">>>\", expanded_symbol=\"v\"):\nyield Label(\"Hello, world.\")\n
"},{"location":"widgets/collapsible/#examples","title":"Examples","text":"The following example contains three Collapsible
s in different states.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Paul \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Leto \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Jessica \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Collapsible, Footer, Label, Markdown\nLETO = \"\"\"\\\n# Duke Leto I Atreides\nHead of House Atreides.\"\"\"\nJESSICA = \"\"\"\n# Lady Jessica\nBene Gesserit and concubine of Leto, and mother of Paul and Alia.\n\"\"\"\nPAUL = \"\"\"\n# Paul Atreides\nSon of Leto and Jessica.\n\"\"\"\nclass CollapsibleApp(App[None]):\n\"\"\"An example of collapsible container.\"\"\"\nBINDINGS = [\n(\"c\", \"collapse_or_expand(True)\", \"Collapse All\"),\n(\"e\", \"collapse_or_expand(False)\", \"Expand All\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Compose app with collapsible containers.\"\"\"\nyield Footer()\nwith Collapsible(collapsed=False, title=\"Leto\"):\nyield Label(LETO)\nyield Collapsible(Markdown(JESSICA), collapsed=False, title=\"Jessica\")\nwith Collapsible(collapsed=True, title=\"Paul\"):\nyield Markdown(PAUL)\ndef action_collapse_or_expand(self, collapse: bool) -> None:\nfor child in self.walk_children(Collapsible):\nchild.collapsed = collapse\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#setting-initial-state","title":"Setting Initial State","text":"The example below shows nested Collapsible
widgets and how to set their initial state.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Toggle \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Toggle
from textual.app import App, ComposeResult\nfrom textual.widgets import Collapsible, Label\nclass CollapsibleApp(App[None]):\ndef compose(self) -> ComposeResult:\nwith Collapsible(collapsed=False):\nwith Collapsible():\nyield Label(\"Hello, world.\")\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#custom-symbols","title":"Custom Symbols","text":"The following example shows Collapsible
widgets with custom expand/collapse symbols.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 >>>\u00a0Togglev\u00a0Toggle Hello,\u00a0world.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Collapsible, Label\nclass CollapsibleApp(App[None]):\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nwith Collapsible(\ncollapsed_symbol=\">>>\",\nexpanded_symbol=\"v\",\n):\nyield Label(\"Hello, world.\")\nwith Collapsible(\ncollapsed_symbol=\">>>\",\nexpanded_symbol=\"v\",\ncollapsed=False,\n):\nyield Label(\"Hello, world.\")\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description collapsed
bool
True
Controls the collapsed/expanded state of the widget."},{"location":"widgets/collapsible/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/collapsible/#bindings","title":"Bindings","text":"The collapsible widget defines the following binding on its title:
Key(s) Description enter Toggle the collapsible."},{"location":"widgets/collapsible/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/collapsible/#textual.widgets.Collapsible","title":"textual.widgets.Collapsibleclass
","text":"def __init__(\nself,\n*children,\ntitle=\"Toggle\",\ncollapsed=True,\ncollapsed_symbol=\"\u25b6\",\nexpanded_symbol=\"\u25bc\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A collapsible container.
Parameters Name Type Description Default*children
Widget
Contents that will be collapsed/expanded.
()
title
str
Title of the collapsed/expanded contents.
'Toggle'
collapsed
bool
Default status of the contents.
True
collapsed_symbol
str
Collapsed symbol before the title.
'\u25b6'
expanded_symbol
str
Expanded symbol before the title.
'\u25bc'
name
str | None
The name of the collapsible.
None
id
str | None
The ID of the collapsible in the DOM.
None
classes
str | None
The CSS classes of the collapsible.
None
disabled
bool
Whether the collapsible is disabled or not.
False
"},{"location":"widgets/content_switcher/","title":"ContentSwitcher","text":"Added in version 0.14.0
A widget for containing and switching display between multiple child widgets.
The example below uses a ContentSwitcher
in combination with two Button
s to create a simple tabbed view. Note how each Button
has an ID set, and how each child of the ContentSwitcher
has a corresponding ID; then a Button.Clicked
handler is used to set ContentSwitcher.current
to switch between the different views.
ContentSwitcherApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0DataTable\u00a0\u00a0Markdown\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u00a0Book\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Year\u00a0\u2502 \u2502\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01965\u00a0\u2502 \u2502\u00a0Dune\u00a0Messiah\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01969\u00a0\u2502 \u2502\u00a0Children\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01976\u00a0\u2502 \u2502\u00a0God\u00a0Emperor\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01981\u00a0\u2502 \u2502\u00a0Heretics\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01984\u00a0\u2502 \u2502\u00a0Chapterhouse:\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01985\u00a0\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from rich.align import VerticalCenter\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Button, ContentSwitcher, DataTable, Markdown\nMARKDOWN_EXAMPLE = \"\"\"# Three Flavours Cornetto\nThe Three Flavours Cornetto trilogy is an anthology series of British\ncomedic genre films directed by Edgar Wright.\n## Shaun of the Dead\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Strawberry | 2004-04-09 | Edgar Wright |\n## Hot Fuzz\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Classico | 2007-02-17 | Edgar Wright |\n## The World's End\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Mint | 2013-07-19 | Edgar Wright |\n\"\"\"\nclass ContentSwitcherApp(App[None]):\nCSS_PATH = \"content_switcher.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal(id=\"buttons\"): # (1)!\nyield Button(\"DataTable\", id=\"data-table\") # (2)!\nyield Button(\"Markdown\", id=\"markdown\") # (3)!\nwith ContentSwitcher(initial=\"data-table\"): # (4)!\nyield DataTable(id=\"data-table\")\nwith VerticalScroll(id=\"markdown\"):\nyield Markdown(MARKDOWN_EXAMPLE)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.query_one(ContentSwitcher).current = event.button.id # (5)!\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(\"Book\", \"Year\")\ntable.add_rows(\n[\n(title.ljust(35), year)\nfor title, year in (\n(\"Dune\", 1965),\n(\"Dune Messiah\", 1969),\n(\"Children of Dune\", 1976),\n(\"God Emperor of Dune\", 1981),\n(\"Heretics of Dune\", 1984),\n(\"Chapterhouse: Dune\", 1985),\n)\n]\n)\nif __name__ == \"__main__\":\nContentSwitcherApp().run()\n
Horizontal
to hold the buttons, each with a unique ID.DataTable
in the ContentSwitcher
.Markdown
in the ContentSwitcher
.ContentSwitcher
. Remember that IDs are unique within parent, so the buttons and the widgets in the ContentSwitcher
can share IDs.Screen {\nalign: center middle;\npadding: 1;\n}\n#buttons {\nheight: 3;\nwidth: auto;\n}\nContentSwitcher {\nbackground: $panel;\nborder: round $primary;\nwidth: 90%;\nheight: 1fr;\n}\nDataTable {\nbackground: $panel;\n}\nMarkdownH2 {\nbackground: $primary;\ncolor: yellow;\nborder: none;\npadding: 0;\n}\n
When the user presses the \"Markdown\" button the view is switched:
ContentSwitcherApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0DataTable\u00a0\u00a0Markdown\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eThree\u00a0Flavours\u00a0Cornetto\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502The\u00a0Three\u00a0Flavours\u00a0Cornetto\u00a0trilogy\u00a0is\u00a0an\u00a0anthology\u00a0series\u2502 \u2502of\u00a0British\u00a0comedic\u00a0genre\u00a0films\u00a0directed\u00a0by\u00a0Edgar\u00a0Wright.\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Shaun\u00a0of\u00a0the\u00a0Dead\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a\u2502 \u2502\u258eStrawberry\u00a0\u00a0\u00a0\u00a0\u00a02004-04-09\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Edgar\u00a0Wright\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hot\u00a0Fuzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a\u2502 \u2502\u258eClassico\u00a0\u00a0\u00a0\u00a02007-02-17\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Edgar\u00a0Wright\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0The\u00a0World's\u00a0End\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
"},{"location":"widgets/content_switcher/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Descriptioncurrent
str
| None
None
The ID of the currently-visible child. None
means nothing is visible."},{"location":"widgets/content_switcher/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/content_switcher/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/content_switcher/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/content_switcher/#textual.widgets.ContentSwitcher","title":"textual.widgets.ContentSwitcherclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\ninitial=None\n):\n
Bases: Container
A widget for switching between different children.
NoteAll child widgets that are to be switched between need a unique ID. Children that have no ID will be hidden and ignored.
Parameters Name Type Description Default*children
Widget
The widgets to switch between.
()
name
str | None
The name of the content switcher.
None
id
str | None
The ID of the content switcher in the DOM.
None
classes
str | None
The CSS classes of the content switcher.
None
disabled
bool
Whether the content switcher is disabled or not.
False
initial
str | None
The ID of the initial widget to show, None
or empty string for the first tab.
None
Note If initial
is not supplied no children will be shown to start with.
class-attribute
instance-attribute
","text":"current: reactive[str | None] = reactive[Optional[str]](\nNone, init=False\n)\n
The ID of the currently-displayed widget.
If set to None
then no widget is visible.
If set to an unknown ID, this will result in NoMatches
being raised.
property
","text":"visible_content: Widget | None\n
A reference to the currently-visible widget.
None
if nothing is visible.
method
","text":"def watch_current(self, old, new):\n
React to the current visible child choice being changed.
Parameters Name Type Description Defaultold
str | None
The old widget ID (or None
if there was no widget).
new
str | None
The new widget ID (or None
if nothing should be shown).
A table widget optimized for displaying a lot of data.
The following example shows how to fill a table with data. First, we use add_columns to include the lane
, swimmer
, country
, and time
columns in the table. After that, we use the add_rows method to insert the rows into the table.
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\ntable.add_rows(ROWS[1:])\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
To add a single row or column use add_row and add_column, respectively.
"},{"location":"widgets/data_table/#styling-and-justifying-cells","title":"Styling and justifying cells","text":"Cells can contain more than just plain strings - Rich renderables such as Text
are also supported. Text
objects provide an easy way to style and justify cell content:
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0Singapore50.39 \u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0PhelpsUnited\u00a0States51.14 \u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0South\u00a0Africa51.14 \u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary51.14 \u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China51.26 \u00a0\u00a0\u00a08\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France51.58 \u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0ShieldsUnited\u00a0States51.73 \u00a0\u00a0\u00a01Aleksandr\u00a0Sadovnikov\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Russia51.84 \u00a0\u00a010\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0Scotland51.84
from rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\nfor row in ROWS[1:]:\n# Adding styled and justified `Text` objects instead of plain strings.\nstyled_row = [\nText(str(cell), style=\"italic #03AC13\", justify=\"right\") for cell in row\n]\ntable.add_row(*styled_row)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/data_table/#keys","title":"Keys","text":"When adding a row to the table, you can supply a key to add_row. A key is a unique identifier for that row. If you don't supply a key, Textual will generate one for you and return it from add_row
. This key can later be used to reference the row, regardless of its current position in the table.
When working with data from a database, for example, you may wish to set the row key
to the primary key of the data to ensure uniqueness. The method add_column also accepts a key
argument and works similarly.
Keys are important because cells in a data table can change location due to factors like row deletion and sorting. Thus, using keys instead of coordinates allows us to refer to data without worrying about its current location in the table.
If you want to change the table based solely on coordinates, you can use the coordinate_to_cell_key method to convert a coordinate to a cell key, which is a (row_key, column_key)
pair.
The coordinate of the cursor is exposed via the cursor_coordinate
reactive attribute. Three types of cursors are supported: cell
, row
, and column
. Change the cursor type by assigning to the cursor_type
reactive attribute.
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\ncursors = cycle([\"column\", \"row\", \"cell\"])\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.cursor_type = next(cursors)\ntable.zebra_stripes = True\ntable.add_columns(*ROWS[0])\ntable.add_rows(ROWS[1:])\ndef key_c(self):\ntable = self.query_one(DataTable)\ntable.cursor_type = next(cursors)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
You can change the position of the cursor using the arrow keys, Page Up, Page Down, Home and End, or by assigning to the cursor_coordinate
reactive attribute.
Cells can be updated in the DataTable
by using the update_cell and update_cell_at methods.
To remove all data in the table, use the clear method. To remove individual rows, use remove_row. The remove_row
method accepts a key
argument, which identifies the row to be removed.
If you wish to remove the row below the cursor in the DataTable
, use coordinate_to_cell_key
to get the row key of the row under the current cursor_coordinate
, then supply this key to remove_row
:
# Get the keys for the row and column under the cursor.\nrow_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)\n# Supply the row key to `remove_row` to delete the row.\ntable.remove_row(row_key)\n
"},{"location":"widgets/data_table/#removing-columns","title":"Removing columns","text":"To remove individual columns, use remove_column. The remove_column
method accepts a key
argument, which identifies the column to be removed.
You can remove the column below the cursor using the same coordinate_to_cell_key
method described above:
# Get the keys for the row and column under the cursor.\n_, column_key = table.coordinate_to_cell_key(table.cursor_coordinate)\n# Supply the column key to `column_row` to delete the column.\ntable.remove_column(column_key)\n
"},{"location":"widgets/data_table/#fixed-data","title":"Fixed data","text":"You can fix a number of rows and columns in place, keeping them pinned to the top and left of the table respectively. To do this, assign an integer to the fixed_rows
or fixed_columns
reactive attributes of the DataTable
.
TableApp \u00a0A\u00a0\u00a0\u00a0B\u00a0\u00a0\u00a0\u00a0C\u00a0\u00a0\u00a0 \u00a01\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0 \u00a02\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0 \u00a079\u00a0\u00a0158\u00a0\u00a0237\u00a0 \u00a080\u00a0\u00a0160\u00a0\u00a0240\u00a0 \u00a081\u00a0\u00a0162\u00a0\u00a0243\u00a0 \u00a082\u00a0\u00a0164\u00a0\u00a0246\u00a0 \u00a083\u00a0\u00a0166\u00a0\u00a0249\u00a0 \u00a084\u00a0\u00a0168\u00a0\u00a0252\u00a0 \u00a085\u00a0\u00a0170\u00a0\u00a0255\u00a0 \u00a086\u00a0\u00a0172\u00a0\u00a0258\u00a0 \u00a087\u00a0\u00a0174\u00a0\u00a0261\u00a0 \u00a088\u00a0\u00a0176\u00a0\u00a0264\u00a0 \u00a089\u00a0\u00a0178\u00a0\u00a0267\u00a0 \u00a090\u00a0\u00a0180\u00a0\u00a0270\u00a0 \u00a091\u00a0\u00a0182\u00a0\u00a0273\u00a0 \u00a092\u00a0\u00a0184\u00a0\u00a0276\u00a0 \u00a093\u00a0\u00a0186\u00a0\u00a0279\u00a0 \u00a094\u00a0\u00a0188\u00a0\u00a0282\u00a0\u2587\u2587 \u00a095\u00a0\u00a0190\u00a0\u00a0285\u00a0 \u00a096\u00a0\u00a0192\u00a0\u00a0288\u00a0 \u00a097\u00a0\u00a0194\u00a0\u00a0291\u00a0 \u00a098\u00a0\u00a0196\u00a0\u00a0294\u00a0 \u00a099\u00a0\u00a0198\u00a0\u00a0297\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nclass TableApp(App):\nCSS = \"DataTable {height: 1fr}\"\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.focus()\ntable.add_columns(\"A\", \"B\", \"C\")\nfor number in range(1, 100):\ntable.add_row(str(number), str(number * 2), str(number * 3))\ntable.fixed_rows = 2\ntable.fixed_columns = 1\ntable.cursor_type = \"row\"\ntable.zebra_stripes = True\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
In the example above, we set fixed_rows
to 2
, and fixed_columns
to 1
, meaning the first two rows and the leftmost column do not scroll - they always remain visible as you scroll through the data table.
The DataTable
can be sorted using the sort method. In order to sort your data by a column, you must have supplied a key
to the add_column
method when you added it. You can then pass this key to the sort
method to sort by that column. Additionally, you can sort by multiple columns by passing multiple keys to sort
.
A \"label\" can be attached to a row using the add_row method. This will add an extra column to the left of the table which the cursor cannot interact with. This column is similar to the leftmost column in a spreadsheet containing the row numbers. The example below shows how to attach simple numbered labels to rows.
Labelled rowsdata_table_labels.pyTableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 1\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 2\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 3\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 4\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 5\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 6\u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 7\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 8\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 9\u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\nfor number, row in enumerate(ROWS[1:], start=1):\nlabel = Text(str(number), style=\"#B0FC38 italic\")\ntable.add_row(*row, label=label)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/data_table/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_header
bool
True
Show the table header show_row_labels
bool
True
Show the row labels (if applicable) fixed_rows
int
0
Number of fixed rows (rows which do not scroll) fixed_columns
int
0
Number of fixed columns (columns which do not scroll) zebra_stripes
bool
False
Display alternating colors on rows header_height
int
1
Height of header row show_cursor
bool
True
Show the cursor cursor_type
str
\"cell\"
One of \"cell\"
, \"row\"
, \"column\"
, or \"none\"
cursor_coordinate
Coordinate Coordinate(0, 0)
The current coordinate of the cursor hover_coordinate
Coordinate Coordinate(0, 0)
The coordinate the mouse cursor is above"},{"location":"widgets/data_table/#messages","title":"Messages","text":"The data table widget defines the following bindings:
Key(s) Description enter Select cells under the cursor. up Move the cursor up. down Move the cursor down. right Move the cursor right. left Move the cursor left."},{"location":"widgets/data_table/#component-classes","title":"Component Classes","text":"The data table widget provides the following component classes:
Class Descriptiondatatable--cursor
Target the cursor. datatable--hover
Target the cells under the hover cursor. datatable--fixed
Target fixed columns and fixed rows. datatable--fixed-cursor
Target highlighted and fixed columns or header. datatable--header
Target the header of the data table. datatable--header-cursor
Target cells highlighted by the cursor. datatable--header-hover
Target hovered header or row label cells. datatable--even-row
Target even rows (row indices start at 0). datatable--odd-row
Target odd rows (row indices start at 0)."},{"location":"widgets/data_table/#textual.widgets.DataTable","title":"textual.widgets.DataTable class
","text":"def __init__(\nself,\n*,\nshow_header=True,\nshow_row_labels=True,\nfixed_rows=0,\nfixed_columns=0,\nzebra_stripes=False,\nheader_height=1,\nshow_cursor=True,\ncursor_foreground_priority=\"css\",\ncursor_background_priority=\"renderable\",\ncursor_type=\"cell\",\ncell_padding=_DEFAULT_CELL_X_PADDING,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
, Generic[CellType]
A tabular widget that contains data.
Parameters Name Type Description Defaultshow_header
bool
Whether the table header should be visible or not.
True
show_row_labels
bool
Whether the row labels should be shown or not.
True
fixed_rows
int
The number of rows, counting from the top, that should be fixed and still visible when the user scrolls down.
0
fixed_columns
int
The number of columns, counting from the left, that should be fixed and still visible when the user scrolls right.
0
zebra_stripes
bool
Enables or disables a zebra effect applied to the background color of the rows of the table, where alternate colors are styled differently to improve the readability of the table.
False
header_height
int
The height, in number of cells, of the data table header.
1
show_cursor
bool
Whether the cursor should be visible when navigating the data table or not.
True
cursor_foreground_priority
Literal['renderable', 'css']
If the data associated with a cell is an arbitrary renderable with a set foreground color, this determines whether that color is prioritised over the cursor component class or not.
'css'
cursor_background_priority
Literal['renderable', 'css']
If the data associated with a cell is an arbitrary renderable with a set background color, this determines whether that color is prioritesed over the cursor component class or not.
'renderable'
cursor_type
CursorType
The type of cursor to be used when navigating the data table with the keyboard.
'cell'
cell_padding
int
The number of cells added on each side of each column. Setting this value to zero will likely make your table very heard to read.
_DEFAULT_CELL_X_PADDING
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\nBinding(\n\"right\", \"cursor_right\", \"Cursor Right\", show=False\n),\nBinding(\n\"left\", \"cursor_left\", \"Cursor Left\", show=False\n),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\n]\n
Key(s) Description enter Select cells under the cursor. up Move the cursor up. down Move the cursor down. right Move the cursor right. left Move the cursor left."},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"datatable--cursor\",\n\"datatable--hover\",\n\"datatable--fixed\",\n\"datatable--fixed-cursor\",\n\"datatable--header\",\n\"datatable--header-cursor\",\n\"datatable--header-hover\",\n\"datatable--odd-row\",\n\"datatable--even-row\",\n}\n
Class Description datatable--cursor
Target the cursor. datatable--hover
Target the cells under the hover cursor. datatable--fixed
Target fixed columns and fixed rows. datatable--fixed-cursor
Target highlighted and fixed columns or header. datatable--header
Target the header of the data table. datatable--header-cursor
Target cells highlighted by the cursor. datatable--header-hover
Target hovered header or row label cells. datatable--even-row
Target even rows (row indices start at 0). datatable--odd-row
Target odd rows (row indices start at 0)."},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cell_padding","title":"cell_padding class-attribute
instance-attribute
","text":"cell_padding = cell_padding\n
Horizontal padding between cells, applied on each side of each cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.columns","title":"columnsinstance-attribute
","text":"columns: dict[ColumnKey, Column] = {}\n
Metadata about the columns of the table, indexed by their key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_background_priority","title":"cursor_background_priorityinstance-attribute
","text":"cursor_background_priority = cursor_background_priority\n
Should we prioritize the cursor component class CSS background or the renderable background in the event where a cell contains a renderable with a background color.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_column","title":"cursor_columnproperty
","text":"cursor_column: int\n
The index of the column that the DataTable cursor is currently on.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_coordinate","title":"cursor_coordinateclass-attribute
instance-attribute
","text":"cursor_coordinate: Reactive[Coordinate] = Reactive(\nCoordinate(0, 0), repaint=False, always_update=True\n)\n
Current cursor Coordinate
.
This can be set programmatically or changed via the method move_cursor
.
instance-attribute
","text":"cursor_foreground_priority = cursor_foreground_priority\n
Should we prioritize the cursor component class CSS foreground or the renderable foreground in the event where a cell contains a renderable with a foreground color.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_row","title":"cursor_rowproperty
","text":"cursor_row: int\n
The index of the row that the DataTable cursor is currently on.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_type","title":"cursor_typeclass-attribute
instance-attribute
","text":"cursor_type: Reactive[CursorType] = cursor_type\n
The type of cursor of the DataTable
.
class-attribute
instance-attribute
","text":"fixed_columns = fixed_columns\n
The number of columns to fix (prevented from scrolling).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.fixed_rows","title":"fixed_rowsclass-attribute
instance-attribute
","text":"fixed_rows = fixed_rows\n
The number of rows to fix (prevented from scrolling).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.header_height","title":"header_heightclass-attribute
instance-attribute
","text":"header_height = header_height\n
The height of the header row (the row of column labels).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.hover_column","title":"hover_columnproperty
","text":"hover_column: int\n
The index of the column that the mouse cursor is currently hovering above.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.hover_coordinate","title":"hover_coordinateclass-attribute
instance-attribute
","text":"hover_coordinate: Reactive[Coordinate] = Reactive(\nCoordinate(0, 0), repaint=False, always_update=True\n)\n
The coordinate of the DataTable
that is being hovered.
property
","text":"hover_row: int\n
The index of the row that the mouse cursor is currently hovering above.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ordered_columns","title":"ordered_columnsproperty
","text":"ordered_columns: list[Column]\n
The list of Columns in the DataTable, ordered as they appear on screen.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ordered_rows","title":"ordered_rowsproperty
","text":"ordered_rows: list[Row]\n
The list of Rows in the DataTable, ordered as they appear on screen.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.row_count","title":"row_countproperty
","text":"row_count: int\n
The number of rows currently present in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.rows","title":"rowsinstance-attribute
","text":"rows: dict[RowKey, Row] = {}\n
Metadata about the rows of the table, indexed by their key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_cursor","title":"show_cursorclass-attribute
instance-attribute
","text":"show_cursor = show_cursor\n
Show/hide both the keyboard and hover cursor.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_header","title":"show_headerclass-attribute
instance-attribute
","text":"show_header = show_header\n
Show/hide the header row (the row of column labels).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_row_labels","title":"show_row_labelsclass-attribute
instance-attribute
","text":"show_row_labels = show_row_labels\n
Show/hide the column containing the labels of rows.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.zebra_stripes","title":"zebra_stripesclass-attribute
instance-attribute
","text":"zebra_stripes = zebra_stripes\n
Apply zebra effect on row backgrounds (light, dark, light, dark, ...).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted","title":"CellHighlightedclass
","text":"def __init__(self, data_table, value, coordinate, cell_key):\n
Bases: Message
Posted when the cursor moves to highlight a new cell.
This is only relevant when the cursor_type
is \"cell\"
. It's also posted when the cell cursor is re-enabled (by setting show_cursor=True
), and when the cursor type is changed to \"cell\"
. Can be handled using on_data_table_cell_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"cell_key: CellKey = cell_key\n
The key for the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.coordinate","title":"coordinateinstance-attribute
","text":"coordinate: Coordinate = coordinate\n
The coordinate of the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.value","title":"valueinstance-attribute
","text":"value: CellType = value\n
The value in the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected","title":"CellSelectedclass
","text":"def __init__(self, data_table, value, coordinate, cell_key):\n
Bases: Message
Posted by the DataTable
widget when a cell is selected.
This is only relevant when the cursor_type
is \"cell\"
. Can be handled using on_data_table_cell_selected
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"cell_key: CellKey = cell_key\n
The key for the selected cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.coordinate","title":"coordinateinstance-attribute
","text":"coordinate: Coordinate = coordinate\n
The coordinate of the cell that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.value","title":"valueinstance-attribute
","text":"value: CellType = value\n
The value in the cell that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted","title":"ColumnHighlightedclass
","text":"def __init__(self, data_table, cursor_column, column_key):\n
Bases: Message
Posted when a column is highlighted.
This message is only posted when the cursor_type
is set to \"column\"
. Can be handled using on_data_table_column_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"column_key = column_key\n
The key of the column that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.cursor_column","title":"cursor_columninstance-attribute
","text":"cursor_column: int = cursor_column\n
The x-coordinate of the column that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected","title":"ColumnSelectedclass
","text":"def __init__(self, data_table, cursor_column, column_key):\n
Bases: Message
Posted when a column is selected.
This message is only posted when the cursor_type
is set to \"column\"
. Can be handled using on_data_table_column_selected
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"column_key = column_key\n
The key of the column that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.cursor_column","title":"cursor_columninstance-attribute
","text":"cursor_column: int = cursor_column\n
The x-coordinate of the column that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected","title":"HeaderSelectedclass
","text":"def __init__(self, data_table, column_key, column_index, label):\n
Bases: Message
Posted when a column header/label is clicked.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.column_index","title":"column_indexinstance-attribute
","text":"column_index = column_index\n
The index for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.column_key","title":"column_keyinstance-attribute
","text":"column_key = column_key\n
The key for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.label","title":"labelinstance-attribute
","text":"label = label\n
The text of the label.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted","title":"RowHighlightedclass
","text":"def __init__(self, data_table, cursor_row, row_key):\n
Bases: Message
Posted when a row is highlighted.
This message is only posted when the cursor_type
is set to \"row\"
. Can be handled using on_data_table_row_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
property
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.cursor_row","title":"cursor_rowinstance-attribute
","text":"cursor_row: int = cursor_row\n
The y-coordinate of the cursor that highlighted the row.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.row_key","title":"row_keyinstance-attribute
","text":"row_key: RowKey = row_key\n
The key of the row that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected","title":"RowLabelSelectedclass
","text":"def __init__(self, data_table, row_key, row_index, label):\n
Bases: Message
Posted when a row label is clicked.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.label","title":"labelinstance-attribute
","text":"label = label\n
The text of the label.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.row_index","title":"row_indexinstance-attribute
","text":"row_index = row_index\n
The index for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.row_key","title":"row_keyinstance-attribute
","text":"row_key = row_key\n
The key for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected","title":"RowSelectedclass
","text":"def __init__(self, data_table, cursor_row, row_key):\n
Bases: Message
Posted when a row is selected.
This message is only posted when the cursor_type
is set to \"row\"
. Can be handled using on_data_table_row_selected
in a subclass of DataTable
or in a parent widget in the DOM.
property
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.cursor_row","title":"cursor_rowinstance-attribute
","text":"cursor_row: int = cursor_row\n
The y-coordinate of the cursor that made the selection.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.row_key","title":"row_keyinstance-attribute
","text":"row_key: RowKey = row_key\n
The key of the row that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the cursor one page down.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the cursor one page up.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_scroll_end","title":"action_scroll_endmethod
","text":"def action_scroll_end(self):\n
Scroll to the bottom of the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_scroll_home","title":"action_scroll_homemethod
","text":"def action_scroll_home(self):\n
Scroll to the top of the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_column","title":"add_columnmethod
","text":"def add_column(\nself, label, *, width=None, key=None, default=None\n):\n
Add a column to the table.
Parameters Name Type Description Defaultlabel
TextType
A str or Text object containing the label (shown top of column).
requiredwidth
int | None
Width of the column in cells or None to fit content.
None
key
str | None
A key which uniquely identifies this column. If None, it will be generated for you.
None
default
CellType | None
The value to insert into pre-existing rows.
None
Returns Type Description ColumnKey
Uniquely identifies this column. Can be used to retrieve this column regardless of its current location in the DataTable (it could have moved after being added due to sorting/insertion/deletion of other columns).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_columns","title":"add_columnsmethod
","text":"def add_columns(self, *labels):\n
Add a number of columns.
Parameters Name Type Description Default*labels
TextType
Column headers.
()
Returns Type Description list[ColumnKey]
A list of the keys for the columns that were added. See the add_column
method docstring for more information on how these keys are used.
method
","text":"def add_row(self, *cells, height=1, key=None, label=None):\n
Add a row at the bottom of the DataTable.
Parameters Name Type Description Default*cells
CellType
Positional arguments should contain cell data.
()
height
int | None
The height of a row (in lines). Use None
to auto-detect the optimal height.
1
key
str | None
A key which uniquely identifies this row. If None, it will be generated for you and returned.
None
label
TextType | None
The label for the row. Will be displayed to the left if supplied.
None
Returns Type Description RowKey
Unique identifier for this row. Can be used to retrieve this row regardless of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other rows).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_rows","title":"add_rowsmethod
","text":"def add_rows(self, rows):\n
Add a number of rows at the bottom of the DataTable.
Parameters Name Type Description Defaultrows
Iterable[Iterable[CellType]]
Iterable of rows. A row is an iterable of cells.
required Returns Type Descriptionlist[RowKey]
A list of the keys for the rows that were added. See the add_row
method docstring for more information on how these keys are used.
method
","text":"def clear(self, columns=False):\n
Clear the table.
Parameters Name Type Description Defaultcolumns
bool
Also clear the columns.
False
Returns Type Description Self
The DataTable
instance.
method
","text":"def coordinate_to_cell_key(self, coordinate):\n
Return the key for the cell currently occupying this coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to exam the current cell key of.
required Returns Type DescriptionCellKey
The key of the cell currently occupying this coordinate.
Raises Type DescriptionCellDoesNotExist
If the coordinate is not valid.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell","title":"get_cellmethod
","text":"def get_cell(self, row_key, column_key):\n
Given a row key and column key, return the value of the corresponding cell.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key of the cell.
requiredcolumn_key
ColumnKey | str
The column key of the cell.
required Returns Type DescriptionCellType
The value of the cell identified by the row and column keys.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell_at","title":"get_cell_atmethod
","text":"def get_cell_at(self, coordinate):\n
Get the value from the cell occupying the given coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to retrieve the value from.
required Returns Type DescriptionCellType
The value of the cell at the coordinate.
Raises Type DescriptionCellDoesNotExist
If there is no cell with the given coordinate.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell_coordinate","title":"get_cell_coordinatemethod
","text":"def get_cell_coordinate(self, row_key, column_key):\n
Given a row key and column key, return the corresponding cell coordinate.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key of the cell.
requiredcolumn_key
Column | str
The column key of the cell.
required Returns Type DescriptionCoordinate
The current coordinate of the cell identified by the row and column keys.
Raises Type DescriptionCellDoesNotExist
If the specified cell does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column","title":"get_columnmethod
","text":"def get_column(self, column_key):\n
Get the values from the column identified by the given column key.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The key of the column.
required Returns Type DescriptionIterable[CellType]
A generator which yields the cells in the column.
Raises Type DescriptionColumnDoesNotExist
If there is no column corresponding to the key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column_at","title":"get_column_atmethod
","text":"def get_column_at(self, column_index):\n
Get the values from the column at a given index.
Parameters Name Type Description Defaultcolumn_index
int
The index of the column.
required Returns Type DescriptionIterable[CellType]
A generator which yields the cells in the column.
Raises Type DescriptionColumnDoesNotExist
If there is no column with the given index.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column_index","title":"get_column_indexmethod
","text":"def get_column_index(self, column_key):\n
Return the current index for the column identified by column_key.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The column key to find the current index of.
required Returns Type Descriptionint
The current index of the specified column key.
Raises Type DescriptionColumnDoesNotExist
If the column key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row","title":"get_rowmethod
","text":"def get_row(self, row_key):\n
Get the values from the row identified by the given row key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key of the row.
required Returns Type Descriptionlist[CellType]
A list of the values contained within the row.
Raises Type DescriptionRowDoesNotExist
When there is no row corresponding to the key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_at","title":"get_row_atmethod
","text":"def get_row_at(self, row_index):\n
Get the values from the cells in a row at a given index. This will return the values from a row based on the rows current position in the table.
Parameters Name Type Description Defaultrow_index
int
The index of the row.
required Returns Type Descriptionlist[CellType]
A list of the values contained in the row.
Raises Type DescriptionRowDoesNotExist
If there is no row with the given index.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_height","title":"get_row_heightmethod
","text":"def get_row_height(self, row_key):\n
Given a row key, return the height of that row in terminal cells.
Parameters Name Type Description Defaultrow_key
RowKey
The key of the row.
required Returns Type Descriptionint
The height of the row, measured in terminal character cells.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_index","title":"get_row_indexmethod
","text":"def get_row_index(self, row_key):\n
Return the current index for the row identified by row_key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key to find the current index of.
required Returns Type Descriptionint
The current index of the specified row key.
Raises Type DescriptionRowDoesNotExist
If the row key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_column_index","title":"is_valid_column_indexmethod
","text":"def is_valid_column_index(self, column_index):\n
Return a boolean indicating whether the column_index is within table bounds.
Parameters Name Type Description Defaultcolumn_index
int
The column index to check.
required Returns Type Descriptionbool
True if the column index is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_coordinate","title":"is_valid_coordinatemethod
","text":"def is_valid_coordinate(self, coordinate):\n
Return a boolean indicating whether the given coordinate is valid.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to validate.
required Returns Type Descriptionbool
True if the coordinate is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_row_index","title":"is_valid_row_indexmethod
","text":"def is_valid_row_index(self, row_index):\n
Return a boolean indicating whether the row_index is within table bounds.
Parameters Name Type Description Defaultrow_index
int
The row index to check.
required Returns Type Descriptionbool
True if the row index is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.move_cursor","title":"move_cursormethod
","text":"def move_cursor(self, *, row=None, column=None, animate=False):\n
Move the cursor to the given position.
Exampledatatable = app.query_one(DataTable)\ndatatable.move_cursor(row=4, column=6)\n# datatable.cursor_coordinate == Coordinate(4, 6)\ndatatable.move_cursor(row=3)\n# datatable.cursor_coordinate == Coordinate(3, 6)\n
Parameters Name Type Description Default row
int | None
The new row to move the cursor to.
None
column
int | None
The new column to move the cursor to.
None
animate
bool
Whether to animate the change of coordinates.
False
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.refresh_column","title":"refresh_column method
","text":"def refresh_column(self, column_index):\n
Refresh the column at the given index.
Parameters Name Type Description Defaultcolumn_index
int
The index of the column to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def refresh_coordinate(self, coordinate):\n
Refresh the cell at a coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def refresh_row(self, row_index):\n
Refresh the row at the given index.
Parameters Name Type Description Defaultrow_index
int
The index of the row to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def remove_column(self, column_key):\n
Remove a column (identified by a key) from the DataTable.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The key identifying the column to remove.
required Raises Type DescriptionColumnDoesNotExist
If the column key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.remove_row","title":"remove_rowmethod
","text":"def remove_row(self, row_key):\n
Remove a row (identified by a key) from the DataTable.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key identifying the row to remove.
required Raises Type DescriptionRowDoesNotExist
If the row key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.sort","title":"sortmethod
","text":"def sort(self, *columns, reverse=False):\n
Sort the rows in the DataTable
by one or more column keys.
columns
ColumnKey | str
One or more columns to sort by the values in.
()
reverse
bool
If True, the sort order will be reversed.
False
Returns Type Description Self
The DataTable
instance.
method
","text":"def update_cell(\nself, row_key, column_key, value, *, update_width=False\n):\n
Update the cell identified by the specified row key and column key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key identifying the row.
requiredcolumn_key
ColumnKey | str
The key identifying the column.
requiredvalue
CellType
The new value to put inside the cell.
requiredupdate_width
bool
Whether to resize the column width to accommodate for the new cell content.
False
Raises Type Description CellDoesNotExist
When the supplied row_key
and column_key
cannot be found in the table.
method
","text":"def update_cell_at(\nself, coordinate, value, *, update_width=False\n):\n
Update the content inside the cell currently occupying the given coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to update the cell at.
requiredvalue
CellType
The new value to place inside the cell.
requiredupdate_width
bool
Whether to resize the column width to accommodate for the new cell content.
False
"},{"location":"widgets/data_table/#textual.widgets.data_table","title":"textual.widgets.data_table","text":""},{"location":"widgets/data_table/#textual.widgets.data_table.CursorType","title":"CursorType module-attribute
","text":"CursorType = Literal['cell', 'row', 'column', 'none']\n
The valid types of cursors for DataTable.cursor_type
.
class
","text":" Bases: Exception
The cell key/index was invalid.
Raised when the coordinates or cell key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.CellKey","title":"CellKeyclass
","text":" Bases: NamedTuple
A unique identifier for a cell in the DataTable.
Even if the cell changes visual location (i.e. moves to a different coordinate in the table), this key can still be used to retrieve it, regardless of where it currently is.
"},{"location":"widgets/data_table/#textual.widgets.data_table.Column","title":"Columnclass
","text":"Metadata for a column in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets._data_table.Column.get_render_width","title":"get_render_widthmethod
","text":"def get_render_width(self, data_table):\n
Width, in cells, required to render the column with padding included.
Parameters Name Type Description Defaultdata_table
DataTable[Any]
The data table where the column will be rendered.
required Returns Type Descriptionint
The width, in cells, required to render the column with padding included.
"},{"location":"widgets/data_table/#textual.widgets.data_table.ColumnDoesNotExist","title":"ColumnDoesNotExistclass
","text":" Bases: Exception
Raised when the column index or column key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.ColumnKey","title":"ColumnKeyclass
","text":" Bases: StringKey
Uniquely identifies a column in the DataTable.
Even if the visual location of the column changes due to sorting or other modifications, a key will always refer to the same column.
"},{"location":"widgets/data_table/#textual.widgets.data_table.DuplicateKey","title":"DuplicateKeyclass
","text":" Bases: Exception
The key supplied already exists.
Raised when the RowKey or ColumnKey provided already refers to an existing row or column in the DataTable. Keys must be unique.
"},{"location":"widgets/data_table/#textual.widgets.data_table.Row","title":"Rowclass
","text":"Metadata for a row in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets.data_table.RowDoesNotExist","title":"RowDoesNotExistclass
","text":" Bases: Exception
Raised when the row index or row key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.RowKey","title":"RowKeyclass
","text":" Bases: StringKey
Uniquely identifies a row in the DataTable.
Even if the visual location of the row changes due to sorting or other modifications, a key will always refer to the same row.
"},{"location":"widgets/digits/","title":"Digits","text":"Added in version 0.33.0
A widget to display numerical values in tall multi-line characters.
The digits 0-9 are supported, in addition to the following characters +
, -
, ^
, :
, and \u00d7
. Other characters will be displayed in a regular size font.
You can set the text to be displayed in the constructor, or call update()
to change the text after the widget has been mounted.
This widget will respect the text-align rule.
The following example displays a few digits of Pi:
Outputdigits.pyDigitApp \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2551\u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u00a0\u2513\u00a0\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2578\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u250f\u2501\u2513\u257a\u2501\u2513\u2551 \u2551\u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u00a0\u2503\u00a0\u00a0\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b\u00a0\u2523\u2501\u2513\u2517\u2501\u2513\u00a0\u2501\u252b\u00a0\u2517\u2501\u2513\u2523\u2501\u252b\u2517\u2501\u252b\u00a0\u00a0\u2503\u2551 \u2551\u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u253b\u2578,\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578,\u2517\u2501\u251b\u257a\u2501\u251b\u257a\u2501\u251b,\u257a\u2501\u251b\u2517\u2501\u251b\u257a\u2501\u251b\u00a0\u00a0\u2579\u2551 \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d
from textual.app import App, ComposeResult\nfrom textual.widgets import Digits\nclass DigitApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #pi {\n border: double green;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Digits(\"3.141,592,653,5897\", id=\"pi\")\nif __name__ == \"__main__\":\napp = DigitApp()\napp.run()\n
Here's another example which uses Digits
to display the current time:
ClockApp \u00a0\u2513\u00a0\u250f\u2501\u2578\u00a0\u00a0\u00a0\u250f\u2501\u2578\u257a\u2501\u2513\u00a0\u00a0\u00a0\u250f\u2501\u2513\u257b\u00a0\u257b \u00a0\u2503\u00a0\u2517\u2501\u2513\u00a0:\u00a0\u2517\u2501\u2513\u00a0\u2501\u252b\u00a0:\u00a0\u2503\u00a0\u2503\u2517\u2501\u252b \u257a\u253b\u2578\u257a\u2501\u251b\u00a0\u00a0\u00a0\u257a\u2501\u251b\u257a\u2501\u251b\u00a0\u00a0\u00a0\u2517\u2501\u251b\u00a0\u00a0\u2579
from datetime import datetime\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Digits\nclass ClockApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #clock {\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Digits(\"\", id=\"clock\")\ndef on_ready(self) -> None:\nself.update_clock()\nself.set_interval(1, self.update_clock)\ndef update_clock(self) -> None:\nclock = datetime.now().time()\nself.query_one(Digits).update(f\"{clock:%T}\")\nif __name__ == \"__main__\":\napp = ClockApp()\napp.run()\n
"},{"location":"widgets/digits/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/digits/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/digits/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/digits/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/digits/#textual.widgets.Digits","title":"textual.widgets.Digitsclass
","text":"def __init__(\nself,\nvalue=\"\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A widget to display numerical values using a 3x3 grid of unicode characters.
name: The name of the widget.\nid: The ID of the widget in the DOM.\nclasses: The CSS classes of the widget.\ndisabled: Whether the widget is disabled or not.\n
"},{"location":"widgets/digits/#textual.widgets._digits.Digits.value","title":"value property
","text":"value: str\n
The current value displayed in the Digits.
"},{"location":"widgets/digits/#textual.widgets._digits.Digits.update","title":"updatemethod
","text":"def update(self, value):\n
Update the Digits with a new value.
Parameters Name Type Description Defaultvalue
str
New value to display.
required Raises Type DescriptionValueError
If the value isn't a str
.
A tree control to navigate the contents of your filesystem.
The example below creates a simple tree to navigate the current working directory.
from textual.app import App, ComposeResult\nfrom textual.widgets import DirectoryTree\nclass DirectoryTreeApp(App):\ndef compose(self) -> ComposeResult:\nyield DirectoryTree(\"./\")\nif __name__ == \"__main__\":\napp = DirectoryTreeApp()\napp.run()\n
"},{"location":"widgets/directory_tree/#filtering","title":"Filtering","text":"There may be times where you want to filter what appears in the DirectoryTree
. To do this inherit from DirectoryTree
and implement your own version of the filter_paths
method. It should take an iterable of Python Path
objects, and return those that pass the filter. For example, if you wanted to take the above code an filter out all of the \"hidden\" files and directories:
DirectoryTreeApp \ud83d\udcc2\u00a0. \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0__pycache__ \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0dist \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0docs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0examples \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0imgs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0notes \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0questions \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0reference \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0sandbox \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0site \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0src \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tests \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tools \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CHANGELOG.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CODE_OF_CONDUCT.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CONTRIBUTING.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0docs.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0faq.yml \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0keys.log \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0LICENSE \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0Makefile \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0mkdocs-common.yml \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0mkdocs-nav-offline.yml
from pathlib import Path\nfrom typing import Iterable\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DirectoryTree\nclass FilteredDirectoryTree(DirectoryTree):\ndef filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:\nreturn [path for path in paths if not path.name.startswith(\".\")]\nclass DirectoryTreeApp(App):\ndef compose(self) -> ComposeResult:\nyield FilteredDirectoryTree(\"./\")\nif __name__ == \"__main__\":\napp = DirectoryTreeApp()\napp.run()\n
"},{"location":"widgets/directory_tree/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_root
bool
True
Show the root node. show_guides
bool
True
Show guide lines between levels. guide_depth
int
4
Amount of indentation between parent and child."},{"location":"widgets/directory_tree/#messages","title":"Messages","text":"The directory tree widget inherits the bindings from the tree widget.
"},{"location":"widgets/directory_tree/#component-classes","title":"Component Classes","text":"The directory tree widget provides the following component classes:
Class Descriptiondirectory-tree--extension
Target the extension of a file name. directory-tree--file
Target files in the directory structure. directory-tree--folder
Target folders in the directory structure. directory-tree--hidden
Target hidden items in the directory structure. See also the component classes for Tree
.
class
","text":"def __init__(\nself,\npath,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Tree[DirEntry]
A Tree widget that presents files and directories.
Parameters Name Type Description Defaultpath
str | Path
Path to directory.
requiredname
str | None
The name of the widget, or None for no name.
None
id
str | None
The ID of the widget in the DOM, or None for no ID.
None
classes
str | None
A space-separated list of classes, or None for no classes.
None
disabled
bool
Whether the directory tree is disabled or not.
False
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"directory-tree--extension\",\n\"directory-tree--file\",\n\"directory-tree--folder\",\n\"directory-tree--hidden\",\n}\n
Class Description directory-tree--extension
Target the extension of a file name. directory-tree--file
Target files in the directory structure. directory-tree--folder
Target folders in the directory structure. directory-tree--hidden
Target hidden items in the directory structure. See also the component classes for Tree
.
class-attribute
instance-attribute
","text":"PATH: Callable[[str | Path], Path] = Path\n
Callable that returns a fresh path object.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.path","title":"pathclass-attribute
instance-attribute
","text":"path: var[str | Path] = path\n
The path that is the root of the directory tree.
NoteThis can be set to either a str
or a pathlib.Path
object, but the value will always be a pathlib.Path
object.
class
","text":"def __init__(self, node, path):\n
Bases: Message
Posted when a directory is selected.
Can be handled using on_directory_tree_directory_selected
in a subclass of DirectoryTree
or in a parent widget in the DOM.
node
TreeNode[DirEntry]
The tree node for the directory that was selected.
requiredpath
Path
The path of the directory that was selected.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.DirectorySelected.control","title":"controlproperty
","text":"control: Tree[DirEntry]\n
The Tree
that had a directory selected.
instance-attribute
","text":"node: TreeNode[DirEntry] = node\n
The tree node of the directory that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.DirectorySelected.path","title":"pathinstance-attribute
","text":"path: Path = path\n
The path of the directory that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected","title":"FileSelectedclass
","text":"def __init__(self, node, path):\n
Bases: Message
Posted when a file is selected.
Can be handled using on_directory_tree_file_selected
in a subclass of DirectoryTree
or in a parent widget in the DOM.
node
TreeNode[DirEntry]
The tree node for the file that was selected.
requiredpath
Path
The path of the file that was selected.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected.control","title":"controlproperty
","text":"control: Tree[DirEntry]\n
The Tree
that had a file selected.
instance-attribute
","text":"node: TreeNode[DirEntry] = node\n
The tree node of the file that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected.path","title":"pathinstance-attribute
","text":"path: Path = path\n
The path of the file that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.clear_node","title":"clear_nodemethod
","text":"def clear_node(self, node):\n
Clear all nodes under the given node.
Returns Type DescriptionSelf
The Tree
instance.
method
","text":"def filter_paths(self, paths):\n
Filter the paths before adding them to the tree.
Parameters Name Type Description Defaultpaths
Iterable[Path]
The paths to be filtered.
required Returns Type DescriptionIterable[Path]
The filtered paths.
By default this method returns all of the paths provided. To create a filtered DirectoryTree
inherit from it and implement your own version of this method.
method
","text":"def process_label(self, label):\n
Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
Parameters Name Type Description Defaultlabel
TextType
Label.
required Returns Type DescriptionText
A Rich Text object.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.reload","title":"reloadmethod
","text":"def reload(self):\n
Reload the DirectoryTree
contents.
method
","text":"def reload_node(self, node):\n
Reload the given node's contents.
Parameters Name Type Description Defaultnode
TreeNode[DirEntry]
The node to reload.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.render_label","title":"render_labelmethod
","text":"def render_label(self, node, base_style, style):\n
Render a label for the given node.
Parameters Name Type Description Defaultnode
TreeNode[DirEntry]
A tree node.
requiredbase_style
Style
The base style of the widget.
requiredstyle
Style
The additional style for the label.
required Returns Type DescriptionText
A Rich Text object containing the label.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.reset_node","title":"reset_nodemethod
","text":"def reset_node(self, node, label, data=None):\n
Clear the subtree and reset the given node.
Parameters Name Type Description Defaultlabel
TextType
The label for the node.
requireddata
DirEntry | None
Optional data for the node.
None
Returns Type Description Self
The Tree
instance.
method
","text":"def validate_path(self, path):\n
Ensure that the path is of the Path
type.
path
str | Path
The path to validate.
required Returns Type DescriptionPath
The validated Path value.
NoteThe result will always be a Python Path
object, regardless of the value given.
method
","text":"def watch_path(self):\n
Watch for changes to the path
of the directory tree.
If the path is changed the directory tree will be repopulated using the new value as the root.
"},{"location":"widgets/footer/","title":"Footer","text":"A simple footer widget which is docked to the bottom of its parent container. Displays available keybindings for the currently focused widget.
The example below shows an app with a single keybinding that contains only a Footer
widget. Notice how the Footer
automatically displays the keybinding.
FooterApp \u00a0Q\u00a0\u00a0Quit\u00a0the\u00a0app\u00a0\u00a0?\u00a0\u00a0Show\u00a0help\u00a0screen\u00a0\u00a0DELETE\u00a0\u00a0Delete\u00a0the\u00a0thing\u00a0
from textual.app import App, ComposeResult\nfrom textual.binding import Binding\nfrom textual.widgets import Footer\nclass FooterApp(App):\nBINDINGS = [\nBinding(key=\"q\", action=\"quit\", description=\"Quit the app\"),\nBinding(\nkey=\"question_mark\",\naction=\"help\",\ndescription=\"Show help screen\",\nkey_display=\"?\",\n),\nBinding(key=\"delete\", action=\"delete\", description=\"Delete the thing\"),\nBinding(key=\"j\", action=\"down\", description=\"Scroll down\", show=False),\n]\ndef compose(self) -> ComposeResult:\nyield Footer()\nif __name__ == \"__main__\":\napp = FooterApp()\napp.run()\n
"},{"location":"widgets/footer/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlight_key
str
None
Stores the currently highlighted key. This is typically the key the cursor is hovered over in the footer."},{"location":"widgets/footer/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/footer/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/footer/#component-classes","title":"Component Classes","text":"The footer widget provides the following component classes:
Class Descriptionfooter--description
Targets the descriptions of the key bindings. footer--highlight
Targets the highlighted key binding. footer--highlight-key
Targets the key portion of the highlighted key binding. footer--key
Targets the key portions of the key bindings."},{"location":"widgets/footer/#additional-notes","title":"Additional Notes","text":"show
argument of the Binding
to False
.key_display
argument of Binding
.class
","text":"def __init__(self):\n
Bases: Widget
A simple footer widget which docks itself to the bottom of the parent container.
"},{"location":"widgets/footer/#textual.widgets._footer.Footer.COMPONENT_CLASSES","title":"COMPONENT_CLASSESclass-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"footer--description\",\n\"footer--key\",\n\"footer--highlight\",\n\"footer--highlight-key\",\n}\n
Class Description footer--description
Targets the descriptions of the key bindings. footer--highlight
Targets the highlighted key binding. footer--highlight-key
Targets the key portion of the highlighted key binding. footer--key
Targets the key portions of the key bindings."},{"location":"widgets/footer/#textual.widgets._footer.Footer.watch_highlight_key","title":"watch_highlight_key async
","text":"def watch_highlight_key(self):\n
If highlight key changes we need to regenerate the text.
"},{"location":"widgets/header/","title":"Header","text":"A simple header widget which docks itself to the top of the parent container.
Note
The application title which is shown in the header is taken from the title
and sub_title
of the application.
The example below shows an app with a Header
.
HeaderApp \u2b58HeaderApp
from textual.app import App, ComposeResult\nfrom textual.widgets import Header\nclass HeaderApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nif __name__ == \"__main__\":\napp = HeaderApp()\napp.run()\n
This example shows how to set the text in the Header
using App.title
and App.sub_title
:
HeaderApp \u2b58Header\u00a0Application\u00a0\u2014\u00a0With\u00a0title\u00a0and\u00a0sub-title
from textual.app import App, ComposeResult\nfrom textual.widgets import Header\nclass HeaderApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\ndef on_mount(self) -> None:\nself.title = \"Header Application\"\nself.sub_title = \"With title and sub-title\"\nif __name__ == \"__main__\":\napp = HeaderApp()\napp.run()\n
"},{"location":"widgets/header/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description tall
bool
True
Whether the Header
widget is displayed as tall or not. The tall variant is 3 cells tall by default. The non-tall variant is a single cell tall. This can be toggled by clicking on the header."},{"location":"widgets/header/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/header/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/header/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/header/#textual.widgets.Header","title":"textual.widgets.Headerclass
","text":"def __init__(\nself,\nshow_clock=False,\n*,\nname=None,\nid=None,\nclasses=None\n):\n
Bases: Widget
A header widget with icon and clock.
Parameters Name Type Description Defaultshow_clock
bool
True
if the clock should be shown on the right of the header.
False
name
str | None
The name of the header widget.
None
id
str | None
The ID of the header widget in the DOM.
None
classes
str | None
The CSS classes of the header widget.
None
"},{"location":"widgets/header/#textual.widgets._header.Header.screen_sub_title","title":"screen_sub_title property
","text":"screen_sub_title: str\n
The sub-title that this header will display.
This depends on Screen.sub_title
and App.sub_title
.
property
","text":"screen_title: str\n
The title that this header will display.
This depends on Screen.title
and App.title
.
class-attribute
instance-attribute
","text":"tall: Reactive[bool] = Reactive(False)\n
Set to True
for a taller header or False
for a single line header.
A single-line text input widget.
The example below shows how you might create a simple form using two Input
widgets.
InputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aDarren\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aLast\u00a0Name\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Input\nclass InputApp(App):\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"First Name\")\nyield Input(placeholder=\"Last Name\")\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
"},{"location":"widgets/input/#validating-input","title":"Validating Input","text":"You can supply one or more validators to the Input
widget to validate the value.
All the supplied validators will run when the value changes, the Input
is submitted, or focus moves out of the Input
. The values \"changed\"
, \"submitted\"
, and \"blur\"
, can be passed as an iterable to the Input
parameter validate_on
to request that validation occur only on the respective mesages. (See InputValidationOn
and Input.validate_on
.) For example, the code below creates an Input
widget that only gets validated when the value is submitted explicitly:
input = Input(validate_on=[\"submitted\"])\n
Validation is considered to have failed if any of the validators fail.
You can check whether the validation succeeded or failed inside an Input.Changed or Input.Submitted handler by looking at the validation_result
attribute on these events.
In the example below, we show how to combine multiple validators and update the UI to tell the user why validation failed. Click the tabs to see the output for validation failures and successes.
input_validation.pyValidation FailureValidation Successfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.validation import Function, Number, ValidationResult, Validator\nfrom textual.widgets import Input, Label, Pretty\nclass InputApp(App):\n# (6)!\nCSS = \"\"\"\n Input.-valid {\n border: tall $success 60%;\n }\n Input.-valid:focus {\n border: tall $success;\n }\n Input {\n margin: 1 1;\n }\n Label {\n margin: 1 2;\n }\n Pretty {\n margin: 1 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Enter an even number between 1 and 100 that is also a palindrome.\")\nyield Input(\nplaceholder=\"Enter a number...\",\nvalidators=[\nNumber(minimum=1, maximum=100), # (1)!\nFunction(is_even, \"Value is not even.\"), # (2)!\nPalindrome(), # (3)!\n],\n)\nyield Pretty([])\n@on(Input.Changed)\ndef show_invalid_reasons(self, event: Input.Changed) -> None:\n# Updating the UI to show the reasons why validation failed\nif not event.validation_result.is_valid: # (4)!\nself.query_one(Pretty).update(event.validation_result.failure_descriptions)\nelse:\nself.query_one(Pretty).update([])\ndef is_even(value: str) -> bool:\ntry:\nreturn int(value) % 2 == 0\nexcept ValueError:\nreturn False\n# A custom validator\nclass Palindrome(Validator): # (5)!\ndef validate(self, value: str) -> ValidationResult:\n\"\"\"Check a string is equal to its reverse.\"\"\"\nif self.is_palindrome(value):\nreturn self.success()\nelse:\nreturn self.failure(\"That's not a palindrome :/\")\n@staticmethod\ndef is_palindrome(value: str) -> bool:\nreturn value == value[::-1]\napp = InputApp()\nif __name__ == \"__main__\":\napp.run()\n
Number
is a built-in Validator
. It checks that the value in the Input
is a valid number, and optionally can check that it falls within a range.Function
lets you quickly define custom validation constraints. In this case, we check the value in the Input
is even.Palindrome
is a custom Validator
defined below.Input.Changed
event has a validation_result
attribute which contains information about the validation that occurred when the value changed.self.failure
corresponds to the message seen on UI.-invalid
CSS class (a red border), which is automatically applied to Input
when validation fails. We can also provide custom styling for the -valid
class, as seen here. In this case, we add a green border around the Input
to indicate successful validation.InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a-23\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e [ 'Must\u00a0be\u00a0between\u00a01\u00a0and\u00a0100.', 'Value\u00a0is\u00a0not\u00a0even.', \"That's\u00a0not\u00a0a\u00a0palindrome\u00a0:/\" ]
InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a44\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e []
Textual offers several built-in validators for common requirements, but you can easily roll your own by extending Validator, as seen for Palindrome
in the example above.
cursor_blink
bool
True
True if cursor blinking is enabled. value
str
\"\"
The value currently in the text input. cursor_position
int
0
The index of the cursor in the value string. placeholder
str
str
The dimmed placeholder text to display when the input is empty. password
bool
False
True if the input should be masked."},{"location":"widgets/input/#messages","title":"Messages","text":"The input widget defines the following bindings:
Key(s) Description left Move the cursor left. ctrl+left Move the cursor one word to the left. right Move the cursor right or accept the completion suggestion. ctrl+right Move the cursor one word to the right. backspace Delete the character to the left of the cursor. home,ctrl+a Go to the beginning of the input. end,ctrl+e Go to the end of the input. delete,ctrl+d Delete the character to the right of the cursor. enter Submit the current value of the input. ctrl+w Delete the word to the left of the cursor. ctrl+u Delete everything to the left of the cursor. ctrl+f Delete the word to the right of the cursor. ctrl+k Delete everything to the right of the cursor."},{"location":"widgets/input/#component-classes","title":"Component Classes","text":"The input widget provides the following component classes:
Class Descriptioninput--cursor
Target the cursor. input--placeholder
Target the placeholder text (when it exists). input--suggestion
Target the auto-completion suggestion (when it exists)."},{"location":"widgets/input/#additional-notes","title":"Additional Notes","text":"border: none;
in your CSS.class
","text":"def __init__(\nself,\nvalue=None,\nplaceholder=\"\",\nhighlighter=None,\npassword=False,\n*,\nsuggester=None,\nvalidators=None,\nvalidate_on=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A text input widget.
Parameters Name Type Description Defaultvalue
str | None
An optional default value for the input.
None
placeholder
str
Optional placeholder text for the input.
''
highlighter
Highlighter | None
An optional highlighter for the input.
None
password
bool
Flag to say if the field should obfuscate its content.
False
suggester
Suggester | None
Suggester
associated with this input instance.
None
validators
Validator | Iterable[Validator] | None
An iterable of validators that the Input value will be checked against.
None
validate_on
Iterable[InputValidationOn] | None
Zero or more of the values \"blur\", \"changed\", and \"submitted\", which determine when to do input validation. The default is to do validation for all messages.
None
name
str | None
Optional name for the input widget.
None
id
str | None
Optional ID for the widget.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the input is disabled or not.
False
"},{"location":"widgets/input/#textual.widgets._input.Input.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"left\", \"cursor_left\", \"cursor left\", show=False\n),\nBinding(\n\"ctrl+left\",\n\"cursor_left_word\",\n\"cursor left word\",\nshow=False,\n),\nBinding(\n\"right\", \"cursor_right\", \"cursor right\", show=False\n),\nBinding(\n\"ctrl+right\",\n\"cursor_right_word\",\n\"cursor right word\",\nshow=False,\n),\nBinding(\n\"backspace\",\n\"delete_left\",\n\"delete left\",\nshow=False,\n),\nBinding(\"home,ctrl+a\", \"home\", \"home\", show=False),\nBinding(\"end,ctrl+e\", \"end\", \"end\", show=False),\nBinding(\n\"delete,ctrl+d\",\n\"delete_right\",\n\"delete right\",\nshow=False,\n),\nBinding(\"enter\", \"submit\", \"submit\", show=False),\nBinding(\n\"ctrl+w\",\n\"delete_left_word\",\n\"delete left to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+u\",\n\"delete_left_all\",\n\"delete all to the left\",\nshow=False,\n),\nBinding(\n\"ctrl+f\",\n\"delete_right_word\",\n\"delete right to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+k\",\n\"delete_right_all\",\n\"delete all to the right\",\nshow=False,\n),\n]\n
Key(s) Description left Move the cursor left. ctrl+left Move the cursor one word to the left. right Move the cursor right or accept the completion suggestion. ctrl+right Move the cursor one word to the right. backspace Delete the character to the left of the cursor. home,ctrl+a Go to the beginning of the input. end,ctrl+e Go to the end of the input. delete,ctrl+d Delete the character to the right of the cursor. enter Submit the current value of the input. ctrl+w Delete the word to the left of the cursor. ctrl+u Delete everything to the left of the cursor. ctrl+f Delete the word to the right of the cursor. ctrl+k Delete everything to the right of the cursor."},{"location":"widgets/input/#textual.widgets._input.Input.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"input--cursor\",\n\"input--placeholder\",\n\"input--suggestion\",\n}\n
Class Description input--cursor
Target the cursor. input--placeholder
Target the placeholder text (when it exists). input--suggestion
Target the auto-completion suggestion (when it exists)."},{"location":"widgets/input/#textual.widgets._input.Input.cursor_screen_offset","title":"cursor_screen_offset property
","text":"cursor_screen_offset: Offset\n
The offset of the cursor of this input in screen-space. (x, y)/(column, row)
"},{"location":"widgets/input/#textual.widgets._input.Input.cursor_width","title":"cursor_widthproperty
","text":"cursor_width: int\n
The width of the input (with extra space for cursor at the end).
"},{"location":"widgets/input/#textual.widgets._input.Input.suggester","title":"suggesterinstance-attribute
","text":"suggester: Suggester | None = suggester\n
The suggester used to provide completions as the user types.
"},{"location":"widgets/input/#textual.widgets._input.Input.validate_on","title":"validate_oninstance-attribute
","text":"validate_on = (\nset(validate_on) & _POSSIBLE_VALIDATE_ON_VALUES\nif validate_on is not None\nelse _POSSIBLE_VALIDATE_ON_VALUES\n)\n
Set with event names to do input validation on.
Validation can only be performed on blur, on input changes and on input submission.
ExampleThis creates an Input
widget that only gets validated when the value is submitted explicitly:
input = Input(validate_on=[\"submitted\"])\n
"},{"location":"widgets/input/#textual.widgets._input.Input.Changed","title":"Changed class
","text":" Bases: Message
Posted when the value changes.
Can be handled using on_input_changed
in a subclass of Input
or in a parent widget in the DOM.
property
","text":"control: Input\n
Alias for self.input.
"},{"location":"widgets/input/#textual.widgets._input.Input.Changed.input","title":"inputinstance-attribute
","text":"input: Input\n
The Input
widget that was changed.
class-attribute
instance-attribute
","text":"validation_result: ValidationResult | None = None\n
The result of validating the value (formed by combining the results from each validator), or None if validation was not performed (for example when no validators are specified in the Input
s init)
instance-attribute
","text":"value: str\n
The value that the input was changed to.
"},{"location":"widgets/input/#textual.widgets._input.Input.Submitted","title":"Submittedclass
","text":" Bases: Message
Posted when the enter key is pressed within an Input
.
Can be handled using on_input_submitted
in a subclass of Input
or in a parent widget in the DOM.
property
","text":"control: Input\n
Alias for self.input.
"},{"location":"widgets/input/#textual.widgets._input.Input.Submitted.input","title":"inputinstance-attribute
","text":"input: Input\n
The Input
widget that is being submitted.
class-attribute
instance-attribute
","text":"validation_result: ValidationResult | None = None\n
The result of validating the value on submission, formed by combining the results for each validator. This value will be None if no validation was performed, which will be the case if no validators are supplied to the corresponding Input
widget.
instance-attribute
","text":"value: str\n
The value of the Input
being submitted.
method
","text":"def action_cursor_left(self):\n
Move the cursor one position to the left.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_left_word","title":"action_cursor_left_wordmethod
","text":"def action_cursor_left_word(self):\n
Move the cursor left to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_right","title":"action_cursor_rightmethod
","text":"def action_cursor_right(self):\n
Accept an auto-completion or move the cursor one position to the right.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_right_word","title":"action_cursor_right_wordmethod
","text":"def action_cursor_right_word(self):\n
Move the cursor right to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left","title":"action_delete_leftmethod
","text":"def action_delete_left(self):\n
Delete one character to the left of the current cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left_all","title":"action_delete_left_allmethod
","text":"def action_delete_left_all(self):\n
Delete all characters to the left of the cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left_word","title":"action_delete_left_wordmethod
","text":"def action_delete_left_word(self):\n
Delete leftward of the cursor position to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right","title":"action_delete_rightmethod
","text":"def action_delete_right(self):\n
Delete one character at the current cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right_all","title":"action_delete_right_allmethod
","text":"def action_delete_right_all(self):\n
Delete the current character and all characters to the right of the cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right_word","title":"action_delete_right_wordmethod
","text":"def action_delete_right_word(self):\n
Delete the current character and all rightward to the start of the next word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_end","title":"action_endmethod
","text":"def action_end(self):\n
Move the cursor to the end of the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_home","title":"action_homemethod
","text":"def action_home(self):\n
Move the cursor to the start of the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_submit","title":"action_submitasync
","text":"def action_submit(self):\n
Handle a submit action.
Normally triggered by the user pressing Enter. This may also run any validators.
"},{"location":"widgets/input/#textual.widgets._input.Input.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.insert_text_at_cursor","title":"insert_text_at_cursormethod
","text":"def insert_text_at_cursor(self, text):\n
Insert new text at the cursor, move the cursor to the end of the new text.
Parameters Name Type Description Defaulttext
str
New text to insert.
required"},{"location":"widgets/input/#textual.widgets._input.Input.validate","title":"validatemethod
","text":"def validate(self, value):\n
Run all the validators associated with this Input on the supplied value.
Runs all validators, combines the result into one. If any of the validators failed, the combined result will be a failure. If no validators are present, None will be returned. This also sets the -invalid
CSS class on the Input if the validation fails, and sets the -valid
CSS class on the Input if the validation succeeds.
ValidationResult | None
A ValidationResult indicating whether all validators succeeded or not. That is, if any validator fails, the result will be an unsuccessful validation.
"},{"location":"widgets/label/","title":"Label","text":"Added in version 0.5.0
A widget which displays static text, but which can also contain more complex Rich renderables.
The example below shows how you can use a Label
widget to display some text.
LabelApp Hello,\u00a0world!
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass LabelApp(App):\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, world!\")\nif __name__ == \"__main__\":\napp = LabelApp()\napp.run()\n
"},{"location":"widgets/label/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/label/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/label/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/label/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/label/#textual.widgets.Label","title":"textual.widgets.Labelclass
","text":" Bases: Static
A simple label widget for displaying text-oriented renderables.
"},{"location":"widgets/list_item/","title":"ListItem","text":"Added in version 0.6.0
ListItem
is the type of the elements in a ListView
.
The example below shows an app with a simple ListView
, consisting of multiple ListItem
s. The arrow keys can be used to navigate the list.
ListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
"},{"location":"widgets/list_item/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
bool
False
True if this ListItem is highlighted"},{"location":"widgets/list_item/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/list_item/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/list_item/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/list_item/#textual.widgets.ListItem","title":"textual.widgets.ListItemclass
","text":" Bases: Widget
A widget that is an item within a ListView
.
A ListItem
is designed for use within a ListView, please see ListView
's documentation for more details on use.
class-attribute
instance-attribute
","text":"highlighted = reactive(False)\n
Is this item highlighted?
"},{"location":"widgets/list_view/","title":"ListView","text":"Added in version 0.6.0
Displays a vertical list of ListItem
s which can be highlighted and selected. Supports keyboard navigation.
The example below shows an app with a simple ListView
.
ListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
Screen {\nalign: center middle;\n}\nListView {\nwidth: 30;\nheight: auto;\nmargin: 2 2;\n}\nLabel {\npadding: 1 2;\n}\n
"},{"location":"widgets/list_view/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description index
int
0
The currently highlighted index."},{"location":"widgets/list_view/#messages","title":"Messages","text":"The list view widget defines the following bindings:
Key(s) Description enter Select the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/list_view/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/list_view/#textual.widgets.ListView","title":"textual.widgets.ListViewclass
","text":"def __init__(\nself,\n*children,\ninitial_index=0,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: VerticalScroll
A vertical list view widget.
Displays a vertical list of ListItem
s which can be highlighted and selected using the mouse or keyboard.
index
The index in the list that's currently highlighted.
Parameters Name Type Description Default*children
ListItem
The ListItems to display in the list.
()
initial_index
int | None
The index that should be highlighted when the list is first mounted.
0
name
str | None
The name of the widget.
None
id
str | None
The unique ID of the widget used in CSS/query selection.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the ListView is disabled or not.
False
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\n]\n
Key(s) Description enter Select the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.highlighted_child","title":"highlighted_child property
","text":"highlighted_child: ListItem | None\n
The currently highlighted ListItem, or None if nothing is highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Highlighted","title":"Highlightedclass
","text":"def __init__(self, list_view, item):\n
Bases: Message
Posted when the highlighted item changes.
Highlighted item is controlled using up/down keys. Can be handled using on_list_view_highlighted
in a subclass of ListView
or in a parent widget in the DOM.
class-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'item'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: ListView\n
The view that contains the item highlighted.
This is an alias for Highlighted.list_view
and is used by the on
decorator.
instance-attribute
","text":"item: ListItem | None = item\n
The highlighted item, if there is one highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Highlighted.list_view","title":"list_viewinstance-attribute
","text":"list_view: ListView = list_view\n
The view that contains the item highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Selected","title":"Selectedclass
","text":"def __init__(self, list_view, item):\n
Bases: Message
Posted when a list item is selected, e.g. when you press the enter key on it.
Can be handled using on_list_view_selected
in a subclass of ListView
or in a parent widget in the DOM.
class-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'item'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: ListView\n
The view that contains the item selected.
This is an alias for Selected.list_view
and is used by the on
decorator.
instance-attribute
","text":"item: ListItem = item\n
The selected item.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Selected.list_view","title":"list_viewinstance-attribute
","text":"list_view: ListView = list_view\n
The view that contains the item selected.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_cursor_down","title":"action_cursor_downmethod
","text":"def action_cursor_down(self):\n
Highlight the next item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Highlight the previous item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_select_cursor","title":"action_select_cursormethod
","text":"def action_select_cursor(self):\n
Select the current item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.append","title":"appendmethod
","text":"def append(self, item):\n
Append a new ListItem to the end of the ListView.
Parameters Name Type Description Defaultitem
ListItem
The ListItem to append.
required Returns Type DescriptionAwaitMount
An awaitable that yields control to the event loop until the DOM has been updated with the new child item.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.clear","title":"clearmethod
","text":"def clear(self):\n
Clear all items from the ListView.
Returns Type DescriptionAwaitRemove
An awaitable that yields control to the event loop until the DOM has been updated to reflect all children being removed.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.extend","title":"extendmethod
","text":"def extend(self, items):\n
Append multiple new ListItems to the end of the ListView.
Parameters Name Type Description Defaultitems
Iterable[ListItem]
The ListItems to append.
required Returns Type DescriptionAwaitMount
An awaitable that yields control to the event loop until the DOM has been updated with the new child items.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.validate_index","title":"validate_indexmethod
","text":"def validate_index(self, index):\n
Clamp the index to the valid range, or set to None if there's nothing to highlight.
Parameters Name Type Description Defaultindex
int | None
The index to clamp.
required Returns Type Descriptionint | None
The clamped index.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.watch_index","title":"watch_indexmethod
","text":"def watch_index(self, old_index, new_index):\n
Updates the highlighting when the index changes.
"},{"location":"widgets/loading_indicator/","title":"LoadingIndicator","text":"Added in version 0.15.0
Displays pulsating dots to indicate when data is being loaded.
Simple usage example:
Outputloading_indicator.pyLoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf
from textual.app import App, ComposeResult\nfrom textual.widgets import LoadingIndicator\nclass LoadingApp(App):\ndef compose(self) -> ComposeResult:\nyield LoadingIndicator()\nif __name__ == \"__main__\":\napp = LoadingApp()\napp.run()\n
"},{"location":"widgets/loading_indicator/#changing-indicator-color","title":"Changing Indicator Color","text":"You can set the color of the loading indicator by setting its color
style.
Here's how you would do that with CSS:
LoadingIndicator {\ncolor: red;\n}\n
"},{"location":"widgets/loading_indicator/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/loading_indicator/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/loading_indicator/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/loading_indicator/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/loading_indicator/#textual.widgets.LoadingIndicator","title":"textual.widgets.LoadingIndicatorclass
","text":" Bases: Widget
Display an animated loading indicator.
"},{"location":"widgets/log/","title":"Log","text":"Added in version 0.32.0
A Log widget displays lines of text which may be appended to in realtime.
Call Log.write_line to write a line at a time, or Log.write_lines to write multiple lines at once. Call Log.clear to clear the Log widget.
Tip
See also RichLog which can write more than just text, and supports a number of advanced features.
The example below shows how to write text to a Log
widget:
LogApp And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2584\u2584 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
from textual.app import App, ComposeResult\nfrom textual.widgets import Log\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass LogApp(App):\n\"\"\"An app with a simple log.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Log()\ndef on_ready(self) -> None:\nlog = self.query_one(Log)\nlog.write_line(\"Hello, World!\")\nfor _ in range(10):\nlog.write_line(TEXT)\nif __name__ == \"__main__\":\napp = LogApp()\napp.run()\n
"},{"location":"widgets/log/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description max_lines
int
None
Maximum number of lines in the log or None
for no maximum. auto_scroll
bool
False
Scroll to end of log when new lines are added."},{"location":"widgets/log/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/log/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/log/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/log/#textual.widgets.Log","title":"textual.widgets.Logclass
","text":"def __init__(\nself,\nhighlight=False,\nmax_lines=None,\nauto_scroll=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Bases: ScrollView
A widget to log text.
Parameters Name Type Description Defaulthighlight
bool
Enable highlighting.
False
max_lines
int | None
Maximum number of lines to display.
None
auto_scroll
bool
Scroll to end on new lines.
True
name
str | None
The name of the text log.
None
id
str | None
The ID of the text log in the DOM.
None
classes
str | None
The CSS classes of the text log.
None
disabled
bool
Whether the text log is disabled or not.
False
"},{"location":"widgets/log/#textual.widgets._log.Log.auto_scroll","title":"auto_scroll class-attribute
instance-attribute
","text":"auto_scroll: var[bool] = auto_scroll\n
Automatically scroll to new lines.
"},{"location":"widgets/log/#textual.widgets._log.Log.highlight","title":"highlightinstance-attribute
","text":"highlight = highlight\n
Enable highlighting.
"},{"location":"widgets/log/#textual.widgets._log.Log.highlighter","title":"highlighterinstance-attribute
","text":"highlighter = ReprHighlighter()\n
The Rich Highlighter object to use, if highlight=True
property
","text":"line_count: int\n
Number of lines of content.
"},{"location":"widgets/log/#textual.widgets._log.Log.lines","title":"linesproperty
","text":"lines: Sequence[str]\n
The raw lines in the TextLog.
Note that this attribute is read only. Changing the lines will not update the Log's contents.
"},{"location":"widgets/log/#textual.widgets._log.Log.max_lines","title":"max_linesclass-attribute
instance-attribute
","text":"max_lines: var[int | None] = max_lines\n
Maximum number of lines to show
"},{"location":"widgets/log/#textual.widgets._log.Log.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the Log.
Returns Type DescriptionSelf
The Log
instance.
method
","text":"def notify_style_update(self):\n
Called by Textual when styles update.
"},{"location":"widgets/log/#textual.widgets._log.Log.refresh_lines","title":"refresh_linesmethod
","text":"def refresh_lines(self, y_start, line_count=1):\n
Refresh one or more lines.
Parameters Name Type Description Defaulty_start
int
First line to refresh.
requiredline_count
int
Total number of lines to refresh.
1
"},{"location":"widgets/log/#textual.widgets._log.Log.write","title":"write method
","text":"def write(self, data, scroll_end=None):\n
Write to the log.
Parameters Name Type Description Defaultdata
str
Data to write.
requiredscroll_end
bool | None
Scroll to the end after writing, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The Log
instance.
method
","text":"def write_line(self, line):\n
Write content on a new line.
Parameters Name Type Description Defaultline
str
String to write to the log.
required Returns Type DescriptionSelf
The Log
instance.
method
","text":"def write_lines(self, lines, scroll_end=None):\n
Write an iterable of lines.
Parameters Name Type Description Defaultlines
Iterable[str]
An iterable of strings to write.
requiredscroll_end
bool | None
Scroll to the end after writing, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The Log
instance.
Added in version 0.11.0
A widget to display a Markdown document.
Tip
See MarkdownViewer for a widget that adds additional features such as a Table of Contents.
"},{"location":"widgets/markdown/#example","title":"Example","text":"The following example displays Markdown from a string.
Outputmarkdown.pyMarkdownExampleApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eMarkdown\u00a0Document\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 This\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0Markdown\u00a0widget. \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Markdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u25cf\u00a0Headers \u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u25cf\u00a0Tables!
from textual.app import App, ComposeResult\nfrom textual.widgets import Markdown\nEXAMPLE_MARKDOWN = \"\"\"\\\n# Markdown Document\nThis is an example of Textual's `Markdown` widget.\n## Features\nMarkdown syntax and extensions are supported.\n- Typography *emphasis*, **strong**, `inline code` etc.\n- Headers\n- Lists (bullet and ordered)\n- Syntax highlighted code blocks\n- Tables!\n\"\"\"\nclass MarkdownExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Markdown(EXAMPLE_MARKDOWN)\nif __name__ == \"__main__\":\napp = MarkdownExampleApp()\napp.run()\n
"},{"location":"widgets/markdown/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/markdown/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/markdown/#component-classes","title":"Component Classes","text":"The markdown widget provides the following component classes:
These component classes target standard inline markdown styles. Changing these will potentially break the standard markdown formatting.
Class Descriptioncode_inline
Target text that is styled as inline code. em
Target text that is emphasized inline. s
Target text that is styled inline with strykethrough. strong
Target text that is styled inline with strong."},{"location":"widgets/markdown/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nmarkdown=None,\n*,\nname=None,\nid=None,\nclasses=None,\nparser_factory=None\n):\n
Bases: Widget
markdown
str | None
String containing Markdown or None to leave blank for now.
None
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
parser_factory
Callable[[], MarkdownIt] | None
A factory function to return a configured MarkdownIt instance. If None
, a \"gfm-like\" parser is used.
None
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
instance-attribute
","text":"COMPONENT_CLASSES = {'em', 'strong', 's', 'code_inline'}\n
These component classes target standard inline markdown styles. Changing these will potentially break the standard markdown formatting.
Class Descriptioncode_inline
Target text that is styled as inline code. em
Target text that is emphasized inline. s
Target text that is styled inline with strykethrough. strong
Target text that is styled inline with strong."},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked","title":"LinkClicked class
","text":"def __init__(self, markdown, href):\n
Bases: Message
A link in the document was clicked.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget containing the link clicked.
This is an alias for LinkClicked.markdown
and is used by the on
decorator.
instance-attribute
","text":"href: str = href\n
The link that was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked.markdown","title":"markdowninstance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget containing the link clicked.
class
","text":"def __init__(self, markdown, block_id):\n
Bases: Message
An item in the TOC was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsSelected.block_id","title":"block_idinstance-attribute
","text":"block_id: str = block_id\n
ID of the block that was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsSelected.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget where the selected item is.
This is an alias for TableOfContentsSelected.markdown
and is used by the on
decorator.
instance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget where the selected item is.
class
","text":"def __init__(self, markdown, table_of_contents):\n
Bases: Message
The table of contents was updated.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsUpdated.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget associated with the table of contents.
This is an alias for TableOfContentsUpdated.markdown
and is used by the on
decorator.
instance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget associated with the table of contents.
instance-attribute
","text":"table_of_contents: TableOfContentsType = table_of_contents\n
Table of contents.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.goto_anchor","title":"goto_anchormethod
","text":"def goto_anchor(self, anchor):\n
Try and find the given anchor in the current document.
Parameters Name Type Description Defaultanchor
str
The anchor to try and find.
required NoteThe anchor is found by looking at all of the headings in the document and finding the first one whose slug matches the anchor.
Note that the slugging method used is similar to that found on GitHub.
Returns Type Descriptionbool
True when the anchor was found in the current document, False otherwise.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.load","title":"loadasync
","text":"def load(self, path):\n
Load a new Markdown document.
Parameters Name Type Description Defaultpath
Path
Path to the document.
required Raises Type DescriptionOSError
If there was some form of error loading the document.
NoteThe exceptions that can be raised by this method are all of those that can be raised by calling Path.read_text
.
staticmethod
","text":"def sanitize_location(location):\n
Given a location, break out the path and any anchor.
Parameters Name Type Description Defaultlocation
str
The location to sanitize.
required Returns Type DescriptionPath
A tuple of the path to the location cleaned of any anchor, plus
str
the anchor (or an empty string if none was found).
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.unhandled_token","title":"unhandled_tokenmethod
","text":"def unhandled_token(self, token):\n
Process an unhandled token.
Parameters Name Type Description Defaulttoken
Token
The token to handle.
required Returns Type DescriptionMarkdownBlock | None
Either a widget to be added to the output, or None
.
method
","text":"def update(self, markdown):\n
Update the document with new Markdown.
Parameters Name Type Description Defaultmarkdown
str
A string containing Markdown.
required Returns Type DescriptionAwaitMount
An optionally awaitable object. Await this to ensure that all children have been mounted.
"},{"location":"widgets/markdown_viewer/","title":"MarkdownViewer","text":"Added in version 0.11.0
A Widget to display Markdown content with an optional Table of Contents.
Note
This Widget adds browser-like functionality on top of the Markdown widget.
"},{"location":"widgets/markdown_viewer/#example","title":"Example","text":"The following example displays Markdown from a string and a Table of Contents.
Outputmarkdown.pyMarkdownExampleApp \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u25bc\u00a0\u2160\u00a0Markdown\u00a0Viewer\u258a\u258e\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Features\u258a\u258eMarkdown\u00a0Viewer\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Tables\u258a\u258e\u258a \u2514\u2500\u2500\u00a0\u2161\u00a0Code\u00a0Blocks\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aThis\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0MarkdownViewer\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aMarkdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u258a \u258a\u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u258a\u25cf\u00a0Headers \u258a\u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u258a\u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u258a\u25cf\u00a0Tables! \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tables\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aTables\u00a0are\u00a0displayed\u00a0in\u00a0a\u00a0DataTable\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258eName\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0TypeDefaultDescription\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a \u258a\u258eshow_headerboolTrueShow\u00a0the\u00a0table\u00a0header\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_rowsint0Number\u00a0of\u00a0fixed\u00a0rows\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_columnsint0Number\u00a0of\u00a0fixed\u00a0columns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258ezebra_stripesboolFalseDisplay\u00a0alternating\u00a0colors\u00a0on\u00a0rows\u258a \u258a\u258eheader_heightint1Height\u00a0of\u00a0header\u00a0row\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258eshow_cursorboolTrueShow\u00a0a\u00a0cell\u00a0cursor\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a
from textual.app import App, ComposeResult\nfrom textual.widgets import MarkdownViewer\nEXAMPLE_MARKDOWN = \"\"\"\\\n# Markdown Viewer\nThis is an example of Textual's `MarkdownViewer` widget.\n## Features\nMarkdown syntax and extensions are supported.\n- Typography *emphasis*, **strong**, `inline code` etc.\n- Headers\n- Lists (bullet and ordered)\n- Syntax highlighted code blocks\n- Tables!\n## Tables\nTables are displayed in a DataTable widget.\n| Name | Type | Default | Description |\n| --------------- | ------ | ------- | ---------------------------------- |\n| `show_header` | `bool` | `True` | Show the table header |\n| `fixed_rows` | `int` | `0` | Number of fixed rows |\n| `fixed_columns` | `int` | `0` | Number of fixed columns |\n| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows |\n| `header_height` | `int` | `1` | Height of header row |\n| `show_cursor` | `bool` | `True` | Show a cell cursor |\n## Code Blocks\nCode blocks are syntax highlighted, with guidelines.\n```python\nclass ListViewExample(App):\n def compose(self) -> ComposeResult:\n yield ListView(\n ListItem(Label(\"One\")),\n ListItem(Label(\"Two\")),\n ListItem(Label(\"Three\")),\n )\n yield Footer()\n```\n\"\"\"\nclass MarkdownExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield MarkdownViewer(EXAMPLE_MARKDOWN, show_table_of_contents=True)\nif __name__ == \"__main__\":\napp = MarkdownExampleApp()\napp.run()\n
"},{"location":"widgets/markdown_viewer/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_table_of_contents
bool True Wether a Table of Contents should be displayed with the Markdown."},{"location":"widgets/markdown_viewer/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/markdown_viewer/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/markdown_viewer/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/markdown_viewer/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nmarkdown=None,\n*,\nshow_table_of_contents=True,\nname=None,\nid=None,\nclasses=None,\nparser_factory=None\n):\n
Bases: VerticalScroll
A Markdown viewer widget.
Parameters Name Type Description Defaultmarkdown
str | None
String containing Markdown, or None to leave blank.
None
show_table_of_contents
bool
Show a table of contents in a sidebar.
True
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
parser_factory
Callable[[], MarkdownIt] | None
A factory function to return a configured MarkdownIt instance. If None
, a \"gfm-like\" parser is used.
None
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.document","title":"document property
","text":"document: Markdown\n
The Markdown document object.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.table_of_contents","title":"table_of_contentsproperty
","text":"table_of_contents: MarkdownTableOfContents\n
The table of contents widget
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.back","title":"backasync
","text":"def back(self):\n
Go back one level in the history.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.forward","title":"forwardasync
","text":"def forward(self):\n
Go forward one level in the history.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.go","title":"goasync
","text":"def go(self, location):\n
Navigate to a new document path.
"},{"location":"widgets/option_list/","title":"OptionList","text":"Added in version 0.17.0
A widget for showing a vertical list of Rich renderable options.
An OptionList
can be constructed with a simple collection of string options:
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258aGemenon\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258aPicon\u258e \u258aSagittaron\u258e \u258aScorpia\u258e \u258aTauron\u258e \u258aVirgon\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(\n\"Aerilon\",\n\"Aquaria\",\n\"Canceron\",\n\"Caprica\",\n\"Gemenon\",\n\"Leonis\",\n\"Libran\",\n\"Picon\",\n\"Sagittaron\",\n\"Scorpia\",\n\"Tauron\",\n\"Virgon\",\n)\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#options-as-option-instances","title":"Options as Option
instances","text":"For finer control over the options, the Option
class can be used; this allows for setting IDs, setting initial disabled state, etc. The Separator
class can be used to add separator lines between options.
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aGemenon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aPicon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aSagittaron\u2584\u2584\u258e \u258aScorpia\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nfrom textual.widgets.option_list import Option, Separator\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(\nOption(\"Aerilon\", id=\"aer\"),\nOption(\"Aquaria\", id=\"aqu\"),\nSeparator(),\nOption(\"Canceron\", id=\"can\"),\nOption(\"Caprica\", id=\"cap\", disabled=True),\nSeparator(),\nOption(\"Gemenon\", id=\"gem\"),\nSeparator(),\nOption(\"Leonis\", id=\"leo\"),\nOption(\"Libran\", id=\"lib\"),\nSeparator(),\nOption(\"Picon\", id=\"pic\"),\nSeparator(),\nOption(\"Sagittaron\", id=\"sag\"),\nOption(\"Scorpia\", id=\"sco\"),\nSeparator(),\nOption(\"Tauron\", id=\"tau\"),\nSeparator(),\nOption(\"Virgon\", id=\"vir\"),\n)\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#options-as-rich-renderables","title":"Options as Rich renderables","text":"Because the prompts for the options can be Rich renderables, this means they can be any height you wish. As an example, here is an option list comprised of Rich tables:
Outputoption_list_tables.pyoption_list.tcssOptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aerilon\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u2583\u2583\u258e \u258a\u2502Demeter\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25021.2\u00a0Billion\u00a0\u00a0\u00a0\u2502Gaoth\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aquaria\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hermes\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250275,000\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502None\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Canceron\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from __future__ import annotations\nfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nCOLONIES: tuple[tuple[str, str, str, str], ...] = (\n(\"Aerilon\", \"Demeter\", \"1.2 Billion\", \"Gaoth\"),\n(\"Aquaria\", \"Hermes\", \"75,000\", \"None\"),\n(\"Canceron\", \"Hephaestus\", \"6.7 Billion\", \"Hades\"),\n(\"Caprica\", \"Apollo\", \"4.9 Billion\", \"Caprica City\"),\n(\"Gemenon\", \"Hera\", \"2.8 Billion\", \"Oranu\"),\n(\"Leonis\", \"Artemis\", \"2.6 Billion\", \"Luminere\"),\n(\"Libran\", \"Athena\", \"2.1 Billion\", \"None\"),\n(\"Picon\", \"Poseidon\", \"1.4 Billion\", \"Queenstown\"),\n(\"Sagittaron\", \"Zeus\", \"1.7 Billion\", \"Tawa\"),\n(\"Scorpia\", \"Dionysus\", \"450 Million\", \"Celeste\"),\n(\"Tauron\", \"Ares\", \"2.5 Billion\", \"Hypatia\"),\n(\"Virgon\", \"Hestia\", \"4.3 Billion\", \"Boskirk\"),\n)\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\n@staticmethod\ndef colony(name: str, god: str, population: str, capital: str) -> Table:\ntable = Table(title=f\"Data for {name}\", expand=True)\ntable.add_column(\"Patron God\")\ntable.add_column(\"Population\")\ntable.add_column(\"Capital City\")\ntable.add_row(god, population, capital)\nreturn table\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(*[self.colony(*row) for row in COLONIES])\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
int
| None
None
The index of the highlighted option. None
means nothing is highlighted."},{"location":"widgets/option_list/#messages","title":"Messages","text":"Both of the messages above inherit from the common base OptionList
, so refer to its documentation to see what attributes are available.
The option list widget defines the following bindings:
Key(s) Description down Move the highlight down. end Move the highlight to the last option. enter Select the current option. home Move the highlight to the first option. pagedown Move the highlight down a page of options. pageup Move the highlight up a page of options. up Move the highlight up."},{"location":"widgets/option_list/#component-classes","title":"Component Classes","text":"The option list provides the following component classes:
Class Descriptionoption-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/option_list/#textual.widgets.OptionList","title":"textual.widgets.OptionList class
","text":"def __init__(\nself,\n*content,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\nwrap=True\n):\n
Bases: ScrollView
A vertical option list with bounce-bar highlighting.
Parameters Name Type Description Default*content
NewOptionListContent
The content for the option list.
()
name
str | None
The name of the option list.
None
id
str | None
The ID of the option list in the DOM.
None
classes
str | None
The CSS classes of the option list.
None
disabled
bool
Whether the option list is disabled or not.
False
wrap
bool
Should prompts be auto-wrapped?
True
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"down\", \"cursor_down\", \"Down\", show=False),\nBinding(\"end\", \"last\", \"Last\", show=False),\nBinding(\"enter\", \"select\", \"Select\", show=False),\nBinding(\"home\", \"first\", \"First\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\"up\", \"cursor_up\", \"Up\", show=False),\n]\n
Key(s) Description down Move the highlight down. end Move the highlight to the last option. enter Select the current option. home Move the highlight to the first option. pagedown Move the highlight down a page of options. pageup Move the highlight up a page of options. up Move the highlight up."},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"option-list--option\",\n\"option-list--option-disabled\",\n\"option-list--option-highlighted\",\n\"option-list--option-highlighted-disabled\",\n\"option-list--option-hover\",\n\"option-list--option-hover-disabled\",\n\"option-list--option-hover-highlighted\",\n\"option-list--option-hover-highlighted-disabled\",\n\"option-list--separator\",\n}\n
Class Description option-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.highlighted","title":"highlighted class-attribute
instance-attribute
","text":"highlighted: reactive[int | None] = None\n
The index of the currently-highlighted option, or None
if no option is highlighted.
property
","text":"option_count: int\n
The count of options.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionHighlighted","title":"OptionHighlightedclass
","text":" Bases: OptionMessage
Message sent when an option is highlighted.
Can be handled using on_option_list_option_highlighted
in a subclass of OptionList
or in a parent node in the DOM.
class
","text":"def __init__(self, option_list, index):\n
Bases: Message
Base class for all option messages.
Parameters Name Type Description Defaultoption_list
OptionList
The option list that owns the option.
requiredindex
int
The index of the option that the message relates to.
required"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.control","title":"controlproperty
","text":"control: OptionList\n
The option list that sent the message.
This is an alias for OptionMessage.option_list
and is used by the on
decorator.
instance-attribute
","text":"option: Option = option_list.get_option_at_index(index)\n
The highlighted option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_id","title":"option_idinstance-attribute
","text":"option_id: str | None = self.option.id\n
The ID of the option that the message relates to.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_index","title":"option_indexinstance-attribute
","text":"option_index: int = index\n
The index of the option that the message relates to.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_list","title":"option_listinstance-attribute
","text":"option_list: OptionList = option_list\n
The option list that sent the message.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionSelected","title":"OptionSelectedclass
","text":" Bases: OptionMessage
Message sent when an option is selected.
Can be handled using on_option_list_option_selected
in a subclass of OptionList
or in a parent node in the DOM.
method
","text":"def action_cursor_down(self):\n
Move the highlight down by one option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Move the highlight up by one option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_first","title":"action_firstmethod
","text":"def action_first(self):\n
Move the highlight to the first option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_last","title":"action_lastmethod
","text":"def action_last(self):\n
Move the highlight to the last option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the highlight down one page.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the highlight up one page.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_select","title":"action_selectmethod
","text":"def action_select(self):\n
Select the currently-highlighted option.
If no option is selected, then nothing happens. If an option is selected, a OptionList.OptionSelected message will be posted.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.add_option","title":"add_optionmethod
","text":"def add_option(self, item=None):\n
Add a new option to the end of the option list.
Parameters Name Type Description Defaultitem
NewOptionListContent
The new item to add.
None
Returns Type Description Self
The OptionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.add_options","title":"add_optionsmethod
","text":"def add_options(self, items):\n
Add new options to the end of the option list.
Parameters Name Type Description Defaultitems
Iterable[NewOptionListContent]
The new items to add.
required Returns Type DescriptionSelf
The OptionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
NoteAll options are checked for duplicate IDs before any option is added. A duplicate ID will cause none of the passed items to be added to the option list.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.clear_options","title":"clear_optionsmethod
","text":"def clear_options(self):\n
Clear the content of the option list.
Returns Type DescriptionSelf
The OptionList
instance.
method
","text":"def disable_option(self, option_id):\n
Disable the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to disable.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.disable_option_at_index","title":"disable_option_at_indexmethod
","text":"def disable_option_at_index(self, index):\n
Disable the option at the given index.
Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.enable_option","title":"enable_optionmethod
","text":"def enable_option(self, option_id):\n
Enable the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to enable.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.enable_option_at_index","title":"enable_option_at_indexmethod
","text":"def enable_option_at_index(self, index):\n
Enable the option at the given index.
Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option","title":"get_optionmethod
","text":"def get_option(self, option_id):\n
Get the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to get.
required Returns Type DescriptionOption
The option with the ID.
Raises Type DescriptionOptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option_at_index","title":"get_option_at_indexmethod
","text":"def get_option_at_index(self, index):\n
Get the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to get.
required Returns Type DescriptionOption
The option at that index.
Raises Type DescriptionOptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option_index","title":"get_option_indexmethod
","text":"def get_option_index(self, option_id):\n
Get the index of the option with the given ID.
Parameters Name Type Description Defaultoption_id
The ID of the option to get the index of.
required Raises Type DescriptionOptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.remove_option","title":"remove_optionmethod
","text":"def remove_option(self, option_id):\n
Remove the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to remove.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.remove_option_at_index","title":"remove_option_at_indexmethod
","text":"def remove_option_at_index(self, index):\n
Remove the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to remove.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.replace_option_prompt","title":"replace_option_promptmethod
","text":"def replace_option_prompt(self, option_id, prompt):\n
Replace the prompt of the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to replace the prompt of.
requiredprompt
RenderableType
The new prompt for the option.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.replace_option_prompt_at_index","title":"replace_option_prompt_at_indexmethod
","text":"def replace_option_prompt_at_index(self, index, prompt):\n
Replace the prompt of the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to replace the prompt of.
requiredprompt
RenderableType
The new prompt for the option.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.scroll_to_highlight","title":"scroll_to_highlightmethod
","text":"def scroll_to_highlight(self, top=False):\n
Ensure that the highlighted option is in view.
Parameters Name Type Description Defaulttop
bool
Scroll highlight to top of the list.
False
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.validate_highlighted","title":"validate_highlighted method
","text":"def validate_highlighted(self, highlighted):\n
Validate the highlighted
property value on access.
method
","text":"def watch_highlighted(self, highlighted):\n
React to the highlighted option having changed.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.watch_show_vertical_scrollbar","title":"watch_show_vertical_scrollbarmethod
","text":"def watch_show_vertical_scrollbar(self):\n
Handle the vertical scrollbar visibility status changing.
show_vertical_scrollbar
is watched because it has an impact on the available width in which to render the renderables that make up the options in the list. If a vertical scrollbar appears or disappears we need to recalculate all the lines that make up the list.
class
","text":" Bases: Exception
Exception raised if a duplicate ID is used.
"},{"location":"widgets/option_list/#textual.widgets.option_list.Option","title":"Optionclass
","text":"def __init__(self, prompt, id=None, disabled=False):\n
Class that holds the details of an individual option.
Parameters Name Type Description Defaultprompt
RenderableType
The prompt for the option.
requiredid
str | None
The optional ID for the option.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.id","title":"id property
","text":"id: str | None\n
The optional ID for the option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.prompt","title":"promptproperty
","text":"prompt: RenderableType\n
The prompt for the option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.set_prompt","title":"set_promptmethod
","text":"def set_prompt(self, prompt):\n
Set the prompt for the option.
Parameters Name Type Description Defaultprompt
RenderableType
The new prompt for the option.
required"},{"location":"widgets/option_list/#textual.widgets.option_list.OptionDoesNotExist","title":"OptionDoesNotExistclass
","text":" Bases: Exception
Exception raised when a request has been made for an option that doesn't exist.
"},{"location":"widgets/option_list/#textual.widgets.option_list.Separator","title":"Separatorclass
","text":"Class used to add a separator to an OptionList.
"},{"location":"widgets/placeholder/","title":"Placeholder","text":"Added in version 0.6.0
A widget that is meant to have no complex functionality. Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.
The placeholder widget has variants that display different bits of useful information. Clicking a placeholder will cycle through its variants.
The example below shows each placeholder variant.
Outputplaceholder.pyplaceholder.tcssPlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula. Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0 vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedconsectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0 lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 sapien\u00a0sapien\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass PlaceholderApp(App):\nCSS_PATH = \"placeholder.tcss\"\ndef compose(self) -> ComposeResult:\nyield VerticalScroll(\nContainer(\nPlaceholder(\"This is a custom label for p1.\", id=\"p1\"),\nPlaceholder(\"Placeholder p2 here!\", id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(),\nHorizontal(\nPlaceholder(variant=\"size\", id=\"col1\"),\nPlaceholder(variant=\"text\", id=\"col2\"),\nPlaceholder(variant=\"size\", id=\"col3\"),\nid=\"c1\",\n),\nid=\"bot\",\n),\nContainer(\nPlaceholder(variant=\"text\", id=\"left\"),\nPlaceholder(variant=\"size\", id=\"topright\"),\nPlaceholder(variant=\"text\", id=\"botright\"),\nid=\"top\",\n),\nid=\"content\",\n)\nif __name__ == \"__main__\":\napp = PlaceholderApp()\napp.run()\n
Placeholder {\nheight: 100%;\n}\n#top {\nheight: 50%;\nwidth: 100%;\nlayout: grid;\ngrid-size: 2 2;\n}\n#left {\nrow-span: 2;\n}\n#bot {\nheight: 50%;\nwidth: 100%;\nlayout: grid;\ngrid-size: 8 8;\n}\n#c1 {\nrow-span: 4;\ncolumn-span: 8;\nheight: 100%;\n}\n#col1, #col2, #col3 {\nwidth: 1fr;\n}\n#p1 {\nrow-span: 4;\ncolumn-span: 4;\n}\n#p2 {\nrow-span: 2;\ncolumn-span: 4;\n}\n#p3 {\nrow-span: 2;\ncolumn-span: 2;\n}\n#p4 {\nrow-span: 1;\ncolumn-span: 2;\n}\n
"},{"location":"widgets/placeholder/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description variant
str
\"default\"
Styling variant. One of default
, size
, text
."},{"location":"widgets/placeholder/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/placeholder/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/placeholder/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/placeholder/#textual.widgets.Placeholder","title":"textual.widgets.Placeholderclass
","text":"def __init__(\nself,\nlabel=None,\nvariant=\"default\",\n*,\nname=None,\nid=None,\nclasses=None\n):\n
Bases: Widget
A simple placeholder widget to use before you build your custom widgets.
This placeholder has a couple of variants that show different data. Clicking the placeholder cycles through the available variants, but a placeholder can also be initialised in a specific variant.
The variants available are:
Variant Placeholder shows default Identifier label or the ID of the placeholder. size Size of the placeholder. text Lorem Ipsum text. Parameters Name Type Description Defaultlabel
str | None
The label to identify the placeholder. If no label is present, uses the placeholder ID instead.
None
variant
PlaceholderVariant
The variant of the placeholder.
'default'
name
str | None
The name of the placeholder.
None
id
str | None
The ID of the placeholder in the DOM.
None
classes
str | None
A space separated string with the CSS classes of the placeholder, if any.
None
"},{"location":"widgets/placeholder/#textual.widgets._placeholder.Placeholder.variant","title":"variant class-attribute
instance-attribute
","text":"variant: Reactive[\nPlaceholderVariant\n] = self.validate_variant(variant)\n
The current variant of the placeholder.
"},{"location":"widgets/placeholder/#textual.widgets._placeholder.Placeholder.cycle_variant","title":"cycle_variantmethod
","text":"def cycle_variant(self):\n
Get the next variant in the cycle.
Returns Type DescriptionSelf
The Placeholder
instance.
method
","text":"def validate_variant(self, variant):\n
Validate the variant to which the placeholder was set.
"},{"location":"widgets/pretty/","title":"Pretty","text":"Display a pretty-formatted object.
The example below shows a pretty-formatted dict
, but Pretty
can display any Python object.
PrettyExample { 'title':\u00a0'Back\u00a0to\u00a0the\u00a0Future', 'releaseYear':\u00a01985, 'director':\u00a0'Robert\u00a0Zemeckis', 'genre':\u00a0'Adventure,\u00a0Comedy,\u00a0Sci-Fi', 'cast':\u00a0[ {'actor':\u00a0'Michael\u00a0J.\u00a0Fox',\u00a0'character':\u00a0'Marty\u00a0McFly'}, {'actor':\u00a0'Christopher\u00a0Lloyd',\u00a0'character':\u00a0'Dr.\u00a0Emmett\u00a0Brown'} ] }
from textual.app import App, ComposeResult\nfrom textual.widgets import Pretty\nDATA = {\n\"title\": \"Back to the Future\",\n\"releaseYear\": 1985,\n\"director\": \"Robert Zemeckis\",\n\"genre\": \"Adventure, Comedy, Sci-Fi\",\n\"cast\": [\n{\"actor\": \"Michael J. Fox\", \"character\": \"Marty McFly\"},\n{\"actor\": \"Christopher Lloyd\", \"character\": \"Dr. Emmett Brown\"},\n],\n}\nclass PrettyExample(App):\ndef compose(self) -> ComposeResult:\nyield Pretty(DATA)\napp = PrettyExample()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/pretty/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/pretty/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/pretty/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/pretty/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/pretty/#textual.widgets.Pretty","title":"textual.widgets.Prettyclass
","text":"def __init__(self, object, *, name=None, id=None, classes=None):\n
Bases: Widget
A pretty-printing widget.
Used to pretty-print any object.
Parameters Name Type Description Defaultobject
Any
The object to pretty-print.
requiredname
str | None
The name of the pretty widget.
None
id
str | None
The ID of the pretty in the DOM.
None
classes
str | None
The CSS classes of the pretty.
None
"},{"location":"widgets/pretty/#textual.widgets._pretty.Pretty.update","title":"update method
","text":"def update(self, object):\n
Update the content of the pretty widget.
Parameters Name Type Description Defaultobject
Any
The object to pretty-print.
required"},{"location":"widgets/progress_bar/","title":"ProgressBar","text":"A widget that displays progress on a time-consuming task.
The example below shows a progress bar in isolation. It shows the progress bar in:
total
progress hasn't been set yet;IndeterminateProgressBar \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501--%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
IndeterminateProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250139%00:00:07 \u00a0S\u00a0\u00a0Start\u00a0
IndeterminateProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Center, Middle\nfrom textual.timer import Timer\nfrom textual.widgets import Footer, ProgressBar\nclass IndeterminateProgressBar(App[None]):\nBINDINGS = [(\"s\", \"start\", \"Start\")]\nprogress_timer: Timer\n\"\"\"Timer to simulate progress happening.\"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nwith Middle():\nyield ProgressBar()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Set up a timer to simulate progess happening.\"\"\"\nself.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)\ndef make_progress(self) -> None:\n\"\"\"Called automatically to advance the progress bar.\"\"\"\nself.query_one(ProgressBar).advance(1)\ndef action_start(self) -> None:\n\"\"\"Start the progress tracking.\"\"\"\nself.query_one(ProgressBar).update(total=100)\nself.progress_timer.resume()\nif __name__ == \"__main__\":\nIndeterminateProgressBar().run()\n
"},{"location":"widgets/progress_bar/#complete-app-example","title":"Complete App Example","text":"The example below shows a simple app with a progress bar that is keeping track of a fictitious funding level for an organisation.
OutputOutput (partial funding)Output (full funding)progress_bar.pyprogress_bar.tcssFunding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25010% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250135% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$15\u00a0received! Donation\u00a0for\u00a0$20\u00a0received!
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$15\u00a0received! Donation\u00a0for\u00a0$20\u00a0received! Donation\u00a0for\u00a0$65\u00a0received!
from textual.app import App, ComposeResult\nfrom textual.containers import Center, VerticalScroll\nfrom textual.widgets import Button, Header, Input, Label, ProgressBar\nclass FundingProgressApp(App[None]):\nCSS_PATH = \"progress_bar.tcss\"\nTITLE = \"Funding tracking\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Center():\nyield Label(\"Funding: \")\nyield ProgressBar(total=100, show_eta=False) # (1)!\nwith Center():\nyield Input(placeholder=\"$$$\")\nyield Button(\"Donate\")\nyield VerticalScroll(id=\"history\")\ndef on_button_pressed(self) -> None:\nself.add_donation()\ndef on_input_submitted(self) -> None:\nself.add_donation()\ndef add_donation(self) -> None:\ntext_value = self.query_one(Input).value\ntry:\nvalue = int(text_value)\nexcept ValueError:\nreturn\nself.query_one(ProgressBar).advance(value)\nself.query_one(VerticalScroll).mount(Label(f\"Donation for ${value} received!\"))\nself.query_one(Input).value = \"\"\nif __name__ == \"__main__\":\nFundingProgressApp().run()\n
100
steps and we hide the ETA countdown because we are not keeping track of a continuous, uninterrupted task.Container {\noverflow: hidden hidden;\nheight: auto;\n}\nCenter {\nmargin-top: 1;\nmargin-bottom: 1;\nlayout: horizontal;\n}\nProgressBar {\npadding-left: 3;\n}\nInput {\nwidth: 16;\n}\nVerticalScroll {\nheight: auto;\n}\n
"},{"location":"widgets/progress_bar/#custom-styling","title":"Custom Styling","text":"This shows a progress bar with custom styling. Refer to the section below for more information.
Indeterminate state39% doneCompletedprogress_bar_styled.pyprogress_bar_styled.tcssStyledProgressBar \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501--%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
StyledProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250139%00:00:07 \u00a0S\u00a0\u00a0Start\u00a0
StyledProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Center, Middle\nfrom textual.timer import Timer\nfrom textual.widgets import Footer, ProgressBar\nclass StyledProgressBar(App[None]):\nBINDINGS = [(\"s\", \"start\", \"Start\")]\nCSS_PATH = \"progress_bar_styled.tcss\"\nprogress_timer: Timer\n\"\"\"Timer to simulate progress happening.\"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nwith Middle():\nyield ProgressBar()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Set up a timer to simulate progess happening.\"\"\"\nself.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)\ndef make_progress(self) -> None:\n\"\"\"Called automatically to advance the progress bar.\"\"\"\nself.query_one(ProgressBar).advance(1)\ndef action_start(self) -> None:\n\"\"\"Start the progress tracking.\"\"\"\nself.query_one(ProgressBar).update(total=100)\nself.progress_timer.resume()\nif __name__ == \"__main__\":\nStyledProgressBar().run()\n
Bar > .bar--indeterminate {\ncolor: $primary;\nbackground: $secondary;\n}\nBar > .bar--bar {\ncolor: $primary;\nbackground: $primary 30%;\n}\nBar > .bar--complete {\ncolor: $error;\n}\nPercentageStatus {\ntext-style: reverse;\ncolor: $secondary;\n}\nETAStatus {\ntext-style: underline;\n}\n
"},{"location":"widgets/progress_bar/#styling-the-progress-bar","title":"Styling the Progress Bar","text":"The progress bar is composed of three sub-widgets that can be styled independently:
Widget name ID DescriptionBar
#bar
The bar that visually represents the progress made. PercentageStatus
#percentage
Label that shows the percentage of completion. ETAStatus
#eta
Label that shows the estimated time to completion."},{"location":"widgets/progress_bar/#bar-component-classes","title":"Bar Component Classes","text":"The bar sub-widget provides the component classes that follow.
These component classes let you modify the foreground and background color of the bar in its different states.
Class Descriptionbar--bar
Style of the bar (may be used to change the color). bar--complete
Style of the bar when it's complete. bar--indeterminate
Style of the bar when it's in an indeterminate state."},{"location":"widgets/progress_bar/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description percentage
float | None
The read-only percentage of progress that has been made. This is None
if the total
hasn't been set. progress
float
0
The number of steps of progress already made. total
float | None
The total number of steps that we are keeping track of."},{"location":"widgets/progress_bar/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/progress_bar/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/progress_bar/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/progress_bar/#textual.widgets.ProgressBar","title":"textual.widgets.ProgressBarclass
","text":"def __init__(\nself,\ntotal=None,\n*,\nshow_bar=True,\nshow_percentage=True,\nshow_eta=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A progress bar widget.
The progress bar uses \"steps\" as the measurement unit.
Exampleclass MyApp(App):\ndef compose(self):\nyield ProgressBar(total=100)\ndef key_space(self):\nself.query_one(ProgressBar).advance(5)\n
Parameters Name Type Description Default total
float | None
The total number of steps in the progress if known.
None
show_bar
bool
Whether to show the bar portion of the progress bar.
True
show_percentage
bool
Whether to show the percentage status of the bar.
True
show_eta
bool
Whether to show the ETA countdown of the progress bar.
True
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.percentage","title":"percentage class-attribute
instance-attribute
","text":"percentage: reactive[float | None] = reactive[\nOptional[float]\n](None)\n
The percentage of progress that has been completed.
The percentage is a value between 0 and 1 and the returned value is only None
if the total progress of the bar hasn't been set yet.
progress_bar = ProgressBar()\nprint(progress_bar.percentage) # None\nprogress_bar.update(total=100)\nprogress_bar.advance(50)\nprint(progress_bar.percentage) # 0.5\n
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.progress","title":"progress class-attribute
instance-attribute
","text":"progress: reactive[float] = reactive(0.0)\n
The progress so far, in number of steps.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.total","title":"totalclass-attribute
instance-attribute
","text":"total: reactive[float | None] = total\n
The total number of steps associated with this progress bar, when known.
The value None
will render an indeterminate progress bar.
method
","text":"def advance(self, advance=1):\n
Advance the progress of the progress bar by the given amount.
Exampleprogress_bar.advance(10) # Advance 10 steps.\n
Parameters Name Type Description Default advance
float
Number of steps to advance progress by.
1
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.compute_percentage","title":"compute_percentage method
","text":"def compute_percentage(self):\n
Keep the percentage of progress updated automatically.
This will report a percentage of 1
if the total is zero.
method
","text":"def update(\nself, *, total=UNUSED, progress=UNUSED, advance=UNUSED\n):\n
Update the progress bar with the given options.
Exampleprogress_bar.update(\ntotal=200, # Set new total to 200 steps.\nprogress=50, # Set the progress to 50 (out of 200).\n)\n
Parameters Name Type Description Default total
None | float | UnusedParameter
New total number of steps.
UNUSED
progress
float | UnusedParameter
Set the progress to the given number of steps.
UNUSED
advance
float | UnusedParameter
Advance the progress by this number of steps.
UNUSED
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.validate_progress","title":"validate_progress method
","text":"def validate_progress(self, progress):\n
Clamp the progress between 0 and the maximum total.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.validate_total","title":"validate_totalmethod
","text":"def validate_total(self, total):\n
Ensure the total is not negative.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.watch_total","title":"watch_totalmethod
","text":"def watch_total(self, total):\n
Re-validate progress.
"},{"location":"widgets/radiobutton/","title":"RadioButton","text":"Added in version 0.13.0
A simple radio button which stores a boolean value.
A radio button is best used with others inside a RadioSet
.
The example below shows radio buttons, used within a RadioSet
.
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import RadioButton, RadioSet\nclass RadioChoicesApp(App[None]):\nCSS_PATH = \"radio_button.tcss\"\ndef compose(self) -> ComposeResult:\nwith RadioSet():\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\", id=\"focus_me\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\ndef on_mount(self) -> None:\nself.query_one(RadioSet).focus()\nif __name__ == \"__main__\":\nRadioChoicesApp().run()\n
Screen {\nalign: center middle;\n}\nRadioSet {\nwidth: 50%;\n}\n
"},{"location":"widgets/radiobutton/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the radio button."},{"location":"widgets/radiobutton/#messages","title":"Messages","text":"The radio button widget defines the following bindings:
Key(s) Description enter, space Toggle the value."},{"location":"widgets/radiobutton/#component-classes","title":"Component Classes","text":"The checkbox widget inherits the following component classes:
Class Descriptiontoggle--button
Targets the toggle button itself. toggle--label
Targets the text label of the toggle button."},{"location":"widgets/radiobutton/#see-also","title":"See Also","text":"class
","text":" Bases: ToggleButton
A radio button widget that represents a boolean value.
NoteA RadioButton
is best used within a RadioSet.
class-attribute
instance-attribute
","text":"BUTTON_INNER = '\u25cf'\n
The character used for the inside of the button.
"},{"location":"widgets/radiobutton/#textual.widgets._radio_button.RadioButton.Changed","title":"Changedclass
","text":" Bases: ToggleButton.Changed
Posted when the value of the radio button changes.
This message can be handled using an on_radio_button_changed
method.
property
","text":"control: RadioButton\n
Alias for Changed.radio_button.
"},{"location":"widgets/radiobutton/#textual.widgets._radio_button.RadioButton.Changed.radio_button","title":"radio_buttonproperty
","text":"radio_button: RadioButton\n
The radio button that was changed.
"},{"location":"widgets/radioset/","title":"RadioSet","text":"Added in version 0.13.0
A container widget that groups RadioButton
s together.
The example below shows two radio sets, one built using a collection of radio buttons, the other a collection of simple strings.
Outputradio_set.pyradio_set.tcssRadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e\u258a\u2590\u25cf\u258c\u00a0Amanda\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e\u258a\u2590\u25cf\u258c\u00a0Connor\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e\u258a\u2590\u25cf\u258c\u00a0Duncan\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e\u258a\u2590\u25cf\u258c\u00a0Heather\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e\u258a\u2590\u25cf\u258c\u00a0Joe\u00a0Dawson\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e\u258a\u2590\u25cf\u258c\u00a0Kurgan,\u00a0The\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e\u258a\u2590\u25cf\u258c\u00a0Methos\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e\u258a\u2590\u25cf\u258c\u00a0Rachel\u00a0Ellenstein\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e\u258a\u2590\u25cf\u258c\u00a0Ram\u00edrez\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import RadioButton, RadioSet\nclass RadioChoicesApp(App[None]):\nCSS_PATH = \"radio_set.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\n# A RadioSet built up from RadioButtons.\nwith RadioSet(id=\"focus_me\"):\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\n# A RadioSet built up from a collection of strings.\nyield RadioSet(\n\"Amanda\",\n\"Connor MacLeod\",\n\"Duncan MacLeod\",\n\"Heather MacLeod\",\n\"Joe Dawson\",\n\"Kurgan, [bold italic red]The[/]\",\n\"Methos\",\n\"Rachel Ellenstein\",\n\"Ram\u00edrez\",\n)\ndef on_mount(self) -> None:\nself.query_one(\"#focus_me\").focus()\nif __name__ == \"__main__\":\nRadioChoicesApp().run()\n
Screen {\nalign: center middle;\n}\nHorizontal {\nalign: center middle;\nheight: auto;\n}\nRadioSet {\nwidth: 45%;\n}\n
"},{"location":"widgets/radioset/#reacting-to-changes-in-a-radio-set","title":"Reacting to Changes in a Radio Set","text":"Here is an example of using the message to react to changes in a RadioSet
:
RadioSetChangedApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e Pressed\u00a0button\u00a0label:\u00a0Battlestar\u00a0Galactica Pressed\u00a0button\u00a0index:\u00a00
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Label, RadioButton, RadioSet\nclass RadioSetChangedApp(App[None]):\nCSS_PATH = \"radio_set_changed.tcss\"\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nwith Horizontal():\nwith RadioSet(id=\"focus_me\"):\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\nwith Horizontal():\nyield Label(id=\"pressed\")\nwith Horizontal():\nyield Label(id=\"index\")\ndef on_mount(self) -> None:\nself.query_one(RadioSet).focus()\ndef on_radio_set_changed(self, event: RadioSet.Changed) -> None:\nself.query_one(\"#pressed\", Label).update(\nf\"Pressed button label: {event.pressed.label}\"\n)\nself.query_one(\"#index\", Label).update(\nf\"Pressed button index: {event.radio_set.pressed_index}\"\n)\nif __name__ == \"__main__\":\nRadioSetChangedApp().run()\n
VerticalScroll {\nalign: center middle;\n}\nHorizontal {\nalign: center middle;\nheight: auto;\n}\nRadioSet {\nwidth: 45%;\n}\n
"},{"location":"widgets/radioset/#messages","title":"Messages","text":"The RadioSet
widget defines the following bindings:
This widget has no component classes.
"},{"location":"widgets/radioset/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\n*buttons,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Container
Widget for grouping a collection of radio buttons into a set.
When a collection of RadioButton
s are grouped with this widget, they will be treated as a mutually-exclusive grouping. If one button is turned on, the previously-on button will be turned off.
buttons
str | RadioButton
The labels or RadioButton
s to group together.
()
name
str | None
The name of the radio set.
None
id
str | None
The ID of the radio set in the DOM.
None
classes
str | None
The CSS classes of the radio set.
None
disabled
bool
Whether the radio set is disabled or not.
False
Note When a str
label is provided, a RadioButton will be created from it.
class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"down,right\", \"next_button\", \"\", show=False),\nBinding(\"enter,space\", \"toggle\", \"Toggle\", show=False),\nBinding(\"up,left\", \"previous_button\", \"\", show=False),\n]\n
Key(s) Description enter, space Toggle the currently-selected button. left, up Select the previous radio button in the set. right, down Select the next radio button in the set."},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.pressed_button","title":"pressed_button property
","text":"pressed_button: RadioButton | None\n
The currently-pressed RadioButton
, or None
if none are pressed.
property
","text":"pressed_index: int\n
The index of the currently-pressed RadioButton
, or -1 if none are pressed.
class
","text":"def __init__(self, radio_set, pressed):\n
Bases: Message
Posted when the pressed button in the set changes.
This message can be handled using an on_radio_set_changed
method.
pressed
RadioButton
The radio button that was pressed.
required"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.Changed.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'pressed'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: RadioSet\n
A reference to the RadioSet
that was changed.
This is an alias for Changed.radio_set
and is used by the on
decorator.
instance-attribute
","text":"index = radio_set.pressed_index\n
The index of the RadioButton
that was pressed to make the change.
instance-attribute
","text":"pressed = pressed\n
The RadioButton
that was pressed to make the change.
instance-attribute
","text":"radio_set = radio_set\n
A reference to the RadioSet
that was changed.
method
","text":"def action_next_button(self):\n
Navigate to the next button in the set.
Note that this will wrap around to the start if at the end.
"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.action_previous_button","title":"action_previous_buttonmethod
","text":"def action_previous_button(self):\n
Navigate to the previous button in the set.
Note that this will wrap around to the end if at the start.
"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.action_toggle","title":"action_togglemethod
","text":"def action_toggle(self):\n
Toggle the state of the currently-selected button.
"},{"location":"widgets/rich_log/","title":"RichLog","text":"A RichLog is a widget which displays scrollable content that may be appended to in realtime.
Call RichLog.write with a string or Rich Renderable to write content to the end of the RichLog. Call RichLog.clear to clear the content.
Tip
See also Log which is an alternative to RichLog
but specialized for simple text.
The example below shows an application showing a RichLog
with different kinds of data logged.
RichLogApp \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=next(iter_values) \u2502\u00a0\u00a0\u00a0exceptStopIteration: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0return \u2502\u00a0\u00a0\u00a0first=True\u2585\u2585 \u2502\u00a0\u00a0\u00a0forvalueiniter_values: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldfirst,False,previous_value \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0first=False \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=value \u2502\u00a0\u00a0\u00a0yieldfirst,True,previous_value \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503lane\u2503swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503time\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25024\u00a0\u00a0\u00a0\u2502Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u2502Singapore\u00a0\u00a0\u00a0\u00a0\u250250.39\u2502 \u25022\u00a0\u00a0\u00a0\u2502Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.14\u2502 \u25025\u00a0\u00a0\u00a0\u2502Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502South\u00a0Africa\u00a0\u250251.14\u2502 \u25026\u00a0\u00a0\u00a0\u2502L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.14\u2502 \u25023\u00a0\u00a0\u00a0\u2502Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.26\u2502 \u25028\u00a0\u00a0\u00a0\u2502Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.58\u2502 \u25027\u00a0\u00a0\u00a0\u2502Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.73\u2502 \u25021\u00a0\u00a0\u00a0\u2502Aleksandr\u00a0Sadovnikov\u2502Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.84\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 Write\u00a0text\u00a0or\u00a0any\u00a0Rich\u00a0renderable! Key(key='H',\u00a0character='H',\u00a0name='upper_h',\u00a0is_printable=True) Key(key='i',\u00a0character='i',\u00a0name='i',\u00a0is_printable=True)
import csv\nimport io\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nCSV = \"\"\"lane,swimmer,country,time\n4,Joseph Schooling,Singapore,50.39\n2,Michael Phelps,United States,51.14\n5,Chad le Clos,South Africa,51.14\n6,L\u00e1szl\u00f3 Cseh,Hungary,51.14\n3,Li Zhuhao,China,51.26\n8,Mehdy Metella,France,51.58\n7,Tom Shields,United States,51.73\n1,Aleksandr Sadovnikov,Russia,51.84\"\"\"\nCODE = '''\\\ndef loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:\n \"\"\"Iterate and generate a tuple with a flag for first and last value.\"\"\"\n iter_values = iter(values)\n try:\n previous_value = next(iter_values)\n except StopIteration:\n return\n first = True\n for value in iter_values:\n yield first, False, previous_value\n first = False\n previous_value = value\n yield first, True, previous_value\\\n'''\nclass RichLogApp(App):\ndef compose(self) -> ComposeResult:\nyield RichLog(highlight=True, markup=True)\ndef on_ready(self) -> None:\n\"\"\"Called when the DOM is ready.\"\"\"\ntext_log = self.query_one(RichLog)\ntext_log.write(Syntax(CODE, \"python\", indent_guides=True))\nrows = iter(csv.reader(io.StringIO(CSV)))\ntable = Table(*next(rows))\nfor row in rows:\ntable.add_row(*row)\ntext_log.write(table)\ntext_log.write(\"[bold magenta]Write text or any Rich renderable!\")\ndef on_key(self, event: events.Key) -> None:\n\"\"\"Write Key events to log.\"\"\"\ntext_log = self.query_one(RichLog)\ntext_log.write(event)\nif __name__ == \"__main__\":\napp = RichLogApp()\napp.run()\n
"},{"location":"widgets/rich_log/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlight
bool
False
Automatically highlight content. markup
bool
False
Apply Rich console markup. max_lines
int
None
Maximum number of lines in the log or None
for no maximum. min_width
int
78 Minimum width of renderables. wrap
bool
False
Enable word wrapping."},{"location":"widgets/rich_log/#messages","title":"Messages","text":"This widget sends no messages.
"},{"location":"widgets/rich_log/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/rich_log/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/rich_log/#textual.widgets.RichLog","title":"textual.widgets.RichLogclass
","text":"def __init__(\nself,\n*,\nmax_lines=None,\nmin_width=78,\nwrap=False,\nhighlight=False,\nmarkup=False,\nauto_scroll=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
A widget for logging text.
Parameters Name Type Description Defaultmax_lines
int | None
Maximum number of lines in the log or None
for no maximum.
None
min_width
int
Minimum width of renderables.
78
wrap
bool
Enable word wrapping (default is off).
False
highlight
bool
Automatically highlight content.
False
markup
bool
Apply Rich console markup.
False
auto_scroll
bool
Enable automatic scrolling to end.
True
name
str | None
The name of the text log.
None
id
str | None
The ID of the text log in the DOM.
None
classes
str | None
The CSS classes of the text log.
None
disabled
bool
Whether the text log is disabled or not.
False
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.auto_scroll","title":"auto_scroll class-attribute
instance-attribute
","text":"auto_scroll: var[bool] = auto_scroll\n
Automatically scroll to the end on write.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.highlight","title":"highlightclass-attribute
instance-attribute
","text":"highlight: var[bool] = highlight\n
Automatically highlight content.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.markup","title":"markupclass-attribute
instance-attribute
","text":"markup: var[bool] = markup\n
Apply Rich console markup.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.max_lines","title":"max_linesclass-attribute
instance-attribute
","text":"max_lines: var[int | None] = max_lines\n
Maximum number of lines in the log or None
for no maximum.
class-attribute
instance-attribute
","text":"min_width: var[int] = min_width\n
Minimum width of renderables.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.wrap","title":"wrapclass-attribute
instance-attribute
","text":"wrap: var[bool] = wrap\n
Enable word wrapping.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the text log.
Returns Type DescriptionSelf
The RichLog
instance.
method
","text":"def write(\nself,\ncontent,\nwidth=None,\nexpand=False,\nshrink=True,\nscroll_end=None,\n):\n
Write text or a rich renderable.
Parameters Name Type Description Defaultcontent
RenderableType | object
Rich renderable (or text).
requiredwidth
int | None
Width to render or None
to use optimal width.
None
expand
bool
Enable expand to widget width, or False
to use width
.
False
shrink
bool
Enable shrinking of content to fit width.
True
scroll_end
bool | None
Enable automatic scroll to end, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The RichLog
instance.
A rule widget to separate content, similar to a <hr>
HTML tag.
The default orientation of a rule is horizontal.
The example below shows horizontal rules with all the available line styles.
Outputhorizontal_rules.pyhorizontal_rules.tcssHorizontalRulesApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0solid\u00a0(default)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0heavy\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0thick\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0dashed\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0double\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ascii\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 ----------------------------------------------------------------
from textual.app import App, ComposeResult\nfrom textual.containers import Vertical\nfrom textual.widgets import Label, Rule\nclass HorizontalRulesApp(App):\nCSS_PATH = \"horizontal_rules.tcss\"\ndef compose(self) -> ComposeResult:\nwith Vertical():\nyield Label(\"solid (default)\")\nyield Rule()\nyield Label(\"heavy\")\nyield Rule(line_style=\"heavy\")\nyield Label(\"thick\")\nyield Rule(line_style=\"thick\")\nyield Label(\"dashed\")\nyield Rule(line_style=\"dashed\")\nyield Label(\"double\")\nyield Rule(line_style=\"double\")\nyield Label(\"ascii\")\nyield Rule(line_style=\"ascii\")\nif __name__ == \"__main__\":\napp = HorizontalRulesApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nVertical {\nheight: auto;\nwidth: 80%;\n}\nLabel {\nwidth: 100%;\ntext-align: center;\n}\n
"},{"location":"widgets/rule/#vertical-rule","title":"Vertical Rule","text":"The example below shows vertical rules with all the available line styles.
Outputvertical_rules.pyvertical_rules.tcssVerticalRulesApp solid\u00a0\u2502heavy\u00a0\u2503thick\u00a0\u2588dashed\u254fdouble\u2551ascii\u00a0| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551|
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Label, Rule\nclass VerticalRulesApp(App):\nCSS_PATH = \"vertical_rules.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nyield Label(\"solid\")\nyield Rule(orientation=\"vertical\")\nyield Label(\"heavy\")\nyield Rule(orientation=\"vertical\", line_style=\"heavy\")\nyield Label(\"thick\")\nyield Rule(orientation=\"vertical\", line_style=\"thick\")\nyield Label(\"dashed\")\nyield Rule(orientation=\"vertical\", line_style=\"dashed\")\nyield Label(\"double\")\nyield Rule(orientation=\"vertical\", line_style=\"double\")\nyield Label(\"ascii\")\nyield Rule(orientation=\"vertical\", line_style=\"ascii\")\nif __name__ == \"__main__\":\napp = VerticalRulesApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nHorizontal {\nwidth: auto;\nheight: 80%;\n}\nLabel {\nwidth: 6;\nheight: 100%;\ntext-align: center;\n}\n
"},{"location":"widgets/rule/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description orientation
RuleOrientation
\"horizontal\"
The orientation of the rule. line_style
LineStyle
\"solid\"
The line style of the rule."},{"location":"widgets/rule/#messages","title":"Messages","text":"This widget sends no messages.
"},{"location":"widgets/rule/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/rule/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/rule/#textual.widgets.Rule","title":"textual.widgets.Ruleclass
","text":"def __init__(\nself,\norientation=\"horizontal\",\nline_style=\"solid\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A rule widget to separate content, similar to a <hr>
HTML tag.
orientation
RuleOrientation
The orientation of the rule.
'horizontal'
line_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.line_style","title":"line_style class-attribute
instance-attribute
","text":"line_style: Reactive[LineStyle] = line_style\n
The line style of the rule.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.orientation","title":"orientationclass-attribute
instance-attribute
","text":"orientation: Reactive[RuleOrientation] = orientation\n
The orientation of the rule.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.horizontal","title":"horizontalclassmethod
","text":"def horizontal(\ncls,\nline_style=\"solid\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Utility constructor for creating a horizontal rule.
Parameters Name Type Description Defaultline_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
Returns Type Description Rule
A rule widget with horizontal orientation.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.vertical","title":"verticalclassmethod
","text":"def vertical(\ncls,\nline_style=\"solid\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Utility constructor for creating a vertical rule.
Parameters Name Type Description Defaultline_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
Returns Type Description Rule
A rule widget with vertical orientation.
"},{"location":"widgets/rule/#textual.widgets.rule","title":"textual.widgets.rule","text":""},{"location":"widgets/rule/#textual.widgets.rule.LineStyle","title":"LineStylemodule-attribute
","text":"LineStyle = Literal[\n\"ascii\",\n\"blank\",\n\"dashed\",\n\"double\",\n\"heavy\",\n\"hidden\",\n\"none\",\n\"solid\",\n\"thick\",\n]\n
The valid line styles of the rule widget.
"},{"location":"widgets/rule/#textual.widgets.rule.RuleOrientation","title":"RuleOrientationmodule-attribute
","text":"RuleOrientation = Literal['horizontal', 'vertical']\n
The valid orientations of the rule widget.
"},{"location":"widgets/rule/#textual.widgets.rule.InvalidLineStyle","title":"InvalidLineStyleclass
","text":" Bases: Exception
Exception raised for an invalid rule line style.
"},{"location":"widgets/rule/#textual.widgets.rule.InvalidRuleOrientation","title":"InvalidRuleOrientationclass
","text":" Bases: Exception
Exception raised for an invalid rule orientation.
"},{"location":"widgets/select/","title":"Select","text":"Added in version 0.24.0
A Select widget is a compact control to allow the user to select between a number of possible options.
The options in a select control may be passed in to the constructor or set later with set_options. Options should be given as a sequence of tuples consisting of two values: the first is the string (or Rich Renderable) to display in the control and list of options, the second is the value of option.
The value of the currently selected option is stored in the value
attribute of the widget, and the value
attribute of the Changed message.
The Select
control is a typing Generic which allows you to set the type of the option values. For instance, if the data type for your values is an integer, you would type the widget as follows:
options = [(\"First\", 1), (\"Second\", 2)]\nmy_select: Select[int] = Select(options)\n
Note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
"},{"location":"widgets/select/#example","title":"Example","text":"The following example presents a Select
with a number of options.
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25bc\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Select\nLINES = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\"\"\".splitlines()\nclass SelectApp(App):\nCSS_PATH = \"select.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Select((line, line) for line in LINES)\n@on(Select.Changed)\ndef select_changed(self, event: Select.Changed) -> None:\nself.title = str(event.value)\nif __name__ == \"__main__\":\napp = SelectApp()\napp.run()\n
Screen {\nalign: center top;\n}\nSelect {\nwidth: 60;\nmargin: 2;\n}\n
"},{"location":"widgets/select/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description expanded
bool
False
True to expand the options overlay. value
SelectType
| None
None
Current value of the Select."},{"location":"widgets/select/#messages","title":"Messages","text":"The Select widget defines the following bindings:
Key(s) Description enter,down,space,up Activate the overlay"},{"location":"widgets/select/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/select/#textual.widgets.Select","title":"textual.widgets.Selectclass
","text":"def __init__(\nself,\noptions,\n*,\nprompt=\"Select\",\nallow_blank=True,\nvalue=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[SelectType]
, Vertical
Widget to select from a list of possible options.
A Select displays the current selection. When activated with Enter the widget displays an overlay with a list of all possible options.
Parameters Name Type Description Defaultoptions
Iterable[tuple[str, SelectType]]
Options to select from.
requiredprompt
str
Text to show in the control when no option is select.
'Select'
allow_blank
bool
Allow the selection of a blank option.
True
value
SelectType | None
Initial value (should be one of the values in options
).
None
name
str | None
The name of the select control.
None
id
str | None
The ID of the control the DOM.
None
classes
str | None
The CSS classes of the control.
None
disabled
bool
Whether the control is disabled or not.
False
"},{"location":"widgets/select/#textual.widgets._select.Select.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [('enter,down,space,up', 'show_overlay')]\n
Key(s) Description enter,down,space,up Activate the overlay"},{"location":"widgets/select/#textual.widgets._select.Select.expanded","title":"expanded class-attribute
instance-attribute
","text":"expanded: var[bool] = var(False, init=False)\n
True to show the overlay, otherwise False.
"},{"location":"widgets/select/#textual.widgets._select.Select.prompt","title":"promptclass-attribute
instance-attribute
","text":"prompt: var[str] = prompt\n
The prompt to show when no value is selected.
"},{"location":"widgets/select/#textual.widgets._select.Select.value","title":"valueclass-attribute
instance-attribute
","text":"value: var[SelectType | None] = var[Optional[SelectType]](\nNone\n)\n
The value of the select.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed","title":"Changedclass
","text":"def __init__(self, select, value):\n
Bases: Message
Posted when the select value was changed.
This message can be handled using a on_select_changed
method.
property
","text":"control: Select\n
The Select that sent the message.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed.select","title":"selectinstance-attribute
","text":"select = select\n
The select widget.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed.value","title":"valueinstance-attribute
","text":"value = value\n
The value of the Select when it changed.
"},{"location":"widgets/select/#textual.widgets._select.Select.action_show_overlay","title":"action_show_overlaymethod
","text":"def action_show_overlay(self):\n
Show the overlay.
"},{"location":"widgets/select/#textual.widgets._select.Select.set_options","title":"set_optionsmethod
","text":"def set_options(self, options):\n
Set the options for the Select.
Parameters Name Type Description Defaultoptions
Iterable[tuple[RenderableType, SelectType]]
An iterable of tuples containing (STRING, VALUE).
required"},{"location":"widgets/selection_list/","title":"SelectionList","text":"Added in version 0.27.0
A widget for showing a vertical list of selectable options.
The SelectionList
control is a Generic
, which allows you to set the type of the selection values. For instance, if the data type for your values is an integer, you would type the widget as follows:
selections = [(\"First\", 1), (\"Second\", 2)]\nmy_selection_list: SelectionList[int] = SelectionList(*selections)\n
Note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
"},{"location":"widgets/selection_list/#examples","title":"Examples","text":"A selection list is designed to be built up of single-line prompts (which can be Rich renderables) and an associated unique value.
"},{"location":"widgets/selection_list/#selections-as-tuples","title":"Selections as tuples","text":"A selection list can be built with tuples, either of two or three values in length. Each tuple must contain a prompt and a value, and it can also optionally contain a flag for the initial selected state of the option.
Outputselection_list_tuples.pyselection_list.tcssSelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, SelectionList\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield SelectionList[int]( # (1)!\n(\"Falken's Maze\", 0, True),\n(\"Black Jack\", 1),\n(\"Gin Rummy\", 2),\n(\"Hearts\", 3),\n(\"Bridge\", 4),\n(\"Checkers\", 5),\n(\"Chess\", 6, True),\n(\"Poker\", 7),\n(\"Fighter Combat\", 8, True),\n)\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as int
, for the type of the values.Screen {\nalign: center middle;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 80%;\nheight: 80%;\n}\n
"},{"location":"widgets/selection_list/#selections-as-selection-objects","title":"Selections as Selection objects","text":"Alternatively, selections can be passed in as Selection
s:
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, SelectionList\nfrom textual.widgets.selection_list import Selection\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield SelectionList[int]( # (1)!\nSelection(\"Falken's Maze\", 0, True),\nSelection(\"Black Jack\", 1),\nSelection(\"Gin Rummy\", 2),\nSelection(\"Hearts\", 3),\nSelection(\"Bridge\", 4),\nSelection(\"Checkers\", 5),\nSelection(\"Chess\", 6, True),\nSelection(\"Poker\", 7),\nSelection(\"Fighter Combat\", 8, True),\n)\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as int
, for the type of the values.Screen {\nalign: center middle;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 80%;\nheight: 80%;\n}\n
"},{"location":"widgets/selection_list/#handling-changes-to-the-selections","title":"Handling changes to the selections","text":"Most of the time, when using the SelectionList
, you will want to know when the collection of selected items has changed; this is ideally done using the SelectedChanged
message. Here is an example of using that message to update a Pretty
with the collection of selected values:
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2510\u250c\u2500\u00a0Selected\u00a0games\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502[\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502\u2502'secret_back_door',\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502\u2502'a_nice_game_of_chess',\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502\u2502'fighter_combat'\u2502 \u2502\u2590X\u258cHearts\u2502\u2502]\u2502 \u2502\u2590X\u258cBridge\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.events import Mount\nfrom textual.widgets import Footer, Header, Pretty, SelectionList\nfrom textual.widgets.selection_list import Selection\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list_selected.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Horizontal():\nyield SelectionList[str]( # (1)!\nSelection(\"Falken's Maze\", \"secret_back_door\", True),\nSelection(\"Black Jack\", \"black_jack\"),\nSelection(\"Gin Rummy\", \"gin_rummy\"),\nSelection(\"Hearts\", \"hearts\"),\nSelection(\"Bridge\", \"bridge\"),\nSelection(\"Checkers\", \"checkers\"),\nSelection(\"Chess\", \"a_nice_game_of_chess\", True),\nSelection(\"Poker\", \"poker\"),\nSelection(\"Fighter Combat\", \"fighter_combat\", True),\n)\nyield Pretty([])\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nself.query_one(Pretty).border_title = \"Selected games\"\n@on(Mount)\n@on(SelectionList.SelectedChanged)\ndef update_selected_view(self) -> None:\nself.query_one(Pretty).update(self.query_one(SelectionList).selected)\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as str
, for the type of the values.Screen {\nalign: center middle;\n}\nHorizontal {\nwidth: 80%;\nheight: 80%;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 1fr;\n}\nPretty {\nwidth: 1fr;\nborder: solid $accent;\n}\n
"},{"location":"widgets/selection_list/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
int
| None
None
The index of the highlighted selection. None
means nothing is highlighted."},{"location":"widgets/selection_list/#messages","title":"Messages","text":"The following messages will be posted as the user interacts with the list:
The following message will be posted if the content of selected
changes, either by user interaction or by API calls:
The selection list widget defines the following bindings:
Key(s) Description space Toggle the state of the highlighted selection.It inherits from OptionList
and so also inherits the following bindings:
The selection list provides the following component classes:
Class Descriptionselection-list--button
Target the default button style. selection-list--button-selected
Target a selected button style. selection-list--button-highlighted
Target a highlighted button style. selection-list--button-selected-highlighted
Target a highlighted selected button style. It inherits from OptionList
and so also makes use of the following component classes:
option-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/selection_list/#textual.widgets.SelectionList","title":"textual.widgets.SelectionList class
","text":"def __init__(\nself,\n*selections,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[SelectionType]
, OptionList
A vertical selection list that allows making multiple selections.
Parameters Name Type Description Default*selections
Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]
The content for the selection list.
()
name
str | None
The name of the selection list.
None
id
str | None
The ID of the selection list in the DOM.
None
classes
str | None
The CSS classes of the selection list.
None
disabled
bool
Whether the selection list is disabled or not.
False
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [Binding('space', 'select')]\n
Key(s) Description space Toggle the state of the highlighted selection."},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"selection-list--button\",\n\"selection-list--button-selected\",\n\"selection-list--button-highlighted\",\n\"selection-list--button-selected-highlighted\",\n}\n
Class Description selection-list--button
Target the default button style. selection-list--button-selected
Target a selected button style. selection-list--button-highlighted
Target a highlighted button style. selection-list--button-selected-highlighted
Target a highlighted selected button style."},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.selected","title":"selected property
","text":"selected: list[SelectionType]\n
The selected values.
This is a list of all of the values associated with selections in the list that are currently in the selected state.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectedChanged","title":"SelectedChangedclass
","text":" Bases: Generic[MessageSelectionType]
, Message
Message sent when the collection of selected values changes.
This message is sent when any change to the collection of selected values takes place; either by user interaction or by API calls.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectedChanged.control","title":"controlproperty
","text":"control: SelectionList[MessageSelectionType]\n
An alias for selection_list
.
instance-attribute
","text":"selection_list: SelectionList[MessageSelectionType]\n
The SelectionList
that sent the message.
class
","text":" Bases: SelectionMessage
Message sent when a selection is highlighted.
Can be handled using on_selection_list_selection_highlighted
in a subclass of SelectionList
or in a parent node in the DOM.
class
","text":"def __init__(self, selection_list, index):\n
Bases: Generic[MessageSelectionType]
, Message
Base class for all selection messages.
Parameters Name Type Description Defaultselection_list
SelectionList
The selection list that owns the selection.
requiredindex
int
The index of the selection that the message relates to.
required"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.control","title":"controlproperty
","text":"control: OptionList\n
The selection list that sent the message.
This is an alias for SelectionMessage.selection_list
and is used by the on
decorator.
instance-attribute
","text":"selection: Selection[\nMessageSelectionType\n] = selection_list.get_option_at_index(index)\n
The highlighted selection.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.selection_index","title":"selection_indexinstance-attribute
","text":"selection_index: int = index\n
The index of the selection that the message relates to.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.selection_list","title":"selection_listinstance-attribute
","text":"selection_list: SelectionList[\nMessageSelectionType\n] = selection_list\n
The selection list that sent the message.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionToggled","title":"SelectionToggledclass
","text":" Bases: SelectionMessage
Message sent when a selection is toggled.
Can be handled using on_selection_list_selection_toggled
in a subclass of SelectionList
or in a parent node in the DOM.
This message is only sent if the selection is toggled by user interaction. See SelectedChanged
for a message sent when any change (selected or deselected, either by user interaction or by API calls) is made to the selected values.
method
","text":"def add_option(self, item=None):\n
Add a new selection option to the end of the list.
Parameters Name Type Description Defaultitem
NewOptionListContent | Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]
The new item to add.
None
Returns Type Description Self
The SelectionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
SelectionError
If the selection option is of the wrong form.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.add_options","title":"add_optionsmethod
","text":"def add_options(self, items):\n
Add new selection options to the end of the list.
Parameters Name Type Description Defaultitems
Iterable[NewOptionListContent | Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]]
The new items to add.
required Returns Type DescriptionSelf
The SelectionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
SelectionError
If one of the selection options is of the wrong form.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.clear_options","title":"clear_optionsmethod
","text":"def clear_options(self):\n
Clear the content of the selection list.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def deselect(self, selection):\n
Mark the given selection as not selected.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to mark as not selected.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def deselect_all(self):\n
Deselect all items.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def get_option(self, option_id):\n
Get the selection option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the selection option to get.
required Returns Type DescriptionSelection[SelectionType]
The selection option with the ID.
Raises Type DescriptionOptionDoesNotExist
If no selection option has the given ID.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.get_option_at_index","title":"get_option_at_indexmethod
","text":"def get_option_at_index(self, index):\n
Get the selection option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the selection option to get.
required Returns Type DescriptionSelection[SelectionType]
The selection option at that index.
Raises Type DescriptionOptionDoesNotExist
If there is no selection option with the index.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.select","title":"selectmethod
","text":"def select(self, selection):\n
Mark the given selection as selected.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to mark as selected.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def select_all(self):\n
Select all items.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def toggle(self, selection):\n
Toggle the selected state of the given selection.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to toggle.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def toggle_all(self):\n
Toggle all items.
Returns Type DescriptionSelf
The SelectionList
instance.
module-attribute
","text":"MessageSelectionType = TypeVar('MessageSelectionType')\n
The type for the value of a Selection
in a SelectionList
message.
module-attribute
","text":"SelectionType = TypeVar('SelectionType')\n
The type for the value of a Selection
in a SelectionList
class
","text":"def __init__(\nself,\nprompt,\nvalue,\ninitial_state=False,\nid=None,\ndisabled=False,\n):\n
Bases: Generic[SelectionType]
, Option
A selection for a SelectionList
.
prompt
TextType
The prompt for the selection.
requiredvalue
SelectionType
The value for the selection.
requiredinitial_state
bool
The initial selected state of the selection.
False
id
str | None
The optional ID for the selection.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.Selection.initial_state","title":"initial_state property
","text":"initial_state: bool\n
The initial selected state for the selection.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.Selection.value","title":"valueproperty
","text":"value: SelectionType\n
The value for this selection.
"},{"location":"widgets/selection_list/#textual.widgets.selection_list.SelectionError","title":"SelectionErrorclass
","text":" Bases: TypeError
Type of an error raised if a selection is badly-formed.
"},{"location":"widgets/sparkline/","title":"Sparkline","text":"Added in version 0.27.0
A widget that is used to visually represent numerical data.
The example below illustrates the relationship between the data, its length, the width of the sparkline, and the number of bars displayed.
Tip
The sparkline data is split into equally-sized chunks. Each chunk is represented by a bar and the width of the sparkline dictates how many bars there are.
Outputsparkline_basic.pysparkline_basic.tcssSparklineBasicApp \u2582\u2584\u2588
from textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\ndata = [1, 2, 2, 1, 1, 4, 3, 1, 1, 8, 8, 2] # (1)!\nclass SparklineBasicApp(App[None]):\nCSS_PATH = \"sparkline_basic.tcss\"\ndef compose(self) -> ComposeResult:\nyield Sparkline( # (2)!\ndata, # (3)!\nsummary_function=max, # (4)!\n)\napp = SparklineBasicApp()\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nalign: center middle;\n}\nSparkline {\nwidth: 3; /* (1)! */\nmargin: 2;\n}\n
The example below shows a sparkline widget with different summary functions. The summary function is what determines the height of each bar.
Outputsparkline.pysparkline.tcssSparklineSummaryFunctionApp \u2582\u2584\u2582\u2584\u2583\u2583\u2586\u2585\u2583\u2582\u2583\u2582\u2583\u2582\u2584\u2587\u2583\u2583\u2587\u2585\u2584\u2583\u2584\u2584\u2583\u2582\u2583\u2582\u2583\u2584\u2584\u2588\u2586\u2582\u2583\u2583\u2585\u2583\u2583\u2584\u2583\u2587\u2583\u2583\u2583\u2584\u2584\u2586\u2583\u2583\u2585\u2582\u2585\u2583\u2584\u2583\u2583\u2584\u2583\u2585\u2586\u2582\u2582\u2583\u2586\u2582\u2583\u2584\u2585\u2584\u2583\u2584\u2584\u2581\u2583\u2582 \u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2582\u2582\u2582\u2582\u2582\u2582\u2581\u2581\u2581\u2581\u2581\u2582\u2581\u2582\u2582\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2581\u2581\u2581\u2581\u2582\u2582\u2582\u2581\u2582\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
import random\nfrom statistics import mean\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\nrandom.seed(73)\ndata = [random.expovariate(1 / 3) for _ in range(1000)]\nclass SparklineSummaryFunctionApp(App[None]):\nCSS_PATH = \"sparkline.tcss\"\ndef compose(self) -> ComposeResult:\nyield Sparkline(data, summary_function=max) # (1)!\nyield Sparkline(data, summary_function=mean) # (2)!\nyield Sparkline(data, summary_function=min) # (3)!\napp = SparklineSummaryFunctionApp()\nif __name__ == \"__main__\":\napp.run()\n
Sparkline {\nwidth: 100%;\nmargin: 2;\n}\n
"},{"location":"widgets/sparkline/#changing-the-colors","title":"Changing the colors","text":"The example below shows how to use component classes to change the colors of the sparkline.
Outputsparkline_colors.pysparkline_colors.tcssSparklineColorsApp \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582
from math import sin\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\nclass SparklineColorsApp(App[None]):\nCSS_PATH = \"sparkline_colors.tcss\"\ndef compose(self) -> ComposeResult:\nnums = [abs(sin(x / 3.14)) for x in range(0, 360 * 6, 20)]\nyield Sparkline(nums, summary_function=max, id=\"fst\")\nyield Sparkline(nums, summary_function=max, id=\"snd\")\nyield Sparkline(nums, summary_function=max, id=\"trd\")\nyield Sparkline(nums, summary_function=max, id=\"frt\")\nyield Sparkline(nums, summary_function=max, id=\"fft\")\nyield Sparkline(nums, summary_function=max, id=\"sxt\")\nyield Sparkline(nums, summary_function=max, id=\"svt\")\nyield Sparkline(nums, summary_function=max, id=\"egt\")\nyield Sparkline(nums, summary_function=max, id=\"nnt\")\nyield Sparkline(nums, summary_function=max, id=\"tnt\")\napp = SparklineColorsApp()\nif __name__ == \"__main__\":\napp.run()\n
Sparkline {\nwidth: 100%;\nmargin: 1;\n}\n#fst > .sparkline--max-color {\ncolor: $success;\n}\n#fst > .sparkline--min-color {\ncolor: $warning;\n}\n#snd > .sparkline--max-color {\ncolor: $warning;\n}\n#snd > .sparkline--min-color {\ncolor: $success;\n}\n#trd > .sparkline--max-color {\ncolor: $error;\n}\n#trd > .sparkline--min-color {\ncolor: $warning;\n}\n#frt > .sparkline--max-color {\ncolor: $warning;\n}\n#frt > .sparkline--min-color {\ncolor: $error;\n}\n#fft > .sparkline--max-color {\ncolor: $accent;\n}\n#fft > .sparkline--min-color {\ncolor: $accent 30%;\n}\n#sxt > .sparkline--max-color {\ncolor: $accent 30%;\n}\n#sxt > .sparkline--min-color {\ncolor: $accent;\n}\n#svt > .sparkline--max-color {\ncolor: $error;\n}\n#svt > .sparkline--min-color {\ncolor: $error 30%;\n}\n#egt > .sparkline--max-color {\ncolor: $error 30%;\n}\n#egt > .sparkline--min-color {\ncolor: $error;\n}\n#nnt > .sparkline--max-color {\ncolor: $success;\n}\n#nnt > .sparkline--min-color {\ncolor: $success 30%;\n}\n#tnt > .sparkline--max-color {\ncolor: $success 30%;\n}\n#tnt > .sparkline--min-color {\ncolor: $success;\n}\n
"},{"location":"widgets/sparkline/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description data
Sequence[float] | None
None
The data represented by the sparkline. summary_function
Callable[[Sequence[float]], float]
max
The function that computes the height of each bar."},{"location":"widgets/sparkline/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/sparkline/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/sparkline/#component-classes","title":"Component Classes","text":"The sparkline widget provides the following component classes:
Use these component classes to define the two colors that the sparkline interpolates to represent its numerical data.
NoteThese two component classes are used exclusively for the color of the sparkline widget. Setting any style other than color
will have no effect.
sparkline--max-color
The color used for the larger values in the data. sparkline--min-color
The colour used for the smaller values in the data."},{"location":"widgets/sparkline/#textual.widgets.Sparkline","title":"textual.widgets.Sparkline class
","text":"def __init__(\nself,\ndata=None,\n*,\nsummary_function=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A sparkline widget to display numerical data.
Parameters Name Type Description Defaultdata
Sequence[float] | None
The initial data to populate the sparkline with.
None
summary_function
Callable[[Sequence[float]], float] | None
Summarises bar values into a single value used to represent each bar.
None
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"sparkline--max-color\",\n\"sparkline--min-color\",\n}\n
Use these component classes to define the two colors that the sparkline interpolates to represent its numerical data.
NoteThese two component classes are used exclusively for the color of the sparkline widget. Setting any style other than color
will have no effect.
sparkline--max-color
The color used for the larger values in the data. sparkline--min-color
The colour used for the smaller values in the data."},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.data","title":"data class-attribute
instance-attribute
","text":"data = data\n
The data that populates the sparkline.
"},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.summary_function","title":"summary_functionclass-attribute
instance-attribute
","text":"summary_function = reactive[\nCallable[[Sequence[float]], float]\n](_max_factory)\n
The function that computes the value that represents each bar.
"},{"location":"widgets/static/","title":"Static","text":"A widget which displays static content. Can be used for Rich renderables and can also be the base for other types of widgets.
The example below shows how you can use a Static
widget as a simple text label (but see Label as a way of displaying text).
StaticApp Hello,\u00a0world!
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass StaticApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, world!\")\nif __name__ == \"__main__\":\napp = StaticApp()\napp.run()\n
"},{"location":"widgets/static/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/static/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/static/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/static/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/static/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nrenderable=\"\",\n*,\nexpand=False,\nshrink=False,\nmarkup=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A widget to display simple static content, or use as a base class for more complex widgets.
Parameters Name Type Description Defaultrenderable
RenderableType
A Rich renderable, or string containing console markup.
''
expand
bool
Expand content if required to fill container.
False
shrink
bool
Shrink content if required to fill container.
False
markup
bool
True if markup should be parsed and rendered.
True
name
str | None
Name of widget.
None
id
str | None
ID of Widget.
None
classes
str | None
Space separated list of class names.
None
disabled
bool
Whether the static is disabled or not.
False
"},{"location":"widgets/static/#textual.widgets._static.Static.update","title":"update method
","text":"def update(self, renderable=''):\n
Update the widget's content area with new text or Rich renderable.
Parameters Name Type Description Defaultrenderable
RenderableType
A new rich renderable. Defaults to empty renderable;
''
"},{"location":"widgets/switch/","title":"Switch","text":"A simple switch widget which stores a boolean value.
The example below shows switches in various states.
Outputswitch.pyswitch.tcssSwitchApp Example\u00a0switches \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e off:\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e on:\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e focused:\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e custom:\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Static, Switch\nclass SwitchApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(\"[b]Example switches\\n\", classes=\"label\")\nyield Horizontal(\nStatic(\"off: \", classes=\"label\"),\nSwitch(animate=False),\nclasses=\"container\",\n)\nyield Horizontal(\nStatic(\"on: \", classes=\"label\"),\nSwitch(value=True),\nclasses=\"container\",\n)\nfocused_switch = Switch()\nfocused_switch.focus()\nyield Horizontal(\nStatic(\"focused: \", classes=\"label\"), focused_switch, classes=\"container\"\n)\nyield Horizontal(\nStatic(\"custom: \", classes=\"label\"),\nSwitch(id=\"custom-design\"),\nclasses=\"container\",\n)\napp = SwitchApp(css_path=\"switch.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nalign: center middle;\n}\n.container {\nheight: auto;\nwidth: auto;\n}\nSwitch {\nheight: auto;\nwidth: auto;\n}\n.label {\nheight: 3;\ncontent-align: center middle;\nwidth: auto;\n}\n#custom-design {\nbackground: darkslategrey;\n}\n#custom-design > .switch--slider {\ncolor: dodgerblue;\nbackground: darkslateblue;\n}\n
"},{"location":"widgets/switch/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the switch."},{"location":"widgets/switch/#messages","title":"Messages","text":"The switch widget defines the following bindings:
Key(s) Description enter,space Toggle the switch state."},{"location":"widgets/switch/#component-classes","title":"Component Classes","text":"The switch widget provides the following component classes:
Class Descriptionswitch--slider
Targets the slider of the switch."},{"location":"widgets/switch/#additional-notes","title":"Additional Notes","text":"Switch
, set border: none;
and padding: 0;
.class
","text":"def __init__(\nself,\nvalue=False,\n*,\nanimate=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A switch widget that represents a boolean value.
Can be toggled by clicking on it or through its bindings.
The switch widget also contains component classes that enable more customization.
Parameters Name Type Description Defaultvalue
bool
The initial value of the switch.
False
animate
bool
True if the switch should animate when toggled.
True
name
str | None
The name of the switch.
None
id
str | None
The ID of the switch in the DOM.
None
classes
str | None
The CSS classes of the switch.
None
disabled
bool
Whether the switch is disabled or not.
False
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter,space\", \"toggle\", \"Toggle\", show=False)\n]\n
Key(s) Description enter,space Toggle the switch state."},{"location":"widgets/switch/#textual.widgets._switch.Switch.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {'switch--slider'}\n
Class Description switch--slider
Targets the slider of the switch."},{"location":"widgets/switch/#textual.widgets._switch.Switch.slider_pos","title":"slider_pos class-attribute
instance-attribute
","text":"slider_pos = reactive(0.0)\n
The position of the slider.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.value","title":"valueclass-attribute
instance-attribute
","text":"value = reactive(False, init=False)\n
The value of the switch; True
for on and False
for off.
class
","text":"def __init__(self, switch, value):\n
Bases: Message
Posted when the status of the switch changes.
Can be handled using on_switch_changed
in a subclass of Switch
or in a parent widget in the DOM.
value
bool
The value that the switch was changed to.
switch
Switch
The Switch
widget that was changed.
property
","text":"control: Switch\n
Alias for self.switch.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.action_toggle","title":"action_togglemethod
","text":"def action_toggle(self):\n
Toggle the state of the switch.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.toggle","title":"togglemethod
","text":"def toggle(self):\n
Toggle the switch value.
As a result of the value changing, a Switch.Changed
message will be posted.
Self
The Switch
instance.
Added in version 0.16.0
Switch between mutually exclusive content panes via a row of tabs.
This widget combines the Tabs and ContentSwitcher widgets to create a convenient way of navigating content.
Only a single child of TabbedContent is visible at once. Each child has an associated tab which will make it visible and hide the others.
"},{"location":"widgets/tabbed_content/#composing","title":"Composing","text":"There are two ways to provide the titles for the tab. You can pass them as positional arguments to the TabbedContent constructor:
def compose(self) -> ComposeResult:\nwith TabbedContent(\"Leto\", \"Jessica\", \"Paul\"):\nyield Markdown(LETO)\nyield Markdown(JESSICA)\nyield Markdown(PAUL)\n
Alternatively you can wrap the content in a TabPane widget, which takes the tab title as the first parameter:
def compose(self) -> ComposeResult:\nwith TabbedContent():\nwith TabPane(\"Leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\"):\nyield Markdown(PAUL)\n
"},{"location":"widgets/tabbed_content/#switching-tabs","title":"Switching tabs","text":"If you need to programmatically switch tabs, you should provide an id
attribute to the TabPane
s.
def compose(self) -> ComposeResult:\nwith TabbedContent():\nwith TabPane(\"Leto\", id=\"leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\n
You can then switch tabs by setting the active
reactive attribute:
# Switch to Jessica tab\nself.query_one(TabbedContent).active = \"jessica\"\n
Note
If you don't provide id
attributes to the tab panes, they will be assigned sequentially starting at tab-1
(then tab-2
etc).
The first child of TabbedContent
will be the initial active tab by default. You can pick a different initial tab by setting the initial
argument to the id
of the tab:
def compose(self) -> ComposeResult:\nwith TabbedContent(initial=\"jessica\"):\nwith TabPane(\"Leto\", id=\"leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\n
"},{"location":"widgets/tabbed_content/#example","title":"Example","text":"The following example contains a TabbedContent
with three tabs.
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, Markdown, TabbedContent, TabPane\nLETO = \"\"\"\n# Duke Leto I Atreides\nHead of House Atreides.\n\"\"\"\nJESSICA = \"\"\"\n# Lady Jessica\nBene Gesserit and concubine of Leto, and mother of Paul and Alia.\n\"\"\"\nPAUL = \"\"\"\n# Paul Atreides\nSon of Leto and Jessica.\n\"\"\"\nclass TabbedApp(App):\n\"\"\"An example of tabbed content.\"\"\"\nBINDINGS = [\n(\"l\", \"show_tab('leto')\", \"Leto\"),\n(\"j\", \"show_tab('jessica')\", \"Jessica\"),\n(\"p\", \"show_tab('paul')\", \"Paul\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Compose app with tabbed content.\"\"\"\n# Footer to show keys\nyield Footer()\n# Add the TabbedContent widget\nwith TabbedContent(initial=\"jessica\"):\nwith TabPane(\"Leto\", id=\"leto\"): # First tab\nyield Markdown(LETO) # Tab content\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabbedContent(\"Paul\", \"Alia\"):\nyield TabPane(\"Paul\", Label(\"First child\"))\nyield TabPane(\"Alia\", Label(\"Second child\"))\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\ndef action_show_tab(self, tab: str) -> None:\n\"\"\"Switch to a new tab.\"\"\"\nself.get_child_by_type(TabbedContent).active = tab\nif __name__ == \"__main__\":\napp = TabbedApp()\napp.run()\n
"},{"location":"widgets/tabbed_content/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description active
str
\"\"
The id
attribute of the active tab. Set this to switch tabs."},{"location":"widgets/tabbed_content/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/tabbed_content/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/tabbed_content/#see-also","title":"See also","text":"class
","text":"def __init__(\nself,\n*titles,\ninitial=\"\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A container with associated tabs to toggle content visibility.
Parameters Name Type Description Default*titles
TextType
Positional argument will be used as title.
()
initial
str
The id of the initial tab, or empty string to select the first tab.
''
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.active","title":"active class-attribute
instance-attribute
","text":"active: reactive[str] = reactive('', init=False)\n
The ID of the active tab, or empty string if none are active.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.tab_count","title":"tab_countproperty
","text":"tab_count: int\n
Total number of tabs.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.Cleared","title":"Clearedclass
","text":"def __init__(self, tabbed_content):\n
Bases: Message
Posted when there are no more tab panes.
Parameters Name Type Description Defaulttabbed_content
TabbedContent
The TabbedContent widget.
required"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.Cleared.control","title":"controlproperty
","text":"control: TabbedContent\n
The TabbedContent
widget that was cleared of all tab panes.
This is an alias for Cleared.tabbed_content
and is used by the on
decorator.
instance-attribute
","text":"tabbed_content = tabbed_content\n
The TabbedContent
widget that contains the tab activated.
class
","text":"def __init__(self, tabbed_content, tab):\n
Bases: Message
Posted when the active tab changes.
Parameters Name Type Description Defaulttabbed_content
TabbedContent
The TabbedContent widget.
requiredtab
Tab
The Tab widget that was selected (contains the tab label).
required"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.TabActivated.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'tab'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: TabbedContent\n
The TabbedContent
widget that contains the tab activated.
This is an alias for TabActivated.tabbed_content
and is used by the on
decorator.
instance-attribute
","text":"tab = tab\n
The Tab
widget that was selected (contains the tab label).
instance-attribute
","text":"tabbed_content = tabbed_content\n
The TabbedContent
widget that contains the tab activated.
method
","text":"def add_pane(self, pane, *, before=None, after=None):\n
Add a new pane to the tabbed content.
Parameters Name Type Description Defaultpane
TabPane
The pane to add.
requiredbefore
TabPane | str | None
Optional pane or pane ID to add the pane before.
None
after
TabPane | str | None
Optional pane or pane ID to add the pane after.
None
Returns Type Description AwaitTabbedContent
An awaitable object that waits for the pane to be added.
Raises Type DescriptionTabs.TabError
If there is a problem with the addition request.
NoteOnly one of before
or after
can be provided. If both are provided a Tabs.TabError
will be raised.
method
","text":"def clear_panes(self):\n
Remove all the panes in the tabbed content.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.disable_tab","title":"disable_tabmethod
","text":"def disable_tab(self, tab_id):\n
Disables the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to disable.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.enable_tab","title":"enable_tabmethod
","text":"def enable_tab(self, tab_id):\n
Enables the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to enable.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.hide_tab","title":"hide_tabmethod
","text":"def hide_tab(self, tab_id):\n
Hides the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to hide.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.remove_pane","title":"remove_panemethod
","text":"def remove_pane(self, pane_id):\n
Remove a given pane from the tabbed content.
Parameters Name Type Description Defaultpane_id
str
The ID of the pane to remove.
required Returns Type DescriptionAwaitTabbedContent
An awaitable object that waits for the pane to be removed.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.show_tab","title":"show_tabmethod
","text":"def show_tab(self, tab_id):\n
Shows the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to show.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.validate_active","title":"validate_activemethod
","text":"def validate_active(self, active):\n
It doesn't make sense for active
to be an empty string.
active
str
Attribute to be validated.
required Returns Type Descriptionstr
Value of active
.
ValueError
If the active attribute is set to empty string when there are tabs available.
"},{"location":"widgets/tabbed_content/#textual.widgets.TabPane","title":"textual.widgets.TabPaneclass
","text":"def __init__(\nself,\ntitle,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A container for switchable content, with additional title.
This widget is intended to be used with TabbedContent.
Parameters Name Type Description Defaulttitle
TextType
Title of the TabPane (will be displayed in a tab label).
required*children
Widget
Widget to go inside the TabPane.
()
name
str | None
Optional name for the TabPane.
None
id
str | None
Optional ID for the TabPane.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the TabPane is disabled or not.
False
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabPane.Disabled","title":"Disabled class
","text":" Bases: TabPaneMessage
Sent when a tab pane is disabled via its reactive disabled
.
class
","text":" Bases: TabPaneMessage
Sent when a tab pane is enabled via its reactive disabled
.
class
","text":" Bases: Message
Base class for TabPane
messages.
property
","text":"control: TabPane\n
The tab pane that is the object of this message.
This is an alias for the attribute tab_pane
and is used by the on
decorator.
instance-attribute
","text":"tab_pane: TabPane\n
The TabPane
that is he object of this message.
Added in version 0.15.0
Displays a number of tab headers which may be activated with a click or navigated with cursor keys.
Construct a Tabs
widget with strings or Text objects as positional arguments, which will set the labels in the tabs. Here's an example with three tabs:
def compose(self) -> ComposeResult:\nyield Tabs(\"First tab\", \"Second tab\", Text.from_markup(\"[u]Third[/u] tab\"))\n
This will create Tab widgets internally, with auto-incrementing id
attributes (\"tab-1\"
, \"tab-2\"
etc). You can also supply Tab
objects directly in the constructor, which will allow you to explicitly set an id
. Here's an example:
def compose(self) -> ComposeResult:\nyield Tabs(\nTab(\"First tab\", id=\"one\"),\nTab(\"Second tab\", id=\"two\"),\n)\n
When the user switches to a tab by clicking or pressing keys, then Tabs
will send a Tabs.TabActivated message which contains the tab
that was activated. You can then use event.tab.id
attribute to perform any related actions.
Clear tabs by calling the clear method. Clearing the tabs will send a Tabs.TabActivated message with the tab
attribute set to None
.
Tabs may be added dynamically with the add_tab method, which accepts strings, Text, or Tab objects.
"},{"location":"widgets/tabs/#example","title":"Example","text":"The following example adds a Tabs
widget above a text label. Press A to add a tab, C to clear the tabs.
TabsApp \u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0HalleckBaron\u00a0Vladimir \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, Tabs\nNAMES = [\n\"Paul Atreidies\",\n\"Duke Leto Atreides\",\n\"Lady Jessica\",\n\"Gurney Halleck\",\n\"Baron Vladimir Harkonnen\",\n\"Glossu Rabban\",\n\"Chani\",\n\"Silgar\",\n]\nclass TabsApp(App):\n\"\"\"Demonstrates the Tabs widget.\"\"\"\nCSS = \"\"\"\n Tabs {\n dock: top;\n }\n Screen {\n align: center middle;\n }\n Label {\n margin:1 1;\n width: 100%;\n height: 100%;\n background: $panel;\n border: tall $primary;\n content-align: center middle;\n }\n \"\"\"\nBINDINGS = [\n(\"a\", \"add\", \"Add tab\"),\n(\"r\", \"remove\", \"Remove active tab\"),\n(\"c\", \"clear\", \"Clear tabs\"),\n]\ndef compose(self) -> ComposeResult:\nyield Tabs(NAMES[0])\nyield Label()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Focus the tabs when the app starts.\"\"\"\nself.query_one(Tabs).focus()\ndef on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:\n\"\"\"Handle TabActivated message sent by Tabs.\"\"\"\nlabel = self.query_one(Label)\nif event.tab is None:\n# When the tabs are cleared, event.tab will be None\nlabel.visible = False\nelse:\nlabel.visible = True\nlabel.update(event.tab.label)\ndef action_add(self) -> None:\n\"\"\"Add a new tab.\"\"\"\ntabs = self.query_one(Tabs)\n# Cycle the names\nNAMES[:] = [*NAMES[1:], NAMES[0]]\ntabs.add_tab(NAMES[0])\ndef action_remove(self) -> None:\n\"\"\"Remove active tab.\"\"\"\ntabs = self.query_one(Tabs)\nactive_tab = tabs.active_tab\nif active_tab is not None:\ntabs.remove_tab(active_tab.id)\ndef action_clear(self) -> None:\n\"\"\"Clear the tabs.\"\"\"\nself.query_one(Tabs).clear()\nif __name__ == \"__main__\":\napp = TabsApp()\napp.run()\n
"},{"location":"widgets/tabs/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description active
str
\"\"
The ID of the active tab. Set this attribute to a tab ID to change the active tab."},{"location":"widgets/tabs/#messages","title":"Messages","text":"The Tabs widget defines the following bindings:
Key(s) Description left Move to the previous tab. right Move to the next tab."},{"location":"widgets/tabs/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/tabs/#textual.widgets.Tabs","title":"textual.widgets.Tabsclass
","text":"def __init__(\nself,\n*tabs,\nactive=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A row of tabs.
Parameters Name Type Description Default*tabs
Tab | TextType
Positional argument should be explicit Tab objects, or a str or Text.
()
active
str | None
ID of the tab which should be active on start.
None
name
str | None
Optional name for the input widget.
None
id
str | None
Optional ID for the widget.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the input is disabled or not.
False
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"left\", \"previous_tab\", \"Previous tab\", show=False\n),\nBinding(\"right\", \"next_tab\", \"Next tab\", show=False),\n]\n
Key(s) Description left Move to the previous tab. right Move to the next tab."},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.active","title":"active class-attribute
instance-attribute
","text":"active: reactive[str] = reactive('', init=False)\n
The ID of the active tab, or empty string if none are active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.active_tab","title":"active_tabproperty
","text":"active_tab: Tab | None\n
The currently active tab, or None if there are no active tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.tab_count","title":"tab_countproperty
","text":"tab_count: int\n
Total number of tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.Cleared","title":"Clearedclass
","text":"def __init__(self, tabs):\n
Bases: Message
Sent when there are no active tabs.
Parameters Name Type Description Defaulttabs
Tabs
The tabs widget.
required"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.Cleared.control","title":"controlproperty
","text":"control: Tabs\n
The tabs widget which was cleared.
This is an alias for Cleared.tabs
which is used by the on
decorator.
instance-attribute
","text":"tabs: Tabs = tabs\n
The tabs widget which was cleared.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabActivated","title":"TabActivatedclass
","text":" Bases: TabMessage
Sent when a new tab is activated.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabDisabled","title":"TabDisabledclass
","text":" Bases: TabMessage
Sent when a tab is disabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabEnabled","title":"TabEnabledclass
","text":" Bases: TabMessage
Sent when a tab is enabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabError","title":"TabErrorclass
","text":" Bases: Exception
Exception raised when there is an error relating to tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabHidden","title":"TabHiddenclass
","text":" Bases: TabMessage
Sent when a tab is hidden.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage","title":"TabMessageclass
","text":"def __init__(self, tabs, tab):\n
Bases: Message
Parent class for all messages that have to do with a specific tab.
Parameters Name Type Description Defaulttabs
Tabs
The Tabs widget.
requiredtab
Tab
The tab that is the object of this message.
required"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'tab'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: Tabs\n
The tabs widget containing the tab that is the object of this message.
This is an alias for the attribute tabs
and is used by the on
decorator.
instance-attribute
","text":"tab: Tab = tab\n
The tab that is the object of this message.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage.tabs","title":"tabsinstance-attribute
","text":"tabs: Tabs = tabs\n
The tabs widget containing the tab.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabShown","title":"TabShownclass
","text":" Bases: TabMessage
Sent when a tab is shown.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.action_next_tab","title":"action_next_tabmethod
","text":"def action_next_tab(self):\n
Make the next tab active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.action_previous_tab","title":"action_previous_tabmethod
","text":"def action_previous_tab(self):\n
Make the previous tab active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.add_tab","title":"add_tabmethod
","text":"def add_tab(self, tab, *, before=None, after=None):\n
Add a new tab to the end of the tab list.
Parameters Name Type Description Defaulttab
Tab | str | Text
A new tab object, or a label (str or Text).
requiredbefore
Tab | str | None
Optional tab or tab ID to add the tab before.
None
after
Tab | str | None
Optional tab or tab ID to add the tab after.
None
Returns Type Description AwaitMount
An awaitable object that waits for the tab to be mounted.
Raises Type DescriptionTabs.TabError
If there is a problem with the addition request.
NoteOnly one of before
or after
can be provided. If both are provided a Tabs.TabError
will be raised.
method
","text":"def clear(self):\n
Clear all the tabs.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the tabs to be removed.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.disable","title":"disablemethod
","text":"def disable(self, tab_id):\n
Disable the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to disable.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.enable","title":"enablemethod
","text":"def enable(self, tab_id):\n
Enable the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to enable.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.hide","title":"hidemethod
","text":"def hide(self, tab_id):\n
Hide the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to hide.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.remove_tab","title":"remove_tabmethod
","text":"def remove_tab(self, tab_or_id):\n
Remove a tab.
Parameters Name Type Description Defaulttab_or_id
Tab | str | None
The Tab to remove or its id.
required Returns Type DescriptionAwaitRemove
An awaitable object that waits for the tab to be removed.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.show","title":"showmethod
","text":"def show(self, tab_id):\n
Show the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to show.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.validate_active","title":"validate_activemethod
","text":"def validate_active(self, active):\n
Check id assigned to active attribute is a valid tab.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.watch_active","title":"watch_activemethod
","text":"def watch_active(self, previously_active, active):\n
Handle a change to the active tab.
"},{"location":"widgets/tabs/#textual.widgets.Tab","title":"textual.widgets.Tabclass
","text":"def __init__(\nself, label, *, id=None, classes=None, disabled=False\n):\n
Bases: Static
A Widget to manage a single tab within a Tabs widget.
Parameters Name Type Description Defaultlabel
TextType
The label to use in the tab.
requiredid
str | None
Optional ID for the widget.
None
classes
str | None
Space separated list of class names.
None
disabled
bool
Whether the tab is disabled or not.
False
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.label_text","title":"label_text property
","text":"label_text: str\n
Undecorated text of the label.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Clicked","title":"Clickedclass
","text":" Bases: TabMessage
A tab was clicked.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Disabled","title":"Disabledclass
","text":" Bases: TabMessage
A tab was disabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Enabled","title":"Enabledclass
","text":" Bases: TabMessage
A tab was enabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.TabMessage","title":"TabMessageclass
","text":" Bases: Message
Tab-related messages.
These are mostly intended for internal use when interacting with Tabs
.
property
","text":"control: Tab\n
The tab that is the object of this message.
This is an alias for the attribute tab
and is used by the on
decorator.
instance-attribute
","text":"tab: Tab\n
The tab that is the object of this message.
"},{"location":"widgets/text_area/","title":"TextArea","text":"Added in version 0.38.0
A widget for editing text which may span multiple lines. Supports syntax highlighting for a selection of languages.
To enable syntax highlighting, you'll need to install the syntax
extra dependencies:
pip install \"textual[syntax]\"\n
poetry add \"textual[syntax]\"\n
This will install tree-sitter
and tree-sitter-languages
. These packages are distributed as binary wheels, so it may limit your applications ability to run in environments where these wheels are not supported.
In this example we load some initial text into the TextArea
, and set the language to \"python\"
to enable syntax highlighting.
TextAreaExample 1\u00a0\u00a0defhello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0print(\"hello\"+\u00a0name)\u00a0\u00a0\u00a0 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nTEXT = \"\"\"\\\ndef hello(name):\n print(\"hello\" + name)\ndef goodbye(name):\n print(\"goodbye\" + name)\n\"\"\"\nclass TextAreaExample(App):\ndef compose(self) -> ComposeResult:\nyield TextArea(TEXT, language=\"python\")\napp = TextAreaExample()\nif __name__ == \"__main__\":\napp.run()\n
To load content into the TextArea
after it has already been created, use the load_text
method.
To update the parser used for syntax highlighting, set the language
reactive attribute:
# Set the language to Markdown\ntext_area.language = \"markdown\"\n
Note
Syntax highlighting is unavailable on Python 3.7.
Note
More built-in languages will be added in the future. For now, you can add your own.
"},{"location":"widgets/text_area/#reading-content-from-textarea","title":"Reading content fromTextArea
","text":"There are a number of ways to retrieve content from the TextArea
:
TextArea.text
property returns all content in the text area as a string.TextArea.selected_text
property returns the text corresponding to the current selection.TextArea.get_text_range
method returns the text between two locations.In all cases, when multiple lines of text are retrieved, the document line separator will be used.
"},{"location":"widgets/text_area/#editing-content-inside-textarea","title":"Editing content insideTextArea
","text":"The content of the TextArea
can be updated using the replace
method. This method is the programmatic equivalent of selecting some text and then pasting.
Some other convenient methods are available, such as insert
, delete
, and clear
.
The cursor location is available via the cursor_location
property, which represents the location of the cursor as a tuple (row_index, column_index)
. These indices are zero-based. Writing a new value to cursor_location
will immediately update the location of the cursor.
>>> text_area = TextArea()\n>>> text_area.cursor_location\n(0, 0)\n>>> text_area.cursor_location = (0, 4)\n>>> text_area.cursor_location\n(0, 4)\n
cursor_location
is a simple way to move the cursor programmatically, but it doesn't let us select text.
To select text, we can use the selection
reactive attribute. Let's select the first two lines of text in a document by adding text_area.selection = Selection(start=(0, 0), end=(2, 0))
to our code:
TextAreaSelection 1\u00a0\u00a0defhello(name): 2\u00a0\u00a0print(\"hello\"+\u00a0name) 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nfrom textual.widgets.text_area import Selection\nTEXT = \"\"\"\\\ndef hello(name):\n print(\"hello\" + name)\ndef goodbye(name):\n print(\"goodbye\" + name)\n\"\"\"\nclass TextAreaSelection(App):\ndef compose(self) -> ComposeResult:\ntext_area = TextArea(TEXT, language=\"python\")\ntext_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)!\nyield text_area\napp = TextAreaSelection()\nif __name__ == \"__main__\":\napp.run()\n
Note that selections can happen in both directions, so Selection((2, 0), (0, 0))
is also valid.
Tip
The end
attribute of the selection
is always equal to TextArea.cursor_location
. In other words, the cursor_location
attribute is simply a convenience for accessing text_area.selection.end
.
There are a number of additional utility methods available for interacting with the cursor.
"},{"location":"widgets/text_area/#location-information","title":"Location information","text":"A number of properties exist on TextArea
which give information about the current cursor location. These properties begin with cursor_at_
, and return booleans. For example, cursor_at_start_of_line
tells us if the cursor is at a start of line.
We can also check the location the cursor would arrive at if we were to move it. For example, get_cursor_right_location
returns the location the cursor would move to if it were to move right. A number of similar methods exist, with names like get_cursor_*_location
.
The move_cursor
method allows you to move the cursor to a new location while selecting text, or move the cursor and scroll to keep it centered.
# Move the cursor from its current location to row index 4,\n# column index 8, while selecting all the text between.\ntext_area.move_cursor((4, 8), select=True)\n
The move_cursor_relative
method offers a very similar interface, but moves the cursor relative to its current location.
There are some methods available which make common selections easier:
select_line
selects a line by index. Bound to F6 by default.select_all
selects all text. Bound to F7 by default.TextArea
ships with some builtin themes, and you can easily add your own.
Themes give you control over the look and feel, including syntax highlighting, the cursor, selection, gutter, and more.
"},{"location":"widgets/text_area/#using-builtin-themes","title":"Using builtin themes","text":"The initial theme of the TextArea
is determined by the theme
parameter.
# Create a TextArea with the 'dracula' theme.\nyield TextArea(\"print(123)\", language=\"python\", theme=\"dracula\")\n
You can check which themes are available using the available_themes
property.
>>> text_area = TextArea()\n>>> print(text_area.available_themes)\n{'dracula', 'github_light', 'monokai', 'vscode_dark'}\n
After creating a TextArea
, you can change the theme by setting the theme
attribute to one of the available themes.
text_area.theme = \"vscode_dark\"\n
On setting this attribute the TextArea
will immediately refresh to display the updated theme.
Using custom (non-builtin) themes is two-step process:
TextAreaTheme
.TextArea.register_theme
.Let's create a simple theme, \"my_cool_theme\"
, which colors the cursor blue, and the cursor line yellow. Our theme will also syntax highlight strings as red, and comments as magenta.
from rich.style import Style\nfrom textual.widgets.text_area import TextAreaTheme\n# ...\nmy_theme = TextAreaTheme(\n# This name will be used to refer to the theme...\nname=\"my_cool_theme\",\n# Basic styles such as background, cursor, selection, gutter, etc...\ncursor_style=Style(color=\"white\", bgcolor=\"blue\"),\ncursor_line_style=Style(bgcolor=\"yellow\"),\n# `syntax_styles` is for syntax highlighting.\n# It maps tokens parsed from the document to Rich styles.\nsyntax_styles={\n\"string\": Style(color=\"red\"),\n\"comment\": Style(color=\"magenta\"),\n}\n)\n
Attributes like cursor_style
and cursor_line_style
apply general language-agnostic styling to the widget.
The syntax_styles
attribute of TextAreaTheme
is used for syntax highlighting and depends on the language
currently in use. For more details, see syntax highlighting.
If you wish to build on an existing theme, you can obtain a reference to it using the TextAreaTheme.get_builtin_theme
classmethod:
from textual.widgets.text_area import TextAreaTheme\nmonokai = TextAreaTheme.get_builtin_theme(\"monokai\")\n
"},{"location":"widgets/text_area/#2-registering-a-theme","title":"2. Registering a theme","text":"Our theme can now be registered with the TextArea
instance.
text_area.register_theme(my_theme)\n
After registering a theme, it'll appear in the available_themes
:
>>> print(text_area.available_themes)\n{'dracula', 'github_light', 'monokai', 'vscode_dark', 'my_cool_theme'}\n
We can now switch to it:
text_area.theme = \"my_cool_theme\"\n
This immediately updates the appearance of the TextArea
:
TextAreaCustomThemes 1\u00a0\u00a0#\u00a0says\u00a0hello 2\u00a0\u00a0def\u00a0hello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0print(\"hello\"\u00a0+\u00a0name)\u00a0\u00a0\u00a0 4\u00a0\u00a0 5\u00a0\u00a0#\u00a0says\u00a0goodbye 6\u00a0\u00a0def\u00a0goodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 7\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0print(\"goodbye\"\u00a0+\u00a0name)\u00a0 8\u00a0\u00a0
"},{"location":"widgets/text_area/#indentation","title":"Indentation","text":"The character(s) inserted when you press tab is controlled by setting the indent_type
attribute to either tabs
or spaces
.
If indent_type == \"spaces\"
, pressing Tab will insert up to indent_width
spaces in order to align with the next tab stop.
When content is loaded into TextArea
, the content is scanned from beginning to end and the first occurrence of a line separator is recorded.
This separator will then be used when content is later read from the TextArea
via the text
property. The TextArea
widget does not support exporting text which contains mixed line endings.
Similarly, newline characters pasted into the TextArea
will be converted.
You can check the line separator of the current document by inspecting TextArea.document.newline
:
>>> text_area = TextArea()\n>>> text_area.document.newline\n'\\n'\n
"},{"location":"widgets/text_area/#line-numbers","title":"Line numbers","text":"The gutter (column on the left containing line numbers) can be toggled by setting the show_line_numbers
attribute to True
or False
.
Setting this attribute will immediately repaint the TextArea
to reflect the new value.
TextArea
","text":"Sometimes, you may wish to subclass TextArea
to add some extra functionality. In this section, we'll briefly explore how we can extend the widget to achieve common goals.
You may wish to hook into certain key presses to inject some functionality. This can be done by over-riding _on_key
and adding the required functionality.
Let's extend TextArea
to add a feature which automatically closes parentheses and moves the cursor to a sensible location.
from textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nclass ExtendedTextArea(TextArea):\n\"\"\"A subclass of TextArea with parenthesis-closing functionality.\"\"\"\ndef _on_key(self, event: events.Key) -> None:\nif event.character == \"(\":\nself.insert(\"()\")\nself.move_cursor_relative(columns=-1)\nevent.prevent_default()\nclass TextAreaKeyPressHook(App):\ndef compose(self) -> ComposeResult:\nyield ExtendedTextArea(language=\"python\")\napp = TextAreaKeyPressHook()\nif __name__ == \"__main__\":\napp.run()\n
This intercepts the key handler when \"(\"
is pressed, and inserts \"()\"
instead. It then moves the cursor so that it lands between the open and closing parentheses.
Typing def hello(
into the TextArea
results in the bracket automatically being closed:
TextAreaKeyPressHook 1\u00a0\u00a0def\u00a0hello()
"},{"location":"widgets/text_area/#advanced-concepts","title":"Advanced concepts","text":""},{"location":"widgets/text_area/#syntax-highlighting","title":"Syntax highlighting","text":"Syntax highlighting inside the TextArea
is powered by a library called tree-sitter
.
Each time you update the document in a TextArea
, an internal syntax tree is updated. This tree is frequently queried to find location ranges relevant to syntax highlighting. We give these ranges names, and ultimately map them to Rich styles inside TextAreaTheme.syntax_styles
.
To illustrate how this works, lets look at how the \"Monokai\" TextAreaTheme
highlights Markdown files.
When the language
attribute is set to \"markdown\"
, a highlight query similar to the one below is used (trimmed for brevity).
(heading_content) @heading\n(link) @link\n
This highlight query maps heading_content
nodes returned by the Markdown parser to the name @heading
, and link
nodes to the name @link
.
Inside our TextAreaTheme.syntax_styles
dict, we can map the name @heading
to a Rich style. Here's a snippet from the \"Monokai\" theme which does just that:
TextAreaTheme(\nname=\"monokai\",\nbase_style=Style(color=\"#f8f8f2\", bgcolor=\"#272822\"),\ngutter_style=Style(color=\"#90908a\", bgcolor=\"#272822\"),\n# ...\nsyntax_styles={\n# Colorise @heading and make them bold\n\"heading\": Style(color=\"#F92672\", bold=True),\n# Colorise and underline @link\n\"link\": Style(color=\"#66D9EF\", underline=True),\n# ...\n},\n)\n
To understand which names can be mapped inside syntax_styles
, we recommend looking at the existing themes and highlighting queries (.scm
files) in the Textual repository.
Tip
You may also wish to take a look at the contents of TextArea._highlights
on an active TextArea
instance to see which highlights have been generated for the open document.
To add support for a language to a TextArea
, use the register_language
method.
To register a language, we require two things:
Language
object which contains the grammar for the language.The easiest way to obtain a Language
object is using the py-tree-sitter-languages
package. Here's how we can use this package to obtain a reference to a Language
object representing Java:
from tree_sitter_languages import get_language\njava_language = get_language(\"java\")\n
Note
py-tree-sitter-languages
may not be available on some architectures (e.g. Macbooks with Apple Silicon running Python 3.7).
The exact version of the parser used when you call get_language
can be checked via the repos.txt
file in the version of py-tree-sitter-languages
you're using. This file contains links to the GitHub repos and commit hashes of the tree-sitter parsers. In these repos you can often find pre-made highlight queries at queries/highlights.scm
, and a file showing all the available node types which can be used in highlight queries at src/node-types.json
.
Since we're adding support for Java, lets grab the Java highlight query from the repo by following these steps:
repos.txt
file from the py-tree-sitter-languages
repo.tree-sitter-java
and go to the repo on GitHub (you may also need to go to the specific commit referenced in repos.txt
).queries/highlights.scm
to see the example highlight query for Java.Be sure to check the license in the repo to ensure it can be freely copied.
Warning
It's important to use a highlight query which is compatible with the parser in use, so pay attention to the commit hash when visiting the repo via repos.txt
.
We now have our Language
and our highlight query, so we can register Java as a language.
from pathlib import Path\nfrom tree_sitter_languages import get_language\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\njava_language = get_language(\"java\")\njava_highlight_query = (Path(__file__).parent / \"java_highlights.scm\").read_text()\njava_code = \"\"\"\\\nclass HelloWorld {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n\"\"\"\nclass TextAreaCustomLanguage(App):\ndef compose(self) -> ComposeResult:\ntext_area = TextArea(text=java_code)\ntext_area.cursor_blink = False\n# Register the Java language and highlight query\ntext_area.register_language(java_language, java_highlight_query)\n# Switch to Java\ntext_area.language = \"java\"\nyield text_area\napp = TextAreaCustomLanguage()\nif __name__ == \"__main__\":\napp.run()\n
Running our app, we can see that the Java code is highlighted. We can freely edit the text, and the syntax highlighting will update immediately.
TextAreaCustomLanguage 1\u00a0\u00a0class\u00a0HelloWorld\u00a0{\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0publicstatic\u00a0void\u00a0main(String[]\u00a0args)\u00a0{\u00a0 3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0System.out.println(\"Hello,\u00a0World!\");\u00a0 4\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0}\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0}\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 6\u00a0\u00a0
Recall that we map names (like @heading
) from the tree-sitter highlight query to Rich style objects inside the TextAreaTheme.syntax_styles
dictionary. If you notice some highlights are missing after registering a language, the issue may be:
TextAreaTheme
doesn't contain a mapping for the name in the highlight query. Adding a new to syntax_styles
should resolve the issue.Tip
The names assigned in tree-sitter highlight queries are often reused across multiple languages. For example, @string
is used in many languages to highlight strings.
language
str | None
None
The language to use for syntax highlighting. theme
str | None
TextAreaTheme.default()
The theme to use for syntax highlighting. selection
Selection
Selection()
The current selection. show_line_numbers
bool
True
Show or hide line numbers. indent_width
int
4
The number of spaces to indent and width of tabs. match_cursor_bracket
bool
True
Enable/disable highlighting matching brackets under cursor. cursor_blink
bool
True
Enable/disable blinking of the cursor when the widget has focus."},{"location":"widgets/text_area/#messages","title":"Messages","text":"The TextArea
widget defines the following bindings:
The TextArea
widget defines no component classes.
Styling should be done exclusively via TextAreaTheme
.
Input
- for single-line text input.TextAreaTheme
- for theming the TextArea
.py-tree-sitter-languages
repository (provides binary wheels for a large variety of tree-sitter languages).class
","text":"def __init__(\nself,\ntext=\"\",\n*,\nlanguage=None,\ntheme=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
text
str
The initial text to load into the TextArea.
''
language
str | None
The language to use.
None
theme
str | None
The theme to use.
None
name
str | None
The name of the TextArea
widget.
None
id
str | None
The ID of the widget, used to refer to it from Textual CSS.
None
classes
str | None
One or more Textual CSS compatible class names separated by spaces.
None
disabled
bool
True if the widget is disabled.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [\nBinding(\n\"escape\",\n\"screen.focus_next\",\n\"Shift Focus\",\nshow=False,\n),\nBinding(\"up\", \"cursor_up\", \"cursor up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"cursor down\", show=False\n),\nBinding(\n\"left\", \"cursor_left\", \"cursor left\", show=False\n),\nBinding(\n\"right\", \"cursor_right\", \"cursor right\", show=False\n),\nBinding(\n\"ctrl+left\",\n\"cursor_word_left\",\n\"cursor word left\",\nshow=False,\n),\nBinding(\n\"ctrl+right\",\n\"cursor_word_right\",\n\"cursor word right\",\nshow=False,\n),\nBinding(\n\"home,ctrl+a\",\n\"cursor_line_start\",\n\"cursor line start\",\nshow=False,\n),\nBinding(\n\"end,ctrl+e\",\n\"cursor_line_end\",\n\"cursor line end\",\nshow=False,\n),\nBinding(\n\"pageup\",\n\"cursor_page_up\",\n\"cursor page up\",\nshow=False,\n),\nBinding(\n\"pagedown\",\n\"cursor_page_down\",\n\"cursor page down\",\nshow=False,\n),\nBinding(\n\"ctrl+shift+left\",\n\"cursor_word_left(True)\",\n\"cursor left word select\",\nshow=False,\n),\nBinding(\n\"ctrl+shift+right\",\n\"cursor_word_right(True)\",\n\"cursor right word select\",\nshow=False,\n),\nBinding(\n\"shift+home\",\n\"cursor_line_start(True)\",\n\"cursor line start select\",\nshow=False,\n),\nBinding(\n\"shift+end\",\n\"cursor_line_end(True)\",\n\"cursor line end select\",\nshow=False,\n),\nBinding(\n\"shift+up\",\n\"cursor_up(True)\",\n\"cursor up select\",\nshow=False,\n),\nBinding(\n\"shift+down\",\n\"cursor_down(True)\",\n\"cursor down select\",\nshow=False,\n),\nBinding(\n\"shift+left\",\n\"cursor_left(True)\",\n\"cursor left select\",\nshow=False,\n),\nBinding(\n\"shift+right\",\n\"cursor_right(True)\",\n\"cursor right select\",\nshow=False,\n),\nBinding(\"f6\", \"select_line\", \"select line\", show=False),\nBinding(\"f7\", \"select_all\", \"select all\", show=False),\nBinding(\n\"backspace\",\n\"delete_left\",\n\"delete left\",\nshow=False,\n),\nBinding(\n\"ctrl+w\",\n\"delete_word_left\",\n\"delete left to start of word\",\nshow=False,\n),\nBinding(\n\"delete,ctrl+d\",\n\"delete_right\",\n\"delete right\",\nshow=False,\n),\nBinding(\n\"ctrl+f\",\n\"delete_word_right\",\n\"delete right to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+x\", \"delete_line\", \"delete line\", show=False\n),\nBinding(\n\"ctrl+u\",\n\"delete_to_start_of_line\",\n\"delete to line start\",\nshow=False,\n),\nBinding(\n\"ctrl+k\",\n\"delete_to_end_of_line\",\n\"delete to line end\",\nshow=False,\n),\n]\n
Key(s) Description escape Focus on the next item. up Move the cursor up. down Move the cursor down. left Move the cursor left. ctrl+left Move the cursor to the start of the word. ctrl+shift+left Move the cursor to the start of the word and select. right Move the cursor right. ctrl+right Move the cursor to the end of the word. ctrl+shift+right Move the cursor to the end of the word and select. home,ctrl+a Move the cursor to the start of the line. end,ctrl+e Move the cursor to the end of the line. shift+home Move the cursor to the start of the line and select. shift+end Move the cursor to the end of the line and select. pageup Move the cursor one page up. pagedown Move the cursor one page down. shift+up Select while moving the cursor up. shift+down Select while moving the cursor down. shift+left Select while moving the cursor left. shift+right Select while moving the cursor right. backspace Delete character to the left of cursor. ctrl+w Delete from cursor to start of the word. delete,ctrl+d Delete character to the right of cursor. ctrl+f Delete from cursor to end of the word. ctrl+x Delete the current line. ctrl+u Delete from cursor to the start of the line. ctrl+k Delete from cursor to the end of the line. f6 Select the current line. f7 Select all text in the document."},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.available_languages","title":"available_languages property
","text":"available_languages: set[str]\n
A list of the names of languages available to the TextArea
.
The values in this list can be assigned to the language
reactive attribute of TextArea
.
The returned list contains the builtin languages plus those registered via the register_language
method. Builtin languages will be listed before user-registered languages, but there are no other ordering guarantees.
property
","text":"available_themes: set[str]\n
A list of the names of the themes available to the TextArea
.
The values in this list can be assigned theme
reactive attribute of TextArea
.
You can retrieve the full specification for a theme by passing one of the strings from this list into TextAreaTheme.get_by_name(theme_name: str)
.
Alternatively, you can directly retrieve a list of TextAreaTheme
objects (which contain the full theme specification) by calling TextAreaTheme.builtin_themes()
.
property
","text":"cursor_at_end_of_line: bool\n
True if and only if the cursor is at the end of a row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_end_of_text","title":"cursor_at_end_of_textproperty
","text":"cursor_at_end_of_text: bool\n
True if and only if the cursor is at the very end of the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_first_line","title":"cursor_at_first_lineproperty
","text":"cursor_at_first_line: bool\n
True if and only if the cursor is on the first line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_last_line","title":"cursor_at_last_lineproperty
","text":"cursor_at_last_line: bool\n
True if and only if the cursor is on the last line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_start_of_line","title":"cursor_at_start_of_lineproperty
","text":"cursor_at_start_of_line: bool\n
True if and only if the cursor is at column 0.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_start_of_text","title":"cursor_at_start_of_textproperty
","text":"cursor_at_start_of_text: bool\n
True if and only if the cursor is at location (0, 0)
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_blink","title":"cursor_blinkclass-attribute
instance-attribute
","text":"cursor_blink: Reactive[bool] = reactive(True)\n
True if the cursor should blink.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_location","title":"cursor_locationwritable
property
","text":"cursor_location: Location\n
The current location of the cursor in the document.
This is a utility for accessing the end
of TextArea.selection
.
property
","text":"cursor_screen_offset: Offset\n
The offset of the cursor relative to the screen.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.document","title":"documentinstance-attribute
","text":"document: DocumentBase = Document(text)\n
The document this widget is currently editing.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.gutter_width","title":"gutter_widthproperty
","text":"gutter_width: int\n
The width of the gutter (the left column containing line numbers).
Returns Type Descriptionint
The cell-width of the line number column. If show_line_numbers
is False
returns 0.
instance-attribute
","text":"indent_type: Literal['tabs', 'spaces'] = 'spaces'\n
Whether to indent using tabs or spaces.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.indent_width","title":"indent_widthclass-attribute
instance-attribute
","text":"indent_width: Reactive[int] = reactive(4)\n
The width of tabs or the multiple of spaces to align to on pressing the tab
key.
If the document currently open contains tabs that are currently visible on screen, altering this value will immediately change the display width of the visible tabs.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.is_syntax_aware","title":"is_syntax_awareproperty
","text":"is_syntax_aware: bool\n
True if the TextArea is currently syntax aware - i.e. it's parsing document content.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.language","title":"languageclass-attribute
instance-attribute
","text":"language: Reactive[str | None] = language\n
The language to use.
This must be set to a valid, non-None value for syntax highlighting to work.
If the value is a string, a built-in language parser will be used if available.
If you wish to use an unsupported language, you'll have to register it first using TextArea.register_language
.
class-attribute
instance-attribute
","text":"match_cursor_bracket: Reactive[bool] = reactive(True)\n
If the cursor is at a bracket, highlight the matching bracket (if found).
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.selected_text","title":"selected_textproperty
","text":"selected_text: str\n
The text between the start and end points of the current selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.selection","title":"selectionclass-attribute
instance-attribute
","text":"selection: Reactive[Selection] = reactive(\nSelection(), always_update=True, init=False\n)\n
The selection start and end locations (zero-based line_index, offset).
This represents the cursor location and the current selection.
The Selection.end
always refers to the cursor location.
If no text is selected, then Selection.end == Selection.start
is True.
The text selected in the document is available via the TextArea.selected_text
property.
class-attribute
instance-attribute
","text":"show_line_numbers: Reactive[bool] = reactive(True)\n
True to show the line number column on the left edge, otherwise False.
Changing this value will immediately re-render the TextArea
.
property
","text":"text: str\n
The entire text content of the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.theme","title":"themeclass-attribute
instance-attribute
","text":"theme: Reactive[str | None] = theme\n
The name of the theme to use.
Themes must be registered using TextArea.register_theme
before they can be used.
Syntax highlighting is only possible when the language
attribute is set.
class
","text":" Bases: Message
Posted when the content inside the TextArea changes.
Handle this message using the on
decorator - @on(TextArea.Changed)
or a method named on_text_area_changed
.
property
","text":"control: TextArea\n
The TextArea
that sent this message.
instance-attribute
","text":"text_area: TextArea\n
The text_area
that sent this message.
class
","text":" Bases: Message
Posted when the selection changes.
This includes when the cursor moves or when text is selected.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.SelectionChanged.selection","title":"selectioninstance-attribute
","text":"selection: Selection\n
The new selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.SelectionChanged.text_area","title":"text_areainstance-attribute
","text":"text_area: TextArea\n
The text_area
that sent this message.
method
","text":"def action_cursor_down(self, select=False):\n
Move the cursor down one cell.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_left","title":"action_cursor_left method
","text":"def action_cursor_left(self, select=False):\n
Move the cursor one location to the left.
If the cursor is at the left edge of the document, try to move it to the end of the previous line.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_line_end","title":"action_cursor_line_end method
","text":"def action_cursor_line_end(self, select=False):\n
Move the cursor to the end of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_line_start","title":"action_cursor_line_startmethod
","text":"def action_cursor_line_start(self, select=False):\n
Move the cursor to the start of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_page_down","title":"action_cursor_page_downmethod
","text":"def action_cursor_page_down(self):\n
Move the cursor and scroll down one page.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_page_up","title":"action_cursor_page_upmethod
","text":"def action_cursor_page_up(self):\n
Move the cursor and scroll up one page.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_right","title":"action_cursor_rightmethod
","text":"def action_cursor_right(self, select=False):\n
Move the cursor one location to the right.
If the cursor is at the end of a line, attempt to go to the start of the next line.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_up","title":"action_cursor_up method
","text":"def action_cursor_up(self, select=False):\n
Move the cursor up one cell.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_word_left","title":"action_cursor_word_left method
","text":"def action_cursor_word_left(self, select=False):\n
Move the cursor left by a single word, skipping trailing whitespace.
Parameters Name Type Description Defaultselect
bool
Whether to select while moving the cursor.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_word_right","title":"action_cursor_word_right method
","text":"def action_cursor_word_right(self, select=False):\n
Move the cursor right by a single word, skipping leading whitespace.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_left","title":"action_delete_leftmethod
","text":"def action_delete_left(self):\n
Deletes the character to the left of the cursor and updates the cursor location.
If there's a selection, then the selected range is deleted.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_line","title":"action_delete_linemethod
","text":"def action_delete_line(self):\n
Deletes the lines which intersect with the selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_right","title":"action_delete_rightmethod
","text":"def action_delete_right(self):\n
Deletes the character to the right of the cursor and keeps the cursor at the same location.
If there's a selection, then the selected range is deleted.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_to_end_of_line","title":"action_delete_to_end_of_linemethod
","text":"def action_delete_to_end_of_line(self):\n
Deletes from the cursor location to the end of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_to_start_of_line","title":"action_delete_to_start_of_linemethod
","text":"def action_delete_to_start_of_line(self):\n
Deletes from the cursor location to the start of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_word_left","title":"action_delete_word_leftmethod
","text":"def action_delete_word_left(self):\n
Deletes the word to the left of the cursor and updates the cursor location.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_word_right","title":"action_delete_word_rightmethod
","text":"def action_delete_word_right(self):\n
Deletes the word to the right of the cursor and keeps the cursor at the same location.
Note that the location that we delete to using this action is not the same as the location we move to when we move the cursor one word to the right. This action does not skip leading whitespace, whereas cursor movement does.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_select_all","title":"action_select_allmethod
","text":"def action_select_all(self):\n
Select all the text in the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_select_line","title":"action_select_linemethod
","text":"def action_select_line(self):\n
Select all the text on the current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cell_width_to_column_index","title":"cell_width_to_column_indexmethod
","text":"def cell_width_to_column_index(self, cell_width, row_index):\n
Return the column that the cell width corresponds to on the given row.
Parameters Name Type Description Defaultcell_width
int
The cell width to convert.
requiredrow_index
int
The index of the row to examine.
required Returns Type Descriptionint
The column corresponding to the cell width on that row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.clamp_visitable","title":"clamp_visitablemethod
","text":"def clamp_visitable(self, location):\n
Clamp the given location to the nearest visitable location.
Parameters Name Type Description Defaultlocation
Location
The location to clamp.
required Returns Type DescriptionLocation
The nearest location that we could conceivably navigate to using the cursor.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.clear","title":"clearmethod
","text":"def clear(self):\n
Delete all text from the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.delete","title":"deletemethod
","text":"def delete(self, start, end, *, maintain_selection_offset=True):\n
Delete the text between two locations in the document.
Parameters Name Type Description Defaultstart
Location
The start location.
requiredend
Location
The end location.
requiredmaintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def edit(self, edit):\n
Perform an Edit.
Parameters Name Type Description Defaultedit
Edit
The Edit to perform.
required Returns Type DescriptionAny
Data relating to the edit that may be useful. The data returned
Any
may be different depending on the edit performed.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.find_matching_bracket","title":"find_matching_bracketmethod
","text":"def find_matching_bracket(self, bracket, search_from):\n
If the character is a bracket, find the matching bracket.
Parameters Name Type Description Defaultbracket
str
The character we're searching for the matching bracket of.
requiredsearch_from
Location
The location to start the search.
required Returns Type DescriptionLocation | None
The Location
of the matching bracket, or None
if it's not found.
Location | None
If the character is not available for bracket matching, None
is returned.
method
","text":"def get_column_width(self, row, column):\n
Get the cell offset of the column from the start of the row.
Parameters Name Type Description Defaultrow
int
The row index.
requiredcolumn
int
The column index (codepoint offset from start of row).
required Returns Type Descriptionint
The cell width of the column relative to the start of the row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_down_location","title":"get_cursor_down_locationmethod
","text":"def get_cursor_down_location(self):\n
Get the location the cursor will move to if it moves down.
Returns Type DescriptionLocation
The location the cursor will move to if it moves down.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_left_location","title":"get_cursor_left_locationmethod
","text":"def get_cursor_left_location(self):\n
Get the location the cursor will move to if it moves left.
Returns Type DescriptionLocation
The location of the cursor if it moves left.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_line_end_location","title":"get_cursor_line_end_locationmethod
","text":"def get_cursor_line_end_location(self):\n
Get the location of the end of the current line.
Returns Type DescriptionLocation
The (row, column) location of the end of the cursors current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_line_start_location","title":"get_cursor_line_start_locationmethod
","text":"def get_cursor_line_start_location(self):\n
Get the location of the start of the current line.
Returns Type DescriptionLocation
The (row, column) location of the start of the cursors current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_right_location","title":"get_cursor_right_locationmethod
","text":"def get_cursor_right_location(self):\n
Get the location the cursor will move to if it moves right.
Returns Type DescriptionLocation
the location the cursor will move to if it moves right.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_up_location","title":"get_cursor_up_locationmethod
","text":"def get_cursor_up_location(self):\n
Get the location the cursor will move to if it moves up.
Returns Type DescriptionLocation
The location the cursor will move to if it moves up.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_word_left_location","title":"get_cursor_word_left_locationmethod
","text":"def get_cursor_word_left_location(self):\n
Get the location the cursor will jump to if it goes 1 word left.
Returns Type DescriptionLocation
The location the cursor will jump on \"jump word left\".
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_word_right_location","title":"get_cursor_word_right_locationmethod
","text":"def get_cursor_word_right_location(self):\n
Get the location the cursor will jump to if it goes 1 word right.
Returns Type DescriptionLocation
The location the cursor will jump on \"jump word right\".
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_target_document_location","title":"get_target_document_locationmethod
","text":"def get_target_document_location(self, event):\n
Given a MouseEvent, return the row and column offset of the event in document-space.
Parameters Name Type Description Defaultevent
MouseEvent
The MouseEvent.
required Returns Type DescriptionLocation
The location of the mouse event within the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_text_range","title":"get_text_rangemethod
","text":"def get_text_range(self, start, end):\n
Get the text between a start and end location.
Parameters Name Type Description Defaultstart
Location
The start location.
requiredend
Location
The end location.
required Returns Type Descriptionstr
The text between start and end.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.insert","title":"insertmethod
","text":"def insert(\nself,\ntext,\nlocation=None,\n*,\nmaintain_selection_offset=True\n):\n
Insert text into the document.
Parameters Name Type Description Defaulttext
str
The text to insert.
requiredlocation
Location | None
The location to insert text, or None to use the cursor location.
None
maintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def load_document(self, document):\n
Load a document into the TextArea.
Parameters Name Type Description Defaultdocument
DocumentBase
The document to load into the TextArea.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.load_text","title":"load_textmethod
","text":"def load_text(self, text):\n
Load text into the TextArea.
This will replace the text currently in the TextArea.
Parameters Name Type Description Defaulttext
str
The text to load into the TextArea.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.move_cursor","title":"move_cursormethod
","text":"def move_cursor(\nself,\nlocation,\nselect=False,\ncenter=False,\nrecord_width=True,\n):\n
Move the cursor to a location.
Parameters Name Type Description Defaultlocation
Location
The location to move the cursor to.
requiredselect
bool
If True, select text between the old and new location.
False
center
bool
If True, scroll such that the cursor is centered.
False
record_width
bool
If True, record the cursor column cell width after navigating so that we jump back to the same width the next time we move to a row that is wide enough.
True
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.move_cursor_relative","title":"move_cursor_relative method
","text":"def move_cursor_relative(\nself,\nrows=0,\ncolumns=0,\nselect=False,\ncenter=False,\nrecord_width=True,\n):\n
Move the cursor relative to its current location.
Parameters Name Type Description Defaultrows
int
The number of rows to move down by (negative to move up)
0
columns
int
The number of columns to move right by (negative to move left)
0
select
bool
If True, select text between the old and new location.
False
center
bool
If True, scroll such that the cursor is centered.
False
record_width
bool
If True, record the cursor column cell width after navigating so that we jump back to the same width the next time we move to a row that is wide enough.
True
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.record_cursor_width","title":"record_cursor_width method
","text":"def record_cursor_width(self):\n
Record the current cell width of the cursor.
This is used where we navigate up and down through rows. If we're in the middle of a row, and go down to a row with no content, then we go down to another row, we want our cursor to jump back to the same offset that we were originally at.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.register_language","title":"register_languagemethod
","text":"def register_language(self, language, highlight_query):\n
Register a language and corresponding highlight query.
Calling this method does not change the language of the TextArea
. On switching to this language (via the language
reactive attribute), syntax highlighting will be performed using the given highlight query.
If a string name
is supplied for a builtin supported language, then this method will update the default highlight query for that language.
Registering a language only registers it to this instance of TextArea
.
language
str | 'Language'
A string referring to a builtin language or a tree-sitter Language
object.
highlight_query
str
The highlight query to use for syntax highlighting this language.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.register_theme","title":"register_thememethod
","text":"def register_theme(self, theme):\n
Register a theme for use by the TextArea
.
After registering a theme, you can set themes by assigning the theme name to the TextArea.theme
reactive attribute. For example text_area.theme = \"my_custom_theme\"
where \"my_custom_theme\"
is the name of the theme you registered.
If you supply a theme with a name that already exists that theme will be overwritten.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.replace","title":"replacemethod
","text":"def replace(\nself,\ninsert,\nstart,\nend,\n*,\nmaintain_selection_offset=True\n):\n
Replace text in the document with new text.
Parameters Name Type Description Defaultinsert
str
The text to insert.
requiredstart
Location
The start location
requiredend
Location
The end location.
requiredmaintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def scroll_cursor_visible(self, center=False, animate=False):\n
Scroll the TextArea
such that the cursor is visible on screen.
center
bool
True if the cursor should be scrolled to the center.
False
animate
bool
True if we should animate while scrolling.
False
Returns Type Description Offset
The offset that was scrolled to bring the cursor into view.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.select_all","title":"select_allmethod
","text":"def select_all(self):\n
Select all of the text in the TextArea
.
method
","text":"def select_line(self, index):\n
Select all the text in the specified line.
Parameters Name Type Description Defaultindex
int
The index of the line to select (starting from 0).
required"},{"location":"widgets/text_area/#textual.widgets.text_area.Highlight","title":"Highlightmodule-attribute
","text":"Highlight = Tuple[StartColumn, EndColumn, HighlightName]\n
A tuple representing a syntax highlight within one line.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Location","title":"Locationmodule-attribute
","text":"Location = Tuple[int, int]\n
A location (row, column) within the document. Indexing starts at 0.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Document","title":"Documentclass
","text":"def __init__(self, text):\n
Bases: DocumentBase
A document which can be opened in a TextArea.
"},{"location":"widgets/text_area/#textual.document._document.Document.line_count","title":"line_countproperty
","text":"line_count: int\n
Returns the number of lines in the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.lines","title":"linesproperty
","text":"lines: list[str]\n
Get the document as a list of strings, where each string represents a line.
Newline characters are not included in at the end of the strings.
The newline character used in this document can be found via the Document.newline
property.
property
","text":"newline: Newline\n
Get the Newline used in this document (e.g. ' ', ' '. etc.)
"},{"location":"widgets/text_area/#textual.document._document.Document.text","title":"textproperty
","text":"text: str\n
Get the text from the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_line","title":"get_linemethod
","text":"def get_line(self, index):\n
Returns the line with the given index from the document.
Parameters Name Type Description Defaultindex
int
The index of the line in the document.
required Returns Type Descriptionstr
The string representing the line.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_size","title":"get_sizemethod
","text":"def get_size(self, tab_width):\n
The Size of the document, taking into account the tab rendering width.
Parameters Name Type Description Defaulttab_width
int
The width to use for tab indents.
required Returns Type DescriptionSize
The size (width, height) of the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_text_range","title":"get_text_rangemethod
","text":"def get_text_range(self, start, end):\n
Get the text that falls between the start and end locations.
Returns the text between start
and end
, including the appropriate line separator character as specified by Document._newline
. Note that _newline
is set automatically to the first line separator character found in the document.
start
Location
The start location of the selection.
requiredend
Location
The end location of the selection.
required Returns Type Descriptionstr
The text between start (inclusive) and end (exclusive).
"},{"location":"widgets/text_area/#textual.document._document.Document.replace_range","title":"replace_rangemethod
","text":"def replace_range(self, start, end, text):\n
Replace text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The EditResult containing information about the completed replace operation.
"},{"location":"widgets/text_area/#textual.widgets.text_area.DocumentBase","title":"DocumentBaseclass
","text":" Bases: ABC
Describes the minimum functionality a Document implementation must provide in order to be used by the TextArea widget.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.line_count","title":"line_countproperty
abstractmethod
","text":"line_count: int\n
Returns the number of lines in the document.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.newline","title":"newlineproperty
abstractmethod
","text":"newline: Newline\n
Return the line separator used in the document.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.text","title":"textproperty
abstractmethod
","text":"text: str\n
The text from the document as a string.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_line","title":"get_lineabstractmethod
","text":"def get_line(self, index):\n
Returns the line with the given index from the document.
This is used in rendering lines, and will be called by the TextArea for each line that is rendered.
Parameters Name Type Description Defaultindex
int
The index of the line in the document.
required Returns Type Descriptionstr
The str instance representing the line.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_size","title":"get_sizeabstractmethod
","text":"def get_size(self, indent_width):\n
Get the size of the document.
The height is generally the number of lines, and the width is generally the maximum cell length of all the lines.
Parameters Name Type Description Defaultindent_width
int
The width to use for tab characters.
required Returns Type DescriptionSize
The Size of the document bounding box.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_text_range","title":"get_text_rangeabstractmethod
","text":"def get_text_range(self, start, end):\n
Get the text that falls between the start and end locations.
Parameters Name Type Description Defaultstart
Location
The start location of the selection.
requiredend
Location
The end location of the selection.
required Returns Type Descriptionstr
The text between start (inclusive) and end (exclusive).
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.query_syntax_tree","title":"query_syntax_treemethod
","text":"def query_syntax_tree(\nself, query, start_point=None, end_point=None\n):\n
Query the tree-sitter syntax tree.
The default implementation always returns an empty list.
To support querying in a subclass, this must be implemented.
Parameters Name Type Description Defaultquery
'Query'
The tree-sitter Query to perform.
requiredstart_point
tuple[int, int] | None
The (row, column byte) to start the query at.
None
end_point
tuple[int, int] | None
The (row, column byte) to end the query at.
None
Returns Type Description list[tuple['Node', str]]
A tuple containing the nodes and text captured by the query.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.replace_range","title":"replace_rangeabstractmethod
","text":"def replace_range(self, start, end, text):\n
Replace the text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The new end location after the edit is complete.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Edit","title":"Editclass
","text":"Implements the Undoable protocol to replace text at some range within a document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.from_location","title":"from_locationinstance-attribute
","text":"from_location: Location\n
The start location of the insert.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.maintain_selection_offset","title":"maintain_selection_offsetinstance-attribute
","text":"maintain_selection_offset: bool\n
If True, the selection will maintain its offset to the replacement range.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.text","title":"textinstance-attribute
","text":"text: str\n
The text to insert. An empty string is equivalent to deletion.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.to_location","title":"to_locationinstance-attribute
","text":"to_location: Location\n
The end location of the insert
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.after","title":"aftermethod
","text":"def after(self, text_area):\n
Possibly update the cursor location after the widget has been refreshed.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
this operation was performed on.
method
","text":"def do(self, text_area):\n
Perform the edit operation.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
to perform the edit on.
EditResult
An EditResult
containing information about the replace operation.
method
","text":"def undo(self, text_area):\n
Undo the edit operation.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
to undo the insert operation on.
EditResult
An EditResult
containing information about the replace operation.
class
","text":"Contains information about an edit that has occurred.
"},{"location":"widgets/text_area/#textual.document._document.EditResult.end_location","title":"end_locationinstance-attribute
","text":"end_location: Location\n
The new end Location after the edit is complete.
"},{"location":"widgets/text_area/#textual.document._document.EditResult.replaced_text","title":"replaced_textinstance-attribute
","text":"replaced_text: str\n
The text that was replaced.
"},{"location":"widgets/text_area/#textual.widgets.text_area.LanguageDoesNotExist","title":"LanguageDoesNotExistclass
","text":" Bases: Exception
Raised when the user tries to use a language which does not exist. This means a language which is not builtin, or has not been registered.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Selection","title":"Selectionclass
","text":" Bases: NamedTuple
A range of characters within a document from a start point to the end point. The location of the cursor is always considered to be the end
point of the selection. The selection is inclusive of the minimum point and exclusive of the maximum point.
class-attribute
instance-attribute
","text":"end: Location = (0, 0)\n
The end location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you finished dragging.
"},{"location":"widgets/text_area/#textual.document._document.Selection.is_empty","title":"is_emptyproperty
","text":"is_empty: bool\n
Return True if the selection has 0 width, i.e. it's just a cursor.
"},{"location":"widgets/text_area/#textual.document._document.Selection.start","title":"startclass-attribute
instance-attribute
","text":"start: Location = (0, 0)\n
The start location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you started dragging.
"},{"location":"widgets/text_area/#textual.document._document.Selection.cursor","title":"cursorclassmethod
","text":"def cursor(cls, location):\n
Create a Selection with the same start and end point - a \"cursor\".
Parameters Name Type Description Defaultlocation
Location
The location to create the zero-width Selection.
required"},{"location":"widgets/text_area/#textual.widgets.text_area.SyntaxAwareDocument","title":"SyntaxAwareDocumentclass
","text":"def __init__(self, text, language):\n
Bases: Document
A wrapper around a Document which also maintains a tree-sitter syntax tree when the document is edited.
The primary reason for this split is actually to keep tree-sitter stuff separate, since it isn't supported in Python 3.7. By having the tree-sitter code isolated in this subclass, it makes it easier to conditionally import. However, it does come with other design flaws (e.g. Document is required to have methods which only really make sense on SyntaxAwareDocument).
If you're reading this and Python 3.7 is no longer supported by Textual, consider merging this subclass into the Document
superclass.
text
str
The initial text contained in the document.
requiredlanguage
str | Language
The language to use. You can pass a string to use a supported language, or pass in your own tree-sitter Language
object.
instance-attribute
","text":"language: Language | None = None\n
The tree-sitter Language or None if tree-sitter is unavailable.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.get_line","title":"get_linemethod
","text":"def get_line(self, line_index):\n
Return the string representing the line, not including new line characters.
Parameters Name Type Description Defaultline_index
int
The index of the line.
required Returns Type Descriptionstr
The string representing the line.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.prepare_query","title":"prepare_querymethod
","text":"def prepare_query(self, query):\n
Prepare a tree-sitter tree query.
Queries should be prepared once, then reused.
To execute a query, call query_syntax_tree
.
Query | None
The prepared query.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.query_syntax_tree","title":"query_syntax_treemethod
","text":"def query_syntax_tree(\nself, query, start_point=None, end_point=None\n):\n
Query the tree-sitter syntax tree.
The default implementation always returns an empty list.
To support querying in a subclass, this must be implemented.
Parameters Name Type Description Defaultquery
Query
The tree-sitter Query to perform.
requiredstart_point
tuple[int, int] | None
The (row, column byte) to start the query at.
None
end_point
tuple[int, int] | None
The (row, column byte) to end the query at.
None
Returns Type Description list[tuple['Node', str]]
A tuple containing the nodes and text captured by the query.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.replace_range","title":"replace_rangemethod
","text":"def replace_range(self, start, end, text):\n
Replace text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The new end location after the edit is complete.
"},{"location":"widgets/text_area/#textual.widgets.text_area.TextAreaTheme","title":"TextAreaThemeclass
","text":"A theme for the TextArea
widget.
Allows theming the general widget (gutter, selections, cursor, and so on) and mapping of tree-sitter tokens to Rich styles.
For example, consider the following snippet from the markdown.scm
highlight query file. We've assigned the heading_content
token type to the name heading
.
(heading_content) @heading\n
Now, we can map this heading
name to a Rich style, and it will be styled as such in the TextArea
, assuming a parser which returns a heading_content
node is used (as will be the case when language=\"markdown\").
TextAreaTheme('my_theme', syntax_styles={'heading': Style(color='cyan', bold=True)})\n
We can register this theme with our TextArea
using the TextArea.register_theme
method, and headings in our markdown files will be styled bold cyan.
class-attribute
instance-attribute
","text":"base_style: Style | None = None\n
The background style of the text area. If None
the parent style will be used.
class-attribute
instance-attribute
","text":"bracket_matching_style: Style | None = None\n
The style to apply to matching brackets. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"cursor_line_gutter_style: Style | None = None\n
The style to apply to the gutter of the line the cursor is on. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"cursor_line_style: Style | None = None\n
The style to apply to the line the cursor is on.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.cursor_style","title":"cursor_styleclass-attribute
instance-attribute
","text":"cursor_style: Style | None = None\n
The style of the cursor. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"gutter_style: Style | None = None\n
The style of the gutter. If None
, a legible Style will be generated.
instance-attribute
","text":"name: str\n
The name of the theme.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.selection_style","title":"selection_styleclass-attribute
instance-attribute
","text":"selection_style: Style | None = None\n
The style of the selection. If None
a default selection Style will be generated.
class-attribute
instance-attribute
","text":"syntax_styles: dict[str, Style] = field(\ndefault_factory=dict\n)\n
The mapping of tree-sitter names from the highlight_query
to Rich styles.
classmethod
","text":"def builtin_themes(cls):\n
Get a list of all builtin TextAreaThemes.
Returns Type Descriptionlist[TextAreaTheme]
A list of all builtin TextAreaThemes.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.default","title":"defaultclassmethod
","text":"def default(cls):\n
Get the default syntax theme.
Returns Type DescriptionTextAreaTheme
The default TextAreaTheme (probably \"monokai\").
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.get_builtin_theme","title":"get_builtin_themeclassmethod
","text":"def get_builtin_theme(cls, theme_name):\n
Get a TextAreaTheme
by name.
Given a theme_name
, return the corresponding TextAreaTheme
object.
theme_name
str
The name of the theme.
required Returns Type Description'TextAreaTheme' | None
The TextAreaTheme
corresponding to the name or None
if the theme isn't
'TextAreaTheme' | None
found.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.get_highlight","title":"get_highlightmethod
","text":"def get_highlight(self, name):\n
Return the Rich style corresponding to the name defined in the tree-sitter highlight query for the current theme.
Parameters Name Type Description Defaultname
str
The name of the highlight.
required Returns Type DescriptionStyle | None
The Style
to use for this highlight, or None
if no style.
class
","text":" Bases: Exception
Raised when the user tries to use a theme which does not exist. This means a theme which is not builtin, or has not been registered.
"},{"location":"widgets/toast/","title":"Toast","text":"Added in version 0.30.0
A widget which displays a notification message.
Note that Toast
isn't designed to be used directly in your applications, but it is instead used by notify
to display a message when using Textual's built-in notification system.
You can customize the style of Toasts by targeting the Toast
CSS type. For example:
Toast {\npadding: 3;\n}\n
The three severity levels also have corresponding classes, allowing you to target the different styles of notification. They are:
-information
-warning
-error
If you wish to tailor the notifications for your application you can add rules to your CSS like this:
Toast.-information {\n/* Styling here. */\n}\nToast.-warning {\n/* Styling here. */\n}\nToast.-error {\n/* Styling here. */\n}\n
You can customize just the title wih the toast--title
class. The following would make the title italic for an information toast:
Toast.-information .toast--title {\ntext-style: italic;\n}\n
"},{"location":"widgets/toast/#example","title":"Example","text":"Outputtoast.py ToastApp \u258e\u258a \u258eIt's\u00a0an\u00a0older\u00a0code,\u00a0sir,\u00a0but\u00a0it\u00a0\u258a \u258echecks\u00a0out.\u258a \u258e\u258a \u258e\u258a \u258ePossible\u00a0trap\u00a0detected\u258a \u258eNow\u00a0witness\u00a0the\u00a0firepower\u00a0of\u00a0this\u258a \u258efully\u00a0ARMED\u00a0and\u00a0OPERATIONAL\u258a \u258ebattle\u00a0station!\u258a \u258e\u258a \u258e\u258a \u258eIt's\u00a0a\u00a0trap!\u258a \u258e\u258a \u258e\u258a \u258eIt's\u00a0against\u00a0my\u00a0programming\u00a0to\u00a0\u258a \u258eimpersonate\u00a0a\u00a0deity.\u258a \u258e\u258a
from textual.app import App\nclass ToastApp(App[None]):\ndef on_mount(self) -> None:\n# Show an information notification.\nself.notify(\"It's an older code, sir, but it checks out.\")\n# Show a warning. Note that Textual's notification system allows\n# for the use of Rich console markup.\nself.notify(\n\"Now witness the firepower of this fully \"\n\"[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!\",\ntitle=\"Possible trap detected\",\nseverity=\"warning\",\n)\n# Show an error. Set a longer timeout so it's noticed.\nself.notify(\"It's a trap!\", severity=\"error\", timeout=10)\n# Show an information notification, but without any sort of title.\nself.notify(\"It's against my programming to impersonate a deity.\", title=\"\")\nif __name__ == \"__main__\":\nToastApp().run()\n
"},{"location":"widgets/toast/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/toast/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/toast/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/toast/#component-classes","title":"Component Classes","text":"The toast widget provides the following component classes:
Class Descriptiontoast--title
Targets the title of the toast."},{"location":"widgets/toast/#textual.widgets._toast","title":"textual.widgets._toast","text":"Widgets for showing notification messages in toasts.
"},{"location":"widgets/toast/#textual.widgets._toast.Toast","title":"Toastclass
","text":"def __init__(self, notification):\n
Bases: Static
A widget for displaying short-lived notifications.
Parameters Name Type Description Defaultnotification
Notification
The notification to show in the toast.
required"},{"location":"widgets/toast/#textual.widgets._toast.Toast.COMPONENT_CLASSES","title":"COMPONENT_CLASSESclass-attribute
","text":"COMPONENT_CLASSES: set[str] = {'toast--title'}\n
Class Description toast--title
Targets the title of the toast."},{"location":"widgets/toast/#textual.widgets._toast.ToastHolder","title":"ToastHolder class
","text":" Bases: Container
Container that holds a single toast.
Used to control the alignment of each of the toasts in the main toast container.
"},{"location":"widgets/toast/#textual.widgets._toast.ToastRack","title":"ToastRackclass
","text":" Bases: Container
A container for holding toasts.
"},{"location":"widgets/toast/#textual.widgets._toast.ToastRack.show","title":"showmethod
","text":"def show(self, notifications):\n
Show the notifications as toasts.
Parameters Name Type Description Defaultnotifications
Notifications
The notifications to show.
required"},{"location":"widgets/tree/","title":"Tree","text":"Added in version 0.6.0
A tree control widget.
The example below creates a simple tree.
Outputtree.pyTreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
from textual.app import App, ComposeResult\nfrom textual.widgets import Tree\nclass TreeApp(App):\ndef compose(self) -> ComposeResult:\ntree: Tree[dict] = Tree(\"Dune\")\ntree.root.expand()\ncharacters = tree.root.add(\"Characters\", expand=True)\ncharacters.add_leaf(\"Paul\")\ncharacters.add_leaf(\"Jessica\")\ncharacters.add_leaf(\"Chani\")\nyield tree\nif __name__ == \"__main__\":\napp = TreeApp()\napp.run()\n
Tree widgets have a \"root\" attribute which is an instance of a TreeNode. Call add() or add_leaf() to add new nodes underneath the root. Both these methods return a TreeNode for the child which you can use to add additional levels.
"},{"location":"widgets/tree/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Descriptionshow_root
bool
True
Show the root node. show_guides
bool
True
Show guide lines between levels. guide_depth
int
4
Amount of indentation between parent and child."},{"location":"widgets/tree/#messages","title":"Messages","text":"The tree widget defines the following bindings:
Key(s) Description enter Select the current item. space Toggle the expand/collapsed space of the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/tree/#component-classes","title":"Component Classes","text":"The tree widget provides the following component classes:
Class Descriptiontree--cursor
Targets the cursor. tree--guides
Targets the indentation guides. tree--guides-hover
Targets the indentation guides under the cursor. tree--guides-selected
Targets the indentation guides that are selected. tree--highlight
Targets the highlighted items. tree--highlight-line
Targets the lines under the cursor. tree--label
Targets the (text) labels of the items."},{"location":"widgets/tree/#textual.widgets.Tree","title":"textual.widgets.Tree class
","text":"def __init__(\nself,\nlabel,\ndata=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[TreeDataType]
, ScrollView
A widget for displaying and navigating data in a tree.
Parameters Name Type Description Defaultlabel
TextType
The label of the root node of the tree.
requireddata
TreeDataType | None
The optional data to associate with the root node of the tree.
None
name
str | None
The name of the Tree.
None
id
str | None
The ID of the tree in the DOM.
None
classes
str | None
The CSS classes of the tree.
None
disabled
bool
Whether the tree is disabled or not.
False
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"space\", \"toggle_node\", \"Toggle\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\n]\n
Key(s) Description enter Select the current item. space Toggle the expand/collapsed space of the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/tree/#textual.widgets._tree.Tree.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"tree--cursor\",\n\"tree--guides\",\n\"tree--guides-hover\",\n\"tree--guides-selected\",\n\"tree--highlight\",\n\"tree--highlight-line\",\n\"tree--label\",\n}\n
Class Description tree--cursor
Targets the cursor. tree--guides
Targets the indentation guides. tree--guides-hover
Targets the indentation guides under the cursor. tree--guides-selected
Targets the indentation guides that are selected. tree--highlight
Targets the highlighted items. tree--highlight-line
Targets the lines under the cursor. tree--label
Targets the (text) labels of the items."},{"location":"widgets/tree/#textual.widgets._tree.Tree.auto_expand","title":"auto_expand class-attribute
instance-attribute
","text":"auto_expand = var(True)\n
Auto expand tree nodes when clicked.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.cursor_line","title":"cursor_lineclass-attribute
instance-attribute
","text":"cursor_line = var(-1, always_update=True)\n
The line with the cursor, or -1 if no cursor.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.cursor_node","title":"cursor_nodeproperty
","text":"cursor_node: TreeNode[TreeDataType] | None\n
The currently selected node, or None
if no selection.
class-attribute
instance-attribute
","text":"guide_depth = reactive(4, init=False)\n
The indent depth of tree nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.hover_line","title":"hover_lineclass-attribute
instance-attribute
","text":"hover_line = var(-1)\n
The line number under the mouse pointer, or -1 if not under the mouse pointer.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.last_line","title":"last_lineproperty
","text":"last_line: int\n
The index of the last line.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.root","title":"rootinstance-attribute
","text":"root = self._add_node(None, text_label, data)\n
The root node of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.show_guides","title":"show_guidesclass-attribute
instance-attribute
","text":"show_guides = reactive(True)\n
Enable display of tree guide lines.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.show_root","title":"show_rootclass-attribute
instance-attribute
","text":"show_root = reactive(True)\n
Show the root of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeCollapsed","title":"NodeCollapsedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is collapsed.
Can be handled using on_tree_node_collapsed
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeCollapsed.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was collapsed.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeExpanded","title":"NodeExpandedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is expanded.
Can be handled using on_tree_node_expanded
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeExpanded.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was expanded.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeHighlighted","title":"NodeHighlightedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is highlighted.
Can be handled using on_tree_node_highlighted
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeHighlighted.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was highlighted.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeSelected","title":"NodeSelectedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is selected.
Can be handled using on_tree_node_selected
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeSelected.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was selected.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.UnknownNodeID","title":"UnknownNodeIDclass
","text":" Bases: Exception
Exception raised when referring to an unknown TreeNode
ID.
method
","text":"def action_cursor_down(self):\n
Move the cursor down one node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Move the cursor up one node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the cursor down a page's-worth of nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the cursor up a page's-worth of nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_scroll_end","title":"action_scroll_endmethod
","text":"def action_scroll_end(self):\n
Move the cursor to the bottom of the tree.
NoteHere bottom means vertically, not branch depth.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_scroll_home","title":"action_scroll_homemethod
","text":"def action_scroll_home(self):\n
Move the cursor to the top of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_select_cursor","title":"action_select_cursormethod
","text":"def action_select_cursor(self):\n
Cause a select event for the target node.
NoteIf auto_expand
is True
use of this action on a non-leaf node will cause both an expand/collapse event to occur, as well as a selected event.
method
","text":"def action_toggle_node(self):\n
Toggle the expanded state of the target node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.clear","title":"clearmethod
","text":"def clear(self):\n
Clear all nodes under root.
Returns Type DescriptionSelf
The Tree
instance.
method
","text":"def get_label_width(self, node):\n
Get the width of the nodes label.
The default behavior is to call render_node
and return the cell length. This method may be overridden in a sub-class if it can be done more efficiently.
node
TreeNode[TreeDataType]
A node.
required Returns Type Descriptionint
Width in cells.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.get_node_at_line","title":"get_node_at_linemethod
","text":"def get_node_at_line(self, line_no):\n
Get the node for a given line.
Parameters Name Type Description Defaultline_no
int
A line number.
required Returns Type DescriptionTreeNode[TreeDataType] | None
A tree node, or None
if there is no node at that line.
method
","text":"def get_node_by_id(self, node_id):\n
Get a tree node by its ID.
Parameters Name Type Description Defaultnode_id
NodeID
The ID of the node to get.
required Returns Type DescriptionTreeNode[TreeDataType]
The node associated with that ID.
Raises Type DescriptionTree.UnknownID
Raised if the TreeNode
ID is unknown.
method
","text":"def process_label(self, label):\n
Process a str
or Text
value into a label.
Maybe overridden in a subclass to change how labels are rendered.
Parameters Name Type Description Defaultlabel
TextType
Label.
required Returns Type DescriptionText
A Rich Text object.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.refresh_line","title":"refresh_linemethod
","text":"def refresh_line(self, line):\n
Refresh (repaint) a given line in the tree.
Parameters Name Type Description Defaultline
int
Line number.
required"},{"location":"widgets/tree/#textual.widgets._tree.Tree.render_label","title":"render_labelmethod
","text":"def render_label(self, node, base_style, style):\n
Render a label for the given node. Override this to modify how labels are rendered.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType]
A tree node.
requiredbase_style
Style
The base style of the widget.
requiredstyle
Style
The additional style for the label.
required Returns Type DescriptionText
A Rich Text object containing the label.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.reset","title":"resetmethod
","text":"def reset(self, label, data=None):\n
Clear the tree and reset the root node.
Parameters Name Type Description Defaultlabel
TextType
The label for the root node.
requireddata
TreeDataType | None
Optional data for the root node.
None
Returns Type Description Self
The Tree
instance.
method
","text":"def scroll_to_line(self, line, animate=True):\n
Scroll to the given line.
Parameters Name Type Description Defaultline
int
A line number.
requiredanimate
bool
Enable animation.
True
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.scroll_to_node","title":"scroll_to_node method
","text":"def scroll_to_node(self, node, animate=True):\n
Scroll to the given node.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType]
Node to scroll in to view.
requiredanimate
bool
Animate scrolling.
True
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.select_node","title":"select_node method
","text":"def select_node(self, node):\n
Move the cursor to the given node, or reset cursor.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType] | None
A tree node, or None to reset cursor.
required"},{"location":"widgets/tree/#textual.widgets._tree.Tree.validate_cursor_line","title":"validate_cursor_linemethod
","text":"def validate_cursor_line(self, value):\n
Prevent cursor line from going outside of range.
Parameters Name Type Description Defaultvalue
int
The value to test.
required ReturnA valid version of the given value.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.validate_guide_depth","title":"validate_guide_depthmethod
","text":"def validate_guide_depth(self, value):\n
Restrict guide depth to reasonable range.
Parameters Name Type Description Defaultvalue
int
The value to test.
required ReturnA valid version of the given value.
"},{"location":"widgets/tree/#textual.widgets.tree.TreeNode","title":"textual.widgets.tree.TreeNodeclass
","text":"def __init__(\nself,\ntree,\nparent,\nid,\nlabel,\ndata=None,\n*,\nexpanded=True,\nallow_expand=True\n):\n
Bases: Generic[TreeDataType]
An object that represents a \"node\" in a tree control.
Parameters Name Type Description Defaulttree
Tree[TreeDataType]
The tree that the node is being attached to.
requiredparent
TreeNode[TreeDataType] | None
The parent node that this node is being attached to.
requiredid
NodeID
The ID of the node.
requiredlabel
Text
The label for the node.
requireddata
TreeDataType | None
Optional data to associate with the node.
None
expanded
bool
Should the node be attached in an expanded state?
True
allow_expand
bool
Should the node allow being expanded by the user?
True
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.allow_expand","title":"allow_expand writable
property
","text":"allow_expand: bool\n
Is this node allowed to expand?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.children","title":"childrenproperty
","text":"children: TreeNodes[TreeDataType]\n
The child nodes of a TreeNode.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.data","title":"datainstance-attribute
","text":"data = data\n
Optional data associated with the tree node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.id","title":"idproperty
","text":"id: NodeID\n
The ID of the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_expanded","title":"is_expandedproperty
","text":"is_expanded: bool\n
Is the node expanded?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_last","title":"is_lastproperty
","text":"is_last: bool\n
Is this the last child node of its parent?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_root","title":"is_rootproperty
","text":"is_root: bool\n
Is this node the root of the tree?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.label","title":"labelwritable
property
","text":"label: TextType\n
The label for the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.line","title":"lineproperty
","text":"line: int\n
The line number for this node, or -1 if it is not displayed.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.parent","title":"parentproperty
","text":"parent: TreeNode[TreeDataType] | None\n
The parent of the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.tree","title":"treeproperty
","text":"tree: Tree[TreeDataType]\n
The tree that this node is attached to.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.RemoveRootError","title":"RemoveRootErrorclass
","text":" Bases: Exception
Exception raised when trying to remove a tree's root node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.add","title":"addmethod
","text":"def add(\nself,\nlabel,\ndata=None,\n*,\nexpand=False,\nallow_expand=True\n):\n
Add a node to the sub-tree.
Parameters Name Type Description Defaultlabel
TextType
The new node's label.
requireddata
TreeDataType | None
Data associated with the new node.
None
expand
bool
Node should be expanded.
False
allow_expand
bool
Allow use to expand the node via keyboard or mouse.
True
Returns Type Description TreeNode[TreeDataType]
A new Tree node
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.add_leaf","title":"add_leafmethod
","text":"def add_leaf(self, label, data=None):\n
Add a 'leaf' node (a node that can not expand).
Parameters Name Type Description Defaultlabel
TextType
Label for the node.
requireddata
TreeDataType | None
Optional data.
None
Returns Type Description TreeNode[TreeDataType]
New node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.collapse","title":"collapsemethod
","text":"def collapse(self):\n
Collapse the node (hide its children).
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def collapse_all(self):\n
Collapse the node (hide its children) and all those below it.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def expand(self):\n
Expand the node (show its children).
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def expand_all(self):\n
Expand the node (show its children) and all those below it.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def remove(self):\n
Remove this node from the tree.
Raises Type DescriptionTreeNode.RemoveRootError
If there is an attempt to remove the root.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.remove_children","title":"remove_childrenmethod
","text":"def remove_children(self):\n
Remove any child nodes of this node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.set_label","title":"set_labelmethod
","text":"def set_label(self, label):\n
Set a new label for the node.
Parameters Name Type Description Defaultlabel
TextType
A str
or Text
object with the new label.
method
","text":"def toggle(self):\n
Toggle the node's expanded state.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def toggle_all(self):\n
Toggle the node's expanded state and make all those below it match.
Returns Type DescriptionSelf
The TreeNode
instance.
Tip
See the navigation links in the header or side-bar.
Click (top left) on mobile.
"},{"location":"#welcome","title":"Welcome","text":"Welcome to the Textual framework documentation.
Get started or go straight to the Tutorial
"},{"location":"#what-is-textual","title":"What is Textual?","text":"Textual is a Rapid Application Development framework for Python, built by Textualize.io.
Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal or a web browser!
Rapid development
Uses your existing Python skills to build beautiful user interfaces.
Low requirements
Run Textual on a single board computer if you want to.
Cross platform
Textual runs just about everywhere.
Remote
Textual apps can run over SSH.
CLI Integration
Textual apps can be launched and run from the command prompt.
Open Source
Textual is licensed under MIT.
CalculatorApp \u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513 \u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b \u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0C\u00a0\u00a0+/-\u00a0\u00a0%\u00a0\u00a0\u00f7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a07\u00a0\u00a08\u00a0\u00a09\u00a0\u00a0\u00d7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a04\u00a0\u00a05\u00a0\u00a06\u00a0\u00a0-\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a01\u00a0\u00a02\u00a0\u00a03\u00a0\u00a0+\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a00\u00a0\u00a0.\u00a0\u00a0=\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
PrideApp
StopwatchApp \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
CombiningLayoutsExample \u2b58CombiningLayoutsExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502HorizontallyPositionedChildrenHere\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a00\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a01\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a02\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2586\u2586\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502Vertical\u00a0layout,\u00a0child\u00a03\u2502\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502Thispanelis\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a05\u2502\u2502usinggrid\u00a0layout!\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a06\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"FAQ/","title":"FAQ","text":""},{"location":"FAQ/#frequently-asked-questions","title":"Frequently Asked Questions","text":"Welcome to the Textual FAQ. Here we try and answer any question that comes up frequently. If you can't find what you are looking for here, see our other help channels.
"},{"location":"FAQ/#does-textual-support-images","title":"Does Textual support images?","text":"Textual doesn't have built-in support for images yet, but it is on the Roadmap.
See also the rich-pixels project for a Rich renderable for images that works with Textual.
"},{"location":"FAQ/#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp","title":"How can I fix ImportError cannot import name ComposeResult from textual.app ?","text":"You likely have an older version of Textual. You can install the latest version by adding the -U
switch which will force pip to upgrade.
The following should do it:
pip install textual-dev -U\n
"},{"location":"FAQ/#how-can-i-select-and-copy-text-in-a-textual-app","title":"How can I select and copy text in a Textual app?","text":"Running a Textual app puts your terminal in to application mode which disables clicking and dragging to select text. Most terminal emulators offer a modifier key which you can hold while you click and drag to restore the behavior you may expect from the command line. The exact modifier key depends on the terminal and platform you are running on.
Refer to the documentation for your terminal emulator, if it is not listed above.
"},{"location":"FAQ/#how-can-i-set-a-translucent-app-background","title":"How can I set a translucent app background?","text":"Some terminal emulators have a translucent background feature which allows the desktop underneath to be partially visible.
This feature is unlikely to work with Textual, as the translucency effect requires the use of ANSI background colors, which Textual doesn't use. Textual uses 16.7 million colors where available which enables consistent colors across all platforms and additional effects which aren't possible with ANSI colors.
For more information on ANSI colors in Textual, see Why no Ansi Themes?.
"},{"location":"FAQ/#how-do-i-center-a-widget-in-a-screen","title":"How do I center a widget in a screen?","text":"Tip
See How To Center Things in the Textual documentation for a more comprensive answer to this question.
To center a widget within a container use align
. But remember that align
works on the children of a container, it isn't something you use on the child you want centered.
For example, here's an app that shows a Button
in the middle of a Screen
:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass ButtonApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"PUSH ME!\")\nif __name__ == \"__main__\":\nButtonApp().run()\n
If you use the above on multiple widgets, you'll find they appear to \"left-align\" in the center of the screen, like this:
+-----+\n| |\n+-----+\n\n+---------+\n| |\n+---------+\n\n+---------------+\n| |\n+---------------+\n
If you want them more like this:
+-----+\n | |\n +-----+\n\n +---------+\n | |\n +---------+\n\n+---------------+\n| |\n+---------------+\n
the best approach is to wrap each widget in a Center
container that individually centers it. For example:
from textual.app import App, ComposeResult\nfrom textual.containers import Center\nfrom textual.widgets import Button\nclass ButtonApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Center(Button(\"PUSH ME!\"))\nyield Center(Button(\"AND ME!\"))\nyield Center(Button(\"ALSO PLEASE PUSH ME!\"))\nyield Center(Button(\"HEY ME ALSO!!\"))\nif __name__ == \"__main__\":\nButtonApp().run()\n
"},{"location":"FAQ/#how-do-i-fix-workerdeclarationerror","title":"How do I fix WorkerDeclarationError?","text":"Textual version 0.31.0 requires that you set thread=True
on the @work
decorator if you want to run a threaded worker.
If you want a threaded worker, you would declare it in the following way:
@work(thread=True)\ndef run_in_background():\n...\n
If you don't want a threaded worker, you should make your work function async
:
@work()\nasync def run_in_background():\n...\n
This change was made because it was too easy to accidentally create a threaded worker, which may produce unexpected results.
"},{"location":"FAQ/#how-do-i-pass-arguments-to-an-app","title":"How do I pass arguments to an app?","text":"When creating your App
class, override __init__
as you would when inheriting normally. For example:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass Greetings(App[None]):\ndef __init__(self, greeting: str=\"Hello\", to_greet: str=\"World\") -> None:\nself.greeting = greeting\nself.to_greet = to_greet\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Static(f\"{self.greeting}, {self.to_greet}\")\n
Then the app can be run, passing in various arguments; for example:
# Running with default arguments.\nGreetings().run()\n# Running with a keyword argument.\nGreetings(to_greet=\"davep\").run()\n# Running with both positional arguments.\nGreetings(\"Well hello\", \"there\").run()\n
"},{"location":"FAQ/#no-widget-called-textlog","title":"No widget called TextLog","text":"The TextLog
widget was renamed to RichLog
in Textual 0.32.0. You will need to replace all references to TextLog
in your code, with RichLog
. Most IDEs will have a search and replace function which will help you do this.
Here's how you should import RichLog:
from textual.widgets import RichLog\n
"},{"location":"FAQ/#why-do-some-key-combinations-never-make-it-to-my-app","title":"Why do some key combinations never make it to my app?","text":"Textual can only ever support key combinations that are passed on by your terminal application. Which keys get passed on can differ from terminal to terminal, and from operating system to operating system.
Because of this it's best to stick to key combinations that are known to be universally-supported; these include the likes of:
When creating bindings for your application we recommend picking keys and key combinations from the above.
Keys that aren't normally passed through by terminals include Cmd and Option on macOS, and the Windows key on Windows.
If you need to test what key combinations work in different environments you can try them out with textual keys
.
You may find that the default macOS Terminal.app doesn't render Textual apps (and likely other TUIs) very well, particuarily when it comes to box characters. For instance, you may find it displays misaligned blocks and lines like this:
You can (mostly) fix this by opening settings -> profiles > Text tab, and changing the font settings. We have found that Menlo Regular font, with a character spacing of 1 and line spacing of 0.805 produces reasonable results. If you want to use another font, you may have to tweak the line spacing until you get good results.
With these changes, Textual apps render more as intended:
Even with this fix, Terminal.app has a few limitations. It is limited to 256 colors, and can be a little slow compared to more modern alternatives. Fortunately there are a number of free terminal emulators for macOS which produces high quality results.
We recommend any of the following terminals:
Textual will not generate escape sequences for the 16 themeable ANSI colors.
This is an intentional design decision we took for for the following reasons:
Textual has a design system which guarantees apps will be readable on all platforms and terminals, and produces better results than ANSI colors.
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
"},{"location":"FAQ/#why-doesnt-the-datatable-scroll-programmatically","title":"Why doesn't theDataTable
scroll programmatically?","text":"If scrolling in your DataTable
is apparently broken, it may be because your DataTable
is using the default value of height: auto
. This means that the table will be sized to fit its rows without scrolling, which may cause the container (typically the screen) to scroll. If you would like the table itself to scroll, set the height to something other than auto
, like 100%
.
NOTE: As of Textual v0.31.0 the max-height
of a DataTable
is set to 100%
, this will mean that the above is no longer the default experience.
Generated by FAQtory
"},{"location":"getting_started/","title":"Getting started","text":"All you need to get started building Textual apps.
"},{"location":"getting_started/#requirements","title":"Requirements","text":"Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs.
Your platform
"},{"location":"getting_started/#linux-all-distros","title":"Linux (all distros)","text":"All Linux distros come with a terminal emulator that can run Textual apps.
"},{"location":"getting_started/#macos","title":"macOS","text":"The default terminal app is limited to 256 colors. We recommend installing a newer terminal such as iterm2, Kitty, or WezTerm.
"},{"location":"getting_started/#windows","title":"Windows","text":"The new Windows Terminal runs Textual apps beautifully.
"},{"location":"getting_started/#installation","title":"Installation","text":"Here's how to install Textual.
"},{"location":"getting_started/#from-pypi","title":"From PyPI","text":"You can install Textual via PyPI, with the following command:
pip install textual\n
If you plan on developing Textual apps, you should also install textual developer tools:
pip install textual-dev\n
"},{"location":"getting_started/#from-conda-forge","title":"From conda-forge","text":"Textual is also available on conda-forge. The preferred package manager for conda-forge is currently micromamba:
micromamba install -c conda-forge textual\n
And for the textual developer tools:
micromamba install -c conda-forge textual-dev\n
"},{"location":"getting_started/#textual-cli","title":"Textual CLI","text":"If you installed the developer tools you should have access to the textual
command. There are a number of sub-commands available which will aid you in building Textual apps. Run the following for a list of the available commands:
textual --help\n
See devtools for more about the textual
command.
Once you have Textual installed, run the following to get an impression of what it can do:
python -m textual\n
If Textual is installed you should see the following:
Textual\u00a0Demo \u2b58Textual\u00a0Demo TOP\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Widgets\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Textual\u00a0widgets\u00a0are\u00a0powerful\u00a0interactive\u00a0components.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Widgets Build\u00a0your\u00a0own\u00a0or\u00a0use\u00a0the\u00a0builtin\u00a0widgets.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0Input\u00a0Text\u00a0/\u00a0Password\u00a0input.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Rich\u00a0content\u00a0\u2022\u00a0Button\u00a0Clickable\u00a0button\u00a0with\u00a0a\u00a0number\u00a0of\u00a0styles.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0Switch\u00a0A\u00a0switch\u00a0to\u00a0toggle\u00a0between\u00a0states.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2583\u2583 \u00a0\u2022\u00a0DataTable\u00a0A\u00a0spreadsheet-like\u00a0widget\u00a0for\u00a0navigating\u00a0data.\u00a0Cells\u00a0may\u00a0contain\u00a0text\u00a0or\u00a0Rich\u00a0 renderables.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 CSS\u00a0\u2022\u00a0Tree\u00a0An\u00a0generic\u00a0tree\u00a0with\u00a0expandable\u00a0nodes.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0DirectoryTree\u00a0A\u00a0tree\u00a0of\u00a0file\u00a0and\u00a0folders.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u2022\u00a0...\u00a0many\u00a0more\u00a0planned\u00a0... \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a \u258eUsername\u258awill\u258e\u258a \u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a \u258e\u258a\u2585\u2585 \u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a \u258ePassword\u258aPassword\u258e\u258a \u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a \u258e\u258a \u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258a \u258e\u00a0Login\u00a0\u258a \u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Foo\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Bar\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Baz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Foo\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(0,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(0,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(1,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(1,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(2,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(2,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(3,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(3,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(4,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(4,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(5,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(5,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(6,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(6,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(7,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(7,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(8,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(8,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(9,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(9,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2582\u2582 \u00a0Cell\u00a0(10,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(10,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(11,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(11,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(12,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(12,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0Cell\u00a0(13,\u00a00)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a01)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a02)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Cell\u00a0(13,\u00a03)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u258f \u00a0CTRL+B\u00a0\u00a0Sidebar\u00a0\u00a0CTRL+T\u00a0\u00a0Toggle\u00a0Dark\u00a0mode\u00a0\u00a0CTRL+S\u00a0\u00a0Screenshot\u00a0\u00a0F1\u00a0\u00a0Notes\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0
"},{"location":"getting_started/#examples","title":"Examples","text":"The Textual repository comes with a number of example apps. To try out the examples, first clone the Textual repository:
HTTPSSSHGitHub CLIgit clone https://github.com/Textualize/textual.git\n
git clone git@github.com:Textualize/textual.git\n
gh repo clone Textualize/textual\n
With the repository cloned, navigate to the /examples/
directory where you will find a number of Python files you can run from the command line:
cd textual/examples/\npython code_browser.py ../\n
"},{"location":"getting_started/#need-help","title":"Need help?","text":"See the help page for how to get help with Textual, or to report bugs.
"},{"location":"help/","title":"Help","text":"If you need help with any aspect of Textual, let us know! We would be happy to hear from you.
"},{"location":"help/#bugs-and-feature-requests","title":"Bugs and feature requests","text":"Report bugs via GitHub on the Textual issues page. You can also post feature requests via GitHub issues, but see the roadmap first.
"},{"location":"help/#help-with-using-textual","title":"Help with using Textual","text":"You can seek help with using Textual in the discussion area on GitHub.
"},{"location":"help/#discord-server","title":"Discord Server","text":"For more realtime feedback or chat, join our discord server to connect with the Textual community.
"},{"location":"roadmap/","title":"Roadmap","text":"We (textualize.io) are actively building and maintaining Textual.
We have many new features in the pipeline. This page will keep track of that work.
"},{"location":"roadmap/#features","title":"Features","text":"High-level features we plan on implementing.
Widgets are key to making user-friendly interfaces. The builtin widgets should cover many common (and some uncommon) use-cases. The following is a list of the widgets we have built or are planning to build.
attrs
objectsPyDantic
objectsWelcome to the Textual Tutorial!
By the end of this page you should have a solid understanding of app development with Textual.
Quote
If you want people to build things, make it fun.
\u2014 Will McGugan (creator of Rich and Textual)
"},{"location":"tutorial/#video-series","title":"Video series","text":"This tutorial has an accompanying video series which covers the same content.
"},{"location":"tutorial/#stopwatch-application","title":"Stopwatch Application","text":"We're going to build a stopwatch application. This application should show a list of stopwatches with buttons to start, stop, and reset the stopwatches. We also want the user to be able to add and remove stopwatches as required.
This will be a simple yet fully featured app \u2014 you could distribute this app if you wanted to!
Here's what the finished app will look like:
stopwatch.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:02.01 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:10.11 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:06.05 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
"},{"location":"tutorial/#try-it-out","title":"Try it out!","text":"The following is not a screenshot, but a fully interactive Textual app running in your browser.
Try in Textual-web
See textual-web if you are interested in publishing your apps on the web.
"},{"location":"tutorial/#get-the-code","title":"Get the code","text":"If you want to try the finished Stopwatch app and follow along with the code, first make sure you have Textual installed then check out the Textual repository:
HTTPSSSHGitHub CLIgit clone https://github.com/Textualize/textual.git\n
git clone git@github.com:Textualize/textual.git\n
gh repo clone Textualize/textual\n
With the repository cloned, navigate to docs/examples/tutorial
and run stopwatch.py
.
cd textual/docs/examples/tutorial\npython stopwatch.py\n
"},{"location":"tutorial/#type-hints-in-brief","title":"Type hints (in brief)","text":"Tip
Type hints are entirely optional in Textual. We've included them in the example code but it's up to you whether you add them to your own projects.
We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, it's a way to express the types of your data, parameters, and return values. Type hinting allows tools like mypy to catch bugs before your code runs.
The following function contains type hints:
def repeat(text: str, count: int) -> str:\n\"\"\"Repeat a string a given number of times.\"\"\"\nreturn text * count\n
Parameter types follow a colon. So text: str
indicates that text
requires a string and count: int
means that count
requires an integer.
Return types follow ->
. So -> str:
indicates this method returns a string.
The first step in building a Textual app is to import and extend the App
class. Here's a basic app class we will use as a starting point for the stopwatch app.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
If you run this code, you should see something like the following:
stopwatch01.py \u2b58StopwatchApp \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
Hit the D key to toggle between light and dark mode.
stopwatch01.py \u2b58StopwatchApp \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
Hit Ctrl+C to exit the app and return to the command prompt.
"},{"location":"tutorial/#a-closer-look-at-the-app-class","title":"A closer look at the App class","text":"Let's examine stopwatch01.py
in more detail.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The first line imports the Textual App
class, which we will use as the base class for our App. The second line imports two builtin widgets: Footer
which shows a bar at the bottom of the screen with bound keys, and Header
which shows a title at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial.
The following lines define the app itself:
stopwatch01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The App class is where most of the logic of Textual apps is written. It is responsible for loading configuration, setting up widgets, handling keys, and more.
Here's what the above app defines:
BINDINGS
is a list of tuples that maps (or binds) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the D key on to the \"toggle_dark\" action. See key bindings in the guide for details.
compose()
is where we construct a user interface with widgets. The compose()
method may return a list of widgets, but it is generally easier to yield them (making this method a generator). In the example code we yield an instance of each of the widget classes we imported, i.e. Header()
and Footer()
.
action_toggle_dark()
defines an action method. Actions are methods beginning with action_
followed by the name of the action. The BINDINGS
list above tells Textual to run this action when the user hits the D key. See actions in the guide for details.
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The final three lines create an instance of the app and calls the run() method which puts your terminal in to application mode and runs the app until you exit with Ctrl+C. This happens within a __name__ == \"__main__\"
block so we could run the app with python stopwatch01.py
or import it as part of a larger project.
Textual comes with a number of builtin widgets, like Header and Footer, which are versatile and re-usable. We will need to build some custom widgets for the stopwatch. Before we dive in to that, let's first sketch a design for the app \u2014 so we know what we're aiming for.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVcXGlP40pcdTAwMTb93r9cdTAwMDIxX2akxq/2paXRqFx1MDAwM4R9XHUwMDBiW8PMXHUwMDEzMrFDXGbxguNA4Kn/+5SddOwktuM4S/tFLVx1MDAxYVxcjuu66tx77lJVf33Z2NhcZj48c/PbxqbZb+pcdTAwMWTL8PX3za/h9TfT71quo5pQ9HfX7fnN6M52XHUwMDEweN1vf/xh6/6LXHUwMDE5eFx1MDAxZL1pam9Wt6d3ukHPsFxcrenaf1iBaXf/XHUwMDEz/jzVbfPfnmtcdTAwMWKBr8WdbJmGXHUwMDE1uP6gL7Nj2qZcdTAwMTN01dP/q/7e2Pgr+pmQzjebge48dczoXHUwMDBiUVMsIFx1MDAwNmzy6qnrRMJcbi5cdTAwMDWFXGaR0VxyVndHdVx1MDAxN5iGam0pkc24Jby0+aizxo/G/VPr6uX83Hu/ueqSPo97bVmdzmXw0Ymk6rrqZeK2buC7L+atZVx1MDAwNG3VXG4nrmd9y3d7T23H7HbHvuN6etNcbj7Ca1x1MDAwMIyuXHUwMDBlhuDbRnylXHUwMDFmTlx1MDAxMCdcdTAwMWFkXHUwMDAwUoboqCH8KpJcXCOEYpi4Plx1MDAxMGbb7ajBV8L8XHUwMDAzRJ9YnEe9+fKkZHKM+Fx1MDAxZVx1MDAwMpqAJlx1MDAwNuF9+IqqQ1xyy4lcdTAwMGXapvXUXHUwMDBlXHUwMDA2gmtcdTAwMWNcbp7o24yGXHUwMDFkXCLEOUFcYqNRS9ijd2BEXGL4c3Lg2rrvXHJcdTAwMDdos1x1MDAxYv6RkDZcdTAwMTR0d1x1MDAxMj5JXGIlpvag2/C2m5+HRzdcdTAwMTf1t7uL8114vnc4etZcdTAwMTjedN933zdHLT+Hv8Wi9TxDXHUwMDFmgFxiMlx1MDAwNiRcdTAwMTaSXHUwMDEwgmNcdTAwMWN2LOdFNTq9Tie+5jZfYtxFV39+LYF3gmBcdTAwMTbeJVx1MDAxNYJQSorj3XrEnty37P7JsSlcdTAwMWT97NV7/VGvON5cdTAwMDXRXHUwMDA0wXxcdTAwMWPsXHUwMDE4YlxyYyqT18uAvaVTRNE02JWGTWOcsSlwc8FcdTAwMDRUUCDrXHUwMDAy9y/MXHUwMDA0Zj9cdTAwMThH82CG67KPb+3+/bFcclx1MDAwZnZunFx1MDAwN3v/sL01XHUwMDE3tlx1MDAxOVx1MDAwNVxigWVhe0zOYmZcdTAwMWMqXHUwMDExXHUwMDE4R1x1MDAxY4nCuE5/63RcXLf1Zrvnm1VAtkxDNlZ4X1x1MDAxY9mBrztdT/dcdTAwMTWaUtBNU9CN8LTpJlAqky9WgO5lXHUwMDAyMJ5n11x0Lq3PcKhcdTAwMTNcdTAwMGZcYq/WddvqfIxNVYRMJell4CZcdTAwMDXVu6bqMcIhXHUwMDFmu/d7x3pcbpG72VTvYPpjoFx1MDAwZSzl6oxusC3DSFx1MDAxYfOmXHUwMDEyQFfP9Fx1MDAwZopcdTAwMThh17eeLEfvXFwl5SvPXHUwMDFmjMlM/lAug0SCo8J65lx1MDAxY2/3t06R6Fx1MDAxY8BG42brXHUwMDAzXFw1/I9q81x1MDAwN2NEXHUwMDAzWIJJd4lgpPRcZoGF/aWmaVx1MDAxMEMvSiFiSskgR8q1XHUwMDEyZG1cdTAwMTQymMpeXHUwMDFmn+zsideL9zuj9tK6sE9/bDfS/aNIU2Jcbvma/thZzJTeYXFmXHUwMDEyWPmXWMaTtVwiZqJcdOM4yUycXHUwMDAzyVxiT8zsLI3JXHUwMDFm5ooyk7JcdTAwMTnpOkOp8rpcdTAwMTbXmaWQXHUwMDEzXHUwMDA3XGIoXHUwMDFkS7jhqyenXHUwMDEyXHUwMDE4XFyMnFx1MDAxYWbXXGbWyk4zTPwkO1xyXHUwMDA0LE9PMGFcdTAwMTEntI1wXGKQXHUwMDE0rHh4c/zs9smja183ru5Ojp73jWtcdTAwMTI0q01PhCtVo2Lc34v8QMQ1oMhhPKYuXHUwMDEz4kSfND3DXHUwMDFhnExcdTAwMTiMXHUwMDE0XHUwMDBlU02OO6dDveMq5FRcdTAwMTaArkDtyvHKx0n/ljx8Xlx1MDAwN96xW/NZv+18st1cbkY8kmRcdTAwMDFcdTAwMWQyjpVZ4ax4wJP+0lx1MDAxNadcdTAwMTVcdTAwMTVIZGBd+WJ0knCWzitcdTAwMDRPwzyFV6hcdTAwMDQq5GGr8MeqXHUwMDEz9Fx1MDAwMPAt/Mc1XHUwMDA018ouMyz0JLskxSzPMVhmptCgQFxicVxuOSusende//LkZGf39uiqVoeg3Ttv+8e/k2RwkZQxXHUwMDA1krOpnDGBQmOccrGqIIgypnHO8Vx1MDAxOJOMJY1cdTAwMDVcdTAwMWRPZY/CXCJcdTAwMDRcdTAwMTX/XHUwMDEwsVYtXHUwMDE0XHUwMDEwYonm0MLyoKRcdTAwMTRngpJcdTAwMTPJkVx1MDAwNKQ4XHUwMDFmyGOhO/unXHKf1M97bbttXl1cdTAwMWZUvZAhqFx1MDAwNlx1MDAwNaWEXHQ+XHUwMDE5mlx1MDAxM00uI7ubVcoomt1ccrP8gvLEq6wlNP++3Xpr2O32XHUwMDE2Mz6fejY4erg74uOuz3JD8/RcdTAwMGWr50IpK5KlM0x5XHUwMDBmXHUwMDAwwDlcXKj8Ua6oXHUwMDBipaxCltJQppElKM1yYnMsXHUwMDAwXHUwMDEwfFx1MDAxNTW/6vhQl4Hurzc2n2HlpzPHoYBcdTAwMGL4TSiborCQiOE5XHUwMDEyYW7t5NW8QPzItnuNeqv/dnyB33+vuvFcIsE5XHUwMDAxqZkwXCKVT1xuVqxspVx1MDAwMnRKwuhFrrve/iDfL8+O/Ju9k7bVMPZcdTAwMGZcdTAwMDS+P2uvkrTSO6xcdTAwMWVpIZpZgFGBP8CcUFA8xZU/zFx1MDAxNVUjXHUwMDE192eoXHUwMDExpVx1MDAxYV9xOrlY2C9cdFx1MDAxMpJSuM5s8lx1MDAxMIFwXHUwMDBlXHUwMDA0LsZYw3BcdTAwMWFo41x1MDAwM7py3pph+zOi/kjM8uyFWKazKFx1MDAxOEZcdTAwMDBcdTAwMTFQ3Fl8RkivfaKzXHUwMDFh3vGI34JXd82b/d9cdTAwMTlfkdlBP9Mog1x1MDAxNOGpJVx1MDAwNsroaVx1MDAwYkf8Lf1cdTAwMTFcdTAwMDCaXHUwMDE28XNccipdXHUwMDFh0/WR4il9R1igsPeoXHUwMDBmOKmHXGJcdTAwMTBlXHUwMDEwMV1fOXRcdTAwMTbP6PpcdTAwMTXbu7o/OFx1MDAwMSe39nZw+eRcdTAwMWScXs63WkxIXHRji7NcIp6BIDvBLJByJOZYXHUwMDE3mf7O6XBv+m63u9XWg2b794NewEzQh1x1MDAwZVx1MDAxM4aMrDTJTCmfXHUwMDA2fUqAxCRcdTAwMDJUKcl6k8xzXHUwMDAzcTG62Td1I0lcdTAwMWJrYJpcdTAwMTl2epJphlx1MDAxMpYnXHUwMDE5XG4zSVx1MDAwNlwiyKOca2Gtc+y3k8tcdTAwMTf48Irk+71OwFx1MDAxYjx9LpVZRkvSNzpb34CGXGKcjIRcIq9cdTAwMTAqfWNkXCJEKUE0XGKJR5OlXHUwMDExXHKZVjWcsohcciNcdTAwMTLSUWVcYuU2QPrdzsOWj/pta5/dP+3hXHUwMDA3ez5CkYrd4/dZVeDCM0vzkGEogUCgePyf/ta/nVJcbkCcZkJcXPlRy4D4XGZGSYF5SvxcdTAwMTKuhyFcdTAwMDCtmVDmXHUwMDA14mKEUnfdYM2EMsMmT1x1MDAxMspQwkVcYiUn56ZAiOZSujP60NxvdY1cdTAwMWYt1Kyf39qn/aB5U/FaJdOYXHUwMDEwJKVYSTHXplLf1ShWXCLMJcOYraBamc4xw1x1MDAxMsZcdTAwMWU7O3uxrf7lNjk6291BXHUwMDFl+Vx1MDAxMOk5t1x1MDAxMntcXFx1MDAxMGF8PbVQxrM3XHUwMDAzIE64ilx1MDAwN+fINNvPp/a2d3x0dF4zOvt35lPt8sWpei2Ua5hcdTAwMTE0UYn/XHUwMDE42H9ccirVXzhgX7RcdTAwMTgqiFRTQcSai6E7XHI9eDyy2s9cdTAwMTdcdTAwMTf711x1MDAwZl3y2q5jP1x1MDAxZOPF8soreuwsry+9w3lcdTAwMTRS4DCHumqvj+akq7lcdTAwMDBcdTAwMTBcdFx1MDAwNouHNPnDXFzZXCIrytRGTjW2XHUwMDA0bVxcSpVcdTAwMTVcYkokoGtdXHUwMDAxXVx1MDAwMoaL+Xzrr7LO4I9lV1lcdMkmP4wogpTC4jt0asfXzkO7Z+yC163vV/XWNdjln5WvXHUwMDBmQY1yXHRTNjVTJrRVr1x1MDAwYi1XZoWIXHUwMDAxiClfRVxuL4+4LpqH6PFwz3Vfnlxm9/Ni79Opi93F+XDpj53Fh+lcdTAwMWRWj1x1MDAwZrHIXFw8ijBcdTAwMDVcXMGmeFx1MDAxOSl/lKuqnSxTOznRhFgxXHUwMDE5XHUwMDE2q98qXHUwMDE2RGqq1rxVdc1cXPi76rczSKV0/TYz84hpls5cdTAwMTGBJZtrb3jt7HnLe39/dPmLrd95dYs9YiND59aSVZ/tgEpGNSogSFnmR2G4LUjmXHUwMDFm84FcdNZJ2W2rXHUwMDAwpejbXHUwMDE0/SGiXGaCSM5D1TNcdTAwMWW59Cdr37dcdTAwMGbPXHUwMDFmxPfDmjh8faa017r/sbRMXG6lXHUwMDAwr6/sXHUwMDE2blx1MDAxOH9cdTAwMWbPnSdthFx1MDAxOPvCyFx1MDAwNHTMVpBjIcY2yY+bh7FcdTAwMTdJ270+XHUwMDEwJtdcdTAwMTJcZlx1MDAwNjWNfrOPP1FcdTAwMDAkmPB5zvvJn+Zq2lx1MDAwMlxuNFxmXHUwMDA1Y1x1MDAwMmMsMJw0XHUwMDA3QoOYhVx1MDAwYiZouFtyNTaBizBcdTAwMDWFZbhcdTAwMWSR8OSBXHUwMDAx8cJcdTAwMGWoQYSEomGGpVx1MDAwMvykvVAuNCfhSq/57UUkZNn6hGCiVH2iXHUwMDFiRnU1yzEs50k1xibj1/lVXHUwMDA3XHUwMDA1yCXS1WYvlHJcdTAwMGJoTKrRXHUwMDEzMFxc8IeT5yGFY6F7USSiodCLYThcdTAwMWNGQeTwhpHp2jRcdTAwMWQjlmn8NfRusO3atlx1MDAxNahcdTAwMDE4dy0nmLwjeqPvoZ61TX1Kb9WTk22TXG7phU9cdTAwMWO3zvFvXHUwMDFiMWSjP0a///k19e6tTDxFrZNQilx1MDAxZvcl+f/cplx1MDAwNKHszJZEUir9wsUzW/lMVFFTwjV1V9pcdTAwMGVMisP1zFxmQ45BaFNXYkaksmScyF9cdTAwMWaeslSGKFx0XHUwMDE5XHUwMDAyXHUwMDEw8TCyXHUwMDAyaOq4XGbVICVgskTOa1x1MDAxMTtCldqW8vOXbUeApiw9UapcdTAwMDEg41xckZ9I3DQwI0IjXHUwMDAyRJ5jvv3IkiW/aDgmXHUwMDBilzzccqIgxaREdFpcdTAwMTaENUSHO1x1MDAwZqek+VvZrEzwhp8p2M5pszKTXHUwMDBmXHS+nNzwhChnhOHiqcGjz7u911Zw8456jkktv9WA9duKWywsNJZusZQnpFx0wfLPR1gsXHUwMDEwimWO3ZxYvtFSPlx1MDAwZVx1MDAxMFwiZM1lsVx1MDAwNVx1MDAwMpbcQGhVXHUwMDAx1mqPY1x1MDAxNJSBeUrVS1xusP7n/DOyUKbxr9RYK5G6mi9cdTAwMWazcLSVlKyct4SzXHUwMDE3okjCwpNw5ti1kj/7XHUwMDE1NT1UxV2/llx1MDAxMidXqlx1MDAwZk6skJpiWIakcth5cqf2Mk1cdTAwMTBcdTAwMTOaXHUwMDE4nLhcdTAwMWFcdTAwMTGPiD2QODOjUVxuOFx1MDAwZuXkTFwiknagXHUwMDA1XHUwMDA2UGBaJlVT5dArn87G3Fx1MDAxNFxiiVQ2XHUwMDFhcCYoJ3FcYj1yU5SXMtixkO8x/X0jrkwkhZ9pXGbN6b7k1jd5zuJcdTAwMWWoaF1IJIpnc9+E3fQ+3G6999yRXHUwMDA3dv2cg+1SlmSdp6lRXHJcdTAwMDCkQszpbC5ccjclrGg5W9FcdTAwMDNcYjFV1mE1S0fzPILT21OvfaVcdTAwMDb70rm+wVcnwdbxoZ3uXHUwMDExzFPIXFz6Y2dcdTAwMTUy0zss7r4oJlxy/cd5dlx1MDAwMeYqY1YskdyJMJX+4FhKguY4ai1/mCu6sEe56pmayFXYXFyBo1x1MDAxM1x1MDAxMMRcZofnXHUwMDExrfPgm1x1MDAxMlx1MDAxMFxczINe/7GGM3gj71jDL0Pl3dQ97zJQ4zZyStTUWMbw5eOx2nyzzPda9kl8X4a6XHUwMDFiKolcdTAwMTlOzF8/v/z8P1xiXCJcdTAwMWT6In0= StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started)Reset"},{"location":"tutorial/#custom-widgets","title":"Custom widgets","text":"We need a Stopwatch
widget composed of the following child widgets:
Textual has a builtin Button
widget which takes care of the first three components. All we need to build is the time display widget which will show the elapsed time and the stopwatch widget itself.
Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.
stopwatch02.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
We've imported two new widgets in this code: Button
, which creates a clickable button, and Static
which is a base class for a simple control. We've also imported ScrollableContainer
from textual.containers
which (as the name suggests) is a Widget
which contains other widgets.
We've defined an empty TimeDisplay
widget by extending Static
. We will flesh this out later.
The Stopwatch widget class also extends Static
. This class has a compose()
method which yields child widgets, consisting of three Button
objects and a single TimeDisplay
object. These widgets will form the stopwatch in our sketch.
The Button constructor takes a label to be displayed in the button (\"Start\"
, \"Stop\"
, or \"Reset\"
). Additionally, some of the buttons set the following parameters:
id
is an identifier we can use to tell the buttons apart in code and apply styles. More on that later.variant
is a string which selects a default style. The \"success\" variant makes the button green, and the \"error\" variant makes it red.To add widgets to our application we first need to yield them from the app's compose()
method:
The new line in StopwatchApp.compose()
yields a single ScrollableContainer
object which will create a scrolling list of stopwatches. When classes contain other widgets (like ScrollableContainer
) they will typically accept their child widgets as positional arguments. We want to start the app with three stopwatches, so we construct three Stopwatch
instances and pass them to the container's constructor.
Let's see what happens when we run stopwatch02.py
.
stopwatch02.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 00:00:00.00 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2586\u2586 \u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 00:00:00.00 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a0 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container but it doesn't look like the sketch. This is because we have yet to apply any styles to our new widgets.
"},{"location":"tutorial/#writing-textual-css","title":"Writing Textual CSS","text":"Every widget has a styles
object with a number of attributes that impact how the widget will appear. Here's how you might set white text and a blue background for a widget:
self.styles.background = \"blue\"\nself.styles.color = \"white\"\n
While it's possible to set all styles for an app this way, it is rarely necessary. Textual has support for CSS (Cascading Style Sheets), a technology used by web browsers. CSS files are data files loaded by your app which contain information about styles to apply to your widgets.
Info
The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn.
CSS makes it easy to iterate on the design of your app and enables live-editing \u2014 you can edit CSS and see the changes without restarting the app!
Let's add a CSS file to our application.
stopwatch03.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch03.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Adding the CSS_PATH
class variable tells Textual to load the following file when the app starts:
Stopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\nTimeDisplay {\ncontent-align: center middle;\ntext-opacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n
If we run the app now, it will look very different.
stopwatch03.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
This app looks much more like our sketch. Let's look at how Textual uses stopwatch03.tcss
to apply styles.
CSS files contain a number of declaration blocks. Here's the first such block from stopwatch03.tcss
again:
Stopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\n
The first line tells Textual that the styles should apply to the Stopwatch
widget. The lines between the curly brackets contain the styles themselves.
Here's how this CSS code changes how the Stopwatch
widget is displayed.
layout: horizontal
aligns child widgets horizontally from left to right.background: $boost
sets the background color to $boost
. The $
prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as \"blue\"
or rgb(20,46,210)
.height: 5
sets the height of our widget to 5 lines of text.margin: 1
sets a margin of 1 cell around the Stopwatch
widget to create a little space between widgets in the list.min-width: 50
sets the minimum width of our widget to 50 cells.padding: 1
sets a padding of 1 cell around the child widgets.Here's the rest of stopwatch03.tcss
which contains further declaration blocks:
TimeDisplay {\ncontent-align: center middle;\nopacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n
The TimeDisplay
block aligns text to the center (content-align
), fades it slightly (opacity
), and sets its height (height
) to 3 lines.
The Button
block sets the width (width
) of buttons to 16 cells (character widths).
The last 3 blocks have a slightly different format. When the declaration begins with a #
then the styles will be applied to widgets with a matching \"id\" attribute. We've set an ID on the Button
widgets we yielded in compose
. For instance the first button has id=\"start\"
which matches #start
in the CSS.
The buttons have a dock
style which aligns the widget to a given edge. The start and stop buttons are docked to the left edge, while the reset button is docked to the right edge.
You may have noticed that the stop button (#stop
in the CSS) has display: none;
. This tells Textual to not show the button. We do this because we don't want to display the stop button when the timer is not running. Similarly, we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section.
We want our Stopwatch
widget to have two states: a default state with a Start and Reset button; and a started state with a Stop button. When a stopwatch is started it should also have a green background and bold text.
We can accomplish this with a CSS class. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles.
Here's the new CSS:
stopwatch04.tcssStopwatch {\nlayout: horizontal;\nbackground: $boost;\nheight: 5;\nmargin: 1;\nmin-width: 50;\npadding: 1;\n}\nTimeDisplay {\ncontent-align: center middle;\ntext-opacity: 60%;\nheight: 3;\n}\nButton {\nwidth: 16;\n}\n#start {\ndock: left;\n}\n#stop {\ndock: left;\ndisplay: none;\n}\n#reset {\ndock: right;\n}\n.started {\ntext-style: bold;\nbackground: $success;\ncolor: $text;\n}\n.started TimeDisplay {\ntext-opacity: 100%;\n}\n.started #start {\ndisplay: none\n}\n.started #stop {\ndisplay: block\n}\n.started #reset {\nvisibility: hidden\n}\n
These new rules are prefixed with .started
. The .
indicates that .started
refers to a CSS class called \"started\". The new styles will be applied only to widgets that have this CSS class.
Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles:
.started #start {\ndisplay: none\n}\n
The .started
selector matches any widget with a \"started\"
CSS class. While #start
matches a child widget with an ID of \"start\"
. So it matches the Start button only for Stopwatches in a started state.
The rule is \"display: none\"
which tells Textual to hide the button.
Modifying a widget's CSS classes is a convenient way to update visuals without introducing a lot of messy display related code.
You can add and remove CSS classes with the add_class() and remove_class() methods. We will use these methods to connect the started state to the Start / Stop buttons.
The following code will start or stop the stopwatches in response to clicking a button.
stopwatch04.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nif event.button.id == \"start\":\nself.add_class(\"started\")\nelif event.button.id == \"stop\":\nself.remove_class(\"started\")\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay(\"00:00:00.00\")\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
The on_button_pressed
method is an event handler. Event handlers are methods called by Textual in response to an event such as a key press, mouse click, etc. Event handlers begin with on_
followed by the name of the event they will handle. Hence on_button_pressed
will handle the button pressed event.
If you run stopwatch04.py
now you will be able to toggle between the two states by clicking the first button:
stopwatch04.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
"},{"location":"tutorial/#reactive-attributes","title":"Reactive attributes","text":"A recurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call refresh() to display new data. However, Textual prefers to do this automatically via reactive attributes.
You can declare a reactive attribute with reactive. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
stopwatch05.pyfrom time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.set_interval(1 / 60, self.update_time)\ndef update_time(self) -> None:\n\"\"\"Method to update the time to the current time.\"\"\"\nself.time = monotonic() - self.start_time\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nif event.button.id == \"start\":\nself.add_class(\"started\")\nelif event.button.id == \"stop\":\nself.remove_class(\"started\")\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets for the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
We have added two reactive attributes to the TimeDisplay
widget: start_time
will contain the time (in seconds) the stopwatch was started, and time
will contain the time to be displayed on the Stopwatch
.
Both attributes will be available on self
as if you had assigned them in __init__
. If you write to either of these attributes the widget will update automatically.
Info
The monotonic
function in this example is imported from the standard library time
module. It is similar to time.time
but won't go backwards if the system clock is changed.
The first argument to reactive
may be a default value for the attribute or a callable that returns a default value. We set the default for start_time
to the monotonic
function which will be called to initialize the attribute with the current time when the TimeDisplay
is added to the app. The time
attribute has a simple float as the default, so self.time
will be initialized to 0
.
The on_mount
method is an event handler called when the widget is first added to the application (or mounted in Textual terminology). In this method we call set_interval() to create a timer which calls self.update_time
sixty times a second. This update_time
method calculates the time elapsed since the widget started and assigns it to self.time
\u2014 which brings us to one of Reactive's super-powers.
If you implement a method that begins with watch_
followed by the name of a reactive attribute, then the method will be called when the attribute is modified. Such methods are known as watch methods.
Because watch_time
watches the time
attribute, when we update self.time
60 times a second we also implicitly call watch_time
which converts the elapsed time to a string and updates the widget with a call to self.update
. Because this happens automatically, we don't need to pass in an initial argument to TimeDisplay
.
The end result is that the Stopwatch
widgets show the time elapsed since the widget was created:
stopwatch05.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.02\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.02\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:01.02\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
We've seen how we can update widgets with a timer, but we still need to wire up the buttons so we can operate stopwatches independently.
"},{"location":"tutorial/#wiring-buttons","title":"Wiring buttons","text":"We need to be able to start, stop, and reset each stopwatch independently. We can do this by adding a few more methods to the TimeDisplay
class.
from time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\ndef start(self) -> None:\n\"\"\"Method to start (or resume) time updating.\"\"\"\nself.start_time = monotonic()\nself.update_timer.resume()\ndef stop(self) -> None:\n\"\"\"Method to stop the time display updating.\"\"\"\nself.update_timer.pause()\nself.total += monotonic() - self.start_time\nself.time = self.total\ndef reset(self) -> None:\n\"\"\"Method to reset the time display to zero.\"\"\"\nself.total = 0\nself.time = 0\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch04.tcss\"\nBINDINGS = [(\"d\", \"toggle_dark\", \"Toggle dark mode\")]\ndef compose(self) -> ComposeResult:\n\"\"\"Called to add widgets to the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Here's a summary of the changes made to TimeDisplay
.
total
reactive attribute to store the total time elapsed between clicking the start and stop buttons.set_interval
has grown a pause=True
argument which starts the timer in pause mode (when a timer is paused it won't run until resume() is called). This is because we don't want the time to update until the user hits the start button.update_time
method now adds total
to the current time to account for the time between any previous clicks of the start and stop buttons.set_interval
which returns a Timer object. We will use this later to resume the timer when we start the Stopwatch.start()
, stop()
, and reset()
methods.In addition, the on_button_pressed
method on Stopwatch
has grown some code to manage the time display when the user clicks a button. Let's look at that in detail:
def on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\n
This code supplies missing features and makes our app useful. We've made the following changes.
id
attribute of the button that was pressed. We can use this to decide what to do in response.query_one
to get a reference to the TimeDisplay
widget.TimeDisplay
that matches the pressed button.\"started\"
class when the Stopwatch is started (self.add_class(\"started\")
), and remove it (self.remove_class(\"started\")
) when it is stopped. This will update the Stopwatch visuals via CSS.If you run stopwatch06.py
you will be able to use the stopwatches independently.
stopwatch06.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Stop\u00a000:00:04.04 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.
"},{"location":"tutorial/#dynamic-widgets","title":"Dynamic widgets","text":"The Stopwatch app creates widgets when it starts via the compose
method. We will also need to create new widgets while the app is running, and remove widgets we no longer need. We can do this by calling mount() to add a widget, and remove() to remove a widget.
Let's use these methods to implement adding and removing stopwatches to our app.
stopwatch.pyfrom time import monotonic\nfrom textual.app import App, ComposeResult\nfrom textual.containers import ScrollableContainer\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, Footer, Header, Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\ndef start(self) -> None:\n\"\"\"Method to start (or resume) time updating.\"\"\"\nself.start_time = monotonic()\nself.update_timer.resume()\ndef stop(self):\n\"\"\"Method to stop the time display updating.\"\"\"\nself.update_timer.pause()\nself.total += monotonic() - self.start_time\nself.time = self.total\ndef reset(self):\n\"\"\"Method to reset the time display to zero.\"\"\"\nself.total = 0\nself.time = 0\nclass Stopwatch(Static):\n\"\"\"A stopwatch widget.\"\"\"\ndef on_button_pressed(self, event: Button.Pressed) -> None:\n\"\"\"Event handler called when a button is pressed.\"\"\"\nbutton_id = event.button.id\ntime_display = self.query_one(TimeDisplay)\nif button_id == \"start\":\ntime_display.start()\nself.add_class(\"started\")\nelif button_id == \"stop\":\ntime_display.stop()\nself.remove_class(\"started\")\nelif button_id == \"reset\":\ntime_display.reset()\ndef compose(self) -> ComposeResult:\n\"\"\"Create child widgets of a stopwatch.\"\"\"\nyield Button(\"Start\", id=\"start\", variant=\"success\")\nyield Button(\"Stop\", id=\"stop\", variant=\"error\")\nyield Button(\"Reset\", id=\"reset\")\nyield TimeDisplay()\nclass StopwatchApp(App):\n\"\"\"A Textual app to manage stopwatches.\"\"\"\nCSS_PATH = \"stopwatch.tcss\"\nBINDINGS = [\n(\"d\", \"toggle_dark\", \"Toggle dark mode\"),\n(\"a\", \"add_stopwatch\", \"Add\"),\n(\"r\", \"remove_stopwatch\", \"Remove\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Called to add widgets to the app.\"\"\"\nyield Header()\nyield Footer()\nyield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch(), id=\"timers\")\ndef action_add_stopwatch(self) -> None:\n\"\"\"An action to add a timer.\"\"\"\nnew_stopwatch = Stopwatch()\nself.query_one(\"#timers\").mount(new_stopwatch)\nnew_stopwatch.scroll_visible()\ndef action_remove_stopwatch(self) -> None:\n\"\"\"Called to remove a timer.\"\"\"\ntimers = self.query(\"Stopwatch\")\nif timers:\ntimers.last().remove()\ndef action_toggle_dark(self) -> None:\n\"\"\"An action to toggle dark mode.\"\"\"\nself.dark = not self.dark\nif __name__ == \"__main__\":\napp = StopwatchApp()\napp.run()\n
Here's a summary of the changes:
ScrollableContainer
object in StopWatchApp
grew a \"timers\"
ID.action_add_stopwatch
to add a new stopwatch.action_remove_stopwatch
to remove a stopwatch.The action_add_stopwatch
method creates and mounts a new stopwatch. Note the call to query_one() with a CSS selector of \"#timers\"
which gets the timer's container via its ID. Once mounted, the new Stopwatch will appear in the terminal. That last line in action_add_stopwatch
calls scroll_visible() which will scroll the container to make the new Stopwatch
visible (if required).
The action_remove_stopwatch
function calls query() with a CSS selector of \"Stopwatch\"
which gets all the Stopwatch
widgets. If there are stopwatches then the action calls last() to get the last stopwatch, and remove() to remove it.
If you run stopwatch.py
now you can add a new stopwatch with the A key and remove a stopwatch with R.
stopwatch.py \u2b58StopwatchApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Start\u00a000:00:00.00\u00a0Reset\u00a0 \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0\u00a0A\u00a0\u00a0Add\u00a0\u00a0R\u00a0\u00a0Remove\u00a0
"},{"location":"tutorial/#what-next","title":"What next?","text":"Congratulations on building your first Textual application! This tutorial has covered a lot of ground. If you are the type that prefers to learn a framework by coding, feel free. You could tweak stopwatch.py
or look through the examples.
Read the guide for the full details on how to build sophisticated TUI applications with Textual.
"},{"location":"widget_gallery/","title":"Widgets","text":"Welcome to the Textual widget gallery.
We have many more widgets planned, or you can build your own.
Info
Textual is a TUI framework. Everything below runs in the terminal.
"},{"location":"widget_gallery/#button","title":"Button","text":"A simple button with a variety of semantic styles.
Button reference
ButtonsApp Standard\u00a0ButtonsDisabled\u00a0Buttons \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Default\u00a0\u00a0Default\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Primary!\u00a0\u00a0Primary!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Success!\u00a0\u00a0Success!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Warning!\u00a0\u00a0Warning!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Error!\u00a0\u00a0Error!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"widget_gallery/#checkbox","title":"Checkbox","text":"A classic checkbox control.
Checkbox reference
CheckboxApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Arrakis\u00a0\ud83d\ude13\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Caladan\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Chusuk\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGiedi\u00a0Prime\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGinaz\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Grumman\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2583\u2583 \u258a\u2590X\u258cKaitain\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e
"},{"location":"widget_gallery/#collapsible","title":"Collapsible","text":"Content that may be toggled on and off by clicking a title.
Collapsible reference
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
"},{"location":"widget_gallery/#contentswitcher","title":"ContentSwitcher","text":"A widget for containing and switching display between multiple child widgets.
ContentSwitcher reference
"},{"location":"widget_gallery/#datatable","title":"DataTable","text":"A powerful data table, with configurable cursors.
DataTable reference
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
"},{"location":"widget_gallery/#digits","title":"Digits","text":"Display numbers in tall characters.
Digits reference
DigitApp \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2551\u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u00a0\u2513\u00a0\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2578\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u250f\u2501\u2513\u257a\u2501\u2513\u2551 \u2551\u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u00a0\u2503\u00a0\u00a0\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b\u00a0\u2523\u2501\u2513\u2517\u2501\u2513\u00a0\u2501\u252b\u00a0\u2517\u2501\u2513\u2523\u2501\u252b\u2517\u2501\u252b\u00a0\u00a0\u2503\u2551 \u2551\u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u253b\u2578,\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578,\u2517\u2501\u251b\u257a\u2501\u251b\u257a\u2501\u251b,\u257a\u2501\u251b\u2517\u2501\u251b\u257a\u2501\u251b\u00a0\u00a0\u2579\u2551 \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d
"},{"location":"widget_gallery/#directorytree","title":"DirectoryTree","text":"A tree view of files and folders.
DirectoryTree reference
DirectoryTreeApp \ud83d\udcc2\u00a0. \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.faq \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.git \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.github \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.ipynb_checkpoints \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.mypy_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.pytest_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.screenshot_cache \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0.vscode \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0__pycache__ \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0dist\u2581\u2581 \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0docs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0examples \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0imgs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0notes \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0questions \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0reference \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0sandbox \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0site \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0src \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tests \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tools \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0.coverage
"},{"location":"widget_gallery/#footer","title":"Footer","text":"A footer to display and interact with key bindings.
Footer reference
FooterApp \u00a0Q\u00a0\u00a0Quit\u00a0the\u00a0app\u00a0\u00a0?\u00a0\u00a0Show\u00a0help\u00a0screen\u00a0\u00a0DELETE\u00a0\u00a0Delete\u00a0the\u00a0thing\u00a0
"},{"location":"widget_gallery/#header","title":"Header","text":"A header to display the app's title and subtitle.
Header reference
HeaderApp \u2b58HeaderApp
"},{"location":"widget_gallery/#input","title":"Input","text":"A control to enter text.
Input reference
InputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aDarren\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aLast\u00a0Name\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#label","title":"Label","text":"A simple text label.
Label reference
"},{"location":"widget_gallery/#listview","title":"ListView","text":"Display a list of items (items may be other widgets).
ListView reference
ListViewExample One Two Three
"},{"location":"widget_gallery/#loadingindicator","title":"LoadingIndicator","text":"Display an animation while data is loading.
LoadingIndicator reference
LoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf
"},{"location":"widget_gallery/#log","title":"Log","text":"Display and update lines of text (such as from a file).
Log reference
LogApp And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2584\u2584 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
"},{"location":"widget_gallery/#markdownviewer","title":"MarkdownViewer","text":"Display and interact with a Markdown document (adds a table of contents and browser-like navigation to Markdown).
MarkdownViewer reference
MarkdownExampleApp \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u25bc\u00a0\u2160\u00a0Markdown\u00a0Viewer\u258a\u258e\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Features\u258a\u258eMarkdown\u00a0Viewer\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Tables\u258a\u258e\u258a \u2514\u2500\u2500\u00a0\u2161\u00a0Code\u00a0Blocks\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aThis\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0MarkdownViewer\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aMarkdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u258a \u258a\u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u258a\u25cf\u00a0Headers \u258a\u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u258a\u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u258a\u25cf\u00a0Tables! \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tables\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aTables\u00a0are\u00a0displayed\u00a0in\u00a0a\u00a0DataTable\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258eName\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0TypeDefaultDescription\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a \u258a\u258eshow_headerboolTrueShow\u00a0the\u00a0table\u00a0header\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_rowsint0Number\u00a0of\u00a0fixed\u00a0rows\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_columnsint0Number\u00a0of\u00a0fixed\u00a0columns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258ezebra_stripesboolFalseDisplay\u00a0alternating\u00a0colors\u00a0on\u00a0rows\u258a \u258a\u258eheader_heightint1Height\u00a0of\u00a0header\u00a0row\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258eshow_cursorboolTrueShow\u00a0a\u00a0cell\u00a0cursor\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a
"},{"location":"widget_gallery/#markdown","title":"Markdown","text":"Display a markdown document.
Markdown reference
MarkdownExampleApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eMarkdown\u00a0Document\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 This\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0Markdown\u00a0widget. \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Markdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u25cf\u00a0Headers \u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u25cf\u00a0Tables!
"},{"location":"widget_gallery/#optionlist","title":"OptionList","text":"Display a vertical list of options (options may be Rich renderables).
OptionList reference
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aGemenon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aPicon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aSagittaron\u2584\u2584\u258e \u258aScorpia\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#placeholder","title":"Placeholder","text":"Display placeholder content while you are designing a UI.
Placeholder reference
PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula. Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0 vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedconsectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0 lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 sapien\u00a0sapien\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0
"},{"location":"widget_gallery/#pretty","title":"Pretty","text":"Display a pretty-formatted Rich renderable.
Pretty reference
PrettyExample { 'title':\u00a0'Back\u00a0to\u00a0the\u00a0Future', 'releaseYear':\u00a01985, 'director':\u00a0'Robert\u00a0Zemeckis', 'genre':\u00a0'Adventure,\u00a0Comedy,\u00a0Sci-Fi', 'cast':\u00a0[ {'actor':\u00a0'Michael\u00a0J.\u00a0Fox',\u00a0'character':\u00a0'Marty\u00a0McFly'}, {'actor':\u00a0'Christopher\u00a0Lloyd',\u00a0'character':\u00a0'Dr.\u00a0Emmett\u00a0Brown'} ] }
"},{"location":"widget_gallery/#progressbar","title":"ProgressBar","text":"A configurable progress bar with ETA and percentage complete.
ProgressBar reference
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250150% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$50\u00a0received!
"},{"location":"widget_gallery/#radiobutton","title":"RadioButton","text":"A simple radio button.
RadioButton reference
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#radioset","title":"RadioSet","text":"A collection of radio buttons, that enforces uniqueness.
RadioSet reference
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e\u258a\u2590\u25cf\u258c\u00a0Amanda\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e\u258a\u2590\u25cf\u258c\u00a0Connor\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e\u258a\u2590\u25cf\u258c\u00a0Duncan\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e\u258a\u2590\u25cf\u258c\u00a0Heather\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e\u258a\u2590\u25cf\u258c\u00a0Joe\u00a0Dawson\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e\u258a\u2590\u25cf\u258c\u00a0Kurgan,\u00a0The\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e\u258a\u2590\u25cf\u258c\u00a0Methos\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e\u258a\u2590\u25cf\u258c\u00a0Rachel\u00a0Ellenstein\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e\u258a\u2590\u25cf\u258c\u00a0Ram\u00edrez\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#richlog","title":"RichLog","text":"Display and update text in a scrolling panel.
RichLog reference
RichLogApp \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=next(iter_values) \u2502\u00a0\u00a0\u00a0exceptStopIteration: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0return \u2502\u00a0\u00a0\u00a0first=True\u2585\u2585 \u2502\u00a0\u00a0\u00a0forvalueiniter_values: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldfirst,False,previous_value \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0first=False \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=value \u2502\u00a0\u00a0\u00a0yieldfirst,True,previous_value \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503lane\u2503swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503time\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25024\u00a0\u00a0\u00a0\u2502Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u2502Singapore\u00a0\u00a0\u00a0\u00a0\u250250.39\u2502 \u25022\u00a0\u00a0\u00a0\u2502Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.14\u2502 \u25025\u00a0\u00a0\u00a0\u2502Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502South\u00a0Africa\u00a0\u250251.14\u2502 \u25026\u00a0\u00a0\u00a0\u2502L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.14\u2502 \u25023\u00a0\u00a0\u00a0\u2502Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.26\u2502 \u25028\u00a0\u00a0\u00a0\u2502Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.58\u2502 \u25027\u00a0\u00a0\u00a0\u2502Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.73\u2502 \u25021\u00a0\u00a0\u00a0\u2502Aleksandr\u00a0Sadovnikov\u2502Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.84\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 Write\u00a0text\u00a0or\u00a0any\u00a0Rich\u00a0renderable! Key(key='H',\u00a0character='H',\u00a0name='upper_h',\u00a0is_printable=True) Key(key='i',\u00a0character='i',\u00a0name='i',\u00a0is_printable=True)
"},{"location":"widget_gallery/#rule","title":"Rule","text":"A rule widget to separate content, similar to a <hr>
HTML tag.
Rule reference
HorizontalRulesApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0solid\u00a0(default)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0heavy\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0thick\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0dashed\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0double\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ascii\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 ----------------------------------------------------------------
"},{"location":"widget_gallery/#select","title":"Select","text":"Select from a number of possible options.
Select reference
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#selectionlist","title":"SelectionList","text":"Select multiple values from a list of options.
SelectionList reference
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"widget_gallery/#sparkline","title":"Sparkline","text":"Display numerical data.
Sparkline reference
SparklineSummaryFunctionApp \u2582\u2584\u2582\u2584\u2583\u2583\u2586\u2585\u2583\u2582\u2583\u2582\u2583\u2582\u2584\u2587\u2583\u2583\u2587\u2585\u2584\u2583\u2584\u2584\u2583\u2582\u2583\u2582\u2583\u2584\u2584\u2588\u2586\u2582\u2583\u2583\u2585\u2583\u2583\u2584\u2583\u2587\u2583\u2583\u2583\u2584\u2584\u2586\u2583\u2583\u2585\u2582\u2585\u2583\u2584\u2583\u2583\u2584\u2583\u2585\u2586\u2582\u2582\u2583\u2586\u2582\u2583\u2584\u2585\u2584\u2583\u2584\u2584\u2581\u2583\u2582 \u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2582\u2582\u2582\u2582\u2582\u2582\u2581\u2581\u2581\u2581\u2581\u2582\u2581\u2582\u2582\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2581\u2581\u2581\u2581\u2582\u2582\u2582\u2581\u2582\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"widget_gallery/#static","title":"Static","text":"Displays simple static content. Typically used as a base class.
Static reference
"},{"location":"widget_gallery/#switch","title":"Switch","text":"An on / off control, inspired by toggle buttons.
Switch reference
SwitchApp Example\u00a0switches \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e off:\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e on:\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e focused:\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e custom:\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"widget_gallery/#tabs","title":"Tabs","text":"A row of tabs you can select with the mouse or navigate with keys.
Tabs reference
TabsApp \u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0HalleckBaron\u00a0Vladimir \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0
"},{"location":"widget_gallery/#tabbedcontent","title":"TabbedContent","text":"A Combination of Tabs and ContentSwitcher to navigate static content.
TabbedContent reference
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
"},{"location":"widget_gallery/#textarea","title":"TextArea","text":"A multi-line text area which supports syntax highlighting various languages.
TextArea reference
TextAreaExample 1\u00a0\u00a0defhello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0print(\"hello\"+\u00a0name)\u00a0\u00a0\u00a0 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
"},{"location":"widget_gallery/#tree","title":"Tree","text":"A tree control with expandable nodes.
Tree reference
TreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
"},{"location":"api/","title":"API","text":"This is a API-level reference to the Textual API. Click the links to your left (or in the burger menu) to open a reference for each module.
If you are new to Textual, you may want to read the tutorial or guide first.
"},{"location":"api/app/","title":"App","text":"Here you will find the App class, which is the base class for Textual apps.
See app basics for how to build Textual apps.
"},{"location":"api/app/#textual.app.AutopilotCallbackType","title":"AutopilotCallbackTypemodule-attribute
","text":"AutopilotCallbackType: TypeAlias = (\n\"Callable[[Pilot[object]], Coroutine[Any, Any, None]]\"\n)\n
Signature for valid callbacks that can be used to control apps.
"},{"location":"api/app/#textual.app.CSSPathType","title":"CSSPathTypemodule-attribute
","text":"CSSPathType = Union[\nstr, PurePath, List[Union[str, PurePath]]\n]\n
Valid ways of specifying paths to CSS files.
"},{"location":"api/app/#textual.app.ActionError","title":"ActionErrorclass
","text":" Bases: Exception
Base class for exceptions relating to actions.
"},{"location":"api/app/#textual.app.ActiveModeError","title":"ActiveModeErrorclass
","text":" Bases: ModeError
Raised when attempting to remove the currently active mode.
"},{"location":"api/app/#textual.app.App","title":"Appclass
","text":"def __init__(\nself, driver_class=None, css_path=None, watch_css=False\n):\n
Bases: Generic[ReturnType]
, DOMNode
The base class for Textual Applications.
Parameters Name Type Description Defaultdriver_class
Type[Driver] | None
Driver class or None
to auto-detect. This will be used by some Textual tools.
None
css_path
CSSPathType | None
Path to CSS or None
to use the CSS_PATH
class variable. To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
None
watch_css
bool
Reload CSS if the files changed. This is set automatically if you are using textual run
with the dev
switch.
False
Raises Type Description CssPathError
When the supplied CSS path(s) are an unexpected type.
"},{"location":"api/app/#textual.app.App.AUTO_FOCUS","title":"AUTO_FOCUSclass-attribute
","text":"AUTO_FOCUS: str | None = '*'\n
A selector to determine what to focus automatically when a screen is activated.
The widget focused is the first that matches the given CSS selector. Setting to None
or \"\"
disables auto focus.
class-attribute
","text":"COMMANDS: set[type[Provider]] = {SystemCommands}\n
Command providers used by the command palette.
Should be a set of command.Provider classes.
"},{"location":"api/app/#textual.app.App.CSS","title":"CSSclass-attribute
","text":"CSS: str = ''\n
Inline CSS, useful for quick scripts. This is loaded after CSS_PATH, and therefore takes priority in the event of a specificity clash.
"},{"location":"api/app/#textual.app.App.CSS_PATH","title":"CSS_PATHclass-attribute
","text":"CSS_PATH: CSSPathType | None = None\n
File paths to load CSS from.
"},{"location":"api/app/#textual.app.App.ENABLE_COMMAND_PALETTE","title":"ENABLE_COMMAND_PALETTEclass-attribute
","text":"ENABLE_COMMAND_PALETTE: bool = True\n
Should the command palette be enabled for the application?
"},{"location":"api/app/#textual.app.App.MODES","title":"MODESclass-attribute
","text":"MODES: dict[str, str | Screen | Callable[[], Screen]] = {}\n
Modes associated with the app and their base screens.
The base screen is the screen at the bottom of the mode stack. You can think of it as the default screen for that stack. The base screens can be names of screens listed in SCREENS, Screen
instances, or callables that return screens.
class HelpScreen(Screen[None]):\n...\nclass MainAppScreen(Screen[None]):\n...\nclass MyApp(App[None]):\nMODES = {\n\"default\": \"main\",\n\"help\": HelpScreen,\n}\nSCREENS = {\n\"main\": MainAppScreen,\n}\n...\n
"},{"location":"api/app/#textual.app.App.SCREENS","title":"SCREENS class-attribute
","text":"SCREENS: dict[str, Screen | Callable[[], Screen]] = {}\n
Screens associated with the app for the lifetime of the app.
"},{"location":"api/app/#textual.app.App.SUB_TITLE","title":"SUB_TITLEclass-attribute
instance-attribute
","text":"SUB_TITLE: str | None = None\n
A class variable to set the default sub-title for the application.
To update the sub-title while the app is running, you can set the sub_title attribute. See also the Screen.SUB_TITLE
attribute.
class-attribute
instance-attribute
","text":"TITLE: str | None = None\n
A class variable to set the default title for the application.
To update the title while the app is running, you can set the title attribute. See also the Screen.TITLE
attribute.
property
","text":"children: Sequence['Widget']\n
A view onto the app's immediate children.
This attribute exists on all widgets. In the case of the App, it will only ever contain a single child, which will be the currently active screen.
Returns Type DescriptionSequence['Widget']
A sequence of widgets.
"},{"location":"api/app/#textual.app.App.current_mode","title":"current_modeproperty
","text":"current_mode: str\n
The name of the currently active mode.
"},{"location":"api/app/#textual.app.App.cursor_position","title":"cursor_positioninstance-attribute
","text":"cursor_position = Offset(0, 0)\n
The position of the terminal cursor in screen-space.
This can be set by widgets and is useful for controlling the positioning of OS IME and emoji popup menus.
"},{"location":"api/app/#textual.app.App.dark","title":"darkclass-attribute
instance-attribute
","text":"dark: Reactive[bool] = Reactive(True, compute=False)\n
Use a dark theme if True
, otherwise use a light theme.
Modify this attribute to switch between light and dark themes.
Exampleself.app.dark = not self.app.dark # Toggle dark mode\n
"},{"location":"api/app/#textual.app.App.debug","title":"debug property
","text":"debug: bool\n
Is debug mode enabled?
"},{"location":"api/app/#textual.app.App.focused","title":"focusedproperty
","text":"focused: Widget | None\n
The widget that is focused on the currently active screen, or None
.
Focused widgets receive keyboard input.
Returns Type DescriptionWidget | None
The currently focused widget, or None
if nothing is focused.
property
","text":"is_headless: bool\n
Is the driver running in 'headless' mode?
Headless mode is used when running tests with run_test.
"},{"location":"api/app/#textual.app.App.log","title":"logproperty
","text":"log: Logger\n
The textual logger.
Exampleself.log(\"Hello, World!\")\nself.log(self.tree)\n
Returns Type Description Logger
A Textual logger.
"},{"location":"api/app/#textual.app.App.namespace_bindings","title":"namespace_bindingsproperty
","text":"namespace_bindings: dict[str, tuple[DOMNode, Binding]]\n
Get currently active bindings.
If no widget is focused, then app-level bindings are returned. If a widget is focused, then any bindings present in the active screen and app are merged and returned.
This property may be used to inspect current bindings.
Returns Type Descriptiondict[str, tuple[DOMNode, Binding]]
A mapping of keys onto pairs of nodes and bindings.
"},{"location":"api/app/#textual.app.App.return_code","title":"return_codeproperty
","text":"return_code: int | None\n
The return code with which the app exited.
Non-zero codes indicate errors. A value of 1 means the app exited with a fatal error. If the app wasn't exited yet, this will be None
.
The return code can be used to exit the process via sys.exit
.
my_app.run()\nsys.exit(my_app.return_code)\n
"},{"location":"api/app/#textual.app.App.return_value","title":"return_value property
","text":"return_value: ReturnType | None\n
The return value of the app, or None
if it has not yet been set.
The return value is set when calling exit.
"},{"location":"api/app/#textual.app.App.screen","title":"screenproperty
","text":"screen: Screen[object]\n
The current active screen.
Returns Type DescriptionScreen[object]
The currently active (visible) screen.
Raises Type DescriptionScreenStackError
If there are no screens on the stack.
"},{"location":"api/app/#textual.app.App.screen_stack","title":"screen_stackproperty
","text":"screen_stack: Sequence[Screen]\n
A snapshot of the current screen stack.
Returns Type DescriptionSequence[Screen]
A snapshot of the current state of the screen stack.
"},{"location":"api/app/#textual.app.App.scroll_sensitivity_x","title":"scroll_sensitivity_xinstance-attribute
","text":"scroll_sensitivity_x: float = 4.0\n
Number of columns to scroll in the X direction with wheel or trackpad.
"},{"location":"api/app/#textual.app.App.scroll_sensitivity_y","title":"scroll_sensitivity_yinstance-attribute
","text":"scroll_sensitivity_y: float = 2.0\n
Number of lines to scroll in the Y direction with wheel or trackpad.
"},{"location":"api/app/#textual.app.App.size","title":"sizeproperty
","text":"size: Size\n
The size of the terminal.
Returns Type DescriptionSize
Size of the terminal.
"},{"location":"api/app/#textual.app.App.sub_title","title":"sub_titleclass-attribute
instance-attribute
","text":"sub_title: Reactive[str] = (\nself.SUB_TITLE if self.SUB_TITLE is not None else \"\"\n)\n
The sub-title for the application.
The initial value for sub_title
will be set to the SUB_TITLE
class variable if it exists, or an empty string if it doesn't.
Sub-titles are typically used to show the high-level state of the app, such as the current mode, or path to the file being worked on.
Assign a new value to this attribute to change the sub-title. The new value is always converted to string.
"},{"location":"api/app/#textual.app.App.title","title":"titleclass-attribute
instance-attribute
","text":"title: Reactive[str] = (\nself.TITLE\nif self.TITLE is not None\nelse f\"{self.__class__.__name__}\"\n)\n
The title for the application.
The initial value for title
will be set to the TITLE
class variable if it exists, or the name of the app if it doesn't.
Assign a new value to this attribute to change the title. The new value is always converted to string.
"},{"location":"api/app/#textual.app.App.use_command_palette","title":"use_command_paletteinstance-attribute
","text":"use_command_palette: bool = self.ENABLE_COMMAND_PALETTE\n
A flag to say if the application should use the command palette.
If set to False
any call to action_command_palette
will be ignored.
property
","text":"workers: WorkerManager\n
The worker manager.
Returns Type DescriptionWorkerManager
An object to manage workers.
"},{"location":"api/app/#textual.app.App.action_add_class","title":"action_add_classasync
","text":"def action_add_class(self, selector, class_name):\n
An action to add a CSS class to the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to add the class to.
requiredclass_name
str
The class to add to the selected widget.
required"},{"location":"api/app/#textual.app.App.action_back","title":"action_backasync
","text":"def action_back(self):\n
An action to go back to the previous screen (pop the current screen).
NoteIf there is no screen to go back to, this is a non-operation (in other words it's safe to call even if there are no other screens on the stack.)
"},{"location":"api/app/#textual.app.App.action_bell","title":"action_bellasync
","text":"def action_bell(self):\n
An action to play the terminal 'bell'.
"},{"location":"api/app/#textual.app.App.action_check_bindings","title":"action_check_bindingsasync
","text":"def action_check_bindings(self, key):\n
An action to handle a key press using the binding system.
Parameters Name Type Description Defaultkey
str
The key to process.
required"},{"location":"api/app/#textual.app.App.action_command_palette","title":"action_command_palettemethod
","text":"def action_command_palette(self):\n
Show the Textual command palette.
"},{"location":"api/app/#textual.app.App.action_focus","title":"action_focusasync
","text":"def action_focus(self, widget_id):\n
An action to focus the given widget.
Parameters Name Type Description Defaultwidget_id
str
ID of widget to focus.
required"},{"location":"api/app/#textual.app.App.action_focus_next","title":"action_focus_nextmethod
","text":"def action_focus_next(self):\n
An action to focus the next widget.
"},{"location":"api/app/#textual.app.App.action_focus_previous","title":"action_focus_previousmethod
","text":"def action_focus_previous(self):\n
An action to focus the previous widget.
"},{"location":"api/app/#textual.app.App.action_pop_screen","title":"action_pop_screenasync
","text":"def action_pop_screen(self):\n
An action to remove the topmost screen and makes the new topmost screen active.
"},{"location":"api/app/#textual.app.App.action_push_screen","title":"action_push_screenasync
","text":"def action_push_screen(self, screen):\n
An action to push a new screen on to the stack and make it active.
Parameters Name Type Description Defaultscreen
str
Name of the screen.
required"},{"location":"api/app/#textual.app.App.action_quit","title":"action_quitasync
","text":"def action_quit(self):\n
An action to quit the app as soon as possible.
"},{"location":"api/app/#textual.app.App.action_remove_class","title":"action_remove_classasync
","text":"def action_remove_class(self, selector, class_name):\n
An action to remove a CSS class from the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to remove the class from.
requiredclass_name
str
The class to remove from the selected widget.
required"},{"location":"api/app/#textual.app.App.action_screenshot","title":"action_screenshotmethod
","text":"def action_screenshot(self, filename=None, path='./'):\n
This action will save an SVG file containing the current contents of the screen.
Parameters Name Type Description Defaultfilename
str | None
Filename of screenshot, or None to auto-generate.
None
path
str
Path to directory. Defaults to current working directory.
'./'
"},{"location":"api/app/#textual.app.App.action_switch_mode","title":"action_switch_mode async
","text":"def action_switch_mode(self, mode):\n
An action that switches to the given mode..
"},{"location":"api/app/#textual.app.App.action_switch_screen","title":"action_switch_screenasync
","text":"def action_switch_screen(self, screen):\n
An action to switch screens.
Parameters Name Type Description Defaultscreen
str
Name of the screen.
required"},{"location":"api/app/#textual.app.App.action_toggle_class","title":"action_toggle_classasync
","text":"def action_toggle_class(self, selector, class_name):\n
An action to toggle a CSS class on the selected widget.
Parameters Name Type Description Defaultselector
str
Selects the widget to toggle the class on.
requiredclass_name
str
The class to toggle on the selected widget.
required"},{"location":"api/app/#textual.app.App.action_toggle_dark","title":"action_toggle_darkmethod
","text":"def action_toggle_dark(self):\n
An action to toggle dark mode.
"},{"location":"api/app/#textual.app.App.add_mode","title":"add_modemethod
","text":"def add_mode(self, mode, base_screen):\n
Adds a mode and its corresponding base screen to the app.
Parameters Name Type Description Defaultmode
str
The new mode.
requiredbase_screen
str | Screen | Callable[[], Screen]
The base screen associated with the given mode.
required Raises Type DescriptionInvalidModeError
If the name of the mode is not valid/duplicated.
"},{"location":"api/app/#textual.app.App.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
See the guide for how to use the animation system.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/app/#textual.app.App.batch_update","title":"batch_update method
","text":"def batch_update(self):\n
A context manager to suspend all repaints until the end of the batch.
"},{"location":"api/app/#textual.app.App.begin_capture_print","title":"begin_capture_printmethod
","text":"def begin_capture_print(self, target, stdout=True, stderr=True):\n
Capture content that is printed (or written to stdout / stderr).
If printing is captured, the target
will be sent an events.Print message.
target
MessageTarget
The widget where print content will be sent.
requiredstdout
bool
Capture stdout.
True
stderr
bool
Capture stderr.
True
"},{"location":"api/app/#textual.app.App.bell","title":"bell method
","text":"def bell(self):\n
Play the console 'bell'.
For terminals that support a bell, this typically makes a notification or error sound. Some terminals may make no sound or display a visual bell indicator, depending on configuration.
"},{"location":"api/app/#textual.app.App.bind","title":"bindmethod
","text":"def bind(\nself,\nkeys,\naction,\n*,\ndescription=\"\",\nshow=True,\nkey_display=None\n):\n
Bind a key to an action.
Parameters Name Type Description Defaultkeys
str
A comma separated list of keys, i.e.
requiredaction
str
Action to bind to.
requireddescription
str
Short description of action.
''
show
bool
Show key in UI.
True
key_display
str | None
Replacement text for key, or None to use default.
None
"},{"location":"api/app/#textual.app.App.call_from_thread","title":"call_from_thread method
","text":"def call_from_thread(self, callback, *args, **kwargs):\n
Run a callable from another thread, and return the result.
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods or set attributes on Textual objects from a thread, you may get unpredictable results.
This method will ensure that your code runs within the correct context.
Tip
Consider using post_message which is also thread-safe.
Parameters Name Type Description Defaultcallback
Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]]
A callable to run.
required*args
object
Arguments to the callback.
()
**kwargs
object
Keyword arguments for the callback.
{}
Raises Type Description RuntimeError
If the app isn't running or if this method is called from the same thread where the app is running.
Returns Type DescriptionCallThreadReturnType
The result of the callback.
"},{"location":"api/app/#textual.app.App.capture_mouse","title":"capture_mousemethod
","text":"def capture_mouse(self, widget):\n
Send all mouse events to the given widget or disable mouse capture.
Parameters Name Type Description Defaultwidget
Widget | None
If a widget, capture mouse event, or None
to end mouse capture.
async
","text":"def check_bindings(self, key, priority=False):\n
Handle a key press.
This method is used internally by the bindings system, but may be called directly if you wish to simulate a key being pressed.
Parameters Name Type Description Defaultkey
str
A key.
requiredpriority
bool
If True
check from App
down, otherwise from focused up.
False
Returns Type Description bool
True if the key was handled by a binding, otherwise False
"},{"location":"api/app/#textual.app.App.clear_notifications","title":"clear_notificationsmethod
","text":"def clear_notifications(self):\n
Clear all the current notifications.
"},{"location":"api/app/#textual.app.App.compose","title":"composemethod
","text":"def compose(self):\n
Yield child widgets for a container.
This method should be implemented in a subclass.
"},{"location":"api/app/#textual.app.App.end_capture_print","title":"end_capture_printmethod
","text":"def end_capture_print(self, target):\n
End capturing of prints.
Parameters Name Type Description Defaulttarget
MessageTarget
The widget that was capturing prints.
required"},{"location":"api/app/#textual.app.App.exit","title":"exitmethod
","text":"def exit(self, result=None, return_code=0, message=None):\n
Exit the app, and return the supplied result.
Parameters Name Type Description Defaultresult
ReturnType | None
Return value.
None
return_code
int
The return code. Use non-zero values for error codes.
0
message
RenderableType | None
Optional message to display on exit.
None
"},{"location":"api/app/#textual.app.App.export_screenshot","title":"export_screenshot method
","text":"def export_screenshot(self, *, title=None):\n
Export an SVG screenshot of the current screen.
See also save_screenshot which writes the screenshot to a file.
Parameters Name Type Description Defaulttitle
str | None
The title of the exported screenshot or None to use app title.
None
"},{"location":"api/app/#textual.app.App.get_child_by_id","title":"get_child_by_id method
","text":"def get_child_by_id(self, id, expect_type=None):\n
Get the first child (immediate descendent) of this DOMNode with the given ID.
Parameters Name Type Description Defaultid
str
The ID of the node to search for.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or use None
to apply no type restriction.
None
Returns Type Description ExpectType | Widget
The first child of this node with the specified ID.
Raises Type DescriptionNoMatches
If no children could be found for this ID.
WrongType
If the wrong type was found.
"},{"location":"api/app/#textual.app.App.get_child_by_type","title":"get_child_by_typemethod
","text":"def get_child_by_type(self, expect_type):\n
Get a child of a give type.
Parameters Name Type Description Defaultexpect_type
type[ExpectType]
The type of the expected child.
required Raises Type DescriptionNoMatches
If no valid child is found.
Returns Type DescriptionExpectType
A widget.
"},{"location":"api/app/#textual.app.App.get_css_variables","title":"get_css_variablesmethod
","text":"def get_css_variables(self):\n
Get a mapping of variables used to pre-populate CSS.
May be implemented in a subclass to add new CSS variables.
Returns Type Descriptiondict[str, str]
A mapping of variable name to value.
"},{"location":"api/app/#textual.app.App.get_driver_class","title":"get_driver_classmethod
","text":"def get_driver_class(self):\n
Get a driver class for this platform.
This method is called by the constructor, and unlikely to be required when building a Textual app.
Returns Type DescriptionType[Driver]
A Driver class which manages input and display.
"},{"location":"api/app/#textual.app.App.get_key_display","title":"get_key_displaymethod
","text":"def get_key_display(self, key):\n
For a given key, return how it should be displayed in an app (e.g. in the Footer widget). By key, we refer to the string used in the \"key\" argument for a Binding instance. By overriding this method, you can ensure that keys are displayed consistently throughout your app, without needing to add a key_display to every binding.
Parameters Name Type Description Defaultkey
str
The binding key string.
required Returns Type Descriptionstr
The display string for the input key.
"},{"location":"api/app/#textual.app.App.get_screen","title":"get_screenmethod
","text":"def get_screen(self, screen):\n
Get an installed screen.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
KeyError
If the named screen doesn't exist.
Returns Type DescriptionScreen
A screen instance.
"},{"location":"api/app/#textual.app.App.get_widget_at","title":"get_widget_atmethod
","text":"def get_widget_at(self, x, y):\n
Get the widget under the given coordinates.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptiontuple[Widget, Region]
The widget and the widget's screen region.
"},{"location":"api/app/#textual.app.App.get_widget_by_id","title":"get_widget_by_idmethod
","text":"def get_widget_by_id(self, id, expect_type=None):\n
Get the first descendant widget with the given ID.
Performs a breadth-first search rooted at the current screen. It will not return the Screen if that matches the ID. To get the screen, use self.screen
.
id
str
The ID to search for in the subtree
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type. Defaults to None.
None
Returns Type Description ExpectType | Widget
The first descendant encountered with this ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID
WrongType
if the wrong type was found.
"},{"location":"api/app/#textual.app.App.install_screen","title":"install_screenmethod
","text":"def install_screen(self, screen, name):\n
Install a screen.
Installing a screen prevents Textual from destroying it when it is no longer on the screen stack. Note that you don't need to install a screen to use it. See push_screen or switch_screen to make a new screen current.
Parameters Name Type Description Defaultscreen
Screen
Screen to install.
requiredname
str
Unique name to identify the screen.
required Raises Type DescriptionScreenError
If the screen can't be installed.
Returns Type DescriptionNone
An awaitable that awaits the mounting of the screen and its children.
"},{"location":"api/app/#textual.app.App.is_mounted","title":"is_mountedmethod
","text":"def is_mounted(self, widget):\n
Check if a widget is mounted.
Parameters Name Type Description Defaultwidget
Widget
A widget.
required Returns Type Descriptionbool
True of the widget is mounted.
"},{"location":"api/app/#textual.app.App.is_screen_installed","title":"is_screen_installedmethod
","text":"def is_screen_installed(self, screen):\n
Check if a given screen has been installed.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
bool
True if the screen is currently installed,
"},{"location":"api/app/#textual.app.App.mount","title":"mountmethod
","text":"def mount(self, *widgets, before=None, after=None):\n
Mount the given widgets relative to the app's screen.
Parameters Name Type Description Default*widgets
Widget
The widget(s) to mount.
()
before
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def mount_all(self, widgets, *, before=None, after=None):\n
Mount widgets from an iterable.
Parameters Name Type Description Defaultwidgets
Iterable[Widget]
An iterable of widgets.
requiredbefore
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def notify(\nself,\nmessage,\n*,\ntitle=\"\",\nseverity=\"information\",\ntimeout=Notification.timeout\n):\n
Create a notification.
Tip
This method is thread-safe.
Parameters Name Type Description Defaultmessage
str
The message for the notification.
requiredtitle
str
The title for the notification.
''
severity
SeverityLevel
The severity of the notification.
'information'
timeout
float
The timeout for the notification.
Notification.timeout
The notify
method is used to create an application-wide notification, shown in a Toast
, normally originating in the bottom right corner of the display.
Notifications can have the following severity levels:
information
warning
error
The default is information
.
# Show an information notification.\nself.notify(\"It's an older code, sir, but it checks out.\")\n# Show a warning. Note that Textual's notification system allows\n# for the use of Rich console markup.\nself.notify(\n\"Now witness the firepower of this fully \"\n\"[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!\",\ntitle=\"Possible trap detected\",\nseverity=\"warning\",\n)\n# Show an error. Set a longer timeout so it's noticed.\nself.notify(\"It's a trap!\", severity=\"error\", timeout=10)\n# Show an information notification, but without any sort of title.\nself.notify(\"It's against my programming to impersonate a deity.\", title=\"\")\n
"},{"location":"api/app/#textual.app.App.panic","title":"panic method
","text":"def panic(self, *renderables):\n
Exits the app and display error message(s).
Used in response to unexpected errors. For a more graceful exit, see the exit method.
Parameters Name Type Description Default*renderables
RenderableType
Text or Rich renderable(s) to display on exit.
()
"},{"location":"api/app/#textual.app.App.pop_screen","title":"pop_screen method
","text":"def pop_screen(self):\n
Pop the current screen from the stack, and switch to the previous screen.
Returns Type DescriptionScreen[object]
The screen that was replaced.
"},{"location":"api/app/#textual.app.App.post_display_hook","title":"post_display_hookmethod
","text":"def post_display_hook(self):\n
Called immediately after a display is done. Used in tests.
"},{"location":"api/app/#textual.app.App.push_screen","title":"push_screenmethod
","text":"def push_screen(\nself, screen, callback=None, wait_for_dismiss=False\n):\n
Push a new screen on the screen stack, making it the current screen.
Parameters Name Type Description Defaultscreen
Screen[ScreenResultType] | str
A Screen instance or the name of an installed screen.
requiredcallback
ScreenResultCallbackType[ScreenResultType] | None
An optional callback function that will be called if the screen is dismissed with a result.
None
wait_for_dismiss
bool
If True
, awaiting this method will return the dismiss value from the screen. When set to False
, awaiting this method will wait for the screen to be mounted. Note that wait_for_dismiss
should only be set to True
when running in a worker.
False
Raises Type Description NoActiveWorker
If using wait_for_dismiss
outside of a worker.
AwaitMount | asyncio.Future[ScreenResultType]
An optional awaitable that awaits the mounting of the screen and its children, or an asyncio Future to await the result of the screen.
"},{"location":"api/app/#textual.app.App.refresh_css","title":"refresh_cssmethod
","text":"def refresh_css(self, animate=True):\n
Refresh CSS.
Parameters Name Type Description Defaultanimate
bool
Also execute CSS animations.
True
"},{"location":"api/app/#textual.app.App.remove_mode","title":"remove_mode method
","text":"def remove_mode(self, mode):\n
Removes a mode from the app.
Screens that are running in the stack of that mode are scheduled for pruning.
Parameters Name Type Description Defaultmode
str
The mode to remove. It can't be the active mode.
required Raises Type DescriptionActiveModeError
If trying to remove the active mode.
UnknownModeError
If trying to remove an unknown mode.
"},{"location":"api/app/#textual.app.App.run","title":"runmethod
","text":"def run(self, *, headless=False, size=None, auto_pilot=None):\n
Run the app.
Parameters Name Type Description Defaultheadless
bool
Run in headless mode (no output).
False
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
None
auto_pilot
AutopilotCallbackType | None
An auto pilot coroutine.
None
Returns Type Description ReturnType | None
App return value.
"},{"location":"api/app/#textual.app.App.run_action","title":"run_actionasync
","text":"def run_action(self, action, default_namespace=None):\n
Perform an action.
Actions are typically associated with key bindings, where you wouldn't need to call this method manually.
Parameters Name Type Description Defaultaction
str | ActionParseResult
Action encoded in a string.
requireddefault_namespace
object | None
Namespace to use if not provided in the action, or None to use app.
None
Returns Type Description bool
True if the event has been handled.
"},{"location":"api/app/#textual.app.App.run_async","title":"run_asyncasync
","text":"def run_async(\nself, *, headless=False, size=None, auto_pilot=None\n):\n
Run the app asynchronously.
Parameters Name Type Description Defaultheadless
bool
Run in headless mode (no output).
False
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
None
auto_pilot
AutopilotCallbackType | None
An auto pilot coroutine.
None
Returns Type Description ReturnType | None
App return value.
"},{"location":"api/app/#textual.app.App.run_test","title":"run_testasync
","text":"def run_test(\nself,\n*,\nheadless=True,\nsize=(80, 24),\ntooltips=False,\nnotifications=False,\nmessage_hook=None\n):\n
An asynchronous context manager for testing apps.
Tip
See the guide for testing Textual apps.
Use this to run your app in \"headless\" mode (no output) and drive the app via a Pilot object.
Exampleasync with app.run_test() as pilot:\nawait pilot.click(\"#Button.ok\")\nassert ...\n
Parameters Name Type Description Default headless
bool
Run in headless mode (no output or input).
True
size
tuple[int, int] | None
Force terminal size to (WIDTH, HEIGHT)
, or None to auto-detect.
(80, 24)
tooltips
bool
Enable tooltips when testing.
False
notifications
bool
Enable notifications when testing.
False
message_hook
Callable[[Message], None] | None
An optional callback that will be called each time any message arrives at any message pump in the app.
None
"},{"location":"api/app/#textual.app.App.save_screenshot","title":"save_screenshot method
","text":"def save_screenshot(\nself, filename=None, path=\"./\", time_format=None\n):\n
Save an SVG screenshot of the current screen.
Parameters Name Type Description Defaultfilename
str | None
Filename of SVG screenshot, or None to auto-generate a filename with the date and time.
None
path
str
Path to directory for output. Defaults to current working directory.
'./'
time_format
str | None
Date and time format to use if filename is None. Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
None
Returns Type Description str
Filename of screenshot.
"},{"location":"api/app/#textual.app.App.set_focus","title":"set_focusmethod
","text":"def set_focus(self, widget, scroll_visible=True):\n
Focus (or unfocus) a widget. A focused widget will receive key events first.
Parameters Name Type Description Defaultwidget
Widget | None
Widget to focus.
requiredscroll_visible
bool
Scroll widget in to view.
True
"},{"location":"api/app/#textual.app.App.stop_animation","title":"stop_animation async
","text":"def stop_animation(self, attribute, complete=True):\n
Stop an animation on an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute whose animation should be stopped.
requiredcomplete
bool
Should the animation be set to its final value?
True
Note If there is no animation scheduled or running, this is a no-op.
"},{"location":"api/app/#textual.app.App.switch_mode","title":"switch_modemethod
","text":"def switch_mode(self, mode):\n
Switch to a given mode.
Parameters Name Type Description Defaultmode
str
The mode to switch to.
required Raises Type DescriptionUnknownModeError
If trying to switch to an unknown mode.
"},{"location":"api/app/#textual.app.App.switch_screen","title":"switch_screenmethod
","text":"def switch_screen(self, screen):\n
Switch to another screen by replacing the top of the screen stack with a new screen.
Parameters Name Type Description Defaultscreen
Screen | str
Either a Screen object or screen name (the name
argument when installed).
method
","text":"def uninstall_screen(self, screen):\n
Uninstall a screen.
If the screen was not previously installed then this method is a null-op. Uninstalling a screen allows Textual to delete it when it is popped or switched. Note that uninstalling a screen is only required if you have previously installed it with install_screen. Textual will also uninstall screens automatically on exit.
Parameters Name Type Description Defaultscreen
Screen | str
The screen to uninstall or the name of a installed screen.
required Returns Type Descriptionstr | None
The name of the screen that was uninstalled, or None if no screen was uninstalled.
"},{"location":"api/app/#textual.app.App.update_styles","title":"update_stylesmethod
","text":"def update_styles(self, node):\n
Immediately update the styles of this node and all descendant nodes.
Should be called whenever CSS classes / pseudo classes change. For example, when you hover over a button, the :hover pseudo class will be added, and this method is called to apply the corresponding :hover styles.
"},{"location":"api/app/#textual.app.App.validate_sub_title","title":"validate_sub_titlemethod
","text":"def validate_sub_title(self, sub_title):\n
Make sure the sub-title is set to a string.
"},{"location":"api/app/#textual.app.App.validate_title","title":"validate_titlemethod
","text":"def validate_title(self, title):\n
Make sure the title is set to a string.
"},{"location":"api/app/#textual.app.App.watch_dark","title":"watch_darkmethod
","text":"def watch_dark(self, dark):\n
Watches the dark bool.
This method handles the transition between light and dark mode when you change the dark attribute.
"},{"location":"api/app/#textual.app.AppError","title":"AppErrorclass
","text":" Bases: Exception
Base class for general App related exceptions.
"},{"location":"api/app/#textual.app.InvalidModeError","title":"InvalidModeErrorclass
","text":" Bases: ModeError
Raised if there is an issue with a mode name.
"},{"location":"api/app/#textual.app.ModeError","title":"ModeErrorclass
","text":" Bases: Exception
Base class for exceptions related to modes.
"},{"location":"api/app/#textual.app.ScreenError","title":"ScreenErrorclass
","text":" Bases: Exception
Base class for exceptions that relate to screens.
"},{"location":"api/app/#textual.app.ScreenStackError","title":"ScreenStackErrorclass
","text":" Bases: ScreenError
Raised when trying to manipulate the screen stack incorrectly.
"},{"location":"api/app/#textual.app.UnknownModeError","title":"UnknownModeErrorclass
","text":" Bases: ModeError
Raised when attempting to use a mode that is not known.
"},{"location":"api/await_remove/","title":"Await remove","text":"An optionally awaitable object returned by methods that remove widgets.
"},{"location":"api/await_remove/#textual.await_remove.AwaitRemove","title":"AwaitRemoveclass
","text":"def __init__(self, finished_flag, task):\n
An awaitable returned by a method that removes DOM nodes.
Returned by Widget.remove and DOMQuery.remove.
Parameters Name Type Description Defaultfinished_flag
Event
The asyncio event to wait on.
requiredtask
Task
The task which does the remove (required to keep a reference).
required"},{"location":"api/binding/","title":"Binding","text":"A binding maps a key press on to an action.
See bindings in the guide for details.
"},{"location":"api/binding/#textual.binding.Binding","title":"Bindingclass
","text":"The configuration of a key binding.
"},{"location":"api/binding/#textual.binding.Binding.action","title":"actioninstance-attribute
","text":"action: str\n
Action to bind to.
"},{"location":"api/binding/#textual.binding.Binding.description","title":"descriptionclass-attribute
instance-attribute
","text":"description: str = ''\n
Description of action.
"},{"location":"api/binding/#textual.binding.Binding.key","title":"keyinstance-attribute
","text":"key: str\n
Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.
"},{"location":"api/binding/#textual.binding.Binding.key_display","title":"key_displayclass-attribute
instance-attribute
","text":"key_display: str | None = None\n
How the key should be shown in footer.
"},{"location":"api/binding/#textual.binding.Binding.priority","title":"priorityclass-attribute
instance-attribute
","text":"priority: bool = False\n
Enable priority binding for this key.
"},{"location":"api/binding/#textual.binding.Binding.show","title":"showclass-attribute
instance-attribute
","text":"show: bool = True\n
Show the action in Footer, or False to hide.
"},{"location":"api/binding/#textual.binding.BindingError","title":"BindingErrorclass
","text":" Bases: Exception
A binding related error.
"},{"location":"api/binding/#textual.binding.InvalidBinding","title":"InvalidBindingclass
","text":" Bases: Exception
Binding key is in an invalid format.
"},{"location":"api/binding/#textual.binding.NoBinding","title":"NoBindingclass
","text":" Bases: Exception
A binding was not found.
"},{"location":"api/color/","title":"Color","text":"This module contains a powerful Color class which Textual uses to manipulate colors.
"},{"location":"api/color/#textual.color--named-colors","title":"Named colors","text":"The following named colors are used by the parse method.
colors \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Name\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503hex\u00a0\u00a0\u00a0\u00a0\u2503RGB\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Color\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u2502\"aliceblue\"\u2502#F0F8FF\u2502rgb(240,\u00a0248,\u00a0255)\u2502\u2502 \u2502\"ansi_black\"\u2502#000000\u2502rgb(0,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_blue\"\u2502#000080\u2502rgb(0,\u00a00,\u00a0128)\u2502\u2502 \u2502\"ansi_bright_black\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"ansi_bright_blue\"\u2502#0000FF\u2502rgb(0,\u00a00,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_cyan\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_green\"\u2502#00FF00\u2502rgb(0,\u00a0255,\u00a00)\u2502\u2502 \u2502\"ansi_bright_magenta\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_red\"\u2502#FF0000\u2502rgb(255,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_bright_white\"\u2502#FFFFFF\u2502rgb(255,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"ansi_bright_yellow\"\u2502#FFFF00\u2502rgb(255,\u00a0255,\u00a00)\u2502\u2502 \u2502\"ansi_cyan\"\u2502#008080\u2502rgb(0,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"ansi_green\"\u2502#008000\u2502rgb(0,\u00a0128,\u00a00)\u2502\u2502 \u2502\"ansi_magenta\"\u2502#800080\u2502rgb(128,\u00a00,\u00a0128)\u2502\u2502 \u2502\"ansi_red\"\u2502#800000\u2502rgb(128,\u00a00,\u00a00)\u2502\u2502 \u2502\"ansi_white\"\u2502#C0C0C0\u2502rgb(192,\u00a0192,\u00a0192)\u2502\u2502 \u2502\"ansi_yellow\"\u2502#808000\u2502rgb(128,\u00a0128,\u00a00)\u2502\u2502 \u2502\"antiquewhite\"\u2502#FAEBD7\u2502rgb(250,\u00a0235,\u00a0215)\u2502\u2502 \u2502\"aqua\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"aquamarine\"\u2502#7FFFD4\u2502rgb(127,\u00a0255,\u00a0212)\u2502\u2502 \u2502\"azure\"\u2502#F0FFFF\u2502rgb(240,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"beige\"\u2502#F5F5DC\u2502rgb(245,\u00a0245,\u00a0220)\u2502\u2502 \u2502\"bisque\"\u2502#FFE4C4\u2502rgb(255,\u00a0228,\u00a0196)\u2502\u2502 \u2502\"black\"\u2502#000000\u2502rgb(0,\u00a00,\u00a00)\u2502\u2502 \u2502\"blanchedalmond\"\u2502#FFEBCD\u2502rgb(255,\u00a0235,\u00a0205)\u2502\u2502 \u2502\"blue\"\u2502#0000FF\u2502rgb(0,\u00a00,\u00a0255)\u2502\u2502 \u2502\"blueviolet\"\u2502#8A2BE2\u2502rgb(138,\u00a043,\u00a0226)\u2502\u2502 \u2502\"brown\"\u2502#A52A2A\u2502rgb(165,\u00a042,\u00a042)\u2502\u2502 \u2502\"burlywood\"\u2502#DEB887\u2502rgb(222,\u00a0184,\u00a0135)\u2502\u2502 \u2502\"cadetblue\"\u2502#5F9EA0\u2502rgb(95,\u00a0158,\u00a0160)\u2502\u2502 \u2502\"chartreuse\"\u2502#7FFF00\u2502rgb(127,\u00a0255,\u00a00)\u2502\u2502 \u2502\"chocolate\"\u2502#D2691E\u2502rgb(210,\u00a0105,\u00a030)\u2502\u2502 \u2502\"coral\"\u2502#FF7F50\u2502rgb(255,\u00a0127,\u00a080)\u2502\u2502 \u2502\"cornflowerblue\"\u2502#6495ED\u2502rgb(100,\u00a0149,\u00a0237)\u2502\u2502 \u2502\"cornsilk\"\u2502#FFF8DC\u2502rgb(255,\u00a0248,\u00a0220)\u2502\u2502 \u2502\"crimson\"\u2502#DC143C\u2502rgb(220,\u00a020,\u00a060)\u2502\u2502 \u2502\"cyan\"\u2502#00FFFF\u2502rgb(0,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"darkblue\"\u2502#00008B\u2502rgb(0,\u00a00,\u00a0139)\u2502\u2502 \u2502\"darkcyan\"\u2502#008B8B\u2502rgb(0,\u00a0139,\u00a0139)\u2502\u2502 \u2502\"darkgoldenrod\"\u2502#B8860B\u2502rgb(184,\u00a0134,\u00a011)\u2502\u2502 \u2502\"darkgray\"\u2502#A9A9A9\u2502rgb(169,\u00a0169,\u00a0169)\u2502\u2502 \u2502\"darkgreen\"\u2502#006400\u2502rgb(0,\u00a0100,\u00a00)\u2502\u2502 \u2502\"darkgrey\"\u2502#A9A9A9\u2502rgb(169,\u00a0169,\u00a0169)\u2502\u2502 \u2502\"darkkhaki\"\u2502#BDB76B\u2502rgb(189,\u00a0183,\u00a0107)\u2502\u2502 \u2502\"darkmagenta\"\u2502#8B008B\u2502rgb(139,\u00a00,\u00a0139)\u2502\u2502 \u2502\"darkolivegreen\"\u2502#556B2F\u2502rgb(85,\u00a0107,\u00a047)\u2502\u2502 \u2502\"darkorange\"\u2502#FF8C00\u2502rgb(255,\u00a0140,\u00a00)\u2502\u2502 \u2502\"darkorchid\"\u2502#9932CC\u2502rgb(153,\u00a050,\u00a0204)\u2502\u2502 \u2502\"darkred\"\u2502#8B0000\u2502rgb(139,\u00a00,\u00a00)\u2502\u2502 \u2502\"darksalmon\"\u2502#E9967A\u2502rgb(233,\u00a0150,\u00a0122)\u2502\u2502 \u2502\"darkseagreen\"\u2502#8FBC8F\u2502rgb(143,\u00a0188,\u00a0143)\u2502\u2502 \u2502\"darkslateblue\"\u2502#483D8B\u2502rgb(72,\u00a061,\u00a0139)\u2502\u2502 \u2502\"darkslategray\"\u2502#2F4F4F\u2502rgb(47,\u00a079,\u00a079)\u2502\u2502 \u2502\"darkslategrey\"\u2502#2F4F4F\u2502rgb(47,\u00a079,\u00a079)\u2502\u2502 \u2502\"darkturquoise\"\u2502#00CED1\u2502rgb(0,\u00a0206,\u00a0209)\u2502\u2502 \u2502\"darkviolet\"\u2502#9400D3\u2502rgb(148,\u00a00,\u00a0211)\u2502\u2502 \u2502\"deeppink\"\u2502#FF1493\u2502rgb(255,\u00a020,\u00a0147)\u2502\u2502 \u2502\"deepskyblue\"\u2502#00BFFF\u2502rgb(0,\u00a0191,\u00a0255)\u2502\u2502 \u2502\"dimgray\"\u2502#696969\u2502rgb(105,\u00a0105,\u00a0105)\u2502\u2502 \u2502\"dimgrey\"\u2502#696969\u2502rgb(105,\u00a0105,\u00a0105)\u2502\u2502 \u2502\"dodgerblue\"\u2502#1E90FF\u2502rgb(30,\u00a0144,\u00a0255)\u2502\u2502 \u2502\"firebrick\"\u2502#B22222\u2502rgb(178,\u00a034,\u00a034)\u2502\u2502 \u2502\"floralwhite\"\u2502#FFFAF0\u2502rgb(255,\u00a0250,\u00a0240)\u2502\u2502 \u2502\"forestgreen\"\u2502#228B22\u2502rgb(34,\u00a0139,\u00a034)\u2502\u2502 \u2502\"fuchsia\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"gainsboro\"\u2502#DCDCDC\u2502rgb(220,\u00a0220,\u00a0220)\u2502\u2502 \u2502\"ghostwhite\"\u2502#F8F8FF\u2502rgb(248,\u00a0248,\u00a0255)\u2502\u2502 \u2502\"gold\"\u2502#FFD700\u2502rgb(255,\u00a0215,\u00a00)\u2502\u2502 \u2502\"goldenrod\"\u2502#DAA520\u2502rgb(218,\u00a0165,\u00a032)\u2502\u2502 \u2502\"gray\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"green\"\u2502#008000\u2502rgb(0,\u00a0128,\u00a00)\u2502\u2502 \u2502\"greenyellow\"\u2502#ADFF2F\u2502rgb(173,\u00a0255,\u00a047)\u2502\u2502 \u2502\"grey\"\u2502#808080\u2502rgb(128,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"honeydew\"\u2502#F0FFF0\u2502rgb(240,\u00a0255,\u00a0240)\u2502\u2502 \u2502\"hotpink\"\u2502#FF69B4\u2502rgb(255,\u00a0105,\u00a0180)\u2502\u2502 \u2502\"indianred\"\u2502#CD5C5C\u2502rgb(205,\u00a092,\u00a092)\u2502\u2502 \u2502\"indigo\"\u2502#4B0082\u2502rgb(75,\u00a00,\u00a0130)\u2502\u2502 \u2502\"ivory\"\u2502#FFFFF0\u2502rgb(255,\u00a0255,\u00a0240)\u2502\u2502 \u2502\"khaki\"\u2502#F0E68C\u2502rgb(240,\u00a0230,\u00a0140)\u2502\u2502 \u2502\"lavender\"\u2502#E6E6FA\u2502rgb(230,\u00a0230,\u00a0250)\u2502\u2502 \u2502\"lavenderblush\"\u2502#FFF0F5\u2502rgb(255,\u00a0240,\u00a0245)\u2502\u2502 \u2502\"lawngreen\"\u2502#7CFC00\u2502rgb(124,\u00a0252,\u00a00)\u2502\u2502 \u2502\"lemonchiffon\"\u2502#FFFACD\u2502rgb(255,\u00a0250,\u00a0205)\u2502\u2502 \u2502\"lightblue\"\u2502#ADD8E6\u2502rgb(173,\u00a0216,\u00a0230)\u2502\u2502 \u2502\"lightcoral\"\u2502#F08080\u2502rgb(240,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"lightcyan\"\u2502#E0FFFF\u2502rgb(224,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"lightgoldenrodyellow\"\u2502#FAFAD2\u2502rgb(250,\u00a0250,\u00a0210)\u2502\u2502 \u2502\"lightgray\"\u2502#D3D3D3\u2502rgb(211,\u00a0211,\u00a0211)\u2502\u2502 \u2502\"lightgreen\"\u2502#90EE90\u2502rgb(144,\u00a0238,\u00a0144)\u2502\u2502 \u2502\"lightgrey\"\u2502#D3D3D3\u2502rgb(211,\u00a0211,\u00a0211)\u2502\u2502 \u2502\"lightpink\"\u2502#FFB6C1\u2502rgb(255,\u00a0182,\u00a0193)\u2502\u2502 \u2502\"lightsalmon\"\u2502#FFA07A\u2502rgb(255,\u00a0160,\u00a0122)\u2502\u2502 \u2502\"lightseagreen\"\u2502#20B2AA\u2502rgb(32,\u00a0178,\u00a0170)\u2502\u2502 \u2502\"lightskyblue\"\u2502#87CEFA\u2502rgb(135,\u00a0206,\u00a0250)\u2502\u2502 \u2502\"lightslategray\"\u2502#778899\u2502rgb(119,\u00a0136,\u00a0153)\u2502\u2502 \u2502\"lightslategrey\"\u2502#778899\u2502rgb(119,\u00a0136,\u00a0153)\u2502\u2502 \u2502\"lightsteelblue\"\u2502#B0C4DE\u2502rgb(176,\u00a0196,\u00a0222)\u2502\u2502 \u2502\"lightyellow\"\u2502#FFFFE0\u2502rgb(255,\u00a0255,\u00a0224)\u2502\u2502 \u2502\"lime\"\u2502#00FF00\u2502rgb(0,\u00a0255,\u00a00)\u2502\u2502 \u2502\"limegreen\"\u2502#32CD32\u2502rgb(50,\u00a0205,\u00a050)\u2502\u2502 \u2502\"linen\"\u2502#FAF0E6\u2502rgb(250,\u00a0240,\u00a0230)\u2502\u2502 \u2502\"magenta\"\u2502#FF00FF\u2502rgb(255,\u00a00,\u00a0255)\u2502\u2502 \u2502\"maroon\"\u2502#800000\u2502rgb(128,\u00a00,\u00a00)\u2502\u2502 \u2502\"mediumaquamarine\"\u2502#66CDAA\u2502rgb(102,\u00a0205,\u00a0170)\u2502\u2502 \u2502\"mediumblue\"\u2502#0000CD\u2502rgb(0,\u00a00,\u00a0205)\u2502\u2502 \u2502\"mediumorchid\"\u2502#BA55D3\u2502rgb(186,\u00a085,\u00a0211)\u2502\u2502 \u2502\"mediumpurple\"\u2502#9370DB\u2502rgb(147,\u00a0112,\u00a0219)\u2502\u2502 \u2502\"mediumseagreen\"\u2502#3CB371\u2502rgb(60,\u00a0179,\u00a0113)\u2502\u2502 \u2502\"mediumslateblue\"\u2502#7B68EE\u2502rgb(123,\u00a0104,\u00a0238)\u2502\u2502 \u2502\"mediumspringgreen\"\u2502#00FA9A\u2502rgb(0,\u00a0250,\u00a0154)\u2502\u2502 \u2502\"mediumturquoise\"\u2502#48D1CC\u2502rgb(72,\u00a0209,\u00a0204)\u2502\u2502 \u2502\"mediumvioletred\"\u2502#C71585\u2502rgb(199,\u00a021,\u00a0133)\u2502\u2502 \u2502\"midnightblue\"\u2502#191970\u2502rgb(25,\u00a025,\u00a0112)\u2502\u2502 \u2502\"mintcream\"\u2502#F5FFFA\u2502rgb(245,\u00a0255,\u00a0250)\u2502\u2502 \u2502\"mistyrose\"\u2502#FFE4E1\u2502rgb(255,\u00a0228,\u00a0225)\u2502\u2502 \u2502\"moccasin\"\u2502#FFE4B5\u2502rgb(255,\u00a0228,\u00a0181)\u2502\u2502 \u2502\"navajowhite\"\u2502#FFDEAD\u2502rgb(255,\u00a0222,\u00a0173)\u2502\u2502 \u2502\"navy\"\u2502#000080\u2502rgb(0,\u00a00,\u00a0128)\u2502\u2502 \u2502\"oldlace\"\u2502#FDF5E6\u2502rgb(253,\u00a0245,\u00a0230)\u2502\u2502 \u2502\"olive\"\u2502#808000\u2502rgb(128,\u00a0128,\u00a00)\u2502\u2502 \u2502\"olivedrab\"\u2502#6B8E23\u2502rgb(107,\u00a0142,\u00a035)\u2502\u2502 \u2502\"orange\"\u2502#FFA500\u2502rgb(255,\u00a0165,\u00a00)\u2502\u2502 \u2502\"orangered\"\u2502#FF4500\u2502rgb(255,\u00a069,\u00a00)\u2502\u2502 \u2502\"orchid\"\u2502#DA70D6\u2502rgb(218,\u00a0112,\u00a0214)\u2502\u2502 \u2502\"palegoldenrod\"\u2502#EEE8AA\u2502rgb(238,\u00a0232,\u00a0170)\u2502\u2502 \u2502\"palegreen\"\u2502#98FB98\u2502rgb(152,\u00a0251,\u00a0152)\u2502\u2502 \u2502\"paleturquoise\"\u2502#AFEEEE\u2502rgb(175,\u00a0238,\u00a0238)\u2502\u2502 \u2502\"palevioletred\"\u2502#DB7093\u2502rgb(219,\u00a0112,\u00a0147)\u2502\u2502 \u2502\"papayawhip\"\u2502#FFEFD5\u2502rgb(255,\u00a0239,\u00a0213)\u2502\u2502 \u2502\"peachpuff\"\u2502#FFDAB9\u2502rgb(255,\u00a0218,\u00a0185)\u2502\u2502 \u2502\"peru\"\u2502#CD853F\u2502rgb(205,\u00a0133,\u00a063)\u2502\u2502 \u2502\"pink\"\u2502#FFC0CB\u2502rgb(255,\u00a0192,\u00a0203)\u2502\u2502 \u2502\"plum\"\u2502#DDA0DD\u2502rgb(221,\u00a0160,\u00a0221)\u2502\u2502 \u2502\"powderblue\"\u2502#B0E0E6\u2502rgb(176,\u00a0224,\u00a0230)\u2502\u2502 \u2502\"purple\"\u2502#800080\u2502rgb(128,\u00a00,\u00a0128)\u2502\u2502 \u2502\"rebeccapurple\"\u2502#663399\u2502rgb(102,\u00a051,\u00a0153)\u2502\u2502 \u2502\"red\"\u2502#FF0000\u2502rgb(255,\u00a00,\u00a00)\u2502\u2502 \u2502\"rosybrown\"\u2502#BC8F8F\u2502rgb(188,\u00a0143,\u00a0143)\u2502\u2502 \u2502\"royalblue\"\u2502#4169E1\u2502rgb(65,\u00a0105,\u00a0225)\u2502\u2502 \u2502\"saddlebrown\"\u2502#8B4513\u2502rgb(139,\u00a069,\u00a019)\u2502\u2502 \u2502\"salmon\"\u2502#FA8072\u2502rgb(250,\u00a0128,\u00a0114)\u2502\u2502 \u2502\"sandybrown\"\u2502#F4A460\u2502rgb(244,\u00a0164,\u00a096)\u2502\u2502 \u2502\"seagreen\"\u2502#2E8B57\u2502rgb(46,\u00a0139,\u00a087)\u2502\u2502 \u2502\"seashell\"\u2502#FFF5EE\u2502rgb(255,\u00a0245,\u00a0238)\u2502\u2502 \u2502\"sienna\"\u2502#A0522D\u2502rgb(160,\u00a082,\u00a045)\u2502\u2502 \u2502\"silver\"\u2502#C0C0C0\u2502rgb(192,\u00a0192,\u00a0192)\u2502\u2502 \u2502\"skyblue\"\u2502#87CEEB\u2502rgb(135,\u00a0206,\u00a0235)\u2502\u2502 \u2502\"slateblue\"\u2502#6A5ACD\u2502rgb(106,\u00a090,\u00a0205)\u2502\u2502 \u2502\"slategray\"\u2502#708090\u2502rgb(112,\u00a0128,\u00a0144)\u2502\u2502 \u2502\"slategrey\"\u2502#708090\u2502rgb(112,\u00a0128,\u00a0144)\u2502\u2502 \u2502\"snow\"\u2502#FFFAFA\u2502rgb(255,\u00a0250,\u00a0250)\u2502\u2502 \u2502\"springgreen\"\u2502#00FF7F\u2502rgb(0,\u00a0255,\u00a0127)\u2502\u2502 \u2502\"steelblue\"\u2502#4682B4\u2502rgb(70,\u00a0130,\u00a0180)\u2502\u2502 \u2502\"tan\"\u2502#D2B48C\u2502rgb(210,\u00a0180,\u00a0140)\u2502\u2502 \u2502\"teal\"\u2502#008080\u2502rgb(0,\u00a0128,\u00a0128)\u2502\u2502 \u2502\"thistle\"\u2502#D8BFD8\u2502rgb(216,\u00a0191,\u00a0216)\u2502\u2502 \u2502\"tomato\"\u2502#FF6347\u2502rgb(255,\u00a099,\u00a071)\u2502\u2502 \u2502\"turquoise\"\u2502#40E0D0\u2502rgb(64,\u00a0224,\u00a0208)\u2502\u2502 \u2502\"violet\"\u2502#EE82EE\u2502rgb(238,\u00a0130,\u00a0238)\u2502\u2502 \u2502\"wheat\"\u2502#F5DEB3\u2502rgb(245,\u00a0222,\u00a0179)\u2502\u2502 \u2502\"white\"\u2502#FFFFFF\u2502rgb(255,\u00a0255,\u00a0255)\u2502\u2502 \u2502\"whitesmoke\"\u2502#F5F5F5\u2502rgb(245,\u00a0245,\u00a0245)\u2502\u2502 \u2502\"yellow\"\u2502#FFFF00\u2502rgb(255,\u00a0255,\u00a00)\u2502\u2502 \u2502\"yellowgreen\"\u2502#9ACD32\u2502rgb(154,\u00a0205,\u00a050)\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"api/color/#textual.color.BLACK","title":"BLACKmodule-attribute
","text":"BLACK: Final = Color(0, 0, 0)\n
A constant for pure black.
"},{"location":"api/color/#textual.color.WHITE","title":"WHITEmodule-attribute
","text":"WHITE: Final = Color(255, 255, 255)\n
A constant for pure white.
"},{"location":"api/color/#textual.color.Color","title":"Colorclass
","text":" Bases: NamedTuple
A class to represent a color.
Colors are stored as three values representing the degree of red, green, and blue in a color, and a fourth \"alpha\" value which defines where the color lies on a gradient of opaque to transparent.
Example>>> from textual.color import Color\n>>> color = Color.parse(\"red\")\n>>> color\nColor(255, 0, 0)\n>>> color.darken(0.5)\nColor(98, 0, 0)\n>>> color + Color.parse(\"green\")\nColor(0, 128, 0)\n>>> color_with_alpha = Color(100, 50, 25, 0.5)\n>>> color_with_alpha\nColor(100, 50, 25, a=0.5)\n>>> color + color_with_alpha\nColor(177, 25, 12)\n
"},{"location":"api/color/#textual.color.Color.a","title":"a class-attribute
instance-attribute
","text":"a: float = 1.0\n
Alpha (opacity) component in range 0 to 1.
"},{"location":"api/color/#textual.color.Color.b","title":"binstance-attribute
","text":"b: int\n
Blue component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.brightness","title":"brightnessproperty
","text":"brightness: float\n
The human perceptual brightness.
A value of 1 is returned for pure white, and 0 for pure black. Other colors lie on a gradient between the two extremes.
"},{"location":"api/color/#textual.color.Color.clamped","title":"clampedproperty
","text":"clamped: Color\n
A clamped color (this color with all values in expected range).
"},{"location":"api/color/#textual.color.Color.css","title":"cssproperty
","text":"css: str\n
The color in CSS RGB or RGBA form.
For example, \"rgb(10,20,30)\"
for an RGB color, or \"rgb(50,70,80,0.5)\"
for an RGBA color.
instance-attribute
","text":"g: int\n
Green component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.hex","title":"hexproperty
","text":"hex: str\n
The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA.
For example, \"#46b3de\"
for an RGB color, or \"#3342457f\"
for a color with alpha.
property
","text":"hex6: str\n
The color in CSS hex form, with 6 digits for RGB. Alpha is ignored.
For example, \"#46b3de\"
.
property
","text":"hsl: HSL\n
This color in HSL format.
HSL color is an alternative way of representing a color, which can be used in certain color calculations.
Returns Type DescriptionHSL
Color encoded in HSL format.
"},{"location":"api/color/#textual.color.Color.inverse","title":"inverseproperty
","text":"inverse: Color\n
The inverse of this color.
Returns Type DescriptionColor
Inverse color.
"},{"location":"api/color/#textual.color.Color.is_transparent","title":"is_transparentproperty
","text":"is_transparent: bool\n
Is the color transparent (i.e. has 0 alpha)?
"},{"location":"api/color/#textual.color.Color.monochrome","title":"monochromeproperty
","text":"monochrome: Color\n
A monochrome version of this color.
Returns Type DescriptionColor
The monochrome (black and white) version of this color.
"},{"location":"api/color/#textual.color.Color.normalized","title":"normalizedproperty
","text":"normalized: tuple[float, float, float]\n
A tuple of the color components normalized to between 0 and 1.
Returns Type Descriptiontuple[float, float, float]
Normalized components.
"},{"location":"api/color/#textual.color.Color.r","title":"rinstance-attribute
","text":"r: int\n
Red component in range 0 to 255.
"},{"location":"api/color/#textual.color.Color.rgb","title":"rgbproperty
","text":"rgb: tuple[int, int, int]\n
The red, green, and blue color components as a tuple of ints.
"},{"location":"api/color/#textual.color.Color.rich_color","title":"rich_colorproperty
","text":"rich_color: RichColor\n
This color encoded in Rich's Color class.
Returns Type DescriptionRichColor
A color object as used by Rich.
"},{"location":"api/color/#textual.color.Color.blend","title":"blendcached
","text":"def blend(self, destination, factor, alpha=None):\n
Generate a new color between two colors.
This method calculates a new color on a gradient. The position on the gradient is given by factor
, which is a float between 0 and 1, where 0 is the original color, and 1 is the destination
color. A value of gradient
between the two extremes produces a color somewhere between the two end points.
destination
Color
Another color.
requiredfactor
float
A blend factor, 0 -> 1.
requiredalpha
float | None
New alpha for result.
None
Returns Type Description Color
A new color.
"},{"location":"api/color/#textual.color.Color.darken","title":"darkencached
","text":"def darken(self, amount, alpha=None):\n
Darken the color by a given amount.
Parameters Name Type Description Defaultamount
float
Value between 0-1 to reduce luminance by.
requiredalpha
float | None
Alpha component for new color or None to copy alpha.
None
Returns Type Description Color
New color.
"},{"location":"api/color/#textual.color.Color.from_hsl","title":"from_hslclassmethod
","text":"def from_hsl(cls, h, s, l):\n
Create a color from HLS components.
Parameters Name Type Description Defaulth
float
Hue.
requiredl
float
Lightness.
requireds
float
Saturation.
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.Color.from_rich_color","title":"from_rich_colorclassmethod
","text":"def from_rich_color(cls, rich_color):\n
Create a new color from Rich's Color class.
Parameters Name Type Description Defaultrich_color
RichColor
An instance of Rich color.
required Returns Type DescriptionColor
A new Color instance.
"},{"location":"api/color/#textual.color.Color.get_contrast_text","title":"get_contrast_textcached
","text":"def get_contrast_text(self, alpha=0.95):\n
Get a light or dark color that best contrasts this color, for use with text.
Parameters Name Type Description Defaultalpha
float
An alpha value to apply to the result.
0.95
Returns Type Description Color
A new color, either an off-white or off-black.
"},{"location":"api/color/#textual.color.Color.lighten","title":"lightenmethod
","text":"def lighten(self, amount, alpha=None):\n
Lighten the color by a given amount.
Parameters Name Type Description Defaultamount
float
Value between 0-1 to increase luminance by.
requiredalpha
float | None
Alpha component for new color or None to copy alpha.
None
Returns Type Description Color
New color.
"},{"location":"api/color/#textual.color.Color.multiply_alpha","title":"multiply_alphamethod
","text":"def multiply_alpha(self, alpha):\n
Create a new color, multiplying the alpha by a constant.
Parameters Name Type Description Defaultalpha
float
A value to multiple the alpha by (expected to be in the range 0 to 1).
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.Color.parse","title":"parsecached
classmethod
","text":"def parse(cls, color_text):\n
Parse a string containing a named color or CSS-style color.
Colors may be parsed from the following formats:
Text beginning with a #
is parsed as a hexadecimal color code, where R, G, B, and A must be hexadecimal digits (0-9A-F):
#RGB
#RGBA
#RRGGBB
#RRGGBBAA
Alternatively, RGB colors can also be specified in the format that follows, where R, G, and B must be numbers between 0 and 255 and A must be a value between 0 and 1:
rgb(R,G,B)
rgb(R,G,B,A)
The HSL model can also be used, with a syntax similar to the above, if H is a value between 0 and 360, S and L are percentages, and A is a value between 0 and 1:
hsl(H,S,L)
hsla(H,S,L,A)
Any other formats will raise a ColorParseError
.
color_text
str | Color
Text with a valid color format. Color objects will be returned unmodified.
required Raises Type DescriptionColorParseError
If the color is not encoded correctly.
Returns Type DescriptionColor
Instance encoding the color specified by the argument.
"},{"location":"api/color/#textual.color.Color.with_alpha","title":"with_alphamethod
","text":"def with_alpha(self, alpha):\n
Create a new color with the given alpha.
Parameters Name Type Description Defaultalpha
float
New value for alpha.
required Returns Type DescriptionColor
A new color.
"},{"location":"api/color/#textual.color.ColorParseError","title":"ColorParseErrorclass
","text":"def __init__(self, message, suggested_color=None):\n
Bases: Exception
A color failed to parse.
Parameters Name Type Description Defaultmessage
str
The error message
requiredsuggested_color
str | None
A close color we can suggest.
None
"},{"location":"api/color/#textual.color.Gradient","title":"Gradient class
","text":"def __init__(self, *stops):\n
Defines a color gradient.
A gradient is defined by a sequence of \"stops\" consisting of a float and a color. The stop indicate the color at that point on a spectrum between 0 and 1.
Parameters Name Type Description Defaultstops
tuple[float, Color]
A colors stop.
()
Raises Type Description ValueError
If any stops are missing (must be at least a stop for 0 and 1).
"},{"location":"api/color/#textual.color.Gradient.get_color","title":"get_colormethod
","text":"def get_color(self, position):\n
Get a color from the gradient at a position between 0 and 1.
Positions that are between stops will return a blended color.
Parameters Name Type Description Defaultposition
float
A number between 0 and 1, where 0 is the first stop, and 1 is the last.
required Returns Type DescriptionColor
A color.
"},{"location":"api/color/#textual.color.HSL","title":"HSLclass
","text":" Bases: NamedTuple
A color in HLS (Hue, Saturation, Lightness) format.
"},{"location":"api/color/#textual.color.HSL.css","title":"cssproperty
","text":"css: str\n
HSL in css format.
"},{"location":"api/color/#textual.color.HSL.h","title":"hinstance-attribute
","text":"h: float\n
Hue in range 0 to 1.
"},{"location":"api/color/#textual.color.HSL.l","title":"linstance-attribute
","text":"l: float\n
Lightness in range 0 to 1.
"},{"location":"api/color/#textual.color.HSL.s","title":"sinstance-attribute
","text":"s: float\n
Saturation in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV","title":"HSVclass
","text":" Bases: NamedTuple
A color in HSV (Hue, Saturation, Value) format.
"},{"location":"api/color/#textual.color.HSV.h","title":"hinstance-attribute
","text":"h: float\n
Hue in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV.s","title":"sinstance-attribute
","text":"s: float\n
Saturation in range 0 to 1.
"},{"location":"api/color/#textual.color.HSV.v","title":"vinstance-attribute
","text":"v: float\n
Value un range 0 to 1.
"},{"location":"api/color/#textual.color.Lab","title":"Labclass
","text":" Bases: NamedTuple
A color in CIE-L*ab format.
"},{"location":"api/color/#textual.color.Lab.L","title":"Linstance-attribute
","text":"L: float\n
Lightness in range 0 to 100.
"},{"location":"api/color/#textual.color.Lab.a","title":"ainstance-attribute
","text":"a: float\n
A axis in range -127 to 128.
"},{"location":"api/color/#textual.color.Lab.b","title":"binstance-attribute
","text":"b: float\n
B axis in range -127 to 128.
"},{"location":"api/color/#textual.color.lab_to_rgb","title":"lab_to_rgbfunction
","text":"def lab_to_rgb(lab, alpha=1.0):\n
Convert a CIE-L*ab color to RGB.
Uses the standard RGB color space with a D65/2\u2070 standard illuminant. Conversion passes through the XYZ color space. Cf. http://www.easyrgb.com/en/math.php.
"},{"location":"api/color/#textual.color.rgb_to_lab","title":"rgb_to_labfunction
","text":"def rgb_to_lab(rgb):\n
Convert an RGB color to the CIE-L*ab format.
Uses the standard RGB color space with a D65/2\u2070 standard illuminant. Conversion passes through the XYZ color space. Cf. http://www.easyrgb.com/en/math.php.
"},{"location":"api/command/","title":"Command","text":"The Textual command palette.
See the guide on the Command Palette for full details.
"},{"location":"api/command/#textual.command.Hits","title":"Hitsmodule-attribute
","text":"Hits: TypeAlias = AsyncIterator[Hit]\n
Return type for the command provider's search
method.
class
","text":"def __init__(self, prompt, command, id=None, disabled=False):\n
Bases: Option
Class that holds a command in the CommandList
.
prompt
RenderableType
The prompt for the option.
requiredcommand
Hit
The details of the command associated with the option.
requiredid
str | None
The optional ID for the option.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"api/command/#textual.command.Command.command","title":"command instance-attribute
","text":"command = command\n
The details of the command associated with the option.
"},{"location":"api/command/#textual.command.CommandInput","title":"CommandInputclass
","text":" Bases: Input
The command palette input control.
"},{"location":"api/command/#textual.command.CommandList","title":"CommandListclass
","text":" Bases: OptionList
The command palette command list.
"},{"location":"api/command/#textual.command.CommandPalette","title":"CommandPaletteclass
","text":"def __init__(self):\n
Bases: ModalScreen[CallbackType]
The Textual command palette.
"},{"location":"api/command/#textual.command.CommandPalette.BINDINGS","title":"BINDINGSclass-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"ctrl+end, shift+end\",\n\"command_list('last')\",\nshow=False,\n),\nBinding(\n\"ctrl+home, shift+home\",\n\"command_list('first')\",\nshow=False,\n),\nBinding(\"down\", \"cursor_down\", show=False),\nBinding(\"escape\", \"escape\", \"Exit the command palette\"),\nBinding(\n\"pagedown\", \"command_list('page_down')\", show=False\n),\nBinding(\n\"pageup\", \"command_list('page_up')\", show=False\n),\nBinding(\"up\", \"command_list('cursor_up')\", show=False),\n]\n
Key(s) Description ctrl+end, shift+end Jump to the last available commands. ctrl+home, shift+home Jump to the first available commands. down Navigate down through the available commands. escape Exit the command palette. pagedown Navigate down a page through the available commands. pageup Navigate up a page through the available commands. up Navigate up through the available commands."},{"location":"api/command/#textual.command.CommandPalette.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"command-palette--help-text\",\n\"command-palette--highlight\",\n}\n
Class Description command-palette--help-text
Targets the help text of a matched command. command-palette--highlight
Targets the highlights of a matched command."},{"location":"api/command/#textual.command.CommandPalette.run_on_select","title":"run_on_select class-attribute
","text":"run_on_select: bool = True\n
A flag to say if a command should be run when selected by the user.
If True
then when a user hits Enter
on a command match in the result list, or if they click on one with the mouse, the command will be selected and run. If set to False
the input will be filled with the command and then Enter
should be pressed on the keyboard or the 'go' button should be pressed.
staticmethod
","text":"def is_open(app):\n
Is the command palette current open?
Parameters Name Type Description Defaultapp
App
The app to test.
required Returns Type Descriptionbool
True
if the command palette is currently open, False
if not.
method
","text":"def on_mount(self, _):\n
Capture the calling screen.
"},{"location":"api/command/#textual.command.CommandPalette.on_unmount","title":"on_unmountasync
","text":"def on_unmount(self):\n
Shutdown providers when command palette is closed.
"},{"location":"api/command/#textual.command.Hit","title":"Hitclass
","text":"Holds the details of a single command search hit.
"},{"location":"api/command/#textual.command.Hit.command","title":"commandinstance-attribute
","text":"command: IgnoreReturnCallbackType\n
The function to call when the command is chosen.
"},{"location":"api/command/#textual.command.Hit.help","title":"helpclass-attribute
instance-attribute
","text":"help: str | None = None\n
Optional help text for the command.
"},{"location":"api/command/#textual.command.Hit.match_display","title":"match_displayinstance-attribute
","text":"match_display: RenderableType\n
A string or Rich renderable representation of the hit.
"},{"location":"api/command/#textual.command.Hit.score","title":"scoreinstance-attribute
","text":"score: float\n
The score of the command hit.
The value should be between 0 (no match) and 1 (complete match).
"},{"location":"api/command/#textual.command.Hit.text","title":"textclass-attribute
instance-attribute
","text":"text: str | None = None\n
The command text associated with the hit, as plain text.
If match_display
is not simple text, this attribute should be provided by the Provider object.
class
","text":"def __init__(\nself, query, *, match_style=None, case_sensitive=False\n):\n
A fuzzy matcher.
Parameters Name Type Description Defaultquery
str
A query as typed in by the user.
requiredmatch_style
Style | None
The style to use to highlight matched portions of a string.
None
case_sensitive
bool
Should matching be case sensitive?
False
"},{"location":"api/command/#textual.fuzzy.Matcher.case_sensitive","title":"case_sensitive property
","text":"case_sensitive: bool\n
Is this matcher case sensitive?
"},{"location":"api/command/#textual.fuzzy.Matcher.match_style","title":"match_styleproperty
","text":"match_style: Style\n
The style that will be used to highlight hits in the matched text.
"},{"location":"api/command/#textual.fuzzy.Matcher.query","title":"queryproperty
","text":"query: str\n
The query string to look for.
"},{"location":"api/command/#textual.fuzzy.Matcher.query_pattern","title":"query_patternproperty
","text":"query_pattern: str\n
The regular expression pattern built from the query.
"},{"location":"api/command/#textual.fuzzy.Matcher.highlight","title":"highlightmethod
","text":"def highlight(self, candidate):\n
Highlight the candidate with the fuzzy match.
Parameters Name Type Description Defaultcandidate
str
The candidate string to match against the query.
required Returns Type DescriptionText
A [rich.text.Text][Text
] object with highlighted matches.
method
","text":"def match(self, candidate):\n
Match the candidate against the query.
Parameters Name Type Description Defaultcandidate
str
Candidate string to match against the query.
required Returns Type Descriptionfloat
Strength of the match from 0 to 1.
"},{"location":"api/command/#textual.command.Provider","title":"Providerclass
","text":"def __init__(self, screen, match_style=None):\n
Bases: ABC
Base class for command palette command providers.
To create new command provider, inherit from this class and implement search
.
screen
Screen[Any]
A reference to the active screen.
required"},{"location":"api/command/#textual.command.Provider.app","title":"appproperty
","text":"app: App[object]\n
A reference to the application.
"},{"location":"api/command/#textual.command.Provider.focused","title":"focusedproperty
","text":"focused: Widget | None\n
The currently-focused widget in the currently-active screen in the application.
If no widget has focus this will be None
.
property
","text":"match_style: Style | None\n
The preferred style to use when highlighting matching portions of the match_display
.
property
","text":"screen: Screen[object]\n
The currently-active screen in the application.
"},{"location":"api/command/#textual.command.Provider.matcher","title":"matchermethod
","text":"def matcher(self, user_input, case_sensitive=False):\n
Create a fuzzy matcher for the given user input.
Parameters Name Type Description Defaultuser_input
str
The text that the user has input.
requiredcase_sensitive
bool
Should matching be case sensitive?
False
Returns Type Description Matcher
A fuzzy matcher object for matching against candidate hits.
"},{"location":"api/command/#textual.command.Provider.search","title":"searchabstractmethod
async
","text":"def search(self, query):\n
A request to search for commands relevant to the given query.
Parameters Name Type Description Defaultquery
str
The user input to be matched.
requiredYields:
Type DescriptionHits
Instances of Hit
.
async
","text":"def shutdown(self):\n
Called when the Provider is shutdown.
Use this method to perform an cleanup, if required.
"},{"location":"api/command/#textual.command.Provider.startup","title":"startupasync
","text":"def startup(self):\n
Called after the Provider is initialized, but before any calls to search
.
class
","text":" Bases: Static
Widget for displaying a search icon before the command input.
"},{"location":"api/command/#textual.command.SearchIcon.icon","title":"iconclass-attribute
instance-attribute
","text":"icon: var[str] = var(\nEmoji.replace(\":magnifying_glass_tilted_right:\")\n)\n
The icon to display.
"},{"location":"api/containers/","title":"Containers","text":"Container widgets for quick styling.
With the exception of Center
and Middle
containers will fill all of the space in the parent widget.
class
","text":" Bases: Widget
A container which aligns children on the X axis.
"},{"location":"api/containers/#textual.containers.Container","title":"Containerclass
","text":" Bases: Widget
Simple container widget, with vertical layout.
"},{"location":"api/containers/#textual.containers.Grid","title":"Gridclass
","text":" Bases: Widget
A container with grid layout.
"},{"location":"api/containers/#textual.containers.Horizontal","title":"Horizontalclass
","text":" Bases: Widget
A container with horizontal layout and no scrollbars.
"},{"location":"api/containers/#textual.containers.HorizontalScroll","title":"HorizontalScrollclass
","text":" Bases: ScrollableContainer
A container with horizontal layout and an automatic scrollbar on the Y axis.
"},{"location":"api/containers/#textual.containers.Middle","title":"Middleclass
","text":" Bases: Widget
A container which aligns children on the Y axis.
"},{"location":"api/containers/#textual.containers.ScrollableContainer","title":"ScrollableContainerclass
","text":" Bases: Widget
A scrollable container with vertical layout, and auto scrollbars on both axis.
"},{"location":"api/containers/#textual.containers.ScrollableContainer.BINDINGS","title":"BINDINGSclass-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"up\", \"scroll_up\", \"Scroll Up\", show=False),\nBinding(\n\"down\", \"scroll_down\", \"Scroll Down\", show=False\n),\nBinding(\"left\", \"scroll_left\", \"Scroll Up\", show=False),\nBinding(\n\"right\", \"scroll_right\", \"Scroll Right\", show=False\n),\nBinding(\n\"home\", \"scroll_home\", \"Scroll Home\", show=False\n),\nBinding(\"end\", \"scroll_end\", \"Scroll End\", show=False),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\n]\n
Keyboard bindings for scrollable containers.
Key(s) Description up Scroll up, if vertical scrolling is available. down Scroll down, if vertical scrolling is available. left Scroll left, if horizontal scrolling is available. right Scroll right, if horizontal scrolling is available. home Scroll to the home position, if scrolling is available. end Scroll to the end position, if scrolling is available. pageup Scroll up one page, if vertical scrolling is available. pagedown Scroll down one page, if vertical scrolling is available."},{"location":"api/containers/#textual.containers.Vertical","title":"Verticalclass
","text":" Bases: Widget
A container with vertical layout and no scrollbars.
"},{"location":"api/containers/#textual.containers.VerticalScroll","title":"VerticalScrollclass
","text":" Bases: ScrollableContainer
A container with vertical layout and an automatic scrollbar on the Y axis.
"},{"location":"api/content_switcher/","title":"Content switcher","text":""},{"location":"api/content_switcher/#textual.widgets.ContentSwitcher","title":"textual.widgets.ContentSwitcherclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\ninitial=None\n):\n
Bases: Container
A widget for switching between different children.
NoteAll child widgets that are to be switched between need a unique ID. Children that have no ID will be hidden and ignored.
Parameters Name Type Description Default*children
Widget
The widgets to switch between.
()
name
str | None
The name of the content switcher.
None
id
str | None
The ID of the content switcher in the DOM.
None
classes
str | None
The CSS classes of the content switcher.
None
disabled
bool
Whether the content switcher is disabled or not.
False
initial
str | None
The ID of the initial widget to show, None
or empty string for the first tab.
None
Note If initial
is not supplied no children will be shown to start with.
class-attribute
instance-attribute
","text":"current: reactive[str | None] = reactive[Optional[str]](\nNone, init=False\n)\n
The ID of the currently-displayed widget.
If set to None
then no widget is visible.
If set to an unknown ID, this will result in NoMatches
being raised.
property
","text":"visible_content: Widget | None\n
A reference to the currently-visible widget.
None
if nothing is visible.
method
","text":"def watch_current(self, old, new):\n
React to the current visible child choice being changed.
Parameters Name Type Description Defaultold
str | None
The old widget ID (or None
if there was no widget).
new
str | None
The new widget ID (or None
if nothing should be shown).
A class to store a coordinate, used by the DataTable.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate","title":"Coordinateclass
","text":" Bases: NamedTuple
An object representing a row/column coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.column","title":"columninstance-attribute
","text":"column: int\n
The column of the coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.row","title":"rowinstance-attribute
","text":"row: int\n
The row of the coordinate within a grid.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.down","title":"downmethod
","text":"def down(self):\n
Get the coordinate below.
Returns Type DescriptionCoordinate
The coordinate below.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.left","title":"leftmethod
","text":"def left(self):\n
Get the coordinate to the left.
Returns Type DescriptionCoordinate
The coordinate to the left.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.right","title":"rightmethod
","text":"def right(self):\n
Get the coordinate to the right.
Returns Type DescriptionCoordinate
The coordinate to the right.
"},{"location":"api/coordinate/#textual.coordinate.Coordinate.up","title":"upmethod
","text":"def up(self):\n
Get the coordinate above.
Returns Type DescriptionCoordinate
The coordinate above.
"},{"location":"api/dom_node/","title":"Dom node","text":"A DOMNode is a base class for any object within the Textual Document Object Model, which includes all Widgets, Screens, and Apps.
"},{"location":"api/dom_node/#textual.dom.WalkMethod","title":"WalkMethodmodule-attribute
","text":"WalkMethod: TypeAlias = Literal['depth', 'breadth']\n
Valid walking methods for the DOMNode.walk_children
method.
class
","text":" Bases: Exception
Exception raised if you supply a id
attribute or class name in the wrong format.
class
","text":" Bases: Exception
Base exception class for errors relating to the DOM.
"},{"location":"api/dom_node/#textual.dom.DOMNode","title":"DOMNodeclass
","text":"def __init__(self, *, name=None, id=None, classes=None):\n
Bases: MessagePump
The base class for object that can be in the Textual DOM (App and Widget)
"},{"location":"api/dom_node/#textual.dom.DOMNode.SCOPED_CSS","title":"SCOPED_CSSclass-attribute
","text":"SCOPED_CSS: bool = True\n
Should default css be limited to the widget type?
"},{"location":"api/dom_node/#textual.dom.DOMNode.ancestors","title":"ancestorsproperty
","text":"ancestors: list[DOMNode]\n
A list of ancestor nodes Nodes by tracing ancestors all the way back to App.
Returns Type Descriptionlist[DOMNode]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.ancestors_with_self","title":"ancestors_with_selfproperty
","text":"ancestors_with_self: list[DOMNode]\n
A list of Nodes by tracing a path all the way back to App.
NoteThis is inclusive of self
.
list[DOMNode]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.auto_refresh","title":"auto_refreshwritable
property
","text":"auto_refresh: float | None\n
Number of seconds between automatic refresh, or None
for no automatic refresh.
property
","text":"background_colors: tuple[Color, Color]\n
The background color and the color of the parent's background.
Returns Type Descriptiontuple[Color, Color]
(<background color>, <color>)
property
","text":"children: Sequence['Widget']\n
A view on to the children.
Returns Type DescriptionSequence['Widget']
The node's children.
"},{"location":"api/dom_node/#textual.dom.DOMNode.classes","title":"classesclass-attribute
instance-attribute
","text":"classes = _ClassesDescriptor()\n
CSS class names for this node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.colors","title":"colorsproperty
","text":"colors: tuple[Color, Color, Color, Color]\n
The widget's background and foreground colors, and the parent's background and foreground colors.
Returns Type Descriptiontuple[Color, Color, Color, Color]
(<parent background>, <parent color>, <background>, <color>)
property
","text":"css_identifier: str\n
A CSS selector that identifies this DOM node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_identifier_styled","title":"css_identifier_styledproperty
","text":"css_identifier_styled: Text\n
A syntax highlighted CSS identifier.
Returns Type DescriptionText
A Rich Text object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_path_nodes","title":"css_path_nodesproperty
","text":"css_path_nodes: list[DOMNode]\n
A list of nodes from the App to this node, forming a \"path\".
Returns Type Descriptionlist[DOMNode]
A list of nodes, where the first item is the App, and the last is this node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.css_tree","title":"css_treeproperty
","text":"css_tree: Tree\n
A Rich tree to display the DOM, annotated with the node's CSS.
Log this to visualize your app in the textual console.
Exampleself.log(self.css_tree)\n
Returns Type Description Tree
A Tree renderable.
"},{"location":"api/dom_node/#textual.dom.DOMNode.display","title":"displaywritable
property
","text":"display: bool\n
Should the DOM node be displayed?
May be set to a boolean to show or hide the node, or to any valid value for the display
rule.
my_widget.display = False # Hide my_widget\n
"},{"location":"api/dom_node/#textual.dom.DOMNode.displayed_children","title":"displayed_children property
","text":"displayed_children: list[Widget]\n
The child nodes which will be displayed.
Returns Type Descriptionlist[Widget]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.id","title":"idwritable
property
","text":"id: str | None\n
The ID of this node, or None if the node has no ID.
"},{"location":"api/dom_node/#textual.dom.DOMNode.is_modal","title":"is_modalproperty
","text":"is_modal: bool\n
Is the node a modal?
"},{"location":"api/dom_node/#textual.dom.DOMNode.name","title":"nameproperty
","text":"name: str | None\n
The name of the node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.parent","title":"parentproperty
","text":"parent: DOMNode | None\n
The parent node.
All nodes have parent once added to the DOM, with the exception of the App which is the root node.
"},{"location":"api/dom_node/#textual.dom.DOMNode.pseudo_classes","title":"pseudo_classesproperty
","text":"pseudo_classes: frozenset[str]\n
A (frozen) set of all pseudo classes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.rich_style","title":"rich_styleproperty
","text":"rich_style: Style\n
Get a Rich Style object for this DOMNode.
Returns Type DescriptionStyle
A Rich style.
"},{"location":"api/dom_node/#textual.dom.DOMNode.screen","title":"screenproperty
","text":"screen: 'Screen[object]'\n
The screen containing this node.
Returns Type Description'Screen[object]'
A screen object.
Raises Type DescriptionNoScreen
If this node isn't mounted (and has no screen).
"},{"location":"api/dom_node/#textual.dom.DOMNode.text_style","title":"text_styleproperty
","text":"text_style: Style\n
Get the text style object.
A widget's style is influenced by its parent. for instance if a parent is bold, then the child will also be bold.
Returns Type DescriptionStyle
A Rich Style.
"},{"location":"api/dom_node/#textual.dom.DOMNode.tree","title":"treeproperty
","text":"tree: Tree\n
A Rich tree to display the DOM.
Log this to visualize your app in the textual console.
Exampleself.log(self.tree)\n
Returns Type Description Tree
A Tree renderable.
"},{"location":"api/dom_node/#textual.dom.DOMNode.visible","title":"visiblewritable
property
","text":"visible: bool\n
Is this widget visible in the DOM?
If a widget hasn't had its visibility set explicitly, then it inherits it from its DOM ancestors.
This may be set explicitly to override inherited values. The valid values include the valid values for the visibility
rule and the booleans True
or False
, to set the widget to be visible or invisible, respectively.
When a node is invisible, Textual will reserve space for it, but won't display anything.
"},{"location":"api/dom_node/#textual.dom.DOMNode.workers","title":"workersproperty
","text":"workers: WorkerManager\n
The app's worker manager. Shortcut for self.app.workers
.
method
","text":"def add_class(self, *class_names):\n
Add class names to this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to add.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.get_component_styles","title":"get_component_stylesmethod
","text":"def get_component_styles(self, name):\n
Get a \"component\" styles object (must be defined in COMPONENT_CLASSES classvar).
Parameters Name Type Description Defaultname
str
Name of the component.
required Raises Type DescriptionKeyError
If the component class doesn't exist.
Returns Type DescriptionRenderStyles
A Styles object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.get_pseudo_classes","title":"get_pseudo_classesmethod
","text":"def get_pseudo_classes(self):\n
Get any pseudo classes applicable to this Node, e.g. hover, focus.
Returns Type DescriptionIterable[str]
Iterable of strings, such as a generator.
"},{"location":"api/dom_node/#textual.dom.DOMNode.has_class","title":"has_classmethod
","text":"def has_class(self, *class_names):\n
Check if the Node has all the given class names.
Parameters Name Type Description Default*class_names
str
CSS class names to check.
()
Returns Type Description bool
True
if the node has all the given class names, otherwise False
.
method
","text":"def has_pseudo_class(self, *class_names):\n
Check for pseudo classes (such as hover, focus etc)
Parameters Name Type Description Default*class_names
str
The pseudo classes to check for.
()
Returns Type Description bool
True
if the DOM node has those pseudo classes, False
if not.
method
","text":"def notify_style_update(self):\n
Called after styles are updated.
Implement this in a subclass if you want to clear any cached data when the CSS is reloaded.
"},{"location":"api/dom_node/#textual.dom.DOMNode.query","title":"querymethod
","text":"def query(self, selector=None):\n
Get a DOM query matching a selector.
Parameters Name Type Description Defaultselector
str | type[QueryType] | None
A CSS selector or None
for all nodes.
None
Returns Type Description DOMQuery[Widget] | DOMQuery[QueryType]
A query object.
"},{"location":"api/dom_node/#textual.dom.DOMNode.query_one","title":"query_onemethod
","text":"def query_one(self, selector, expect_type=None):\n
Get a single Widget matching the given selector or selector type.
Parameters Name Type Description Defaultselector
str | type[QueryType]
A selector.
requiredexpect_type
type[QueryType] | None
Require the object be of the supplied type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If no node matches the query.
TooManyMatches
If there is more than one matching node in the query.
Returns Type DescriptionQueryType | Widget
A widget matching the selector.
"},{"location":"api/dom_node/#textual.dom.DOMNode.remove_class","title":"remove_classmethod
","text":"def remove_class(self, *class_names):\n
Remove class names from this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to remove.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.reset_styles","title":"reset_stylesmethod
","text":"def reset_styles(self):\n
Reset styles back to their initial state.
"},{"location":"api/dom_node/#textual.dom.DOMNode.run_worker","title":"run_workermethod
","text":"def run_worker(\nself,\nwork,\nname=\"\",\ngroup=\"default\",\ndescription=\"\",\nexit_on_error=True,\nstart=True,\nexclusive=False,\nthread=False,\n):\n
Run work in a worker.
A worker runs a function, coroutine, or awaitable, in the background as an async task or as a thread.
Parameters Name Type Description Defaultwork
WorkType[ResultType]
A function, async function, or an awaitable object to run in a worker.
requiredname
str | None
A short string to identify the worker (in logs and debugging).
''
group
str
A short string to identify a group of workers.
'default'
description
str
A longer string to store longer information on the worker.
''
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
start
bool
Start the worker immediately.
True
exclusive
bool
Cancel all workers in the same group.
False
thread
bool
Mark the worker as a thread worker.
False
Returns Type Description Worker[ResultType]
New Worker instance.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_class","title":"set_classmethod
","text":"def set_class(self, add, *class_names):\n
Add or remove class(es) based on a condition.
Parameters Name Type Description Defaultadd
bool
Add the classes if True, otherwise remove them.
required Returns Type DescriptionSelf
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_classes","title":"set_classesmethod
","text":"def set_classes(self, classes):\n
Replace all classes.
Parameters Name Type Description Defaultclasses
str | Iterable[str]
A string containing space separated classes, or an iterable of class names.
required Returns Type DescriptionSelf
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.set_styles","title":"set_stylesmethod
","text":"def set_styles(self, css=None, **update_styles):\n
Set custom styles on this object.
Parameters Name Type Description Defaultcss
str | None
Styles in CSS format.
None
**update_styles
Keyword arguments map style names on to style.
{}
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.toggle_class","title":"toggle_classmethod
","text":"def toggle_class(self, *class_names):\n
Toggle class names on this Node.
Parameters Name Type Description Default*class_names
str
CSS class names to toggle.
()
Returns Type Description Self
Self.
"},{"location":"api/dom_node/#textual.dom.DOMNode.walk_children","title":"walk_childrenmethod
","text":"def walk_children(\nself,\nfilter_type=None,\n*,\nwith_self=False,\nmethod=\"depth\",\nreverse=False\n):\n
Walk the subtree rooted at this node, and return every descendant encountered in a list.
Parameters Name Type Description Defaultfilter_type
type[WalkType] | None
Filter only this type, or None for no filter.
None
with_self
bool
Also yield self in addition to descendants.
False
method
WalkMethod
One of \"depth\" or \"breadth\".
'depth'
reverse
bool
Reverse the order (bottom up).
False
Returns Type Description list[DOMNode] | list[WalkType]
A list of nodes.
"},{"location":"api/dom_node/#textual.dom.DOMNode.watch","title":"watchmethod
","text":"def watch(self, obj, attribute_name, callback, init=True):\n
Watches for modifications to reactive attributes on another object.
ExampleHere's how you could detect when the app changes from dark to light mode (and vice versa).
def on_dark_change(old_value:bool, new_value:bool):\n# Called when app.dark changes.\nprint(\"App.dark when from {old_value} to {new_value}\")\nself.watch(self.app, \"dark\", self.on_dark_change, init=False)\n
Parameters Name Type Description Default obj
DOMNode
Object containing attribute to watch.
requiredattribute_name
str
Attribute to watch.
requiredcallback
WatchCallbackType
A callback to run when attribute changes.
requiredinit
bool
Check watchers on first call.
True
"},{"location":"api/dom_node/#textual.dom.NoScreen","title":"NoScreen class
","text":" Bases: DOMError
Raised when the node has no associated screen.
"},{"location":"api/dom_node/#textual.dom.check_identifiers","title":"check_identifiersfunction
","text":"def check_identifiers(description, *names):\n
Validate identifier and raise an error if it fails.
Parameters Name Type Description Defaultdescription
str
Description of where identifier is used for error message.
required*names
str
Identifiers to check.
()
"},{"location":"api/errors/","title":"Errors","text":"General exception classes.
"},{"location":"api/errors/#textual.errors.DuplicateKeyHandlers","title":"DuplicateKeyHandlersclass
","text":" Bases: TextualError
More than one handler for a single key press.
For example, if the handlers key_ctrl_i
and key_tab
were defined on the same widget, then this error would be raised.
class
","text":" Bases: TextualError
Specified widget was not found.
"},{"location":"api/errors/#textual.errors.RenderError","title":"RenderErrorclass
","text":" Bases: TextualError
An object could not be rendered.
"},{"location":"api/errors/#textual.errors.TextualError","title":"TextualErrorclass
","text":" Bases: Exception
Base class for Textual errors.
"},{"location":"api/events/","title":"Events","text":"Builtin events sent by Textual.
Events may be marked as \"Bubbles\" and \"Verbose\". See the events guide for an explanation of bubbling. Verbose events are excluded from the textual console, unless you explicitly request them with the -v
switch as follows:
textual console -v\n
"},{"location":"api/events/#textual.events.Blur","title":"Blur class
","text":" Bases: Event
Sent when a widget is blurred (un-focussed).
class
","text":" Bases: MouseEvent
Sent when a widget is clicked.
class
","text":" Bases: Event
Sent to a widget to request it to compose and mount children.
class
","text":" Bases: Event
Sent when a child widget is blurred.
property
","text":"control: Widget\n
The widget that was blurred (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was blurred.
"},{"location":"api/events/#textual.events.DescendantFocus","title":"DescendantFocusclass
","text":" Bases: Event
Sent when a child widget is focussed.
property
","text":"control: Widget\n
The widget that was focused (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was focused.
"},{"location":"api/events/#textual.events.Enter","title":"Enterclass
","text":" Bases: Event
Sent when the mouse is moved over a widget.
class
","text":" Bases: Message
The base class for all events.
"},{"location":"api/events/#textual.events.Focus","title":"Focusclass
","text":" Bases: Event
Sent when a widget is focussed.
class
","text":" Bases: Event
Sent when a widget has been hidden.
A widget may be hidden by setting its visible
flag to False
, if it is no longer in a layout, or if it has been offset beyond the edges of the terminal.
class
","text":" Bases: Event
Sent when there are no more items in the message queue.
This is a pseudo-event in that it is created by the Textual system and doesn't go through the usual message queue.
class
","text":" Bases: Event
Base class for input events.
"},{"location":"api/events/#textual.events.Key","title":"Keyclass
","text":"def __init__(self, key, character):\n
Bases: InputEvent
Sent when the user hits a key on the keyboard.
key
str
The key that was pressed.
requiredcharacter
str | None
A printable character or None
if it is not printable.
aliases
list[str]
The aliases for the key, including the key itself.
"},{"location":"api/events/#textual.events.Key.is_printable","title":"is_printableproperty
","text":"is_printable: bool\n
Check if the key is printable (produces a unicode character).
Returns Type Descriptionbool
True if the key is printable.
"},{"location":"api/events/#textual.events.Key.name","title":"nameproperty
","text":"name: str\n
Name of a key suitable for use as a Python identifier.
"},{"location":"api/events/#textual.events.Key.name_aliases","title":"name_aliasesproperty
","text":"name_aliases: list[str]\n
The corresponding name for every alias in aliases
list.
class
","text":" Bases: Event
Sent when the mouse is moved away from a widget.
class
","text":" Bases: Event
Sent when the App is running but before the terminal is in application mode.
Use this event to run any set up that doesn't require any visuals such as loading configuration and binding keys.
class
","text":" Bases: Event
Sent when a widget is mounted and may receive messages.
class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Sent when the mouse has been captured.
When a mouse has been captured, all further mouse events will be sent to the capturing widget.
Parameters Name Type Description Defaultmouse_position
Offset
The position of the mouse when captured.
required"},{"location":"api/events/#textual.events.MouseDown","title":"MouseDownclass
","text":" Bases: MouseEvent
Sent when a mouse button is pressed.
class
","text":"def __init__(\nself,\nx,\ny,\ndelta_x,\ndelta_y,\nbutton,\nshift,\nmeta,\nctrl,\nscreen_x=None,\nscreen_y=None,\nstyle=None,\n):\n
Bases: InputEvent
Sent in response to a mouse event.
x
int
The relative x coordinate.
requiredy
int
The relative y coordinate.
requireddelta_x
int
Change in x since the last message.
requireddelta_y
int
Change in y since the last message.
requiredbutton
int
Indexed of the pressed button.
requiredshift
bool
True if the shift key is pressed.
requiredmeta
bool
True if the meta key is pressed.
requiredctrl
bool
True if the ctrl key is pressed.
requiredscreen_x
int | None
The absolute x coordinate.
None
screen_y
int | None
The absolute y coordinate.
None
style
Style | None
The Rich Style under the mouse cursor.
None
"},{"location":"api/events/#textual.events.MouseEvent.delta","title":"delta property
","text":"delta: Offset\n
Mouse coordinate delta (change since last event).
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.offset","title":"offsetproperty
","text":"offset: Offset\n
The mouse coordinate as an offset.
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.screen_offset","title":"screen_offsetproperty
","text":"screen_offset: Offset\n
Mouse coordinate relative to the screen.
Returns Type DescriptionOffset
Mouse coordinate.
"},{"location":"api/events/#textual.events.MouseEvent.style","title":"stylewritable
property
","text":"style: Style\n
The (Rich) Style under the cursor.
"},{"location":"api/events/#textual.events.MouseEvent.get_content_offset","title":"get_content_offsetmethod
","text":"def get_content_offset(self, widget):\n
Get offset within a widget's content area, or None if offset is not in content (i.e. padding or border).
Parameters Name Type Description Defaultwidget
Widget
Widget receiving the event.
required Returns Type DescriptionOffset | None
An offset where the origin is at the top left of the content area.
"},{"location":"api/events/#textual.events.MouseEvent.get_content_offset_capture","title":"get_content_offset_capturemethod
","text":"def get_content_offset_capture(self, widget):\n
Get offset from a widget's content area.
This method works even if the offset is outside the widget content region.
Parameters Name Type Description Defaultwidget
Widget
Widget receiving the event.
required Returns Type DescriptionOffset
An offset where the origin is at the top left of the content area.
"},{"location":"api/events/#textual.events.MouseMove","title":"MouseMoveclass
","text":" Bases: MouseEvent
Sent when the mouse cursor moves.
class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Mouse has been released.
mouse_position
Offset
The position of the mouse when released.
required"},{"location":"api/events/#textual.events.MouseScrollDown","title":"MouseScrollDownclass
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled down.
class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled up.
class
","text":" Bases: MouseEvent
Sent when a mouse button is released.
class
","text":"def __init__(self, text):\n
Bases: Event
Event containing text that was pasted into the Textual application. This event will only appear when running in a terminal emulator that supports bracketed paste mode. Textual will enable bracketed pastes when an app starts, and disable it when the app shuts down.
text
str
The text that has been pasted.
required"},{"location":"api/events/#textual.events.Print","title":"Printclass
","text":"def __init__(self, text, stderr=False):\n
Bases: Event
Sent to a widget that is capturing prints.
text
str
Text that was printed.
requiredstderr
bool
True if the print was to stderr, or False for stdout.
False
"},{"location":"api/events/#textual.events.Ready","title":"Ready class
","text":" Bases: Event
Sent to the app when the DOM is ready.
class
","text":"def __init__(self, size, virtual_size, container_size=None):\n
Bases: Event
Sent when the app or widget has been resized.
size
Size
The new size of the Widget.
requiredvirtual_size
Size
The virtual size (scrollable size) of the Widget.
requiredcontainer_size
Size | None
The size of the Widget's container widget.
None
"},{"location":"api/events/#textual.events.ScreenResume","title":"ScreenResume class
","text":" Bases: Event
Sent to screen that has been made active.
class
","text":" Bases: Event
Sent to screen when it is no longer active.
class
","text":" Bases: Event
Sent when a widget has become visible.
class
","text":"def __init__(self, timer, time, count=0, callback=None):\n
Bases: Event
Sent in response to a timer.
class
","text":" Bases: Event
Sent when a widget is unmounted and may not longer receive messages.
Filter classes.
Note
Filters are used internally, and not recommended for use by Textual app developers.
Filters are used internally to process terminal output after it has been rendered. Currently this is used internally to convert the application to monochrome, when the NO_COLOR env var is set.
In the future, this system will be used to implement accessibility features.
"},{"location":"api/filter/#textual.filter.NO_DIM","title":"NO_DIMmodule-attribute
","text":"NO_DIM = Style(dim=False)\n
A Style to set dim to False.
"},{"location":"api/filter/#textual.filter.ANSIToTruecolor","title":"ANSIToTruecolorclass
","text":"def __init__(self, terminal_theme):\n
Bases: LineFilter
Convert ANSI colors to their truecolor equivalents.
Parameters Name Type Description Defaultterminal_theme
TerminalTheme
A rich terminal theme.
required"},{"location":"api/filter/#textual.filter.ANSIToTruecolor.apply","title":"applymethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.ANSIToTruecolor.truecolor_style","title":"truecolor_stylecached
","text":"def truecolor_style(self, style):\n
Replace system colors with truecolor equivalent.
Parameters Name Type Description Defaultstyle
Style
Style to apply truecolor filter to.
required Returns Type DescriptionStyle
New style.
"},{"location":"api/filter/#textual.filter.DimFilter","title":"DimFilterclass
","text":"def __init__(self, dim_factor=0.5):\n
Bases: LineFilter
Replace dim attributes with modified colors.
Parameters Name Type Description Defaultdim_factor
float
The factor to dim by; 0 is 100% background (i.e. invisible), 1.0 is no change.
0.5
"},{"location":"api/filter/#textual.filter.DimFilter.apply","title":"apply method
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.LineFilter","title":"LineFilterclass
","text":" Bases: ABC
Base class for a line filter.
"},{"location":"api/filter/#textual.filter.LineFilter.apply","title":"applyabstractmethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.Monochrome","title":"Monochromeclass
","text":" Bases: LineFilter
Convert all colors to monochrome.
"},{"location":"api/filter/#textual.filter.Monochrome.apply","title":"applymethod
","text":"def apply(self, segments, background):\n
Transform a list of segments.
Parameters Name Type Description Defaultsegments
list[Segment]
A list of segments.
requiredbackground
Color
The background color.
required Returns Type Descriptionlist[Segment]
A new list of segments.
"},{"location":"api/filter/#textual.filter.dim_color","title":"dim_colorcached
","text":"def dim_color(background, color, factor):\n
Dim a color by blending towards the background
Parameters Name Type Description Defaultbackground
RichColor
background color.
requiredcolor
RichColor
Foreground color.
requiredfactor
float
Blend factor
required Returns Type DescriptionRichColor
New dimmer color.
"},{"location":"api/filter/#textual.filter.dim_style","title":"dim_stylecached
","text":"def dim_style(style, background, factor):\n
Replace dim attribute with a dim color.
Parameters Name Type Description Defaultstyle
Style
Style to dim.
requiredfactor
float
Blend factor.
required Returns Type DescriptionStyle
New dimmed style.
"},{"location":"api/filter/#textual.filter.monochrome_style","title":"monochrome_stylecached
","text":"def monochrome_style(style):\n
Convert colors in a style to monochrome.
Parameters Name Type Description Defaultstyle
Style
A Rich Style.
required Returns Type DescriptionStyle
A new Rich style.
"},{"location":"api/fuzzy_matcher/","title":"Fuzzy matcher","text":"Fuzzy matcher.
This class is used by the command palette to match search terms.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher","title":"Matcherclass
","text":"def __init__(\nself, query, *, match_style=None, case_sensitive=False\n):\n
A fuzzy matcher.
Parameters Name Type Description Defaultquery
str
A query as typed in by the user.
requiredmatch_style
Style | None
The style to use to highlight matched portions of a string.
None
case_sensitive
bool
Should matching be case sensitive?
False
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.case_sensitive","title":"case_sensitive property
","text":"case_sensitive: bool\n
Is this matcher case sensitive?
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.match_style","title":"match_styleproperty
","text":"match_style: Style\n
The style that will be used to highlight hits in the matched text.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.query","title":"queryproperty
","text":"query: str\n
The query string to look for.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.query_pattern","title":"query_patternproperty
","text":"query_pattern: str\n
The regular expression pattern built from the query.
"},{"location":"api/fuzzy_matcher/#textual.fuzzy.Matcher.highlight","title":"highlightmethod
","text":"def highlight(self, candidate):\n
Highlight the candidate with the fuzzy match.
Parameters Name Type Description Defaultcandidate
str
The candidate string to match against the query.
required Returns Type DescriptionText
A [rich.text.Text][Text
] object with highlighted matches.
method
","text":"def match(self, candidate):\n
Match the candidate against the query.
Parameters Name Type Description Defaultcandidate
str
Candidate string to match against the query.
required Returns Type Descriptionfloat
Strength of the match from 0 to 1.
"},{"location":"api/geometry/","title":"Geometry","text":"Functions and classes to manage terminal geometry (anything involving coordinates or dimensions).
"},{"location":"api/geometry/#textual.geometry.NULL_OFFSET","title":"NULL_OFFSETmodule-attribute
","text":"NULL_OFFSET: Final = Offset(0, 0)\n
An offset constant for (0, 0).
"},{"location":"api/geometry/#textual.geometry.NULL_REGION","title":"NULL_REGIONmodule-attribute
","text":"NULL_REGION: Final = Region(0, 0, 0, 0)\n
A Region constant for a null region (at the origin, with both width and height set to zero).
"},{"location":"api/geometry/#textual.geometry.NULL_SPACING","title":"NULL_SPACINGmodule-attribute
","text":"NULL_SPACING: Final = Spacing(0, 0, 0, 0)\n
A Spacing constant for no space.
"},{"location":"api/geometry/#textual.geometry.SpacingDimensions","title":"SpacingDimensionsmodule-attribute
","text":"SpacingDimensions: TypeAlias = Union[\nint,\nTuple[int],\nTuple[int, int],\nTuple[int, int, int, int],\n]\n
The valid ways in which you can specify spacing.
"},{"location":"api/geometry/#textual.geometry.Offset","title":"Offsetclass
","text":" Bases: NamedTuple
A cell offset defined by x and y coordinates.
Offsets are typically relative to the top left of the terminal or other container.
Textual prefers the names x
and y
, but you could consider x
to be the column and y
to be the row.
Offsets support addition, subtraction, multiplication, and negation.
Example>>> from textual.geometry import Offset\n>>> offset = Offset(3, 2)\n>>> offset\nOffset(x=3, y=2)\n>>> offset += Offset(10, 0)\n>>> offset\nOffset(x=13, y=2)\n>>> -offset\nOffset(x=-13, y=-2)\n
"},{"location":"api/geometry/#textual.geometry.Offset.clamped","title":"clamped property
","text":"clamped: Offset\n
This offset with x
and y
restricted to values above zero.
property
","text":"is_origin: bool\n
Is the offset at (0, 0)?
"},{"location":"api/geometry/#textual.geometry.Offset.x","title":"xclass-attribute
instance-attribute
","text":"x: int = 0\n
Offset in the x-axis (horizontal)
"},{"location":"api/geometry/#textual.geometry.Offset.y","title":"yclass-attribute
instance-attribute
","text":"y: int = 0\n
Offset in the y-axis (vertical)
"},{"location":"api/geometry/#textual.geometry.Offset.blend","title":"blendmethod
","text":"def blend(self, destination, factor):\n
Calculate a new offset on a line between this offset and a destination offset.
Parameters Name Type Description Defaultdestination
Offset
Point where factor would be 1.0.
requiredfactor
float
A value between 0 and 1.0.
required Returns Type DescriptionOffset
A new point on a line between self and destination.
"},{"location":"api/geometry/#textual.geometry.Offset.get_distance_to","title":"get_distance_tomethod
","text":"def get_distance_to(self, other):\n
Get the distance to another offset.
Parameters Name Type Description Defaultother
Offset
An offset.
required Returns Type Descriptionfloat
Distance to other offset.
"},{"location":"api/geometry/#textual.geometry.Region","title":"Regionclass
","text":" Bases: NamedTuple
Defines a rectangular region.
A Region consists of a coordinate (x and y) and dimensions (width and height).
(x, y)\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u25b2\n \u2502 \u2502 \u2502\n \u2502 \u2502 \u2502\n \u2502 \u2502 height\n \u2502 \u2502 \u2502\n \u2502 \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u25bc\n \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500 width \u2500\u2500\u2500\u2500\u2500\u2500\u25b6\n
Example >>> from textual.geometry import Region\n>>> region = Region(4, 5, 20, 10)\n>>> region\nRegion(x=4, y=5, width=20, height=10)\n>>> region.area\n200\n>>> region.size\nSize(width=20, height=10)\n>>> region.offset\nOffset(x=4, y=5)\n>>> region.contains(1, 2)\nFalse\n>>> region.contains(10, 8)\nTrue\n
"},{"location":"api/geometry/#textual.geometry.Region.area","title":"area property
","text":"area: int\n
The area under the region.
"},{"location":"api/geometry/#textual.geometry.Region.bottom","title":"bottomproperty
","text":"bottom: int\n
Maximum Y value (non inclusive).
"},{"location":"api/geometry/#textual.geometry.Region.bottom_left","title":"bottom_leftproperty
","text":"bottom_left: Offset\n
Bottom left offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.bottom_right","title":"bottom_rightproperty
","text":"bottom_right: Offset\n
Bottom right offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.center","title":"centerproperty
","text":"center: tuple[float, float]\n
The center of the region.
Note, that this does not return an Offset
, because the center may not be an integer coordinate.
tuple[float, float]
Tuple of floats.
"},{"location":"api/geometry/#textual.geometry.Region.column_range","title":"column_rangeproperty
","text":"column_range: range\n
A range object for X coordinates.
"},{"location":"api/geometry/#textual.geometry.Region.column_span","title":"column_spanproperty
","text":"column_span: tuple[int, int]\n
A pair of integers for the start and end columns (x coordinates) in this region.
The end value is exclusive.
"},{"location":"api/geometry/#textual.geometry.Region.corners","title":"cornersproperty
","text":"corners: tuple[int, int, int, int]\n
The top left and bottom right coordinates as a tuple of four integers.
"},{"location":"api/geometry/#textual.geometry.Region.height","title":"heightclass-attribute
instance-attribute
","text":"height: int = 0\n
The height of the region.
"},{"location":"api/geometry/#textual.geometry.Region.line_range","title":"line_rangeproperty
","text":"line_range: range\n
A range object for Y coordinates.
"},{"location":"api/geometry/#textual.geometry.Region.line_span","title":"line_spanproperty
","text":"line_span: tuple[int, int]\n
A pair of integers for the start and end lines (y coordinates) in this region.
The end value is exclusive.
"},{"location":"api/geometry/#textual.geometry.Region.offset","title":"offsetproperty
","text":"offset: Offset\n
The top left corner of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.reset_offset","title":"reset_offsetproperty
","text":"reset_offset: Region\n
An region of the same size at (0, 0).
Returns Type DescriptionRegion
A region at the origin.
"},{"location":"api/geometry/#textual.geometry.Region.right","title":"rightproperty
","text":"right: int\n
Maximum X value (non inclusive).
"},{"location":"api/geometry/#textual.geometry.Region.size","title":"sizeproperty
","text":"size: Size\n
Get the size of the region.
"},{"location":"api/geometry/#textual.geometry.Region.top_right","title":"top_rightproperty
","text":"top_right: Offset\n
Top right offset of the region.
Returns Type DescriptionOffset
An offset.
"},{"location":"api/geometry/#textual.geometry.Region.width","title":"widthclass-attribute
instance-attribute
","text":"width: int = 0\n
The width of the region.
"},{"location":"api/geometry/#textual.geometry.Region.x","title":"xclass-attribute
instance-attribute
","text":"x: int = 0\n
Offset in the x-axis (horizontal).
"},{"location":"api/geometry/#textual.geometry.Region.y","title":"yclass-attribute
instance-attribute
","text":"y: int = 0\n
Offset in the y-axis (vertical).
"},{"location":"api/geometry/#textual.geometry.Region.at_offset","title":"at_offsetmethod
","text":"def at_offset(self, offset):\n
Get a new Region with the same size at a given offset.
Parameters Name Type Description Defaultoffset
tuple[int, int]
An offset.
required Returns Type DescriptionRegion
New Region with adjusted offset.
"},{"location":"api/geometry/#textual.geometry.Region.clip","title":"clipmethod
","text":"def clip(self, width, height):\n
Clip this region to fit within width, height.
Parameters Name Type Description Defaultwidth
int
Width of bounds.
requiredheight
int
Height of bounds.
required Returns Type DescriptionRegion
Clipped region.
"},{"location":"api/geometry/#textual.geometry.Region.clip_size","title":"clip_sizemethod
","text":"def clip_size(self, size):\n
Clip the size to fit within minimum values.
Parameters Name Type Description Defaultsize
tuple[int, int]
Maximum width and height.
required Returns Type DescriptionRegion
No region, not bigger than size.
"},{"location":"api/geometry/#textual.geometry.Region.contains","title":"containsmethod
","text":"def contains(self, x, y):\n
Check if a point is in the region.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Region.contains_point","title":"contains_pointmethod
","text":"def contains_point(self, point):\n
Check if a point is in the region.
Parameters Name Type Description Defaultpoint
tuple[int, int]
A tuple of x and y coordinates.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Region.contains_region","title":"contains_regioncached
","text":"def contains_region(self, other):\n
Check if a region is entirely contained within this region.
Parameters Name Type Description Defaultother
Region
A region.
required Returns Type Descriptionbool
True if the other region fits perfectly within this region.
"},{"location":"api/geometry/#textual.geometry.Region.crop_size","title":"crop_sizemethod
","text":"def crop_size(self, size):\n
Get a region with the same offset, with a size no larger than size
.
size
tuple[int, int]
Maximum width and height (WIDTH, HEIGHT).
required Returns Type DescriptionRegion
New region that could fit within size
.
method
","text":"def expand(self, size):\n
Increase the size of the region by adding a border.
Parameters Name Type Description Defaultsize
tuple[int, int]
Additional width and height.
required Returns Type DescriptionRegion
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.from_corners","title":"from_cornersclassmethod
","text":"def from_corners(cls, x1, y1, x2, y2):\n
Construct a Region form the top left and bottom right corners.
Parameters Name Type Description Defaultx1
int
Top left x.
requiredy1
int
Top left y.
requiredx2
int
Bottom right x.
requiredy2
int
Bottom right y.
required Returns Type DescriptionRegion
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.from_offset","title":"from_offsetclassmethod
","text":"def from_offset(cls, offset, size):\n
Create a region from offset and size.
Parameters Name Type Description Defaultoffset
tuple[int, int]
Offset (top left point).
requiredsize
tuple[int, int]
Dimensions of region.
required Returns Type DescriptionRegion
A region instance.
"},{"location":"api/geometry/#textual.geometry.Region.from_union","title":"from_unionclassmethod
","text":"def from_union(cls, regions):\n
Create a Region from the union of other regions.
Parameters Name Type Description Defaultregions
Collection[Region]
One or more regions.
required Returns Type DescriptionRegion
A Region that encloses all other regions.
"},{"location":"api/geometry/#textual.geometry.Region.get_scroll_to_visible","title":"get_scroll_to_visibleclassmethod
","text":"def get_scroll_to_visible(\ncls, window_region, region, *, top=False\n):\n
Calculate the smallest offset required to translate a window so that it contains another region.
This method is used to calculate the required offset to scroll something in to view.
Parameters Name Type Description Defaultwindow_region
Region
The window region.
requiredregion
Region
The region to move inside the window.
requiredtop
bool
Get offset to top of window.
False
Returns Type Description Offset
An offset required to add to region to move it inside window_region.
"},{"location":"api/geometry/#textual.geometry.Region.grow","title":"growcached
","text":"def grow(self, margin):\n
Grow a region by adding spacing.
Parameters Name Type Description Defaultmargin
tuple[int, int, int, int]
Grow space by (<top>, <right>, <bottom>, <left>)
.
Region
New region.
"},{"location":"api/geometry/#textual.geometry.Region.inflect","title":"inflectmethod
","text":"def inflect(self, x_axis=+1, y_axis=+1, margin=None):\n
Inflect a region around one or both axis.
The x_axis
and y_axis
parameters define which direction to move the region. A positive value will move the region right or down, a negative value will move the region left or up. A value of 0
will leave that axis unmodified.
\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2502\n\u2551 \u2551\n\u2551 Self \u2551 \u2502\n\u2551 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u2502\n\n\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u253c \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\n\n \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 \u2502\n \u2502 \u2502 Result \u2502\n \u2502 \u2502\n \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default x_axis
int
+1 to inflect in the positive direction, -1 to inflect in the negative direction.
+1
y_axis
int
+1 to inflect in the positive direction, -1 to inflect in the negative direction.
+1
margin
Spacing | None
Additional margin.
None
Returns Type Description Region
A new region.
"},{"location":"api/geometry/#textual.geometry.Region.intersection","title":"intersectioncached
","text":"def intersection(self, region):\n
Get the overlapping portion of the two regions.
Parameters Name Type Description Defaultregion
Region
A region that overlaps this region.
required Returns Type DescriptionRegion
A new region that covers when the two regions overlap.
"},{"location":"api/geometry/#textual.geometry.Region.overlaps","title":"overlapscached
","text":"def overlaps(self, other):\n
Check if another region overlaps this region.
Parameters Name Type Description Defaultother
Region
A Region.
required Returns Type Descriptionbool
True if other region shares any cells with this region.
"},{"location":"api/geometry/#textual.geometry.Region.shrink","title":"shrinkcached
","text":"def shrink(self, margin):\n
Shrink a region by subtracting spacing.
Parameters Name Type Description Defaultmargin
tuple[int, int, int, int]
Shrink space by (<top>, <right>, <bottom>, <left>)
.
Region
The new, smaller region.
"},{"location":"api/geometry/#textual.geometry.Region.split","title":"splitcached
","text":"def split(self, cut_x, cut_y):\n
Split a region in to 4 from given x and y offsets (cuts).
cut_x \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 \u2502 \u2502 \u2502\n \u2502 0 \u2502 \u2502 1 \u2502\n \u2502 \u2502 \u2502 \u2502\ncut_y \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 2 \u2502 \u2502 3 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut_x
int
Offset from self.x where the cut should be made. If negative, the cut is taken from the right edge.
requiredcut_y
int
Offset from self.y where the cut should be made. If negative, the cut is taken from the lower edge.
required Returns Type Descriptiontuple[Region, Region, Region, Region]
Four new regions which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.split_horizontal","title":"split_horizontalcached
","text":"def split_horizontal(self, cut):\n
Split a region in to two, from a given y offset.
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 0 \u2502\n \u2502 \u2502\n cut \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n \u2502 1 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut
int
An offset from self.y where the cut should be made. May be negative, for the offset to start from the lower edge.
required Returns Type Descriptiontuple[Region, Region]
Two regions, which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.split_vertical","title":"split_verticalcached
","text":"def split_vertical(self, cut):\n
Split a region in to two, from a given x offset.
cut \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2510\n \u2502 0 \u2502\u2502 1 \u2502\n \u2502 \u2502\u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default cut
int
An offset from self.x where the cut should be made. If cut is negative, it is taken from the right edge.
required Returns Type Descriptiontuple[Region, Region]
Two regions, which add up to the original (self).
"},{"location":"api/geometry/#textual.geometry.Region.translate","title":"translatecached
","text":"def translate(self, offset):\n
Move the offset of the Region.
Parameters Name Type Description Defaultoffset
tuple[int, int]
Offset to add to region.
required Returns Type DescriptionRegion
A new region shifted by (x, y)
"},{"location":"api/geometry/#textual.geometry.Region.translate_inside","title":"translate_insidemethod
","text":"def translate_inside(self, container, x_axis=True, y_axis=True):\n
Translate this region, so it fits within a container.
This will ensure that there is as little overlap as possible. The top left of the returned region is guaranteed to be within the container.
\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 container \u2502 \u2502 container \u2502\n\u2502 \u2502 \u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 \u2502 \u2500\u2500\u25b6 \u2502 \u2502 return \u2502\n\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2510 \u2502 \u2502 \u2502\n\u2502 \u2502 self \u2502 \u2502 \u2502 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524 \u2502 \u2514\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n \u2502 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n
Parameters Name Type Description Default container
Region
A container region.
requiredx_axis
bool
Allow translation of X axis.
True
y_axis
bool
Allow translation of Y axis.
True
Returns Type Description Region
A new region with same dimensions that fits with inside container.
"},{"location":"api/geometry/#textual.geometry.Region.union","title":"unioncached
","text":"def union(self, region):\n
Get the smallest region that contains both regions.
Parameters Name Type Description Defaultregion
Region
Another region.
required Returns Type DescriptionRegion
An optimally sized region to cover both regions.
"},{"location":"api/geometry/#textual.geometry.Size","title":"Sizeclass
","text":" Bases: NamedTuple
The dimensions (width and height) of a rectangular region.
Example>>> from textual.geometry import Size\n>>> size = Size(2, 3)\n>>> size\nSize(width=2, height=3)\n>>> size.area\n6\n>>> size + Size(10, 20)\nSize(width=12, height=23)\n
"},{"location":"api/geometry/#textual.geometry.Size.area","title":"area property
","text":"area: int\n
The area occupied by a region of this size.
"},{"location":"api/geometry/#textual.geometry.Size.height","title":"heightclass-attribute
instance-attribute
","text":"height: int = 0\n
The height in cells.
"},{"location":"api/geometry/#textual.geometry.Size.line_range","title":"line_rangeproperty
","text":"line_range: range\n
A range object that covers values between 0 and height
.
property
","text":"region: Region\n
A region of the same size, at the origin.
"},{"location":"api/geometry/#textual.geometry.Size.width","title":"widthclass-attribute
instance-attribute
","text":"width: int = 0\n
The width in cells.
"},{"location":"api/geometry/#textual.geometry.Size.contains","title":"containsmethod
","text":"def contains(self, x, y):\n
Check if a point is in area defined by the size.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Size.contains_point","title":"contains_pointmethod
","text":"def contains_point(self, point):\n
Check if a point is in the area defined by the size.
Parameters Name Type Description Defaultpoint
tuple[int, int]
A tuple of x and y coordinates.
required Returns Type Descriptionbool
True if the point is within the region.
"},{"location":"api/geometry/#textual.geometry.Spacing","title":"Spacingclass
","text":" Bases: NamedTuple
The spacing around a renderable, such as padding and border.
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
\u250c \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\u25b2\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2510\n \u2502 top\n\u2502 \u250f\u2501\u2501\u2501\u2501\u2501\u25bc\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2502\n \u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u25b6\u2503 \u2503\u25c0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u25b6\n\u2502 left \u2503 \u2503 right \u2502\n \u2503 \u2503\n\u2502 \u2517\u2501\u2501\u2501\u2501\u2501\u25b2\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2502\n \u2502 bottom\n\u2514 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500\u25bc\u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2500 \u2518\n
Example >>> from textual.geometry import Region, Spacing\n>>> region = Region(2, 3, 20, 10)\n>>> spacing = Spacing(1, 2, 3, 4)\n>>> region.grow(spacing)\nRegion(x=-2, y=2, width=26, height=14)\n>>> region.shrink(spacing)\nRegion(x=6, y=4, width=14, height=6)\n>>> spacing.css\n'1 2 3 4'\n
"},{"location":"api/geometry/#textual.geometry.Spacing.bottom","title":"bottom class-attribute
instance-attribute
","text":"bottom: int = 0\n
Space from the bottom of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.bottom_right","title":"bottom_rightproperty
","text":"bottom_right: tuple[int, int]\n
A pair of integers for the right, and bottom space.
"},{"location":"api/geometry/#textual.geometry.Spacing.css","title":"cssproperty
","text":"css: str\n
A string containing the spacing in CSS format.
For example: \"1\" or \"2 4\" or \"4 2 8 2\".
"},{"location":"api/geometry/#textual.geometry.Spacing.height","title":"heightproperty
","text":"height: int\n
Total space in the y axis.
"},{"location":"api/geometry/#textual.geometry.Spacing.left","title":"leftclass-attribute
instance-attribute
","text":"left: int = 0\n
Space from the left of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.right","title":"rightclass-attribute
instance-attribute
","text":"right: int = 0\n
Space from the right of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.top","title":"topclass-attribute
instance-attribute
","text":"top: int = 0\n
Space from the top of a region.
"},{"location":"api/geometry/#textual.geometry.Spacing.top_left","title":"top_leftproperty
","text":"top_left: tuple[int, int]\n
A pair of integers for the left, and top space.
"},{"location":"api/geometry/#textual.geometry.Spacing.totals","title":"totalsproperty
","text":"totals: tuple[int, int]\n
A pair of integers for the total horizontal and vertical space.
"},{"location":"api/geometry/#textual.geometry.Spacing.width","title":"widthproperty
","text":"width: int\n
Total space in the x axis.
"},{"location":"api/geometry/#textual.geometry.Spacing.all","title":"allclassmethod
","text":"def all(cls, amount):\n
Construct a Spacing with a given amount of spacing on all edges.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to all edges
required Returns Type DescriptionSpacing
Spacing(amount, amount, amount, amount)
method
","text":"def grow_maximum(self, other):\n
Grow spacing with a maximum.
Parameters Name Type Description Defaultother
Spacing
Spacing object.
required Returns Type DescriptionSpacing
New spacing where the values are maximum of the two values.
"},{"location":"api/geometry/#textual.geometry.Spacing.horizontal","title":"horizontalclassmethod
","text":"def horizontal(cls, amount):\n
Construct a Spacing with a given amount of spacing on horizontal edges, and no vertical spacing.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to horizontal edges
required Returns Type DescriptionSpacing
Spacing(0, amount, 0, amount)
classmethod
","text":"def unpack(cls, pad):\n
Unpack padding specified in CSS style.
Parameters Name Type Description Defaultpad
SpacingDimensions
An integer, or tuple of 1, 2, or 4 integers.
required Raises Type DescriptionValueError
If pad
is an invalid value.
Spacing
New Spacing object.
"},{"location":"api/geometry/#textual.geometry.Spacing.vertical","title":"verticalclassmethod
","text":"def vertical(cls, amount):\n
Construct a Spacing with a given amount of spacing on vertical edges, and no horizontal spacing.
Parameters Name Type Description Defaultamount
int
The magnitude of spacing to apply to vertical edges
required Returns Type DescriptionSpacing
Spacing(amount, 0, amount, 0)
function
","text":"def clamp(value, minimum, maximum):\n
Adjust a value so it is not less than a minimum and not greater than a maximum value.
Parameters Name Type Description Defaultvalue
T
A value.
requiredminimum
T
Minimum value.
requiredmaximum
T
Maximum value.
required Returns Type DescriptionT
New value that is not less than the minimum or greater than the maximum.
"},{"location":"api/logger/","title":"Logger","text":"A logger class that logs to the Textual console.
"},{"location":"api/logger/#textual.Logger","title":"textual.Loggerclass
","text":"def __init__(\nself,\nlog_callable,\ngroup=LogGroup.INFO,\nverbosity=LogVerbosity.NORMAL,\n):\n
A Textual logger.
"},{"location":"api/logger/#textual.Logger.debug","title":"debugproperty
","text":"debug: Logger\n
Logs debug messages.
"},{"location":"api/logger/#textual.Logger.error","title":"errorproperty
","text":"error: Logger\n
Logs errors.
"},{"location":"api/logger/#textual.Logger.event","title":"eventproperty
","text":"event: Logger\n
Logs events.
"},{"location":"api/logger/#textual.Logger.info","title":"infoproperty
","text":"info: Logger\n
Logs information.
"},{"location":"api/logger/#textual.Logger.logging","title":"loggingproperty
","text":"logging: Logger\n
Logs from stdlib logging module.
"},{"location":"api/logger/#textual.Logger.system","title":"systemproperty
","text":"system: Logger\n
Logs system information.
"},{"location":"api/logger/#textual.Logger.verbose","title":"verboseproperty
","text":"verbose: Logger\n
A verbose logger.
"},{"location":"api/logger/#textual.Logger.warning","title":"warningproperty
","text":"warning: Logger\n
Logs warnings.
"},{"location":"api/logger/#textual.Logger.worker","title":"workerproperty
","text":"worker: Logger\n
Logs worker information.
"},{"location":"api/logger/#textual.Logger.verbosity","title":"verbositymethod
","text":"def verbosity(self, verbose):\n
Get a new logger with selective verbosity.
Parameters Name Type Description Defaultverbose
bool
True to use HIGH verbosity, otherwise NORMAL.
required Returns Type DescriptionLogger
New logger.
"},{"location":"api/logging/","title":"Logging","text":"A Textual Logging handler.
If there is an active Textual app, then log messages will go via the app (and logged via textual console).
If there is no active app, then log messages will go to stderr or stdout, depending on configuration.
"},{"location":"api/logging/#textual.logging.TextualHandler","title":"TextualHandlerclass
","text":"def __init__(self, stderr=True, stdout=False):\n
Bases: Handler
A Logging handler for Textual apps.
Parameters Name Type Description Defaultstderr
bool
Log to stderr when there is no active app.
True
stdout
bool
Log to stdout when there is not active app.
False
"},{"location":"api/logging/#textual.logging.TextualHandler.emit","title":"emit method
","text":"def emit(self, record):\n
Invoked by logging.
"},{"location":"api/map_geometry/","title":"Map geometry","text":"A data structure returned by screen.find_widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry","title":"textual._compositor.MapGeometryclass
","text":" Bases: NamedTuple
Defines the absolute location of a Widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.clip","title":"clipinstance-attribute
","text":"clip: Region\n
A region to clip the widget by (if a Widget is within a container).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.container_size","title":"container_sizeinstance-attribute
","text":"container_size: Size\n
The container size (area not occupied by scrollbars).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.dock_gutter","title":"dock_gutterinstance-attribute
","text":"dock_gutter: Spacing\n
Space from the container reserved by docked widgets.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.order","title":"orderinstance-attribute
","text":"order: tuple[tuple[int, int, int], ...]\n
Tuple of tuples defining the painting order of the widget.
Each successive triple represents painting order information with regards to ancestors in the DOM hierarchy and the last triple provides painting order information for this specific widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.region","title":"regioninstance-attribute
","text":"region: Region\n
The (screen) region occupied by the widget.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.virtual_region","title":"virtual_regioninstance-attribute
","text":"virtual_region: Region\n
The region relative to the container (but not necessarily visible).
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.virtual_size","title":"virtual_sizeinstance-attribute
","text":"virtual_size: Size\n
The virtual size (scrollable area) of a widget if it is a container.
"},{"location":"api/map_geometry/#textual._compositor.MapGeometry.visible_region","title":"visible_regionproperty
","text":"visible_region: Region\n
The Widget region after clipping.
"},{"location":"api/message/","title":"Message","text":"The base class for all messages (including events).
"},{"location":"api/message/#textual.message.Message","title":"Messageclass
","text":"def __init__(self):\n
Base class for a message.
"},{"location":"api/message/#textual.message.Message.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
","text":"ALLOW_SELECTOR_MATCH: set[str] = set()\n
Additional attributes that can be used with the on
decorator.
These attributes must be widgets.
"},{"location":"api/message/#textual.message.Message.control","title":"controlproperty
","text":"control: Widget | None\n
The widget associated with this message, or None by default.
"},{"location":"api/message/#textual.message.Message.handler_name","title":"handler_nameclass-attribute
","text":"handler_name: str\n
Name of the default message handler.
"},{"location":"api/message/#textual.message.Message.is_forwarded","title":"is_forwardedproperty
","text":"is_forwarded: bool\n
Has the message been forwarded?
"},{"location":"api/message/#textual.message.Message.prevent_default","title":"prevent_defaultmethod
","text":"def prevent_default(self, prevent=True):\n
Suppress the default action(s). This will prevent handlers in any base classes from being called.
Parameters Name Type Description Defaultprevent
bool
True if the default action should be suppressed, or False if the default actions should be performed.
True
"},{"location":"api/message/#textual.message.Message.stop","title":"stop method
","text":"def stop(self, stop=True):\n
Stop propagation of the message to parent.
Parameters Name Type Description Defaultstop
bool
The stop flag.
True
"},{"location":"api/message_pump/","title":"Message pump","text":"A MessagePump
is a base class for any object which processes messages, which includes Widget, Screen, and App.
Tip
Most of the method here are useful in general app development.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump","title":"MessagePumpclass
","text":"def __init__(self, parent=None):\n
Base class which supplies a message pump.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.app","title":"appproperty
","text":"app: 'App[object]'\n
Get the current app.
Returns Type Description'App[object]'
The current app.
Raises Type DescriptionNoActiveAppError
if no active app could be found for the current asyncio context
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.has_parent","title":"has_parentproperty
","text":"has_parent: bool\n
Does this object have a parent?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_attached","title":"is_attachedproperty
","text":"is_attached: bool\n
Is the node attached to the app via the DOM?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_parent_active","title":"is_parent_activeproperty
","text":"is_parent_active: bool\n
Is the parent active?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.is_running","title":"is_runningproperty
","text":"is_running: bool\n
Is the message pump running (potentially processing messages)?
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.log","title":"logproperty
","text":"log: Logger\n
Get a logger for this object.
Returns Type DescriptionLogger
A logger.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.call_after_refresh","title":"call_after_refreshmethod
","text":"def call_after_refresh(self, callback, *args, **kwargs):\n
Schedule a callback to run after all messages are processed and the screen has been refreshed. Positional and keyword arguments are passed to the callable.
Parameters Name Type Description Defaultcallback
Callback
A callable.
required Returns Type Descriptionbool
True
if the callback was scheduled, or False
if the callback could not be scheduled (may occur if the message pump was closed or closing).
method
","text":"def call_later(self, callback, *args, **kwargs):\n
Schedule a callback to run after all messages are processed in this object. Positional and keywords arguments are passed to the callable.
Parameters Name Type Description Defaultcallback
Callback
Callable to call next.
required*args
Any
Positional arguments to pass to the callable.
()
**kwargs
Any
Keyword arguments to pass to the callable.
{}
Returns Type Description bool
True
if the callback was scheduled, or False
if the callback could not be scheduled (may occur if the message pump was closed or closing).
method
","text":"def call_next(self, callback, *args, **kwargs):\n
Schedule a callback to run immediately after processing the current message.
Parameters Name Type Description Defaultcallback
Callback
Callable to run after current event.
required*args
Any
Positional arguments to pass to the callable.
()
**kwargs
Any
Keyword arguments to pass to the callable.
{}
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.check_idle","title":"check_idle method
","text":"def check_idle(self):\n
Prompt the message pump to call idle if the queue is empty.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.check_message_enabled","title":"check_message_enabledmethod
","text":"def check_message_enabled(self, message):\n
Check if a given message is enabled (allowed to be sent).
Parameters Name Type Description Defaultmessage
Message
A message object.
required Returns Type Descriptionbool
True
if the message will be sent, or False
if it is disabled.
method
","text":"def disable_messages(self, *messages):\n
Disable message types from being processed.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.dispatch_key","title":"dispatch_keyasync
","text":"def dispatch_key(self, event):\n
Dispatch a key event to method.
This method will call the method named 'key_' if it exists. Some keys have aliases. The first alias found will be invoked if it exists. If multiple handlers exist that match the key, an exception is raised. Parameters Name Type Description Default event
events.Key
A key event.
required Returns Type Descriptionbool
True if key was handled, otherwise False.
Raises Type DescriptionDuplicateKeyHandlers
When there's more than 1 handler that could handle this key.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.enable_messages","title":"enable_messagesmethod
","text":"def enable_messages(self, *messages):\n
Enable processing of messages types.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.on_event","title":"on_eventasync
","text":"def on_event(self, event):\n
Called to process an event.
Parameters Name Type Description Defaultevent
events.Event
An Event object.
required"},{"location":"api/message_pump/#textual.message_pump.MessagePump.post_message","title":"post_messagemethod
","text":"def post_message(self, message):\n
Posts a message on to this widget's queue.
Parameters Name Type Description Defaultmessage
Message
A message (including Event).
required Returns Type Descriptionbool
True
if the messages was processed, False
if it wasn't.
method
","text":"def prevent(self, *message_types):\n
A context manager to temporarily prevent the given message types from being posted.
Exampleinput = self.query_one(Input)\nwith self.prevent(Input.Changed):\ninput.value = \"foo\"\n
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.set_interval","title":"set_interval method
","text":"def set_interval(\nself,\ninterval,\ncallback=None,\n*,\nname=None,\nrepeat=0,\npause=False\n):\n
Call a function at periodic intervals.
Parameters Name Type Description Defaultinterval
float
Time between calls.
requiredcallback
TimerCallback | None
Function to call.
None
name
str | None
Name of the timer object.
None
repeat
int
Number of times to repeat the call or 0 for continuous.
0
pause
bool
Start the timer paused.
False
Returns Type Description Timer
A timer object.
"},{"location":"api/message_pump/#textual.message_pump.MessagePump.set_timer","title":"set_timermethod
","text":"def set_timer(\nself, delay, callback=None, *, name=None, pause=False\n):\n
Make a function call after a delay.
Parameters Name Type Description Defaultdelay
float
Time to wait before invoking callback.
requiredcallback
TimerCallback | None
Callback to call after time has expired.
None
name
str | None
Name of the timer (for debug).
None
pause
bool
Start timer paused.
False
Returns Type Description Timer
A timer object.
"},{"location":"api/on/","title":"On","text":"Decorator to declare that the method is a message handler.
The decorator accepts an optional CSS selector that will be matched against a widget exposed by a control
attribute on the message.
# Handle the press of buttons with ID \"#quit\".\n@on(Button.Pressed, \"#quit\")\ndef quit_button(self) -> None:\nself.app.quit()\n
Keyword arguments can be used to match additional selectors for attributes listed in ALLOW_SELECTOR_MATCH
.
# Handle the activation of the tab \"#home\" within the `TabbedContent` \"#tabs\".\n@on(TabbedContent.TabActivated, \"#tabs\", tab=\"#home\")\ndef switch_to_home(self) -> None:\nself.log(\"Switching back to the home tab.\")\n...\n
Parameters Name Type Description Default message_type
type[Message]
The message type (i.e. the class).
requiredselector
str | None
An optional selector. If supplied, the handler will only be called if selector
matches the widget from the control
attribute of the message.
None
**kwargs
str
Additional selectors for other attributes of the message.
{}
"},{"location":"api/pilot/","title":"Pilot","text":"The pilot object is used by App.run_test to programmatically operate an app.
See the guide on how to test Textual apps.
"},{"location":"api/pilot/#textual.pilot.OutOfBounds","title":"OutOfBoundsclass
","text":" Bases: Exception
Raised when the pilot mouse target is outside of the (visible) screen.
"},{"location":"api/pilot/#textual.pilot.Pilot","title":"Pilotclass
","text":"def __init__(self, app):\n
Bases: Generic[ReturnType]
Pilot object to drive an app.
"},{"location":"api/pilot/#textual.pilot.Pilot.app","title":"appproperty
","text":"app: App[ReturnType]\n
"},{"location":"api/pilot/#textual.pilot.Pilot.click","title":"click async
","text":"def click(\nself,\nselector=None,\noffset=(0, 0),\nshift=False,\nmeta=False,\ncontrol=False,\n):\n
Simulate clicking with the mouse at a specified position.
The final position to be clicked is computed based on the selector provided and the offset specified and it must be within the visible area of the screen.
Parameters Name Type Description Defaultselector
type[Widget] | str | None
A selector to specify a widget that should be used as the reference for the click offset. If this is not specified, the offset is interpreted relative to the screen. You can use this parameter to try to click on a specific widget. However, if the widget is currently hidden or obscured by another widget, the click may not land on the widget you specified.
None
offset
tuple[int, int]
The offset to click. The offset is relative to the selector provided or to the screen, if no selector is provided.
(0, 0)
shift
bool
Click with the shift key held down.
False
meta
bool
Click with the meta key held down.
False
control
bool
Click with the control key held down.
False
Raises Type Description OutOfBounds
If the position to be clicked is outside of the (visible) screen.
Returns Type Descriptionbool
True if no selector was specified or if the click landed on the selected widget, False otherwise.
"},{"location":"api/pilot/#textual.pilot.Pilot.exit","title":"exitasync
","text":"def exit(self, result):\n
Exit the app with the given result.
Parameters Name Type Description Defaultresult
ReturnType
The app result returned by run
or run_async
.
async
","text":"def hover(self, selector=None, offset=(0, 0)):\n
Simulate hovering with the mouse cursor at a specified position.
The final position to be hovered is computed based on the selector provided and the offset specified and it must be within the visible area of the screen.
Parameters Name Type Description Defaultselector
type[Widget] | str | None | None
A selector to specify a widget that should be used as the reference for the hover offset. If this is not specified, the offset is interpreted relative to the screen. You can use this parameter to try to hover a specific widget. However, if the widget is currently hidden or obscured by another widget, the hover may not land on the widget you specified.
None
offset
tuple[int, int]
The offset to hover. The offset is relative to the selector provided or to the screen, if no selector is provided.
(0, 0)
Raises Type Description OutOfBounds
If the position to be hovered is outside of the (visible) screen.
Returns Type Descriptionbool
True if no selector was specified or if the hover landed on the selected widget, False otherwise.
"},{"location":"api/pilot/#textual.pilot.Pilot.pause","title":"pauseasync
","text":"def pause(self, delay=None):\n
Insert a pause.
Parameters Name Type Description Defaultdelay
float | None
Seconds to pause, or None to wait for cpu idle.
None
"},{"location":"api/pilot/#textual.pilot.Pilot.press","title":"press async
","text":"def press(self, *keys):\n
Simulate key-presses.
Parameters Name Type Description Default*keys
str
Keys to press.
()
"},{"location":"api/pilot/#textual.pilot.Pilot.wait_for_animation","title":"wait_for_animation async
","text":"def wait_for_animation(self):\n
Wait for any current animation to complete.
"},{"location":"api/pilot/#textual.pilot.Pilot.wait_for_scheduled_animations","title":"wait_for_scheduled_animationsasync
","text":"def wait_for_scheduled_animations(self):\n
Wait for any current and scheduled animations to complete.
"},{"location":"api/pilot/#textual.pilot.WaitForScreenTimeout","title":"WaitForScreenTimeoutclass
","text":" Bases: Exception
Exception raised if messages aren't being processed quickly enough.
If this occurs, the most likely explanation is some kind of deadlock in the app code.
"},{"location":"api/query/","title":"Query","text":"A DOMQuery is a set of DOM nodes returned by query.
The set of nodes may be further refined with filter and exclude. Additional methods apply actions to all nodes in the query.
Info
If this sounds like JQuery, a (once) popular JS library, it is no coincidence.
"},{"location":"api/query/#textual.css.query.ExpectType","title":"ExpectTypemodule-attribute
","text":"ExpectType = TypeVar('ExpectType')\n
Type variable used to further restrict queries.
"},{"location":"api/query/#textual.css.query.QueryType","title":"QueryTypemodule-attribute
","text":"QueryType = TypeVar('QueryType', bound='Widget')\n
Type variable used to type generic queries.
"},{"location":"api/query/#textual.css.query.DOMQuery","title":"DOMQueryclass
","text":"def __init__(\nself, node, *, filter=None, exclude=None, parent=None\n):\n
Bases: Generic[QueryType]
Warning
You won't need to construct this manually, as DOMQuery
objects are returned by query.
node
DOMNode
A DOM node.
requiredfilter
str | None
Query to filter children in the node.
None
exclude
str | None
Query to exclude children in the node.
None
parent
DOMQuery | None
The parent query, if this is the result of filtering another query.
None
Raises Type Description InvalidQueryFormat
If the format of the query is invalid.
"},{"location":"api/query/#textual.css.query.DOMQuery.node","title":"nodeproperty
","text":"node: DOMNode\n
The node being queried.
"},{"location":"api/query/#textual.css.query.DOMQuery.nodes","title":"nodesproperty
","text":"nodes: list[QueryType]\n
Lazily evaluate nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.add_class","title":"add_classmethod
","text":"def add_class(self, *class_names):\n
Add the given class name(s) to nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.exclude","title":"excludemethod
","text":"def exclude(self, selector):\n
Exclude nodes that match a given selector.
Parameters Name Type Description Defaultselector
str
A CSS selector.
required Returns Type DescriptionDOMQuery[QueryType]
New DOM query.
"},{"location":"api/query/#textual.css.query.DOMQuery.filter","title":"filtermethod
","text":"def filter(self, selector):\n
Filter this set by the given CSS selector.
Parameters Name Type Description Defaultselector
str
A CSS selector.
required Returns Type DescriptionDOMQuery[QueryType]
New DOM Query.
"},{"location":"api/query/#textual.css.query.DOMQuery.first","title":"firstmethod
","text":"def first(self, expect_type=None):\n
Get the first matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If there are no matching nodes in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.last","title":"lastmethod
","text":"def last(self, expect_type=None):\n
Get the last matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If there are no matching nodes in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.only_one","title":"only_onemethod
","text":"def only_one(self, expect_type=None):\n
Get the only matching node.
Parameters Name Type Description Defaultexpect_type
type[ExpectType] | None
Require matched node is of this type, or None for any type.
None
Raises Type Description WrongType
If the wrong type was found.
NoMatches
If no node matches the query.
TooManyMatches
If there is more than one matching node in the query.
Returns Type DescriptionQueryType | ExpectType
The matching Widget.
"},{"location":"api/query/#textual.css.query.DOMQuery.refresh","title":"refreshmethod
","text":"def refresh(self, *, repaint=True, layout=False):\n
Refresh matched nodes.
Parameters Name Type Description Defaultrepaint
bool
Repaint node(s).
True
layout
bool
Layout node(s).
False
Returns Type Description DOMQuery[QueryType]
Query for chaining.
"},{"location":"api/query/#textual.css.query.DOMQuery.remove","title":"removemethod
","text":"def remove(self):\n
Remove matched nodes from the DOM.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the widgets to be removed.
"},{"location":"api/query/#textual.css.query.DOMQuery.remove_class","title":"remove_classmethod
","text":"def remove_class(self, *class_names):\n
Remove the given class names from the nodes.
"},{"location":"api/query/#textual.css.query.DOMQuery.results","title":"resultsmethod
","text":"def results(self, filter_type=None):\n
Get query results, optionally filtered by a given type.
Parameters Name Type Description Defaultfilter_type
type[ExpectType] | None
A Widget class to filter results, or None for no filter.
None
Yields:
Type DescriptionQueryType | ExpectType
Iterator[Widget | ExpectType]: An iterator of Widget instances.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_class","title":"set_classmethod
","text":"def set_class(self, add, *class_names):\n
Set the given class name(s) according to a condition.
Parameters Name Type Description Defaultadd
bool
Add the classes if True, otherwise remove them.
required Returns Type DescriptionDOMQuery[QueryType]
Self.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_classes","title":"set_classesmethod
","text":"def set_classes(self, classes):\n
Set the classes on nodes to exactly the given set.
Parameters Name Type Description Defaultclasses
str | Iterable[str]
A string of space separated classes, or an iterable of class names.
required Returns Type DescriptionDOMQuery[QueryType]
Self.
"},{"location":"api/query/#textual.css.query.DOMQuery.set_styles","title":"set_stylesmethod
","text":"def set_styles(self, css=None, **update_styles):\n
Set styles on matched nodes.
Parameters Name Type Description Defaultcss
str | None
CSS declarations to parser, or None.
None
"},{"location":"api/query/#textual.css.query.DOMQuery.toggle_class","title":"toggle_class method
","text":"def toggle_class(self, *class_names):\n
Toggle the given class names from matched nodes.
"},{"location":"api/query/#textual.css.query.InvalidQueryFormat","title":"InvalidQueryFormatclass
","text":" Bases: QueryError
Query did not parse correctly.
"},{"location":"api/query/#textual.css.query.NoMatches","title":"NoMatchesclass
","text":" Bases: QueryError
No nodes matched the query.
"},{"location":"api/query/#textual.css.query.QueryError","title":"QueryErrorclass
","text":" Bases: Exception
Base class for a query related error.
"},{"location":"api/query/#textual.css.query.TooManyMatches","title":"TooManyMatchesclass
","text":" Bases: QueryError
Too many nodes matched the query.
"},{"location":"api/query/#textual.css.query.WrongType","title":"WrongTypeclass
","text":" Bases: QueryError
Query result was not of the correct type.
"},{"location":"api/reactive/","title":"Reactive","text":"The Reactive
class implements reactivity.
class
","text":"def __init__(\nself,\ndefault,\n*,\nlayout=False,\nrepaint=True,\ninit=False,\nalways_update=False,\ncompute=True\n):\n
Bases: Generic[ReactiveType]
Reactive descriptor.
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredlayout
bool
Perform a layout on change.
False
repaint
bool
Perform a repaint on change.
True
init
bool
Call watchers on initialize (post mount).
False
always_update
bool
Call watchers even when the new value equals the old value.
False
compute
bool
Run compute methods when attribute is changed.
True
"},{"location":"api/reactive/#textual.reactive.TooManyComputesError","title":"TooManyComputesError class
","text":" Bases: Exception
Raised when an attribute has public and private compute methods.
"},{"location":"api/reactive/#textual.reactive.reactive","title":"reactiveclass
","text":"def __init__(\nself,\ndefault,\n*,\nlayout=False,\nrepaint=True,\ninit=True,\nalways_update=False\n):\n
Bases: Reactive[ReactiveType]
Create a reactive attribute.
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredlayout
bool
Perform a layout on change.
False
repaint
bool
Perform a repaint on change.
True
init
bool
Call watchers on initialize (post mount).
True
always_update
bool
Call watchers even when the new value equals the old value.
False
"},{"location":"api/reactive/#textual.reactive.var","title":"var class
","text":"def __init__(self, default, init=True, always_update=False):\n
Bases: Reactive[ReactiveType]
Create a reactive attribute (with no auto-refresh).
Parameters Name Type Description Defaultdefault
ReactiveType | Callable[[], ReactiveType]
A default value or callable that returns a default.
requiredinit
bool
Call watchers on initialize (post mount).
True
always_update
bool
Call watchers even when the new value equals the old value.
False
"},{"location":"api/screen/","title":"Screen","text":"The Screen
class is a special widget which represents the content in the terminal. See Screens for details.
module-attribute
","text":"ScreenResultCallbackType = Union[\nCallable[[ScreenResultType], None],\nCallable[[ScreenResultType], Awaitable[None]],\n]\n
Type of a screen result callback function.
"},{"location":"api/screen/#textual.screen.ScreenResultType","title":"ScreenResultTypemodule-attribute
","text":"ScreenResultType = TypeVar('ScreenResultType')\n
The result type of a screen.
"},{"location":"api/screen/#textual.screen.ModalScreen","title":"ModalScreenclass
","text":"def __init__(self, name=None, id=None, classes=None):\n
Bases: Screen[ScreenResultType]
A screen with bindings that take precedence over the App's key bindings.
The default styling of a modal screen will dim the screen underneath.
"},{"location":"api/screen/#textual.screen.ResultCallback","title":"ResultCallbackclass
","text":"def __init__(self, requester, callback, future=None):\n
Bases: Generic[ScreenResultType]
Holds the details of a callback.
Parameters Name Type Description Defaultrequester
MessagePump
The object making a request for the callback.
requiredcallback
ScreenResultCallbackType[ScreenResultType] | None
The callback function.
requiredfuture
asyncio.Future[ScreenResultType] | None
A Future to hold the result.
None
"},{"location":"api/screen/#textual.screen.ResultCallback.callback","title":"callback instance-attribute
","text":"callback: ScreenResultCallbackType | None = callback\n
The callback function.
"},{"location":"api/screen/#textual.screen.ResultCallback.future","title":"futureinstance-attribute
","text":"future = future\n
A future for the result
"},{"location":"api/screen/#textual.screen.ResultCallback.requester","title":"requesterinstance-attribute
","text":"requester = requester\n
The object in the DOM that requested the callback.
"},{"location":"api/screen/#textual.screen.Screen","title":"Screenclass
","text":"def __init__(self, name=None, id=None, classes=None):\n
Bases: Generic[ScreenResultType]
, Widget
The base class for screens.
Parameters Name Type Description Defaultname
str | None
The name of the screen.
None
id
str | None
The ID of the screen in the DOM.
None
classes
str | None
The CSS classes for the screen.
None
"},{"location":"api/screen/#textual.screen.Screen.AUTO_FOCUS","title":"AUTO_FOCUS class-attribute
","text":"AUTO_FOCUS: str | None = None\n
A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given CSS selector. Set to None
to inherit the value from the screen's app. Set to \"\"
to disable auto focus.
class-attribute
","text":"COMMANDS: set[type[Provider]] = set()\n
Command providers used by the command palette, associated with the screen.
Should be a set of command.Provider
classes.
class-attribute
","text":"CSS: str = ''\n
Inline CSS, useful for quick scripts. Rules here take priority over CSS_PATH.
NoteThis CSS applies to the whole app.
"},{"location":"api/screen/#textual.screen.Screen.CSS_PATH","title":"CSS_PATHclass-attribute
","text":"CSS_PATH: CSSPathType | None = None\n
File paths to load CSS from.
NoteThis CSS applies to the whole app.
"},{"location":"api/screen/#textual.screen.Screen.SUB_TITLE","title":"SUB_TITLEclass-attribute
","text":"SUB_TITLE: str | None = None\n
A class variable to set the default sub-title for the screen.
This overrides the app sub-title. To update the sub-title while the screen is running, you can set the sub_title attribute.
"},{"location":"api/screen/#textual.screen.Screen.TITLE","title":"TITLEclass-attribute
","text":"TITLE: str | None = None\n
A class variable to set the default title for the screen.
This overrides the app title. To update the title while the screen is running, you can set the title attribute.
"},{"location":"api/screen/#textual.screen.Screen.focus_chain","title":"focus_chainproperty
","text":"focus_chain: list[Widget]\n
A list of widgets that may receive focus, in focus order.
"},{"location":"api/screen/#textual.screen.Screen.focused","title":"focusedclass-attribute
instance-attribute
","text":"focused: Reactive[Widget | None] = Reactive(None)\n
The focused widget or None
for no focus.
property
","text":"is_current: bool\n
Is the screen current (i.e. visible to user)?
"},{"location":"api/screen/#textual.screen.Screen.is_modal","title":"is_modalproperty
","text":"is_modal: bool\n
Is the screen modal?
"},{"location":"api/screen/#textual.screen.Screen.layers","title":"layersproperty
","text":"layers: tuple[str, ...]\n
Layers from parent.
Returns Type Descriptiontuple[str, ...]
Tuple of layer names.
"},{"location":"api/screen/#textual.screen.Screen.stack_updates","title":"stack_updatesclass-attribute
instance-attribute
","text":"stack_updates: Reactive[int] = Reactive(0, repaint=False)\n
An integer that updates when the screen is resumed.
"},{"location":"api/screen/#textual.screen.Screen.sub_title","title":"sub_titleclass-attribute
instance-attribute
","text":"sub_title: Reactive[str | None] = self.SUB_TITLE\n
Screen sub-title to override the app sub-title.
"},{"location":"api/screen/#textual.screen.Screen.title","title":"titleclass-attribute
instance-attribute
","text":"title: Reactive[str | None] = self.TITLE\n
Screen title to override the app title.
"},{"location":"api/screen/#textual.screen.Screen.action_dismiss","title":"action_dismissmethod
","text":"def action_dismiss(self, result=_NoResult):\n
A wrapper around dismiss
that can be called as an action.
result
ScreenResultType | Type[_NoResult]
The optional result to be passed to the result callback.
_NoResult
"},{"location":"api/screen/#textual.screen.Screen.can_view","title":"can_view method
","text":"def can_view(self, widget):\n
Check if a given widget is in the current view (scrollable area).
Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible.
Parameters Name Type Description Defaultwidget
Widget
A widget that is a descendant of self.
required Returns Type Descriptionbool
True if the entire widget is in view, False if it is partially visible or not in view.
"},{"location":"api/screen/#textual.screen.Screen.dismiss","title":"dismissmethod
","text":"def dismiss(self, result=_NoResult):\n
Dismiss the screen, optionally with a result.
If result
is provided and a callback was set when the screen was pushed, then the callback will be invoked with result
.
result
ScreenResultType | Type[_NoResult]
The optional result to be passed to the result callback.
_NoResult
Raises Type Description ScreenStackError
If trying to dismiss a screen that is not at the top of the stack.
"},{"location":"api/screen/#textual.screen.Screen.find_widget","title":"find_widgetmethod
","text":"def find_widget(self, widget):\n
Get the screen region of a Widget.
Parameters Name Type Description Defaultwidget
Widget
A Widget within the composition.
required Returns Type DescriptionMapGeometry
Region relative to screen.
Raises Type DescriptionNoWidget
If the widget could not be found in this screen.
"},{"location":"api/screen/#textual.screen.Screen.focus_next","title":"focus_nextmethod
","text":"def focus_next(self, selector='*'):\n
Focus the next widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget. If no focusable widget matches the given CSS selector, focus is set to None
.
selector
str | type[QueryType]
CSS selector to filter what nodes can be focused.
'*'
Returns Type Description Widget | None
Newly focused widget, or None for no focus. If the return is not None
, then it is guaranteed that the widget returned matches the CSS selectors given in the argument.
method
","text":"def focus_previous(self, selector='*'):\n
Focus the previous widget, optionally filtered by a CSS selector.
If no widget is currently focused, this will focus the first focusable widget. If no focusable widget matches the given CSS selector, focus is set to None
.
selector
str | type[QueryType]
CSS selector to filter what nodes can be focused.
'*'
Returns Type Description Widget | None
Newly focused widget, or None for no focus. If the return is not None
, then it is guaranteed that the widget returned matches the CSS selectors given in the argument.
method
","text":"def get_offset(self, widget):\n
Get the absolute offset of a given Widget.
Parameters Name Type Description Defaultwidget
Widget
A widget
required Returns Type DescriptionOffset
The widget's offset relative to the top left of the terminal.
"},{"location":"api/screen/#textual.screen.Screen.get_style_at","title":"get_style_atmethod
","text":"def get_style_at(self, x, y):\n
Get the style under a given coordinate.
Parameters Name Type Description Defaultx
int
X Coordinate.
requiredy
int
Y Coordinate.
required Returns Type DescriptionStyle
Rich Style object.
"},{"location":"api/screen/#textual.screen.Screen.get_widget_at","title":"get_widget_atmethod
","text":"def get_widget_at(self, x, y):\n
Get the widget at a given coordinate.
Parameters Name Type Description Defaultx
int
X Coordinate.
requiredy
int
Y Coordinate.
required Returns Type Descriptiontuple[Widget, Region]
Widget and screen region.
"},{"location":"api/screen/#textual.screen.Screen.get_widgets_at","title":"get_widgets_atmethod
","text":"def get_widgets_at(self, x, y):\n
Get all widgets under a given coordinate.
Parameters Name Type Description Defaultx
int
X coordinate.
requiredy
int
Y coordinate.
required Returns Type DescriptionIterable[tuple[Widget, Region]]
Sequence of (WIDGET, REGION) tuples.
"},{"location":"api/screen/#textual.screen.Screen.set_focus","title":"set_focusmethod
","text":"def set_focus(self, widget, scroll_visible=True):\n
Focus (or un-focus) a widget. A focused widget will receive key events first.
Parameters Name Type Description Defaultwidget
Widget | None
Widget to focus, or None to un-focus.
requiredscroll_visible
bool
Scroll widget in to view.
True
"},{"location":"api/screen/#textual.screen.Screen.validate_sub_title","title":"validate_sub_title method
","text":"def validate_sub_title(self, sub_title):\n
Ensure the sub-title is a string or None
.
method
","text":"def validate_title(self, title):\n
Ensure the title is a string or None
.
ScrollView
is a base class for line api widgets.
class
","text":" Bases: ScrollableContainer
A base class for a Widget that handles its own scrolling (i.e. doesn't rely on the compositor to render children).
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.is_scrollable","title":"is_scrollableproperty
","text":"is_scrollable: bool\n
Always scrollable.
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.refresh_lines","title":"refresh_linesmethod
","text":"def refresh_lines(self, y_start, line_count=1):\n
Refresh one or more lines.
Parameters Name Type Description Defaulty_start
int
First line to refresh.
requiredline_count
int
Total number of lines to refresh.
1
"},{"location":"api/scroll_view/#textual.scroll_view.ScrollView.scroll_to","title":"scroll_to method
","text":"def scroll_to(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to a given (absolute) coordinate, optionally animating.
Parameters Name Type Description Defaultx
float | None
X coordinate (column) to scroll to, or None
for no change.
None
y
float | None
Y coordinate (row) to scroll to, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/scrollbar/","title":"Scrollbar","text":"Implements the scrollbar-related widgets for internal use.
You will not need to use the widgets defined in this module.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar","title":"ScrollBarclass
","text":"def __init__(self, vertical=True, name=None, *, thickness=1):\n
Bases: Widget
class-attribute
","text":"renderer: Type[ScrollBarRender] = ScrollBarRender\n
The class used for rendering scrollbars. This can be overridden and set to a ScrollBarRender-derived class in order to delegate all scrollbar rendering to that class. E.g.:
class MyScrollBarRender(ScrollBarRender): ...\n\napp = MyApp()\nScrollBar.renderer = MyScrollBarRender\napp.run()\n
Because this variable is accessed through specific instances (rather than through the class ScrollBar itself) it is also possible to set this on specific scrollbar instance to change only that instance:
my_widget.horizontal_scrollbar.renderer = MyScrollBarRender\n
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_grab","title":"action_grab method
","text":"def action_grab(self):\n
Begin capturing the mouse cursor.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_scroll_down","title":"action_scroll_downmethod
","text":"def action_scroll_down(self):\n
Scroll vertical scrollbars down, horizontal scrollbars right.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBar.action_scroll_up","title":"action_scroll_upmethod
","text":"def action_scroll_up(self):\n
Scroll vertical scrollbars up, horizontal scrollbars left.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollBarCorner","title":"ScrollBarCornerclass
","text":"def __init__(self, name=None):\n
Bases: Widget
Widget which fills the gap between horizontal and vertical scrollbars, should they both be present.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollDown","title":"ScrollDownclass
","text":" Bases: ScrollMessage
Message sent when clicking below handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollLeft","title":"ScrollLeftclass
","text":" Bases: ScrollMessage
Message sent when clicking above handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollMessage","title":"ScrollMessageclass
","text":" Bases: Message
Base class for all scrollbar messages.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollRight","title":"ScrollRightclass
","text":" Bases: ScrollMessage
Message sent when clicking below handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollTo","title":"ScrollToclass
","text":"def __init__(self, x=None, y=None, animate=True):\n
Bases: ScrollMessage
Message sent when click and dragging handle.
"},{"location":"api/scrollbar/#textual.scrollbar.ScrollUp","title":"ScrollUpclass
","text":" Bases: ScrollMessage
Message sent when clicking above handle.
"},{"location":"api/strip/","title":"Strip","text":"A Strip contains the result of rendering a widget. See line API for how to use Strips.
"},{"location":"api/strip/#textual.strip.Strip","title":"Stripclass
","text":"def __init__(self, segments, cell_length=None):\n
Represents a 'strip' (horizontal line) of a Textual Widget.
A Strip is like an immutable list of Segments. The immutability allows for effective caching.
Parameters Name Type Description Defaultsegments
Iterable[Segment]
An iterable of segments.
requiredcell_length
int | None
The cell length if known, or None to calculate on demand.
None
"},{"location":"api/strip/#textual.strip.Strip.cell_length","title":"cell_length property
","text":"cell_length: int\n
Get the number of cells required to render this object.
"},{"location":"api/strip/#textual.strip.Strip.link_ids","title":"link_idsproperty
","text":"link_ids: set[str]\n
A set of the link ids in this Strip.
"},{"location":"api/strip/#textual.strip.Strip.text","title":"textproperty
","text":"text: str\n
Segment text.
"},{"location":"api/strip/#textual.strip.Strip.adjust_cell_length","title":"adjust_cell_lengthmethod
","text":"def adjust_cell_length(self, cell_length, style=None):\n
Adjust the cell length, possibly truncating or extending.
Parameters Name Type Description Defaultcell_length
int
New desired cell length.
requiredstyle
Style | None
Style when extending, or None
.
None
Returns Type Description Strip
A new strip with the supplied cell length.
"},{"location":"api/strip/#textual.strip.Strip.apply_filter","title":"apply_filtermethod
","text":"def apply_filter(self, filter, background):\n
Apply a filter to all segments in the strip.
Parameters Name Type Description Defaultfilter
LineFilter
A line filter object.
required Returns Type DescriptionStrip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.apply_style","title":"apply_stylemethod
","text":"def apply_style(self, style):\n
Apply a style to the Strip.
Parameters Name Type Description Defaultstyle
Style
A Rich style.
required Returns Type DescriptionStrip
A new strip.
"},{"location":"api/strip/#textual.strip.Strip.blank","title":"blankclassmethod
","text":"def blank(cls, cell_length, style=None):\n
Create a blank strip.
Parameters Name Type Description Defaultcell_length
int
Desired cell length.
requiredstyle
StyleType | None
Style of blank.
None
Returns Type Description Strip
New strip.
"},{"location":"api/strip/#textual.strip.Strip.crop","title":"cropmethod
","text":"def crop(self, start, end=None):\n
Crop a strip between two cell positions.
Parameters Name Type Description Defaultstart
int
The start cell position (inclusive).
requiredend
int | None
The end cell position (exclusive).
None
Returns Type Description Strip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.crop_extend","title":"crop_extendmethod
","text":"def crop_extend(self, start, end, style):\n
Crop between two points, extending the length if required.
Parameters Name Type Description Defaultstart
int
Start offset of crop.
requiredend
int
End offset of crop.
requiredstyle
Style | None
Style of additional padding.
required Returns Type DescriptionStrip
New cropped Strip.
"},{"location":"api/strip/#textual.strip.Strip.divide","title":"dividemethod
","text":"def divide(self, cuts):\n
Divide the strip in to multiple smaller strips by cutting at given (cell) indices.
Parameters Name Type Description Defaultcuts
Iterable[int]
An iterable of cell positions as ints.
required Returns Type DescriptionSequence[Strip]
A new list of strips.
"},{"location":"api/strip/#textual.strip.Strip.extend_cell_length","title":"extend_cell_lengthmethod
","text":"def extend_cell_length(self, cell_length, style=None):\n
Extend the cell length if it is less than the given value.
Parameters Name Type Description Defaultcell_length
int
Required minimum cell length.
requiredstyle
Style | None
Style for padding if the cell length is extended.
None
Returns Type Description Strip
A new Strip.
"},{"location":"api/strip/#textual.strip.Strip.from_lines","title":"from_linesclassmethod
","text":"def from_lines(cls, lines, cell_length=None):\n
Convert lines (lists of segments) to a list of Strips.
Parameters Name Type Description Defaultlines
list[list[Segment]]
List of lines, where a line is a list of segments.
requiredcell_length
int | None
Cell length of lines (must be same) or None if not known.
None
Returns Type Description list[Strip]
List of strips.
"},{"location":"api/strip/#textual.strip.Strip.index_to_cell_position","title":"index_to_cell_positionmethod
","text":"def index_to_cell_position(self, index):\n
Given a character index, return the cell position of that character. This is the sum of the cell lengths of all the characters before the character at index
.
index
int
The index to convert.
required Returns Type Descriptionint
The cell position of the character at index
.
classmethod
","text":"def join(cls, strips):\n
Join a number of strips in to one.
Parameters Name Type Description Defaultstrips
Iterable[Strip | None]
An iterable of Strips.
required Returns Type DescriptionStrip
A new combined strip.
"},{"location":"api/strip/#textual.strip.Strip.simplify","title":"simplifymethod
","text":"def simplify(self):\n
Simplify the segments (join segments with same style)
Returns Type DescriptionStrip
New strip.
"},{"location":"api/strip/#textual.strip.Strip.style_links","title":"style_linksmethod
","text":"def style_links(self, link_id, link_style):\n
Apply a style to Segments with the given link_id.
Parameters Name Type Description Defaultlink_id
str
A link id.
requiredlink_style
Style
Style to apply.
required Returns Type DescriptionStrip
New strip (or same Strip if no changes).
"},{"location":"api/strip/#textual.strip.StripRenderable","title":"StripRenderableclass
","text":"def __init__(self, strips):\n
A renderable which renders a list of strips in to lines.
"},{"location":"api/strip/#textual.strip.get_line_length","title":"get_line_lengthfunction
","text":"def get_line_length(segments):\n
Get the line length (total length of all segments).
Parameters Name Type Description Defaultsegments
Iterable[Segment]
Iterable of segments.
required Returns Type Descriptionint
Length of line in cells.
"},{"location":"api/suggester/","title":"Suggester","text":"The Suggester
class is used by the Input widget.
class
","text":"def __init__(self, suggestions, *, case_sensitive=True):\n
Bases: Suggester
Give completion suggestions based on a fixed list of options.
Examplecountries = [\"England\", \"Scotland\", \"Portugal\", \"Spain\", \"France\"]\nclass MyApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield Input(suggester=SuggestFromList(countries, case_sensitive=False))\n
If the user types P inside the input widget, a completion suggestion for \"Portugal\"
appears.
suggestions
Iterable[str]
Valid suggestions sorted by decreasing priority.
requiredcase_sensitive
bool
Whether suggestions are computed in a case sensitive manner or not. The values provided in the argument suggestions
represent the canonical representation of the completions and they will be suggested with that same casing.
True
"},{"location":"api/suggester/#textual.suggester.SuggestFromList.get_suggestion","title":"get_suggestion async
","text":"def get_suggestion(self, value):\n
Gets a completion from the given possibilities.
Parameters Name Type Description Defaultvalue
str
The current value.
required Returns Type Descriptionstr | None
A valid completion suggestion or None
.
class
","text":"def __init__(self, *, use_cache=True, case_sensitive=False):\n
Bases: ABC
Defines how widgets generate completion suggestions.
To define a custom suggester, subclass Suggester
and implement the async method get_suggestion
. See SuggestFromList
for an example.
use_cache
bool
Whether to cache suggestion results.
True
case_sensitive
bool
Whether suggestions are case sensitive or not. If they are not, incoming values are casefolded before generating the suggestion.
False
"},{"location":"api/suggester/#textual.suggester.Suggester.cache","title":"cache instance-attribute
","text":"cache: LRUCache[str, str | None] | None = (\nLRUCache(1024) if use_cache else None\n)\n
Suggestion cache, if used.
"},{"location":"api/suggester/#textual.suggester.Suggester.get_suggestion","title":"get_suggestionabstractmethod
async
","text":"def get_suggestion(self, value):\n
Try to get a completion suggestion for the given input value.
Custom suggesters should implement this method.
NoteThe value argument will be casefolded if self.case_sensitive
is False
.
If your implementation is not deterministic, you may need to disable caching.
Parameters Name Type Description Defaultvalue
str
The current value of the requester widget.
required Returns Type Descriptionstr | None
A valid suggestion or None
.
class
","text":" Bases: Message
Sent when a completion suggestion is ready.
"},{"location":"api/suggester/#textual.suggester.SuggestionReady.suggestion","title":"suggestioninstance-attribute
","text":"suggestion: str\n
The string suggestion.
"},{"location":"api/suggester/#textual.suggester.SuggestionReady.value","title":"valueinstance-attribute
","text":"value: str\n
The value to which the suggestion is for.
"},{"location":"api/system_commands_source/","title":"System commands source","text":"A command palette command provider for Textual system commands.
This is a simple command provider that makes the most obvious application actions available via the command palette.
"},{"location":"api/system_commands_source/#textual._system_commands.SystemCommands","title":"SystemCommandsclass
","text":" Bases: Provider
A source of command palette commands that run app-wide tasks.
Used by default in App.COMMANDS
.
async
","text":"def search(self, query):\n
Handle a request to search for system commands that match the query.
Parameters Name Type Description Defaultuser_input
The user input to be matched.
requiredYields:
Type DescriptionHits
Command hits for use in the command palette.
"},{"location":"api/timer/","title":"Timer","text":"Timer objects are created by set_interval or set_timer.
"},{"location":"api/timer/#textual.timer.TimerCallback","title":"TimerCallbackmodule-attribute
","text":"TimerCallback = Union[\nCallable[[], Awaitable[None]], Callable[[], None]\n]\n
Type of valid callbacks to be used with timers.
"},{"location":"api/timer/#textual.timer.Timer","title":"Timerclass
","text":"def __init__(\nself,\nevent_target,\ninterval,\n*,\nname=None,\ncallback=None,\nrepeat=None,\nskip=True,\npause=False\n):\n
A class to send timer-based events.
Parameters Name Type Description Defaultevent_target
MessageTarget
The object which will receive the timer events.
requiredinterval
float
The time between timer events, in seconds.
requiredname
str | None
A name to assign the event (for debugging).
None
callback
TimerCallback | None
A optional callback to invoke when the event is handled.
None
repeat
int | None
The number of times to repeat the timer, or None to repeat forever.
None
skip
bool
Enable skipping of scheduled events that couldn't be sent in time.
True
pause
bool
Start the timer paused.
False
"},{"location":"api/timer/#textual.timer.Timer.pause","title":"pause method
","text":"def pause(self):\n
Pause the timer.
A paused timer will not send events until it is resumed.
"},{"location":"api/timer/#textual.timer.Timer.reset","title":"resetmethod
","text":"def reset(self):\n
Reset the timer, so it starts from the beginning.
"},{"location":"api/timer/#textual.timer.Timer.resume","title":"resumemethod
","text":"def resume(self):\n
Resume a paused timer.
"},{"location":"api/timer/#textual.timer.Timer.stop","title":"stopmethod
","text":"def stop(self):\n
Stop the timer.
"},{"location":"api/types/","title":"Types","text":"Export some objects that are used by Textual and that help document other features.
"},{"location":"api/types/#textual.types.ActionParseResult","title":"ActionParseResultmodule-attribute
","text":"ActionParseResult: TypeAlias = \"tuple[str, tuple[Any, ...]]\"\n
An action is its name and the arbitrary tuple of its arguments.
"},{"location":"api/types/#textual.types.CSSPathType","title":"CSSPathTypemodule-attribute
","text":"CSSPathType: TypeAlias = Union[\nstr, PurePath, List[Union[str, PurePath]]\n]\n
Valid ways of specifying paths to CSS files.
"},{"location":"api/types/#textual.types.CallbackType","title":"CallbackTypemodule-attribute
","text":"CallbackType = Union[\nCallable[[], Awaitable[None]], Callable[[], None]\n]\n
Type used for arbitrary callables used in callbacks.
"},{"location":"api/types/#textual.types.CursorType","title":"CursorTypemodule-attribute
","text":"CursorType = Literal['cell', 'row', 'column', 'none']\n
The valid types of cursors for DataTable.cursor_type
.
module-attribute
","text":"EasingFunction = Callable[[float], float]\n
Signature for a function that parametrises animation speed.
An easing function must map the interval [0, 1] into the interval [0, 1].
"},{"location":"api/types/#textual.types.IgnoreReturnCallbackType","title":"IgnoreReturnCallbackTypemodule-attribute
","text":"IgnoreReturnCallbackType = Union[\nCallable[[], Awaitable[Any]], Callable[[], Any]\n]\n
A callback which ignores the return type.
"},{"location":"api/types/#textual.types.InputValidationOn","title":"InputValidationOnmodule-attribute
","text":"InputValidationOn = Literal['blur', 'changed', 'submitted']\n
Possible messages that trigger input validation.
"},{"location":"api/types/#textual.types.WatchCallbackType","title":"WatchCallbackTypemodule-attribute
","text":"WatchCallbackType = Union[\nCallable[[], Awaitable[None]],\nCallable[[Any], Awaitable[None]],\nCallable[[Any, Any], Awaitable[None]],\nCallable[[], None],\nCallable[[Any], None],\nCallable[[Any, Any], None],\n]\n
Type used for callbacks passed to the watch
method of widgets.
class
","text":" Bases: Protocol
Protocol for objects that can have their intrinsic values animated.
For example, the transition between two colors can be animated because the class Color
satisfies this protocol.
class
","text":" Bases: Exception
Raised when supplied CSS path(s) are invalid.
"},{"location":"api/types/#textual.types.MessageTarget","title":"MessageTargetclass
","text":" Bases: Protocol
Protocol that must be followed by objects that can receive messages.
"},{"location":"api/types/#textual.types.NoActiveAppError","title":"NoActiveAppErrorclass
","text":" Bases: RuntimeError
Runtime error raised if we try to retrieve the active app when there is none.
"},{"location":"api/types/#textual.types.RenderStyles","title":"RenderStylesclass
","text":"def __init__(self, node, base, inline_styles):\n
Bases: StylesBase
Presents a combined view of two Styles object: a base Styles and inline Styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.base","title":"baseproperty
","text":"base: Styles\n
Quick access to base (css) style.
"},{"location":"api/types/#textual.css.styles.RenderStyles.css","title":"cssproperty
","text":"css: str\n
Get the CSS for the combined styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.gutter","title":"gutterproperty
","text":"gutter: Spacing\n
Get space around widget.
Returns Type DescriptionSpacing
Space around widget content.
"},{"location":"api/types/#textual.css.styles.RenderStyles.inline","title":"inlineproperty
","text":"inline: Styles\n
Quick access to the inline styles.
"},{"location":"api/types/#textual.css.styles.RenderStyles.rich_style","title":"rich_styleproperty
","text":"rich_style: Style\n
Get a Rich style for this Styles object.
"},{"location":"api/types/#textual.css.styles.RenderStyles.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
str | float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation. Defaults to value
if not set.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/types/#textual.css.styles.RenderStyles.clear_rule","title":"clear_rule method
","text":"def clear_rule(self, rule_name):\n
Clear a rule (from inline).
"},{"location":"api/types/#textual.css.styles.RenderStyles.get_rules","title":"get_rulesmethod
","text":"def get_rules(self):\n
Get rules as a dictionary
"},{"location":"api/types/#textual.css.styles.RenderStyles.has_rule","title":"has_rulemethod
","text":"def has_rule(self, rule):\n
Check if a rule has been set.
"},{"location":"api/types/#textual.css.styles.RenderStyles.merge","title":"mergemethod
","text":"def merge(self, other):\n
Merge values from another Styles.
Parameters Name Type Description Defaultother
StylesBase
A Styles object.
required"},{"location":"api/types/#textual.css.styles.RenderStyles.reset","title":"resetmethod
","text":"def reset(self):\n
Reset the rules to initial state.
"},{"location":"api/types/#textual.types.UnusedParameter","title":"UnusedParameterclass
","text":"Helper type for a parameter that isn't specified in a method call.
"},{"location":"api/validation/","title":"Validation","text":"Framework for validating string values
"},{"location":"api/validation/#textual.validation.Failure","title":"Failureclass
","text":"Information about a validation failure.
"},{"location":"api/validation/#textual.validation.Failure.description","title":"descriptionclass-attribute
instance-attribute
","text":"description: str | None = None\n
An optional override for describing this failure. Takes precedence over any messages set in the Validator.
"},{"location":"api/validation/#textual.validation.Failure.validator","title":"validatorinstance-attribute
","text":"validator: Validator\n
The Validator which produced the failure.
"},{"location":"api/validation/#textual.validation.Failure.value","title":"valueclass-attribute
instance-attribute
","text":"value: str | None = None\n
The value which resulted in validation failing.
"},{"location":"api/validation/#textual.validation.Function","title":"Functionclass
","text":"def __init__(self, function, failure_description=None):\n
Bases: Validator
A flexible validator which allows you to provide custom validation logic.
"},{"location":"api/validation/#textual.validation.Function.function","title":"functioninstance-attribute
","text":"function = function\n
Function which takes the value to validate and returns True if valid, and False otherwise.
"},{"location":"api/validation/#textual.validation.Function.ReturnedFalse","title":"ReturnedFalseclass
","text":" Bases: Failure
Indicates validation failed because the supplied function returned False.
"},{"location":"api/validation/#textual.validation.Function.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Function.validate","title":"validatemethod
","text":"def validate(self, value):\n
Validate that the supplied function returns True.
Parameters Name Type Description Defaultvalue
str
The value to pass into the supplied function.
required Returns Type DescriptionValidationResult
A ValidationResult indicating success if the function returned True, and failure if the function return False.
"},{"location":"api/validation/#textual.validation.Integer","title":"Integerclass
","text":" Bases: Number
Validator which ensures the value is an integer which falls within a range.
"},{"location":"api/validation/#textual.validation.Integer.NotAnInteger","title":"NotAnIntegerclass
","text":" Bases: Failure
Indicates a failure due to the value not being a valid integer.
"},{"location":"api/validation/#textual.validation.Integer.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Integer.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value
is an integer, optionally within a range.
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Length","title":"Lengthclass
","text":"def __init__(\nself,\nminimum=None,\nmaximum=None,\nfailure_description=None,\n):\n
Bases: Validator
Validate that a string is within a range (inclusive).
"},{"location":"api/validation/#textual.validation.Length.maximum","title":"maximuminstance-attribute
","text":"maximum = maximum\n
The inclusive maximum length of the value, or None if unbounded.
"},{"location":"api/validation/#textual.validation.Length.minimum","title":"minimuminstance-attribute
","text":"minimum = minimum\n
The inclusive minimum length of the value, or None if unbounded.
"},{"location":"api/validation/#textual.validation.Length.Incorrect","title":"Incorrectclass
","text":" Bases: Failure
Indicates a failure due to the length of the value being outside the range.
"},{"location":"api/validation/#textual.validation.Length.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Length.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value falls within the maximum and minimum length constraints.
Parameters Name Type Description Defaultvalue
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Number","title":"Numberclass
","text":"def __init__(\nself,\nminimum=None,\nmaximum=None,\nfailure_description=None,\n):\n
Bases: Validator
Validator that ensures the value is a number, with an optional range check.
"},{"location":"api/validation/#textual.validation.Number.maximum","title":"maximuminstance-attribute
","text":"maximum = maximum\n
The maximum value of the number, inclusive. If None
, the maximum is unbounded.
instance-attribute
","text":"minimum = minimum\n
The minimum value of the number, inclusive. If None
, the minimum is unbounded.
class
","text":" Bases: Failure
Indicates a failure due to the value not being a valid number (decimal/integer, inc. scientific notation)
"},{"location":"api/validation/#textual.validation.Number.NotInRange","title":"NotInRangeclass
","text":" Bases: Failure
Indicates a failure due to the number not being within the range [minimum, maximum].
"},{"location":"api/validation/#textual.validation.Number.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Number.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that value
is a valid number, optionally within a range.
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.Regex","title":"Regexclass
","text":"def __init__(self, regex, flags=0, failure_description=None):\n
Bases: Validator
A validator that checks the value matches a regex (via re.fullmatch
).
instance-attribute
","text":"flags = flags\n
The flags to pass to re.fullmatch
.
instance-attribute
","text":"regex = regex\n
The regex which we'll validate is matched by the value.
"},{"location":"api/validation/#textual.validation.Regex.NoResults","title":"NoResultsclass
","text":" Bases: Failure
Indicates validation failed because the regex could not be found within the value string.
"},{"location":"api/validation/#textual.validation.Regex.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Regex.validate","title":"validatemethod
","text":"def validate(self, value):\n
Ensure that the value matches the regex.
Parameters Name Type Description Defaultvalue
str
The value that should match the regex.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.URL","title":"URLclass
","text":" Bases: Validator
Validator that checks if a URL is valid (ensuring a scheme is present).
"},{"location":"api/validation/#textual.validation.URL.InvalidURL","title":"InvalidURLclass
","text":" Bases: Failure
Indicates that the URL is not valid.
"},{"location":"api/validation/#textual.validation.URL.describe_failure","title":"describe_failuremethod
","text":"def describe_failure(self, failure):\n
Describes why the validator failed.
Parameters Name Type Description Defaultfailure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.URL.validate","title":"validatemethod
","text":"def validate(self, value):\n
Validates that value
is a valid URL (contains a scheme).
value
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/validation/#textual.validation.ValidationResult","title":"ValidationResultclass
","text":"The result of calling a Validator.validate
method.
property
","text":"failure_descriptions: list[str]\n
Utility for extracting failure descriptions as strings.
Useful if you don't care about the additional metadata included in the Failure
objects.
list[str]
A list of the string descriptions explaining the failing validations.
"},{"location":"api/validation/#textual.validation.ValidationResult.failures","title":"failuresclass-attribute
instance-attribute
","text":"failures: Sequence[Failure] = field(default_factory=list)\n
A list of reasons why the value was invalid. Empty if valid=True
"},{"location":"api/validation/#textual.validation.ValidationResult.is_valid","title":"is_validproperty
","text":"is_valid: bool\n
True if the validation was successful.
"},{"location":"api/validation/#textual.validation.ValidationResult.failure","title":"failurestaticmethod
","text":"def failure(failures):\n
Construct a failure ValidationResult.
Parameters Name Type Description Defaultfailures
Sequence[Failure]
The failures.
required Returns Type DescriptionValidationResult
A failure ValidationResult.
"},{"location":"api/validation/#textual.validation.ValidationResult.merge","title":"mergestaticmethod
","text":"def merge(results):\n
Merge multiple ValidationResult objects into one.
Parameters Name Type Description Defaultresults
Sequence['ValidationResult']
List of ValidationResult objects to merge.
required Returns Type Description'ValidationResult'
Merged ValidationResult object.
"},{"location":"api/validation/#textual.validation.ValidationResult.success","title":"successstaticmethod
","text":"def success():\n
Construct a successful ValidationResult.
Returns Type DescriptionValidationResult
A successful ValidationResult.
"},{"location":"api/validation/#textual.validation.Validator","title":"Validatorclass
","text":"def __init__(self, failure_description=None):\n
Bases: ABC
Base class for the validation of string values.
Commonly used in conjunction with the Input
widget, which accepts a list of validators via its constructor. This validation framework can also be used to validate any 'stringly-typed' values (for example raw command line input from sys.args
).
To implement your own Validator
, subclass this class.
class Palindrome(Validator):\ndef validate(self, value: str) -> ValidationResult:\ndef is_palindrome(value: str) -> bool:\nreturn value == value[::-1]\nreturn self.success() if is_palindrome(value) else self.failure(\"Not palindrome!\")\n
"},{"location":"api/validation/#textual.validation.Validator.failure_description","title":"failure_description instance-attribute
","text":"failure_description = failure_description\n
A description of why the validation failed.
The description (intended to be user-facing) to attached to the Failure if the validation fails. This failure description is ultimately accessible at the time of validation failure via the Input.Changed
or Input.Submitted
event, and you can access it on your message handler (a method called, for example, on_input_changed
or a method decorated with @on(Input.Changed)
.
method
","text":"def describe_failure(self, failure):\n
Return a string description of the Failure.
Used to provide a more fine-grained description of the failure. A Validator could fail for multiple reasons, so this method could be used to provide a different reason for different types of failure.
Warning
This method is only called if no other description has been supplied. If you supply a description inside a call to self.failure(description=\"...\")
, or pass a description into the constructor of the validator, those will take priority, and this method won't be called.
failure
Failure
Information about why the validation failed.
required Returns Type Descriptionstr | None
A string description of the failure.
"},{"location":"api/validation/#textual.validation.Validator.failure","title":"failuremethod
","text":"def failure(self, description=None, value=None, failures=None):\n
Shorthand for signaling validation failure.
You can return failure(...) from a Validator.validate
implementation to signal validation succeeded.
description
str | None
The failure description that will be used. When used in conjunction with the Input widget, this is the description that will ultimately be available inside the handler for Input.Changed
. If not supplied, the failure_description
from the Validator
will be used. If that is not supplied either, then the describe_failure
method on Validator
will be called.
None
value
str | None
The value that was considered invalid. This is optional, and only needs to be supplied if required in your Input.Changed
handler.
None
failures
Failure | Sequence[Failure] | None
The reasons the validator failed. If not supplied, a generic Failure
will be included in the ValidationResult returned from this function.
None
Returns Type Description ValidationResult
A ValidationResult representing failed validation, and containing the metadata supplied to this function.
"},{"location":"api/validation/#textual.validation.Validator.success","title":"successmethod
","text":"def success(self):\n
Shorthand for ValidationResult(True)
.
You can return success() from a Validator.validate
method implementation to signal that validation has succeeded.
ValidationResult
A ValidationResult indicating validation succeeded.
"},{"location":"api/validation/#textual.validation.Validator.validate","title":"validateabstractmethod
","text":"def validate(self, value):\n
Validate the value and return a ValidationResult describing the outcome of the validation.
Parameters Name Type Description Defaultvalue
str
The value to validate.
required Returns Type DescriptionValidationResult
The result of the validation.
"},{"location":"api/walk/","title":"Walk","text":"Functions for walking the DOM.
Note
For most purposes you would be better off using query, which uses these functions internally.
"},{"location":"api/walk/#textual.walk.walk_breadth_first","title":"walk_breadth_firstfunction
","text":"def walk_breadth_first(\nroot, filter_type=None, *, with_root=True\n):\n
Walk the tree breadth first (children first).
Note
Avoid changing the DOM (mounting, removing etc.) while iterating with this function. Consider walk_children which doesn't have this limitation.
Parameters Name Type Description Defaultroot
DOMNode
The root note (starting point).
requiredfilter_type
type[WalkType] | None
Optional DOMNode subclass to filter by, or None
for no filter.
None
with_root
bool
Include the root in the walk.
True
Returns Type Description Iterable[DOMNode] | Iterable[WalkType]
An iterable of DOMNodes, or the type specified in filter_type
.
function
","text":"def walk_depth_first(root, filter_type=None, *, with_root=True):\n
Walk the tree depth first (parents first).
Note
Avoid changing the DOM (mounting, removing etc.) while iterating with this function. Consider walk_children which doesn't have this limitation.
Parameters Name Type Description Defaultroot
DOMNode
The root note (starting point).
requiredfilter_type
type[WalkType] | None
Optional DOMNode subclass to filter by, or None
for no filter.
None
with_root
bool
Include the root in the walk.
True
Returns Type Description Iterable[DOMNode] | Iterable[WalkType]
An iterable of DOMNodes, or the type specified in filter_type
.
The base class for widgets.
"},{"location":"api/widget/#textual.widget.AwaitMount","title":"AwaitMountclass
","text":"def __init__(self, parent, widgets):\n
An optional awaitable returned by mount and mount_all.
Exampleawait self.mount(Static(\"foo\"))\n
"},{"location":"api/widget/#textual.widget.MountError","title":"MountError class
","text":" Bases: WidgetError
Error raised when there was a problem with the mount request.
"},{"location":"api/widget/#textual.widget.PseudoClasses","title":"PseudoClassesclass
","text":" Bases: NamedTuple
Used for render/render_line based widgets that use caching. This structure can be used as a cache-key.
"},{"location":"api/widget/#textual.widget.PseudoClasses.enabled","title":"enabledinstance-attribute
","text":"enabled: bool\n
Is 'enabled' applied?
"},{"location":"api/widget/#textual.widget.PseudoClasses.focus","title":"focusinstance-attribute
","text":"focus: bool\n
Is 'focus' applied?
"},{"location":"api/widget/#textual.widget.PseudoClasses.hover","title":"hoverinstance-attribute
","text":"hover: bool\n
Is 'hover' applied?
"},{"location":"api/widget/#textual.widget.Widget","title":"Widgetclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: DOMNode
A Widget is the base class for Textual widgets.
See also static for starting point for your own widgets.
Parameters Name Type Description Default*children
Widget
Child widgets.
()
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"api/widget/#textual.widget.Widget.BORDER_SUBTITLE","title":"BORDER_SUBTITLE class-attribute
","text":"BORDER_SUBTITLE: str = ''\n
Initial value for border_subtitle attribute.
"},{"location":"api/widget/#textual.widget.Widget.BORDER_TITLE","title":"BORDER_TITLEclass-attribute
","text":"BORDER_TITLE: str = ''\n
Initial value for border_title attribute.
"},{"location":"api/widget/#textual.widget.Widget.allow_horizontal_scroll","title":"allow_horizontal_scrollproperty
","text":"allow_horizontal_scroll: bool\n
Check if horizontal scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"},{"location":"api/widget/#textual.widget.Widget.allow_vertical_scroll","title":"allow_vertical_scrollproperty
","text":"allow_vertical_scroll: bool\n
Check if vertical scroll is permitted.
May be overridden if you want different logic regarding allowing scrolling.
"},{"location":"api/widget/#textual.widget.Widget.auto_links","title":"auto_linksclass-attribute
instance-attribute
","text":"auto_links: Reactive[bool] = Reactive(True)\n
Widget will highlight links automatically.
"},{"location":"api/widget/#textual.widget.Widget.border_subtitle","title":"border_subtitleclass-attribute
instance-attribute
","text":"border_subtitle: str | Text | None = _BorderTitle()\n
A title to show in the bottom border (if there is one).
"},{"location":"api/widget/#textual.widget.Widget.border_title","title":"border_titleclass-attribute
instance-attribute
","text":"border_title: str | Text | None = _BorderTitle()\n
A title to show in the top border (if there is one).
"},{"location":"api/widget/#textual.widget.Widget.can_focus","title":"can_focusclass-attribute
instance-attribute
","text":"can_focus: bool = False\n
Widget may receive focus.
"},{"location":"api/widget/#textual.widget.Widget.can_focus_children","title":"can_focus_childrenclass-attribute
instance-attribute
","text":"can_focus_children: bool = True\n
Widget's children may receive focus.
"},{"location":"api/widget/#textual.widget.Widget.container_size","title":"container_sizeproperty
","text":"container_size: Size\n
The size of the container (parent widget).
Returns Type DescriptionSize
Container size.
"},{"location":"api/widget/#textual.widget.Widget.container_viewport","title":"container_viewportproperty
","text":"container_viewport: Region\n
The viewport region (parent window).
Returns Type DescriptionRegion
The region that contains this widget.
"},{"location":"api/widget/#textual.widget.Widget.content_offset","title":"content_offsetproperty
","text":"content_offset: Offset\n
An offset from the Widget origin where the content begins.
Returns Type DescriptionOffset
Offset from widget's origin.
"},{"location":"api/widget/#textual.widget.Widget.content_region","title":"content_regionproperty
","text":"content_region: Region\n
Gets an absolute region containing the content (minus padding and border).
Returns Type DescriptionRegion
Screen region that contains a widget's content.
"},{"location":"api/widget/#textual.widget.Widget.content_size","title":"content_sizeproperty
","text":"content_size: Size\n
The size of the content area.
Returns Type DescriptionSize
Content area size.
"},{"location":"api/widget/#textual.widget.Widget.disabled","title":"disabledclass-attribute
instance-attribute
","text":"disabled: Reactive[bool] = disabled\n
Is the widget disabled? Disabled widgets can not be interacted with, and are typically styled to look dimmer.
"},{"location":"api/widget/#textual.widget.Widget.dock_gutter","title":"dock_gutterproperty
","text":"dock_gutter: Spacing\n
Space allocated to docks in the parent.
Returns Type DescriptionSpacing
Space to be subtracted from scrollable area.
"},{"location":"api/widget/#textual.widget.Widget.expand","title":"expandclass-attribute
instance-attribute
","text":"expand: Reactive[bool] = Reactive(False)\n
Rich renderable may expand beyond optimal size.
"},{"location":"api/widget/#textual.widget.Widget.focusable","title":"focusableproperty
","text":"focusable: bool\n
Can this widget currently be focused?
"},{"location":"api/widget/#textual.widget.Widget.gutter","title":"gutterproperty
","text":"gutter: Spacing\n
Spacing for padding / border / scrollbars.
Returns Type DescriptionSpacing
Additional spacing around content area.
"},{"location":"api/widget/#textual.widget.Widget.has_focus","title":"has_focusclass-attribute
instance-attribute
","text":"has_focus: Reactive[bool] = Reactive(False, repaint=False)\n
Does this widget have focus? Read only.
"},{"location":"api/widget/#textual.widget.Widget.highlight_link_id","title":"highlight_link_idclass-attribute
instance-attribute
","text":"highlight_link_id: Reactive[str] = Reactive('')\n
The currently highlighted link id. Read only.
"},{"location":"api/widget/#textual.widget.Widget.horizontal_scrollbar","title":"horizontal_scrollbarproperty
","text":"horizontal_scrollbar: ScrollBar\n
The horizontal scrollbar.
NoteThis will create a scrollbar if one doesn't exist.
Returns Type DescriptionScrollBar
ScrollBar Widget.
"},{"location":"api/widget/#textual.widget.Widget.hover_style","title":"hover_styleclass-attribute
instance-attribute
","text":"hover_style: Reactive[Style] = Reactive(\nStyle, repaint=False\n)\n
The current hover style (style under the mouse cursor). Read only.
"},{"location":"api/widget/#textual.widget.Widget.is_container","title":"is_containerproperty
","text":"is_container: bool\n
Is this widget a container (contains other widgets)?
"},{"location":"api/widget/#textual.widget.Widget.is_horizontal_scroll_end","title":"is_horizontal_scroll_endproperty
","text":"is_horizontal_scroll_end: bool\n
Is the horizontal scroll position at the maximum?
"},{"location":"api/widget/#textual.widget.Widget.is_horizontal_scrollbar_grabbed","title":"is_horizontal_scrollbar_grabbedproperty
","text":"is_horizontal_scrollbar_grabbed: bool\n
Is the user dragging the vertical scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.is_scrollable","title":"is_scrollableproperty
","text":"is_scrollable: bool\n
Can this widget be scrolled?
"},{"location":"api/widget/#textual.widget.Widget.is_vertical_scroll_end","title":"is_vertical_scroll_endproperty
","text":"is_vertical_scroll_end: bool\n
Is the vertical scroll position at the maximum?
"},{"location":"api/widget/#textual.widget.Widget.is_vertical_scrollbar_grabbed","title":"is_vertical_scrollbar_grabbedproperty
","text":"is_vertical_scrollbar_grabbed: bool\n
Is the user dragging the vertical scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.layer","title":"layerproperty
","text":"layer: str\n
Get the name of this widgets layer.
Returns Type Descriptionstr
Name of layer.
"},{"location":"api/widget/#textual.widget.Widget.layers","title":"layersproperty
","text":"layers: tuple[str, ...]\n
Layers of from parent.
Returns Type Descriptiontuple[str, ...]
Tuple of layer names.
"},{"location":"api/widget/#textual.widget.Widget.link_hover_style","title":"link_hover_styleproperty
","text":"link_hover_style: Style\n
Style of links underneath the mouse cursor.
Returns Type DescriptionStyle
Rich Style.
"},{"location":"api/widget/#textual.widget.Widget.link_style","title":"link_styleproperty
","text":"link_style: Style\n
Style of links.
Returns Type DescriptionStyle
Rich style.
"},{"location":"api/widget/#textual.widget.Widget.max_scroll_x","title":"max_scroll_xproperty
","text":"max_scroll_x: int\n
The maximum value of scroll_x
.
property
","text":"max_scroll_y: int\n
The maximum value of scroll_y
.
class-attribute
instance-attribute
","text":"mouse_over: Reactive[bool] = Reactive(False, repaint=False)\n
Is the mouse over this widget? Read only.
"},{"location":"api/widget/#textual.widget.Widget.offset","title":"offsetwritable
property
","text":"offset: Offset\n
Widget offset from origin.
Returns Type DescriptionOffset
Relative offset.
"},{"location":"api/widget/#textual.widget.Widget.opacity","title":"opacityproperty
","text":"opacity: float\n
Total opacity of widget.
"},{"location":"api/widget/#textual.widget.Widget.outer_size","title":"outer_sizeproperty
","text":"outer_size: Size\n
The size of the widget (including padding and border).
Returns Type DescriptionSize
Outer size.
"},{"location":"api/widget/#textual.widget.Widget.region","title":"regionproperty
","text":"region: Region\n
The region occupied by this widget, relative to the Screen.
Raises Type DescriptionNoScreen
If there is no screen.
errors.NoWidget
If the widget is not on the screen.
Returns Type DescriptionRegion
Region within screen occupied by widget.
"},{"location":"api/widget/#textual.widget.Widget.scroll_offset","title":"scroll_offsetproperty
","text":"scroll_offset: Offset\n
Get the current scroll offset.
Returns Type DescriptionOffset
Offset a container has been scrolled by.
"},{"location":"api/widget/#textual.widget.Widget.scroll_x","title":"scroll_xclass-attribute
instance-attribute
","text":"scroll_x: Reactive[float] = Reactive(\n0.0, repaint=False, layout=False\n)\n
The scroll position on the X axis.
"},{"location":"api/widget/#textual.widget.Widget.scroll_y","title":"scroll_yclass-attribute
instance-attribute
","text":"scroll_y: Reactive[float] = Reactive(\n0.0, repaint=False, layout=False\n)\n
The scroll position on the Y axis.
"},{"location":"api/widget/#textual.widget.Widget.scrollable_content_region","title":"scrollable_content_regionproperty
","text":"scrollable_content_region: Region\n
Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars).
Returns Type DescriptionRegion
Screen region that contains a widget's content.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_corner","title":"scrollbar_cornerproperty
","text":"scrollbar_corner: ScrollBarCorner\n
The scrollbar corner.
NoteThis will create a scrollbar corner if one doesn't exist.
Returns Type DescriptionScrollBarCorner
ScrollBarCorner Widget.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_gutter","title":"scrollbar_gutterproperty
","text":"scrollbar_gutter: Spacing\n
Spacing required to fit scrollbar(s).
Returns Type DescriptionSpacing
Scrollbar gutter spacing.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_size_horizontal","title":"scrollbar_size_horizontalproperty
","text":"scrollbar_size_horizontal: int\n
Get the height used by the horizontal scrollbar.
Returns Type Descriptionint
Number of rows in the horizontal scrollbar.
"},{"location":"api/widget/#textual.widget.Widget.scrollbar_size_vertical","title":"scrollbar_size_verticalproperty
","text":"scrollbar_size_vertical: int\n
Get the width used by the vertical scrollbar.
Returns Type Descriptionint
Number of columns in the vertical scrollbar.
"},{"location":"api/widget/#textual.widget.Widget.scrollbars_enabled","title":"scrollbars_enabledproperty
","text":"scrollbars_enabled: tuple[bool, bool]\n
A tuple of booleans that indicate if scrollbars are enabled.
Returns Type Descriptiontuple[bool, bool]
A tuple of (, )"},{"location":"api/widget/#textual.widget.Widget.scrollbars_space","title":"scrollbars_space property
","text":"
scrollbars_space: tuple[int, int]\n
The number of cells occupied by scrollbars for width and height
"},{"location":"api/widget/#textual.widget.Widget.show_horizontal_scrollbar","title":"show_horizontal_scrollbarclass-attribute
instance-attribute
","text":"show_horizontal_scrollbar: Reactive[bool] = Reactive(\nFalse, layout=True\n)\n
Show a horizontal scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.show_vertical_scrollbar","title":"show_vertical_scrollbarclass-attribute
instance-attribute
","text":"show_vertical_scrollbar: Reactive[bool] = Reactive(\nFalse, layout=True\n)\n
Show a horizontal scrollbar?
"},{"location":"api/widget/#textual.widget.Widget.shrink","title":"shrinkclass-attribute
instance-attribute
","text":"shrink: Reactive[bool] = Reactive(True)\n
Rich renderable may shrink below optimal size.
"},{"location":"api/widget/#textual.widget.Widget.siblings","title":"siblingsproperty
","text":"siblings: list[Widget]\n
Get the widget's siblings (self is removed from the return list).
Returns Type Descriptionlist[Widget]
A list of siblings.
"},{"location":"api/widget/#textual.widget.Widget.size","title":"sizeproperty
","text":"size: Size\n
The size of the content area.
Returns Type DescriptionSize
Content area size.
"},{"location":"api/widget/#textual.widget.Widget.tooltip","title":"tooltipwritable
property
","text":"tooltip: RenderableType | None\n
Tooltip for the widget, or None
for no tooltip.
property
","text":"vertical_scrollbar: ScrollBar\n
The vertical scrollbar (create if necessary).
NoteThis will create a scrollbar if one doesn't exist.
Returns Type DescriptionScrollBar
ScrollBar Widget.
"},{"location":"api/widget/#textual.widget.Widget.virtual_region","title":"virtual_regionproperty
","text":"virtual_region: Region\n
The widget region relative to it's container (which may not be visible, depending on scroll offset).
Returns Type DescriptionRegion
The virtual region.
"},{"location":"api/widget/#textual.widget.Widget.virtual_region_with_margin","title":"virtual_region_with_marginproperty
","text":"virtual_region_with_margin: Region\n
The widget region relative to its container (including margin), which may not be visible, depending on the scroll offset.
Returns Type DescriptionRegion
The virtual region of the Widget, inclusive of its margin.
"},{"location":"api/widget/#textual.widget.Widget.virtual_size","title":"virtual_sizeclass-attribute
instance-attribute
","text":"virtual_size: Reactive[Size] = Reactive(\nSize(0, 0), layout=True\n)\n
The virtual (scrollable) size of the widget.
"},{"location":"api/widget/#textual.widget.Widget.visible_siblings","title":"visible_siblingsproperty
","text":"visible_siblings: list[Widget]\n
A list of siblings which will be shown.
Returns Type Descriptionlist[Widget]
List of siblings.
"},{"location":"api/widget/#textual.widget.Widget.window_region","title":"window_regionproperty
","text":"window_region: Region\n
The region within the scrollable area that is currently visible.
Returns Type DescriptionRegion
New region.
"},{"location":"api/widget/#textual.widget.Widget.animate","title":"animatemethod
","text":"def animate(\nself,\nattribute,\nvalue,\n*,\nfinal_value=Ellipsis,\nduration=None,\nspeed=None,\ndelay=0.0,\neasing=DEFAULT_EASING,\non_complete=None\n):\n
Animate an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute to animate.
requiredvalue
float | Animatable
The value to animate to.
requiredfinal_value
object
The final value of the animation. Defaults to value
if not set.
Ellipsis
duration
float | None
The duration of the animate.
None
speed
float | None
The speed of the animation.
None
delay
float
A delay (in seconds) before the animation starts.
0.0
easing
EasingFunction | str
An easing method.
DEFAULT_EASING
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.begin_capture_print","title":"begin_capture_print method
","text":"def begin_capture_print(self, stdout=True, stderr=True):\n
Capture text from print statements (or writes to stdout / stderr).
If printing is captured, the widget will be sent an events.Print message.
Call end_capture_print to disable print capture.
Parameters Name Type Description Defaultstdout
bool
Capture stdout.
True
stderr
bool
Capture stderr.
True
"},{"location":"api/widget/#textual.widget.Widget.blur","title":"blur method
","text":"def blur(self):\n
Blur (un-focus) the widget.
Focus will be moved to the next available widget in the focus chain..
Returns Type DescriptionSelf
The Widget
instance.
method
","text":"def can_view(self, widget):\n
Check if a given widget is in the current view (scrollable area).
Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible.
Parameters Name Type Description Defaultwidget
Widget
A widget that is a descendant of self.
required Returns Type Descriptionbool
True if the entire widget is in view, False if it is partially visible or not in view.
"},{"location":"api/widget/#textual.widget.Widget.capture_mouse","title":"capture_mousemethod
","text":"def capture_mouse(self, capture=True):\n
Capture (or release) the mouse.
When captured, mouse events will go to this widget even when the pointer is not directly over the widget.
Parameters Name Type Description Defaultcapture
bool
True to capture or False to release.
True
"},{"location":"api/widget/#textual.widget.Widget.check_message_enabled","title":"check_message_enabled method
","text":"def check_message_enabled(self, message):\n
Check if a given message is enabled (allowed to be sent).
Parameters Name Type Description Defaultmessage
Message
A message object
required Returns Type Descriptionbool
True
if the message will be sent, or False
if it is disabled.
method
","text":"def compose(self):\n
Called by Textual to create child widgets.
Extend this to build a UI.
Exampledef compose(self) -> ComposeResult:\nyield Header()\nyield Container(\nTree(), Viewer()\n)\nyield Footer()\n
"},{"location":"api/widget/#textual.widget.Widget.end_capture_print","title":"end_capture_print method
","text":"def end_capture_print(self):\n
End print capture (set with capture_print).
"},{"location":"api/widget/#textual.widget.Widget.focus","title":"focusmethod
","text":"def focus(self, scroll_visible=True):\n
Give focus to this widget.
Parameters Name Type Description Defaultscroll_visible
bool
Scroll parent to make this widget visible.
True
Returns Type Description Self
The Widget
instance.
method
","text":"def get_child_by_id(self, id, expect_type=None):\n
Return the first child (immediate descendent) of this node with the given ID.
Parameters Name Type Description Defaultid
str
The ID of the child.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type.
None
Returns Type Description ExpectType | Widget
The first child of this node with the ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID
WrongType
if the wrong type was found.
"},{"location":"api/widget/#textual.widget.Widget.get_child_by_type","title":"get_child_by_typemethod
","text":"def get_child_by_type(self, expect_type):\n
Get a child of a give type.
Parameters Name Type Description Defaultexpect_type
type[ExpectType]
The type of the expected child.
required Raises Type DescriptionNoMatches
If no valid child is found.
Returns Type DescriptionExpectType
A widget.
"},{"location":"api/widget/#textual.widget.Widget.get_component_rich_style","title":"get_component_rich_stylemethod
","text":"def get_component_rich_style(self, name, *, partial=False):\n
Get a Rich style for a component.
Parameters Name Type Description Defaultname
str
Name of component.
requiredpartial
bool
Return a partial style (not combined with parent).
False
Returns Type Description Style
A Rich style object.
"},{"location":"api/widget/#textual.widget.Widget.get_content_height","title":"get_content_heightmethod
","text":"def get_content_height(self, container, viewport, width):\n
Called by Textual to get the height of the content area. May be overridden in a subclass.
Parameters Name Type Description Defaultcontainer
Size
Size of the container (immediate parent) widget.
requiredviewport
Size
Size of the viewport.
requiredwidth
int
Width of renderable.
required Returns Type Descriptionint
The height of the content.
"},{"location":"api/widget/#textual.widget.Widget.get_content_width","title":"get_content_widthmethod
","text":"def get_content_width(self, container, viewport):\n
Called by textual to get the width of the content area. May be overridden in a subclass.
Parameters Name Type Description Defaultcontainer
Size
Size of the container (immediate parent) widget.
requiredviewport
Size
Size of the viewport.
required Returns Type Descriptionint
The optimal width of the content.
"},{"location":"api/widget/#textual.widget.Widget.get_pseudo_class_state","title":"get_pseudo_class_statemethod
","text":"def get_pseudo_class_state(self):\n
Get an object describing whether each pseudo class is present on this object or not.
Returns Type DescriptionPseudoClasses
A PseudoClasses object describing the pseudo classes that are present.
"},{"location":"api/widget/#textual.widget.Widget.get_pseudo_classes","title":"get_pseudo_classesmethod
","text":"def get_pseudo_classes(self):\n
Pseudo classes for a widget.
Returns Type DescriptionIterable[str]
Names of the pseudo classes.
"},{"location":"api/widget/#textual.widget.Widget.get_style_at","title":"get_style_atmethod
","text":"def get_style_at(self, x, y):\n
Get the Rich style in a widget at a given relative offset.
Parameters Name Type Description Defaultx
int
X coordinate relative to the widget.
requiredy
int
Y coordinate relative to the widget.
required Returns Type DescriptionStyle
A rich Style object.
"},{"location":"api/widget/#textual.widget.Widget.get_widget_by_id","title":"get_widget_by_idmethod
","text":"def get_widget_by_id(self, id, expect_type=None):\n
Return the first descendant widget with the given ID.
Performs a depth-first search rooted at this widget.
Parameters Name Type Description Defaultid
str
The ID to search for in the subtree.
requiredexpect_type
type[ExpectType] | None
Require the object be of the supplied type, or None for any type.
None
Returns Type Description ExpectType | Widget
The first descendant encountered with this ID.
Raises Type DescriptionNoMatches
if no children could be found for this ID.
WrongType
if the wrong type was found.
"},{"location":"api/widget/#textual.widget.Widget.mount","title":"mountmethod
","text":"def mount(self, *widgets, before=None, after=None):\n
Mount widgets below this widget (making this widget a container).
Parameters Name Type Description Default*widgets
Widget
The widget(s) to mount.
()
before
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def mount_all(self, widgets, *, before=None, after=None):\n
Mount widgets from an iterable.
Parameters Name Type Description Defaultwidgets
Iterable[Widget]
An iterable of widgets.
requiredbefore
int | str | Widget | None
Optional location to mount before. An int
is the index of the child to mount before, a str
is a query_one
query to find the widget to mount before.
None
after
int | str | Widget | None
Optional location to mount after. An int
is the index of the child to mount after, a str
is a query_one
query to find the widget to mount after.
None
Returns Type Description AwaitMount
An awaitable object that waits for widgets to be mounted.
Raises Type DescriptionMountError
If there is a problem with the mount request.
NoteOnly one of before
or after
can be provided. If both are provided a MountError
will be raised.
method
","text":"def move_child(self, child, before=None, after=None):\n
Move a child widget within its parent's list of children.
Parameters Name Type Description Defaultchild
int | Widget
The child widget to move.
requiredbefore
int | Widget | None
Child widget or location index to move before.
None
after
int | Widget | None
Child widget or location index to move after.
None
Raises Type Description WidgetError
If there is a problem with the child or target.
NoteOnly one of before
or after
can be provided. If neither or both are provided a WidgetError
will be raised.
method
","text":"def notify(\nself,\nmessage,\n*,\ntitle=\"\",\nseverity=\"information\",\ntimeout=Notification.timeout\n):\n
Create a notification.
Tip
This method is thread-safe.
Parameters Name Type Description Defaultmessage
str
The message for the notification.
requiredtitle
str
The title for the notification.
''
severity
SeverityLevel
The severity of the notification.
'information'
timeout
float
The timeout for the notification.
Notification.timeout
See App.notify
for the full documentation for this method.
method
","text":"def post_message(self, message):\n
Post a message to this widget.
Parameters Name Type Description Defaultmessage
Message
Message to post.
required Returns Type Descriptionbool
True if the message was posted, False if this widget was closed / closing.
"},{"location":"api/widget/#textual.widget.Widget.post_render","title":"post_rendermethod
","text":"def post_render(self, renderable):\n
Applies style attributes to the default renderable.
Returns Type DescriptionConsoleRenderable
A new renderable.
"},{"location":"api/widget/#textual.widget.Widget.refresh","title":"refreshmethod
","text":"def refresh(self, *regions, repaint=True, layout=False):\n
Initiate a refresh of the widget.
This method sets an internal flag to perform a refresh, which will be done on the next idle event. Only one refresh will be done even if this method is called multiple times.
By default this method will cause the content of the widget to refresh, but not change its size. You can also set layout=True
to perform a layout.
Warning
It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will do this automatically.
Parameters Name Type Description Default*regions
Region
Additional screen regions to mark as dirty.
()
repaint
bool
Repaint the widget (will call render() again).
True
layout
bool
Also layout widgets in the view.
False
Returns Type Description Self
The Widget
instance.
method
","text":"def release_mouse(self):\n
Release the mouse.
Mouse events will only be sent when the mouse is over the widget.
"},{"location":"api/widget/#textual.widget.Widget.remove","title":"removemethod
","text":"def remove(self):\n
Remove the Widget from the DOM (effectively deleting it).
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the widget to be removed.
"},{"location":"api/widget/#textual.widget.Widget.remove_children","title":"remove_childrenmethod
","text":"def remove_children(self):\n
Remove all children of this Widget from the DOM.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the children to be removed.
"},{"location":"api/widget/#textual.widget.Widget.render","title":"rendermethod
","text":"def render(self):\n
Get renderable for widget.
Returns Type DescriptionRenderableType
Any renderable.
"},{"location":"api/widget/#textual.widget.Widget.render_line","title":"render_linemethod
","text":"def render_line(self, y):\n
Render a line of content.
Parameters Name Type Description Defaulty
int
Y Coordinate of line.
required Returns Type DescriptionStrip
A rendered line.
"},{"location":"api/widget/#textual.widget.Widget.render_lines","title":"render_linesmethod
","text":"def render_lines(self, crop):\n
Render the widget in to lines.
Parameters Name Type Description Defaultcrop
Region
Region within visible area to render.
required Returns Type Descriptionlist[Strip]
A list of list of segments.
"},{"location":"api/widget/#textual.widget.Widget.render_str","title":"render_strmethod
","text":"def render_str(self, text_content):\n
Convert str in to a Text object.
If you pass in an existing Text object it will be returned unaltered.
Parameters Name Type Description Defaulttext_content
str | Text
Text or str.
required Returns Type DescriptionText
A text object.
"},{"location":"api/widget/#textual.widget.Widget.run_action","title":"run_actionasync
","text":"def run_action(self, action):\n
Perform a given action, with this widget as the default namespace.
Parameters Name Type Description Defaultaction
str
Action encoded as a string.
required"},{"location":"api/widget/#textual.widget.Widget.scroll_down","title":"scroll_downmethod
","text":"def scroll_down(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one line down.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_end","title":"scroll_end method
","text":"def scroll_end(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to the end of the container.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_home","title":"scroll_home method
","text":"def scroll_home(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to home position.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_left","title":"scroll_left method
","text":"def scroll_left(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one cell left.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_down","title":"scroll_page_down method
","text":"def scroll_page_down(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page down.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_left","title":"scroll_page_left method
","text":"def scroll_page_left(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page left.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_right","title":"scroll_page_right method
","text":"def scroll_page_right(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page right.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_page_up","title":"scroll_page_up method
","text":"def scroll_page_up(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one page up.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_relative","title":"scroll_relative method
","text":"def scroll_relative(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll relative to current position.
Parameters Name Type Description Defaultx
float | None
X distance (columns) to scroll, or None
for no change.
None
y
float | None
Y distance (rows) to scroll, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
. Or None
to use duration
.
None
duration
float | None
Duration of animation, if animate is True
and speed is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_right","title":"scroll_right method
","text":"def scroll_right(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one cell right.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_to","title":"scroll_to method
","text":"def scroll_to(\nself,\nx=None,\ny=None,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll to a given (absolute) coordinate, optionally animating.
Parameters Name Type Description Defaultx
float | None
X coordinate (column) to scroll to, or None
for no change.
None
y
float | None
Y coordinate (row) to scroll to, or None
for no change.
None
animate
bool
Animate to new scroll position.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Note The call to scroll is made after the next refresh.
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_center","title":"scroll_to_centermethod
","text":"def scroll_to_center(\nself,\nwidget,\nanimate=True,\n*,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\norigin_visible=True,\non_complete=None\n):\n
Scroll this widget to the center of self.
The center of the widget will be scrolled to the center of the container.
Parameters Name Type Description Defaultwidget
Widget
The widget to scroll to the center of self.
requiredanimate
bool
Whether to animate the scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
origin_visible
bool
Ensure that the top left corner of the widget remains visible after the scroll.
True
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_region","title":"scroll_to_region method
","text":"def scroll_to_region(\nself,\nregion,\n*,\nspacing=None,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\ncenter=False,\ntop=False,\norigin_visible=True,\nforce=False,\non_complete=None\n):\n
Scrolls a given region in to view, if required.
This method will scroll the least distance required to move region
fully within the scrollable area.
region
Region
A region that should be visible.
requiredspacing
Spacing | None
Optional spacing around the region.
None
animate
bool
True
to animate, or False
to jump.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
top
bool
Scroll region
to top of container.
False
origin_visible
bool
Ensure that the top left of the widget is within the window.
True
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Returns Type Description Offset
The distance that was scrolled.
"},{"location":"api/widget/#textual.widget.Widget.scroll_to_widget","title":"scroll_to_widgetmethod
","text":"def scroll_to_widget(\nself,\nwidget,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\ncenter=False,\ntop=False,\norigin_visible=True,\nforce=False,\non_complete=None\n):\n
Scroll scrolling to bring a widget in to view.
Parameters Name Type Description Defaultwidget
Widget
A descendant widget.
requiredanimate
bool
True
to animate, or False
to jump.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
top
bool
Scroll widget to top of container.
False
origin_visible
bool
Ensure that the top left of the widget is within the window.
True
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
Returns Type Description bool
True
if any scrolling has occurred in any descendant, otherwise False
.
method
","text":"def scroll_up(\nself,\n*,\nanimate=True,\nspeed=None,\nduration=None,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll one line up.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate
is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed is None
.
None
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.scroll_visible","title":"scroll_visible method
","text":"def scroll_visible(\nself,\nanimate=True,\n*,\nspeed=None,\nduration=None,\ntop=False,\neasing=None,\nforce=False,\non_complete=None\n):\n
Scroll the container to make this widget visible.
Parameters Name Type Description Defaultanimate
bool
Animate scroll.
True
speed
float | None
Speed of scroll if animate is True
; or None
to use duration
.
None
duration
float | None
Duration of animation, if animate
is True
and speed
is None
.
None
top
bool
Scroll to top of container.
False
easing
EasingFunction | str | None
An easing method for the scrolling animation.
None
force
bool
Force scrolling even when prohibited by overflow styling.
False
on_complete
CallbackType | None
A callable to invoke when the animation is finished.
None
"},{"location":"api/widget/#textual.widget.Widget.stop_animation","title":"stop_animation async
","text":"def stop_animation(self, attribute, complete=True):\n
Stop an animation on an attribute.
Parameters Name Type Description Defaultattribute
str
Name of the attribute whose animation should be stopped.
requiredcomplete
bool
Should the animation be set to its final value?
True
Note If there is no animation scheduled or running, this is a no-op.
"},{"location":"api/widget/#textual.widget.Widget.watch_disabled","title":"watch_disabledmethod
","text":"def watch_disabled(self):\n
Update the styles of the widget and its children when disabled is toggled.
"},{"location":"api/widget/#textual.widget.Widget.watch_has_focus","title":"watch_has_focusmethod
","text":"def watch_has_focus(self, value):\n
Update from CSS if has focus state changes.
"},{"location":"api/widget/#textual.widget.Widget.watch_mouse_over","title":"watch_mouse_overmethod
","text":"def watch_mouse_over(self, value):\n
Update from CSS if mouse over state changes.
"},{"location":"api/widget/#textual.widget.WidgetError","title":"WidgetErrorclass
","text":" Bases: Exception
Base widget error.
"},{"location":"api/work/","title":"Work","text":"A decorator used to create workers.
Parameters Name Type Description Defaultmethod
Callable[FactoryParamSpec, ReturnType] | Callable[FactoryParamSpec, Coroutine[None, None, ReturnType]] | None
A function or coroutine.
None
name
str
A short string to identify the worker (in logs and debugging).
''
group
str
A short string to identify a group of workers.
'default'
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
exclusive
bool
Cancel all workers in the same group.
False
description
str | None
Readable description of the worker for debugging purposes. By default, it uses a string representation of the decorated method and its arguments.
None
thread
bool
Mark the method as a thread worker.
False
"},{"location":"api/worker/","title":"Worker","text":"A class to manage concurrent work.
"},{"location":"api/worker/#textual.worker.WorkType","title":"WorkTypemodule-attribute
","text":"WorkType: TypeAlias = Union[\nCallable[[], Coroutine[None, None, ResultType]],\nCallable[[], ResultType],\nAwaitable[ResultType],\n]\n
Type used for workers.
"},{"location":"api/worker/#textual.worker.active_worker","title":"active_workermodule-attribute
","text":"active_worker: ContextVar[Worker] = ContextVar(\n\"active_worker\"\n)\n
Currently active worker context var.
"},{"location":"api/worker/#textual.worker.DeadlockError","title":"DeadlockErrorclass
","text":" Bases: WorkerError
The operation would result in a deadlock.
"},{"location":"api/worker/#textual.worker.NoActiveWorker","title":"NoActiveWorkerclass
","text":" Bases: Exception
There is no active worker.
"},{"location":"api/worker/#textual.worker.Worker","title":"Workerclass
","text":"def __init__(\nself,\nnode,\nwork=None,\n*,\nname=\"\",\ngroup=\"default\",\ndescription=\"\",\nexit_on_error=True,\nthread=False\n):\n
Bases: Generic[ResultType]
A class to manage concurrent work (either a task or a thread).
Parameters Name Type Description Defaultnode
DOMNode
The widget, screen, or App that initiated the work.
requiredwork
WorkType | None
A callable, coroutine, or other awaitable object to run in the worker.
None
name
str
Name of the worker (short string to help identify when debugging).
''
group
str
The worker group.
'default'
description
str
Description of the worker (longer string with more details).
''
exit_on_error
bool
Exit the app if the worker raises an error. Set to False
to suppress exceptions.
True
thread
bool
Mark the worker as a thread worker.
False
"},{"location":"api/worker/#textual.worker.Worker.completed_steps","title":"completed_steps property
","text":"completed_steps: int\n
The number of completed steps.
"},{"location":"api/worker/#textual.worker.Worker.error","title":"errorproperty
","text":"error: BaseException | None\n
The exception raised by the worker, or None
if there was no error.
property
","text":"is_cancelled: bool\n
Has the work been cancelled?
Note that cancelled work may still be running.
"},{"location":"api/worker/#textual.worker.Worker.is_finished","title":"is_finishedproperty
","text":"is_finished: bool\n
Has the task finished (cancelled, error, or success)?
"},{"location":"api/worker/#textual.worker.Worker.is_running","title":"is_runningproperty
","text":"is_running: bool\n
Is the task running?
"},{"location":"api/worker/#textual.worker.Worker.node","title":"nodeproperty
","text":"node: DOMNode\n
The node where this worker was run from.
"},{"location":"api/worker/#textual.worker.Worker.progress","title":"progressproperty
","text":"progress: float\n
Progress as a percentage.
If the total steps is None, then this will return 0. The percentage will be clamped between 0 and 100.
"},{"location":"api/worker/#textual.worker.Worker.result","title":"resultproperty
","text":"result: ResultType | None\n
The result of the worker, or None
if there is no result.
writable
property
","text":"state: WorkerState\n
The current state of the worker.
"},{"location":"api/worker/#textual.worker.Worker.total_steps","title":"total_stepsproperty
","text":"total_steps: int | None\n
The number of total steps, or None if indeterminate.
"},{"location":"api/worker/#textual.worker.Worker.StateChanged","title":"StateChangedclass
","text":"def __init__(self, worker, state):\n
Bases: Message
The worker state changed.
Parameters Name Type Description Defaultworker
Worker
The worker object.
requiredstate
WorkerState
New state.
required"},{"location":"api/worker/#textual.worker.Worker.advance","title":"advancemethod
","text":"def advance(self, steps=1):\n
Advance the number of completed steps.
Parameters Name Type Description Defaultsteps
int
Number of steps to advance.
1
"},{"location":"api/worker/#textual.worker.Worker.cancel","title":"cancel method
","text":"def cancel(self):\n
Cancel the task.
"},{"location":"api/worker/#textual.worker.Worker.run","title":"runasync
","text":"def run(self):\n
Run the work.
Implement this method in a subclass, or pass a callable to the constructor.
Returns Type DescriptionResultType
Return value of the work.
"},{"location":"api/worker/#textual.worker.Worker.update","title":"updatemethod
","text":"def update(self, completed_steps=None, total_steps=-1):\n
Update the number of completed steps.
Parameters Name Type Description Defaultcompleted_steps
int | None
The number of completed seps, or None
to not change.
None
total_steps
int | None
The total number of steps, None
for indeterminate, or -1 to leave unchanged.
-1
"},{"location":"api/worker/#textual.worker.Worker.wait","title":"wait async
","text":"def wait(self):\n
Wait for the work to complete.
Raises Type DescriptionWorkerFailed
If the Worker raised an exception.
WorkerCancelled
If the Worker was cancelled before it completed.
Returns Type DescriptionResultType
The return value of the work.
"},{"location":"api/worker/#textual.worker.WorkerCancelled","title":"WorkerCancelledclass
","text":" Bases: WorkerError
The worker was cancelled and did not complete.
"},{"location":"api/worker/#textual.worker.WorkerError","title":"WorkerErrorclass
","text":" Bases: Exception
A worker related error.
"},{"location":"api/worker/#textual.worker.WorkerFailed","title":"WorkerFailedclass
","text":"def __init__(self, error):\n
Bases: WorkerError
The worker raised an exception and did not complete.
"},{"location":"api/worker/#textual.worker.WorkerState","title":"WorkerStateclass
","text":" Bases: enum.Enum
A description of the worker's current state.
"},{"location":"api/worker/#textual.worker.WorkerState.CANCELLED","title":"CANCELLEDclass-attribute
instance-attribute
","text":"CANCELLED = 3\n
Worker is not running, and was cancelled.
"},{"location":"api/worker/#textual.worker.WorkerState.ERROR","title":"ERRORclass-attribute
instance-attribute
","text":"ERROR = 4\n
Worker is not running, and exited with an error.
"},{"location":"api/worker/#textual.worker.WorkerState.PENDING","title":"PENDINGclass-attribute
instance-attribute
","text":"PENDING = 1\n
Worker is initialized, but not running.
"},{"location":"api/worker/#textual.worker.WorkerState.RUNNING","title":"RUNNINGclass-attribute
instance-attribute
","text":"RUNNING = 2\n
Worker is running.
"},{"location":"api/worker/#textual.worker.WorkerState.SUCCESS","title":"SUCCESSclass-attribute
instance-attribute
","text":"SUCCESS = 5\n
Worker is not running, and completed successfully.
"},{"location":"api/worker/#textual.worker.get_current_worker","title":"get_current_workerfunction
","text":"def get_current_worker():\n
Get the currently active worker.
Raises Type DescriptionNoActiveWorker
If there is no active worker.
Returns Type DescriptionWorker
A Worker instance.
"},{"location":"api/worker_manager/","title":"Worker manager","text":"A class to manage workers for an app.
You access this object via App.workers or Widget.workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager","title":"WorkerManagerclass
","text":"def __init__(self, app):\n
An object to manager a number of workers.
You will not have to construct this class manually, as widgets, screens, and apps have a worker manager accessibly via a workers
attribute.
app
App
An App instance.
required"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.add_worker","title":"add_workermethod
","text":"def add_worker(self, worker, start=True, exclusive=True):\n
Add a new worker.
Parameters Name Type Description Defaultworker
Worker
A Worker instance.
requiredstart
bool
Start the worker if True, otherwise the worker must be started manually.
True
exclusive
bool
Cancel all workers in the same group as worker
.
True
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_all","title":"cancel_all method
","text":"def cancel_all(self):\n
Cancel all workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_group","title":"cancel_groupmethod
","text":"def cancel_group(self, node, group):\n
Cancel a single group.
Parameters Name Type Description Defaultnode
DOMNode
Worker DOM node.
requiredgroup
str
A group name.
required Returns Type Descriptionlist[Worker]
A list of workers that were cancelled.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.cancel_node","title":"cancel_nodemethod
","text":"def cancel_node(self, node):\n
Cancel all workers associated with a given node
Parameters Name Type Description Defaultnode
DOMNode
A DOM node (widget, screen, or App).
required Returns Type Descriptionlist[Worker]
List of cancelled workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.start_all","title":"start_allmethod
","text":"def start_all(self):\n
Start all the workers.
"},{"location":"api/worker_manager/#textual._worker_manager.WorkerManager.wait_for_complete","title":"wait_for_completeasync
","text":"def wait_for_complete(self, workers=None):\n
Wait for workers to complete.
Parameters Name Type Description Defaultworkers
Iterable[Worker] | None
An iterable of workers or None to wait for all workers in the manager.
None
"},{"location":"blog/","title":"Textual Blog","text":""},{"location":"blog/2023/03/15/no-async-async-with-python/","title":"No-async async with Python","text":"A (reasonable) criticism of async is that it tends to proliferate in your code. In order to await
something, your functions must be async
all the way up the call-stack. This tends to result in you making things async
just to support that one call that needs it or, worse, adding async
just-in-case. Given that going from def
to async def
is a breaking change there is a strong incentive to go straight there.
Before you know it, you have adopted a policy of \"async all the things\".
Textual is an async framework, but doesn't require the app developer to use the async
and await
keywords (but you can if you need to). This post is about how Textual accomplishes this async-agnosticism.
Info
See this example from the docs for an async-less Textual app.
"},{"location":"blog/2023/03/15/no-async-async-with-python/#an-apology","title":"An apology","text":"But first, an apology! In a previous post I said Textual \"doesn't do any IO of its own\". This is not accurate. Textual responds to keys and mouse events (Input) and writes content to the terminal (Output).
Although Textual clearly does do IO, it uses asyncio
mainly for concurrency. It allows each widget to update its part of the screen independently from the rest of the app.
The first no-async async technique is the \"Await me maybe\" pattern, a term first coined by Simon Willison. This is particularly applicable to callbacks (or in Textual terms, message handlers).
The await_me_maybe
function below can run a callback that is either a plain old function or a coroutine (async def
). It does this by awaiting the result of the callback if it is awaitable, or simply returning the result if it is not.
import asyncio\nimport inspect\ndef plain_old_function():\nreturn \"Plain old function\"\nasync def async_function():\nreturn \"Async function\"\nasync def await_me_maybe(callback):\nresult = callback()\nif inspect.isawaitable(result):\nreturn await result\nreturn result\nasync def run_framework():\nprint(\nawait await_me_maybe(plain_old_function)\n)\nprint(\nawait await_me_maybe(async_function)\n)\nif __name__ == \"__main__\":\nasyncio.run(run_framework())\n
"},{"location":"blog/2023/03/15/no-async-async-with-python/#optionally-awaitable","title":"Optionally awaitable","text":"The \"await me maybe\" pattern is great when an async framework calls the app's code. The app developer can choose to write async code or not. Things get a little more complicated when the app wants to call the framework's API. If the API has asynced all the things, then it would force the app to do the same.
Textual's API consists of regular methods for the most part, but there are a few methods which are optionally awaitable. These are not coroutines (which must be awaited to do anything).
In practice, this means that those API calls initiate something which will complete a short time later. If you discard the return value then it won't prevent it from working. You only need to await
if you want to know when it has finished.
The mount
method is one such method. Calling it will add a widget to the screen:
def on_key(self):\n# Add MyWidget to the screen\nself.mount(MyWidget(\"Hello, World!\"))\n
In this example we don't care that the widget hasn't been mounted immediately, only that it will be soon.
Note
Textual awaits the result of mount after the message handler, so even if you don't explicitly await it, it will have been completed by the time the next message handler runs.
We might care if we want to mount a widget then make some changes to it. By making the handler async
and awaiting the result of mount, we can be sure that the widget has been initialized before we update it:
async def on_key(self):\n# Add MyWidget to the screen\nawait self.mount(MyWidget(\"Hello, World!\"))\n# add a border\nself.query_one(MyWidget).styles.border = (\"heavy\", \"red\")\n
Incidentally, I found there were very few examples of writing awaitable objects in Python. So here is the code for AwaitMount
which is returned by the mount
method:
class AwaitMount:\n\"\"\"An awaitable returned by mount() and mount_all().\"\"\"\ndef __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:\nself._parent = parent\nself._widgets = widgets\nasync def __call__(self) -> None:\n\"\"\"Allows awaiting via a call operation.\"\"\"\nawait self\ndef __await__(self) -> Generator[None, None, None]:\nasync def await_mount() -> None:\nif self._widgets:\naws = [\ncreate_task(widget._mounted_event.wait(), name=\"await mount\")\nfor widget in self._widgets\n]\nif aws:\nawait wait(aws)\nself._parent.refresh(layout=True)\nreturn await_mount().__await__()\n
"},{"location":"blog/2023/03/15/no-async-async-with-python/#summing-up","title":"Summing up","text":"Textual did initially \"async all the things\", which you might see if you find some old Textual code. Now async is optional.
This is not because I dislike async. I'm a fan! But it does place a small burden on the developer (more to type and think about). With the current API you generally don't need to write coroutines, or remember to await things. But async is there if you need it.
We're finding that Textual is increasingly becoming a UI to things which are naturally concurrent, so async was a good move. Concurrency can be a tricky subject, so we're planning some API magic to take the pain out of running tasks, threads, and processes. Stay tuned!
Join us on our Discord server if you want to talk about these things with the Textualize developers.
"},{"location":"blog/2022/12/08/be-the-keymaster/","title":"Be the Keymaster!","text":""},{"location":"blog/2022/12/08/be-the-keymaster/#that-didnt-go-to-plan","title":"That didn't go to plan","text":"So... yeah... the blog. When I wrote my previous (and first) post I had wanted to try and do a post towards the end of each week, highlighting what I'd done on the \"dogfooding\" front. Life kinda had other plans. Not in a terrible way, but it turns out that getting both flu and Covid jabs (AKA \"jags\" as they tend to say in my adopted home) on the same day doesn't really agree with me too well.
I have been working, but there's been some odd moments in the past week and a bit and, last week, once I got to the end, I was glad for it to end. So no blog post happened.
Anyway...
"},{"location":"blog/2022/12/08/be-the-keymaster/#what-have-i-been-up-to","title":"What have I been up to?","text":"While mostly sat feeling sorry for myself on my sofa, I have been coding. Rather than list all the different things here in detail, I'll quickly mention them with links to where to find them and play with them if you want:
"},{"location":"blog/2022/12/08/be-the-keymaster/#fivepyfive","title":"FivePyFive","text":"While my Textual 5x5 puzzle is one of the examples in the Textual repo, I wanted to make it more widely available so people can download it with pip
or pipx
. See over on PyPi and see if you can solve it. ;-)
I wanted to put together a very small example of how someone may put together a third party widget library, and in doing so selected what I thought was going to be a mostly-useless example: a wrapper around a text-based QR code generator website. Weirdly I've had a couple of people express a need for QR codes in the terminal since publishing that!
"},{"location":"blog/2022/12/08/be-the-keymaster/#pispy","title":"PISpy","text":"PISpy is a very simple terminal-based client for the PyPi API. Mostly it provides a hypertext interface to Python package details, letting you look up a package and then follow its dependency links. It's very simple at the moment, but I think more fun things can be done with this.
"},{"location":"blog/2022/12/08/be-the-keymaster/#oidia","title":"OIDIA","text":"I'm a big fan of the use of streak-tracking in one form or another. Personally I use a streak-tracking app for keeping tabs of all sorts of good (and bad) habits, and as a heavy user of all things Apple I make a lot of use of the Fitness rings, etc. So I got to thinking it might be fun to do a really simple, no shaming, no counting, just recording, steak app for the Terminal. OIDIA is the result.
As of the time of writing I only finished the first version of this yesterday evening, so there are plenty of rough edges; but having got it to a point where it performed the basic tasks I wanted from it, that seemed like a good time to publish.
Expect to see this getting more updates and polish.
"},{"location":"blog/2022/12/08/be-the-keymaster/#wait-what-about-this-keymaster-thing","title":"Wait, what about this Keymaster thing?","text":"Ahh, yes, about that... So one of the handy things I'm finding about Textual is its key binding system. The more I build Textual apps, the more I appreciate the bindings, how they can be associated with specific widgets, the use of actions (which can be used from other places too), etc.
But... (there's always a \"but\" right -- I mean, there'd be no blog post to be had here otherwise).
The terminal doesn't have access to all the key combinations you may want to use, and also, because some keys can't necessarily be \"typed\", at least not easily (think about it: there's no F1 character, you have to type F1
), many keys and key combinations need to be bound with specific names.
So there's two problems here: how do I discover what keys even turn up in my application, and when they do, what should I call them when I pass them to Binding
?
That felt like a \"well Dave just build an app for it!\" problem. So I did:
If you're building apps with Textual and you want to discover what keys turn up from your terminal and are available to your application, you can:
$ pipx install textual-keys\n
and then just run textual-keys
and start mashing the keyboard to find out.
There's a good chance that this app, or at least a version of it, will make it into Textual itself (very likely as one of the devtools). But for now it's just an easy install away.
I think there's a call to be made here too: have you built anything to help speed up how you work with Textual, or just make the development experience \"just so\"? If so, do let us know, and come yell about it on the #show-and-tell
channel in our Discord server.
I spent some time optimizing Textual on Windows recently, and discovered something which may be of interest to anyone working with async code on that platform.
Animation, scrolling, and fading had always been unsatisfactory on Windows. Textual was usable, but the lag when scrolling made apps feel far less snappy that other platforms. On macOS and Linux, scrolling is fast enough that it feels close to a native app, not something running in a terminal. Yet the Windows experience never improved, even as Textual got faster with each release.
I had chalked this up to Windows Terminal being slow to render updates. After all, the classic Windows terminal was (and still is) glacially slow. Perhaps Microsoft just weren't focusing on performance.
In retrospect, that was highly improbable. Like all modern terminals, Windows Terminal uses the GPU to render updates. Even without focussing on performance, it should be fast.
I figured I'd give it one last attempt to speed up Textual on Windows. If I failed, Windows would forever be a third-class platform for Textual apps.
It turned out that it was nothing to do with performance, per se. The issue was with a single asyncio function: asyncio.sleep
.
Textual has a Timer
class which creates events at regular intervals. It powers the JS-like set_interval
and set_timer
functions. It is also used internally to do animation (such as smooth scrolling). This Timer class calls asyncio.sleep
to wait the time between one event and the next.
On macOS and Linux, calling asynco.sleep
is fairly accurate. If you call sleep(3.14)
, it will return within 1% of 3.14 seconds. This is not the case for Windows, which for historical reasons uses a timer with a granularity of 15 milliseconds. The upshot is that sleep times will be rounded up to the nearest multiple of 15 milliseconds.
This limit appears to hold true for all async primitives on Windows. If you wait for something with a timeout, it will return on a multiple of 15 milliseconds. Fortunately there is work in the CPython pipeline to make this more accurate. Thanks to Steve Dower for pointing this out.
This lack of accuracy in the timer meant that timer events were created at a far slower rate than intended. Animation was slower because Textual was waiting too long between updates.
Once I had figured that out, I needed an alternative to asyncio.sleep
for Textual's Timer class. And I found one. The following version of sleep
is accurate to well within 1%:
from time import sleep as time_sleep\nfrom asyncio import get_running_loop\nasync def sleep(sleep_for: float) -> None:\n\"\"\"An asyncio sleep.\n On Windows this achieves a better granularity than asyncio.sleep\n Args:\n sleep_for (float): Seconds to sleep for.\n \"\"\" \nawait get_running_loop().run_in_executor(None, time_sleep, sleep_for)\n
That is a drop-in replacement for sleep on Windows. With it, Textual runs a lot smoother. Easily on par with macOS and Linux.
It's not quite perfect. There is a little tearing during full \"screen\" updates, but performance is decent all round. I suspect when this bug is fixed (big thanks to Paul Moore for looking in to that), and Microsoft implements this protocol then Textual on Windows will be A+.
This Windows improvement will be in v0.9.0 of Textual, which will be released in a few days.
"},{"location":"blog/2023/02/11/the-heisenbug-lurking-in-your-async-code/","title":"The Heisenbug lurking in your async code","text":"I'm taking a brief break from blogging about Textual to bring you this brief PSA for Python developers who work with async code. I wanted to expand a little on this tweet.
If you have ever used asyncio.create_task
you may have created a bug for yourself that is challenging (read almost impossible) to reproduce. If it occurs, your code will likely fail in unpredictable ways.
The root cause of this Heisenbug is that if you don't hold a reference to the task object returned by create_task
then the task may disappear without warning when Python runs garbage collection. In other words, the code in your task will stop running with no obvious indication why.
This behavior is well documented, as you can see from this excerpt (emphasis mine):
But who reads all the docs? And who has perfect recall if they do? A search on GitHub indicates that there are a lot of projects where this bug is waiting for just the right moment to ruin somebody's day.
I suspect the reason this mistake is so common is that tasks are a lot like threads (conceptually at least). With threads you can just launch them and forget. Unless you mark them as \"daemon\" threads they will exist for the lifetime of your app. Not so with Tasks.
The solution recommended in the docs is to keep a reference to the task for as long as you need it to live. On modern Python you could use TaskGroups which will keep references to your tasks. As long as all the tasks you spin up are in TaskGroups, you should be fine.
"},{"location":"blog/2023/03/08/overhead-of-python-asyncio-tasks/","title":"Overhead of Python Asyncio tasks","text":"Every widget in Textual, be it a button, tree view, or a text input, runs an asyncio task. There is even a task for scrollbar corners (the little space formed when horizontal and vertical scrollbars meet).
Info
It may be IO that gives AsyncIO its name, but Textual doesn't do any IO of its own. Those tasks are used to power message queues, so that widgets (UI components) can do whatever they do at their own pace.
Its fair to say that Textual apps launch a lot of tasks. Which is why when I was trying to optimize startup (for apps with 1000s of widgets) I suspected it was task related.
I needed to know how much of an overhead it was to launch tasks. Tasks are lighter weight than threads, but how much lighter? The only way to know for certain was to profile.
The following code launches a load of do nothing tasks, then waits for them to shut down. This would give me an idea of how performant create_task
is, and also a baseline for optimizations. I would know the absolute limit of any optimizations I make.
from asyncio import create_task, wait, run\nfrom time import process_time as time\nasync def time_tasks(count=100) -> float:\n\"\"\"Time creating and destroying tasks.\"\"\"\nasync def nop_task() -> None:\n\"\"\"Do nothing task.\"\"\"\npass\nstart = time()\ntasks = [create_task(nop_task()) for _ in range(count)]\nawait wait(tasks)\nelapsed = time() - start\nreturn elapsed\nfor count in range(100_000, 1000_000 + 1, 100_000):\ncreate_time = run(time_tasks(count))\ncreate_per_second = 1 / (create_time / count)\nprint(f\"{count:,} tasks \\t {create_per_second:0,.0f} tasks per/s\")\n
And here is the output:
100,000 tasks 280,003 tasks per/s\n200,000 tasks 255,275 tasks per/s\n300,000 tasks 248,713 tasks per/s\n400,000 tasks 248,383 tasks per/s\n500,000 tasks 241,624 tasks per/s\n600,000 tasks 260,660 tasks per/s\n700,000 tasks 244,510 tasks per/s\n800,000 tasks 247,455 tasks per/s\n900,000 tasks 242,744 tasks per/s\n1,000,000 tasks 259,715 tasks per/s\n
Info
Running on an M1 MacBook Pro.
This tells me I can create, run, and shutdown 260K tasks per second.
That's fast.
Clearly create_task
is as close as you get to free in the Python world, and I would need to look elsewhere for optimizations. Turns out Textual spends far more time processing CSS rules than creating tasks (obvious in retrospect). I've noticed some big wins there, so the next version of Textual will be faster to start apps with a metric tonne of widgets.
But I still need to know what to do with those scrollbar corners. A task for two characters. I don't even...
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/","title":"A year of building for the terminal","text":"I joined Textualize back in January 2022, and since then have been hard at work with the team on both Rich and Textual. Over the course of the year, I\u2019ve been able to work on a lot of really cool things. In this post, I\u2019ll review a subset of the more interesting and visual stuff I\u2019ve built. If you\u2019re into terminals and command line tooling, you\u2019ll hopefully see at least one thing of interest!
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#a-file-manager-powered-by-textual","title":"A file manager powered by Textual","text":"I\u2019ve been slowly developing a file manager as a \u201cdogfooding\u201d project for Textual. It takes inspiration from tools such as Ranger and Midnight Commander.
As of December 2022, it lets you browse your file system, filtering, multi-selection, creating and deleting files/directories, opening files in your $EDITOR
and more.
I\u2019m happy with how far this project has come \u2014 I think it\u2019s a good example of the type of powerful application that can be built with Textual with relatively little code. I\u2019ve been able to focus on features, instead of worrying about terminal emulator implementation details.
The project is available on GitHub.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#better-diffs-in-the-terminal","title":"Better diffs in the terminal","text":"Diffs in the terminal are often difficult to read at a glance. I wanted to see how close I could get to achieving a diff display of a quality similar to that found in the GitHub UI.
To attempt this, I built a tool called Dunk. It\u2019s a command line program which you can pipe your git diff
output into, and it\u2019ll convert it into something which I find much more readable.
Although I\u2019m not particularly proud of the code - there are a lot of \u201chacks\u201d going on, but I\u2019m proud of the result. If anything, it shows what can be achieved for tools like this.
For many diffs, the difference between running git diff
and git diff | dunk | less -R
is night and day.
It\u2019d be interesting to revisit this at some point. It has its issues, but I\u2019d love to see how it can be used alongside Textual to build a terminal-based diff/merge tool. Perhaps it could be combined with\u2026
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#code-editor-floating-gutter","title":"Code editor floating gutter","text":"This is a common feature in text editors and IDEs: when you scroll to the right, you should still be able to see what line you\u2019re on. Out of interest, I tried to recreate the effect in the terminal using Textual.
Textual CSS offers a dock
property which allows you to attach a widget to an edge of its parent. By creating a widget that contains a vertical list of numbers and setting the dock
property to left
, we can create a floating gutter effect. Then, we just need to keep the scroll_y
in sync between the gutter and the content to ensure the line numbers stay aligned.
While working on Shira (a proof-of-concept, terminal-based Python object explorer), I wrote some autocompleting dropdown functionality.
Textual forgoes the z-index concept from browser CSS and instead uses a \u201cnamed layer\u201d system. Using the layers
property you can defined an ordered list of named layers, and using the layer
property, you can assign a descendant widget to one of those layers.
By creating a new layer above all others and assigning a widget to that layer, we can ensure that widget is painted above everything else.
In order to determine where to place the dropdown, we can track the current value in the dropdown by watch
ing the reactive input \u201cvalue\u201d inside the Input widget. This method will be called every time the value
of the Input changes, and we can use this hook to amend the position of our dropdown position to accommodate for the length of the input value.
I\u2019ve now extracted this into a separate library called textual-autocomplete.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#tabs-with-animated-underline","title":"Tabs with animated underline","text":"The aim here was to create a tab widget with underlines that animates smoothly as another tab is selected.
The difficulty with implementing something like this is that we don\u2019t have pixel-perfect resolution when animating - a terminal window is just a big grid of fixed-width character cells.
However, when animating things in a terminal, we can often achieve better granularity using Unicode related tricks. In this case, instead of shifting the bar along one whole cell, we adjust the endings of the bar to be a character which takes up half of a cell.
The exact characters that form the bar are \"\u257a\", \"\u2501\" and \"\u2578\". When the bar sits perfectly within cell boundaries, every character is \u201c\u2501\u201d. As it travels over a cell boundary, the left and right ends of the bar are updated to \"\u257a\" and \"\u2578\" respectively.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#snapshot-testing-for-terminal-apps","title":"Snapshot testing for terminal apps","text":"One of the great features we added to Rich this year was the ability to export console contents to an SVG. This feature was later exposed to Textual, allowing users to capture screenshots of their running Textual apps. Ultimately, I ended up creating a tool for snapshot testing in the Textual codebase.
Snapshot testing is used to ensure that Textual output doesn\u2019t unexpectedly change. On disk, we store what we expect the output to look like. Then, when we run our unit tests, we get immediately alerted if the output has changed.
This essentially automates the process of manually spinning up several apps and inspecting them for unexpected visual changes. It\u2019s great for catching subtle regressions!
In Textual, each CSS property has its own canonical example and an associated snapshot test. If we accidentally break a property in a way that affects the visual output, the chances of it sneaking into a release are greatly reduced, because the corresponding snapshot test will fail.
As part of this work, I built a web interface for comparing snapshots with test output. There\u2019s even a little toggle which highlights the differences, since they\u2019re sometimes rather subtle.
Since the terminal output shown in the video above is just an SVG image, I was able to add the \"Show difference\" functionality by overlaying the two images and applying a single CSS property: mix-blend-mode: difference;
.
The snapshot testing functionality itself is implemented as a pytest plugin, and it builds on top of a snapshot testing framework called syrupy.
It's quite likely that this will eventually be exposed to end-users of Textual.
"},{"location":"blog/2022/12/20/a-year-of-building-for-the-terminal/#demonstrating-animation","title":"Demonstrating animation","text":"I built an example app to demonstrate how to animate in Textual and the available easing functions.
The smoothness here is achieved using tricks similar to those used in the tabs I discussed earlier. In fact, the bar that animates in the video above is the same Rich renderable that is used by Textual's scrollbars.
You can play with this app by running textual easing
. Please use animation sparingly.
When developing terminal based applications, performing simple debugging using print
can be difficult, since the terminal is in application mode.
A project I worked on earlier in the year to improve the situation was the Textual developer console, which you can launch with textual console
.
Then, by running a Textual application with the --dev
flag, all standard output will be redirected to it. This means you can use the builtin print
function and still immediately see the output. Textual itself also writes information to this console, giving insight into the messages that are flowing through an application.
Cells in the terminal are roughly two times taller than they are wide. This means, that two horizontally adjacent cells form an approximate square.
Using this fact, I wrote a simple library based on Rich and PIL which can convert an image file into terminal output. You can find the library, rich-pixels
, on GitHub.
It\u2019s particularly good for displaying simple pixel art images. The SVG image below is also a good example of the SVG export functionality I touched on earlier.
RichSince the library generates an object which is renderable using Rich, these can easily be embedded inside Textual applications.
Here's an example of that in a scrapped \"Pok\u00e9dex\" app I threw together:
This is a rather naive approach to the problem... but I did it for fun!
Other methods for displaying images in the terminal include:
That was a whirlwind tour of just some of the projects I tackled in 2022. If you found it interesting, be sure to follow me on Twitter. I don't post often, but when I do, it's usually about things similar to those I've discussed here.
"},{"location":"blog/2022/11/06/new-blog/","title":"New Blog","text":"Welcome to the first post on the Textual blog.
I plan on using this as a place to make announcements regarding new releases of Textual, and any other relevant news.
The first piece of news is that we've reorganized this site a little. The Events, Styles, and Widgets references are now under \"Reference\", and what used to be under \"Reference\" is now \"API\" which contains API-level documentation. I hope that's a little clearer than it used to be!
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/","title":"So you're looking for a wee bit of Textual help...","text":""},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#introduction","title":"Introduction","text":"Quote
Patience, Highlander. You have done well. But it'll take time. You are generations being born and dying. You are at one with all living things. Each man's thoughts and dreams are yours to know. You have power beyond imagination. Use it well, my friend. Don't lose your head.
Juan S\u00e1nchez Villalobos Ram\u00edrez, Chief metallurgist to King Charles V of Spain
As of the time of writing, I'm a couple or so days off having been with Textualize for 3 months. It's been fun, and educational, and every bit as engaging as I'd hoped, and more. One thing I hadn't quite prepared for though, but which I really love, is how so many other people are learning Textual along with me.
Even in those three months the library has changed and expanded quite a lot, and it continues to do so. Meanwhile, more people are turning up and using the framework; you can see this online in social media, blogs and of course in the ever-growing list of projects on GitHub which depend on Textual.
This inevitably means there's a lot of people getting to grips with a new tool, and one that is still a bit of a moving target. This in turn means lots of people are coming to us to get help.
As I've watched this happen I've noticed a few patterns emerging. Some of these good or neutral, some... let's just say not really beneficial to those seeking the help, or to those trying to provide the help. So I wanted to write a little bit about the different ways you can get help with Textual and your Textual-based projects, and to also try and encourage people to take the most helpful and positive approach to getting that help.
Now, before I go on, I want to make something very clear: I'm writing this as an individual. This is my own personal view, and my own advice from me to anyone who wishes to take it. It's not Textual (the project) or Textualize (the company) policy, rules or guidelines. This is just some ageing hacker's take on how best to go about asking for help, informed by years of asking for and also providing help in email, on Usenet, on forums, etc.
Or, put another way: if what you read in here seems sensible to you, I figure we'll likely have already hit it off over on GitHub or in the Discord server. ;-)
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#where-to-go-for-help","title":"Where to go for help","text":"At this point this is almost a bit of an FAQ itself, so I thought I'd address it here: where's the best place to ask for help about Textual, and what's the difference between GitHub Issues, Discussions and our Discord server?
I'd suggest thinking of them like this:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#discord","title":"Discord","text":"You have a question, or need help with something, and perhaps you could do with a reply as soon as possible. But, and this is the really important part, it doesn't matter if you don't get a response. If you're in this situation then the Discord server is possibly a good place to start. If you're lucky someone will be hanging about who can help out.
I can't speak for anyone else, but keep this in mind: when I look in on Discord I tend not to go scrolling back much to see if anything has been missed. If something catches my eye, I'll try and reply, but if it doesn't... well, it's mostly an instant chat thing so I don't dive too deeply back in time.
Going from Discord to a GitHub issue
As a slight aside here: sometimes people will pop up in Discord, ask a question about something that turns out looking like a bug, and that's the last we hear of it. Please, please, please, if this happens, the most helpful thing you can do is go raise an issue for us. It'll help us to keep track of problems, it'll help get your problem fixed, it'll mean everyone benefits.
My own advice would be to treat Discord as an ephemeral resource. It happens in the moment but fades away pretty quickly. It's like knocking on a friend's door to see if they're in. If they're not in, you might leave them a note, which is sort of like going to...
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#github","title":"GitHub","text":"On the other hand, if you have a question or need some help or something where you want to stand a good chance of the Textual developers (amongst others) seeing it and responding, I'd recommend that GitHub is the place to go. Dropping something into the discussions there, or leaving an issue, ensures it'll get seen. It won't get lost.
As for which you should use -- a discussion or an issue -- I'd suggest this: if you need help with something, or you want to check your understanding of something, or you just want to be sure something is a problem before taking it further, a discussion might be the best thing. On the other hand, if you've got a clear bug or feature request on your hands, an issue makes a lot of sense.
Don't worry if you're not sure which camp your question or whatever falls into though; go with what you think is right. There's no harm done either way (I may move an issue to a discussion first before replying, if it's really just a request for help -- but that's mostly so everyone can benefit from finding it in the right place later on down the line).
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#the-dos-and-donts-of-getting-help","title":"The dos and don'ts of getting help","text":"Now on to the fun part. This is where I get a bit preachy. Ish. Kinda. A little bit. Again, please remember, this isn't a set of rules, this isn't a set of official guidelines, this is just a bunch of \"if you want my advice, and I know you didn't ask but you've read this far so you actually sort of did don't say I didn't warn you!\" waffle.
This isn't going to be an exhaustive collection, far from it. But I feel these are some important highlights.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#do","title":"Do...","text":"When looking for help, in any of the locations mentioned above, I'd totally encourage:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#be-clear-and-detailed","title":"Be clear and detailed","text":"Too much detail is almost always way better than not enough. \"My program didn't run\", often even with some of the code supplied, is so much harder to help than \"I ran this code I'm posting here, and I expected this particular outcome, and I expected it because I'd read this particular thing in the docs and had comprehended it to mean this, but instead the outcome was this exception here, and I'm a bit stuck -- can someone offer some pointers?\"
The former approach means there often ends up having to be a back and forth which can last a long time, and which can sometimes be frustrating for the person asking. Manage frustration: be clear, tell us everything you can.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#say-what-resources-youve-used-already","title":"Say what resources you've used already","text":"If you've read the potions of the documentation that relate to what you're trying to do, it's going to be really helpful if you say so. If you don't, it might be assumed you haven't and you may end up being pointed at them.
So, please, if you've checked the documentation, looked in the FAQ, done a search of past issues or discussions or perhaps even done a search on the Discord server... please say so.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#be-polite","title":"Be polite","text":"This one can go a long way when looking for help. Look, I get it, programming is bloody frustrating at times. We've all rage-quit some code at some point, I'm sure. It's likely going to be your moment of greatest frustration when you go looking for help. But if you turn up looking for help acting all grumpy and stuff it's not going to come over well. Folk are less likely to be motivated to lend a hand to someone who seems rather annoyed.
If you throw in a please and thank-you here and there that makes it all the better.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#fully-consider-the-replies","title":"Fully consider the replies","text":"You could find yourself getting a reply that you're sure won't help at all. That's fair. But be sure to fully consider it first. Perhaps you missed the obvious along the way and this is 100% the course correction you'd unknowingly come looking for in the first place. Sure, the person replying might have totally misunderstood what was being asked, or might be giving a wrong answer (it me! I've totally done that and will again!), but even then a reply along the lines of \"I'm not sure that's what I'm looking for, because...\" gets everyone to the solution faster than \"lol nah\".
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#entertain-what-might-seem-like-odd-questions","title":"Entertain what might seem like odd questions","text":"Aye, I get it, being asked questions when you're looking for an answer can be a bit frustrating. But if you find yourself on the receiving end of a small series of questions about your question, keep this in mind: Textual is still rather new and still developing and it's possible that what you're trying to do isn't the correct way to do that thing. To the person looking to help you it may seem to them you have an XY problem.
Entertaining those questions might just get you to the real solution to your problem.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#allow-for-language-differences","title":"Allow for language differences","text":"You don't need me to tell you that a project such as Textual has a global audience. With that rather obvious fact comes the other fact that we don't all share the same first language. So, please, as much as possible, try and allow for that. If someone is trying to help you out, and they make it clear they're struggling to follow you, keep this in mind.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#acknowledge-the-answer","title":"Acknowledge the answer","text":"I suppose this is a variation on \"be polite\" (really, a thanks can go a long way), but there's more to this than a friendly acknowledgement. If someone has gone to the trouble of offering some help, it's helpful to everyone who comes after you to acknowledge if it worked or not. That way a future help-seeker will know if the answer they're reading stands a chance of being the right one.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#accept-that-textual-is-zero-point-software-right-now","title":"Accept that Textual is zero-point software (right now)","text":"Of course the aim is to have every release of Textual be stable and useful, but things will break. So, please, do keep in mind things like:
Of course it can be a bit frustrating a times, but overall the aim is to have the best framework possible in the long run.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#dont","title":"Don't...","text":"Okay, now for a bit of old-hacker finger-wagging. Here's a few things I'd personally discourage:
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#lack-patience","title":"Lack patience","text":"Sure, it can be annoying. You're in your flow, you've got a neat idea for a thing you want to build, you're stuck on one particular thing and you really need help right now! Thing is, that's unlikely to happen. Badgering individuals, or a whole resource, to reply right now, or complaining that it's been $TIME_PERIOD
since you asked and nobody has replied... that's just going to make people less likely to reply.
This one often goes hand in hand with the \"lack patience\" thing: Be it asking on Discord, or in GitHub issues, discussions or even PRs, unnecessarily tagging individuals is a bit rude. Speaking for myself and only myself: I love helping folk with Textual. If I could help everyone all the time the moment they have a problem, I would. But it doesn't work like that. There's any number of reasons I might not be responding to a particular request, including but not limited to (here I'm talking personally because I don't want to speak for anyone else, but I'm sure I'm not alone here):
You get the idea though. So while I'm off having a well-rounded life, it's not good to get unnecessarily intrusive alerts to something that either a) doesn't actually directly involve me or b) could wait.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#seek-personal-support","title":"Seek personal support","text":"Again, I'm going to speak totally for myself here, but I also feel the general case is polite for all: there's a lot of good support resources available already; sending DMs on Discord or Twitter or in the Fediverse, looking for direct personal support, isn't really the best way to get help. Using the public/collective resources is absolutely the best way to get that help. Why's it a bad idea to dive into DMs? Here's some reasons I think it's not a good idea:
I suppose this should really be phrased as a do rather than a don't, as here I want to encourage something positive. A few times I've helped people out who have been very apologetic about their questions being \"noob\" questions, or about how they're fairly new to Python, or programming in general. Really, please, don't feel the need to apologise and don't be ashamed of where you're at.
If you've asked something that's obviously answered in the documentation, that's not a problem; you'll likely get pointed at the docs and it's what happens next that's the key bit. If the attitude is \"oh, cool, that's exactly what I needed to be reading, thanks!\" that's a really positive thing. The only time it's a problem is when there's a real reluctance to use the available resources. We've all seen that person somewhere at some point, right? ;-)
Not knowing things is totally cool.
"},{"location":"blog/2023/01/09/so-youre-looking-for-a-wee-bit-of-textual-help/#conclusion","title":"Conclusion","text":"So, that's my waffle over. As I said at the start: this is my own personal thoughts on how to get help with Textual, both as someone whose job it is to work on Textual and help people with Textual, and also as a FOSS advocate and supporter who can normally be found helping Textual users when he's not \"on the clock\" too.
What I've written here isn't exhaustive. Neither is it novel. Plenty has been written on the general subject in the past, and I'm sure more will be written on the subject in the future. I do, however, feel that these are the most common things I notice. I'd say those dos and don'ts cover 90% of \"can I get some help?\" interactions; perhaps closer to 99%.
Finally, and I think this is the most important thing to remember, the next time you are battling some issue while working with Textual: don't lose your head!
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/","title":"On dog food, the (original) Metaverse, and (not) being bored","text":""},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#introduction","title":"Introduction","text":"Quote
Cutler, armed with a schedule, was urging the team to \"eat its own dog food\". Part macho stunt and part common sense, the \"dog food diet\" was the cornerstone of Cutler\u2019s philosophy.
G. Pascal Zachary \u2014 Show-Stopper!
I can't remember exactly when it was -- it was likely late in 1994 or some time in 1995 -- when I first came across the concept of, or rather the name for the concept of, \"eating your own dog food\". The idea and the name played a huge part in the book Show-Stopper! by G. Pascal Zachary. The idea wasn't new to me of course; I'd been writing code for over a decade by then and plenty of times I'd built things and then used those things to do things, but it was fascinating to a mostly-self-taught 20-something me to be reading this (excellent -- go read it if you care about the history of your craft) book and to see the idea written down and named.
While Textualize isn't (thankfully -- really, I do recommend reading the book) anything like working on the team building Windows NT, the idea of taking a little time out from working on Textual, and instead work with Textual, makes a lot of sense. It's far too easy to get focused on adding things and improving things and tweaking things while losing sight of the fact that people will want to build with your product.
So you can imagine how pleased I was when Will announced that he wanted all of us to spend a couple or so weeks building something with Textual. I had, of course, already written one small application with the library, and had plans for another (in part it's how I ended up working here), but I'd yet to really dive in and try and build something more involved.
Giving it some thought: I wasn't entirely sure what I wanted to build though. I do want to use Textual to build a brand new terminal-based Norton Guide reader (not my first, not by a long way) but I felt that was possibly a bit too niche, and actually could take a bit too long anyway. Maybe not, it remains to be seen.
Eventually I decided on this approach: try and do a quick prototype of some daft idea each day or each couple of days, do that for a week or so, and then finally try and settle down on something less trivial. This approach should work well in that it'll help introduce me to more of Textual, help try out a few different parts of the library, and also hopefully discover some real pain-points with working with it and highlight a list of issues we should address -- as seen from the perspective of a developer working with the library.
So, here I am, at the end of week one. What I want to try and do is briefly (yes yes, I know, this introduction is the antithesis of brief) talk about what I built and perhaps try and highlight some lessons learnt, highlight some patterns I think are useful, and generally do an end-of-week version of a TIL. TWIL?
Yeah. I guess this is a TWIL.
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#gridinfo","title":"gridinfo","text":"I started the week by digging out a quick hack I'd done a couple of weeks earlier, with a view to cleaning it up. It started out as a fun attempt to do something with Rich Pixels while also making a terminal-based take on slstats.el
. I'm actually pleased with the result and how quickly it came together.
The point of the application itself is to show some general information about the current state of the Second Life grid (hello to any fellow residents of the original Metaverse!), and to also provide a simple region lookup screen that, using Rich Pixels, will display the object map (albeit in pretty low resolution -- but that's the fun of this!).
So the opening screen looks like this:
and a lookup of a region looks like this:
Here's a wee video of the whole thing in action:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#worth-a-highlight","title":"Worth a highlight","text":"Here's a couple of things from the code that I think are worth a highlight, as things to consider when building Textual apps:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#dont-use-the-default-screen","title":"Don't use the default screen","text":"Use of the default Screen
that's provided by the App
is handy enough, but I feel any non-trivial application should really put as much code as possible in screens that relate to key \"work\". Here's the entirety of my application code:
class GridInfo( App[ None ] ):\n\"\"\"TUI app for showing information about the Second Life grid.\"\"\"\nCSS_PATH = \"gridinfo.css\"\n\"\"\"The name of the CSS file for the app.\"\"\"\nTITLE = \"Grid Information\"\n\"\"\"str: The title of the application.\"\"\"\nSCREENS = {\n\"main\": Main,\n\"region\": RegionInfo\n}\n\"\"\"The collection of application screens.\"\"\"\ndef on_mount( self ) -> None:\n\"\"\"Set up the application on startup.\"\"\"\nself.push_screen( \"main\" )\n
You'll notice there's no work done in the app, other than to declare the screens, and to set the main
screen running when the app is mounted.
on_mount
","text":"My initial version of the application had it loading up the data from the Second Life and GridSurvey APIs in Main.on_mount
. This obviously wasn't a great idea as it made the startup appear slow. That's when I realised just how handy call_after_refresh
is. This meant I could show some placeholder information and then fire off the requests (3 of them: one to get the main grid information, one to get the grid concurrency data, and one to get the grid size data), keeping the application looking active and updating the display when the replies came in.
While building this app I think there was only really the one pain-point, and I suspect it's mostly more on me than on Textual itself: getting a good layout and playing whack-a-mole with CSS. I suspect this is going to be down to getting more and more familiar with CSS and the terminal (which is different from laying things out for the web), while also practising with various layout schemes -- which is where the revamped Placeholder
class is going to be really useful.
The next application was initially going to be a very quick hack, but actually turned into a less-trivial build than I'd initially envisaged; not in a negative way though. The more I played with it the more I explored and I feel that this ended up being my first really good exploration of some useful (personal -- your kilometerage may vary) patterns and approaches when working with Textual.
The application itself is a terminal client for the Bored-API. I had initially intended to roll my own code for working with the API, but I noticed that someone had done a nice library for it and it seemed silly to not build on that. Not needing to faff with that, I could concentrate on the application itself.
At first I was just going to let the user click away at a button that showed a random activity, but this quickly morphed into a \"why don't I make this into a sort of TODO list builder app, where you can add things to do when you are bored, and delete things you don't care for or have done\" approach.
Here's a view of the main screen:
and here's a view of the filter pop-over:
"},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#worth-a-highlight_1","title":"Worth a highlight","text":""},{"location":"blog/2022/11/26/on-dog-food-the-original-metaverse-and-not-being-bored/#dont-put-all-your-bindings-in-one-place","title":"Don't put all yourBINDINGS
in one place","text":"This came about from me overloading the use of the escape
key. I wanted it to work more or less like this:
It was easy enough to do, and I had an action in the Main
screen that escape
was bound to (again, in the Main
screen) that did all this logic with some if
/elif
work but it didn't feel elegant. Moreover, it meant that the Footer
always displayed the same description for the key.
That's when I realised that it made way more sense to have a Binding
for escape
in every widget that was the actual context for escape's use. So I went from one top-level binding to...
...\nclass Activity( Widget ):\n\"\"\"A widget that holds and displays a suggested activity.\"\"\"\nBINDINGS = [\n...\nBinding( \"escape\", \"deselect\", \"Switch to Types\" )\n]\n...\nclass Filters( Vertical ):\n\"\"\"Filtering sidebar.\"\"\"\nBINDINGS = [\nBinding( \"escape\", \"close\", \"Close Filters\" )\n]\n...\nclass Main( Screen ):\n\"\"\"The main application screen.\"\"\"\nBINDINGS = [\nBinding( \"escape\", \"quit\", \"Close\" )\n]\n\"\"\"The bindings for the main screen.\"\"\"\n
This was so much cleaner and I got better Footer
descriptions too. I'm going to be leaning hard on this approach from now on.
Until I wrote this application I hadn't really had a need to define or use my own Message
s. During work on this I realised how handy they really are. In the code I have an Activity
widget which takes care of the job of moving itself amongst its siblings if the user asks to move an activity up or down. When this happens I also want the Main
screen to save the activities to the filesystem as things have changed.
Thing is: I don't want the screen to know what an Activity
is capable of and I don't want an Activity
to know what the screen is capable of; especially the latter as I really don't want a child of a screen to know what the screen can do (in this case \"save stuff\").
This is where messages come in. Using a message I could just set things up so that the Activity
could shout out \"HEY I JUST DID A THING THAT CHANGES ME\" and not care who is listening and not care what they do with that information.
So, thanks to this bit of code in my Activity
widget...
class Moved( Message ):\n\"\"\"A message to indicate that an activity has moved.\"\"\"\ndef action_move_up( self ) -> None:\n\"\"\"Move this activity up one place in the list.\"\"\"\nif self.parent is not None and not self.is_first:\nparent = cast( Widget, self.parent )\nparent.move_child(\nself, before=parent.children.index( self ) - 1\n)\nself.emit_no_wait( self.Moved( self ) )\nself.scroll_visible( top=True )\n
...the Main
screen can do this:
def on_activity_moved( self, _: Activity.Moved ) -> None:\n\"\"\"React to an activity being moved.\"\"\"\nself.save_activity_list()\n
Warning
The code above used emit_no_wait
. Since this blog post was first published that method has been removed from Textual. You should use post_message_no_wait
or post_message
instead now.
On top of the issues of getting to know terminal-based-CSS that I mentioned earlier:
Input
widgets. They too are on the roadmap but I managed to cobble together fairly good working versions for my purposes. In doing so though I did further highlight that the reactive attribute facility needs a wee bit more attention as I ran into some (already-known) bugs. Thankfully in my case it was a very easy workaround.The first week of \"dogfooding\" has been fun and I'm more convinced than ever that it's an excellent exercise for Textualize to engage in. I didn't quite manage my plan of \"one silly trivial prototype per day\", which means I've ended up with two (well technically one and a half I guess given that gridinfo
already existed as a prototype) applications rather than four. I'm okay with that. I got a lot of utility out of this.
Now to look at the list of ideas I have going and think about what I'll kick next week off with...
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/","title":"What I learned from my first non-trivial PR","text":"PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0 ligula.\u00a0Nullam\u00a0imperdiet\u00a0sem\u00a0tellus, sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0\u2586\u2586consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0\u2586\u2586 Sed\u00a0lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 lacinia,\u00a0sapien\u00a0sapien\u00a0congue\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quisIt's 8:59 am and, by my Portuguese standards, it is freezing cold outside: 5 or 6 degrees Celsius. It is my second day at Textualize and I just got into the office. I undress my many layers of clothing to protect me from the Scottish cold and I sit down in my improvised corner of the Textualize office. As I sit down, I turn myself in my chair to face my boss and colleagues to ask \u201cSo, what should I do today?\u201d. I was not expecting Will's answer, but the challenge excited me:
\u201cI thought I'll just throw you in the deep end and have you write some code.\u201d
What happened next was that I spent two days working on PR #1229 to add a new widget to the Textual code base. At the time of writing, the pull request has not been merged yet. Well, to be honest with you, it hasn't even been reviewed by anyone... But that won't stop me from blogging about some of the things I learned while creating this PR.
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#the-placeholder-widget","title":"The placeholder widget","text":"This PR adds a widget called Placeholder
to Textual. As per the documentation, this widget \u201cis meant to have no complex functionality. Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.\u201d
The point of the placeholder widget is that you can focus on building the layout of your app without having to have all of your (custom) widgets ready. The placeholder widget also displays a couple of useful pieces of information to help you work out the layout of your app, namely the ID of the widget itself (or a custom label, if you provide one) and the width and height of the widget.
As an example of usage of the placeholder widget, you can refer to the screenshot at the top of this blog post, which I included below so you don't have to scroll up:
PlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0 ligula.\u00a0Nullam\u00a0imperdiet\u00a0sem\u00a0tellus, sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0\u2586\u2586consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0\u2586\u2586 Sed\u00a0lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 lacinia,\u00a0sapien\u00a0sapien\u00a0congue\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quisThe top left and top right widgets have custom labels. Immediately under the top right placeholder, you can see some placeholders identified as #p3
, #p4
, and #p5
. Those are the IDs of the respective placeholders. Then, rows 2 and 3 contain some placeholders that show their respective size and some placeholders that just contain some text.
So, how does a code monkey start working on a non-trivial PR within 24 hours of joining a company? The answer is simple: just copy and paste code! But instead of copying and pasting from Stack Overflow, I decided to copy and paste from the internal code base.
My task was to create a new widget, so I thought it would be a good idea to take a look at the implementation of other Textual widgets. For some reason I cannot seem to recall, I decided to take a look at the implementation of the button widget that you can find in _button.py. By looking at how the button widget is implemented, I could immediately learn a few useful things about what I needed to do and some other things about how Textual works.
For example, a widget can have a class attribute called DEFAULT_CSS
that specifies the default CSS for that widget. I learned this just from staring at the code for the button widget.
Studying the code base will also reveal the standards that are in place. For example, I learned that for a widget with variants (like the button with its \u201csuccess\u201d and \u201cerror\u201d variants), the widget gets a CSS class with the name of the variant prefixed by a dash. You can learn this by looking at the method Button.watch_variant
:
class Button(Static, can_focus=True):\n# ...\ndef watch_variant(self, old_variant: str, variant: str):\nself.remove_class(f\"-{old_variant}\")\nself.add_class(f\"-{variant}\")\n
In short, looking at code and files that are related to the things you need to do is a great way to get information about things you didn't even know you needed.
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#handling-the-placeholder-variant","title":"Handling the placeholder variant","text":"A button widget can have a different variant, which is mostly used by Textual to determine the CSS that should apply to the given button. For the placeholder widget, we want the variant to determine what information the placeholder shows. The original GitHub issue mentions 5 variants for the placeholder:
The variant can be assigned when the placeholder is first instantiated, for example, Placeholder(\"css\")
would create a placeholder that shows its own CSS. However, we also want to have an on_click
handler that cycles through all the possible variants. I was getting ready to reinvent the wheel when I remembered that the standard module itertools
has a lovely tool that does exactly what I needed! Thus, all I needed to do was create a new cycle
through the variants each time a placeholder is created and then grab the next variant whenever the placeholder is clicked:
class Placeholder(Static):\ndef __init__(\nself,\nvariant: PlaceholderVariant = \"default\",\n*,\nlabel: str | None = None,\nname: str | None = None,\nid: str | None = None,\nclasses: str | None = None,\n) -> None:\n# ...\nself.variant = self.validate_variant(variant)\n# Set a cycle through the variants with the correct starting point.\nself._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)\nwhile next(self._variants_cycle) != self.variant:\npass\ndef on_click(self) -> None:\n\"\"\"Click handler to cycle through the placeholder variants.\"\"\"\nself.cycle_variant()\ndef cycle_variant(self) -> None:\n\"\"\"Get the next variant in the cycle.\"\"\"\nself.variant = next(self._variants_cycle)\n
I am just happy that I had the insight to add this little while
loop when a placeholder is instantiated:
from itertools import cycle\n# ...\nclass Placeholder(Static):\n# ...\ndef __init__(...):\n# ...\nself._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)\nwhile next(self._variants_cycle) != self.variant:\npass\n
Can you see what would be wrong if this loop wasn't there?
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#updating-the-render-of-the-placeholder-on-variant-change","title":"Updating the render of the placeholder on variant change","text":"If the variant of the placeholder is supposed to determine what information the placeholder shows, then that information must be updated every time the variant of the placeholder changes. Thankfully, Textual has reactive attributes and watcher methods, so all I needed to do was... Defer the problem to another method:
class Placeholder(Static):\n# ...\nvariant = reactive(\"default\")\n# ...\ndef watch_variant(\nself, old_variant: PlaceholderVariant, variant: PlaceholderVariant\n) -> None:\nself.validate_variant(variant)\nself.remove_class(f\"-{old_variant}\")\nself.add_class(f\"-{variant}\")\nself.call_variant_update() # <-- let this method do the heavy lifting!\n
Doing this properly required some thinking. Not that the current proposed solution is the best possible, but I did think of worse alternatives while I was thinking how to tackle this. I wasn't entirely sure how I would manage the variant-dependant rendering because I am not a fan of huge conditional statements that look like switch statements:
if variant == \"default\":\n# render the default placeholder\nelif variant == \"size\":\n# render the placeholder with its size\nelif variant == \"state\":\n# render the state of the placeholder\nelif variant == \"css\":\n# render the placeholder with its CSS rules\nelif variant == \"text\":\n# render the placeholder with some text inside\n
However, I am a fan of using the built-in getattr
and I thought of creating a rendering method for each different variant. Then, all I needed to do was make sure the variant is part of the name of the method so that I can programmatically determine the name of the method that I need to call. This means that the method Placeholder.call_variant_update
is just this:
class Placeholder(Static):\n# ...\ndef call_variant_update(self) -> None:\n\"\"\"Calls the appropriate method to update the render of the placeholder.\"\"\"\nupdate_variant_method = getattr(self, f\"_update_{self.variant}_variant\")\nupdate_variant_method()\n
If self.variant
is, say, \"size\"
, then update_variant_method
refers to _update_size_variant
:
class Placeholder(Static):\n# ...\ndef _update_size_variant(self) -> None:\n\"\"\"Update the placeholder with the size of the placeholder.\"\"\"\nwidth, height = self.size\nself._placeholder_label.update(f\"[b]{width} x {height}[/b]\")\n
This variant \"size\"
also interacts with resizing events, so we have to watch out for those:
class Placeholder(Static):\n# ...\ndef on_resize(self, event: events.Resize) -> None:\n\"\"\"Update the placeholder \"size\" variant with the new placeholder size.\"\"\"\nif self.variant == \"size\":\nself._update_size_variant()\n
"},{"location":"blog/2022/11/22/what-i-learned-from-my-first-non-trivial-pr/#deleting-code-is-a-hurtful-blessing","title":"Deleting code is a (hurtful) blessing","text":"To conclude this blog post, let me muse about the fact that the original issue mentioned five placeholder variants and that my PR only includes two and a half.
After careful consideration and after coming up with the getattr
mechanism to update the display of the placeholder according to the active variant, I started showing the \u201cfinal\u201d product to Will and my other colleagues. Eventually, we ended up getting rid of the variant for CSS and the variant that shows the placeholder state. This means that I had to delete part of my code even before it saw the light of day.
On the one hand, deleting those chunks of code made me a bit sad. After all, I had spent quite some time thinking about how to best implement that functionality! But then, it was time to write documentation and tests, and I verified that the best code is the code that you don't even write! The code you don't write is guaranteed to have zero bugs and it also does not need any documentation whatsoever!
So, it was a shame that some lines of code I poured my heart and keyboard into did not get merged into the Textual code base. On the other hand, I am quite grateful that I won't have to fix the bugs that will certainly reveal themselves in a couple of weeks or months from now. Heck, the code hasn't been merged yet and just by writing this blog post I noticed a couple of tweaks that were missing!
"},{"location":"blog/2023/07/29/pull-requests-are-cake-or-puppies/","title":"Pull Requests are cake or puppies","text":"Broadly speaking, there are two types of contributions you can make to an Open Source project.
The first type is typically a bug fix, but could also be a documentation update, linting fix, or other change which doesn't impact core functionality. Such a contribution is like cake. It's a simple, delicious, gift to the project.
The second type of contribution often comes in the form of a new feature. This contribution likely represents a greater investment of time and effort than a bug fix. It is still a gift to the project, but this contribution is not cake.
A feature PR has far more in common with a puppy. The maintainer(s) may really like the feature but hesitate to merge all the same. They may even reject the contribution entirely. This is because a feature PR requires an ongoing burden to maintain. In the same way that a puppy needs food and walkies, a new feature will require updates and fixes long after the original contribution. Even if it is an amazing feature, the maintainer may not want to commit to that ongoing work.
The chances of a feature being merged can depend on the maturity of the project. At the beginning of a project, a maintainer may be delighted with a new feature contribution. After all, having others join you to build something is the joy of Open Source. And yet when a project gets more mature there may be a growing resistance to adding new features, and a greater risk that a feature PR is rejected or sits unappreciated in the PR queue.
So how should a contributor avoid this? If there is any doubt, it's best to propose the feature to the maintainers before undertaking the work. In all likelihood they will be happy for your contribution, just be prepared for them to say \"thanks but no thanks\". Don't take it as a rejection of your gift: it's just that the maintainer can't commit to taking on a puppy.
There are other ways to contribute code to a project that don't require the code to be merged in to the core. You could publish your change as a third party library. Take it from me: maintainers love it when their project spawns an ecosystem. You could also blog about how you solved your problem without an update to the core project. Having a resource that can be googled for, or a maintainer can direct people to, can be a huge help.
What prompted me to think about this is that my two main projects, Rich and Textual, are at quite different stages in their lifetime. Rich is relatively mature, and I'm unlikely to accept a puppy. If you can achieve what you need without adding to the core library, I am probably going to decline a new feature. Textual is younger and still accepting puppies \u2014 in addition to stick insects, gerbils, capybaras and giraffes.
Tip
If you are maintainer, and you do have to close a feature PR, feel free to link to this post.
Join us on the Discord Server if you want to discuss puppies and other creatures.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/","title":"Textual 0.11.0 adds a beautiful Markdown widget","text":"We released Textual 0.10.0 25 days ago, which is a little longer than our usual release cycle. What have we been up to?
The headline feature of this release is the enhanced Markdown support. Here's a screenshot of an example:
MarkdownApp \u258bHeader\u00a0level\u00a06\u00a0content. \u25bc\u00a0\u2160\u00a0Textual\u00a0Markdown\u00a0Browser\u00a0-\u00a0Demo\u258b \u251c\u2500\u2500\u00a0\u25bc\u00a0\u2161\u00a0Headers\u258b\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2162\u00a0This\u00a0is\u00a0H3\u258b\u258e\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2163\u00a0This\u00a0is\u00a0H4\u258b\u258eTypography\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u25bc\u00a0\u2164\u00a0This\u00a0is\u00a0H5\u258b\u258e\u258b \u2502\u00a0\u00a0\u00a0\u2514\u2500\u2500\u00a0\u2165\u00a0This\u00a0is\u00a0H6\u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u251c\u2500\u2500\u00a0\u25bc\u00a0\u2161\u00a0Typography\u258bThe\u00a0usual\u00a0Markdown\u00a0typography\u00a0is\u00a0supported.\u00a0The\u00a0exact\u00a0output\u00a0depends\u00a0on\u00a0 \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Emphasis\u258byour\u00a0terminal,\u00a0although\u00a0most\u00a0are\u00a0fairly\u00a0consistent.\u2581\u2581 \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Strong\u258b \u2502\u00a0\u00a0\u00a0\u2523\u2501\u2501\u00a0\u2162\u00a0Strikethrough\u258bEmphasis \u2502\u00a0\u00a0\u00a0\u2517\u2501\u2501\u00a0\u2162\u00a0Inline\u00a0code\u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u251c\u2500\u2500\u00a0\u2161\u00a0Fences\u258bEmphasis\u00a0is\u00a0rendered\u00a0with\u00a0*asterisks*,\u00a0and\u00a0looks\u00a0like\u00a0this; \u251c\u2500\u2500\u00a0\u2161\u00a0Quote\u258b \u2514\u2500\u2500\u00a0\u2161\u00a0Tables\u258bStrong \u258b\u2594\u2594\u2594\u2594\u2594\u2594 \u258bUse\u00a0two\u00a0asterisks\u00a0to\u00a0indicate\u00a0strong\u00a0which\u00a0renders\u00a0in\u00a0bold,\u00a0e.g.\u00a0 \u258b**strong**\u00a0render\u00a0strong. \u258b \u258bStrikethrough \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bTwo\u00a0tildes\u00a0indicates\u00a0strikethrough,\u00a0e.g.\u00a0~~cross\u00a0out~~\u00a0render\u00a0cross\u00a0out. \u258b\u2582\u2582 \u258bInline\u00a0code \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bInline\u00a0code\u00a0is\u00a0indicated\u00a0by\u00a0backticks.\u00a0e.g.\u00a0import\u00a0this. \u258b \u258b\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258b\u258e\u258b \u258b\u258eFences\u258b \u258b\u258e\u258b \u258b\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258bFenced\u00a0code\u00a0blocks\u00a0are\u00a0introduced\u00a0with\u00a0three\u00a0back-ticks\u00a0and\u00a0the\u00a0optional\u00a0 \u258bparser.\u00a0Here\u00a0we\u00a0are\u00a0rendering\u00a0the\u00a0code\u00a0in\u00a0a\u00a0sub-widget\u00a0with\u00a0syntax\u00a0 \u258bhighlighting\u00a0and\u00a0indent\u00a0guides. \u258b \u258bIn\u00a0the\u00a0future\u00a0I\u00a0think\u00a0we\u00a0could\u00a0add\u00a0controls\u00a0to\u00a0export\u00a0the\u00a0code,\u00a0copy\u00a0to\u00a0 \u258bthe\u00a0clipboard.\u00a0Heck,\u00a0even\u00a0run\u00a0it\u00a0and\u00a0show\u00a0the\u00a0output? \u258b \u258b \u258b@lru_cache(maxsize=1024) \u258bdefsplit(self,cut_x:int,cut_y:int)->tuple[Region,Region,Regi \u258b\u2502\u00a0\u00a0\u00a0\"\"\"Split\u00a0a\u00a0region\u00a0in\u00a0to\u00a04\u00a0from\u00a0given\u00a0x\u00a0and\u00a0y\u00a0offsets\u00a0(cuts). \u00a0T\u00a0\u00a0TOC\u00a0\u00a0B\u00a0\u00a0Back\u00a0\u00a0F\u00a0\u00a0Forward\u00a0Tip
You can generate these SVG screenshots for your app with textual run my_app.py --screenshot 5
which will export a screenshot after 5 seconds.
There are actually 2 new widgets: Markdown for a simple Markdown document, and MarkdownViewer which adds browser-like navigation and a table of contents.
Textual has had support for Markdown since day one by embedding a Rich Markdown object -- which still gives decent results! This new widget adds dynamic controls such as scrollable code fences and tables, in addition to working links.
In future releases we plan on adding more Markdown extensions, and the ability to easily embed custom widgets within the document. I'm sure there are plenty of interesting applications that could be powered by dynamically generated Markdown documents.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#datatable-improvements","title":"DataTable improvements","text":"There has been a lot of work on the DataTable API. We've added the ability to sort the data, which required that we introduce the concept of row and column keys. You can now reference rows / columns / cells by their coordinate or by row / column key.
Additionally there are new update_cell and update_cell_at methods to update cells after the data has been populated. Future releases will have more methods to manipulate table data, which will make it a very general purpose (and powerful) widget.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#tree-control","title":"Tree control","text":"The Tree widget has grown a few methods to programmatically expand, collapse and toggle tree nodes.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#breaking-changes","title":"Breaking changes","text":"There are a few breaking changes in this release. These are mostly naming and import related, which should be easy to fix if you are affected. Here's a few notable examples:
Checkbox
has been renamed to Switch
. This is because we plan to introduce complimentary Checkbox
and RadioButton
widgets in a future release, but we loved the look of Switches too much to drop them.emit
and emit_no_wait
methods. These methods posted message to the parent widget, but we found that made it problematic to subclass widgets. In almost all situations you want to replace these with self.post_message
(or self.post_message_no_wait
).Be sure to check the CHANGELOG for the full details on potential breaking changes.
"},{"location":"blog/2023/02/15/textual-0110-adds-a-beautiful-markdown-widget/#join-us","title":"Join us!","text":"We're having fun on our Discord server. Join us there to talk to Textualize developers and share ideas.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/","title":"Textual 0.12.0 adds syntactical sugar and batch updates","text":"It's been just 9 days since the previous release, but we have a few interesting enhancements to the Textual API to talk about.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#better-compose","title":"Better compose","text":"We've added a little syntactical sugar to Textual's compose
methods, which aids both readability and editability (that might not be a word).
First, let's look at the old way of building compose methods. This snippet is taken from the textual colors
command.
for color_name in ColorSystem.COLOR_NAMES:\nitems: list[Widget] = [ColorLabel(f'\"{color_name}\"')]\nfor level in LEVELS:\ncolor = f\"{color_name}-{level}\" if level else color_name\nitem = ColorItem(\nColorBar(f\"${color}\", classes=\"text label\"),\nColorBar(\"$text-muted\", classes=\"muted\"),\nColorBar(\"$text-disabled\", classes=\"disabled\"),\nclasses=color,\n)\nitems.append(item)\nyield ColorGroup(*items, id=f\"group-{color_name}\")\n
This code composes the following color swatches:
ColorsApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 primary \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b\u2581\u2581 secondary\u258e\"primary\"\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b background\u258e$primary-darken-3$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b primary-background\u258e$primary-darken-2$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b secondary-background\u258e$primary-darken-1$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b surface\u258e$primary$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b panel\u258e$primary-lighten-1$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b boost\u258e$primary-lighten-2$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b warning\u258e$primary-lighten-3$text-muted$text-disabled\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b error\u258e\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 success \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258b accent\u258e\"secondary\"\u258b \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258b \u258e\u258b \u00a0D\u00a0\u00a0Toggle\u00a0dark\u00a0mode\u00a0Tip
You can see this by running textual colors
from the command line.
The old way was not all that bad, but it did make it hard to see the structure of your app at-a-glance, and editing compose methods always felt a little laborious.
Here's the new syntax, which uses context managers to add children to containers:
for color_name in ColorSystem.COLOR_NAMES:\nwith ColorGroup(id=f\"group-{color_name}\"):\nyield Label(f'\"{color_name}\"')\nfor level in LEVELS:\ncolor = f\"{color_name}-{level}\" if level else color_name\nwith ColorItem(classes=color):\nyield ColorBar(f\"${color}\", classes=\"text label\")\nyield ColorBar(\"$text-muted\", classes=\"muted\")\nyield ColorBar(\"$text-disabled\", classes=\"disabled\")\n
The context manager approach generally results in fewer lines of code, and presents attributes on the same line as containers themselves. Additionally, adding widgets to a container can be as simple is indenting them.
You can still construct widgets and containers with positional arguments, but this new syntax is preferred. It's not documented yet, but you can start using it now. We will be updating our examples in the next few weeks.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#batch-updates","title":"Batch updates","text":"Textual is smart about performing updates to the screen. When you make a change that might repaint the screen, those changes don't happen immediately. Textual makes a note of them, and repaints the screen a short time later (around a 1/60th of a second). Multiple updates are combined so that Textual does less work overall, and there is none of the flicker you might get with multiple repaints.
Although this works very well, it is possible to introduce a little flicker if you make changes across multiple widgets. And especially if you add or remove many widgets at once. To combat this we have added a batch_update context manager which tells Textual to disable screen updates until the end of the with block.
The new Markdown widget uses this context manager when it updates its content. Here's the code:
with self.app.batch_update():\nawait self.query(\"MarkdownBlock\").remove()\nawait self.mount_all(output)\n
Without the batch update there are a few frames where the old markdown blocks are removed and the new blocks are added (which would be perceived as a brief flicker). With the update, the update appears instant.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#disabled-widgets","title":"Disabled widgets","text":"A few widgets (such as Button) had a disabled
attribute which would fade the widget a little and make it unselectable. We've extended this to all widgets. Although it is particularly applicable to input controls, anything may be disabled. Disabling a container makes its children disabled, so you could use this for disabling a form, for example.
Tip
Disabled widgets may be styled with the :disabled
CSS pseudo-selector.
Also in this release is another context manager, which will disable specified Message types. This doesn't come up as a requirement very often, but it can be very useful when it does. This one is documented, see Preventing events for details.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#full-changelog","title":"Full changelog","text":"As always see the release page for additional changes and bug fixes.
"},{"location":"blog/2023/02/24/textual-0120-adds-syntactical-sugar-and-batch-updates/#join-us","title":"Join us!","text":"We're having fun on our Discord server. Join us there to talk to Textualize developers and share ideas.
"},{"location":"blog/2023/03/09/textual-0140-shakes-up-posting-messages/","title":"Textual 0.14.0 shakes up posting messages","text":"Textual version 0.14.0 has landed just a week after 0.13.0.
Note
We like fast releases for Textual. Fast releases means quicker feedback, which means better code.
What's new?
We did a little shake-up of posting messages which will simplify building widgets. But this does mean a few breaking changes.
There are two methods in Textual to post messages: post_message
and post_message_no_wait
. The former was asynchronous (you needed to await
it), and the latter was a regular method call. These two methods have been replaced with a single post_message
method.
To upgrade your project to Textual 0.14.0, you will need to do the following:
await
keywords from any calls to post_message
.post_message_no_wait
with post_message
.Additionally, we've simplified constructing messages classes. Previously all messages required a sender
argument, which had to be manually set. This was a clear violation of our \"no boilerplate\" policy, and has been dropped. There is still a sender
property on messages / events, but it is set automatically.
So prior to 0.14.0 you might have posted messages like the following:
await self.post_message(self.Changed(self, item=self.item))\n
You can now replace it with this simpler function call:
self.post_message(self.Change(item=self.item))\n
This also means that you will need to drop the sender from any custom messages you have created.
If this was code pre-0.14.0:
class MyWidget(Widget):\nclass Changed(Message):\n\"\"\"My widget change event.\"\"\"\ndef __init__(self, sender:MessageTarget, item_index:int) -> None:\nself.item_index = item_index\nsuper().__init__(sender)\n
You would need to make the following change (dropping sender
).
class MyWidget(Widget):\nclass Changed(Message):\n\"\"\"My widget change event.\"\"\"\ndef __init__(self, item_index:int) -> None:\nself.item_index = item_index\nsuper().__init__()\n
If you have any problems upgrading, join our Discord server, we would be happy to help.
See the release notes for the full details on this update.
"},{"location":"blog/2023/03/13/textual-0150-adds-a-tabs-widget/","title":"Textual 0.15.0 adds a tabs widget","text":"We've just pushed Textual 0.15.0, only 4 days after the previous version. That's a little faster than our typical release cadence of 1 to 2 weeks.
What's new in this release?
The highlight of this release is a new Tabs widget to display tabs which can be navigated much like tabs in a browser. Here's a screenshot:
TabsApp Paul\u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0Halleck \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0In a future release, this will be combined with the ContentSwitcher widget to create a traditional tabbed dialog. Although Tabs is still useful as a standalone widgets.
Tip
I like to tweet progress with widgets on Twitter. See the #textualtabs hashtag which documents progress on this widget.
Also in this release is a new LoadingIndicator widget to display a simple animation while waiting for data. Here's a screenshot:
LoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cfAs always, see the release notes for the full details on this update.
If you want to talk about these widgets, or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/","title":"Textual 0.16.0 adds TabbedContent and border titles","text":"Textual 0.16.0 lands 9 days after the previous release. We have some new features to show you.
There are two highlights in this release. In no particular order, the first is TabbedContent which uses a row of tabs to navigate content. You will have likely encountered this UI in the desktop and web. I think in Windows they are known as \"Tabbed Dialogs\".
This widget combines existing Tabs and ContentSwitcher widgets and adds an expressive interface for composing. Here's a trivial example to use content tabs to navigate a set of three markdown documents:
def compose(self) -> ComposeResult:\nwith TabbedContent(\"Leto\", \"Jessica\", \"Paul\"):\nyield Markdown(LETO)\nyield Markdown(JESSICA)\nyield Markdown(PAUL)\n
Here's an example of the UI you can create with this widget (note the nesting)!
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/#border-titles","title":"Border titles","text":"The second highlight is a frequently requested feature (FRF?). Widgets now have the two new string properties, border_title
and border_subtitle
, which will be displayed within the widget's border.
You can set the alignment of these titles via border-title-align
and border-subtitle-align
. Titles may contain Console Markup, so you can add additional color and style to the labels.
Here's an example of a widget with a title:
BorderApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 ascii \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 none \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550double\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 hidden\u2551\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551I\u00a0must\u00a0not\u00a0fear.\u2551 blank\u2551Fear\u00a0is\u00a0the\u00a0mind-killer.\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2551 round\u2551I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551path.\u2551 solid\u2586\u2586\u2551Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2551remain.\u2551 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2551\u2551 double\u2551\u2551 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 dashed \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 heavy \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581BTW the above is a command you can run to see the various border styles you can apply to widgets.
textual borders\n
"},{"location":"blog/2023/03/22/textual-0160-adds-tabbedcontent-and-border-titles/#container-changes","title":"Container changes","text":"Breaking change
If you have an app that uses any container classes, you should read this section.
We've made a change to containers in this release. Previously all containers had auto scrollbars, which means that any container would scroll if its children didn't fit. With nested layouts, it could be tricky to understand exactly which containers were scrolling. In 0.16.0 we split containers in to scrolling and non-scrolling versions. So Horizontal
will now not scroll by default, but HorizontalScroll
will have automatic scrollbars.
As always, see the release notes for the full details on this update.
If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/","title":"Textual 0.17.0 adds translucent screens and Option List","text":"This is a surprisingly large release, given it has been just 7 days since the last version (and we were down a developer for most of that time).
What's new in this release?
There are two new notable features I want to cover. The first is a compositor effect.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#translucent-screens","title":"Translucent screens","text":"Textual has a concept of \"screens\" which you can think of as independent UI modes, each with their own user interface and logic. The App class keeps a stack of these screens so you can switch to a new screen and later return to the previous screen.
Screens
See the guide to learn more about the screens API.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXOtT20hcdTAwMTL/nr+C4r7sVcWz0z3vrbq6XHUwMDAyXHUwMDEyXHUwMDEyQnhsyObB3VZK2MLW4ddaMsZs5X+/XHUwMDFlhSDJQorBxnHiXHUwMDBmXHUwMDE4a+RRa+bX3b9+yH8/2djYTKbDcPO3jc3wqlx1MDAxOXSj1iiYbD71xy/DUVx1MDAxY1xy+jSE6ed4MFx1MDAxZTXTMztJMox/+/XXXjC6XGKTYTdohuwyisdBN07GrWjAmoPer1FcdTAwMTL24n/7v4dBL/zXcNBrJSOWXaRcdTAwMTG2omQw+nKtsFx1MDAxYvbCflx1MDAxMtPs/6HPXHUwMDFiXHUwMDFif6d/c9K1oqA36LfS09OBnHhazFx1MDAxZT1cdTAwMWP0U1FBXHUwMDBiLZW28vaEKH5GXHUwMDE3S8JcdTAwMTaNnpPAYTbiXHUwMDBmbcrRNNFcdTAwMDae897Fx119cmhcdTAwMGZOxyfZVc+jbvckmXZTmeJcdTAwMDHdSjZcdTAwMTYno8FF+D5qJVx1MDAxZH/pmeNV31x1MDAxYVxyxu1OP4zjwndcdTAwMDbDoFx1MDAxOSVTOqb47cGg306nyI5cXNGnXHUwMDA2cs6M0Vx1MDAxNqTiXHUwMDEy6G7V7fiXXHRcdTAwMDQz1lx1MDAxOFx1MDAwNUJcdTAwMWEhXHUwMDA1qFx1MDAxOcl2XHUwMDA2XdpcdTAwMDeS7Fx1MDAxZjx9ZbKdXHUwMDA1zYs2XHTYb2XngFxugrPz7JzJzf1Kp5i0Uphs+k5cdTAwMTi1O4nfIauZNcBdfjRcdTAwMGXTTXCgpJNaZlvkrzjca6Vg+HN2XHUwMDE1O8FoeLNam7H/kJPWXHUwMDBi+nxcdTAwMTZJeTTl9lm8grPdXHUwMDEwYKe1v/3X85NcdTAwMDP5+2CrfztXXHUwMDAxesFoNJhs3o58vvkvXHUwMDEzbTxsXHUwMDA1X1x1MDAxMFx1MDAwNVpLa43TXHUwMDEyTVx1MDAwNspu1L+gwf64282OXHKaXHUwMDE3XHUwMDE5XGLTo5+f3lx1MDAxYvp0mSroo+OO0KD03NBcdTAwMGbHU3ux39vnfHz+ctLeiyb6hfue0Fx1MDAwN/5N7IPTTFx1MDAxOSNRc1x1MDAwZVx1MDAwMoyyXHUwMDA17EuBXGalQYKeddo4vlx1MDAxOPbPgzPO1Vx1MDAxMrGPQipwlq9cdTAwMTb7vd45n2zx5NlhNFxmwz9eXHUwMDFlbb86iJeEfVx1MDAwYlxccG6Whf0kvEruXHUwMDAyvkVdXHUwMDA1fFx1MDAxMNZx5NLh3Mh/d941l1fDy5fT3taHwfjj8PiF2F1v5CMqprRBXHUwMDA0dEZ6XHUwMDBiWlx1MDAwML7lwMhcdTAwMDRJclxi1iHkrMBDcG+c4udYxj1wW1x1MDAwNryBWZhrgdL7pp/IxDtcdTAwMGJK2PvAPEPToJ+cRNepjbaFo7tBL+pOXHUwMDBikEjxT1x1MDAwMp40R2HY34D/9n/pRK1W2P9nfsfikK7vJ9TFb251o7bXls1ueF5UoyRcIlx1MDAxZXY7nFxmcmvcJElcdTAwMDKabrTXmr2jwShqR/2g+7Zaqlpt/rLMd6gzUVx1MDAxM5w9nNNnIHojxPz6XFy/8/fQZ5zF5uPps3HMSFx1MDAwMGm4pXdbVGfjJFx1MDAwM81cdTAwMWRcdTAwMWGU6EjjXHUwMDFmRZ1cdTAwMWQyrogwS2MsR9TuXHUwMDBl5XZMIzk6KVFcdTAwMDHXOlx1MDAwM/BXlyaFv4VcdTAwMDeoeipkjao/ijLGSTBKtqN+K+q3aTCzXCJfQ5K9OVx1MDAxY0Sqvs2xl5IzhVxcS8FpI5UgL1x1MDAwNLmT2sHQLyFcdTAwMDMgTqLJZKNVTtibXHUwMDEzPt9cblx1MDAxNfZb31x1MDAxNqk+UMmJ1OBcZml5nPV7pjhqilx1MDAxM0pCSaZcdTAwMDVIXHUwMDA3nITiTlmnSlJ1gzjZXHUwMDE59HpRQmt/PIj6yexcdTAwMWGni7nldbxcdTAwMTNcdTAwMDYl40F3lVx1MDAxZps1XHUwMDA2Qz9j0aZn/21k2pJ+uP3/z6d3nt2ohHI6WkJxNt+T/PtcdTAwMDNcdTAwMTi5sJWGXGYtqVx1MDAwNlx1MDAxMZdM8b/JyM9G2NpcdTAwMGKuw/1nW1x1MDAwN89f9LWNIVhvXlwinGOOTFx1MDAxNVxiNN5y68xcdTAwMTJ8XHRGLeNcdTAwMDLAOeK+ZCaEmJHsIcGo1suj5ESVkKJcYs5cdTAwMWaBrNRYMECFuIqIkYLtSkeLZLhcdTAwMDVwNb+jPdzpXHUwMDFjXGZfXHUwMDFjvdtcdTAwMWRcdTAwMWNfj9xhcjQ+XHUwMDFmivVcdTAwMDao5IJcdJ9cdFGWdFWqYrJEXG7NuOPGXHUwMDEyQqVcdTAwMDZcXFxmnsuOXHUwMDE3SVx1MDAxZaE4mTS7fHDWMenXZ8rufYyvOsPx+MNWdPomuVx1MDAxOMllXHUwMDA1jIQ3yHG7x4O+XHUwMDE1qlxu+k6AUkS65jfN76eHb9o9t3+0r/76NH32abtzKI+WivxWXHUwMDEwd8IlQ98xolx1MDAxZYJcdTAwMTONXHUwMDA0YpGuXHUwMDAwfaGQXHUwMDExKZFcdTAwMDY5cDBcXC9GMi02XHUwMDFkhGqZ6CdcdTAwMTNpSTS+4nSJXHLDl+PX19fds1P96VxcJJGYnvL50P+0bt5Y7U+v4+3jyejgdO/Vofj0x9SeLWHe8+H7ydvG5Ni+v+7Fp1x1MDAxN83wI3ZcdTAwMGaWMC9cdTAwMWab//VO3mLyprl9XHUwMDE1NT/uvlx1MDAxMc1oWfE0J+S5pTnAqrSRrktcdTAwMWKRWjiKvOzcNuAsnuKOONpcdTAwMDXV3u6Mmq33h+r6dL3DTGlcdTAwMWSzSkvNOZleNZMuXHUwMDA1QaNOcrKERJ3JXHUwMDE3zlxudj9cdTAwMTNA6npcdTAwMTbewc1ErqZxq/myrO9CWqIrhj+Ct6tBolx1MDAwMan1fZCYbXiW2Vx1MDAxMVx1MDAxNOWlgYfnwDZcdTAwMTdJXHUwMDE38jzZVb7meYLhkFxyXHUwMDA3w09xmln55e4sj9CF7z12lqckU63qVeZ4dHVkROxXXHUwMDEz71Zyft2rN57L0L1H8L8gmERUXHUwMDE0ZiOZOzFTq7CGIVx1MDAxN9pcYqPJXHUwMDE3w2Ip2yrd40RvXHUwMDAxkVgwXHUwMDAx06E1QpZ1XHUwMDExuPGCmpRvXHUwMDAyXHUwMDE5XHUwMDA0V9ZNQjUpTC7JvopEXHUwMDBmcFqcx0z01NO6jUJWxVEsXHUwMDBmjmyoRE2rmUsx3CRVXHUwMDE0U1x1MDAxNFx1MDAwMyuOdFx1MDAwMtpvZnqKt/EjZVtqQJWOl/GUTfkk/35vo2JyRYVZo0JwXHUwMDAx8iD3yFx1MDAxYtczp/U0Ko5LZi1ZXGZDsaySZtaoXGJmXHUwMDA0V4pWnlxmXHUwMDBizEZcdTAwMWLLMipoOWghfSmfLpIrRuVsimWSglx1MDAwZetcdTAwMWNcdTAwMTiCXG6WykSgjZXOc5PV2lx1MDAxNKCYXCJbte9oU4DRXHUwMDBl0PIopykqJlx1MDAxZVJOXHUwMDFlXHUwMDAzZ8QmrNTkQoThXFyan9SmVEPKv1x1MDAxYWU0LcuiXHUwMDE08l+zXHUwMDE5XFyurZS+nDm3SalPnaynSdHKMCO55lx1MDAxMiVIk+tk8d/XXHUwMDAyXHUwMDE4+X3iMd6x4YItXHUwMDE1VSaFdMFcdTAwMTkjyWhJWnXSiez+b02KU1xmpXbKcsFccqrcrtxYXHUwMDE0dPRdhfpcdTAwMDFcdTAwMDHEQiSFK5fJ8nCDMqt8P4FaN6r3NVx1MDAxZC5t6T3Vuq5XSldTXHUwMDA1Tv5cdTAwMTM06vmpgn6m8FX0cnLZfvfhQrevT49+j79rn+C31ZrIMzBcbj6QTCatr5lpXHUwMDE5UUAkTSluXHUwMDFkUSaXL8ivR2VGeTOvVptcZlhdJ59cdTAwMTbVXlx1MDAwN4Rwvk1s/ui4cbU/tn+1wsvOycdLXHUwMDExTl87PNhZe3Rq5uNcdTAwMDdB2DMobLFwSG6XkS9cIuRcbq2EQbdcdTAwMTA6l16YcUIgUTfzgHB4kdR0I57s7CB2I9lcdTAwMTZcdTAwMWZa4z+O48nFslKyVlpuga9cdTAwMDD7NuegS1xy3IAkjJyfcMX93dFe7+L5azHF8L1otTvDg9Z6Q79cdTAwMDHWMrCKmKXTzmlcdTAwMGVcdTAwMDXoXHUwMDBiKYlcZiuL3KLv6l1cYvlfyjLLLElcdTAwMTJHXHUwMDA0XHUwMDEy7CcpypxtPT9/XHUwMDFmbPHD9rv9t1x1MDAwN83rQZDE46X1xqK0uDSNqlxmYWpcbp2gfHVbajN/W3h92Wd9I1x1MDAxOFxupYVcdTAwMDRSXHUwMDFiXHUwMDAxekafiOiQWfHxi6b9WKzOWVx1MDAxZMBcdTAwMThFXHUwMDExlKPolYiLJZ9VVizincxnZZRcIq/mfLmjpF5cdTAwMTTqy2Jss5pcdTAwMTCG1P1BNZBl50Q448AlxXBKXHUwMDE5jeA4N3d2r/neNk3RKsWlZJBuTvjZklwijWpQfVx1MDAxOS7hKZvxSf79vnVTqWD26C071cjR3ec5k6vXoVx1MDAxY530XHUwMDFiz/u6od1+6/CV6thcboPSXHSanfEoXFxcdTAwMDNcdTAwMWZN+GJcdTAwMDZ89yTB0T/jUEy0XHUwMDFhJ1x1MDAxOHF0X1x1MDAwZVx1MDAxMMpavlD1Jlx1MDAxOVx1MDAwNf14XHUwMDE4jEhd7jAsucxpTdO9XHUwMDE0qJw1YsWM9DGfLfHZXHUwMDAy41bedI9r2XSPizfdc1fdXHJBtsOSf7zHk5P1O38vtV5dP0SDXHUwMDAySlx1MDAwNspYIynYXHUwMDE3zuqZxnuLjLyhptPQmVxccWXpWlxyilx0XHUwMDAwKYiekW1x7q5cdTAwMTJcblx1MDAwMpM+XHLCLYVMoPJcdTAwMWRaN3TBu1x1MDAwN4fwkCzJXCJ0gTyzclx1MDAwZtHLOelCvcvYKDa7k+8z5CONTOvo5bIscEaLJITl3JdR9Ndi5D1cdTAwMWLw61x1MDAxZpcsUFx1MDAxOFx1MDAxMCCNpZ3zXHUwMDE5XHUwMDAyJY0uyWRcdTAwMTjSgCR648jGXHUwMDE5xJJMP1x1MDAxMk+pXHUwMDA2s381yjheXHUwMDE2TVx1MDAxMZWJXHUwMDA0XHUwMDA0n2+mUHV+nlwiPlxcyNa1fHn54tnhm9Z04sK+qirdrFx1MDAwZk9cdTAwMDHBmSFoI7FD32ePRYOGqH1cdTAwMDXRKCAuY6VSXHUwMDBipVx1MDAxM75BVO5o8ypcdTAwMTNcdTAwMTWyXHUwMDFjUlx0IdWPw1Se1s37mFx1MDAxOVx1MDAwNLK0Wq+eXHUwMDAxXHTiXHUwMDFhl1FcdTAwMWOddcN1okBcdTAwMDWxXHUwMDFlxoFcdTAwMTRWXHUwMDA2NuAsoZK4/vxN4fVbv65cdTAwMTTIP/IglFTeLKBRYqYtXHUwMDFjkFx0Ylx1MDAxNVx1MDAxNoUherTYXHUwMDAzO7X2XHUwMDAyXHUwMDFkOUokj01Ow3IpsyvdWlx1MDAwZu3du/TJXHUwMDAwXHS0MVjOl/jSXHUwMDAxRaSr7iF5sFrOSYDqfVGRXHUwMDAwgdCeXHUwMDA3Ulx1MDAxNFxuXHUwMDE2yeHlclx1MDAwNF9cdTAwMTmQZNJvJaBcdTAwMTJcdTAwMWOMeegziPW59qJUXFxcbmPJISljtNEu94NcdTAwMWa3YllmNHFcdTAwMDPaWue4MkKWpPqRSFAlnP2rXHUwMDA05CUxIFGdqCFhUEtcdTAwMGbXue3ZyfXlKzWNj6ZHL+LB5OPWZft479O6MyCK1ohcdTAwMDGh4MSCjK9ZXHUwMDE0XHUwMDEzNUJcdTAwMThGXHUwMDExgnRElFxibvlfUPlOmVx1MDAxYSBcdTAwMWHmIFx1MDAxN1x1MDAxNfzotUPHXHUwMDA1t8LlbmiFmZo15Cm4OE8xrlqvwVx1MDAxN1xyrTXzN6/Ub/2a8lx1MDAxNFxuKsH/mFx1MDAwZjk3w41cdTAwMTC5eCFtXHUwMDEwXHUwMDAwzdAnckxcdTAwMWHai8VcdTAwMWXdrNVr7dtotPDP9/tcdTAwMDdcdTAwMDfhXHUwMDBlLdeGOeu7XHUwMDE2NddWlVx1MDAxYdP8U2zKoV3l7yQspJVz0pR6h7FRKOs4RS9OXHUwMDFiXHUwMDA1WphcXGfyRpZcdTAwMTORXGLCao1cdTAwMWH8XHUwMDEzYuWfJJiLpNT3wszIRFxcSCvyyEJq8lx1MDAxZqIkXHUwMDEzkmfx7Wv+4XY0d7X0/0hcdTAwMTSlXHUwMDEyyOlgXHUwMDExwlVcdTAwMDTlyc3s/jGhk4TwdrtcdTAwMTVcdTAwMDTpqHVjyrNb3LyMwsn2XT056cvbx3QxvVx1MDAxNVxu/Y3+/fnJ5/9cdTAwMDPV4pXXIn0= Screen 1(hidden)app.pop_screen()Screen 2(hidden)Screen 3(visible)Screen 2(visible)
Screens can be used to build modal dialogs by pushing a screen with controls / buttons, and popping the screen when the user has finished with it. The problem with this approach is that there was nothing to indicate to the user that the original screen was still there, and could be returned to.
In this release we have added alpha support to the Screen's background color which allows the screen underneath to show through, typically blended with a little color. Applying this to a screen makes it clear than the user can return to the previous screen when they have finished interacting with the modal.
Here's how you can enable this effect with CSS:
DialogScreen {\nalign: center middle;\nbackground: $primary 30%;\n}\n
Setting the background to $primary
will make the background blue (with the default theme). The addition of 30%
sets the alpha so that it will be blended with the background. Here's the kind of effect this creates:
There are 4 screens in the above screenshot, one for the base screen and one for each of the three dialogs. Note how each screen modifies the color of the screen below, but leaves everything visible.
See the docs on screen opacity if you want to add this to your apps.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#option-list","title":"Option list","text":"Textual has had a ListView widget for a while, which is an excellent way of navigating a list of items (actually other widgets). In this release we've added an OptionList which is similar in appearance, but uses the line api under the hood. The Line API makes it more efficient when you approach thousands of items.
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258aGemenon\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258aPicon\u258e \u258aSagittaron\u258e \u258aScorpia\u258e \u258aTauron\u258e \u258aVirgon\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
The Options List accepts Rich renderable, which means that anything Rich can render may be displayed in a list. Here's an Option List of tables:
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aerilon\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Demeter\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25021.2\u00a0Billion\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Gaoth\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aquaria\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2582\u2582\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hermes\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250275,000\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502None\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Canceron\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hephaestus\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25026.7\u00a0Billion\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hades\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Caprica\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
We plan to build on the OptionList
widget to implement drop-downs, menus, check lists, etc. But it is still very useful as it is, and you can add it to apps now.
There are a number of fixes regarding refreshing in this release. If you had issues with parts of the screen not updating, the new version should resolve it.
There's also a new logging handler, and a \"thick\" border type.
See release notes for the full details.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#next-week","title":"Next week","text":"Next week we plan to take a break from building Textual to building apps with Textual. We do this now and again to give us an opportunity to step back and understand things from the perspective of a developer using Textual. We will hopefully have something interesting to show from the exercise, and new Open Source apps to share.
"},{"location":"blog/2023/03/29/textual-0170-adds-translucent-screens-and-option-list/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/04/04/textual-0180-adds-api-for-managing-concurrent-workers/","title":"Textual 0.18.0 adds API for managing concurrent workers","text":"Less than a week since the last release, and we have a new API to show you.
This release adds a new Worker API designed to manage concurrency, both asyncio tasks and threads.
An API to manage concurrency may seem like a strange addition to a library for building user interfaces, but on reflection it makes a lot of sense. People are building Textual apps to interface with REST APIs, websockets, and processes; and they are running into predictable issues. These aren't specifically Textual problems, but rather general problems related to async tasks and threads. It's not enough for us to point users at the asyncio docs, we needed a better answer.
The new run_worker
method provides an easy way of launching \"Workers\" (a wrapper over async tasks and threads) which also manages their lifetime.
One of the challenges I've found with tasks and threads is ensuring that they are shut down in an orderly manner. Interestingly enough, Textual already implemented an orderly shutdown procedure to close the tasks that power widgets: children are shut down before parents, all the way up to the App (the root node). The new API piggybacks on to that existing mechanism to ensure that worker tasks are also shut down in the same order.
Tip
You won't need to worry about this gnarly issue with the new Worker API.
I'm particularly pleased with the new @work
decorator which can turn a coroutine OR a regular function into a Textual Worker object, by scheduling it as either an asyncio task or a thread. I suspect this will solve 90% of the concurrency issues we see with Textual apps.
See the Worker API for the details.
"},{"location":"blog/2023/04/04/textual-0180-adds-api-for-managing-concurrent-workers/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/05/03/textual-0230-improves-message-handling/","title":"Textual 0.23.0 improves message handling","text":"It's been a busy couple of weeks at Textualize. We've been building apps with Textual, as part of our dog-fooding week. The first app, Frogmouth, was released at the weekend and already has 1K GitHub stars! Expect two more such apps this month.
Frogmouth /Users/willmcgugan/projects/textual/FAQ.md ContentsLocalBookmarksHistory\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u258e\u258a \u258eHow\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u258a \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e\u258a \u2503\u25bc\u00a0\u2160\u00a0Frequently\u00a0Asked\u00a0Questions\u2503\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Does\u00a0Textual\u00a0support\u00a0images?\u2503When\u00a0creating\u00a0your\u00a0App\u00a0class,\u00a0override\u00a0__init__\u00a0as\u00a0you\u00a0would\u00a0wheninheriting\u00a0normally.\u00a0For\u00a0example: \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0fix\u00a0ImportError\u00a0cannot\u00a0i\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0select\u00a0and\u00a0copy\u00a0text\u00a0in\u00a0\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0set\u00a0a\u00a0translucent\u00a0app\u00a0ba\u2503fromtextual.appimportApp,ComposeResult \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0center\u00a0a\u00a0widget\u00a0in\u00a0a\u00a0scre\u2503fromtextual.widgetsimportStatic \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0do\u00a0some\u00a0key\u00a0combinations\u00a0never\u2503classGreetings(App[None]): \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0look\u00a0good\u00a0on\u00a0m\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2514\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0support\u00a0ANSI\u00a0t\u2503\u2502\u00a0\u00a0\u00a0def__init__(self,greeting:str=\"Hello\",to_greet:str=\"World\")->None: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.greeting=greeting \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.to_greet=to_greet \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0super().__init__() \u2503\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2503\u2502\u00a0\u00a0\u00a0defcompose(self)->ComposeResult: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldStatic(f\"{self.greeting},\u00a0{self.to_greet}\") \u2503\u2503 \u2503\u2503 \u2503\u2503Then\u00a0the\u00a0app\u00a0can\u00a0be\u00a0run,\u00a0passing\u00a0in\u00a0various\u00a0arguments;\u00a0for\u00a0example: \u2503\u2503\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0default\u00a0arguments. \u2503\u2503Greetings().run() \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0a\u00a0keyword\u00a0arguyment. \u2503\u2503Greetings(to_greet=\"davep\").run()\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0both\u00a0positional\u00a0arguments. \u2503\u2503Greetings(\"Well\u00a0hello\",\"there\").run() \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2503\u2589\u2503\u258e\u258a \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u00a0F1\u00a0\u00a0Help\u00a0\u00a0F2\u00a0\u00a0About\u00a0\u00a0CTRL+N\u00a0\u00a0Navigation\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0Tip
Join our mailing list if you would like to be the first to hear about our apps.
We haven't stopped developing Textual in that time. Today we released version 0.23.0 which has a really interesting API update I'd like to introduce.
Textual widgets can send messages to each other. To respond to those messages, you implement a message handler with a naming convention. For instance, the Button widget sends a Pressed
event. To handle that event, you implement a method called on_button_pressed
.
Simple enough, but handler methods are called to handle pressed events from all Buttons. To manage multiple buttons you typically had to write a large if
statement to wire up each button to the code it should run. It didn't take many Buttons before the handler became hard to follow.
Version 0.23.0 introduces the @on
decorator which allows you to dispatch events based on the widget that initiated them.
This is probably best explained in code. The following two listings respond to buttons being pressed. The first uses a single message handler, the second uses the decorator approach:
on_decorator01.pyon_decorator02.pyOutput on_decorator01.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (1)!\n\"\"\"Handle all button pressed events.\"\"\"\nif event.button.id == \"bell\":\nself.bell()\nelif event.button.has_class(\"toggle\", \"dark\"):\nself.dark = not self.dark\nelif event.button.id == \"quit\":\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\n@on(Button.Pressed, \"#bell\") # (1)!\ndef play_bell(self):\n\"\"\"Called when the bell button is pressed.\"\"\"\nself.bell()\n@on(Button.Pressed, \".toggle.dark\") # (2)!\ndef toggle_dark(self):\n\"\"\"Called when the 'toggle dark' button is pressed.\"\"\"\nself.dark = not self.dark\n@on(Button.Pressed, \"#quit\") # (3)!\ndef quit(self):\n\"\"\"Called when the quit button is pressed.\"\"\"\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
#
to match the id)OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
The decorator dispatches events based on a CSS selector. This means that you could have a handler per button, or a handler for buttons with a shared class, or parent.
We think this is a very flexible mechanism that will help keep code readable and maintainable.
"},{"location":"blog/2023/05/03/textual-0230-improves-message-handling/#why-didnt-we-do-this-earlier","title":"Why didn't we do this earlier?","text":"It's a reasonable question to ask: why didn't we implement this in an earlier version? We were certainly aware there was a deficiency in the API.
The truth is simply that we didn't have an elegant solution in mind until recently. The @on
decorator is, I believe, an elegant and powerful mechanism for dispatching handlers. It might seem obvious in hindsight, but it took many iterations and brainstorming in the office to come up with it!
If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/","title":"Textual 0.24.0 adds a Select control","text":"Coming just 5 days after the last release, we have version 0.24.0 which we are crowning the King of Textual releases. At least until it is deposed by version 0.25.0.
The highlight of this release is the new Select widget: a very familiar control from the web and desktop worlds. Here's a screenshot and code:
Output (expanded)select_widget.pyselect.cssSelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Select\nLINES = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\"\"\".splitlines()\nclass SelectApp(App):\nCSS_PATH = \"select.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Select((line, line) for line in LINES)\n@on(Select.Changed)\ndef select_changed(self, event: Select.Changed) -> None:\nself.title = str(event.value)\nif __name__ == \"__main__\":\napp = SelectApp()\napp.run()\n
\n
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#new-styles","title":"New styles","text":"This one required new functionality in Textual itself. The \"pull-down\" overlay with options presented a difficulty with the previous API. The overlay needed to appear over any content below it. This is possible (using layers), but there was no simple way of positioning it directly under the parent widget.
We solved this with a new \"overlay\" concept, which can considered a special layer for user interactions like this Select, but also pop-up menus, tooltips, etc. Widgets styled to use the overlay appear in their natural place in the \"document\", but on top of everything else.
A second problem we tackled was ensuring that an overlay widget was never clipped. This was also solved with a new rule called \"constrain\". Applying constrain
to a widget will keep the widget within the bounds of the screen. In the case of Select
, if you expand the options while at the bottom of the screen, then the overlay will be moved up so that you can see all the options.
These new rules are currently undocumented as they are still subject to change, but you can see them in the Select source if you are interested.
In a future release these will be finalized and you can confidently use them in your own projects.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#fixes-for-the-on-decorator","title":"Fixes for the @on decorator","text":"The new @on
decorator is proving popular. To recap, it is a more declarative and finely grained way of dispatching messages. Here's a snippet from the calculator example which uses @on
:
@on(Button.Pressed, \"#plus,#minus,#divide,#multiply\")\ndef pressed_op(self, event: Button.Pressed) -> None:\n\"\"\"Pressed one of the arithmetic operations.\"\"\"\nself.right = Decimal(self.value or \"0\")\nself._do_math()\nassert event.button.id is not None\nself.operator = event.button.id\n
The decorator arranges for the method to be called when any of the four math operation buttons are pressed.
In 0.24.0 we've fixed some missing attributes which prevented the decorator from working with some messages. We've also extended the decorator to use keywords arguments, so it will match attributes other than control
.
There is a surprising number of fixes in this release for just 5 days. See CHANGELOG.md for details.
"},{"location":"blog/2023/05/08/textual-0240-adds-a-select-control/#join-us","title":"Join us","text":"If you want to talk about this update or anything else Textual related, join us on our Discord server.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/","title":"Textual adds Sparklines, Selection list, Input validation, and tool tips","text":"It's been 12 days since the last Textual release, which is longer than our usual release cycle of a week.
We've been a little distracted with our \"dogfood\" projects: Frogmouth and Trogon. Both of which hit 1000 Github stars in 24 hours. We will be maintaining / updating those, but it is business as usual for this Textual release (and it's a big one). We have such sights to show you.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#sparkline-widget","title":"Sparkline widget","text":"A Sparkline is essentially a mini-plot. Just detailed enough to keep an eye on time-series data.
SparklineColorsApp \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582 \u2581\u2582\u2582\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2583\u2583\u2582\u2581\u2582\u2582\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2582Colors are configurable, and all it takes is a call to set_interval
to make it animate.
Next up is the SelectionList widget. Essentially a scrolling list of checkboxes. Lots of use cases for this one.
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#tooltips","title":"Tooltips","text":"We've added tooltips to Textual widgets.
The API couldn't be simpler: simply assign a string to the tooltip
property on any widget. This string will be displayed after 300ms when you hover over the widget.
As always, you can configure how the tooltips will be displayed with CSS.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#input-updates","title":"Input updates","text":"We have some quality of life improvements for the Input widget.
You can now use a simple declarative API to validating input.
InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258afoo\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e ['Must\u00a0be\u00a0a\u00a0valid\u00a0number.',\u00a0'Value\u00a0is\u00a0not\u00a0even.',\u00a0\"That's\u00a0not\u00a0a\u00a0palindrome\u00a0:/\"]Also in this release is a suggestion API, which will suggest auto completions as you type. Hit right to accept the suggestion.
Here's a screenshot:
FruitsApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258astrawberry\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258eYou could use this API to offer suggestions from a fixed list, or even pull the data from a network request.
"},{"location":"blog/2023/06/01/textual-adds-sparklines-selection-list-input-validation-and-tool-tips/#join-us","title":"Join us","text":"Development on Textual is fast. We're very responsive to issues and feature requests.
If you have any suggestions, jump on our Discord server and you may see your feature in the next release!
"},{"location":"blog/2023/07/03/textual-0290-refactors-dev-tools/","title":"Textual 0.29.0 refactors dev tools","text":"It's been a slow week or two at Textualize, with Textual devs taking well-earned annual leave, but we still managed to get a new version out.
Version 0.29.0 has shipped with a number of fixes (see the release notes for details), but I'd like to use this post to explain a change we made to how Textual developer tools are distributed.
Previously if you installed textual[dev]
you would get the Textual dev tools plus the library itself. If you were distributing Textual apps and didn't need the developer tools you could drop the [dev]
.
We did this because the less dependencies a package has, the fewer installation issues you can expect to get in the future. And Textual is surprisingly lean if you only need to run apps, and not build them.
Alas, this wasn't quite as elegant solution as we hoped. The dependencies defined in extras wouldn't install commands, so textual
was bundled with the core library. This meant that if you installed the Textual package without the [dev]
you would still get the textual
command on your path but it wouldn't run.
We solved this by creating two packages: textual
contains the core library (with minimal dependencies) and textual-dev
contains the developer tools. If you are building Textual apps, you should install both as follows:
pip install textual textual-dev\n
That's the only difference. If you run in to any issues feel free to ask on the Discord server!
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/","title":"Textual 0.30.0 adds desktop-style notifications","text":"We have a new release of Textual to talk about, but before that I'd like to cover a little Textual news.
By sheer coincidence we reached 20,000 stars on GitHub today. Now stars don't mean all that much (at least until we can spend them on coffee), but its nice to know that twenty thousand developers thought Textual was interesting enough to hit the \u2605 button. Thank you!
In other news: we moved office. We are now a stone's throw away from Edinburgh Castle. The office is around three times as big as the old place, which means we have room for wide standup desks and dual monitors. But more importantly we have room for new employees. Don't send your CVs just yet, but we hope to grow the team before the end of the year.
Exciting times.
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/#new-release","title":"New Release","text":"And now, for the main feature. Version 0.30 adds a new notification system. Similar to desktop notifications, it displays a small window with a title and message (called a toast) for a pre-defined number of seconds.
Notifications are great for short timely messages to add supplementary information for the user. Here it is in action:
The API is super simple. To display a notification, call notify()
with a message and an optional title.
def on_mount(self) -> None:\nself.notify(\"Hello, from Textual!\", title=\"Welcome\")\n
"},{"location":"blog/2023/07/17/textual-0300-adds-desktop-style-notifications/#textualize-video-channel","title":"Textualize Video Channel","text":"In case you missed it; Textualize now has a YouTube channel. Our very own Rodrigo has recorded a video tutorial series on how to build Textual apps. Check it out!
We will be adding more videos in the near future, covering anything from beginner to advanced topics.
Don't worry if you prefer reading to watching videos. We will be adding plenty more content to the Textual docs in the near future. Watch this space.
As always, if you want to discuss anything with the Textual developers, join us on the Discord server.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/","title":"Textual 0.38.0 adds a syntax aware TextArea","text":"This is the second big feature release this month after last week's command palette.
The TextArea has finally landed. I know a lot of folk have been waiting for this one. Textual's TextArea is a fully-featured widget for editing code, with syntax highlighting and line numbers. It is highly configurable, and looks great.
Darren Burns (the author of this widget) has penned a terrific write-up on the TextArea. See Things I learned while building Textual's TextArea for some of the challenges he faced.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#scoped-css","title":"Scoped CSS","text":"Another notable feature added in 0.38.0 is scoped CSS. A common gotcha in building Textual widgets is that you could write CSS that impacted styles outside of that widget.
Consider the following widget:
class MyWidget(Widget):\nDEFAULT_CSS = \"\"\"\n MyWidget {\n height: auto;\n border: magenta;\n }\n Label {\n border: solid green;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"foo\")\nyield Label(\"bar\")\n
The author has intended to style the labels in that widget by adding a green border. This does work for the widget in question, but (prior to 0.38.0) the Label
rule would style all Labels (including any outside of the widget) \u2014 which was probably not intended.
With version 0.38.0, the CSS is scoped so that only the widget's labels will be styled. This is almost always what you want, which is why it is enabled by default. If you do want to style something outside of the widget you can set SCOPED_CSS=False
(as a classvar).
We've also made a slight quality of life improvement to the CSS, by adding :light
and :dark
pseudo selectors. This allows you to change styles depending on whether you have dark mode enabled or not.
This was possible before, just a little verbose. Here's how you would do it in 0.37.0:
App.-dark-mode MyWidget Label {\n...\n}\n
In 0.38.0 it's a little more concise and readable:
MyWidget:dark Label {\n...\n}\n
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#testing-guide","title":"Testing guide","text":"Not strictly part of the release, but we've added a guide on testing Textual apps.
As you may know, we are on a mission to make TUIs a serious proposition for critical apps, which makes testing essential. We've extracted and documented our internal testing tools, including our snapshot tests pytest plugin pytest-textual-snapshot.
This gives devs powerful tools to ensure the quality of their apps. Let us know your thoughts on that!
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#release-notes","title":"Release notes","text":"See the release page for the full details on this release.
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#whats-next","title":"What's next?","text":"There's lots of features planned over the next few months. One feature I am particularly excited by is a widget to generate plots by wrapping the awesome Plotext library. Check out some early work on this feature:
"},{"location":"blog/2023/09/21/textual-0380-adds-a-syntax-aware-textarea/#join-us","title":"Join us","text":"Join our Discord server if you want to discuss Textual with the Textualize devs, or the community.
"},{"location":"blog/2022/11/08/version-040/","title":"Version 0.4.0","text":"We've released version 0.4.0 of Textual.
As this is the first post tagged with release
let me first explain where the blog fits in with releases. We plan on doing a post for every note-worthy release. Which likely means all but the most trivial updates (typos just aren't that interesting). Blog posts will be supplementary to release notes which you will find on the Textual repository.
Blog posts will give a little more background for the highlights in a release, and a rationale for changes and new additions. We embrace building in public, which means that we would like you to be as up-to-date with new developments as if you were sitting in our office. It's a small office, and you might not be a fan of the Scottish weather (it's dreich), but you can at least be here virtually.
Release 0.4.0 follows 0.3.0, released on October 31st. Here are the highlights of the update.
"},{"location":"blog/2022/11/08/version-040/#updated-mount-method","title":"Updated Mount Method","text":"The mount method has seen some work. We've dropped the ability to assign an id
via keyword attributes, which wasn't terribly useful. Now, an id
must be assigned via the constructor.
The mount method has also grown before
and after
parameters which tell Textual where to add a new Widget (the default was to add it to the end). Here are a few examples:
# Mount at the start\nself.mount(Button(id=\"Buy Coffee\"), before=0)\n# Mount after a selector\nself.mount(Static(\"Password is incorrect\"), after=\"Dialog Input.-error\")\n# Mount after a specific widget\ntweet = self.query_one(\"Tweet\")\nself.mount(Static(\"Consider switching to Mastodon\"), after=tweet)\n
Textual needs much of the same kind of operations as the JS API exposed by the browser. But we are determined to make this way more intuitive. The new mount method is a step towards that.
"},{"location":"blog/2022/11/08/version-040/#faster-updates","title":"Faster Updates","text":"Textual now writes to stdout in a thread. The upshot of this is that Textual can work on the next update before the terminal has displayed the previous frame.
This means smoother updates all round! You may notice this when scrolling and animating, but even if you don't, you will have more CPU cycles to play with in your Textual app.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2ca3Oa3Fx1MDAxNsff91NkPG8r3fdLZ86cybW5t401SXPmmVx1MDAwZUFUXCKK4WIunX73Z0FsXHUwMDAwI2pcdTAwMTKDTPLCKlx1MDAxYvZebNaP/1prQ39/WFurhXdDu/Z5rWbfWqbrtHzzpvYx3j6y/cDxXHUwMDA20ESS34FcdTAwMTf5VrJnN1xmh8HnT5/6pt+zw6FrWrYxcoLIdIMwajmeYXn9T05o94P/xZ/HZt/+79Drt0LfSFx1MDAwN6nbLSf0/IexbNfu24MwgN7/XHUwMDBmv9fWfiefXHUwMDE563zbXG7NQce1k1x1MDAwM5Km1ECK1eTWY2+QXHUwMDE4SzDDVFx1MDAxMk7Q41x1MDAxZU6wXHUwMDA144V2XHUwMDBimttgs522xJtq55fW8dVgOzo971xuWd857UaDzct02Lbjuo3wzn2YXG7T6kZ+xqgg9L2efea0wi6044ntj8dcdTAwMDVcdTAwMWXMQnqU70Wd7sBcdTAwMGWC3DHe0LSc8C7ehlLzXHUwMDFmZuHzWrrlXHUwMDE2filuKClcdTAwMTVcdTAwMTVCYc6kXHUwMDE0j63J8ZxcdTAwMWGEU41cdTAwMDThXGL+Ju3a9FxcuFx1MDAxNGDXf6hgbUumll2aVq9cdTAwMDPmXHJa6T6EqEtbKJXudfP3fJkyiJSUUclcdTAwMWZcdTAwMWK7ttPphtDKkKGlXCJgI5KYXHUwMDExRVMr7ORqxC2USMZcdTAwMWVcdTAwMWLioYd7rcQx/pmczK7pXHUwMDBmx5NWXHUwMDBi4lx1MDAxZlx1MDAxObNji7cnvSrrWZlcdTAwMGJ+2T5qXHUwMDFjOMdcciFcdTAwMWPinp9uXHUwMDFmXHUwMDA218HPx75yblx1MDAxONq3Ye2x4c/H6d3m9v646IBpt+Nv6Vx0R8OW+eCwWFxiqWGKJaY09Vx1MDAwMNdcdTAwMTn0oHFcdTAwMTC5brrNs3qpj3/I2PtMuDQthotKXHUwMDA03pa5YPPgut9ccr5aR0PyvVlf92+PLbp/9l1UXHUwMDFkrjp4q0GRXHUwMDE2XHUwMDA0cy01opzk8FwiXGJcdTAwMWKEUnBuJqTChFx1MDAxN+LF27Rlsdl42YJphabhRVxiM4RmkjMmgFx1MDAxZq5ewFx1MDAxONxcdTAwMGaVwHBcdTAwMDaqXFzIml/I/p55MrpAw9b1tT/sbl37J29cdNn0XHUwMDAxn1x1MDAwM1x1MDAxOUwkUrxcdTAwMTTIXHUwMDE4xkWQYSm4RIhxsTBkw1x1MDAxZt++7l1cdTAwMWW1aGRcdTAwMWTgId9wej/vulWHjFx0XHUwMDBljGFcdTAwMDJcdTAwMTOviKSgXCJcdTAwMTNcdTAwMTImXGbMlVx1MDAxNFx1MDAxOCeuzYohW6mGYaQg2lx1MDAxMFx1MDAxNJNy+dq17qXd22t2b2lvyLqX9uHoXHUwMDFifku+plx1MDAwZlhRXHUwMDExY1pcdTAwMTbxJZhcIkhgtLiG3e2qKPq1dVx1MDAxZlx1MDAxYzXNkfn91t05cdpVx4tQaVx1MDAwMFVcdTAwMTju/pJzzSfo0spA0FwiXHUwMDE555RLnLnZVE/CXGLinCnKsChcdTAwMTexg63v6Ng+6nxcdTAwMGbCbvO62d7mqH/2lohNXHUwMDFmcHWI5ezMJoikXGIuTFx1MDAxOIeQnujFxWt2oFBRuupCXHUwMDE4TIBzM1xuai2QyONFkDZcYokjyPiCKKqXXHUwMDExIT6FKyOZjzRlg9ExPlx1MDAwMJVcdTAwMTBYv4VCLTOaSi+5N1xiXHUwMDFizn2ScaDc1lx1MDAxZLPvuHe5q5b4aOxGyfjZeVxmbFx1MDAxODNxSpXbe911OrFcdTAwMTfXLDhcdTAwMGLbzzl46Fim+7hD32m1svpigVx0JvTp7y2S23i+03FcdTAwMDam+yNv4YuIK6x3MKmpQGRxMZud/FZcdTAwMTQ3TJmBsODgUFxcQFx1MDAwMqry+Vx1MDAxOFx1MDAxNpCPMVxu07BENXuKWyZcdTAwMGKcgVx1MDAxYqaEXHUwMDBirbF8XHUwMDAztVrmnf91uJ35Tsm0zSnTTdL2YOCLYCvOzTjHXHUwMDFhJluphXGbXHUwMDFkQ1RcdTAwMTQ3iqnBXHUwMDE1Y5prXHUwMDA0mZGarH5cYlx1MDAwM0muYFx1MDAxZYA1ls18ViFuXHUwMDEwNCrMdbniVjJtK1x1MDAxMLc5SU8p4iYgZ6BU4cWDydlZcUVx44BcdTAwMWKjWsPpclx1MDAxOVdCJsSNXHUwMDE5XHUwMDE4JE/qh1xmSqo3wW1BcVx1MDAwMyvB0pJDyXevbXNcbngv0LbZ1ZHMZE4gJ1x0QpqpZ1x1MDAwNJT7I3SAdjZa/kE/XHUwMDFhnUdHfp1wXXXkMKKGXHUwMDAy5WJcYkI1hjjOIcewNFx1MDAwNChcdTAwMWZCY5ErJG7FpUfQZ1x1MDAwMVx1MDAwMW/JdVx1MDAxMeF87TaO2DpB9+bJSe8wwvhm9Jq6yFx1MDAxYnU7r9wyfcC02/G3alQ0OS1cXJbjRGhCsmvAc2Wy8+XQPJDfnOZF65c2zZsrc++u6szWMUlcdTAwMTa9OTi8oFwiXHUwMDBlTfPUXG6QUak4pH/JqncxtasvaWKCQEhFdpGnXHUwMDE0dCN2u3s9ijbcXHUwMDAzT9zwweHVcc/uvVx1MDAxZd2ldztcdTAwMGbd6Vx1MDAwM1ZcdTAwMTVdrYvQJZpoXHIh7uJye9A7adCGa91ftK9+7Gy2T5v3vdOqo0spMTAnXHUwMDAwLlx1MDAxMVxmXCJZzfLkYmFcYlx1MDAxZcdcdTAwMWRcdTAwMGbAZG51lVx1MDAxMlxcolC85l82tPvWyW5k4u0uP6xcdTAwMWaMblx1MDAwNlx1MDAwM1Cr6PXQLr3bedBOXHUwMDFmsKLQSsSLoMVcdTAwMWHFSal+XHUwMDA2tdH2VXR4tyt/ycPG9lx1MDAxNW9IsnfxperUYsRcZsqFwpJSXHUwMDExL2boPLWgt5CfYyrwPGorILiSY1x1MDAxNa9Olczu1sXXy0NN7ky457Gj4O6871xmL1/P7tK7ncfu9Fx1MDAwMVfHbmHtVlx1MDAxND9YoyA4XHUwMDA0aJ+R286OayqKbZ0jXHUwMDAzUYRcdTAwMTVcdTAwMDI448WSiYdDmcRcdTAwMDZcdTAwMTNSUPqQ3L7Rasli9du4sqWIxO+7orSC+u2cXHUwMDE0b5n1W4xcdTAwMGLjWy21ZOCKiyM3u1xuUFHkMMeGjlx1MDAxN/0gxGVxOWmCOEhcXLlcdTAwMDYh1VgnXG62yvXJeMVcdTAwMDRcdKrouyau/Fx1MDAxYe6cOuhcdTAwMTLXJ8lcZt5cdTAwMTSDm756xuM3syOJqvKmkEFcdTAwMTDDXHUwMDFh4jnIx8hEOlx0XHUwMDAyJyghT+PF8vVNQ85cdTAwMGKRqX7XXHUwMDBmXHUwMDAzrEDe5iRUS5U3XZxcdFIpiFr8VaPZKXdFYaMxbCBtTGGqp1x1MDAxNG/i7IwhJMRY2+Sb4Lbg+iRcdTAwMDfkJc247jvErXxtm1N0XFyitlx1MDAxNT5cdTAwMGKAXHUwMDA1XCKCaJEpy8yj7eJLdK+Q02tcXHX3XHUwMDFhO1dnW0PvdLfqtGFFXGZcdTAwMTRnZVx1MDAwNCdcdTAwMGZcdTAwMDPofCippVx1MDAwMUmdYip+qlx1MDAxM9LZV7FWXFwoXHUwMDE1U2osT2mjXHUwMDA0bn/wUX4kSXRptG3Ybc9/XHUwMDFlbq7dXHUwMDBlZ8BcdTAwMTZ6w1wi0nJnMYnV2JJcdTAwMTdxVfg6XHUwMDA0ZkzHIUqmejePK9usb1x1MDAwNH3xrd+MzlG9j++3tjevK8+VIIZcdTAwMDBqXG64ojxeoVx1MDAxMFxuKVx1MDAxND9BOut1o9eRlcn9ZpAlKKVSKVa+jpVI1no7J0qrXHUwMDAz68GQmVxcmb7v3UxNxlDhujxcdTAwMDQjSHKK9OJoscb53k7n+HJ9py72/P3dU7y19cKHaSafynzDV424QWAvTjWjkIzhiXSMXCJlcFx1MDAwMOLvXHUwMDAzbK9Lx4rJgsFccqD872NymE5J0LChMFx1MDAwNKtiSuSIOFFcdTAwMWGJXHUwMDE3vIueWFg2cUFo+uGGM2g5g87kIfagVdDimkG46fX7TlxiZnzznEE4uUfS73rs7V3bfEJcdTAwMGX0nG2rhb4zsYI2jDvNL4uk39ZSz0l+PH7/5+PUvVx0llx1MDAwNlI4juhxPreI/+rI0EhcdTAwMDGAmPB5PVx1MDAxNTtH0lPqXHUwMDE3aUdcdTAwMWay/z5Xb0XxS/SIKU3gfNLLPrdGc/Szuf+r88VcdTAwMTXO6c9r+/asqe2rqt9cdTAwMTXqXFxcdTAwMTlcdTAwMTBcdTAwMWRcblx1MDAxZf+PXHUwMDFjQiOevytAKG9gzcZcdTAwMDXRV69CzLgtLCS4mFx0grR8529t/HD6lYhkXHUwMDEzO1x1MDAxZbj6MEa2Zlx1MDAwZYeNMC7SfFx1MDAxZVNcdTAwMDZcdTAwMTfAaY1PMe2tNnLsm40pXHUwMDFl0E7+4l5cdTAwMTNWYyrsePp///nw51+TJY25In0= UpdateWriteUpdateWriteUpdateWriteUpdateWriteBeforeAfterTime"},{"location":"blog/2022/11/08/version-040/#multiple-css-paths","title":"Multiple CSS Paths","text":"Up to version 0.3.0, Textual would only read a single CSS file set in the CSS_PATH
class variable. You can now supply a list of paths if you have more than one CSS file.
This change was prompted by tuilwindcss which brings a TailwindCSS like approach to building Textual Widgets. Also check out calmcode.io by the same author, which is an amazing resource.
"},{"location":"blog/2022/12/11/version-060/","title":"Textual 0.6.0 adds a treemendous new widget","text":"A new release of Textual lands 3 weeks after the previous release -- and it's a big one.
Information
If you're new here, Textual is TUI framework for Python.
"},{"location":"blog/2022/12/11/version-060/#tree-control","title":"Tree Control","text":"The headline feature of version 0.6.0 is a new tree control built from the ground-up. The previous Tree control suffered from an overly complex API and wasn't scalable (scrolling slowed down with 1000s of nodes).
This new version has a simpler API and is highly scalable (no slowdown with larger trees). There are also a number of visual enhancements in this version.
Here's a very simple example:
Outputtree.pyTreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
from textual.app import App, ComposeResult\nfrom textual.widgets import Tree\nclass TreeApp(App):\ndef compose(self) -> ComposeResult:\ntree: Tree[dict] = Tree(\"Dune\")\ntree.root.expand()\ncharacters = tree.root.add(\"Characters\", expand=True)\ncharacters.add_leaf(\"Paul\")\ncharacters.add_leaf(\"Jessica\")\ncharacters.add_leaf(\"Chani\")\nyield tree\nif __name__ == \"__main__\":\napp = TreeApp()\napp.run()\n
Here's the tree control being used to navigate some JSON (json_tree.py in the examples directory).
I'm biased of course, but I think this terminal based tree control is more usable (and even prettier) than just about anything I've seen on the web or desktop. So much of computing tends to organize itself in to a tree that I think this widget will find a lot of uses.
The Tree control forms the foundation of the DirectoryTree widget, which has also been updated. Here it is used in the code_browser.py example:
"},{"location":"blog/2022/12/11/version-060/#list-view","title":"List View","text":"We have a new ListView control to navigate and select items in a list. Items can be widgets themselves, which makes this a great platform for building more sophisticated controls.
Outputlist_view.pylist_view.cssListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
\n
"},{"location":"blog/2022/12/11/version-060/#placeholder","title":"Placeholder","text":"The Placeholder widget was broken since the big CSS update. We've brought it back and given it a bit of a polish.
Use this widget in place of custom widgets you have yet to build when designing your UI. The colors are automatically cycled to differentiate one placeholder from the next. You can click a placeholder to cycle between its ID, size, and lorem ipsum text.
Outputplaceholder.pyplaceholder.cssPlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3 #p5Placeholder Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0 Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0 33\u00a0x\u00a011nec\u00a0libero\u00a0quis\u00a0gravida.\u00a034\u00a0x\u00a011 Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0 Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0 sed\u00a0vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0 amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0 adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0 imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibus50\u00a0x\u00a011 sit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sed lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapien congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0 vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0 mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis. Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0consectetur\u00a0 adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0accumsan.\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula.\u00a0Nullam\u00a0 imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibusimperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0vehicula\u00a0nisl\u00a0faucibus sit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedsit\u00a0amet.\u00a0Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sed lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapienlacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0sapien\u00a0sapien congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0pellentesque\u00a0quam\u00a0quam\u00a0 vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0vel\u00a0nisl.\u00a0Curabitur\u00a0vulputate\u00a0erat\u00a0pellentesque\u00a0 mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis.mauris\u00a0posuere,\u00a0non\u00a0dictum\u00a0risus\u00a0mattis.
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass PlaceholderApp(App):\nCSS_PATH = \"placeholder.tcss\"\ndef compose(self) -> ComposeResult:\nyield VerticalScroll(\nContainer(\nPlaceholder(\"This is a custom label for p1.\", id=\"p1\"),\nPlaceholder(\"Placeholder p2 here!\", id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(),\nHorizontal(\nPlaceholder(variant=\"size\", id=\"col1\"),\nPlaceholder(variant=\"text\", id=\"col2\"),\nPlaceholder(variant=\"size\", id=\"col3\"),\nid=\"c1\",\n),\nid=\"bot\",\n),\nContainer(\nPlaceholder(variant=\"text\", id=\"left\"),\nPlaceholder(variant=\"size\", id=\"topright\"),\nPlaceholder(variant=\"text\", id=\"botright\"),\nid=\"top\",\n),\nid=\"content\",\n)\nif __name__ == \"__main__\":\napp = PlaceholderApp()\napp.run()\n
\n
"},{"location":"blog/2022/12/11/version-060/#fixes","title":"Fixes","text":"As always, there are a number of fixes in this release. Mostly related to layout. See CHANGELOG.md for the details.
"},{"location":"blog/2022/12/11/version-060/#whats-next","title":"What's next?","text":"The next release will focus on pain points we discovered while in a dog-fooding phase (see the DevLog for details on what Textual devs have been building).
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/","title":"Textual 0.37.0 adds a command palette","text":"Textual version 0.37.0 has landed! The highlight of this release is the new command palette.
A command palette gives users quick access to features in your app. If you hit ctrl+backslash in a Textual app, it will bring up the command palette where you can start typing commands. The commands are matched with a fuzzy search, so you only need to type two or three characters to get to any command.
Here's a video of it in action:
Adding your own commands to the command palette is a piece of cake. Here's the (command) Provider class used in the example above:
class ColorCommands(Provider):\n\"\"\"A command provider to select colors.\"\"\"\nasync def search(self, query: str) -> Hits:\n\"\"\"Called for each key.\"\"\"\nmatcher = self.matcher(query)\nfor color in COLOR_NAME_TO_RGB.keys():\nscore = matcher.match(color)\nif score > 0:\nyield Hit(\nscore,\nmatcher.highlight(color),\npartial(self.app.post_message, SwitchColor(color)),\n)\n
And here is how you add a provider to your app:
class ColorApp(App):\n\"\"\"Experiment with the command palette.\"\"\"\nCOMMANDS = App.COMMANDS | {ColorCommands}\n
We're excited about this feature because it is a step towards bringing a common user interface to Textual apps.
Quote
It's a Textual app. I know this.
\u2014 You, maybe.
The goal is to be able to build apps that may look quite different, but take no time to learn, because once you learn how to use one Textual app, you can use them all.
See the Guide for details on how to work with the command palette.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#what-else","title":"What else?","text":"Also in 0.37.0 we have a new Collapsible widget, which is a great way of adding content while avoiding a cluttered screen.
And of course, bug fixes and other updates. See the release page for the full details.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#whats-next","title":"What's next?","text":"Coming very soon, is a new TextEditor widget. This is a super powerful widget to enter arbitrary text, with beautiful syntax highlighting for a number of languages. We're expecting that to land next week. Watch this space, or join the Discord server if you want to be the first to try it out.
"},{"location":"blog/2023/09/15/textual-0370-adds-a-command-palette/#join-us","title":"Join us","text":"Join our Discord server if you want to discuss Textual with the Textualize devs, or the community.
"},{"location":"blog/2022/12/07/letting-your-cook-multitask-while-bringing-water-to-a-boil/","title":"Letting your cook multitask while bringing water to a boil","text":"Whenever you are cooking a time-consuming meal, you want to multitask as much as possible. For example, you do not want to stand still while you wait for a pot of water to start boiling. Similarly, you want your applications to remain responsive (i.e., you want the cook to \u201cmultitask\u201d) while they do some time-consuming operations in the background (e.g., while the water heats up).
The animation below shows an example of an application that remains responsive (colours on the left still change on click) even while doing a bunch of time-consuming operations (shown on the right).
In this blog post, I will teach you how to multitask like a good cook.
"},{"location":"blog/2022/12/07/letting-your-cook-multitask-while-bringing-water-to-a-boil/#wasting-time-staring-at-pots","title":"Wasting time staring at pots","text":"There is no point in me presenting a solution to a problem if you don't understand the problem I am trying to solve. Suppose we have an application that needs to display a huge amount of data that needs to be read and parsed from a file. The first time I had to do something like this, I ended up writing an application that \u201cblocked\u201d. This means that while the application was reading and parsing the data, nothing else worked.
To exemplify this type of scenario, I created a simple application that spends five seconds preparing some data. After the data is ready, we display a Label
on the right that says that the data has been loaded. On the left, the app has a big rectangle (a custom widget called ColourChanger
) that you can click and that changes background colours randomly.
When you start the application, you can click the rectangle on the left to change the background colour of the ColourChanger
, as the animation below shows:
However, as soon as you press l
to trigger the data loading process, clicking the ColourChanger
widget doesn't do anything. The app doesn't respond because it is busy working on the data. This is the code of the app so you can try it yourself:
import time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget): # (1)!\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")] # (2)!\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None: # (3)!\ntime.sleep(5) # (4)!\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\"))\nMyApp().run()\n
ColourChanger
changes colours, randomly, when clicked.l
that runs an action that we know will take some time (for example, reading and parsing a huge file).action_load
is responsible for starting our time-consuming task and then reporting back.I think it is easy to understand why the widget ColourChanger
stops working when we hit the time.sleep
call if we consider the cooking analogy I have written about before in my blog. In short, Python behaves like a lone cook in a kitchen:
Things like \u201cchopping up vegetables\u201d and \u201cseasoning a salad\u201d are blocking, i.e., they need the cook's time and attention. In the app that I showed above, the call to time.sleep
is blocking, so the cook can't go and do anything else until the time interval elapses.
It makes a lot of sense to think that a cook would multitask in their kitchen, but Python isn't like a smart cook. Python is like a very dumb cook who only ever does one thing at a time and waits until each thing is completely done before doing the next thing. So, by default, Python would act like a cook who fills up a pan with water, starts heating the water, and then stands there staring at the water until it starts boiling instead of doing something else. It is by using the module asyncio
from the standard library that our cook learns to do other tasks while awaiting the completion of the things they already started doing.
Textual is an async framework, which means it knows how to interoperate with the module asyncio
and this will be the solution to our problem. By using asyncio
with the tasks we want to run in the background, we will let the application remain responsive while we load and parse the data we need, or while we crunch the numbers we need to crunch, or while we connect to some slow API over the Internet, or whatever it is you want to do.
The module asyncio
uses the keyword async
to know which functions can be run asynchronously. In other words, you use the keyword async
to identify functions that contain tasks that would otherwise force the cook to waste time. (Functions with the keyword async
are called coroutines.)
The module asyncio
also introduces a function asyncio.create_task
that you can use to run coroutines concurrently. So, if we create a coroutine that is in charge of doing the time-consuming operation and then run it with asyncio.create_task
, we are well on our way to fix our issues.
However, the keyword async
and asyncio.create_task
alone aren't enough. Consider this modification of the previous app, where the method action_load
now uses asyncio.create_task
to run a coroutine who does the sleeping:
import asyncio\nimport time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget):\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")]\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None: # (1)!\nasyncio.create_task(self._do_long_operation()) # (2)!\nasync def _do_long_operation(self) -> None: # (3)!\ntime.sleep(5)\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\"))\nMyApp().run()\n
action_load
now defers the heavy lifting to another method we created.asyncio.create_task
because it is a coroutine._do_long_operation
has the keyword async
, so it is a coroutine.This modified app also works but it suffers from the same issue as the one before! The keyword async
tells Python that there will be things inside that function that can be awaited by the cook. That is, the function will do some time-consuming operation that doesn't require the cook's attention. However, we need to tell Python which time-consuming operation doesn't require the cook's attention, i.e., which time-consuming operation can be awaited, with the keyword await
.
Whenever we want to use the keyword await
, we need to do it with objects that are compatible with it. For many things, that means using specialised libraries:
time.sleep
, one can use await asyncio.sleep
;requests
to make Internet requests, use aiohttp
; oraiofiles
.To fix the last example application, all we need to do is replace the call to time.sleep
with a call to asyncio.sleep
and then use the keyword await
to signal Python that we can be doing something else while we sleep. The animation below shows that we can still change colours while the application is completing the time-consuming operation.
import asyncio\nimport time\nfrom random import randint\nfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Grid, VerticalScroll\nfrom textual.widget import Widget\nfrom textual.widgets import Footer, Label\nclass ColourChanger(Widget):\ndef on_click(self) -> None:\nself.styles.background = Color(\nrandint(1, 255),\nrandint(1, 255),\nrandint(1, 255),\n)\nclass MyApp(App[None]):\nBINDINGS = [(\"l\", \"load\", \"Load data\")]\nCSS = \"\"\"\n Grid {\n grid-size: 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nColourChanger(),\nVerticalScroll(id=\"log\"),\n)\nyield Footer()\ndef action_load(self) -> None:\nasyncio.create_task(self._do_long_operation())\nasync def _do_long_operation(self) -> None:\nself.query_one(\"#log\").mount(Label(\"Starting \u23f3\")) # (1)!\nawait asyncio.sleep(5) # (2)!\nself.query_one(\"#log\").mount(Label(\"Data loaded \u2705\")) # (3)!\nMyApp().run()\n
await
the time-consuming operation so that the application remains responsive.Because our time-consuming operation runs concurrently, everything else in the application still works while we await for the time-consuming operation to finish. In particular, we can keep changing colours (like the animation above showed) but we can also keep activating the binding with the key l
to start multiple instances of the same time-consuming operation! The animation below shows just this:
Warning
The animation GIFs in this blog post show low-quality colours in an attempt to reduce the size of the media files you have to download to be able to read this blog post. If you run Textual locally you will see beautiful colours \u2728
"},{"location":"blog/2023/07/27/using-rich-inspect-to-interrogate-python-objects/","title":"Using Rich Inspect to interrogate Python objects","text":"The Rich library has a few functions that are admittedly a little out of scope for a terminal color library. One such function is inspect
which is so useful you may want to pip install rich
just for this feature.
The easiest way to describe inspect
is that it is Python's builtin help()
but easier on the eye (and with a few more features). If you invoke it with any object, inspect
will display a nicely formatted report on that object \u2014 which makes it great for interrogating objects from the REPL. Here's an example:
>>> from rich import inspect\n>>> text_file = open(\"foo.txt\", \"w\")\n>>> inspect(text_file)\n
Here we're inspecting a file object, but it could be literally anything. You will see the following output in the terminal:
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256fBy default, inspect
will generate a data-oriented summary with a text representation of the object and its data attributes. You can also add methods=True
to show all the methods in the public API. Here's an example:
>>> inspect(text_file, methods=True)\n
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2502close\u00a0=def\u00a0close():Flush\u00a0and\u00a0close\u00a0the\u00a0IO\u00a0object.\u2502 \u2502detach\u00a0=def\u00a0detach():Separate\u00a0the\u00a0underlying\u00a0buffer\u00a0from\u00a0the\u00a0TextIOBase\u00a0and\u00a0return\u00a0it.\u2502 \u2502fileno\u00a0=def\u00a0fileno():Returns\u00a0underlying\u00a0file\u00a0descriptor\u00a0if\u00a0one\u00a0exists.\u2502 \u2502flush\u00a0=def\u00a0flush():Flush\u00a0write\u00a0buffers,\u00a0if\u00a0applicable.\u2502 \u2502isatty\u00a0=def\u00a0isatty():Return\u00a0whether\u00a0this\u00a0is\u00a0an\u00a0'interactive'\u00a0stream.\u2502 \u2502read\u00a0=def\u00a0read(size=-1,\u00a0/):Read\u00a0at\u00a0most\u00a0n\u00a0characters\u00a0from\u00a0stream.\u2502 \u2502readable\u00a0=def\u00a0readable():Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0reading.\u2502 \u2502readline\u00a0=def\u00a0readline(size=-1,\u00a0/):Read\u00a0until\u00a0newline\u00a0or\u00a0EOF.\u2502 \u2502readlines\u00a0=def\u00a0readlines(hint=-1,\u00a0/):Return\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0from\u00a0the\u00a0stream.\u2502 \u2502reconfigure\u00a0=def\u00a0reconfigure(*,\u00a0encoding=None,\u00a0errors=None,\u00a0newline=None,\u00a0line_buffering=None,\u00a0\u2502 \u2502write_through=None):Reconfigure\u00a0the\u00a0text\u00a0stream\u00a0with\u00a0new\u00a0parameters.\u2502 \u2502seek\u00a0=def\u00a0seek(cookie,\u00a0whence=0,\u00a0/):Change\u00a0stream\u00a0position.\u2502 \u2502seekable\u00a0=def\u00a0seekable():Return\u00a0whether\u00a0object\u00a0supports\u00a0random\u00a0access.\u2502 \u2502tell\u00a0=def\u00a0tell():Return\u00a0current\u00a0stream\u00a0position.\u2502 \u2502truncate\u00a0=def\u00a0truncate(pos=None,\u00a0/):Truncate\u00a0file\u00a0to\u00a0size\u00a0bytes.\u2502 \u2502writable\u00a0=def\u00a0writable():Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0writing.\u2502 \u2502write\u00a0=def\u00a0write(text,\u00a0/):\u2502 \u2502Write\u00a0string\u00a0to\u00a0stream.\u2502 \u2502Returns\u00a0the\u00a0number\u00a0of\u00a0characters\u00a0written\u00a0(which\u00a0is\u00a0always\u00a0equal\u00a0to\u2502 \u2502the\u00a0length\u00a0of\u00a0the\u00a0string).\u2502 \u2502writelines\u00a0=def\u00a0writelines(lines,\u00a0/):Write\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0to\u00a0stream.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f The documentation is summarized by default to avoid generating verbose reports. If you want to see the full unabbreviated help you can add help=True
:
>>> inspect(text_file, methods=True, help=True)\n
Rich \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500<class'_io.TextIOWrapper'>\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502Character\u00a0and\u00a0line\u00a0based\u00a0layer\u00a0over\u00a0a\u00a0BufferedIOBase\u00a0object,\u00a0buffer.\u2502 \u2502\u2502 \u2502encoding\u00a0gives\u00a0the\u00a0name\u00a0of\u00a0the\u00a0encoding\u00a0that\u00a0the\u00a0stream\u00a0will\u00a0be\u2502 \u2502decoded\u00a0or\u00a0encoded\u00a0with.\u00a0It\u00a0defaults\u00a0to\u00a0locale.getencoding().\u2502 \u2502\u2502 \u2502errors\u00a0determines\u00a0the\u00a0strictness\u00a0of\u00a0encoding\u00a0and\u00a0decoding\u00a0(see\u2502 \u2502help(codecs.Codec)\u00a0or\u00a0the\u00a0documentation\u00a0for\u00a0codecs.register)\u00a0and\u2502 \u2502defaults\u00a0to\u00a0\"strict\".\u2502 \u2502\u2502 \u2502newline\u00a0controls\u00a0how\u00a0line\u00a0endings\u00a0are\u00a0handled.\u00a0It\u00a0can\u00a0be\u00a0None,\u00a0'',\u2502 \u2502'\\n',\u00a0'\\r',\u00a0and\u00a0'\\r\\n'.\u00a0\u00a0It\u00a0works\u00a0as\u00a0follows:\u2502 \u2502\u2502 \u2502*\u00a0On\u00a0input,\u00a0if\u00a0newline\u00a0is\u00a0None,\u00a0universal\u00a0newlines\u00a0mode\u00a0is\u2502 \u2502\u00a0\u00a0enabled.\u00a0Lines\u00a0in\u00a0the\u00a0input\u00a0can\u00a0end\u00a0in\u00a0'\\n',\u00a0'\\r',\u00a0or\u00a0'\\r\\n',\u00a0and\u2502 \u2502\u00a0\u00a0these\u00a0are\u00a0translated\u00a0into\u00a0'\\n'\u00a0before\u00a0being\u00a0returned\u00a0to\u00a0the\u2502 \u2502\u00a0\u00a0caller.\u00a0If\u00a0it\u00a0is\u00a0'',\u00a0universal\u00a0newline\u00a0mode\u00a0is\u00a0enabled,\u00a0but\u00a0line\u2502 \u2502\u00a0\u00a0endings\u00a0are\u00a0returned\u00a0to\u00a0the\u00a0caller\u00a0untranslated.\u00a0If\u00a0it\u00a0has\u00a0any\u00a0of\u2502 \u2502\u00a0\u00a0the\u00a0other\u00a0legal\u00a0values,\u00a0input\u00a0lines\u00a0are\u00a0only\u00a0terminated\u00a0by\u00a0the\u00a0given\u2502 \u2502\u00a0\u00a0string,\u00a0and\u00a0the\u00a0line\u00a0ending\u00a0is\u00a0returned\u00a0to\u00a0the\u00a0caller\u00a0untranslated.\u2502 \u2502\u2502 \u2502*\u00a0On\u00a0output,\u00a0if\u00a0newline\u00a0is\u00a0None,\u00a0any\u00a0'\\n'\u00a0characters\u00a0written\u00a0are\u2502 \u2502\u00a0\u00a0translated\u00a0to\u00a0the\u00a0system\u00a0default\u00a0line\u00a0separator,\u00a0os.linesep.\u00a0If\u2502 \u2502\u00a0\u00a0newline\u00a0is\u00a0''\u00a0or\u00a0'\\n',\u00a0no\u00a0translation\u00a0takes\u00a0place.\u00a0If\u00a0newline\u00a0is\u00a0any\u2502 \u2502\u00a0\u00a0of\u00a0the\u00a0other\u00a0legal\u00a0values,\u00a0any\u00a0'\\n'\u00a0characters\u00a0written\u00a0are\u00a0translated\u2502 \u2502\u00a0\u00a0to\u00a0the\u00a0given\u00a0string.\u2502 \u2502\u2502 \u2502If\u00a0line_buffering\u00a0is\u00a0True,\u00a0a\u00a0call\u00a0to\u00a0flush\u00a0is\u00a0implied\u00a0when\u00a0a\u00a0call\u00a0to\u2502 \u2502write\u00a0contains\u00a0a\u00a0newline\u00a0character.\u2502 \u2502\u2502 \u2502\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u2502 \u2502\u2502<_io.TextIOWrappername='foo.txt'mode='w'encoding='UTF-8'>\u2502\u2502 \u2502\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2502 \u2502\u2502 \u2502buffer\u00a0=<_io.BufferedWritername='foo.txt'>\u2502 \u2502closed\u00a0=False\u2502 \u2502encoding\u00a0='UTF-8'\u2502 \u2502errors\u00a0='strict'\u2502 \u2502line_buffering\u00a0=False\u2502 \u2502mode\u00a0='w'\u2502 \u2502name\u00a0='foo.txt'\u2502 \u2502newlines\u00a0=None\u2502 \u2502write_through\u00a0=False\u2502 \u2502close\u00a0=def\u00a0close():\u2502 \u2502Flush\u00a0and\u00a0close\u00a0the\u00a0IO\u00a0object.\u2502 \u2502\u2502 \u2502This\u00a0method\u00a0has\u00a0no\u00a0effect\u00a0if\u00a0the\u00a0file\u00a0is\u00a0already\u00a0closed.\u2502 \u2502detach\u00a0=def\u00a0detach():\u2502 \u2502Separate\u00a0the\u00a0underlying\u00a0buffer\u00a0from\u00a0the\u00a0TextIOBase\u00a0and\u00a0return\u00a0it.\u2502 \u2502\u2502 \u2502After\u00a0the\u00a0underlying\u00a0buffer\u00a0has\u00a0been\u00a0detached,\u00a0the\u00a0TextIO\u00a0is\u00a0in\u00a0an\u2502 \u2502unusable\u00a0state.\u2502 \u2502fileno\u00a0=def\u00a0fileno():\u2502 \u2502Returns\u00a0underlying\u00a0file\u00a0descriptor\u00a0if\u00a0one\u00a0exists.\u2502 \u2502\u2502 \u2502OSError\u00a0is\u00a0raised\u00a0if\u00a0the\u00a0IO\u00a0object\u00a0does\u00a0not\u00a0use\u00a0a\u00a0file\u00a0descriptor.\u2502 \u2502flush\u00a0=def\u00a0flush():\u2502 \u2502Flush\u00a0write\u00a0buffers,\u00a0if\u00a0applicable.\u2502 \u2502\u2502 \u2502This\u00a0is\u00a0not\u00a0implemented\u00a0for\u00a0read-only\u00a0and\u00a0non-blocking\u00a0streams.\u2502 \u2502isatty\u00a0=def\u00a0isatty():\u2502 \u2502Return\u00a0whether\u00a0this\u00a0is\u00a0an\u00a0'interactive'\u00a0stream.\u2502 \u2502\u2502 \u2502Return\u00a0False\u00a0if\u00a0it\u00a0can't\u00a0be\u00a0determined.\u2502 \u2502read\u00a0=def\u00a0read(size=-1,\u00a0/):\u2502 \u2502Read\u00a0at\u00a0most\u00a0n\u00a0characters\u00a0from\u00a0stream.\u2502 \u2502\u2502 \u2502Read\u00a0from\u00a0underlying\u00a0buffer\u00a0until\u00a0we\u00a0have\u00a0n\u00a0characters\u00a0or\u00a0we\u00a0hit\u00a0EOF.\u2502 \u2502If\u00a0n\u00a0is\u00a0negative\u00a0or\u00a0omitted,\u00a0read\u00a0until\u00a0EOF.\u2502 \u2502readable\u00a0=def\u00a0readable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0reading.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0read()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502readline\u00a0=def\u00a0readline(size=-1,\u00a0/):\u2502 \u2502Read\u00a0until\u00a0newline\u00a0or\u00a0EOF.\u2502 \u2502\u2502 \u2502Returns\u00a0an\u00a0empty\u00a0string\u00a0if\u00a0EOF\u00a0is\u00a0hit\u00a0immediately.\u2502 \u2502readlines\u00a0=def\u00a0readlines(hint=-1,\u00a0/):\u2502 \u2502Return\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0from\u00a0the\u00a0stream.\u2502 \u2502\u2502 \u2502hint\u00a0can\u00a0be\u00a0specified\u00a0to\u00a0control\u00a0the\u00a0number\u00a0of\u00a0lines\u00a0read:\u00a0no\u00a0more\u2502 \u2502lines\u00a0will\u00a0be\u00a0read\u00a0if\u00a0the\u00a0total\u00a0size\u00a0(in\u00a0bytes/characters)\u00a0of\u00a0all\u2502 \u2502lines\u00a0so\u00a0far\u00a0exceeds\u00a0hint.\u2502 \u2502reconfigure\u00a0=def\u00a0reconfigure(*,\u00a0encoding=None,\u00a0errors=None,\u00a0newline=None,\u00a0line_buffering=None,\u00a0\u2502 \u2502write_through=None):\u2502 \u2502Reconfigure\u00a0the\u00a0text\u00a0stream\u00a0with\u00a0new\u00a0parameters.\u2502 \u2502\u2502 \u2502This\u00a0also\u00a0does\u00a0an\u00a0implicit\u00a0stream\u00a0flush.\u2502 \u2502seek\u00a0=def\u00a0seek(cookie,\u00a0whence=0,\u00a0/):\u2502 \u2502Change\u00a0stream\u00a0position.\u2502 \u2502\u2502 \u2502Change\u00a0the\u00a0stream\u00a0position\u00a0to\u00a0the\u00a0given\u00a0byte\u00a0offset.\u00a0The\u00a0offset\u00a0is\u2502 \u2502interpreted\u00a0relative\u00a0to\u00a0the\u00a0position\u00a0indicated\u00a0by\u00a0whence.\u00a0\u00a0Values\u2502 \u2502for\u00a0whence\u00a0are:\u2502 \u2502\u2502 \u2502*\u00a00\u00a0--\u00a0start\u00a0of\u00a0stream\u00a0(the\u00a0default);\u00a0offset\u00a0should\u00a0be\u00a0zero\u00a0or\u00a0positive\u2502 \u2502*\u00a01\u00a0--\u00a0current\u00a0stream\u00a0position;\u00a0offset\u00a0may\u00a0be\u00a0negative\u2502 \u2502*\u00a02\u00a0--\u00a0end\u00a0of\u00a0stream;\u00a0offset\u00a0is\u00a0usually\u00a0negative\u2502 \u2502\u2502 \u2502Return\u00a0the\u00a0new\u00a0absolute\u00a0position.\u2502 \u2502seekable\u00a0=def\u00a0seekable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0supports\u00a0random\u00a0access.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0seek(),\u00a0tell()\u00a0and\u00a0truncate()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502This\u00a0method\u00a0may\u00a0need\u00a0to\u00a0do\u00a0a\u00a0test\u00a0seek().\u2502 \u2502tell\u00a0=def\u00a0tell():Return\u00a0current\u00a0stream\u00a0position.\u2502 \u2502truncate\u00a0=def\u00a0truncate(pos=None,\u00a0/):\u2502 \u2502Truncate\u00a0file\u00a0to\u00a0size\u00a0bytes.\u2502 \u2502\u2502 \u2502File\u00a0pointer\u00a0is\u00a0left\u00a0unchanged.\u00a0\u00a0Size\u00a0defaults\u00a0to\u00a0the\u00a0current\u00a0IO\u2502 \u2502position\u00a0as\u00a0reported\u00a0by\u00a0tell().\u00a0\u00a0Returns\u00a0the\u00a0new\u00a0size.\u2502 \u2502writable\u00a0=def\u00a0writable():\u2502 \u2502Return\u00a0whether\u00a0object\u00a0was\u00a0opened\u00a0for\u00a0writing.\u2502 \u2502\u2502 \u2502If\u00a0False,\u00a0write()\u00a0will\u00a0raise\u00a0OSError.\u2502 \u2502write\u00a0=def\u00a0write(text,\u00a0/):\u2502 \u2502Write\u00a0string\u00a0to\u00a0stream.\u2502 \u2502Returns\u00a0the\u00a0number\u00a0of\u00a0characters\u00a0written\u00a0(which\u00a0is\u00a0always\u00a0equal\u00a0to\u2502 \u2502the\u00a0length\u00a0of\u00a0the\u00a0string).\u2502 \u2502writelines\u00a0=def\u00a0writelines(lines,\u00a0/):\u2502 \u2502Write\u00a0a\u00a0list\u00a0of\u00a0lines\u00a0to\u00a0stream.\u2502 \u2502\u2502 \u2502Line\u00a0separators\u00a0are\u00a0not\u00a0added,\u00a0so\u00a0it\u00a0is\u00a0usual\u00a0for\u00a0each\u00a0of\u00a0the\u2502 \u2502lines\u00a0provided\u00a0to\u00a0have\u00a0a\u00a0line\u00a0separator\u00a0at\u00a0the\u00a0end.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f There are a few more arguments to refine the level of detail you need (private methods, dunder attributes etc). You can see the full range of options with this delightful little incantation:
>>> inspect(inspect)\n
If you are interested in Rich or Textual, join our Discord server!
"},{"location":"blog/2023/07/27/using-rich-inspect-to-interrogate-python-objects/#addendum","title":"Addendum","text":"Here's how to have inspect
always available without an explicit import:
Put this in your pythonrc file: pic.twitter.com/pXTi69ykZL
\u2014 Tushar Sadhwani (@sadhlife) July 27, 2023"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/","title":"Spinners and progress bars in Textual","text":"One of the things I love about mathematics is that you can solve a problem just by guessing the correct answer. That is a perfectly valid strategy for solving a problem. The only thing you need to do after guessing the answer is to prove that your guess is correct.
I used this strategy, to some success, to display spinners and indeterminate progress bars from Rich in Textual.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#display-an-indeterminate-progress-bar-in-textual","title":"Display an indeterminate progress bar in Textual","text":"I have been playing around with Textual and recently I decided I needed an indeterminate progress bar to show that some data was loading. Textual is likely to get progress bars in the future, but I don't want to wait for the future! I want my progress bars now! Textual builds on top of Rich, so if Rich has progress bars, I reckoned I could use them in my Textual apps.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#progress-bars-in-rich","title":"Progress bars in Rich","text":"Creating a progress bar in Rich is as easy as opening up the documentation for Progress
and copying & pasting the code.
import time\nfrom rich.progress import track\nfor _ in track(range(20), description=\"Processing...\"):\ntime.sleep(0.5) # Simulate work being done\n
The function track
provides a very convenient interface for creating progress bars that keep track of a well-specified number of steps. In the example above, we were keeping track of some task that was going to take 20 steps to complete. (For example, if we had to process a list with 20 elements.) However, I am looking for indeterminate progress bars.
Scrolling further down the documentation for rich.progress
I found what I was looking for:
import time\nfrom rich.progress import Progress\nwith Progress() as progress:\n_ = progress.add_task(\"Loading...\", total=None) # (1)!\nwhile True:\ntime.sleep(0.01)\n
total=None
is what makes it an indeterminate progress bar.So, putting an indeterminate progress bar on the screen is easy. Now, I only needed to glue that together with the little I know about Textual to put an indeterminate progress bar in a Textual app.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#guessing-what-is-what-and-what-goes-where","title":"Guessing what is what and what goes where","text":"What I want is to have an indeterminate progress bar inside my Textual app. Something that looks like this:
The GIF above shows just the progress bar. Obviously, the end goal is to have the progress bar be part of a Textual app that does something.
So, when I set out to do this, my first thought went to the stopwatch app in the Textual tutorial because it has a widget that updates automatically, the TimeDisplay
. Below you can find the essential part of the code for the TimeDisplay
widget and a small animation of it updating when the stopwatch is started.
TimeDisplay
widgetOutput from time import monotonic\nfrom textual.reactive import reactive\nfrom textual.widgets import Static\nclass TimeDisplay(Static):\n\"\"\"A widget to display elapsed time.\"\"\"\nstart_time = reactive(monotonic)\ntime = reactive(0.0)\ntotal = reactive(0.0)\ndef on_mount(self) -> None:\n\"\"\"Event handler called when widget is added to the app.\"\"\"\nself.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)\ndef update_time(self) -> None:\n\"\"\"Method to update time to current.\"\"\"\nself.time = self.total + (monotonic() - self.start_time)\ndef watch_time(self, time: float) -> None:\n\"\"\"Called when the time attribute changes.\"\"\"\nminutes, seconds = divmod(time, 60)\nhours, minutes = divmod(minutes, 60)\nself.update(f\"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}\")\n
The reason the time display updates magically is due to the three methods that I highlighted in the code above:
on_mount
is called when the TimeDisplay
widget is mounted on the app and, in it, we use the method set_interval
to let Textual know that every 1 / 60
seconds we would like to call the method update_time
. (In other words, we would like update_time
to be called 60 times per second.)update_time
(which is called automatically a bunch of times per second) will update the reactive attribute time
. When this attribute update happens, the method watch_time
kicks in.watch_time
is a watcher method and gets called whenever the attribute self.time
is assigned to. So, if the method update_time
is called a bunch of times per second, the watcher method watch_time
is also called a bunch of times per second. In it, we create a nice representation of the time that has elapsed and we use the method update
to update the time that is being displayed.I thought it would be reasonable if a similar mechanism needed to be in place for my progress bar, but then I realised that the progress bar seems to update itself... Looking at the indeterminate progress bar example from before, the only thing going on was that we used time.sleep
to stop our program for a bit. We didn't do anything to update the progress bar... Look:
with Progress() as progress:\n_ = progress.add_task(\"Loading...\", total=None) # (1)!\nwhile True:\ntime.sleep(0.01)\n
After pondering about this for a bit, I realised I would not need a watcher method for anything. The watcher method would only make sense if I needed to update an attribute related to some sort of artificial progress, but that clearly isn't needed to get the bar going...
At some point, I realised that the object progress
is the object of interest. At first, I thought progress.add_task
would return the progress bar, but it actually returns the integer ID of the task added, so the object of interest is progress
. Because I am doing nothing to update the bar explicitly, the object progress
must be updating itself.
The Textual documentation also says that we can build widgets from Rich renderables, so I concluded that if Progress
were a renderable, then I could inherit from Static
and use the method update
to update the widget with my instance of Progress
directly. I gave it a try and I put together this code:
from rich.progress import Progress, BarColumn\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass IndeterminateProgress(Static):\ndef __init__(self):\nsuper().__init__(\"\")\nself._bar = Progress(BarColumn()) # (1)!\nself._bar.add_task(\"\", total=None) # (2)!\ndef on_mount(self) -> None:\n# When the widget is mounted start updating the display regularly.\nself.update_render = self.set_interval(\n1 / 60, self.update_progress_bar\n) # (3)!\ndef update_progress_bar(self) -> None:\nself.update(self._bar) # (4)!\nclass MyApp(App):\ndef compose(self) -> ComposeResult:\nyield IndeterminateProgress()\nif __name__ == \"__main__\":\napp = MyApp()\napp.run()\n
Progress
that just cares about the bar itself (Rich progress bars can have a label, an indicator for the time left, etc).total=None
for the indeterminate progress bar.update_progress_bar
60 times per second.Static.update
with the Progress
object because self._bar
is a Rich renderable.And lo and behold, it worked:
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#proving-it-works","title":"Proving it works","text":"I finished writing this piece of code and I was ecstatic because it was working! After all, my Textual app starts and renders the progress bar. And so, I shared this simple app with someone who wanted to do a similar thing, but I was left with a bad taste in my mouth because I couldn't really connect all the dots and explain exactly why it worked.
Plot twist
By the end of the blog post, I will be much closer to a full explanation!
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#display-a-rich-spinner-in-a-textual-app","title":"Display a Rich spinner in a Textual app","text":"A day after creating my basic IndeterminateProgress
widget, I found someone that was trying to display a Rich spinner in a Textual app. Actually, it was someone that had filed an issue against Rich. They didn't ask \u201chow can I display a Rich spinner in a Textual app?\u201d, but they filed an alleged bug that crept up on them when they tried displaying a spinner in a Textual app.
When reading the issue I realised that displaying a Rich spinner looked very similar to displaying a Rich progress bar, so I made a tiny change to my code and tried to run it:
CodeSpinner runningfrom rich.spinner import Spinner\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass SpinnerWidget(Static):\ndef __init__(self):\nsuper().__init__(\"\")\nself._spinner = Spinner(\"moon\") # (1)!\ndef on_mount(self) -> None:\nself.update_render = self.set_interval(1 / 60, self.update_spinner)\ndef update_spinner(self) -> None:\nself.update(self._spinner)\nclass MyApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield SpinnerWidget()\nMyApp().run()\n
Progress
, we create an instance of Spinner
and save it so we can call self.update(self._spinner)
later on.After creating the progress bar and spinner widgets I thought of creating the little display that was shown at the beginning of the blog post:
When writing the code for this app, I realised both widgets had a lot of shared code and logic and I tried abstracting away their common functionality. That led to the code shown below (more or less) where I implemented the updating functionality in IntervalUpdater
and then let the IndeterminateProgressBar
and SpinnerWidget
instantiate the correct Rich renderable.
from rich.progress import Progress, BarColumn\nfrom rich.spinner import Spinner\nfrom textual.app import RenderableType\nfrom textual.widgets import Button, Static\nclass IntervalUpdater(Static):\n_renderable_object: RenderableType # (1)!\ndef update_rendering(self) -> None: # (2)!\nself.update(self._renderable_object)\ndef on_mount(self) -> None: # (3)!\nself.interval_update = self.set_interval(1 / 60, self.update_rendering)\nclass IndeterminateProgressBar(IntervalUpdater):\n\"\"\"Basic indeterminate progress bar widget based on rich.progress.Progress.\"\"\"\ndef __init__(self) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Progress(BarColumn()) # (4)!\nself._renderable_object.add_task(\"\", total=None)\nclass SpinnerWidget(IntervalUpdater):\n\"\"\"Basic spinner widget based on rich.spinner.Spinner.\"\"\"\ndef __init__(self, style: str) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Spinner(style) # (5)!\n
IntervalUpdate
should set the attribute _renderable_object
to the instance of the Rich renderable that we want to animate.update_rendering
and on_mount
are exactly the same as what we had before, both in the progress bar widget and in the spinner widget.update_rendering
and on_mount
are exactly the same as what we had before, both in the progress bar widget and in the spinner widget._renderable_object
to an instance of Progress
._renderable_object
to an instance of Spinner
.But I wanted something more! I wanted to make my app similar to the stopwatch app from the terminal and thus wanted to add a \u201cPause\u201d and a \u201cResume\u201d button. These buttons should, respectively, stop the progress bar and the spinner animations and resume them.
Below you can see the code I wrote and a short animation of the app working.
App codeCSSOutputfrom rich.progress import Progress, BarColumn\nfrom rich.spinner import Spinner\nfrom textual.app import App, ComposeResult, RenderableType\nfrom textual.containers import Grid, Horizontal, Vertical\nfrom textual.widgets import Button, Static\nclass IntervalUpdater(Static):\n_renderable_object: RenderableType\ndef update_rendering(self) -> None:\nself.update(self._renderable_object)\ndef on_mount(self) -> None:\nself.interval_update = self.set_interval(1 / 60, self.update_rendering)\ndef pause(self) -> None: # (1)!\nself.interval_update.pause()\ndef resume(self) -> None: # (2)!\nself.interval_update.resume()\nclass IndeterminateProgressBar(IntervalUpdater):\n\"\"\"Basic indeterminate progress bar widget based on rich.progress.Progress.\"\"\"\ndef __init__(self) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Progress(BarColumn())\nself._renderable_object.add_task(\"\", total=None)\nclass SpinnerWidget(IntervalUpdater):\n\"\"\"Basic spinner widget based on rich.spinner.Spinner.\"\"\"\ndef __init__(self, style: str) -> None:\nsuper().__init__(\"\")\nself._renderable_object = Spinner(style)\nclass LiveDisplayApp(App[None]):\n\"\"\"App showcasing some widgets that update regularly.\"\"\"\nCSS_PATH = \"myapp.css\"\ndef compose(self) -> ComposeResult:\nyield Vertical(\nGrid(\nSpinnerWidget(\"moon\"),\nIndeterminateProgressBar(),\nSpinnerWidget(\"aesthetic\"),\nSpinnerWidget(\"bouncingBar\"),\nSpinnerWidget(\"earth\"),\nSpinnerWidget(\"dots8Bit\"),\n),\nHorizontal(\nButton(\"Pause\", id=\"pause\"), # (3)!\nButton(\"Resume\", id=\"resume\", disabled=True),\n),\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (4)!\npressed_id = event.button.id\nassert pressed_id is not None\nfor widget in self.query(IntervalUpdater):\ngetattr(widget, pressed_id)() # (5)!\nfor button in self.query(Button): # (6)!\nif button.id == pressed_id:\nbutton.disabled = True\nelse:\nbutton.disabled = False\nLiveDisplayApp().run()\n
pause
looks at the attribute interval_update
(returned by the method set_interval
) and tells it to stop calling the method update_rendering
60 times per second.resume
looks at the attribute interval_update
(returned by the method set_interval
) and tells it to resume calling the method update_rendering
60 times per second.on_button_pressed
will wait for button presses and will take care of pausing or resuming the animations.IntervalUpdater
in our app and use a little bit of introspection to call the correct method (pause
or resume
) in our widgets. Notice this was only possible because the buttons were assigned IDs that matched the names of the methods. (I love Python !)Screen {\nalign: center middle;\n}\nHorizontal {\nheight: 1fr;\nalign-horizontal: center;\n}\nButton {\nmargin: 0 3 0 3;\n}\nGrid {\nheight: 4fr;\nalign: center middle;\ngrid-size: 3 2;\ngrid-columns: 8;\ngrid-rows: 1;\ngrid-gutter: 1;\nborder: gray double;\n}\nIntervalUpdater {\ncontent-align: center middle;\n}\n
If you think this was a lot, take a couple of deep breaths before moving on.
The only issue with my app is that... it does not work! If you press the button to pause the animations, it looks like the widgets are paused. However, you can see that if I move my mouse over the paused widgets, they update:
Obviously, that caught me by surprise, in the sense that I expected it work. On the other hand, this isn't surprising. After all, I thought I had guessed how I could solve the problem of displaying these Rich renderables that update live and I thought I knew how to pause and resume their animations, but I hadn't convinced myself I knew exactly why it worked.
Warning
This goes to show that sometimes it is not the best idea to commit code that you wrote and that works if you don't know why it works. The code might seem to work and yet have deficiencies that will hurt you further down the road.
As it turns out, the reason why pausing is not working is that I did not grok why the rendering worked in the first place... So I had to go down that rabbit hole first.
"},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#understanding-the-rich-rendering-magic","title":"Understanding the Rich rendering magic","text":""},{"location":"blog/2022/11/24/spinners-and-progress-bars-in-textual/#how-staticupdate-works","title":"HowStatic.update
works","text":"The most basic way of creating a Textual widget is to inherit from Widget
and implement the method render
that just returns the thing that must be printed on the screen. Then, the widget Static
provides some functionality on top of that: the method update
.
The method Static.update(renderable)
is used to tell the widget in question that its method render
(called when the widget needs to be drawn) should just return renderable
. So, if the implementation of the method IntervalUpdater.update_rendering
(the method that gets called 60 times per second) is this:
class IntervalUpdater(Static):\n# ...\ndef update_rendering(self) -> None:\nself.update(self._renderable_object)\n
Then, we are essentially saying \u201chey, the thing in self._renderable_object
is what must be returned whenever Textual asks you to render yourself. So, this really proves that both Progress
and Spinner
from Rich are renderables. But what is more, this shows that my implementation of IntervalUpdater
can be simplified greatly! In fact, we can boil it down to just this:
class IntervalUpdater(Static):\n_renderable_object: RenderableType\ndef __init__(self, renderable_object: RenderableType) -> None: # (1)!\nsuper().__init__(renderable_object) # (2)!\ndef on_mount(self) -> None:\nself.interval_update = self.set_interval(1 / 60, self.refresh) # (3)!\n
IntervalUpdater
, now we give it the Rich renderable that we want displayed. If this Rich renderable is something that updates over time, then those changes will be reflected in the rendering.Static
with the renderable object itself, instead of initialising with the empty string \"\"
and then updating repeatedly.self.refresh
60 times per second. We don't need the auxiliary method update_rendering
because this widget (an instance of Static
) already knows what its renderable is.Once you understand the code above you will realise that the previous implementation of update_rendering
was actually doing superfluous work because the repeated calls to self.update
always had the exact same object. Again, we see strong evidence that the Rich progress bars and the spinners have the inherent ability to display a different representation of themselves as time goes by.
I kept seeing strong evidence that Rich spinners and Rich progress bars updated their own rendering but I still did not have actual proof. So, I went digging around to see how Spinner
was implemented and I found this code (from the file spinner.py
at the time of writing):
class Spinner:\n# ...\ndef __rich_console__(\nself, console: \"Console\", options: \"ConsoleOptions\"\n) -> \"RenderResult\":\nyield self.render(console.get_time()) # (1)!\n# ...\ndef render(self, time: float) -> \"RenderableType\": # (2)!\n# ...\nframe_no = ((time - self.start_time) * self.speed) / ( # (3)!\nself.interval / 1000.0\n) + self.frame_no_offset\n# ...\n# ...\n
__rich_console__
that is supposed to return the result of rendering the spinner. Instead, it defers its work to the method render
... However, to call the method render
, we need to pass the argument console.get_time()
, which the spinner uses to know in which state it is!render
takes a time
and returns a renderable!time
, and the time when the spinner started!The snippet of code shown above, from the implementation of Spinner
, explains why moving the mouse over a spinner (or a progress bar) that supposedly was paused makes it move. We no longer get repeated updates (60 times per second) because we told our app that we wanted to pause the result of set_interval
, so we no longer get automatic updates. However, moving the mouse over the spinners and the progress bar makes Textual want to re-render them and, when it does, it figures out that time was not frozen (obviously!) and so the spinners and the progress bar have a different frame to show.
To get a better feeling for this, do the following experiment:
textual console
in a terminal to open the Textual devtools console.print(\"Rendering from within spinner\")
to the beginning of the method Spinner.render
(from Rich).print(\"Rendering static\")
to the beginning of the method Static.render
(from Textual).Static.render
and another from the Spinner.render
.The result of steps 6 and 7 are shown below. Notice that, in the beginning of the animation, the screen on the right shows some prints but is quiet because no more prints are coming in. When the mouse enters the screen and starts going over widgets, the screen on the right gets new prints in pairs, first from Static.render
(which Textual calls to render the widget) and then from Spinner.render
because ultimately we need to know how the Spinner looks.
Now, at this point, I made another educated guess and deduced that progress bars work in the same way! I still have to prove it, and I guess I will do so in another blog post, coming soon, where our spinner and progress bar widgets can be properly paused!
I will see you soon
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/","title":"Stealing Open Source code from Textual","text":"I would like to talk about a serious issue in the Free and Open Source software world. Stealing code. You wouldn't steal a car would you?
But you should steal code from Open Source projects. Respect the license (you may need to give attribution) but stealing code is not like stealing a car. If I steal your car, I have deprived you of a car. If you steal my open source code, I haven't lost anything.
Warning
I'm not advocating for piracy. Open source code gives you explicit permission to use it.
From my point of view, I feel like code has greater value when it has been copied / modified in another project.
There are a number of files and modules in Textual that could either be lifted as is, or wouldn't require much work to extract. I'd like to cover a few here. You might find them useful in your next project.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#loop-first-last","title":"Loop first / last","text":"How often do you find yourself looping over an iterable and needing to know if an element is the first and/or last in the sequence? It's a simple thing, but I find myself needing this a lot, so I wrote some helpers in _loop.py.
I'm sure there is an equivalent implementation on PyPI, but steal this if you need it.
Here's an example of use:
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):\nyield move_to(x, y)\nyield from line\nif not last:\nyield new_line\n
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#lru-cache","title":"LRU Cache","text":"Python's lru_cache can be the one-liner that makes your code orders of magnitude faster. But it has a few gotchas.
The main issue is managing the lifetime of these caches. The decorator keeps a single global cache, which will keep a reference to every object in the function call. On an instance method that means you keep references to self
for the lifetime of your app.
For a more flexibility you can use the LRUCache implementation from Textual. This uses essentially the same algorithm as the stdlib decorator, but it is implemented as a container.
Here's a quick example of its use. It works like a dictionary until you reach a maximum size. After that, new elements will kick out the element that was used least recently.
>>> from textual._cache import LRUCache\n>>> cache = LRUCache(maxsize=3)\n>>> cache[\"foo\"] = 1\n>>> cache[\"bar\"] = 2\n>>> cache[\"baz\"] = 3\n>>> dict(cache)\n{'foo': 1, 'bar': 2, 'baz': 3}\n>>> cache[\"egg\"] = 4\n>>> dict(cache)\n{'bar': 2, 'baz': 3, 'egg': 4}\n
In Textual, we use a LRUCache to store the results of rendering content to the terminal. For example, in a datatable it is too costly to render everything up front. So Textual renders only the lines that are currently visible on the \"screen\". The cache ensures that scrolling only needs to render the newly exposed lines, and lines that haven't been displayed in a while are discarded to save memory.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#color","title":"Color","text":"Textual has a Color class which could be extracted in to a module of its own.
The Color class can parse colors encoded in a variety of HTML and CSS formats. Color object support a variety of methods and operators you can use to manipulate colors, in a fairly natural way.
Here's some examples in the REPL.
>>> from textual.color import Color\n>>> color = Color.parse(\"lime\")\n>>> color\nColor(0, 255, 0, a=1.0)\n>>> color.darken(0.8)\nColor(0, 45, 0, a=1.0)\n>>> color + Color.parse(\"red\").with_alpha(0.1)\nColor(25, 229, 0, a=1.0)\n>>> color = Color.parse(\"#12a30a\")\n>>> color\nColor(18, 163, 10, a=1.0)\n>>> color.css\n'rgb(18,163,10)'\n>>> color.hex\n'#12A30A'\n>>> color.monochrome\nColor(121, 121, 121, a=1.0)\n>>> color.monochrome.hex\n'#797979'\n>>> color.hsl\nHSL(h=0.3246187363834423, s=0.8843930635838151, l=0.33921568627450976)\n>>>\n
There are some very good color libraries in PyPI, which you should also consider using. But Textual's Color class is lean and performant, with no C dependencies.
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#geometry","title":"Geometry","text":"This may be my favorite module in Textual: geometry.py.
The geometry module contains a number of classes responsible for storing and manipulating 2D geometry. There is an Offset
class which is a two dimensional point. A Region
class which is a rectangular region defined by a coordinate and dimensions. There is a Spacing
class which defines additional space around a region. And there is a Size
class which defines the dimensions of an area by its width and height.
These objects are used by Textual's layout engine and compositor, which makes them the oldest and most thoroughly tested part of the project.
There's a lot going on in this module, but the docstrings are quite detailed and have unicode art like this to help explain things.
cut_x \u2193\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 \u2502 \u2502 \u2502\n \u2502 0 \u2502 \u2502 1 \u2502\n \u2502 \u2502 \u2502 \u2502\n cut_y \u2192 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2510\n \u2502 2 \u2502 \u2502 3 \u2502\n \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2518\n
"},{"location":"blog/2022/11/20/stealing-open-source-code-from-textual/#you-should-steal-our-code","title":"You should steal our code","text":"There is a lot going on in the Textual Repository. Including a CSS parser, renderer, layout and compositing engine. All written in pure Python. Steal it with my blessing.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/","title":"Things I learned building a text editor for the terminal","text":"TextArea
is the latest widget to be added to Textual's growing collection. It provides a multi-line space to edit text, and features optional syntax highlighting for a selection of languages.
Adding a TextArea
to your Textual app is as simple as adding this to your compose
method:
yield TextArea()\n
Enabling syntax highlighting for a language is as simple as:
yield TextArea(language=\"python\")\n
Working on the TextArea
widget for Textual taught me a lot about Python and my general approach to software engineering. It gave me an appreciation for the subtle functionality behind the editors we use on a daily basis \u2014 features we may not even notice, despite some engineer spending hours perfecting it to provide a small boost to our development experience.
This post is a tour of some of these learnings.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#vertical-cursor-movement-is-more-than-just-cursor_row","title":"Vertical cursor movement is more than justcursor_row++
","text":"When you move the cursor vertically, you can't simply keep the same column index and clamp it within the line. Editors should maintain the visual column offset where possible, meaning they must account for double-width emoji (sigh \ud83d\ude14) and East-Asian characters.
Notice that although the cursor is on column 11 while on line 1, it lands on column 6 when it arrives at line 3. This is because the 6th character of line 3 visually aligns with the 11th character of line 1.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#edits-from-other-sources-may-move-my-cursor","title":"Edits from other sources may move my cursor","text":"There are two ways to interact with the TextArea
:
In the example below, Hello, world!\\n
is repeatedly inserted at the start of the document via the API. Notice that this updates the location of my cursor, ensuring that I don't lose my place.
This subtle feature should aid those implementing collaborative and multi-cursor editing.
This turned out to be one of the more complex features of the whole project, and went through several iterations before I was happy with the result.
Thankfully it resulted in some wonderful Tetris-esque whiteboards along the way!
A TetrisArea white-boarding session.
Sometimes stepping away from the screen and scribbling on a whiteboard with your colleagues (thanks Dave!) is what's needed to finally crack a tough problem.
Many thanks to David Brochart for sending me down this rabbit hole!
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#spending-a-few-minutes-running-a-profiler-can-be-really-beneficial","title":"Spending a few minutes running a profiler can be really beneficial","text":"While building the TextArea
widget I avoided heavy optimisation work that may have affected readability or maintainability.
However, I did run a profiler in an attempt to detect flawed assumptions or mistakes which were affecting the performance of my code.
I spent around 30 minutes profiling TextArea
using pyinstrument, and the result was a ~97% reduction in the time taken to handle a key press. What an amazing return on investment for such a minimal time commitment!
\"pyinstrument -r html\" produces this beautiful output.
pyinstrument unveiled two issues that were massively impacting performance.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#1-reparsing-highlighting-queries-on-each-key-press","title":"1. Reparsing highlighting queries on each key press","text":"I was constructing a tree-sitter Query
object on each key press, incorrectly assuming it was a low-overhead call. This query was completely static, so I moved it into the constructor ensuring the object was created only once. This reduced key processing time by around 94% - a substantial and very much noticeable improvement.
This seems obvious in hindsight, but the code in question was written earlier in the project and had been relegated in my mind to \"code that works correctly and will receive less attention from here on out\". pyinstrument quickly brought this code back to my attention and highlighted it as a glaring performance bug.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#2-namedtuples-are-slower-than-i-expected","title":"2. NamedTuples are slower than I expected","text":"In Python, NamedTuple
s are slow to create relative to tuple
s, and this cost was adding up inside an extremely hot loop which was instantiating a large number of them. pyinstrument revealed that a large portion of the time during syntax highlighting was spent inside NamedTuple.__new__
.
Here's a quick benchmark which constructs 10,000 NamedTuple
s:
\u276f hyperfine -w 2 'python sandbox/darren/make_namedtuples.py'\nBenchmark 1: python sandbox/darren/make_namedtuples.py\nTime (mean \u00b1 \u03c3): 15.9 ms \u00b1 0.5 ms [User: 12.8 ms, System: 2.5 ms]\nRange (min \u2026 max): 15.2 ms \u2026 18.4 ms 165 runs\n
Here's the same benchmark using tuple
instead:
\u276f hyperfine -w 2 'python sandbox/darren/make_tuples.py'\nBenchmark 1: python sandbox/darren/make_tuples.py\nTime (mean \u00b1 \u03c3): 9.3 ms \u00b1 0.5 ms [User: 6.8 ms, System: 2.0 ms]\nRange (min \u2026 max): 8.7 ms \u2026 12.3 ms 256 runs\n
Switching to tuple
resulted in another noticeable increase in responsiveness. Key-press handling time dropped by almost 50%! Unfortunately, this change does impact readability. However, the scope in which these tuples were used was very small, and so I felt it was a worthy trade-off.
In order to support syntax highlighting, we make use of the tree-sitter library, which maintains a syntax tree representing the structure of our document.
To perform highlighting, we follow these steps:
Cycling through a few of the builtin themes.
Another benefit that I didn't consider before working on this project is that tree-sitter parsers can also be used to highlight syntax errors in a document. This can be useful in some situations - for example, highlighting mismatched HTML closing tags:
Highlighting mismatched closing HTML tags in red.
Before building this widget, I was oblivious as to how we might approach syntax highlighting. Without tree-sitter's incremental parsing approach, I'm not sure reasonable performance would have been feasible.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#edits-are-replacements","title":"Edits are replacements","text":"All single-cursor edits can be distilled into a single behaviour: replace_range
. This replaces a range of characters with some text. We can use this one method to easily implement deletion, insertion, and replacement of text.
This greatly simplified my initial approach, which involved unique implementations for inserting and deleting.
"},{"location":"blog/2023/09/18/things-i-learned-while-building-textuals-textarea/#the-line-between-text-area-and-vscode-in-the-terminal","title":"The line between \"text area\" and \"VSCode in the terminal\"","text":"A project like this has no clear finish line. There are always new features, optimisations, and refactors waiting to be made.
So where do we draw the line?
We want to provide a widget which can act as both a basic multiline text area that anyone can drop into their app, yet powerful and extensible enough to act as the foundation for a Textual-powered text editor.
Yet, the more features we add, the more opinionated the widget becomes, and the less that users will feel like they can build it into their own thing. Finding the sweet spot between feature-rich and flexible is no easy task.
I don't think the answer is clear, and I don't believe it's possible to please everyone.
Regardless, I'm happy with where we've landed, and I'm really excited to see what people build using TextArea
in the future!
It's no surprise that a common question on the Textual Discord server is how to go about producing plots in the terminal. A popular solution that has been suggested is Plotext. While Plotext doesn't directly support Textual, it is easy to use with Rich and, because of this, we wanted to make it just as easy to use in your Textual applications.
With this in mind we've created textual-plotext
: a library that provides a widget for using Plotext plots in your app. In doing this we've tried our best to make it as similar as possible to using Plotext in a conventional Python script.
Take this code from the Plotext README:
import plotext as plt\ny = plt.sin() # sinusoidal test signal\nplt.scatter(y)\nplt.title(\"Scatter Plot\") # to apply a title\nplt.show() # to finally plot\n
The Textual equivalent of this (including everything needed to make this a fully-working Textual application) is:
from textual.app import App, ComposeResult\nfrom textual_plotext import PlotextPlot\nclass ScatterApp(App[None]):\ndef compose(self) -> ComposeResult:\nyield PlotextPlot()\ndef on_mount(self) -> None:\nplt = self.query_one(PlotextPlot).plt\ny = plt.sin() # sinusoidal test signal\nplt.scatter(y)\nplt.title(\"Scatter Plot\") # to apply a title\nif __name__ == \"__main__\":\nScatterApp().run()\n
When run the result will look like this:
Aside from a couple of the more far-out plot types1 you should find that everything you can do with Plotext in a conventional script can also be done in a Textual application.
Here's a small selection of screenshots from a demo built into the library, each of the plots taken from the Plotext README:
A key design goal of this widget is that you can develop your plots so that the resulting code looks very similar to that in the Plotext documentation. The core difference is that, where you'd normally import the plotext
module as plt
and then call functions via plt
, you instead use the plt
property made available by the widget.
You don't even need to call the build
or show
functions as textual-plotext
takes care of this for you. You can see this in action in the scatter code shown earlier.
Of course, moving any existing plotting code into your Textual app means you will need to think about how you get the data and when and where you build your plot. This might be where the Textual worker API becomes useful.
We've included a longer-form example application that shows off the glorious Scottish weather we enjoy here at Textual Towers, with an application that uses workers to pull down weather data from a year ago and plot it.
If you are an existing Plotext user who wants to turn your plots into full terminal applications, we think this will be very familiar and accessible. If you're a Textual user who wants to add plots to your application, we think Plotext is a great library for this.
If you have any questions about this, or anything else to do with Textual, feel free to come and join us on our Discord server or in our GitHub discussions.
Right now there's no animated gif or video support.\u00a0\u21a9
If you know us, you will know that we are the team behind Rich and Textual \u2014 two popular Python libraries that work magic in the terminal.
Note
Not to mention Rich-CLI, Trogon, and Frogmouth
Today we are adding one project more to that lineup: textual-web.
Textual Web takes a Textual-powered TUI and turns it in to a web application. Here's a video of that in action:
With the textual-web
command you can publish any Textual app on the web, making it available to anyone you send the URL to. This works without creating a socket server on your machine, so you won't have to configure firewalls and ports to share your applications.
We're excited about the possibilities here. Textual web apps are fast to spin up and tear down, and they can run just about anywhere that has an outgoing internet connection. They can be built by a single developer without any experience with a traditional web stack. All you need is proficiency in Python and a little time to read our lovely docs.
Future releases will expose more of the Web platform APIs to Textual apps, such as notifications and file system access. We plan to do this in a way that allows the same (Python) code to drive those features. For instance, a Textual app might save a file to disk in a terminal, but offer to download it in the browser.
Also in the pipeline is PWA support, so you can build terminal apps, web apps, and desktop apps with a single codebase.
Textual Web is currently in a public beta. Join our Discord server if you would like to help us test, or if you have any questions.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/","title":"To TUI or not to TUI","text":"Tech moves pretty fast. If you don\u2019t stop and look around once in a while, you could miss it. And yet some technology feels like it has been around forever.
Terminals are one of those forever-technologies.
My interest is in Text User Interfaces: interactive apps that run within a terminal. I spend lot of time thinking about where TUIs might fit within the tech ecosystem, and how much more they could be doing for developers. Hardly surprising, since that is what we do at Textualize.
Recently I had the opportunity to test how new TUI projects would be received. You can consider these to be \"testing the water\", and hopefully representative of TUI apps in general.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#the-projects","title":"The projects","text":"In April we took a break from building Textual, to building apps with Textual. We had three ideas to work on, and three devs to do the work. One idea we parked for later. The other two were so promising we devoted more time to them. Both projects took around three developer-weeks to build, which also included work on Textual itself and standard duties for responding to issues / community requests. We released them in May.
The first project was Frogmouth, a Markdown browser. I think this TUI does better than the equivalent web experience in many ways. The only notable missing feature is images, and that will happen before too long.
Here's a screenshot:
Frogmouth /Users/willmcgugan/projects/textual/FAQ.md ContentsLocalBookmarksHistory\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u258e\u258a \u258eHow\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u258a \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e\u258a \u2503\u25bc\u00a0\u2160\u00a0Frequently\u00a0Asked\u00a0Questions\u2503\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Does\u00a0Textual\u00a0support\u00a0images?\u2503When\u00a0creating\u00a0your\u00a0App\u00a0class,\u00a0override\u00a0__init__\u00a0as\u00a0you\u00a0would\u00a0wheninheriting\u00a0normally.\u00a0For\u00a0example: \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0fix\u00a0ImportError\u00a0cannot\u00a0i\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0select\u00a0and\u00a0copy\u00a0text\u00a0in\u00a0\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0can\u00a0I\u00a0set\u00a0a\u00a0translucent\u00a0app\u00a0ba\u2503fromtextual.appimportApp,ComposeResult \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0center\u00a0a\u00a0widget\u00a0in\u00a0a\u00a0scre\u2503fromtextual.widgetsimportStatic \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0How\u00a0do\u00a0I\u00a0pass\u00a0arguments\u00a0to\u00a0an\u00a0app?\u2503 \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0do\u00a0some\u00a0key\u00a0combinations\u00a0never\u2503classGreetings(App[None]): \u2503\u251c\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0look\u00a0good\u00a0on\u00a0m\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2514\u2500\u2500\u00a0\u2161\u00a0Why\u00a0doesn't\u00a0Textual\u00a0support\u00a0ANSI\u00a0t\u2503\u2502\u00a0\u00a0\u00a0def__init__(self,greeting:str=\"Hello\",to_greet:str=\"World\")->None: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.greeting=greeting \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0self.to_greet=to_greet \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0super().__init__() \u2503\u2503\u2502\u00a0\u00a0\u00a0 \u2503\u2503\u2502\u00a0\u00a0\u00a0defcompose(self)->ComposeResult: \u2503\u2503\u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldStatic(f\"{self.greeting},\u00a0{self.to_greet}\") \u2503\u2503 \u2503\u2503 \u2503\u2503Then\u00a0the\u00a0app\u00a0can\u00a0be\u00a0run,\u00a0passing\u00a0in\u00a0various\u00a0arguments;\u00a0for\u00a0example: \u2503\u2503\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0default\u00a0arguments. \u2503\u2503Greetings().run() \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0a\u00a0keyword\u00a0arguyment. \u2503\u2503Greetings(to_greet=\"davep\").run()\u2585\u2585 \u2503\u2503 \u2503\u2503#\u00a0Running\u00a0with\u00a0both\u00a0positional\u00a0arguments. \u2503\u2503Greetings(\"Well\u00a0hello\",\"there\").run() \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2503\u2589\u2503\u258e\u258a \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u00a0F1\u00a0\u00a0Help\u00a0\u00a0F2\u00a0\u00a0About\u00a0\u00a0CTRL+N\u00a0\u00a0Navigation\u00a0\u00a0CTRL+Q\u00a0\u00a0Quit\u00a0Info
Quick aside about these \"screenshots\", because its a common ask. They aren't true screenshots, but rather SVGs exported by Textual.
We posted Frogmouth on Hacker News and Reddit on a Sunday morning (US time). A day later, it had 1,000 stars and lots of positive feedback.
The second project was Trogon, a library this time. Trogon automatically creates a TUI for command line apps. Same deal: we released it on a Sunday morning, and it reached 1K stars even quicker than Frogmouth.
Trogon sqlite-utilstransform v3.31Transform\u00a0a\u00a0table\u00a0beyond\u00a0the\u00a0capabilities\u00a0of\u00a0ALTER\u00a0TABLE \u258a\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258e disable-wal\u258a\u258a\u258e\u258e drop-table\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e drop-view\u258a\u258e\u2587\u2587 dump\u258aOptions\u258e duplicate\u258a\u258e enable-counts\u258a--type\u00a0multiple\u00a0<text\u00a0choice>\u258e enable-fts\u258a\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258e enable-wal\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e extract\u258a\u2502\u258a\u258e\u2502\u258e\u2585\u2585 index-foreign-keys\u258a\u2502\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2502\u258e indexes\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e insert\u258a\u2502\u258aSelect\u25b2\u258e\u2502\u258e insert-files\u258a\u2502\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2502\u258e install\u258a\u2514\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2518\u258e memory\u258a\u258aSelect\u258e\u258e optimize\u258a\u258aINTEGER\u258e\u258e populate-fts\u258a\u258aTEXT\u258e\u258e query\u258a\u258aFLOAT\u258e\u258e rebuild-fts\u258a\u258a\u258aBLOB\u258e\u258e\u258e reset-counts\u258a\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e\u258e rows\u258a\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258e schema\u258a+\u00a0value\u258e search\u258aDrop\u00a0this\u00a0column\u258e tables\u258a\u258e transform\u258a--rename\u00a0multiple\u00a0<text\u00a0text>\u258e triggers\u258a\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258e uninstall\u258a\u2502\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2502\u258e upsert\u258a\u2502\u258a\u258e\u2502\u258e vacuum views$\u00a0sqlite-utils\u00a0transform \u00a0CTRL+R\u00a0\u00a0Close\u00a0&\u00a0Run\u00a0\u00a0CTRL+T\u00a0Focus\u00a0Command\u00a0Tree\u00a0\u00a0CTRL+O\u00a0\u00a0Command\u00a0Info\u00a0\u00a0CTRL+S\u00a0\u00a0Search\u00a0\u00a0F1\u00a0\u00a0About\u00a0Both of these projects are very young, but off to a great start. I'm looking forward to seeing how far we can taken them.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#wrapping-up","title":"Wrapping up","text":"With previous generations of software, TUIs have required a high degree of motivation to build. That has changed with the work that we (and others) have been doing. A TUI can be a powerful and maintainable piece of software which works as a standalone project, or as a value-add to an existing project.
As a forever-technology, a TUI is a safe bet.
"},{"location":"blog/2023/06/06/to-tui-or-not-to-tui/#discord","title":"Discord","text":"Want to discuss this post with myself or other Textualize devs? Join our Discord server...
"},{"location":"css_types/","title":"CSS Types","text":"CSS types define the values that Textual CSS styles accept.
CSS types will be linked from within the styles reference in the \"Formal Syntax\" section of each style. The CSS types will be denoted by a keyword enclosed by angle brackets <
and >
.
For example, the style align-horizontal
references the CSS type <horizontal>
:
\nalign-horizontal: <horizontal>;\n"},{"location":"css_types/border/","title":"<border>","text":"
The <border>
CSS type represents a border style.
The <border>
type can take any of the following values:
ascii
A border with plus, hyphen, and vertical bar characters. blank
A blank border (reserves space for a border). dashed
Dashed line border. double
Double lined border. heavy
Heavy border. hidden
Alias for \"none\". hkey
Horizontal key-line border. inner
Thick solid border. none
Disabled border. outer
Solid border with additional space around content. round
Rounded corners. solid
Solid border. tall
Solid border with additional space top and bottom. thick
Border style that is consistently thick across edges. vkey
Vertical key-line border. wide
Solid border with additional space left and right."},{"location":"css_types/border/#border-command","title":"Border command","text":"The textual
CLI has a subcommand which will let you explore the various border types interactively, when applied to the CSS rule border
:
textual borders\n
"},{"location":"css_types/border/#examples","title":"Examples","text":""},{"location":"css_types/border/#css","title":"CSS","text":"#container {\nborder: heavy red;\n}\n#heading {\nborder-bottom: solid blue;\n}\n
"},{"location":"css_types/border/#python","title":"Python","text":"widget.styles.border = (\"heavy\", \"red\")\nwidget.styles.border_bottom = (\"solid\", \"blue\")\n
"},{"location":"css_types/color/","title":"<color>","text":"The <color>
CSS type represents a color.
Warning
Not to be confused with the color
CSS rule to set text color.
A <color>
should be in one of the formats explained in this section. A bullet point summary of the formats available follows:
red
);#F35573
);#F35573A0
);rgb(23, 78, 200)
);hsl(290, 70%, 80%)
);Textual's default themes also provide many CSS variables with colors that can be used out of the box.
"},{"location":"css_types/color/#named-colors","title":"Named colors","text":"A named color is a <name>
that Textual recognises. Below, you can find a (collapsed) list of all of the named colors that Textual recognises, along with their hexadecimal values, their RGB values, and a visual sample.
The hexadecimal RGB format starts with an octothorpe #
and is then followed by 3 or 6 hexadecimal digits: 0123456789ABCDEF
. Casing is ignored.
#RRGGBB
:RR
represents the red channel;GG
represents the green channel; andBB
represents the blue channel.#RGB
.In a 3 digit color, each channel is represented by a single digit which is duplicated when converting to the 6 digit format. For example, the color #A2F
is the same as #AA22FF
.
This is the same as the hex RGB value, but with an extra channel for the alpha component (that sets opacity).
#RRGGBBAA
, equivalent to the format #RRGGBB
with two extra digits for opacity.#RGBA
, equivalent to the format #RGB
with an extra digit for opacity.rgb
description","text":"The rgb
format description is a functional description of a color in the RGB color space. This description follows the format rgb(red, green, blue)
, where red
, green
, and blue
are decimal integers between 0 and 255. They represent the value of the channel with the same name.
For example, rgb(0, 255, 32)
is equivalent to #00FF20
.
rgba
description","text":"The rgba
format description is the same as the rgb
with an extra parameter for opacity, which should be a value between 0
and 1
.
For example, rgba(0, 255, 32, 0.5)
is the color rgb(0, 255, 32)
with 50% opacity.
hsl
description","text":"The hsl
format description is a functional description of a color in the HSL color space. This description follows the format hsl(hue, saturation, lightness)
, where
hue
is a float between 0 and 360;saturation
is a percentage between 0%
and 100%
; andlightness
is a percentage between 0%
and 100%
.For example, the color #00FF20
would be represented as hsl(128, 100%, 50%)
in the HSL color space.
hsla
description","text":"The hsla
format description is the same as the hsl
with an extra parameter for opacity, which should be a value between 0
and 1
.
For example, hsla(128, 100%, 50%, 0.5)
is the color hsl(128, 100%, 50%)
with 50% opacity.
Header {\nbackground: red; /* Color name */\n}\n.accent {\ncolor: $accent; /* Textual variable */\n}\n#footer {\ntint: hsl(300, 20%, 70%); /* HSL description */\n}\n
"},{"location":"css_types/color/#python","title":"Python","text":"In Python, rules that expect a <color>
can also accept an instance of the type Color
.
# Mimicking the CSS syntax\nwidget.styles.background = \"red\" # Color name\nwidget.styles.color = \"$accent\" # Textual variable\nwidget.styles.tint = \"hsl(300, 20%, 70%)\" # HSL description\nfrom textual.color import Color\n# Using a Color object directly...\ncolor = Color(16, 200, 45)\n# ... which can also parse the CSS syntax\ncolor = Color.parse(\"#A8F\")\n
"},{"location":"css_types/horizontal/","title":"<horizontal>","text":"The <horizontal>
CSS type represents a position along the horizontal axis.
The <horizontal>
type can take any of the following values:
center
Aligns in the center of the horizontal axis. left
(default) Aligns on the left of the horizontal axis. right
Aligns on the right of the horizontal axis."},{"location":"css_types/horizontal/#examples","title":"Examples","text":""},{"location":"css_types/horizontal/#css","title":"CSS","text":".container {\nalign-horizontal: right;\n}\n
"},{"location":"css_types/horizontal/#python","title":"Python","text":"widget.styles.align_horizontal = \"right\"\n
"},{"location":"css_types/integer/","title":"<integer>","text":"The <integer>
CSS type represents an integer number.
An <integer>
is any valid integer number like -10
or 42
.
Note
Some CSS rules may expect an <integer>
within certain bounds. If that is the case, it will be noted in that rule.
.classname {\noffset: 10 -20\n}\n
"},{"location":"css_types/integer/#python","title":"Python","text":"In Python, a rule that expects a CSS type <integer>
will expect a value of the type int
:
widget.styles.offset = (10, -20)\n
"},{"location":"css_types/name/","title":"<name>","text":"The <name>
type represents a sequence of characters that identifies something.
A <name>
is any non-empty sequence of characters:
a-z
, A-Z
, or underscore _
; anda-zA-Z
, digits 0-9
, underscores _
, and hiphens -
.Screen {\nlayers: onlyLetters Letters-and-hiphens _lead-under letters-1-digit;\n}\n
"},{"location":"css_types/name/#python","title":"Python","text":"widget.styles.layers = \"onlyLetters Letters-and-hiphens _lead-under letters-1-digit\"\n
"},{"location":"css_types/number/","title":"<number>","text":"The <number>
CSS type represents a real number, which can be an integer or a number with a decimal part (akin to a float
in Python).
A <number>
is an <integer>
, optionally followed by the decimal point .
and a decimal part composed of one or more digits.
Grid {\ngrid-size: 3 6 /* Integers are numbers */\n}\n.translucid {\nopacity: 0.5 /* Numbers can have a decimal part */\n}\n
"},{"location":"css_types/number/#python","title":"Python","text":"In Python, a rule that expects a CSS type <number>
will accept an int
or a float
:
widget.styles.grid_size = (3, 6) # Integers are numbers\nwidget.styles.opacity = 0.5 # Numbers can have a decimal part\n
"},{"location":"css_types/overflow/","title":"<overflow>","text":"The <overflow>
CSS type represents overflow modes.
The <overflow>
type can take any of the following values:
auto
Determine overflow mode automatically. hidden
Don't overflow. scroll
Allow overflowing."},{"location":"css_types/overflow/#examples","title":"Examples","text":""},{"location":"css_types/overflow/#css","title":"CSS","text":"#container {\noverflow-y: hidden; /* Don't overflow */\n}\n
"},{"location":"css_types/overflow/#python","title":"Python","text":"widget.styles.overflow_y = \"hidden\" # Don't overflow\n
"},{"location":"css_types/percentage/","title":"<percentage>","text":"The <percentage>
CSS type represents a percentage value. It is often used to represent values that are relative to the parent's values.
Warning
Not to be confused with the <scalar>
type.
A <percentage>
is a <number>
followed by the percent sign %
(without spaces). Some rules may clamp the values between 0%
and 100%
.
#footer {\n/* Integer followed by % */\ncolor: red 70%;\n/* The number can be negative/decimal, although that may not make sense */\noffset: -30% 12.5%;\n}\n
"},{"location":"css_types/percentage/#python","title":"Python","text":"# Integer followed by %\nwidget.styles.color = \"red 70%\"\n# The number can be negative/decimal, although that may not make sense\nwidget.styles.offset = (\"-30%\", \"12.5%\")\n
"},{"location":"css_types/scalar/","title":"<scalar>","text":"The <scalar>
CSS type represents a length. It can be a <number>
and a unit, or the special value auto
. It is used to represent lengths, for example in the width
and height
rules.
Warning
Not to be confused with the <number>
or <percentage>
types.
A <scalar>
can be any of the following:
10
);1fr
);50%
);25w
/75h
);25vw
/75vh
); orauto
to compute the optimal size to fit without scrolling.A complete reference table and detailed explanations follow. You can skip to the examples.
Unit symbol Unit Example Description\"\"
Cell 10
Number of cells (rows or columns). \"fr\"
Fraction 1fr
Specifies the proportion of space the widget should occupy. \"%\"
Percent 75%
Length relative to the container widget. \"w\"
Width 25w
Percentage relative to the width of the container widget. \"h\"
Height 75h
Percentage relative to the height of the container widget. \"vw\"
Viewport width 25vw
Percentage relative to the viewport width. \"vh\"
Viewport height 75vh
Percentage relative to the viewport height. - Auto auto
Tries to compute the optimal size to fit without scrolling."},{"location":"css_types/scalar/#cell","title":"Cell","text":"The number of cells is the only unit for a scalar that is absolute. This can be an integer or a float but floats are truncated to integers.
If used to specify a horizontal length, it corresponds to the number of columns. For example, in width: 15
, this sets the width of a widget to be equal to 15 cells, which translates to 15 columns.
If used to specify a vertical length, it corresponds to the number of lines. For example, in height: 10
, this sets the height of a widget to be equal to 10 cells, which translates to 10 lines.
The unit fraction is used to represent proportional sizes.
For example, if two widgets are side by side and one has width: 1fr
and the other has width: 3fr
, the second one will be three times as wide as the first one.
The percent unit matches a <percentage>
and is used to specify a total length relative to the space made available by the container widget.
If used to specify a horizontal length, it will be relative to the width of the container. For example, width: 50%
sets the width of a widget to 50% of the width of its container.
If used to specify a vertical length, it will be relative to the height of the container. For example, height: 50%
sets the height of a widget to 50% of the height of its container.
The width unit is similar to the percent unit, except it sets the percentage to be relative to the width of the container.
For example, width: 25w
sets the width of a widget to 25% of the width of its container and height: 25w
sets the height of a widget to 25% of the width of its container. So, if the container has a width of 100 cells, the width and the height of the child widget will be of 25 cells.
The height unit is similar to the percent unit, except it sets the percentage to be relative to the height of the container.
For example, height: 75h
sets the height of a widget to 75% of the height of its container and width: 75h
sets the width of a widget to 75% of the height of its container. So, if the container has a height of 100 cells, the width and the height of the child widget will be of 75 cells.
This is the same as the width unit, except that it is relative to the width of the viewport instead of the width of the immediate container. The width of the viewport is the width of the terminal minus the widths of widgets that are docked left or right.
For example, width: 25vw
will try to set the width of a widget to be 25% of the viewport width, regardless of the widths of its containers.
This is the same as the height unit, except that it is relative to the height of the viewport instead of the height of the immediate container. The height of the viewport is the height of the terminal minus the heights of widgets that are docked top or bottom.
For example, height: 75vh
will try to set the height of a widget to be 75% of the viewport height, regardless of the height of its containers.
This special value will try to calculate the optimal size to fit the contents of the widget without scrolling.
For example, if its container is big enough, a label with width: auto
will be just as wide as its text.
Horizontal {\nwidth: 60; /* 60 cells */\nheight: 1fr; /* proportional size of 1 */\n}\n
"},{"location":"css_types/scalar/#python","title":"Python","text":"widget.styles.width = 16 # Cell unit can be specified with an int/float\nwidget.styles.height = \"1fr\" # proportional size of 1\n
"},{"location":"css_types/text_align/","title":"<text-align>","text":"The <text-align>
CSS type represents alignments that can be applied to text.
Warning
Not to be confused with the text-align
CSS rule that sets the alignment of text in a widget.
A <text-align>
can be any of the following values:
center
Center alignment. end
Alias for right
. justify
Text is justified inside the widget. left
Left alignment. right
Right alignment. start
Alias for left
. Tip
The meanings of start
and end
will likely change when RTL languages become supported by Textual.
Label {\ntext-align: justify;\n}\n
"},{"location":"css_types/text_align/#python","title":"Python","text":"widget.styles.text_align = \"justify\"\n
"},{"location":"css_types/text_style/","title":"<text-style>","text":"The <text-style>
CSS type represents styles that can be applied to text.
Warning
Not to be confused with the text-style
CSS rule that sets the style of text in a widget.
A <text-style>
can be the value none
for plain text with no styling, or any space-separated combination of the following values:
bold
Bold text. italic
Italic text. reverse
Reverse video text (foreground and background colors reversed). strike
Strikethrough text. underline
Underline text."},{"location":"css_types/text_style/#examples","title":"Examples","text":""},{"location":"css_types/text_style/#css","title":"CSS","text":"#label1 {\n/* You can specify any value by itself. */\nrule: strike;\n}\n#label2 {\n/* You can also combine multiple values. */\nrule: strike bold italic reverse;\n}\n
"},{"location":"css_types/text_style/#python","title":"Python","text":"# You can specify any value by itself\nwidget.styles.text_style = \"strike\"\n# You can also combine multiple values\nwidget.styles.text_style = \"strike bold italic reverse\n
"},{"location":"css_types/vertical/","title":"<vertical>","text":"The <vertical>
CSS type represents a position along the vertical axis.
The <vertical>
type can take any of the following values:
bottom
Aligns at the bottom of the vertical axis. middle
Aligns in the middle of the vertical axis. top
(default) Aligns at the top of the vertical axis."},{"location":"css_types/vertical/#examples","title":"Examples","text":""},{"location":"css_types/vertical/#css","title":"CSS","text":".container {\nalign-vertical: top;\n}\n
"},{"location":"css_types/vertical/#python","title":"Python","text":"widget.styles.align_vertical = \"top\"\n
"},{"location":"events/","title":"Events","text":"A reference to Textual events.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"events/blur/","title":"Blur","text":"The Blur
event is sent to a widget when it loses focus.
No other attributes
"},{"location":"events/blur/#code","title":"Code","text":""},{"location":"events/blur/#textual.events.Blur","title":"textual.events.Blurclass
","text":" Bases: Event
Sent when a widget is blurred (un-focussed).
The Click
event is sent to a widget when the user clicks a mouse button.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/click/#code","title":"Code","text":""},{"location":"events/click/#textual.events.Click","title":"textual.events.Click class
","text":" Bases: MouseEvent
Sent when a widget is clicked.
The DescendantBlur
event is sent to a widget when one of its children loses focus.
No other attributes
"},{"location":"events/descendant_blur/#code","title":"Code","text":""},{"location":"events/descendant_blur/#textual.events.DescendantBlur","title":"textual.events.DescendantBlurclass
","text":" Bases: Event
Sent when a child widget is blurred.
property
","text":"control: Widget\n
The widget that was blurred (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was blurred.
"},{"location":"events/descendant_blur/#see-also","title":"See also","text":"The DescendantFocus
event is sent to a widget when one of its descendants receives focus.
No other attributes
"},{"location":"events/descendant_focus/#code","title":"Code","text":""},{"location":"events/descendant_focus/#textual.events.DescendantFocus","title":"textual.events.DescendantFocusclass
","text":" Bases: Event
Sent when a child widget is focussed.
property
","text":"control: Widget\n
The widget that was focused (alias of widget
).
instance-attribute
","text":"widget: Widget\n
The widget that was focused.
"},{"location":"events/descendant_focus/#see-also","title":"See also","text":"The Enter
event is sent to a widget when the mouse pointer first moves over a widget.
No other attributes
"},{"location":"events/enter/#code","title":"Code","text":""},{"location":"events/enter/#textual.events.Enter","title":"textual.events.Enterclass
","text":" Bases: Event
Sent when the mouse is moved over a widget.
The Focus
event is sent to a widget when it receives input focus.
No other attributes
"},{"location":"events/focus/#code","title":"Code","text":""},{"location":"events/focus/#textual.events.Focus","title":"textual.events.Focusclass
","text":" Bases: Event
Sent when a widget is focussed.
The Hide
event is sent to a widget when it is hidden from view.
No additional attributes
"},{"location":"events/hide/#code","title":"Code","text":""},{"location":"events/hide/#textual.events.Hide","title":"textual.events.Hideclass
","text":" Bases: Event
Sent when a widget has been hidden.
A widget may be hidden by setting its visible
flag to False
, if it is no longer in a layout, or if it has been offset beyond the edges of the terminal.
The Key
event is sent to a widget when the user presses a key on the keyboard.
key
str Name of the key that was pressed. char
str or None The character that was pressed, or None it isn't printable."},{"location":"events/key/#code","title":"Code","text":""},{"location":"events/key/#textual.events.Key","title":"textual.events.Key class
","text":"def __init__(self, key, character):\n
Bases: InputEvent
Sent when the user hits a key on the keyboard.
key
str
The key that was pressed.
requiredcharacter
str | None
A printable character or None
if it is not printable.
aliases
list[str]
The aliases for the key, including the key itself.
"},{"location":"events/key/#textual.events.Key.is_printable","title":"is_printableproperty
","text":"is_printable: bool\n
Check if the key is printable (produces a unicode character).
Returns Type Descriptionbool
True if the key is printable.
"},{"location":"events/key/#textual.events.Key.name","title":"nameproperty
","text":"name: str\n
Name of a key suitable for use as a Python identifier.
"},{"location":"events/key/#textual.events.Key.name_aliases","title":"name_aliasesproperty
","text":"name_aliases: list[str]\n
The corresponding name for every alias in aliases
list.
The Leave
event is sent to a widget when the mouse pointer moves off a widget.
No other attributes
"},{"location":"events/leave/#code","title":"Code","text":""},{"location":"events/leave/#textual.events.Leave","title":"textual.events.Leaveclass
","text":" Bases: Event
Sent when the mouse is moved away from a widget.
The Load
event is sent to the app prior to switching the terminal to application mode.
The load event is typically used to do any setup actions required by the app that don't change the display.
No additional attributes
"},{"location":"events/load/#code","title":"Code","text":""},{"location":"events/load/#textual.events.Load","title":"textual.events.Loadclass
","text":" Bases: Event
Sent when the App is running but before the terminal is in application mode.
Use this event to run any set up that doesn't require any visuals such as loading configuration and binding keys.
The Mount
event is sent to a widget and Application when it is first mounted.
The mount event is typically used to set the initial state of a widget or to add new children widgets.
No additional attributes
"},{"location":"events/mount/#code","title":"Code","text":""},{"location":"events/mount/#textual.events.Mount","title":"textual.events.Mountclass
","text":" Bases: Event
Sent when a widget is mounted and may receive messages.
The MouseCapture
event is sent to a widget when it is capturing mouse events from outside of its borders on the screen.
mouse_position
Offset Mouse coordinates when the mouse was captured"},{"location":"events/mouse_capture/#code","title":"Code","text":""},{"location":"events/mouse_capture/#textual.events.MouseCapture","title":"textual.events.MouseCapture class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Sent when the mouse has been captured.
When a mouse has been captured, all further mouse events will be sent to the capturing widget.
Parameters Name Type Description Defaultmouse_position
Offset
The position of the mouse when captured.
required"},{"location":"events/mouse_down/","title":"MouseDown","text":"The MouseDown
event is sent to a widget when a mouse button is pressed.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_down/#code","title":"Code","text":""},{"location":"events/mouse_down/#textual.events.MouseDown","title":"textual.events.MouseDown class
","text":" Bases: MouseEvent
Sent when a mouse button is pressed.
The MouseMove
event is sent to a widget when the mouse pointer is moved over a widget.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_move/#code","title":"Code","text":""},{"location":"events/mouse_move/#textual.events.MouseMove","title":"textual.events.MouseMove class
","text":" Bases: MouseEvent
Sent when the mouse cursor moves.
The MouseRelease
event is sent to a widget when it is no longer receiving mouse events outside of its borders.
mouse_position
Offset Mouse coordinates when the mouse was released"},{"location":"events/mouse_release/#code","title":"Code","text":""},{"location":"events/mouse_release/#textual.events.MouseRelease","title":"textual.events.MouseRelease class
","text":"def __init__(self, mouse_position):\n
Bases: Event
Mouse has been released.
mouse_position
Offset
The position of the mouse when released.
required"},{"location":"events/mouse_scroll_down/","title":"MouseScrollDown","text":"The MouseScrollDown
event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved down.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget"},{"location":"events/mouse_scroll_down/#code","title":"Code","text":""},{"location":"events/mouse_scroll_down/#textual.events.MouseScrollDown","title":"textual.events.MouseScrollDown class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled down.
The MouseScrollUp
event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved up.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget"},{"location":"events/mouse_scroll_up/#code","title":"Code","text":""},{"location":"events/mouse_scroll_up/#textual.events.MouseScrollUp","title":"textual.events.MouseScrollUp class
","text":" Bases: MouseEvent
Sent when the mouse wheel is scrolled up.
The MouseUp
event is sent to a widget when the user releases a mouse button.
x
int Mouse x coordinate, relative to Widget y
int Mouse y coordinate, relative to Widget delta_x
int Change in x since last mouse event delta_y
int Change in y since last mouse event button
int Index of mouse button shift
bool Shift key pressed if True meta
bool Meta key pressed if True ctrl
bool Ctrl key pressed if True screen_x
int Mouse x coordinate relative to the screen screen_y
int Mouse y coordinate relative to the screen"},{"location":"events/mouse_up/#code","title":"Code","text":""},{"location":"events/mouse_up/#textual.events.MouseUp","title":"textual.events.MouseUp class
","text":" Bases: MouseEvent
Sent when a mouse button is released.
The Paste
event is sent to a widget when the user pastes text.
text
str The text that was pasted"},{"location":"events/paste/#code","title":"Code","text":""},{"location":"events/paste/#textual.events.Paste","title":"textual.events.Paste class
","text":"def __init__(self, text):\n
Bases: Event
Event containing text that was pasted into the Textual application. This event will only appear when running in a terminal emulator that supports bracketed paste mode. Textual will enable bracketed pastes when an app starts, and disable it when the app shuts down.
text
str
The text that has been pasted.
required"},{"location":"events/resize/","title":"Resize","text":"The Resize
event is sent to a widget when its size changes and when it is first made visible.
size
Size The new size of the Widget virtual_size
Size The virtual size (scrollable area) of the Widget container_size
Size The size of the container (parent widget)"},{"location":"events/resize/#code","title":"Code","text":""},{"location":"events/resize/#textual.events.Resize","title":"textual.events.Resize class
","text":"def __init__(self, size, virtual_size, container_size=None):\n
Bases: Event
Sent when the app or widget has been resized.
size
Size
The new size of the Widget.
requiredvirtual_size
Size
The virtual size (scrollable size) of the Widget.
requiredcontainer_size
Size | None
The size of the Widget's container widget.
None
"},{"location":"events/screen_resume/","title":"ScreenResume","text":"The ScreenResume
event is sent to a Screen when it becomes current.
No other attributes
"},{"location":"events/screen_resume/#code","title":"Code","text":""},{"location":"events/screen_resume/#textual.events.ScreenResume","title":"textual.events.ScreenResumeclass
","text":" Bases: Event
Sent to screen that has been made active.
The ScreenSuspend
event is sent to a Screen when it is replaced by another screen.
No other attributes
"},{"location":"events/screen_suspend/#code","title":"Code","text":""},{"location":"events/screen_suspend/#textual.events.ScreenSuspend","title":"textual.events.ScreenSuspendclass
","text":" Bases: Event
Sent to screen when it is no longer active.
The Show
event is sent to a widget when it becomes visible.
No additional attributes
"},{"location":"events/show/#code","title":"Code","text":""},{"location":"events/show/#textual.events.Show","title":"textual.events.Showclass
","text":" Bases: Event
Sent when a widget has become visible.
These are the examples from the documentation, used to generate screenshots.
You can run them with the textual CLI.
For example:
textual run text_style.py\n
"},{"location":"guide/","title":"Guide","text":"Welcome to the Textual Guide! An in-depth reference on how to build apps with Textual.
"},{"location":"guide/#example-code","title":"Example code","text":"Most of the code in this guide is fully working\u2014you could cut and paste it if you wanted to.
Although it is probably easier to check out the Textual repository and navigate to the docs/examples/guide
directory and run the examples from there.
Textual uses CSS to apply style to widgets. If you have any exposure to web development you will have encountered CSS, but don't worry if you haven't: this chapter will get you up to speed.
VSCode User?
The official Textual CSS extension adds syntax highlighting for both external files and inline CSS.
"},{"location":"guide/CSS/#stylesheets","title":"Stylesheets","text":"CSS stands for Cascading Stylesheet. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies styles to widgets, but otherwise it is the same idea.
Let's look at some Textual CSS.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
This is an example of a CSS rule set. There may be many such sections in any given CSS file.
Let's break this CSS code down a bit.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
The first line is a selector which tells Textual which widget(s) to modify. In the above example, the styles will be applied to a widget defined by the Python class Header
.
Header {\ndock: top;\nheight: 3;\ncontent-align: center middle;\nbackground: blue;\ncolor: white;\n}\n
The lines inside the curly braces contains CSS rules, which consist of a rule name and rule value separated by a colon and ending in a semicolon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semicolons.
The first rule in the above example reads \"dock: top;\"
. The rule name is dock
which tells Textual to place the widget on an edge of the screen. The text after the colon is top
which tells Textual to dock to the top of the screen. Other valid values for dock
are \"right\", \"bottom\", or \"left\"; but \"top\" is most appropriate for a header.
The DOM, or Document Object Model, is a term borrowed from the web world. Textual doesn't use documents but the term has stuck. In Textual CSS, the DOM is an arrangement of widgets you can visualize as a tree-like structure.
Some widgets contain other widgets: for instance, a list control widget will likely also have item widgets, or a dialog widget may contain button widgets. These child widgets form the branches of the tree.
Let's look at a trivial Textual app.
dom1.pyOutputfrom textual.app import App\nclass ExampleApp(App):\npass\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
ExampleApp
This example creates an instance of ExampleApp
, which will implicitly create a Screen
object. In DOM terms, the Screen
is a child of ExampleApp
.
With the above example, the DOM will look like the following:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nM1Ya0/jOFx1MDAxNP3Or6i6X3YlXGKOY8fxSKtcdTAwMTXPpSywo1x1MDAwMVxyj9VcYrmJaT3Na1x1MDAxMpfHIP77XqdMXHUwMDFlbVxiZVx1MDAxN0ZEUZv4OtfX1+fc4+R+pdfr67tU9j/0+vLWXHUwMDE3oVxuMnHTXzXt1zLLVVx1MDAxMoNcdFx1MDAxN/d5Ms38oudY6zT/sL5cdTAwMWWJbFwidVx1MDAxYVxuX1rXKp+KMNfTQCWWn0TrSsso/8P8XHUwMDFliUj+niZRoDOrXHUwMDFhZE1cdTAwMDZKJ9lsLFx1MDAxOcpIxjpcdTAwMDfv/8B9r3df/NaiXHUwMDBilIiSOCi6XHUwMDE3hlp4njvfepTERaiUIVx1MDAwN3PqkbKDyrdhMC1cdTAwMDOwXkHAsrKYpn56sbN1pD45eiNcdTAwMWKyfNP5Nvy6d1WNeqXC8FjfhbM8XGJ/PM1kZc11lkzkqVxu9Fx1MDAxOOz2XFx7+VxcnkBcbqqnsmQ6XHUwMDFhxzLPXHUwMDFizySp8JW+M21cYpWtXCJcdTAwMWVcdTAwMTU+qpZbk1x1MDAwMeJamHmO5zpcdTAwMGV1XHUwMDEwqc23cECY5VLsXHUwMDEwh9G5mLaSXHUwMDEw1lx1MDAwMGL6XHUwMDA1XHUwMDE1R1x1MDAxNdVQ+JNcdTAwMTGEXHUwMDE2XHUwMDA3VVx1MDAxZlx1MDAwZvvcrs335sdMa1x1MDAwM46lXHUwMDFhjbVpxNjyXHUwMDEwcT1GZ75r+ZBF/m3P5pRcdTAwMTKMcWkxI6aDoFx1MDAwMMKX+fyNRZY+5qmfm5tatCbQnXlcdTAwMTTVkVRbY+dcIkv5vlx1MDAxYVxmvk7GfyX88CxcdTAwMWRcdTAwMGZcdTAwMGVLX1xy2Gl5q/ul4WG1y+2Ze1x1MDAxMm1cdTAwMGUv7evp9v6BPls7+8jRfrtbkWXJzfN+XHUwMDFiUawuO5HK7eNVlchpXHUwMDFhiFx1MDAxOfZt10XE5sjjXHUwMDBl4aU9VPFcdTAwMDSM8TRcZqu2xJ9UdFmpxbtA0kacdYba5CmG2thQXHUwMDE0XHUwMDEw4i1N0e7le69cdTAwMTSldidFObeAXG6GLP+HoTpcdTAwMTNxnopcZljQwlLWxlK+wErmeraDXFxcdTAwMWK9Piu7kMihOr1cdTAwMDSJ1YInsT5W31x1MDAwYjS5XHUwMDE2hWKEsIsw41x1MDAxY1HW6LUrXCJcdTAwMTXeNdawgCxEvnMrojSUXHUwMDFiafrrb/VcdTAwMTTnXHUwMDEyXCIpXFyTxjNcdTAwMWKhXHUwMDFhXHUwMDE5aPd9mJvMXHUwMDFhqNdcbkSu7Fx1MDAxMKkgXGJrXGL0IVx1MDAxMFx1MDAwMT6zwTKCk2RqpGJcdTAwMTGetMXZScZM+nqGxVx1MDAxNkZS+qRmYlx1MDAwNCDkUJXdpVx1MDAxOXn+PdGXXyfDk+PRwblzQsefkvPLd89IXHUwMDE3W8hlhHheXHUwMDFiI1x1MDAxZNuxXHUwMDEwI9h+U0pSukhJj0GlmFx1MDAxM+tHalx1MDAwMqRcdTAwMTHFXHUwMDFlcV+fml3KXHUwMDE27MfnQ0rOXHUwMDBmtlx1MDAwMrw33tldu9zDn9+jYM78nu5/vr45INuHXHUwMDA3XHUwMDE5XHL+vMNTTLbdV/CLT4PB3u7EP/Q2iH1cdTAwMTKFf+/EXHUwMDE3ozdcdTAwMTX49sS/QOCZkVZe7a/eSOBcdPXmW3+UXHUwMDEzwinUYUKX34J3o+3dVlx1MDAxM9ZZTVxisZhdaNzbXHUwMDE1XHUwMDEz0lJMsDNfREBcdTAwMWFhXHUwMDE3wp2fKu8vx2GbvGPUaO2Q82M/kzJ+SspZo/+rSfkzMjgv5WWMnZSbVZJcdTAwMTbOMfxcdTAwMTTlQCZAv+FcXF7Bu0vxO+Wc43BcdTAwMGJe7lx1MDAxMXNaOYdcdTAwMTm1XFzOjYJcdTAwMTNujjdjXHUwMDFlslxid5vkLlx06Fx1MDAxMIsz7FJcdTAwMTcvyLlcdTAwMDebXuDGf9loXHUwMDE3wf1sJuZaZHpTxYGKR2CslFxm2OhPzbhryEKO7VLCoVx1MDAxNlKOXHTyylmb6YnU7D0tXHUwMDAyckBcdTAwMWPYg1x1MDAxYYxWr5+98kNQ19b4sXMpqX1cdTAwMTlcdTAwMDfPXHUwMDA2hThUX8Tg1Vx1MDAwME7KmLdcdTAwMTBcdTAwMTW24LWh2HVcdTAwMTXfKmyHPVx1MDAxNVY7zVx1MDAxN8JcbkWut5IoUlx1MDAxYdL/MVGxnk9zkc9ccsPvsVx1MDAxNMG8XHUwMDE1plW3zVx1MDAxN4LUeGzu3KqrXsWU4qa8/rLa2nttXHUwMDExweaoYbfysFL/NzuQwmdfpOmxXHUwMDA2pJVrXHUwMDAwYFbBY+GuJta/VvJms+Xb0lVxmDRcdTAwMTYpNCVHmundP6w8/Fx1MDAwYlxiYlx1MDAxObwifQ== ExampleApp()Screen()This doesn't look much like a tree yet. Let's add a header and a footer to this application, which will create more branches of the tree:
dom2.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Footer\nclass ExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
ExampleApp \u2b58ExampleApp
With a header and a footer widget the DOM looks like this:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1aa0/bSFx1MDAxNP3Or0DZL7tS4877UWm1glx1MDAxNpZ3aWFcdTAwMDNlVVWuPSReP2s7QKj633dsgu28XHUwMDFjXHUwMDEzXHUwMDEyNpXWQiGZmdy5nrnnzLk3/r6xudlKXHUwMDA3kWq92WypO8v0XHUwMDFjOzZvW6+y9lx1MDAxYlx1MDAxNSdOXHUwMDE46C6Uf07CfmzlI3tpXHUwMDFhJW9ev/bN2FVp5JmWMm6cpG96Sdq3ndCwQv+1kyo/+SN7PTF99XtcdTAwMTT6dlx1MDAxYVx1MDAxYuUkbWU7aVx1MDAxOD/MpTzlqyBNtPW/9efNze/5a8U72zH9MLDz4XlHxT0hx1tPwiB3XHUwMDE1Ulx1MDAwNLEkklx1MDAxNFx1MDAwM5zknZ4sVbbuvdZcdTAwMGWrsidralxyTi//7Jn/qItLwY5Odq7hh17nvJz12vG8s3TgPayDafX6sSp7kzRcdTAwMGVddeHYaS+bfKy9+F5cdTAwMTLqJSi/XHUwMDE1h/1uL1BJMvKdMDItJ1x1MDAxZOg2XG6KRjPo5ibKlrtsXHUwMDAxIDVcdTAwMTBcdTAwMTdYMIwpXHUwMDA2RJS3m31fSINRhFx05nTMo7ehp3dAe/RcdTAwMGLIr9Knr6bldrVjgV2OXHUwMDExyJKwcre3j/dZma+nnG4vzVx1MDAxYVx1MDAxMTJcdTAwMDQgTHD6YLuyXHUwMDFhKl99yCDjknAhip5sxmjfzsPg8/jq9cw4XHUwMDFhrlIryT5UvM1cdTAwMWPdXHUwMDE5j6FqXHUwMDFjVXbY/bgtrlx1MDAwZTBoR9euc9x7f+6eqm+FrZGgS9Vd2io6fryqM1x1MDAwYjto76vf+3JLOsHX+Pro+vh+f3u6WTOOw9umdpfu7sjoV00nLM1cdTAwMGXflfvTj2wzXHUwMDFkbilcdTAwMDNcdTAwMDTpXHUwMDAwpITgot9zXHUwMDAyV3dcdTAwMDZ9zyvbQsstMbhR8XdcdTAwMDL5I35WYVx1MDAwZsFM2DMuJEKYoca4r1/mNcU9XHUwMDAydbiHXHUwMDA0XHUwMDFhlOZcdTAwMTB8XHUwMDBl7tPYXGaSyIw1tqZgn0/Dvlx1MDAxY8c6oYBwKjFaPtSXXHUwMDE5h+V2h0F65tw/xJJBNcNcdTAwMDHEXHUwMDAw4lJcdTAwMDLKR0btmr7jXHJGdjBcdTAwMGZY7fnOnelHntqKol9/q65worQnuWky8p0tz+lmgd2y9L2peCTmU0efm8VcdTAwMDDfsW2vXHUwMDEyf5Z2xNQ24/0mZ1hcdTAwMTg7XScwvfNpftZCMVZW+lx1MDAxMIpT8EhcdTAwMTmcjcd87Vx1MDAwNGON8eh9ROdcdTAwMDcn51dXXHUwMDFjfGA+3btcbkkvXXc8YmhcdTAwMDDGXHRcdTAwMTFiXHUwMDFhXHUwMDFlXHUwMDExpVx1MDAwNuBcdTAwMDTBlVx1MDAwMpLSSUBcbq55YkxcdTAwMDA8XHUwMDFlwlx1MDAxNHLOKV9cdTAwMDEy6061I6ftfzlcdTAwMWFcdTAwMWO+9c47g0/3XHUwMDFkeb69dbK+h/DFQefm9oi8Oz6Kqf3nXHUwMDAw9Vx1MDAxMXnHlmBcdTAwMTdd2Pt7u651LLZcYjz3vfc7wVV3XHR2l76880TD9Fx0XHUwMDFiekturtt+6rZcdTAwMDdcdTAwMWbIXHUwMDE3c0dYd+q47y9hXHUwMDE1tlx1MDAwZb+ddtPww5dTR4pcdTAwMDO307uEnzrN7DZcdTAwMTE5XHUwMDE4gVx1MDAxMjUrXHUwMDEyOYTNXHUwMDE2OZhcbkkolOWIeaRaXHUwMDFmXHUwMDE260qqrJZUXHUwMDA1MzhcdTAwMDTymclNPaeSKZyKSmHxyKVQICQpYCtIaJZcdTAwMTmI01RcdTAwMGVcdTAwMDIjrTWq5syKlVxuZilcdTAwMWE+Mn5pimaOXHUwMDFhXHUwMDE4VzSFj7WYe8D8XHUwMDE00HExXHUwMDEzc1x1MDAxMFx1MDAxMEq0lm1eUKg/ktZcdTAwMTNzXHUwMDE4XGJDUlx1MDAwMTiejjnIXHImZSZkiMyulSFcdTAwMGZcdTAwMThEslFwXHUwMDE3XHUwMDAwxMSQXHUwMDFjMcrQpKrRniGh4bhcdTAwMDBcdTAwMTJz71x1MDAxNkWiwIwvgsQkNeN021x0bCfo6s7yLNNotPrZvG1gXHUwMDAwrNVcdTAwMWGRmlxmqURcdTAwMDSI4raz2zOjbGNcckKylIdSJlx0YkRWRlxmS2x1XHUwMDE5wnBwcai2VGDPdVxuSE2/gOtcZkn/UV5ip/Bcblx1MDAxOTp7ytVnXlx1MDAwN4KYz3JrOswn3PLMJH1cdTAwMWL6vpPq5T9ccp0gXHUwMDFkX+Z8PbcyfPeUaY/36tuq9o1cdTAwMTNBlFlcdTAwMWNVsOW7zVx1MDAxMir5h+L951dTR7cnQzi7KsFbWtio/l8oXHUwMDA3g1x1MDAwMM1OwpD2g2KEmlx1MDAxN0VOXHUwMDBmXHUwMDA3b4OrvnQv/Y8n9uG9+5f7z81/y1061uaQXHUwMDE31OSFMIJcdTAwMDRcdMr1a4XNM1x1MDAwM4RcdTAwMTBDY4Q9dldy0v84XHUwMDE304QlKIa0dOhFUjHn6F03OtlNXHUwMDBmLv0t92T70N/12zPU9/+p2NPtrmh5l252XoY3fcKG3j4jw3skxdnHblx1MDAwNqfKT0ArysQkJuOtXHUwMDE1ZoX64GWoeXmrfvvWlVkhrmVWzlxmJiBmkIFVM2uzjFxmccZYRqsvmpA9OVx1MDAxZZ+XkO1pXHUwMDE1o+JcdTAwMTdOyOYog/GErPDxXHUwMDE50oaBurSMIy2NafNSiJfsXHUwMDFlmGfx9o06oJ3j+z3w7XhcdTAwMWKsO1x1MDAwMDFhXHUwMDA2gZzAXHUwMDFjX9mvXHUwMDFiY9KGXHUwMDFiWId8MWBdlI3kOkmQ1VrWiyib40+nZF/sWIdcdTAwMDNcdTAwMTd+ci/aN1F/XHUwMDAw/1c2y1I2K1ren8XsPME0fcKG3q60dI04rXLNilx1MDAwNFx1MDAxM6SYjTdcdTAwMTeEzTHQvoAnKKb6/VtXwqawlrC5NCjEWFx1MDAwZVx1MDAwNdVcblx0u2FcdDvLPzlcdTAwMDGlmy8jmJ5cdTAwMTiPz1x1MDAxM0y7YZi+uGCaozfGXHUwMDA1U+FjLfRmVrBcdTAwMTma/UicoEBcdTAwMTJcZnDzXHUwMDEydn32tq7QXHUwMDAzwFx1MDAxMFRcYplVQ1x1MDAwNVx1MDAxNmhcdTAwMDR5WEslwXV+wIfIw6uDXHUwMDFlXHUwMDAyhqSMS0klg1JCMYlEgVxyqZNIJFx1MDAxONY+M4nGgUmAhFx1MDAxNEm0XHUwMDAwMJ9R0F48k5ld0G5Q8C2PuWqlmVBcdTAwMDBcdTAwMDGlXHUwMDAy6pVgXGLDyqjH6jdFXHUwMDE0wmH2KTBcdTAwMWVcdTAwMGWYX89cdTAwMWXxqT61XHUwMDE59YkhoONcZnFJXHUwMDAw1uGEJnyCyOA6USZMZ8Ukq1x1MDAxM6BcdKd+qmr27GDOrokwLu1tVP8/mc8gwLNcdI3RXGbmXFw011x1MDAxMvXqal1cdFxyXHUwMDBiXHUwMDAzScFcdTAwMDVikmnJUFx1MDAxZVRcdTAwMGaEpqVcdTAwMDTCNPuVnFxiQenqXGJNYoNBiHQ8M4xw5cHeks6kgVx1MDAwNOeSUo6ZoHLy2V/B9Z2QhTLCZ/HZokJj2XxcdTAwMDZcZk1j2lx1MDAxYlx1MDAwMfR2MSRk+ZxiwVx1MDAxZNyA+TOTXHUwMDEwPGzognxWrzxGfKJASL1OTGtcdTAwMDRcbinhXHUwMDEzLlx0gzJ9XGZcdTAwMDGYXHUwMDFmm1iIn5rNZlx1MDAwNXJ2TYTwLCrbXHUwMDE4mm+ZUXSW6ngrtkKHtGNcdTAwMGbVaXmPrVx1MDAxYkfdbk95vP46vzLBl69mxkIqu9PvPzZ+/Fx1MDAwYlx0sVx1MDAwYuIifQ== ExampleApp()Screen()Header()Footer()Note
We've simplified the above example somewhat. Both the Header and Footer widgets contain children of their own. When building an app with pre-built widgets you rarely need to know how they are constructed unless you plan on changing the styles of individual components.
Both Header and Footer are children of the Screen object.
To further explore the DOM, we're going to build a simple dialog with a question and two buttons. To do this we're going to import and use a few more builtin widgets:
textual.layout.Container
For our top-level dialog.textual.layout.Horizontal
To arrange widgets left to right.textual.widgets.Static
For simple content.textual.widgets.Button
For a clickable button.from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal\nfrom textual.widgets import Button, Footer, Header, Static\nQUESTION = \"Do you want to learn about Textual CSS?\"\nclass ExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nyield Container(\nStatic(QUESTION, classes=\"question\"),\nHorizontal(\nButton(\"Yes\", variant=\"success\"),\nButton(\"No\", variant=\"error\"),\nclasses=\"buttons\",\n),\nid=\"dialog\",\n)\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example.
Here's the DOM created by the above code:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1d+1PiyFx1MDAxNv59/oop95e9VWO2+/T7Vt265Vx1MDAwYnVcdTAwMWSfOFx1MDAwYnrnllx1MDAxNSFcdTAwMDIjLyGIurX/+z1cdTAwMWSVXHUwMDA0Qlx1MDAwMihB2DvU7qhJ6Jx0n/Od7+tX/vz0+fOa/9j21v75ec17KLn1Wrnj9te+2OP3XqdbazXxXHUwMDE0XHUwMDA0f3dbvU4puLLq++3uP3/7reF2bj2/XXdLnnNf6/bcetfvlWstp9Rq/FbzvUb33/bfI7fh/avdapT9jlx1MDAxM95k3SvX/Fbn+V5e3Wt4Tb+Lpf9cdTAwMDf//vz5z+DfiHXlmttoNcvB5cGJiHlGjlx1MDAxZT1qNVx1MDAwM1NccuGCXHUwMDAzIzC4oNbdxpv5Xlx1MDAxOc/eoMFeeMZcdTAwMWVa29y+K3b2XHUwMDFlL5t5wXP7f0C+d212w7ve1Or1vP9Yf65cdTAwMDe3VO11vPBs1++0br1CrexX8TxcdTAwMWQ5Pvhet4VVXHUwMDEwfqvT6lWqTa/bXHUwMDFk+k6r7ZZq/iNcdTAwMWVcdTAwMTNkcNBtVoJcIsIjXHUwMDBmwVx1MDAwNeBQJkBcdTAwMTCquDBE8cHp4PvUYYRqo1x1MDAxOFBcdTAwMGVajtq11apjO6Bdv5DgXHUwMDEzWnbtlm4raF6zXHUwMDFjXqOhZGjkmfuvT6uZwzlcdTAwMDPA21x1MDAxOGZcYojBJVWvVqn69lx1MDAxYVx1MDAwMEdcdTAwMTMutVx1MDAxMs+3ipjiXHUwMDA1TUJcdTAwMDVQ4FLqsEmtXHUwMDAx7f1y4Fx1MDAxYv9cdTAwMWSt0qrbab9U3VrX/lx1MDAxMTHe2r0z6lhR54o0u5bnXHUwMDFi5sdlsVrq7JOTk8vCuVuQg7KGPNH3XHUwMDFl/LXBib++pFx1MDAxNXt4uv90kXso+p1tb72a71x1MDAxNlx1MDAxNfCb8cW6nU6rP225XHUwMDE5mTv3Yoeu/jLtXHLDYl9+XHUwMDBim73XLrvPwUulJFxco1trqfXgfL3WvMWTzV69XHUwMDFlXHUwMDFla5Vuw3j/XHUwMDE0sTeGMkN2RiGG6NGjr1x1MDAxMCOAXHUwMDE50IqEXHUwMDBlO1x0YtJreVkhRqVBXGZFXHUwMDA0XHUwMDAywoFTot6PMX7HbXbbblx1MDAwN1x1MDAwM3dcZs6oyThcdTAwMDNxXFxB41xmXHUwMDEwakK754Yr8/TO0Fx1MDAwYlpNP1978oKyXHUwMDFjQTUnIFx0KGOIUENX5dxGrf441LCBXHUwMDFio+VcdTAwMWLt9q//iFZ110NcdTAwMTOCMsXQxVx1MDAxYvVaxfr5Wlx0XHUwMDFmyutcZoWAX8OUPbigUSuX61x1MDAxMX8soVx1MDAwNS6W2dmfJn22OrVKrenWz4dcZkxccsmOV/KffXJMXFxcbkWS4pIqIyhorvTUgVx0rdO9XHUwMDFi/fitcX2+sd+oXHUwMDFlVcVV/2TZXHUwMDAzkyrHXHUwMDAwZ1x1MDAxNORzYFx1MDAwZcUloO8oYFxu/3/O/dmFpVx1MDAxOFx1MDAxM4daOVx1MDAxOKacKVx1MDAxMYtHrlxyIZpcbj3/eEzLcGfyxtvtq1x1MDAxZtWzwl3ha+OSi527+vLm+cLvf9z3v/Ltw69cdTAwMWRR3n2EXHUwMDFl8O2EhDxTuVAo7+/lbkuHeoPT80b9eKd5WZlDuVx1MDAxOVVvRsWq+9pe7m6HNjo7V+Vt/1x1MDAxZe6P6/OoXHUwMDA1Tjpt2rwvXHUwMDFkXHUwMDE1clx1MDAwN0e6sF1oXHUwMDFk+lx1MDAwN+8qd1x1MDAxMo9cdTAwMWFfQVOae/2jsXFSv5dU+OubZVM5OGo9PE5n7rLwMy7V6NFBXHUwMDFlkIgykis2fVx1MDAxZUh3tyXNXHUwMDAzKK1S8lx1MDAwMNdcdTAwMGXD9jCv/Cy7PMDH8TFcdTAwMTbDf6VBWDKTgc7Lmo9cdTAwMDFcdTAwMTk6msK/8qWO5zWTKJhcdTAwMWG6fm5cdTAwMTRsXHUwMDAyi1x1MDAxOaVgXHUwMDAzXHUwMDFiU1x1MDAwM+858MdEnoakwFx1MDAwM+CCXHUwMDEzXHUwMDFkYdyT4i49i35I3FEyMfCMcDTlhqlxgYcs1GFcXFx1MDAwZlximMgs8IjDjSQmyrVcdTAwMDbxx7hjXHUwMDE0SCEhTsRcdTAwMDQzmnEuYfZAXGasW3Qgdn2342/WmuVas4Inw3yGwVjq2fuuXHUwMDEzhzAqXHUwMDA1R3RRXHUwMDAyQZGE1W5cdTAwMWbPbdtmQ1x1MDAxMSlQR1xuIVxyXHUwMDA3yU3kipfeyDRF83LxILGuec3yRKOIUehPXG6lXHUwMDFj/idUqExcdTAwMDZWgYMyLyDMQe9cdTAwMThlKsms8VFcdTAwMWUzq+52/a1Wo1HzsfpPWrWmP1rNQX1u2PCuem559Cw+VvTcKFx1MDAwZbRticOkO/ztc1x1MDAxOCnBXHUwMDFmg9//+2Xs1etxXHUwMDE3tp+I84YlfIr+fJN0pJSz0cOv0MUlw3yKXjo1dDVcdTAwMGVcbnDyXGJ721vffpQ2L2BcdTAwMDOKXHUwMDA172Ohi09CLkaZI1x1MDAxOJJcdTAwMDFuOOJTRIpcdTAwMDVfZ9rRRFx1MDAxM2OoXHUwMDA0TcjSSEfJXHUwMDA1YGTDgnuIycPW9UFLXlx1MDAxZJT13f3ZUfuGnlx1MDAxZPKfynFeyjGj6l2tYucvSCdcdMfxXHUwMDBmXHUwMDEyXHUwMDE2+4qzXHUwMDFmLfAoiYDEXGJaY1wipVxiUtN3wKe33rKCtU5cdTAwMDNrRVx1MDAxY8OxNZShXHUwMDAxWC+BwFx1MDAwM6Q7iohcdTAwMGbocDczeOP7XHUwMDA03lx1MDAxZdJcIq+zYIE3gWuMXG68gY3v4Epa0KTo41x1MDAxMt2NSja9zDvYOqnkjqhqyZvzvavTi2qp+vWDx78mhlx1MDAxZlx1MDAxMlBkQ1x1MDAwNFx1MDAwNZ4kmqlcYvlcYr7OiaMkRiVjWiFtJ9nJvFx1MDAxOclcdTAwMTJcYoPijrxB3L2HK12RnD686lx1MDAxNVx1MDAwZp425FaDbPbORK/4kyvNiytlVL0/i/2QvvvxXHUwMDBmMjNcdTAwMDWbJem9jYIpZUZcdTAwMGZcdTAwMGYmQVx1MDAxMKlcdTAwMTjRfHpcdTAwMGWW3nxLmlx1MDAwNFx1MDAxOE1LXHUwMDAyXG5cdTAwMWNtXGZRQFnWSWA6XHUwMDBlXHUwMDA2XHUwMDA0oVx1MDAxZqniR3SyL46D5Votf+FcdTAwMWNsXHUwMDAyh1x1MDAxOeVgXHUwMDAzXHUwMDFiUyMvuZNd8sTIk8LYvs7pIy9dZC7p6JamXHUwMDBlhlx1MDAxNuGGcEKJNEORx4hxJDOCaFx1MDAwM1ooynl2kWdQaFx1MDAxOabBSKBcXI2LQ2rn63BGXHUwMDE4wlx1MDAwMGgqqVx1MDAxOY1LKvCo5lGVtrA+9zfFZXKf+1x1MDAxNH3SYcqLdoZzQTUxQiCY2s68cETyc9hDLzmAeVx1MDAxZLJk7OWCyX3uQ0alq6Vho7CtXHUwMDEwuLlcdTAwMTRcdTAwMWFcdTAwMTRmu7hNXHUwMDE0XHUwMDFjXHRCXGKtJeVcdTAwMTRcZo/ZtFJcdTAwMWTuid5sP3E/XHUwMDBly/tcdTAwMTT9OTOcof8ndr1TJrF6XHKfYT5lOmVbTkCTTDvAqMK4tLOhR1x1MDAwMY1Sh1wi2+DUgLQ/Mlx1MDAwNDTJXHUwMDFkaWPMoFSkhof9SpFZ29QhdpYrXHUwMDA1jf9Hc81gNpfSXGZcdTAwMTBkVlx1MDAxZtBe0YA41FBlQYrhXHUwMDAzXHUwMDAzeiRcdTAwMGKD41x1MDAwNVxyXHUwMDE0Nlx1MDAxMuproGiMXHUwMDE2KP4jV7xlsG7SXHUwMDE4XCJxbIojXHUwMDFjMHtcYkFcdTAwMDVXMZO0g4RcdTAwMDBcdTAwMTDFlE2ETOskk8ZcdTAwMTOYlYazRFdcdTAwMGVOxpx4XmiGzZ9cdTAwMDRmKFx1MDAwNdArpJl+XHUwMDFjMX0u1pJOgUA1ZFeYIEkzXFxhK1xmz1xyt8OMhklCMckwxSC7rmnpXHUwMDE4XHUwMDE0XtyuMEF6xWSYZFwi5Fxmg1xiXHUwMDA1kUJYZaBRxsWwjGDcc6pM+IyrimVvJmdAQFAk2kZgXHUwMDE2UiQ+e8Jgi2rkb1x1MDAxYUOKMKNeqcGM3Kx0ly/+vv/Acr9fX5y6hzfXh+R4N8EmgsmQXCJhMVx1MDAwMVx1MDAxZFHAY0ZR7mhO0dHAXHUwMDAwIKc0sNJwtp7ozvZcdTAwMTN35Fx1MDAxOfEstcNcdTAwMWaISOzroUhJXHUwMDE4g1kmdqW385KiXHUwMDFh5n5cdTAwMDdcdTAwMTBcbjhcdTAwMTOgsFx1MDAxMUKkeJ5cdTAwMWVhXHUwMDFjhjrBKNQoQI1cdTAwMWGxa55cZi1EzLDHP0wpr7jFgGk0KFSkXHUwMDBi6ep/8vvC2z2R3fLvKt8/qNxvbVx1MDAxZW/+7OqfV1d/RtW7WsVmNU9/tWrhXHUwMDFk0/RcdTAwMTPKnTQyMf5BpjT3x7fqU1x1MDAxOfLFYt4tnpeqV4WN+lnCmo2ZXHUwMDFh7eBcXO+c9HKHXHUwMDA3Z113vXrQudl4qO9OV+5rXpwjXHUwMDBiS02xyatJVeJyXHUwMDA14NoopF7TS4Z0d1vW5FxuLC25XG6kcotJrmJMco0ssHxNrlx1MDAxMrOrnbKbQXbNeiSFyqGjKSMpW69jXHUwMDFjv35v2lx1MDAxM7Xyv77bjVx1MDAxN+qtyve1783xIyxcdTAwMDKGylx1MDAxOVxmoNS9m2Hnn2l8ZVx1MDAwMmNcdTAwMWNcdTAwMWRfmWj5O6gwJ4mDL+jAXHUwMDAwis6wvcTR3vXm7Teyv5477uzU1pv89NvjzrJcdTAwMDer5Kg17LwzrpRcclZcdTAwMThcblZhuGNQh2CsZlx1MDAxZKxcdTAwMTFNXHUwMDFlMuF4b6TQilx0XHUwMDEzXHUwMDFkgF1cYlx1MDAxNW7vNftcdTAwMTe3x9c7ZVLNre+vXHUwMDFmk3rx8CdcdTAwMTWeXHUwMDE3XHUwMDE1zqh6V6vYrKjwatXC/KlwRuZOYtjjb5g9XHUwMDEzTi13v3/WKPTFjr7eJFx1MDAxN/6RPKrRq4TJ7TOVu9voXHUwMDFll76dNd2LgtgvbNx5lav1/nTlLlxyc+cscVwirFCEK1x1MDAxMd1cdTAwMTllXHUwMDEyXHUwMDE5SHe3pSVcdTAwMDNcIoVcZkhiXHUwMDFjvlx1MDAxODKgx5CBMczdjklcdTAwMTi7XHUwMDA2/O/M3PeQXHUwMDEwP1lcdTAwMGVcXP/VXHUwMDFlfybBpbrb7XpdZMLXPd9vNbuLJvFcdTAwMTPIbmyi+lxmXHUwMDBm8Vx1MDAwZT4vk/eM4VRwXCJcdTAwMDUjU4dweX/f39l+2uHXrFuu7l+oRv/SXfZcdTAwMTBcdTAwMTZ2N1x1MDAwMCUkl1JcdTAwMDCG8PB4nZLS4YBS1273xIBnuJXTlHzeLmoxoFx1MDAxNzyJfbt597he8lxi9b8+Ulwi+24+d978SefnReczqt7VKjYrOr9atZBcdTAwMTWdX61aqJvNLX29u8W3pPTy51x1MDAwN1x1MDAwZpVcXG5KevxG9TH+Qf6P2Hx0ksdo11x1MDAxZWFMaVxyYno6n+5cdTAwMTfLylx1MDAwNVx1MDAwNEvjXHUwMDAyii6KXHUwMDBijKPzKsZcdTAwMDVcZppIuFAruKp0eja/XHUwMDE5MN1cYlx0/r524SHx/fL8173bqblNXHUwMDFmKXG3Vyrh0yXzejVcXPiceP1cdTAwMDTSO8rr3/Y472D4KD6TwtpQ6+UgZpiR97Tlnp6ap7PKZvGylds4h0b+dNmjWlx1MDAxOe1IXHUwMDAzxE5cdTAwMDWFmEhXUjh2wi5+1FIwfCCUMY46fcG7QbKvxW5+s974arbyxe5cdTAwMWb8x5Ms/tzTY25cdTAwMTQ/o+pdrWKzovirVVx1MDAwYllR/NWqhflT/IzMnaRcdTAwMWPG33BKa98xvvDy28crh2h/cmxcdTAwMDVcdTAwMTNcdTAwMDXgTNLplUN6+y0px9CEpXFcZkVcdTAwMTbFMaZTXHUwMDBlVFx1MDAxOY5qLjqz6v9DOlx1MDAxY7XGUG1cdTAwMGZcdTAwMDOrs2jdMIFKT6FcdTAwMWImPUtqMKeLXHUwMDA2XHUwMDAxyX1cdTAwMDFcdTAwMDJQz3NcdTAwMDPTr0ms9o+L9fouK/hf20y15GX+zr1e9ohmmjtcdTAwMTiqWmKYXHUwMDA0fVx1MDAwMWIopIWRjkBtpbnKXFw2jFm6M2ZgwFx1MDAxMIFBRt+yd+l7ZMN957Kfq2z+qKpK+3ZrY+Obq9tnP2XDvGRDRtW7WsVmJVx1MDAxYlarXHUwMDE2spJccqtSXHUwMDBik3j4+Fx1MDAxYi4hX1x1MDAxNjqRL2thqN1cdTAwMGV++uSaXs1Lm1xcTVpylYQuKrnqMcl1XGZfJswgX1ZvWFx1MDAwNrs6dDnvu8hgXyaNn37byZ/vXHUwMDFmXHUwMDFmfbF/jM4+uet5Xb+WOokmXHUwMDFi0jyBSca283/rXHUwMDEzpcZ14uJ3mVx1MDAxONacXHUwMDFhLYDNsJAlfcXQkoZ1sP0/U6hcdTAwMTCQjGL0XHUwMDBl78sqXHUwMDE4c4BJxlXmKlx1MDAxOIyDXHUwMDE1zijeXGLsmmFcdTAwMTVcdTAwMGZyyZ3xu0RyorRcdTAwMWHaROpvseR9luXlQjBit8IhWIfcaFx1MDAxYbnqdTtcIq00XHUwMDExxK7GJnY19stcdTAwMDVcdEveh1x1MDAxZmOVlp0ne5L9XGZ8KCznU/Tn7JtcdTAwMDFFXHUwMDEy/ygz0NpcbrxcdTAwMTmW16RPiV5SXGKRXHUwMDAwXHUwMDBlXHUwMDE3WNtCWM+iw3tcdTAwMDFcdCZcdTAwMWNcdEoxXHUwMDA1zDBcdTAwMDZmxK45QlxikY7dt9A6ONGM6DFcdTAwMTBcIrD57Yt77IxcdTAwMDBjKJD4XHUwMDBiRYDYxXJcdTAwMWM+4IVcIktcdTAwMDEmxOFKcFCSSqVcdTAwMTFRTPw1XHUwMDFm2lx1MDAwMWk4J4Tad3wgXHUwMDE3TMeSJJPS59eOmKSEXCJUMlwiqNAmvpdcdTAwMTF37I5mTHGBXHUwMDE2XHUwMDBiyVTMpFVcdTAwMDKxZFe2n7hcdTAwMTPPXHUwMDBizCRJ3jaDM/vuVD3DsED6QMmSoplcdTAwMTLSXHUwMDAxu1x1MDAxZj/XytjN3IZljlx1MDAwMcegrmBcdTAwMTK4nYmhR+ya42ZAxNFcdTAwMDKBk1x1MDAwMlD7hoKw2kM+JFx1MDAxY5RiRiq7ZT5nXHUwMDEw31ODW9/h5i1vjV1mLJtcdTAwMDE4wM7+4kiJhGG2VaPvSVx1MDAxYeyJSKyu5VxiL1x1MDAwNONN67ehWfrowDBbXHUwMDAzKkBSXHUwMDEwOliCQlh8IzTlIFx1MDAwMCuqXHUwMDA3RGU1gSzRi4OTo/47L1x1MDAxOItsZVx1MDAxN+NklGKiYDO8TTF9ouiyolx1MDAxOJNcdTAwMGV7fte1XHUwMDA0VFx1MDAwNsNbPVtcdTAwMTSzkk4pad/LrNiIXfNDMbxcdTAwMTFcYlRnRnOmkVSNQzHlgFx1MDAwNo2pXHUwMDA1qN2bKbZcdTAwMDSKoZnYZvxvRsimXHUwMDA2seC9YkhcdTAwMDFcdTAwMDBZqaaMKjlmL0dKXHUwMDFkgdyIXGIuUPNgXHUwMDEye+N2s+lzI0esYsq+XCJcdTAwMDVcdTAwMThcdTAwMTNKo9aKb2kmXHUwMDFkXGZuJIlcdTAwMDSI3Ss0btMqYdl6ojPbT8yNk8Ds08tcctbcdtv2dnmD1kC/rpVf+lx1MDAwMMOnXFy7r3n9zXjc/XJcdTAwMTN8bMdXUJ9cdTAwMTaJPPusf/716a//XHUwMDAxsk3fXHUwMDAxIn0= App()Screen()Header()Footer()Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")Static( QUESTION, classes=\"questions\")Here's the output from this example:
ExampleApp \u2b58ExampleApp Do\u00a0you\u00a0want\u00a0to\u00a0learn\u00a0about\u00a0Textual\u00a0CSS? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
You may recognize some elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
"},{"location":"guide/CSS/#css-files","title":"CSS files","text":"To add a stylesheet set the CSS_PATH
classvar to a relative path:
Note
Textual CSS files are typically given the extension .tcss
to differentiate them from browser CSS (.css
).
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal\nfrom textual.widgets import Button, Footer, Header, Static\nQUESTION = \"Do you want to learn about Textual CSS?\"\nclass ExampleApp(App):\nCSS_PATH = \"dom4.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Footer()\nyield Container(\nStatic(QUESTION, classes=\"question\"),\nHorizontal(\nButton(\"Yes\", variant=\"success\"),\nButton(\"No\", variant=\"error\"),\nclasses=\"buttons\",\n),\nid=\"dialog\",\n)\nif __name__ == \"__main__\":\napp = ExampleApp()\napp.run()\n
You may have noticed that some constructors have additional keyword arguments: id
and classes
. These are used by the CSS to identify parts of the DOM. We will cover these in the next section.
Here's the CSS file we are applying:
dom4.tcss/* The top level dialog (a Container) */\n#dialog {\nheight: 100%;\nmargin: 4 8;\nbackground: $panel;\ncolor: $text;\nborder: tall $background;\npadding: 1 2;\n}\n/* The button class */\nButton {\nwidth: 1fr;\n}\n/* Matches the question text */\n.question {\ntext-style: bold;\nheight: 100%;\ncontent-align: center middle;\n}\n/* Matches the button container */\n.buttons {\nwidth: 100%;\nheight: auto;\ndock: bottom;\n}\n
The CSS contains a number of rule sets with a selector and a list of rules. You can also add comments with text between /*
and */
which will be ignored by Textual. Add comments to leave yourself reminders or to temporarily disable selectors.
With the CSS in place, the output looks very different:
ExampleApp \u2b58ExampleApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aDo\u00a0you\u00a0want\u00a0to\u00a0learn\u00a0about\u00a0Textual\u00a0CSS?\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0Yes\u00a0\u00a0No\u00a0\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/CSS/#using-multiple-css-files","title":"Using multiple CSS files","text":"You can also set the CSS_PATH
class variable to a list of paths. Textual will combine the rules from all of the supplied paths.
It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your .py
files?
A major advantage of CSS is that it separates how your app looks from how it works. Setting styles in Python can generate a lot of spaghetti code which can make it hard to see the important logic in your application.
A second advantage of CSS is that you can customize builtin and third-party widgets just as easily as you can your own app or widgets.
Finally, Textual CSS allows you to live edit the styles in your app. If you run your application with the following command, any changes you make to the CSS file will be instantly updated in the terminal:
textual run my_app.py --dev\n
Being able to iterate on the design without restarting the application makes it easier and faster to design beautiful interfaces.
"},{"location":"guide/CSS/#selectors","title":"Selectors","text":"A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to.
Selectors can target a kind of widget or a very specific widget. For instance, you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface.
Let's look at the selectors supported by Textual CSS.
"},{"location":"guide/CSS/#type-selector","title":"Type selector","text":"The type selector matches the name of the (Python) class. For example, the following widget can be matched with a Button
selector:
from textual.widgets import Static\nclass Button(Static):\npass\n
The following rule applies a border to this widget:
Button {\nborder: solid blue;\n}\n
The type selector will also match a widget's base classes. Consequently, a Static
selector will also style the button because the Button
Python class extends Static
.
Static {\nbackground: blue;\nborder: rounded white;\n}\n
Note
The fact that the type selector matches base classes is a departure from browser CSS which doesn't have the same concept.
You may have noticed that the border
rule exists in both Static and Button. When this happens, Textual will use the most recently defined sub-class within a list of bases. So Button wins over Static, and Static wins over Widget (the base class of all widgets). Hence if both rules were in a stylesheet, the buttons would be \"solid blue\" and not \"rounded white\".
Every Widget can have a single id
attribute, which is set via the constructor. The ID should be unique to its container.
Here's an example of a widget with an ID:
yield Button(id=\"next\")\n
You can match an ID with a selector starting with a hash (#
). Here is how you might draw a red outline around the above button:
#next {\noutline: red;\n}\n
A Widget's id
attribute can not be changed after the Widget has been constructed.
Every widget can have a number of class names applied. The term \"class\" here is borrowed from web CSS, and has a different meaning to a Python class. You can think of a CSS class as a tag of sorts. Widgets with the same tag will share styles.
CSS classes are set via the widget's classes
parameter in the constructor. Here's an example:
yield Button(classes=\"success\")\n
This button will have a single class called \"success\"
which we could target via CSS to make the button a particular color.
You may also set multiple classes separated by spaces. For instance, here is a button with both an error
class and a disabled
class:
yield Button(classes=\"error disabled\")\n
To match a Widget with a given class in CSS you can precede the class name with a dot (.
). Here's a rule with a class selector to match the \"success\"
class name:
.success {\nbackground: green;\ncolor: white;\n}\n
Note
You can apply a class name to any widget, which means that widgets of different types could share classes.
Class name selectors may be chained together by appending another full stop and class name. The selector will match a widget that has all of the class names set. For instance, the following sets a red background on widgets that have both error
and disabled
class names.
.error.disabled {\nbackground: darkred;\n}\n
Unlike the id
attribute, a widget's classes can be changed after the widget was created. Adding and removing CSS classes is the recommended way of changing the display while your app is running. There are a few methods you can use to manage CSS classes.
The universal selector is denoted by an asterisk and will match all widgets.
For example, the following will draw a red outline around all widgets:
* {\noutline: solid red;\n}\n
"},{"location":"guide/CSS/#pseudo-classes","title":"Pseudo classes","text":"Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the :hover
pseudo selector.
Button:hover {\nbackground: green;\n}\n
The background: green
is only applied to the Button underneath the mouse cursor. When you move the cursor away from the button it will return to its previous background color.
Here are some other pseudo classes:
:disabled
Matches widgets which are in a disabled state.:enabled
Matches widgets which are in an enabled state.:focus
Matches widgets which have input focus.:focus-within
Matches widgets with a focused child widget.:dark
Matches widgets in dark mode (where App.dark == True
).:light
Matches widgets in dark mode (where App.dark == False
).More sophisticated selectors can be created by combining simple selectors. The logic used to combine selectors is know as a combinator.
"},{"location":"guide/CSS/#descendant-combinator","title":"Descendant combinator","text":"If you separate two selectors with a space it will match widgets with the second selector that have an ancestor that matches the first selector.
Here's a section of DOM to illustrate this combinator:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPiSFx1MDAxMn7vX+HwPOzsRKOp+5iIiVxyfODbbjdu293bXHUwMDEzXHUwMDBljGSQXHUwMDExXHUwMDEylmSwPTH/favARlx1MDAwMiRZXHUwMDFjYmCnefAhQSmVyuP7KiuLPz9sbGyGz1x1MDAxZGvzt41N66lec2zTr/U2P+rjXctcdTAwMGZsz1WnUP//wHv06/13NsOwXHUwMDEz/Pbrr+2a37LCjlOrW0bXXHUwMDBlXHUwMDFla05cdTAwMTA+mrZn1L32r3ZotYP/6J+ntbb1e8drm6FvRFx1MDAxNylZplx1MDAxZHr+4FqWY7UtN1xm1Oj/Vf9vbPzZ/1x1MDAxOZPOt+phzW04Vv9cdTAwMDP9UzFcdTAwMDFcdTAwMTEg44dPPbcvLVx1MDAxNFx1MDAxY1x1MDAwYimAkMN32MGOumBomer0nVx1MDAxMtqKzpj9a1x1MDAwNVx1MDAwN7x5b1Pil1x1MDAxOWx/2nX2v5Ur0XXvbMephs/OQFx1MDAxN7V689GPSVx1MDAxNYS+17KubDNs6quPXHUwMDFkXHUwMDFmfi7wlFx1MDAxYaJP+d5jo+laQTDyXHUwMDE5r1Or2+GzPlx1MDAwNsDw6EBccr9tREee1H+UY4MxTlx1MDAwNGFcdTAwMDBxQfDwrP48ZtJcdTAwMDBEQCoxoOokXHUwMDE5k2vbc9SzUHL9ZDHOXHUwMDAxiCS7rdVbXHIlnmtcdTAwMGXfXHUwMDEz+jU36NR89cSi9/Xe7pix4bGmZTeaoTooRHQ9q693iFx1MDAwMFaCMFx1MDAxYd2FvkrnwOxcdTAwMWLBXHUwMDFm43pr1vzOq342XHUwMDAz/U9MQi3c7rhcdTAwMDXFrSj2bKl7V7rCsve1aVx1MDAxZcKb26dcdTAwMTPGYDBcdTAwMWNrxORC6yncXHUwMDFjnvjrY9aw7W9fji727ra64ZfLw9557+Wk0blNXHUwMDFltub7Xi/vuFeHl93eMdk5OfapufeMXHUwMDFlXHUwMDEx2WFcdTAwMGJcdTAwMThcdTAwMTddmVx1MDAwN/uVVv1ElFx0vGg7Z7vut8ZcdTAwMDLGLUi96zXs8ePOPpDXXHUwMDA3L0934qhSMZ9uat3eXHUwMDBm5S5m2H1rXHUwMDBmXHUwMDA33bpdvtm+XHUwMDBi7ytcdTAwMDe75fOr47mUO1wixce8N5JT3Obxdlx1MDAwZmBcdTAwMGK07zudg6Pj7svN+U6YT9zXv6JQ+Ngxa4OspYIsJJBcIiZcdTAwMDSPwq1juy110n10nOiYV29Fie5DTOCJXHUwMDE0O3L/I9lcdTAwMTWK8cNv2ZVzKiHjMMpZ7yXXbLNY2eQqspKrIFx1MDAwNl1OcqVcdMmV8vHkilR2RVx1MDAxY9LosS0sty7SXHUwMDFho4fuuWHVfunDNTZytFJr287zyHPrm6mSdFudrtmu5f/83dUnbPP375umXXO8xvfN7+6/41pcdTAwMGUsJY1cdTAwMWWeopFxyo7d0Ca+6Vh3o7ZcdTAwMWbaXG6oXHUwMDBlT7dt04xDz/rbtVx1MDAwZvJcdTAwMDBGz7dcdTAwMWK2W3Mu8kqe6abZSJhClOarkCtcdTAwMTAsOVx1MDAxMjy3s55fPDjWS3nr8XHr5MA5Obt2ryv3f6+z0nd9lVx1MDAwMYNCXHRcYodcdL5KMTOkgJwqV9W+SlN9XHUwMDE19F9z+KpcdTAwMDSTvirYhK9cdTAwMDKAMYNAXHUwMDE24KxZiem+etjYvfDc7Va9xfyn1sG31kNKYvpcdTAwMDGEp1x1MDAxZrcg9a7XsEVcdTAwMDHh9dLChd/c6d12rYtcdTAwMTBcdTAwMWY6XHUwMDEwiZfWeWc+xLqOWlg8blx1MDAxZoyLn1x1MDAxZkpfkajUQ9JcdKrW887Z9lx1MDAwM1/AuGZcdTAwMWJcXG57h597leujbafb8XdvXHUwMDFiuFCekaz4aNjXvzJcdTAwMTFcdTAwMThmJMr/RfFcdTAwMDGKU/lcdTAwMDCkilx1MDAwYlxiRkGUV9/DXHUwMDE42fa2qlx1MDAxOINmYVxmXG5ccrJcdTAwMWOMIVx1MDAxMjDGJFx1MDAxZpCAXHUwMDEzIFxiL2CubZHWOFx1MDAxZlx1MDAxZthXMPtFI2vnZ318XHUwMDAwretOLVxirEDh69vHMPTcYNnU4Fx1MDAxZFx1MDAwND1OXHKmuYlM581mXHRcXKbOlzPMXHUwMDA1Izj/bPmX8PzCe3q+2eptnZ1+uXj5/PKpzFL8d8xcdTAwMGb/LjpPODGApFxiXG7JuXJfQkb8lyNqKCpcdTAwMGJcdFx1MDAxM8rJXHRcdTAwMTfz+O9PXHUwMDEw3VxuoajxzFxmXHUwMDAxXHUwMDEyrKRcdTAwMDScocW7b1ZcdTAwMDZkrOneyy77elolnYY8wkfHj5X5gcBcdTAwMGaGUKh612vYolx1MDAxOMJ6aaEohrBeWnDk1ra43dsm24xZ1Yujp0alssK2sHyCkHwjOcWtXHUwMDFmnV6QzrF/+tXfvjWt44483vuyuELEUoiHiNWWx4lcdTAwMDeTXHUwMDEwXCKJRX7ikW1cdTAwMTcrWokgXFxmQlx1MDAxNypccrwo6DI99YBcdTAwMTFsfJvepLpARPCyS1x1MDAxMUulXHUwMDFlW31Y/vP3za+WwubJ9Fx1MDAwMpKRj1xy+UNd3Y7lz04w3oHf41x1MDAwNGNcXNRMR8wmXHUwMDExXCK2pGbMXHUwMDFiXHUwMDExXHUwMDA0XGZDOUVZkMPKWYvsud3mXvUlaODnXHUwMDEz379ebVx1MDAxNsEkMqBSXHUwMDAye3XF0UlcdTAwMDBNXCKo1GW41SBcdTAwMTFAeyElYMkk4iGQlaP2WZVcdTAwMWOet2jFNe/RzqfLXHUwMDFmJGJRJKIg9a7XsEWRiPXSQlEkYr20UFx1MDAxNIlYLy0svijyXHUwMDFlN0m+kWjY17/+flx1MDAwZSFlOodAurTBXHUwMDEwyT/5mf38VpRDMMmzgFx1MDAwYtVcZmNBwGVcdTAwMTFcdTAwMTSCS0VcdTAwMWZcYohhmv9nXG5x6i2bQbxcdTAwMDO9U1x1MDAxOcRA0kwvXHUwMDFjXHUwMDA0l1x1MDAwNDdkIL2EXGIh1oYp83thdm15NUuITFx1MDAxN1x0XHRjXHUwMDE0YFwigEQjPkhcdTAwMThVJ1x1MDAxMZVcdTAwMTJDKinjhfkgMDBcdTAwMDZUcixcdTAwMTCmXGIozdNJn2TSgMopKIVCxVx1MDAwZS7wuItcIlx1MDAwMDliKFZVyu2ifVlndVHC4UxcdTAwMGJcdTAwMGWDsOaHW7Zr2m5DnYwy3VsnSp51fX2nrj9cdTAwMDZ9LVx1MDAwMkY5xVxcqVx1MDAxMFx0XHUwMDE5X1x1MDAwM6p1UetoTmZgSVxiXHUwMDAxQFExiFV4fX3DMONuWq75vkzZXHUwMDA1xZhMJSVcdTAwMTTmXHUwMDAwqStcIlxuoZIv8rmhUMjAnDMpKEVcdTAwMTLolDAhlFNcdTAwMGLCba/dtkOl+0+e7YbjOu4rs6w9vWnVzPGz6qbi58ZDQkePOMpcIqO/Nlwin+n/M/z7j4/J7043Zv2aMONovFx1MDAwZvHfU0czXGJcdTAwMDFcdTAwMWU/PKynXHUwMDAySFx1MDAwNIQyesN70SxcdTAwMWK8rSqmYMTgXHUwMDAyMimFJJLEVozrzzNMXGYqMUOIUPVcdTAwMDY2LtdcdTAwMDIxXHUwMDA1xFx1MDAwNkZQXHUwMDFiPEbKolGk92h2XHUwMDA0XHUwMDE4XGZzhFx0xFx1MDAwNKugXHUwMDE1W58xXGJnXHUwMDAyS6DcgswwVzJXOJtcdTAwMTVx5FxmZ7lDXHUwMDA3MFxiplx1MDAxOCCgMCDAkkNcdTAwMWPzo9fIXHUwMDAxocH0Ulx1MDAxZklcdTAwMDBcdTAwMDE6XHUwMDE0z1x1MDAxNs6ywceoTFxiS8l1L1x1MDAxZmBcdTAwMDKDJJlUXHUwMDAwoFKrknKOXHUwMDEw4utcdTAwMWTO0m1ZvyaseFHRLD5tO4HNJCfKmfNcdTAwMDez7CrZqlx1MDAwNjMsXGaKXHUwMDEwg0JiXHUwMDE1zMZjXHUwMDE5M1x1MDAwMMVcdTAwMWF8MJX1i1x1MDAwYmVSKmvGkimszFWWTmr9XHUwMDEwKuhcIqFbXFxcdTAwMTGEKn5MrPyCXG6VIKpS/z81lJU0JiCSqidJmYLZQrCYXHUwMDE3vcVccmyoUKf8jFx1MDAwM4XFlabfkMGUwSy7XHUwMDE2MyqVTodSuVx1MDAxM0QqpElcdTAwMDQnhKJcdTAwMDZcdTAwMDRUhVfOgZZrUqR1XG5lpVRj1q9cdDOeMpRl1qlcdTAwMTQySVxyZyq39d08f9HYadbA2XFnL7Shf0rK7Z3wyjxdcapJdEfMSFxmQ0hcdTAwMThQQlx1MDAwMlx1MDAxOCpwcSpNoJKCXHUwMDFiiKtcdTAwMDTCJ0BcdTAwMTfGkGDFhJfcXHUwMDEw7jlP1XLrwobynpZcdTAwMWN0ePxcdTAwMTLUq/PPwJ6cXHUwMDFmvHytPF2H/o5ValaDa47I3T+wQFWQelx1MDAwYlx1MDAxYZZ37f3Kwy5s+7s35k7YRd0zZ1x1MDAxMVogwO9At1s/vapcdTAwMWOdiqudK+8kPFpd7d7et8ufnC6DNCxtmbJxdKqyWqHlg+RcdTAwMWLJKe5cdTAwMWM91pnjfjmzzmXpwWqJ3a8v95/cs8vSXU5reEtb70CkXGJHXHUwMDE3VO6gNH2elWFFXHUwMDFmXHUwMDE0ssjfXHUwMDBlmm1uq5r86Hjyo9JcdTAwMTBcbm5cdTAwMTSZ+khC6kNcdTAwMTPTpopaKFx1MDAxNlFExluk4UXPNypsIDByNKOwUa37luX+nFLS4CPvX1hJ41x1MDAxZJQ2XtJcdTAwMTjKmOljqYSZp1ZcdTAwMTSFXHUwMDAy+EDPUud2sexYtpouRlx0MahmWFx1MDAxYzLEYUxcdTAwMWT93Vx1MDAxMaA0KKSFXCJNXGJcciggZlSRKSooTZj0IypcZqQgT6hcdTAwMWVRX+5cdTAwMTkqjPOWL/gsjpiTJGd7wUZ8bo1KrtNcdTAwMDElkjCMXHUwMDEyKDIxXHUwMDAwXHUwMDA1XFxcdTAwMDfN1zNTUuMpSilMPVx1MDAwZu04XHUwMDA0ci6jRqS4KEA339F+c1x1MDAxZJZyQqJ1YsbptqtfMauNXHUwMDA2+lx1MDAxMP8929pNlE6KXHTlXGIjPsVcdTAwMWPf+YV74MnHo93OTfPo7vzm0uJcdTAwMGbWiscsjJShgdGwNNgoTShwQFx1MDAxMFx1MDAxNJz2o1x1MDAxMlx1MDAxYpNogVEr305pSGFcdTAwMDTlnXyG2upcXFx1MDAxYqV99tF1q/n5oXlwWeru032zu3OfXGZ+fyzcnH7cgtS7XsNcdTAwMTa2UdpaaaGgYYvacKEgcVx1MDAxN0/i393XLfFGcor7cIvLVVRmdyc7XHUwMDE31WvvM7l7MlvrNTeAkEhdtVx1MDAwMLFUp5mg+SdcdTAwMDey7WJVUVx1MDAwME1GXHUwMDAxglx1MDAxOHhJKCDflm5QXGJcdTAwMDSgXHUwMDEyqID+jaInXHUwMDBi5tzTLbBN67bmL3/nhkxUm2tTt1x1MDAxMdHngOtZJXmqWFx1MDAxOVx1MDAwNGCKXd26veolsvDR4fVpq1x0fdn4dmydpHhq3feCoNSshfXm3++tXHUwMDEwXHUwMDE5irlob1x1MDAwNWNe2f88YobgXHUwMDA1zjJwPumpXHSdVlxiXCLMgIRL3tCtVVx1MDAwNtf7N4dP5llcdTAwMGaf7l1cdTAwMTDzmJXdXHUwMDFmgH1RgL0g9a7XsEVcdTAwMDH29dJCUZ1W66WFojqtXG5cdTAwMTJ38ds1XHUwMDE0JO57tCX5gjmlnYO2ZI5bpdVnSlx1MDAwZt2DXHUwMDFitoPuXHUwMDFliLtXPkwhhStLh6RM7WdXUiBAOEf5XHUwMDBiOdl2saJ0XGLyTIBFoIGLXHUwMDA0WCxcdTAwMDFgTVIhLplUkFx1MDAxN69h2XTqfjB9bMAnvm9cdTAwMWW4QVhznGXzoHfYQkp7WKrgmb6Z3i+W3rQpOVx1MDAwMphROMWOdZnrOlbTNSlcdTAwMTaGXHUwMDE4W7XwVl3tN1x1MDAxY1x1MDAxNemXRK/PXHUwMDFjxIVJXHUwMDA3JdKgjI0tJVx1MDAxY+5Dj1VUXHUwMDE1s1CheVdcdTAwMWbP5KqLLqyWgMGERIxJXHUwMDE1WpmEOKr3bkTlTIIxlVGFL6WyOir/XHUwMDFhVThLSfajXzHLicb4XHUwMDEw/z11nIgtZlx1MDAxYe/DYlxcb6RI85c1s7HSaoZcdFx1MDAwMqhBqMBcdTAwMTKqZIpZbHnBoK2UXHUwMDE4sexe5LQm0F83JaTuXHUwMDFml0xFZ0FcdTAwMTJyO4OGnmIl8vVcdTAwMTX74ozXuVx1MDAxNEgp5ZLPsjn+Klx1MDAwN5DsucXRXHUwMDAwotuwXHUwMDAwJZjplUSQxL4oYFx1MDAxOEK4IbBKglx1MDAxMFxyNJnQK5BrjUZ2pt9cdTAwMThpd1VcdTAwMDJJSlx0XHUwMDEyglx1MDAwYoZcdTAwMTPaXSfbW9cpZmWYr35NXHUwMDE47pTxK70kk1qRwSp5UEbzdypcdTAwMWP4n+Wlc/FcYus9XHUwMDFmXHUwMDA1l1x1MDAwN95J2fs8+ywvXHUwMDFh97VcdTAwMWMhLIpN03RfIVx1MDAwMlxmoWyLxSdz+ztTXGJmXGIwWIU0T+D66a5GXHUwMDExRYm7aaHoPqOFm5MrxpBe76ZcdTAwMDRZ8qKM4jdxXFzewtC2traNsGlcdTAwMDVWXCKbiYHGadhM6HXSqMzIrYzzlrg4s2FcdTAwMGYq0796R1LdcVx1MDAxZW98fs99s1x1MDAxZvVS3He25knMXHUwMDEzvLTvvlx1MDAxMlx1MDAxOFhcbiwwXHUwMDAzXHUwMDFjXHUwMDAzmr6pRY6vycrwYa5cdTAwMThcdTAwMTFcdTAwMWWjRNH2MsRQaVx1MDAxMlx1MDAwMVxyyrnKp5N7WWi6XCJcdTAwMTBe9lZcdTAwMTZcdTAwMDUjjuxsMJLbXHUwMDExXHUwMDAwXHUwMDEyXHUwMDEzhiBVhEVSjmLvXHUwMDFh9ktKQvHMi0Fz90lqYaR6XoLrfnxAJUlo3uRcbvFcdTAwMTJFoF43ROVcdTAwMTMyrVx1MDAxM/BIMF79Kk3a7YIgXHUwMDA3lOlcdTAwMWRcIpwjpU+FP3NHLeubXHUwMDBm7ZOtW8e8XHUwMDExqGe13Fx1MDAxNv+yv1x1MDAxNqBcdTAwMDNcdTAwMTNcdTAwMTW1JjGHOmAw2bf1oiCHiEJNXHUwMDA25GBSXHUwMDExOimW/SVcdTAwMDCrNbE/XHUwMDFm4vjF9Nx/hb9svKV6O1hcdTAwMDXgkSDVbPhcdTAwMDPFZsTH9+NcdTAwMDVUXHUwMDA1T8R5fk/OfvCrjD9cdTAwMTAxMMaC6u2zIIjvXHUwMDEw1XdoXHUwMDE1YYnsr+wqXGJ/MGlwyoWKXHUwMDE5XHUwMDEyYUXdo4npqKjBjMGU31x1MDAwNLFcdTAwMTCMU5XIwLJbUVxuhlx1MDAxZtl5YWNkwlx1MDAwMynlYaQ3RVx1MDAxMIIgNDndIYz+ZMeM4CP3LIdcdTAwMTJF51lcYlx1MDAwMVwiXGbr73lcdTAwMTJsQlx1MDAxNjiQN0madYJcdTAwMWSpNqtfpaG5pmGOXHUwMDBmr1x1MDAwM2/WOp1qqGxrqH9lvrb5XHUwMDFhqaO72+zaVm8ryav6L1x1MDAxZFx1MDAwMPt61GHG0vf4519cdTAwMWb++lx1MDAxZow/wb0ifQ== Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button(\"Yes\")Button(\"No\")Screen()Container( id=\"sidebar\")Button( \"Install\")match these*don't* match thisLet's say we want to make the text of the buttons in the dialog bold, but we don't want to change the Button in the sidebar. We can do this with the following rule:
#dialog Button {\ntext-style: bold;\n}\n
The #dialog Button
selector matches all buttons that are below the widget with an ID of \"dialog\". No other buttons will be matched.
As with all selectors, you can combine as many as you wish. The following will match a Button
that is under a Horizontal
widget and under a widget with an id of \"dialog\"
:
#dialog Horizontal Button {\ntext-style: bold;\n}\n
"},{"location":"guide/CSS/#child-combinator","title":"Child combinator","text":"The child combinator is similar to the descendant combinator but will only match an immediate child. To create a child combinator, separate two selectors with a greater than symbol (>
). Any whitespace around the >
will be ignored.
Let's use this to match the Button in the sidebar given the following DOM:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPayFx1MDAxNv2eX+HyfMmrXHUwMDFhNL0vUzX1ylx1MDAxYl6It1x1MDAxOMdxXqamXHUwMDA0yFi2QFjIYDw1//3dxlx1MDAxOFx0kFx1MDAwNDiIQCaaqlx1MDAxOCTRurp9l3P6dvf8/W5jYzPstZzN3zc2naeq7bm1wO5u/mrOd5yg7fpNuET639v+Y1Dt33lcdTAwMWKGrfbvv/3WsIN7J2x5dtWxOm770fba4WPN9a2q3/jNXHKdRvu/5t9cdTAwMTO74fzR8lx1MDAxYrUwsKKHXHUwMDE0nJpcdTAwMWL6wcuzXHUwMDFjz2k4zbBccq3/XHUwMDBmvm9s/N3/NyZd4FRDu1n3nP5cdTAwMGb6l2JcdTAwMDJcdTAwMTLKx0+f+M2+tJgxxKhiWFx1MDAwZe9w27vwwNCpweVcdTAwMWJcdTAwMTDaia6YU5unT1x1MDAwZr3CcfehXFyq1iv+x6B81Ph4XHUwMDE1PffG9byLsOe96MKu3j5cdTAwMDYxqdph4N87V24tvDVPXHUwMDFmOz/8XdtcdTAwMDc1RL9cbvzH+m3TabdHfuO37KpcdTAwMWL24Fx1MDAxY0fDky9a+H0jOvNkblDIYkQxoZSgSpPhRfNrirhFJOiAIcSU0HxMqlx1MDAxZN+Dnlx1MDAwMKl+Qf0jkqtiV+/rIFxcsza8J1xm7Ga7ZVx1MDAwN9Bf0X3d1/dcdTAwMTVieO7Wceu3IZxUKnqe09e6VlJcdTAwMTKCWNQn5iGtw1rfXHUwMDAy/lx1MDAxY1farVx1MDAxZLRcdTAwMDbK2WybLzFcdTAwMDGNbHvj5lx1MDAxMzehWMdcdTAwMWU3gkcquttb+1x1MDAwNz1153/aqYf+zrCtXHUwMDExe1x1MDAwYp2ncHN44Z9fs5ptfLkslfdvtjvh5aej7nn3+bjeqiQ3a1x1MDAwN4HfnbXdq6NPne5cdTAwMDe2e/wh4LX9XHUwMDFleSRsVyygXXJVOzwo3leP1Vx1MDAxNsPlhne61/xSX0C7Oal3vZr98Lh7gPTnw+enXHUwMDFiVSpcdTAwMTZrT3/Zne5P5S6m2eYneVx1MDAxM1xcf9zzd0J9dl1raX5Fb1ZXucGjLu70xNlflza9Pqtf1vjV0dk3iTtcIsWvs75I1OzgU1x1MDAxNGFcdTAwMWZbNfslXHUwMDEzQuiG5KBcdTAwMDX8p/Twuuc27+Fi89HzonN+9T5Knu9i8k6k7Vx1MDAxMTlHMjZT46eHXHUwMDE5XHUwMDFicoMkSNCZXHUwMDEzdnb3rWrC5lx1MDAxOVx0myBLLidh84SEzaO8PEjYkKtcdTAwMDVcdTAwMTOYxSxjYVx1MDAxOXuRxlx1MDAxOPW531xmL9znvj2JkbNFu+F6vZFu61spSLpcdTAwMDOXbbfpXHUwMDA07782zVx1MDAwNbf2x9fNmmt7fv3r5tfmf+JqbjsgjWmek5F2tjy3bix803NuRk0/dFx1MDAwMftcdTAwMGUvN9xaLY5mq6/PPpxcdTAwMDWD+oFbd5u2V55V8kwvzVx1MDAwNtec41RX5eDHWjOGZ/bVx+1tzfZ6qHhyt9XcVzLsnlx1MDAwNEer7qtCWkQhRDGd9FUmqUVcdTAwMTlTXHUwMDA0XHUwMDEx3vfVXHUwMDFjnVWjSWdVYtxZmWZcdTAwMTBBOc7BV7Oy3e5D8S44Oz54OC/uljpN++7y41x1MDAxN/cnul5cdTAwMTS6zkm969VsXuh6vbRQXHUwMDBlbne7lY5TXHUwMDBl6ZGHiXq+P299+NdpIS8ycNCz5e1e62z3ktxXXHUwMDFmcIufXHUwMDFmXHUwMDFl11x1MDAxNtBuVVx1MDAxY5/xc9ooXHUwMDFl1IJS4aSwfVK8JavYa9NIRvJcdTAwMDOjZlx1MDAwN5++P8ngUoyfXHUwMDFlXCJcdTAwMTdcIjHVUszBMrL1vKLIRZJcZuSipKWWhFxcVFx1MDAwMnKZpFx1MDAxOVpSjaj6sVnGXHUwMDAxgPdng9e99+b8XHUwMDBiYK96drvttFx1MDAwMbVXXHUwMDFlw9BvtpdNOKbg8nHCMc9LZDpvNvdQKHVgXzGwXHUwMDEzhYme2YErXb3fKNRDXSn0XHUwMDBlnXapt394WllxXHUwMDA3ZmZcXJ9LKbg0fkFGPVhcYmJhLFx1MDAxOcKcI+M835t6SI6QknHGuFx1MDAxNOqx5fTY/vmhf1cv71x1MDAxNLYudz63y92Ugbaf1GP+dnNS73o1m1x1MDAxN/VYLy3kRT3WS1x1MDAwYp7e3lGV/Vx1MDAxZLYjhHNRLj3Vi8VcdTAwMTW2hbyYx8LFncY8klx1MDAxZlx1MDAxODU7+PT9mYfSLJV5IFwiIFczOfuYabaeV1x1MDAxNbjwLOCiuSWWXHUwMDAzXFySmEdsaPSVeSBcZlxmXGKxXHUwMDFjgMtcdTAwMTRrjFx1MDAwMau8mcd2XHUwMDFmlb//unntXHUwMDAwNE9mXHUwMDE3mI38bEhcdTAwMWaq8DpO8HZ+MVx1MDAwNXyP84txUTPdMJtDQNem+lwiVlxuXHUwMDBijsXsJOLx+aDTPH1+vmS03PN4VTS/PFx1MDAxN1fcXHUwMDE3wcgspFx1MDAxNXDrXHUwMDE3X1x1MDAxY+NcdTAwMTDYklpcIqXRanBcYkqxXHUwMDE2XFzFRiOWwiGuXHUwMDBlXHUwMDBmzz5cXD8/bFx1MDAxZlx1MDAxNC78QtdcdTAwMGWfPznHPznEojhETupdr2bz4lx1MDAxMOulhbw4xHppIS9cdTAwMGWxXlrIq9iycHGnUZPkXHUwMDA3Rs1cdTAwMGU+LVx1MDAxMlxmZmKiNGpCXHUwMDExkuOnI2pcIjlSfJ6iSLaeV1x1MDAxM1x1MDAwZUnEMuBcdTAwMTAwXHUwMDEzslx1MDAxYzg0XHUwMDFiM8GaKVx1MDAwMmLKXHUwMDFjJkuvIDU58ZfNTKYg+lRm8lwiaaZcdTAwMTO+hKxcdTAwMDQvlFx1MDAwNKU6IcdSKCb57E6YXVx0X00nXHUwMDA0hG9cdTAwMTFwQLAyjlx1MDAxMJd8xFx1MDAwYlx1MDAxOcKWkERx+MC0XCKxQu6i3Vx1MDAxMFlAOFx1MDAxMMZcXEumheI0NnQzdEuhISgwXCKgZ1x1MDAwNHiimvRSjLjCwHDo/F7aXHUwMDE3dtle2lx1MDAwZe0g3HabNbdZh4tRrntdjDPLPMS+X1dcdTAwMWbbfTVcIlx1MDAwMb1IJSeIKOgyLWN31e2W4XpcdTAwMTYolzGEjL4pkXhwwzDnbjrN2nSZskuVMZlcbiBcdTAwMTSViMBcdTAwMTNcdMdA9LlSfEIqYlEphVx1MDAwNsJJNIJAKyak8ux2uOM3XHUwMDFhblxiyj/z3WY4ruS+NreMt986dm38KrxV/Np4WGiZXHUwMDE2R/lp9Gkj8pv+l+HnP39NvjvdnM0xYchRe+/if+eOaJjg1MlcdTAwMTbgXHUwMDE1nDGsZ1x1MDAxZvHMhoUrXHUwMDFh0SS2OIGghSRYN4uiSP/XXHUwMDEyWVx1MDAxYZCVMkNcdTAwMWKaxIZDXHUwMDE3XHUwMDBlKzC1qFCKQWDlbGTSRzTogiymXHUwMDAwXHUwMDAyMZCTgj3EJn5cZuq4mijMwWt+rGg2c+RcdTAwMDD9UI6FMj5EXHUwMDA0pFx1MDAxZlx1MDAxNvOiQdzAkKKwXHUwMDE5NWaIIU5cdTAwMDR7YzTLhFx1MDAxZqMyXHUwMDExqjX4rEJIKFxuXHUwMDFkPSlcdTAwMTO4P9dcZnPCzVxuO1x1MDAxMHytg1m6LZtjwopcdTAwMTdcdTAwMTbLIGWkwjOALIRcbj7HvJPs8tuKXHUwMDA2M8aBIyHMTWKkMjZA/lx1MDAxMs2IxbAwI7VcXFx1MDAwYinHxVpcXDTTXHUwMDFh+phA0NRcdTAwMWNDOoutXHUwMDFhioJcdTAwMTmz4CqVcFx1MDAxZsZE0IlZZVx1MDAxOEJcdTAwMTlcdTAwMDNcdTAwMGbG/9ZoVjCggHFMXHUwMDAwalx1MDAxM1xiaYyihHBGLUhcXFx1MDAwMJOAXHUwMDFlQ1xu44K+LZ5lXHUwMDE3ekal4lx1MDAxYfA/1lx1MDAxMlx1MDAxM4hqmuBcdKG4XHUwMDA1uFx1MDAxYVwirJTIyDUp0jpFs0KqMZtjwoznjGaZRTBcdTAwMTnjJWNcdTAwMDGNaCyQmme1nfZKl6Wb+6fS9dVFrfaJ2Fx1MDAxN1x1MDAxZj6ueDhjZlx1MDAwNY8wuYJAzkYyXG4j/eFcYlx1MDAwMbpcdTAwMDeEKlx1MDAwMbVpXHUwMDA0d+RcdTAwMDfOYlWtKICBbPBoKidgXHUwMDE4QGZcdLQknmyWUlx1MDAwYrvrlu9cXO+ycdTuak4qXi0sep++fbD3+Pzw+br49DlcZnadwu1F+7MkbFx1MDAxMTP2161cdTAwMTaWk3pzalZ23IPiw1x1MDAxZW5cdTAwMDR7f9V2w1x1MDAwZemceovQXHUwMDAyQ0FcdTAwMGI3O9WTq2LpRF3tXvnHYWl1tVu5a2ydeVx1MDAxZIF5WNiu6XrpxH/qra64i197PlDD58NcdTAwMTLx2lx1MDAxN6p1etxmndZt6ZBfflO70yoryVxuipp9zY5cdTAwMGJEYpmJNq2yXHUwMDAySDiVNCDgYkQxOnuWzTaLXHUwMDE1zbJmtUl6ltXCgoSGXHUwMDAx5+mcsyxLyLIk0v1rdqVUamqw1uKza96VlVj9YEpl5aJcdTAwMWE4TvN9Sk1Fjty/sJrKXHUwMDE0jDheU1x1MDAxOcqY6XjpfF2lbyZBgD4gTdjs2z9lR87V9DzOsVx1MDAwNWRcdTAwMWNcXItcdTAwMTGqXGJcdTAwMWatpsB3S0klluB5XHUwMDE4XHUwMDAzi1SUaOCZSopYrXnoiIybMlx1MDAwMFx1MDAwN8ebwLtcdTAwMDQhRpjW4lxyeHeVmXq2O2zEx/i4llx1MDAwMuJcdTAwMTJcdTAwMDeWLijhMYo4oMTMQlx1MDAxY1RcZtpcdTAwMWJcXJmTn89R0Vx1MDAxMVx1MDAxOFx0XHUwMDA19JxhXHSBMqGgXHUwMDAzsiCgsohzbpKb1lx1MDAxM1wirVx1MDAxMz9PN15zxMw2auhd/O/bpqfSdGpcdTAwMGVsUYE7izm217huPV2497RyfcnK4fn+XHUwMDE26dyffl7x4EWJtKggXHUwMDFjSHpcdTAwMDJsMJvXYWVqwfqFnH/33eugQ1x1MDAxOFn+XCK34z1WPFdq9+amVun2yrf+VlhOISA/J6jO325O6l2vZnPbvW6ttJBTs7ntXpePuHmNIOQk7nn9oX7uXFw9XHUwMDEwLb94lyeNtt1yUsZRXHUwMDE2tdle4otEzb6Cg+89MEEoSV1cdTAwMDFDMCFCKI5nh1x1MDAxONn9t6JcdTAwMTCDkiyIQVx1MDAwMOsuXHRizLbfXHUwMDFlxpIjSlx1MDAwMX2v39DEN+6313ZrTsVcdTAwMGWWvf/FXHUwMDE04DzThnsjome66pRcdTAwMTVr6ZtjMsaAiDE++3DG9lHTx1xi6JdDjypP5Vx1MDAxMr7URSfFXcfcbtRZx6csvdVZMZrqrVhbXGZpRvCLt45OPmCCWIJcbo6Y+PbRjF8wqSgllEpwVZkweJGw5Vx1MDAwNSZcdTAwMTKx+Fx1MDAwNNalsIHzvaND9CRLN6dXLr66PdNfetf7P9nAothATupdr2bzYlx1MDAwM+ulhbyWq62XXHUwMDE28lqulpO4eW15sV6dtnxOlPxcIjOKe1rd3T65bqn60Ze7Y7vje0/bjM0m7uDTd+daXHUwMDE00dTldUT3t9mLIYVp2C3bLL5cdTAwMGLVmlx1MDAwMb1cdTAwMTGWhd6ktPCi0NuU0dxcdTAwMDT8lrC1uWRMXHUwMDAzXHUwMDA0zFx1MDAwMb+tXHUwMDBl01x1MDAxYSxaM+de+MrXzcNmO7Q9b9k8a1xuXHUwMDFkSVltlyp4ppOmXHUwMDE3jFx1MDAxOUl1Ulx1MDAwNCleUDVcdTAwMDfDyp5cdTAwMTKzmlx1MDAwM1winHFLMCp14oBcYuHCMsMgS3BSxixJiOCCJMzZYNriQjAkJkvFVCBwJPKWXUJ+iEpxwVxmWWlcIoSmXHUwMDEwZzWmOKk8yyjlmsoppeJR+deoYltIMlx1MDAxZnPEXGYnauNd/O/8MYOn1mk55cpUjGfP65m4bDUjXHUwMDA2w8Si4OuvQ6hcdCt2XHUwMDExNsaY91x1MDAxY1x1MDAxM2RcdTAwMTFuZvQogYmAXGJcdTAwMTCb2Fx1MDAxNa3XNZNcdTAwMTckVlx0U6qppGZcdTAwMDe2t+T7VVx1MDAwZVx1MDAxY9njl6OBXHUwMDAzXHUwMDExJFx1MDAxMWdUSCRcdTAwMTiOZlx1MDAxN1xmXHUwMDAzh7RcdTAwMTQliGIzXHUwMDE3XHUwMDA3joR1XHUwMDE3M001yc71XHUwMDFiI4uHQVx1MDAxY7PGiygzUYlINLlOd3K18DqFqnSrNUdkr3OGq3RcdTAwMWWSselcdTAwMTnHRFx1MDAxMqlm5yFS2yd35O7sYLdXrX/0xd72WUWkxKtcdTAwMTVcdTAwMTlDJlx1MDAxNFx1MDAwMI7Go9jhZb8z9mrd31TmyVx1MDAxODkmSdPeJiFcZlx1MDAwNtJcdTAwMDH2IN8y2+1bho5Xi2YnXHUwMDExm9lnt142a05gXGLLRnjrtjde9lx1MDAxZE+e6qpGfjz7VNfQb6WxmZHXXHUwMDFhpy7Jor1ccnxA0EhcdTAwMDVcdTAwMWbIJGQ5x+L67P5fSW+mWFlcdTAwMTC2zNbiXHUwMDE0Y6FGx1x1MDAxNFx1MDAwNMA9zNToXG6qMY92hJRv92hNLU0pXHUwMDE1ZjNcdTAwMWUmaGxCXnxcdTAwMTVX4lx1MDAxYS6zmo/QkZLdXHUwMDBmXHUwMDAxOLJzwijggNxcdTAwMGXAXHUwMDExXHUwMDExXGKMWkNATli4XHUwMDBl0VrNwlW+XHUwMDE5a1x1MDAxOHGkJmbSqlZcdTAwMDSeiVTiYliwMqzM/zvDbFx1MDAwMrXe2CPVes1ReDXcNOTxbtDupt1qXYRgZcNeXHUwMDAwQ3Zrg5BcdTAwMWS93GbHdbrbXHTuddM/TFx1MDAwNOyr0Vx1MDAwNFx1MDAxYce84t//vPvn/yxzRNUifQ== Container( id=\"dialog\")Horizontal( classes=\"buttons\")Button(\"Yes\")Button(\"No\")Screen()Container( id=\"sidebar\")Button( \"Install\")Underline this buttonWe can use the following CSS to style all buttons which have a parent with an ID of sidebar
:
#sidebar > Button {\ntext-style: underline;\n}\n
"},{"location":"guide/CSS/#specificity","title":"Specificity","text":"It is possible that several selectors match a given widget. If the same style is applied by more than one selector then Textual needs a way to decide which rule wins. It does this by following these rules:
The selector with the most IDs wins. For instance #next
beats .button
and #dialog #next
beats #next
. If the selectors have the same number of IDs then move to the next rule.
The selector with the most class names wins. For instance .button.success
beats .success
. For the purposes of specificity, pseudo classes are treated the same as regular class names, so .button:hover
counts as 2 class names. If the selectors have the same number of class names then move to the next rule.
The selector with the most types wins. For instance Container Button
beats Button
.
The specificity rules are usually enough to fix any conflicts in your stylesheets. There is one last way of resolving conflicting selectors which applies to individual rules. If you add the text !important
to the end of a rule then it will \"win\" regardless of the specificity.
Warning
Use !important
sparingly (if at all) as it can make it difficult to modify your CSS in the future.
Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons:
Button:hover {\nbackground: blue !important;\n}\n
"},{"location":"guide/CSS/#css-variables","title":"CSS Variables","text":"You can define variables to reduce repetition and encourage consistency in your CSS. Variables in Textual CSS are prefixed with $
. Here's an example of how you might define a variable called $border
:
$border: wide green;\n
With our variable assigned, we can write $border
and it will be substituted with wide green
. Consider the following snippet:
#foo {\nborder: $border;\n}\n
This will be translated into:
#foo {\nborder: wide green;\n}\n
Variables allow us to define reusable styling in a single place. If we decide we want to change some aspect of our design in the future, we only have to update a single variable.
Note
Variables can only be used in the values of a CSS declaration. You cannot, for example, refer to a variable inside a selector.
Variables can refer to other variables. Let's say we define a variable $success: lime;
. Our $border
variable could then be updated to $border: wide $success;
, which will be translated to $border: wide lime;
.
Actions are allow-listed functions with a string syntax you can embed in links and bind to keys. In this chapter we will discuss how to create actions and how to run them.
"},{"location":"guide/actions/#action-methods","title":"Action methods","text":"Action methods are methods on your app or widgets prefixed with action_
. Aside from the prefix these are regular methods which you could call directly if you wished.
Information
Action methods may be coroutines (defined with the async
keyword).
Let's write an app with a simple action.
actions01.pyfrom textual.app import App\nfrom textual import events\nclass ActionsApp(App):\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\ndef on_key(self, event: events.Key) -> None:\nif event.key == \"r\":\nself.action_set_background(\"red\")\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
The action_set_background
method is an action which sets the background of the screen. The key handler above will call this action if you press the R key.
Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an action string. For instance, the string \"set_background('red')\"
is an action string which would call self.action_set_background('red')
.
The following example replaces the immediate call with a call to run_action() which parses an action string and dispatches it to the appropriate method.
actions02.pyfrom textual import events\nfrom textual.app import App\nclass ActionsApp(App):\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nasync def on_key(self, event: events.Key) -> None:\nif event.key == \"r\":\nawait self.run_action(\"set_background('red')\")\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
Note that the run_action()
method is a coroutine so on_key
needs to be prefixed with the async
keyword.
You will not typically need this in a real app as Textual will run actions in links or key bindings. Before we discuss these, let's have a closer look at the syntax for action strings.
"},{"location":"guide/actions/#syntax","title":"Syntax","text":"Action strings have a simple syntax, which for the most part replicates Python's function call syntax.
Important
As much as they look like Python code, Textual does not call Python's eval
function to compile action strings.
Action strings have the following format:
\"bell\"
will call action_bell()
.set_background(\"red\")
will call action_set_background(\"red\")
.If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other Python symbols.
Consequently \"set_background('blue')\"
is a valid action string, but \"set_background(new_color)\"
is not \u2014 because new_color
is a variable and not a literal.
Actions may be embedded as links within console markup. You can create such links with a @click
tag.
The following example mounts simple static text with embedded action links.
actions03.pyOutput actions03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('red')]Red[/]\n[@click=set_background('green')]Green[/]\n[@click=set_background('blue')]Blue[/]\n\"\"\"\nclass ActionsApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
ActionsApp Set\u00a0your\u00a0background Red Green Blue
When you click any of the links, Textual runs the \"set_background\"
action to change the background to the given color.
Textual will run actions bound to keys. The following example adds key bindings for the R, G, and B keys which call the \"set_background\"
action.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('red')]Red[/]\n[@click=set_background('green')]Green[/]\n[@click=set_background('blue')]Blue[/]\n\"\"\"\nclass ActionsApp(App):\nBINDINGS = [\n(\"r\", \"set_background('red')\", \"Red\"),\n(\"g\", \"set_background('green')\", \"Green\"),\n(\"b\", \"set_background('blue')\", \"Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
ActionsApp Set\u00a0your\u00a0background Red Green Blue
If you run this example, you can change the background by pressing keys in addition to clicking links.
See the previous section on input for more information on bindings.
"},{"location":"guide/actions/#namespaces","title":"Namespaces","text":"Textual will look for action methods in the class where they are defined (App, Screen, or Widget). If we were to create a custom widget it can have its own set of actions.
The following example defines a custom widget with its own set_background
action.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\n[b]Set your background[/b]\n[@click=set_background('cyan')]Cyan[/]\n[@click=set_background('magenta')]Magenta[/]\n[@click=set_background('yellow')]Yellow[/]\n\"\"\"\nclass ColorSwitcher(Static):\ndef action_set_background(self, color: str) -> None:\nself.styles.background = color\nclass ActionsApp(App):\nCSS_PATH = \"actions05.tcss\"\nBINDINGS = [\n(\"r\", \"set_background('red')\", \"Red\"),\n(\"g\", \"set_background('green')\", \"Green\"),\n(\"b\", \"set_background('blue')\", \"Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield ColorSwitcher(TEXT)\nyield ColorSwitcher(TEXT)\ndef action_set_background(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = ActionsApp()\napp.run()\n
actions05.tcssScreen {\nlayout: grid;\ngrid-size: 1;\ngrid-gutter: 2 4;\ngrid-rows: 1fr;\n}\nColorSwitcher {\nheight: 100%;\nmargin: 2 4;\n}\n
There are two instances of the custom widget mounted. If you click the links in either of them it will changed the background for that widget only. The R, G, and B key bindings are set on the App so will set the background for the screen.
You can optionally prefix an action with a namespace, which tells Textual to run actions for a different object.
Textual supports the following action namespaces:
app
invokes actions on the App.screen
invokes actions on the screen.In the previous example if you wanted a link to set the background on the app rather than the widget, we could set a link to app.set_background('red')
.
Textual supports the following builtin actions which are defined on the app.
Ths chapter discusses how to use Textual's animation system to create visual effects such as movement, blending, and fading.
"},{"location":"guide/animation/#animating-styles","title":"Animating styles","text":"Textual's animator can change an attribute from one value to another in fixed increments over a period of time. You can apply animations to styles such as offset
to move widgets around the screen, and opacity
to create fading effects.
Apps and widgets both have an animate method which will animate properties on those objects. Additionally, styles
objects have an identical animate
method which will animate styles.
Let's look at an example of how we can animate the opacity of a widget to make it fade out. The following example app contains a single Static
widget which is immediately animated to an opacity of 0.0
(making it invisible) over a duration of two seconds.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass AnimationApp(App):\ndef compose(self) -> ComposeResult:\nself.box = Static(\"Hello, World!\")\nself.box.styles.background = \"red\"\nself.box.styles.color = \"black\"\nself.box.styles.padding = (1, 2)\nyield self.box\ndef on_mount(self):\nself.box.styles.animate(\"opacity\", value=0.0, duration=2.0)\nif __name__ == \"__main__\":\napp = AnimationApp()\napp.run()\n
The animator updates the value of the opacity
attribute on the styles
object in small increments over two seconds. Here's what the output will look like after each half a second.
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
AnimationApp Hello,\u00a0World!
"},{"location":"guide/animation/#duration-and-speed","title":"Duration and Speed","text":"When requesting an animation you can specify a duration or speed. The duration is how long the animation should take in seconds. The speed is how many units a value should change in one second. For instance, if you animate a value at 0 to 10 with a speed of 2, it will complete in 5 seconds.
"},{"location":"guide/animation/#easing-functions","title":"Easing functions","text":"The easing function determines the journey a value takes on its way to the target value. It could move at a constant pace, or it might start off slow then accelerate towards its final value. Textual supports a number of easing functions.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1XWVPjRlx1MDAxMH7nV1DeVyzmPrYqlYJcdTAwMDVcdTAwMTJcdTAwMDJLXHUwMDBlczib2ofBXHUwMDFh21x1MDAxM8uSkMZcdTAwMDaW4r+nJVx1MDAxM48vwrnUblX0YGumZ3q+6e6vu3Wztr7e8Ne5bbxfb9irjklcXFxcmMvGRjU/tkXpslx1MDAxNESkXHUwMDFll9mo6NQr+97n5fvNzaEpXHUwMDA21ueJ6dho7MqRSUo/il1cdTAwMTZ1suGm83ZY/lj9XHUwMDFlmaH9Ic+GsS+icEjTxs5nxeQsm9ihTX1cdNr/gvH6+k39O4POXHUwMDE0RTZcdTAwMDFWT1x1MDAwN3BKL05cdTAwMWVlaY1cdTAwMTMjrjSjWuDpXG5X7sBR3sYg7lx1MDAwMlxcXHUwMDFiJNVU42Tvl7TZ/nX76rh1fDDO+4j+dNVcdKd2XZK0/HUysYLp9EeFXHLS0lx1MDAxN9nAnrnY9/812sz8dF9syj5cdTAwMDCYiots1OuntqwuXHUwMDFmkGa56Th/XV9cdTAwMDNNZ03aq5WEmStcdTAwMThRwSMqXHUwMDE0XHUwMDE3XGZRQZhUU2m1n2lcdTAwMWNxXHUwMDAxcsyRRIxcdTAwMTK5gOxDloBcdTAwMWZcdTAwMDDZO1Q/XHUwMDAx2rnpXGZ6gC+Np2t8YdIyN1x1MDAwNXgrrLu8uzOONGZIXHUwMDEwPpX0rev1fWVcdTAwMGXCXCIlg6C0tVx1MDAxM7DCmiuhUZBUXHUwMDA35vtxXHUwMDFkXHUwMDBin1x1MDAxN43YN0V+Z6tGXHJsXHUwMDA2bDXcnVx0pLB5lMdm4nQsXHUwMDA0x1xcXHUwMDEzWtlqKk9cXDpcdTAwMDBhOkqSMJd1XHUwMDA2K+Kk9Kbw2y6NXdpb3GLT+Fx1MDAxZUliSv8hXHUwMDFiXHUwMDBlnVx1MDAwN1x1MDAxOL9lLvWLK2q9W1WQ962JV2ielS2yIa80XHUwMDA28lRPeFtcdTAwMGbRUlx1MDAwZqbvnzdWrm4uebGevXNg2L02+3+78UTa4pkwXFzkraCaIaxkYPZDvKUnh1x1MDAwN7y9d/7n3uFheaJHgra3+t9cdTAwMDFvRUQgXHUwMDE2pdRaXHUwMDAwbdlcdTAwMTJvMaFIYES1XHUwMDE0mi9cdTAwMDB7PdpShFwiKSmjclx1MDAwNXFxJCFtUEyWuEs5Ykpr9sbUhdAgwY7/U3dmwVxuR1ZPM/jwifRcdTAwMDXr2pXsRfL+skuYpkpjXHUwMDFj8utD9G3r38nu2cfty7//6F1cZoa7R1x1MDAwM3aQvDJ9y1xm+o7Xr7pUXHUwMDAwNSWjeJagNX2VjJCQXFxTRojmSC9cdTAwMDBcdTAwMGL0VYJY3XlcdH1pJKSCUsqpRFqH8lx1MDAxZqovXHUwMDAyMFx1MDAwNHMhJWGKXHUwMDEyvURmgpnAXHUwMDAyIf6mbFZcbmCrXHUwMDAw+Htj85zsVamMMYso4lpqSoRcdTAwMTYqNLHV06QogsZcdTAwMTZxJjWD5lx0yVx1MDAwN/VpXHUwMDEyKVwiJHTDoJJzzOb0YYVcIkisWFIou1x1MDAxMlx1MDAwYvVwqrkn6Gp1y/H2xMTj7ZVflXjEfVmnSjpcdTAwMDRcbkPoKlx1MDAxZUo6O+3Wyadd0vz482D3tDX+1L44z/Hzklx1MDAwZV5kx1dLOuDsiDBcdTAwMDZdPuJcdTAwMDRcbvB8q89cdHiRay5cdTAwMTTl0PD/R855acvAXHUwMDAyrJBklrt7wYggirFw2UcnlbJcdTAwMWE8M6lQKlmIlCcklW6W+pb7Ulx1MDAwN1x1MDAxNJqb3TNDl1xcz/mtjtHKUG5oZy1Z2rpsVlx1MDAxZjdza7dcdTAwMTLXS+uqarvzwe1cdTAwMWR8XHUwMDE1T8U+m7l3XHUwMDA3zjagrthfyjdZ4XouNcnxLI5nsYrcW8zhq1x1MDAxMVPIXG6Pp9Vps9y/lGdcdTAwMTe90+ukvfOlO+52bfNbp1x1MDAxNYH0x1x1MDAxNaZcXCqFkObznTiQKVx1MDAxMlxcK2jEXHUwMDE5NFLQkn81XnHyKF5BktaKMvyMYv1cdTAwMTJesSrLvlx1MDAxOa/GJlx1MDAxOX1cdTAwMTPEmlx1MDAwMJkwa+2uXCI2TJ63PNhcdTAwMDdcdTAwMTZMeFx1MDAwNi5w8d0lg7rG2NnL7eUoeNetn0przdaKXHUwMDE4tnLAze3a7T8uXFzYXHUwMDFjIn0= timevalueRun the following from the command prompt to preview them.
textual easing\n
You can specify which easing method to use via the easing
parameter on the animate
method. The default easing method is \"in_out_cubic\"
which accelerates and then decelerates to produce a pleasing organic motion.
Note
The textual easing
preview requires the textual-dev
package to be installed (using pip install textual-dev
).
You can pass a callable to the animator via the on_complete
parameter. Textual will run the callable when the animation has completed.
You can delay the start of an animation with the delay
parameter of the animate
method. This parameter accepts a float
value representing the number of seconds to delay the animation by. For example, self.box.styles.animate(\"opacity\", value=0.0, duration=2.0, delay=5.0)
delays the start of the animation by five seconds, meaning the animation will start after 5 seconds and complete 2 seconds after that.
In this chapter we will cover how to use Textual's App class to create an application. Just enough to get you up to speed. We will go in to more detail in the following chapters.
"},{"location":"guide/app/#the-app-class","title":"The App class","text":"The first step in building a Textual app is to import the App class and create a subclass. Let's look at the simplest app class:
from textual.app import App\nclass MyApp(App):\npass\n
"},{"location":"guide/app/#the-run-method","title":"The run method","text":"To run an app we create an instance and call run().
simple02.pyfrom textual.app import App\nclass MyApp(App):\npass\nif __name__ == \"__main__\":\napp = MyApp()\napp.run()\n
Apps don't get much simpler than this\u2014don't expect it to do much.
Tip
The __name__ == \"__main__\":
condition is true only if you run the file with python
command. This allows us to import app
without running the app immediately. It also allows the devtools run command to run the app in development mode. See the Python docs for more information.
If we run this app with python simple02.py
you will see a blank terminal, something like the following:
MyApp
When you call App.run() Textual puts the terminal in to a special state called application mode. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the screen).
If you hit Ctrl+C Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored.
Tip
A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the Option key. See the documentation for your terminal software for how to select text in application mode.
"},{"location":"guide/app/#events","title":"Events","text":"Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with on_
followed by the name of the event.
One such event is the mount event which is sent to an application after it enters application mode. You can respond to this event by defining a method called on_mount
.
Info
You may have noticed we use the term \"send\" and \"sent\" in relation to event handler methods in preference to \"calling\". This is because Textual uses a message passing system where events are passed (or sent) between components. See events for details.
Another such event is the key event which is sent when the user presses a key. The following example contains handlers for both those events:
event01.pyfrom textual.app import App\nfrom textual import events\nclass EventApp(App):\nCOLORS = [\n\"white\",\n\"maroon\",\n\"red\",\n\"purple\",\n\"fuchsia\",\n\"olive\",\n\"yellow\",\n\"navy\",\n\"teal\",\n\"aqua\",\n]\ndef on_mount(self) -> None:\nself.screen.styles.background = \"darkblue\"\ndef on_key(self, event: events.Key) -> None:\nif event.key.isdecimal():\nself.screen.styles.background = self.COLORS[int(event.key)]\nif __name__ == \"__main__\":\napp = EventApp()\napp.run()\n
The on_mount
handler sets the self.screen.styles.background
attribute to \"darkblue\"
which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
EventApp
The key event handler (on_key
) has an event
parameter which will receive a Key instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
Note
It is unusual (but not unprecedented) for a method's parameters to affect how it is called. Textual accomplishes this by inspecting the method prior to calling it.
Some events contain additional information you can inspect in the handler. The Key event has a key
attribute which is the name of the key that was pressed. The on_key
method above uses this attribute to change the background color if any of the keys from 0 to 9 are pressed.
Textual is powered by Python's asyncio framework which uses the async
and await
keywords.
Textual knows to await your event handlers if they are coroutines (i.e. prefixed with the async
keyword). Regular functions are generally fine unless you plan on integrating other async libraries (such as httpx for reading data from the internet).
Tip
For a friendly introduction to async programming in Python, see FastAPI's concurrent burgers article.
"},{"location":"guide/app/#widgets","title":"Widgets","text":"Widgets are self-contained components responsible for generating the output for a portion of the screen. Widgets respond to events in much the same way as the App. Most apps that do anything interesting will contain at least one (and probably many) widgets which together form a User Interface.
Widgets can be as simple as a piece of text, a button, or a fully-fledged component like a text editor or file browser (which may contain widgets of their own).
"},{"location":"guide/app/#composing","title":"Composing","text":"To add widgets to your app implement a compose()
method which should return an iterable of Widget
instances. A list would work, but it is convenient to yield widgets, making the method a generator.
The following example imports a builtin Welcome
widget and yields it from App.compose()
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Welcome\nclass WelcomeApp(App):\ndef compose(self) -> ComposeResult:\nyield Welcome()\ndef on_button_pressed(self) -> None:\nself.exit()\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
When you run this code, Textual will mount the Welcome
widget which contains Markdown content and a button:
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Notice the on_button_pressed
method which handles the Button.Pressed event sent by a button contained in the Welcome
widget. The handler calls App.exit() to exit the app.
While composing is the preferred way of adding widgets when your app starts it is sometimes necessary to add new widget(s) in response to events. You can do this by calling mount() which will add a new widget to the UI.
Here's an app which adds a welcome widget in response to any key press:
widgets02.pyfrom textual.app import App\nfrom textual.widgets import Welcome\nclass WelcomeApp(App):\ndef on_key(self) -> None:\nself.mount(Welcome())\ndef on_button_pressed(self) -> None:\nself.exit()\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
When you first run this you will get a blank screen. Press any key to add the welcome widget. You can even press a key multiple times to add several widgets.
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503\u2582\u2582 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0 \u258c\u00a0that\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0 \u258c\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn \u258c\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 \u258c\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0OK\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#awaiting-mount","title":"Awaiting mount","text":"When you mount a widget, Textual will mount everything the widget composes. Textual guarantees that the mounting will be complete by the next message handler, but not immediately after the call to mount()
. This may be a problem if you want to make any changes to the widget in the same message handler.
Let's first illustrate the problem with an example. The following code will mount the Welcome widget in response to a key press. It will also attempt to modify the Button in the Welcome widget by changing its label from \"OK\" to \"YES!\".
from textual.app import App\nfrom textual.widgets import Button, Welcome\nclass WelcomeApp(App):\ndef on_key(self) -> None:\nself.mount(Welcome())\nself.query_one(Button).label = \"YES!\" # (1)!\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
query_one
method.If you run this example, you will find that Textual raises a NoMatches exception when you press a key. This is because the mount process has not yet completed when we attempt to change the button.
To solve this we can optionally await the result of mount()
, which requires we make the function async
. This guarantees that by the following line, the Button has been mounted, and we can change its label.
from textual.app import App\nfrom textual.widgets import Button, Welcome\nclass WelcomeApp(App):\nasync def on_key(self) -> None:\nawait self.mount(Welcome())\nself.query_one(Button).label = \"YES!\"\nif __name__ == \"__main__\":\napp = WelcomeApp()\napp.run()\n
Here's the output. Note the changed button text:
WelcomeApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Welcome!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b Textual\u00a0is\u00a0a\u00a0TUI,\u00a0or\u00a0Text\u00a0User\u00a0Interface,\u00a0framework\u00a0for\u00a0Python\u00a0inspired\u00a0by\u00a0\u00a0 modern\u00a0web\u00a0development.\u00a0We\u00a0hope\u00a0you\u00a0enjoy\u00a0using\u00a0Textual! Dune\u00a0quote \u258c\u00a0\"I\u00a0must\u00a0not\u00a0fear.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0little-death\u00a0that \u258c\u00a0brings\u00a0total\u00a0obliteration.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass \u258c\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner \u258c\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only \u258c\u00a0I\u00a0will\u00a0remain.\" \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0YES!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#exiting","title":"Exiting","text":"An app will run until you call App.exit() which will exit application mode and the run method will return. If this is the last line in your code you will return to the command prompt.
The exit method will also accept an optional positional value to be returned by run()
. The following example uses this to return the id
(identifier) of a clicked button.
from textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
Running this app will give you the following:
QuestionApp Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Clicking either of those buttons will exit the app, and the run()
method will return either \"yes\"
or \"no\"
depending on button clicked.
You may have noticed that we subclassed App[str]
rather than the usual App
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
The addition of [str]
tells mypy that run()
is expected to return a string. It may also return None
if App.exit() is called without a return value, so the return type of run
will be str | None
. Replace the str
in [str]
with the type of the value you intend to call the exit method with.
Note
Type annotations are entirely optional (but recommended) with Textual.
"},{"location":"guide/app/#return-code","title":"Return code","text":"When you exit a Textual app with App.exit()
, you can optionally specify a return code with the return_code
parameter.
What are return codes?
Returns codes are a standard feature provided by your operating system. When any application exits it can return an integer to indicate if it was successful or not. A return code of 0
indicates success, any other value indicates that an error occurred. The exact meaning of a non-zero return code is application-dependant.
When a Textual app exits normally, the return code will be 0
. If there is an unhandled exception, Textual will set a return code of 1
. You may want to set a different value for the return code if there is error condition that you want to differentiate from an unhandled exception.
Here's an example of setting a return code for an error condition:
if critical_error:\nself.exit(return_code=4, message=\"Critical error occurred\")\n
The app's return code can be queried with app.return_code
, which will be None
if it hasn't been set, or an integer.
Textual won't explicitly exit the process. To exit the app with a return code, you should call sys.exit
. Here's how you might do that:
if __name__ == \"__main__\"\napp = MyApp()\napp.run()\nimport sys\nsys.exit(app.return_code or 0)\n
"},{"location":"guide/app/#css","title":"CSS","text":"Textual apps can reference CSS files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy).
Info
Textual apps typically use the extension .tcss
for external CSS files to differentiate them from browser (.css
) files.
The chapter on Textual CSS describes how to use CSS in detail. For now let's look at how your app references external CSS files.
The following example enables loading of CSS by adding a CSS_PATH
class variable:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button, Label\nclass QuestionApp(App[str]):\nCSS_PATH = \"question02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
Note
We also added an id
to the Label
, because we want to style it in the CSS.
If the path is relative (as it is above) then it is taken as relative to where the app is defined. Hence this example references \"question01.tcss\"
in the same directory as the Python code. Here is that CSS file:
Screen {\nlayout: grid;\ngrid-size: 2;\ngrid-gutter: 2;\npadding: 2;\n}\n#question {\nwidth: 100%;\nheight: 100%;\ncolumn-span: 2;\ncontent-align: center bottom;\ntext-style: bold;\n}\nButton {\nwidth: 100%;\n}\n
When \"question02.py\"
runs it will load \"question02.tcss\"
and update the app and widgets accordingly. Even though the code is almost identical to the previous sample, the app now looks quite different:
QuestionApp Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/app/#classvar-css","title":"Classvar CSS","text":"While external CSS files are recommended for most applications, and enable some cool features like live editing, you can also specify the CSS directly within the Python code.
To do this set a CSS
class variable on the app to a string containing your CSS.
Here's the question app with classvar CSS:
question03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Label, Button\nclass QuestionApp(App[str]):\nCSS = \"\"\"\n Screen {\n layout: grid;\n grid-size: 2;\n grid-gutter: 2;\n padding: 2;\n }\n #question {\n width: 100%;\n height: 100%;\n column-span: 2;\n content-align: center bottom;\n text-style: bold;\n }\n Button {\n width: 100%;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = QuestionApp()\nreply = app.run()\nprint(reply)\n
"},{"location":"guide/app/#title-and-subtitle","title":"Title and subtitle","text":"Textual apps have a title
attribute which is typically the name of your application, and an optional sub_title
attribute which adds additional context (such as the file your are working on). By default, title
will be set to the name of your App class, and sub_title
is empty. You can change these defaults by defining TITLE
and SUB_TITLE
class variables. Here's an example of that:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button, Header, Label\nclass MyApp(App[str]):\nCSS_PATH = \"question02.tcss\"\nTITLE = \"A Question App\"\nSUB_TITLE = \"The most important question\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\nif __name__ == \"__main__\":\napp = MyApp()\nreply = app.run()\nprint(reply)\n
Note that the title and subtitle are displayed by the builtin Header widget at the top of the screen:
A\u00a0Question\u00a0App \u2b58A\u00a0Question\u00a0App\u00a0\u2014\u00a0The\u00a0most\u00a0important\u00a0question Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
You can also set the title attributes dynamically within a method of your app. The following example sets the title and subtitle in response to a key press:
question_title02.pyfrom textual.app import App, ComposeResult\nfrom textual.events import Key\nfrom textual.widgets import Button, Header, Label\nclass MyApp(App[str]):\nCSS_PATH = \"question02.tcss\"\nTITLE = \"A Question App\"\nSUB_TITLE = \"The most important question\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(\"Do you love Textual?\", id=\"question\")\nyield Button(\"Yes\", id=\"yes\", variant=\"primary\")\nyield Button(\"No\", id=\"no\", variant=\"error\")\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(event.button.id)\ndef on_key(self, event: Key):\nself.title = event.key\nself.sub_title = f\"You just pressed {event.key}!\"\nif __name__ == \"__main__\":\napp = MyApp()\nreply = app.run()\nprint(reply)\n
If you run this app and press the T key, you should see the header update accordingly:
A\u00a0Question\u00a0App \u2b58t\u00a0\u2014\u00a0You\u00a0just\u00a0pressed\u00a0t! Do\u00a0you\u00a0love\u00a0Textual? \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Yes\u00a0\u00a0No\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Info
Note that there is no need to explicitly refresh the screen when setting the title attributes. This is an example of reactivity, which we will cover later in the guide.
"},{"location":"guide/app/#whats-next","title":"What's next","text":"In the following chapter we will learn more about how to apply styles to your widgets and app.
"},{"location":"guide/command_palette/","title":"Command Palette","text":"Textual apps have a built-in command palette, which gives users a quick way to access certain functionality within your app.
In this chapter we will explain what a command palette is, how to use it, and how you can add your own commands.
"},{"location":"guide/command_palette/#launching-the-command-palette","title":"Launching the command palette","text":"Press Ctrl + \\
(ctrl and backslash) to invoke the command palette screen, which contains of a single input widget. Textual will suggest commands as you type in that input. Press Up or Down to select a command from the list, and Enter to invoke it.
Commands are looked up via a fuzzy search, which means Textual will show commands that match the keys you type in the same order, but not necessarily at the start of the command. For instance the \"Toggle light/dark mode\" command will be shown if you type \"to\" (for toggle), but you could also type \"dm\" (to match dark mode). This scheme allows the user to quickly get to a particular command with a minimum of key-presses.
Command PaletteCommand Palette after 't'Command Palette after 'td'ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0eCommand\u00a0Palette\u00a0Search... \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0et Toggle\u00a0light/dark\u00a0mode Toggle\u00a0the\u00a0application\u00a0between\u00a0light\u00a0and\u00a0dark\u00a0mode Quit\u00a0the\u00a0application Quit\u00a0the\u00a0application\u00a0as\u00a0soon\u00a0as\u00a0possible Ring\u00a0the\u00a0bell Ring\u00a0the\u00a0terminal's\u00a0'bell' \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
ViewerApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \ud83d\udd0etd Toggle\u00a0light/dark\u00a0mode Toggle\u00a0the\u00a0application\u00a0between\u00a0light\u00a0and\u00a0dark\u00a0mode \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/command_palette/#default-commands","title":"Default commands","text":"Textual apps have the following commands enabled by default:
\"Toggle light/dark mode\"
This will toggle between light and dark mode, by setting App.dark
to either True
or False
.\"Quit the application\"
Quits the application. The equivalent of pressing ++ctrl+C++.\"Play the bell\"
Plays the terminal bell, by calling App.bell
.To add your own command(s) to the command palette, define a command.Provider
class then add it to the COMMANDS
class var on your App
class.
Let's look at a simple example which adds the ability to open Python files via the command palette.
The following example will display a blank screen initially, but if you bring up the command palette and start typing the name of a Python file, it will show the command to open it.
Tip
If you are running that example from the repository, you may want to add some additional Python files to see how the examples works with multiple files.
command01.pyfrom __future__ import annotations\nfrom functools import partial\nfrom pathlib import Path\nfrom rich.syntax import Syntax\nfrom textual.app import App, ComposeResult\nfrom textual.command import Hit, Hits, Provider\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Static\nclass PythonFileCommands(Provider):\n\"\"\"A command provider to open a Python file in the current working directory.\"\"\"\ndef read_files(self) -> list[Path]:\n\"\"\"Get a list of Python files in the current working directory.\"\"\"\nreturn list(Path(\"./\").glob(\"*.py\"))\nasync def startup(self) -> None: # (1)!\n\"\"\"Called once when the command palette is opened, prior to searching.\"\"\"\nworker = self.app.run_worker(self.read_files, thread=True)\nself.python_paths = await worker.wait()\nasync def search(self, query: str) -> Hits: # (2)!\n\"\"\"Search for Python files.\"\"\"\nmatcher = self.matcher(query) # (3)!\napp = self.app\nassert isinstance(app, ViewerApp)\nfor path in self.python_paths:\ncommand = f\"open {str(path)}\"\nscore = matcher.match(command) # (4)!\nif score > 0:\nyield Hit(\nscore,\nmatcher.highlight(command), # (5)!\npartial(app.open_file, path),\nhelp=\"Open this file in the viewer\",\n)\nclass ViewerApp(App):\n\"\"\"Demonstrate a command source.\"\"\"\nCOMMANDS = App.COMMANDS | {PythonFileCommands} # (6)!\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nyield Static(id=\"code\", expand=True)\ndef open_file(self, path: Path) -> None:\n\"\"\"Open and display a file with syntax highlighting.\"\"\"\nsyntax = Syntax.from_path(\nstr(path),\nline_numbers=True,\nword_wrap=False,\nindent_guides=True,\ntheme=\"github-dark\",\n)\nself.query_one(\"#code\", Static).update(syntax)\nif __name__ == \"__main__\":\napp = ViewerApp()\napp.run()\n
There are three methods you can override in a command provider: startup
, search
, and shutdown
. All of these methods should be coroutines (async def
). Only search
is required, the other methods are optional. Let's explore those methods in detail.
The startup
method is called when the command palette is opened. You can use this method as way of performing work that needs to be done prior to searching. In the example, we use this method to get the Python (.py) files in the current working directory.
The search
method is responsible for finding results (or hits) that match the user's input. This method should yield Hit
objects for any command that matches the query
argument.
Exactly how the matching is implemented is up to the author of the command provider, but we recommend using the builtin fuzzy matcher object, which you can get by calling matcher
. This object has a match()
method which compares the user's search term against the potential command and returns a score. A score of zero means no hit, and you can discard the potential command. A score of above zero indicates the confidence in the result, where 1 is an exact match, and anything lower indicates a less confident match.
The Hit
contains information about the score (used in ordering) and how the hit should be displayed, and an optional help string. It also contains a callback, which will be run if the user selects that command.
In the example above, the callback is a lambda which calls the open_file
method in the example app.
Note
Unlike most other places in Textual, errors in command provider will not exit the app. This is a deliberate design decision taken to prevent a single broken Provider
class from making the command palette unusable. Errors in command providers will be logged to the console.
The shutdown
method is called when the command palette is closed. You can use this as a hook to gracefully close any objects you created in startup
.
You can also associate commands with a screen by adding a COMMANDS
class var to your Screen class.
Commands defined on a screen are only considered when that screen is active. You can use this to implement commands that are specific to a particular screen, that wouldn't be applicable everywhere in the app.
"},{"location":"guide/command_palette/#disabling-the-command-palette","title":"Disabling the command palette","text":"The command palette is enabled by default. If you would prefer not to have the command palette, you can set ENABLE_COMMAND_PALETTE = False
on your app class.
Here's an app class with no command palette:
class NoPaletteApp(App):\nENABLE_COMMAND_PALETTE = False\n
"},{"location":"guide/design/","title":"Design System","text":"Textual's design system consists of a number of predefined colors and guidelines for how to use them in your app.
You don't have to follow these guidelines, but if you do, you will be able to mix builtin widgets with third party widgets and your own creations, without worrying about clashing colors.
Information
Textual's color system is based on Google's Material design system, modified to suit the terminal.
"},{"location":"guide/design/#designing-with-colors","title":"Designing with Colors","text":"Textual pre-defines a number of colors as CSS variables. For instance, the CSS variable $primary
is set to #004578
(the blue used in headers). You can use $primary
in place of the color in the background and color rules, or other any other rule that accepts a color.
Here's an example of CSS that uses color variables:
MyWidget {\nbackground: $primary;\ncolor: $text;\n}\n
Using variables rather than explicit colors allows Textual to apply color themes. Textual supplies a default light and dark theme, but in the future many more themes will be available.
"},{"location":"guide/design/#base-colors","title":"Base Colors","text":"There are 12 base colors defined in the color scheme. The following table lists each of the color names (as used in CSS) and a description of where to use them.
Color Description$primary
The primary color, can be considered the branding color. Typically used for titles, and backgrounds for strong emphasis. $secondary
An alternative branding color, used for similar purposes as $primary
, where an app needs to differentiate something from the primary color. $primary-background
The primary color applied to a background. On light mode this is the same as $primary
. In dark mode this is a dimmed version of $primary
. $secondary-background
The secondary color applied to a background. On light mode this is the same as $secondary
. In dark mode this is a dimmed version of $secondary
. $background
A color used for the background, where there is no content. $surface
The color underneath text. $panel
A color used to differentiate a part of the UI form the main content. Typically used for dialogs or sidebars. $boost
A color with alpha that can be used to create layers on a background. $warning
Indicates a warning. Text or background. $error
Indicates an error. Text or background. $success
Used to indicate success. Text or background. $accent
Used sparingly to draw attention to a part of the UI (typically borders around focused widgets)."},{"location":"guide/design/#shades","title":"Shades","text":"For every color, Textual generates 3 dark shades and 3 light shades.
-lighten-1
, -lighten-2
, or -lighten-3
to the color's variable name to get lighter shades (3 is the lightest).-darken-1
, -darken-2
, and -darken-3
to a color to get the darker shades (3 is the darkest).For example, $secondary-darken-1
is a slightly darkened $secondary
, and $error-lighten-3
is a very light version of the $error
color.
There are two color themes in Textual, a light mode and dark mode. You can switch between them by toggling the dark
attribute on the App class.
In dark mode $background
and $surface
are off-black. Dark mode also set $primary-background
and $secondary-background
to dark versions of $primary
and $secondary
.
The design system defines three CSS variables you should use for text color.
$text
sets the color of text in your app. Most text in your app should have this color.$text-muted
sets a slightly faded text color. Use this for text which has lower importance. For instance a sub-title or supplementary information.$text-disabled
sets faded out text which indicates it has been disabled. For instance, menu items which are not applicable and can't be clicked.You can set these colors via the color property. The design system uses auto
colors for text, which means that Textual will pick either white or black (whichever has better contrast).
Information
These text colors all have some alpha applied, so that even $text
isn't pure white or pure black. This is done because blending in a little of the background color produces text that is not so harsh on the eyes.
In a future version of Textual you will be able to modify theme colors directly, and allow users to configure preferred themes.
"},{"location":"guide/design/#color-preview","title":"Color Preview","text":"Run the following from the command line to preview the colors defined in the color system:
textual colors\n
"},{"location":"guide/design/#theme-reference","title":"Theme Reference","text":"Here's a list of the colors defined in the default light and dark themes.
Textual\u00a0Theme\u00a0Colors \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Light\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Dark\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-3\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-2\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0$primary-background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-darken-1\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-1\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-2\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$primary-background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$primary-background-lighten-3\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-3\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-2\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0$secondary-background-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-darken-1\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-1\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-2\u00a0\u00a0\u00a0 \u00a0\u00a0$secondary-background-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0$secondary-background-lighten-3\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$surface-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$panel-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$boost-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$warning-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$error-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$success-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-darken-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-1\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-2\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0$accent-lighten-3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0
"},{"location":"guide/devtools/","title":"Devtools","text":"Note
If you don't have the textual
command on your path, you may have forgotten to install the textual-dev
package.
See getting started for details.
Textual comes with a command line application of the same name. The textual
command is a super useful tool that will help you to build apps.
Take a moment to look through the available subcommands. There will be even more helpful tools here in the future.
textual --help\n
"},{"location":"guide/devtools/#run","title":"Run","text":"The run
sub-command runs Textual apps. If you supply a path to a Python file it will load and run the app.
textual run my_app.py\n
This is equivalent to running python my_app.py
from the command prompt, but will allow you to set various switches which can help you debug, such as --dev
which enable the Console.
See the run
subcommand's help for details:
textual run --help\n
You can also run Textual apps from a python import. The following command would import music.play
and run a Textual app in that module:
textual run music.play\n
This assumes you have a Textual app instance called app
in music.play
. If your app has a different name, you can append it after a colon:
textual run music.play:MusicPlayerApp\n
Note
This works for both Textual app instances and classes.
"},{"location":"guide/devtools/#running-from-commands","title":"Running from commands","text":"If your app is installed as a command line script, you can use the -c
switch to run it. For instance, the following will run the textual colors
command:
textual run -c textual colors\n
"},{"location":"guide/devtools/#live-editing","title":"Live editing","text":"If you combine the run
command with the --dev
switch your app will run in development mode.
textual run --dev my_app.py\n
One of the features of dev mode is live editing of CSS files: any changes to your CSS will be reflected in the terminal a few milliseconds later.
This is a great feature for iterating on your app's look and feel. Open the CSS in your editor and have your app running in a terminal. Edits to your CSS will appear almost immediately after you save.
"},{"location":"guide/devtools/#console","title":"Console","text":"When building a typical terminal application you are generally unable to use print
when debugging (or log to the console). This is because anything you write to standard output will overwrite application content. Textual has a solution to this in the form of a debug console which restores print
and adds a few additional features to help you debug.
To use the console, open up two terminal emulators. Run the following in one of the terminals:
textual console\n
You should see the Textual devtools welcome message:
textual\u00a0console \u258cTextual\u00a0Development\u00a0Console\u00a0v0.36.0 \u258cRun\u00a0a\u00a0Textual\u00a0app\u00a0with\u00a0textual\u00a0run\u00a0--dev\u00a0my_app.py\u00a0to\u00a0connect. \u258cPress\u00a0Ctrl+C\u00a0to\u00a0quit.
In the other console, run your application with textual run
and the --dev
switch:
textual run --dev my_app.py\n
Anything you print
from your application will be displayed in the console window. Textual will also write log messages to this window which may be helpful when debugging your application.
Textual writes log messages to inform you about certain events, such as when the user presses a key or clicks on the terminal. To avoid swamping you with too much information, some events are marked as \"verbose\" and will be excluded from the logs. If you want to see these log messages, you can add the -v
switch.
textual console -v\n
"},{"location":"guide/devtools/#decreasing-verbosity","title":"Decreasing verbosity","text":"Log messages are classififed in to groups, and the -x
flag can be used to exclude all message from a group. The groups are: EVENT
, DEBUG
, INFO
, WARNING
, ERROR
, PRINT
, SYSTEM
, and LOGGING
. The group a message belongs to is printed after its timestamp.
Multiple groups may be excluded, for example to exclude everything except warning, errors, and print
statements:
textual console -x SYSTEM -x EVENT -x DEBUG -x INFO\n
"},{"location":"guide/devtools/#custom-port","title":"Custom port","text":"You can use the option --port
to specify a custom port to run the console on, which comes in handy if you have other software running on the port that Textual uses by default:
textual console --port 7342\n
Then, use the command run
with the same --port
option:
textual run --dev --port 7342 my_app.py\n
"},{"location":"guide/devtools/#textual-log","title":"Textual log","text":"Use the log
function to pretty-print data structures and anything that Rich can display.
You can import the log function as follows:
from textual import log\n
Here's a few examples of writing to the console, with log
:
def on_mount(self) -> None:\nlog(\"Hello, World\") # simple string\nlog(locals()) # Log local variables\nlog(children=self.children, pi=3.141592) # key/values\nlog(self.tree) # Rich renderables\n
"},{"location":"guide/devtools/#log-method","title":"Log method","text":"There's a convenient shortcut to log
on the App
and Widget
objects. This is useful in event handlers. Here's an example:
from textual.app import App\nclass LogApp(App):\ndef on_load(self):\nself.log(\"In the log handler!\", pi=3.141529)\ndef on_mount(self):\nself.log(self.tree)\nif __name__ == \"__main__\":\nLogApp().run()\n
"},{"location":"guide/devtools/#logging-handler","title":"Logging handler","text":"Textual has a logging handler which will write anything logged via the builtin logging library to the devtools. This may be useful if you have a third-party library that uses the logging module, and you want to see those logs with Textual logs.
Note
The logging library works with strings only, so you won't be able to log Rich renderables such as self.tree
with the logging handler.
Here's an example of configuring logging to use the TextualHandler
.
import logging\nfrom textual.app import App\nfrom textual.logging import TextualHandler\nlogging.basicConfig(\nlevel=\"NOTSET\",\nhandlers=[TextualHandler()],\n)\nclass LogApp(App):\n\"\"\"Using logging with Textual.\"\"\"\ndef on_mount(self) -> None:\nlogging.debug(\"Logged via TextualHandler\")\nif __name__ == \"__main__\":\nLogApp().run()\n
"},{"location":"guide/events/","title":"Events and Messages","text":"We've used event handler methods in many of the examples in this guide. This chapter explores events and messages (see below) in more detail.
"},{"location":"guide/events/#messages","title":"Messages","text":"Events are a particular kind of message sent by Textual in response to input and other state changes. Events are reserved for use by Textual, but you can also create custom messages for the purpose of coordinating between widgets in your app.
More on that later, but for now keep in mind that events are also messages, and anything that is true of messages is true of events.
"},{"location":"guide/events/#message-queue","title":"Message Queue","text":"Every App and Widget object contains a message queue. You can think of a message queue as orders at a restaurant. The chef takes an order and makes the dish. Orders that arrive while the chef is cooking are placed in a line. When the chef has finished a dish they pick up the next order in the line.
Textual processes messages in the same way. Messages are picked off a queue and processed (cooked) by a handler method. This guarantees messages and events are processed even if your code can not handle them right away.
This processing of messages is done within an asyncio Task which is started when you mount the widget. The task monitors a queue for new messages and dispatches them to the appropriate handler when they arrive.
Tip
The FastAPI docs have an excellent introduction to Python async programming.
By way of an example, let's consider what happens if you were to type \"Text\" in to a Input
widget. When you hit the T key, Textual creates a key event and sends it to the widget's message queue. Ditto for E, X, and T.
The widget's task will pick the first message from the queue (a key event for the T key) and call the on_key
method with the event as the first argument. In other words it will call Input.on_key(event)
, which updates the display to show the new letter.
When the on_key
method returns, Textual will get the next event from the queue and repeat the process for the remaining keys. At some point the queue will be empty and the widget is said to be in an idle state.
Note
This example illustrates a point, but a typical app will be fast enough to have processed a key before the next event arrives. So it is unlikely you will have so many key events in the message queue.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPbOLb93r9cIpX+8l5Vi1xy3Fx1MDAwYlxcXHUwMDAwUzX1yrvlxI5cdTAwMTfF25splyxRiy1LskSvXf3f50JxLGohJVlL5ExUldgmaVx1MDAxMCbP3Vx1MDAwZi7++u3Dh4/RUzP8+I9cdTAwMGZcdTAwMWbDx0K+Vi228lx1MDAwZlx1MDAxZv/wx+/DVrvaqPMp6Pzcbty1XG6dKytR1Gz/488/b/Kt6zBq1vKFMLivtu/ytXZ0V6w2gkLj5s9qXHUwMDE03rT/z/+/l79cdP/ZbNxcdTAwMTSjVtC9SSYsVqNG69u9wlp4XHUwMDEz1qM2j/7//POHXHUwMDBmf3X+j82uVq2HnWs7R7tzU6D7j+416p15XCKR4tOye0G1vc53isJcIp8t8WzD7lx1MDAxOX/oY+FY1pu721x1MDAwN42709rGTqV1+bTXyHTvWqrWakfRU+3bQ8hcdTAwMTcqd63YnNpRq3FcdTAwMWSeVItR5fszi1x1MDAxZH/9vWK+XeFcdLyebjXuypV62PZ/u3w92mjmXHUwMDBi1ejJXHUwMDFmXHUwMDEz4vVovl7uXGbSPfLIP2VcdTAwMDBFYFx1MDAxZFx1MDAxOaW1QjSWXk/7XHUwMDAxMnxSKyc0WWONUYawb25rjVx1MDAxYb9cYp7b76Lz6U7uMl+4LvNcZuvF12uiVr7ebuZb/Lq61z18/6tcdTAwMDOSioxUyqFcdTAwMDDFd3u9pFx1MDAxMlbLlci/XHUwMDE2XHUwMDBiXHUwMDAxSJJSW5RGQXe27bDzZow2xlxup8zrXHQ/hWa22IHHv/tcdTAwMWZsJd9qvjy/j52pxqbvf9yIYav7y3fNYv5cdTAwMWJcdTAwMTAkXHUwMDExgjZcdTAwMWFcdTAwMTGlfT3PeLvmk/W7Wq17rFG4XHUwMDFlgp12lG9Fq9V6sVov9/9KWC8mnKnl29Fa4+amXHUwMDFh8TT2XHUwMDFi1XrUf0Vn3JVWq/FQXHTzxSEjJ55r+uG6wuQ/3e8+dOHT+eH1+3//MfTq5HfqP1x1MDAwM2+zO9xv8a9//zGZXFzHxbZPrqU1XHUwMDEy/VvrXHUwMDAyZJRgXHUwMDE3XHUwMDFmt+uHNbm/cqLXilLKp1P8urX0gk1cdTAwMTiAQaeVllx1MDAwNqxm+e5cdTAwMTVsXHUwMDE5KFx1MDAwM1JoaYGUk6J/arOTa1xmLFx0x4IpXHUwMDE0KiMsdoW2K9fGXHUwMDA1pNFJLdFcYsDYbL7LtZaSQHfVzy+5/oFynfxOO2f73+aEct1cblx1MDAwYtE3VFx1MDAwZlx1MDAxMW5cdTAwMGKq/+h34TYgyFkruiBcdTAwMTkl23pcdTAwMTez6vhuf0tcdTAwMWWXXHUwMDBln45WN6urXHUwMDBme2+TbdmPwe+/126w0zJbmy1VQGCsklpb/qe74uJHXHUwMDAwZ1x1MDAwMudcdTAwMDRcdTAwMTFYh85p0zeziUT7d1WgsKSVXHUwMDFhYrCpK1x1MDAxYa+SbFx1MDAwN0TXXHUwMDE5XCJWMNrNXnRfcfVXXGZ9L6/2+jS7cnZcdTAwMWGFZ1x1MDAwZW9Xwp2zbPSw1vxcdTAwMTiH6SveovAx+vh64u8/foZhe67+Y9xcdTAwMWJ2h/0uqDNUjqky3zPPmLhcdTAwMWIjksRdXHUwMDFhUsiuKcmx5T39MS+tvFx1MDAwYpcm71xiJlCzkvd0XHUwMDE3XVx1MDAwZpF4wH6JZ/3EXHUwMDEzVSrmc4wt8m3/w6KtdalRj46qz/7Rg+g5upm/qdY6XHUwMDBm+fVwXHUwMDA3qT7mvfdzXG4+hU//c1x1MDAxZD79819cdTAwMWbDf3383/izbYdcdTAwMWRcdTAwMDeV5+d6fnmlVi3XOyEjXHUwMDBmXHUwMDEwtnpQXHUwMDFmVTnGfb3gplosxq1ggWeU5zFb2XGsV6NVLVfr+VoudcJvt8RSOpckmyDY9+T34cZcdTAwMGagm0BQyX6y95VTKp5+NjuGtnaWXzalXHUwMDBljJLkUKFjV1x1MDAwN/pssVCBs8ZZJVxmsG+ippHN6W0xsVtG1mo5uWBOY4tLXHUwMDE2Tyh7sEVCXHUwMDFlZTZcdTAwMGLbUT17tT290fwvXHUwMDFmdpSJXHUwMDFmfsPlM/FSxtI1/TZeXHUwMDE44jBC4vg+ffpzXl49XCJS9VxiqkDNSo/MxMZrhWTi7+W/wcQ/LtrEjzCKI03845QmXHUwMDFlY2++XzSVcFx1MDAxY+lrM777fWLv7k5LVXyqf3Lbd+XqMUVN/Vx1MDAwZURTXHUwMDA11pJBpYXSZGLa6ttcYi4wXHUwMDE2vOdttXFcdTAwMTLtNLI5vY2X6KQlqWBcdTAwMGU58DT7tpYt3lZutrPQus3kSoXyVrNcdTAwMDJcdTAwMGbTm81fw85j2FG+w/BcdTAwMWIuoe9cdTAwMDAmMVx1MDAxZFxiXHUwMDAyyLDcqq5uXHUwMDFlpaDSn/PSKijWQGlcblxuXVx1MDAwMDNTULNwXHUwMDFlkDg45Hm8ISX4jp2HaNHOw1xiczvSeYimdFx1MDAxZYSEJNmUvqpsSajx81x1MDAwM6JcXCg+fd543nzOrZ6a1np0YrYuXHUwMDEzZLPQarTbmUo+KlQmrsXNXFw+Ob5cdKRcdTAwMDGSTrGEOid7xVNcdTAwMDdWXHUwMDBiw+60clx1MDAwNlx1MDAwNcr5pe/Yq1xiWFx1MDAxMtBnTaWMXHUwMDE1Sbt1OFx1MDAxMoFlh0YpQuzUXHUwMDA2+0WXWMU4K/Fcclx1MDAxOYRpRJescjSB6L5cdTAwMWS1XHUwMDA2XHUwMDEyXHUwMDEzzmA58lwimKB2/PDl7Ph+t+lobbN+vXffzl2VaDdcdTAwMDGzfbj7cWjFwFxuzViUgpW0xD6wXHUwMDAyaTYhPi5nXHUwMDE42XmCXHUwMDE1XHUwMDAzIUn74rU0zlxmMyw2IFx1MDAxMqhYj1x1MDAwMCFcdTAwMDNlXHUwMDAwrdJcIlnygcpyW5pUuIa1WrXZXHUwMDFlXG5W0olMXHUwMDA3p4W39+P7Puu3bq0sXHUwMDFh1fUoV6GV+pdqWTxfvVx1MDAwNauL83wk6MBcYumENlx1MDAwNlx1MDAxND952Vx1MDAwM1apXHUwMDAzjUaQY8xqXHUwMDA2K01Fcvi9lNegYVx1MDAxMKksMEpcdTAwMDBoR+xxgoonZ16hKiHQRMqxUlx1MDAxNYBcYjFcdTAwMTftO1RcdTAwMWRJY1x1MDAxNDtoPydUjUgsXHUwMDE2+EJcdTAwMDJpJeT4Sb6TxtZOY2vvYHf/5jh7lFx1MDAxN7lneX605GDV4K1vJzRX6ND2eekqQKk1Y4RcdTAwMDVcdTAwMTdcZsXM3dvAeimEnlx1MDAxYljRSkOCZe8nXHUwMDA1KyWnvdjYgEOYgGWSoULrRu+F1fPTs8v80VWrjodPS1x1MDAwZVYrXHUwMDAy0pbh4Yz3XHUwMDA2bS9WkVxyL1tV7SyHcYyT6VJeXHUwMDEyLtmvmlx1MDAxN1ZZliSHxPie9Wqqx0o6UbNqUFx1MDAxMlmnjFx1MDAxZmWd5MzVYSV/U9k938k837ab1y6iXHUwMDA0rL6V7ThztIKgXHUwMDAwpFJAXHUwMDA2vVbqJTFLclx1MDAwMTgvs6y1PMV5jlxcRyNcdTAwMDPHYZZX4GRUTD2+XHUwMDAyVstcdTAwMDDNNzWPXG6MjnHQvytX45xcdTAwMDWcS23l5cTQJOP9cVbVVm+fNr82M/fb59tfT7ZKZ/NMMlx1MDAwZb9hd9iX7354klx1MDAxMZO9bClYN1x1MDAxMlx1MDAxODF+TJj+mJc0x1xiUqZJmKFAWWG0L1xisveC81x1MDAwYlx1MDAwYpFGSlx1MDAxOFx1MDAwZZhcdTAwMDBcdTAwMDaLs07rd8hH8sszXHUwMDE4Y2jIXHUwMDAya1x1MDAwYqN7LkpJP+aGplx1MDAxYVH0XFz4mkmshaVe6E+WaEw3XHUwMDFh/YnG3Fx1MDAxNElFJWWSLJJcdTAwMDEhrFx1MDAxNuOHvMeZ6PK2urlXOz0pn12VVk4zN/nscjtmLFuBJasth6LaOWN7ef3SYoDs7CiUgFx1MDAxY/xOtVxc56VcdTAwMTY5xDEzgWGnT7BMKVx1MDAxZrfi8OSMXHUwMDEywnFIKzTH6U5cZjpmyidtOEpZbqlMxWryXHUwMDEylORo11x1MDAxYZRKqFx0XHUwMDAyiIddsV68P195PG89Xrebh1FUeWjN2Cmby9oy1tVWO4UgjZZcdTAwMWPi92BVkVx1MDAwZZBDXGb5Ld6FfiFasqVlUmhfTsS3xLy/1qB0zv1cdTAwMDRryyhZsI2w1lx1MDAxOFx1MDAwYuPzYS42L53UT7BfrFx1MDAxZcv1c7neLlx1MDAxZp4svVxcXHUwMDEz+Fx1MDAxNaPgWIU5xyFMb9LVSzU/dDZR5Dx+xfyoalx1MDAxOEjl2CfloE5rXHUwMDAwXHUwMDE4YoX8UiQgskpY9lx1MDAxNy1cckg1O1fOL2Waw+qUX0LtP5MuLEt4pZ2T/S9zQplcdTAwMWVBdVOJnqVcdTAwMTRoXHUwMDA1mHh9YZRor+7fXVx1MDAxY8r26flDTWy3rlx1MDAwZlx0sVx1MDAwMctcdTAwMWXlZVhjXHUwMDA2noPKVpmkYC+lN84z2lx1MDAwNb6K7UChXHUwMDE22s3Cu5yG6SZYs/ty5IKZboXWYeZi5XRtu1x1MDAwNNW7jWN+zc/H+9OTvH5ccjuPYUcloYbfsDvsd82yqIhcIpnpZm3/4S6bXHUwMDA2lNPG2lx02lWkPufl1U8yVT9ZXHUwMDFi0Kz00yyIbpI9VH415i39KH505uk90eRHWNu50+S1SF7B4pRcdTAwMTakXHUwMDEwx09LRe58y17WWpXN58+5q0+5i82tKImJsUyyyY4/kSSQVkiUvYkpPlx1MDAxMXSWt4DlmExN10dmetfBXGKNPnhcXLDnXHUwMDEwfn740l5dfb4o7aysRcdcdTAwMWKHub3bcHqj+WvYdzTsKIdk+Fxyl9AhUTpR6ZGViJMszE9/ysur8kSaynNcInCzUnkz8Ua01Vx1MDAwNMLJJc+4z9hcdTAwMWJZOO9+hP2eO+9cdTAwMWUokXfv0Fx1MDAxOHJajC+ZT1dP5d3jXG6gK2Tz+0dcctkw2fz7oN3rXHUwMDAwkP1cdTAwMDF2v1x1MDAxZItnf57SYEBWKOUkkmDJmF+eUmtcdTAwMTlcYoNgXHUwMDEwWVx1MDAxM0DM6YhcdTAwMTHvNV9iXHUwMDFjkTZcdTAwMWPokVx1MDAxOFJ+UKCs027BoYRcdTAwMDGn1Fx1MDAwNML7dty6WGewfthKUlx1MDAxYeI07pFd29a/NL9cdTAwMWWph3ZOZa5lTX6+XFy/uFh25r3HXHUwMDAwaWLAcrTvpO1cdTAwMDMsXHUwMDA0YKywXGZcdTAwMDGNen7VMq1cXKDQovJcdTAwMGLGKL6ONF7cdc5cdTAwMDFbXHUwMDEyy3hGUv1oRY7OeziSXHUwMDBiwapcdTAwMDJQM1slkkJcdTAwMGa1NpFcdTAwMWVcboJDPclcbmV8XHL7aaWZrZePi5vnj7uVtdrGp690t+QsXHUwMDA0NjGBdVx1MDAxNlx1MDAwMY10IJXp5TIri1x1MDAwMVx1MDAwZqJQa4NcdTAwMWNo4bRcXOZE4r0mzzpSgr84I4d0XHUwMDE2lFx1MDAxMFx1MDAxOKklq1/tXHUwMDFjuXjrk1dcdTAwMWFcdTAwMDKAdKhcdTAwMTesV1x1MDAxN4VVh8mpXHRcdTAwMTZRYmtcdTAwMDPjpybOro4q2Vx1MDAxM1co1Vx1MDAwYitcdTAwMDeZ3U27+1x1MDAxNZeed69cdTAwMDKPUCNAsONDivqwKlx1MDAwM9ZyymotSKrp8lx1MDAxMims+1x1MDAxOSCVpc5cdTAwMTH/XHUwMDE1uGD/fWFQdYlcdTAwMTU4VEJIXHUwMDAxMD7N8su5OVxcKa1cdTAwMWPU90pfi632Sq7cLN4tOVItXHUwMDA0WmhALaxfx1xyXHUwMDAzQFx1MDAwNb8kz1x1MDAxYlxcS1O2Yk4j3c9cdTAwMDCqRiqrLL2BKrM0QFx1MDAxZOGrJrZcdTAwMWSQmpBgolx1MDAxOOvgYCVcdTAwMTNlbr/uPt6b/U+nlXpNbL+xXHLpXCJJ9zbo8Pek0X49f1/HXCJCXHUwMDFikFGsblx1MDAxOcxg5Pz8VTRcdTAwMTSwl+GZhOBLwkNcdTAwMDCrPVx1MDAxMY1naDtsIVx1MDAxMFx1MDAwM/6qNJZxTKRcdTAwMTbMub+9P6jIkthcdTAwMTD7N7vV0+yXyu7Jp+5b6sHjJEnLmVx1MDAwZjsqaTn8ht1hX75cdTAwMWIlvHNPWpJJ5mVcdTAwMDJcdTAwMTkt0NnxbUz6Y17SrCWbj1S5VTogXCIgwYrMoJnjYlx1MDAxOVx1MDAxYSm38VYlL4LK8bFmV1xyXHUwMDE3767Hqkhv5PLLQFx1MDAxYcdxXHUwMDExsNUkNp+q56o0Mn9cdTAwMTh/zHNm84+wRlx1MDAwM2z+cIpcXKWRid1cdTAwMTaMXHUwMDA1/uBcdTAwMDTiWL86XHUwMDE3XHUwMDBlz8zuZeuomTvcKOWz0cZyu3ygfdHU8lx1MDAxN5IgXHUwMDA0qD5Z9JlcdTAwMThAJdnP8vHJVFx1MDAxZV9cbptcdTAwMWZcYtHPQrGDXHUwMDE505DxfI/hXHUwMDBiNMuqRf5ucNFcdTAwMWFcYt/01yyc6eCthptALlOhmsjmd4lcdTAwMGLAQLKDXHUwMDAziGp8mJrjzY2mOP1aKT2stHdcdTAwMTGuXHUwMDFmSup+6Vm/nZ1iiP/zvTbQ8S/0dlx1MDAwNnHCXHUwMDA1gFKC8ett3Vx1MDAxYzeUmFxym99cdTAwMGbgJP5cIv4uXHUwMDA38fdcdTAwMDex+V1ipYyjXG5jhJ2gJ8Xh5Vx1MDAxNT5cItjbp88n7UyYscfl+vrSyzVh4Fh/WVx1MDAwZehZtEH0eoNeqn3mRYBcdTAwMDZcdTAwMTNfXHUwMDA3O3suv7K+14+z7Fx1MDAxNzlcdTAwMTVPxCWS+c1ghVtpYJy8qS/FL6nunJsxnT/prXbOXHUwMDBlvM9cdMU6vVxmTsmdvKTfzcw5sFx1MDAxM3ScuXiWpcszudW42C192s/WcpWd99G7Vvt9kzjqY1x1MDAwZlxydN/CbemMX4PnwDdQ0qzyZuBhTkHLXHUwMDAz4djnXHUwMDA1WjAtb1/kLteal+p0o3L7eFx1MDAxODZcdTAwMGJPutTtstxcdTAwMDO5SbIwv4b9NeyoxNnwXHUwMDFidof9rlx1MDAwN2dofFJValwi209TYubMt4OUksZffJD+lJdWl7IvlKZLwVx1MDAxN8VnpUtnwvdcdTAwMTNcdTAwMWOzcyxml7xLV/fFv0++31xi12BcdTAwMDF8v+RilFG+i9dcdTAwMDR+XHUwMDBlbd5dXHUwMDE3zvbWqfglX4n29s/3dlx1MDAxM1uWLlx1MDAxN99cdTAwMGZE4FxmXGKjhe9MKmNdsTqRjPeE/Fx1MDAxZbdW+35mMD82rla+RYe0VnC8KmxM+mJ0P1xiXHUwMDFjz5M0XHREgYP1KIlcbv062Fx1MDAwNYuu8f06ZmVL0iuoyV3LJOtQ313WjV9B3dws2Mdy9sneX8rdtdXSefnpxC053Vx1MDAwZnyvZd+e1oKxMr4v6je8YmB8/zZcdTAwMGXBnW9cZjlHvFJcdTAwMDDI01CgXHUwMDFjuWHL6MFcdTAwMDZcdTAwMTZcdTAwMDU4w+FcdTAwMWNcdTAwMDdxclx1MDAxMK+ao1xiJFx0i6amWICZ4TWNmpKywMuT01x1MDAxY6hcdJrsrWx9Pmt8LbSL5nzncdvIjcuzXFz0XHUwMDE2tC6Qm4K+0Vwi61dcdTAwMDPot1x1MDAxMOjrXsp/f1x1MDAwMM5cbuf33o5b/1k32mV/XHUwMDA2pee/alx1MDAxYq9cdTAwMDHGqSmkpG88pFl76njm/rUntFwiYlxys3By6qKgSoksKraBrN7dXHUwMDA0u9qf4Vnt071dWa3uX1x1MDAxZdRcdTAwMWWbd4XjZpKnvixI1a7TXHUwMDE23PBLVtag6stoXHUwMDAyXHUwMDA2orPNXHUwMDE2P1x0RtN0O/KltdmdXHUwMDAxVFx02Vwiszgtmke1IKhKkbJTu2CsXHUwMDAyXHUwMDFim/FcdTAwMGJra7dnj2H2aKNw0FxcvTtcbqN2e+UmKVx1MDAwMb8sYLXsK7K2s8Yho9U521x1MDAwN1ZcYpRU7Eii9WCdikSV2md3XHUwMDA2YDVglVx1MDAxNvGC/ftcdTAwMDNrusuarFn5PVx1MDAwMitcdTAwMWRN41x1MDAwN1rR7v6FuSiXTu7yrvX5oZy5XFw3XHUwMDA3S0/665CHtGVb70j5/d178CqFsYHjqMY3aFx1MDAxMHK69oMjSH8qkMR4XHUwMDA08MyJYYtUtPRcdTAwMGK8wCm/XHUwMDE3sVSDi1RYu0jPXHUwMDFmgDm0hn45MTTJuHef39v7slx1MDAwZke3mYeHwu1D+2v1tjF97vK9XGY7KiU6/IbdYV++XHUwMDFipVx1MDAxM3BWOiFxa3KV2JBcdTAwMDVcdTAwMTjbXHUwMDFjSdP4XHUwMDBiK9Kf8pKmREHadG1gtd9Lxlx1MDAwN4ZcdTAwMWHQzrNcdTAwMTVcdTAwMWONVFx1MDAwN8O4hFx1MDAxY15r86adXHUwMDEyp7NYXHUwMDE4S5a/uS+wY1fW51x1MDAwZXhAXHUwMDE3V7UjuYSPXHUwMDBiJFx1MDAxM46wcoNkwsdp2IRp7F70q7xgXHUwMDAyPkflrPJ8eYqVK5cp5FpHn3dL9XJ5ud1JXHUwMDA2XHUwMDAzXHUwMDA3N563Yp3xLYJVv0D6XHUwMDEyhjWecKuJY/qp/MlcdTAwMTRCIYuWIa2VUsbK2HL0eEZJd1x1MDAxYTmBYdUh41x1MDAxYkl9zyhJQyB/SOwzs/XOibyj5Iy95qjUM9HHhmmzoj+3r0ru6GrroJJTh3hw/by29LSjXGaA8cuZlFx1MDAxM35xiO03XHUwMDFk6L03v+LL75dn58c7mlx0mZA63YTe5EdOyzqahPtcdTAwMWGbx0/NOvoxXFxCflx1MDAxZonuIN/MeUU3vlRHVysnbnPzZrN+9OX86Gz1dOP8aHXppdrIQHg2l187KGz/JvZcdTAwMWShdlxuXHRcdTAwMWRcdTAwMTA//DluxFwi/G5cdTAwMTVcdTAwMWOFalwi8LtFqGE2XGI5WGXH1SpU7Fx1MDAxYmhcdTAwMWOQa09qd7pni/Nfgv1cdTAwMDNcdTAwMDU7k/Ja/WfghU4o2iPK7GmbLfL9/Fx1MDAxMo2x5Xun0ag8rjdk5n5/7aZyer5xXHUwMDFh3W++jzK7XHRYvK1Fllx1MDAwZYfxXHUwMDFkKF9k3O/J6Jv+k1x1MDAxMobN99xk3PcpXHUwMDAxLYxfy2ZcdTAwMDTqYYxhwsDybKzwsZ52XHUwMDAzIaBf1sUgolx1MDAwNW9cdTAwMGVmJbpJXCLAt6PWqcSQXGL4Vfq93cavsl9X7E21JU736qtHxbtnVXu8v3xTgn2xcFx1MDAwNe3/Ut3pKY5cdTAwMDMmSTFcXK3SSrJTXHUwMDAwen6kLU9cdTAwMGLhgFx1MDAwNpTPXHUwMDEzW6GG0EJ8W1x1MDAxZOFcdTAwMTkqwneCwngg8lx1MDAxYVx1MDAxNfl9j1x1MDAwNIpcdTAwMDWnLCa1SKmATeus41x1MDAxMuvsfomCJ++MX1x1MDAwZrpoP118udtsVWBlz62V98rm4K603Fx1MDAwMTzrhYBcdTAwMTHg9SqrWCn7o1wi0lx1MDAwMVjR2fC4t0nYjNvqsL8qXHUwMDFjKE/NxyHrXHUwMDAxfTXIoNDaoensXHQ2WFxyQmfYu47npX4qoDqdXFxcdTAwMGLyPVx1MDAxNk18O4GRXHUwMDFkoGzt881u5ejE1Y+b+TD6VMmJJc808fNcdTAwMGV8Po3YzKPyPn0/Ulx1MDAxNYdcdTAwMDLWOWPQTFu4TGurMz1SwTdcdTAwMDNmQ/iTXCJVxnfY6ocqR8BW9qzaXHUwMDFkzbTbyVx1MDAxZEa3ollcXL9ccq9BrV/Jh+aSY9WpQGnyvo6RxJI7oFUx4JDUNyxjZ9HAdGo1rbHO1GBcdTAwMDVgpLr4XHUwMDFlQ+9cdTAwMGarI2ihKVxcZn5cIuArumOD9TjKStrP5chd01x1MDAxOYX526eVqkhcdTAwMDDrXHUwMDEy1dhd4DpcdTAwMTU1wSGnjL3rb5vag1xiUDFI+Jx05KZSrSNq7DqQ7GyA51OzN+KGRFhaXHUwMDA27KZ4rq5veyjt4EJr3+uCT8/DY305MbRsfV3BXHUwMDFjfcpcdTAwMWTa7fVjzK5uhZtccnszfTX8v3zYUbX74TfsXHUwMDBl+/LdwnRNYlx1MDAxZiCZzOf1IZiRQo+vZtJcdTAwMWbzslx1MDAxNu9BpatcdTAwMTlcdTAwMGWcXHUwMDE1XHUwMDA3rEJcdTAwMTjfXHUwMDFicY5qhkarmSHFe3ROk1Y/YvvQN+Vme1x1MDAxYlx1MDAwMVx0tvNGKfZcdTAwMDaI/1xi6rkqvXhcdTAwMWbFn/Scq/cj7OeQ6n00RfneQvJcdTAwMGVHXHUwMDE2pfKZj/EzrNnzfFttnn2l/f1wd0OYnZvPoV1uR1x1MDAxNYVcbsjvU+1cdTAwMTe/KG1s31xuQ41s+i1pY3zLrFx1MDAxZVx1MDAwZeaM+1x1MDAwMVx0XHUwMDAyo3zlnuLNe+OJKl+0l4o/woe6XHUwMDAzjqpB1Fx1MDAxNlx1MDAxMN5FUPXbi0X6mG82j1wiXHUwMDFlkk9/gy7Pulp8kdruMFx1MDAxZu+r4cPqsEC18/FcItBcdTAwMTFcdTAwMDBcdTAwMGay0M/5r79/+/s/t9XlXHUwMDAwIn0= events.Key(key=\"e\")events.Key(key=\"x\")events.Key(key=\"t\")Tevents.Key(key=\"x\")events.Key(key=\"t\")Teevents.Key(key=\"t\")TexText"},{"location":"guide/events/#default-behaviors","title":"Default behaviors","text":"You may be familiar with Python's super function to call a function defined in a base class. You will not have to use this in event handlers as Textual will automatically call handler methods defined in a widget's base class(es).
For instance, let's say we are building the classic game of Pong and we have written a Paddle
widget which extends Static. When a Key event arrives, Textual calls Paddle.on_key
(to respond to Left and Right keys), then Static.on_key
, and finally Widget.on_key
.
If you don't want this behavior you can call prevent_default() on the event object. This tells Textual not to call any more handlers on base classes.
Warning
You won't need prevent_default
very often. Be sure to know what your base classes do before calling it, or you risk disabling some core features builtin to Textual.
Messages have a bubble
attribute. If this is set to True
then events will be sent to a widget's parent after processing. Input events typically bubble so that a widget will have the opportunity to respond to input events if they aren't handled by their children.
The following diagram shows an (abbreviated) DOM for a UI with a container and two buttons. With the \"No\" button focused, it will receive the key event first.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1bbVPa2lx1MDAxNv7ur3C4X3pnarrfX85M54yioFSxVk+tPZ5xYlx1MDAxMiElJDRcdCDt9L/fXHUwMDE1UFx1MDAxMt5cdTAwMDIqcHBu80Eh2eysvfZ6nv2slZ2fW9vbhbjXclxuf2xcdTAwMTece8v0XFw7NLuFt8n5jlx1MDAxM0Zu4MMl0v9cdTAwMWVcdTAwMDXt0Oq3rMdxK/rj3bumXHUwMDE5Npy45ZmWY3TcqG16Udy23cCwguY7N3aa0Z/J36rZdN63gqZcdTAwMWSHRnqTXHUwMDFkx3bjIFx1MDAxY9zL8Zym48dcdTAwMTH0/jd8397+2f+bsc52zWbg2/3m/Vx1MDAwYql5nODxs9XA75uKOUKcMCT1sIVcdTAwMWLtw91ix4bLd2Cxk15JTlx1MDAxNXrRt6vuXHUwMDE34pu8yI/l/snt58reSXrbO9fzzuOeN3CEadXboZNejeIwaDiXrlx1MDAxZNeTu4+dXHUwMDFm/i5cbsBcdTAwMDfpr8KgXav7Tlx1MDAxNI38JmiZllx1MDAxYveSc1xiXHLPmn6t30d65j6ZIcVcciQ0JZprLDVVw6vJ77UhMeJcdTAwMTQrjSTmmo3bVVxmPJhcYrDrP6h/pJbdmlajXHUwMDA25vl22kZcdTAwMTFL48yYu4+jVdRgjFx1MDAxMlwiwVxmqlx1MDAxMeHDJnXHrdXjpFxyIYZCTCjJXHUwMDA3t8qY4vSnhGtFqVx1MDAxNul8JbdvXHUwMDFk2f3Q+GfcoXUzbD04rlx1MDAxMCVfMqYnVlx1MDAxZozHVTa2MpNOxFWl7Fx1MDAxZlQ+fyzvX1rfj3ul8/tw2NdIIMbOfVxcXHUwMDE4Xvj19ne3M7tcdTAwMWRp/XbRXHUwMDFiLmituL+6b1x1MDAxZvPDkPpnJdwrnXlfet3p1pphXHUwMDE4dDP9PnxKo6ndss1cdTAwMDEjYCEoSyiDS8SG1z3Xb8BFv+156bnAaqQkspUxeIK7RsafIS5G0fjZR+JcIohSXHUwMDA0WOApiOZcdTAwMTFX/vRtKnFplENcXFx1MDAwMlx1MDAxOVx1MDAwMlx1MDAxMyBcdTAwMGLBXHUwMDA2zPVcdTAwMTLiikPTj1pmXGJ8MIW85HzyXCJcdTAwMTNkRVx1MDAxMJFMJ3S2fLrKj05O5Vx1MDAxM6IzXHKCwI/P3Vx1MDAxZv2lUVx1MDAxOFx1MDAxYyuGiIBBaI24XHUwMDFjaVUym67XXHUwMDFimdd+XHUwMDE4g+W7rdab/2ZdXHUwMDFkOWDCYLlcdTAwMWRpvOu5tSTOXHUwMDBiXHUwMDE2XGbKXHRHIFx1MDAxMLsgXHUwMDA0hlxymq5te5lwtMBcdTAwMDJcdTAwMTP6XGaPXHUwMDE2WZOD0K25vuldjFx1MDAxOJhcdTAwMGLJXHUwMDAxJUzBpFwiszGJwfGYscyiNVx1MDAwZpP5JLWhmKRSXHUwMDFhhDIsZKJcdTAwMTVENjL6XHUwMDFkMGJwwCsgUilBJScrQyU1XHUwMDE0cCSTQlxugShXelxuKqk2XHUwMDE0xVopLjSGcJ5cdTAwMDAp5lxcwigofjpG+6Y+XHUwMDFio09bQTJ2mGG85/q269fgYrr0ParkRTDRR7HVTqzcXHUwMDAxhlx1MDAwNYBzjlxiU8BcdTAwMWNEZFx1MDAxYdXMVlx1MDAxMvRcdTAwMDZcdTAwMTeKSFx1MDAwNbHNlCZYPDRcdTAwMTiuwFx1MDAwNce355tUvPlcdTAwMDbS8NI8s8t2T/rt3a87++VZJjGsMdZIYyqJXHUwMDEyRLFcdKswg+mHmcNEXHUwMDEzglx1MDAwNPydMMszo7hcdTAwMTg0m25cZs7/XHUwMDE4uH487uS+N3dcdTAwMTO011x1MDAxZNNcdTAwMWW/XG7Dyl5cdTAwMWKnhVbS46h4TD9tp7Dpf1x1MDAxOX7+5+3U1juzozk5JuI47W8r+39cdTAwMTajhY5cdTAwMTVcdTAwMGbwPIXVXGK4eFx1MDAxNq1cdTAwMDGSXHRcdTAwMDaxsbjSyJ/nXHJlNaKUQSVcdTAwMTJIJ1x1MDAwYp5iZIzVNJBcdTAwMWWiSFx1MDAwM2q1TtbF1WlccpHOxZDGVKp8XHUwMDFleFx1MDAwYphVipHVZi2pkGp8iM5kpV5FOLBkLL+dWl9PXp5cXHjNXHUwMDFmn2/+wlx1MDAxZr2DYlxye3Xkur1ytJhcXM/t97LyudM9ZvsnxyG3yz3SJmxfLKFfcmlcdTAwMWZcdTAwMWSWXHUwMDFh1onaZfii6Z1cdTAwMWX4X2tL6HdF7n1d3TbE5VGn1PqEb9pRvdHhJXRXtf/vnPuCXGZ2vebOy+On33BBa0vlxoUon3+7u6yfdSp+3Tv+XHUwMDE27CzBXHUwMDBi91fki/zavjmpoHL5XHUwMDE2k2bN6Z0tqT7AJFx1MDAxMZCOrro+QIhW46dcdTAwMWZXbYq51ELRxXOR/LDY1FVb07xVXHUwMDFiUjJDrmnV5lNW7Uxq9LBqKyk4XHUwMDE4K+g6K1x1MDAwMkxohTR6QjxOr1xiLFpcdTAwMDEoPqbnb6795IJrv79OKvReULsuXFz704tcdTAwMDOZPHGkOOA5d6PR/6TSwFx1MDAxYy06Xlx1MDAxYZhr+fM1NkWKzkIrRlxcI0GzYTFcdTAwMGaunVx1MDAxYoJlLf7c2bPo0W3ZP2qflXv/Llxc+Ty0YkFcZqIxpDmSUsGoXHUwMDFhRSucMrhmSlx1MDAwMVx1MDAxMFx1MDAwNWFMrlx1MDAwZaxcdTAwMTkwpFx1MDAxMluMg5VgjDBkY5lq31o09sVheFOMnfaN3K1Wrq7qny5+XHUwMDFjfvitsZelsVfk3tfV7ao09uvywqo09uvygqf3iuq2XFxkRSGc84tcdTAwMGb3tVJpg72w/JRgXlx1MDAwNjN9IGm3XHUwMDBmn3JcdTAwMTSYwpqwpyiwXFyhMSsjoCSjcMc1XHUwMDA2wVRRgejiXHUwMDFhI3/+NlVjyHyNodelMdRcdTAwMTSNISc0htKYScroXG52NMxcdTAwMGJH/IRwfFlCsNeO48B/k5xcdTAwMWLo6uvClVx1MDAxM11cdTAwMTfeXHUwMDBlvnXM0DX9XHUwMDE4pHbUtixcdTAwMTjd7CxBjna+pCxhjphcdTAwMWXPXHUwMDEynjecXFxE56dcdTAwMGV49lNHjFx1MDAwNYQ8hPvimf7ljYjrxdZt1W+ffqrY9ztcdTAwMDeXZ/VNz/SpVlx1MDAwNoaoXHUwMDE1QkHSz4SczFx1MDAxZJCAbjjTjJOV7lx1MDAwNVgsecCISkQ5ZWtOXHUwMDFl0MfK4Un7qnL48axcdTAwMTL39lx1MDAwZXo8OPF/J1x1MDAwZstKXHUwMDFlVuTe19XtqpKH1+WFVSVcdTAwMGavy1x1MDAwYqtKXHUwMDFlXpdcdTAwMTde8DjhmTnJ9IGk3T58yntKwUEkp09cdTAwMTBWlpPgmTlcdFGCXHUwMDEzQcjie1x1MDAwYvKnb0O1XHUwMDBiQzRfu+i1aZdcdTAwMDWTXHUwMDEypSmiXHUwMDAyr0C6LDNcdTAwMWWXnpRUgylcIt5cdTAwMDG8huvOSOZo9Fx1MDAwNTKSeWPJXHUwMDA188z9j1jJmc9cdTAwMWMx0kJLxjPhPVx1MDAwZs75tLmhcKaKXHUwMDE5QiCtklx1MDAwMoLi2YpK/6Gjklx1MDAwNlx1MDAxNZJizYHeOFErrDFgXGaWXGJGXHUwMDE4p5RQwtkkulx1MDAwNTe45lopRGjyglx1MDAwN1x1MDAxZFx1MDAwNztnyXNi8ZyNRM/fXHUwMDAw+Vx1MDAwMrAvuFx1MDAwMXLh3YbIYJgqrFx1MDAwNeVIJvOVaTPYaUhcZlxmPoZlilxuLlx1MDAxNOTakztcclx1MDAxN9pcdTAwMDCZXHUwMDBm6lx1MDAxMZO4XHUwMDEwyVx1MDAwZUBcdTAwMDSrXHUwMDA1pJFcbk/YXHUwMDA0M4+0XHUwMDA0vIE9SMBcZuNcdJte0+7H2ZGcXHUwMDFjXHUwMDEzMZx2t5X9/1xmOpstTmBcdTAwMDKoktn8fVx1MDAxZZvlXHUwMDE3pjeVzSQxINDAXHUwMDEzXGYpxHXqj1x1MDAwMZkpQzCMOaeKXHUwMDBi/qJXw+ZQXHUwMDE5NVx1MDAxOMijxFx1MDAwNM4kKKUpVEZcZlx1MDAwMcJcdTAwMDR0iVJcdTAwMTIxltnv/Vh0oUpcYs1AvKyXzJgmXHUwMDE5sfQvktlcdTAwMGVQXHUwMDA3lVx1MDAwMCTMQEpKJsgkdYCjKbhZUVxuLCM0aM7n0Vl+1XTMqMQmXHUwMDA07UEkSEz0hFEw/UQgcCeiXHUwMDE45CdWr5rOdmaHc3JMXHUwMDA28lx1MDAxM1x0LbdcXCwzr7OOcVx1MDAxYeeYwaLCXHUwMDE3L1x1MDAxNvNcdTAwMWatfetL7XDvwlx1MDAxM7FC3S46vmttOqdcdEZcckWJgHDjXHUwMDEyKzZZK9ZUKFhQYGXNvlT2nPddmSWcO87YJKVcdTAwMTGR9pxcdTAwMTaKM1vQXHUwMDFlOEtCqqVhTp7xXGJoXHUwMDFlZ1xyw2pKyaLt69L9j6/V71cq+u53j3ixcVJ9eSWkKE87ptM+/G7+1Y0vT2k1qsYzXHUwMDFl+i6pXHUwMDEyMn0gabePiJrN36BcdTAwMDKUeMrjsFxccM6qhEiSkzrBIVx1MDAwNVx1MDAxNotcdTAwMDMzf/o2XHUwMDE2mCpcdTAwMGaYmsHStCRg5qpccsKnQJNMpEZcdTAwMThcdTAwMDNXcs7Uup/OPjFcdTAwMWPTWU9cdTAwMGIhmUeGI4WQdJCPhVx1MDAxMKeT2GR8cHpvXHUwMDFhTu/9deHiujDjXHUwMDA1Tj3y46W9wDlnkVx1MDAxOa92TDd4gMmtXHUwMDA3oFx1MDAxN8xW6zxcdTAwMDa/XHUwMDBlXHUwMDA1XGZMnWs/OCf1ZaHjOt29Kbx+1z+SXvs4T1x1MDAwMOUkXHUwMDEz9/PX1q//XHUwMDAxXHUwMDA3vCMgIn0= App()Container( id=\"dialog\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")events.Key(key=\"T\")After Textual calls Button.on_key
the event bubbles to the button's parent and will call Container.on_key
(if it exists).
As before, the event bubbles to its parent (the App class).
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT4lhcdTAwMTP+7q+w2C/zVlxy2XO/bNXWW4p3XHUwMDFkXHUwMDFkXHUwMDA1dZzXLStAhFxmgWBcdTAwMTK8zNb+97dcdTAwMGYqXHS3XHUwMDEwlTgwO1QpkFx1MDAxYzqdPt1Pnu7Tyd8rq6uF6KHrXHUwMDE0/lgtOPc123PrgX1X+Gi23zpB6PpcdTAwMWTYRfrfQ79cdTAwMTfU+iObUdRccv/4/fe2XHUwMDFktJyo69k1x7p1w57thVGv7vpWzW//7kZOO/yv+X9ot50/u367XHUwMDFlXHUwMDA1VnyQolN3Iz94PJbjOW2nXHUwMDEzhSD9f/B9dfXv/v+EdnXXbvuden94f0esXHUwMDFl13p066Hf6auKmWBMak3jXHUwMDExbrhcdTAwMDFHi5w67L5cdTAwMDaNnXiP2VRAZVxc/LZ3vr7+5UpUrtTa3XkobuPDXrueV45cdTAwMWW8R0PYtWYvcOK9YVx1MDAxNPgt59ytR01z9JHtg9+FPtgg/lXg91x1MDAxYc2OXHUwMDEzhkO/8bt2zY1cdTAwMWXMNoRcdTAwMDZb7U6jLyPeclx1MDAwZt+KmEhLIaQ5ZZxJitRgd19cdTAwMDDllmJKI0a1YFxu61x1MDAxMcVKvlx1MDAwNzNcdTAwMDGK/Yb6r1i1ql1rNUC/Tj1cdTAwMWWjSE3jxEnfPZ+uolx1MDAxNmOUXHUwMDEwSVx0WFx1MDAxY1x1MDAxMT5cdTAwMTjSdNxGMzJjXGJcdTAwMDE9mVCSP1x1MDAxZSphI6c/J1hJolx05yreY1x1MDAxNOju1vve8deoTZt20H2yXSE0X1x1MDAxMspcdTAwMWK9N0ddK+leiXnvKSEu9q/0fveb+vL15vtR8M0hXHUwMDAzWUO+XHUwMDE4OfdRYbDjn4+/xP4kYodGf8x6wKzaXHUwMDFlRrf35Vx1MDAwYreoP92cVnfOTlx1MDAxZZwvXHUwMDA3k7W1g8C/S8h9+lx1MDAxNPt+r1u3XHUwMDFmIVxmXHUwMDBiQVx1MDAxOeJEUarkYL/ndlqws9PzvHibX2vFqLeSUHhcZmyHzj+JtEhMRVrEXHUwMDE4w0igOOhnIW369C0u0pI0pFXCklx1MDAxY1x1MDAxM1x1MDAwMFn2ZqSNXHUwMDAyu1x1MDAxM3btXHUwMDAw4GtcdTAwMDLaytloS8bRXHUwMDE1SSFcYvxcIlZsbug6T/eMvcDvRGX3e9/FhMWxYohcYkTgmo64XHUwMDFjXHUwMDFhtWW3Xe9haGL7flxmmq91u1x1MDAxZv6TNHXogFxufZl8aPCa5zaMo1x1MDAxN2pwUk4wXHUwMDE0XHUwMDAzkVx1MDAwYtRlMKDt1utewlx1MDAxZmuggVxyMoPdLCzCXHUwMDBm3Ibbsb3KkIKpMfmIXHRcdTAwMTOCXHUwMDEyI6KmRaVcIkozJjDOXHUwMDFllKkotahBSYVFXGJFhHCFNNA9OVx1MDAxNJREUPBcdTAwMWNcdTAwMDAnJoFYYIJZflFpSawxVlxcXHRkXFyejFx1MDAwNyUjXHUwMDE2XHUwMDE1lFJcYlxcRVx1MDAxNUpQ0+dcdTAwMTiVmFx1MDAxMcpwwjczx2hf1dfG6Fx1MDAxMJq9IEbDyFx1MDAwZaJ1t1N3O1xy2Fx1MDAxOV/7nnl9lpjoR3GtZ7QsXCJcdTAwMGJcIlxcMiVcdTAwMTUgLONcdTAwMWMmViXGNeyusaOFsWCYXHUwMDFhsFVgcfI0YHBcdTAwMTUuOJ36bK22909Pd13S+7z9cNw624nOTsrlL5O0XHUwMDAypVx1MDAwMDypQDA/iiNcdTAwMDPwMeZcdTAwMGW00lx1MDAxNlKUXHUwMDBiialWiFA5ppRnh1HJb7fdXGKs/9l3O9GolfvmXFwz4d507ProXjip5L5RXFzoXHUwMDFhicNkN/60XHUwMDFhx03/y+DzX1x1MDAxZieOLk51Z/NcdTAwMWFz5FjcSvJ9XHUwMDFholx1MDAwNU4teoznXHSoRijlo5ufUY0gpYngmtLMsJY+y4tcbmuYUEsyXG54QJnJ31hsXHUwMDEyI4FiZmkuIaWD2Vx1MDAxMZrlSDZETPxcdTAwMDZApmKweEIuXHKhylx1MDAxMCM5kIs0Zu2fXHUwMDE3T2+6p9WOOjhu3n1cdTAwMGbsi1wiZ29PL7pF50SSXHLt7jKt9nuHLba9s5eNsKfKPd87u707YFx1MDAxYp9cdTAwMGVcdTAwMDJe334gPcI2xFx1MDAxY+SS8/ruzlar9kmtMVxcaXtHm52vjTnIzcm8yyW2Jc53b7e6J/iqXHUwMDE3Nlu3fFx1MDAwYl1cdTAwMWbW/3XGfUNcdTAwMGX7XHUwMDEzWSHqXu00Nq+0s1x1MDAxOVRxsX5cdTAwMWKG3l4wXHUwMDA3KyCnsvnJ82XjqHm/s394sulT725cdTAwMTGtO6tOMvmAsdhnejCVjDLNgcfH5Dmnelx1MDAwNrDsqSRcdTAwMDNcdTAwMGItXHT8yewkI93OXHUwMDBiSzKwSiVcdTAwMTlcdTAwMTRZ7H1IXHUwMDA2n0AyXHUwMDEySf5cdTAwMTPJgFx1MDAxY85cdTAwMTQxWDwv71DBeHRI/Fx1MDAwMoecXFzByFqxKD2XXHUwMDEzPlxcdsxcdTAwMGW3/uelWVx1MDAwM/H8xmXhsjO5mMHJkJxBrcJzrofd/0WljFx1MDAxOdR5tJQxU/PUSE3NXHQopmRauCpIXyVjQmaO1kP3XHUwMDA2bzU66/dcdTAwMDeoev39SFKvVKr/2GjlM4OVMG0pXHUwMDAyXHUwMDE5L1wiWFxuotVwsDJcdTAwMDU5XHUwMDE505JJzjWwcZFfsGo0XHUwMDFlrEqMXHUwMDA2K1NcdTAwMDIyYczeOSNwTnmVVuAlO8Xt1s3DWsA3rn5lXHUwMDA088pcYnIy73KJzSsjWC4r5JVcdTAwMTEsl1x1MDAxNTy9XlLV7Vx1MDAxMitcdOGUK/v3ja2teTD3nNTNK4FZlkmblb9MPmAs9unTXHUwMDBmz18oYdNbXySmlFx1MDAwYqqzM6J0Oy8sI2LpjEi+XHUwMDE3I1JcdTAwMTNcdTAwMTiRXHUwMDFjY0RKaES1UD91+rLei1wiv/PBbHvMXHUwMDAyLlx1MDAwYlx1MDAxN054Wfj4+O3WXHUwMDBlXFy7XHUwMDEzQWJcdTAwMTD2ajU4u+k5jVx1MDAxY1x1MDAxNj6nnGZcdTAwMDb3XHUwMDFmzWledzqpIT0j0Vx1MDAxMVPjmmjNMKTj2fss1Oej3fXjXHUwMDBi965q22F5u/LlU/PsfvHLXHUwMDEy1IJ4RVxcYoI1XHUwMDExmqmRuMaWwtysWULSQ4XKL64zZjpCaa6QeOe2tWtSo7h3Ujyon8pcdTAwMTLueC1ug9Bfmc6cMp2czLtcXGLzynSWy1xueWU6y2WFvDKd5bJCXis1y2KFWVx01ORcdTAwMDPGYp8+LUBcdTAwMDLFU1x1MDAxMijJJWGSZb93IN3Oi8q01FxmoiXei2hlS6BcdTAwMTjTjCgt/m1cdNShPyHhcFx1MDAwMF2C986eZiRcdTAwMTRcdTAwMTmyp1nnklx1MDAxYcxTO2FcdMJpy7mYMcp59rwpXHUwMDFk5Fx1MDAxNzWaibRcdTAwMTinlEqJXHUwMDA0I5QmXG5ccv1wRtzChGAlsemkppLnXHUwMDE3z1x1MDAxOFtCgFx1MDAxMkZcdTAwMWZcdTAwMDJQy8bDW3CLa66V6ZXUSGI6XHUwMDFh7Vx1MDAwMExCICXly6P99b2wL7/6JPSY1lx1MDAwYlx1MDAxYveRMkw5XHUwMDAxX6RiSmsrsTCYjVx1MDAxMDNCKMVoou8yS/fq0+DZnbCxTmBlXHUwMDBlXHQsolxmfFx1MDAwMsWoO9BcdCZcdTAwMTNpKbRcdTAwMDR9kIBJw9N0mlxmXHUwMDBmYzotUyPsdFc2rzEnjsWtJN9f3tqvp9d3KTiQubsxO56lV/1cdTAwMTdcdTAwMTXPqDbBgFx1MDAxOdicXHUwMDEygHBcdTAwMWRfQ1x1MDAxZvFMWFRcdTAwMGJcYiNNNVx1MDAxNXnecIOpXHUwMDA1XGZIK1x1MDAwNJPNpFBcdTAwMTN6+1x1MDAwNbGEpkhcdTAwMDBfUlx1MDAxMoGyXHR0fWIvXFxcIlx1MDAwZVx1MDAwMPOKXHUwMDA18Vx1MDAwNcWzXCKAXHUwMDA3NVxyOTA5nEgmkmD1iFx1MDAxZGA4XG5mU2ZBQlxinWgxylx0z4xORiWzJoBcdTAwMDG2cNySXHUwMDFjXHUwMDAzXHUwMDFhtYhAYFx1MDAxZkSxUlx1MDAxYatpOk0uXHUwMDE2LzWeXHUwMDE1pzuzeY278Vx1MDAwYlx1MDAxMS21uq3o9LtcYvshTjDJjmqVXHUwMDA3t1k+uHPuSifOt/Jaw1x0i9/2fyyq0VmgRihcdTAwMWVes1x1MDAxYcm4jPmxJNLgXHUwMDE5SvY0veZ2bVZcdTAwMTPONWdsXHUwMDFj0UhcIpmLK9sxTDzfj4SVuWmKklx1MDAxY+5HXHUwMDFh+NWEqkXt+vz4QNb3mq1cdTAwMWLvXHUwMDBivjj8fmJvbb29XHUwMDE4UpJHt7bT27mxT++i8yN6XHUwMDE4XHUwMDFlRvuTxb6odpOTunNcdTAwMTc7q3Yz+YCx2GdcdTAwMDBIudpcdTAwMDDyJlx1MDAxNk1yqt1IOb10g1x1MDAwMdZcdTAwMTBcdTAwMDeyllx1MDAxOUbSzbyoMFwiUmBcdTAwMDRi1sJcdFx1MDAxOHlcdTAwMTOKpDIjwifgXGJcdTAwMTlL5bBkXFzBgX5A4eZV1CdRuCFoaOugcFx1MDAxM5/Kc+HGuTU6WfvOw4eW8/DnZaFyWZhy67FcdTAwMWX68dxuPZ5xQVx1MDAxY63OTFb49Vd3nUjzx8KSYE2xTjzaY1ZYejdl9nnz/qHZ3tpu672d43DXX1/wsFx1MDAwNPSxuGKacHPNXHUwMDE0hI801Fx1MDAxM2KZZmVI/Vx1MDAxOXBmJN90N/LbL+8ms9LoVVx1MDAwYtdvubpfXHR0UdtZP946p3vVs/2Tln/8eWNxr+45qbssYmeRhslcdTAwMDfMqG2vWj/fYdXKzV6RrYn2UbVabspsc5aBjEhNaO5PRlE65W5lSLA4ZSp7TpM+fYuKejxcdTAwMTX1OLbk3FBvXHUwMDFldISYslx1MDAwMEf8XZ+EXHUwMDAy7kg5euuTUJaJjsy4gudNR8wjiaZGJqZKcYNcdTAwMGaZI3N9jbZcdTAwMTTfaWzfn5XOfMlC7yxoLXpcclx1MDAxNZtcdTAwMWGpXHUwMDEwXHUwMDFjklx1MDAwMY20XHUwMDEymlxyhSbm3JJcXHJwTZO64eRcIsyPICRcXDNO2KtcdTAwMTZ430JIPt/qYD3c6V03v29ffN3arGzc3PdcdTAwMTaXkOSk7nKJ3f3aq5xViN495Vx1MDAxN2frd07P35XeXCJcdTAwMWF3XHUwMDE2f5p8wFx1MDAxZs+fKFVEXHUwMDEynHsxXHUwMDA3J59cdTAwMDQ5XG7TXHUwMDE0XHUwMDAwQSRJ3CyUTp++RUVpjFNRWnGLzFxypedSz4G0kSMk87i5c55cdTAwMGW57Fxmalx1MDAwNueYXHUwMDAzg0p5ttz09WdJXHRcdTAwMTOcveDRcqkw9aKoJO9cdTAwMTaV5vZcItPywKnCiGAx0lx1MDAxZEeRtFx1MDAwNMdSIVxykavV9NuLXHUwMDFjIeVbgtI8fkxRhDnlXGaSK8YxmfC4XHUwMDA0XHUwMDAxWVx1MDAxNoP9XHUwMDE0eC2nVNCx9jmTnUlcdTAwMDRcdTAwMTDznlx1MDAxZDVPUfuq/rmMT5dLzzNWh5/jXHUwMDA2zF9rJMCI2DzCLNHN8bw4zCxBuVx1MDAxNppIhVx1MDAxOWj+3NPxwqfLpcfu6tCKNaJEKTgq4qbpR3MxrpayXHUwMDAwdDVkrlxcKUSVYGNaLdUqdJpP91x1MDAwN4y7cyxzJfn+Yr6RuJyNXHUwMDAwXHUwMDFiU5pcIk1Zdrpx4GyfXpVON4vnVcq/ke1cdTAwMTLZrp0sPLBJYVx1MDAxMbP2jzhcdTAwMDZGQYZxjUjz5DlOzJ2KXG6ihOXXJpgowMRkY6xvxviAIFi9a7VcdTAwMDbYXHUwMDE45TSvxaPxrt9qr1pNXHUwMDEygSS5UEOjs3byRn53XHUwMDFhr1x1MDAxODqLUVx1MDAxMvGkyWNsrTxFcMHudstcdTAwMTFYaFx1MDAwMHgwXHRu/ek0Y3mFW9e5W59QXHUwMDFhuO6/jNR+vJrIcMxcdTAwMTT8/c/KP/9cdTAwMDeoXHUwMDAzMlx1MDAwNiJ9 App()Container( id=\"dialog\")Button( \"Yes\", variant=\"success\")Button( \"No\", variant=\"error\")events.Key(key=\"T\")events.Key(key=\"T\")events.Key(key=\"T\")bubbleThe App class is always the root of the DOM, so there is nowhere for the event to bubble to.
"},{"location":"guide/events/#stopping-bubbling","title":"Stopping bubbling","text":"Event handlers may stop this bubble behavior by calling the stop() method on the event or message. You might want to do this if a widget has responded to the event in an authoritative way. For instance when a text input widget responds to a key event it stops the bubbling so that the key doesn't also invoke a key binding.
"},{"location":"guide/events/#custom-messages","title":"Custom messages","text":"You can create custom messages for your application that may be used in the same way as events (recall that events are simply messages reserved for use by Textual).
The most common reason to do this is if you are building a custom widget and you need to inform a parent widget about a state change.
Let's look at an example which defines a custom message. The following example creates color buttons which\u2014when clicked\u2014send a custom message.
custom01.pyOutput custom01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.message import Message\nfrom textual.widgets import Static\nclass ColorButton(Static):\n\"\"\"A color button.\"\"\"\nclass Selected(Message):\n\"\"\"Color selected message.\"\"\"\ndef __init__(self, color: Color) -> None:\nself.color = color\nsuper().__init__()\ndef __init__(self, color: Color) -> None:\nself.color = color\nsuper().__init__()\ndef on_mount(self) -> None:\nself.styles.margin = (1, 2)\nself.styles.content_align = (\"center\", \"middle\")\nself.styles.background = Color.parse(\"#ffffff33\")\nself.styles.border = (\"tall\", self.color)\ndef on_click(self) -> None:\n# The post_message method sends an event to be handled in the DOM\nself.post_message(self.Selected(self.color))\ndef render(self) -> str:\nreturn str(self.color)\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nyield ColorButton(Color.parse(\"#008080\"))\nyield ColorButton(Color.parse(\"#808000\"))\nyield ColorButton(Color.parse(\"#E9967A\"))\nyield ColorButton(Color.parse(\"#121212\"))\ndef on_color_button_selected(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
ColorApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(0,\u00a0128,\u00a0128)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(128,\u00a0128,\u00a00)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(233,\u00a0150,\u00a0122)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aColor(18,\u00a018,\u00a018)\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the custom message class which extends Message. The constructor stores a color object which handler methods will be able to inspect.
The message class is defined within the widget class itself. This is not strictly required but recommended, for these reasons:
ColorButton
, you have access to the message class via ColorButton.Selected
.on_selected
, the handler name becomes on_color_button_selected
. This makes it less likely that your chosen name will clash with another message.To send a message call the post_message() method. This will place a message on the widget's message queue and run any message handlers.
It is common for widgets to send messages to themselves, and allow them to bubble. This is so a base class has an opportunity to handle the message. We do this in the example above, which means a subclass could add a on_color_button_selected
if it wanted to handle the message itself.
You can temporarily disable posting of messages of a particular type by calling prevent, which returns a context manager (used with Python's with
keyword). This is typically used when updating data in a child widget and you don't want to receive notifications that something has changed.
The following example will play the terminal bell as you type. It does this by handling Input.Changed and calling bell(). There is a Clear button which sets the input's value to an empty string. This would normally also result in a Input.Changed
event being sent (and the bell playing). Since we don't want the button to make a sound, the assignment to value
is wrapped within a prevent context manager.
Tip
In reality, playing the terminal bell as you type would be very irritating -- we don't recommend it!
prevent.pyOutput prevent.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Button, Input\nclass PreventApp(App):\n\"\"\"Demonstrates `prevent` context manager.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Input()\nyield Button(\"Clear\", id=\"clear\")\ndef on_button_pressed(self) -> None:\n\"\"\"Clear the text input.\"\"\"\ninput = self.query_one(Input)\nwith input.prevent(Input.Changed): # (1)!\ninput.value = \"\"\ndef on_input_changed(self) -> None:\n\"\"\"Called as the user types.\"\"\"\nself.bell() # (2)!\nif __name__ == \"__main__\":\napp = PreventApp()\napp.run()\n
PreventApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Clear\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
"},{"location":"guide/events/#message-handlers","title":"Message handlers","text":"Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.
"},{"location":"guide/events/#handler-naming","title":"Handler naming","text":"Textual uses the following scheme to map messages classes on to a Python method.
\"on_\"
.\"_\"
.Messages have a namespace if they are defined as a child class of a Widget. The namespace is the name of the parent class. For instance, the builtin Input
class defines it's Changed
message as follow:
class Input(Widget):\n...\nclass Changed(Message):\n\"\"\"Posted when the value changes.\"\"\"\n...\n
Because Changed
is a child class of Input
, its namespace will be \"input\" (and the handler name will be on_input_changed
). This allows you to have similarly named events, without clashing event handler names.
Tip
If you are ever in doubt about what the handler name should be for a given event, print the handler_name
class variable for your event class.
Here's how you would check the handler name for the Input.Changed
event:
>>> from textual.widgets import Input\n>>> Input.Changed.handler_name\n'on_input_changed'\n
"},{"location":"guide/events/#on-decorator","title":"On decorator","text":"In addition to the naming convention, message handlers may be created with the on
decorator, which turns a method into a handler for the given message or event.
For instance, the two methods declared below are equivalent:
@on(Button.Pressed)\ndef handle_button_pressed(self):\n...\ndef on_button_pressed(self):\n...\n
While this allows you to name your method handlers anything you want, the main advantage of the decorator approach over the naming convention is that you can specify which widget(s) you want to handle messages for.
Let's first explore where this can be useful. In the following example we have three buttons, each of which does something different; one plays the bell, one toggles dark mode, and the other quits the app.
on_decorator01.pyOutput on_decorator01.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\ndef on_button_pressed(self, event: Button.Pressed) -> None: # (1)!\n\"\"\"Handle all button pressed events.\"\"\"\nif event.button.id == \"bell\":\nself.bell()\nelif event.button.has_class(\"toggle\", \"dark\"):\nself.dark = not self.dark\nelif event.button.id == \"quit\":\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Note how the message handler has a chained if
statement to match the action to the button. While this works just fine, it can be a little hard to follow when the number of buttons grows.
The on
decorator takes a CSS selector in addition to the event type which will be used to select which controls the handler should work with. We can use this to write a handler per control rather than manage them all in a single handler.
The following example uses the decorator approach to write individual message handlers for each of the three buttons:
on_decorator02.pyOutput on_decorator02.pyfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nclass OnDecoratorApp(App):\nCSS_PATH = \"on_decorator.tcss\"\ndef compose(self) -> ComposeResult:\n\"\"\"Three buttons.\"\"\"\nyield Button(\"Bell\", id=\"bell\")\nyield Button(\"Toggle dark\", classes=\"toggle dark\")\nyield Button(\"Quit\", id=\"quit\")\n@on(Button.Pressed, \"#bell\") # (1)!\ndef play_bell(self):\n\"\"\"Called when the bell button is pressed.\"\"\"\nself.bell()\n@on(Button.Pressed, \".toggle.dark\") # (2)!\ndef toggle_dark(self):\n\"\"\"Called when the 'toggle dark' button is pressed.\"\"\"\nself.dark = not self.dark\n@on(Button.Pressed, \"#quit\") # (3)!\ndef quit(self):\n\"\"\"Called when the quit button is pressed.\"\"\"\nself.exit()\nif __name__ == \"__main__\":\napp = OnDecoratorApp()\napp.run()\n
#
to match the id)OnDecoratorApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Bell\u00a0\u00a0Toggle\u00a0dark\u00a0\u00a0Quit\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
While there are a few more lines of code, it is clearer what will happen when you click any given button.
Note that the decorator requires that the message class has a control
attribute which should be the widget associated with the message. Messages from builtin controls will have this attribute, but you may need to add control
to any custom messages you write.
Note
If multiple decorated handlers match the message, then they will all be called in the order they are defined.
The naming convention handler will be called after any decorated handlers.
"},{"location":"guide/events/#applying-css-selectors-to-arbitrary-attributes","title":"Applying CSS selectors to arbitrary attributes","text":"The on
decorator also accepts selectors as keyword arguments that may be used to match other attributes in a Message, provided those attributes are in Message.ALLOW_SELECTOR_MATCH
.
The snippet below shows how to match the message TabbedContent.TabActivated
only when the tab with id home
was activated:
@on(TabbedContent.TabActivated, tab=\"#home\")\ndef home_tab(self) -> None:\nself.log(\"Switched back to home tab.\")\n...\n
"},{"location":"guide/events/#handler-arguments","title":"Handler arguments","text":"Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from custom01.py
above) contains a message
parameter. The body of the code makes use of the message to set a preset color.
def on_color_button_selected(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\n
A similar handler can be written using the decorator on
:
@on(ColorButton.Selected)\ndef animate_background_color(self, message: ColorButton.Selected) -> None:\nself.screen.styles.animate(\"background\", message.color, duration=0.5)\n
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
def on_color_button_selected(self) -> None:\nself.app.bell()\n
This pattern is a convenience that saves writing out a parameter that may not be used.
"},{"location":"guide/events/#async-handlers","title":"Async handlers","text":"Message handlers may be coroutines. If you prefix your handlers with the async
keyword, Textual will await
them. This lets your handler use the await
keyword for asynchronous APIs.
If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from its message queue until the handler has returned. This is rarely a problem in practice; as long as handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use.
Info
To re-use the chef analogy, if an order comes in for beef wellington (which takes a while to cook), orders may start to pile up and customers may have to wait for their meal. The solution would be to have another chef work on the wellington while the first chef picks up new orders.
Network access is a common cause of slow handlers. If you try to retrieve a file from the internet, the message handler may take anything up to a few seconds to return, which would prevent the widget or app from updating during that time. The solution is to launch a new asyncio task to do the network task in the background.
Let's look at an example which looks up word definitions from an api as you type.
Note
You will need to install httpx with pip install httpx
to run this example.
import asyncio\ntry:\nimport httpx\nexcept ImportError:\nraise ImportError(\"Please install httpx with 'pip install httpx' \")\nfrom rich.json import JSON\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass DictionaryApp(App):\n\"\"\"Searches a dictionary API as-you-type.\"\"\"\nCSS_PATH = \"dictionary.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Search for a word\")\nyield VerticalScroll(Static(id=\"results\"), id=\"results-container\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"A coroutine to handle a text changed message.\"\"\"\nif message.value:\n# Look up the word in the background\nasyncio.create_task(self.lookup_word(message.value))\nelse:\n# Clear the results\nself.query_one(\"#results\", Static).update()\nasync def lookup_word(self, word: str) -> None:\n\"\"\"Looks up a word.\"\"\"\nurl = f\"https://api.dictionaryapi.dev/api/v2/entries/en/{word}\"\nasync with httpx.AsyncClient() as client:\nresults = (await client.get(url)).text\nif word == self.query_one(Input).value:\nself.query_one(\"#results\", Static).update(JSON(results))\nif __name__ == \"__main__\":\napp = DictionaryApp()\napp.run()\n
dictionary.tcssScreen {\nbackground: $panel;\n}\nInput {\ndock: top;\nwidth: 100%;\nheight: 1;\npadding: 0 1;\nmargin: 1 1 0 1;\n}\n#results {\nwidth: auto;\nmin-height: 100%;\n}\n#results-container {\nbackground: $background 50%;\noverflow: auto;\nmargin: 1 2;\nheight: 100%;\n}\n
DictionaryApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSearch\u00a0for\u00a0a\u00a0word\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the highlighted line in the above code which calls asyncio.create_task
to run a coroutine in the background. Without this you would find typing in to the text box to be unresponsive.
This chapter will discuss how to make your app respond to input in the form of key presses and mouse actions.
Quote
More Input!
\u2014 Johnny Five
"},{"location":"guide/input/#keyboard-input","title":"Keyboard input","text":"The most fundamental way to receive input is via Key events which are sent to your app when the user presses a key. Let's write an app to show key events as you type.
key01.pyOutput key01.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\ndef on_key(self, event: events.Key) -> None:\nself.query_one(RichLog).write(event)\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
InputApp Key(key='T',\u00a0character='T',\u00a0name='upper_t',\u00a0is_printable=True) Key(key='e',\u00a0character='e',\u00a0name='e',\u00a0is_printable=True) Key(key='x',\u00a0character='x',\u00a0name='x',\u00a0is_printable=True) Key(key='t',\u00a0character='t',\u00a0name='t',\u00a0is_printable=True) Key(key='u',\u00a0character='u',\u00a0name='u',\u00a0is_printable=True) Key(key='a',\u00a0character='a',\u00a0name='a',\u00a0is_printable=True) Key(key='l',\u00a0character='l',\u00a0name='l',\u00a0is_printable=True) Key( key='exclamation_mark', character='!', name='exclamation_mark', is_printable=True )
When you press a key, the app will receive the event and write it to a RichLog widget. Try pressing a few keys to see what happens.
Tip
For a more feature rich version of this example, run textual keys
from the command line.
The key event contains the following attributes which your app can use to know how to respond.
"},{"location":"guide/input/#key","title":"key","text":"The key
attribute is a string which identifies the key that was pressed. The value of key
will be a single character for letters and numbers, or a longer identifier for other keys.
Some keys may be combined with the Shift key. In the case of letters, this will result in a capital letter as you might expect. For non-printable keys, the key
attribute will be prefixed with shift+
. For example, Shift+Home will produce an event with key=\"shift+home\"
.
Many keys can also be combined with Ctrl which will prefix the key with ctrl+
. For instance, Ctrl+P will produce an event with key=\"ctrl+p\"
.
Warning
Not all keys combinations are supported in terminals and some keys may be intercepted by your OS. If in doubt, run textual keys
from the command line.
If the key has an associated printable character, then character
will contain a string with a single Unicode character. If there is no printable character for the key (such as for function keys) then character
will be None
.
For example the P key will produce character=\"p\"
but F2 will produce character=None
.
The name
attribute is similar to key
but, unlike key
, is guaranteed to be valid within a Python function name. Textual derives name
from the key
attribute by lower casing it and replacing +
with _
. Upper case letters are prefixed with upper_
to distinguish them from lower case names.
For example, Ctrl+P produces name=\"ctrl_p\"
and Shift+P produces name=\"upper_p\"
.
The is_printable
attribute is a boolean which indicates if the key would typically result in something that could be used in an input widget. If is_printable
is False
then the key is a control code or function key that you wouldn't expect to produce anything in an input.
Some keys or combinations of keys can produce the same event. For instance, the Tab key is indistinguishable from Ctrl+I in the terminal. For such keys, Textual events will contain a list of the possible keys that may have produced this event. In the case of Tab, the aliases
attribute will contain [\"tab\", \"ctrl+i\"]
Textual offers a convenient way of handling specific keys. If you create a method beginning with key_
followed by the key name (the event's name
attribute), then that method will be called in response to the key press.
Let's add a key method to the example code.
key02.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\ndef on_key(self, event: events.Key) -> None:\nself.query_one(RichLog).write(event)\ndef key_space(self) -> None:\nself.bell()\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
Note the addition of a key_space
method which is called in response to the space key, and plays the terminal bell noise.
Note
Consider key methods to be a convenience for experimenting with Textual features. In nearly all cases, key bindings and actions are preferable.
"},{"location":"guide/input/#input-focus","title":"Input focus","text":"Only a single widget may receive key events at a time. The widget which is actively receiving key events is said to have input focus.
The following example shows how focus works in practice.
key03.pykey03.tcssOutput key03.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nclass KeyLogger(RichLog):\ndef on_key(self, event: events.Key) -> None:\nself.write(event)\nclass InputApp(App):\n\"\"\"App to display key events.\"\"\"\nCSS_PATH = \"key03.tcss\"\ndef compose(self) -> ComposeResult:\nyield KeyLogger()\nyield KeyLogger()\nyield KeyLogger()\nyield KeyLogger()\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
key03.tcssScreen {\nlayout: grid;\ngrid-size: 2 2;\ngrid-columns: 1fr;\n}\nKeyLogger {\nborder: blank;\n}\nKeyLogger:hover {\nborder: wide $secondary;\n}\nKeyLogger:focus {\nborder: wide $accent;\n}\n
InputApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 name='o',\u258echaracter='d',\u258a is_printable=True\u258ename='d',\u258a )\u258eis_printable=True\u258a Key(\u258e)\u258a key='tab',\u258eKey(\u258a character='\\t',\u258ekey='exclamation_mark',\u258a name='tab',\u258echaracter='!',\u258a is_printable=False,\u2586\u2586\u258ename='exclamation_mark',\u2587\u2587\u258a aliases=['tab',\u00a0'ctrl+i']\u258eis_printable=True\u258a )\u258e)\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
The app splits the screen in to quarters, with a RichLog
widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that the widget has focus. Key events will be sent to the focused widget only.
Tip
the :focus
CSS pseudo-selector can be used to apply a style to the focused widget.
You can move focus by pressing the Tab key to focus the next widget. Pressing Shift+Tab moves the focus in the opposite direction.
"},{"location":"guide/input/#controlling-focus","title":"Controlling focus","text":"Textual will handle keyboard focus automatically, but you can tell Textual to focus a widget by calling the widget's focus() method.
"},{"location":"guide/input/#focus-events","title":"Focus events","text":"When a widget receives focus, it is sent a Focus event. When a widget loses focus it is sent a Blur event.
"},{"location":"guide/input/#bindings","title":"Bindings","text":"Keys may be associated with actions for a given widget. This association is known as a key binding.
To create bindings, add a BINDINGS
class variable to your app or widget. This should be a list of tuples of three strings. The first value is the key, the second is the action, the third value is a short human readable description.
The following example binds the keys R, G, and B to an action which adds a bar widget to the screen.
binding01.pybinding01.tcssOutput binding01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Footer, Static\nclass Bar(Static):\npass\nclass BindingApp(App):\nCSS_PATH = \"binding01.tcss\"\nBINDINGS = [\n(\"r\", \"add_bar('red')\", \"Add Red\"),\n(\"g\", \"add_bar('green')\", \"Add Green\"),\n(\"b\", \"add_bar('blue')\", \"Add Blue\"),\n]\ndef compose(self) -> ComposeResult:\nyield Footer()\ndef action_add_bar(self, color: str) -> None:\nbar = Bar(color)\nbar.styles.background = Color.parse(color).with_alpha(0.5)\nself.mount(bar)\nself.call_after_refresh(self.screen.scroll_end, animate=False)\nif __name__ == \"__main__\":\napp = BindingApp()\napp.run()\n
binding01.tcssBar {\nheight: 5;\ncontent-align: center middle;\ntext-style: bold;\nmargin: 1 2;\ncolor: $text;\n}\n
BindingApp red\u2582\u2582 green blue blue \u00a0R\u00a0\u00a0Add\u00a0Red\u00a0\u00a0G\u00a0\u00a0Add\u00a0Green\u00a0\u00a0B\u00a0\u00a0Add\u00a0Blue\u00a0
Note how the footer displays bindings and makes them clickable.
Tip
Multiple keys can be bound to a single action by comma-separating them. For example, (\"r,t\", \"add_bar('red')\", \"Add Red\")
means both R and T are bound to add_bar('red')
.
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a Binding instance which exposes a few more options.
"},{"location":"guide/input/#priority-bindings","title":"Priority bindings","text":"Individual bindings may be marked as a priority, which means they will be checked prior to the bindings of the focused widget. This feature is often used to create hot-keys on the app or screen. Such bindings can not be disabled by binding the same key on a widget.
You can create priority key bindings by setting priority=True
on the Binding object. Textual uses this feature to add a default binding for Ctrl+C so there is always a way to exit the app. Here's the bindings from the App base class. Note the first binding is set as a priority:
BINDINGS = [\nBinding(\"ctrl+c\", \"quit\", \"Quit\", show=False, priority=True),\nBinding(\"tab\", \"focus_next\", \"Focus Next\", show=False),\nBinding(\"shift+tab\", \"focus_previous\", \"Focus Previous\", show=False),\n]\n
"},{"location":"guide/input/#show-bindings","title":"Show bindings","text":"The footer widget can inspect bindings to display available keys. If you don't want a binding to display in the footer you can set show=False
. The default bindings on App do this so that the standard Ctrl+C, Tab and Shift+Tab bindings don't typically appear in the footer.
Textual will send events in response to mouse movement and mouse clicks. These events contain the coordinates of the mouse cursor relative to the terminal or widget.
Information
The trackpad (and possibly other pointer devices) are treated the same as the mouse in terminals.
Terminal coordinates are given by a pair values named x
and y
. The X coordinate is an offset in characters, extending from the left to the right of the screen. The Y coordinate is an offset in lines, extending from the top of the screen to the bottom.
Coordinates may be relative to the screen, so (0, 0)
would be the top left of the screen. Coordinates may also be relative to a widget, where (0, 0)
would be the top left of the widget itself.
When you move the mouse cursor over a widget it will receive MouseMove events which contain the coordinate of the mouse and information about what modifier keys (Ctrl, Shift etc) are held down.
The following example shows mouse movements being used to attach a widget to the mouse cursor.
mouse01.pymouse01.tcss mouse01.pyfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widgets import RichLog, Static\nclass PlayArea(Container):\ndef on_mount(self) -> None:\nself.capture_mouse()\ndef on_mouse_move(self, event: events.MouseMove) -> None:\nself.screen.query_one(RichLog).write(event)\nself.query_one(Ball).offset = event.offset - (8, 2)\nclass Ball(Static):\npass\nclass MouseApp(App):\nCSS_PATH = \"mouse01.tcss\"\ndef compose(self) -> ComposeResult:\nyield RichLog()\nyield PlayArea(Ball(\"Textual\"))\nif __name__ == \"__main__\":\napp = MouseApp()\napp.run()\n
mouse01.tcssScreen {\nlayers: log ball;\n}\nTextLog {\nlayer: log;\n}\nPlayArea {\nopacity: 0%;\nlayer: ball;\n}\nBall {\nlayer: ball;\nwidth: auto;\nheight: 1;\nbackground: $secondary;\nborder: tall $secondary;\ncolor: $background;\nbox-sizing: content-box;\ntext-style: bold;\npadding: 0 4;\n}\n
If you run mouse01.py
you should find that it logs the mouse move event, and keeps a widget pinned directly under the cursor.
The on_mouse_move
handler sets the offset style of the ball (a rectangular one) to match the mouse coordinates.
In the mouse01.py
example there was a call to capture_mouse()
in the mount handler. Textual will send mouse move events to the widget directly under the cursor. You can tell Textual to send all mouse events to a widget regardless of the position of the mouse cursor by calling capture_mouse.
Call release_mouse to restore the default behavior.
Warning
If you capture the mouse, be aware you might get negative mouse coordinates if the cursor is to the left of the widget.
Textual will send a MouseCapture event when the mouse is captured, and a MouseRelease event when it is released.
"},{"location":"guide/input/#enter-and-leave-events","title":"Enter and Leave events","text":"Textual will send a Enter event to a widget when the mouse cursor first moves over it, and a Leave event when the cursor moves off a widget.
"},{"location":"guide/input/#click-events","title":"Click events","text":"There are three events associated with clicking a button on your mouse. When the button is initially pressed, Textual sends a MouseDown event, followed by MouseUp when the button is released. Textual then sends a final Click event.
If you want your app to respond to a mouse click you should prefer the Click event (and not MouseDown or MouseUp). This is because a future version of Textual may support other pointing devices which don't have up and down states.
"},{"location":"guide/input/#scroll-events","title":"Scroll events","text":"Most mice have a scroll wheel which you can use to scroll the window underneath the cursor. Scrollable containers in Textual will handle these automatically, but you can handle MouseScrollDown and MouseScrollUp if you want build your own scrolling functionality.
Information
Terminal emulators will typically convert trackpad gestures in to scroll events.
"},{"location":"guide/layout/","title":"Layout","text":"In Textual, the layout defines how widgets will be arranged (or laid out) inside a container. Textual supports a number of layouts which can be set either via a widget's styles
object or via CSS. Layouts can be used for both high-level positioning of widgets on screen, and for positioning of nested widgets.
The vertical
layout arranges child widgets vertically, from top to bottom.
The example below demonstrates how children are arranged inside a container with the vertical
layout.
VerticalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Two\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Three\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass VerticalLayoutExample(App):\nCSS_PATH = \"vertical_layout.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = VerticalLayoutExample()\napp.run()\n
Screen {\nlayout: vertical;\n}\n.box {\nheight: 1fr;\nborder: solid green;\n}\n
Notice that the first widget yielded from the compose
method appears at the top of the display, the second widget appears below it, and so on. Inside vertical_layout.tcss
, we've assigned layout: vertical
to Screen
. Screen
is the parent container of the widgets yielded from the App.compose
method, and can be thought of as the terminal window itself.
Note
The layout: vertical
CSS isn't strictly necessary in this case, since Screens use a vertical
layout by default.
We've assigned each child .box
a height of 1fr
, which ensures they're each allocated an equal portion of the available height.
You might also have noticed that the child widgets are the same width as the screen, despite nothing in our CSS file suggesting this. This is because widgets expand to the width of their parent container (in this case, the Screen
).
Just like other styles, layout
can be adjusted at runtime by modifying the styles
of a Widget
instance:
widget.styles.layout = \"vertical\"\n
Using fr
units guarantees that the children fill the available height of the parent. However, if the total height of the children exceeds the available space, then Textual will automatically add a scrollbar to the parent Screen
.
Note
A scrollbar is added automatically because Screen
contains the declaration overflow-y: auto;
.
For example, if we swap out height: 1fr;
for height: 10;
in the example above, the child widgets become a fixed height of 10, and a scrollbar appears (assuming our terminal window is sufficiently small):
VerticalLayoutScrolledExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2582\u2582 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Two\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502
With the parent container in focus, we can use our mouse wheel, trackpad, or keyboard to scroll it.
"},{"location":"guide/layout/#horizontal","title":"Horizontal","text":"The horizontal
layout arranges child widgets horizontally, from left to right.
The example below shows how we can arrange widgets horizontally, with minimal changes to the vertical layout example above.
Outputhorizontal_layout.pyhorizontal_layout.tcssHorizontalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass HorizontalLayoutExample(App):\nCSS_PATH = \"horizontal_layout.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = HorizontalLayoutExample()\napp.run()\n
Screen {\nlayout: horizontal;\n}\n.box {\nheight: 100%;\nwidth: 1fr;\nborder: solid green;\n}\n
We've changed the layout
to horizontal
inside our CSS file. As a result, the widgets are now arranged from left to right instead of top to bottom.
We also adjusted the height of the child .box
widgets to 100%
. As mentioned earlier, widgets expand to fill the width of their parent container. They do not, however, expand to fill the container's height. Thus, we need explicitly assign height: 100%
to achieve this.
A consequence of this \"horizontal growth\" behaviour is that if we remove the width restriction from the above example (by deleting width: 1fr;
), each child widget will grow to fit the width of the screen, and only the first widget will be visible. The other two widgets in our layout are offscreen, to the right-hand side of the screen. In the case of horizontal
layout, Textual will not automatically add a scrollbar.
To enable horizontal scrolling, we can use the overflow-x: auto;
declaration:
HorizontalLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u258a
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass HorizontalLayoutExample(App):\nCSS_PATH = \"horizontal_layout_overflow.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nif __name__ == \"__main__\":\napp = HorizontalLayoutExample()\napp.run()\n
Screen {\nlayout: horizontal;\noverflow-x: auto;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
With overflow-x: auto;
, Textual automatically adds a horizontal scrollbar since the width of the children exceeds the available horizontal space in the parent container.
Textual comes with several \"container\" widgets. Among them, we have Vertical, Horizontal, and Grid which have the corresponding layout.
The example below shows how we can combine these containers to create a simple 2x2 grid. Inside a single Horizontal
container, we place two Vertical
containers. In other words, we have a single row containing two columns.
UtilityContainersExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502One\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Two\u2502\u2502Four\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVertical(\nStatic(\"One\"),\nStatic(\"Two\"),\nclasses=\"column\",\n),\nVertical(\nStatic(\"Three\"),\nStatic(\"Four\"),\nclasses=\"column\",\n),\n)\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Static {\ncontent-align: center middle;\nbackground: crimson;\nborder: solid darkred;\nheight: 1fr;\n}\n.column {\nwidth: 1fr;\n}\n
You may be tempted to use many levels of nested utility containers in order to build advanced, grid-like layouts. However, Textual comes with a more powerful mechanism for achieving this known as grid layout, which we'll discuss below.
"},{"location":"guide/layout/#composing-with-context-managers","title":"Composing with context managers","text":"In the previous section, we've shown how you add children to a container (such as Horizontal
and Vertical
) using positional arguments. It's fine to do it this way, but Textual offers a simplified syntax using context managers, which is generally easier to write and edit.
When composing a widget, you can introduce a container using Python's with
statement. Any widgets yielded within that block are added as a child of the container.
Let's update the utility containers example to use the context manager approach.
utility_containers_using_with.pyutility_containers.pyutility_containers.tcssOutputNote
This code uses context managers to compose widgets.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nwith Vertical(classes=\"column\"):\nyield Static(\"One\")\nyield Static(\"Two\")\nwith Vertical(classes=\"column\"):\nyield Static(\"Three\")\nyield Static(\"Four\")\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Note
This is the original code using positional arguments.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, Vertical\nfrom textual.widgets import Static\nclass UtilityContainersExample(App):\nCSS_PATH = \"utility_containers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVertical(\nStatic(\"One\"),\nStatic(\"Two\"),\nclasses=\"column\",\n),\nVertical(\nStatic(\"Three\"),\nStatic(\"Four\"),\nclasses=\"column\",\n),\n)\nif __name__ == \"__main__\":\napp = UtilityContainersExample()\napp.run()\n
Static {\ncontent-align: center middle;\nbackground: crimson;\nborder: solid darkred;\nheight: 1fr;\n}\n.column {\nwidth: 1fr;\n}\n
UtilityContainersExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502One\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Two\u2502\u2502Four\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
Note how the end result is the same, but the code with context managers is a little easier to read. It is up to you which method you want to use, and you can mix context managers with positional arguments if you like!
"},{"location":"guide/layout/#grid","title":"Grid","text":"The grid
layout arranges widgets within a grid. Widgets can span multiple rows and columns to create complex layouts. The diagram below hints at what can be achieved using layout: grid
.
Note
Grid layouts in Textual have little in common with browser-based CSS Grid.
To get started with grid layout, define the number of columns and rows in your grid with the grid-size
CSS property and set layout: grid
. Widgets are inserted into the \"cells\" of the grid from left-to-right and top-to-bottom order.
The following example creates a 3 x 2 grid and adds six widgets to it
Outputgrid_layout1.pygrid_layout1.tcssGridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout1.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3 2;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
If we were to yield a seventh widget from our compose
method, it would not be visible as the grid does not contain enough cells to accommodate it. We can tell Textual to add new rows on demand to fit the number of widgets, by omitting the number of rows from grid-size
. The following example creates a grid with three columns, with rows created on demand:
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Seven\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout2.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nyield Static(\"Seven\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Since we specified that our grid has three columns (grid-size: 3
), and we've yielded seven widgets in total, a third row has been created to accommodate the seventh widget.
Now that we know how to define a simple uniform grid, let's look at how we can customize it to create more complex layouts.
"},{"location":"guide/layout/#row-and-column-sizes","title":"Row and column sizes","text":"You can adjust the width of columns and the height of rows in your grid using the grid-columns
and grid-rows
properties. These properties can take multiple values, letting you specify dimensions on a column-by-column or row-by-row basis.
Continuing on from our earlier 3x2 example grid, let's adjust the width of the columns using grid-columns
. We'll make the first column take up half of the screen width, with the other two columns sharing the remaining space equally.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout3_row_col_adjust.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 2fr 1fr 1fr;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Since our grid-size
is 3 (meaning it has three columns), our grid-columns
declaration has three space-separated values. Each of these values sets the width of a column. The first value refers to the left-most column, the second value refers to the next column, and so on. In the example above, we've given the left-most column a width of 2fr
and the other columns widths of 1fr
. As a result, the first column is allocated twice the width of the other columns.
Similarly, we can adjust the height of a row using grid-rows
. In the following example, we use %
units to adjust the first row of our grid to 25%
height, and the second row to 75%
height (while retaining the grid-columns
change from above).
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout4_row_col_adjust.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 2fr 1fr 1fr;\ngrid-rows: 25% 75%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
If you don't specify enough values in a grid-columns
or grid-rows
declaration, the values you have provided will be \"repeated\". For example, if your grid has four columns (i.e. grid-size: 4;
), then grid-columns: 2 4;
is equivalent to grid-columns: 2 4 2 4;
. If it instead had three columns, then grid-columns: 2 4;
would be equivalent to grid-columns: 2 4 2;
.
The grid-columns
and grid-rows
rules can both accept a value of \"auto\" in place of any of the dimensions, which tells Textual to calculate an optimal size based on the content.
Let's modify the previous example to make the first column an auto
column.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502First\u00a0column\u2502\u2502Two\u2502\u2502Three\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout_auto.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"First column\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-columns: auto 1fr 1fr;\ngrid-rows: 25% 75%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Notice how the first column is just wide enough to fit the content of each cell. The layout will adjust accordingly if you update the content for any widget in that column.
"},{"location":"guide/layout/#cell-spans","title":"Cell spans","text":"Cells may span multiple rows or columns, to create more interesting grid arrangements.
To make a single cell span multiple rows or columns in the grid, we need to be able to select it using CSS. To do this, we'll add an ID to the widget inside our compose
method so we can set the row-span
and column-span
properties using CSS.
Let's add an ID of #two
to the second widget yielded from compose
, and give it a column-span
of 2 to make that widget span two columns. We'll also add a slight tint using tint: magenta 40%;
to draw attention to it.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u00a0(column-span:\u00a02)\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Three\u2502\u2502Four\u2502\u2502Five\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Six\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout5_col_span.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two [b](column-span: 2)\", classes=\"box\", id=\"two\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n#two {\ncolumn-span: 2;\ntint: magenta 40%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Notice that the widget expands to fill columns to the right of its original position. Since #two
now spans two cells instead of one, all widgets that follow it are shifted along one cell in the grid to accommodate. As a result, the final widget wraps on to a new row at the bottom of the grid.
Note
In the example above, setting the column-span
of #two
to be 3 (instead of 2) would have the same effect, since there are only 2 columns available (including #two
's original column).
We can similarly adjust the row-span
of a cell to have it span multiple rows. This can be used in conjunction with column-span
, meaning one cell may span multiple rows and columns. The example below shows row-span
in action. We again target widget #two
in our CSS, and add a row-span: 2;
declaration to it.
GridLayoutExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502One\u2502\u2502Two\u00a0(column-span:\u00a02\u00a0and\u00a0row-span:\u00a02)\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2502\u2502 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u2502\u2502 \u2502Three\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502Four\u2502\u2502Five\u2502\u2502Six\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout6_row_span.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two [b](column-span: 2 and row-span: 2)\", classes=\"box\", id=\"two\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\napp = GridLayoutExample()\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\n}\n#two {\ncolumn-span: 2;\nrow-span: 2;\ntint: magenta 40%;\n}\n.box {\nheight: 100%;\nborder: solid green;\n}\n
Widget #two
now spans two columns and two rows, covering a total of four cells. Notice how the other cells are moved to accommodate this change. The widget that previously occupied a single cell now occupies four cells, thus displacing three cells to a new row.
The spacing between cells in the grid can be adjusted using the grid-gutter
CSS property. By default, cells have no gutter, meaning their edges touch each other. Gutter is applied across every cell in the grid, so grid-gutter
must be used on a widget with layout: grid
(not on a child/cell widget).
To illustrate gutter let's set our Screen
background color to lightgreen
, and the background color of the widgets we yield to darkmagenta
. Now if we add grid-gutter: 1;
to our grid, one cell of spacing appears between the cells and reveals the light green background of the Screen
.
GridLayoutExample OneTwoThree FourFiveSix
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass GridLayoutExample(App):\nCSS_PATH = \"grid_layout7_gutter.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"One\", classes=\"box\")\nyield Static(\"Two\", classes=\"box\")\nyield Static(\"Three\", classes=\"box\")\nyield Static(\"Four\", classes=\"box\")\nyield Static(\"Five\", classes=\"box\")\nyield Static(\"Six\", classes=\"box\")\nif __name__ == \"__main__\":\napp = GridLayoutExample()\napp.run()\n
Screen {\nlayout: grid;\ngrid-size: 3;\ngrid-gutter: 1;\nbackground: lightgreen;\n}\n.box {\nbackground: darkmagenta;\nheight: 100%;\n}\n
Notice that gutter only applies between the cells in a grid, pushing them away from each other. It doesn't add any spacing between cells and the edges of the parent container.
Tip
You can also supply two values to the grid-gutter
property to set vertical and horizontal gutters respectively. Since terminal cells are typically two times taller than they are wide, it's common to set the horizontal gutter equal to double the vertical gutter (e.g. grid-gutter: 1 2;
) in order to achieve visually consistent spacing around grid cells.
Widgets may be docked. Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2aWXPbNlx1MDAxMIDf8ys0ymvM4D4y0+nItpKocWwnjuOj0+nQJCTSokiWpCxLXHUwMDE5//cuaVXURVx1MDAxZrKqqtGDLWFcdGBcdHy72Fx1MDAwNfDjVa1Wz4axqb+r1c2tY1x1MDAwN76b2IP6m7z8xiSpXHUwMDFmhSBcIsXvNOonTvGkl2Vx+u7t256ddE1cdTAwMTZcdTAwMDe2Y6xcdTAwMWI/7dtBmvVdP7KcqPfWz0wv/TX/e2j3zC9x1HOzxCo72TGun0XJfV8mMD1cdTAwMTNmKbT+O/yu1X5cdTAwMTR/p7RLjJPZYScwRYVCVCrIXHSbLz2MwkJZzDXHTCuMJk/46T70l1x1MDAxOVx1MDAxN8Rt0NmUkryovk9cdTAwMWKNLDiPm7vvveD4YtQ6b3mdstu2XHUwMDFmXHUwMDA0J9kwKNRykihNdzw7c7zyiTRLoq45893MyzWYK5/UTSNcdTAwMTiJslZcdTAwMTL1O15o0nSmTlx1MDAxNNuOn1xy8zJUvsL9SLyrlSW3+TBcYksppLimkk9cdTAwMDR5VVwiLc0wQ4LwOWX2olx1MDAwMOZcdTAwMDCUeY2KT6nOle10O6BT6E6eyVx1MDAxMjtMYzuBmSqfXHUwMDFijF+TaWFRqfRMJ57xO15cdTAwMDZSSpSl2LReqSkmQFLMXHUwMDE4RVxcTVx1MDAwNHmnccstWPhjfuw8O4nHY1RP81x1MDAxZlNcbue6NqdAKiv3Y9e+n28sXHUwMDA0YVx1MDAxYyOtJKJcdTAwMTN54IddXHUwMDEwhv0gKMtcIqdbXCJSlN69WYFNrGklm4wjXCI1k09nM+yfXHUwMDFj73qX3/rdXHUwMDEz3jvY/Vx1MDAxNCFf7lWwOcfXLJVkk1RKhlxiZUuoJJjNUbF2KplVgSRcdTAwMTFcdTAwMTYmQMIyKJHGXHUwMDFhXHUwMDFjXHUwMDA3+lx1MDAxZkNpgsCP0+VIXG5VhaQg4D84werJRLZUvG9uj6L9a9p2/mzEPlx1MDAxYuhwXHUwMDE1XCI35yeFtKhSQmI1RyRcdTAwMDVUheZcdTAwMTS/zE++btuccLJIIyaLxE94xNhic13f08hcdTAwMDVSQlx1MDAwYkV+Tlx1MDAxYVx0IVU0YiykJuA7no6jz0dnXel+uP14ddL63CW94Pw82G5cdTAwMWM1tjSli6s25ZbgL1xcslx1MDAwMcUrhPi6UCyCXGLNlPhJUZSiXG5FmFx1MDAxZKQwsPhkXHUwMDEym8R8O/R6XzzxvXf05f1+s5mNXHUwMDBltptEjKXF5Cx0Y1x1MDAxMl9cdTAwMWE7vsbkXG587rpA1FgqJqT8P6/QXHUwMDBmh41cdTAwMTC5VLpFSlx1MDAxOVx1MDAxM1x1MDAxMOc/PW68uTj8XHUwMDFj68ug2T/5yptcdTAwMTk7v4pdv1x1MDAwMkbPdrx+Yv57XHUwMDFjuVx1MDAwNjTIXFxKkVfldJ3rtFhGJdWWXHUwMDEwRcZ031x1MDAxMF2Ek1wiYtFZ5cZ0XG4miCZywys2jFx1MDAwNkViM3TKXHUwMDA3Mm4qXGJoJJ+R1Vx1MDAxY1x1MDAxZYlcdTAwMTZpXFz3XHUwMDAzX35cdTAwMTmq9vfWzaCRbjudXHUwMDA0c0stYJjXXHUwMDE11FKQ2NGXZjZjn7mMT8iYLYZcdTAwMTHmS1NcdTAwMWJJQCghZJRMXHUwMDE3n3lAMVx1MDAwNF2SK0o2SygkXHUwMDE3SMrNXHUwMDEwqpSsXCKU5IqoZ1x1MDAxMbpz9MGcfo/OzcDP1M7p8V+O23K2n1BlgUdcdTAwMTBswYFiziyBJCMzKdB6XHUwMDExJVpZfFVAXHUwMDA1olQwyjaLJ5jFxvBElVmPRlhTNZ1cdTAwMTY9Rif969PnVn/onO3tfrn0mmfNTvL5z+2nXHUwMDEzsu2lyzthzFKU6lx1MDAxN29ccj1Cp0RcdTAwMTDN4dVcdTAwMWMoQ+A/MaGbXHJAYc2hXHUwMDAyb4RQgmTlLlx1MDAxMURHVEJcdTAwMWH4jGyo8enyMOy6UUz6R3vu7c23bD+Mt1x1MDAxZFFOmSU5X7KjLoWl+Dr8JyHqyiwlXHUwMDE0wotcIrZcdTAwMThcdTAwMTOotVjkXHUwMDE0XHUwMDBibTGxdFx1MDAwZlx1MDAxM1x1MDAxMiRcblx1MDAxM1x1MDAwNPRveIlcdTAwMTeYboZQrFHl1rpSmmBKpnKox1x1MDAwMFx1MDAxNa33e8e/XHRcdTAwMTnstM2+STJFXHUwMDBm+61tXHUwMDA3NF/hXHRcdTAwMTWUqPlcdTAwMTiUQs4uOV5DkvSQXHUwMDBmXHUwMDA1K1x1MDAxOJ83XHUwMDE1XHItI1RbsJapXHUwMDEyYzpcdTAwMGYqpVx1MDAxNFDlerMnQDBykESvi1M7SaLBUi/KKlx1MDAxMWWUS4LwM1x1MDAwZSbbl1xyXHUwMDE571x1MDAwN5/E6FwiuKTy4243XHUwMDFjXHUwMDBlVkN0c8c/WECaRDBmRFx1MDAxMIWEwnKGU5afTT5cZil3NEPuqpDmm1xiXHUwMDEwXHUwMDAyc5jyonu2yChB2lr0n1x1MDAwMoPqXG6vcjJZKLdcdTAwMWGXlMKQPsd/TulhJ9muXHUwMDFmun7YXHUwMDAx4T+M1ian661cdTAwMDKig49fvUBcdTAwMGVcdGmedk4v5ECnX9HpRNdcdTAwMWOjyOnnWu4gXHUwMDBiwlxywlx1MDAxNbhcdTAwMTdCNSyDik891rHjYv4teZ/ojiV3XHUwMDEzfUzoltrMvoCdZntRr+dn8OrHkVx1MDAxZmbzT1x1MDAxNO/SyI3KM7Y7L4WWp2Xz1lx1MDAxN+ctlldcdPJP+a1W4ln8mHz/483Sp3cq+SmkXHUwMDA1OmVcdTAwMWKvpv9XOYvM3GZLfVx1MDAwNa5cZrgkg0BcdTAwMTRcdTAwMDLO0sE+5itcdTAwMWWe5i1dznCxnC1cdTAwMWNcdTAwMTQzXG6xOnn5WUi1k4BwfolbWPBcdFx1MDAxME9cdIhcdP+N449cdEM/pvB6kt+fYeveXHUwMDE0JpK7f4D8N1x1MDAxY047XG6zXHUwMDEzf1TsqKCZ0vd2z1x1MDAwZoYzXHUwMDE4XHUwMDE00OeXa4q2ajDwXHUwMDFkk01PV2qg61wiuVAzlVx1MDAxYYHfya2jXHUwMDFlmPas2WS+Y1x1MDAwN1x1MDAxM3FcdTAwMTZNXHKvXHUwMDAzStjQXFzSWnBcdTAwMWVR4nf80Fx1MDAwZb4tVWglw8XVW02QIVxuSVx1MDAxOFJPz5RcdTAwMGVcdTAwMDJzdINij35cdTAwMThdOFe/XVx1MDAwZkbBaMWt+s2t8lRLaz6NXHUwMDA3O7aUnj+/WfNcdTAwMDVcdTAwMGbMS4VcdTAwMWawXFyMXHUwMDE01lx1MDAxMHuwXHKb7uHxdYNeKXamlZdcXCeDXHUwMDBmo1x1MDAwMTtYm+lyjMRz9qteZrpcdTAwMDf2MOpnY0tJt8F25zRaMUTn1Vx1MDAxN7RcdTAwMTCh+DnXs1x1MDAxZZ7uLbVdxoVFXHUwMDE5yq/hIaXx1GWL+1x1MDAwMJ1Z+rG7g0q2+dXqRqykhbjGeqzA1MZTadJcdTAwMTBcdTAwMWFUXdeCcsGJ5qusyy9cdNVz81OrmN9TQ/VcdTAwMDdXgtlQXHUwMDFkVpj8SFxcUYihJFXlJJahOrG0QkyonzdWr+SokE4jVFx1MDAxNbK/XHUwMDFhN1634/gkg/meTFx1MDAwZiDlu2OfWb5h/cY3g91lR8vFJ3dJxSjnpm/y9/xx9+rub5B4Q/4ifQ== Docked widgetLayout widgetsTo dock a widget to an edge, add a dock: <EDGE>;
declaration to it, where <EDGE>
is one of top
, right
, bottom
, or left
. For example, a sidebar similar to that shown in the diagram above can be achieved using dock: left;
. The code below shows a simple sidebar implementation.
DockLayoutExample SidebarDocking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0\u2587\u2587 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout1_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
If we run the app above and scroll down, the body text will scroll but the sidebar does not (note the position of the scrollbar in the output shown above).
Docking multiple widgets to the same edge will result in overlap. The first widget yielded from compose
will appear below widgets yielded after it. Let's dock a second sidebar, #another-sidebar
, to the left of the screen. This new sidebar is double the width of the one previous one, and has a deeppink
background.
DockLayoutExample Sidebar1Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars. \u2587\u2587 Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0 fixes\u00a0its\u00a0position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0 right,\u00a0bottom,\u00a0or\u00a0left\u00a0edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0 making\u00a0them\u00a0ideal\u00a0for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0 and\u00a0sidebars.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout2_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar2\", id=\"another-sidebar\")\nyield Static(\"Sidebar1\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\napp = DockLayoutExample()\nif __name__ == \"__main__\":\napp.run()\n
#another-sidebar {\ndock: left;\nwidth: 30;\nheight: 100%;\nbackground: deeppink;\n}\n#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
Notice that the original sidebar (#sidebar
) appears on top of the newly docked widget. This is because #sidebar
was yielded after #another-sidebar
inside the compose
method.
Of course, we can also dock widgets to multiple edges within the same container. The built-in Header
widget contains some internal CSS which docks it to the top. We can yield it inside compose
, and without any additional CSS, we get a header fixed to the top of the screen.
DockLayoutExample Sidebar1DockLayoutExample Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Header, Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout3_sidebar_header.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header(id=\"header\")\nyield Static(\"Sidebar1\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
If we wished for the sidebar to appear below the header, it'd simply be a case of yielding the sidebar before we yield the header.
"},{"location":"guide/layout/#layers","title":"Layers","text":"Textual has a concept of layers which gives you finely grained control over the order widgets are placed.
When drawing widgets, Textual will first draw on lower layers, working its way up to higher layers. As such, widgets on higher layers will be drawn on top of those on lower layers.
Layer names are defined with a layers
style on a container (parent) widget. Descendants of this widget can then be assigned to one of these layers using a layer
style.
The layers
style takes a space-separated list of layer names. The leftmost name is the lowest layer, and the rightmost is the highest layer. Therefore, if you assign a descendant to the rightmost layer name, it'll be drawn on the top layer and will be visible above all other descendants.
An example layers
declaration looks like: layers: one two three;
. To add a widget to the topmost layer in this case, you'd add a declaration of layer: three;
to it.
In the example below, #box1
is yielded before #box2
. Given our earlier discussion on yield order, you'd expect #box2
to appear on top. However, in this case, both #box1
and #box2
are assigned to layers which define the reverse order, so #box1
is on top of #box2
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"guide/layout/#offsets","title":"Offsets","text":"Widgets have a relative offset which is added to the widget's location, after its location has been determined via its parent's layout. This means that if a widget hasn't had its offset modified using CSS or Python code, it will have an offset of (0, 0)
.
The offset of a widget can be set using the offset
CSS property. offset
takes two values.
x
(horizontal) offset. Positive values will shift the widget to the right. Negative values will shift the widget to the left.y
(vertical) offset. Positive values will shift the widget down. Negative values will shift the widget up.The sections above show how the various layouts in Textual can be used to position widgets on screen. In a real application, you'll make use of several layouts.
The example below shows how an advanced layout can be built by combining the various techniques described on this page.
Outputcombining_layouts.pycombining_layouts.tcssCombiningLayoutsExample \u2b58CombiningLayoutsExample \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502HorizontallyPositionedChildrenHere\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a00\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a01\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2585\u2585\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a02\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\u2502\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502Thispanelis\u2502 \u2502\u2502\u2502\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a03\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502usinggrid\u00a0layout!\u2502 \u2502Vertical\u00a0layout,\u00a0child\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Header, Static\nclass CombiningLayoutsExample(App):\nCSS_PATH = \"combining_layouts.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Container(id=\"app-grid\"):\nwith VerticalScroll(id=\"left-pane\"):\nfor number in range(15):\nyield Static(f\"Vertical layout, child {number}\")\nwith Horizontal(id=\"top-right\"):\nyield Static(\"Horizontally\")\nyield Static(\"Positioned\")\nyield Static(\"Children\")\nyield Static(\"Here\")\nwith Container(id=\"bottom-right\"):\nyield Static(\"This\")\nyield Static(\"panel\")\nyield Static(\"is\")\nyield Static(\"using\")\nyield Static(\"grid layout!\", id=\"bottom-right-final\")\nif __name__ == \"__main__\":\napp = CombiningLayoutsExample()\napp.run()\n
#app-grid {\nlayout: grid;\ngrid-size: 2; /* two columns */\ngrid-columns: 1fr;\ngrid-rows: 1fr;\n}\n#left-pane > Static {\nbackground: $boost;\ncolor: auto;\nmargin-bottom: 1;\npadding: 1;\n}\n#left-pane {\nwidth: 100%;\nheight: 100%;\nrow-span: 2;\nbackground: $panel;\nborder: dodgerblue;\n}\n#top-right {\nheight: 100%;\nbackground: $panel;\nborder: mediumvioletred;\n}\n#top-right > Static {\nwidth: auto;\nheight: 100%;\nmargin-right: 1;\nbackground: $boost;\n}\n#bottom-right {\nheight: 100%;\nlayout: grid;\ngrid-size: 3;\ngrid-columns: 1fr;\ngrid-rows: 1fr;\ngrid-gutter: 1;\nbackground: $panel;\nborder: greenyellow;\n}\n#bottom-right-final {\ncolumn-span: 2;\n}\n#bottom-right > Static {\nheight: 100%;\nbackground: $boost;\n}\n
Textual layouts make it easy to design and build real-life applications with relatively little code.
"},{"location":"guide/queries/","title":"DOM Queries","text":"In the previous chapter we introduced the DOM which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS selectors.
Selectors are a very useful idea and can do more that apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how!
"},{"location":"guide/queries/#query-one","title":"Query one","text":"The query_one method gets a single widget in an app or other widget. If you call it with a selector it will return the first matching widget.
Let's say we have a widget with an ID of send
and we want to get a reference to it in our app. We could do this with the following:
send_button = self.query_one(\"#send\")\n
If there is no widget with an ID of send
, Textual will raise a NoMatches exception. Otherwise it will return the matched widget.
You can also add a second parameter for the expected type.
send_button = self.query_one(\"#send\", Button)\n
If the matched widget is not a button (i.e. if isinstance(widget, Button)
equals False
), Textual will raise a WrongType exception.
Tip
The second parameter allows type-checkers like MyPy to know the exact return type. Without it, MyPy would only know the result of query_one
is a Widget (the base class).
Apps and widgets have a query method which finds (or queries) widgets. This method returns a DOMQuery object which is a list-like container of widgets.
If you call query
with no arguments, you will get back a DOMQuery
containing all widgets. This method is recursive, meaning it will also return child widgets (as many levels as required).
Here's how you might iterate over all the widgets in your app:
for widget in self.query():\nprint(widget)\n
Called on the app
, this will retrieve all widgets in the app. If you call the same method on a widget, it will return the children of that widget.
Note
All the query and related methods work on both App and Widget sub-classes.
"},{"location":"guide/queries/#query-selectors","title":"Query selectors","text":"You can call query
with a CSS selector. Let's look a few examples:
If we want to find all the button widgets, we could do something like the following:
for button in self.query(\"Button\"):\nprint(button)\n
Any selector that works in CSS will work with the query
method. For instance, if we want to find all the disabled buttons in a Dialog widget, we could do this:
for button in self.query(\"Dialog Button.disabled\"):\nprint(button)\n
Info
The selector Dialog Button.disabled
says find all the Button
with a CSS class of disabled
that are a child of a Dialog
widget.
Query objects have a results method which is an alternative way of iterating over widgets. If you supply a type (i.e. a Widget class) then this method will generate only objects of that type.
The following example queries for widgets with the disabled
CSS class and iterates over just the Button objects.
for button in self.query(\".disabled\").results(Button):\nprint(button)\n
Tip
This method allows type-checkers like MyPy to know the exact type of the object in the loop. Without it, MyPy would only know that button
is a Widget
(the base class).
We've seen that the query method returns a DOMQuery object you can iterate over in a for loop. Query objects behave like Python lists and support all of the same operations (such as query[0]
, len(query)
,reverse(query)
etc). They also have a number of other methods to simplify retrieving and modifying widgets.
The first and last methods return the first or last matching widget from the selector, respectively.
Here's how we might find the last button in an app:
last_button = self.query(\"Button\").last()\n
If there are no buttons, Textual will raise a NoMatches exception. Otherwise it will return a button widget.
Both first()
and last()
accept an expect_type
argument that should be the class of the widget you are expecting. Let's say we want to get the last widget with class .disabled
, and we want to check it really is a button. We could do this:
disabled_button = self.query(\".disabled\").last(Button)\n
The query selects all widgets with a disabled
CSS class. The last
method gets the last disabled widget and checks it is a Button
and not any other kind of widget.
If the last widget is not a button, Textual will raise a WrongType exception.
Tip
Specifying the expected type allows type-checkers like MyPy to know the exact return type.
"},{"location":"guide/queries/#filter","title":"Filter","text":"Query objects have a filter method which further refines a query. This method will return a new query object with widgets that match both the original query and the new selector
Let's say we have a query which gets all the buttons in an app, and we want a new query object with just the disabled buttons. We could write something like this:
# Get all the Buttons\nbuttons_query = self.query(\"Button\")\n# Buttons with 'disabled' CSS class\ndisabled_buttons = buttons_query.filter(\".disabled\")\n
Iterating over disabled_buttons
will give us all the disabled buttons.
Query objects have an exclude method which is the logical opposite of filter. The exclude
method removes any widgets from the query object which match a selector.
Here's how we could get all the buttons which don't have the disabled
class set.
# Get all the Buttons\nbuttons_query = self.query(\"Button\")\n# Remove all the Buttons with the 'disabled' CSS class\nenabled_buttons = buttons_query.exclude(\".disabled\")\n
"},{"location":"guide/queries/#loop-free-operations","title":"Loop-free operations","text":"Once you have a query object, you can loop over it to call methods on the matched widgets. Query objects also support a number of methods which make an update to every matched widget without an explicit loop.
For instance, let's say we want to disable all buttons in an app. We could do this by calling add_class() on a query object.
self.query(\"Button\").add_class(\"disabled\")\n
This single line is equivalent to the following:
for widget in self.query(\"Button\"):\nwidget.add_class(\"disabled\")\n
Here are the other loop-free methods on query objects:
Textual's reactive attributes are attributes with superpowers. In this chapter we will look at how reactive attributes can simplify your apps.
Quote
With great power comes great responsibility.
\u2014 Uncle Ben
"},{"location":"guide/reactivity/#reactive-attributes","title":"Reactive attributes","text":"Textual provides an alternative way of adding attributes to your widget or App, which doesn't require adding them to your class constructor (__init__
). To create these attributes import reactive from textual.reactive
, and assign them in the class scope.
The following code illustrates how to create reactive attributes:
from textual.reactive import reactive\nfrom textual.widget import Widget\nclass Reactive(Widget):\nname = reactive(\"Paul\") # (1)!\ncount = reactive(0) # (2)!\nis_cool = reactive(True) # (3)!\n
\"Paul\"
0
.True
.The reactive
constructor accepts a default value as the first positional argument.
Information
Textual uses Python's descriptor protocol to create reactive attributes, which is the same protocol used by the builtin property
decorator.
You can get and set these attributes in the same way as if you had assigned them in an __init__
method. For instance self.name = \"Jessica\"
, self.count += 1
, or print(self.is_cool)
.
You can also set the default to a function (or other callable). Textual will call this function to get the default value. The following code illustrates a reactive value which will be automatically assigned the current time when the widget is created:
from time import time\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nclass Timer(Widget):\nstart_time = reactive(time) # (1)!\n
time
function returns the current time in seconds.There is no need to specify a type hint if a reactive attribute has a default value, as type checkers will assume the attribute is the same type as the default.
You may want to add explicit type hints if the attribute type is a superset of the default type. For instance if you want to make an attribute optional. Here's how you would create a reactive string attribute which may be None
:
name: reactive[str | None] = reactive(\"Paul\")\n
"},{"location":"guide/reactivity/#smart-refresh","title":"Smart refresh","text":"The first superpower we will look at is \"smart refresh\". When you modify a reactive attribute, Textual will make note of the fact that it has changed and refresh automatically.
Information
If you modify multiple reactive attributes, Textual will only do a single refresh to minimize updates.
Let's look at an example which illustrates this. In the following app, the value of an input is used to update a \"Hello, World!\" type greeting.
refresh01.pyrefresh01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input\nclass Name(Widget):\n\"\"\"Generates a greeting.\"\"\"\nwho = reactive(\"name\")\ndef render(self) -> str:\nreturn f\"Hello, {self.who}!\"\nclass WatchApp(App):\nCSS_PATH = \"refresh01.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter your name\")\nyield Name()\ndef on_input_changed(self, event: Input.Changed) -> None:\nself.query_one(Name).who = event.value\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
Input {\ndock: top;\nmargin-top: 1;\n}\nName {\nheight: 100%;\ncontent-align: center middle;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aTextual\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e Hello,\u00a0Textual!
The Name
widget has a reactive who
attribute. When the app modifies that attribute, a refresh happens automatically.
Information
Textual will check if a value has really changed, so assigning the same value wont prompt an unnecessary refresh.
"},{"location":"guide/reactivity/#disabling-refresh","title":"Disabling refresh","text":"If you don't want an attribute to prompt a refresh or layout but you still want other reactive superpowers, you can use var to create an attribute. You can import var
from textual.reactive
.
The following code illustrates how you create non-refreshing reactive attributes.
class MyWidget(Widget):\ncount = var(0) # (1)!\n
self.count
wont cause a refresh or layout.The smart refresh feature will update the content area of a widget, but will not change its size. If modifying an attribute should change the size of the widget, you can set layout=True
on the reactive attribute. This ensures that your CSS layout will update accordingly.
The following example modifies \"refresh01.py\" so that the greeting has an automatic width.
refresh02.pyrefresh02.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input\nclass Name(Widget):\n\"\"\"Generates a greeting.\"\"\"\nwho = reactive(\"name\", layout=True) # (1)!\ndef render(self) -> str:\nreturn f\"Hello, {self.who}!\"\nclass WatchApp(App):\nCSS_PATH = \"refresh02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter your name\")\nyield Name()\ndef on_input_changed(self, event: Input.Changed) -> None:\nself.query_one(Name).who = event.value\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
Input {\ndock: top;\nmargin-top: 1;\n}\nName {\nwidth: auto;\nheight: auto;\nborder: heavy $secondary;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aname\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Hello,\u00a0name!\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
If you type in to the input now, the greeting will expand to fit the content. If you were to set layout=False
on the reactive attribute, you should see that the box remains the same size when you type.
The next superpower we will look at is validation, which can check and potentially modify a value you assign to a reactive attribute.
If you add a method that begins with validate_
followed by the name of your attribute, it will be called when you assign a value to that attribute. This method should accept the incoming value as a positional argument, and return the value to set (which may be the same or a different value).
A common use for this is to restrict numbers to a given range. The following example keeps a count. There is a button to increase the count, and a button to decrease it. The validation ensures that the count will never go above 10 or below zero.
validate01.pyvalidate01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.reactive import reactive\nfrom textual.widgets import Button, RichLog\nclass ValidateApp(App):\nCSS_PATH = \"validate01.tcss\"\ncount = reactive(0)\ndef validate_count(self, count: int) -> int:\n\"\"\"Validate value.\"\"\"\nif count < 0:\ncount = 0\nelif count > 10:\ncount = 10\nreturn count\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nButton(\"+1\", id=\"plus\", variant=\"success\"),\nButton(\"-1\", id=\"minus\", variant=\"error\"),\nid=\"buttons\",\n)\nyield RichLog(highlight=True)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"plus\":\nself.count += 1\nelse:\nself.count -= 1\nself.query_one(RichLog).write(f\"{self.count=}\")\nif __name__ == \"__main__\":\napp = ValidateApp()\napp.run()\n
#buttons {\ndock: top;\nheight: auto;\n}\n
ValidateApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0+1\u00a0\u00a0-1\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
If you click the buttons in the above example it will show the current count. When self.count
is modified in the button handler, Textual runs validate_count
which performs the validation to limit the value of count.
Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch method names begin with watch_
followed by the name of the attribute, and should accept one or two arguments. If the method accepts a single argument, it will be called with the new assigned value. If the method accepts two positional arguments, it will be called with both the old value and the new value.
The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example \"darkorchid\"
or \"#52de44\"
.
from textual.app import App, ComposeResult\nfrom textual.color import Color, ColorParseError\nfrom textual.containers import Grid\nfrom textual.reactive import reactive\nfrom textual.widgets import Input, Static\nclass WatchApp(App):\nCSS_PATH = \"watch01.tcss\"\ncolor = reactive(Color.parse(\"transparent\")) # (1)!\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a color\")\nyield Grid(Static(id=\"old\"), Static(id=\"new\"), id=\"colors\")\ndef watch_color(self, old_color: Color, new_color: Color) -> None: # (2)!\nself.query_one(\"#old\").styles.background = old_color\nself.query_one(\"#new\").styles.background = new_color\ndef on_input_submitted(self, event: Input.Submitted) -> None:\ntry:\ninput_color = Color.parse(event.value)\nexcept ColorParseError:\npass\nelse:\nself.query_one(Input).value = \"\"\nself.color = input_color # (3)!\nif __name__ == \"__main__\":\napp = WatchApp()\napp.run()\n
self.color
is changed.Input {\ndock: top;\nmargin-top: 1;\n}\n#colors {\ngrid-size: 2 1;\ngrid-gutter: 2 4;\ngrid-columns: 1fr;\nmargin: 0 1;\n}\n#old {\nheight: 100%;\nborder: wide $secondary;\n}\n#new {\nheight: 100%;\nborder: wide $secondary;\n}\n
WatchApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258adarkorchid\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u258e\u258a\u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
The color is parsed in on_input_submitted
and assigned to self.color
. Because color
is reactive, Textual also calls watch_color
with the old and new values.
Textual only calls watch methods if the value of a reactive attribute changes. If the newly assigned value is the same as the previous value, the watch method is not called. You can override this behaviour by passing always_update=True
to reactive
.
Compute methods are the final superpower offered by the reactive
descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with compute_
followed by the name of the reactive value.
You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes.
The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes.
computed01.pycomputed01.tcssOutputfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.containers import Horizontal\nfrom textual.reactive import reactive\nfrom textual.widgets import Input, Static\nclass ComputedApp(App):\nCSS_PATH = \"computed01.tcss\"\nred = reactive(0)\ngreen = reactive(0)\nblue = reactive(0)\ncolor = reactive(Color.parse(\"transparent\"))\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nInput(\"0\", placeholder=\"Enter red 0-255\", id=\"red\"),\nInput(\"0\", placeholder=\"Enter green 0-255\", id=\"green\"),\nInput(\"0\", placeholder=\"Enter blue 0-255\", id=\"blue\"),\nid=\"color-inputs\",\n)\nyield Static(id=\"color\")\ndef compute_color(self) -> Color: # (1)!\nreturn Color(self.red, self.green, self.blue).clamped\ndef watch_color(self, color: Color) -> None: # (2)\nself.query_one(\"#color\").styles.background = color\ndef on_input_changed(self, event: Input.Changed) -> None:\ntry:\ncomponent = int(event.value)\nexcept ValueError:\nself.bell()\nelse:\nif event.input.id == \"red\":\nself.red = component\nelif event.input.id == \"green\":\nself.green = component\nelse:\nself.blue = component\nif __name__ == \"__main__\":\napp = ComputedApp()\napp.run()\n
compute_color
changes.#color-inputs {\ndock: top;\nheight: auto;\n}\nInput {\nwidth: 1fr;\n}\n#color {\nheight: 100%;\nborder: tall $secondary;\n}\n
ComputedApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a0\u258e\u258a0\u258e\u258a0\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the compute_color
method which combines the color components into a Color object. It will be recalculated when any of the red
, green
, or blue
attributes are modified.
When the result of compute_color
changes, Textual will also call watch_color
since color
still has the watch method superpower.
Note
Textual will first attempt to call the compute method for a reactive attribute, followed by the validate method, and finally the watch method.
Note
It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when any reactive attribute changes.
"},{"location":"guide/screens/","title":"Screens","text":"This chapter covers Textual's screen API. We will discuss how to create screens and switch between them.
"},{"location":"guide/screens/#what-is-a-screen","title":"What is a screen?","text":"Screens are containers for widgets that occupy the dimensions of your terminal. There can be many screens in a given app, but only one screen is active at a time.
Textual requires that there be at least one screen object and will create one implicitly in the App class. If you don't change the screen, any widgets you mount or compose will be added to this default screen.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nM1Ya0/jOFx1MDAxNP3Or6i6X3YlXGKOY8fxSKtcdTAwMTXPpSywo1x1MDAwMVxyj9VcYrmJaT3Na1x1MDAxMpfHIP77XqdMXHUwMDFlbVxiZVx1MDAxN0ZEUZv4OtfX1+fc4+R+pdfr67tU9j/0+vLWXHUwMDE3oVxuMnHTXzXt1zLLVVx1MDAxMoNcdFx1MDAxN/d5Ms38oudY6zT/sL5cdTAwMWWJbFwidVx1MDAxYVxuX1rXKp+KMNfTQCWWn0TrSsso/8P8XHUwMDFliUj+niZRoDOrXHUwMDFhZE1cdTAwMDZKJ9lsLFx1MDAxOcpIxjpcdTAwMDfv/8B9r3df/NaiXHUwMDBilIiSOCi6XHUwMDE3hlp4njvfepTERaiUIVx1MDAwN3PqkbKDyrdhMC1cdTAwMDOwXkHAsrKYpn56sbN1pD45eiNcdTAwMWKyfNP5Nvy6d1WNeqXC8FjfhbM8XGJ/PM1kZc11lkzkqVxu9Fx1MDAxOOz2XFx7+VxcnkBcbqqnsmQ6XHUwMDFhxzLPXHUwMDFizySp8JW+M21cYpWtXCJcdTAwMWVcdTAwMTU+qpZbk1x1MDAwMeJamHmO5zpcdTAwMGV1XHUwMDEwqc23cECY5VLsXHUwMDEwh9G5mLaSXHUwMDEw1lx1MDAwMGL6XHUwMDA1XHUwMDE1R1x1MDAxNdVQ+JNcdTAwMTGEXHUwMDE2XHUwMDA3VVx1MDAxZlx1MDAwZvvcrs335sdMa1x1MDAwM46lXHUwMDFhjbVpxNjyXHUwMDEwcT1GZ75r+ZBF/m3P5pRcdTAwMTKMcWkxI6aDoFx1MDAwMMKX+fyNRZY+5qmfm5tatCbQnXlcdTAwMTTVkVRbY+dcIkv5vlx1MDAxYVxmvk7GfyX88CxcdTAwMWRcdTAwMGZcdTAwMGVLX1xy2Gl5q/ul4WG1y+2Ze1x1MDAxMm1cdTAwMGUv7evp9v6BPls7+8jRfrtbkWXJzfN+XHUwMDFiUawuO5HK7eNVlchpXHUwMDFhiFx1MDAxOfZt10XE5sjjXHUwMDBl4aU9VPFcdTAwMDSM8TRcZqu2xJ9UdFmpxbtA0kacdYba5CmG2thQXHUwMDE0XHUwMDEw4i1N0e7le69cdTAwMTSldidFObeAXG6GLP+HoTpcdTAwMTNxnopcZljQwlLWxlK+wErmeraDXFxcdTAwMWK9Piu7kMihOr1cdTAwMDSJ1YInsT5W31x1MDAwYjS5XHUwMDE2hWKEsIsw41x1MDAxY1HW6LUrXCJcdTAwMTXeNdawgCxEvnMrojSUXHUwMDFiafrrb/VcdTAwMTTnXHUwMDEyXCIpXFyTxjNcdTAwMWKhXHUwMDFhXHUwMDE5aPd9mJvMXHUwMDFhqNdcbkSu7Fx1MDAxMKkgXGJrXGL0IVx1MDAxMFx1MDAwMT6zwTKCk2RqpGJcdTAwMTGetMXZScZM+nqGxVx1MDAxNkZS+qRmYlx1MDAwNCDkUJXdpVx1MDAxOXn+PdGXXyfDk+PRwblzQsefkvPLd89IXHUwMDE3W8hlhHheXHUwMDFiI1x1MDAxZNuxXHUwMDEwI9h+U0pSukhJj0GlmFx1MDAxM+tHalx1MDAwMqRcdTAwMTHFXHUwMDFlcV+fml3KXHUwMDE27MfnQ0rOXHUwMDBmtlx1MDAwMrw33tldu9zDn9+jYM78nu5/vr45INuHXHUwMDA3XHUwMDE5XHL+vMNTTLbdV/CLT4PB3u7EP/Q2iH1cdTAwMTKFf+/EXHUwMDE3ozdcdTAwMTX49sS/QOCZkVZe7a/eSOBcdPXmW3+UXHUwMDEzwinUYUKX34J3o+3dVlx1MDAxM9ZZTVxisZhdaNzbXHUwMDE1XHUwMDEz0lJMsDNfREBcdTAwMWFhXHUwMDE3wp2fKu8vx2GbvGPUaO2Q82M/kzJ+SspZo/+rSfkzMjgv5WWMnZSbVZJcdTAwMTbOMfxcdTAwMTTlQCZAv+FcXF7Bu0vxO+Wc43BcdTAwMGJe7lx1MDAxMXNaOYdcdTAwMTm1XFzOjYJcdTAwMTNujjdjXHUwMDFlslxid5vkLlx06Fx1MDAxMIsz7FJcdTAwMTcvyLlcdTAwMDebXuDGf9loXHUwMDE3wf1sJuZaZHpTxYGKR2CslFxm2OhPzbhryEKO7VLCoVx1MDAxNlKOXHTyylmb6YnU7D0tXHUwMDAyckBcdTAwMWPYg1x1MDAxYYxWr5+98kNQ19b4sXMpqX1cdTAwMTlcdTAwMDfPXHUwMDA2hThUX8Tg1Vx1MDAwME7KmLdcdTAwMTBcdTAwMTW24LWh2HVcdTAwMTXfKmyHPVx1MDAxNVY7zVx1MDAxN8JcbkWut5IoUlx1MDAxYdL/MVGxnk9zkc9ccsPvsVx1MDAxNMG8XHUwMDE1plW3zVx1MDAxN4LUeGzu3KqrXsWU4qa8/rLa2nttXHUwMDExweaoYbfysFL/NzuQwmdfpOmxXHUwMDA2pJVrXHUwMDAwYFbBY+GuJta/VvJms+Xb0lVxmDRcdTAwMTYpNCVHmundP6w8/Fx1MDAwYlxiYlx1MDAxObwifQ== ExampleApp()Screen()"},{"location":"guide/screens/#creating-a-screen","title":"Creating a screen","text":"You can create a screen by extending the Screen class which you can import from textual.screen
. The screen may be styled in the same way as other widgets, with the exception that you can't modify the screen's dimensions (as these will always be the size of your terminal).
Let's look at a simple example of writing a screen class to simulate Window's blue screen of death.
screen01.pyscreen01.tcssOutput screen01.pyfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Static\nERROR_TEXT = \"\"\"\nAn error has occurred. To continue:\nPress Enter to return to Windows, or\nPress CTRL+ALT+DEL to restart your computer. If you do this,\nyou will lose any unsaved information in all open applications.\nError: 0E : 016F : BFF9B3D4\n\"\"\"\nclass BSOD(Screen):\nBINDINGS = [(\"escape\", \"app.pop_screen\", \"Pop screen\")]\ndef compose(self) -> ComposeResult:\nyield Static(\" Windows \", id=\"title\")\nyield Static(ERROR_TEXT)\nyield Static(\"Press any key to continue [blink]_[/]\", id=\"any-key\")\nclass BSODApp(App):\nCSS_PATH = \"screen01.tcss\"\nSCREENS = {\"bsod\": BSOD()}\nBINDINGS = [(\"b\", \"push_screen('bsod')\", \"BSOD\")]\nif __name__ == \"__main__\":\napp = BSODApp()\napp.run()\n
screen01.tcssBSOD {\nalign: center middle;\nbackground: blue;\ncolor: white;\n}\nBSOD>Static {\nwidth: 70;\n}\n#title {\ncontent-align-horizontal: center;\ntext-style: reverse;\n}\n#any-key {\ncontent-align-horizontal: center;\n}\n
BSODApp \u00a0Windows\u00a0 An\u00a0error\u00a0has\u00a0occurred.\u00a0To\u00a0continue: Press\u00a0Enter\u00a0to\u00a0return\u00a0to\u00a0Windows,\u00a0or Press\u00a0CTRL+ALT+DEL\u00a0to\u00a0restart\u00a0your\u00a0computer.\u00a0If\u00a0you\u00a0do\u00a0this, you\u00a0will\u00a0lose\u00a0any\u00a0unsaved\u00a0information\u00a0in\u00a0all\u00a0open\u00a0applications. Error:\u00a00E\u00a0:\u00a0016F\u00a0:\u00a0BFF9B3D4 Press\u00a0any\u00a0key\u00a0to\u00a0continue\u00a0_
If you run this you will see an empty screen. Hit the B key to show a blue screen of death. Hit Esc to return to the default screen.
The BSOD
class above defines a screen with a key binding and compose method. These should be familiar as they work in the same way as apps.
The app class has a new SCREENS
class variable. Textual uses this class variable to associate a name with screen object (the name is used to reference screens in the screen API). Also in the app is a key binding associated with the action \"push_screen('bsod')\"
. The screen class has a similar action \"pop_screen\"
bound to the Esc key. We will cover these actions below.
You can associate a screen with a name by defining a SCREENS
class variable in your app, which should be a dict
that maps names on to Screen
objects. The name of the screen may be used interchangeably with screen objects in much of the screen API.
You can also install new named screens dynamically with the install_screen method. The following example installs the BSOD
screen in a mount handler rather than from the SCREENS
variable.
from textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Static\nERROR_TEXT = \"\"\"\nAn error has occurred. To continue:\nPress Enter to return to Windows, or\nPress CTRL+ALT+DEL to restart your computer. If you do this,\nyou will lose any unsaved information in all open applications.\nError: 0E : 016F : BFF9B3D4\n\"\"\"\nclass BSOD(Screen):\nBINDINGS = [(\"escape\", \"app.pop_screen\", \"Pop screen\")]\ndef compose(self) -> ComposeResult:\nyield Static(\" Windows \", id=\"title\")\nyield Static(ERROR_TEXT)\nyield Static(\"Press any key to continue [blink]_[/]\", id=\"any-key\")\nclass BSODApp(App):\nCSS_PATH = \"screen02.tcss\"\nBINDINGS = [(\"b\", \"push_screen('bsod')\", \"BSOD\")]\ndef on_mount(self) -> None:\nself.install_screen(BSOD(), name=\"bsod\")\nif __name__ == \"__main__\":\napp = BSODApp()\napp.run()\n
screen02.tcssBSOD {\nalign: center middle;\nbackground: blue;\ncolor: white;\n}\nBSOD>Static {\nwidth: 70;\n}\n#title {\ncontent-align-horizontal: center;\ntext-style: reverse;\n}\n#any-key {\ncontent-align-horizontal: center;\n}\n
BSODApp \u00a0Windows\u00a0 An\u00a0error\u00a0has\u00a0occurred.\u00a0To\u00a0continue: Press\u00a0Enter\u00a0to\u00a0return\u00a0to\u00a0Windows,\u00a0or Press\u00a0CTRL+ALT+DEL\u00a0to\u00a0restart\u00a0your\u00a0computer.\u00a0If\u00a0you\u00a0do\u00a0this, you\u00a0will\u00a0lose\u00a0any\u00a0unsaved\u00a0information\u00a0in\u00a0all\u00a0open\u00a0applications. Error:\u00a00E\u00a0:\u00a0016F\u00a0:\u00a0BFF9B3D4 Press\u00a0any\u00a0key\u00a0to\u00a0continue\u00a0_
Although both do the same thing, we recommend SCREENS
for screens that exist for the lifetime of your app.
Screens defined in SCREENS
or added with install_screen are installed screens. Textual will keep these screens in memory for the lifetime of your app.
If you have installed a screen, but you later want it to be removed and cleaned up, you can call uninstall_screen.
"},{"location":"guide/screens/#screen-stack","title":"Screen stack","text":"Textual apps keep a stack of screens. You can think of this screen stack as a stack of paper, where only the very top sheet is visible. If you remove the top sheet, the paper underneath becomes visible. Screens work in a similar way.
Note
You can also make parts of the top screen translucent, so that deeper screens show through. See Screen opacity.
The active screen (top of the stack) will render the screen and receive input events. The following API methods on the App class can manipulate this stack, and let you decide which screen the user can interact with.
"},{"location":"guide/screens/#push-screen","title":"Push screen","text":"The push_screen method puts a screen on top of the stack and makes that screen active. You can call this method with the name of an installed screen, or a screen object.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVcXG1z2khcdTAwMTL+nl/h8n3Zq1xu2nnr6Zmturpcbk5cdTAwMWN7XHUwMDEzx/Fb/HK35ZJBgNaAMFx1MDAxMrbxVv779chcdTAwMGVcdTAwMTIvXCJgMEtyVGyMRkitmaeffrpnJn+92tjYTPqdYPO3jc3gvuI3w2rXv9t87Y7fXHUwMDA23TiM2tQk0s9x1OtW0jNcdTAwMWJJ0ol/+/XXlt+9XHUwMDBlkk7Tr1x1MDAwNN5tXHUwMDE49/xmnPSqYeRVotavYVx1MDAxMrTif7vfn/xW8K9O1KomXS+7SSmohknUfbxX0FxmWkE7ienq/6HPXHUwMDFiXHUwMDFif6W/c9ZVQ79cdTAwMTW1q+npaUPOPLSjRz9F7dRUY5hgWqFcdTAwMWGcXHUwMDEwxm/pZklQpdZcdTAwMWFcdTAwMTlcdTAwMWNkLe7QJjQv9MPZWalafuj/WTqqmebJXHUwMDE3nt21XHUwMDE2NptHSb+Z2lx1MDAxNEf0KFlbnHSj6+A0rCZccmrlI8eLvtWNevVGO4jjoe9EXHUwMDFkv1x1MDAxMiZ9d4yxwVG/XU+vkVx1MDAxZLmnT4prjzFccmgtN1x1MDAwMCx7WPd9oYzHuFVWgFx1MDAxMlpxgSOGbUVNXHUwMDFhXHUwMDA2MuxcdTAwMWYsfWWmXfmV6zrZ165m53Dw/aua1tlZd09cdTAwMGasLHjKKIkwaGpcdTAwMDRhvZE4I4z2XGZyZvOtcZCOXHUwMDAyV1ZbhsCzXHUwMDE2d9PObjWFw1x1MDAxZqP92PC7naf+2ozdh5zBztZ3o1jK4yk30sFu9fz86Fg97Fx1MDAxZVx1MDAxZX5IToKqvdy6XHUwMDFmXFxrXGJ8frdcdTAwMWLdbVx1MDAwZVq+Pv2VmdbrVP1HTHGtlWVcdTAwMTJcZkeW9XQzbF9TY7vXbGbHosp1XHUwMDA2w/To19dzg19cdFVcdTAwMDR+Qo7Qmis5O/rvoHFwvV+PXHUwMDBmPnROXHK7XHUwMDEwd1dvP/TWXHUwMDFj/YJ5wjKu6FGNNiiH4c9Re1xujVx1MDAwNcGNsoh8MfjX/CvGYIng18BcdTAwMDRYo/lqwVx1MDAxZuvz8Kh8X98qb5v7crdzqe3t4VLAb1xmXHUwMDFhLkGwZYE/XHTuk0nIXHUwMDA3XHKFyDdGXHUwMDEx+Fx0XHUwMDBmMyNfto9iXHUwMDFktMzno3q3vHuO8mRfrDnvXHUwMDFiXHSe0VJoXHUwMDAzipxcdTAwMWPsXHUwMDEw8ClcdTAwMTB4TCgujaBcdTAwMWZccrBcdTAwMTDu0Vx1MDAwMquJcdxzZsZcdTAwMDGPfFxm5lx1MDAxNH6YlIrJn4fjhbZKSJhcdTAwMDPmXHUwMDE5mqJ2clx1MDAxND5cdTAwMDQpOVxmXHUwMDFk3fZbYbM/XHUwMDA0iVx1MDAxNP9k4FGlXHUwMDFiXHUwMDA07Vxy/t/2L42wWlxy2v/MXHUwMDBmWVx1MDAxY9D93Vx1MDAwNfXwN980w7rzls1mUFx1MDAxYnajJCQpNmhOolxcXHUwMDFmV8hcdTAwMTKfLtfdrY4+UdRccuth229cdTAwMWVcdTAwMTdb9Sxv1kZcdTAwMTR7M1xigUTgXHUwMDE5/L/nzVx1MDAxNyfbd35n//D+Zlx1MDAwN45bV+clXHUwMDE5Xa+5Nyu0XHUwMDFlSoZWUawg+squ4r5P4cVcdTAwMTDgkFx0ozSSWHpcdTAwMTFvXHUwMDE2MiPMgTfnjj15s2aS5FxymiyU/uAxy1x0Nupblj3pipxZbPxCiVN41VxmJjuzgKFvrsiZ81ZNdebHbp7gzVxc5eLCqDtTTFKkj0Xm8N9z5+kjP4c7i1Fsvpg7g9RcdTAwMWVcdTAwMThtKTZLhZSaXHKrUqU8y8iVXHUwMDA1Q2lAczli2HL8XHUwMDE50JOcXiR/XHUwMDE13Vx1MDAwYsxcdTAwMDT3RuY5cVxmkqhF0082bt9it6A2inXyXHUwMDE5+Vlq51x1MDAxNHf/nkPOk0Hl7PC7STlsV8N2nVx1MDAxYTMm+VZm2J0hRqQuXFzpOSuZp4FzSYNI6Vx1MDAwNVx09ixRdV3hd5zN0mOGRFx1MDAwZadMg34o6Xo64+vAqqBd/b5N0/OvIZtcdTAwMTgpXFxOXHRccpL2s1xcXHQ7Zlx1MDAxNNmkZZrzcCYkmT5mU9OPk62o1VxuXHUwMDEz6vrPUdhORrs47cs3zs1cdTAwMWKBP8ZcdTAwMWb0TPm2UT7ouCtcdTAwMGXTevbXRuYw6YfB33+8nnh2MZbda1xmxdnlXuXf52Yyul0hkZGsZsRcdTAwMDSQucz3iGy6XHUwMDFlXUtcIjPUtYpcdTAwMGLrKkdEMyp72DTLkOhRzoeugEN5iFUjdi2HxzR6hmv6p4GowfKMTFx1MDAwNzRmXGJcdTAwMDCaXHUwMDEyaVdH4qBzicZcdTAwMTONSUtcdTAwMDJcdTAwMTJgxSSGXCLPqMsnselp61x1MDAxMGFI5ViCXHUwMDEzYl1HWZM76YnEhCdcdTAwMTEoWilwWog/l8Sml1BzNpXIKGlpXFy4pZtcdTAwMDGiVuNGecagdIqBMkgk++GHZrFSIZTT1jFcdTAwMTTPSWNTXG6F0rDRo1x1MDAwM1wi05K7bFx1MDAxNmZPsPzfXHUwMDBm3vfffLx4+yG86PcvL9tcdTAwMGbNXHUwMDAztt5cdFx1MDAxNiFcdTAwMWY8XHUwMDA1oDSjIE3cnYmhtE5cdTAwMGXCQ8s1UlJPRJZcdTAwMTNsa1ImJ7BokCBeoExeXFzLI6fjdq5cIsdz8Vx0qjBjUOT3zpjZq3kx6NPLclx1MDAxZuO9m/2y6uzU7vq4t/bwtJ6SXHUwMDAyXFzh0lx1MDAwMojhQMuN8Fx1MDAxOKN2XHUwMDEyQYyyXG47qlx1MDAwMP7eOjblXGJcdTAwMDBMw1x1MDAwYpSxp1TgrFwiPWJXXHUwMDAwTi1kXHUwMDExOEFKrTnD2UXgVrVcdTAwMWQlW5c7+zbeerhcdTAwMGXOeVJcdTAwMGab61x1MDAwZU4hPFLXRlJ4XHUwMDAyru1wdaqktYdcdTAwMTTTSGExJPDgYjLQiIrlwVx1MDAxMqkzzcCZYSueYTRBsNP7+PDQvLrQlzWZhLJ/wXJCaCz/XHUwMDE4tHx9Pe26+/H+obi77iad29Ln2t71l/v34tNcdTAwMTKuW8Wbm+B3/Vx0opCfRNt7N3uldydLuO5N+fN+VZ2etu7Ptt9WoFx1MDAwYu/fXHUwMDA0/WVV4Y1GsHJZXHUwMDFjUFSelpJcdTAwMTdcdTAwMTFcdTAwMDBSVkHaWsqZXHTg0n93/mf/rnZcdTAwMTi/3T/dub1Oro72dtY7XHUwMDBipDOMR1x1MDAxOZ5kLolcdTAwMTJsZJa1RKmFJ4hcYkmju0lYu2BcIkhZ01UwQTxBbrZ74PpqzOGVm/cmmbdSqYRcYozPXHUwMDE1jbJcdTAwMTHPSsiUT3NDbKtJoXKj7dA5g4JyhrVvXHUwMDA1Zb/T8Tq9uHFcdTAwMTmnNdxfXHUwMDFl3+TksnKupL+KsnKhbVNdsbAkI6coRZe1XHUwMDAym10oTqe8Zbhi1Y9cdTAwMWLBsoOx9ijUSlxutIYh48PB2IBcdTAwMDecXHUwMDE5amTkhsYsNvFb5IncXHUwMDEzzsW04kZxd5dcdI7JuSRSsKBpRCwl72JsJom7iVx1MDAwMiZI187vqc8vy5BuJLvnWaCQs2Omssx0ibeRL8tYo5hQSlCqRfwhclXNp1xuXGJ4Wlxu5UaUXHUwMDEz/VEnPp1QUJVcdTAwMTl+ilx1MDAxZqg0UoyotHVcZkvZ9V7l3+emXHUwMDEzZVxuQztHkETtYlx1MDAwZXE/XeusKaFcdTAwMDBcdTAwMDePUkpcdEaCXCKVn027pISiSFkjXG5cdTAwMDDNgVx1MDAxOGexuapcIkJcdTAwMTFcdTAwMWVQnm+UIHfgUptcdFx1MDAxYZ9z7qFby6WsK7vz3ErHgdJHXHUwMDFhM3jWcqpF+IS6jWfG/I18UlwiQrGCuVVBjjGktpg765FQKHhcYkB3krbaMGV/UkIpRJR7jWNpWXxC2UAhnyhcdTAwMDZMXG4+R6F1eq63pnxcIjR6qJTRmlx1MDAxMiPQw3RcIoT1pFx1MDAxNVppXHUwMDFhXHUwMDE4o5larJJVLFAsY5xuT9lcbpOcTZj6pifxSEXRXHUwMDE1LKUsbv52lE9QXHUwMDExMii1y1x1MDAxYVZCJ0ipy0vOXHUwMDFhzSFPSJxJKy2iXHUwMDA2olxuXHUwMDFh1DE6sVx1MDAxZadcdTAwMGUkkyl40Kjit6nXn41OXG5cdTAwMDGVNo5BaU46mbbEu3ihK7FcdTAwMWKSaJqjMv7pTInTqFxcOjg9tlx1MDAwZqdYPWbnX2prXnwkXHTmKTBGK0U9j7lcdTAwMTn5lE8096hcdTAwMDeMJFVohFx1MDAxNHKx0sNLbHBAXHUwMDEy+EOZ2ErKj0dcdTAwMTf7b5p7R8FJr1x1MDAwM+XD6qfT3avP0bLKbujkRcbcL1h6l4XhVEmwXHUwMDFhQc++kKxxU2p3XHUwMDBmto7fn15Hl5dxq9xTbz+sO/qBMlx1MDAxZlx1MDAwNsg5PaxQo/tcdTAwMWIs86RbdUSpXHUwMDEx0zQq6zUvxLlgXHUwMDE0RC0+I4IuMDGElph4XHUwMDFlQf5cXHSiLlxctmyF0qTF5+Dmz7+fd9iX3bPem/P6Wb9S7d10+s8qRq1cdTAwMTSdgjRcdTAwMDJ1ttRcdTAwMWElz3XH41x1MDAwNZRcdTAwMDdcXKBcdTAwMTKkXHUwMDExXHUwMDAw0C6WOy59Yki4WXVjV7wrYYF5oe9cdTAwMTKzJI3CzNKIuWg+RPDiJMcyRVx1MDAwMlxi7ezbzirJznZ8dVx1MDAxYpr7sniItpvxzVFcdTAwMTfWPckxaDw3XHUwMDExQsLEas1gWJWUXHUwMDA0+Vx1MDAwNaBUaaqthF5s31lcdTAwMTHy81x1MDAxYlCmrNjnXGYkokSxYqSrOHp7v7d3wpPjg6D8caeCeLK/XHUwMDE0pFPmzjVH8ayqy1wiS/blWi7Zl1x1MDAwYi/ZXHUwMDA3LC6DOpaXgs1cdTAwMTHKplx1MDAwZvx6TnFazTyBXHUwMDAyJMFcbpRcdTAwMWTZRq09bi0pXHUwMDE5Zlx1MDAwNXk7vkwgk5LuopVA6TJcdTAwMWGYtFx1MDAxZMcqz1xiSomsUW5cdTAwMTNcdTAwMGVcdTAwMWL3dVx1MDAwMaBcZua3XHUwMDFmr2Kp69xxJ2fHTEWL6UFiI1+0oFx1MDAwNF1cdTAwMGLLSSy7moTiuZNcdTAwMDZLXVx1MDAwNWjDnO3I3L7EpzN+tqJFqVx1MDAxMFHuNYal7HKv8u9zq1x1MDAwM1moio3klKHjXHUwMDFje/mOXHUwMDBm3vdKeHi6/eXjx/pd/fL0T41FS01cdTAwMWJ+pdHrXHUwMDA266CL0Vx1MDAxNYRAWMNcdTAwMTQoPZK1XHUwMDAxqWYrgCSElJbU68uIg9zE1jRtXHUwMDAwlMhz58Gr1Vx1MDAwNrUusWjnXGaO3zW2XHUwMDBmv1x1MDAxY4ja4bugM5s2eD3tui9a9rDWTZCtTHM8bqldXHUwMDA3nfFkyfO0XHUwMDA1isL/ocWme+D4XHUwMDFjM6zTcTNcdTAwMTdcdTAwMWasUFxcaItcdTAwMWVBR9Cjulx1MDAxZPt2ZFx1MDAxYo1cdTAwMTJcdTAwMWVoXHUwMDA13M2aUFx1MDAxZbtYXHUwMDExpzBZXHUwMDAwj9PFXHLnkrJxJifJXHUwMDBiro1cdTAwMDeIRksrXHUwMDA1XHUwMDEy6Mc20qCbu2GYXHUwMDFisZXMiTzb8WaUXHUwMDE308PMxvCuXHUwMDE1t0NDS+pAt0JcdTAwMDAmLNrgdJLmMt1qwMBcdTAwMDD8rFx1MDAwMqNcdTAwMThT7lVcdTAwMWGH05xcdTAwMTKjkFM0K+RcdTAwMTTQbjGylLNrjOkxY105XHUwMDA1085329qY0E5VjXCKJVx1MDAwNWKYNcYoXHUwMDE0L7Qm2yjPpYaoLaOsg7p9nFLIXHUwMDBlt1KNJCjRXHUwMDFiSW4xXoejhEpxQlHWef9/nFwiaFx1MDAxOK2rXHUwMDE4XHUwMDE56lx1MDAwYs3HOUW6rXCIlIQq1EaQfpzOKUVWTZ9cdTAwMDJcdTAwMWOxijFlJLOKI1GZlVx1MDAxM5jOc5UwTckxxVx1MDAwNUvZxI+9Qa9cdTAwMTDP7lVcdTAwMWGHclx1MDAxMZ29erqDW/x6lFx1MDAxMO5cdTAwMDZcdTAwMDNC0Fx1MDAwZatPSjB7zM3bMLgrT5qRSV9OeKVcdTAwMWTquChwXHUwMDBm+9fXV1//XHUwMDA3mNhRliJ9 Screen 1(hidden)Screen 2 (visible)app.push_screen(screen3)Screen 3 (visible)hidden"},{"location":"guide/screens/#action","title":"Action","text":"You can also push screens with the \"app.push_screen\"
action, which requires the name of an installed screen.
The pop_screen method removes the top-most screen from the stack, and makes the new top screen active.
Note
The screen stack must always have at least one screen. If you attempt to remove the last screen, Textual will raise a ScreenStackError exception.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXOtT20hcdTAwMTL/nr+C4r7sVcWz0z3vrbq6XHUwMDAyXHUwMDEyXHUwMDEyQnhsyObB3VZK2MLW4ddaMsZs5X+/XHUwMDFlhSDJQorBxnHiXHUwMDBmXHUwMDE4a+RRa+bX3b9+yH8/2djYTKbDcPO3jc3wqlx1MDAxOXSj1iiYbD71xy/DUVx1MDAxY1xy+jSE6ed4MFx1MDAxZTXTMztJMox/+/XXXjC6XGKTYTdohuwyisdBN07GrWjAmoPer1FcdTAwMTL24n/7v4dBL/zXcNBrJSOWXaRcdTAwMTG2omQw+nKtsFx1MDAxYvbCflx1MDAxMtPs/6HPXHUwMDFiXHUwMDFif6d/c9K1oqA36LfS09OBnHhazFx1MDAxZT1cdTAwMWP0U1FBXHUwMDBiLZW28vaEKH5GXHUwMDE3S8JcdTAwMTaNnpPAYTbiXHUwMDBmbcrRNNFcdTAwMDae897Fx119cmhcdTAwMGZOxyfZVc+jbvckmXZTmeJcdTAwMDHdSjZcdTAwMTYno8FF+D5qJVx1MDAxZH/pmeNV31x1MDAxYVxyxu1OP4zjwndcdTAwMDbDoFx1MDAxOSVTOqb47cGg306nyI5cXNGnXHUwMDA2cs6M0Vx1MDAxNqTiXHUwMDEy6G7V7fiXXHRcdTAwMDQz1lx1MDAxOFx1MDAwNUJcdTAwMWEhXHUwMDA1qFx1MDAxOcl2XHUwMDA2XdpcdTAwMDeS7Fx1MDAxZjx9ZbKdXHUwMDA1zYs2XHTYb2XngFxugrPz7JzJzf1Kp5i0Uphs+k5cdTAwMTi1O4nfIauZNcBdfjRcdTAwMGXTTXCgpJNaZlvkrzjca6Vg+HN2XHUwMDE1O8FoeLNam7H/kJPWXHUwMDBi+nxcdTAwMTZJeTTl9lm8grPdXHUwMDEwYKe1v/3X85NcdTAwMDP5+2CrfztXXHUwMDAxesFoNJhs3o58vvkvXHUwMDEzbTxsXHUwMDA1X1x1MDAxMFx1MDAwNVpLa43TXHUwMDEyTVx1MDAwNspu1L+gwf64282OXHKaXHUwMDE3XHUwMDE5XGLTo5+f3lx1MDAxYvp0mSroo+OO0KD03NBcdTAwMGbHU3ux39vnfHz+ctLeiyb6hfue0Fx1MDAwN/5N7IPTTFx1MDAxOSNRc1x1MDAwZVx1MDAwMoyyXHUwMDA17EuBXGalQYKeddo4vlx1MDAxOPbPgzPO1Vx1MDAxMrGPQipwlq9cdTAwMTb7vd45n2zx5NlhNFxmwz9eXHUwMDFlbb86iJeEfVx1MDAwYlxccG6Whf0kvEruXHUwMDAyvkVdXHUwMDA1fFx1MDAxMNZx5NLh3Mh/d941l1fDy5fT3taHwfjj8PiF2F1v5CMqprRBXHUwMDA0dEZ6XHUwMDBiWlx1MDAwML7lwMhcdTAwMDRJclxi1iHkrMBDcG+c4udYxj1wW1x1MDAwNryBWZhrgdL7pp/IxDtcdTAwMGJK2PvAPEPToJ+cRNepjbaFo7tBL+pOXHUwMDBikEjxT1x1MDAwMp40R2HY34D/9n/pRK1W2P9nfsfikK7vJ9TFb251o7bXls1ueF5UoyRcIlx1MDAxZXY7nFxmcmvcJElcdTAwMDKabrTXmr2jwShqR/2g+7Zaqlpt/rLMd6gzUVx1MDAxM5w9nNNnIHojxPz6XFy/8/fQZ5zF5uPps3HMSFx1MDAwMGm4pXdbVGfjJFx1MDAwM81cdTAwMWRcdTAwMWGU6EjjXHUwMDFmRZ1cdTAwMWQyrogwS2MsR9TuXHUwMDBl5XZMIzk6KVFcdTAwMDHXOlx1MDAwM/BXlyaFv4VcdTAwMDeoeipkjao/ijLGSTBKtqN+K+q3aTCzXCJfQ5K9OVx1MDAxY0Sqvs2xl5IzhVxcS8FpI5UgL1x1MDAwNLmT2sHQLyFcdTAwMDMgTqLJZKNVTtibXHUwMDEzPt9cblx1MDAxNfZb31x1MDAxNqk+UMmJ1OBcZml5nPV7pjhqilx1MDAxM0pCSaZcdTAwMDVIXHUwMDA3nITiTlmnSlJ1gzjZXHUwMDE59HpRQmt/PIj6yexcdTAwMWGni7nldbxcdTAwMTNcdTAwMDYl40F3lVx1MDAxZps1XHUwMDA2Qz9j0aZn/21k2pJ+uP3/z6d3nt2ohHI6WkJxNt+T/PtcdTAwMDNcdTAwMTi5sJWGXGYtqVx1MDAwNlx1MDAxMZdM8b/JyM9G2NpcdTAwMGKuw/1nW1x1MDAwN89f9LWNIVhvXlwinGOOTFx1MDAxNVxiNN5y68xcdTAwMTJ8XHRGLeNcdTAwMDLAOeK+ZCaEmJHsIcGo1suj5ESVkKJcYs5cdTAwMWaBrNRYMECFuIqIkYLtSkeLZLhcdTAwMDVwNb+jPdzpXHUwMDFjXGZfXHUwMDFjvdtcdTAwMWRcdTAwMWNfj9xhcjQ+XHUwMDFmivVcdTAwMDao5IJcdJ9cdFGWdFWqYrJEXG7NuOPGXHUwMDEyQqVcdTAwMDZcXFxmnsuOXHUwMDE3SVx1MDAxZaE4mTS7fHDWMenXZ8rufYyvOsPx+MNWdPomuVx1MDAxOMllXHUwMDA1jIQ3yHG7x4O+XHUwMDE1qlxu+k6AUkS65jfN76eHb9o9t3+0r/76NH32abtzKI+WivxWXHUwMDEwd8IlQ98xolx1MDAxZYJcdTAwMTONXHUwMDA0YpGuXHUwMDAwfaGQXHUwMDExKZFcdTAwMDY5cDBcXC9GMi02XHUwMDFkhGqZ6CdcdTAwMTNpSTS+4nSJXHLDl+PX19fds1P96VxcJJGYnvL50P+0bt5Y7U+v4+3jyejgdO/Vofj0x9SeLWHe8+H7ydvG5Ni+v+7Fp1x1MDAxN83wI3ZcdTAwMGaWMC9cdTAwMWab//VO3mLyprl9XHUwMDE1NT/uvlx1MDAxMc1oWfE0J+S5pTnAqrSRrktcdTAwMWKRWjiKvOzcNuAsnuKOONpcdTAwMDXV3u6Mmq33h+r6dL3DTGlcdTAwMWSzSkvNOZleNZMuXHUwMDA1QaNOcrKERJ3JXHUwMDE3zlxudj9cdTAwMTNA6npcdTAwMTbewc1ErqZxq/myrO9CWqIrhj+Ct6tBolx1MDAwMan1fZCYbXiW2Vx1MDAxMVx1MDAxNOWlgYfnwDZcdTAwMTdJXHUwMDE38jzZVb7meYLhkFxyXHUwMDA3w09xmln55e4sj9CF7z12lqckU63qVeZ4dHVkROxXXHUwMDEz71Zyft2rN57L0L1H8L8gmERUXHUwMDE0ZiOZOzFTq7CGIVx1MDAxN9pcYqPJXHUwMDE3w2Ip2yrd40RvXHUwMDAxkVgwXHUwMDAx06E1QpZ1XHUwMDExuPGCmpRvXHUwMDAyXHUwMDE5XHUwMDA0V9ZNQjUpTC7JvopEXHUwMDBmcFqcx0z01NO6jUJWxVEsXHUwMDBmjmyoRE2rmUsx3CRVXHUwMDE0U1x1MDAxNFx1MDAwMyuOdFx1MDAwMtpvZnqKt/EjZVtqQJWOl/GUTfkk/35vo2JyRYVZo0JwXHUwMDAx8iD3yFx1MDAxYtczp/U0Ko5LZi1ZXGZDsaySZtaoXGJmXHUwMDA0V4pWnlxmXHUwMDBizEZcdTAwMWLLMipoOWghfSmfLpIrRuVsimWSglx1MDAwZetcdTAwMWNcdTAwMTiCXG6WykSgjZXOc5PV2lx1MDAxNKCYXCJbte9oU4DRXHUwMDBl0PIopykqJlx1MDAxZVJOXHUwMDFlXHUwMDAzZ8QmrNTkQoThXFyan9SmVEPKv1x1MDAxYWU0LcuiXHUwMDE08l+zXHUwMDE5XFyurZS+nDm3SalPnaynSdHKMCO55lx1MDAxMiVIk+tk8d/XXHUwMDAyXHUwMDE4+X3iMd6x4YItXHUwMDE1VSaFdMFcdTAwMTkjyWhJWnXSiez+b02KU1xmpXbKcsFccqrcrtxYXHUwMDE0dPRdhfpcdTAwMDFcdTAwMDHEQiSFK5fJ8nCDMqt8P4FaN6r3NVx1MDAxZC5t6T3Vuq5XSldTXHUwMDA1Tv5cdTAwMTM06vmpgn6m8FX0cnLZfvfhQrevT49+j79rn+C31ZrIMzBcbj6QTCatr5lpXHUwMDE5UUAkTSluXHUwMDFkUSaXL8ivR2VGeTOvVptcZlhdJ59cdTAwMTbVXlx1MDAwN4Rwvk1s/ui4cbU/tn+1wsvOycdLXHUwMDExTl87PNhZe3Rq5uNcdTAwMDdB2DMobLFwSG6XkS9cIuRcbq2EQbdcdTAwMTA6l16YcUIgUTfzgHB4kdR0I57s7CB2I9lcdTAwMTZcdTAwMWZa4z+O48nFslKyVlpuga9cdTAwMDD7NuegS1xy3IAkjJyfcMX93dFe7+L5azHF8L1otTvDg9Z6Q79cdTAwMDHWMrCKmKXTzmlcdTAwMGVcdTAwMDXoXHUwMDBiKYlcZiuL3KLv6l1cYvlfyjLLLElcdTAwMTJHXHUwMDA0XHUwMDEy7CcpypxtPT9/XHUwMDFmbPHD9rv9t1x1MDAwN83rQZDE46X1xqK0uDSNqlxmYWpcbp2gfHVbajN/W3h92Wd9I1x1MDAxOFxupYVcdTAwMDRSXHUwMDFiXHUwMDAxekafiOiQWfHxi6b9WKzOWVx1MDAxZMBcdTAwMThFXHUwMDExlKPolYiLJZ9VVizincxnZZRcIq/mfLmjpF5cdTAwMTTqy2Jss5pcdTAwMTCG1P1BNZBl50Q448AlxXBKXHUwMDE5jeA4N3d2r/neNk3RKsWlZJBuTvjZklwijWpQfVx1MDAxOS7hKZvxSf79vnVTqWD26C071cjR3ec5k6vXoVx1MDAxY530XHUwMDFiz/u6od1+6/CV6thcboPSXHSanfEoXFxcdTAwMDNcdTAwMWZN+GJcdTAwMDZ89yTB0T/jUEy0XHUwMDFhJ1x1MDAxOHF0X1x1MDAwZVx1MDAxMMpavlD1Jlx1MDAxOVx1MDAwNf14XHUwMDE4jEhd7jAsucxpTdO9XHUwMDE0qJw1YsWM9DGfLfHZXHUwMDAy41bedI9r2XSPizfdc1fdXHJBtsOSf7zHk5P1O38vtV5dP0SDXHUwMDAySlx1MDAwNspYIynYXHUwMDE3zuqZxnuLjLyhptPQmVxccWXpWlxyilx0XHUwMDAwKYiekW1x7q5cdTAwMTJcblx1MDAwMpM+XHLCLYVMoPJcdTAwMWRaN3TBu1x1MDAwN4fwkCzJXCJ0gTyzclx1MDAwZtHLOelCvcvYKDa7k+8z5CONTOvo5bIscEaLJITl3JdR9Ndi5D1cdTAwMWLw61x1MDAxZpcsUFx1MDAxOFx1MDAxMCCNpZ3zXHUwMDE5XHUwMDAyJY0uyWRcdTAwMTjSgCR648jGXHUwMDE5xJJMP1x1MDAxMk+pXHUwMDA2s381yjheXHUwMDE2TVx1MDAxMZWJXHUwMDA0XHUwMDA0n2+mUHV+nlwiPlxcyNa1fHn54tnhm9Z04sK+qirdrFx1MDAwZk9cdTAwMDHBmSFoI7FD32ePRYOGqH1cdTAwMDXRKCAuY6VSXHUwMDBipVx1MDAxM75BVO5o8ypcdTAwMTNcdTAwMTWyXHUwMDFjUlx0IdWPw1Se1s37mFx1MDAxOVx1MDAwNLK0Wq+eXHUwMDAxXHTiXHUwMDFhl1FcdTAwMWOddcN1okBcdTAwMDWxXHUwMDFlxoFcdTAwMTRWXHUwMDA2NuAsoZK4/vxN4fVbv65cdTAwMTTIP/IglFTeLKBRYqYtXHUwMDFjkFx0Ylx1MDAxNVx1MDAxNoUherTYXHUwMDAzO7X2XHUwMDAyXHUwMDFkOUokj01Ow3IpsyvdWlx1MDAwZu3du/TJXHUwMDAwXHS0MVjOl/jSXHUwMDAxRaSr7iF5sFrOSYDqfVGRXHUwMDAwgdCeXHUwMDA3Ulx1MDAxNFxuXHUwMDE2yeHlclx1MDAwNF9cdTAwMTmQZNJvJaBcdTAwMTJcdTAwMWOMeegziPW59qJUXFxcbmPJISljtNEu94NcdTAwMWa3YllmNHFcdTAwMDPaWue4MkKWpPqRSFAlnP2rXHUwMDA05CUxIFGdqCFhUEtcdTAwMGbXue3ZyfXlKzWNj6ZHL+LB5OPWZft479O6MyCK1ohcdTAwMDGh4MSCjK9ZXHUwMDE0XHUwMDEzNUJcdTAwMThGXHUwMDExgnRElFxibvlfUPlOmVx1MDAxYSBcdTAwMWHmIFx1MDAxN1x1MDAxNfzotUPHXHUwMDA1t8LlbmiFmZo15Cm4OE8xrlqvwVx1MDAxN1xyrTXzN6/Ub/2a8lx1MDAxNFxuKsH/mFx1MDAwZjk3w41cdTAwMTC5eCFtXHUwMDEwXHUwMDAwzdAnckxcdTAwMWHai8VcdTAwMWXdrNVr7dtotPDP9/tcdTAwMDdcdTAwMDfhXHUwMDBlLdeGOeu7XHUwMDE2NddWlVx1MDAxYdP8U2zKoV3l7yQspJVz0pR6h7FRKOs4RS9OXHUwMDFiXHUwMDA1WphcXGfyRpZcdTAwMTORXGLCao1cdTAwMWH8XHUwMDEzYuWfJJiLpNT3wszIRFxcSCvyyEJq8lx1MDAxZqIkXHUwMDEzkmfx7Wv+4XY0d7X0/0hcdTAwMTSlXHUwMDEyyOlgXHUwMDExwlVcdTAwMDTlyc3s/jGhk4TwdrtcdTAwMTVcdTAwMDTpqHVjyrNb3LyMwsn2XT056cvbx3QxvVx1MDAxNVxu/Y3+/fnJ5/9cdTAwMDPV4pXXIn0= Screen 1(hidden)app.pop_screen()Screen 2(hidden)Screen 3(visible)Screen 2(visible)When you pop a screen it will be removed and deleted unless it has been installed or there is another copy of the screen on the stack.
"},{"location":"guide/screens/#action_1","title":"Action","text":"You can also pop screens with the \"app.pop_screen\"
action.
The switch_screen method replaces the top of the stack with a new screen.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXG1T20hcdTAwMTL+nl9BcV/2qsLsTPe8btXVXHUwMDE1XHUwMDAxNlx1MDAwMVx1MDAxMpNccuH17opcdTAwMTK2bGsxsrFlXGZs5b9fjyCWLL9gg3DIKlXY1sjj1szTTz/dM8pfb1ZWVpPbTrj628pqeFNcclpRrVx1MDAxYlxmVt/689dht1x1MDAxN7VjaoL0c6/d71bTK5tJ0un99uuvl0H3XCJMOq2gXHUwMDFhsuuo11x1MDAwZlq9pF+L2qzavvw1SsLL3r/930pwXHUwMDE5/qvTvqwlXZb9yFpYi5J29/63wlZ4XHUwMDE5xkmPev9cdTAwMGZ9Xln5K/2bs65cdTAwMTZcdTAwMDWX7biWXp425MwzUDxbacepqWiElFxiKrsg6m3SjyVhjVrrZHCYtfhTq7fHrZPex6uT643+2ra4qrq7KKlmv1qPWq395LaV2tRr061kbb2k275cYo+iWtKkVlE4P+1b3Xa/0YzDXm/kO+1OUI2SWzqn+PBkXHUwMDEwN9IusjM39GlccpVgxmgrpOJSKG3VsN13IDUyY41RXHUwMDAypUGJQlx1MDAxNSzbaLdoXHUwMDFlyLJ/8PTIbDtcdTAwMGaqXHUwMDE3XHIyMK5l11xiXHUwMDE1XHUwMDA05/XsmsHD/UqnmLRcdTAwMTJN1n0zjFx1MDAxYc3Ez5DVzFx1MDAxYcFdvrVcdTAwMTemk1x1MDAwMCA5OpR22OB/sbNdS8Hwv+IoNoNu52G0Vnv+Q85ab+hWXHUwMDExSXk05eY5TCpR2N89QLt7uub0detcZuKPw75GoFx1MDAxN3S77cHqsOXbw7vMtH6nXHUwMDE23CNKaC2tdU5cIupsMltRfEGNcb/Vys61q1x1MDAxN1x1MDAxOVxi07Pf3i5cZn2Jalx1MDAxYfSFXHUwMDExllx1MDAxYm5cdTAwMTTOjf0/wi+wKW/OKp9cdTAwMGU+XHUwMDFjXHLuTreCne0vP1x1MDAxMvuCP1x1MDAwZX5pmDJGguZcXKAwelx1MDAwNPvogKHlXHUwMDFjXGJfTlx1MDAxYsefh/16cM65Klx1MDAxMftkmHBOw5LB/2Vf7Z00vsRna/31w62d/atccn5cdTAwMTmUXHUwMDAyfsdRcO2kKVx1MDAwYvxJeJNMQr6yelx1MDAxYfKNdMid4PNcdTAwMDM/6H95t3n2Z3vzc3x4dHP8YaNcIlx1MDAwZVx1MDAwZl478J1hloNBXHUwMDAx3FhrR4FvhGJIzi+ERUW+XHUwMDAxz8K9cYrXYVx1MDAxY/eC23HAXHUwMDFiUYS5UVxuLU1cdCxcdTAwMTflL0fxXHUwMDFl5dKAxlx1MDAwNVCeoalcdTAwMWQn+9FdmHLDyNnfg8uodTtcdTAwMDKJXHUwMDE0/mTgfrVcdTAwMWKG8Yr4b/xLM6rVwvif+Vx1MDAxOeuF9Pu+Qz36zfVW1PDOstpcbuujXpREpMOGzUk7N8ZVsiSg7rrbteJcdTAwMWS1u1EjioPW1+lWPc2ZXHUwMDAxi2eHYVxmOEVUoXF+bz4wx1f609ZBv9M53ZKbXHUwMDFmq+8/71x1MDAxZL52b7aOkXJDq1BobnKKNlxyYyiYoJNaodNKKlMwrFx1MDAxY29cdTAwMDbMOGTozblzXHUwMDBm3uxcZqCRSmZ38DeIWUpQzFi2N8PKL5Q2ReetcLI3g1x1MDAxYfnmkrw5b9VMb75cdTAwMWbmXHTuLCjgTPVnXHJcdTAwMTSXtMuJocf8efbML+DPxSD4gv5cZsYxdEpLZVx1MDAwNXJNideoQ2vrm41cdTAwMTDg6FJcdTAwMTC6YFo5XHUwMDFlrYk2hFBcdTAwMTKApsQ6nvHG0L9cdTAwMWRnXHUwMDE20GpDjsCdmaRRSVxccKKdJ0Tv1MxcdTAwMTn+PsMjlbJOLKJcInN2XHUwMDA03eRdXHUwMDE016K4QY1cdTAwMTmVfK8ybM9cdTAwMTElUlx1MDAxZq72vZWcXHTllPSJsybcWpVcdEs/XHUwMDE2QcdcdTAwMWLNQFhJk01aXmr/7+GKb0Ozwrj2uFGzM7CcUWucgeHcijTrp+R/kk2KQqexUjjpaO6tXHUwMDE5s6lcdTAwMTX0ko325WWU0Nh/bkdxUlx1MDAxY+N0MNe9ozfDYIxB6J7ybUVG6PhcdTAwMWVHiT17t5K5TPph+P5/bydePVx1MDAxNcv+XHUwMDE4Q3HW25v868JUZkFOT7BcdTAwMTUnW3CBPGO2XCJ9pUxmgUnUymjDSX7wQnXJSMtIXHUwMDBmWFx1MDAxNFx1MDAwMlx1MDAxMVxi/C9DZI5ZckJjuOFWTiQy5Vx1MDAxOGiN5H9cdTAwMDLISclZx5iMfIE6cFnDMojs6YnCnEQ2O3lcdTAwMWQhMs0taUhKXHRBccvR5i6651xmw1BrcjJCtsy50YIsNruGOmJcdTAwMTGXxPIkJo1cdTAwMTREU1x1MDAxM0js5+asabD1x9o4Ylx1MDAxN2StXHUwMDE5hUG0tnj2O29cdTAwMDHXXHUwMDE0yEDLjNlcdTAwMWXjrePT885e5Th+31x1MDAxZlxcRWu1TrR+dNF83Vx1MDAxOVx1MDAxNfi6oPWFXHUwMDExMFx1MDAwMimBzO72vihuXHUwMDE5+aZwzlx1MDAxMYNbmUsvn15cdTAwMTTXurzSoCNgSJJemdmlZVmPXHUwMDE1rrPg8nKFa6LGafjUToLBXFx6+Vx1MDAxODrXb+tcdTAwMDfBzqf1+LyyTTnCIN6CratS0VlcdTAwMGJ6zbBcXHgqYL5cXG2c4Vx1MDAwZYBcdTAwMTdcdTAwMTN+R0GXlI5cdTAwMDalwaJ2ZdStVYmVayFcdTAwMDWCUy+CzyFcdTAwMGJOqFx1MDAwMtTtYSVuXHUwMDFjbFx1MDAxY21tXZ9WKlx1MDAxZiuN3km/nCqAplx1MDAxMETiQC1cdTAwMDH9gtT4dFnprFEqP+WP0vOHs697h5+PzuqdXHUwMDAzPKq15Hn7Knnl9KzSlFx0lNdcdTAwMWFcblRu+S/twCGzaDSBTNNYQK75Kfi3UHVcIixz3caBXCI5vOxFS1x1MDAxYoZcdTAwMWb6XHUwMDFm7+5a56f6rI5JhLenfD70v53V77vbzul54/Bi1+3dbVx1MDAwZrqbUbR7Wi+h3+NrXHUwMDFi697B7mDQaZze1bu1qLr+R1x0/fbE+sGOXHUwMDFl3Fx1MDAwZWrrXCLYUdHazWkvLIdcdTAwMDW8KJDKubJYYFrJXHUwMDFi3dRcdTAwMDDo9Tl3Ml9Ee4xcdTAwMDFcdTAwMGX1p+jaXHUwMDFkh1eV3ubmn1j5uvm+rZ7CXHUwMDAwy0ssXHUwMDAxNVOCXCJcdTAwMWNcdTAwMTjKLa1Uo1x1MDAwYliCI6OMUyNpOGVcdTAwMTQ+s+ZccmDPw1x08kzpXHSppJxcdTAwMTDuaEpcdTAwMTRlTi9Q9J4lx5RxsFxiXHUwMDE0s1x1MDAxOc/K0mhcdTAwMTjZXHKgvVxmttqNXFwzLFJn+vd7kTrodFhvQJlb86yXVoZ/uX/BycXq3ELBMorVM6yb6Y/Ti9ZcdTAwMWPMNI90vvRHOkzM7ZCzma9cZod8XHRNqli6sMNcdH7kl06MeCRKzVxmSLBcXFhDI1JcdTAwMTRcdTAwMGLlOCQw37lUoFxyWIE4qdRjXHUwMDA1Q4da+1VlXHUwMDE0+dTtwVvRSikt8ieE5+dUeoBTQvlcdTAwMTRvnbPSM1vnreTrKs761X+Lylx1MDAxOcmlyVx1MDAxNVx1MDAxZVx1MDAxZVxuK4o5wf1FSFOluJFcdTAwMGZcdTAwMTdMKfWM3sVPVIKZjid/XHUwMDE0kZT19ib/+oRcdTAwMTWwnHuMhXdcdTAwMGUkLFx1MDAwNZ9f4M/WO6+TTYw0zO+70ki3i4iFXHUwMDA1MFx0TKVcdTAwMDOhTH7TVplUwpklia5IO5BzgsZcdFwi3zpcdTAwMDagUFJYXHUwMDE0xlx1MDAxYZlcdTAwMWKfXHUwMDA3Klx1MDAxMZo0XGKiWm7R2C836SetR5dNJWuCUTwgMVwiOWViXG5cdTAwMTByXHUwMDE33VOJZKTPNEpBI619reJvSiVrU1x1MDAwMeWPMSiVxiXcTS9cdTAwMTZcdTAwMTC1SePdaG4umZ3rvU4ukY5cdTAwMDaXK2JVXHUwMDFmzLRcdTAwMWRcdTAwMTUmNOSMa1x1MDAwZVx1MDAwNECjKVuXXHUwMDA1w8piXHUwMDEzpL7RXGItXHUwMDE1IV2bSUVcdTAwMDPHmfa0RmlcdTAwMDVcdTAwMDClNFx1MDAxM6SJcSgo21nyajrkPffHSlx1MDAxM6JcdTAwMTMgceaXRSjqar9vfTKlOO5cZo20X71cdTAwMWVfuv57UMpcZlD5Y1xmTlx1MDAwYnLKrJ3jZvqWO0cj75TUXHUwMDE57TxKKr/38fxz/Wj75FCfSLu373ai3dddgbSWM+VcdTAwMDR4tuZo5Gj5gSQzI6bn1ilcdTAwMDSH8nn7Z8tfXHUwMDFlks44Q1x1MDAxZbLkesSyloesnFx1MDAxZfJoSEg1gpj/mZ6dXHJcdTAwMGW7h4Ov8CFpVFx1MDAwNvWrvT+7W+9fOzolo2nXVlx1MDAwYidcdTAwMDGwXHUwMDEw8biXXCJCXHUwMDFiR6qMW8WfXHUwMDE38souj5NmJlx1MDAwNlEvUSxcdTAwMWJcdTAwMTLgXHUwMDEyq+NcdTAwMWLHXHUwMDFmZaRO63+Yg9v1rb3Eid8/XZRSbS7dpaZusJ7+iJyg8FwiXHSQXHUwMDBiXHUwMDE0t1x1MDAwME+qX9Q+bm13XHUwMDE0XHUwMDFjvW+a5ufaa5eQVvpcctbKbzJBa/Lrb2k6KvzOXHUwMDE3J8ibKFx1MDAxZlXuZfxcdCaloONcdTAwMWKshd/jrVx1MDAxNVx1MDAxN0t+XuLlcO6LY2ilWPrzXHUwMDEy+Cp3WOPzd1hLmPr4k/D79Ei32Pn3Jc6e+Ve5fOS0ZqBImVHex7WDwv5cdK2Ypmb/JIWw+efryvRnbVx1MDAxOGl30ipSS+B20sNQ1jFOXHUwMDEzXHUwMDAyXHUwMDAwXHUwMDBl1Eih68HXwVxuxV1+rW8ppeone+Oc+eDsXHUwMDEwMZJcdTAwMGZcbktJMeU3KFx1MDAxZFx0WpywXHUwMDA3XHUwMDEwaKa11ztaXHUwMDFhTjP+PVxyWnBb4mxcdTAwMTm4MrK5WlC+ZZVcdTAwMTbaot+GkD1dN7Rcblx1MDAxOKWupHGs81dKbse3fP9MmehULPujiOKsszf510VVicht8iqSXHUwMDE4aVx1MDAxMrA0vvPnoFti33aSRudT+6ZDaDpcYmrx2bspJNZcZqrNfjf88TpfkPDgQlxiIDCholxmf1ToXHUwMDFigcwh+ke+QPuc7zk8lnSDuNdcdLrkXHUwMDEwXHUwMDEztEkuxc20ybi058I/s/xcIlx1MDAwYuGztMmL7vuS4DRmkm9pT391w8v2dV7m/nBlktn0NF2SX+YperT0iSwp7Pk9evakL+TRS9zXXCItU9zHdK7AXHUwMDA3hsLzXHUwMDEyfl+LzzMoeZbGuGetos/0aFx1MDAwMaSB/Fx1MDAxNlNOXHUwMDFhSXOZ+48gsno1MFx1MDAwNc5vMjHoXHUwMDFmRCu6u0/wpXJmmdXq5/jjnOpkdqgoKFx1MDAwMUupo6LwbqSQOlx1MDAxYqJMnnCGXHUwMDE0XHUwMDE40Vx1MDAxMI+npamnqZPZu5hHXHUwMDE0XHUwMDEzd1xuaL6Ms1r6xYRxmyhqkDiRaK2jTFx1MDAwMIT6uZ/9mlx1MDAwZWV/rFx1MDAxNVE8TZ68efhcdTAwMDG/eWg/IcxccqeDYFx1MDAxZNVcdTAwMWWIPLvL1esoXHUwMDFjvJu0nzo9PEem4+mZKPT3+te3N9/+XHUwMDBmJe3LnyJ9 Screen 1(hidden)Screen 2 (visible)app.switch_screen(screen3)Screen 3 (visible)Screen 2 removedLike pop_screen, if the screen being replaced is not installed it will be removed and deleted.
"},{"location":"guide/screens/#action_2","title":"Action","text":"You can also switch screens with the \"app.switch_screen\"
action which accepts the name of the screen to switch to.
If a screen has a background color with an alpha component, then the background color will be blended with the screen beneath it. For example, if the top-most screen has a background set to rgba(0,0,255,0.5)
then anywhere in the screen not occupied with a widget will display the second screen from the top, tinted with 50% blue.
Note
Although parts of other screens may be made visible with background alpha, only the top-most is active (can respond to mouse and keyboard).
One use of background alpha is to style modal dialogs (see below).
"},{"location":"guide/screens/#modal-screens","title":"Modal screens","text":"Screens may be used to create modal dialogs, where the main interface is temporarily disabled (but still visible) while the user is entering information.
The following example pushes a screen when you hit the Q key to ask you if you really want to quit. From the quit screen you can click either Quit to exit the app immediately, or Cancel to dismiss the screen and return to the main screen.
OutputOutput (after pressing Q)modal01.pymodal01.tcssModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2585\u2585 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
ModalApp \u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588 \u2588\u2588 \u2588\u2588 \u2588Are\u00a0you\u00a0sure\u00a0you\u00a0want\u00a0to\u00a0quit?\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2588 \u2588\u00a0Quit\u00a0\u00a0Cancel\u00a0\u2588 \u2588\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2588 \u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588
modal01.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import Screen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(Screen):\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.app.exit()\nelse:\nself.app.pop_screen()\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\nself.push_screen(QuitScreen())\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
modal01.tcssQuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
Note the request_quit
action in the app which pushes a new instance of QuitScreen
. This makes the quit screen active. If you click Cancel, the quit screen calls pop_screen to return the default screen. This also removes and deletes the QuitScreen
object.
There are two flaws with this modal screen, which we can fix in the same way.
The first flaw is that the app adds a new quit screen every time you press Q, even when the quit screen is still visible. Consequently if you press Q three times, you will have to click Cancel three times to get back to the main screen. This is because bindings defined on App are always checked, and we call push_screen
for every press of Q.
The second flaw is that the modal dialog doesn't look modal. There is no indication that the main interface is still there, waiting to become active again.
We can solve both those issues by replacing our use of Screen with ModalScreen. This screen sub-class will prevent key bindings on the app from being processed. It also sets a background with a little alpha to allow the previous screen to show through.
Let's see what happens when we use ModalScreen
.
ModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2585\u2585 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
ModalApp \u2b58ModalApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0i\u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588 Where\u00a0the\u00a0\u2588\u2588st\u00a0not\u00a0f Fear\u00a0is\u00a0th\u2588\u2588 Fear\u00a0is\u00a0th\u2588Are\u00a0you\u00a0sure\u00a0you\u00a0want\u00a0to\u00a0quit?\u2588 I\u00a0will\u00a0fac\u2588\u2588 I\u00a0will\u00a0per\u2588\u2588\u2585\u2585 And\u00a0when\u00a0i\u2588\u2588 Where\u00a0the\u00a0\u2588\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2588st\u00a0not\u00a0f Fear\u00a0is\u00a0th\u2588\u00a0Quit\u00a0\u00a0Cancel\u00a0\u2588 Fear\u00a0is\u00a0th\u2588\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2588 I\u00a0will\u00a0fac\u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588 I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.I\u00a0must\u00a0not\u00a0f Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. \u00a0Q\u00a0\u00a0Quit\u00a0
modal02.pyfrom textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(ModalScreen):\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.app.exit()\nelse:\nself.app.pop_screen()\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\n\"\"\"Action to display the quit dialog.\"\"\"\nself.push_screen(QuitScreen())\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
modal01.tcssQuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
Now when we press Q, the dialog is displayed over the main screen. The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input.
"},{"location":"guide/screens/#returning-data-from-screens","title":"Returning data from screens","text":"It is a common requirement for screens to be able to return data. For instance, you may want a screen to show a dialog and have the result of that dialog processed after the screen has been popped.
To return data from a screen, call dismiss()
on the screen with the data you wish to return. This will pop the screen and invoke a callback set when the screen was pushed (with push_screen
).
Let's modify the previous example to use dismiss
rather than an explicit pop_screen
.
from textual.app import App, ComposeResult\nfrom textual.containers import Grid\nfrom textual.screen import ModalScreen\nfrom textual.widgets import Button, Footer, Header, Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass QuitScreen(ModalScreen[bool]): # (1)!\n\"\"\"Screen with a dialog to quit.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Grid(\nLabel(\"Are you sure you want to quit?\", id=\"question\"),\nButton(\"Quit\", variant=\"error\", id=\"quit\"),\nButton(\"Cancel\", variant=\"primary\", id=\"cancel\"),\nid=\"dialog\",\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nif event.button.id == \"quit\":\nself.dismiss(True)\nelse:\nself.dismiss(False)\nclass ModalApp(App):\n\"\"\"An app with a modal dialog.\"\"\"\nCSS_PATH = \"modal01.tcss\"\nBINDINGS = [(\"q\", \"request_quit\", \"Quit\")]\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Label(TEXT * 8)\nyield Footer()\ndef action_request_quit(self) -> None:\n\"\"\"Action to display the quit dialog.\"\"\"\ndef check_quit(quit: bool) -> None:\n\"\"\"Called when QuitScreen is dismissed.\"\"\"\nif quit:\nself.exit()\nself.push_screen(QuitScreen(), check_quit)\nif __name__ == \"__main__\":\napp = ModalApp()\napp.run()\n
[bool]
QuitScreen {\nalign: center middle;\n}\n#dialog {\ngrid-size: 2;\ngrid-gutter: 1 2;\ngrid-rows: 1fr 3;\npadding: 0 1;\nwidth: 60;\nheight: 11;\nborder: thick $background 80%;\nbackground: $surface;\n}\n#question {\ncolumn-span: 2;\nheight: 1fr;\nwidth: 1fr;\ncontent-align: center middle;\n}\nButton {\nwidth: 100%;\n}\n
In the on_button_pressed
message handler we call dismiss
with a boolean that indicates if the user has chosen to quit the app. This boolean is passed to the check_quit
function we provided when QuitScreen
was pushed.
Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller. This makes it easier for the app to perform any cleanup actions prior to exiting, for example.
Returning data in this way can help keep your code manageable by making it easy to re-use your Screen
classes in other contexts.
You may have noticed in the previous example that we changed the base class to ModalScreen[bool]
. The addition of [bool]
adds typing information that tells the type checker to expect a boolean in the call to dismiss
, and that any callback set in push_screen
should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
Some apps may benefit from having multiple screen stacks, rather than just one. Consider an app with a dashboard screen, a settings screen, and a help screen. These are independent in the sense that we don't want to prevent the user from switching between them, even if there are one or more modal screens on the screen stack. But we may still want each individual screen to have a navigation stack where we can push and pop screens.
In Textual we can manage this with modes. A mode is simply a named screen stack, which we can switch between as required. When we switch modes, the topmost screen in the new mode becomes the active visible screen.
The following diagram illustrates such an app with modes. On startup the app switches to the \"dashboard\" mode which makes the top of the stack visible.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2aW0/bSFx1MDAxNMff+ylQ9mVXKu7cL5VWK6ClXHUwMDA1UmhJuZRtVTn2JPHGsY3tJEDFd99jw8ZcdTAwMTdcYiSkXHUwMDA0Km1cdTAwMWWCPWfsOTPz+885M+HHi5WVRnpcdTAwMWWZxuuVhjlzbN9zY3vceJmVj0yceGFcdTAwMDAmkt8n4TB28pq9NI2S169eXHLsuG/SyLdcdTAwMWRjjbxkaPtJOnS90HLCwSsvNYPkr+x711x1MDAxZZg/o3DgprFVNLJqXFwvXHLjq7aMb1x1MDAwNiZIXHUwMDEzePvfcL+y8iP/LnnnevYgXGbcvHpuKLmnab10N1xmclcx5YpcdTAwMTOJNZrU8JI30FpqXFwwd8BjU1iyosaRjYZ7zeh78+JTW9uO2t48+/ChaLbj+X4rPfdzp5JcdTAwMTD6UtiSNFx1MDAwZfvmyHPTXtZ2rXzaU3E47PZcdTAwMDKTJJVnwsh2vPRcdTAwMWPKeOG7XHUwMDFkdPNXXHUwMDE0JWdwtyotjVx00pxcdTAwMTFOlGKMTMxXz1OLXHUwMDEwwlx1MDAwNYyT0lxuXHUwMDA2pObYRujDPIBjv6H8U7jWtp1+XHUwMDE3/Fx1MDAwYtyiXHUwMDBl5rbd7lx1MDAxNHXG190lQlpMM1x1MDAwMe1ThbVWk1x1MDAxYT3jdXtp1jvOLCWxkFxiXHUwMDBiTqUo/DD5dGioXHUwMDAwb2BsYshcdTAwMWGPttyci2/lXHUwMDExXHUwMDBi3OtcdTAwMTFcdTAwMGKGvl/4m1x1MDAxOd6WWCqeXHUwMDE5Rq59NelYwDhQxbGkolx1MDAxOCnfXHUwMDBi+vXX+aHTLzjJSy9fzo0n43wqnoogISijbGY8t9+hQ9U8+dTc2Wz7zY3Drffn0f5T4onRvXxyXHUwMDBi5lRrwpiQmFAlK3zChFtcdTAwMDIhSiSlSmjJXHUwMDE2wrNjt1x1MDAxMeKPgyehjGOt0Fx1MDAxMvBkQiMk+Vx1MDAxMvBUpaGo4amFXCLgzVx1MDAxY3SK3ogo01x1MDAxMu6J8665ftzdx1x1MDAwM3fzmdOJLYyBTC44V1xmRlx1MDAxZtEqnlhCXHUwMDA1ilx1MDAxOPArXHUwMDA1XHUwMDExdLHlU1x1MDAxMUdj8yh8YkJcdTAwMTiGJYUsXHUwMDAxUIZcdTAwMTFF0NxcdTAwMTJcdTAwMDClkkxcdTAwMDM0XHUwMDBiaowjiWdcdTAwMDd0dEC3u1EoRu/eXHUwMDFlOKnauaCO/5SA0vv4lDhcdTAwMDOUQ1dcdTAwMDVDWlx1MDAxMF6hkyNkMWCTaSGZYrTu1nxwtlxya7vtXz22g4gxXHUwMDEzVC6DzVIwuFx1MDAxMduZZJrCnM1cZufnL/2NcNPZ+4L39b67ZtZcdTAwMDej95+fNZxUUEtcbqmk1Fx1MDAwMsM3rcGpLSWIJCBhzLhaiM2OK1xyZv+zOTObnN3BJkJKc0g8Z2bT/b56vLcrm+GgXHUwMDFk+73xXHUwMDA2e+tvnjw3Ni3IXCJplkUykucuvFx1MDAwNiu3lIZcdTAwMDSTMYlcdTAwMTFcdTAwMTdcdTAwMTVWmURgXHUwMDE08Fx1MDAxNIxccqRcdTAwMDJ0IViZI0znl89CfzasqTlLbyNcdTAwMTWXwlaNVMVhaYG1Q85cZqp/SFtvdk9OTtaJcS76slx1MDAxZnL1aVxuqD3b6VxyY/P0SShcdTAwMTOWXHUwMDEwkFxcMlx1MDAwNmkoIZKzXG6cXHUwMDEySVx1MDAwYlZRyHUgxSOYL5aCTovymCuLaq6xlkAmYvImnOXk91xuR4Ipw1x1MDAxY5V20o+II+TmisyTc1x1MDAxNtNcdTAwMWVcdTAwMDZpy7vIk0ZVKd20XHUwMDA3nn9embmcU1x1MDAxOKmvXHLXTnrt0I7dr41Gxbzme90gx810qkynnmP7XHUwMDEzc1x1MDAxYUaF1YHmbC8w8ZZbdzuMva5cdTAwMTfY/ue7m4ZcdTAwMWWb95OVwiolg207MZk1z4pcdTAwMWakQqBrarzAXGa2o4ry2eOFQVx0+uLt75zzXvfirFx1MDAxZq7udFx1MDAwZbaeVobsPlx1MDAxNSpMsr0ggTVcdTAwMDfyadiD10SoLYIo5lx1MDAxYWFcYjNqsXOKaVwiXHUwMDE0ylx1MDAxMopqXGJRXGJyJlXKSZ6NXGKh92iek4lFRdgzfrR8/dVbfVTpTVx1MDAwZoDZgVx1MDAxOFwipdbuXHUwMDEz3sH2If9cdTAwMThcdTAwMWSGp7J1vrHzXHUwMDBmYmunb5vPXFx4jHKLYlx1MDAwNblcdTAwMWJHnGOlqvuIXFx5XHUwMDA0Q1qnRCa/R1xuf0RZsIvWRCDCqS6r6dlIXHUwMDBmKTlXOrao9Fx1MDAxMpOmXtBNli+/21p+TFx0lnO0mzt5yLnpPLsl0Vx1MDAxMuHx+MBcdTAwMWZ8XGLGXFydXlx1MDAxY1x1MDAwZtvjvYeJkNTKXHUwMDFmL1x0JcRCXHUwMDAy4p5cdTAwMDagQVx1MDAwNJRU41x1MDAxZtHEXHUwMDAyocLOKtNcdTAwMDfEpukqVLLD21x1MDAwZlShVlkrXHUwMDEwZJDimpaOwO9cdTAwMTChUOAz4z9vS3RtKPgpze1ol1x1MDAxY21/POqf4vFWa20zSnbtuNjqVWCz4zhcdTAwMWM3JpbL66s7XHUwMDE0rjQofJ5fpVx1MDAxNlP4mpN6I7Py+8hLvLZv/liuyqe3/jOUfjX4t0mdsHrpROqwXHUwMDA3k0rL2bebd9PwJEqX91x0PYt0QnPKXHUwMDE51Vx1MDAxNFx1MDAxM1ngklx1MDAxZqxAMOZIXHSuXGLnTEl2x1HIXHUwMDAyQkdcdTAwMTZWXHUwMDFjgStMZb9OXHUwMDEzSW45XGZhzJKIXHUwMDExgYXQWGB9U/lCQFx1MDAwZlx1MDAxOCqWqvulX2j6P1bIdcnlg6LywzWbpHacrnuBXHUwMDBika7q2PU/RGzNXHUwMDEwTXKVO8PMy1VkIcmFJJpjXGJZTFx1MDAxNucm2cjYUbbJgSqUXG5CKMKSiJtdN4FbuFTthZ2kXHUwMDFi4WDgpdD/j6FcdTAwMTek9Vx1MDAxYXmH1jLh9Yx9Q//w5rKtrtAoe2N1+S2uVlxuhvObyfW3l7fWXr2Dr+xzg6zihS/Kf7M1O2+iYUdRK4WZn0xcdTAwMTSg5rnXS27Rz8bIM+P121x1MDAwZbDzT1x1MDAxNlxy8rHOllx1MDAwNpPjePni8l9cdTAwMDSHyVx1MDAxMCJ9 \"dashboard\"\"help\"\"settings\"Active (visible)If we later change the mode to \"settings\", the top of that mode's screen stack becomes visible.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2a2VLbSFx1MDAxNIbv81x1MDAxNJTnNii9L6mammJfMmxcdTAwMDFcdTAwMDJkkkrJUttWLEtCkrFJinef01xuYy1gNrNlfGHsPrL6tPr7z1wi8fPN3FxcKz9PTOv9XFzLjD03XGb81Fx1MDAxZLXe2vEzk2ZBXHUwMDFjgYlcdTAwMTTfs3iYesWRvTxPsvfv3lxy3LRv8iR0PeOcXHUwMDA12dBccrN86Fx1MDAwN7HjxYN3QW5cdTAwMDbZX/Z921x1MDAxZJg/k3jg56lTTjJv/CCP019zmdBcZkyUZ3D2f+D73NzP4r3inVx1MDAxZriDOPKLw1x1MDAwYkPpXHUwMDFlJbw5ulx1MDAxZEeFq5xcdIlcdTAwMTlcIlx1MDAxM3uQLcNcXLnxwdhcdTAwMDF/TWmxQ62j3Z01s7iXu9m8TpLTlW/JsEPLSTtBXHUwMDE47ufnYeFSXHUwMDE2w0pKW5ancd9cdTAwMWNcdTAwMDV+3lx1MDAwMytujE/7VVx1MDAxYVx1MDAwZru9yGRZ7Tdx4npBfm5cdTAwMTeHJoNu1C1OUY6M7Y80dbDWmGtcIpggQtOJ2f6eKe4wXCK1XHUwMDA2XHUwMDEzZVxcNdxaikPYXHUwMDAzcOtcdTAwMGZUvErH2q7X74J3kV9cdTAwMWWDueu2O+Uxo8vFXHUwMDEyIVx1MDAxZKaZUIxRXHUwMDA1zpSz9EzQ7eXWTc5cdTAwMWMlsZBcYlx1MDAwYk6lKP0wxWbAXHUwMDAy7Fx1MDAxOVx1MDAxOJtcdTAwMTjs5MmGXzDxtXq9XCL/8npFwzAs/bWGlSZHVZYq27y/sX7a3/Hzle1w72Rk0sWTxW97k3XVwHPTNFx1MDAxZbUmlovLT6VHw8R3f1x1MDAwMYXh4iuGXHUwMDA1oVKWSIZB1G86XHUwMDFixl6/ZLBcdTAwMTi9eHtv8JlcdTAwMTLTwFx1MDAwNyooJVx1MDAwMle24jb0+3LpQ1x1MDAxYeJvx6j9PTzoxt3R9+HWK0dcdTAwMWbYXHUwMDE2XHUwMDAwNiGMXHUwMDEy1Fx1MDAwMJ9cdEchLlx1MDAwNVx1MDAxMoLCzrCZyO+4bYT405BPQJewUejRyH9ccmxqoqexSTVBmHBy96jMR8nR1tjfpOM12U+Xu0ujT/HglaOpXHUwMDFkiLlSaURcdTAwMTmSVNbYpGClWDFFuCRIK8ZnglNcdTAwMTFPY/MkcGKQXHUwMDE2xoqQ/1x1MDAxN520kiWbJYOWmFx1MDAxM87Znek88Vx1MDAwZdaOt7zOp7PNY7W1tDFYWF7cfkk62W10akxcdTAwMWNCXHUwMDA0x5hSpKSqwVx0VDpcdTAwMWHUXHTVhKRcdTAwMTji60xstlxya/vt36RkuFx1MDAxMU0hlHpcdTAwMTY01TQ0MVKwYCwqaf82NlPlkf7ybrhcdTAwMWLujE829lx1MDAwZvn6SftF61mMboOTXHUwMDBiu+1cdTAwMWFTXHUwMDBiqNSkXHUwMDFlOpkmXHUwMDBlXHUwMDEyXG5ziVx1MDAxONdcdTAwMTQ1/bpnWvelwez3p1x1MDAxM1xuXHUwMDFjhckz0Mn59F6LXG6GIKKgO8OZeWp1NzvrbVxmP6ydjr6vXHUwMDA0KplfeXVwOohcdTAwMTIoppVgRFx1MDAwYlx1MDAwNVA2aJVcdTAwMGXCSENcdTAwMDcmXHUwMDEwpFx1MDAwZVanlUNzhlxixlx1MDAwMlxuXHUwMDFlTORsRSjzhOn8XHUwMDBmitDHpTU34/w6VGGSqYGUI4FcdTAwMTnn8u7dUZet7+xcdTAwMWRcdTAwMWStRHtYXHUwMDFk8uHpeEzaXHUwMDBiU1jtuV5vmJpcdTAwMTdP84QpXHUwMDA3NpxcdMKVRErX2Vx1MDAxNLZcYoXuiCPIKZg+UZ7HXFw5VHONtVx1MDAwNDBcdTAwMTGTV9mkvEkjwZTZPVwiz0GjJlCG03vQWG56XHUwMDFj5fvBXHUwMDBme+GJqo2uuoMgPK/tW4EpXFypLy3fzXrt2E39L61WzbxcdTAwMTBcdTAwMDZdS24rNJ060nngueHEnMdJafVgOjeITLrhN92O06BcdTAwMWJEbnhw89SwYrM+XHRcdTAwMTRO5W5a282MtdpcdTAwMDXyXHUwMDA3ibBcdTAwMWHymlwiXHUwMDE0UHpcIoird+9cdTAwMDPnPZxcdTAwMWZcdTAwMWZ8Xo/ib5tcdTAwMWZHW6uHy8qLXrlcYjFEXFyHKi6FgFpcdTAwMDZcdTAwMTFdr2ckkkWG4IgwTrSa7Vx1MDAwNt00XHUwMDE1XG7lXGJFNWPgXHUwMDAwZqpSlLxcdTAwMWFcdTAwMTVCI4Lv0/rNqsKeXHST51x1MDAxN2Bz1ifVnsDN0Yn2JKOSQ69992Ltx+r4cIVcdTAwMDdq6cTdPmDR2s5cdTAwMGZ/8PFltXd7LyEocygniDJbc6Ayxf2SXHUwMDFlNLpSI6lcdTAwMTDngqjZWompXHSQKEdcdTAwMGLoY1x1MDAwNII0o6tyejXaI5yx+9Rjs2ovM3lcdTAwMWVE3ez59XfdzE+pQYxlc7TMf1RqXHUwMDAxjcHd85//cXfps4/mN8+S4OPSh8V2XHUwMDEwz88/TIOkMf6EXHUwMDFhZNpB0MkjibWwbVJNhERxh2PMidJQqaLqfbcrKlSyw9tcdTAwMGZToYI4QDn0b1xiQ9yrdlx1MDAxZjeIUEDJXGZtwuP1RJeGa1x1MDAxZknhwd/n/vKmiFx1MDAwZldcdTAwMGU6cZxiubpQ3pmowXb/R1Ka2j/PpvBcdTAwMDUvXHUwMDBmzszzars552Oo+teFvk7WeuqTN4K0JETf48HbzTv/XCKqlreK2lxuXG60LyhcdTAwMTSuiuD6LWRcbjlPSlvTaY5cdTAwMTWYb3i+MYOoqVx1MDAwMz08gTDKtOQgVVROM1G11I5iXHUwMDFjXHUwMDBlXHUwMDAxzdubhldFblx1MDAxZlx1MDAwZlx1MDAxMlapXHJuV3kp3/9QIZcjXHUwMDE3XHUwMDBmLH5cdTAwMWYqzyx303wxiHxIanXHLv+lYuNcdTAwMGWJo1x1MDAxMLQ3tF5cIlx1MDAwNyvrk6BcblOoQVx1MDAxMKGVo7puYmOpXHUwMDAztVxmWFx1MDAxMId2XHUwMDFlXHUwMDEyXHUwMDE4u7J2XHUwMDEz+aVP9WW4Wb5cdTAwMTRcdTAwMGZcdTAwMDZBXHUwMDBlXHUwMDE3YDdcdTAwMGWivHlEsaJcdTAwMDUrvJ5xr6hcdTAwMWXOXFy1NVx1MDAxNZrYM9ZDbflprmS4+DL5/PXttUdPx8u+roBVnu5N9a+NzsVcdTAwMDQtN0n2c9j4yT5cdTAwMDFpgX9cdTAwMTlcXMtVts5cdTAwMDIzWrzuZnXxsnG/uNI2MJiCxos3XHUwMDE3/1x1MDAwMiHc1HkifQ== \"dashboard\"\"help\"\"settings\"ActiveTo add modes to your app, define a MODES
class variable in your App class which should be a dict
that maps the name of the mode on to either a screen object, a callable that returns a screen, or the name of an installed screen. However you specify it, the values in MODES
set the base screen for each mode's screen stack.
You can switch between these screens at any time by calling App.switch_mode
. When you switch to a new mode, the topmost screen in the new stack becomes visible. Any calls to App.push_screen
or App.pop_screen
will affect only the active mode.
Let's look at an example with modes:
modes01.pyOutputOutput (after pressing S)from textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Footer, Placeholder\nclass DashboardScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Dashboard Screen\")\nyield Footer()\nclass SettingsScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Settings Screen\")\nyield Footer()\nclass HelpScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Placeholder(\"Help Screen\")\nyield Footer()\nclass ModesApp(App):\nBINDINGS = [\n(\"d\", \"switch_mode('dashboard')\", \"Dashboard\"), # (1)!\n(\"s\", \"switch_mode('settings')\", \"Settings\"),\n(\"h\", \"switch_mode('help')\", \"Help\"),\n]\nMODES = {\n\"dashboard\": DashboardScreen, # (2)!\n\"settings\": SettingsScreen,\n\"help\": HelpScreen,\n}\ndef on_mount(self) -> None:\nself.switch_mode(\"dashboard\") # (3)!\nif __name__ == \"__main__\":\napp = ModesApp()\napp.run()\n
switch_mode
is a builtin action to switch modes.DashboardScreen
with the name \"dashboard\".ModesApp Dashboard\u00a0Screen \u00a0D\u00a0\u00a0Dashboard\u00a0\u00a0S\u00a0\u00a0Settings\u00a0\u00a0H\u00a0\u00a0Help\u00a0
ModesApp Settings\u00a0Screen \u00a0D\u00a0\u00a0Dashboard\u00a0\u00a0S\u00a0\u00a0Settings\u00a0\u00a0H\u00a0\u00a0Help\u00a0
Here we have defined three screens. One for a dashboard, one for settings, and one for help. We've bound keys to each of these screens, so the user can switch between the screens.
Pressing D, S, or H switches between these modes.
"},{"location":"guide/styles/","title":"Styles","text":"In this chapter we will explore how you can apply styles to your application to create beautiful user interfaces.
"},{"location":"guide/styles/#styles-object","title":"Styles object","text":"Every Textual widget class provides a styles
object which contains a number of attributes. These attributes tell Textual how the widget should be displayed. Setting any of these attributes will update the screen accordingly.
Note
These docs use the term screen to describe the contents of the terminal, which will typically be a window on your desktop.
Let's look at a simple example which sets styles on screen
(a special widget that represents the screen).
from textual.app import App\nclass ScreenApp(App):\ndef on_mount(self) -> None:\nself.screen.styles.background = \"darkblue\"\nself.screen.styles.border = (\"heavy\", \"white\")\nif __name__ == \"__main__\":\napp = ScreenApp()\napp.run()\n
The first line sets the background style to \"darkblue\"
which will change the background color to dark blue. There are a few other ways of setting color which we will explore later.
The second line sets border to a tuple of (\"heavy\", \"white\")
which tells Textual to draw a white border with a style of \"heavy\"
. Running this code will show the following:
ScreenApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
"},{"location":"guide/styles/#styling-widgets","title":"Styling widgets","text":"Setting styles on screen is useful, but to create most user interfaces we will also need to apply styles to other widgets.
The following example adds a static widget which we will apply some styles to:
widget.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass WidgetApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(\"Textual\")\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.border = (\"heavy\", \"white\")\nif __name__ == \"__main__\":\napp = WidgetApp()\napp.run()\n
The compose method stores a reference to the widget before yielding it. In the mount handler we use that reference to set the same styles on the widget as we did for the screen example. Here is the result:
WidgetApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Textual\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Widgets will occupy the full width of their container and as many lines as required to fit in the vertical direction.
Note how the combined height of the widget is three rows in the terminal. This is because a border adds two rows (and two columns). If you were to remove the line that sets the border style, the widget would occupy a single row.
Information
Widgets will wrap text by default. If you were to replace \"Textual\"
with a long paragraph of text, the widget will expand downwards to fit.
There are a number of style attributes which accept colors. The most commonly used are color which sets the default color of text on a widget, and background which sets the background color (beneath the text).
You can set a color value to one of a number of pre-defined color constants, such as \"crimson\"
, \"lime\"
, and \"palegreen\"
. You can find a full list in the Color API.
Here's how you would set the screen background to lime:
self.screen.styles.background = \"lime\"\n
In addition to color names, you can also use any of the following ways of expressing a color:
#
followed by three pairs of one or two hex digits; one for the red, green, and blue color components. For example, #f00
is an intense red color, and #9932CC
is dark orchid.rgb
followed by a tuple of three numbers in the range 0 to 255. For example rgb(255,0,0)
is intense red, and rgb(153,50,204)
is dark orchid.hsl
followed by a angle between 0 and 360 and two percentage values, representing Hue, Saturation and Lightness. For example hsl(0,100%,50%)
is intense red and hsl(280,60%,49%)
is dark orchid.The background and color styles also accept a Color object which can be used to create colors dynamically.
The following example adds three widgets and sets their color styles.
colors01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Static\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(\"Textual One\")\nyield self.widget1\nself.widget2 = Static(\"Textual Two\")\nyield self.widget2\nself.widget3 = Static(\"Textual Three\")\nyield self.widget3\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"#9932CC\"\nself.widget2.styles.background = \"hsl(150,42.9%,49.4%)\"\nself.widget2.styles.color = \"blue\"\nself.widget3.styles.background = Color(191, 78, 96)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
Here is the output:
ColorApp Textual\u00a0One Textual\u00a0Two Textual\u00a0Three
"},{"location":"guide/styles/#alpha","title":"Alpha","text":"Textual represents color internally as a tuple of three values for the red, green, and blue components.
Textual supports a common fourth value called alpha which can make a color translucent. If you set alpha on a background color, Textual will blend the background with the color beneath it. If you set alpha on the text color, then Textual will blend the text with the background color.
There are a few ways you can set alpha on a color in Textual.
\"#9932CC7f\"
is a dark orchid which is roughly 50% translucent.rgba
format, which is identical to rgb
with the additional of a fourth value that should be between 0 and 1, where 0 is invisible and 1 is opaque. For example \"rgba(192,78,96,0.5)\"
.a
parameter on a Color object. For example Color(192, 78, 96, a=0.5)
creates a translucent dark orchid.The following example shows what happens when you set alpha on background colors:
colors01.pyfrom textual.app import App, ComposeResult\nfrom textual.color import Color\nfrom textual.widgets import Static\nclass ColorApp(App):\ndef compose(self) -> ComposeResult:\nself.widgets = [Static(\"\") for n in range(10)]\nyield from self.widgets\ndef on_mount(self) -> None:\nfor index, widget in enumerate(self.widgets, 1):\nalpha = index * 0.1\nwidget.update(f\"alpha={alpha:.1f}\")\nwidget.styles.background = Color(191, 78, 96, a=alpha)\nif __name__ == \"__main__\":\napp = ColorApp()\napp.run()\n
Notice that at an alpha of 0.1 the background almost matches the screen, but at 1.0 it is a solid color.
ColorApp alpha=0.1 alpha=0.2 alpha=0.3 alpha=0.4 alpha=0.5 alpha=0.6 alpha=0.7 alpha=0.8 alpha=0.9 alpha=1.0
"},{"location":"guide/styles/#dimensions","title":"Dimensions","text":"Widgets occupy a rectangular region of the screen, which may be as small as a single character or as large as the screen (potentially larger if scrolling is enabled).
"},{"location":"guide/styles/#box-model","title":"Box Model","text":"The following styles influence the dimensions of a widget.
Additionally, the margin style adds space around a widget's border, which isn't technically part of the widget, but provides visual separation between widgets.
Together these styles compose the widget's box model. The following diagram shows how these settings are combined:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT2txcdTAwMTb+3l/h+H4tu/t+6cyZM9VqrbfWevfMO51cYlx1MDAwMaJAaFx1MDAxMlx1MDAxNHyn//2sXHUwMDFklCSERFCw6Dn50EpcdTAwMTKSxd7redaz1r78825lZTVcdTAwMWF03dWPK6tuv+q0vFrg3K6+t+dv3CD0/Fx1MDAwZVxcovHn0O9cdTAwMDXV+M5mXHUwMDE0dcOPXHUwMDFmPrSd4NqNui2n6qJcdTAwMWIv7DmtMOrVPFx1MDAxZlX99lx1MDAwNy9y2+G/7b/7Ttv9V9dv16JcdTAwMDAlL6m4NS/yg+G73JbbdjtRXGJP/1x1MDAwZnxeWfkn/jdlXeBWI6fTaLnxXHUwMDE34kuJgZzT8bP7fic2llBFleCck9FcdTAwMWRe+Fx1MDAxOd5cdTAwMTe5NbhcXFx1MDAwN5vd5Io9tTo46Tt6Y//TbvubXHUwMDFmnISHXHUwMDFkv16tJa+te63WYTRoXHKbwqk2e0HKqDBcbvxr99SrRU379rHzo+/V/MhcdTAwMWEwulx1MDAxY/i9RrPjhmHmS37XqXrRwJ7DeHR22FxmXHUwMDFmV5IzfdtcdTAwMDZYXCIhpMGYXHUwMDEwybA0cnQ5flx1MDAwMMdIY2FcYiZGKEFcdTAwMTVcdTAwMWYzbd1vQW+AaX/h+Ehsu3Sq11xyMLBTXHUwMDFi3Vx1MDAxM1x1MDAwNU4n7DpcdTAwMDH0WXLf7f2PXHUwMDE22CCmNJVUXHUwMDBiplxyS35P0/VcdTAwMWHNyFx1MDAxYUtcdMJaXHUwMDE4psTwbYmxoVx1MDAxYndcZtjJXGI8JfmR1oTu11rsI3+Pt2vTXHS69823XHUwMDFh2lx1MDAwZinzreVcdTAwMWLjXHUwMDBllnayVN9/r+v+vt5vXa5HV0dH52dVXHUwMDE11ndGz8p4pFx1MDAxM1x1MDAwNP7t6ujK7/u/XHUwMDEy03rdmjP0MlwiJSOSSo0l06PrLa9zXHJcdTAwMTc7vVYrOedXr1x1MDAxM8eMz/5+/1x1MDAwNERIZVxuXHUwMDExYYji4Fx1MDAwNFRNjYjdra+tur9f3aq3XHUwMDA3P1x1MDAxYd7N7lx1MDAwMd1cZlx1MDAwYlx1MDAxMFx1MDAxMfqA75nxMPatx+DAXHUwMDFlRYNcdTAwMDI0XHUwMDE4TVx1MDAxOVx1MDAxNVx1MDAxYyshWFx1MDAxNlxylGokJSZKXHS4R6cvj6NB1FmtykvR8Fx1MDAxN69Kty7ySGBCIS1cdTAwMDSXWok8XGKoMIhcdTAwMWFcdTAwMDNewVx1MDAxOcNU5EFAXHUwMDE5XHUwMDEzklxirl9cdTAwMTZcdTAwMDTVb7vnzbOt49rAmE5vn3RVs+a9Qlx1MDAxMHBZXGZcdTAwMDImMeVUSjE1XGKud6+u2mHw6zRw+4HZONu46PHPT1x1MDAwYlx1MDAwYrRcYlx1MDAwNjUnbM43LFxiRpFSXFxIYaiSWpAsXHUwMDBlXHUwMDE0uKDWWlx1MDAxOUOoIIqwQlx1MDAxY7hSqedEXHUwMDA18O88XHUwMDA0SMq1XHUwMDFmiJ9pXGJh0DX8pZz+wZdcIrdcdTAwMWZlvXzY8TtcdTAwMTef9k5+bW1drG1dXHUwMDA0P2818/prTsrn309+7PDLd1x1MDAwM7355Yiz3Z1+c933mt+rXHUwMDFi11+WXHUwMDEzS5nfn5Z/KZCMwchIQjhcdTAwMDeKmlx1MDAxYUU3XHUwMDFlZnzT7Kvewefq9aU52D6W3+YsrmZcZiaPg0hcdTAwMWGDuLA/lEpmtKFcdTAwMTlcdTAwMTBcdMKQ1pJcdTAwMTgjtNRcdTAwMTBNXHUwMDE2pqwkzUOIilx1MDAxY4KwkFQxiHHzR9A8nTHpdL9cdTAwMTNcdTAwMWR6d7bdKc6c3XTaXmuQ6bfYS8HSPSdoeJ10W4YuvDMmd525+1PLa1g/Xm259ayDR1x1MDAxZWQjo8uRn/rlVXi7XHUwMDAzj1x1MDAwYr7Wxn+FXHUwMDFmePBmp3WUteRJ2GKiXHUwMDEwW1x1MDAwNGSaUcDabGpwXHLCzs7hQe1qw2l+3dq9lp2Dm7D5gplcdTAwMGJ+XCK6IERRK4K0XHLLXFxk0Fx1MDAwNalcdTAwMWJcdTAwMDLgYSpccmRcdTAwMTOMXHUwMDFhtTB4pXKicngpSFxcXGZ+YVXmqeb2TXV7/etGh31yu+etSPw8mmskSdTSosH73anVvE5jXHUwMDE50PtgytNCI1bjZ1x1MDAxZuBcdTAwMGLaXHUwMDFkXHUwMDEyXFwpplx1MDAwZo2Tdcayo1cwVVwiMFx1MDAxOSdIvZDA5Fx1MDAxM1x1MDAwNCZNvW9cYl9gXHUwMDFiyLYgUL98cFxcXHUwMDE0vlhcdTAwMGVf63BcdTAwMTmsWoGmciaDzExcdTAwMDZZXHUwMDE1vuVcdTAwMDYlMGt7tVo628pcIu2xJGlcdTAwMWN8XHUwMDE5O0tcdTAwMTFYnuhpXlx1MDAwNEPCpWCCSjJ9tWPnR6O5dbm5b1x1MDAwNs2rk8b+USDO+rdcdTAwMDU4rFx1MDAwNn5cdTAwMThWmk5UbVx1MDAxNmFxvNC2OJlcdTAwMWFcdTAwMTc9tFx1MDAxMYRgI1xyIYxnsEipRJBZXHRNjZREM1lcXFx1MDAwMpyi6FGKxcdcdTAwMGJcdTAwMWaGcCzzwVVcYlxyvVx1MDAwNVx1MDAwMvZlY+vF55/O2t2nauv48MugvXf16cfBXHUwMDE1ny62lmZ/e9v9XHLj17c3RXBUOe+37rw9df3HYnYpwIbvn1x1MDAwNC4qcVx1MDAxMbooMUopkGVTg6u8pWdcdTAwMDZXYSVl7uBcdTAwMTJGIU4heGCNtVx1MDAwMcfO5oBcdTAwMTSucmhcdGIgXHUwMDAzNGRxOSAjiHJcYqZcdTAwMWNiKSeQdubxxTRcdTAwMTJcdTAwMDRMZFx1MDAxNFBuMOXjKCNYWv+RqXxyapjFpr50XHUwMDEwXGYjJ4jWvE6s1D6mkPYwcjSMPj0xOOmJdXzt4cPTtmxf3ahcdTAwMTM/XHUwMDA1N1xim9Ve7Fx1MDAwMlxiY26w4Fx1MDAxYfqCXHUwMDFhbFL3NJxu3ESIKMNcdTAwMTVcdTAwMTAp3Mah3+/vXHUwMDE4XHUwMDAxftXt1Fx1MDAxZTepPJikTKpgRDU3jFxi8DBwMS1VziiKjFx1MDAwMXMgXHSC+5SUQuWMajlhtO63257Ved99r1x1MDAxM403cdyWnyzam66Tk8fwo9LXxmmha5+YpdPkr5VcdTAwMDQy8YfR33+/n3h3oSvbo5Lz4uRx79L/z6rZ7buK+Ixhrlx1MDAxOZMzpNzlLvdcInz2RN1O49ZXoFx1MDAxNojmNFW5XHUwMDE51rQ4XHUwMDAyNU9cdTAwMTlIXHUwMDA1cDSlx+yaY00rXHT1JUm35sImXHUwMDEwL1dcdTAwMTR+ti5YQPyeJSfI59xrflBLS/s/l3LfW/I0OWL5sVxivuCwilE9vdQv12fzXHUwMDE505k7cpUmXGJcdTAwMTRcYoGMmyvKx1Q+5DqIgFx1MDAxMiFcdTAwMDRcdTAwMTBDXHUwMDE0Xdw4P1x1MDAwNCwhpVxyVMDdXFzTXHTFaWB4XHUwMDA29zCOXHUwMDE52GnrezkpXCKYYoxi/lx1MDAwNGQvg1x1MDAxNFx1MDAxOVx1MDAwZp5zUFx1MDAwNMNYr5GWXHUwMDE0Q8tcdTAwMTgsQX/wtERJaVx1MDAwNoWNVkpyyZTQkHK9akFQ6FH2qOSdaUZFUMwpjJeMXHUwMDE0K2MoVnz6Ql75JJIlZlx1MDAxNWh6XHUwMDAzv5RA2/Ox/IZhXHUwMDA0XHQ7tFx1MDAxM8EgkPi4XfNkXHUwMDE1XHTv4LFcdTAwMTZcdTAwMDbP5olcZs7QitJcdTAwMTRslWCtJFxc5jNcdTAwMWNcdTAwMDHGgqh5ylx1MDAxONjrpJXyWWsp0oCO5EZcdTAwMDCSKFFcdTAwMDI4I2m8JPl55SxS5EH2yPvOjCxcdTAwMTJrplx0JKJTXHUwMDEyepxDpGBcdTAwMDY4LTUp6zFcdTAwMGVZ29w4ucWuUP0tsbl95ZxXfTlY9nFyYFxyXHUwMDA09IHt1Cqp+Vx1MDAxOIdAXHUwMDFlh1xmqFx1MDAxMiPhXHUwMDA2I1x1MDAwNF/kXHUwMDE0RESkMpPLj1x1MDAwNI1XJlx1MDAxZkhDXHUwMDEzhTGHZPOtkUb2YfPFcubaXFyBPKFcdTAwMTftMeq/OVx1MDAwMVfIYuBqJY2tqE9fXHUwMDEwYNu/blx1MDAwZqtcdTAwMDbj3unNSbOmyMHaN7b0wIWmVpBdXHUwMDFiqZSwXHUwMDAzXGJcdTAwMTngcq3tbF3OoNlcdTAwMDEgIDdcdTAwMTdcdTAwMDZcXFx1MDAwZbJXXHUwMDEwkVx1MDAxZVx1MDAxOVx1MDAxOOFcdTAwMTYjXHUwMDE2K8NcdMDlXHUwMDAwXFyRKVP8XHUwMDFmuH9cdTAwMTC4+V60RyXpwHlcdPd0oT1cdTAwMDddyCVcdTAwMTQ1eHrofu7XpXvxq7Le9DaOf7Y/fz6+/LGx9NBVXHUwMDE0KfBHyqmiXHUwMDEyXHUwMDA0XVx1MDAwNrqMcWQ4t9OIXHUwMDE1aFx1MDAxZbLIaoBRRnHGXGJjoKxcZp9Q14PcXHUwMDAxXHSMNYeeseV3kUoj7mc8cylcdTAwMTSh4pVcdTAwMDK5SJz7Z8e7J25w1T350Vx1MDAxZmzc7eytNUNZMFxugDlcdTAwMTaMQdBRiimuVaoqnoxNUFx1MDAwMklcdTAwMWFcdTAwMTNwp5mQ879cdTAwMTiFLFTHV4pdKr6c96Z50VxuKFg2fvqBV5TClNmumZpWSO07ub08qG5+ObxsrK/fnlx1MDAwN+J4b/lphSBbeLKDUYaQ8Wk9SiNlsJ3xg+2s2MVccndcdTAwMTJkbbAlXHUwMDA3zOFl6XZcdTAwMWbRiiHgKKBbiKCaKZErXHUwMDA2KEq4XHUwMDA2J3mlsv7ZpFx1MDAwMjKaQKbLJFx1MDAwNVxmYYEpyXOKRra4Q5WhmFx1MDAxMSHMXHUwMDFi5ZRid7LHuCPNyCdFI466eFx1MDAwMoWCfrEj0dOLlPJeX1Y24Vx1MDAxY0lNtDBKXGLI/rOFXHUwMDAxxlx1MDAwNbI+Z9dCcWpSq3bmXlx1MDAxN0hcdTAwMWVdMthoQDpcdTAwMTKhzVx1MDAwYk/wLY9cdTAwMTNcdTAwMTlPm2lcdTAwMTJSuawtfe6D2y+C5Z40iLk17LWUXHUwMDAz/KlBzHtLSlx0oajiYEwhIVx1MDAxMMUhrIKomX698vlp4/i6urfryruoe0hMv1x1MDAxMX3dWnZG4DZtXHUwMDExTFPQd5ZcdTAwMDGzaVx1MDAwYsVcdTAwMDYkrq38gPbA6ULiXHUwMDAyhlx1MDAxYqC9gfCp4cD8lEzQXHUwMDE3QE9cdTAwMTiDXHUwMDEw1Vx1MDAwMi5TjXW+XHUwMDAwXHUwMDAxeZfAdsnz65RcdTAwMThvrVx1MDAwMFHcq/ao5Dt0xkhfXHUwMDA07LQ8XHUwMDFkn5sgjLaZw/TDiL1m55Z4XHUwMDBl+bV5Snb5trk46vhF85CXXHUwMDA215pcdESltPPqMNCcyuZcclx1MDAxNEtEQIcyw5nGgJrFVVx1MDAxMjHSRmktXHUwMDA1SCwstZ5Uj+B2XHUwMDE3XHUwMDAyUIFK2GXimuSHXHUwMDExJaRcdTAwMGWcavXmZie8VlxcXHUwMDE3dao9Krn+nFx1MDAxMdbFXHUwMDA1gZJcdTAwMTVcdTAwMDbUzlx1MDAxMOCQXHUwMDExT4/s5vb15UG3Ujlccu+2XHUwMDBl1ytsoPYrRZOgl1x1MDAwNtmSaGR3XHUwMDE2XHUwMDAxXHUwMDE1z5igKrtMj1x1MDAxOMu5XHUwMDE4Wt72S3pa8twrXHUwMDAyTFwiXHUwMDEwXGbA21x1MDAwNOjFpEZdUyN8QENcdTAwMWOua8ZcdTAwMTRcdTAwMDVcdJFbZc4451pcdTAwMTP6SuN1UUngSNd37i62XHUwMDFiP9yzjbWdwDn9eXjzs6DOSOxcXFx1MDAwZYhJXHUwMDEyMmHDSXqyTVJntHOMsVx1MDAxMZgzm1x1MDAxMt3f8NaKXHUwMDAylUKXXHUwMDFhXs1509xoJb1mLbdxXHUwMDExwVSotPZ9jFakS25PXHUwMDBm1k7Xe7+O62FwuH6MXbr0tGIoMsDXkFx1MDAwYmBlXGZcdTAwMWKjXHUwMDE1bZBcItb9qFLMyOLlg8+mXHUwMDE1amfcXHUwMDAxfeF4jDNN+JlcdTAwMTFIwlx0RJ94sJHp9GLke8VAiMBcdTAwMDJj/r/KLFx1MDAxOMHPl1RBXG5cdTAwMGKQsqFxwvxcIoKInZwllMKEQ7fn1zG8XHJmKXYqe1Qm+NOM1FJUclQlK1wiXHK1vjLDfMbyvl9WXrFNXHUwMDBmylx1MDAxZNI7q81otuRIlERSc6OJsfNcdTAwMWQoW1x1MDAxY7GI5NFlu1xuQMakiZb6XHS88ZyiY7lcdTAwMTTN+NpMRcfyWFT63Fx1MDAwN8dfXHUwMDA02z2p6Dh03pRcdTAwMDP8qZrj0JCnaVxyXjxVgjOioEVnmJ1YvqvR0s5wZlxiYo3dQ4RplopLw0lOQiFiXGaVIDMoXHUwMDExophcdTAwMTBcdTAwMThnXHUwMDBlf1ZlXHUwMDAyXHUwMDEzO81cdTAwMTAzXGJcdTAwMGbAw8JM2nfEqlwiXHUwMDAxYcFcdTAwMTZBIUayXFxcbmOkgUeItzdZsUiClO8tMFx1MDAxMlx1MDAxN1x1MDAxMlx1MDAxOWm1uuTSjk7zlJhP0lx1MDAxZrtcdTAwMDCUXHUwMDAw30L+w6lcdTAwMDHflzlcdTAwMDXymnRGpcSn4ut5d5pRaFx1MDAxNOcwqmRcdTAwMDcjIZmgRE5PLOWb3CwtsVDEscF2koFcdTAwMTFybPKkXHUwMDA0satBXGZcdTAwMTOFXHUwMDAxPCUrKZ/PK5rH0/ol1liI9JKqpDKikVx1MDAwNPlHIKM1oI7YhFx1MDAwNVlgpmBY0SdswbBcZrxSuD6idG+sLDdASlwiuYYkRoNOJGpCZUTYtTKScFx1MDAwNUhcdTAwMTOg3lx1MDAxZiqNby2BqVx1MDAxNDuVPfLuNCOtlG7rYkjhts5cdTAwMTRDjMaE0umzmIprjs+jy7NQRc2Ts1x1MDAwZebru79cdTAwMGVcbqhlubZ10Vx1MDAxONJlu1kpt9BMVyaG27pcdTAwMThky3eGXHRNKFBNMcM8f1tcdTAwMTeCXHUwMDE4J0VcdTAwMGIrqKCIMUj9qWRcdTAwMDVcdTAwMTO1IeBcdTAwMWFcdTAwMGXukrTEsq/jLs1yXHUwMDE2ur+LgoQwtfXmtPu7vLt/6KrT7Vx1MDAxZUbwyFx1MDAxMSlCW3u1++wneczqjeferk3Y1bhcdTAwMWVcdTAwMWbW5LhcdTAwMTEsQlxc29L//H73+7/nXHUwMDBiXHUwMDAzXCIifQ== MarginPaddingContent areaBorderHeightWidth"},{"location":"guide/styles/#width-and-height","title":"Width and height","text":"Setting the width restricts the number of columns used by a widget, and setting the height restricts the number of rows. Let's look at an example which sets both dimensions.
dimensions01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.height = 10\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
This code produces the following result.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path.
Note how the text wraps in the widget, and is cropped because it doesn't fit in the space provided.
"},{"location":"guide/styles/#auto-dimensions","title":"Auto dimensions","text":"In practice, we generally want the size of a widget to adapt to its content, which we can do by setting a dimension to \"auto\"
.
Let's set the height to auto and see what happens.
dimensions02.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.height = \"auto\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
If you run this you will see the height of the widget now grows to accommodate the full text:
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0 will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
"},{"location":"guide/styles/#units","title":"Units","text":"Textual offers a few different units which allow you to specify dimensions relative to the screen or container. Relative units can better make use of available space if the user resizes the terminal.
%
) symbol and will set a dimension to a proportion of the widget's parent size. For instance, setting width to \"50%\"
will cause a widget to be half the width of its parent.vw
unit sets a dimension to a percentage of the terminal width, and vh
sets a dimension to a percentage of the terminal height.w
unit sets a dimension to a percentage of the available width (which may be smaller than the terminal size if the widget is within another widget).h
unit sets a dimension to a percentage of the available height.The following example demonstrates applying percentage units:
dimensions03.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.height = \"80%\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
With the width set to \"50%\"
and the height set to \"80%\"
, the widget will keep those relative dimensions when resizing the terminal window:
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0 me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0 will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0 will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0 total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0 the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0 its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
"},{"location":"guide/styles/#fr-units","title":"FR units","text":"Percentage units can be problematic for some relative values. For instance, if we want to divide the screen into thirds, we would have to set a dimension to 33.3333333333%
which is awkward. Textual supports fr
units which are often better than percentage-based units for these situations.
When specifying fr
units for a given dimension, Textual will divide the available space by the sum of the fr
units on that dimension. That space will then be divided amongst the widgets as a proportion of their individual fr
values.
Let's look at an example. We will create two widgets, one with a height of \"2fr\"
and one with a height of \"1fr\"
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass DimensionsApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.height = \"2fr\"\nself.widget2.styles.height = \"1fr\"\nif __name__ == \"__main__\":\napp = DimensionsApp()\napp.run()\n
The total fr
units for height is 3. The first widget will have a screen height of two thirds because its height style is set to 2fr
. The second widget's height style is 1fr
so its screen height will be one third. Here's what that looks like.
DimensionsApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
"},{"location":"guide/styles/#maximum-and-minimums","title":"Maximum and minimums","text":"The same units may also be used to set limits on a dimension. The following styles set minimum and maximum sizes and can accept any of the values used in width and height.
Padding adds space around your content which can aid readability. Setting padding to an integer will add that number additional rows and columns around the content area. The following example sets padding to 2:
padding01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.padding = 2\nif __name__ == \"__main__\":\napp = PaddingApp()\napp.run()\n
Notice the additional space around the text:
PaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0 that\u00a0brings\u00a0total\u00a0 obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past, I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0 to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0 there\u00a0will\u00a0be\u00a0nothing.\u00a0 Only\u00a0I\u00a0will\u00a0remain.
You can also set padding to a tuple of two integers which will apply padding to the top/bottom and left/right edges. The following example sets padding to (2, 4)
which adds two rows to the top and bottom of the widget, and 4 columns to the left and right of the widget.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"purple\"\nself.widget.styles.width = 30\nself.widget.styles.padding = (2, 4)\nif __name__ == \"__main__\":\napp = PaddingApp()\napp.run()\n
Compare the output of this example to the previous example:
PaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0 mind-killer. Fear\u00a0is\u00a0the\u00a0 little-death\u00a0that\u00a0 brings\u00a0total\u00a0 obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0 pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0 past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0 inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0 path. Where\u00a0the\u00a0fear\u00a0has\u00a0 gone\u00a0there\u00a0will\u00a0be\u00a0 nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
You can also set padding to a tuple of four values which applies padding to each edge individually. The first value is the padding for the top of the widget, followed by the right of the widget, then bottom, then left.
"},{"location":"guide/styles/#border","title":"Border","text":"The border style draws a border around a widget. To add a border set styles.border
to a tuple of two values. The first value is the border type, which should be a string. The second value is the border color which will accept any value that works with color and background.
The following example adds a border around a widget:
border01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BorderApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Label(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.border = (\"heavy\", \"yellow\")\nif __name__ == \"__main__\":\napp = BorderApp()\napp.run()\n
Here is the result:
BorderApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2503nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
There are many other border types. Run the following from the command prompt to preview them.
textual borders\n
"},{"location":"guide/styles/#title-alignment","title":"Title alignment","text":"Widgets have two attributes, border_title
and border_subtitle
which (if set) will be displayed within the border. The border_title
attribute is displayed in the top border, and border_subtitle
is displayed in the bottom border.
There are two styles to set the alignment of these border labels, which may be set to \"left\", \"right\", or \"center\".
border-title-align
sets the alignment of the title, which defaults to \"left\".border-subtitle-align
sets the alignment of the subtitle, which defaults to \"right\".The following example sets both titles and changes the alignment of the title (top) to \"center\".
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BorderTitleApp(App[None]):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.border = (\"heavy\", \"yellow\")\nself.widget.border_title = \"Litany Against Fear\"\nself.widget.border_subtitle = \"by Frank Herbert, in \u201cDune\u201d\"\nself.widget.styles.border_title_align = \"center\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Note the addition of the titles and their alignments:
BorderTitleApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Litany\u00a0Against\u00a0Fear\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2503nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0by\u00a0Frank\u00a0Herbert,\u00a0in\u00a0\u201cDune\u201d\u00a0\u2501\u251b
"},{"location":"guide/styles/#outline","title":"Outline","text":"Outline is similar to border and is set in the same way. The difference is that outline will not change the size of the widget, and may overlap the content area. The following example sets an outline on a widget:
outline01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineApp(App):\ndef compose(self) -> ComposeResult:\nself.widget = Static(TEXT)\nyield self.widget\ndef on_mount(self) -> None:\nself.widget.styles.background = \"darkblue\"\nself.widget.styles.width = \"50%\"\nself.widget.styles.outline = (\"heavy\", \"yellow\")\nif __name__ == \"__main__\":\napp = OutlineApp()\napp.run()\n
Notice how the outline overlaps the text in the widget.
OutlineApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503ear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0\u2503 \u2503otal\u00a0obliteration.\u2503 \u2503\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u2503 \u2503hrough\u00a0me.\u2503 \u2503nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0\u2503 \u2503he\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Outline can be useful to emphasize a widget, but be mindful that it may obscure your content.
"},{"location":"guide/styles/#box-sizing","title":"Box sizing","text":"When you set padding or border it reduces the size of the widget's content area. In other words, setting padding or border won't change the width or height of the widget.
This is generally desirable when you arrange things on screen as you can add border or padding without breaking your layout. Occasionally though you may want to keep the size of the content area constant and grow the size of the widget to fit padding and border. The box-sizing style allows you to switch between these two modes.
If you set box_sizing
to \"content-box\"
then space required for padding and border will be added to the widget dimensions. The default value of box_sizing
is \"border-box\"
. Compare the box model diagram for content-box
to the to the box model for border-box
.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGlT28pcdTAwMTL9nl9Bcb/Gysz0bJ2qV6/Yl1x1MDAxMCAsXHTJq1spYVx1MDAwYizwXHUwMDE2W8bArfz31yNcdTAwMTZJliWwMY6TukpCsEaWRjN9Tp/uWf55s7CwXHUwMDE43XSCxfdcdTAwMGKLwXXVb4S1rj9YfOvOX1x1MDAwNd1e2G5RkYg/99r9bjW+slx1MDAxZUWd3vt375p+9zKIOlxyv1x1MDAxYXhXYa/vN3pRv1x1MDAxNra9arv5LoyCZu+/7ueu31xm/tNpN2tR10tcdTAwMWVSXHRqYdTu3j0raFx1MDAwNM2gXHUwMDE19eju/6PPXHUwMDBiXHUwMDBi/8Q/U7XrXHUwMDA21chvnTeC+Fx1MDAwYnFRUkGpzPDZ3XYrrqxUXFxLieyxPOyt0tOioEaFZ1TjIClxp1x1MDAxNtVJX+2Y48anXHUwMDBmK8tcdTAwMDfXjWZ40lo+Tlx1MDAxZXpcdTAwMTY2XHUwMDFhh9FN464h/Gq9301VqVx1MDAxN3Xbl8GXsFx1MDAxNtWpnFx1MDAwZp1//F6tXHUwMDFkuVxuPFx1MDAxNnfb/fN6K+j1Ml9qd/xqXHUwMDE43bhzLKn/XSO8X0jOXFzTp1xuXGJcdTAwMGa1NVxcXHUwMDFhRW9rRNIg7lx1MDAwNkJ5Qlx1MDAxYtDKMlx1MDAwYkYrXHUwMDA1Q1VbaTeoL6hqf7H4SOp26lcvz6mCrdrjNVHXb/U6fpd6LLlucP/SiqFcdTAwMDfGXG4trFx1MDAwMouQvE89XGLP65HrXHUwMDEzwT1mXHUwMDE1glF3T9NJbYK4Y1x1MDAwNENtuNaQvKWrQ2erXHUwMDE2m8jfw1xyW/e7nfv2W+y5XHUwMDBmqfq7qq9ccttX2sZSnf/j+uvG9tnJPr+48LdVyGorXHUwMDFmLlx1MDAwZVx1MDAxZe+VMUi/221cdTAwMGZcdTAwMTZcdTAwMWZLft7/llSt36n5d2bmXkRIxplcdTAwMTQqeaFG2Lqkwla/0UjOtauXiWXGZ3++nVx1MDAwMFx1MDAxMFx1MDAxYaFcdTAwMTBcdTAwMTCAXHUwMDFjJTPPR0TQ6e5o4P5t31x1MDAxZixvfP+6Jzu6WYCIXpvQPTZcdTAwMWWGvvVcdTAwMTRcdTAwMWPgKTSA8VxiXHUwMDAymlx1MDAxYq1RSc6MzKCBc/C44Fx1MDAxYYAjWSAyVYhcdTAwMDZ1XHUwMDA2taosRcNfsqqDM5VHXHUwMDAyKONZpSTBUuVBIFx1MDAxNHpcdTAwMDJcdLNMXHUwMDAyMDKMXHUwMDFjXGK4Je5ijIOdLVxiqns7X+snm8e1XHUwMDFixFZ/l3dMvVx1MDAxNv6GIJBWXHUwMDE3gYBcYlxuXGZcIpfPXHUwMDA2XHUwMDAxRqfB7sdGY1x1MDAxYsz+5kr9495BuLkxmVtcdTAwMTCFbsHv1afrXHUwMDE2XHUwMDEwPCEtKs6UloZZkcWBXHUwMDA2jyhcdTAwMThAKaG4JNdQiINAXHUwMDFi81x1MDAxMq+Q7vNHXGJwaYdtXHUwMDFlXHUwMDA01YLqNWOT34K1o5Poy97H9kmwXHUwMDFmNcLLz8fV76NNPlxurqOUxb8tu+3h0fopu8L95tGn6HJ5aXn1eFuL5yGp9L5Tr27m6rfPfeCvw32mnmmlamxcdTAwMTHkpSaq1TiG37tcdTAwMTZcdTAwMWSz/vXHSudQXpyurP6wrZrembJcdTAwMTJcdTAwMWPT8z2NeOdWUFgrNaAwRmJcdTAwMDbxgNZcdTAwMDPpXHUwMDA0IEeUWlxmU9H0ZKBcdTAwMTZ5vFx1MDAwYjVcZndcdTAwMGVcbiW5YvNcbjpvmsaYdHq7XHUwMDE1XHUwMDFkhrdBrFEzZ9f9Zti4yfRbbKVU049+9zxspduyXHUwMDE30DOD2Mdnrl5qhOfOjlx1MDAxN1x1MDAxYsFZ1sCjkFx1MDAwMqfH4qidevMqPd2n23W3asNv0e6G9GS/cZStyUTYXHUwMDAyI1xusSWsZEKq57vTpUF978dutHVzc1xm6+2br6dflr75M4yy2ITgXCLhiNJyi1JcdTAwMDCyrDtcdTAwMDVhXHR65G+F0lrSP/Nq6EpcdNoydGkjKVx1MDAxYzMptz9cdTAwMTNvurRdw9Wr3dMts1x1MDAwNlx1MDAxZjuDXHK5PMDOL1x1MDAxM5Avw+6+X6uFrfN5XHUwMDAw70NVJvOMglx1MDAwZp99QC93XHUwMDAyUY+RXCIpl1x1MDAxZvNcbl5yfSVaWFxi7YlcdTAwMTlpYTlCXHUwMDBii9Tz7tHLhdRcbq36g3wj5PC1QsVUq1x1MDAwNWorfzTIcDTIqvStoFtcdTAwMDKzZlirpVx1MDAwM8Ms0p5cbuiGwZepZylcdTAwMDLLY1IsXHUwMDE0qFxcOM/CpFx1MDAxNM9cdTAwMDZitX/bWPmxXHUwMDE0+T40XHUwMDBlj1pcdTAwMWbPw5NOrVx1MDAwMIjVbrvXq9T9qFovXHUwMDAyoyxcdTAwMDLj1FWqS9BQkIekQJXT5CqDRc6ZR1wilYFVxlpGTqxcdTAwMTCLz8jPlGLx6Vx1MDAxY1xyXHUwMDEyXHUwMDE56Lxv1Vx1MDAxNtEgn3F+ctnfq96eXW037GF7XHUwMDBm+X57U/k4hYCyxj5/qHZPbtesXHUwMDFhyNslXFw7XHK7dj5TPnfPXHUwMDFmXHUwMDA1rZLgT3AgzjdqXGYnV97UY2OrMOkzdWxxMmlkoLlLsFx1MDAxYiVTuVx1MDAxNHdcdTAwMDPJOVx1MDAxNVtcdTAwMGLUXHUwMDFhWnElhlE/PZVcbpw8LpJcdTAwMWIzXHUwMDFjJLd6xFBcdTAwMDBYj0JRbUFw61x1MDAwNLVcdTAwMWPGXHUwMDE5xalWKlx1MDAxNJMgLa7qrL1gL/K70XLYiqXa+1x1MDAxNNjIXHUwMDEzVvtxt3qMSWRKWmpdgVxmXHUwMDEznC2e+524kz1uUFx1MDAxYdTuMomJOFh4XHUwMDFjK7vzYitwc7Mp+ss7XHUwMDFi+uhi/Zj8WCNYekDnI+ZcdTAwMTeDVq20Slx1MDAxNeZRXGKHwFx1MDAxNZBp0N8kdnmslPBcdTAwMTCpOiCQrjNaK1NUqdFuKVepht+LVtrNZuiU3n47bEXDTVx1MDAxY7flklx1MDAwM3w98HP6mF4qXTbMXGZcdTAwMWR3xyyjJr8tJKCJPzz+/vfbkVdcdTAwMTeasjsqOStObvcm/f+4op2DKkxhc1x0zKF7XGZKXHUwMDFibSwzpbTJpLvlnlx1MDAxMZI4nNpfMMwltdBcdTAwMTOCsKKEJTzxYrXw4qRW0lx1MDAxYiVht+JEwZKzXHUwMDE5R90v0Fx1MDAwNq/gw8eJXG7yUfdyu1tLi/tfXHUwMDE3dN/XZDJJwpUsnJjA3aBcdTAwMWaF3qlBqqfwW67SpjNcdTAwMDY1deyCXHUwMDE0XHUwMDFlkMKnuJskPcEziVx1MDAwMe/kXGLzNLNGXHUwMDEzsIFcdTAwMWPecEJgeuAlXHUwMDE3p7R2ro34W1oxXCJBTSxPjKtBMpD0x/JcXFAuXGYyhlx1MDAwMn9TNTLsP59cdTAwMTJcdTAwMDWGoTVGS01q0VKclFx1MDAxM1x1MDAwNdazmjqOSJm5aTYyrWX+dE1QaFDuqORtaUxRUMwqXHUwMDE0y1x1MDAxNFx1MDAwNjqMXHUwMDE0PSGNP19cdTAwMTWUz3mZY1bhhFxyXHUwMDE0SiphWHaCh+SS7NJYRnJNkVx1MDAwNac4dvqsotHB06lngopMJf3TtGKssFx1MDAwNFx1MDAxMUZcdTAwMTdwqfNRXHUwMDBlt4bCXHUwMDAxnCRT/9vxXG7ziClcdTAwMTSBQ3CjiFaS5khcdTAwMDKgXCJcdTAwMWFcdTAwMTk9O+83p5FcIlx1MDAwYnJH3nbGpJFYNo1gXHUwMDExhOJMpDUomLYqueIpXHUwMDEy0cd0Tn7b3T9cdTAwMWaovY3o1lZcdTAwMDbt7mQkMruxcsmUZ610Q+HkwFxmXHUwMDFmykJq4Vx1MDAxMblQkVx1MDAxNKhcZvLXXHUwMDFizpPoWdTU4kZcbklcdTAwMDYgR1xm7zGPWSlcdTAwMDGsJDVlNaRH9Vx1MDAxZcSJdrGRSc0km1x0iVx1MDAxMM+mXHUwMDA2l16DRLI3my62M2VTXHUwMDA1dnGvuqMyokOnXHUwMDA0bVxyavjsI7RcdTAwMTGIbUhrPz/qODw+Xr3+hD++NU5cdTAwMDbtlVUxOPig+nNcdTAwMGZtLjwmjFFcdTAwMWFJXHUwMDA0gM7mQIH0gbGK2oFcdTAwMTSEpk+vlzKQpJBcdTAwMTVX6Vx1MDAxMYRcdTAwMTSkIVx1MDAxNo9mxLxcdTAwMThO6Fx1MDAxMnbWUFx1MDAxNoCQxDz/QvnhyPeiOypJXHUwMDA3Tkvbo8XhsylpT0EygHq+tF/fXGYvwP9yeHKw9v3G9PR688BuzT10XHUwMDAxPDBAYVxmMi7lkFems1x1MDAxZfltyzl55Hio/lx1MDAxNaW9Mlx1MDAwNlx1MDAxMJVcdTAwMDYg8YVyRPaPOF5cdTAwMTC9W+DGpfRVzidzelx1MDAwN6TXkDNW9lxc8pQpTWX8XCIjxPXF8vbpkeZXl37YPFx1MDAxZfRcdTAwMWHLXHUwMDExT09cdTAwMWRNZ1x1MDAxMShcdTAwMDJcdTAwMTbIKFZcdTAwMDOOViPafFx1MDAxZYFTME2UJ8m6XHUwMDE1oHrAU8FcYsZrksirav1KsU3FxTlzmlx1MDAxNq9wLorlvlx1MDAxMoxcdTAwMTGNyedrgsp1/fvR5dbFWuvbh481pj/rw09zPzVWUiylwFquSbNcIiP1lWVcdTAwMTZccp5QXHUwMDAy3ChcdTAwMGXxrFx1MDAxNq+3Ror8XHUwMDA2Z4JcZl4yN8/BwKj5fORzgOojjXGLXHUwMDE1VC5nXHUwMDAwZCDaoJpgqvyLiIXE6lxcXHUwMDEwXHUwMDBi8zhDNMqNgVx0hdqkoPSYndQgUbhoXHUwMDE2uFL4h7JKiTm5Y9iQxuSUosFJa1xus5DWcCZcdTAwMTlcdTAwMWIjXHRZ3uvzSihMeU5cdTAwMTRKaS1YJYaUikBPXHUwMDEzj1x1MDAxM4ujdvmU11MqKlx1MDAwMWTJuCRIt1x1MDAxYc6KXHRiipeMS5b7ioypjTVnqVxc3Jbe98HuS2hutuOdm3e9ljKAXzXeeV+TUkYoyjtcdTAwMTBcdTAwMWZcdTAwMTdSQiwySM2PkXhY2j483Fplllx1MDAxZKxcdTAwMWT8WGnsbKnjrVx0XHUwMDA3JmbHXHTIPa1Rc5JcdTAwMTkk45TKJlx1MDAxZVB6XHUwMDFjhdVCXHUwMDE40NrK11t/wz3rVsBcdTAwMDI9xFxyQOmU/EtcdTAwMTSG8khcdTAwMDVZXHUwMDA0i0ZKrUx+XHQquVxmdGNcdTAwMTMzjl6IU8FMgr4/PFx1MDAwZlEp7ta4ON+jYzr7XCJoW1W4+EdwXHUwMDA2XGJcIj2g/uTKOlx1MDAxZV2cXHUwMDFlX0U7387WQrH+5Wrtw+lg3pFNze2RXHUwMDE3Z8LSoVx1MDAxZIqz0HbJIGFcdTAwMTW5e+Pmmr4mtEFow9yQXHUwMDExcVxijExLMM+6OaBWaYoxOeaCh3heoeapqGI2uDYgXne88bfFdUGfxqW57lx1MDAxY1x1MDAxM9UlXHUwMDEzlFThsiDBlFx1MDAwMW7ZXHUwMDE4XHUwMDEzXGZbS2ebn7+sRlx1MDAxYqe3N7jx+Vx1MDAxY7aD02kvXGaa/nxp48JEQ3yqKebnILJcdTAwMTOUXGZRKvlrZrTlbjFcdTAwMWRcdTAwMGVVbHq4ttTLwjDDrTI8PTybXHUwMDFhMDBWulx1MDAwNFx1MDAxYVx1MDAwMFx1MDAxOIGc59bPc0GBl8suzTgrQO2X0lxi089cbny++bz27Wj95OKK0LpcdTAwMTNt3X5a2d0uSDdyQOGmlFx1MDAxOVCMJNaIqcxuzlx1MDAxOTlHaigmKTTmf2y6scik4sK8NU2NVqB4taFVaMmN4vPXXG53t0Cavf7tdme9YTe+Nb/uhLti3lnFLVx1MDAxNSbbc0pcZrQxmF1taKRHoolcdTAwMTlFP5hg/PXEgqWIg7ulZZLiLyPtiEQj97hcdTAwMTVcdTAwMWNccuNuzbKFXHUwMDE0wz2QilIxoGZMKsJcdTAwMWGchlp4MakwjyCkXGJJWjO00pr0eox7SqFWXHUwMDE0JFx1MDAwMJWhdpTMilx1MDAwN+X9p3FKoUG5o5KzpTEppSjZaIrXTYJbiWdcdTAwMTV//mSl8n6fU0LhXHUwMDE2PWDAOVx1MDAxMDzIv1x1MDAwZi3r0tTyUlPUrIxb7FaypdWLk42JPirbe4BcIiVyrVx1MDAwMFx1MDAxM0RcdTAwMTgvyTaWS9CMrY2VbSx3QqX3fTD8XHUwMDEypptttvHOeFNcdTAwMDbwq5KNd1x1MDAxNZlMY5BwK5RcdTAwMThWU4huxtjdq3yjprmdXHUwMDAzXHKeUlJQjOYmobCko+M5TtJlXCKZXHUwMDExQFElXHRcdTAwMTBePJxJN/Lli4YzyYJJSVx1MDAxMztcdItSKVx1MDAxY7U9ifBQkY7Qd9tUMsjHLmi15NJMsunjbzdcdLriVoFyt5OMdfNL2ajhS+2h5syCllTKpEzJ96yQXHUwMDE5vZdBToD8TjKjUmJUcXnensaUXHUwMDFhxdFLSnRcdTAwMGUxXHUwMDBiUiBFfTVcdTAwMDazlO+FM7fMXCI8a4lcdTAwMTZAo0tGXHLNnpTK01x1MDAxMq1LXHUwMDBmoYSS9ZYvJ1x1MDAxNivjdVx1MDAwMlx1MDAxNH0wRcH6qOjFepriSW7BXHUwMDA1llx1MDAxNvJrtrj7srRcdTAwMTNtJjtcdTAwMGa8Mpo9JDOaXkpcdTAwMTjLjeYmtfjoIVwioV5cIk/odlx1MDAwNFaotMoss8jQx+jNuHL08WfEL5Vio3JH3pzGpJXS7V+wZFx1MDAxNlx1MDAxNrqlMEaPseji5lxmev1PW8dw+vX6y/fT6569Piqa3jlX279I5lHrut0nQIGgTshcdTAwMGWluP15rZvFgi5nJYUuli4v3/+Fe6SUXGZcdTAwMTZtXHUwMDAwIzxcdTAwMDBhXHUwMDE41aFgpjZcdTAwMTlcdTAwMTEhkU8yU/vfnWCet1x1MDAxM8yb+5su+p3OYUS3fCRFauuwdlx1MDAxZv8kt1m8XG6DwfKInZrP4sNVOW5cdTAwMDSHkMC19D8/3/z8P1KdJ/cifQ== MarginPaddingContent areaBorderHeightWidth
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9vr+CYr/G2nn0vFJ161x1MDAxNs9cdTAwMTBcdTAwMTJcYpCsgdzaSim2sFx1MDAwNbJlZFx1MDAxMSBb+e+3RybWw5KwjU2c7CqpXHUwMDA0NLLU1vQ5fbrn8fdva2vr8f3AW3+5tu7dtdzAb0fu7fpcdTAwMGJ7/otcdTAwMTdccv2wj00s+X1cdTAwMTjeRK3kym5cdTAwMWNcdTAwMGaGL//4o+dGV148XGLclud88Yc3bjCMb9p+6LTC3lx1MDAxZn7s9Yb/tf9cdTAwMWW6Pe8/g7DXjiMnfUjDa/txXHUwMDE4jZ7lXHUwMDA1Xs/rx0O8+//w97W1v5N/M9ZFXit2+53ASz6QNKVcdTAwMDaCkMWzh2E/MVZIXHUwMDAyyihcdTAwMTi3+8NtfFrstbHxXHUwMDAyLfbSXHUwMDE2e2r9rue/8+NmcPL57IB5XHUwMDE3LvGvXHUwMDA2nfShXHUwMDE3flx1MDAxMLyP74PRi3Bb3ZsoY9IwjsIr79Rvx11sp4Xz48+1w9hcdTAwMWEwbo7Cm06371xyh7lcdTAwMGaFXHUwMDAzt+XH9/ZcdTAwMWMh47Ojl/ByLT1zh79x7nBKtNRCcVwiOOhxq/082GaiOVx1MDAxN1xmXHUwMDE4SC5cdTAwMGKGbYVcdTAwMDH2XHUwMDA0XHUwMDFh9jtJjtSyz27rqoPm9dvja+LI7Vx1MDAwZlx1MDAwN26E/ZVed/vwlVx1MDAwNTFcdTAwMGVXmkmmXHUwMDA114an36br+Z1ubI1h1CFaXHUwMDE4rsToaVx1MDAxOWu8pFskp5wrotm4wZoweN1O/OOv4lvtutHg4eWtXHUwMDBm7S9cdTAwMTnzreU7RefKOlim51xy8TZO7k7J3edBXHUwMDEzneY0eHfahPG9ct7oRlF4uz5u+fbwU2razaDtjnyMSslcdTAwMTlcdTAwMTB8+6D5uD3w+1fY2L9cdIL0XFzYukrdMjn77cVcdTAwMWNokIZVoYFqYjRcdTAwMTDK2NR4ODm97MZcdTAwMWLhtup97Hxccj5+eCvfvb2vwMMwRGzPjIbCp1x1MDAxZVx1MDAwM1x1MDAwM39cZlx1MDAwYpSgt1x1MDAwYsZcYjFcdTAwMDZ9jGb8yH5eoP9RySRBJzXEyKJdKVx1MDAxOMRcdTAwMDVvt6BcdTAwMTZcZr9DS3pcdTAwMTdiXHUwMDEyXGJcXChHXHUwMDBiXHUwMDAxUisxiVx1MDAwMSaMw4yRmiA0XHRcdTAwMTOTXHUwMDE44JxqLkHA82Kg9e7tefds78/2vTH9m0M6UN22/1x1MDAxM2JcdTAwMDA0VGKAUMJcdTAwMDDUXGYxYftcXJxcXDc9ev9n8+xEXHUwMDBm995cdTAwMDSXXHUwMDFmTueLXHSsXG5cdTAwMDVtd9hdbEyg6GTC8jBwXHLAqVB5XHUwMDFjKOooQzlcdTAwMDEjXGZjylTiwJNKPSUooH9PQoBmYtSDzzNcboDxKXv5szj94U5M3vpcdTAwMWRcdTAwMTF/jravTuLgXCLce/Wm3Olj7y7O+PyLutteXGZOXulX/a2d/YbYi09aXHUwMDAxOT6/m1x1MDAwZUtcdTAwMTX3zVnxYtov8uMgmrMzKyiVrkInSCRFaVx1MDAxNJlcdTAwMWGcXHUwMDA3XHUwMDFmVaB29oP30cX713s3w43b1n60YME2Y4iaQq8x6TCigVx1MDAxOcWI4kJCXHUwMDBlm/hcblx1MDAxY661tGKNXHUwMDBiMLxcdTAwMTKbT1x1MDAxNWySTUKTiVwiMlx1MDAwMcMkXG6yZUSjRTpj2ulhP37vf7XvnZHc2V2351x1MDAwN/e5fku81DqSXHUwMDFidfx+9lVcdTAwMGU9fOZIN+Wu3lxi/I714/XAu8g7eOxjfjNujsPMN2/h0128XfS6XfxcdTAwMTZh5OOT3eBD3pK5sMVcdTAwMTWtwlx1MDAxNkXppylcdTAwMTAxfeR7c35w+nZn+PntznHcUTuvOifdT3NGvrmyITJcdTAwMWa6qHFAXHUwMDAxXHUwMDEwXGZrhGNOkkeX4tIxkoNAiYgvg6uloSujMWrQRVxyXHUwMDEzXHUwMDEyreR68fCqXHUwMDBiUFFHXHUwMDFlNFx1MDAwM/r5LrzqkyDUp2Hv4vCHib2ngffIbbf9fmdcdTAwMTXQ+92U+UJj5itcdTAwMTfhK4iNXHUwMDE3POMmj8G3XtmsKnyphDrhisjF5OlZhCuUXGJXllx0xlx1MDAwZvhcdTAwMDVcblx1MDAxOMzR8X+d8MgnXHUwMDEwtoXNaNVcdTAwMWG+K7dcdTAwMWNmplx1MDAxY2Yt/JRcdTAwMTfVXHUwMDAwree329k0Lo+1x7KvXCL8cnbWYrA+gzSqXHUwMDEyiEQxRtksXHUwMDE5ZGfbb59s3HXce/ewXHUwMDE37t1cZm72gFRcdTAwMDCxXHUwMDE1hcNho+vGrW5cdTAwMTVcdTAwMTihXG6MXHUwMDBiXHUwMDE3qkkxXHUwMDA1OCgjXHUwMDA0l0LlXHUwMDBii4JqR6AyJJpQplxyiEooTlFLqYXi4/VcdTAwMTSDykaWXHUwMDA0V8JcdTAwMTlVmstnriY2L11/6+p6p8ng3WB/+PngVu2cPyn7XHUwMDFi3fe8/fVi19/a273YdU9PT10uP7Y+rWaFZvT8MmwhdqrAhXxcdTAwMGWU80xF7DFs1b/pmbFVWaFZOLZcdTAwMThcdTAwMDOHalx1MDAxNKuGIbKooDxcdTAwMDcujSrWgNFcdTAwMDQ1LJGELS9cdOTUYWAoXHUwMDAzRTlQLUuq9txcdTAwMDLdSI1g0txcdTAwMTBcdTAwMDZFnKH1wsBcXCliYulzx8Bh7Ebxpt9PpNrLXGbSMFx1MDAwZbZukk51XGJKXGZMXHUwMDEwNL5cXFsqTkG23nFcdTAwMDdJXHUwMDE3O1x1MDAxNN1VXHUwMDE5aS9cdTAwMDOTdtHaeExrXHUwMDE0w+KtY3bNdjY25T5rvTmmh/y8t/1cdTAwMWSaY8Cve/12rUlccuIwXHJcdTAwMDYlXHUwMDExJ1rjX6kmjGKOMWhcdTAwMGVnXHUwMDA2r1NSXG5VZVR5UJowKnCH8VbY6/lW51x1MDAxZIV+Py6+4uRdbli0dz13Qlx1MDAxZuOXyrZcdTAwMTVpYWDvmKfT9Ke1XHUwMDE0Mskv45//elF6daUn26Mx4cTp7X7L/j+raEfXXHUwMDE3xdNjsYCRx8pYPT2hlTvLs1x1MDAxMtqcwlx1MDAxZDRihitqhLKygFx1MDAxNataysGEXFxK7Fx1MDAxZMy6dbVaeHJVK33ZdXk31cJIhO3PI1xyllx1MDAxMMJnyVxuJvPuzTBqZ8X9j0u7XHUwMDFmLJlPkVBUlpVcYmbSKIXeP/2gab1IW8yA0cLRXHUwMDBiRDtSXHUwMDEwJE4g3GiTwedIjmAzZkVcdTAwMWP9TGnk0KWhXHUwMDE3g5yQ0lx1MDAwNjdkcNCspESNPM/xXHUwMDFhjpaCLVx1MDAxM9CJpNygnVx1MDAxNNRcdTAwMWMltVWQI8VcdTAwMDD6mCpQxGilJGDXXGKt02g0Vlx1MDAwNdrRkmEqRFxmkahUICtmfnVRUOlP9mhMutKMqqCaVLjkxdNjUsHwx7iZZWZS/fyUXHUwMDE1Jlx1MDAxNVx1MDAwMIZZu1accZpGkIRTXHUwMDE4qlWCnlx1MDAwYlRcdTAwMTCioLqU93ROkYZcdTAwMTJI1LN9Xiqcc6SiNNOIXHUwMDEwNFx1MDAwNclDTiQ50tY5XHUwMDA05mT/XHUwMDAwViHYb0YgNFx1MDAxOFVcdTAwMDJJJX1cdTAwMWJp/lNFXCLlk+h+clx1MDAxMqlyIHtMus6MJJJoplx1MDAxMlx1MDAwZdGiejyAII1cdTAwMGL0yOnHynfc+91B0CHNe9o6+nIs2NZ1pz9cdTAwMWaFPN9YOShw8ItcIj2D4sJQmtclglJcdTAwMDdTPWCUXCLhSrk8XVwiMN2XmOyXliCpU6xOfs8yXHUwMDEwPVxcSzPPdMbVJo38zVx1MDAxNovlXFzbQoFcXNKL9lx1MDAxOPffgoCLwKxcdTAwMDIuYlpcYok6e2rcfo1JTM8uL4/5K7155DF+pE/C1cetclx1MDAwNFKmRKFjU6g8bJUmXHUwMDBlalx1MDAwMi2JoFx1MDAxY4XZ8opcdTAwMDGA2ldQkVx1MDAxZFx1MDAxYlx1MDAxOKOWODyRhSWwTSZcdTAwMGZgXHUwMDE2NMeg3r+wTdpcdTAwMTZcbtvJXrRHI+3ARal2wyonp1FJmJI0XHUwMDFik1x1MDAxZkNu97T1seG7uumfiNthXHUwMDE0XGbgar9q4G91kGvszFHCXGZTzNhZNPmBXHTJXHUwMDE0akOpNGWYWGUnky9etVx1MDAxYrt4g9tcdTAwMDVcdTAwMDE2PYCSup5cdTAwMWShxORBg5DKlutFxpxcdTAwMTGUNbdF2J82XHUwMDAwl2f8XHUwMDA0iOBcXFx1MDAxYqFcdTAwMTRXoFVWi49HJ1BcdTAwMTNJ4Fx1MDAxOG64ydVcdTAwMDRyYl1cXEf87O71wac+XHUwMDA0fPPr8aG5fffpkbGJZXLIUmV8o9qlkuZJb1pcdTAwMTSvUKKqS4xIYVxcIK9MX2I87X9yNztcdTAwMWJcdTAwMDfXsblcdTAwMGVft44/6aG6Wn1iUY62Yy+Gg9CSZtZtJcSiqMOMXaYkUVx1MDAxNDC+vFx1MDAxMU9MXHUwMDE5gFx1MDAxM84lXHUwMDEwUJiz8ZJygKHoKlxuXHUwMDEzXHUwMDBlwTRXYrJcdTAwMTiAmFI8y0k/O62gMqaYvFwiWVwiLlxieiOdZFx1MDAxNe1IXHUwMDBlXHUwMDE4XHUwMDE4XGYjnFxuUVlH/EexSrU72aPoSDMyStWoo1bV1UXQXHUwMDA0I7NR009cdTAwMTUs769V51x1MDAxMyRcZoxvmml0VU15fqWX5MqRQlx1MDAwM1x1MDAwM8XxXmp583xTXHUwMDE41I43XHUwMDAyp9RcdTAwMGXaz05cdTAwMThPXHUwMDE5bqyPXHUwMDE0OVebaSZSvbStve93v19cdTAwMDbNzTWMuTfqtYxcdTAwMDP8qGHMXHUwMDA3S2pcdTAwMTmhquaQXHUwMDFioyzOq1wiRlx1MDAxYqpcdTAwMTlMP1x1MDAwZmEn2N30olx1MDAwZsHB9kWjcfl+b/NAdVc+d7GpXHUwMDBiQt1cYm2IMpLnU1x1MDAxN0GoYydvUmaFs1x1MDAxMMurOlx1MDAxMEdcdTAwMTLDkPIxg0LuZ7REYYBwXGJBMapcdTAwMDU2M010SfHQ2HnFdJ6lXHUwMDAxqyAyfrUqRHWv2qMx2aEzxvoqZGeHxlxuwFaCSsblXGZcdTAwMDOJW/os+qT3z/eGTdlvXHUwMDFmeTJqbp6tOq5BMocwzKE0SClcdTAwMDXPZ1x1MDAwZWCsLtVSXHUwMDAyaFBMXHUwMDE2J0gvXHUwMDEy11x1MDAxYWWVlkJJSqTWZSVcdLA7XHUwMDFjoFxmVIKJZMnVXHUwMDA0rDm26rmUwFNRndryL6rHXHUwMDE3VPapPVx1MDAxYVx1MDAxM905I6irS1x1MDAwMjWLXGYwvbKlRlx1MDAwZdOXXHUwMDA0ro/8+y3SNedcdTAwMWa67Y7vn53e7u6t/ChcdTAwMDFcdTAwMDNwXGaVRktjXGLDfLqwdYnmXHUwMDBlxUxTKWqzTbnEcVx1MDAwMlx1MDAwNKVDsEeYpkTR7Gq8zCCf5lx1MDAwMrBdc66YoXRiXHI7xYTDUFwi5plTuFxu0C4vNqKjUtSPJNlMXHUwMDA3aHZcdTAwMDZNWmy0U42JXHUwMDExXHUwMDA0OFx1MDAxM7Sy2rjt9ptnPGTn9+JcdTAwMGJcdTAwMWKqo2PYV41ftS7QqPSpUeuEOy2MWZioLFx1MDAwZVx1MDAxOCWTXYCmXHUwMDE3XGaXYuvd61x1MDAxYlxugygkV1+uXHUwMDA2zd1ccrq/6sRiXHUwMDE3XHUwMDAxK1wiXHUwMDExiShcdTAwMDeULTlcdTAwMTaIhTmMMrthXHUwMDEyXHUwMDAzu6JrebzC7OQ7u9hYKjvJSZcsXG62w1hcdTAwMTQoXHUwMDA2ICGl0lxcZ1x1MDAwMsN3ZtHG2PDzI1KBJTFcdTAwMGJxiCDSXHUwMDBls1x1MDAwMoKEI7WUzDGiXHUwMDBlZVxcM0zXXGJcdTAwMDVM6SqXM/yjmKXaqezRKPGnXHUwMDE5qaWq6qhqJFx1MDAwYrpcdTAwMGJcdTAwMTUzTEcq77FcdTAwMTXnXHUwMDE1JoQjNJN25Vx1MDAwMFxiY1xuq7ZAXHUwMDFhh2qFnopdw1x1MDAxOC9cdTAwMWG2wKJj+uC6oqO09Icq65lcdTAwMTc51IvRnKvNVHWsj0W19/3u98sgu7mqjiPnzTjAjyo6jlxmmU9qQMbFi8NcdTAwMTBUXHUwMDE4JG0mp6851m+atLKTnFxyKjmuXHTlRmCQyqcwXG6sXHUwMDEwwVx1MDAxNIeB3dOnZoJcIlx1MDAwN+7Ck2pcdTAwMTNcdTAwMDSNtSNRo9XYwpTtP8JcdTAwMWMjXHUwMDA0hlx1MDAwNTtcdTAwMDVcdTAwMGWN5ZM5XGbmWUJcdTAwMTIxzz5cXKugNGZbPGGXeVKN6krb7TUwnGaS/1x1MDAwN1xyYreLsVwiXVx1MDAwMrZcdTAwMTKAjIovSJDSrVxuJiTIzyQ0XHUwMDFhNU6VtE/604xKozqJ0ZVFT8G5xD9meq1Rv9nNXG5cdTAwMTNcdTAwMGKVnNi1lPhfcVxupeCONFx1MDAwNLVcYv4k6qZQPp1YNCQrXHUwMDAxJNGWXHUwMDE3oIRX7PIwRmzJSlx1MDAxOM3snKtcIq/gWck5Uz9gifiySiPUQaEnQWNcdTAwMTKjqZJUlVRGhGPXXHUwMDFlUVBcYlx1MDAxZCFFblx1MDAxZEWOPco325pgj18jgWlU+5Q9Jr1pRlap3dzFsMokxqC254ZPTyyt4y+dm6NBcDxodT9s0r1cdTAwMGZfL5tVeyyt2NYu0tFcbr8tui9nupDH2G1yXHI6LbW+bZipnjzx9K1dqMOBVi2sYII5ljUsXHUwMDAzlk/VtjsoamaL88+b4vy8W7zMQoe/fb9xctN1dzB4XHUwMDFm4y3HhIjv2m8/pD7pbda/+N7tZsmGyVx1MDAxN8lhTU5egsWHZ9/0399++/Z/3Vx1MDAxNVx1MDAwZVx1MDAwMiJ9 MarginPaddingContent areaBorderHeightWidth
The following example creates two widgets with a width of 30, a height of 6, and a border and padding of 1. The first widget has the default box_sizing
(\"border-box\"
). The second widget sets box_sizing
to \"content-box\"
.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass BoxSizing(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.width = 30\nself.widget2.styles.width = 30\nself.widget1.styles.height = 6\nself.widget2.styles.height = 6\nself.widget1.styles.border = (\"heavy\", \"white\")\nself.widget2.styles.border = (\"heavy\", \"white\")\nself.widget1.styles.padding = 1\nself.widget2.styles.padding = 1\nself.widget2.styles.box_sizing = \"content-box\"\nif __name__ == \"__main__\":\napp = BoxSizing()\napp.run()\n
The padding and border of the first widget is subtracted from the height leaving only 2 lines in the content area. The second widget also has a height of 6, but the padding and border adds additional height so that the content area remains 6 lines.
BoxSizing \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u2503 \u2503brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
"},{"location":"guide/styles/#margin","title":"Margin","text":"Margin is similar to padding in that it adds space, but unlike padding, margin is outside of the widget's border. It is used to add space between widgets.
The following example creates two widgets, each with a margin of 2.
margin01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass MarginApp(App):\ndef compose(self) -> ComposeResult:\nself.widget1 = Static(TEXT)\nyield self.widget1\nself.widget2 = Static(TEXT)\nyield self.widget2\ndef on_mount(self) -> None:\nself.widget1.styles.background = \"purple\"\nself.widget2.styles.background = \"darkgreen\"\nself.widget1.styles.border = (\"heavy\", \"white\")\nself.widget2.styles.border = (\"heavy\", \"white\")\nself.widget1.styles.margin = 2\nself.widget2.styles.margin = 2\nif __name__ == \"__main__\":\napp = MarginApp()\napp.run()\n
Notice how each widget has an additional two rows and columns around the border.
MarginApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2503Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
Note
In the above example both widgets have a margin of 2, but there are only 2 lines of space between the widgets. This is because margins of consecutive widgets overlap. In other words when there are two widgets next to each other Textual picks the greater of the two margins.
"},{"location":"guide/styles/#more-styles","title":"More styles","text":"We've covered the most fundamental styles used by Textual apps, but there are many more which you can use to customize many aspects of how your app looks. See the Styles reference for a comprehensive list.
In the next chapter we will discuss Textual CSS which is a powerful way of applying styles to widgets that keeps your code free of style attributes.
"},{"location":"guide/testing/","title":"Testing","text":"Code testing is an important part of software development. This chapter will cover how to write tests for your Textual apps.
"},{"location":"guide/testing/#what-is-testing","title":"What is testing?","text":"It is common to write tests alongside your app. A test is simply a function that confirms your app is working correctly.
Learn more about testing
We recommend Python Testing with pytest for a comprehensive guide to writing tests.
"},{"location":"guide/testing/#do-you-need-to-write-tests","title":"Do you need to write tests?","text":"The short answer is \"no\", you don't need to write tests.
In practice however, it is almost always a good idea to write tests. Writing code that is completely bug free is virtually impossible, even for experienced developers. If you want to have confidence that your application will run as you intended it to, then you should write tests. Your test code will help you find bugs early, and alert you if you accidentally break something in the future.
"},{"location":"guide/testing/#testing-frameworks-for-textual","title":"Testing frameworks for Textual","text":"Textual doesn't require any particular test framework. You can use any test framework you are familiar with, but we will be using pytest in this chapter.
"},{"location":"guide/testing/#testing-apps","title":"Testing apps","text":"You can often test Textual code in the same way as any other app, and use similar techniques. But when testing user interface interactions, you may need to use Textual's dedicated test features.
Let's write a simple Textual app so we can demonstrate how to test it. The following app shows three buttons labelled \"red\", \"green\", and \"blue\". Clicking one of those buttons or pressing a corresponding R, G, and B key will change the background color.
rgb.pyOutputfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Button, Footer\nclass RGBApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n Horizontal {\n width: auto;\n height: auto;\n }\n \"\"\"\nBINDINGS = [\n(\"r\", \"switch_color('red')\", \"Go Red\"),\n(\"g\", \"switch_color('green')\", \"Go Green\"),\n(\"b\", \"switch_color('blue')\", \"Go Blue\"),\n]\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nyield Button(\"Red\", id=\"red\")\nyield Button(\"Green\", id=\"green\")\nyield Button(\"Blue\", id=\"blue\")\nyield Footer()\n@on(Button.Pressed)\ndef pressed_button(self, event: Button.Pressed) -> None:\nassert event.button.id is not None\nself.action_switch_color(event.button.id)\ndef action_switch_color(self, color: str) -> None:\nself.screen.styles.background = color\nif __name__ == \"__main__\":\napp = RGBApp()\napp.run()\n
RGBApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Red\u00a0\u00a0Green\u00a0\u00a0Blue\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u00a0R\u00a0\u00a0Go\u00a0Red\u00a0\u00a0G\u00a0\u00a0Go\u00a0Green\u00a0\u00a0B\u00a0\u00a0Go\u00a0Blue\u00a0
Although it is straightforward to test an app like this manually, it is not practical to click every button and hit every key in your app after changing a single line of code. Tests allow us to automate such testing so we can quickly simulate user interactions and check the result.
To test our simple app we will use the run_test()
method on the App
class. This replaces the usual call to run()
and will run the app in headless mode, which prevents Textual from updating the terminal but otherwise behaves as normal.
The run_test()
method is an async context manager which returns a Pilot
object. You can use this object to interact with the app as if you were operating it with a keyboard and mouse.
Let's look at the tests for the example above:
test_rgb.pyfrom rgb import RGBApp\nfrom textual.color import Color\nasync def test_keys(): # (1)!\n\"\"\"Test pressing keys has the desired result.\"\"\"\napp = RGBApp()\nasync with app.run_test() as pilot: # (2)!\n# Test pressing the R key\nawait pilot.press(\"r\") # (3)!\nassert app.screen.styles.background == Color.parse(\"red\") # (4)!\n# Test pressing the G key\nawait pilot.press(\"g\")\nassert app.screen.styles.background == Color.parse(\"green\")\n# Test pressing the B key\nawait pilot.press(\"b\")\nassert app.screen.styles.background == Color.parse(\"blue\")\n# Test pressing the X key\nawait pilot.press(\"x\")\n# No binding (so no change to the color)\nassert app.screen.styles.background == Color.parse(\"blue\")\nasync def test_buttons():\n\"\"\"Test pressing keys has the desired result.\"\"\"\napp = RGBApp()\nasync with app.run_test() as pilot:\n# Test clicking the \"red\" button\nawait pilot.click(\"#red\") # (5)!\nassert app.screen.styles.background == Color.parse(\"red\")\n# Test clicking the \"green\" button\nawait pilot.click(\"#green\")\nassert app.screen.styles.background == Color.parse(\"green\")\n# Test clicking the \"blue\" button\nawait pilot.click(\"#blue\")\nassert app.screen.styles.background == Color.parse(\"blue\")\n
run_test()
method requires that it run in a coroutine, so tests must use the async
keyword.id
of red
(the button labelled \"Red\").There are two tests defined in test_rgb.py
. The first to test keys and the second to test button clicks. Both tests first construct an instance of the app and then call run_test()
to get a Pilot object. The test_keys
function simulates key presses with Pilot.press
, and test_buttons
simulates button clicks with Pilot.click
.
After simulating a user interaction, Textual tests will typically check the state has been updated with an assert
statement. The pytest
module will record any failures of these assert statements as a test fail.
If you run the tests with pytest test_rgb.py
you should get 2 passes, which will confirm that the user will be able to click buttons or press the keys to change the background color.
If you later update this app, and accidentally break this functionality, one or more of your tests will fail. Knowing which test has failed will help you quickly track down where your code was broken.
"},{"location":"guide/testing/#simulating-key-presses","title":"Simulating key presses","text":"We've seen how the press
method simulates keys. You can also supply multiple keys to simulate the user typing in to the app. Here's an example of simulating the user typing the word \"hello\".
await pilot.press(\"h\", \"e\", \"l\", \"l\", \"o\")\n
Each string creates a single keypress. You can also use the name for non-printable keys (such as \"enter\") and the \"ctrl+\" modifier. These are the same identifiers as used for key events, which you can experiment with by running textual keys
.
You can simulate mouse clicks in a similar way with Pilot.click
. If you supply a CSS selector Textual will simulate clicking on the matching widget.
Note
If there is another widget in front of the widget you want to click, you may end up clicking the topmost widget rather than the widget indicated in the selector. This is generally what you want, because a real user would experience the same thing.
"},{"location":"guide/testing/#clicking-the-screen","title":"Clicking the screen","text":"If you don't supply a CSS selector, then the click will be relative to the screen. For example, the following simulates a click at (0, 0):
await pilot.click()\n
"},{"location":"guide/testing/#click-offsets","title":"Click offsets","text":"If you supply an offset
value, it will be added to the coordinates of the simulated click. For example the following line would simulate a click at the coordinates (10, 5).
await pilot.click(offset=(10, 5))\n
If you combine this with a selector, then the offset will be relative to the widget. Here's how you would click the line above a button.
await pilot.click(Button, offset(0, -1))\n
"},{"location":"guide/testing/#modifier-keys","title":"Modifier keys","text":"You can simulate clicks in combination with modifier keys, by setting the shift
, meta
, or control
parameters. Here's how you could simulate ctrl-clicking a widget with an ID of \"slider\":
await pilot.click(\"#slider\", control=True)\n
"},{"location":"guide/testing/#changing-the-screen-size","title":"Changing the screen size","text":"The default size of a simulated app is (80, 24). You may want to test what happens when the app has a different size. To do this, set the size
parameter of run_test
to a different size. For example, here is how you would simulate a terminal resized to 100 columns and 50 lines:
async with app.run_test(size=(100, 50)) as pilot:\n...\n
"},{"location":"guide/testing/#pausing-the-pilot","title":"Pausing the pilot","text":"Some actions in a Textual app won't change the state immediately. For instance, messages may take a moment to bubble from the widget that sent them. If you were to post a message and immediately assert
you may find that it fails because the message hasn't yet been processed.
You can generally solve this by calling pause()
which will wait for all pending messages to be processed. You can also supply a delay
parameter, which will insert a delay prior to waiting for pending messages.
Textual itself has a large battery of tests. If you are interested in how we write tests, see the tests/ directory in the Textual repository.
"},{"location":"guide/testing/#snapshot-testing","title":"Snapshot testing","text":"Snapshot testing is the process of recording the output of a test, and comparing it against the output from previous runs.
Textual uses snapshot testing internally to ensure that the builtin widgets look and function correctly in every release. We've made the pytest plugin we built available for public use.
The official Textual pytest plugin can help you catch otherwise difficult to detect visual changes in your app.
It works by generating an SVG screenshot (such as the images in these docs) from your app. If the screenshot changes in any test run, you will have the opportunity to visually compare the new output against previous runs.
"},{"location":"guide/testing/#installing-the-plugin","title":"Installing the plugin","text":"You can install pytest-textual-snapshot
using your favorite package manager (pip
, poetry
, etc.).
pip install pytest-textual-snapshot\n
"},{"location":"guide/testing/#creating-a-snapshot-test","title":"Creating a snapshot test","text":"With the package installed, you now have access to the snap_compare
pytest fixture.
Let's look at an example of how we'd create a snapshot test for the calculator app below.
CalculatorApp \u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513 \u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b \u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0C\u00a0\u00a0+/-\u00a0\u00a0%\u00a0\u00a0\u00f7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a07\u00a0\u00a08\u00a0\u00a09\u00a0\u00a0\u00d7\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a04\u00a0\u00a05\u00a0\u00a06\u00a0\u00a0-\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a01\u00a0\u00a02\u00a0\u00a03\u00a0\u00a0+\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a00\u00a0\u00a0.\u00a0\u00a0=\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
First, we need to create a new test and specify the path to the Python file containing the app. This path should be relative to the location of the test.
def test_calculator(snap_compare):\nassert snap_compare(\"path/to/calculator.py\")\n
Let's run the test as normal using pytest
.
pytest\n
When this test runs for the first time, an SVG screenshot of the calculator app is generated, and the test will fail. Snapshot tests always fail on the first run, since there's no previous version to compare the snapshot to.
If you open the snapshot report in your browser, you'll see something like this:
Tip
You can usually open the link directly from the terminal, but some terminal emulators may require you to hold Ctrl or Cmd while clicking for links to work.
The report explains that there's \"No history for this test\". It's our job to validate that the initial snapshot looks correct before proceeding. Our calculator is rendering as we expect, so we'll save this snapshot:
pytest --snapshot-update\n
Warning
Only ever run pytest with --snapshot-update
if you're happy with how the output looks on the left hand side of the snapshot report. When using --snapshot-update
, you're saying \"I'm happy with all of the screenshots in the snapshot test report, and they will now represent the ground truth which all future runs will be compared against\". As such, you should only run pytest --snapshot-update
after running pytest
and confirming the output looks good.
Now that our snapshot is saved, if we run pytest
(with no arguments) again, the test will pass. This is because the screenshot taken during this test run matches the one we saved earlier.
The real power of snapshot testing comes from its ability to catch visual regressions which could otherwise easily be missed.
Imagine a new developer joins your team, and tries to make a few changes to the calculator. While making this change they accidentally break some styling which removes the orange coloring from the buttons on the right of the app. When they run pytest
, they're presented with a report which reveals the damage:
On the right, we can see our \"historical\" snapshot - this is the one we saved earlier. On the left is how our app is currently rendering - clearly not how we intended!
We can click the \"Show difference\" toggle at the top right of the diff to overlay the two versions:
This reveals another problem, which could easily be missed in a quick visual inspection - our new developer has also deleted the number 4!
Tip
Snapshot tests work well in CI on all supported operating systems, and the snapshot report is just an HTML file which can be exported as a build artifact.
"},{"location":"guide/testing/#pressing-keys","title":"Pressing keys","text":"You can simulate pressing keys before the snapshot is captured using the press
parameter.
def test_calculator_pressing_numbers(snap_compare):\nassert snap_compare(\"path/to/calculator.py\", press=[\"1\", \"2\", \"3\"])\n
"},{"location":"guide/testing/#changing-the-terminal-size","title":"Changing the terminal size","text":"To capture the snapshot with a different terminal size, pass a tuple (width, height)
as the terminal_size
parameter.
def test_calculator(snap_compare):\nassert snap_compare(\"path/to/calculator.py\", terminal_size=(50, 100))\n
"},{"location":"guide/testing/#running-setup-code","title":"Running setup code","text":"You can also run arbitrary code before the snapshot is captured using the run_before
parameter.
In this example, we use run_before
to hover the mouse cursor over the widget with ID number-5
before taking the snapshot.
def test_calculator_hover_number(snap_compare):\nasync def run_before(pilot) -> None:\nawait pilot.hover(\"#number-5\")\nassert snap_compare(\"path/to/calculator.py\", run_before=run_before)\n
For more information, visit the pytest-textual-snapshot
repo on GitHub.
In this chapter we will explore widgets in more detail, and how you can create custom widgets of your own.
"},{"location":"guide/widgets/#what-is-a-widget","title":"What is a widget?","text":"A widget is a component of your UI responsible for managing a rectangular region of the screen. Widgets may respond to events in much the same way as an app. In many respects, widgets are like mini-apps.
Information
Every widget runs in its own asyncio task.
"},{"location":"guide/widgets/#custom-widgets","title":"Custom widgets","text":"There is a growing collection of builtin widgets in Textual, but you can build entirely custom widgets that work in the same way.
The first step in building a widget is to import and extend a widget class. This can either be Widget which is the base class of all widgets, or one of its subclasses.
Let's create a simple custom widget to display a greeting.
hello01.pyfrom textual.app import App, ComposeResult, RenderResult\nfrom textual.widget import Widget\nclass Hello(Widget):\n\"\"\"Display a greeting.\"\"\"\ndef render(self) -> RenderResult:\nreturn \"Hello, [b]World[/b]!\"\nclass CustomApp(App):\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
The three highlighted lines define a custom widget class with just a render() method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later.
Note that the text contains tags in square brackets, i.e. [b]
. This is console markup which allows you to embed various styles within your content. If you run this you will find that World
is in bold.
CustomApp Hello,\u00a0World!
This (very simple) custom widget may be styled in the same way as builtin widgets, and targeted with CSS. Let's add some CSS to this app.
hello02.pyhello02.tcss hello02.pyfrom textual.app import App, ComposeResult, RenderResult\nfrom textual.widget import Widget\nclass Hello(Widget):\n\"\"\"Display a greeting.\"\"\"\ndef render(self) -> RenderResult:\nreturn \"Hello, [b]World[/b]!\"\nclass CustomApp(App):\nCSS_PATH = \"hello02.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello02.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\ncolor: $text;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
The addition of the CSS has completely transformed our custom widget.
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHello,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/widgets/#static-widget","title":"Static widget","text":"While you can extend the Widget class, a subclass will typically be a better starting point. The Static class is a widget subclass which caches the result of render, and provides an update() method to update the content area.
Let's use Static to create a widget which cycles through \"hello\" in various languages.
hello03.pyhello03.tcssOutput hello03.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\ndef on_mount(self) -> None:\nself.next_word()\ndef on_click(self) -> None:\nself.next_word()\ndef next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"{hello}, [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello03.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello03.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note that there is no render()
method on this widget. The Static class is handling the render for us. Instead we call update()
when we want to update the content within the widget.
The next_word
method updates the greeting. We call this method from the mount handler to get the first word, and from a click handler to cycle through the greetings when we click the widget.
When building an app it is best to keep your CSS in an external file. This allows you to see all your CSS in one place, and to enable live editing. However if you intend to distribute a widget (via PyPI for instance) it can be convenient to bundle the code and CSS together. You can do this by adding a DEFAULT_CSS
class variable inside your widget class.
Textual's builtin widgets bundle CSS in this way, which is why you can see nicely styled widgets without having to copy any CSS code.
Here's the Hello example again, this time the widget has embedded default CSS:
hello04.pyhello04.tcssOutput hello04.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\nDEFAULT_CSS = \"\"\"\n Hello {\n width: 40;\n height: 9;\n padding: 1 2;\n background: $panel;\n border: $secondary tall;\n content-align: center middle;\n }\n \"\"\"\ndef on_mount(self) -> None:\nself.next_word()\ndef on_click(self) -> None:\nself.next_word()\ndef next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"{hello}, [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello04.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello04.tcssScreen {\nalign: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
"},{"location":"guide/widgets/#scoped-css","title":"Scoped CSS","text":"Default CSS is scoped by default. All this means is that CSS defined in DEFAULT_CSS
will affect the widget and potentially its children only. This is to prevent you from inadvertently breaking an unrelated widget.
You can disabled scoped CSS by setting the class var SCOPED_CSS
to False
.
CSS defined within DEFAULT_CSS
has an automatically lower specificity than CSS read from either the App's CSS
class variable or an external stylesheet. In practice this means that your app's CSS will take precedence over any CSS bundled with widgets.
Text in a widget may be marked up with links which perform an action when clicked. Links in console markup use the following format:
\"Click [@click='app.bell']Me[/]\"\n
The @click
tag introduces a click handler, which runs the app.bell
action.
Let's use markup links in the hello example so that the greeting becomes a link which updates the widget.
hello05.pyhello05.tcssOutput hello05.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\ndef on_mount(self) -> None:\nself.action_next_word()\ndef action_next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"[@click='next_word']{hello}[/], [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello05.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
hello05.tcssScreen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the next_word
action which updates the next word.
Every widget has a border_title
and border_subtitle
attribute. Setting border_title
will display text within the top border, and setting border_subtitle
will display text within the bottom border.
Note
Border titles will only display if the widget has a border enabled.
The default value for these attributes is empty string, which disables the title. You can change the default value for the title attributes with the BORDER_TITLE
and BORDER_SUBTITLE
class variables.
Let's demonstrate setting a title, both as a class variable and a instance variable:
hello06.pyhello06.tcssOutput hello06.pyfrom itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nhellos = cycle(\n[\n\"Hola\",\n\"Bonjour\",\n\"Guten tag\",\n\"Salve\",\n\"N\u01d0n h\u01ceo\",\n\"Ol\u00e1\",\n\"Asalaam alaikum\",\n\"Konnichiwa\",\n\"Anyoung haseyo\",\n\"Zdravstvuyte\",\n\"Hello\",\n]\n)\nclass Hello(Static):\n\"\"\"Display a greeting.\"\"\"\nBORDER_TITLE = \"Hello Widget\" # (1)!\ndef on_mount(self) -> None:\nself.action_next_word()\nself.border_subtitle = \"Click for next hello\" # (2)!\ndef action_next_word(self) -> None:\n\"\"\"Get a new hello and update the content area.\"\"\"\nhello = next(hellos)\nself.update(f\"[@click='next_word']{hello}[/], [b]World[/b]!\")\nclass CustomApp(App):\nCSS_PATH = \"hello05.tcss\"\ndef compose(self) -> ComposeResult:\nyield Hello()\nif __name__ == \"__main__\":\napp = CustomApp()\napp.run()\n
title
attribute via class variable.subtitle
via an instance attribute.Screen {\nalign: center middle;\n}\nHello {\nwidth: 40;\nheight: 9;\npadding: 1 2;\nbackground: $panel;\nborder: $secondary tall;\ncontent-align: center middle;\n}\n
CustomApp \u258a\u2594\u00a0Hello\u00a0Widget\u00a0\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aHola,\u00a0World!\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u00a0Click\u00a0for\u00a0next\u00a0hello\u00a0\u2581\u258e
Note that titles are limited to a single line of text. If the supplied text is too long to fit within the widget, it will be cropped (and an ellipsis added).
There are a number of styles that influence how titles are displayed (color and alignment). See the style reference for details.
"},{"location":"guide/widgets/#rich-renderables","title":"Rich renderables","text":"In previous examples we've set strings as content for Widgets. You can also use special objects called renderables for advanced visuals. You can use any renderable defined in Rich or third party libraries.
Lets make a widget that uses a Rich table for its content. The following app is a solution to the classic fizzbuzz problem often used to screen software engineers in job interviews. The problem is this: Count up from 1 to 100, when the number is divisible by 3, output \"fizz\"; when the number is divisible by 5, output \"buzz\"; and when the number is divisible by both 3 and 5 output \"fizzbuzz\".
This app will \"play\" fizz buzz by displaying a table of the first 15 numbers and columns for fizz and buzz.
fizzbuzz01.pyfizzbuzz01.tcssOutput fizzbuzz01.pyfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass FizzBuzz(Static):\ndef on_mount(self) -> None:\ntable = Table(\"Number\", \"Fizz?\", \"Buzz?\")\nfor n in range(1, 16):\nfizz = not n % 3\nbuzz = not n % 5\ntable.add_row(\nstr(n),\n\"fizz\" if fizz else \"\",\n\"buzz\" if buzz else \"\",\n)\nself.update(table)\nclass FizzBuzzApp(App):\nCSS_PATH = \"fizzbuzz01.tcss\"\ndef compose(self) -> ComposeResult:\nyield FizzBuzz()\nif __name__ == \"__main__\":\napp = FizzBuzzApp()\napp.run()\n
fizzbuzz01.tcssScreen {\nalign: center middle;\n}\nFizzBuzz {\nwidth: auto;\nheight: auto;\nbackground: $primary;\ncolor: $text;\n}\n
FizzBuzzApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Number\u2503Fizz?\u2503Buzz?\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25021\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25022\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25023\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u25024\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25025\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u2502 \u25026\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u25027\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25028\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25029\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u250210\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u2502 \u250211\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250212\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502\u2502 \u250213\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250214\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250215\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u2502buzz\u00a0\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
"},{"location":"guide/widgets/#content-size","title":"Content size","text":"Textual will auto-detect the dimensions of the content area from rich renderables if width or height is set to auto
. You can override auto dimensions by implementing get_content_width() or get_content_height().
Let's modify the default width for the fizzbuzz example. By default, the table will be just wide enough to fix the columns. Let's force it to be 50 characters wide.
fizzbuzz02.pyfizzbuzz02.tcssOutput fizzbuzz02.pyfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Size\nfrom textual.widgets import Static\nclass FizzBuzz(Static):\ndef on_mount(self) -> None:\ntable = Table(\"Number\", \"Fizz?\", \"Buzz?\", expand=True)\nfor n in range(1, 16):\nfizz = not n % 3\nbuzz = not n % 5\ntable.add_row(\nstr(n),\n\"fizz\" if fizz else \"\",\n\"buzz\" if buzz else \"\",\n)\nself.update(table)\ndef get_content_width(self, container: Size, viewport: Size) -> int:\n\"\"\"Force content width size.\"\"\"\nreturn 50\nclass FizzBuzzApp(App):\nCSS_PATH = \"fizzbuzz02.tcss\"\ndef compose(self) -> ComposeResult:\nyield FizzBuzz()\nif __name__ == \"__main__\":\napp = FizzBuzzApp()\napp.run()\n
fizzbuzz02.tcssScreen {\nalign: center middle;\n}\nFizzBuzz {\nwidth: auto;\nheight: auto;\nbackground: $primary;\ncolor: $text;\n}\n
FizzBuzzApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Number\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Fizz?\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503Buzz?\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25021\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25022\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25023\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u25024\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25025\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u25026\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u25027\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25028\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u25029\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u250210\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u250211\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250212\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502 \u250213\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250214\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u2502\u2502 \u250215\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502fizz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502buzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
Note that we've added expand=True
to tell the Table
to expand beyond the optimal width, so that it fills the 50 characters returned by get_content_width
.
Widgets can have tooltips which is content displayed when the user hovers the mouse over the widget. You can use tooltips to add supplementary information or help messages.
Tip
It is best not to rely on tooltips for essential information. Some users prefer to use the keyboard exclusively and may never see tooltips.
To add a tooltip, assign to the widget's tooltip
property. You can set text or any other Rich renderable.
The following example adds a tooltip to a button:
tooltip01.pyOutput (before hover)Output (after hover) tooltip01.pyfrom textual.app import App, ComposeResult\nfrom textual.widgets import Button\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\"\"\"\nclass TooltipApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"Click me\", variant=\"success\")\ndef on_mount(self) -> None:\nself.query_one(Button).tooltip = TEXT\nif __name__ == \"__main__\":\napp = TooltipApp()\napp.run()\n
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Click\u00a0me\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
"},{"location":"guide/widgets/#customizing-the-tooltip","title":"Customizing the tooltip","text":"If you don't like the default look of the tooltips, you can customize them to your liking with CSS. Add a rule to your CSS that targets Tooltip
. Here's an example:
from textual.app import App, ComposeResult\nfrom textual.widgets import Button\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\"\"\"\nclass TooltipApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n Tooltip {\n padding: 2 4;\n background: $primary;\n color: auto 90%;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Button(\"Click me\", variant=\"success\")\ndef on_mount(self) -> None:\nself.query_one(Button).tooltip = TEXT\nif __name__ == \"__main__\":\napp = TooltipApp()\napp.run()\n
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Click\u00a0me\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
TooltipApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0 brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
"},{"location":"guide/widgets/#line-api","title":"Line API","text":"A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size. If a widget is large enough to require scrolling, or updates frequently, then this redrawing can make your app feel less responsive. Textual offers an alternative API which reduces the amount of work required to refresh a widget, and makes it possible to update portions of a widget (as small as a single character) without a full redraw. This is known as the line API.
Note
The Line API requires a little more work that typical Rich renderables, but can produce powerful widgets such as the builtin DataTable which can handle thousands or even millions of rows.
"},{"location":"guide/widgets/#render-line-method","title":"Render Line method","text":"To build a widget with the line API, implement a render_line
method rather than a render
method. The render_line
method takes a single integer argument y
which is an offset from the top of the widget, and should return a Strip object containing that line's content. Textual will call this method as required to get content for every row of characters in the widget.
Let's look at an example before we go in to the details. The following Textual app implements a widget with the line API that renders a checkerboard pattern. This might form the basis of a chess / checkers game. Here's the code:
checker01.pyOutput checker01.pyfrom rich.segment import Segment\nfrom rich.style import Style\nfrom textual.app import App, ComposeResult\nfrom textual.strip import Strip\nfrom textual.widget import Widget\nclass CheckerBoard(Widget):\n\"\"\"Render an 8x8 checkerboard.\"\"\"\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nrow_index = y // 4 # A checkerboard square consists of 4 rows\nif row_index >= 8: # Generate blank lines when we reach the end\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2 # Used to alternate the starting square on each row\nwhite = Style.parse(\"on white\") # Get a style object for a white background\nblack = Style.parse(\"on black\") # Get a style object for a black background\n# Generate a list of segments with alternating black and white space characters\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(8)\n]\nstrip = Strip(segments, 8 * 8)\nreturn strip\nclass BoardApp(App):\n\"\"\"A simple app to show our widget.\"\"\"\ndef compose(self) -> ComposeResult:\nyield CheckerBoard()\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp
The render_line
method above calculates a Strip
for every row of characters in the widget. Each strip contains alternating black and white space characters which form the squares in the checkerboard.
You may have noticed that the checkerboard widget makes use of some objects we haven't covered before. Let's explore those.
"},{"location":"guide/widgets/#segment-and-style","title":"Segment and Style","text":"A Segment is a class borrowed from the Rich project. It is small object (actually a named tuple) which bundles a string to be displayed and a Style which tells Textual how the text should look (color, bold, italic etc).
Let's look at a simple segment which would produce the text \"Hello, World!\" in bold.
greeting = Segment(\"Hello, World!\", Style(bold=True))\n
This would create the following object:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1aW1PbRlx1MDAxOH3nVzDuSztcdTAwMTM2e790ptOhJFx1MDAxNEIuUEJoaTuMsNa2gixcdTAwMTlJ5pJO/nu/XHUwMDE1RpIl7CBjUqd+XHUwMDAwe3el/Xb3nPNdpH/W1tc72c3Idn5cXO/Y665cdTAwMTdcdTAwMDZ+4l11nrn2S5ukQVx1MDAxY0FcdTAwMTfNf6fxOOnmI1x1MDAwN1k2Sn98/nzoJec2XHUwMDFihV7XossgXHUwMDFke2Gajf0gRt14+DzI7DD92f1961xy7U+jeOhnXHQqJ9mwfpDFye1cXDa0Q1x1MDAxYmUp3P1P+L2+/k/+t2JdYruZXHUwMDE39UObX5B3lVx1MDAwNirJ661v4yg3lkhFhGRcdTAwMWHLYkSQvoD5MutDd1x1MDAwZmy2ZY9r6vAjuvlhK+7ffPQvtlx1MDAwZcJNSo7CP8ppe0FcdTAwMThcdTAwMWVmN+HtVnjdwTipXHUwMDE4lWZJfG6PXHUwMDAzP1x1MDAxYrjZa+3Fdb6XXHUwMDBlwICiO4nH/UFkU7dcdTAwMDO4aI1HXjfIbtyNcNl6u1xy1XHX7pBcZkZSca41Zsatueh11zNKXHUwMDExx0ZLyZRcdTAwMTCMypphW3FcYmdcdTAwMDGGfYfzT2nZmdc974N5kV+MyVx1MDAxMi9KR15cdTAwMDInVo67miyZXHUwMDBiiqQglCqMXHKunszAXHUwMDA2/UHmjosgho1SynDMsJCiNMbmp0JcZnN2XHUwMDEyo4tcdTAwMWVnwmjXz1x1MDAxMfJ3ddtcIn+ybXeQKUHDJi2fy8W48S/rYKtcdTAwMDKugoN3e0dXxmNDtn/iXHUwMDFk7uyeblx1MDAxZqTvVLHgKXR6SVx1MDAxMl91ip7Pk2+loeOR791cIlx1MDAwZVx1MDAwMCk4XHUwMDExWlFDTdFcdTAwMWZcdTAwMDbROXRG4zAs2+LueVx00rXKStqxg1a2scZcdTAwMGVcdTAwMDBcdTAwMGLRilx1MDAxOf5gclxcnrw7vd5S9rV93dvjsYjt/lx0WYxcdTAwMWN0XHUwMDE2OdJcdTAwMTgk4n5ukFx1MDAwNblhkFwiXFxzXHUwMDAw1TQtXHUwMDE4QVhSMZNcclpSa7qLs4FiiaQhXHUwMDFjJuHGfVSTXHUwMDBlgiFtpm2744HEmlxuzivn80Q8mIdUXHUwMDAxu8SWhdTMXmf3gnQmRlx1MDAwNTVcZnSLPlxcwI9Aen57b7btx4R9XHUwMDE0Nt7ZXHUwMDBlg8GSXHUwMDA1fOlcdTAwMThlRFwirakmtFx1MDAwNlH+dFpNKtJbwJHyOlxmXHKmXHUwMDA0XHUwMDBiptqgcFxuXHUwMDFmrWT3evx7cPkq+nBp9/ax2Lu+Or5cdTAwMWPsLUl2OWfaXHUwMDE40lx1MDAwMswlauIoO1xmPtmc1FOt294wXGLzoyqac5SDgX91dmxcdTAwMTjGz9aP4yT0/+pUjyq1MHvtdu66zTDoO0Z0QtubpkpcdTAwMTZA8FR0Z/Go7O2CXHUwMDFkXHUwMDFl3C7Z9evriZOgXHUwMDFmRF74fpZNi3tcdTAwMTbGVL31jrWGXHUwMDEwIK3hXHUwMDBmZ+3u3ubbbSrVxYuD991cdTAwMWXTn3x/J5jB2lx1MDAxYfv+K79cIihGVIBCmqZjwVxiT7XWucsk73XVIzxcdTAwMGJhSNx5ldaeRTpBxZqV6/m/XHUwMDA1WMYwgkvFelwit0VmM4AoyjGRpEVwdfM2ftU/O9u/XHUwMDEx4uJ0g1x1MDAxZI9e71x1MDAxZmyuuuNcdTAwMTJMI6yVXHUwMDEyhlPnKNg0XHUwMDExuECSc8HInCjr0X5Mmyb4m35MSaG5Ulx1MDAxNU16Sj/2q/fJo3tcdTAwMDO6uSuOduTxsWTnLy+Wlj5cdTAwMTjNOG2B7sf5sVx1MDAxYz3fn8Wh/9P7ZGx/WFx1MDAwNT/WsGmxuLNidZ3AnEHKXHUwMDAwaWhcdTAwMTmafonAL0/UwcvRfnK+8bt9dbQpe1x1MDAxYofh6apcdTAwMTOYUYYoJNzwMVxuXHUwMDEy93K57nouOVx1MDAwMvpSXHUwMDE3PHHYjbp3XVwii+k9Low2XFyXkka6QK6N61rNaFRcdTAwMTBcdTAwMTBF9lQsJlNsdFx1MDAwNvZcdTAwMTNrsyDqo2kyVChcXIH616DwtEGL8Vx1MDAxN8/kL/gjXHR7rMpcdTAwMDFfou9V/8Ppm1x1MDAxNyPpXHUwMDFmvtl68UZEoThcdTAwMWOfrDp9hYJA0HBiZO6AWZ2+XG5RIDV0XHUwMDAwezWresVl87dCyTn8hWReSY1L2H+zTphcdTAwMTCBK1x1MDAxOfRXo2+aY2mV+Htr0VxcXHUwMDAy327vfUlkpVxmWvfAWlx1MDAwYlx1MDAwMVx1MDAxOU5cdTAwMGJcdTAwMGY8X69XlMJMXHUwMDBiJFx1MDAwNGR04Fx1MDAwZaRcdTAwMDF/PFx1MDAxZENzSZCmmFNtnthcdTAwMDVcdTAwMWKkQElcXKFEaqOwvqc8pDRSICXmLlx1MDAxY6hcdTAwMThzV7yXcFx1MDAwM6ZcdTAwMWVXu6eTlsVqlos71jTzkuyXIPJcdTAwMDHW04ZNnlHtPiDQy7ncXHUwMDFkOys3MMJcXFx1MDAxMVc9g3CSUWp4+WjG7Y03cqtFkuWlv8aqbeR/2Zr5XHUwMDA1z4o1YFxmwVx1MDAwNsPeMEyoNIRy1TCGMMjn8pJGw5rQS7OteDhcZjLY7v04iLL6tub7t+m4PrBeQ0BgNdW+uiiM3Fx1MDAxZKc1vfy2XpIm/1F8//vZvaNnYtl9Nlx1MDAxYTAub7dW/d9azqiaWclcdTAwMDYh4EJcYqVcdTAwMWUuZ/P914rKmWRcdTAwMWNcdTAwMTmqMVMu6IDMoaFmsFx1MDAwNdJhzEUkrGbX8tRcZuJcIsyNUkZcdTAwMGLIb4SsJP6lnCmEXHUwMDFkXHUwMDE1INOrXHUwMDE5M1EzJjXRXHUwMDFjyzZFg2XL2eLZ/lx1MDAwM+VsfuBblzNcdTAwMDaUYiBoXHUwMDEwVzqK8cq4W1x1MDAwNVx1MDAxMYgzdr+CPEjP5tfBpvWMXG7KNVx1MDAwMVx1MDAwNitOsKaGNawhXHUwMDAykVx1MDAxOer6LenZxmw45911JLdcdTAwMTS0WVx0lmGi3lokWFxmpuK0zZO5M9zt9a8hXHUwMDFhXHUwMDFkXHUwMDFmXnw8+qN38mv8epFcdTAwMWH/XCJitth7XHUwMDE1Tq5cdTAwMDTPdUxgXHUwMDAzwVlZpHA3oJQh8KJGXHUwMDEy6TA/O7tcdTAwMDLn7/H5YvZdr9drqph6UFWEQOKHtWStdGrxvOqJS/dSV6pQXyuvWqWMasFcXIpSQurNRfRcdTAwMDFbXHUwMDBiQCUtkqn5p7ySj+S4XHUwMDExiCqmlVCUS1x1MDAxMMKSkTldhUBaQSxuXHUwMDA0eHZiKq/GLI2wXHUwMDEwP3NcZiqtJOdcdTAwMWFCoEq4Vz6ZI65mg4VDPDZcdTAwMTJX9GzygFx1MDAwZbSVske+XHUwMDAw9aioQ2JcdTAwMTeutnlFqW3UMd9cdTAwMWJMuXnKMVx1MDAwM99GKTbCbUwz5lBIuUhAaYWpXHUwMDE0RuNcdTAwMDVjj/lv/9ViXHUwMDBmLMDlau5cdTAwMWVwSaFpwyhcdTAwMDKBMNPgrImGMFx1MDAxMoY0n5h+SyHIbGS7T1x1MDAwM9OzXCKQtclcdTAwMDRcdTAwMWRvNDrMXHUwMDAwdMVxXHUwMDAwylx1MDAwM39cIuDlKjuXgb365X76OVx1MDAwNq5N9tNcdJLNmfB57fO/MTl+mCJ9 \"Hello, World\"Style(bold=True)greeting.textgreeting.stylegreetingBoth Rich and Textual work with segments to generate content. A Textual app is the result of combining hundreds, or perhaps thousands, of segments,
"},{"location":"guide/widgets/#strips","title":"Strips","text":"A Strip is a container for a number of segments covering a single line (or row) in the Widget. A Strip will contain at least one segment, but often many more.
A Strip
is constructed from a list of Segment
objects. Here's now you might construct a strip that displays the text \"Hello, World!\", but with the second word in bold:
segments = [\nSegment(\"Hello, \"),\nSegment(\"World\", Style(bold=True)),\nSegment(\"!\")\n]\nstrip = Strip(segments)\n
The first and third Segment
omit a style, which results in the widget's default style being used. The second segment has a style object which applies bold to the text \"World\". If this were part of a widget it would produce the text: Hello, World!
The Strip
constructor has an optional second parameter, which should be the cell length of the strip. The strip above has a length of 13, so we could have constructed it like this:
strip = Strip(segments, 13)\n
Note that the cell length parameter is not the total number of characters in the string. It is the number of terminal \"cells\". Some characters (such as Asian language characters and certain emoji) take up the space of two Western alphabet characters. If you don't know in advance the number of cells your segments will occupy, it is best to omit the length parameter so that Textual calculates it automatically.
"},{"location":"guide/widgets/#component-classes","title":"Component classes","text":"When applying styles to widgets we can use CSS to select the child widgets. Widgets rendered with the line API don't have children per-se, but we can still use CSS to apply styles to parts of our widget by defining component classes. Component classes are associated with a widget by defining a COMPONENT_CLASSES
class variable which should be a set
of strings containing CSS class names.
In the checkerboard example above we hard-coded the color of the squares to \"white\" and \"black\". But what if we want to create a checkerboard with different colors? We can do this by defining two component classes, one for the \"white\" squares and one for the \"dark\" squares. This will allow us to change the colors with CSS.
The following example replaces our hard-coded colors with component classes.
checker02.pyOutput checker02.pyfrom rich.segment import Segment\nfrom textual.app import App, ComposeResult\nfrom textual.strip import Strip\nfrom textual.widget import Widget\nclass CheckerBoard(Widget):\n\"\"\"Render an 8x8 checkerboard.\"\"\"\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard .checkerboard--black-square {\n background: #004578;\n }\n \"\"\"\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nrow_index = y // 4 # four lines per row\nif row_index >= 8:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(8)\n]\nstrip = Strip(segments, 8 * 8)\nreturn strip\nclass BoardApp(App):\n\"\"\"A simple app to show our widget.\"\"\"\ndef compose(self) -> ComposeResult:\nyield CheckerBoard()\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp
The COMPONENT_CLASSES
class variable above adds two class names: checkerboard--white-square
and checkerboard--black-square
. These are set in the DEFAULT_CSS
but can modified in the app's CSS
class variable or external CSS.
Tip
Component classes typically begin with the name of the widget followed by two hyphens. This is a convention to avoid potential name clashes.
The render_line
method calls get_component_rich_style to get Style
objects from the CSS, which we apply to the segments to create a more colorful looking checkerboard.
A Line API widget can be made to scroll by extending the ScrollView class (rather than Widget
). The ScrollView
class will do most of the work, but we will need to manage the following details:
ScrollView
class requires a virtual size, which is the size of the scrollable content and should be set via the virtual_size
property. If this is larger than the widget then Textual will add scrollbars.render_line
method to generate strips for the visible area of the widget, taking into account the current position of the scrollbars.Let's add scrolling to our checkerboard example. A standard 8 x 8 board isn't sufficient to demonstrate scrolling so we will make the size of the board configurable and set it to 100 x 100, for a total of 10,000 squares.
checker03.pyOutput checker03.pyfrom __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Size\nfrom textual.strip import Strip\nfrom textual.scroll_view import ScrollView\nfrom rich.segment import Segment\nclass CheckerBoard(ScrollView):\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard .checkerboard--black-square {\n background: #004578;\n }\n \"\"\"\ndef __init__(self, board_size: int) -> None:\nsuper().__init__()\nself.board_size = board_size\n# Each square is 4 rows and 8 columns\nself.virtual_size = Size(board_size * 8, board_size * 4)\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nscroll_x, scroll_y = self.scroll_offset # The current scroll position\ny += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!\nrow_index = y // 4 # four lines per row\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\nif row_index >= self.board_size:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\nsegments = [\nSegment(\" \" * 8, black if (column + is_odd) % 2 else white)\nfor column in range(self.board_size)\n]\nstrip = Strip(segments, self.board_size * 8)\n# Crop the strip so that is covers the visible area\nstrip = strip.crop(scroll_x, scroll_x + self.size.width)\nreturn strip\nclass BoardApp(App):\ndef compose(self) -> ComposeResult:\nyield CheckerBoard(100)\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp \u2585\u2585 \u258b
The virtual size is set in the constructor to match the total size of the board, which will enable scrollbars (unless you have your terminal zoomed out very far). You can update the virtual_size
attribute dynamically as required, but our checkerboard isn't going to change size so we only need to set it once.
The render_line
method gets the scroll offset which is an Offset containing the current position of the scrollbars. We add scroll_offset.y
to the y
argument because y
is relative to the top of the widget, and we need a Y coordinate relative to the scrollable content.
We also need to compensate for the position of the horizontal scrollbar. This is done in the call to strip.crop
which crops the strip to the visible area between scroll_x
and scroll_x + self.size.width
.
Tip
Strip objects are immutable, so methods will return a new Strip rather than modifying the original.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaXNcIkmS/d6/QlbzcZuccI97zMbWdKFcdTAwMTPd6GB3TMYpkFKAOCSktv7v645UXCI5XHUwMDEyoYvKYtVtqiqSI4h87v6eh4fHX38sLf3oPDbLP/619KPcK+bDWqmVf/jxJz9+X261a406XcL+v9uNbqvYf2a102m2//XPf97mWzflTjPMXHUwMDE3y8F9rd3Nh+1Ot1RrXHUwMDA0xcbtP2ud8m37v/n3Xv62/O9m47bUaVx1MDAwNYNcdTAwMGZJlUu1TqP1/FnlsHxbrnfa9O7/Q/9eWvqr/zsyula52MnXr8Jy/1x1MDAwNf1Lg1x1MDAwMVx1MDAxYWVHXHUwMDFm3WvU+4NcdTAwMDVcdTAwMTRotdBavj6j1l6jz+uUS3S5QmMuXHUwMDBmrvBDP453K1AvX/dcdTAwMGWLK6nWib/e1M3LjcHHVmpheNx5XGafp1wiX6x2W5FBtTutxk35rFbqVPnTR1x1MDAxZX99XbtBszB4VavRvarWy+320GtcdTAwMWHNfLHWeaTHpHh98HlcdTAwMTL+tTR4pEf/SjlcZjRcdTAwMDJ4kFx1MDAwZZXE14v8auVcXKC0tVJIROE1yJFhrTZCulx1MDAxMzSsf4j+z2BghXzx5opGVy9ccp6D6FxuZTN4zsPLlzUmXHUwMDEwylqtUGlnhFevz6iWa1fVXHUwMDBlPcVi4KQ2wlx1MDAxOIHCOTdcdTAwMThnu9y/XHUwMDFmXGJSKbruXHUwMDA3N5Q/vrlV6mPjP9FcdTAwMTmrl15mrN5ccsPBiPnCelx1MDAwNE+D13SbpfzzbVx1MDAwN2O1Rk9DoLl5vVx1MDAxZdbqN6NvXHUwMDE3Noo3XHUwMDAzpPRcdTAwMWb9+89cdTAwMGZAlO5KXHUwMDFjRK1cdTAwMDaNXHUwMDAy3OxcYl3LVm6ue8t7d1x1MDAxOXl2eFxcObnPZdeWXHUwMDEzjlBtXHUwMDAy0MpcdTAwMWGp0ErrnDfjXHUwMDE4ldJrXHRcdTAwMDRTacEkXHUwMDE2ozR85b0wesEgaoWLgyhISSZcdORDZsZo+WRzm1x1MDAwNl8olNTRQWbL7excdTAwMWPcqIRjNFx1MDAwNTJcdTAwMTDgnfZgwVx1MDAxOedgXHUwMDE4pNrIQFx1MDAxOSfoXHUwMDEyoFOj40pcdTAwMGVEQUvp6D9YOIjqOIhaa5SQxsLMXGI93y/s7VxcaFdoXHUwMDFjXHUwMDFj3ubP7zeJ37R/LUJBvOlGVWC1oWl3XHUwMDA2vFFgh1x1MDAwMGqkXHUwMDBmiFx1MDAwM3hPXHUwMDFlSmilMbFcYkWwVtFQXHUwMDE3XHUwMDBloVx1MDAxMEtFUSjiNqD97IH+NpXLy3RxOXucq96U16pcdTAwMDfrXHUwMDFiISTdiTpcYoiFKidccjqg71xmw1x1MDAxMFVcIiBcdTAwMGaqtFx1MDAwMkVE1erEQlx1MDAxNCyRXHUwMDE0TTFg0bio9bFcXFx1MDAxNFx1MDAxY01cdTAwMTdFNz27XHUwMDFi9TZvdNi4vu5cdTAwMTazcFx1MDAxOXZLjX27l3CMXHUwMDAy6sBcdTAwMWKhKcxcdTAwMTNcdTAwMDR8RFx1MDAxMD2HeU1cXNRcYutQ9mNKciHafy25lPm5UT9cdTAwMWa5JHxcdTAwMWNEtfDOXHUwMDAy+plcdTAwMDHaetquLtv1PVxym5uynFp7Slx1MDAxZlRPk1x1MDAxZefpzlKgJ1x1MDAwNmdR05/aXHJLesJtgI6UvDGoPGKSqahw4KxcdTAwMTBcdTAwMGJcdTAwMDdRXHUwMDE5n3RykmKbfIdcdTAwMTNdllx1MDAwNd+53Xpyjerm8Xr2XGbyp/WkK/pcdTAwMTRgIDmvRLLeoSBHaUYwqomMXG5E68lFXHUwMDE5n9ysXHUwMDEzgDWk6oWaX6SfXHUwMDBmRq0wcVx1MDAxOPXgPOiogHhcdTAwMGKjK141fKdzs1Jo3+CtKUH7ot5MOEbBXHUwMDEzXHUwMDA2lVx1MDAwNVx1MDAwMUpcdTAwMDLFe1x1MDAxY470ylx1MDAxOYKGoD+EQYr3oJKLUUnwcdZEXHUwMDAy42JglD4pXHUwMDBlo8zMlNJmdozuhL3qWlXmMlx1MDAxZFFcdTAwMGXXzcNj2OydJFx1MDAxY6NSyoCYptBcdTAwMTTKhTM4mrwniFxuY+mOWCFRJDgxSjZGMUBoOb/M6JzcqME4iFwiKE30Rjo9M0b1zXpYKFXu08f3WpRy5+FlfT1MOEbRqEA68j9cdTAwMWWU0TKShntcdTAwMTZMXHUwMDEwOMKuQk5MYYKdKPlcdTAwMTJA4qNcdTAwMGKHUFx1MDAxZJu7d2hQ0q2ZPeu0eqMuOnBdusruXHUwMDE1W6dcdTAwMGYtma3m80lcdTAwMTdM7EWV8kQzXHRcdTAwMDFcdTAwMDCRtYyfWSdA4ywvjoIzXHSGqJVcdTAwMTL418JBVMk4iKKj2dL+XHUwMDFkq0v6+mJze9dm95dVuYHb+6enzZxNOkTBm4Cw4STxXHUwMDFhXHUwMDAxUo5hXHUwMDE0XHUwMDAzLSjWkGJcImbuklx1MDAwYlGyMbpjOMdF+jlRUVx1MDAxZFx1MDAwYlFtke5cdTAwMWH42SU9bi7bo3C7lrtfbV5f7eNtRVxc51x1MDAxMlx1MDAxZeaVkIEha1x1MDAxNN4rXHUwMDBmVo2GeVwiXHUwMDAxSMhcdTAwMTDSeUy0oDfaWWW1XTQn6lxcbF6UvIvxRkaXSN+C6F7n8r6mbnqnzZW73HLnMXN8VF3/XHUwMDFkIGqEJS6qnZMjqXvOOVx1MDAxMVd1gqhcdTAwMDCr/eSKJS6FsVx1MDAxNtTCIVTGMlFcdTAwMDM0a1rI2X3oxe7DRqHaq4Rdd7ZV92erjfr+RdLDPNHMwFiHJJPAOI3KjkDUXHUwMDA2XHUwMDAyXHUwMDFk31x1MDAxOGe4jiixXHUwMDEwReEleVHQi5ZysjLWiyryoUJqNbtcdTAwMTNcci+ecvumqFx1MDAwZm/NQ72zv3N73To/TLhcdTAwMTNNOZJD9D3BSclcdJtcdTAwMTGEelx1MDAxMVgwSoNcdTAwMDaLTiVXLCktkWJeRFksXHUwMDA2QJ1cdTAwMTdxXHUwMDAwXHUwMDA1Vlx1MDAxMHTv9OxcYr16KKZPa21wl9nH7VpVmePc+i9Ois5Q6GRJXGZJ+qaEQZr3KD6eMVxugdJkrFZcdTAwMDOp/YiLSlx1MDAxYUbpXCLxXHUwMDE05Vx1MDAxNm3908ZjlL+wgWhZxZvVou2KKp6c7Oid5mrT5DLd6nphM+lOXHUwMDE0REA6wztcdTAwMDOoud4uUpPAb+BcYj2kokjqk2Am8CS3jMRcdTAwMTlnhV84JlxusVFcdTAwMWWFXHUwMDE2Xoj3rCx5XHUwMDExVjvdwoYt75ZqXCJzXuymdlx1MDAxYlx0Ryj5UGk8ek1cXFR7gcNy3itcdTAwMTF4hSRDXHUwMDE0XG6Z4Fx1MDAxMlx1MDAxMpLxxEXkwiXtnYivXHUwMDE1JXySdphcdTAwMWSehUZcdTAwMTWuWts7t08rXHUwMDA3t1f3ze398/VMwuGZsi6w1pIs1I6IJrnRXHUwMDExfMpAopTOoqbJwORWiqIgLi1cdTAwMTDEwvFQXHUwMDFiW2+PLJSMtbPT0OPMRerk5nxv67540d3aL4dcdTAwMGZcdTAwMGb791x0hyigXHJcdTAwMTR7SFCS5LBcdTAwMWGN8Fx1MDAwNGAgnU86RHlcdTAwMGLJLbe3aLVfQFx1MDAxN+ohNtukpLXo4Vx1MDAxZEp+fcs9XFyd7tQz9+XD7PLKSemyW096OpRcdTAwMDHKy1x1MDAxMp5cdTAwMWOQ5FxyIUNcdTAwMDAl7qlcdTAwMDMhNTlZrZXxNsGLSob/035cdTAwMDG1fHwps1x1MDAwNE9cIvZcdTAwMWRL8/dcdTAwMWJcdTAwMGad86Otq8xarry6fX2xXazmz1x1MDAxM1x1MDAwZdFcdTAwMTS5UMF1MsJpY6xcdTAwMWYpZSaM2sAp+lx1MDAxMUT0SEol14tcdTAwMDJdsDSChcuHmtgw7zQ4XHUwMDE33Y79pk7avYHbTmpzt3l3XHUwMDEwbuyk6j19eZD0ZFx1MDAxM3jL2+eFQCSkRpcwnnNNXHUwMDE4KND9XHJ3hGKdXFylXHUwMDA0QCYk1OIlRH188VxiOO2dXHUwMDE3XHUwMDA2Zlx1MDAwZvS5Wlx1MDAxNcqNVLpWz2RrOrO50fBcdTAwMGa/WMvPUuBkXHUwMDAyQqDXllx1MDAxM0p+dFWJMCqJXHIxepBcdTAwMTdtXHUwMDEyi1FjUJOkc4vmRJ2MdaIgOIXt5DtcbpzOT1x1MDAwZotccn9YujqrXHUwMDFkZU+0bj21Vs5cdTAwMTJcdTAwMWXo0fDKJil2pYiWXHUwMDEy3Vx1MDAxY07ZO7qsSMqT4JdcdTAwMWGT7EXRWuJcIkYvmlpyJjbhpFhcdTAwMWPCe7YsZc+3m1x1MDAxYrWtjaNMa2tXn3SOz07bSW+TI6VcdTAwMGVcdTAwMWNcYqKhxEhp1ofVkldcdTAwMTA4a/t1I+RpXHUwMDEznFx1MDAxMVx1MDAwNcnKwZk59iCZV1x1MDAwNV5spT2QNDBWKJw9zvuV8/rpffWyU8BcdTAwMTW3WXkqrJVcdTAwMWbuXHUwMDEyXHUwMDBlUfAuIDFvhffaaFx1MDAwMGlGMKpcdTAwMDKKn2iIqYJEmVxcQe/J0Kywi0dFMX7fp1x1MDAxMZr3jNvZNydcdTAwMWbs7dhuSp7ByeN1/cGmsVPYLCdcdTAwMWOiSlx1MDAxMDxIXHTT/UUhhVx1MDAxZNlcckJyMTCaVD1xVMNro8mFqDZcbr3Ri1bH7OPT9oCO63tRzVx1MDAwZdH1Qlhv3e1cdTAwMTfSXHUwMDBltleObjZuXHUwMDFl1sKjxEOUXHUwMDAzPVFRYclcdTAwMGaBXHUwMDE4dqL9tKi3gvyr8kbK5Fx1MDAxNo9cdTAwMDCS/Vxit4iCPt6LUtBcdTAwMTDc9WB2Lrpmd+5ztfW1p/AqbNbq4DauNm5cdTAwMTNcdTAwMGVR8p+BIMmOdPspkFx1MDAwYmdHMcqro16C1nR7olQvcSBVvDuVSfOCgVSa+I54zit05Edmz91X7/Nb6V1cdTAwMTf2Llxua2m7nXdHO+W4XHUwMDFhp1x1MDAxMbBcckN0tFx1MDAxZfP1VaV8u1p+XHUwMDA3Ru0sbUVZXHRpZTn1NJq6573JXHUwMDA2eEcl31x1MDAxOTDuUyVOnVa+3m7mW4SBcZxqrVx1MDAwMmOs05ZmXurIkskrTlx0oUG/o5TWRJ9hUs8xxWu5qL5cdTAwMGWnL1x1MDAxN1x1MDAwNsCK3O+Gvdjcv8Dl5ulx+7RSa/m6yVxmmndccqEw32o1XHUwMDFlfrxe+fvPae+bOd+9XHUwMDE2+TOzVz0uioNmoVx1MDAxM+7ordne9+VvybAucDZ+M4vgLXJcdTAwMTbf0c5v+bKS71xcXHUwMDFk7Fx1MDAxZjdSVVkopo+PN1RcXD5iqnmNrj99azM/bblcdTAwMTZNXHUwMDExTyalN8yjjXRcdTAwMDFcdTAwMDV+0CwriG/Hl2HTs/LqjVx1MDAwMFCpVMatSipHI1BI4ZY+30VcdTAwMTbTX62KXCJT4HkvXHUwMDAzyU2DUX3+umHVaMlcdTAwMWJWv45FT7Wq89LpKt5V7vaq+96Yxoo5yJz8juhcdTAwMTdcdTAwMTCf6rA0o85F6/LfQr8tbu7Ui4VO53Hn3pnlp9Ku6vaSvqShdSB5r1x1MDAwNVx1MDAxMoSUVsRyhlxyQEEgyd+DI1x1MDAwNjy0pTtpXGbIglRCLdy6MFxijMcoXHRJ40HNzn+2lrP3XHUwMDBm4U1W5S5219IrWbVij4pJhyh4XHUwMDE1oNFWcl9cdTAwMGIyWTWGUFx1MDAwMVx1MDAwNlx1MDAwNXlp3tGdZI7OXHUwMDA1XHUwMDE4Xi9cdTAwMWNH91x1MDAxMYowuqbRryrRZvZUhzvdyJz27uzmyurxzr06XHUwMDBm5ermL27DNsvCMFx1MDAwNqSYvSdcdTAwMDLurbejPYFcdTAwMTVcdTAwMDaSN1x1MDAxYzpP0TvJzVm80t5TXHUwMDE0WDg3qlxcrFx1MDAxYjVWSVx1MDAxMlc4e6TvPS7fhPlwfTt31MvlTn0h3HWtxPNcXCG04jVGr7mkeyTOXHUwMDEz0SWKS1x1MDAxNJIgqpz93I7YXHUwMDE4omtcdTAwMDOtaFx1MDAxMJKbwGk1QT7CM9tGYl7cKFx1MDAwNqL7dV52XHUwMDFiXG6Dyir4ukW3aURcdTAwMTex97jTbsPxg6rD9uPBtj+utWcjulPl429EoMthWGu2J9tcdTAwMTTxxjibQsKIXHUwMDA2Zdzs4nE1tfm0dbyXlb1cdTAwMDPZvs4101x1MDAwZvthXFxcdTAwMDJxqlHNz+1cdTAwMWKiXHUwMDFlfI5cdTAwMDa3XHUwMDFmXHUwMDEz0c6Fz05fXHUwMDEzMUHJus1+cot5Ja9R47hNsXS0xtHryb+p6IlcdTAwMWSvNuUoKqDtb9Hs96yXoyZcdTAwMDVcdTAwMWVcdTAwMTVyt/D5iMdP2FSSsK+mtPXkXHLsxsvZXHUwMDE3yVx1MDAxZkXtonq/bVx1MDAwMVx1MDAxZXZ6XHUwMDE1n013u+eYbOhbctVAmDFeSEUqeVQ2qn5iXVvlUVxu87l64kq+QN7ke9BvKfJcdTAwMTn9lfnIZMBTx+f1nOZ2rO9pUrPSOzg9Or9q1zOt+onRqdst7dLJxqc3XHUwMDAxeUyJwnpBXHUwMDE4XHUwMDFjQadcZlx1MDAxY0hcdTAwMDOWa+GI9Hxcbp2AXHUwMDA15ybw8a9AJyrlXHUwMDAwo0dcdTAwMDH8Nuh8K/NcdTAwMTa79qiNJOeJcnbJeLeVrm0yc7BcdTAwMTdP9e4y3jzI9C9u8zVcdTAwMDMhN4RBL/uiUWvrRurd+SwuuiOSdJxcIsKb3I3rXHUwMDAwaHjBZ+G6KHlcdTAwMTWfeFPsRH20XHUwMDEx9FtcdTAwMThN27ua9duVxure6nlnuXZb3ln7xZ27Z0trXHUwMDEwj9GOlCNnf4drNblHXHLvXHUwMDE1XCKhJrj1QoJrNWl00jrxO0b56aduxm9sXHUwMDAzT1x1MDAxOFx1MDAxNVx1MDAxNFneUU58mOnKXZu9W1x1MDAxM+srOzv32Vx1MDAxNYzd2fblYV58XGKh2lx1MDAwNki+UlxuirROR49cdTAwMTjtl8FpRVxi9U5JQb/d51LDxXJJlfLjXHUwMDAwlcR1pVx1MDAxNUSB0ViylkhkXHUwMDFiJDYwMF4765ltSHKYalx1MDAxNKLIXUeV/Uo3+nJholxuM2m9d5KRT1ukXdcvU3p143LvcDZcdTAwMTX257T33czVTHhSf+q08+Ji9XBv/ab31PgqdUdwILo+XHUwMDFm51x1MDAxZp/aIOZsXHUwMDE0yvdcdTAwMWPEuFJO5eHo2nWaZ49yZb1X2c2sJZ6gXHUwMDEwOVx0LFx1MDAxYc3HL1x07q0w3v1JXGLD59xcdTAwMTlF/jW5PfRcXL9kxSyc85+6Ic9xu1x1MDAwZfOOKuiLsL0s072101x1MDAwYpHN71x1MDAxYtvJp5dlsp0/qdxcdTAwMDClXHUwMDA04tDcjlSPIJS9v6BcdTAwMTBoiK55Jz/ZcFxcXHUwMDE1TbkyIVx0XHUwMDAx3lx1MDAwN9JbXHUwMDAwj9yHSkyo3yDvT+BFQzpUOOWjNVx1MDAwNT8hXG5cXH6iXHUwMDE27oCmaD/xXHUwMDExiEq2SVx1MDAxZF1cZn1cdTAwMTOhd729w4vl/HZnZ2VHnl2J1XVI/uI16lx1MDAwMKxcInhwP3mMXHUwMDFlY/HcidRcdTAwMDSSXHUwMDBiMFx1MDAxNOk8XHUwMDFmLeBJmlx1MDAxN1x1MDAwNU6kWLt4zSHIaOMwalx1MDAxNUU4mozZI31ntbZXrbWrdrmi0o3Udc2ellx1MDAwYknHKFx1MDAxYVx1MDAxOWiB5JpcZoAzY/2cTUBcdTAwMTSagihywjfBzXKl5lx1MDAxM7VcZs4tXHUwMDEzXHUwMDExab/4rdkygFg2yrkh3nw/O0Srayd+67GweplXXHUwMDA3T83zfPmweFx1MDAxOXcqeEJcdTAwMDK9dC6g6GmRmDc5Slx1MDAxY85DXHUwMDE4TVx1MDAxMVihUJI7zmvxPTJcdTAwMGZkoIhE8mJcdTAwMDFZgvGRj1x1MDAxOVx1MDAwNHqv+Cwzy1x1MDAwZV8hUZLxxTbB8mHo1d8p875Vjs3TXHUwMDAyplfBWURcdTAwMDPvqDG6gHSx2Mvu11x1MDAwZdPiuuuf0ie4XHUwMDFld1x1MDAxY2lC1jPYXHUwMDA0LHeC9rxcdTAwMWRFXHUwMDFhXHUwMDFjWXAjscY9KZSxRDC9/tySRizXhcCQtCBkU5RcdTAwMTAmmltcdTAwMWGsaSA3vFx1MDAxNMJb0d8zM8Z1uaU198iYz3Lz91pcdTAwMDBxh1xiw/2kXHUwMDA1dMq9zkTw2/hcXFx1MDAwNDvDfmO5mbF/WMrc7V9cdTAwMWTpjebVQ+8207kut+1jsrfAXHUwMDEw5lx1MDAwM97agqiJXHUwMDE1Kj16yKly/eI7XHUwMDE0ktgpfvKs6Im1S6gn8JHoyVx1MDAxNi+bWYWWnjuKzVx1MDAwNdrftbnlNH1oiqliPsy1ZK+3msvmunj8y4LGXHUwMDAwmY1657j21K8uckOPpvO3tfBxXGJdfVuiXHUwMDAx3tdanW4+vGzTXHUwMDBig5c7XHUwMDE3uf3tMlxyoP+Oeuily2Htik3vR1iuXGbbZKdGXHUwMDEz83q504is4Vx1MDAxNGkoeXq71lZp9Cs1WrWrWj1cdTAwMWaeTFx1MDAxOdZU3/A80Vx1MDAxM5yDjO9cdTAwMWLG68rkqmdcdTAwMTfY01x1MDAwMfVcdTAwMGLKXHUwMDFh3/RcZlrYgMNcdJLxK1x1MDAxN21o1H+51IHU5Fx1MDAxYVR/Z4P83Fx1MDAwMtXkosZAcDrfSevpfy0mXHUwMDE0NVwi14EpxedcdTAwMWIrjUpGT1x1MDAxNXshhY6rXHUwMDAwXFy0XHUwMDE3cZKES2TW8q3OSq1eqtWv6OLAdfwoP3/01lxmXHUwMDExpm+zxS6PMiVcdTAwMDLPRf2aj6ZWpP5cdTAwMDbHXFzwXHUwMDE05JvPtFuS6lx1MDAwM+2lN8R7lHt5xqtcdTAwMGb7Ua6X3lx1MDAxZdT0nZ/RQUFAZkM3RIO1Rlx1MDAxYTM+Jkk0qK+6pNbWXHUwMDFib8aGXHUwMDE05tud1cbtba1DU3/QqNU7o1Pcn8tltupqOT/mL+grRa+Nmn+T33E4Plxm/rY0sJH+P17//p8/Jz47XHUwMDE1i+H+1XH4XHUwMDBl3vCP6J/vdl3g4lx1MDAwZsxcdTAwMTQ0rzp6XHUwMDE4+Fu+a3rQSqTvXHUwMDAyXGacMoq4hTPK4OjiOlx1MDAwMZ9cdTAwMTNcIkKTojTuk1x1MDAxNXRcdTAwMTNJTUBKQTjuXHUwMDEyQoJcdTAwMTYnkXn0JvBoXHUwMDE0N1j2Q2vJr9VcdTAwMWbgSVx1MDAxMkj8f+a6ROCclyTDvFx1MDAwM7qFkSbdr16CXHUwMDBi6lxy3TuCOadcdTAwMDOMn+64hr/F7+Q/YnHEP2NcYnqn94hcdTAwMTNFXHUwMDE4aVx1MDAxYzbqPKRA8lTvKM5pZFx1MDAxZs9ubaPcLGbO2/uq53Xq9EOFXHUwMDBm89NEzlx1MDAwNzTd5LWVdlx1MDAwZYRcdTAwMTjO2fJOzUDxjk3pnJRcbj/XXmWy+1AzaVwiXHUwMDEwXHUwMDEyrHZfeLTJK4K+dmP+VE10eUfM/na1dLV2SVx1MDAxYXPz5GB/q1hdXHUwMDA0TfR8N5MmiZ5H9TFagT5eXHUwMDExkSP22pnZN8xOx1NcdTAwMTJphVTE5/hcdTAwMDRuzV1cdTAwMDEs4qhnQKJcdTAwMWRcdTAwMTSUXHUwMDE09y6Unz1cdTAwMTJpomdcdTAwMDBhXHUwMDAyR1x1MDAxZu1IgFx1MDAxMmsk/TXuKIhCoyT2Yzw3K3U+kjP5mUyxzlxuObfVxm/lXHUwMDE101x1MDAwM8wrZfBcdTAwMDF6a9FYllx1MDAxZdxcdTAwMDd7smhcdTAwMTKcXHUwMDA146JcdTAwMWPBZbdK/oypXy2Jnlx1MDAwN4UuXHUwMDAwzjtznLGSXHUwMDBmMpqsmoRcdTAwMTe8QEKciIZlzLhO+51ITTyE+Sc1jt530pp474XxR7p5x4fcvKPubHrUSqL3sjJwJHZI+TG43UhCXHUwMDA3+KhccuuE1EZ5bsvyXHLbVEFDXHUwMDAwXHUwMDE0XCJcYsaOTFxm3KR1voC7oDrSqJJbbMrIdtSfzotgQaFmfodf/1r3XHUwMDE1cU5AhFx1MDAxNEi1XCJcdTAwMGIk7uw35lC48Vx1MDAxZlqFkmjhc9Ob6f7r91VG8Vh6vjxcdTAwMDajd3qROHFkpm3MRXDeR1Olb/ZHrW489MJcXObx8WlVy+WM0vXK2q/0XCL+LS+S0nyomXL9XHUwMDAzd4zGkcMktFS8nmRRXHUwMDBiukNexi+WXHUwMDE2PeYx/43iqH/ajv9KdTStfppl+mBcZt+tM0hJVIJ2sdVcYsPLRqXSLidi6WXCqD5cdTAwMTapdXzrNG1cdTAwMDRZdZS8vmVj03dFJzFSp6znXHUwMDEzqsnXW66vJa8/ZGOqf4C1JetcdTAwMTJGO4VTemd+2MhcdTAwMDBVYJXnXHUwMDE0XHUwMDFjSC8hsmFrXHUwMDEwqo1cZri3XHUwMDA3elSWrHIsg8l1Q4pcdTAwMGL95tLyXHUwMDA0rDPqK0L1aFB7O4hPb1oywuWl53RcdTAwMWVqcpXCXHJK/pZcdTAwMDbJTU/S0mrF7W608vL3ZvuxQOpfXHUwMDFkhdBcdTAwMTdF6eh+37H6fetcdTAwMWRzg9mj9NHZzX796v60dt29yOS1PCnBdpjsKI3OkGrkQ1x1MDAxNLRA6e1o3SmRJ6GF8WSeXHUwMDE433huXHUwMDA29zG1rSmYXHRcdTAwMWK2JoRqkoOMdfi6XHUwMDFh/VdcdTAwMWP9ilx1MDAxNjFzTDg+Lv176SXiPiaBXHUwMDAyXGaN52PB30yR6aRrjFx1MDAxNPJcdTAwMWR1XHUwMDE3U+91XCJNXHUwMDE3hFxulCNpY6Xpn1x1MDAxYTmyOYyrZT2FXGLe4m7UlMqLT1uv8oHzvHvGcz9cdTAwMTIhJ3BcdTAwMDBSYVx1MDAwZcBcdHR82ICa0Fx1MDAwMseK/smyZi6rmGA1XHUwMDA1ky/gXHUwMDAwcZF+eihYiq5iUpwnXHTCfcOF9laPXHUwMDA3elBcdTAwMDFdI3fBrWelNm682OFr2Vx1MDAwN9FGQYzCsjDiqr5J3IN71HE9oHRa0vB/b+5cdTAwMTFcdTAwMGJg/kmNYfeLyIeRscVcdTAwMTc0XHUwMDBloKDrZ+9cdTAwMGaDXHUwMDA3qdZaeLjse2F2I1u73c6Bc8l2YOSZXHUwMDAydl7kw4zhxZAh/+UlXHUwMDA2SE5cXDllLZeifJ//slx1MDAxM1x1MDAxY9Yk9tE/1O1cdTAwMGKXQ6aRj0/sXHUwMDBlf5t8XHUwMDEwnfaRpv7fTT56g2DfS1x1MDAwMvlcdTAwMThcdTAwMWHPx8hcdTAwMDeXp8VcdTAwMTkvzbAw7zr+aPrNTqTxXHUwMDAy9yVBpygmeTJgXHUwMDFjyT2QTlx1MDAwYlx1MDAxMNGT/Vx1MDAxYe99fO1DibSHqHzCeJ1cdTAwMGZQsCZUxnKX7HFTNuTdjdJeKvLfWsFY41x1MDAwN7DAgfUri8a/xfhm5Fx1MDAxZdNDQSSGXHUwMDBiJTiuXHRyr9xBYFx1MDAwMlx1MDAxM+CVTor0XHUwMDFj4ZWy3HOByPHH2Mf0ziZcdTAwMDOyM2FcdTAwMTTc5Vx1MDAwMFx1MDAwNaBzgLxs/nuTjTjA8k9qXGarX8Q1tI7lXHUwMDFhUvSLe9/RwnzdbKz2uqc5dyGOb25qVX1cdTAwMWFmk801NK9cdTAwMDKR7XnBp11ZO9xcdTAwMWKaZjvgynVNXCKK7suUXHUwMDBl+5+lXHUwMDFhOKk63Y4lRHmbL1x1MDAxOdxcdTAwMTdcdTAwMWVcdTAwMGU8jWp8b8NcdTAwMThUVnxIaX2aaiz91//Wn5dcdTAwMWGmV1mZ4beZJ/uYNMRcdTAwMGZcdTAwMTJcdTAwMTLw8VvUKOKAtO85pWA6Jlx1MDAxMmni3lx1MDAwN55z4ZJcdTAwMWKB2JGaK1wiXCKBRd7VzkdcdTAwMTR4XHUwMDFiT0g+rSbIv5NIXHUwMDA3329RJGDSKqTl4lx1MDAxNW208dKSjejx7Shk/PRiO5+OfsinWOiP2OiMjGR6wFiKZlx1MDAxZZDPylx1MDAwND6uwFC8x/GdXHUwMDFmKuBSW3IqgMhV31x1MDAxZt2MMnVcdTAwMGL+0JCI8lx1MDAxMNEl5Vx1MDAwZp6X7u3YkFxcoPlwXGbNjVx1MDAwZpif/N51V6l4XHUwMDA09y+PgfeLKFxuQPwxcyRcInjh/Fx1MDAxZOdg1dxGXHUwMDBmM+kw18hgunS0JY5cbvvbiXZg4HSgNVx1MDAxZj/FvJDsYPR4XHUwMDAwXHUwMDEzOEt6SkpyXHUwMDBicko70lx1MDAxOc7BmurB7IQ2uZFcXPvPTlx1MDAxZlxc1+Hn2C/p06slkfa/QyxiXHUwMDEwKX6yiJVGvlVabjYn0oXI28yDLryO5dmo/nix21x1MDAxZvlm87hDs/Tq42j+a6WXrzp4x1x1MDAxZve18sPK5OV8XtH/48VQ2VwiynxcdTAwMWL++vuPv/9cdTAwMGZcYuGAVyJ9 virtual_size.heightvirtual_size.widthself.scroll_offsety = scroll_yx = scroll_xx = scroll_x +self.size.widthBoardApp"},{"location":"guide/widgets/#region-updates","title":"Region updates","text":"The Line API makes it possible to refresh parts of a widget, as small as a single character. Refreshing smaller regions makes updates more efficient, and keeps your widget feeling responsive.
To demonstrate this we will update the checkerboard to highlight the square under the mouse pointer. Here's the code:
checker04.pyOutput checker04.pyfrom __future__ import annotations\nfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.geometry import Offset, Region, Size\nfrom textual.reactive import var\nfrom textual.strip import Strip\nfrom textual.scroll_view import ScrollView\nfrom rich.segment import Segment\nfrom rich.style import Style\nclass CheckerBoard(ScrollView):\nCOMPONENT_CLASSES = {\n\"checkerboard--white-square\",\n\"checkerboard--black-square\",\n\"checkerboard--cursor-square\",\n}\nDEFAULT_CSS = \"\"\"\n CheckerBoard > .checkerboard--white-square {\n background: #A5BAC9;\n }\n CheckerBoard > .checkerboard--black-square {\n background: #004578;\n }\n CheckerBoard > .checkerboard--cursor-square {\n background: darkred;\n }\n \"\"\"\ncursor_square = var(Offset(0, 0))\ndef __init__(self, board_size: int) -> None:\nsuper().__init__()\nself.board_size = board_size\n# Each square is 4 rows and 8 columns\nself.virtual_size = Size(board_size * 8, board_size * 4)\ndef on_mouse_move(self, event: events.MouseMove) -> None:\n\"\"\"Called when the user moves the mouse over the widget.\"\"\"\nmouse_position = event.offset + self.scroll_offset\nself.cursor_square = Offset(mouse_position.x // 8, mouse_position.y // 4)\ndef watch_cursor_square(\nself, previous_square: Offset, cursor_square: Offset\n) -> None:\n\"\"\"Called when the cursor square changes.\"\"\"\ndef get_square_region(square_offset: Offset) -> Region:\n\"\"\"Get region relative to widget from square coordinate.\"\"\"\nx, y = square_offset\nregion = Region(x * 8, y * 4, 8, 4)\n# Move the region in to the widgets frame of reference\nregion = region.translate(-self.scroll_offset)\nreturn region\n# Refresh the previous cursor square\nself.refresh(get_square_region(previous_square))\n# Refresh the new cursor square\nself.refresh(get_square_region(cursor_square))\ndef render_line(self, y: int) -> Strip:\n\"\"\"Render a line of the widget. y is relative to the top of the widget.\"\"\"\nscroll_x, scroll_y = self.scroll_offset # The current scroll position\ny += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!\nrow_index = y // 4 # four lines per row\nwhite = self.get_component_rich_style(\"checkerboard--white-square\")\nblack = self.get_component_rich_style(\"checkerboard--black-square\")\ncursor = self.get_component_rich_style(\"checkerboard--cursor-square\")\nif row_index >= self.board_size:\nreturn Strip.blank(self.size.width)\nis_odd = row_index % 2\ndef get_square_style(column: int, row: int) -> Style:\n\"\"\"Get the cursor style at the given position on the checkerboard.\"\"\"\nif self.cursor_square == Offset(column, row):\nsquare_style = cursor\nelse:\nsquare_style = black if (column + is_odd) % 2 else white\nreturn square_style\nsegments = [\nSegment(\" \" * 8, get_square_style(column, row_index))\nfor column in range(self.board_size)\n]\nstrip = Strip(segments, self.board_size * 8)\n# Crop the strip so that is covers the visible area\nstrip = strip.crop(scroll_x, scroll_x + self.size.width)\nreturn strip\nclass BoardApp(App):\ndef compose(self) -> ComposeResult:\nyield CheckerBoard(100)\nif __name__ == \"__main__\":\napp = BoardApp()\napp.run()\n
BoardApp \u2585\u2585 \u258b
We've added a style to the checkerboard which is the color of the highlighted square, with a default of \"darkred\". We will need this when we come to render the highlighted square.
We've also added a reactive variable called cursor_square
which will hold the coordinate of the square underneath the mouse. Note that we have used var which gives us reactive superpowers but won't automatically refresh the whole widget, because we want to update only the squares under the cursor.
The on_mouse_move
handler takes the mouse coordinates from the MouseMove object and calculates the coordinate of the square underneath the mouse. There's a little math here, so let's break it down.
self.scroll_offset
to event.offset
.If the cursor square coordinate calculated in on_mouse_move
changes, Textual will call watch_cursor_square
with the previous coordinate and new coordinate of the square. This method works out the regions of the widget to update and essentially does the reverse of the steps we took to go from mouse coordinates to square coordinates. The get_square_region
function calculates a Region object for each square and uses them as a positional argument in a call to refresh. Passing Region objects to refresh
tells Textual to update only the cells underneath those regions, and not the entire widget.
Note
Textual is smart about performing updates. If you refresh multiple regions, Textual will combine them into as few non-overlapping regions as possible.
The final step is to update the render_line
method to use the cursor style when rendering the square underneath the mouse.
You should find that if you move the mouse over the widget now, it will highlight the square underneath the mouse pointer in red.
"},{"location":"guide/widgets/#line-api-examples","title":"Line API examples","text":"The following builtin widgets use the Line API. If you are building advanced widgets, it may be worth looking through the code for inspiration!
Widgets may be combined to create new widgets with additional features. Such widgets are known as compound widgets. The stopwatch in the tutorial is an example of a compound widget.
A compound widget can be used like any other widget. The only thing that differs is that when you build a compound widget, you write a compose()
method which yields child widgets, rather than implement a render
or render_line
method.
The following is an example of a compound widget.
compound01.pyOutput compound01.pyfrom textual.app import App, ComposeResult\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label\nclass InputWithLabel(Widget):\n\"\"\"An input with a label.\"\"\"\nDEFAULT_CSS = \"\"\"\n InputWithLabel {\n layout: horizontal;\n height: auto;\n }\n InputWithLabel Label {\n padding: 1;\n width: 12;\n text-align: right;\n }\n InputWithLabel Input {\n width: 1fr;\n }\n \"\"\"\ndef __init__(self, input_label: str) -> None:\nself.input_label = input_label\nsuper().__init__()\ndef compose(self) -> ComposeResult: # (1)!\nyield Label(self.input_label)\nyield Input()\nclass CompoundApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n InputWithLabel {\n width: 80%;\n margin: 1;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield InputWithLabel(\"First Name\")\nyield InputWithLabel(\"Last Name\")\nyield InputWithLabel(\"Email\")\nif __name__ == \"__main__\":\napp = CompoundApp()\napp.run()\n
compose
method makes this widget a compound widget.CompoundApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e First\u00a0Name\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u00a0Last\u00a0Name\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u00a0\u00a0\u00a0\u00a0\u00a0Email\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
The InputWithLabel
class bundles an Input with a Label to create a new widget that displays a right-aligned label next to an input control. You can re-use this InputWithLabel
class anywhere in a Textual app, including in other widgets.
Widgets rarely exist in isolation, and often need to communicate or exchange data with other parts of your app. This is not difficult to do, but there is a risk that widgets can become dependant on each other, making it impossible to reuse a widget without copying a lot of dependant code.
In this section we will show how to design and build a fully-working app, while keeping widgets reusable.
"},{"location":"guide/widgets/#designing-the-app","title":"Designing the app","text":"We are going to build a byte editor which allows you to enter a number in both decimal and binary. You could use this as a teaching aid for binary numbers.
Here's a sketch of what the app should ultimately look like:
Tip
There are plenty of resources on the web, such as this excellent video from Khan Academy if you want to brush up on binary numbers.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaU/jyFx1MDAxNv3ev1x1MDAwMjFf3pMmnlpvVY309MTSQCCsYe2nXHUwMDExMomTmCTOYidcdTAwMDRG/d/frUBcdTAwMTNncchcdTAwMDKM0400PVx1MDAxMNtJueqcU+de36r8/WVtbT16bHrrf66te72CW/OLbfdh/Xf7etdrh34jwEOs/3fY6LRcdTAwMGL9MytR1Fxm//zjj7rbrnpRs+ZcdTAwMTY8p+uHXHUwMDFkt1x1MDAxNkadot9wXG6N+lx1MDAxZn7k1cP/2n+P3Lr3n2ajXozazuBDMl7Rj1x1MDAxYe3nz/JqXt1cdTAwMGKiXHUwMDEw3/1/+Pfa2t/9f2Ota3uFyFxyyjWvf0H/UKyBko++etRcYvqNpVx1MDAwNDQ1lHP6eoZcdTAwMWZu4+dFXlx1MDAxMVx1MDAwZpewzd7giH1pXHUwMDFkzppcdTAwMTdnwVOnSW865dxWZn9jc/dm8LElv1bLR4+1frNcbu1GXHUwMDE4ZipuVKhcZs5cYqN2o+pd+cWoYlsw8vrrtWFcdTAwMDN7YnBVu9EpV1x1MDAwMi9cZoeuaTTdglx1MDAxZj3amySvLz53xJ9rg1d6+Jek2lFEU05cdTAwMTRcdTAwMDHDxetBe7XQ4GggWjNcdTAwMTCEMdAjrdpq1HAwsFW/kf7PoF13bqFaxsZcdTAwMDXFwTmiXHUwMDAwXklcdTAwMGXOeXi5VymoQzhjnFHNKSHy9YyK55crkb01RVx1MDAxZInNUJJcdTAwMTEmqVx1MDAxMmzQXHUwMDEyrz8mXHUwMDE0NKGcXHUwMDExMlx1MDAxOFT7+c1ssY+Pv+I9XHUwMDE2XHUwMDE0X3os6NRqgybbXHUwMDAzX2OYXHUwMDFhXFzTaVx1MDAxNt3o5WOUUoZQrYlcdTAwMTn0Rs1cdTAwMGaqo29Xa1x1MDAxNKpcdTAwMDO09F/9/vtcdTAwMDIwpYQn41RcdTAwMTNcclx1MDAwMsDMjtM9dpJxm72oVT047Fx1MDAxYz1cdTAwMTGze8C/Lo5T9k44xWF/XHUwMDEzqMrBW+VcZlxiUcA4XGYjVVx1MDAxYYdcdTAwMDHiR1HBXHJ2i1pcdTAwMDaqUdtccsKm20YkTIKrcLBcdTAwMTlcXHBcIimlXHUwMDEz0MqBOJpcdTAwMTIjXHUwMDA0cI5cdTAwMDRjMIpWMJJIzpn8NLCaT1x1MDAwMWucmKNY5VpQI1x1MDAwNZiZsZrb2bvZolx1MDAwZlutWq8rRHGDkqfrIFx1MDAwMasjePsnUUqIXHUwMDAxhVomULFGQKpcdTAwMWSthcbxXHUwMDAw0Fxu4CNBylx1MDAxZEJBMi1cdTAwMTVVRulxlDLtXHUwMDAwXHUwMDEwjlx1MDAxYU9cdTAwMThwS6xRlCrNSJ9Sq4dSr1bzm+FEjIJcdTAwMTKJXHUwMDE41YIowbWSM2PU69ZPN77dXHUwMDFmXFzlstmb6kaudbW3XV5cdTAwMDSj7zXjz4BRXHUwMDFjeUMkXHUwMDEwoVx1MDAxMa7EsGGQXHUwMDAygpRLg8rGjIXOUnN+yZVMsnF8Uu5YRyFccuCEzpBcdTAwMTJiXHUwMDFjoJQ5XHUwMDEyQFx1MDAxOJRQwlAshVx1MDAxYVx1MDAwNShcdTAwMTeUMWpibfxcdTAwMTlcdTAwMDCqmE5cdTAwMDIo+jBcdTAwMWMxodTM+DSsV7nNRlx1MDAwN7nuUfd8v51vcdVopFx1MDAxY59SXCJcdTAwMDJcdTAwMDFcdTAwMDEqXHRcdTAwMDe0pSP4VFx1MDAwZVCBRGVUSFx1MDAwNVIsXHTQO3ScXHUwMDFmXHUwMDA1UIZyYlx1MDAxOFx1MDAwMbWCXHUwMDEz/TSE6uRpXlKMXCJcdTAwMDSfXHUwMDE5oHv5ulxmy2euOLmqXHUwMDFmh8F5buN6p5NygGqOYVx1MDAxMZM4W0itXHUwMDE4lyNcdTAwMDBcdTAwMDVcdTAwMDdRY7hmiFWtR83HfPik7E5r+Dh8XHUwMDEynFx1MDAwNriWn1x1MDAxNzV9jlx1MDAxMdUyXHUwMDExoFx1MDAxOERcbowk+excdTAwMTBccjJnV0FwuXta7mZ36MZGePh0t5duiCqG8ZA0SmBgLVx1MDAxMYxmXGKiUjJcdTAwMDSwZIDgXHUwMDExeFAuXHUwMDA10VKpNFx0n1x1MDAxM1x1MDAwMFx1MDAxOWPKj1x1MDAxOVxccpzCcVx1MDAxMp9cdTAwMDN/P5AwwFx1MDAwMn955XsyLF+vXHUwMDE5XFxcdTAwMWTDUuT1XHUwMDA2Jjo28s2Th92jTKV0obY297fJ6WbLO9Drr+d9/33y2z5f/PW47rmt/Xz1kGarXHUwMDE33lGzenFcdTAwMTJcdTAwMGV/yo/Pd9vtxkPsfV9+S+KSxrBSsHgotSSXhu4/RiNhXHUwMDEyaaRBgVx1MDAxYVxuYd+i0eTOnEyjiluodNpe+omk3o9IU1x1MDAwMzrGx+nExuiEXHUwMDAxNjZCXHUwMDEzmc6QbTDWjSDK+099S0uGXt1x637tcWi4+uDE/lkz8e5cdTAwMGI9/LxnJFx1MDAwZZ25UfPLXHUwMDE2uus1rzSM6cgvuLXXw3W/WIzPXHUwMDFkXHUwMDA1/HBcdTAwMTffsZ2dRfNcdTAwMWJtv+xcdTAwMDdu7XzQtsUnK5qciTZCXHUwMDEzYSPSmVnmV0/Oj2vlzMP+9l6tK+tHJrpkqWeZXHUwMDExyDLGNNpcdTAwMTFcZnHYsOVHmXE4xnnKXHUwMDE4jFx1MDAwYthcdTAwMDfm9lx1MDAwNHdsXHUwMDFhXHUwMDFjuKVcdTAwMTBIOSm7x5hcdTAwMDPKSGyyTeRQYsbzJngpXHUwMDFlg3nC0oUmtXR4LWqSXHUwMDEzKqibIONcdTAwMDH6W/DduoON6FwiMsXb++vTXFzwcHK2XHUwMDE1h9o/9Vx1MDAxY+VtXGJrKlx1MDAxZI6ux3CDs7PUZlx1MDAwNMLCMTgmXHUwMDE4XHUwMDFhUUYwMljKcVx0UiBSTZokXHUwMDFj++Rcbj9cdTAwMDQ7XWpKJ6CXOlJcdTAwMTJF7dRtjb9cdTAwMWVcdTAwMDUvN4JK8vGGLFx1MDAxZNhlsXFcdTAwMWRPWHOFgVx1MDAxM5394cp+b/eCXHUwMDA2N1t1cXpPj8J6JnNZ99OuvUZcdTAwMDFcblx1MDAxYdpw9DFCXHUwMDFiMVx1MDAwZVxcITHg11x1MDAwMiNdJZeKZj9Fe5F7XHUwMDEy47t/XHUwMDE2v/Fu/1D8isRAVzFcdTAwMTQhXHUwMDE5XHUwMDFmz7fgXHUwMDFifVx1MDAxNU2/c1jyXHUwMDAytl1RXHUwMDE5kTO79HZcdTAwMTW0XHUwMDE3J1x1MDAxOFx1MDAwN4HKudFcdTAwMTKNOGUjXHUwMDEwXHUwMDA2h0hcdTAwMDGaXHUwMDE5gtJrlkHwh0qvfZzLmFwiep5s4TtGw88oyOe/XHUwMDFkdO/Pe3dunlSOXHUwMDAyUz2JVP3dwlb0d7FHt1x1MDAxZkpccpmYRqdAUTw4mUPaN/d6XHUwMDA10T5Sgp1vfDvdr9Jet1dLu7RjbzuUY8dcdTAwMTNcdTAwMDFExaPCV1ttn1NcdTAwMTP0JahasFxmMT5B2pmy8bdcdTAwMTK/iLRzQVx1MDAxM/HLJEhOyVx1MDAxY88p71xcssd446RVydz1mt6J37qst1ZB2y2G0XegfFNcdTAwMTR3zSeEhuhaKMNAg/LlQsPfNGjPTEi2v4e4o61cdTAwMDai8YRfXHUwMDA0vclVS1x1MDAxOP9cdTAwMTjCXHUwMDE5mT1cdTAwMDHf2rlcdTAwMTf+dnBcdTAwMWJcXJU293dP+d7N8U1SIUhqxFx1MDAxNyiaXHUwMDEyXGb4lFbow1x1MDAwMMRcYnCFI21lndBcblx1MDAwNZGnPadhn2VRXHUwMDFlL877qeGLTE9cdTAwMTRfyYnSqFx1MDAwNLPj9+yqVbx42pG128JZVVx1MDAxNKNm7f6gsFxu4lx1MDAwYlQ7XHUwMDE0XHJcdTAwMWJojFx1MDAwZlxy3vN4bKhcdTAwMTDZwI1SiObUii9lXHUwMDFh8Dao+UXgy03yXHUwMDAzepuZxLBRzVx1MDAxZVx1MDAxNz5cXFb35bedyq6f9erB8VngPVx1MDAxY6Q+rYF65lx1MDAxMCptvVxmXGLsj9G0XHUwMDA2c9D14pQsXHUwMDE1pfFAIJ3ySzlGK4LJX1x1MDAwNcBCJeY1NKP2QVx1MDAwMZ1dfm9Ubkfz4lG1ZIr5Mjts1VtVs1x1MDAxMvKr7MNHNPpoXCK0UWN5XHLEsCRGM2LQbi1ZaPKh8quFXCJGxiOan1x1MDAxYr06Oatsi4Ml9sbsqYf7w1x1MDAxM9K4PLy99MONh43QXFxH+fOktFxcauRXceUwjNgwNiNcdTAwMDTFS41AlztSoCnGIaGGkbRnle1iIFxmt3+Z3IOEZP+A3Vx1MDAwNMJQmD33kMtcdTAwMTauvcr1TeusXrjNPmRcdTAwMGav+fHxKuivXHUwMDA1sTRacS2YfVx1MDAxNqTHQUxt9lx1MDAwMf1cdTAwMTSVii9cdTAwMDPij9VfyVx1MDAxOcM2/ir6K6eU+INcdTAwMTbGMJgjdVbee3wqXHUwMDExt37VaoS3hzdcdTAwMWI8W8Omp1xcf7VE4bOVSURb68BGrUNfXHUwMDE2gSphUcGXeibyXHT6y4kkiH4+T5nqXG7jXHUwMDE3ZGL2gffLis1cdTAwMWNld1ftzVMhzEGvfXfwKFx1MDAxYrVcdTAwMWJcdTAwMWFcdTAwMTb2V0F90Tw4VKBNsE/0uIxVwP2AMChbXHUwMDFiheBcdTAwMDK0walVX4bmXFxY+/CLgJcmi68xVGCwMrv3velcdTAwMTVOdffkXHUwMDAyrlx1MDAwZnfunlqd/cvk1Vx1MDAwManRXjSLqL3CYM8zNbR+91V7cUKWiFrN4s44ndqL5uHZ//xcIvCNXHUwMDE1q43AXHUwMDE3XHUwMDE1XHUwMDAwea7nSPxcdTAwMTa7VffxdrtcdTAwMDS3j3fu08XxiV9/zK6C9lwiT1x1MDAxZEJcdTAwMThcdTAwMDDKL1omOlx1MDAxZb4pZp8/UvtoWaU48Vx1MDAwYii9gPj+eZxvUq0+Jcm6S23yXGKHi8yM3MKW2cs8VkpcdTAwMTc7ep9lg9Pc0Vx1MDAwZZUrgVxcw1x1MDAxY3RcdTAwMGKEXG4lXHUwMDEwejCCXFxcdTAwMDKOXHUwMDEyNppcdTAwMDctUZ2TXHUwMDBi9lx1MDAxMTeuWFx1MDAxMLmxRN1cdTAwMDCqZFx1MDAxNJv9dYHA4lx1MDAwNYbLXHUwMDE2679cdTAwMDJ3Ql1P7mJcdTAwMGZuu7RLOlx1MDAwN/eiXFwq34WN2FQ6XHUwMDA0sbnrelx1MDAwNKNcdTAwMWFiOYPFllx1MDAwMsQqUN5YXG4wNC6DlVx1MDAwMGLoxFlXXHUwMDAyRI1m0jKAoVx1MDAxYlx1MDAxOK35J9NL/pN4XG7JZUmKISbto6qZaXr+UKrlNk0+XGLK7u5Fdvss4zG6XHUwMDEyNLXPXHUwMDBlKWFcdTAwMTjWac3VyMNxXCLt7MPxP1xmX5VKtkjLsHSSXHUwMDFiXHUwMDFhI6mdgDBcdTAwMTD5+PU0n09cIppcdTAwMDZcdTAwMTLRxUhERTKLXHUwMDA0XHUwMDAyRtrtNWZmUY5+bfaK1b2C2JK1m8JJt83yuVVgkVbELoanXHUwMDA2JzOiOZ1AI0BJsfGzkmy0XZ862VEmXHJX8bTxz8Mkllx1MDAwNiaxXHUwMDA1mVx1MDAwNIllhuhcdTAwMTjR/+NcdHpmJu2dbXSzl5fhrp/fyarr6Lq3ebGzXG5MwlDGYVLYXHUwMDFiRnuoKYwxSYKtz5bckPhjoHdl0qRcYmeMSZRcdTAwMTOpOPC5kqGrwiSRXHUwMDA2JolcdTAwMDWZlFxcs6BcZlx1MDAwN61cdTAwMDWfPf66XHUwMDBmz13aOFx1MDAwN5/Ip8fbsOh+O6+vRNZcdTAwMTZcZjhcdTAwMDLjL4M84dyIcVwiXHUwMDAxKENcdTAwMTkwW8r9z1x1MDAxMkmjs/uUzS8+n0gyXHJEkouau+RtXHUwMDA3XGLaXGKGnTk7k85uy3tQb3qV7Y2n3YjSXG5/MKvBJC5cdTAwMWRFtX28rFx1MDAxNWej1T9cdTAwMDRcdTAwMWPWL4RcIooyPeVcdTAwMTHexzOJc6FcdTAwMTVTn7BR3OdcdTAwMTNcdNJAJFiQSDzR22HQIKyhmWNKOi+b/MmFyLiFfWRJJLdcdTAwMWW71XBcdTAwMTWIJFx1MDAxNbo3YiTBXHUwMDE5WFx1MDAwYi1GyjhwSuKcUlx1MDAxMFx1MDAxY12VgOQyjqWIRGYhkt1yz65cbvhcdTAwMTlnJJVcdTAwMDZcIqlcdTAwMDWJZJL34Fx1MDAxNVx1MDAxYeNaXHUwMDEy31XyLVwi8XLQpHukWij1NiumSup1fytpOUuqiKQpdZRdUkw44lxcqdFcdTAwMTlJONTuzStcZmg2bUnLx6dcdTAwMWJcZlx1MDAxMcauqvlcdTAwMTmJxNNAJL4gkZLTdlx1MDAxMuyihDm2XG7ptIS/V2td3T1G3V3voFx1MDAwYse3Okqg0WLbslx1MDAxNd2w4r3zdlLaIVx1MDAwNqdcdTAwMTljjJB0JDrCOdlcdTAwMTFcdTAwMDKhi7GRYYpM2XyVgyhcdTAwMTXUdFxuTdyYTc6U9pbcSPGu+1a+XHUwMDFjWIGd0pZjZzZodqJ//Ttcclx1MDAxY/3RlKlMfe7QXHRUTa5BXHUwMDEz9lx1MDAwYlx1MDAwM0Do2esgplx1MDAwZnA6d1CU4IDk6Fx0cUJDYzi8fkhcYuZQTe3G2VxiMymSXHUwMDBi2Fx1MDAxNyYqY1x1MDAwZfpVQFx1MDAxOVDcME5iNVx1MDAxOINcdTAwMWQ+ibGb4jNb1Gp3XHUwMDAxZWPfi1x1MDAwMFx1MDAxYVBr9Fxc+/ksOlx1MDAxMVwi1WQ8mzJcdTAwMDfVwshtR5t+UPSD8vpQXHUwMDA1xsu3fGRnkPw+OVx1MDAwYlx1MDAxZNtK4lx1MDAxMG1QSXH0XHUwMDAwhUzSwY5Ltlx1MDAwYtymXHUwMDFkYUdcdKLQunGFWGaoxi9nXGaqPLyg+Hajpm9cdTAwMTVcdTAwMTdrVIZYzFx1MDAxOIyUXHUwMDE5XG68xMbJsUZRh1GluMBwXHUwMDA1zyGcwVijam5cdTAwMThtNdA0Rtj5J1xyP4hGO7nfm1x1MDAxYpbYXHUwMDE1z1x1MDAxZJNcdLyp+LFRXHUwMDA1aNp3XHUwMDFjluzBb2tcdTAwMDOW9P94/f2v3yeenVxmYvszXHUwMDBl38H7fYn/f26bkVx1MDAxOPditCdcdTAwMTiXc+z+ynVR+81vN6Ql/bPcXHUwMDE27+RPL5LyRynRLiPAkYaAfC4vieVl7PVcdTAwMWGYY5fzckSixDebkjlaVLtgtlx1MDAwNKyiknE6X1n34i4jXVx1MDAxYtss5zLyXHUwMDBmPkaE6bBcdTAwMTmvbVnMZ0Dy5pdGg/0yhtlr3adcdTAwMGZxOrmqwG4wK5GLz1x1MDAxYt5cdTAwMGZzVWpHKk5sZa4ynCfv0bMwVzGmt3swo1ZcdTAwMTiceDSZQF1cdTAwMDNcdTAwMGVcdTAwMTdcdTAwMTLDfi2pxKhg/NuXXHUwMDA04OVcdTAwMTBflPuRNmNhrs1oM6ZL/vCMjlx1MDAwZVx1MDAwMnWMK+A4hDqW9Xid0rmDc5/hKLtcbm9cdTAwMDAnw8VsxvTd2WKNsjWxXFzbXHUwMDFkXHUwMDE57JdcZlHB2ViTKDpcdTAwMTFA8ae2JsNcdTAwMTiQK20yXHUwMDEyXHUwMDExbH8yY+B9L4+RuMaXXHQjQZHZM1x1MDAxObXC3XaVX3tX8p7C42XuW/787C7dskWtdzOGXHUwMDExm1BcdTAwMTdGjqyNXHUwMDA0YVVNc1uCi1xu9/6qJSdsLz8xXHKo3u/LYaaZi4+srkVlofEt8j/aXFzk3Duvllx1MDAwZW/xoylcdTAwMGJaiymlTVx1MDAwNsNcdTAwMTJcdTAwMTLfjuvNXCLBqSOcVpIyW9XE7ZbwXHUwMDA02Ti8f1x1MDAwZqC5QFVcdTAwMTRMKrvNLUwphl+UpoY7dr9jw6xcIlxuPcFbyH5cdTAwMGLt14xxobiKnfLDW6CEXGJit8H4XHUwMDE0b7Ew1Wb0XHUwMDE207V+LZ7CwFHhxm6/ZPc+l3w8WWB3PVWKgTJcdTAwMDKHj6nFjMX0pVx1MDAxZSNuXHUwMDA3m4KjiZG60XbhsFx1MDAxZW+UcLTRXHUwMDAynaxcdTAwMTREYYipV9pbZJJcdTAwMTBsf8awm2Qtvry8/7rbbOYjRNzrcCCW/eKLQlx1MDAwZm5yvet7XHUwMDBmm5NJZnn25aU7rep4/SVL3798/z/RhT5iIn0= 901245673Input()Switch()Label()There are three types of built-in widget in the sketch, namely (Input, Label, and Switch). Rather than manage these as a single collection of widgets, we can arrange them in to logical groups with compound widgets. This will make our app easier to work with.
Try in Textual-web "},{"location":"guide/widgets/#identifying-components","title":"Identifying components","text":"We will divide this UI into three compound widgets:
BitSwitch
for a switch with a numeric label.ByteInput
which contains 8 BitSwitch
widgets.ByteEditor
which contains a ByteInput
and an Input to show the decimal value.This is not the only way we could implement our design with compound widgets. So why these three widgets? As a rule of thumb, a widget should handle one piece of data, which is why we have an independent widget for a bit, a byte, and the decimal value.
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVdaVNcdTAwMWJLsv3uX+HwfJlcdTAwMTdx6VtZlbXdiFx1MDAxN1x1MDAxMyDAYEBcYiPWXHUwMDE3XHUwMDEzjpbUWkBcdTAwMWLqXHUwMDE2ICb831+WzKDW0kJcdTAwMGLybVx1MDAwMSYw9KKurjqZeTIrK+s/nz5//lx1MDAxMvXawZe/Pn9cdFx1MDAxZYt+vVbq+Fx1MDAwZl/+cMfvg05YazXpXHUwMDE07/9cdTAwMWS2up1i/8pqXHUwMDE0tcO//vyz4Xdug6hd94uBd19cdTAwMGK7fj2MuqVayyu2XHUwMDFhf9aioFx1MDAxMf7L/cz6jeB/261GKep4g4dsXHUwMDA0pVrU6vx6VlBcdTAwMGZcdTAwMWFBM1xu6dP/j/7+/Pk//Z+x1nWCYuQ3K/Wgf0P/VKyBio9cdTAwMWXNtpr9xlx1MDAwMkMtwGqAlytq4TY9L1xuSnS6TG1cdTAwMGVcdTAwMDZn3KEvT9XLm28n33vfOrlCrprJt2VcdTAwMTFLg8eWa/X6adSr95tV7LTCcKPqR8Xq4Iow6rRug4taKaq6XHUwMDE2jFx1MDAxY3+5N2xRT1xm7uq0upVqM1xiw6F7Wm2/WIt67iXZy8FfXHUwMDFk8dfnwZFH+ktZjzPNJVx1MDAwN8tBoVQvZ93tdNBDg6BcdTAwMTQ3hiGzI83KtOo0XHUwMDFh1Kx/sP7XoGFcdTAwMDW/eFuh1jVLg2uwqIKyXHUwMDFjXFzz8PyyXHUwMDEywWOCc8HBXGJgTL5cXFFccmqVauRcdTAwMWGiwZPGcC0541x1MDAxMjRcdTAwMGXGLlxm+oNcdTAwMDKSziM1cXC3e357v9RcdTAwMDfIv+Nd1iw9d1mzW69cdTAwMGaa7E7sxEA1uKfbLvm/xlx1MDAxZZTWRiDn2sKgXHL1WvN29OPqreLtXHUwMDAwLv2jP/9YXHUwMDAwp1x1MDAwNEZMXHUwMDA0KpeaXHUwMDAzXHUwMDA3XHUwMDEwM1x1MDAwM/VwL7NxcJcrlc+yqnfUqu9njoVcXFx1MDAxY6j8jYBKw/5cdTAwMWFStWeVRNRouZExXHUwMDAw9G9X6Fx1MDAxOSOBsGxBW7RmXHUwMDE5pEZcdTAwMWS/XHUwMDE5tv1cdTAwMGVcdTAwMDFhXHUwMDEyWtFTglx1MDAwYlx1MDAxNExcdTAwMDLAXHUwMDA0sFxuxTxcdTAwMDPMXCIqISRcdTAwMTiuxsDKjdBKk5Z5Z2DVXHUwMDEyXHUwMDEysVxuKNFqasrMWL3LXHUwMDE2dq5cdTAwMThuPcHm4VZWl0s3XHUwMDE3359cdTAwMTKwOoK3v1x1MDAxMaWSXHUwMDE5bYVS1oBRYyils6TMNKCyTOtcdTAwMTWiVHhcZpQkUaGnWW3GYcqNp1x1MDAxNFx1MDAxM2hcdTAwMTTjSlx1MDAxMKTHYGolkKDhOqrUoF6vtcOJXHUwMDE4VUYkYdRcdTAwMDJaxpTUM0P01lx1MDAwNPzkoFx1MDAwMPmbrd7RiVx0ermjy5NFIPpWXHUwMDE2/3WIaushXHUwMDExXHUwMDFjXHUwMDFhc6GsiGnK/u1cdTAwMWE9lFx1MDAxNmjotbJWxPpqXHUwMDExk1/2JXGLcXiCoDZwLq1cInvOkSz3XHUwMDA0m889qVx1MDAxNFpSoYxcdTAwMGLBUY/iXHUwMDEzJFx1MDAxMVx1MDAwMuBkXHUwMDAy31x1MDAxNUC10ElcdTAwMDBcdTAwMDVcdTAwMDKv5ZbPXHUwMDBl0KenzUaNXHUwMDFkXHUwMDFlbaszWVx1MDAwNX9v92t19yzdXHUwMDAwXHUwMDA1pjwynMqANcYy4GJcdTAwMDSiwuNkPi0zilx1MDAwYkviuiREXHUwMDBiRDlXXHUwMDA1UU28lIGAd4ZQm2jmOelcdTAwMTR6aTE7QkWb1W52oqPsTvXmQF1cdTAwMWbnXHUwMDFizcJWylx1MDAxMSrQXHUwMDEzXG40k2RnLfkvOIJQwlx1MDAwNYGT/Fx1MDAxOYtkXcUo/5hcdTAwMGahwFx1MDAwYsaoVSGUXHUwMDEzOrVUxM7WXHUwMDBmoq84TixcdKRgiXxLLYWcXHUwMDE5pfXNzon6epCv6G+t81x1MDAwNopM/qmeSzdKqas9TV6xXHUwMDAy8kaI5ckhkHJpyLcnT8pcdTAwMWHpPEi1XHUwMDE0Rsvl8iSATkBkrM//a8aJf1x1MDAxMvNcItdtXHUwMDBlXHUwMDA0/lx1MDAxN1x1MDAwYlx1MDAwMzSI5yM/k4H5cs/g7lx1MDAxOJqi4HHApGNDn+/ulvf38o9cdTAwMGbly1x1MDAxM7v1vWefXHUwMDBluzdfXq77+fzbb0P9UDtjgJc2XHTvhlx1MDAwNpuTzzl7mGDyO0+Ge9UvVrudIPWAV+LtXHUwMDAwP9X5ilx1MDAxMZaBtzVcdTAwMGV74thcdTAwMTaRpzRcYjBcdTAwMTjrVjM6rT31jTtcdTAwMWI6uus3avXe0HD1sUn989nGuy9cZuh5fcVrhq7crNcqzb52XHLKw5COakW//nK6USuV4kq+SFx1MDAwZvfpXHUwMDEzO/uz6OZWp1apNf16ftC2JazKlLCxZuQsky2dnfucibNN0auD3GiI7c7DztNcdTAwMDO/a6ZdzFx1MDAwNFwiiVx1MDAxOSlsziwoXHUwMDE0w6E4XHUwMDE0xrMo+yFcdTAwMDUgeK8uXHUwMDEyh8LTTFklXGaToKScXHUwMDE0i+PcU9pKbUnyXHJcdTAwMDNmx2NxKFx1MDAxNbXXmnnEcCHrk1x1MDAwZVrEWWL4XHUwMDAzhJRcdTAwMDaQ3P6ZXHUwMDAxfHC2KcOL6Nthpn2OXHUwMDA15V/dV6r3f/+8x1xmIFbSM8BcdTAwMTVcdTAwMTB7N9rYYVuByDyJZCVcYlrWWFx1MDAwMtdS7FxiWZFJPclQeIzcXFxcdTAwMTIoJS11PUxcdTAwMDAweORBanJ6ibxcdTAwMGIjzSh+OWlcdTAwMWOpXHUwMDE5zuNhrjV8XHUwMDEzWVx1MDAwZVjqSnLI2OzwbTX09fZ2ttjL3NbuNkVcdTAwMThcXFx1MDAxZUo/7fpXcu2Ri0lcdTAwMWVcdTAwMWYpNOByXHUwMDFjuoygXHUwMDA0XHUwMDFjOCNcdTAwMDeUL1x1MDAxNcH7XHUwMDFkXG6YMcmN4UZ/XHUwMDEwXHUwMDA0y+T5PMWJunJtZvdLg+2Tk30ondejnVx1MDAxZvLxurXFi9dmXHUwMDFkXHUwMDE0sETjcSFQgVx1MDAxMJprMCMoXHUwMDA2wo22StF1xDFSq37pXGZcdTAwMTNcdTAwMTaIQ3xcdTAwMTD0qsTgtJFSxVxy6WvQLdT3KmdcdTAwMDeb96fFQGm1n+ncdjN3aVe+XHUwMDAwzCNqa1x1MDAxOTjSKKRcdTAwMWVcdTAwMGVOo7BcdTAwMWVjKDRKXHUwMDAxXHUwMDFh5OqczDdSvmQwrTBcXHxcdTAwMTD4XG6ZXHUwMDE4XHUwMDE0JFx1MDAxMWdO+9qZXHUwMDAxvHt4e3pZzp0/tJqN/ePK9SPb7lx1MDAxZa+D7nUgNlx1MDAwNFFitprQg6PK17lwmoNVTFxuS65cdTAwMWMug+J/XHUwMDE4ZVx1MDAwMjshfv1cdTAwMTbql8yHm1x1MDAwMjRcdTAwMWaE/VxuTI4+SO2yo+RcdTAwMWNJa2fnu5eH29vlXHUwMDAzfVx1MDAxNlRcIlZu4o/N9CtgRbzBcMVcdTAwMTjXqK1cdTAwMWVGLpFfXHUwMDEwXHUwMDFjmJFutmOFXHRcdTAwMTZvo35dhlx1MDAxMCNcdTAwMDb/QbQvxnyVUfKgLLWC4eza965Z7+BBuVuA4kE2f3GQffpRTprZTpf2VS7zhli+1eSxas3GMcystYJ4XHUwMDA0MSrUy8VcdTAwMWVWqX1dviZIXHUwMDBlXHUwMDFmxHNDlpzdpvv5fHaOXHUwMDE5xd5pMTRFznd2s51K5nLv5OvGblLKcGq0L+fWI8IghZtHUVxc4Fxidok5SKZcdTAwMTDRzX2DhKWYw+/gv1x1MDAwMP08zY+igE1y7EGgy5+Nx9deQzDkq6adLVx1MDAxN24u9+5cdTAwMWHb19nKg9pPQdL7bCgmssQ5aWF0UbIxXHUwMDEwI1x1MDAxZFdSo1Nvy2VvrFL/KlwiOvTNPlxi+0WTXHUwMDFj+yW/jaivkLOjt3rQyYeNw/bOWXi3u5FcdTAwMGZt8VtcdTAwMTZTr3+N9lxiXHUwMDEw5LZcdFx1MDAxN26B8ehcdTAwMDO4XHUwMDE4XGZotDruLKRU+yrSOZqA9EHUr9TJXHUwMDA0wjDUXHUwMDEwp1Kv4fe+yO5OK7WwsbeFm9cnd5FfbZXXQvtcdTAwMWFDXHUwMDA0V1x1MDAxYmktM1x1MDAxNuOZXHUwMDE4LyBcdTAwMTY0NuS/uUVYfLnMpJXyX+E+wX6U2K9MzpyHflx1MDAxN7mMlpnxe+37aifb/da5rCt21oyAIT9Ku/5Fhlx1MDAxZVfoXCKmjjnwUeZgPcFcdNZklC1PfeqDXHUwMDEyaDR5mlx1MDAxZlx1MDAwNL5KJUZcdTAwMWaASa5cdTAwMTXOXHUwMDEzfrjZle3vR+qSseuef3WV2z82lfN1UL9cdTAwMGXC5KGRLTJWS8PNuPrlVmtcIsdcdTAwMDC4ZHL9SpWvZkyTz/1RtK/iidqXM6Vccqp4mspr8O12evuHXHUwMDE13Fx1MDAxMVet4F5vZXgzb3qp176qr32ZtVx1MDAxY7hcdTAwMTAxr/1F+2olhVx1MDAxNkwxxLRcdTAwMDd/wdL7WMnYXHUwMDA3XHSfUZcl6l9LbJBjXFzdvFx1MDAwNuDHvdtKeHPh8+vHXFw1v3lzXHUwMDFmXT9l10L/9leBgjbKXG5Ffc/lOIpcdTAwMTGFW+5O/2C5OYzVTr4xg1x1MDAwMqx8P5mTSan1XHUwMDAwidDlXGbBXHUwMDE5ojnm3W7bX4PM1VnQ/V45yD49Ni/CcmEt5o0lck9cdTAwMGLnsVx1MDAxMWqJXHUwMDAxj0RcdTAwMWaAk/7lXGKSVLNEmMJcdTAwMWRcdTAwMDRcblx1MDAxZlx1MDAxN0RubLRcdTAwMDdQZaPYXHUwMDA06Vx1MDAwMtBcdTAwMTg3mqvMrid/NVx1MDAwNpHFsutjXHUwMDFm8Ep2/VDPXHKS63HowlmT66NWOymzfuhcdTAwMDVG0+jZ9Cz6JFHSyYtbXHUwMDA10VFrxFx1MDAxY9lvW6J9b3PhUfugoDG30WlcdTAwMWSZncw6SFx1MDAxMlx1MDAxYfQss8LNYVx1MDAwYsVwNInTZVx1MDAxOXEpXWcoMlx1MDAxMlOWYS8jSpNoy7gkUWPcYtx5WMq6XGJcdTAwMTKkQZBgMUFcdTAwMDKZKEnkyoJi8VmtV1NB7ptf4ewqd1x1MDAwMUenbV3M5i+KrcZaXGJcdTAwMTKxXHUwMDE0cNEnRiBlio+LkbHOPLtcXDzF/1aLhIKcXHUwMDEyXHUwMDFiX4L7flx1MDAwNImnQZD4goKkk1x1MDAxN1xuK7DkW1x1MDAwM5+d3PXC6sPj7db53fX33Yvz5tXF1d7xWqSlXGJcdTAwMDaelsI4v1x1MDAwNInLjYpcdTAwMTLJmZGMPG9cdTAwMTJcdTAwMDNjkr2SpSRpklx1MDAxYjIuSeSSXHUwMDE4l1x1MDAxZP5cdTAwMWW5XHUwMDFkpkGScEFJSk4vUORcdTAwMGVYXHUwMDA1c1QuMVdcdTAwMDf4taDOiz/OXHUwMDFh55nocKdcXM111kGQOCrPOs+USVdcdTAwMDVCglx1MDAxYZMkblx1MDAwMazi2lx1MDAxNV5YlZs0kyhx5Vx1MDAxMu90LMfs/UiSTIMkyUXJnUmSJFJ+Uiszz1wiocvDo4ctc7b740T29i5yZYjqtbVcdTAwMTAlsjVcdTAwMWVcdTAwMTlcdTAwMWNcdTAwMDZE4MhcdTAwMWRcdTAwMDJtxkTJoGJcdTAwMDY12Wlr2GrcpFx1MDAxOa1cdTAwMTJcdTAwMTlPRoZxrnDYusiSSoMsqVx1MDAwNWVcdJP5XHUwMDFk00R7mJhjyX6mlM1sh+a8cZMrXHUwMDA03ZNWbvPworJcdTAwMTayxJXHXFzNLGt5v0TTqCwxz0pE1NZVfF2VKLFcdTAwMTlFXHRcdTAwMTmq91x1MDAxObzTaVx1MDAxMCW9mChxlrz+RLmiySDZ7GZcdH50wzB6aLDj8z14ZFmf5UpJ1S9SJUpCcY9cdTAwMTO3XHUwMDAzMJqTXHUwMDBlMSPRO2Y90iuuQFx1MDAxZFx00rRVKKuPOmhUXHUwMDFhLYjfXHUwMDEzdEAyw/DbJEmkQZLEXHUwMDEyxWRscja2XHUwMDE0ynKNZnbD1Ny/vTu4lFulzrm82snrS773mH/jXHUwMDE5/ZJcdTAwMWZWg1x1MDAwNFFiXHUwMDBiiZJcdTAwMDTjITdMMlx1MDAxN1x1MDAwZZdsJOrAhMcsXG6ppNJkqKfU4Sha7nN/qihNndN3XHUwMDE1pN1cdTAwMDNcdTAwMTQ3rmrNpNJlWnmayKawQjBqT9xxe/amXHUwMDE4J1x1MDAwZahQvZ3dej4xUrLsOdRkbOPh4TJ7XFyyt5uV3Yub3o5ccl/ebFxihn6n03pIYc0ySF6PS+4zMVx1MDAwMFx1MDAwMDV7XHUwMDE0+yravc0yntssfisp2/leOdkpJk1cdTAwMDelR1x1MDAwNJRcdTAwMTSeXHUwMDA1REt9bp1cdTAwMWIzJFx1MDAwMsJcdTAwMTJt45qTOpBcdTAwMTIlT15Rs6xcdTAwMDRALD9jSt0yJVG4+mlvZ1HWXHUwMDA345Ps1exF0bZq0elDjVx1MDAxOMw//2ey5VqsOtqilivenKnC+6tjJ0hvPFx1MDAwM2tsa1x1MDAwMtMvkzBHStr0oU5pSppC6TF62X6VXHUwMDEylzI5Yr9cXD14QSBcdTAwMTP0rfiUelx1MDAxMEvbL+Zxi1x1MDAxMo0w9CBcdTAwMTNPm1x1MDAxZlxis/Ikama50UowXHUwMDFlr/k6qMVJhpjFd5RIZVHCMPI70VatWao1K1+Gsoqet4XZn8Eg9CW22O1rbk+7jG3JXHUwMDE5aMuJdsjYRVx1MDAxNb9Nl1x1MDAxOI+4LVxyN/F+RaOtn89cdTAwMGbSloJm6fVcdTAwMTZNZ2lDLVx1MDAwMueZ0oOk1Vx1MDAxY21sqeVLk8Ajb11Zolx1MDAxY9KQbVx1MDAwMDueS1X3wyjTajRqXHUwMDEx9XyuVWtGoz3c78pNJ+LVwFx1MDAxZlNcdTAwMWH0UvFzo7qg7T5xWIlcdTAwMGZ++zyQlf5cdTAwMWYvv//7j4lXbyRj2H2No3fwgZ/i/y9W2FFPKSymjVuKPUf91Ivi9cHTRifs7FfK/KZ519RVm5RcdTAwMWSeXHUwMDFlJmKkJ1x1MDAxNaGNSctcdTAwMTSPr2v5xUS0p6VcdTAwMDRgVkvNpuSkXHUwMDAwKUO/sLguI5XpKdRu6p5+XGJpJySqXHUwMDE4XCLj5NhKrV1cdTAwMDaeVLFuet69QjO0Kp4/uUqeclx1MDAxNvpPcHxRr1x1MDAwNyivRKF1nrHifM24uJ1qzSWNylx1MDAxY8vbj/Ld7t2PTDPfXHQvXHUwMDFl7cVcdTAwMTM8QEGnXlx1MDAwMiR6iEBcdTAwMDZbWaflcZiLS4PkXHUwMDAxXHUwMDEy+pnb1YdjMlx1MDAxN19WXHUwMDAyJm4sNKGGsHSRUivecCZ8fUC+JFx1MDAxOe9FwX6z3Y1SQsZjzVmMjHNIXFzexFx00W5XojnSWKZcdTAwMGZ1Ssk4MOOJX8XXmDDkTVx1MDAwZlenkFp5XHUwMDE2hVx1MDAwMSO1ZdM2XHRbVnyJqlCPc86MsYbHp/hfhNlcdTAwMTJ5c3tHWGO4dcHkMdnWxEJcdTAwMTU19H1Q8en2IEZ8N4j5up3C0FxyoUFr9ThcdTAwMTfXnuHSXG43TSU1qlx1MDAxNyY4J1x1MDAxOZ/O0obdXHUwMDAz0vi/Qn9cdTAwMTJVrNrugIuvNfVOXHUwMDA0rPvaXHUwMDE4w+qbXHUwMDEyb56Y6kBcdTAwMWTNNeBcdTAwMWMxhLMrv36+fdPbPtBfe49cdTAwMGa7ufvd3lsvKp7GOlx1MDAxNtySS3jo0qxcdFmCSzWyuSFnrqKv1lx1MDAwMiXTgvh3Mu2QRYustFx1MDAwNPFcdTAwMTbcI34vgLqd+t5MmGJcdTAwMTJgPCW0KzFM9IjJ8Z04ibZcdTAwMGJt4uuaVslJXG7lw9ZtXHUwMDA3mtunnfLpcclcdTAwMDS3N42t9Vwi3skxcCBcdTAwMTJKqnCeXHUwMDE4+OZjq7jTuS40N+Do/vvtzs2VLrdTL1x1MDAwMZxxr79lXHUwMDAwMuVKQlxme56ESE/2tylSbqNcIm2SXHUwMDBim/CC8UWwXHUwMDA08eaTplUnXHUwMDEwb7fRXHUwMDE1zLVpwHvB+PK8e6e/jXN6iPdLe1x1MDAxNmPekFxck95tXHUwMDFlIIycI+11+linlHiTufBcdTAwMTi3RJtcdTAwMTVze9uO7NiHzPFcdTAwMDdcdTAwMGJCMUcweHJCxNLia0hRKCPIXFxcdTAwMTJNIco2gXlL44JLxlx1MDAwNbpcdTAwMTUqsmJcdTAwMTOkW1omh2pcdTAwMDGvM/Weblx1MDAxMoapN2Ouklx1MDAxNtFu5crzgIs7j1Nd8MiddDXPXHUwMDA1uVOAOFx1MDAxZXaeiX5PJ2tD9Jsxji40LJjWNGh2kFx1MDAxZftOXGJ4MnDd1zhkk1x1MDAxOPin5yd88dvt04iA9tL/XHUwMDA04VrpWV1cdTAwMGZe88t9LXjYmryjm9vU7dNzhzrFXHUwMDEz9Fx1MDAxN3D//PTz/1x1MDAwMcOHY8gifQ== 901245673BitSwitch()ByteInput()ByteEditor()In the following code we will implement the three widgets. There will be no functionality yet, but it should look like our design.
byte01.pyOutput byte01.pyfrom __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258abyte\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Note the compose()
methods of each of the widgets.
The BitSwitch
yields a Label which displays the bit number, and a Switch control for that bit. The default CSS for BitSwitch
aligns its children vertically, and sets the label's text-align to center.
The ByteInput
yields 8 BitSwitch
widgets and arranges them horizontally. It also adds a focus-within
style in its CSS to draw an accent border when any of the switches are focused.
The ByteEditor
yields a ByteInput
and an Input
control. The default CSS stacks the two controls on top of each other to divide the screen in to two parts.
With these three widgets, the DOM for our app will look like this:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1daVPjSNL+3r+CYL/0Row1WXfVREy8wdnc0OZo4O1ccsLYXHUwMDAyXHUwMDBijG1kmWtj/vtmXHUwMDE5XHUwMDFhSdZh2fiiPUPTLclyuiqfzCezslL//bKwsFx1MDAxODy33cW/XHUwMDE2XHUwMDE23adqpeHV/Mrj4lx1MDAxZvb4g+t3vFZcdTAwMTNP0d6/O62uX+1dWVx1MDAwZoJ2568//7yr+Ldu0G5Uqq7z4HW6lUYn6Na8llNt3f3pXHUwMDA17l3n/+yfe5U79+92665cdTAwMTb4TvghJbfmXHUwMDA1Lf/1s9yGe+c2g1x1MDAwZd79//HfXHUwMDBiXHUwMDBi/+39XHUwMDE5kc53q0Gled1we2/onVxuXHUwMDA1VET0XHUwMDFm3Ws1e8JSXHUwMDBlVFx1MDAxOcPDXHUwMDBivM4qflxc4Nbw7Fx1MDAxNYrshmfsocUr9nK7d9N9rJtm6eB43Vx1MDAwZs5L5+vhp155jcZh8Nx4XHUwMDFkiUq13vUjMnVcdTAwMDK/dev+8GpB/dfARY6/v6/TwkFcYt/lt7rX9abbsd+fvFx1MDAxZm21K1UveLbHXHUwMDAw3o++XHUwMDBlwl9cdTAwMGLhkSd7hWFcdTAwMGVQQY0xRIDR6v1s7/1cblx1MDAxY6GZltQoKpRhfXKttFx1MDAxYThcdTAwMTMo17/EXHUwMDE1q1V5KNllpXp7jeI1a+/XXHUwMDA0fqXZaVd8nK/wuse3b0xEONB117uuXHUwMDA3eFCa8PPc3rhcdTAwMTOliFx1MDAwNk01fz9jP6W9WeupwH+iI9OsvY1Ms9tohILZXHUwMDEza/1qXHUwMDEzVZ2Y+lx1MDAwNO5TKG1ksluVdndcdTAwMWJMUN7erizz48btaaVZXny/7p8/0m/7+ubO3vdye31cdTAwMWb2dlx1MDAxZb7V+MWe/H7sPsU/5dfnV3y/9Vj0vjv+wVx1MDAwZn61U/7O+ePekeeXvNXvlWL3fftbOIDddq3yqutEKlxyXFyDUSZcdTAwMDKXhte87Vx1MDAxZttGq3pcdTAwMWLC40tE4Fx1MDAwNCxj41x1MDAxYUGkzEakXHUwMDAwbrSRQlx1MDAxNkZk+iSNhEgyNURSMI6IIDJcdTAwMDKC3lxyXGadXHUwMDE2JClNQpKKXHUwMDA0JKlihlx1MDAxMEXk2CCZoYVKXHUwMDBiqUBLoYbQwnC2W83g0Hvp2XaIXHUwMDFkXa/ceY3n2IT11Fx1MDAxM1x1MDAwN2j5OXDXev7m67+jI9lx8ZPtrYiOvWep4V1bNV6s4ndx/ZiGXHUwMDA3XHUwMDFlurD3XHUwMDBi7rxaLeqUqihIXHUwMDA17+lvXHUwMDE2cSYt37v2mpXGUZqcucB7XHUwMDA1flxu8lxiRIal31x1MDAxOVx1MDAxMqI1IyxyxSDs5du4ecWe0Fx1MDAwZYBUgilKXHUwMDE1NSBj2KNcXDqCXHUwMDE4oVx1MDAwNbUjolQ2+KD3XHUwMDFhXHUwMDFkfEY5QDgxXHUwMDA0lOT4k0SiXHUwMDEwXHUwMDBl2lx1MDAwN4WAXHUwMDEwXHUwMDFhgCnSXHUwMDBmTHtKgpHDuMrQqfxSXHUwMDE4+nbknynDtVx1MDAxM1T8YNlr1rzmdVxcsDfOV1x1MDAwNCf2O1fa1q04hlx0nFPKcUg0ZyxyxVWr2rXfo1x1MDAwNFx1MDAwZcd5XHUwMDE30nAj0c1IymTiy7vN2mChLq7u/aP9l2Zru37hi43qQ1N8f0xcbmVcdTAwMWOCdp5cdTAwMWHG0KArSXm6UIJcdTAwMDHHadRIzigorlx1MDAxMjI1Kp1gpXV351x1MDAwNTj2XHUwMDA3La9cdTAwMTn0j3FvMJcs8utupdZ/XHUwMDE2v1P0XFy/iWjbO8apUvi3hVx1MDAxMEG9f7z//T9/pF5dylJs+0qodHi3L9HfWbYtl+tTKmX/4Xf7XHUwMDA2SC6oXHUwMDA2XHUwMDE1erxB9i1/jmdi39Qg86aII1x1MDAwNdVUcEMojm+c7DNcdTAwMDKOMlx1MDAwMq1cdTAwMWLHWUDjJ/rkXHUwMDFhn3UjOiQ171x1MDAwNi1cIv4vXHUwMDBihryTcsZDXHUwMDFmO1x1MDAxYrJ/6W8/updcdTAwMWKb9eP6snfmnrfXl1fOZk32L7rPOytwt/tcdTAwMWPcd8pAXHUwMDFloHJTL81zXHUwMDEwMZI/XHUwMDE4KYigVOssqGtmJEFcdTAwMTdQnMmkz/6cI13mI11MXHLpTCWRLpNIXHUwMDA3gaKCoWND+qRjiEi4MyCGWPnF7L/+bNpcdTAwMTNV9NdcdTAwMWS38/fPxaDV/rn4s5lcdTAwMWVZXGJcdTAwMWW703vg0HCvgtHjilx1MDAwMW6rP64oIPtcdTAwMDc8slxmeVg/TFx0Rr+AlEdcdTAwMTfH6crhnqqdbHzb89ae2lxcb7i+u+TNe/6NXHUwMDEx9MlcdTAwMThYIf9AXHUwMDAyQkGyXHUwMDE4UjlnjqaMIDUhXHUwMDFjgOpMoH442jeQXHUwMDA0qkpE+5Io4JJFvtdsXFzyzfpccl8+u1/deKhcXH6/a+12N48vr4q6uJuLk12x3azed4+f6jdUXHUwMDFk3d22XHUwMDFlxuA6XHUwMDBmbsr153OvfHrbeVhT61stf3XDXHUwMDFiw323r6ube+tcdTAwMGZPasv9UTnaa+/drWP8MS6XzFx1MDAxNGOhXk3KJUtNM7HOuOGaUiiO9fTpn6lPLoB1XHS5WEd6zlwiWFdcdTAwMTPDuklL7CV8sjXBlKE846Pf8+OUbcJss9nuXHUwMDA2WXm9XGbv+9G83lx1MDAwMCeVltf7JebojpYh581cdTAwMDKfMMC1UkNk9nboXHUwMDBm/2bHXdl9WuvSMrku7Z+Wm/PuZylTXHUwMDBlXHUwMDE4xJbgXHUwMDFh/zPxxJ5cdTAwMDTiXGKNJopcdTAwMTjFpFx1MDAxMlx1MDAxM3SzJMXNykTujjD0sdJwOevQl2JQeVjdO395emr5vPHy/HJ/dTRrP3tx841cdTAwMWVtPK2xTX/l4rl+u+5cdTAwMDdwPob78v3DXHUwMDA3s7NcdTAwMTI86lx1MDAxMu8snWnXK2k1Lj+rqWZcIlT4XHT5WcZ19pI2hnpcbqO+SEZ4XHUwMDEw1tOnf879LOU8XHUwMDBm65Q6MFx1MDAxNayblKx90s1SQimyXHUwMDAxXHUwMDBl41vSXHUwMDFlp1x1MDAxNn7QzXrB4aNcdTAwMTdU619hun52gJNK+NmInFx1MDAwYlx1MDAxZlx0aVx1MDAxNWR6WoywMJzT0lx1MDAxNF/AfvK8yr1/e3dSWq+wXHUwMDE3ft899PZmvIgmXHUwMDA3pp7AXHUwMDExxiiOP4wzwXhcZn2cI8lVlFx1MDAwMVd4XHRcdTAwMDOSzXI/nHoyPIk/naS5XHUwMDA06ZEydIy5p9FcXO3a/snN7qM8XT2g66XT1WD7eUOuTSFcdTAwMWI82HVNKWurc7CjqUKFkUOkg9KHc86xI3KxI+LY6WfP41xcnyFJ6CSztkhmmcJIVY4vXHUwMDFiND9cdTAwMTHia9j1mvbslSrWW42a6//9c/FcdTAwMTKDss60XHUwMDEzt1x1MDAwM1xcQb9DKyR9LkwzXHUwMDBiRVx1MDAxNCGZMLWL2ZrpyIrdwHXUXFzDNadcZpNI5lx1MDAxMFsqXHUwMDAwXHUwMDA2tZ/RXHUwMDE4TJm2oSaA0JZeXG5cdTAwMWVZdlx1MDAxZTdOwaFcdTAwMTRxITV+XHUwMDE0YsPoXHUwMDE0jyeYXHUwMDAzQjFcdTAwMDCNYT5cdTAwMThcdTAwMTFBwHuih4PNtqthKrg+XaVIwaJcZulQw7mixtbgXHRJZFpVXHUwMDA2OFx1MDAxOEpJW4kqUG6NLMYkvnyhSpF8VL9cdTAwMGJFcJ654UJcdTAwMTBbmWKkyKhfXHUwMDAxblBcdTAwMWKkoTjjSlFJXHUwMDEyUn2qWpFs9bavpGKHN/xcdTAwMTL9PbSFo1xcZK5cdTAwMWZcdTAwMTODNIUoPVx1MDAwNIvPT4PMqYljWjvSXHUwMDE45F2ao1wi0ThcdTAwMTVBaDiK4yBQsGUz0ULtcVx1MDAxN8JRXHUwMDA3yZ8gqM5MUqHCiVx0iVx0d4iU1GhGgWiTXFy00ohcdTAwMGIzY/uGcpFIiD9++5afWY7bXHRcdTAwMDaaIZxcdTAwMTjYKmI0/ilW0NixJoxwUFx1MDAxY936aOYtP1xuj8uEQ8RcYrJeXCJ6dc9JmexKqVwi1iRTYVx1MDAxZFx1MDAxNv/cxi1Tse0rodJDmrb8JFx1MDAwNc+u9MVcdTAwMTkgXHUwMDE0lVx1MDAxNYrnXGJ3L1a+lcs3zD+puZetnSXxtNahc15cdTAwMWbDwDjoVDRDXHUwMDAzJ1x1MDAwNbKPOIMj3DGMgFx1MDAxMpzgXHUwMDBmzsLkXCKtVINcdTAwMTZJirwlXHRcdTAwMTFcdTAwMTNcdTAwMWMjv1kvXHUwMDA3bJ2s3DWvK8es/qw3ZGX95P6YXHUwMDA2s15cdTAwMWX/XHUwMDFkKtY4z2RcdTAwMWNI5jgyXHUwMDEyXHUwMDE4XCL3kT5Nc1x1MDAwZUkh8iDJtMOnXHUwMDAzSV0obY9cdTAwMDabXHUwMDAzj/Ki3yf1XHUwMDExVn1NN2k/wJNkXHUwMDE3p4285yW6kbBcdTAwMGZ2XHUwMDFj41x1MDAwZWLZbWHU5du3OeX5XHUwMDFjtFx1MDAwM5pcYs21pHh5nOczbVx1MDAxYyEw1jWogISTSEZy/LlcZoZ2TuGAI8pcdOEqJVx1MDAwNckxOqfCbv/UWuNcdTAwMTU6uVx1MDAxYk1cdTAwMGKD0IzuZfpccnNcdTAwMTn5QInzalx1MDAwMkJyYFx1MDAwMlVcdTAwMTlcdTAwMDNpmuTVysGJ14ZzrnrUO5k0KMT1XHUwMDBix1x1MDAxZuBgyI7ScMlQ+YxkJpbOeFx1MDAxNVxuldIuliE1XHUwMDA2IGj4PzfXz9Zt+0pq9ZBsP9u+oYPKsm+Ca/u/LJ6qzedZc2rfcMxcdTAwMWSltVwiXHUwMDFjObTk0X3mr1v6LOtAdkVcdTAwMDBpXHUwMDA3V5NcXFSh1JGglOQ2KWzT5CnMn6HeS6KYZFRcdTAwMTiI7C59s29cXKJcdTAwMWVcdTAwMTk6VLHApzNvhTf1UVx1MDAwMTiQhNg9myotJ1xuXHUwMDBlx1x1MDAxM1xubZvhQFxig1x1MDAxMXf0XHI0uL8kslx1MDAxYslcdTAwMDBQjVx1MDAxOLHqlCqSoIg80au8xVA8ZZPhZ7Jt2VptX1x0fVx1MDAxZdKy5Zc1yki7ikSaXHUwMDE2Z5zhNFx1MDAxNN/Rx5fXuydrm62V04p7UX95MK2no/05XHUwMDBmmohgjpBMqN6ym2AsXHUwMDFlNSlGXHUwMDFjXHUwMDA13O5lRf+OqjY541asrlEjhVRy9u076lx1MDAwZlx1MDAxYvewXq67bOX2qnbaofWDy4PfvfwwXHUwMDFhNE+q/FBFdnb2QVx1MDAxMqmcXHUwMDEyisjiXHUwMDAxVfoszTlcIqXMRaSgXHUwMDBlnVxuXCLT1oKTaVxmLa2QZjo1/kPrYDjVo6QxdiqXbuPrz0X4ufjvhakmMlx1MDAwNriS/kRGXFzQXHUwMDBmOEStMkszKJJcdTAwMWZi2DBcdTAwMWLqXHUwMDBlzrfcs+f7Yyit7m6elPeuyrf1kzmHXHUwMDFm1cg4rLZJXHRUXHRcdTAwMTJcdTAwMDdcdTAwMWa6Q0OQiFCMXHUwMDAzlJi5N1Q4I2Cj4Fx1MDAxOXvD/bVcdTAwMDefL61cdTAwMWZcdTAwMWRstq+6Tb3JO+V9PVx1MDAxZl7LkGhcdTAwMTOWSXktozKz78hsXHUwMDE1V2irXHUwMDBiwyZ9NOdcdTAwMWI2XGYsj8yCXHL6LD5cctjIlK5cdTAwMTApLsvG8kzy6dTLXHUwMDBmq4BcdTAwMWZzWW9F6FN2V1x1MDAwM1xmfb+7XG6FzEVbdlbKiEw3hbGxJU5miEYsuWx8XrNSXG5pIJpcdTAwMTZGXHUwMDE1XHUwMDExOtqAoVcnrLjDNZ5lXHUwMDAwljH3yzU+xFx06ShJXGIyM2X7f6mUjaE2O8mRI1wiU1x1MDAwNIPxNEvm3Fx1MDAxNcG36qhlmEF9jaJ6tI0tXHUwMDA1k1LD1LIwu5hiXHUwMDA3lnDbtimSXHUwMDEyXHRrWVxmgM1cXDGMXHUwMDA2XHUwMDAwVT7x5Vx1MDAwYuWl8olmXFwornBcdTAwMTJccqBMVEguaVIocIxdf+VS4JByopNCfabMVClTue0rqdbh/b5Ef49QPZhTXsOBMsX0XHUwMDEwW93z+dW82jejUd9Q4yjYoSe0b1x1MDAwZp7CIJlcdTAwMTnCJVx1MDAxOE3BTM7AcVxmXHUwMDA3mN1Qj26FoUFlKVEx5460+1x1MDAwNFx1MDAxNcFYWEqW4OnUNiuj3JjZXHUwMDE2XHUwMDEwXCJcdTAwMTOZZNa9sIGzffKQMFx1MDAxYZtXt/tyeKxz3ZstYY5cdTAwMTFcYitbISGYXHUwMDFk99FcZlxcPjWJXHQlXGJcdTAwMTFcdTAwMDS0XaCgXGIxkpRcdFx1MDAxY2XLqI0t39WA4n1q+5at2r2z/Uo9pHlcdTAwMWKQeYfsblx1MDAxZb2F5KF6hZ7cXHUwMDFmXFysLYlD+dgodVx1MDAwZk42Lk7rL4ejmbjpdVx1MDAxNEDn4dhcdTAwMGVcdTAwMTmcXHUwMDBiTanS8ZhcdFx1MDAwN8hRXGZcdTAwMDRcdTAwMDBnyI50dtA0rY5cdTAwMDJcdTAwMTKhoW2XlbGFTe9cdTAwMWE1VLKhc7ZVW/Jqa+Z4/9ErLZeO5OXZ9lx1MDAxNDrh5N634ZLzXHUwMDFk0Tl9MFtcdTAwMDcvXHUwMDE1v96+WSndjeG+3aPmcX1rp7y0XHUwMDEx1O9fSof+j63zsW3L1EyISMg8qeSINJlduoSxLbmH6VxunDr5c05muGF5SKfMoVNBetp+5pS2PcCR3JNxds1cdTAwMWOnXHUwMDBlhnP9sX5cdTAwMDJquqWJXHUwMDAzXFxUdj9cdTAwMDE1cpqEyuwulkTbsihFSPE8Sb7pnFfoMeIwRjFcdTAwMWVlxvbcVvE4QlDuYEBLMNzoXHUwMDE5oslVJ2JcdTAwMDTg2MpcdTAwMGWjgCkqIa2Fllx1MDAwMlx1MDAwNykos5V0XHUwMDFhf6KlRG9xXHUwMDA0Tlx1MDAxYTBOh+rqM/Y4QlxiSkfKaI57I1x1MDAxMjhUXCJZx+jbprq0MskwQjjEXHUwMDFlN4RcdG2f/ZBk7IWiiHz0xqNcYklt/Vx1MDAwZVx1MDAxN7ZcZl0xlVx1MDAxMEk7XHUwMDEyY1x1MDAxZmVcdTAwMWK5aUWJTM7GZ1xuXCKy9dq+XHUwMDEyXHUwMDFhPdYgQqvMVkVWJ1RsZ/Ug81Zb3VIrRy+Px7v3z3urm9v+9f7jjDtcdTAwMTVcclxcdeFUOVxmXGZD48VRoYyMW7de9Vx1MDAwZdo1MDaLRcRcdTAwMDStW7FcdTAwMTiCXG6NTidatDCbXHUwMDEwgtzfXHUwMDFjlLdWa+X25Vn1uHPaZJuXT7831Udzwya+XHUwMDBiXHTNWnZBXHUwMDFkKG2fVjBE5jJ9muZcdTAwMWOSnDg6XHUwMDA3krZ8ZyqQTOvAkkL2kVx1MDAxYtk1hOl0YFx1MDAxOVpcdTAwMGI/RvZ/lcWoqdfvXGZwJln1O+qj9TsmO9TWUvPeWlFh+Fx1MDAxZEO9Jsjyj4fjhzOoPnWPbneuunNcdTAwMGU/ZF9cdTAwMGUyfeRV1G5cdTAwMDeH/vpcdTAwMWS7tIaUXHUwMDEx9dDk9Medkju0JVx1MDAxMchcdTAwMTWVnnVKbf3+XHUwMDA2bpmskcf79o/d5TX329Nz4c5hXHUwMDEz9Vu2gymfuN+yj8fJ9FtcdTAwMTTokHVv6cM558DR1NHZwFx1MDAxMeDoKVx1MDAwMKdYXHUwMDAxXHUwMDBmwUBdXHUwMDE5JulUMlRDq+DHnNZsKnhcdTAwMDbY+vFX8GSXeWtkioxcdTAwMDEp3lx1MDAxZiefkc9pZsp2wLG8XHUwMDBiXHUwMDE0aIK0MN5cdTAwMDNMKoaAY1x1MDAxYc+B4Vx1MDAxYbKbzH54gdtcdTAwMTJSYXu54MdQalJcdTAwMTDIpW0mQoxcIr0nN5DEU1W17fIuh2s2Pe60lEGNXHUwMDE56jFcdTAwMDSRXHUwMDExLZSWKpxcdTAwMDOybaeEwbhcXFx1MDAwMtJdXCIjnfhcdTAwMTdcIkvJtm8oKIlRkaIjPykun2jGheJ2kLTtXGbJKfL+lDV34iiGsVx0Z0Joxpn55Fx1MDAxZHIyVdu+XHUwMDEySlx1MDAxZN7uS/T38Gl3XHUwMDE2cZ79xcD24YHUXGaxZzafXc2rbVx1MDAxM9ox9slrRCvOXHUwMDE19Fx1MDAxN+9oRzImeoVcdTAwMWaCkcl14kBcdTAwMDVcdTAwMDD7uC6tKUFcdTAwMWIlU9a/mHGQ/YHtQsqZ3Vx1MDAxY9tv26Tdi4k2epZcdTAwMWJmR6cg47ZtNn6SYG2IsrUhXHUwMDEwZmBDK8JcdTAwMWRj60okXHUwMDA2o4xSKZJcdTAwMGacLGTa8ilJXFwmxlx0t9V46E2FTjFsXHUwMDE4XHUwMDE0okhUo1x1MDAwMUT+ysTnzrlnqrV9JVx1MDAxNHpIu5ZcdTAwMTUjXHSZmWy3T3lcdTAwMTmi39fz877ZbW3uXpOT/c7SycqZ9+1ged5NXHUwMDFhU8ThvTJPaqjdnFx1MDAxZDdpgCaNgCUhhGnOJ/dQbZPymHuRiJC4ffpcdTAwMTmHKVx1MDAwNUh2q/pIzGukR2o7jvOz+ZUsXHUwMDA0db+7INNX8SNcdTAwMTMwXFyYXHUwMDE0tNpZMVLsXHUwMDBi9Vx1MDAwN0T9Qr1C7MtcdTAwMWKEXHUwMDE3K+32YYCj9m7ucD682ttXXHUwMDBmb7z44LmPy0mN+NdV72Xv2oOtxYjbczT/fPnnf+6uXHUwMDA3ViJ9 ByteEditor()Container( classes=\"top\")ByteInput()BitSwitch(0)Input( placeholder=\"bytes\")Container()Label(\"0\") Switch() BitSwitch(7)Label(\"7\") Switch() ...(1 thru 6)Now that we have the design in place, we can implement the behavior.
"},{"location":"guide/widgets/#data-flow","title":"Data flow","text":"We want to ensure that our widgets are re-usable, which we can do by following the guideline of \"attributes down, messages up\". This means that a widget can update a child by setting its attributes or calling its methods, but widgets should only ever send messages to their parent (or other ancestors).
Info
This pattern of only setting attributes in one direction and using messages for the opposite direction is known as uni-directional data flow.
In practice, this means that to update a child widget you get a reference to it and use it like any other Python object. Here's an example of an action that updates a child widget:
def action_set_true(self):\nself.query_one(Switch).value = 1\n
If a child needs to update a parent, it should send a message with post_message.
Here's an example of posting message:
def on_click(self):\nself.post_message(MyWidget.Change(active=True))\n
Note that attributes down and messages up means that you can't modify widgets on the same level directly. If you want to modify a sibling, you will need to send a message to the parent, and the parent would make the changes.
The following diagram illustrates this concept:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1bbU/byFx1MDAxNv7eX4HYL3uljTtvZ14qXV1cdTAwMTHabmm7QFx1MDAwYrS3vVpVJjHEJYmzjlx1MDAwM6Wr/vd9xkDsxHFcYmkoUF1LpYk9XHUwMDFlXHUwMDFmz5znec45M/n70draenY+iNafrK1HX1phN26n4dn6b/78aZRcdTAwMGXjpI9LXCL/PkxGaStv2cmywfDJ48e9MD2JskE3bEXBaTxcdTAwMWOF3WE2asdJ0Ep6j+Ms6lxy/+P/boe96N+DpNfO0qB4SCNqx1mSXjwr6ka9qJ9cctH7//B9be3v/G/JujRqZWH/uFx1MDAxYuU35Jdcblx1MDAwM6W2bPr0dtLPreWGXHUwMDE5LomsXHUwMDFht4iHT/HALGrj8lx1MDAxMYyOiiv+1Hp7I1x1MDAxOVxmXlx1MDAxZf4uP71qXHUwMDFldk5cdTAwMWJCfNz5XFw89yjudvey8+7FWIStzigtWTXM0uQkelx1MDAxZrezztXQlc6P71x1MDAxYiZcdTAwMTiG4q40XHUwMDE5XHUwMDFkd/rR0I9cdTAwMDBcdTAwMWafTVx1MDAwNmErzs79OVa84MUwPFkrznzBN6VYwPFHSSdcdTAwMWOXjunxZd9cdTAwMDHOXHUwMDA2TFx1MDAxMmdCa+Gc4VOGbSZdTFx1MDAwNlxm+4WOZLulXG7TXHUwMDBlw9bJMezrt8dtsjTsXHUwMDBmXHUwMDA3YYopK9qdXb4yJ1x1MDAxYmjmpOKKlDFE41x1MDAxNp0oPu5kaGJN4Egz6yRcdO6kdoUxUT4rVlx1MDAxYiVcdTAwMWOZYvi8XHUwMDA1g6127iF/loet375cdTAwMWO2/qjbLYz2XHUwMDE3nk17VdmzJrwri75cdTAwMTRvUvKEdCdcdTAwMWWdb1Bz1Njdf7v59Vlj820nXlx1MDAxZrf79tvsbi9ubkafs99Pn37gpn9+4I6V2Op3pp5y9fwwTZOzRftcdTAwMWQ4+vzcbDe/dj+yNOp8lVx1MDAwM2lcdTAwMGZW0O+zt9m2ftE8dPZsZ1s8c3uvKVmFve+fvZJvXHUwMDA3XHUwMDFmtYuy5qe9/us/WKPXWazfy0/FhI9cdTAwMDbt8Fx1MDAwMrhcXFx1MDAxYsuEs0qRLlxcvVx1MDAxYvdPpn2hm7ROXG6sPypcdTAwMTlcXGGZXHQ/KFx1MDAxM4w1NH16TDDWOSGVYHZhgpntVktcdTAwMTHMNI5vkWBcZlx1MDAwYpSyhitNmoQuXtffr7hccpzj3Fx0xom70mCsml9cXNH1mFCo8IBLXHUwMDA2IW1cdTAwMTiXJTO+l0BW6YPFTCf9bC/+6lx1MDAwN1vIwFx1MDAxOKOc0lJcdTAwMTitmJto9Dzsxd3zibnLfVx1MDAxNYO1m4/Tr/8qj+gwglx1MDAxMb5XZSfab3TjY+/P6y3cXHUwMDEypVx1MDAxM66exZDmcYPDJMuSXtGgXHUwMDA1I0L0mW4tXCKRSVx1MDAxYVx1MDAxZsf9sLs/beNcXPTN1XjFLK+FoDRcbjOxOFx1MDAwMF80P785y87Tk8M/PvRenGyop6/l2d1cdTAwMDLQXFyHP2FUoJ0w0mjOlZN8UuDJWK//Vlx1MDAwYoDTcuFqXHUwMDAxyPLjblx1MDAwNVx1MDAxZfZJRCpOr1xmn8tcdPxr2j7/LyUv9ejAfvncO2Bb7tPxzyrwbz5svd9I3iE0fNVP4iMxXHUwMDE4ZsasSIitI0xp4di3JMSIXG51XHUwMDFkXHUwMDBiQJtcdTAwMDRBf1x1MDAxNmaB2ZN/v1lAQmeVM2BDKa21JZnwt2tGXHUwMDAxlFx1MDAxY3PBXHUwMDE4Mabp1kiglEDMUWEr4Gyal7Kz25NheKDmdyfDm5242/7BKnyNjE2r8JWJ3yHC4PNaXHUwMDExhi44y1x1MDAxMFx1MDAwMS5cZkDqf+2evmxcdTAwMWOIvZPPO/uHZ+HWwba551x1MDAwMNRcdTAwMDJcdTAwMTCDe8DhhFx1MDAxNFx1MDAxNVx1MDAxMTZwXHUwMDFmidRVO01GqlvD30pEWCHFdkqy1cFzOVx1MDAxNY7C072NdP9Mmv2/tt8kfGh3mns/QNV+wnSYXHUwMDE0XHUwMDE38tZVXHUwMDE42VUtXHIwqaWU7lx1MDAwNvW22dN/z2lA+3zXh+NETDNpJnhAM1x1MDAxNVx1MDAxOMRDllx1MDAxOKJcdTAwMTI9p9r2Y3TYOKRcdTAwMDZCKLUyoK/SXHUwMDA3XHUwMDFmvFx1MDAwZV+jY0vp8Fx1MDAwNfZngE9oUa/BllxmyF6oxVPh+UnMfS12W1x1MDAxNzhcco+2UivLidRcdTAwMDT8oGtcdTAwMTBH4sxoq1x1MDAxOLPThlx1MDAxNfDjVqvw8DuKUSpAXHUwMDFjYJhcdTAwMDLtcTzRVNFoRIDQXHUwMDE00TozTlx1MDAxMKeKXGZjvlx1MDAxMFCQpZtUu1x1MDAwYmG58lx1MDAxOHF55ttyoF26hjXMwjRrxv123D+eNOxyVWeRilGO69bIW9lggVx1MDAxMFx1MDAxY1x1MDAxY8KJWbJWXCKyLzU7XHUwMDBlXHUwMDA3efhcdTAwMTNoLlx1MDAxZIfN1nKjuKy8fdRvX2/V/FxietIqXHUwMDEwkZPge8hcdTAwMWLhqFx1MDAxYeVcdTAwMDKtlOKwyFx1MDAxMUlesahcdTAwMWJcdTAwMGWzzaTXizNcZv1uXHUwMDEy97PpIc7HcsMjv1x1MDAxM4Xt6at4o/K1aYpcdTAwMTj4XHUwMDFlJ6Ow4tNagaD8y/jzn7/NbN2QNmCOw3G5VVx1MDAwZTGk1OX7XHUwMDE1hoOUVYj3cZVLc21/tUjxR1x1MDAwNSNFd4/K/9+YLJWoXHJUyFxi6Vx1MDAwNWbxOGV+pLlcdTAwMWGqbCfeOVx1MDAxNudKupYqSVx1MDAwNcz4ckH+skpPZiyai0AppoEgXHUwMDAxPiUzZdgqM1x1MDAxNlxuYIxcdTAwMTKet5mRZlx1MDAwNlfCXHJcZlx1MDAxN4asYX6lUlbL+iSVsfpuqXLp+GZBqrxcdClcdJJcZrNmpDbMKWZLsLpkJcFcdTAwMDLIoCGlreRcdTAwMDaR6XJMOT/GKVx1MDAxOcWCXHUwMDFj4dxIJpnmXG6onknfXHUwMDFhyavQxLWDUfSgybLetf1RcepcdTAwMWKSW1x1MDAxZbzO4DZuarnNK1x1MDAxMIaWXHUwMDE3VYHruK3RODxcdTAwMTl0e413nz703n462uc7NnyzXHUwMDFjt01cdTAwMTc9bi9cZoRPXHUwMDA3ym92IORcYkK5qWJcZiSHXHSGLFxmLlxi4qintpZcdTAwMTOhXGKXpzZJXHUwMDAxQWl8XHUwMDFjKiGYppSAjalccnpqXHUwMDEwqXCutJLWQVx1MDAwMitxoECw6inmzrhccjKAaEtcdTAwMTdcdTAwMTO4PLdNY7HmyopRPnFtxfFQ7Vx1MDAxY/ujOrsrXHUwMDAyuWD1q57EuGWalVxue9eh/JXZ33p3tN//a3evXHUwMDExd3Y/7vY2yzi+pyjnXGahKHNcdTAwMWF6XCJcdTAwMWSjyVJcdTAwMGI5XHUwMDFl+HqTXHUwMDE2mlx05Ma3XHUwMDA3cul11GJCuJRCilx1MDAxOeFcdTAwMGLoXGJcdTAwMTk4poWk1UaUmlxcVVxckUEohqt3inFokSpM+z/Gr47aXHUwMDE59kdlbm9cYvD6XHUwMDE0xcnps+NcdTAwMTRFwFuM0IunKPOXju9pNUdqXHUwMDFiKM9kklx1MDAxY1x1MDAxM0pNXHUwMDE2c0grjDyzSmryy1x1MDAxOfVrKpE25nsyXHUwMDE0K1x1MDAwMzyFM1x1MDAwYiVcdTAwMTZOmFx1MDAxOWsqXHUwMDA2TSxcdTAwMTfqosTNbaWY44zz+UkxXHUwMDEwP2Ep5yb5XHT32039tjHpk49S4lbkXHUwMDAy0pfymN9GZ1x1MDAwNMPs28rbL5Sg3KjAhEArz4c1hEXzqk0skPA0MLVcdTAwMGaJyIxcdTAwMGJcdTAwMTJcdTAwMGYzP/HFdMF93VxuaVwiXHUwMDFj1JXvbihcdTAwMTko5vNIvKt23OrruqvFSd5dXHUwMDA1XCKrXCJKcHLtJkxcdTAwMTCHVCRcdTAwMTYve89fhbunREkqr0JcdTAwMTJcdTAwMTG3xu+PniBK5D+B4yRcdTAwMTTmXHUwMDA1woHxqGXK7y17I71Hwmu19lm8sULPqnv7QoBcdTAwMTVcdTAwMDC+c1x1MDAwNKtLw3RcdTAwMTlcZjGEyoIxW9j5XHUwMDEzXHUwMDE2c1x1MDAxNqYlsJIzoCUlXHL+Icx1pVxm44qXMMPgLtKCIVx1MDAxM1x1MDAxMXrJqvdccmo5/udcdTAwMDJKYKqM407MsIhcdTAwMDXgUWNcdTAwMTCngDyMIFMx6SExpVx1MDAxNFx1MDAwMahcdTAwMTDqIJj0xepcdGqTKlBcdTAwMDZcdTAwMTFcYsf0cKmEvK63epj4o1x1MDAwMpBVXHUwMDExJWZrzs9hpDQgS7Z4TDl/I8Q9pUptKPDcXCKl31x1MDAxZlOiwjykJFx1MDAxYvhanZXI11x1MDAxOVx1MDAxOODWQkpHgfNLQlpcdTAwMTE8h5XixTFPalx1MDAwM6+CjVx1MDAxMpE+VNNUat7cec1G9Fvc/Fx1MDAxM/LkTerLXG40o1x1MDAwNVx1MDAwNtRxSZzPiCldQIjsuEDCRuAtW2Wl1caULIBNiLBcdTAwMWPYm/zCs6pcdTAwMTbiPVU643dDglx1MDAxMrhGXvOgqbIhXHUwMDFj0mXn02XwpCPi5dtcdTAwMWKKw/MtoCfhv1baa8myUYuV/GpcdTAwMDUmN2TLur1Mtv6HPbBcdTAwMDQxp6XC7a+jyk7nTTM92n16MNJnh9REYrT//PzeUyVcdTAwMDZcdTAwMWUyZIB0+G15xfTqh4O+oCm130RcYlx1MDAwN741ruRyxi97RKVMzpGpSFx1MDAwZeYuLLm1zUxcYscs5Hupgnh5M1x1MDAxM5s4O2f3Ulx1MDAwZuaGx9Fw7dfRYPZcdTAwMWUmXrOHqVx1MDAxYlx1MDAxZE369+RcdTAwMGWmLFx1MDAxOdRtX5p4mem9SpNcdTAwMDYthTBobFx1MDAxZMSQYlvos1i8gp18jJ/2qdGmdCS2XHUwMDA2zVx1MDAxZGaH2+9cdTAwMWVcdTAwMDLCXHUwMDA09EprST66nMrbiFx1MDAwN2SsX1xi9Vx1MDAwYqeO32LeZtRCXHUwMDEw035DkrP6x0DMr1UuVY9eXG5iYZal8eEo8z7dTs7691x1MDAwMmZVoy6g9uhSLtfDwWAvw7iNg1x1MDAxNcxI3L58+aLr9dM4OmtWveKXo/zwvebw9UCJ8jjx26Nv/1x1MDAwMC2LavoifQ== Parent()Child()Child()messages (up)attributes (down)"},{"location":"guide/widgets/#messages-up","title":"Messages up","text":"Let's extend the ByteEditor
so that clicking any of the 8 BitSwitch
widgets updates the decimal value. To do this we will add a custom message to BitSwitch
that we catch in the ByteEditor
.
from __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.message import Message\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\nclass BitChanged(Message):\n\"\"\"Sent when the 'bit' changes.\"\"\"\ndef __init__(self, bit: int, value: bool) -> None:\nsuper().__init__()\nself.bit = bit\nself.value = value\nvalue = reactive(0) # (1)!\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\ndef on_switch_changed(self, event: Switch.Changed) -> None: # (2)!\n\"\"\"When the switch changes, notify the parent via a message.\"\"\"\nevent.stop() # (3)!\nself.value = event.value # (4)!\nself.post_message(self.BitChanged(self.bit, event.value))\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\ndef on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:\n\"\"\"When a switch changes, update the value.\"\"\"\nvalue = 0\nfor switch in self.query(BitSwitch):\nvalue |= switch.value << switch.bit\nself.query_one(Input).value = str(value)\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
Switch
widgets, when it changes state.ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a32\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0\u2503 \u2503\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2503 \u2503\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u2503 \u2503\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
BitSwitch
widget now has an on_switch_changed
method which will handle a Switch.Changed
message, sent when the user clicks a switch. We use this to store the new value of the bit, and sent a new custom message, BitSwitch.BitChanged
.ByteEditor
widget handles the BitSwitch.Changed
message by calculating the decimal value and setting it on the input.The following is a (simplified) DOM diagram to show how the new message is processed:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPiSNL+7l9BeL/MXHUwMDFiMdbUlXVMxMaGb7vb99l4e8MhQLZlZKBBPjfmv29cdTAwMTZ2I4FcdTAwMGVcdTAwMDTGgHteOtrYkiilqvLJfDKrsvjvQqm0XHUwMDE4Pre8xT9Li95T1Vxy/FrbfVxc/N1cdTAwMWV/8NpcdTAwMWS/2cBTrPt3p3nfrnavvFx0w1bnzz/+uHPbdS9sXHUwMDA1btVzXHUwMDFl/M69XHUwMDFidML7mt90qs27P/zQu+v8y/7cc++8f7aad7Ww7UQ3WfJqfthsv97LXHUwMDBivDuvXHUwMDExdrD1f+PfpdJ/uz9j0rW9aug2rlx1MDAwM6/7ge6pmIBcdTAwMWH44OG9ZqMrrSGEK0JcdTAwMDTtXeB31vB+oVfDs1cos1x1MDAxN52pdVx1MDAxZs9cdTAwMTfl/ZPjQ7ax/0Rf/MOL5tI2j2575Vx1MDAwN8Fx+Fx1MDAxY7x2hVu9uW/HhOqE7WbdO/dr4c3PnotcdTAwMWTvfa7TxF6IPtVu3l/fNLyO7YBI0GbLrfrhsz1GSO/oay/8WYqOPOFfXHUwMDAwwpHAiFRGXHUwMDFhQ0x05+7nJXWEUExSpoXgQOSAYKvNXHUwMDAwx1x1MDAwMlx1MDAwNftcdTAwMDdcXPFaVUSiVdxq/Vx1MDAxYeVr1HrXhG230Wm5bVx1MDAxY7House3R6Y8uvWN51/fhHhQRlx1MDAwZtXxulx1MDAxZFx1MDAwZlx1MDAxYSRKZFTvhL1Ja7vW1YH/xHumUXvrmcZ9XHUwMDEwRHLZXHUwMDEz64N6XHUwMDEz150+/Vx0vadI2NhgP1x1MDAxZl3723opuPpa11+vvJ1rslx1MDAxNJ4v9q776/f0Zl8/zF42audnXHUwMDE3x0vN3X3plVx1MDAxZla/3Kq1/rv8vL/bbjdcdTAwMWZj7b79XHUwMDE2Peh9q+a+6iSVSlx1MDAxM6FcdDdMR4NcdTAwMWP4jfpgXHUwMDFmXHUwMDA0zWo9UuOFmMBcdPz0PX9cdTAwMWM6hoos6FDNmEFRiC6MnfTuXHUwMDFjXHUwMDBiO3R62FE0XHUwMDBmO5o51ExcdTAwMDU7RiehQ80gdKhcdTAwMDZcdTAwMDZcXFGYXHUwMDE4dLK0kDJKiVx1MDAwMjKKXHUwMDE2RmPdbITH/ktXkWTf0Vxy985cdTAwMGae+4arq57YPSvPobfedVxmv/1fvFx1MDAxZjtcdTAwMWXeudtcdTAwMTTv+8xy4F9bNV6s4rN47T5cclx1MDAwZn30Nb1cdTAwMGLu/Fot7j2qKIiLbba3i1x1MDAxOP1m27/2XHUwMDFibnCSJmcu8HJcdTAwMWRcdTAwMTdHdGWhj1FcdTAwMDaGoz6qwujTle3d1fKjvOzcbLeCSiB2XaPn3nNJ7lx1MDAxMK0oVcSAVMz0oY9p6aAx1Fx1MDAxYzVeXHUwMDAyZVx1MDAwND5cZn2o60U8XHUwMDE32lx1MDAwMkEkxaGZsetqy/UtVl7+tvTilqv1tc7S4flyY1xuriu3XbF3V79cclx1MDAxZaTim+et2y/N8mXdrU+g3VBcdTAwMDSbT4dXXHUwMDBmXHUwMDE1oYOV2vPm84/bJz1cdFerNChD0PhHivVBrpZrk8lSKTIhTlx1MDAwNep3YbCnXHUwMDBm/ydwtdlg50Q6MFx1MDAxZLBcdTAwMWKRxHqKq1XGXGJ0+zGv81x1MDAwYvlaPzx+9MPqzW9qur52iJtK+NqYnKV8Z/tcbv007IEhmdijVCilmCpcdTAwMWVcIuZbzznFnqTSQTZhXHKe6uKv39Ey6ShcdTAwMTDCWkJqiFx1MDAwMJ2JPdJ9jY894qBcdTAwMDWQXHUwMDFhKbVcdTAwMDEgRpuUiFx1MDAxMYgjXHUwMDE41VQrJVx1MDAwNIYgbFx1MDAxMJqGI2Y4k6NcdTAwMDSQkV/5qTHs7chfo1x1MDAwM/ZniEbHXHUwMDAxbCd02+GK36j5jet+wd5SIUVYqX1mt4XXXHSHXHUwMDEzjFx1MDAwN1xml0pII6L+7Fx1MDAxYYHqfafb6Tiw1t1cdJAouFx1MDAwNtCJZ/dcdTAwMWG14TLlo7cnk8ZBRlx1MDAwMov/0LlyXHUwMDAxXCJdJlxmuqjSXG7sf1x1MDAxY26iXHUwMDEyQlx1MDAwNW4nXFxt3t35Ifb9QdNvhIN93O3MZVx1MDAwYv1cdTAwMWLPrVxynsWHip9cdTAwMWK0XHUwMDExLdtiP1x1MDAwYot+K0VcdTAwMTDq/tH7/T+/p1+dqdn2ldDpqLmF+PtYoVx1MDAwNDbHXHUwMDA2XHUwMDBm9yxcdTAwMWNcdTAwMTJcdTAwMGJcdTAwMWNwXHUwMDE5c7fDLFxc5UTedbbWn/ZcdTAwMGVEsFX/8m19tbV1PFtcdTAwMGKnhpJcdTAwMGJcdTAwMDKOUlx1MDAwNrhcdTAwMDTgQotcdTAwMDFcdTAwMGKHps1yXHUwMDBmSz9cYmp/pJFcdTAwMTM3cKmRXHUwMDA0RMfeTFx1MDAxOGPaUFxudNY5sCevfHZ5e7N50ZKrrfrB8u7LVc2fXHUwMDAy4Z9cdTAwMTdijujIzoFcdTAwMTmUwlx1MDAxMCOL58DSu3POoYPYYFx1MDAxMXRcdTAwMDaAw2FKwIk501x1MDAxY1ZutFx1MDAxMFIjaZtcdTAwMWEpXHUwMDFmy8ePRcp33IpcdTAwMTf89n1RfV9EsjtNVj7E4lx1MDAwZrLyfkHf4bdoXHUwMDBlM1x1MDAwNyqBXGJcdTAwMTGLm4eBr3a6t7dzclY7P0TQ3Vx1MDAxY79UV59cdTAwMGXUnINPcqTehHGMM20kovvzz1x1MDAwMmk7kUxQJJyKXGIjs4PiKfktqlx1MDAwNMbnJJ7OmI3jkpvkaUM9fttvXHUwMDFmeU+rXHUwMDE3661V9+jub5RRXHUwMDEyLCejhOacauQ8xTNK6d0559hcdTAwMDHlsGzscDot7EiThE7SdXHKXGaGXHUwMDA1ZnpzN9NzXW9Jmim7rSFcdTAwMDZ/0G1FQuZcIi4zkcQgliZcdTAwMWGAnNboy/goUVY+eZ7XPFx1MDAxMoZZVDFUY2lcdTAwMTBYnPZBjlx1MDAwMzhcdTAwMWPhyKnmiEnGP1xmcYDYNpJcdTAwMTjOXGbjUsTuXHUwMDE0+S7pUINcdTAwMDNiU+tKMMVcdTAwMDfxSFx1MDAxObOeN25cdTAwMTimm0ZcdTAwMWHLWcS6tFBcdTAwMWGpcMpcdTAwMDZcdTAwMDNSMFJcdTAwMDB6d8K0jl3xM2WzZONcdTAwMDTCKJGK2excdTAwMWJRSiZcdTAwMWW+UFx1MDAxZSmfb8aEXHUwMDAywOFlaDhcdTAwMTUnPDJH/UJcdE1cdTAwMTWnwIBcdTAwMThhM2BcdJk+U1x1MDAxYWkpU7XtK6HUUXNcdTAwMGLx99FtXHUwMDFidlxcpm0jXGJ5SiFS02G2LZ9fzatt48xBViVcdTAwMTRcdTAwMWE3XG5aRqHuq20zXHUwMDBl2jeBJyQ3mis1INhkjVx1MDAxYva3wpDbKIRaynSVQLyitlM0XHUwMDFlXHUwMDA2JY5f8zPBRJTQXHUwMDA0Ufr/1s2aeodcdTAwMTMp0WppJVx1MDAxNGozxC6JMtJcdTAwMWOQMnNjKHInKWQyXHUwMDFmXci45bOSmHFcdTAwMTNGXHUwMDAxt/8p1TLVuKFInFx1MDAxYqIpJ8SumDNJe/uZbFumYttXUqVHtG25qVx1MDAwNqpoJndD5ibRmlIovtqGtOvbX7dcdTAwMGWDXHUwMDEzctmRrdN6eHrS+jL3XHUwMDA2zlx1MDAxOIcx27WMaWl0xO67k4CGOVRqVDc8XHUwMDBm6G6z7VtFMVxyufbt7Vx1MDAxYSGS1o2ylGhcdFx1MDAxMvZcdTAwMGJVRFxuytHQvst+0eH2q/eZkfJcdTAwMGbaf1bNaq3R2Vx1MDAxNPvqWMOB6bxcdTAwMTROnMPJ9cph58vt3XVjjSw/XHR2tr4rP1f+gVwiWcxcdTAwMDJcdTAwMTRGQtTOXHUwMDAwysJ4Su/NOceTXCJcIlx1MDAxN096cnjKT92RlPCHJ/JcdTAwMGZMXCKbVFx1MDAwMqa3noWNoITRWMfyXHUwMDBm1EHDzImWTKBKkdhUTV8+oj+9sNhcdTAwMGL1ndVcdTAwMWJcdTAwMWM5r/bb94Y9++BcdTAwMDb33j9P2vfe90b6qlx1MDAxNy77WurlIVx1MDAwMu+qXHUwMDFmXHUwMDA0I6UphjiL9DRFruzjcXyusyk+XGJcdTAwMGV8hEKJfFx1MDAwYjZcdTAwMTnA1tzOjTdRxGqGPk5TXHUwMDEwXHUwMDEyQCPp6l+CJoh0XHUwMDE05UA018hR9MchXHUwMDE2hIO3oFx1MDAxOHNR5OiKcEhcdTAwMDJcdTAwMThcdTAwMTRyViosYUJahOBcdTAwMWTEM0ib1Fx1MDAwND5cbp4nTvC54eojXHR++NU/OHKXbzfPt063j2twpra93TiZjqgyM2huKTfUXHUwMDE4ZVikyz3GXHJcdTAwMGXaarxCXHUwMDAypZxRNuZcIph8OPeLpDhcdTAwMTeW7uJQXHUwMDAz4TIhXHUwMDEyd5jgikpk91xmLTP/7LmLTL1+PT2o0pNk+JzFqOLgenpAXHUwMDFlaVx1MDAwN7+wfSt7QeXg7GqLPD7Cycry/ZHoXFzuz/1yeux/ooFcYlx1MDAwNmBcZiH9XHUwMDE5XGbQxulcdTAwMDaTimMsqSE7gfH+1fSRsYrsWYKQaHTsXFxyMevF9GflzfJcdTAwMTKtrHjV1epcdTAwMGZf3vDypbgqSuXp4cF6cNsgq/7h3kZwtXfyrHaCidSBWVx1MDAxNtWN/j+aynNmMpFDQWpcdTAwMDPoXHUwMDE0WWHopHfnnHP510qUXGI6/VxcXlx1MDAxMuOg3ZpcdTAwMDJ0XG6WgVx1MDAxMYw7qITY/MtHcvlRtTCVy89/XHUwMDFk2Fx1MDAxMJv/QXVggtHMRTCAPotJw4tcdTAwMDfSO/ri6kbpx1v/oHy+fPz88Lgmzubeb0nmXHUwMDEwZsBcdTAwMTBKpCZU94FcdTAwMGa7XHUwMDAw/Vx1MDAxNmBcdTAwMTQtKFKKWFx1MDAxMDOrKjAlJGKPTbAyZDzH1VDrbnAsXq7Kl1qrs8ZdsIy+5+NcdTAwMWRXbruHpzWobZRltVx1MDAxNXYuqD5mlaO9/Vx0tNtoP/JNODva6ZgnODhZXHRX7vjW53K0gsVcdTAwMTZ/JCpRrOtcdTAwMTkpaZY+/PPuaFx1MDAxNcnDumCOnlxu1otcdTAwMTWBcTvNSmBKa3am7GdnVVx1MDAwMzbER31EXHKYkCxz+sdmgjhBT1tcdTAwMTh4+aZzToEnKXGEsrVCXGZcdTAwMTksVf25L1x0zFx1MDAwMZDKXHUwMDA2yoxzPSjX5Ga3hWNX7DFcdTAwMDWKo1x0gFx1MDAxNFx1MDAxOErqoFx1MDAxOVSaaWWUMDpJf1FAjpxcdTAwMWNmVlx1MDAwMNZFq2CxXGJ78qmvfDpaXHUwMDFhLO/SXHUwMDFjiNLG1lGp+Oqdn2ViXHUwMDE4Llx1MDAxOIGBg1x1MDAwMmCCQ+LZXHUwMDBi5b7yoVvKLO/CuybzcdpB6ElDqVx1MDAxNFx1MDAwNohmn3tyO0uv7Suh0VFjXHUwMDBi8ffxXHUwMDAyXGIhs6e2hUShsG8jRVx1MDAxZGbc7k9b6ujBe3jY2Hrcba1cdLPjXHUwMDFkzdi4XHUwMDE1qP7iaNuwbzU3nLBcdTAwMDFSgdbOXHUwMDAxQrStuELkfmB1a9FF9MxWwaNGzDqA8I8qa1crtdtccvdCfDup1Fx1MDAwM1J396dA9OeGkEM2dFBPXHUwMDEwWECL04L03pxz5FDlmGzkSDkl5Fx1MDAxNKv+UoxcdTAwMDDGSXJyXHUwMDE54/mh47Or/lx1MDAxYWLxP6z6XHUwMDBiVOZcblx1MDAxMqQo1Fx1MDAxOGGKXHUwMDA3w99u7veO/fLVKWeN3VbraYtvm4c5XHUwMDA3XHUwMDFm+mZHcIyDpTJMx6ffX91cdTAwMTaSKGq3YEPk0fhsznTcVjLvhUxcdTAwMDOMgZnvfnS6slG5fdg/uEJd9Y/LnFc3Ksd/pzySjFx1MDAxOH5yMaOmwG1qpTB00rtzzqFcdTAwMDPaMdnQkeDAVKBTrPSLMqa7ZWi/4nzNbGq/htj7Sdd+cWmyIVx1MDAwN5JQu1a1eJCVz53nNYNEmMNcdTAwMTlGuJJcdTAwMTDDgffvXHUwMDE0oFxmdaRcdTAwMTZcdTAwMTjjaoVRp1x1MDAxOZw+mmBcdTAwMDbJOCCMUZJwgy5Jpm0hhFx1MDAwMbe0Y4ZgQHljXHUwMDBiP3r7b0hQo/ixT5c+KpyqsWVd0ih0XHUwMDFjSPPRoEZ7kpSyqsN44tFcdTAwMGJlj/KJZim7qFx1MDAwYjiFXHUwMDE0ofqqw0RCps+UPVrKVGr7Sqhz1NxC/H10s2ZU5s5BjFFcdTAwMWPzUTh4PrWaV6vGucPRbFx1MDAxMSYpKvfAKm5lwDFCITaowivIx+XFke1TxolmVlx1MDAxOMVT4mFcdTAwMDFcdTAwMGW39Y1GIErBJEsmUH2UlCNFx7+uWXst51x1MDAxMnainiB2XGKw5PJLaic9bEZcXOFVaEjUmCVf+WSklF3QlVx1MDAxNEk7SGmFIYBcdTAwMDZQXHUwMDEzwz73itAspbavQXVcdTAwMWXRpuV/K1x1MDAwMIPsJW2ca6kkXHUwMDFiIUI65N5cdTAwMGVpiy1cdTAwMTnenZZbT5VvlVx1MDAxM3ExW8Mmhld7Scfmy4TQXHUwMDAwXHUwMDE4gPTvjqFcdTAwMTh3hMUqdoPdXHUwMDA0M5utuZJ6opZr1/7hSWE3i0+r9lx1MDAxMilcdTAwMGLaIEHHMEzjwtD3bun4YdVe9y7dXGb8h/37L/5hsKb3g43640rRjFx1MDAwM/9xcv+1/nxSP/56015fuffPrlduP1fGgdHsxdVUgFGCY1RQXHUwMDE4T+ndOd94wuA9XHUwMDBmT5xOXHUwMDBlT/nJurTNLpLVXpTYRJ31NCMgasYph7HLvXpLROak4muIv8hcXOEyftFXvjfkMnPdXHUwMDE59jVcdTAwMTOKj7DHobtz0Vxcalxcr9HTXHUwMDAzXb/wNm+bZ8/e3LN8Y3fO0EaBVlQgw+pDL1x1MDAxOEQvVcyWR1x1MDAxYVx1MDAxZV/sPlx1MDAxYm8ojWGESP2+vVx1MDAxYj7MXHUwMDFidiqHoVe+8E7MQV2dtFdum5Xdtb+TN2RcInu/XfRcdTAwMTNcdTAwMTKdoSjuXHLTu3POXHUwMDAxZd1hXHUwMDFloMzkXHUwMDAwNVx0d8hcYjVcdTAwMTQ0+URcdTAwMGI5f1x1MDAxOW84xGF8gDfMzHdRqTPT+Fx1MDAwNlx1MDAxOLWhd/F8V74pm9dcImgllUOUNFx1MDAxYdWKoaPpX1xiqqh0tOXz1NhccnNIzjZH7922wFx1MDAxMVozSbjmWktqTErRoOSv+11cdJTHriZMbuJmP6pcdTAwMTSwWVVBTyXplc8nS32Jc8LRJNtcdEfVXVwiK2M5l7dcdTAwMTSTdIBLpYjm3e9bgTHXgubDutS3XHUwMDE2XHUwMDE0PVx1MDAwMZPC1lxc21R+MuslXHUwMDFjprlB3VVgR1MlJ1x1MDAxOD5T1mspW7W7p1x1MDAxM1pcdTAwMWQ1uFx1MDAxMH9cdTAwMWZzp6PY5PlgSlx1MDAxZs2fVYziu1Tm19/PhJvAcFx1MDAwM8fsvrCCXCI/wchnoJZTULtcdTAwMDeE5Izh8NiKz5x9Yaex0Vx1MDAxMVxiYlBcdTAwMGJgXrn+16PDcufMO79i4erezsHDt6WD2nzsczTq3lx1MDAxM2NxfVtlm8n1XHUwMDE5t7KoXHUwMDExguf07px3PKlcXDzpXHTiaVx1MDAxMlx1MDAxYlx1MDAxZFEgqFx1MDAxZDJe8vPRO1x1MDAxZI3l/H+9nY6GeItJ73SUidlcdTAwMWNcdTAwMGaImJVSk1x1MDAxMVxuXCI6wU1l47D98uOyeaafVn/slDfXZuxcdTAwMDJcdTAwMGKUWVxu4oDgXHUwMDE4fVx1MDAxYrtiU/av61x1MDAxNkY51FZZaqT5hKpY0elkfSCPfcFNXHUwMDBmsbEvOowqqi3t0yNtVjo+Ylx1MDAwMVx1MDAxOYJ6L2JcdTAwMGKvj1t2Sq+KXuogXHTtlPq1vnSHT+Nee6kojX0/8ygoXHKbrSyI9j3lIFx1MDAxZVx1MDAwYko6XHUwMDE2JmVOkZKynnyETVx1MDAwZfjT5Y/VJ7qp1lx1MDAxZlr8eV08Xla/VudcdTAwMWWRhDukXHUwMDFidFx1MDAxM40/419waVx1MDAxYjDMfoFcdTAwMTVgPIbRpCTZlVx1MDAxNkUy0NmAjH+bXrSwJPn1VGg60EqS6UxcdTAwMWVJSsW7PWjxumen1Esxldpep9W0ul557io9hrylRFx1MDAwMup7I2yW/LBTXHUwMDFhJCZxhzpdqL7/IV5RvPBGsVx1MDAxN91W6zjEcelFXHUwMDE3OOJ+7a1zI1FcdTAwMTZcdTAwMWZ873ElReWuui/batcyWFx1MDAxMHrdUOWvhb/+XHUwMDA3KtdcdTAwMDdDIn0= ByteEditor()BitSwitch(7)Label(\"7\") Switch() Switch.Changed( value=True)ByteEditor()BitSwitch(7)Label(\"7\") Switch() BitSwitch.Changed( value=True)BitSwitch.Changed( value=True)Switch.Changed( value=True)A. Switch sends Switch.Changed messageB. BitSwitch responds by sending BitSwitch.Changedto its parent"},{"location":"guide/widgets/#attributes-down","title":"Attributes down","text":"We also want the switches to update if the user edits the decimal value.
Since the switches are children of ByteEditor
we can update them by setting their attributes directly. This is an example of \"attributes down\".
from __future__ import annotations\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.geometry import clamp\nfrom textual.message import Message\nfrom textual.reactive import reactive\nfrom textual.widget import Widget\nfrom textual.widgets import Input, Label, Switch\nclass BitSwitch(Widget):\n\"\"\"A Switch with a numeric label above it.\"\"\"\nDEFAULT_CSS = \"\"\"\n BitSwitch {\n layout: vertical;\n width: auto;\n height: auto;\n }\n BitSwitch > Label {\n text-align: center;\n width: 100%;\n }\n \"\"\"\nclass BitChanged(Message):\n\"\"\"Sent when the 'bit' changes.\"\"\"\ndef __init__(self, bit: int, value: bool) -> None:\nsuper().__init__()\nself.bit = bit\nself.value = value\nvalue = reactive(0)\ndef __init__(self, bit: int) -> None:\nself.bit = bit\nsuper().__init__()\ndef compose(self) -> ComposeResult:\nyield Label(str(self.bit))\nyield Switch()\ndef watch_value(self, value: bool) -> None: # (1)!\n\"\"\"When the value changes we want to set the switch accordingly.\"\"\"\nself.query_one(Switch).value = value\ndef on_switch_changed(self, event: Switch.Changed) -> None:\n\"\"\"When the switch changes, notify the parent via a message.\"\"\"\nevent.stop()\nself.value = event.value\nself.post_message(self.BitChanged(self.bit, event.value))\nclass ByteInput(Widget):\n\"\"\"A compound widget with 8 switches.\"\"\"\nDEFAULT_CSS = \"\"\"\n ByteInput {\n width: auto;\n height: auto;\n border: blank;\n layout: horizontal;\n }\n ByteInput:focus-within {\n border: heavy $secondary;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor bit in reversed(range(8)):\nyield BitSwitch(bit)\nclass ByteEditor(Widget):\nDEFAULT_CSS = \"\"\"\n ByteEditor > Container {\n height: 1fr;\n align: center middle;\n }\n ByteEditor > Container.top {\n background: $boost;\n }\n ByteEditor Input {\n width: 16;\n }\n \"\"\"\nvalue = reactive(0)\ndef validate_value(self, value: int) -> int: # (2)!\n\"\"\"Ensure value is between 0 and 255.\"\"\"\nreturn clamp(value, 0, 255)\ndef compose(self) -> ComposeResult:\nwith Container(classes=\"top\"):\nyield Input(placeholder=\"byte\")\nwith Container():\nyield ByteInput()\ndef on_bit_switch_bit_changed(self, event: BitSwitch.BitChanged) -> None:\n\"\"\"When a switch changes, update the value.\"\"\"\nvalue = 0\nfor switch in self.query(BitSwitch):\nvalue |= switch.value << switch.bit\nself.query_one(Input).value = str(value)\ndef on_input_changed(self, event: Input.Changed) -> None: # (3)!\n\"\"\"When the text changes, set the value of the byte.\"\"\"\ntry:\nself.value = int(event.value or \"0\")\nexcept ValueError:\npass\ndef watch_value(self, value: int) -> None: # (4)!\n\"\"\"When self.value changes, update switches.\"\"\"\nfor switch in self.query(BitSwitch):\nwith switch.prevent(BitSwitch.BitChanged): # (5)!\nswitch.value = bool(value & (1 << switch.bit)) # (6)!\nclass ByteInputApp(App):\ndef compose(self) -> ComposeResult:\nyield ByteEditor()\nif __name__ == \"__main__\":\napp = ByteInputApp()\napp.run()\n
BitSwitch
's value changed, we want to update the builtin Switch
to match.Input.Changed
event when the user modified the value in the input.ByteEditor
value changes, update all the switches to match.BitChanged
message from being sent.switch
is a child, we can set its attributes directly.ByteInputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a100\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0\u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a00\u00a0\u00a0\u00a0\u00a0\u00a0 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
Changed
event, which we handle with on_input_changed
by setting self.value
, which is a reactive value we added to ByteEditor
.watch_value
which sets the value of each of the eight switches. Because we are working with children of the ByteEditor
, we can set attributes directly without going via a message.In this chapter we will explore the topic of concurrency and how to use Textual's Worker API to make it easier.
The Worker API was added in version 0.18.0
"},{"location":"guide/workers/#concurrency","title":"Concurrency","text":"There are many interesting uses for Textual which require reading data from an internet service. When an app requests data from the network it is important that it doesn't prevent the user interface from updating. In other words, the requests should be concurrent (happen at the same time) as the UI updates.
Managing this concurrency is a tricky topic, in any language or framework. Even for experienced developers, there are gotchas which could make your app lock up or behave oddly. Textual's Worker API makes concurrency far less error prone and easier to reason about.
"},{"location":"guide/workers/#workers_1","title":"Workers","text":"Before we go into detail, let's see an example that demonstrates a common pitfall for apps that make network requests.
The following app uses httpx to get the current weather for any given city, by making a request to wttr.in.
weather01.pyweather.tcssOutput weather01.pyimport httpx\nfrom rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nawait self.update_weather(message.value)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
weather.tcssInput {\ndock: top;\nwidth: 100%;\n}\n#weather-container {\nwidth: 100%;\nheight: 1fr;\nalign: center middle;\noverflow: auto;\n}\n#weather {\nwidth: auto;\nheight: auto;\n}\n
WeatherApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aEnter\u00a0a\u00a0City\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
If you were to run this app, you should see weather information update as you type. But you may find that the input is not as responsive as usual, with a noticeable delay between pressing a key and seeing it echoed in screen. This is because we are making a request to the weather API within a message handler, and the app will not be able to process other messages until the request has completed (which may be anything from a few hundred milliseconds to several seconds later).
To resolve this we can use the run_worker method which runs the update_weather
coroutine (async def
function) in the background. Here's the code:
import httpx\nfrom rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.run_worker(self.update_weather(message.value), exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
This one line change will make typing as responsive as you would expect from any app.
The run_worker
method schedules a new worker to run update_weather
, and returns a Worker object. This happens almost immediately, so it won't prevent other messages from being processed. The update_weather
function is now running concurrently, and will finish a second or two later.
Tip
The Worker object has a few useful methods on it, but you can often ignore it as we did in weather02.py
.
The call to run_worker
also sets exclusive=True
which solves an additional problem with concurrent network requests: when pulling data from the network, there is no guarantee that you will receive the responses in the same order as the requests. For instance, if you start typing \"Paris\", you may get the response for \"Pari\" after the response for \"Paris\", which could show the wrong weather information. The exclusive
flag tells Textual to cancel all previous workers before starting the new one.
An alternative to calling run_worker
manually is the work decorator, which automatically generates a worker from the decorated method.
Let's use this decorator in our weather app:
weather03.pyimport httpx\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
The addition of @work(exclusive=True)
converts the update_weather
coroutine into a regular function which when called will create and start a worker. Note that even though update_weather
is an async def
function, the decorator means that we don't need to use the await
keyword when calling it.
Tip
The decorator takes the same arguments as run_worker
.
When you run a worker, the return value of the function won't be available until the work has completed. You can check the return value of a worker with the worker.result
attribute which will initially be None
, but will be replaced with the return value of the function when it completes.
If you need the return value you can call worker.wait which is a coroutine that will wait for the work to complete. But note that if you do this in a message handler it will also prevent the widget from updating until the worker returns. Often a better approach is to handle worker events which will notify your app when a worker completes, and the return value is available without waiting.
"},{"location":"guide/workers/#cancelling-workers","title":"Cancelling workers","text":"You can cancel a worker at any time before it is finished by calling Worker.cancel. This will raise a CancelledError within the coroutine, and should cause it to exit prematurely.
"},{"location":"guide/workers/#worker-errors","title":"Worker errors","text":"The default behavior when a worker encounters an exception is to exit the app and display the traceback in the terminal. You can also create workers which will not immediately exit on exception, by setting exit_on_error=False
on the call to run_worker
or the @work
decorator.
Workers are managed by a single WorkerManager instance, which you can access via app.workers
. This is a container-like object which you iterate over to see your active workers.
Workers are tied to the DOM node (widget, screen, or app) where they are created. This means that if you remove the widget or pop the screen where they are created, then the tasks will be cleaned up automatically. Similarly if you exit the app, any running tasks will be cancelled.
Worker objects have a state
attribute which will contain a WorkerState enumeration that indicates what the worker is doing at any given time. The state
attribute will contain one of the following values:
worker.error
will contain the exception. SUCCESS The worker completed successful, and worker.result
will contain the return value. Workers start with a PENDING
state, then go to RUNNING
. From there, they will go to CANCELLED
, ERROR
or SUCCESS
.
When a worker changes state, it sends a Worker.StateChanged event to the widget where the worker was created. You can handle this message by defining an on_worker_state_changed
event handler. For instance, here is how we might log the state of the worker that updates the weather:
import httpx\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nfrom textual.worker import Worker\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True)\nasync def update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nasync with httpx.AsyncClient() as client:\nresponse = await client.get(url)\nweather = Text.from_ansi(response.text)\nweather_widget.update(weather)\nelse:\n# No city, so just blank out the weather\nweather_widget.update(\"\")\ndef on_worker_state_changed(self, event: Worker.StateChanged) -> None:\n\"\"\"Called when the worker state changes.\"\"\"\nself.log(event)\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
If you run the above code with textual
you should see the worker lifetime events logged in the Textual console.
textual run weather04.py --dev\n
"},{"location":"guide/workers/#thread-workers","title":"Thread workers","text":"In previous examples we used run_worker
or the work
decorator in conjunction with coroutines. This works well if you are using an async API like httpx
, but if your API doesn't support async you may need to use threads.
What are threads?
Threads are a form of concurrency supplied by your Operating System. Threads allow your code to run more than a single function simultaneously.
You can create threads by setting thread=True
on the run_worker
method or the work
decorator. The API for thread workers is identical to async workers, but there are a few differences you need to be aware of when writing code for thread workers.
The first difference is that you should avoid calling methods on your UI directly, or setting reactive variables. You can work around this with the App.call_from_thread method which schedules a call in the main thread.
The second difference is that you can't cancel threads in the same way as coroutines, but you can manually check if the worker was cancelled.
Let's demonstrate thread workers by replacing httpx
with urllib.request
(in the standard library). The urllib
module is not async aware, so we will need to use threads:
from urllib.request import Request, urlopen\nfrom rich.text import Text\nfrom textual import work\nfrom textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Input, Static\nfrom textual.worker import Worker, get_current_worker\nclass WeatherApp(App):\n\"\"\"App to display the current weather.\"\"\"\nCSS_PATH = \"weather.tcss\"\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"Enter a City\")\nwith VerticalScroll(id=\"weather-container\"):\nyield Static(id=\"weather\")\nasync def on_input_changed(self, message: Input.Changed) -> None:\n\"\"\"Called when the input changes\"\"\"\nself.update_weather(message.value)\n@work(exclusive=True, thread=True)\ndef update_weather(self, city: str) -> None:\n\"\"\"Update the weather for the given city.\"\"\"\nweather_widget = self.query_one(\"#weather\", Static)\nworker = get_current_worker()\nif city:\n# Query the network API\nurl = f\"https://wttr.in/{city}\"\nrequest = Request(url)\nrequest.add_header(\"User-agent\", \"CURL\")\nresponse_text = urlopen(request).read().decode(\"utf-8\")\nweather = Text.from_ansi(response_text)\nif not worker.is_cancelled:\nself.call_from_thread(weather_widget.update, weather)\nelse:\n# No city, so just blank out the weather\nif not worker.is_cancelled:\nself.call_from_thread(weather_widget.update, \"\")\ndef on_worker_state_changed(self, event: Worker.StateChanged) -> None:\n\"\"\"Called when the worker state changes.\"\"\"\nself.log(event)\nif __name__ == \"__main__\":\napp = WeatherApp()\napp.run()\n
In this example, the update_weather
is not asynchronous (i.e. a regular function). The @work
decorator has thread=True
which makes it a thread worker. Note the use of get_current_worker which the function uses to check if it has been cancelled or not.
Important
Textual will raise an exception if you add the work
decorator to a regular function without thread=True
.
Most Textual functions are not thread-safe which means you will need to use call_from_thread
to run them from a thread worker. An exception would be post_message which is thread-safe. If your worker needs to make multiple updates to the UI, it is a good idea to send custom messages and let the message handler update the state of the UI.
Welcome to the How To section.
Here you will find How To articles which cover various topics at a higher level than the Guide or Reference. We will be adding more articles in the future. If there is anything you would like to see covered, open an issue in the Textual repository!
"},{"location":"how-to/center-things/","title":"Center things","text":"If you have ever needed to center something in a web page, you will be glad to know it is much easier in Textual.
This article discusses a few different ways in which things can be centered, and the differences between them.
"},{"location":"how-to/center-things/#aligning-widgets","title":"Aligning widgets","text":"The align rule will center a widget relative to one or both edges. This rule is applied to a container, and will impact how the container's children are arranged. Let's see this in practice with a trivial app containing a Static widget:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's the output:
CenterApp Hello,\u00a0World!
The container of the widget is the screen, which has the align: center middle;
rule applied. The center
part tells Textual to align in the horizontal direction, and middle
tells Textual to align in the vertical direction.
The output may surprise you. The text appears to be aligned in the middle (i.e. vertical edge), but left aligned on the horizontal. This isn't a bug \u2014 I promise. Let's make a small change to reveal what is happening here. In the next example, we will add a background and a border to our text:
Tip
Adding a border is a very good way of visualizing layout issues, if something isn't behaving as you would expect.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\", id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
The static widget will now have a blue background and white border:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHello,\u00a0World!\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note the static widget is as wide as the screen. Since the widget is as wide as its container, there is no room for it to move in the horizontal direction.
Info
The align
rule applies to widgets, not the text.
In order to see the center
alignment, we will have to make the widget smaller than the width of the screen. Let's set the width of the Static widget to auto
, which will make the widget just wide enough to fit the content:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, World!\", id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this now, you should see the widget is aligned on both axis:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHello,\u00a0World!\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#aligning-text","title":"Aligning text","text":"In addition to aligning widgets, you may also want to align text. In order to demonstrate the difference, lets update the example with some longer text. We will also set the width of the widget to something smaller, to force the text to wrap.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's what it looks like with longer text:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eCould\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258eterminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u258a \u258eclassified\u00a0address.\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note how the widget is centered, but the text within it is flushed to the left edge. Left aligned text is the default, but you can also center the text with the text-align rule. Let's center align the longer text by setting this rule:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n text-align: center;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this, you will see that each line of text is individually centered:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
You can also use text-align
to right align text or justify the text (align to both edges).
There is one last rule that can help us center things. The content-align rule aligns content within a widget. It treats the text as a rectangular region and positions it relative to the space inside a widget's border.
In order to see why we might need this rule, we need to make the Static widget larger than required to fit the text. Let's set the height of the Static widget to 9 to give the content room to move:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n height: 9;\n text-align: center;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's what it looks like with the larger widget:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Textual aligns a widget's content to the top border by default, which is why the space is below the text. We can tell Textual to align the content to the center by setting content-align: center middle
;
Note
Strictly speaking, we only need to align the content vertically here (there is no room to move the content left or right) So we could have done content-align-vertical: middle;
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nQUOTE = \"Could not find you in Seattle and no terminal is in operation at your classified address.\"\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #hello {\n background: blue 50%;\n border: wide white;\n width: 40;\n height: 9;\n text-align: center;\n content-align: center middle;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(QUOTE, id=\"hello\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this now, the content will be centered within the widget:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u258a \u258e\u00a0Could\u00a0not\u00a0find\u00a0you\u00a0in\u00a0Seattle\u00a0and\u00a0no\u00a0\u258a \u258e\u00a0\u00a0\u00a0terminal\u00a0is\u00a0in\u00a0operation\u00a0at\u00a0your\u00a0\u00a0\u00a0\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0classified\u00a0address.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#aligning-multiple-widgets","title":"Aligning multiple widgets","text":"It's just as easy to align multiple widgets as it is a single widget. Applying align: center middle;
to the parent widget (screen or other container) will align all its children.
Let's create an example with two widgets. The following code adds two widgets with auto dimensions:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"How about a nice game\", classes=\"words\")\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
This produces the following output:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
We can center both those widgets by applying the align
rule as before:
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"How about a nice game\", classes=\"words\")\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
Here's the output:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
Note how the widgets are aligned as if they are a single group. In other words, their position relative to each other didn't change, just their position relative to the screen.
If you do want to center each widget independently, you can place each widget inside its own container, and set align
for those containers. Textual has a builtin Center
container for just this purpose.
Let's wrap our two widgets in a Center
container:
from textual.app import App, ComposeResult\nfrom textual.containers import Center\nfrom textual.widgets import Static\nclass CenterApp(App):\n\"\"\"How to center things.\"\"\"\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n .words {\n background: blue 50%;\n border: wide white;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nyield Static(\"How about a nice game\", classes=\"words\")\nwith Center():\nyield Static(\"of chess?\", classes=\"words\")\nif __name__ == \"__main__\":\napp = CenterApp()\napp.run()\n
If you run this, you will see that the widgets are centered relative to each other, not just the screen:
CenterApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eHow\u00a0about\u00a0a\u00a0nice\u00a0game\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eof\u00a0chess?\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
"},{"location":"how-to/center-things/#summary","title":"Summary","text":"Keep the following in mind when you want to center content in Textual:
align
rule is applied to the parent of the widget you want to center (i.e. the widget's container).text-align
rule aligns text on a line by line basis.content-align
rule aligns content within a widget.Center
container if you want to align multiple widgets relative to each other.If you need further help, we are here to help.
"},{"location":"how-to/design-a-layout/","title":"Design a Layout","text":"This article discusses an approach you can take when designing the layout for your applications.
Textual's layout system is flexible enough to accommodate just about any application design you could conceive of, but it may be hard to know where to start. We will go through a few tips which will help you get over the initial hurdle of designing an application layout.
"},{"location":"how-to/design-a-layout/#tip-1-make-a-sketch","title":"Tip 1. Make a sketch","text":"The initial design of your application is best done with a sketch. You could use a drawing package such as Excalidraw for your sketch, but pen and paper is equally as good.
Start by drawing a rectangle to represent a blank terminal, then draw a rectangle for each element in your application. Annotate each of the rectangles with the content they will contain, and note wether they will scroll (and in what direction).
For the purposes of this article we are going to design a layout for a Twitter or Mastodon client, which will have a header / footer and a number of columns.
Note
The approach we are discussing here is applicable even if the app you want to build looks nothing like our sketch!
Here's our sketch:
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daW9cdTAwMWK7kv1+f0WQ92VcdTAwMDa46ktcdTAwMTbJXCJ5gcHAi1x1MDAxY1mOl8iKt8GDoaUtyda+eHvIf5+ivKjVUsstW7JbuTaQxGktzWafOudcdTAwMTRZTf7njy9fvvbv2v7Xv7989W9LhXqt3C3cfP3TXHUwMDFkv/a7vVqrSS/B8P+91qBbXHUwMDFhvrPa77d7f//1V6PQvfL77Xqh5HvXtd6gUO/1XHUwMDA35VrLK7VcdTAwMWF/1fp+o/e/7u+9QsP/n3arUe53vdFJUn651m91XHUwMDFmzuXX/Ybf7Pfo2/+P/v/ly3+Gf1x1MDAwN1rX9Uv9QrNS94dcdTAwMWZcdTAwMTi+NGogZ0aFXHUwMDBm77Waw9ZyLoxGxVxyPL+j1tukXHUwMDEz9v0yvXxBjfZHr7hDX1x1MDAwZnL9dT+3q/ay56WbXCK2djHdXHUwMDFkjM57UavXXHUwMDBm+3f1YbtK3Vavl6pcdTAwMTb6peroXHUwMDFkvX63deVcdTAwMWbXyv3qU/dcdTAwMDWOP3+216KuXHUwMDE4farbXHUwMDFhVKpNv+d6gT9cdTAwMWZttVx1MDAwYqVa/254lez56ENX/P1ldOSW/pdC4Vx0LY1VqLW1KtAr7lx1MDAwYkAoXHUwMDBmhFx1MDAxNKhcdTAwMDVarahfQi3baNXpjlDL/sWGP6O2XHUwMDE1XHUwMDBipatcbjWwWX5+T79baPbahS7dt9H7blx1MDAxZa9ZSemhcCdjinM+akfVr1WqfXqHQOZcdTAwMTnOrJQohLs9OGqMP7w1XHUwMDFjjNCopVx1MDAxNs+vuCa0t8tDnPw72HHN8mPHNVx1MDAwN/X6qNXuhXRcdTAwMDBbo89cZtrlwlx1MDAwM1x1MDAwMjhcdTAwMWFcdIpJ1Fxio1x1MDAwZanXmlfhr6u3Slcj0Fxmj/7681x1MDAxNXDVKlx1MDAxYa1WcVx1MDAwNIlcdTAwMThcdTAwMWKsuVLn5367sL63s3+Lza2fXHUwMDA3nZ3tTlx1MDAwNFhDgPtImFx1MDAxYcGVtIpxJjhBIIRT6Vx1MDAxOaUlgcJcdTAwMDJKi8vEqfBcdTAwMThHXHUwMDA1dD6urTaTQFx1MDAwNeMhMiFccjJAQaCeXHUwMDAwKt0zJYGuZvVw6tfrtXZvKkpxXHUwMDA2p1x1MDAxYZCcI1cmNkzbP86OS+lstssvc9f5+3TqrtG8fVxyTPn7wVRxz1xuhdTxQljOQIbolDjOMCMl11x1MDAwZcqgw1x1MDAxMTRcdTAwMTdM/3VRUKBgXHUwMDEyolxceJJcdTAwMDEoi0D/SGPkJEY5eFxuKVKISFx1MDAxOVxiXHUwMDAxUocxyql9ilNcdTAwMWL5b1x1MDAwNVItbFx1MDAxNEi1QXfNXHUwMDEwX/dP1jrpu8ZB7qC4tnXT2upvna3jTcIxXG7KY9TdyLnhlsBcdTAwMDEhiFxuT1x1MDAxObRkXG4kUZhV8o1cdTAwMTAtMqaWXHUwMDA1Uc1cdTAwMTXpgeC/XHUwMDE5Qq2IQijnjJSFS81iQ7TeSbdTV1x1MDAxNexs1upXWLrrb53UXHUwMDBlk1xyUVx1MDAxMljgXHUwMDAyQWjBmWRWh1x1MDAxMFxujmQl3Vx1MDAxNVxyxLVv41BcdTAwMGVFY3BZXHUwMDAwpcsgqkcuV1DoZ+dPKlx1MDAxMqPu1iihXHUwMDAzb3hcdKLr37ZcbrsldbLV2V1vnt3+OL6pXHUwMDFjNJJcctFcdTAwMTTlXCKk4lZcdTAwMTFFcnBJyThGXHUwMDExXHQ+hFxmTTTKJcOlYFRJ5oym4Vx1MDAxNCnaQiBOXHUwMDAyOZMnNaekytphO0CFIUquTJGLNYrNXHUwMDAx0SewjOBcIlx1MDAxZY/8ikbu82dGn1x1MDAwZcCt79+O3HZcdTAwMDBcdTAwMWPd9mG1cbB9tj/YK+VzlbXWgVx1MDAxOdx9fX7fr8ffosJCXGImjTLWLiosxtpcdTAwMTmMXGJcdTAwMTNcdTAwMTVcdTAwMTCczs5dvqBiR8T0i55cdTAwMWVcdTAwMTHVQqk66PpcdTAwMWZcdTAwMWZcdTAwMTNcXCO5W+JKQIK+YTLE21x1MDAxYTxcdTAwMDdB4nNcdTAwMTlcbpnXXHUwMDA0XHUwMDA1xVx1MDAxZbuYYn4p8oBcdTAwMTPaXHUwMDE5p1x1MDAwZSeRmJafTVx1MDAwNoFcdTAwMDWuyZ3PXHUwMDE1XHUwMDA0r+Pp11x1MDAwMHJ041vN/mHt3vU9sLGjW4VGrX43du+GSKWuyviFst/9OnZ8rV6rONR+LVF7gy9cdTAwMTJ2+7VSof78hkatXFxcdTAwMGVqQIlOVqg1/e52XHUwMDFj7m51a5Vas1DPR7SFrt3PPOupXHUwMDE3uDPFQs93r1x1MDAwZVx1MDAwM+j1Qlx1MDAwNVx1MDAxOJmTkns0QltcdTAwMTN/5GRtc6O+M2Bddn/XuK/YfL5/lc68LizfcexEXHUwMDE5j5NRkoxsfUhcdTAwMDPcN1xihp7ipGQ8XHUwMDE4XHUwMDE3XHUwMDBiXHUwMDFlNeGKuEFScvEkiKPrXHUwMDFmhSX5KbCGXHUwMDFisnXKqCA/PLkp0IZy6sDgz1x1MDAxMt2UJt5cdTAwMGVkP0t1U1x1MDAwMapcZouHM/zW5fmxUdqpbbT2sr3NXGZ2N1x1MDAwZTK57av9evpV43vvOXBcIjy6t+hcdTAwMTJwMis6hFBuKCdcdTAwMTX0gmIhYLzGTalCoXgh5TTPzz1GSIUpg89Selx1MDAxMjSzj01cYjbxXHUwMDExnUJcdTAwMWKlmVx0XGZcYr5cdTAwMTWcrzNM2Xbnvt45Ov/x48f5Sf3i6vgwfXxcdTAwMWMwTH9O/9qHXHUwMDBm51t7lUIu5Z9095pH9lx1MDAxMCv1y+1cdTAwMWbjZ3k6f6Hbbd3MYcQ4XHUwMDAwhZRQi4qoSCNcdTAwMTY5vkMmjGhcdTAwMGWtiZ+aTO/MhFx1MDAxYrFcdTAwMTRcdTAwMTGpIOtDySBlXHUwMDA1aMLhJJin6bBloFxirnYh2cm0eJLW46QsmnJBy6ZGVcDEPI2MazJvxr5cdTAwMDPFXHUwMDBiSpjIi1x1MDAwNYj1dUaM49jRXHUwMDE5Rix/4/v9JfmwXHUwMDE3SD/sw0JNiWfD5Fx1MDAxYmyYgPDR5/FcdTAwMDLBXGYjb1x1MDAxMt+GVXnaNrYvdzqp6lx1MDAwNuxfyrPTn0onX+BcZnJcdTAwMDGGh+1cdTAwMTdcdTAwMWGPRIVcdTAwMDJEaGCaiVCbPsUteOu3mtlccr9/36hWz5pq/7qU7drcSXxcdTAwMTFcdTAwMDLFKVx1MDAwMdNq+aNcdTAwMDFcdTAwMDFWXGKrkLbcXHUwMDE4w+dcdTAwMTggm37VK6BCJD+KSWHleHYxxL1hXHUwMDFlJSXyzXnHylx1MDAwYpCYXHUwMDAzjMlcdTAwMTWgXHUwMDE3SPnDXHUwMDA1yLLw0ZEtVJJroUT8uen1k5udY5VLnzXRXFzdf9va6ejNXtJcdTAwMTWISN5cdTAwMDBcdC03XHUwMDFjJ4dcdTAwMDEkMOp24iVOIYtGvNFcdTAwMTT+5jqUXHUwMDFmXFyv5fa+X/LU/u3deW692Pl29X1OXHUwMDFkUjJQXHUwMDE4sixcdTAwMWTSkb5cdTAwMGKYkFxcQrBW5iXYT7/qxOuQ8JRigCBcdTAwMTXnlIKG0iHiYU9qXHUwMDEwlFx1MDAwYjFBXHUwMDE27Y1cdTAwMDbsU4xcdTAwMTIgRi/w80eLkTA8fPQpKlxySM1cdTAwMDH1aIzrpaBs9b5cdTAwMWSet9bWe2epn+1jW7lnJ7yQdC1SXHUwMDFlkTjprlx1MDAxNVO1SLteXHUwMDE3Slx1MDAxYm6QXHTFlydGU8ah5cTAc1JU5yB9WG9uXHUwMDFmVlx1MDAwN/5ajde+dUSnXdqZU3U0LF91Zk26kOxcdTAwMDCoOZKf6Vx1MDAxN71cdTAwMDKig0hcdTAwMTIvNJsqOkYsXHUwMDE04Z+qk1x1MDAwMNV5gYk/WnU4RqpcdTAwMGVcdTAwMDHEWUGr45codPZcdTAwMGWvXHUwMDFi6Hc6WXPon9rDwkb7eDPZsmOYp13dNVk9YVx0fCM6f5hlolBcdTAwMTGCXCKTQmk8Yl9cdTAwMTOSXHUwMDAwpuhPKywzXHUwMDFlc1Vj04ORXGLa01x1MDAxOJ6MfZz+ZHTcXG7BR/q0xFwiXHUwMDA1VFxc2PcpJlx1MDAwM1x1MDAxYpmkcCGlq+Ww8f3Qjl83379l6rtcdTAwMTfqqr2pdLfWbq4nXHUwMDFimK6aXGaBOdplXHUwMDE2UYcqXHUwMDFlldJcdTAwMWWlxXRYg5VcdTAwMTbeVvFooGS5P1x0zH9ENVmnWMzk8T6T7V3ft3WuslNax/R8XHUwMDBlytBcdTAwMWTgi4qLyLxcdTAwMWRlVEhoXHUwMDAzwDmzc1D11ItOuIPiWntcdTAwMTKtq8IgymMqVE2mUHiMoXtcdTAwMDe5TXzjXHUwMDEwcmQxmSTAWyvoNCBcZlx1MDAwNNK2hFx1MDAxNJMtyD/FLybbarX6Sysme4G6w1x1MDAwNirclqVcdTAwMTeTXHUwMDA1XHUwMDA3JENRKSkkiacxvk5cdTAwMWSfyduLTnFXZHbSm5mN3Lp/1j9KejFcdTAwMTnnnPyJtGjMw2zl6GtcdTAwMWVqySxFpXJPOHF8c43nSlaUTdelx6Gajla63vJ57fREXFyKrl/cuD1fWHVccmk2W5gwzTZsMnp2k9hcdTAwMWHcJEL86fxcdTAwMDNd6N521i94tre/0Thkx1x1MDAwN+e902RcdTAwMWI2QrdngFx1MDAxMftCuJZ5XHUwMDE4XHUwMDA2wDyrjEW7iDD47SdT1vKnu9vbP1x1MDAwZnY2+1x1MDAxOdk46lx1MDAxZmyd5W3cirXs9o+LU6wp1chu9ZrXR5XCzdXu4irW3NTg0s1cdTAwMWXMKP90s0RcdTAwMWGZjVx1MDAxZE7TezPpbo9SXHUwMDFjrYiOXHUwMDE5Ob6JXHUwMDA0SFxi4ZGlUlxcPZYwvy2cPlx1MDAwN8timr1lXHUwMDBllr3A+lx1MDAxZj1YJmT0xKlcIoiilvGHsEV3/fJ252fZ7MHZcVVV8VStdZOvcKRvJOVcdTAwMTiWsGFEalI4So0oXHUwMDAzs1x1MDAwYnjA7TdcdTAwMTe4/PptrWvWXHUwMDBlb1tcdTAwMWIoikff7zavXHUwMDBi+3M9wyZcdTAwMTRcYlxc+qhDcGooLEQgmUBleXzUT7/qxFx1MDAwYlx1MDAxMXpcdTAwMTZdMkdJ/6RcdTAwMTBcdTAwMTlcdTAwMTJcIvc4r3vY9+3FmqsqRK/AY3KF6Fx1MDAwNXL+cCGy0c8zUL5H2TaK+KtcdTAwMDGcsfNO6qJvMsfVtZ3Uxam6zlx1MDAwNzLQhCqR8lx1MDAxNFx1MDAwNzR0qUJPKJFcdTAwMDTwXGZl92RcdTAwMWQ1uHj5VKJcdTAwMTlKxLLp0rbMs61Byp5cdTAwMWOcXHUwMDE2JWtcdTAwMTVcdTAwMDbzjX9LYiZcdTAwMTbE/1KUKLpCXHUwMDA2NWHBTVx1MDAwYsVcdTAwMDb99IteXHUwMDAxIZJuXHUwMDEwmaDjVlx1MDAxYVx1MDAxOFx1MDAxN1wiKZTHkFx1MDAwMGdcZqi3V2uuqlx1MDAxMP1WXHUwMDE50Vx1MDAwYtz80UIkZ1RQk1WSXHUwMDA20cZcdTAwMTeiXvNO+er7fjs/yFxmWje8t7X5bTfpQiQ98r9aXHUwMDAyQ+VcbmdHXGb0ULQmSIiY0kYwMz5h9Y8uWjtcdTAwMTPrezc3crN4spMvpG4vq7uVg0pcdTAwMDIlR+jIKVeyIHS/jOXxNWf6Va+A5lxi4chcdTAwMWXc8LWRIatl1EJcdTAwMTG+4qozz9pcdTAwMDDJVZ1cdTAwMTeI+KNVh7xeVFSCMlxiwOJPNG01N3c7W6ksbMP5oLnm64vNb1FcdTAwMDNcdTAwMTJcdNFcdTAwMWNQzFOMXHUwMDFiTVx1MDAxZW+yXGJCXHUwMDAwp9RILWpJnZUuWVx1MDAxYq7Ywd6rZE1gdMlcdTAwMWFcdTAwMThB3oDJ+KVcdTAwMDCbOyeH7eyp3fyW/nFweqK+i3w/asWOxJRcdTAwMDLQZXqKYFx1MDAwMUrgtFx1MDAxMWLOPFSWhJPDm59r/u0qXHUwMDAxdvs3p8Wj01x1MDAxY5rMnbzoraW3blpnXHUwMDBim7WUYFx1MDAwMlx1MDAxNfhLrVx1MDAwNFx1MDAxMJFcdTAwMTUxnJO2U0Ns/JVrjptnxVRd18o1xnJZOEinipuphFx1MDAxM7QxRNBaI2pwSz2HaopcdTAwMDE8gr5WXGa5q/JXn/Mks1KFa57ezd3traV+yPtTuGfFjctKMW4hwPFdeW3ztsVcdTAwMGYzuWo5n8FBrnmbW1xcIYCVbOkpyMxVXHUwMDAztNKcXHUwMDFiXHUwMDFi/2nN6b2Z8Fx1MDAxNITMtSeFVdZcdTAwMTlszoJcdTAwMTHzUFx04HJw7Vx1MDAxYziGXHUwMDE20/hMQVY1XHUwMDA1eYH2PzpcdTAwMDVcdTAwMTFcInLgXHUwMDBiKE92i1xixlx1MDAxZvdK93aE/H7q31xcXHUwMDFmd1x1MDAwYmv3ftpcdTAwMWVVo8aiXHUwMDEzI3HaXHUwMDE1u1lheFjEXHUwMDFlSlx1MDAwMcAjkUelXHUwMDFmpkTDXHL7lLjg/b+6YoNGZuOuVsZu75JlZfv84GLO0TD3Z9lSXHUwMDA0PPpcdTAwMDFcdTAwMDRAVFx1MDAwNIb4XHUwMDBmIEy/6MQrkXElaVx1MDAxYbmRjPAvxlFvSIhIXHUwMDE4mISQ7/vUoVXVoVx1MDAxN7j5w3XIRJaJUrRcbuJnmGNcdJutVvU8ZVtHfnX/JNPM1lxuulHNJl+IXGJtQqCSROdhIZIgPWRMuVx1MDAxZFjQ2OC04KdcdTAwMTJNKtH29fqV8Sv5Sr3Fi6Zlv12y9LzzMu+iRFx1MDAxOF1cdTAwMDAjlVuxiFx1MDAwNYpcdTAwMDVegv30q15cdTAwMDEpcvVWyEBcdTAwMWJcdTAwMTGWXCIp0DPcjXxcdG5ccshFbIjxKUVcdTAwMWYrRS+w80dLkdSRUoSKvJLSc9Sk2SNbNlBLr/XyV6XizV1e/cgmfCVcdTAwMDEw6GlGcWCUskqHXHUwMDAzUitPUpxQsqRcdTAwMDAkLi8jWqlCgFx1MDAxMzi9zJTklrxs71c3dnZz+6U7lUDBXHUwMDEx0etkSPdwXHUwMDFiSX98vZl+0SugN1x1MDAxY6zbeskyJoIrRD/UXHUwMDAx6EXi+1NwXHUwMDEyIDgvkPBHXHUwMDBiXHUwMDBlRG+AqrVGXHUwMDAwruOnPt30UZrt7O83hTpJy8Za5uw+9zPZguOqnIGSXG5BXHUwMDA0NNysYywgXHUwMDA1uMdcdTAwMTJcdTAwMDJjcG9cbsjVL1x1MDAwM1x1MDAxMDqQXGIvtVxmQEfv+oRCgWVyjpVr9lmrv5nL+oY31/X6xe4mXjRcdTAwMTO/qix6buEowp52i5mFXHUwMDE28qNcdTAwMWXwrFvv+nFcdTAwMTW0t42SRVx1MDAwMVO6labdhlNuq1x1MDAwM/c1OFx0T5Bu116SXHUwMDExPVxcx1x1MDAwMyZcdTAwMGJcdTAwMDDeXHUwMDE3o1x1MDAxMoRcXPoq5DNYU7n1XHUwMDAyOJujiv460ylmXHUwMDFh9udm+eLozFx1MDAxN1x1MDAwNzlTgGbSnVxmXHUwMDExJlx1MDAwMVx1MDAxMNFwI1x1MDAxMVhgfYSHrSS14yxOyTORhjViedtGk5eR5ExcdTAwMTRcYlx1MDAwYqFAmbGUXGY1yY1wwLKt+9KLUlx1MDAxNuSV5linpnbrlyO8Ut2/6M9wSv1WO8omjTV3Yjma8VMuYjWa6MdjXCLHxITbbFCjiV8nUDXr+3fn1fIgla1s5Vx1MDAxYq3LuzxL/OxcZoWFR1xcL4XVhlEqMl58hpx0h6SXQKeMU55/dFxc58hcdTAwMTZcdTAwMTbyV2dcXF9cdTAwMWNuX36HUjrTb3/G9Vx1MDAwN8b1QzdPM5Qztlx1MDAxMVVuu2vkMv7I2mxCT2pkg/bc9ijKKKVItsdcdTAwMWQloPBcdTAwMDSS41x1MDAxNNxcIuCbxrpnXHUwMDA2tmWeNW5FVMtcdTAwMTRcdTAwMTnHXHUwMDEx31x1MDAwNi0loqA2MOOe0MNcdEvJiXtcdTAwMTSToOdcdTAwMTmMmFxcXGZcdTAwMTFcdTAwMWWPzFhcZnFG5Zog9tOv2my01y90++u1ZrnWrIw3zH849XZcZl84jOjSwLWSeaApYXLzXHUwMDE0bjhSju6s65hC2/G2Ry6c2lxmlj9MsE1cXLrfLL/cpNn11IEmpZhnXHUwMDE0pztkhit6XHUwMDE4hWKiTZR5o9vcW5I9dONQdqJN9UKvv9FqNGp96vmDVq3ZXHUwMDBm9/CwK9dcXNBX/cJcdTAwMDTh0DVcdTAwMDVfXHUwMDBis0PbfeM4yY9++zKKnuF/nn//959T352KwrX7mUD06Nv+XGL+Ozet2ehHq1xmUr9Solx1MDAxY3/4ZracJZXVXHUwMDE0o1x1MDAwNJQpUIbwL9n4g1x1MDAxY1xi3ONusFx1MDAwMJlcdTAwMTaISyyWt8JDbYZLW4xtXGY4mj/gXHUwMDFlXHUwMDEzlFxmW0roXHRcdTAwMWWT01x0XHUwMDFjKWWiTJnNY15cdTAwMTbOasxcYnzVY78xWW22KVx1MDAxZadcdTAwMTBiNNdXXGJARKLZiO6fOcR40m1cdTAwMDIurGTuwVFjZvPa+HWsXHUwMDE0u0yH1/C1XHRkzckukfM1NnIyUlx0NG5p8/iL1lx1MDAxY1X29zZQ9vzaXHUwMDFl+Nnvt53Dw42bpJNLSqJLd4hBrEIlJIyzXHUwMDBiV+5JXHUwMDFkrVxyMolumONNw3Czn8Ux2uNCXHUwMDE5lNHZ0MScXHK3lFxyKVx1MDAwN4x3SYfesMftn7O+91xyK1x1MDAxMc783iU+2OD2one9/8b0Lf5cdTAwMTRcdTAwMTahaNBo9r7811N29qVX6rbq9f9+35QuRjNcdTAwMTYxpVx1MDAxNemHzIxcdTAwMDc9lFtseq5nzWdcdTAwMDM6oZwljEdGXHUwMDEzrHFcdTAwMWJccihcdTAwMDXjXHUwMDAzOI5HXHUwMDE0XHUwMDEzRltcdTAwMTKS5Y7MkunVJM1cbshccmshg9tCjCa3QHiGh2r+nthcdTAwMGKcnVx1MDAxYptSeG8/5MLY7dm3RD80W1x1MDAxN8ezPNBkcyVKpa1Ukk2meZy6XFxcInPVkbONUFRrZu92PO7OXHUwMDE4Uk5jQIOWXG5IlfREczQhkfwsvW4lXHUwMDEycuVEo1bKg0VcdTAwMDPa/Vx1MDAwNKE8p1x1MDAwMYukM2TRdCY510zMs13BbFx1MDAxZE0onVnjaW5cdGSEXCJcdCZUmsxccvfc7CRcYiWXTGfaeJRmSLe/MFx1MDAxMavk09hMoEfo0I55hzvdXHUwMDA0nkp95LRhnVx1MDAwMVx1MDAwNFx1MDAxN194f05T1Ly5dtpYXHUwMDFhp1x1MDAxMYtI4G6tU1wiXG6plbTTSMRyx2jSXHUwMDBlf8zkMFEsZpu9LO5cdTAwMTjPckKVXHUwMDE0YIxcdTAwMDX6VUzSLHiUhJEyuM50T2/halx1MDAxM1skst3PJKZcdTAwMTdGbjxyXHUwMDEynVTOclxu9vjcNtvLJ5TbXGI4XHUwMDFlKkBKLCWA0eOT6NxQz1x1MDAxM945d0umkl1b3pg8R+5m891CXHUwMDBlklx1MDAwMlLBlEJcdTAwMGbKOSlYJcWhXHUwMDFlrvRcdTAwMTBMdlx1MDAxZshNKtRqrlxc8/d1a47ZyPRcZvdop0ZJXHUwMDE2XHUwMDFjXHUwMDEwfnJrZFx1MDAxOEjFXHTsZlg680rPNvsp33FmQ+ZG+MDtduVOOTko71x1MDAxNlx1MDAxNzFuSS73yFx1MDAwMzVcdHGiUatEbdHAXHUwMDFlvjpcdTAwMDHpObkter3nSNvGnfZqPUel88Fpo8GuQF9tnaX3i93SbvbnbtRcIueJoTaNnuSkXHUwMDE3XHUwMDA0fjfXKMeX70CiNiNcZqM/Llx1MDAwZmVLXHUwMDFjN1NIpt1cdTAwMWHlRuhcZuppS9hMKatcdTAwMTSMzLVcclxc5YruXvNYRvDWcaj4ZVx1MDAwNNVWt3bvxoueRn7ed/xpxumXWl5cdTAwMTCoXHUwMDFiXHUwMDBll1xyKcvA7VpcdTAwMTY73mfDIaHxzqX2iF6F21x1MDAwN5TcitLjhdSopVx1MDAwN4xcdTAwMWOkojcuN+Ct52qDrZ3Ykz7gZKwnnVx1MDAwMNqIkSdtXHUwMDE5XHSHlfOE/6In4ubedWpeKzNbVr6EJuKoP9zjXCJ0f8mowlx1MDAxNN/gJvNcdTAwMWZ2d32li5m5a9uYi2Hu4Vx1MDAxOMVcdTAwMDRnROtcdTAwMDKnjIOBoFx1MDAxYqxcdTAwMWTna5fIMTZ5L1bKxURh2v2kJuBcdTAwMWNlYv54PMHXQrt92Ce4Pd9cdTAwMGXCd638yPWjq/x6XfNv1qfUi19cZn+cXGZccvvTUZI/jIFff/z6f3dfuVAifQ== HeaderTweetTweetTweetTweetFooterTweetTweetTweetTweetTweetTweetTweetTweetFixedFixedColumns (vertical scroll)horizontal scrollIt's rough, but it's all we need.
Try in Textual-web "},{"location":"how-to/design-a-layout/#tip-2-work-outside-in","title":"Tip 2. Work outside in","text":"Like a sculpture with a block of marble, it is best to work from the outside towards the center. If your design has fixed elements (like a header, footer, or sidebar), start with those first.
In our sketch we have a header and footer. Since these are the outermost widgets, we will begin by adding them.
Tip
Textual has builtin Header and Footer widgets which you could use in a real application.
The following example defines an app, a screen, and our header and footer widgets. Since we're starting from scratch and don't have any functionality for our widgets, we are going to use the Placeholder widget to help us visualize our design.
In a real app, we would replace these placeholders with more useful content.
layout01.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder): # (1)!\npass\nclass Footer(Placeholder): # (2)!\npass\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\") # (3)!\nyield Footer(id=\"Footer\") # (4)!\nclass LayoutApp(App):\ndef on_mount(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header
"},{"location":"how-to/design-a-layout/#tip-3-apply-docks","title":"Tip 3. Apply docks","text":"This app works, but the header and footer don't behave as expected. We want both of these widgets to be fixed to an edge of the screen and limited in height. In Textual this is known as docking which you can apply with the dock rule.
We will dock the header and footer to the top and bottom edges of the screen respectively, by adding a little CSS to the widget classes:
layout02.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Footer
The DEFAULT_CSS
class variable is used to set CSS directly in Python code. We could define these in an external CSS file, but writing the CSS inline like this can be convenient if it isn't too complex.
When you dock a widget, it reduces the available area for other widgets. This means that Textual will automatically compensate for the 6 additional lines reserved for the header and footer.
"},{"location":"how-to/design-a-layout/#tip-4-use-fr-units-for-flexible-things","title":"Tip 4. Use FR Units for flexible things","text":"After we've added the header and footer, we want the remaining space to be used for the main interface, which will contain the columns in the sketch. This area is flexible (will change according to the size of the terminal), so how do we ensure that it takes up precisely the space needed?
The simplest way is to use fr units. By setting both the width and height to 1fr
, we are telling Textual to divide the space equally amongst the remaining widgets. There is only a single widget, so that widget will fill all of the remaining space.
Let's make that change.
layout03.pyOutputfrom textual.app import App, ComposeResult\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass ColumnsContainer(Placeholder):\nDEFAULT_CSS = \"\"\"\n ColumnsContainer {\n width: 1fr;\n height: 1fr;\n border: solid white;\n }\n \"\"\" # (1)!\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nyield ColumnsContainer(id=\"Columns\")\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
1fr
. We also add a border just to illustrate the dimensions better.LayoutApp #Header \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502#Columns\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 #Footer
As you can see, the central Columns area will resize with the terminal window.
"},{"location":"how-to/design-a-layout/#tip-5-use-containers","title":"Tip 5. Use containers","text":"Before we add content to the Columns area, we have an opportunity to simplify. Rather than extend Placeholder
for our ColumnsContainer
widget, we can use one of the builtin containers. A container is simply a widget designed to contain other widgets. Containers are styled with fr
units to fill the remaining space so we won't need to add any more CSS.
Let's replace the ColumnsContainer
class in the previous example with a HorizontalScroll
container, which also adds an automatic horizontal scrollbar.
from textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nyield HorizontalScroll() # (1)!\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Footer
The container will appear as blank space until we add some widgets to it.
Let's add the columns to the HorizontalScroll
. A column is itself a container which will have a vertical scrollbar, so we will define our Column
by subclassing VerticalScroll
. In a real app, these columns will likely be added dynamically from some kind of configuration, but let's add 4 to visualize the layout.
We will also define a Tweet
placeholder and add a few to each column.
from textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll, VerticalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass Tweet(Placeholder):\npass\nclass Column(VerticalScroll):\ndef compose(self) -> ComposeResult:\nfor tweet_no in range(1, 20):\nyield Tweet(id=f\"Tweet{tweet_no}\")\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nwith HorizontalScroll():\nyield Column()\nyield Column()\nyield Column()\nyield Column()\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header #Tweet1#Tweet1#Tweet1#Tweet1 #Footer
Note from the output that each Column
takes a quarter of the screen width. This happens because Column
extends a container which has a width of 1fr
.
It makes more sense for a column in a Twitter / Mastodon client to use a fixed width. Let's set the width of the columns to 32.
We also want to reduce the height of each \"tweet\". In the real app, you might set the height to \"auto\" so it fits the content, but lets set it to 5 lines for now.
Here's the final example and a reminder of the sketch.
layout06.pyOutputSketchfrom textual.app import App, ComposeResult\nfrom textual.containers import HorizontalScroll, VerticalScroll\nfrom textual.screen import Screen\nfrom textual.widgets import Placeholder\nclass Header(Placeholder):\nDEFAULT_CSS = \"\"\"\n Header {\n height: 3;\n dock: top;\n }\n \"\"\"\nclass Footer(Placeholder):\nDEFAULT_CSS = \"\"\"\n Footer {\n height: 3;\n dock: bottom;\n }\n \"\"\"\nclass Tweet(Placeholder):\nDEFAULT_CSS = \"\"\"\n Tweet {\n height: 5;\n width: 1fr;\n border: tall $background;\n }\n \"\"\"\nclass Column(VerticalScroll):\nDEFAULT_CSS = \"\"\"\n Column {\n height: 1fr;\n width: 32;\n margin: 0 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nfor tweet_no in range(1, 20):\nyield Tweet(id=f\"Tweet{tweet_no}\")\nclass TweetScreen(Screen):\ndef compose(self) -> ComposeResult:\nyield Header(id=\"Header\")\nyield Footer(id=\"Footer\")\nwith HorizontalScroll():\nyield Column()\nyield Column()\nyield Column()\nyield Column()\nclass LayoutApp(App):\ndef on_ready(self) -> None:\nself.push_screen(TweetScreen())\nif __name__ == \"__main__\":\napp = LayoutApp()\napp.run()\n
LayoutApp #Header \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet1\u258e\u258a#Tweet1\u258e\u258a#Tweet1\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u2583\u2583\u258a\u258e\u2583\u2583\u258a\u258e \u258a#Tweet2\u258e\u258a#Tweet2\u258e\u258a#Tweet2\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet3\u258e\u258a#Tweet3\u258e\u258a#Tweet3\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet4\u258e\u258a#Tweet4\u258e\u258a#Tweet4\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a#Tweet5\u258e\u258a#Tweet5\u258e\u258a#Tweet5\u258e \u258a\u258e\u258a\u258e\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258c #Footer
eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daW9cdTAwMWK7kv1+f0WQ92VcdTAwMDa46ktcdTAwMTbJXCJ5gcHAi1x1MDAxY1mOl8iKt8GDoaUtyda+eHvIf5+ivKjVUsstW7JbuTaQxGktzWafOudcdTAwMTRZTf7njy9fvvbv2v7Xv7989W9LhXqt3C3cfP3TXHUwMDFkv/a7vVqrSS/B8P+91qBbXHUwMDFhvrPa77d7f//1V6PQvfL77Xqh5HvXtd6gUO/1XHUwMDA35VrLK7VcdTAwMWF/1fp+o/e/7u+9QsP/n3arUe53vdFJUn651m91XHUwMDFmzuXX/Ybf7Pfo2/+P/v/ly3+Gf1x1MDAwN1rX9Uv9QrNS94dcdTAwMWZcdTAwMTi+NGogZ0aFXHUwMDBm77Waw9ZyLoxGxVxyPL+j1tukXHUwMDEz9v0yvXxBjfZHr7hDX1x1MDAwZnL9dT+3q/ay56WbXCK2djHdXHUwMDFkjM57UavXXHUwMDBm+3f1YbtK3Vavl6pcdTAwMTb6peroXHUwMDFkvX63deVcdTAwMWbXyv3qU/dcdTAwMDWOP3+216KuXHUwMDE4farbXHUwMDFhVKpNv+d6gT9cdTAwMWZttVx1MDAwYqVa/254lez56ENX/P1ldOSW/pdC4Vx0LY1VqLW1KtAr7lx1MDAwYkAoXHUwMDBmhFx1MDAxNKhcdTAwMDVarahfQi3baNXpjlDL/sWGP6O2XHUwMDE1XHUwMDBipatcbjWwWX5+T79baPbahS7dt9H7blx1MDAxZa9ZSemhcCdjinM+akfVr1WqfXqHQOZcdTAwMTnOrJQohLs9OGqMP7w1XHUwMDFjjNCopVx1MDAxNs+vuCa0t8tDnPw72HHN8mPHNVx1MDAwN/X6qNXuhXRcdTAwMDBbo89cZtrlwlx1MDAwM1x1MDAwMjhcdTAwMWFcdIpJ1Fxio1x1MDAwZanXmlfhr6u3Slcj0Fxmj/7681x1MDAxNXDVKlx1MDAxYa1WcVx1MDAwNIlcdTAwMThcdTAwMWKsuVLn5367sL63s3+Lza2fXHUwMDA3nZ3tTlx1MDAwNFhDgPtImFx1MDAxYcGVtIpxJjhBIIRT6Vx1MDAxOaUlgcJcdTAwMDJKi8vEqfBcdTAwMThHXHUwMDA1dD6urTaTQFx1MDAwNeMhMiFccjJAQaCeXHUwMDAwKt0zJYGuZvVw6tfrtXZvKkpxXHUwMDA2p1x1MDAxYZCcI1cmNkzbP86OS+lstssvc9f5+3TqrtG8fVxyTPn7wVRxz1xuhdTxQljOQIbolDjOMCMl11x1MDAwZcqgw1x1MDAxMTRcdTAwMTdM/3VRUKBgXHUwMDEyolxceJJcdTAwMDEoi0D/SGPkJEY5eFxuKVKISFx1MDAxOVxiXHUwMDAxUocxyql9ilNcdTAwMWL5b1x1MDAwNVItbFx1MDAxNEi1QXfNXHUwMDEwX/dP1jrpu8ZB7qC4tnXT2upvna3jTcIxXG7KY9TdyLnhlsBcdTAwMDEhiFxuT1x1MDAxObRkXG4kUZhV8o1cdTAwMTAtMqaWXHUwMDA1Uc1cdTAwMTXpgeC/XHUwMDE5Qq2IQijnjJSFS81iQ7TeSbdTV1x1MDAxNexs1upXWLrrb53UXHUwMDBlk1xyUVx1MDAxMljgXHUwMDAyQWjBmWRWh1x1MDAxMFxujmQl3Vx1MDAxNVxyxLVv41BcdTAwMGVFY3BZXHUwMDAwpcsgqkcuV1DoZ+dPKlx1MDAxMqPu1iihXHUwMDAzb3hcdKLr37ZcbrsldbLV2V1vnt3+OL6pXHUwMDFjNJJcctFcdTAwMTTlXCKk4lZcdTAwMTFFcnBJyThGXHUwMDExXHQ+hFxmTTTKJcOlYFRJ5oym4Vx1MDAxNCnaQiBOXHUwMDAyOZMnNaekytphO0CFIUquTJGLNYrNXHUwMDAx0SewjOBcIlx1MDAxZY/8ikbu82dGn1x1MDAwZcCt79+O3HZcdTAwMDBcdTAwMWPd9mG1cbB9tj/YK+VzlbXWgVx1MDAxOdx9fX7fr8ffosJCXGImjTLWLiosxtpcdTAwMTmMXGJcdTAwMTNcdTAwMTVcdTAwMTCczs5dvqBiR8T0i55cdTAwMWVcdTAwMTHVQqk66PpcdTAwMWZcdTAwMWZcdTAwMTNcXCO5W+JKQIK+YTLE21x1MDAxYTxcdTAwMDdB4nNcdTAwMTlcbpnXXHUwMDA0XHUwMDA1xVx1MDAxZbuYYn4p8oBcdTAwMTPaXHUwMDE5p1x1MDAwZSeRmJafTVx1MDAwNoFcdTAwMDWuyZ3PXHUwMDE1XHUwMDA0r+Pp11x1MDAwMHJ041vN/mHt3vU9sLGjW4VGrX43du+GSKWuyviFst/9OnZ8rV6rONR+LVF7gy9cdTAwMTJ2+7VSof78hkatXFxcdTAwMGVqQIlOVqg1/e52XHUwMDFj7m51a5Vas1DPR7SFrt3PPOupXHUwMDE3uDPFQs93r1x1MDAwZVx1MDAwM+j1Qlx1MDAwNVx1MDAxOJmTkns0QltcdTAwMTN/5GRtc6O+M2Bddn/XuK/YfL5/lc68LizfcexEXHUwMDE5j5NRkoxsfUhcdTAwMDPcN1xihp7ipGQ8XHUwMDE4XHUwMDE3XHUwMDBiXHUwMDFlNeGKuEFScvEkiKPrXHUwMDFmhSX5KbCGXHUwMDFisnXKqCA/PLkp0IZy6sDgz1x1MDAxMt2UJt5cdTAwMGVkP0t1U1x1MDAwMapcZouHM/zW5fmxUdqpbbT2sr3NXGZ2N1x1MDAwZTK57av9evpV43vvOXBcIjy6t+hcdTAwMTJwMis6hFBuKCdcdTAwMTX0gmIhYLzGTalCoXgh5TTPzz1GSIUpg89Selx1MDAxMjSzj01cYjbxXHUwMDExnUJcdTAwMWKlmVx0XGZcYr5cdTAwMTWcrzNM2Xbnvt45Ov/x48f5Sf3i6vgwfXxcdTAwMWMwTH9O/9qHXHUwMDBm51t7lUIu5Z9095pH9lx1MDAxMCv1y+1cdTAwMWbjZ3k6f6Hbbd3MYcQ4XHUwMDAwhZRQi4qoSCNcdTAwMTY5vkMmjGhcdTAwMGWtiZ+aTO/MhFx1MDAxYrFcdTAwMTRcdTAwMTGpIOtDySBlXHUwMDA1aMLhJJin6bBloFxirnYh2cm0eJLW46QsmnJBy6ZGVcDEPI2MazJvxr5cdTAwMDPFXHUwMDBiSpjIi1x1MDAwNYj1dUaM49jRXHUwMDE5Rix/4/v9JfmwXHUwMDE3SD/sw0JNiWfD5Fx1MDAxYmyYgPDR5/FcdTAwMDLBXGYjb1x1MDAxMt+GVXnaNrYvdzqp6lx1MDAwNuxfyrPTn0onX+BcZnJcdTAwMDGGh+1cdTAwMTdcdTAwMWGPRIVcdTAwMDJEaGCaiVCbPsUteOu3mtlccr9/36hWz5pq/7qU7drcSXxcdTAwMTFcdTAwMDLFKVx1MDAwMdNq+aNcdTAwMDFcdTAwMDFWXGKrkLbcXHUwMDE4w+dcdTAwMTggm37VK6BCJD+KSWHleHYxxL1hXHUwMDFlJSXyzXnHylx1MDAwYpCYXHUwMDAzjMlcdTAwMTWgXHUwMDE3SPnDXHUwMDA1yLLw0ZEtVJJroUT8uen1k5udY5VLnzXRXFzdf9va6ejNXtJcdTAwMTWISN5cdTAwMDBcdC03XHUwMDFjJ4dcdTAwMDEkMOp24iVOIYtGvNFcdTAwMTT+5jqUXHUwMDFmXFyv5fa+X/LU/u3deW692Pl29X1OXHUwMDFkUjJQXHUwMDE4sixcdTAwMWTSkb5cdTAwMGKYkFxcQrBW5iXYT7/qxOuQ8JRigCBcdTAwMTXnlIKG0iHiYU9qXHUwMDEwlFx1MDAwYjFBXHUwMDE27Y1cdTAwMDbsU4xcdTAwMTIgRi/w80eLkTA8fPQpKlxySM1cdTAwMDH1aIzrpaBs9b5cdTAwMWSet9bWe2epn+1jW7lnJ7yQdC1SXHUwMDFlkTjprlx1MDAxNVO1SLteXHUwMDE3Slx1MDAxYm6QXHTFlydGU8ah5cTAc1JU5yB9WG9uXHUwMDFmVlx1MDAwN/5ajde+dUSnXdqZU3U0LF91Zk26kOxcdTAwMDCoOZKf6Vx1MDAxN71cdTAwMDKig0hcdTAwMTIvNJsqOkYsXHUwMDE04Z+qk1x1MDAwMNV5gYk/WnU4RqpcdTAwMGVcdTAwMDHEWUGr45codPZcdTAwMGWvXHUwMDFi6Hc6WXPon9rDwkb7eDPZsmOYp13dNVk9YVx0fCM6f5hlolBcdTAwMTGCXCKTQmk8Yl9cdTAwMTOSXHUwMDAwpuhPKywzXHUwMDFlc1Vj04ORXGLa01x1MDAxOJ6MfZz+ZHTcXG7BR/q0xFwiXHUwMDA1VFxc2PcpJlx1MDAwM1x1MDAxYpmkcCGlq+Ww8f3Qjl83379l6rtcdTAwMTfqqr2pdLfWbq4nXHUwMDFimK6aXGaBOdplXHUwMDE2UYcqXHUwMDFlldJcdTAwMWWlxXRYg5VcdTAwMTbeVvFooGS5P1x0zH9ENVmnWMzk8T6T7V3ft3WuslNax/R8XHUwMDBlytBcdTAwMWTgi4qLyLxcdTAwMWRlVEhoXHUwMDAzwDmzc1D11ItOuIPiWntcdTAwMTKtq8IgymMqVE2mUHiMoXtcdTAwMDe5TXzjXHUwMDEwcmQxmSTAWyvoNCBcZlx1MDAwNNK2hFx1MDAxNJMtyD/FLybbarX6Sysme4G6w1x1MDAwNirclqVcdTAwMTeTXHUwMDA1XHUwMDA3JENRKSkkiacxvk5cdTAwMWSfyduLTnFXZHbSm5mN3Lp/1j9KejFcdTAwMTnnnPyJtGjMw2zl6GtcdTAwMWVqySxFpXJPOHF8c43nSlaUTdelx6Gajla63vJ57fREXFyKrl/cuD1fWHVccmk2W5gwzTZsMnp2k9hcdTAwMWHcJEL86fxcdTAwMDNd6N521i94tre/0Thkx1x1MDAwN+e902RcdTAwMWI2QrdngFx1MDAxMftCuJZ5XHUwMDE4XHUwMDA2wDyrjEW7iDD47SdT1vKnu9vbP1x1MDAwZnY2+1x1MDAxOdk46lx1MDAxZmyd5W3cirXs9o+LU6wp1chu9ZrXR5XCzdXu4irW3NTg0s1cdTAwMWXMKP90s0RcdTAwMWGZjVx1MDAxZE7TezPpbo9SXHUwMDFjrYiOXHUwMDE5Ob6JXHUwMDA0SFxi4ZGlUlxcPZYwvy2cPlx1MDAwN8timr1lXHUwMDBllr3A+lx1MDAxZj1YJmT0xKlcIoiilvGHsEV3/fJ252fZ7MHZcVVV8VStdZOvcKRvJOVcdTAwMTiWsGFEalI4So0oXHUwMDAzs1x1MDAwYnjA7TdcdTAwMTe4/PptrWvWXHUwMDBlb1tcdTAwMWIoikff7zavXHUwMDBi+3M9wyZcdTAwMTRcYlxc+qhDcGooLEQgmUBleXzUT7/qxFx1MDAwYlx1MDAxMXpcdTAwMTZdMkdJ/6RcdTAwMTBcdTAwMTlcdTAwMTJcIvc4r3vY9+3FmqsqRK/AY3KF6Fx1MDAwNXL+cCGy0c8zUL5H2TaK+KtcdTAwMDGcsfNO6qJvMsfVtZ3Uxam6zlx1MDAwNzLQhCqR8lx1MDAxNFx1MDAwNzR0qUJPKJFcdTAwMDTwXGZl92RcdTAwMWQ1uHj5VKJcdTAwMTlKxLLp0rbMs61Byp5cdTAwMWOcXHUwMDE2JWtcdTAwMTVcdTAwMDbzjX9LYiZcdTAwMTbE/1KUKLpCXHUwMDA2NWHBTVx1MDAwYsVcdTAwMDb99IteXHUwMDAxIZJuXHUwMDEwmaDjVlx1MDAxYVx1MDAxOFx1MDAxN1wiKZTHkFx1MDAwMGdcZqi3V2uuqlx1MDAxMP1WXHUwMDE50Vx1MDAwYtz80UIkZ1RQk1WSXHUwMDA20cZcdTAwMTeiXvNO+er7fjs/yFxmWje8t7X5bTfpQiQ98r9aXHUwMDAyQ+VcbmdHXGb0ULQmSIiY0kYwMz5h9Y8uWjtcdTAwMTPrezc3crN4spMvpG4vq7uVg0pcdTAwMDIlR+jIKVeyIHS/jOXxNWf6Va+A5lxi4chcdTAwMWXc8LWRIatl1EJcdTAwMTG+4qozz9pcdTAwMDDJVZ1cdTAwMTeI+KNVh7xeVFSCMlxiwOJPNG01N3c7W6ksbMP5oLnm64vNb1FcdTAwMDNcdTAwMTJcdNFcdTAwMWNQzFOMXHUwMDFiTVx1MDAxZW+yXGJCXHUwMDAwp9RILWpJnZUuWVx1MDAxYq7Ywd6rZE1gdMlcdTAwMWFcdTAwMThB3oDJ+KVcdTAwMDCbOyeH7eyp3fyW/nFweqK+i3w/asWOxJRcdTAwMDLQZXqKYFx1MDAwMUrgtFx1MDAxMWLOPFSWhJPDm59r/u0qXHUwMDAxdvs3p8Wj01x1MDAxY5rMnbzoraW3blpnXHUwMDBim7WUYFx1MDAwMlx1MDAxNfhLrVx1MDAwNFx1MDAxMJFcdTAwMTUxnJO2U0Ns/JVrjptnxVRd18o1xnJZOEinipuphFx1MDAxM7QxRNBaI2pwSz2HaopcdTAwMDE8gr5WXGa5q/JXn/Mks1KFa57ezd3traV+yPtTuGfFjctKMW4hwPFdeW3ztsVcdTAwMGYzuWo5n8FBrnmbW1xcIYCVbOkpyMxVXHUwMDAztNKcXHUwMDFiXHUwMDFi/2nN6b2Z8Fx1MDAxNITMtSeFVdZcdTAwMTlszoJcdTAwMTHzUFx04HJw7Vx1MDAxYziGXHUwMDE20/hMQVY1XHUwMDA1eYH2PzpcdTAwMDVcdTAwMTFcInLgXHUwMDBiKE92i1xixlx1MDAxZvdK93aE/H7q31xcXHUwMDFmd1x1MDAwYmv3ftpcdTAwMWVVo8aiXHUwMDEzI3HaXHUwMDE1u1lheFjEXHUwMDFlSlx1MDAwMcAjkUelXHUwMDFmpkTDXHL7lLjg/b+6YoNGZuOuVsZu75JlZfv84GLO0TD3Z9lSXHUwMDA0PPpcdTAwMDFcdTAwMDRAVFx1MDAwNIb4XHUwMDBmIEy/6MQrkXElaVx1MDAxYbmRjPAvxlFvSIhIXHUwMDE4mISQ7/vUoVXVoVx1MDAxN7j5w3XIRJaJUrRcbuJnmGNcdJutVvU8ZVtHfnX/JNPM1lxuulHNJl+IXGJtQqCSROdhIZIgPWRMuVx1MDAxZFjQ2OC04KdcdTAwMTJNKtH29fqV8Sv5Sr3Fi6Zlv12y9LzzMu+iRFx1MDAxOF1cdTAwMDAjlVuxiFx1MDAwNYpcdTAwMDVegv30q15cdTAwMDEpcvVWyEBcdTAwMWJcdTAwMTGWXCIp0DPcjXxcdG5ccshFbIjxKUVcdTAwMWYrRS+w80dLkdSRUoSKvJLSc9Sk2SNbNlBLr/XyV6XizV1e/cgmfCVcdTAwMDEw6GlGcWCUskqHXHUwMDAzUitPUpxQsqRcdTAwMDAkLi8jWqlCgFx1MDAxMzi9zJTklrxs71c3dnZz+6U7lUDBXHUwMDEx0etkSPdwXHUwMDFiSX98vZl+0SugN1x1MDAxY6zbeskyJoIrRD/UXHUwMDAx6EXi+1NwXHUwMDEyIDgvkPBHXHUwMDBiXHUwMDBlRG+AqrVGXHUwMDAwruOnPt30UZrt7O83hTpJy8Za5uw+9zPZguOqnIGSXG5BXHUwMDA0NNysYywgXHUwMDA1uMdcdTAwMTJcdTAwMDJjcG9cbsjVL1x1MDAwM1x1MDAxMDqQXGIvtVxmQEfv+oRCgWVyjpVr9lmrv5nL+oY31/X6xe4mXjRcdTAwMTO/qix6buEowp52i5mFXHUwMDE28qNcdTAwMWXwrFvv+nFcdTAwMTW0t42SRVx1MDAwMVO6labdhlNuq1x1MDAwM/c1OFx0T5Bu116SXHUwMDExPVxcx1x1MDAwMyZcdTAwMGJcdTAwMDDeXHUwMDE3o1x1MDAxMoRcXPoq5DNYU7n1XHUwMDAyOJujiv460ylmXHUwMDFh9udm+eLozFx1MDAxN1x1MDAwNzlTgGbSnVxmXHUwMDExJlx1MDAwMVx1MDAxMNFwI1x1MDAxMVhgfYSHrSS14yxOyTORhjViedtGk5eR5ExcdTAwMTRcYlx1MDAwYqFAmbGUXGY1yY1wwLKt+9KLUlx1MDAxNuSV5linpnbrlyO8Ut2/6M9wSv1WO8omjTV3Yjma8VMuYjWa6MdjXCLHxITbbFCjiV8nUDXr+3fn1fIgla1s5Vx1MDAxYq3LuzxL/OxcZoWFR1xcL4XVhlEqMl58hpx0h6SXQKeMU55/dFxc58hcdTAwMTZcdTAwMTbyV2dcXF9cdTAwMWNuX36HUjrTb3/G9Vx1MDAwN8b1QzdPM5Qztlx1MDAxMVVuu2vkMv7I2mxCT2pkg/bc9ijKKKVItsdcdTAwMWQloPBcdTAwMDSS41x1MDAxNNxcIuCbxrpnXHUwMDA2tmWeNW5FVMtcdTAwMTRcdTAwMTnHXHUwMDEx31x1MDAwNi0loqA2MOOe0MNcdEvJiXtcdTAwMTSToOdcdTAwMTmMmFxcXGZcdTAwMTFcdTAwMWWPzFhcZnFG5Zog9tOv2my01y90++u1ZrnWrIw3zH849XZcZl84jOjSwLWSeaApYXLzXHUwMDE0bjhSju6s65hC2/G2Ry6c2lxmlj9MsE1cXLrfLL/cpNn11IEmpZhnXHUwMDE0pztkhit6XHUwMDE4hWKiTZR5o9vcW5I9dONQdqJN9UKvv9FqNGp96vmDVq3ZXHUwMDBm9/CwK9dcXNBX/cJcdTAwMDTh0DVcdTAwMDVfXHUwMDBis0PbfeM4yY9++zKKnuF/nn//959T352KwrX7mUD06Nv+XGL+Ozet2ehHq1xmUr9Solx1MDAxY3/4ZracJZXVXHUwMDE0o1x1MDAwNJQpUIbwL9n4g1x1MDAxY1xi3ONusFx1MDAwMJlcdTAwMTaISyyWt8JDbYZLW4xtXGY4mj/gXHUwMDFlXHUwMDEzlFxmW0roXHRcdTAwMWWT01x0XHUwMDFjKWWiTJnNY15cdTAwMTbOasxcYnzVY78xWW22KVx1MDAxZadcdTAwMTBiNNdXXGJARKLZiO6fOcR40m1cdTAwMDIurGTuwVFjZvPa+HWsXHUwMDE0u0yH1/C1XHRkzckukfM1NnIyUlx0NG5p8/iL1lx1MDAxY1X29zZQ9vzaXHUwMDFl+Nnvt53Dw42bpJNLSqJLd4hBrEIlJIyzXHUwMDBiV+5JXHUwMDFkrVxyMolumONNw3Czn8Ux2uNCXHUwMDE5lNHZ0MScXHK3lFxyKVx1MDAwN4x3SYfesMftn7O+91xyK1x1MDAxMc783iU+2OD2one9/8b0Lf5cdTAwMTRcdTAwMTahaNBo9r7811N29qVX6rbq9f9+35QuRjNcdTAwMTYxpVx1MDAxNemHzIxcdTAwMDc9lFtseq5nzWdcdTAwMDM6oZwljEdGXHUwMDEzrHFcdTAwMWJccihcdTAwMDXjXHUwMDAzOI5HXHUwMDE0XHUwMDEzRltcdTAwMTKS5Y7MkunVJM1cbshccmshg9tCjCa3QHiGh2r+nthcdTAwMGKcnVx1MDAxYptSeG8/5MLY7dm3RD80W1x1MDAxN8ezPNBkcyVKpa1Ukk2meZy6XFxcInPVkbONUFRrZu92PO7OXHUwMDE4Uk5jQIOWXG5IlfREczQhkfwsvW4lXHUwMDEycuVEo1bKg0VcdTAwMDPa/Vx1MDAwNKE8p1x1MDAwMYukM2TRdCY510zMs13BbFx1MDAxZE0onVnjaW5cdGSEXCJcdCZUmsxccvfc7CRcYiWXTGfaeJRmSLe/MFx1MDAxMavk09hMoEfo0I55hzvdXHUwMDA0nkp95LRhnVx1MDAwMVx1MDAwNFx1MDAxN194f05T1Ly5dtpYXHUwMDFhp1x1MDAxMYtI4G6tU1wiXG6plbTTSMRyx2jSXHUwMDBlf8zkMFEsZpu9LO5cdTAwMTjPckKVXHUwMDE0YIxcdTAwMDX6VUzSLHiUhJEyuM50T2/halx1MDAxM1skst3PJKZcdTAwMTdGbjxyXHUwMDEynVTOclxu9vjcNtvLJ5TbXGI4XHUwMDFlKkBKLCWA0eOT6NxQz1x1MDAxM945d0umkl1b3pg8R+5m891CXHUwMDBlklx1MDAwMlLBlEJcdTAwMGbKOSlYJcWhXHUwMDFlrvRcdTAwMTBMdlx1MDAxZshNKtRqrlxc8/d1a47ZyPRcZvdop0ZJXHUwMDE2XHUwMDFjXHUwMDEwfnJrZFx1MDAxOEjFXHTsZlg680rPNvsp33FmQ+ZG+MDtduVOOTko71x1MDAxNlx1MDAxNzFuSS73yFx1MDAwMzVcdHGiUatEbdHAXHUwMDFlvjpcdTAwMDHpObkter3nSNvGnfZqPUel88Fpo8GuQF9tnaX3i93SbvbnbtRcIueJoTaNnuSkXHUwMDE3XHUwMDA0fjfXKMeX70CiNiNcZqM/Llx1MDAwZmVLXHUwMDFjN1NIpt1cdTAwMWHlRuhcZuppS9hMKatcdTAwMTSMzLVcclxc5YruXvNYRvDWcaj4ZVx1MDAwNNVWt3bvxoueRn7ed/xpxumXWl5cdTAwMTCoXHUwMDFiXHUwMDBll1xyKcvA7VpcdTAwMTY73mfDIaHxzqX2iF6F21x1MDAwN5TcitLjhdSopVx1MDAwN4xcdTAwMWOkojcuN+Ct52qDrZ3Ykz7gZKwnnVx1MDAwMNqIkSdtXHUwMDE5XHSHlfOE/6In4ubedWpeKzNbVr6EJuKoP9zjXCJ0f8mowlx1MDAxNN/gJvNcdTAwMWZ2d32li5m5a9uYi2Hu4Vx1MDAxOMVcdTAwMDRnROtcdTAwMDKnjIOBoFx1MDAxYqxcdTAwMWTna5fIMTZ5L1bKxURh2v2kJuBcdTAwMWNlYv54PMHXQrt92Ce4Pd9cdTAwMGXCd638yPWjq/x6XfNv1qfUi19cZn+cXGZccvvTUZI/jIFff/z6f3dfuVAifQ== HeaderTweetTweetTweetTweetFooterTweetTweetTweetTweetTweetTweetTweetTweetFixedFixedColumns (vertical scroll)horizontal scroll
A layout like this is a great starting point. In a real app, you would start replacing each of the placeholders with builtin or custom widgets.
"},{"location":"how-to/design-a-layout/#summary","title":"Summary","text":"Layout is the first thing you will tackle when building a Textual app. The following tips will help you get started.
fr
for flexible space within layouts.If you need further help, we are here to help.
"},{"location":"reference/","title":"Reference","text":"Welcome to the Textual Reference.
CSS Types
CSS Types are the data types that CSS styles accept in their rules.
CSS Types Reference
Events
Events are how Textual communicates with your application.
Events Reference
Styles
All the styles you can use to take your Textual app to the next level.
Styles Reference
Widgets
How to use the many widgets builtin to Textual.
Widgets Reference
A reference to Widget styles.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"styles/align/","title":"Align","text":"The align
style aligns children within a container.
\nalign: <horizontal> <vertical>;\n\nalign-horizontal: <horizontal>;\nalign-vertical: <vertical>;\n
The align
style takes a <horizontal>
followed by a <vertical>
.
You can also set the alignment for each axis individually with align-horizontal
and align-vertical
.
This example contains a simple app with two labels centered on the screen with align: center middle;
:
AlignApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503Vertical\u00a0alignment\u00a0with\u00a0Textual\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503Take\u00a0note,\u00a0browsers.\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Label\nclass AlignApp(App):\ndef compose(self):\nyield Label(\"Vertical alignment with [b]Textual[/]\", classes=\"box\")\nyield Label(\"Take note, browsers.\", classes=\"box\")\napp = AlignApp(css_path=\"align.tcss\")\n
Screen {\nalign: center middle;\n}\n.box {\nwidth: 40;\nheight: 5;\nmargin: 1;\npadding: 1;\nbackground: green;\ncolor: white 90%;\nborder: heavy white;\n}\n
"},{"location":"styles/align/#all-alignments","title":"All alignments","text":"The next example shows a 3 by 3 grid of containers with text labels. Each label has been aligned differently inside its container, and its text shows its align: ...
value.
AlignAllApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502left\u00a0top\u2502\u2502center\u00a0top\u2502\u2502right\u00a0top\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502left\u00a0middle\u2502\u2502center\u00a0middle\u2502\u2502right\u00a0middle\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502 \u2502left\u00a0bottom\u2502\u2502center\u00a0bottom\u2502\u2502right\u00a0bottom\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass AlignAllApp(App):\n\"\"\"App that illustrates all alignments.\"\"\"\nCSS_PATH = \"align_all.tcss\"\ndef compose(self) -> ComposeResult:\nyield Container(Label(\"left top\"), id=\"left-top\")\nyield Container(Label(\"center top\"), id=\"center-top\")\nyield Container(Label(\"right top\"), id=\"right-top\")\nyield Container(Label(\"left middle\"), id=\"left-middle\")\nyield Container(Label(\"center middle\"), id=\"center-middle\")\nyield Container(Label(\"right middle\"), id=\"right-middle\")\nyield Container(Label(\"left bottom\"), id=\"left-bottom\")\nyield Container(Label(\"center bottom\"), id=\"center-bottom\")\nyield Container(Label(\"right bottom\"), id=\"right-bottom\")\n
#left-top {\n/* align: left top; this is the default value and is implied. */\n}\n#center-top {\nalign: center top;\n}\n#right-top {\nalign: right top;\n}\n#left-middle {\nalign: left middle;\n}\n#center-middle {\nalign: center middle;\n}\n#right-middle {\nalign: right middle;\n}\n#left-bottom {\nalign: left bottom;\n}\n#center-bottom {\nalign: center bottom;\n}\n#right-bottom {\nalign: right bottom;\n}\nScreen {\nlayout: grid;\ngrid-size: 3 3;\ngrid-gutter: 1;\n}\nContainer {\nbackground: $boost;\nborder: solid gray;\nheight: 100%;\n}\nLabel {\nwidth: auto;\nheight: 1;\nbackground: $accent;\n}\n
"},{"location":"styles/align/#css","title":"CSS","text":"/* Align child widgets to the center. */\nalign: center middle;\n/* Align child widget to the top right */\nalign: right top;\n/* Change the horizontal alignment of the children of a widget */\nalign-horizontal: right;\n/* Change the vertical alignment of the children of a widget */\nalign-vertical: middle;\n
"},{"location":"styles/align/#python","title":"Python","text":"# Align child widgets to the center\nwidget.styles.align = (\"center\", \"middle\")\n# Align child widgets to the top right\nwidget.styles.align = (\"right\", \"top\")\n# Change the horizontal alignment of the children of a widget\nwidget.styles.align_horizontal = \"right\"\n# Change the vertical alignment of the children of a widget\nwidget.styles.align_vertical = \"middle\"\n
"},{"location":"styles/align/#see-also","title":"See also","text":"content-align
to set the alignment of content inside a widget.text-align
to set the alignment of text in a widget.The background
style sets the background color of a widget.
\nbackground: <color> [<percentage>];\n
The background
style requires a <color>
optionally followed by <percentage>
to specify the color's opacity (clamped between 0%
and 100%
).
This example creates three widgets and applies a different background to each.
Outputbackground.pybackground.tcssBackgroundApp Widget\u00a01 Widget\u00a02 Widget\u00a03
from textual.app import App\nfrom textual.widgets import Label\nclass BackgroundApp(App):\ndef compose(self):\nyield Label(\"Widget 1\", id=\"static1\")\nyield Label(\"Widget 2\", id=\"static2\")\nyield Label(\"Widget 3\", id=\"static3\")\napp = BackgroundApp(css_path=\"background.tcss\")\n
Label {\nwidth: 100%;\nheight: 1fr;\ncontent-align: center middle;\ncolor: white;\n}\n#static1 {\nbackground: red;\n}\n#static2 {\nbackground: rgb(0, 255, 0);\n}\n#static3 {\nbackground: hsl(240, 100%, 50%);\n}\n
"},{"location":"styles/background/#different-opacity-settings","title":"Different opacity settings","text":"The next example creates ten widgets laid out side by side to show the effect of setting different percentages for the background color's opacity.
Outputbackground_transparency.pybackground_transparency.tcssBackgroundTransparencyApp 10%20%30%40%50%60%70%80%90%100%
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass BackgroundTransparencyApp(App):\n\"\"\"Simple app to exemplify different transparency settings.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Static(\"10%\", id=\"t10\")\nyield Static(\"20%\", id=\"t20\")\nyield Static(\"30%\", id=\"t30\")\nyield Static(\"40%\", id=\"t40\")\nyield Static(\"50%\", id=\"t50\")\nyield Static(\"60%\", id=\"t60\")\nyield Static(\"70%\", id=\"t70\")\nyield Static(\"80%\", id=\"t80\")\nyield Static(\"90%\", id=\"t90\")\nyield Static(\"100%\", id=\"t100\")\napp = BackgroundTransparencyApp(css_path=\"background_transparency.tcss\")\n
#t10 {\nbackground: red 10%;\n}\n#t20 {\nbackground: red 20%;\n}\n#t30 {\nbackground: red 30%;\n}\n#t40 {\nbackground: red 40%;\n}\n#t50 {\nbackground: red 50%;\n}\n#t60 {\nbackground: red 60%;\n}\n#t70 {\nbackground: red 70%;\n}\n#t80 {\nbackground: red 80%;\n}\n#t90 {\nbackground: red 90%;\n}\n#t100 {\nbackground: red 100%;\n}\nScreen {\nlayout: horizontal;\n}\nStatic {\nheight: 100%;\nwidth: 1fr;\ncontent-align: center middle;\n}\n
"},{"location":"styles/background/#css","title":"CSS","text":"/* Blue background */\nbackground: blue;\n/* 20% red background */\nbackground: red 20%;\n/* RGB color */\nbackground: rgb(100, 120, 200);\n/* HSL color */\nbackground: hsl(290, 70%, 80%);\n
"},{"location":"styles/background/#python","title":"Python","text":"You can use the same syntax as CSS, or explicitly set a Color
object for finer-grained control.
# Set blue background\nwidget.styles.background = \"blue\"\n# Set through HSL model\nwidget.styles.background = \"hsl(351,32%,89%)\"\nfrom textual.color import Color\n# Set with a color object by parsing a string\nwidget.styles.background = Color.parse(\"pink\")\nwidget.styles.background = Color.parse(\"#FF00FF\")\n# Set with a color object instantiated directly\nwidget.styles.background = Color(120, 60, 100)\n
"},{"location":"styles/background/#see-also","title":"See also","text":"color
to set the color of text in a widget.The border
style enables the drawing of a box around a widget.
A border style may also be applied to individual edges with border-top
, border-right
, border-bottom
, and border-left
.
Note
border
and outline
cannot coexist in the same edge of a widget.
\nborder: [<border>] [<color>] [<percentage>];\n\nborder-top: [<border>] [<color>] [<percentage>];\nborder-right: [<border>] [<color> [<percentage>]];\nborder-bottom: [<border>] [<color> [<percentage>]];\nborder-left: [<border>] [<color> [<percentage>]];\n
In CSS, the border is set with a border style and a color. Both are optional. An optional percentage may be added to blend the border with the background color.
In Python, the border is set with a tuple of border style and a color.
"},{"location":"styles/border/#border-command","title":"Border command","text":"The textual
CLI has a subcommand which will let you explore the various border types interactively:
textual borders\n
Alternatively, you can see the examples below.
"},{"location":"styles/border/#examples","title":"Examples","text":""},{"location":"styles/border/#basic-usage","title":"Basic usage","text":"This examples shows three widgets with different border styles.
Outputborder.pyborder.tcssBorderApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0border\u00a0is\u00a0solid\u00a0red\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0border\u00a0is\u00a0dashed\u00a0green\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258aMy\u00a0border\u00a0is\u00a0tall\u00a0blue\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderApp(App):\ndef compose(self):\nyield Label(\"My border is solid red\", id=\"label1\")\nyield Label(\"My border is dashed green\", id=\"label2\")\nyield Label(\"My border is tall blue\", id=\"label3\")\napp = BorderApp(css_path=\"border.tcss\")\n
#label1 {\nbackground: red 20%;\ncolor: red;\nborder: solid red;\n}\n#label2 {\nbackground: green 20%;\ncolor: green;\nborder: dashed green;\n}\n#label3 {\nbackground: blue 20%;\ncolor: blue;\nborder: tall blue;\n}\nScreen {\nbackground: white;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border/#all-border-types","title":"All border types","text":"The next example shows a grid with all the available border types.
Outputborder_all.pyborder_all.tcssAllBordersApp +------------------+\u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 |ascii|blank\u254fdashed\u254f +------------------+\u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2551double\u2551\u2503heavy\u2503hidden/none \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2597\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2596\u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c hkey\u2590inner\u258c\u258couter\u2590 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u259d\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2598\u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u2502round\u2502\u2502solid\u2502\u258atall\u258e \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u2588\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2588\u258f\u2595\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2588thick\u2588\u258fvkey\u2595\u258ewide\u258a \u2588\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2588\u258f\u2595\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass AllBordersApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"ascii\", id=\"ascii\"),\nLabel(\"blank\", id=\"blank\"),\nLabel(\"dashed\", id=\"dashed\"),\nLabel(\"double\", id=\"double\"),\nLabel(\"heavy\", id=\"heavy\"),\nLabel(\"hidden/none\", id=\"hidden\"),\nLabel(\"hkey\", id=\"hkey\"),\nLabel(\"inner\", id=\"inner\"),\nLabel(\"outer\", id=\"outer\"),\nLabel(\"round\", id=\"round\"),\nLabel(\"solid\", id=\"solid\"),\nLabel(\"tall\", id=\"tall\"),\nLabel(\"thick\", id=\"thick\"),\nLabel(\"vkey\", id=\"vkey\"),\nLabel(\"wide\", id=\"wide\"),\n)\napp = AllBordersApp(css_path=\"border_all.tcss\")\n
#ascii {\nborder: ascii $accent;\n}\n#blank {\nborder: blank $accent;\n}\n#dashed {\nborder: dashed $accent;\n}\n#double {\nborder: double $accent;\n}\n#heavy {\nborder: heavy $accent;\n}\n#hidden {\nborder: hidden $accent;\n}\n#hkey {\nborder: hkey $accent;\n}\n#inner {\nborder: inner $accent;\n}\n#outer {\nborder: outer $accent;\n}\n#round {\nborder: round $accent;\n}\n#solid {\nborder: solid $accent;\n}\n#tall {\nborder: tall $accent;\n}\n#thick {\nborder: thick $accent;\n}\n#vkey {\nborder: vkey $accent;\n}\n#wide {\nborder: wide $accent;\n}\nGrid {\ngrid-size: 3 5;\nalign: center middle;\ngrid-gutter: 1 2;\n}\nLabel {\nwidth: 20;\nheight: 3;\ncontent-align: center middle;\n}\n
"},{"location":"styles/border/#borders-and-outlines","title":"Borders and outlines","text":"The next example makes the difference between border
and outline
clearer by having three labels side-by-side. They contain the same text, have the same width and height, and are styled exactly the same up to their border
and outline
styles.
This example also shows that a widget cannot contain both a border
and an outline
:
OutlineBorderApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502ear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path\u2502 \u2502here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502I\u00a0must\u00a0not\u00a0fear.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineBorderApp(App):\ndef compose(self):\nyield Label(TEXT, classes=\"outline\")\nyield Label(TEXT, classes=\"border\")\nyield Label(TEXT, classes=\"outline border\")\napp = OutlineBorderApp(css_path=\"outline_vs_border.tcss\")\n
Label {\nheight: 8;\n}\n.outline {\noutline: $error round;\n}\n.border {\nborder: $success heavy;\n}\n
"},{"location":"styles/border/#css","title":"CSS","text":"/* Set a heavy white border */\nborder: heavy white;\n/* Set a red border on the left */\nborder-left: outer red;\n/* Set a rounded orange border, 50% opacity. */\nborder: round orange 50%;\n
"},{"location":"styles/border/#python","title":"Python","text":"# Set a heavy white border\nwidget.styles.border = (\"heavy\", \"white\")\n# Set a red border on the left\nwidget.styles.border_left = (\"outer\", \"red\")\n
"},{"location":"styles/border/#see-also","title":"See also","text":"box-sizing
to specify how to account for the border in a widget's dimensions.outline
to add an outline around the content of a widget.border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-align
style sets the horizontal alignment for the border subtitle.
\nborder-subtitle-align: <horizontal>;\n
The border-subtitle-align
style takes a <horizontal>
that determines where the border subtitle is aligned along the top edge of the border. This means that the border corners are always visible.
The default alignment is right
.
This example shows three labels, each with a different border subtitle alignment:
Outputborder_subtitle_align.pyborder_subtitle_align.tcssBorderSubtitleAlignApp \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0subtitle\u00a0is\u00a0on\u00a0the\u00a0left.\u2502 \u2502\u2502 \u2514\u2500\u00a0<\u00a0Left\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0subtitle\u00a0is\u00a0centered\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u00a0Centered!\u00a0\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258aMy\u00a0subtitle\u00a0is\u00a0on\u00a0the\u00a0right\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u00a0Right\u00a0>\u00a0\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderSubtitleAlignApp(App):\ndef compose(self):\nlbl = Label(\"My subtitle is on the left.\", id=\"label1\")\nlbl.border_subtitle = \"< Left\"\nyield lbl\nlbl = Label(\"My subtitle is centered\", id=\"label2\")\nlbl.border_subtitle = \"Centered!\"\nyield lbl\nlbl = Label(\"My subtitle is on the right\", id=\"label3\")\nlbl.border_subtitle = \"Right >\"\nyield lbl\napp = BorderSubtitleAlignApp(css_path=\"border_subtitle_align.tcss\")\n
#label1 {\nborder: solid $secondary;\nborder-subtitle-align: left;\n}\n#label2 {\nborder: dashed $secondary;\nborder-subtitle-align: center;\n}\n#label3 {\nborder: tall $secondary;\nborder-subtitle-align: right;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border_subtitle_align/#complete-usage-reference","title":"Complete usage reference","text":"This example shows all border title and subtitle alignments, together with some examples of how (sub)titles can have custom markup. Open the code tabs to see the details of the code examples.
Outputborder_sub_title_align_all.pyborder_sub_title_align_all.tcssBorderSubTitleAlignAll \u258fBorder\u00a0title\u2595\u256d\u2500Lef\u2026\u2500\u256e\u2581\u2581\u2581\u2581\u2581Left\u2581\u2581\u2581\u2581\u2581 \u258fThis\u00a0is\u00a0the\u00a0story\u00a0of\u2595\u2502a\u00a0Python\u2502\u258edeveloper\u00a0that\u258a \u258fBorder\u00a0subtitle\u2595\u2570\u2500Cen\u2026\u2500\u256f\u2594\u2594\u2594\u2594\u2594@@@\u2594\u2594\u2594\u2594\u2594\u2594 +--------------+\u2500Title\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 |had\u00a0to\u00a0fill\u00a0up|nine\u00a0labelsand\u00a0ended\u00a0up\u00a0redoing\u00a0it +-Left-------+\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500Subtitle\u2500 \u2500Title,\u00a0but\u00a0really\u00a0looo\u2026\u2500 \u2500Title,\u00a0but\u00a0r\u2026\u2500\u2500Title,\u00a0but\u00a0reall\u2026\u2500 because\u00a0the\u00a0first\u00a0tryhad\u00a0some\u00a0labelsthat\u00a0were\u00a0too\u00a0long. \u2500Subtitle,\u00a0bu\u2026\u2500\u2500Subtitle,\u00a0but\u00a0re\u2026\u2500 \u2500Subtitle,\u00a0but\u00a0really\u00a0l\u2026\u2500
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Label\ndef make_label_container( # (11)!\ntext: str, id: str, border_title: str, border_subtitle: str\n) -> Container:\nlbl = Label(text, id=id)\nlbl.border_title = border_title\nlbl.border_subtitle = border_subtitle\nreturn Container(lbl)\nclass BorderSubTitleAlignAll(App[None]):\ndef compose(self):\nwith Grid():\nyield make_label_container( # (1)!\n\"This is the story of\",\n\"lbl1\",\n\"[b]Border [i]title[/i][/]\",\n\"[u][r]Border[/r] subtitle[/]\",\n)\nyield make_label_container( # (2)!\n\"a Python\",\n\"lbl2\",\n\"[b red]Left, but it's loooooooooooong\",\n\"[reverse]Center, but it's loooooooooooong\",\n)\nyield make_label_container( # (3)!\n\"developer that\",\n\"lbl3\",\n\"[b i on purple]Left[/]\",\n\"[r u white on black]@@@[/]\",\n)\nyield make_label_container(\n\"had to fill up\",\n\"lbl4\",\n\"\", # (4)!\n\"[link=https://textual.textualize.io]Left[/]\", # (5)!\n)\nyield make_label_container( # (6)!\n\"nine labels\", \"lbl5\", \"Title\", \"Subtitle\"\n)\nyield make_label_container( # (7)!\n\"and ended up redoing it\",\n\"lbl6\",\n\"Title\",\n\"Subtitle\",\n)\nyield make_label_container( # (8)!\n\"because the first try\",\n\"lbl7\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (9)!\n\"had some labels\",\n\"lbl8\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (10)!\n\"that were too long.\",\n\"lbl9\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\napp = BorderSubTitleAlignAll(css_path=\"border_sub_title_align_all.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Grid {\ngrid-size: 3 3;\nalign: center middle;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\nalign: center middle;\n}\n#lbl1 { /* (1)! */\nborder: vkey $secondary;\n}\n#lbl2 { /* (2)! */\nborder: round $secondary;\nborder-title-align: right;\nborder-subtitle-align: right;\n}\n#lbl3 {\nborder: wide $secondary;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl4 {\nborder: ascii $success;\nborder-title-align: center; /* (3)! */\nborder-subtitle-align: left;\n}\n#lbl5 { /* (4)! */\n/* No border = no (sub)title. */\nborder: none $success;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl6 { /* (5)! */\nborder-top: solid $success;\nborder-bottom: solid $success;\n}\n#lbl7 { /* (6)! */\nborder-top: solid $error;\nborder-bottom: solid $error;\npadding: 1 2;\nborder-subtitle-align: left;\n}\n#lbl8 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl9 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: right;\n}\n
left
and the default alignment for the subtitle is right
.none
/hidden
, the (sub)title is not shown.border-subtitle-align: left;\nborder-subtitle-align: center;\nborder-subtitle-align: right;\n
"},{"location":"styles/border_subtitle_align/#python","title":"Python","text":"widget.styles.border_subtitle_align = \"left\"\nwidget.styles.border_subtitle_align = \"center\"\nwidget.styles.border_subtitle_align = \"right\"\n
"},{"location":"styles/border_subtitle_align/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-background
style sets the background color of the border_subtitle.
\nborder-subtitle-background: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_subtitle_background/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_background/#css","title":"CSS","text":"border-subtitle-background: blue;\n
"},{"location":"styles/border_subtitle_background/#python","title":"Python","text":"widget.styles.border_subtitle_background = \"blue\"\n
"},{"location":"styles/border_subtitle_background/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-color
style sets the color of the border_subtitle.
\nborder-subtitle-color: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_subtitle_color/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_color/#css","title":"CSS","text":"border-subtitle-color: red;\n
"},{"location":"styles/border_subtitle_color/#python","title":"Python","text":"widget.styles.border_subtitle_color = \"red\"\n
"},{"location":"styles/border_subtitle_color/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-subtitle-style
style sets the text style of the border_subtitle.
\nborder-subtitle-style: <text-style>;\n"},{"location":"styles/border_subtitle_style/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_subtitle_style/#css","title":"CSS","text":"border-subtitle-style: bold underline;\n
"},{"location":"styles/border_subtitle_style/#python","title":"Python","text":"widget.styles.border_subtitle_style = \"bold underline\"\n
"},{"location":"styles/border_subtitle_style/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-align
style sets the horizontal alignment for the border title.
\nborder-title-align: <horizontal>;\n
The border-title-align
style takes a <horizontal>
that determines where the border title is aligned along the top edge of the border. This means that the border corners are always visible.
The default alignment is left
.
This example shows three labels, each with a different border title alignment:
Outputborder_title_align.pyborder_title_align.tcssBorderTitleAlignApp \u250c\u2500\u00a0<\u00a0Left\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502My\u00a0title\u00a0is\u00a0on\u00a0the\u00a0left.\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u00a0Centered!\u00a0\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 \u254f\u254f \u254fMy\u00a0title\u00a0is\u00a0centered\u254f \u254f\u254f \u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u00a0Right\u00a0>\u00a0\u2594\u258e \u258a\u258e \u258aMy\u00a0title\u00a0is\u00a0on\u00a0the\u00a0right\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App\nfrom textual.widgets import Label\nclass BorderTitleAlignApp(App):\ndef compose(self):\nlbl = Label(\"My title is on the left.\", id=\"label1\")\nlbl.border_title = \"< Left\"\nyield lbl\nlbl = Label(\"My title is centered\", id=\"label2\")\nlbl.border_title = \"Centered!\"\nyield lbl\nlbl = Label(\"My title is on the right\", id=\"label3\")\nlbl.border_title = \"Right >\"\nyield lbl\napp = BorderTitleAlignApp(css_path=\"border_title_align.tcss\")\n
#label1 {\nborder: solid $secondary;\nborder-title-align: left;\n}\n#label2 {\nborder: dashed $secondary;\nborder-title-align: center;\n}\n#label3 {\nborder: tall $secondary;\nborder-title-align: right;\n}\nScreen > Label {\nwidth: 100%;\nheight: 5;\ncontent-align: center middle;\ncolor: white;\nmargin: 1;\nbox-sizing: border-box;\n}\n
"},{"location":"styles/border_title_align/#complete-usage-reference","title":"Complete usage reference","text":"This example shows all border title and subtitle alignments, together with some examples of how (sub)titles can have custom markup. Open the code tabs to see the details of the code examples.
Outputborder_sub_title_align_all.pyborder_sub_title_align_all.tcssBorderSubTitleAlignAll \u258fBorder\u00a0title\u2595\u256d\u2500Lef\u2026\u2500\u256e\u2581\u2581\u2581\u2581\u2581Left\u2581\u2581\u2581\u2581\u2581 \u258fThis\u00a0is\u00a0the\u00a0story\u00a0of\u2595\u2502a\u00a0Python\u2502\u258edeveloper\u00a0that\u258a \u258fBorder\u00a0subtitle\u2595\u2570\u2500Cen\u2026\u2500\u256f\u2594\u2594\u2594\u2594\u2594@@@\u2594\u2594\u2594\u2594\u2594\u2594 +--------------+\u2500Title\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 |had\u00a0to\u00a0fill\u00a0up|nine\u00a0labelsand\u00a0ended\u00a0up\u00a0redoing\u00a0it +-Left-------+\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500Subtitle\u2500 \u2500Title,\u00a0but\u00a0really\u00a0looo\u2026\u2500 \u2500Title,\u00a0but\u00a0r\u2026\u2500\u2500Title,\u00a0but\u00a0reall\u2026\u2500 because\u00a0the\u00a0first\u00a0tryhad\u00a0some\u00a0labelsthat\u00a0were\u00a0too\u00a0long. \u2500Subtitle,\u00a0bu\u2026\u2500\u2500Subtitle,\u00a0but\u00a0re\u2026\u2500 \u2500Subtitle,\u00a0but\u00a0really\u00a0l\u2026\u2500
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Label\ndef make_label_container( # (11)!\ntext: str, id: str, border_title: str, border_subtitle: str\n) -> Container:\nlbl = Label(text, id=id)\nlbl.border_title = border_title\nlbl.border_subtitle = border_subtitle\nreturn Container(lbl)\nclass BorderSubTitleAlignAll(App[None]):\ndef compose(self):\nwith Grid():\nyield make_label_container( # (1)!\n\"This is the story of\",\n\"lbl1\",\n\"[b]Border [i]title[/i][/]\",\n\"[u][r]Border[/r] subtitle[/]\",\n)\nyield make_label_container( # (2)!\n\"a Python\",\n\"lbl2\",\n\"[b red]Left, but it's loooooooooooong\",\n\"[reverse]Center, but it's loooooooooooong\",\n)\nyield make_label_container( # (3)!\n\"developer that\",\n\"lbl3\",\n\"[b i on purple]Left[/]\",\n\"[r u white on black]@@@[/]\",\n)\nyield make_label_container(\n\"had to fill up\",\n\"lbl4\",\n\"\", # (4)!\n\"[link=https://textual.textualize.io]Left[/]\", # (5)!\n)\nyield make_label_container( # (6)!\n\"nine labels\", \"lbl5\", \"Title\", \"Subtitle\"\n)\nyield make_label_container( # (7)!\n\"and ended up redoing it\",\n\"lbl6\",\n\"Title\",\n\"Subtitle\",\n)\nyield make_label_container( # (8)!\n\"because the first try\",\n\"lbl7\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (9)!\n\"had some labels\",\n\"lbl8\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\nyield make_label_container( # (10)!\n\"that were too long.\",\n\"lbl9\",\n\"Title, but really loooooooooong!\",\n\"Subtitle, but really loooooooooong!\",\n)\napp = BorderSubTitleAlignAll(css_path=\"border_sub_title_align_all.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Grid {\ngrid-size: 3 3;\nalign: center middle;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\nalign: center middle;\n}\n#lbl1 { /* (1)! */\nborder: vkey $secondary;\n}\n#lbl2 { /* (2)! */\nborder: round $secondary;\nborder-title-align: right;\nborder-subtitle-align: right;\n}\n#lbl3 {\nborder: wide $secondary;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl4 {\nborder: ascii $success;\nborder-title-align: center; /* (3)! */\nborder-subtitle-align: left;\n}\n#lbl5 { /* (4)! */\n/* No border = no (sub)title. */\nborder: none $success;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl6 { /* (5)! */\nborder-top: solid $success;\nborder-bottom: solid $success;\n}\n#lbl7 { /* (6)! */\nborder-top: solid $error;\nborder-bottom: solid $error;\npadding: 1 2;\nborder-subtitle-align: left;\n}\n#lbl8 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: center;\nborder-subtitle-align: center;\n}\n#lbl9 {\nborder-top: solid $error;\nborder-bottom: solid $error;\nborder-title-align: right;\n}\n
left
and the default alignment for the subtitle is right
.none
/hidden
, the (sub)title is not shown.border-title-align: left;\nborder-title-align: center;\nborder-title-align: right;\n
"},{"location":"styles/border_title_align/#python","title":"Python","text":"widget.styles.border_title_align = \"left\"\nwidget.styles.border_title_align = \"center\"\nwidget.styles.border_title_align = \"right\"\n
"},{"location":"styles/border_title_align/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-background
style sets the background color of the border_title.
\nborder-title-background: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_title_background/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_background/#css","title":"CSS","text":"border-title-background: blue;\n
"},{"location":"styles/border_title_background/#python","title":"Python","text":"widget.styles.border_title_background = \"blue\"\n
"},{"location":"styles/border_title_background/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-color
style sets the color of the border_title.
\nborder-title-color: (<color> | auto) [<percentage>];\n"},{"location":"styles/border_title_color/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_color/#css","title":"CSS","text":"border-title-color: red;\n
"},{"location":"styles/border_title_color/#python","title":"Python","text":"widget.styles.border_title_color = \"red\"\n
"},{"location":"styles/border_title_color/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The border-title-style
style sets the text style of the border_title.
\nborder-title-style: <text-style>;\n"},{"location":"styles/border_title_style/#example","title":"Example","text":"
The following examples demonstrates customization of the border color and text style rules.
Outputborder_title_colors.pyborder_title_colors.tcssBorderTitleApp \u250f\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503Hello,\u00a0World!\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0Textual\u00a0Rocks\u00a0\u2501\u251b
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass BorderTitleApp(App):\nCSS_PATH = \"border_title_colors.tcss\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, World!\")\ndef on_mount(self) -> None:\nlabel = self.query_one(Label)\nlabel.border_title = \"Textual Rocks\"\nlabel.border_subtitle = \"Textual Rocks\"\nif __name__ == \"__main__\":\napp = BorderTitleApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nLabel {\npadding: 4 8;\nborder: heavy red;\nborder-title-color: green;\nborder-title-background: white;\nborder-title-style: bold;\nborder-subtitle-color: magenta;\nborder-subtitle-background: yellow;\nborder-subtitle-style: italic;\n}\n
"},{"location":"styles/border_title_style/#css","title":"CSS","text":"border-title-style: bold underline;\n
"},{"location":"styles/border_title_style/#python","title":"Python","text":"widget.styles.border_title_style = \"bold underline\"\n
"},{"location":"styles/border_title_style/#see-also","title":"See also","text":"border-title-align
to set the title's alignment.border-title-color
to set the title's color.border-title-background
to set the title's background color.border-title-style
to set the title's text style.
border-subtitle-align
to set the sub-title's alignment.
border-subtitle-color
to set the sub-title's color.border-subtitle-background
to set the sub-title's background color.border-subtitle-style
to set the sub-title's text style.The box-sizing
style determines how the width and height of a widget are calculated.
box-sizing: border-box | content-box;\n
"},{"location":"styles/box_sizing/#values","title":"Values","text":"Value Description border-box
(default) Padding and border are included in the width and height. If you add padding and/or border the widget will not change in size, but you will have less space for content. content-box
Padding and border will increase the size of the widget, leaving the content area unaffected."},{"location":"styles/box_sizing/#example","title":"Example","text":"Both widgets in this example have the same height (5). The top widget has box-sizing: border-box
which means that padding and border reduce the space for content. The bottom widget has box-sizing: content-box
which increases the size of the widget to compensate for padding and border.
BoxSizingApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eI'm\u00a0using\u00a0border-box!\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eI'm\u00a0using\u00a0content-box!\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Static\nclass BoxSizingApp(App):\ndef compose(self):\nyield Static(\"I'm using border-box!\", id=\"static1\")\nyield Static(\"I'm using content-box!\", id=\"static2\")\napp = BoxSizingApp(css_path=\"box_sizing.tcss\")\n
#static1 {\nbox-sizing: border-box;\n}\n#static2 {\nbox-sizing: content-box;\n}\nScreen {\nbackground: white;\ncolor: black;\n}\nApp Static {\nbackground: blue 20%;\nheight: 5;\nmargin: 2;\npadding: 1;\nborder: wide black;\n}\n
"},{"location":"styles/box_sizing/#css","title":"CSS","text":"/* Set box sizing to border-box (default) */\nbox-sizing: border-box;\n/* Set box sizing to content-box */\nbox-sizing: content-box;\n
"},{"location":"styles/box_sizing/#python","title":"Python","text":"# Set box sizing to border-box (default)\nwidget.box_sizing = \"border-box\"\n# Set box sizing to content-box\nwidget.box_sizing = \"content-box\"\n
"},{"location":"styles/box_sizing/#see-also","title":"See also","text":"border
to add a border around a widget.padding
to add spacing around the content of a widget.The color
style sets the text color of a widget.
\ncolor: (<color> | auto) [<percentage>];\n
The color
style requires a <color>
followed by an optional <percentage>
to specify the color's opacity.
You can also use the special value of \"auto\"
in place of a color. This tells Textual to automatically select either white or black text for best contrast against the background.
This example sets a different text color for each of three different widgets.
Outputcolor.pycolor.tcssColorApp I'm\u00a0red! I'm\u00a0rgb(0,\u00a0255,\u00a00)! I'm\u00a0hsl(240,\u00a0100%,\u00a050%)!
from textual.app import App\nfrom textual.widgets import Label\nclass ColorApp(App):\ndef compose(self):\nyield Label(\"I'm red!\", id=\"label1\")\nyield Label(\"I'm rgb(0, 255, 0)!\", id=\"label2\")\nyield Label(\"I'm hsl(240, 100%, 50%)!\", id=\"label3\")\napp = ColorApp(css_path=\"color.tcss\")\n
Label {\nheight: 1fr;\ncontent-align: center middle;\nwidth: 100%;\n}\n#label1 {\ncolor: red;\n}\n#label2 {\ncolor: rgb(0, 255, 0);\n}\n#label3 {\ncolor: hsl(240, 100%, 50%);\n}\n
"},{"location":"styles/color/#auto","title":"Auto","text":"The next example shows how auto
chooses between a lighter or a darker text color to increase the contrast and improve readability.
ColorApp The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog! The\u00a0quick\u00a0brown\u00a0fox\u00a0jumps\u00a0over\u00a0the\u00a0lazy\u00a0dog!
from textual.app import App\nfrom textual.widgets import Label\nclass ColorApp(App):\ndef compose(self):\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl1\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl2\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl3\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl4\")\nyield Label(\"The quick brown fox jumps over the lazy dog!\", id=\"lbl5\")\napp = ColorApp(css_path=\"color_auto.tcss\")\n
Label {\ncolor: auto 80%;\ncontent-align: center middle;\nheight: 1fr;\nwidth: 100%;\n}\n#lbl1 {\nbackground: red 80%;\n}\n#lbl2 {\nbackground: yellow 80%;\n}\n#lbl3 {\nbackground: blue 80%;\n}\n#lbl4 {\nbackground: pink 80%;\n}\n#lbl5 {\nbackground: green 80%;\n}\n
"},{"location":"styles/color/#css","title":"CSS","text":"/* Blue text */\ncolor: blue;\n/* 20% red text */\ncolor: red 20%;\n/* RGB color */\ncolor: rgb(100, 120, 200);\n/* Automatically choose color with suitable contrast for readability */\ncolor: auto;\n
"},{"location":"styles/color/#python","title":"Python","text":"You can use the same syntax as CSS, or explicitly set a Color
object.
# Set blue text\nwidget.styles.color = \"blue\"\nfrom textual.color import Color\n# Set with a color object\nwidget.styles.color = Color.parse(\"pink\")\n
"},{"location":"styles/color/#see-also","title":"See also","text":"background
to set the background color in a widget.The content-align
style aligns content inside a widget.
\ncontent-align: <horizontal> <vertical>;\n\ncontent-align-horizontal: <horizontal>;\ncontent-align-vertical: <vertical>;\n
The content-align
style takes a <horizontal>
followed by a <vertical>
.
You can specify the alignment of content on both the horizontal and vertical axes at the same time, or on each of the axis separately. To specify content alignment on a single axis, use the respective style and type:
content-align-horizontal
takes a <horizontal>
and does alignment along the horizontal axis; andcontent-align-vertical
takes a <vertical>
and does alignment along the vertical axis.This first example shows three labels stacked vertically, each with different content alignments.
Outputcontent_align.pycontent_align.tcssContentAlignApp With\u00a0content-align\u00a0you\u00a0can... ...Easily\u00a0align\u00a0content... ...Horizontally\u00a0and\u00a0vertically!
from textual.app import App\nfrom textual.widgets import Label\nclass ContentAlignApp(App):\ndef compose(self):\nyield Label(\"With [i]content-align[/] you can...\", id=\"box1\")\nyield Label(\"...[b]Easily align content[/]...\", id=\"box2\")\nyield Label(\"...Horizontally [i]and[/] vertically!\", id=\"box3\")\napp = ContentAlignApp(css_path=\"content_align.tcss\")\n
#box1 {\ncontent-align: left top;\nbackground: red;\n}\n#box2 {\ncontent-align-horizontal: center;\ncontent-align-vertical: middle;\nbackground: green;\n}\n#box3 {\ncontent-align: right bottom;\nbackground: blue;\n}\nLabel {\nwidth: 100%;\nheight: 1fr;\npadding: 1;\ncolor: white;\n}\n
"},{"location":"styles/content_align/#all-content-alignments","title":"All content alignments","text":"The next example shows a 3 by 3 grid of labels. Each label has its text aligned differently.
Outputcontent_align_all.pycontent_align_all.tcssAllContentAlignApp left\u00a0topcenter\u00a0topright\u00a0top left\u00a0middlecenter\u00a0middleright\u00a0middle left\u00a0bottomcenter\u00a0bottomright\u00a0bottom
from textual.app import App\nfrom textual.widgets import Label\nclass AllContentAlignApp(App):\ndef compose(self):\nyield Label(\"left top\", id=\"left-top\")\nyield Label(\"center top\", id=\"center-top\")\nyield Label(\"right top\", id=\"right-top\")\nyield Label(\"left middle\", id=\"left-middle\")\nyield Label(\"center middle\", id=\"center-middle\")\nyield Label(\"right middle\", id=\"right-middle\")\nyield Label(\"left bottom\", id=\"left-bottom\")\nyield Label(\"center bottom\", id=\"center-bottom\")\nyield Label(\"right bottom\", id=\"right-bottom\")\napp = AllContentAlignApp(css_path=\"content_align_all.tcss\")\n
#left-top {\n/* content-align: left top; this is the default implied value. */\n}\n#center-top {\ncontent-align: center top;\n}\n#right-top {\ncontent-align: right top;\n}\n#left-middle {\ncontent-align: left middle;\n}\n#center-middle {\ncontent-align: center middle;\n}\n#right-middle {\ncontent-align: right middle;\n}\n#left-bottom {\ncontent-align: left bottom;\n}\n#center-bottom {\ncontent-align: center bottom;\n}\n#right-bottom {\ncontent-align: right bottom;\n}\nScreen {\nlayout: grid;\ngrid-size: 3 3;\ngrid-gutter: 1;\n}\nLabel {\nwidth: 100%;\nheight: 100%;\nbackground: $primary;\n}\n
"},{"location":"styles/content_align/#css","title":"CSS","text":"/* Align content in the very center of a widget */\ncontent-align: center middle;\n/* Align content at the top right of a widget */\ncontent-align: right top;\n/* Change the horizontal alignment of the content of a widget */\ncontent-align-horizontal: right;\n/* Change the vertical alignment of the content of a widget */\ncontent-align-vertical: middle;\n
"},{"location":"styles/content_align/#python","title":"Python","text":"# Align content in the very center of a widget\nwidget.styles.content_align = (\"center\", \"middle\")\n# Align content at the top right of a widget\nwidget.styles.content_align = (\"right\", \"top\")\n# Change the horizontal alignment of the content of a widget\nwidget.styles.content_align_horizontal = \"right\"\n# Change the vertical alignment of the content of a widget\nwidget.styles.content_align_vertical = \"middle\"\n
"},{"location":"styles/content_align/#see-also","title":"See also","text":"align
to set the alignment of children widgets inside a container.text-align
to set the alignment of text in a widget.The display
style defines whether a widget is displayed or not.
display: block | none;\n
"},{"location":"styles/display/#values","title":"Values","text":"Value Description block
(default) Display the widget as normal. none
The widget is not displayed and space will no longer be reserved for it."},{"location":"styles/display/#example","title":"Example","text":"Note that the second widget is hidden by adding the \"remove\"
class which sets the display style to none
.
DisplayApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a01\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a03\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Static\nclass DisplayApp(App):\ndef compose(self):\nyield Static(\"Widget 1\")\nyield Static(\"Widget 2\", classes=\"remove\")\nyield Static(\"Widget 3\")\napp = DisplayApp(css_path=\"display.tcss\")\n
Screen {\nbackground: green;\n}\nStatic {\nheight: 5;\nbackground: white;\ncolor: blue;\nborder: heavy blue;\n}\nStatic.remove {\ndisplay: none;\n}\n
"},{"location":"styles/display/#css","title":"CSS","text":"/* Widget is shown */\ndisplay: block;\n/* Widget is not shown */\ndisplay: none;\n
"},{"location":"styles/display/#python","title":"Python","text":"# Hide the widget\nself.styles.display = \"none\"\n# Show the widget again\nself.styles.display = \"block\"\n
There is also a shortcut to show / hide a widget. The display
property on Widget
may be set to True
or False
to show or hide the widget.
# Hide the widget\nwidget.display = False\n# Show the widget\nwidget.display = True\n
"},{"location":"styles/display/#see-also","title":"See also","text":"visibility
to specify whether a widget is visible or not.The dock
style is used to fix a widget to the edge of a container (which may be the entire terminal window).
dock: bottom | left | right | top;\n
The option chosen determines the edge to which the widget is docked.
"},{"location":"styles/dock/#examples","title":"Examples","text":""},{"location":"styles/dock/#basic-usage","title":"Basic usage","text":"The example below shows a left
docked sidebar. Notice that even though the content is scrolled, the sidebar remains fixed.
DockLayoutExample SidebarDocking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0\u2587\u2587 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container. Docked\u00a0widgets\u00a0will\u00a0not\u00a0scroll\u00a0out\u00a0of\u00a0view,\u00a0making\u00a0them\u00a0ideal\u00a0 for\u00a0sticky\u00a0headers,\u00a0footers,\u00a0and\u00a0sidebars. Docking\u00a0a\u00a0widget\u00a0removes\u00a0it\u00a0from\u00a0the\u00a0layout\u00a0and\u00a0fixes\u00a0its\u00a0 position,\u00a0aligned\u00a0to\u00a0either\u00a0the\u00a0top,\u00a0right,\u00a0bottom,\u00a0or\u00a0left\u00a0 edges\u00a0of\u00a0a\u00a0container.
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nDocking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container.\nDocked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars.\n\"\"\"\nclass DockLayoutExample(App):\nCSS_PATH = \"dock_layout1_sidebar.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"Sidebar\", id=\"sidebar\")\nyield Static(TEXT * 10, id=\"body\")\nif __name__ == \"__main__\":\napp = DockLayoutExample()\napp.run()\n
#sidebar {\ndock: left;\nwidth: 15;\nheight: 100%;\ncolor: #0f2b41;\nbackground: dodgerblue;\n}\n
"},{"location":"styles/dock/#advanced-usage","title":"Advanced usage","text":"The second example shows how one can use full-width or full-height containers to dock labels to the edges of a larger container. The labels will remain in that position (docked) even if the container they are in scrolls horizontally and/or vertically.
Outputdock_all.pydock_all.tcssDockAllApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502top\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502leftright\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502bottom\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass DockAllApp(App):\ndef compose(self):\nyield Container(\nContainer(Label(\"left\"), id=\"left\"),\nContainer(Label(\"top\"), id=\"top\"),\nContainer(Label(\"right\"), id=\"right\"),\nContainer(Label(\"bottom\"), id=\"bottom\"),\nid=\"big_container\",\n)\napp = DockAllApp(css_path=\"dock_all.tcss\")\n
#left {\ndock: left;\nheight: 100%;\nwidth: auto;\nalign-vertical: middle;\n}\n#top {\ndock: top;\nheight: auto;\nwidth: 100%;\nalign-horizontal: center;\n}\n#right {\ndock: right;\nheight: 100%;\nwidth: auto;\nalign-vertical: middle;\n}\n#bottom {\ndock: bottom;\nheight: auto;\nwidth: 100%;\nalign-horizontal: center;\n}\nScreen {\nalign: center middle;\n}\n#big_container {\nwidth: 75%;\nheight: 75%;\nborder: round white;\n}\n
"},{"location":"styles/dock/#css","title":"CSS","text":"dock: bottom; /* Docks on the bottom edge of the parent container. */\ndock: left; /* Docks on the left edge of the parent container. */\ndock: right; /* Docks on the right edge of the parent container. */\ndock: top; /* Docks on the top edge of the parent container. */\n
"},{"location":"styles/dock/#python","title":"Python","text":"widget.styles.dock = \"bottom\" # Dock bottom.\nwidget.styles.dock = \"left\" # Dock left.\nwidget.styles.dock = \"right\" # Dock right.\nwidget.styles.dock = \"top\" # Dock top.\n
"},{"location":"styles/dock/#see-also","title":"See also","text":"The height
style sets a widget's height.
\nheight: <scalar>;\n
The height
style needs a <scalar>
to determine the vertical length of the widget. By default, it sets the height of the content area, but if box-sizing
is set to border-box
it sets the height of the border area.
This examples creates a widget with a height of 50% of the screen.
Outputheight.pyheight.tcssHeightApp Widget
from textual.app import App\nfrom textual.widget import Widget\nclass HeightApp(App):\ndef compose(self):\nyield Widget()\napp = HeightApp(css_path=\"height.tcss\")\n
Screen > Widget {\nbackground: green;\nheight: 50%;\ncolor: white;\n}\n
"},{"location":"styles/height/#all-height-formats","title":"All height formats","text":"The next example creates a series of wide widgets with heights set with different units. Open the CSS file tab to see the comments that explain how each height is computed. (The output includes a vertical ruler on the right to make it easier to check the height of each widget.)
Outputheight_comparison.pyheight_comparison.tcssHeightComparisonApp #cells\u00b7 \u00b7 \u00b7 #percent\u00b7 \u2022 \u00b7 #w\u00b7 \u00b7 \u00b7 \u2022 #h\u00b7 \u00b7 \u00b7 \u00b7 #vw\u2022 \u00b7 \u00b7 \u00b7 #vh\u00b7 \u2022 #auto\u00b7 #fr1\u00b7 #fr2\u00b7 \u00b7
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Label, Placeholder, Static\nclass Ruler(Static):\ndef compose(self):\nruler_text = \"\u00b7\\n\u00b7\\n\u00b7\\n\u00b7\\n\u2022\\n\" * 100\nyield Label(ruler_text)\nclass HeightComparisonApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(id=\"cells\"), # (1)!\nPlaceholder(id=\"percent\"),\nPlaceholder(id=\"w\"),\nPlaceholder(id=\"h\"),\nPlaceholder(id=\"vw\"),\nPlaceholder(id=\"vh\"),\nPlaceholder(id=\"auto\"),\nPlaceholder(id=\"fr1\"),\nPlaceholder(id=\"fr2\"),\n)\nyield Ruler()\napp = HeightComparisonApp(css_path=\"height_comparison.tcss\")\n
#cells {\nheight: 2; /* (1)! */\n}\n#percent {\nheight: 12.5%; /* (2)! */\n}\n#w {\nheight: 5w; /* (3)! */\n}\n#h {\nheight: 12.5h; /* (4)! */\n}\n#vw {\nheight: 6.25vw; /* (5)! */\n}\n#vh {\nheight: 12.5vh; /* (6)! */\n}\n#auto {\nheight: auto; /* (7)! */\n}\n#fr1 {\nheight: 1fr; /* (8)! */\n}\n#fr2 {\nheight: 2fr; /* (9)! */\n}\nScreen {\nlayers: ruler;\noverflow: hidden;\n}\nRuler {\nlayer: ruler;\ndock: right;\nwidth: 1;\nbackground: $accent;\n}\n
VerticalScroll
container. Because it expands to fit all of the terminal, the width of the VerticalScroll
is 80 and 5% of 80 is 4.VerticalScroll
container. Because it expands to fit all of the terminal, the height of the VerticalScroll
is 24 and 12.5% of 24 is 3.1fr
, which means this placeholder will have half the height of a placeholder with 2fr
.2fr
, which means this placeholder will have twice the height of a placeholder with 1fr
./* Explicit cell height */\nheight: 10;\n/* Percentage height */\nheight: 50%;\n/* Automatic height */\nheight: auto\n
"},{"location":"styles/height/#python","title":"Python","text":"self.styles.height = 10 # Explicit cell height can be an int\nself.styles.height = \"50%\"\nself.styles.height = \"auto\"\n
"},{"location":"styles/height/#see-also","title":"See also","text":"max-height
and min-height
to limit the height of a widget.width
to set the width of a widget.The layer
style defines the layer a widget belongs to.
\nlayer: <name>;\n
The layer
style accepts a <name>
that defines the layer this widget belongs to. This <name>
must correspond to a <name>
that has been defined in a layers
style by an ancestor of this widget.
More information on layers can be found in the guide.
Warning
Using a <name>
that hasn't been defined in a layers
declaration of an ancestor of this widget has no effect.
In the example below, #box1
is yielded before #box2
. However, since #box1
is on the higher layer, it is drawn on top of #box2
.
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"styles/layer/#css","title":"CSS","text":"/* Draw the widget on the layer called 'below' */\nlayer: below;\n
"},{"location":"styles/layer/#python","title":"Python","text":"# Draw the widget on the layer called 'below'\nwidget.styles.layer = \"below\"\n
"},{"location":"styles/layer/#see-also","title":"See also","text":"layers
to define an ordered set of layers.The layers
style allows you to define an ordered set of layers.
\nlayers: <name>+;\n
The layers
style accepts one or more <name>
that define the layers that the widget is aware of, and the order in which they will be painted on the screen.
The values used here can later be referenced using the layer
property. The layers defined first in the list are drawn under the layers that are defined later in the list.
More information on layers can be found in the guide.
"},{"location":"styles/layers/#example","title":"Example","text":"In the example below, #box1
is yielded before #box2
. However, since #box1
is on the higher layer, it is drawn on top of #box2
.
LayersExample box1\u00a0(layer\u00a0=\u00a0above) box2\u00a0(layer\u00a0=\u00a0below)
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass LayersExample(App):\nCSS_PATH = \"layers.tcss\"\ndef compose(self) -> ComposeResult:\nyield Static(\"box1 (layer = above)\", id=\"box1\")\nyield Static(\"box2 (layer = below)\", id=\"box2\")\nif __name__ == \"__main__\":\napp = LayersExample()\napp.run()\n
Screen {\nalign: center middle;\nlayers: below above;\n}\nStatic {\nwidth: 28;\nheight: 8;\ncolor: auto;\ncontent-align: center middle;\n}\n#box1 {\nlayer: above;\nbackground: darkcyan;\n}\n#box2 {\nlayer: below;\nbackground: orange;\noffset: 12 6;\n}\n
"},{"location":"styles/layers/#css","title":"CSS","text":"/* Bottom layer is called 'below', layer above it is called 'above' */\nlayers: below above;\n
"},{"location":"styles/layers/#python","title":"Python","text":"# Bottom layer is called 'below', layer above it is called 'above'\nwidget.style.layers = (\"below\", \"above\")\n
"},{"location":"styles/layers/#see-also","title":"See also","text":"layer
to set the layer a widget belongs to.The layout
style defines how a widget arranges its children.
\nlayout: grid | horizontal | vertical;\n
The layout
style takes an option that defines how child widgets will be arranged, as per the table shown below.
grid
Child widgets will be arranged in a grid. horizontal
Child widgets will be arranged along the horizontal axis, from left to right. vertical
(default) Child widgets will be arranged along the vertical axis, from top to bottom. See the layout guide for more information.
"},{"location":"styles/layout/#example","title":"Example","text":"Note how the layout
style affects the arrangement of widgets in the example below. To learn more about the grid layout, you can see the layout guide or the grid reference.
LayoutApp Layout Is Vertical LayoutIsHorizontal
from textual.app import App\nfrom textual.containers import Container\nfrom textual.widgets import Label\nclass LayoutApp(App):\ndef compose(self):\nyield Container(\nLabel(\"Layout\"),\nLabel(\"Is\"),\nLabel(\"Vertical\"),\nid=\"vertical-layout\",\n)\nyield Container(\nLabel(\"Layout\"),\nLabel(\"Is\"),\nLabel(\"Horizontal\"),\nid=\"horizontal-layout\",\n)\napp = LayoutApp(css_path=\"layout.tcss\")\n
#vertical-layout {\nlayout: vertical;\nbackground: darkmagenta;\nheight: auto;\n}\n#horizontal-layout {\nlayout: horizontal;\nbackground: darkcyan;\nheight: auto;\n}\nLabel {\nmargin: 1;\nwidth: 12;\ncolor: black;\nbackground: yellowgreen;\n}\n
"},{"location":"styles/layout/#css","title":"CSS","text":"layout: horizontal;\n
"},{"location":"styles/layout/#python","title":"Python","text":"widget.styles.layout = \"horizontal\"\n
"},{"location":"styles/layout/#see-also","title":"See also","text":"The margin
style specifies spacing around a widget.
\nmargin: <integer>\n # one value for all edges\n | <integer> <integer>\n # top/bot left/right\n | <integer> <integer> <integer> <integer>;\n # top right bot left\n\nmargin-top: <integer>;\nmargin-right: <integer>;\nmargin-bottom: <integer>;\nmargin-left: <integer>;\n
The margin
specifies spacing around the four edges of the widget equal to the <integer>
specified. The number of values given defines what edges get what margin:
<integer>
sets the same margin for the four edges of the widget;<integer>
set margin for top/bottom and left/right edges, respectively.<integer>
set margin for the top, right, bottom, and left edges, respectively.Tip
To remember the order of the edges affected by the rule margin
when it has 4 values, think of a clock. Its hand starts at the top and the goes clockwise: top, right, bottom, left.
Alternatively, margin can be set for each edge individually through the styles margin-top
, margin-right
, margin-bottom
, and margin-left
, respectively.
In the example below we add a large margin to a label, which makes it move away from the top-left corner of the screen.
Outputmargin.pymargin.tcssMarginApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258a \u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0\u258a \u258eits\u00a0path.\u258a \u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a \u258eremain.\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass MarginApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = MarginApp(css_path=\"margin.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\n}\nLabel {\nmargin: 4 8;\nbackground: blue 20%;\nborder: blue wide;\nwidth: 100%;\n}\n
"},{"location":"styles/margin/#all-margin-settings","title":"All margin settings","text":"The next example shows a grid. In each cell, we have a placeholder that has its margins set in different ways.
Outputmargin_all.pymargin_all.tcssMarginAllApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502margin\u2502\u2502margin:\u00a01\u00a0\u2502 \u2502no\u00a0margin\u2502\u2502margin:\u00a01\u2502\u2502:\u00a01\u00a05\u2502\u25021\u00a02\u00a06\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502margin-bottom:\u00a04\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502margin-right:\u00a0\u2502\u2502\u2502\u2502margin-left:\u00a03\u2502 \u2502\u2502\u25023\u2502\u2502\u2502\u2502\u2502 \u2502margin-top:\u00a04\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Placeholder\nclass MarginAllApp(App):\ndef compose(self):\nyield Grid(\nContainer(Placeholder(\"no margin\", id=\"p1\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1\", id=\"p2\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1 5\", id=\"p3\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin: 1 1 2 6\", id=\"p4\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-top: 4\", id=\"p5\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-right: 3\", id=\"p6\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-bottom: 4\", id=\"p7\"), classes=\"bordered\"),\nContainer(Placeholder(\"margin-left: 3\", id=\"p8\"), classes=\"bordered\"),\n)\napp = MarginAllApp(css_path=\"margin_all.tcss\")\n
Screen {\nbackground: $background;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nwidth: 100%;\nheight: 100%;\n}\nContainer {\nwidth: 100%;\nheight: 100%;\n}\n.bordered {\nborder: white round;\n}\n#p1 {\n/* default is no margin */\n}\n#p2 {\nmargin: 1;\n}\n#p3 {\nmargin: 1 5;\n}\n#p4 {\nmargin: 1 1 2 6;\n}\n#p5 {\nmargin-top: 4;\n}\n#p6 {\nmargin-right: 3;\n}\n#p7 {\nmargin-bottom: 4;\n}\n#p8 {\nmargin-left: 3;\n}\n
"},{"location":"styles/margin/#css","title":"CSS","text":"/* Set margin of 1 around all edges */\nmargin: 1;\n/* Set margin of 2 on the top and bottom edges, and 4 on the left and right */\nmargin: 2 4;\n/* Set margin of 1 on the top, 2 on the right,\n3 on the bottom, and 4 on the left */\nmargin: 1 2 3 4;\nmargin-top: 1;\nmargin-right: 2;\nmargin-bottom: 3;\nmargin-left: 4;\n
"},{"location":"styles/margin/#python","title":"Python","text":"Python does not provide the properties margin-top
, margin-right
, margin-bottom
, and margin-left
. However, you can set the margin to a single integer, a tuple of 2 integers, or a tuple of 4 integers:
# Set margin of 1 around all edges\nwidget.styles.margin = 1\n# Set margin of 2 on the top and bottom edges, and 4 on the left and right\nwidget.styles.margin = (2, 4)\n# Set margin of 1 on top, 2 on the right, 3 on the bottom, and 4 on the left\nwidget.styles.margin = (1, 2, 3, 4)\n
"},{"location":"styles/margin/#see-also","title":"See also","text":"padding
to add spacing around the content of a widget.The max-height
style sets a maximum height for a widget.
\nmax-height: <scalar>;\n
The max-height
style accepts a <scalar>
that defines an upper bound for the height
of a widget. That is, the height of a widget is never allowed to exceed max-height
.
The example below shows some placeholders that were defined to span vertically from the top edge of the terminal to the bottom edge. Then, we set max-height
individually on each placeholder.
MaxHeightApp max-height:\u00a010w max-height:\u00a010 max-height:\u00a050% max-height:\u00a0999
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Placeholder\nclass MaxHeightApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(\"max-height: 10w\", id=\"p1\"),\nPlaceholder(\"max-height: 999\", id=\"p2\"),\nPlaceholder(\"max-height: 50%\", id=\"p3\"),\nPlaceholder(\"max-height: 10\", id=\"p4\"),\n)\napp = MaxHeightApp(css_path=\"max_height.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\n}\nPlaceholder {\nheight: 100%;\nwidth: 1fr;\n}\n#p1 {\nmax-height: 10w;\n}\n#p2 {\nmax-height: 999; /* (1)! */\n}\n#p3 {\nmax-height: 50%;\n}\n#p4 {\nmax-height: 10;\n}\n
/* Set the maximum height to 10 rows */\nmax-height: 10;\n/* Set the maximum height to 25% of the viewport height */\nmax-height: 25vh;\n
"},{"location":"styles/max_height/#python","title":"Python","text":"# Set the maximum height to 10 rows\nwidget.styles.max_height = 10\n# Set the maximum height to 25% of the viewport height\nwidget.styles.max_height = \"25vh\"\n
"},{"location":"styles/max_height/#see-also","title":"See also","text":"min-height
to set a lower bound on the height of a widget.height
to set the height of a widget.The max-width
style sets a maximum width for a widget.
\nmax-width: <scalar>;\n
The max-width
style accepts a <scalar>
that defines an upper bound for the width
of a widget. That is, the width of a widget is never allowed to exceed max-width
.
The example below shows some placeholders that were defined to span horizontally from the left edge of the terminal to the right edge. Then, we set max-width
individually on each placeholder.
MaxWidthApp max-width:\u00a0 50h max-width:\u00a0999 max-width:\u00a050% max-width:\u00a030
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Placeholder\nclass MaxWidthApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(\"max-width: 50h\", id=\"p1\"),\nPlaceholder(\"max-width: 999\", id=\"p2\"),\nPlaceholder(\"max-width: 50%\", id=\"p3\"),\nPlaceholder(\"max-width: 30\", id=\"p4\"),\n)\napp = MaxWidthApp(css_path=\"max_width.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\n}\nPlaceholder {\nwidth: 100%;\nheight: 1fr;\n}\n#p1 {\nmax-width: 50h;\n}\n#p2 {\nmax-width: 999; /* (1)! */\n}\n#p3 {\nmax-width: 50%;\n}\n#p4 {\nmax-width: 30;\n}\n
/* Set the maximum width to 10 rows */\nmax-width: 10;\n/* Set the maximum width to 25% of the viewport width */\nmax-width: 25vw;\n
"},{"location":"styles/max_width/#python","title":"Python","text":"# Set the maximum width to 10 rows\nwidget.styles.max_width = 10\n# Set the maximum width to 25% of the viewport width\nwidget.styles.max_width = \"25vw\"\n
"},{"location":"styles/max_width/#see-also","title":"See also","text":"min-width
to set a lower bound on the width of a widget.width
to set the width of a widget.The min-height
style sets a minimum height for a widget.
\nmin-height: <scalar>;\n
The min-height
style accepts a <scalar>
that defines a lower bound for the height
of a widget. That is, the height of a widget is never allowed to be under min-height
.
The example below shows some placeholders with their height set to 50%
. Then, we set min-height
individually on each placeholder.
MinHeightApp min-height:\u00a025% min-height:\u00a075% min-height:\u00a030 min-height:\u00a040w \u2583\u2583
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Placeholder\nclass MinHeightApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(\"min-height: 25%\", id=\"p1\"),\nPlaceholder(\"min-height: 75%\", id=\"p2\"),\nPlaceholder(\"min-height: 30\", id=\"p3\"),\nPlaceholder(\"min-height: 40w\", id=\"p4\"),\n)\napp = MinHeightApp(css_path=\"min_height.tcss\")\n
Horizontal {\nheight: 100%;\nwidth: 100%;\noverflow-y: auto;\n}\nPlaceholder {\nwidth: 1fr;\nheight: 50%;\n}\n#p1 {\nmin-height: 25%; /* (1)! */\n}\n#p2 {\nmin-height: 75%;\n}\n#p3 {\nmin-height: 30;\n}\n#p4 {\nmin-height: 40w;\n}\n
/* Set the minimum height to 10 rows */\nmin-height: 10;\n/* Set the minimum height to 25% of the viewport height */\nmin-height: 25vh;\n
"},{"location":"styles/min_height/#python","title":"Python","text":"# Set the minimum height to 10 rows\nwidget.styles.min_height = 10\n# Set the minimum height to 25% of the viewport height\nwidget.styles.min_height = \"25vh\"\n
"},{"location":"styles/min_height/#see-also","title":"See also","text":"max-height
to set an upper bound on the height of a widget.height
to set the height of a widget.The min-width
style sets a minimum width for a widget.
\nmin-width: <scalar>;\n
The min-width
style accepts a <scalar>
that defines a lower bound for the width
of a widget. That is, the width of a widget is never allowed to be under min-width
.
The example below shows some placeholders with their width set to 50%
. Then, we set min-width
individually on each placeholder.
MinWidthApp min-width:\u00a025% min-width:\u00a075% min-width:\u00a0100 min-width:\u00a0400h
from textual.app import App\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Placeholder\nclass MinWidthApp(App):\ndef compose(self):\nyield VerticalScroll(\nPlaceholder(\"min-width: 25%\", id=\"p1\"),\nPlaceholder(\"min-width: 75%\", id=\"p2\"),\nPlaceholder(\"min-width: 100\", id=\"p3\"),\nPlaceholder(\"min-width: 400h\", id=\"p4\"),\n)\napp = MinWidthApp(css_path=\"min_width.tcss\")\n
VerticalScroll {\nheight: 100%;\nwidth: 100%;\noverflow-x: auto;\n}\nPlaceholder {\nheight: 1fr;\nwidth: 50%;\n}\n#p1 {\nmin-width: 25%;\n/* (1)! */\n}\n#p2 {\nmin-width: 75%;\n}\n#p3 {\nmin-width: 100;\n}\n#p4 {\nmin-width: 400h;\n}\n
/* Set the minimum width to 10 rows */\nmin-width: 10;\n/* Set the minimum width to 25% of the viewport width */\nmin-width: 25vw;\n
"},{"location":"styles/min_width/#python","title":"Python","text":"# Set the minimum width to 10 rows\nwidget.styles.min_width = 10\n# Set the minimum width to 25% of the viewport width\nwidget.styles.min_width = \"25vw\"\n
"},{"location":"styles/min_width/#see-also","title":"See also","text":"max-width
to set an upper bound on the width of a widget.width
to set the width of a widget.The offset
style defines an offset for the position of the widget.
\noffset: <scalar> <scalar>;\n\noffset-x: <scalar>;\noffset-y: <scalar>\n
The two <scalar>
in the offset
define, respectively, the offsets in the horizontal and vertical axes for the widget.
To specify an offset along a single axis, you can use offset-x
and offset-y
.
In this example, we have 3 widgets with differing offsets.
Outputoffset.pyoffset.tcssOffsetApp \u258c\u2590 \u258cChani\u00a0(offset\u00a00\u00a0\u2590 \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c\u258c-3)\u2590 \u258c\u2590\u258c\u2590 \u258c\u2590\u258c\u2590 \u258c\u2590\u258c\u2590 \u258cPaul\u00a0(offset\u00a08\u00a02)\u2590\u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u258c\u2590 \u258c\u2590 \u258c\u2590 \u258c\u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u258c\u2590 \u258c\u2590 \u258c\u2590 \u258cDuncan\u00a0(offset\u00a04\u00a0\u2590 \u258c10)\u2590 \u258c\u2590 \u258c\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f
from textual.app import App\nfrom textual.widgets import Label\nclass OffsetApp(App):\ndef compose(self):\nyield Label(\"Paul (offset 8 2)\", classes=\"paul\")\nyield Label(\"Duncan (offset 4 10)\", classes=\"duncan\")\nyield Label(\"Chani (offset 0 -3)\", classes=\"chani\")\napp = OffsetApp(css_path=\"offset.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\nlayout: horizontal;\n}\nLabel {\nwidth: 20;\nheight: 10;\ncontent-align: center middle;\n}\n.paul {\noffset: 8 2;\nbackground: red 20%;\nborder: outer red;\ncolor: red;\n}\n.duncan {\noffset: 4 10;\nbackground: green 20%;\nborder: outer green;\ncolor: green;\n}\n.chani {\noffset: 0 -3;\nbackground: blue 20%;\nborder: outer blue;\ncolor: blue;\n}\n
"},{"location":"styles/offset/#css","title":"CSS","text":"/* Move the widget 8 cells in the x direction and 2 in the y direction */\noffset: 8 2;\n/* Move the widget 4 cells in the x direction\noffset-x: 4;\n/* Move the widget -3 cells in the y direction\noffset-y: -3;\n
"},{"location":"styles/offset/#python","title":"Python","text":"You cannot change programmatically the offset for a single axis. You have to set the two axes at the same time.
# Move the widget 2 cells in the x direction, and 4 in the y direction.\nwidget.styles.offset = (2, 4)\n
"},{"location":"styles/offset/#see-also","title":"See also","text":"The opacity
style sets the opacity of a widget.
While terminals are not capable of true opacity, Textual can create an approximation by blending widgets with their background color.
"},{"location":"styles/opacity/#syntax","title":"Syntax","text":"\nopacity: <number> | <percentage>;\n
The opacity of a widget can be set as a <number>
or a <percentage>
. If given as a number, then opacity
should be a value between 0 and 1, where 0 is the background color and 1 is fully opaque. If given as a percentage, 0% is the background color and 100% is fully opaque.
Typically, if you set this value it would be somewhere between the two extremes. For instance, setting the opacity of a widget to 70%
will make it appear dimmer than surrounding widgets, which could be used to display a disabled state.
This example shows, from top to bottom, increasing opacity values for a label with a border and some text. When the opacity is zero, all we see is the (black) background.
Outputopacity.pyopacity.tcssOpacityApp \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258copacity:\u00a00%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a025%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a050%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a075%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c \u258c\u2590 \u258copacity:\u00a0100%\u2590 \u258c\u2590 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f
from textual.app import App\nfrom textual.widgets import Label\nclass OpacityApp(App):\ndef compose(self):\nyield Label(\"opacity: 0%\", id=\"zero-opacity\")\nyield Label(\"opacity: 25%\", id=\"quarter-opacity\")\nyield Label(\"opacity: 50%\", id=\"half-opacity\")\nyield Label(\"opacity: 75%\", id=\"three-quarter-opacity\")\nyield Label(\"opacity: 100%\", id=\"full-opacity\")\napp = OpacityApp(css_path=\"opacity.tcss\")\n
#zero-opacity {\nopacity: 0%;\n}\n#quarter-opacity {\nopacity: 25%;\n}\n#half-opacity {\nopacity: 50%;\n}\n#three-quarter-opacity {\nopacity: 75%;\n}\n#full-opacity {\nopacity: 100%;\n}\nScreen {\nbackground: black;\n}\nLabel {\nwidth: 100%;\nheight: 1fr;\nborder: outer dodgerblue;\nbackground: lightseagreen;\ncontent-align: center middle;\ntext-style: bold;\n}\n
"},{"location":"styles/opacity/#css","title":"CSS","text":"/* Fade the widget to 50% against its parent's background */\nopacity: 50%;\n
"},{"location":"styles/opacity/#python","title":"Python","text":"# Fade the widget to 50% against its parent's background\nwidget.styles.opacity = \"50%\"\n
"},{"location":"styles/opacity/#see-also","title":"See also","text":"text-opacity
to blend the color of a widget's content with its background color.The outline
style enables the drawing of a box around the content of a widget, which means the outline is drawn over the content area.
Note
border
and outline
cannot coexist in the same edge of a widget.
\noutline: [<border>] [<color>];\n\noutline-top: [<border>] [<color>];\noutline-right: [<border>] [<color>];\noutline-bottom: [<border>] [<color>];\noutline-left: [<border>] [<color>];\n
The style outline
accepts an optional <border>
that sets the visual style of the widget outline and an optional <color>
to set the color of the outline.
Unlike the style border
, the frame of the outline is drawn over the content area of the widget. This rule can be useful to add temporary emphasis on the content of a widget, if you want to draw the user's attention to it.
The textual
CLI has a subcommand which will let you explore the various border types interactively, when applied to the CSS rule border
:
textual borders\n
"},{"location":"styles/outline/#examples","title":"Examples","text":""},{"location":"styles/outline/#basic-usage","title":"Basic usage","text":"This example shows a widget with an outline. Note how the outline occludes the text area.
Outputoutline.pyoutline.tcssOutlineApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u258a \u258e\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258e\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258a \u258end\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u258a \u258eath.\u258a \u258ehere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = OutlineApp(css_path=\"outline.tcss\")\n
Screen {\nbackground: white;\ncolor: black;\n}\nLabel {\nmargin: 4 8;\nbackground: green 20%;\noutline: wide green;\nwidth: 100%;\n}\n
"},{"location":"styles/outline/#all-outline-types","title":"All outline types","text":"The next example shows a grid with all the available outline types.
Outputoutline_all.pyoutline_all.tcssAllOutlinesApp +------------------+\u250f\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u2513 |ascii|blank\u254fdashed\u254f +------------------+\u2517\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u251b \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2551double\u2551\u2503heavy\u2503hidden/none \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2597\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2596 hkey\u2590inner\u258cnone \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u259d\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2598 \u259b\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u2580\u259c\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u258couter\u2590\u2502round\u2502\u2502solid\u2502 \u2599\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u259f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258f\u2595\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258atall\u258e\u258fvkey\u2595\u258ewide\u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258f\u2595\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass AllOutlinesApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"ascii\", id=\"ascii\"),\nLabel(\"blank\", id=\"blank\"),\nLabel(\"dashed\", id=\"dashed\"),\nLabel(\"double\", id=\"double\"),\nLabel(\"heavy\", id=\"heavy\"),\nLabel(\"hidden/none\", id=\"hidden\"),\nLabel(\"hkey\", id=\"hkey\"),\nLabel(\"inner\", id=\"inner\"),\nLabel(\"none\", id=\"none\"),\nLabel(\"outer\", id=\"outer\"),\nLabel(\"round\", id=\"round\"),\nLabel(\"solid\", id=\"solid\"),\nLabel(\"tall\", id=\"tall\"),\nLabel(\"vkey\", id=\"vkey\"),\nLabel(\"wide\", id=\"wide\"),\n)\napp = AllOutlinesApp(css_path=\"outline_all.tcss\")\n
#ascii {\noutline: ascii $accent;\n}\n#blank {\noutline: blank $accent;\n}\n#dashed {\noutline: dashed $accent;\n}\n#double {\noutline: double $accent;\n}\n#heavy {\noutline: heavy $accent;\n}\n#hidden {\noutline: hidden $accent;\n}\n#hkey {\noutline: hkey $accent;\n}\n#inner {\noutline: inner $accent;\n}\n#none {\noutline: none $accent;\n}\n#outer {\noutline: outer $accent;\n}\n#round {\noutline: round $accent;\n}\n#solid {\noutline: solid $accent;\n}\n#tall {\noutline: tall $accent;\n}\n#vkey {\noutline: vkey $accent;\n}\n#wide {\noutline: wide $accent;\n}\nGrid {\ngrid-size: 3 5;\nalign: center middle;\ngrid-gutter: 1 2;\n}\nLabel {\nwidth: 20;\nheight: 3;\ncontent-align: center middle;\n}\n
"},{"location":"styles/outline/#borders-and-outlines","title":"Borders and outlines","text":"The next example makes the difference between border
and outline
clearer by having three labels side-by-side. They contain the same text, have the same width and height, and are styled exactly the same up to their border
and outline
styles.
This example also shows that a widget cannot contain both a border
and an outline
:
OutlineBorderApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502ear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502ear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502nd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path\u2502 \u2502here\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503I\u00a0must\u00a0not\u00a0fear.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0mind-killer.\u2503 \u2503Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2503 \u2503I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2503 \u2503I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2503 \u2503And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502I\u00a0must\u00a0not\u00a0fear.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0mind-killer.\u2502 \u2502Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2502 \u2502I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2502 \u2502I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u2502 \u2502And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path.\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OutlineBorderApp(App):\ndef compose(self):\nyield Label(TEXT, classes=\"outline\")\nyield Label(TEXT, classes=\"border\")\nyield Label(TEXT, classes=\"outline border\")\napp = OutlineBorderApp(css_path=\"outline_vs_border.tcss\")\n
Label {\nheight: 8;\n}\n.outline {\noutline: $error round;\n}\n.border {\nborder: $success heavy;\n}\n
"},{"location":"styles/outline/#css","title":"CSS","text":"/* Set a heavy white outline */\noutline:heavy white;\n/* set a red outline on the left */\noutline-left:outer red;\n
"},{"location":"styles/outline/#python","title":"Python","text":"# Set a heavy white outline\nwidget.outline = (\"heavy\", \"white\")\n# Set a red outline on the left\nwidget.outline_left = (\"outer\", \"red\")\n
"},{"location":"styles/outline/#see-also","title":"See also","text":"border
to add a border around a widget.The overflow
style specifies if and when scrollbars should be displayed.
\noverflow: <overflow> <overflow>;\n\noverflow-x: <overflow>;\noverflow-y: <overflow>;\n
The style overflow
accepts two values that determine when to display scrollbars in a container widget. The two values set the overflow for the horizontal and vertical axes, respectively.
Overflow may also be set individually for each axis:
overflow-x
sets the overflow for the horizontal axis; andoverflow-y
sets the overflow for the vertical axis.The default setting for containers is overflow: auto auto
.
Warning
Some built-in containers like Horizontal
and VerticalScroll
override these defaults.
Here we split the screen into left and right sections, each with three vertically scrolling widgets that do not fit into the height of the terminal.
The left side has overflow-y: auto
(the default) and will automatically show a scrollbar. The right side has overflow-y: hidden
which will prevent a scrollbar from being shown.
OverflowApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258eI\u00a0must\u00a0not\u00a0fear.\u258a\u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a\u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a\u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a \u258ebrings\u00a0total\u00a0obliteration.\u258a\u258ebrings\u00a0total\u00a0obliteration.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a\u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u258a\u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0\u258a \u258eand\u00a0through\u00a0me.\u258a\u258eand\u00a0through\u00a0me.\u258a \u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0\u258a\u258eAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0\u258a \u258ewill\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0\u258a\u258eturn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0\u258a \u258eits\u00a0path.\u258a\u2581\u2581\u258epath.\u258a \u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0\u258a\u258eWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u258a \u258ewill\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0\u258a\u258ebe\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u258a \u258eremain.\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258eI\u00a0must\u00a0not\u00a0fear.\u258a \u258eI\u00a0must\u00a0not\u00a0fear.\u258a\u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a \u258eFear\u00a0is\u00a0the\u00a0mind-killer.\u258a\u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a \u258eFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0\u258a\u258ebrings\u00a0total\u00a0obliteration.\u258a \u258ebrings\u00a0total\u00a0obliteration.\u258a\u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a \u258eI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258a\u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0\u258a \u258eI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u258a\u258eand\u00a0through\u00a0me.\u258a
from textual.app import App\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass OverflowApp(App):\ndef compose(self):\nyield Horizontal(\nVerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id=\"left\"),\nVerticalScroll(Static(TEXT), Static(TEXT), Static(TEXT), id=\"right\"),\n)\napp = OverflowApp(css_path=\"overflow.tcss\")\n
Screen {\nbackground: $background;\ncolor: black;\n}\nVerticalScroll {\nwidth: 1fr;\n}\nStatic {\nmargin: 1 2;\nbackground: green 80%;\nborder: green wide;\ncolor: white 90%;\nheight: auto;\n}\n#right {\noverflow-y: hidden;\n}\n
"},{"location":"styles/overflow/#css","title":"CSS","text":"/* Automatic scrollbars on both axes (the default) */\noverflow: auto auto;\n/* Hide the vertical scrollbar */\noverflow-y: hidden;\n/* Always show the horizontal scrollbar */\noverflow-x: scroll;\n
"},{"location":"styles/overflow/#python","title":"Python","text":"Overflow cannot be programmatically set for both axes at the same time.
# Hide the vertical scrollbar\nwidget.styles.overflow_y = \"hidden\"\n# Always show the horizontal scrollbar\nwidget.styles.overflow_x = \"scroll\"\n
"},{"location":"styles/padding/","title":"Padding","text":"The padding
style specifies spacing around the content of a widget.
\npadding: <integer> # one value for all edges\n | <integer> <integer>\n # top/bot left/right\n | <integer> <integer> <integer> <integer>;\n # top right bot left\n\npadding-top: <integer>;\npadding-right: <integer>;\npadding-bottom: <integer>;\npadding-left: <integer>;\n
The padding
specifies spacing around the content of a widget, thus this spacing is added inside the widget. The values of the <integer>
determine how much spacing is added and the number of values define what edges get what padding:
<integer>
sets the same padding for the four edges of the widget;<integer>
set padding for top/bottom and left/right edges, respectively.<integer>
set padding for the top, right, bottom, and left edges, respectively.Tip
To remember the order of the edges affected by the rule padding
when it has 4 values, think of a clock. Its hand starts at the top and then goes clockwise: top, right, bottom, left.
Alternatively, padding can be set for each edge individually through the rules padding-top
, padding-right
, padding-bottom
, and padding-left
, respectively.
This example adds padding around some text.
Outputpadding.pypadding.tcssPaddingApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0 path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0 remain.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass PaddingApp(App):\ndef compose(self):\nyield Label(TEXT)\napp = PaddingApp(css_path=\"padding.tcss\")\n
Screen {\nbackground: white;\ncolor: blue;\n}\nLabel {\npadding: 4 8;\nbackground: blue 20%;\nwidth: 100%;\n}\n
"},{"location":"styles/padding/#all-padding-settings","title":"All padding settings","text":"The next example shows a grid. In each cell, we have a placeholder that has its padding set in different ways. The effect of each padding setting is noticeable in the colored background around the text of each placeholder.
Outputpadding_all.pypadding_all.tcssPaddingAllApp no\u00a0padding padding:\u00a01padding:padding:\u00a01\u00a01 1\u00a052\u00a06 padding-right:\u00a03padding-bottom:\u00a04padding-left:\u00a03 padding-top:\u00a04
from textual.app import App\nfrom textual.containers import Container, Grid\nfrom textual.widgets import Placeholder\nclass PaddingAllApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(\"no padding\", id=\"p1\"),\nPlaceholder(\"padding: 1\", id=\"p2\"),\nPlaceholder(\"padding: 1 5\", id=\"p3\"),\nPlaceholder(\"padding: 1 1 2 6\", id=\"p4\"),\nPlaceholder(\"padding-top: 4\", id=\"p5\"),\nPlaceholder(\"padding-right: 3\", id=\"p6\"),\nPlaceholder(\"padding-bottom: 4\", id=\"p7\"),\nPlaceholder(\"padding-left: 3\", id=\"p8\"),\n)\napp = PaddingAllApp(css_path=\"padding_all.tcss\")\n
Screen {\nbackground: $background;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nwidth: auto;\nheight: auto;\n}\n#p1 {\n/* default is no padding */\n}\n#p2 {\npadding: 1;\n}\n#p3 {\npadding: 1 5;\n}\n#p4 {\npadding: 1 1 2 6;\n}\n#p5 {\npadding-top: 4;\n}\n#p6 {\npadding-right: 3;\n}\n#p7 {\npadding-bottom: 4;\n}\n#p8 {\npadding-left: 3;\n}\n
"},{"location":"styles/padding/#css","title":"CSS","text":"/* Set padding of 1 around all edges */\npadding: 1;\n/* Set padding of 2 on the top and bottom edges, and 4 on the left and right */\npadding: 2 4;\n/* Set padding of 1 on the top, 2 on the right,\n3 on the bottom, and 4 on the left */\npadding: 1 2 3 4;\npadding-top: 1;\npadding-right: 2;\npadding-bottom: 3;\npadding-left: 4;\n
"},{"location":"styles/padding/#python","title":"Python","text":"In Python, you cannot set any of the individual padding
styles padding-top
, padding-right
, padding-bottom
, and padding-left
.
However, you can set padding to a single integer, a tuple of 2 integers, or a tuple of 4 integers:
# Set padding of 1 around all edges\nwidget.styles.padding = 1\n# Set padding of 2 on the top and bottom edges, and 4 on the left and right\nwidget.styles.padding = (2, 4)\n# Set padding of 1 on top, 2 on the right, 3 on the bottom, and 4 on the left\nwidget.styles.padding = (1, 2, 3, 4)\n
"},{"location":"styles/padding/#see-also","title":"See also","text":"box-sizing
to specify how to account for padding in a widget's dimensions.padding
to add spacing around a widget.The scrollbar-gutter
style allows reserving space for a vertical scrollbar.
\nscrollbar-gutter: auto | stable;\n"},{"location":"styles/scrollbar_gutter/#values","title":"Values","text":"Value Description
auto
(default) No space is reserved for a vertical scrollbar. stable
Space is reserved for a vertical scrollbar. Setting the value to stable
prevents unwanted layout changes when the scrollbar becomes visible, whereas the default value of auto
means that the layout of your application is recomputed when a vertical scrollbar becomes needed.
In the example below, notice the gap reserved for the scrollbar on the right side of the terminal window.
Outputscrollbar_gutter.pyscrollbar_gutter.tcssScrollbarGutterApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
from textual.app import App\nfrom textual.widgets import Static\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass ScrollbarGutterApp(App):\ndef compose(self):\nyield Static(TEXT, id=\"text-box\")\napp = ScrollbarGutterApp(css_path=\"scrollbar_gutter.tcss\")\n
Screen {\nscrollbar-gutter: stable;\n}\n#text-box {\ncolor: floralwhite;\nbackground: darkmagenta;\n}\n
"},{"location":"styles/scrollbar_gutter/#css","title":"CSS","text":"scrollbar-gutter: auto; /* Don't reserve space for a vertical scrollbar. */\nscrollbar-gutter: stable; /* Reserve space for a vertical scrollbar. */\n
"},{"location":"styles/scrollbar_gutter/#python","title":"Python","text":"self.styles.scrollbar_gutter = \"auto\" # Don't reserve space for a vertical scrollbar.\nself.styles.scrollbar_gutter = \"stable\" # Reserve space for a vertical scrollbar.\n
"},{"location":"styles/scrollbar_size/","title":"Scrollbar-size","text":"The scrollbar-size
style defines the width of the scrollbars.
\nscrollbar-size: <integer> <integer>;\n # horizontal vertical\n\nscrollbar-size-horizontal: <integer>;\nscrollbar-size-vertical: <integer>;\n
The scrollbar-size
style takes two <integer>
to set the horizontal and vertical scrollbar sizes, respectively. This customisable size is the width of the scrollbar, given that its length will always be 100% of the container.
The scrollbar widths may also be set individually with scrollbar-size-horizontal
and scrollbar-size-vertical
.
In this example we modify the size of the widget's scrollbar to be much larger than usual.
Outputscrollbar_size.pyscrollbar_size.tcssScrollbarApp I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.\u2581\u2581\u2581\u2581 I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.
from textual.app import App\nfrom textual.containers import ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield ScrollableContainer(Label(TEXT * 5), classes=\"panel\")\napp = ScrollbarApp(css_path=\"scrollbar_size.tcss\")\n
Screen {\nbackground: white;\ncolor: blue 80%;\nlayout: horizontal;\n}\nLabel {\npadding: 1 2;\nwidth: 200;\n}\n.panel {\nscrollbar-size: 10 4;\npadding: 1 2;\n}\n
"},{"location":"styles/scrollbar_size/#scrollbar-sizes-comparison","title":"Scrollbar sizes comparison","text":"In the next example we show three containers with differently sized scrollbars.
Tip
If you want to hide the scrollbar but still allow the container to scroll using the mousewheel or keyboard, you can set the scrollbar size to 0
.
ScrollbarApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0pastAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0tWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0thWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0t I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2587I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.\u2587\u2587 I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0pastAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0tWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0thWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0t I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.\u2582I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0 I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0oI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 \u258fAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u258f \u258fWhere\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0th\u258f \u258fI\u00a0must\u00a0not\u00a0fear.\u258f \u258fFear\u00a0is\u00a0the\u00a0mind-killer.\u258f \u258f\u2589\u258f
from textual.app import App\nfrom textual.containers import Horizontal, ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield Horizontal(\nScrollableContainer(Label(TEXT * 5), id=\"v1\"),\nScrollableContainer(Label(TEXT * 5), id=\"v2\"),\nScrollableContainer(Label(TEXT * 5), id=\"v3\"),\n)\napp = ScrollbarApp(css_path=\"scrollbar_size2.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
ScrollableContainer {\nwidth: 1fr;\n}\n#v1 {\nscrollbar-size: 5 1;\nbackground: red 20%;\n}\n#v2 {\nscrollbar-size-vertical: 1;\nbackground: green 20%;\n}\n#v3 {\nscrollbar-size-horizontal: 5;\nbackground: blue 20%;\n}\n
"},{"location":"styles/scrollbar_size/#css","title":"CSS","text":"/* Set horizontal scrollbar to 10, and vertical scrollbar to 4 */\nscrollbar-size: 10 4;\n/* Set horizontal scrollbar to 10 */\nscrollbar-size-horizontal: 10;\n/* Set vertical scrollbar to 4 */\nscrollbar-size-vertical: 4;\n
"},{"location":"styles/scrollbar_size/#python","title":"Python","text":"The style scrollbar-size
has no Python equivalent. The scrollbar sizes must be set independently:
# Set horizontal scrollbar to 10:\nwidget.styles.scrollbar_size_horizontal = 10\n# Set vertical scrollbar to 4:\nwidget.styles.scrollbar_size_vertical = 4\n
"},{"location":"styles/text_align/","title":"Text-align","text":"The text-align
style sets the text alignment in a widget.
\ntext-align: <text-align>;\n
The text-align
style accepts a value of the type <text-align>
that defines how text is aligned inside the widget.
The default value is start
.
This example shows, from top to bottom: left
, center
, right
, and justify
text alignments.
TextAlign Left\u00a0alignedCenter\u00a0aligned I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0 mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 little-death\u00a0that\u00a0brings\u00a0total\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u00a0\u00a0 obliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0Iobliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0I will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0\u00a0\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0 through\u00a0me.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0through\u00a0me.\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 Right\u00a0alignedJustified \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0theI\u00a0\u00a0must\u00a0\u00a0not\u00a0\u00a0fear.\u00a0\u00a0Fear\u00a0\u00a0\u00a0is\u00a0\u00a0\u00a0the \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0themind-killer.\u00a0\u00a0\u00a0\u00a0\u00a0Fear\u00a0\u00a0\u00a0\u00a0\u00a0is\u00a0\u00a0\u00a0\u00a0\u00a0the \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0little-death\u00a0that\u00a0brings\u00a0totallittle-death\u00a0\u00a0\u00a0that\u00a0\u00a0\u00a0brings\u00a0\u00a0\u00a0total obliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0Iobliteration.\u00a0I\u00a0will\u00a0face\u00a0my\u00a0fear.\u00a0I \u00a0\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0andwill\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0\u00a0me\u00a0\u00a0and \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0through\u00a0me.through\u00a0me.
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nTEXT = (\n\"I must not fear. Fear is the mind-killer. Fear is the little-death that \"\n\"brings total obliteration. I will face my fear. I will permit it to pass over \"\n\"me and through me.\"\n)\nclass TextAlign(App):\ndef compose(self):\nyield Grid(\nLabel(\"[b]Left aligned[/]\\n\" + TEXT, id=\"one\"),\nLabel(\"[b]Center aligned[/]\\n\" + TEXT, id=\"two\"),\nLabel(\"[b]Right aligned[/]\\n\" + TEXT, id=\"three\"),\nLabel(\"[b]Justified[/]\\n\" + TEXT, id=\"four\"),\n)\napp = TextAlign(css_path=\"text_align.tcss\")\n
#one {\ntext-align: left;\nbackground: lightblue;\n}\n#two {\ntext-align: center;\nbackground: indianred;\n}\n#three {\ntext-align: right;\nbackground: palegreen;\n}\n#four {\ntext-align: justify;\nbackground: palevioletred;\n}\nLabel {\npadding: 1 2;\nheight: 100%;\ncolor: auto;\n}\nGrid {\ngrid-size: 2 2;\n}\n
"},{"location":"styles/text_align/#css","title":"CSS","text":"/* Set text in the widget to be right aligned */\ntext-align: right;\n
"},{"location":"styles/text_align/#python","title":"Python","text":"# Set text in the widget to be right aligned\nwidget.styles.text_align = \"right\"\n
"},{"location":"styles/text_align/#see-also","title":"See also","text":"align
to set the alignment of children widgets inside a container.content-align
to set the alignment of content inside a widget.The text-opacity
style blends the foreground color (i.e. text) with the background color.
\ntext-opacity: <number> | <percentage>;\n
The text opacity of a widget can be set as a <number>
or a <percentage>
. If given as a number, then text-opacity
should be a value between 0 and 1, where 0 makes the foreground color match the background (effectively making text invisible) and 1 will display text as normal. If given as a percentage, 0% will result in invisible text, and 100% will display fully opaque text.
Typically, if you set this value it would be somewhere between the two extremes. For instance, setting text-opacity
to 70%
would result in slightly faded text. Setting it to 0.3
would result in very dim text.
Warning
Be careful not to set text opacity so low as to make it hard to read.
"},{"location":"styles/text_opacity/#example","title":"Example","text":"This example shows, from top to bottom, increasing text-opacity
values.
TextOpacityApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a025%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a050%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a075%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0text-opacity:\u00a0100%\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0
from textual.app import App\nfrom textual.widgets import Label\nclass TextOpacityApp(App):\ndef compose(self):\nyield Label(\"text-opacity: 0%\", id=\"zero-opacity\")\nyield Label(\"text-opacity: 25%\", id=\"quarter-opacity\")\nyield Label(\"text-opacity: 50%\", id=\"half-opacity\")\nyield Label(\"text-opacity: 75%\", id=\"three-quarter-opacity\")\nyield Label(\"text-opacity: 100%\", id=\"full-opacity\")\napp = TextOpacityApp(css_path=\"text_opacity.tcss\")\n
#zero-opacity {\ntext-opacity: 0%;\n}\n#quarter-opacity {\ntext-opacity: 25%;\n}\n#half-opacity {\ntext-opacity: 50%;\n}\n#three-quarter-opacity {\ntext-opacity: 75%;\n}\n#full-opacity {\ntext-opacity: 100%;\n}\nLabel {\nheight: 1fr;\nwidth: 100%;\ntext-align: center;\ntext-style: bold;\n}\n
"},{"location":"styles/text_opacity/#css","title":"CSS","text":"/* Set the text to be \"half-faded\" against the background of the widget */\ntext-opacity: 50%;\n
"},{"location":"styles/text_opacity/#python","title":"Python","text":"# Set the text to be \"half-faded\" against the background of the widget\nwidget.styles.text_opacity = \"50%\"\n
"},{"location":"styles/text_opacity/#see-also","title":"See also","text":"opacity
to specify the opacity of a whole widget.The text-style
style sets the style for the text in a widget.
\ntext-style: <text-style>;\n
text-style
will take all the values specified and will apply that styling combination to the text in the widget.
Each of the three text panels has a different text style, respectively bold
, italic
, and reverse
, from left to right.
TextStyleApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0 that\u00a0brings\u00a0total\u00a0that\u00a0brings\u00a0total\u00a0that\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0 over\u00a0me\u00a0and\u00a0through\u00a0me.over\u00a0me\u00a0and\u00a0through\u00a0me.over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0 I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0 to\u00a0see\u00a0its\u00a0path.to\u00a0see\u00a0its\u00a0path.to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0 there\u00a0will\u00a0be\u00a0nothing.\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Onlythere\u00a0will\u00a0be\u00a0nothing.\u00a0Only Only\u00a0I\u00a0will\u00a0remain.I\u00a0will\u00a0remain.I\u00a0will\u00a0remain.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass TextStyleApp(App):\ndef compose(self):\nyield Label(TEXT, id=\"lbl1\")\nyield Label(TEXT, id=\"lbl2\")\nyield Label(TEXT, id=\"lbl3\")\napp = TextStyleApp(css_path=\"text_style.tcss\")\n
Screen {\nlayout: horizontal;\n}\nLabel {\nwidth: 1fr;\n}\n#lbl1 {\nbackground: red 30%;\ntext-style: bold;\n}\n#lbl2 {\nbackground: green 30%;\ntext-style: italic;\n}\n#lbl3 {\nbackground: blue 30%;\ntext-style: reverse;\n}\n
"},{"location":"styles/text_style/#all-text-styles","title":"All text styles","text":"The next example shows all different text styles on their own, as well as some combinations of styles in a single widget.
Outputtext_style_all.pytext_style_all.tcssAllTextStyleApp nonebolditalicreverse I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 mind-killer.mind-killer.mind-killer.mind-killer. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 little-death\u00a0thatlittle-death\u00a0that\u00a0little-death\u00a0thatlittle-death\u00a0that\u00a0 brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0 fear.fear.fear.fear. strikeunderlinebold\u00a0italicreverse\u00a0strike I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 mind-killer.mind-killer.mind-killer.mind-killer. Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0Fear\u00a0is\u00a0the\u00a0 little-death\u00a0thatlittle-death\u00a0that\u00a0little-death\u00a0thatlittle-death\u00a0that\u00a0 brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0brings\u00a0total\u00a0 obliteration.obliteration.obliteration.obliteration. I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0I\u00a0will\u00a0face\u00a0my\u00a0 fear.fear.fear.fear. I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0I\u00a0will\u00a0permit\u00a0it\u00a0
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass AllTextStyleApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"none\\n\" + TEXT, id=\"lbl1\"),\nLabel(\"bold\\n\" + TEXT, id=\"lbl2\"),\nLabel(\"italic\\n\" + TEXT, id=\"lbl3\"),\nLabel(\"reverse\\n\" + TEXT, id=\"lbl4\"),\nLabel(\"strike\\n\" + TEXT, id=\"lbl5\"),\nLabel(\"underline\\n\" + TEXT, id=\"lbl6\"),\nLabel(\"bold italic\\n\" + TEXT, id=\"lbl7\"),\nLabel(\"reverse strike\\n\" + TEXT, id=\"lbl8\"),\n)\napp = AllTextStyleApp(css_path=\"text_style_all.tcss\")\n
#lbl1 {\ntext-style: none;\n}\n#lbl2 {\ntext-style: bold;\n}\n#lbl3 {\ntext-style: italic;\n}\n#lbl4 {\ntext-style: reverse;\n}\n#lbl5 {\ntext-style: strike;\n}\n#lbl6 {\ntext-style: underline;\n}\n#lbl7 {\ntext-style: bold italic;\n}\n#lbl8 {\ntext-style: reverse strike;\n}\nGrid {\ngrid-size: 4;\ngrid-gutter: 1 2;\nmargin: 1 2;\nheight: 100%;\n}\nLabel {\nheight: 100%;\n}\n
"},{"location":"styles/text_style/#css","title":"CSS","text":"text-style: italic;\n
"},{"location":"styles/text_style/#python","title":"Python","text":"widget.styles.text_style = \"italic\"\n
"},{"location":"styles/tint/","title":"Tint","text":"The tint
style blends a color with the whole widget.
\ntint: <color> [<percentage>];\n
The tint style blends a <color>
with the widget. The color should likely have an alpha component (specified directly in the color used or by the optional <percentage>
), otherwise the end result will obscure the widget content.
This examples shows a green tint with gradually increasing alpha.
Outputtint.pytint.tcssTintApp tint:\u00a0green\u00a00%; tint:\u00a0green\u00a010%; tint:\u00a0green\u00a020%; tint:\u00a0green\u00a030%; tint:\u00a0green\u00a040%; tint:\u00a0green\u00a050%; \u2584\u2584 tint:\u00a0green\u00a060%; tint:\u00a0green\u00a070%;
from textual.app import App\nfrom textual.color import Color\nfrom textual.widgets import Label\nclass TintApp(App):\ndef compose(self):\ncolor = Color.parse(\"green\")\nfor tint_alpha in range(0, 101, 10):\nwidget = Label(f\"tint: green {tint_alpha}%;\")\nwidget.styles.tint = color.with_alpha(tint_alpha / 100) # (1)!\nyield widget\napp = TintApp(css_path=\"tint.tcss\")\n
Color
instance with varying levels of opacity, set through the method with_alpha.Label {\nheight: 3;\nwidth: 100%;\ntext-style: bold;\nbackground: white;\ncolor: black;\ncontent-align: center middle;\n}\n
"},{"location":"styles/tint/#css","title":"CSS","text":"/* A red tint (could indicate an error) */\ntint: red 20%;\n/* A green tint */\ntint: rgba(0, 200, 0, 0.3);\n
"},{"location":"styles/tint/#python","title":"Python","text":"# A red tint\nfrom textual.color import Color\nwidget.styles.tint = Color.parse(\"red\").with_alpha(0.2);\n# A green tint\nwidget.styles.tint = \"rgba(0, 200, 0, 0.3)\"\n
"},{"location":"styles/visibility/","title":"Visibility","text":"The visibility
style determines whether a widget is visible or not.
\nvisibility: hidden | visible;\n
visibility
takes one of two values to set the visibility of a widget.
hidden
The widget will be invisible. visible
(default) The widget will be displayed as normal."},{"location":"styles/visibility/#visibility-inheritance","title":"Visibility inheritance","text":"Note
Children of an invisible container can be visible.
By default, children inherit the visibility of their parents. So, if a container is set to be invisible, its children widgets will also be invisible by default. However, those widgets can be made visible if their visibility is explicitly set to visibility: visible
. This is shown in the second example below.
Note that the second widget is hidden while leaving a space where it would have been rendered.
Outputvisibility.pyvisibility.tcssVisibilityApp \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a01\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503Widget\u00a03\u2503 \u2503\u2503 \u2503\u2503 \u2517\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u251b
from textual.app import App\nfrom textual.widgets import Label\nclass VisibilityApp(App):\ndef compose(self):\nyield Label(\"Widget 1\")\nyield Label(\"Widget 2\", classes=\"invisible\")\nyield Label(\"Widget 3\")\napp = VisibilityApp(css_path=\"visibility.tcss\")\n
Screen {\nbackground: green;\n}\nLabel {\nheight: 5;\nwidth: 100%;\nbackground: white;\ncolor: blue;\nborder: heavy blue;\n}\nLabel.invisible {\nvisibility: hidden;\n}\n
"},{"location":"styles/visibility/#overriding-container-visibility","title":"Overriding container visibility","text":"The next example shows the interaction of the visibility
style with invisible containers that have visible children. The app below has three rows with a Horizontal
container per row and three placeholders per row. The containers all have a white background, and then:
VisibilityContainersApp PlaceholderPlaceholderPlaceholder PlaceholderPlaceholderPlaceholder
from textual.app import App\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass VisibilityContainersApp(App):\ndef compose(self):\nyield VerticalScroll(\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"top\",\n),\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"middle\",\n),\nHorizontal(\nPlaceholder(),\nPlaceholder(),\nPlaceholder(),\nid=\"bot\",\n),\n)\napp = VisibilityContainersApp(css_path=\"visibility_containers.tcss\")\n
Horizontal {\npadding: 1 2; /* (1)! */\nbackground: white;\nheight: 1fr;\n}\n#top {} /* (2)! */\n#middle { /* (3)! */\nvisibility: hidden;\n}\n#bot { /* (4)! */\nvisibility: hidden;\n}\n#bot > Placeholder { /* (5)! */\nvisibility: visible;\n}\nPlaceholder {\nwidth: 1fr;\n}\n
Horizontal
is visible.Horizontal
is visible by default, and so are its children.Horizontal
is made invisible and its children will inherit that setting.Horizontal
is made invisible.../* Widget is invisible */\nvisibility: hidden;\n/* Widget is visible */\nvisibility: visible;\n
"},{"location":"styles/visibility/#python","title":"Python","text":"# Widget is invisible\nself.styles.visibility = \"hidden\"\n# Widget is visible\nself.styles.visibility = \"visible\"\n
There is also a shortcut to set a Widget's visibility. The visible
property on Widget
may be set to True
or False
.
# Make a widget invisible\nwidget.visible = False\n# Make the widget visible again\nwidget.visible = True\n
"},{"location":"styles/visibility/#see-also","title":"See also","text":"display
to specify whether a widget is displayed or not.The width
style sets a widget's width.
\nwidth: <scalar>;\n
The style width
needs a <scalar>
to determine the horizontal length of the width. By default, it sets the width of the content area, but if box-sizing
is set to border-box
it sets the width of the border area.
This example adds a widget with 50% width of the screen.
Outputwidth.pywidth.tcssWidthApp Widget
from textual.app import App\nfrom textual.widget import Widget\nclass WidthApp(App):\ndef compose(self):\nyield Widget()\napp = WidthApp(css_path=\"width.tcss\")\n
Screen > Widget {\nbackground: green;\nwidth: 50%;\ncolor: white;\n}\n
"},{"location":"styles/width/#all-width-formats","title":"All width formats","text":"Outputwidth_comparison.pywidth_comparison.tcss WidthComparisonApp #cells#percent#w#h#vw#vh#auto#fr1#fr3 \u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022\u00b7\u00b7\u00b7\u00b7\u2022
from textual.app import App\nfrom textual.containers import Horizontal\nfrom textual.widgets import Label, Placeholder, Static\nclass Ruler(Static):\ndef compose(self):\nruler_text = \"\u00b7\u00b7\u00b7\u00b7\u2022\" * 100\nyield Label(ruler_text)\nclass WidthComparisonApp(App):\ndef compose(self):\nyield Horizontal(\nPlaceholder(id=\"cells\"), # (1)!\nPlaceholder(id=\"percent\"),\nPlaceholder(id=\"w\"),\nPlaceholder(id=\"h\"),\nPlaceholder(id=\"vw\"),\nPlaceholder(id=\"vh\"),\nPlaceholder(id=\"auto\"),\nPlaceholder(id=\"fr1\"),\nPlaceholder(id=\"fr3\"),\n)\nyield Ruler()\napp = WidthComparisonApp(css_path=\"width_comparison.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
#cells {\nwidth: 9; /* (1)! */\n}\n#percent {\nwidth: 12.5%; /* (2)! */\n}\n#w {\nwidth: 10w; /* (3)! */\n}\n#h {\nwidth: 25h; /* (4)! */\n}\n#vw {\nwidth: 15vw; /* (5)! */\n}\n#vh {\nwidth: 25vh; /* (6)! */\n}\n#auto {\nwidth: auto; /* (7)! */\n}\n#fr1 {\nwidth: 1fr; /* (8)! */\n}\n#fr3 {\nwidth: 3fr; /* (9)! */\n}\nScreen {\nlayers: ruler;\n}\nRuler {\nlayer: ruler;\ndock: bottom;\noverflow: hidden;\nheight: 1;\nbackground: $accent;\n}\n
Horizontal
container. Because it expands to fit all of the terminal, the width of the Horizontal
is 80 and 10% of 80 is 8.Horizontal
container. Because it expands to fit all of the terminal, the height of the Horizontal
is 24 and 25% of 24 is 6.\"#auto\"
, the placeholder has its width set to 5.1fr
, which means this placeholder will have a third of the width of a placeholder with 3fr
.3fr
, which means this placeholder will have triple the width of a placeholder with 1fr
./* Explicit cell width */\nwidth: 10;\n/* Percentage width */\nwidth: 50%;\n/* Automatic width */\nwidth: auto;\n
"},{"location":"styles/width/#python","title":"Python","text":"widget.styles.width = 10\nwidget.styles.width = \"50%\nwidget.styles.width = \"auto\"\n
"},{"location":"styles/width/#see-also","title":"See also","text":"max-width
and min-width
to limit the width of a widget.height
to set the height of a widget.There are a number of styles relating to the Textual grid
layout.
For an in-depth look at the grid layout, visit the grid guide.
Property Descriptioncolumn-span
Number of columns a cell spans. grid-columns
Width of grid columns. grid-gutter
Spacing between grid cells. grid-rows
Height of grid rows. grid-size
Number of columns and rows in the grid layout. row-span
Number of rows a cell spans."},{"location":"styles/grid/#syntax","title":"Syntax","text":"\ncolumn-span: <integer>;\n\ngrid-columns: <scalar>+;\n\ngrid-gutter: <scalar> [<scalar>];\n\ngrid-rows: <scalar>+;\n\ngrid-size: <integer> [<integer>];\n\nrow-span: <integer>;\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/grid/#example","title":"Example","text":"The example below shows all the styles above in action. The grid-size: 3 4;
declaration sets the grid to 3 columns and 4 rows. The first cell of the grid, tinted magenta, shows a cell spanning multiple rows and columns. The spacing between grid cells is defined by the grid-gutter
style.
GridApp Grid\u00a0cell\u00a01Grid\u00a0cell\u00a02 row-span:\u00a03; column-span:\u00a02; Grid\u00a0cell\u00a03 Grid\u00a0cell\u00a04 Grid\u00a0cell\u00a05Grid\u00a0cell\u00a06Grid\u00a0cell\u00a07
from textual.app import App\nfrom textual.widgets import Static\nclass GridApp(App):\ndef compose(self):\nyield Static(\"Grid cell 1\\n\\nrow-span: 3;\\ncolumn-span: 2;\", id=\"static1\")\nyield Static(\"Grid cell 2\", id=\"static2\")\nyield Static(\"Grid cell 3\", id=\"static3\")\nyield Static(\"Grid cell 4\", id=\"static4\")\nyield Static(\"Grid cell 5\", id=\"static5\")\nyield Static(\"Grid cell 6\", id=\"static6\")\nyield Static(\"Grid cell 7\", id=\"static7\")\napp = GridApp(css_path=\"grid.tcss\")\n
Screen {\nlayout: grid;\ngrid-size: 3 4;\ngrid-rows: 1fr;\ngrid-columns: 1fr;\ngrid-gutter: 1;\n}\nStatic {\ncolor: auto;\nbackground: lightblue;\nheight: 100%;\npadding: 1 2;\n}\n#static1 {\ntint: magenta 40%;\nrow-span: 3;\ncolumn-span: 2;\n}\n
Warning
The styles listed on this page will only work when the layout is grid
.
The column-span
style specifies how many columns a widget will span in a grid layout.
Note
This style only affects widgets that are direct children of a widget with layout: grid
.
\ncolumn-span: <integer>;\n
The column-span
style accepts a single non-negative <integer>
that quantifies how many columns the given widget spans.
The example below shows a 4 by 4 grid where many placeholders span over several columns.
Outputcolumn_span.pycolumn_span.tcssMyApp #p1 #p2#p3 #p4#p5 #p6#p7
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Placeholder\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(id=\"p1\"),\nPlaceholder(id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(id=\"p6\"),\nPlaceholder(id=\"p7\"),\n)\napp = MyApp(css_path=\"column_span.tcss\")\n
#p1 {\ncolumn-span: 4;\n}\n#p2 {\ncolumn-span: 3;\n}\n#p3 {\ncolumn-span: 1; /* Didn't need to be set explicitly. */\n}\n#p4 {\ncolumn-span: 2;\n}\n#p5 {\ncolumn-span: 2;\n}\n#p6 {\n/* Default value is 1. */\n}\n#p7 {\ncolumn-span: 3;\n}\nGrid {\ngrid-size: 4 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nheight: 100%;\n}\n
"},{"location":"styles/grid/column_span/#css","title":"CSS","text":"column-span: 3;\n
"},{"location":"styles/grid/column_span/#python","title":"Python","text":"widget.styles.column_span = 3\n
"},{"location":"styles/grid/column_span/#see-also","title":"See also","text":"row-span
to specify how many rows a widget spans.The grid-columns
style allows to define the width of the columns of the grid.
Note
This style only affects widgets with layout: grid
.
\ngrid-columns: <scalar>+;\n
The grid-columns
style takes one or more <scalar>
that specify the length of the columns of the grid.
If there are more columns in the grid than scalars specified in grid-columns
, they are reused cyclically. If the number of <scalar>
is in excess, the excess is ignored.
The example below shows a grid with 10 labels laid out in a grid with 2 rows and 5 columns.
We set grid-columns: 1fr 16 2fr
. Because there are more rows than scalars in the style definition, the scalars will be reused:
1fr
;16
; and2fr
.MyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u2502width\u00a0=\u00a016\u2502\u25022fr\u2502\u25021fr\u2502\u2502width\u00a0=\u00a016\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u2502width\u00a0=\u00a016\u2502\u25022fr\u2502\u25021fr\u2502\u2502width\u00a0=\u00a016\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"2fr\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\nLabel(\"2fr\"),\nLabel(\"1fr\"),\nLabel(\"width = 16\"),\n)\napp = MyApp(css_path=\"grid_columns.tcss\")\n
Grid {\ngrid-size: 5 2;\ngrid-columns: 1fr 16 2fr;\n}\nLabel {\nborder: round white;\ncontent-align-horizontal: center;\nwidth: 100%;\nheight: 100%;\n}\n
"},{"location":"styles/grid/grid_columns/#css","title":"CSS","text":"/* Set all columns to have 50% width */\ngrid-columns: 50%;\n/* Every other column is twice as wide as the first one */\ngrid-columns: 1fr 2fr;\n
"},{"location":"styles/grid/grid_columns/#python","title":"Python","text":"grid.styles.grid_columns = \"50%\"\ngrid.styles.grid_columns = \"1fr 2fr\"\n
"},{"location":"styles/grid/grid_columns/#see-also","title":"See also","text":"grid-rows
to specify the height of the grid rows.The grid-gutter
style sets the size of the gutter in the grid layout. That is, it sets the space between adjacent cells in the grid.
Gutter is only applied between the edges of cells. No spacing is added between the edges of the cells and the edges of the container.
Note
This style only affects widgets with layout: grid
.
\ngrid-gutter: <integer> [<integer>];\n
The grid-gutter
style takes one or two <integer>
that set the length of the gutter along the vertical and horizontal axes. If only one <integer>
is supplied, it sets the vertical and horizontal gutters. If two are supplied, they set the vertical and horizontal gutters, respectively.
The example below employs a common trick to apply visually consistent spacing around all grid cells.
Outputgrid_gutter.pygrid_gutter.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25025\u2502\u25026\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25027\u2502\u25028\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\nLabel(\"6\"),\nLabel(\"7\"),\nLabel(\"8\"),\n)\napp = MyApp(css_path=\"grid_gutter.tcss\")\n
Grid {\ngrid-size: 2 4;\ngrid-gutter: 1 2; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
/* Set vertical and horizontal gutters to be the same */\ngrid-gutter: 5;\n/* Set vertical and horizontal gutters separately */\ngrid-gutter: 1 2;\n
"},{"location":"styles/grid/grid_gutter/#python","title":"Python","text":"Vertical and horizontal gutters correspond to different Python properties, so they must be set separately:
widget.styles.grid_gutter_vertical = \"1\"\nwidget.styles.grid_gutter_horizontal = \"2\"\n
"},{"location":"styles/grid/grid_rows/","title":"Grid-rows","text":"The grid-rows
style allows to define the height of the rows of the grid.
Note
This style only affects widgets with layout: grid
.
\ngrid-rows: <scalar>+;\n
The grid-rows
style takes one or more <scalar>
that specify the length of the rows of the grid.
If there are more rows in the grid than scalars specified in grid-rows
, they are reused cyclically. If the number of <scalar>
is in excess, the excess is ignored.
The example below shows a grid with 10 labels laid out in a grid with 5 rows and 2 columns.
We set grid-rows: 1fr 6 25%
. Because there are more rows than scalars in the style definition, the scalars will be reused:
1fr
;6
; and25%
.MyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u25021fr\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502height\u00a0=\u00a06\u2502\u2502height\u00a0=\u00a06\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u250225%\u2502\u250225%\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u25021fr\u2502\u25021fr\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502height\u00a0=\u00a06\u2502\u2502height\u00a0=\u00a06\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1fr\"),\nLabel(\"1fr\"),\nLabel(\"height = 6\"),\nLabel(\"height = 6\"),\nLabel(\"25%\"),\nLabel(\"25%\"),\nLabel(\"1fr\"),\nLabel(\"1fr\"),\nLabel(\"height = 6\"),\nLabel(\"height = 6\"),\n)\napp = MyApp(css_path=\"grid_rows.tcss\")\n
Grid {\ngrid-size: 2 5;\ngrid-rows: 1fr 6 25%;\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
"},{"location":"styles/grid/grid_rows/#css","title":"CSS","text":"/* Set all rows to have 50% height */\ngrid-rows: 50%;\n/* Every other row is twice as tall as the first one */\ngrid-rows: 1fr 2fr;\n
"},{"location":"styles/grid/grid_rows/#python","title":"Python","text":"grid.styles.grid_rows = \"50%\"\ngrid.styles.grid_rows = \"1fr 2fr\"\n
"},{"location":"styles/grid/grid_rows/#see-also","title":"See also","text":"grid-columns
to specify the width of the grid columns.The grid-size
style sets the number of columns and rows in a grid layout.
The number of rows can be left unspecified and it will be computed automatically.
Note
This style only affects widgets with layout: grid
.
\ngrid-size: <integer> [<integer>];\n
The grid-size
style takes one or two non-negative <integer>
. The first defines how many columns there are in the grid. If present, the second one sets the number of rows \u2013 regardless of the number of children of the grid \u2013, otherwise the number of rows is computed automatically.
In the first example, we create a grid with 2 columns and 5 rows, although we do not have enough labels to fill in the whole grid:
Outputgrid_size_both.pygrid_size_both.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502 \u25025\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\n)\napp = MyApp(css_path=\"grid_size_both.tcss\")\n
Grid {\ngrid-size: 2 4; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
In the second example, we create a grid with 2 columns and however many rows are needed to display all of the grid children:
Outputgrid_size_columns.pygrid_size_columns.tcssMyApp \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u25021\u2502\u25022\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u25023\u2502\u25024\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2502\u2502\u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2502 \u2502\u2502 \u25025\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Label\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nLabel(\"1\"),\nLabel(\"2\"),\nLabel(\"3\"),\nLabel(\"4\"),\nLabel(\"5\"),\n)\napp = MyApp(css_path=\"grid_size_columns.tcss\")\n
Grid {\ngrid-size: 2; /* (1)! */\n}\nLabel {\nborder: round white;\ncontent-align: center middle;\nwidth: 100%;\nheight: 100%;\n}\n
/* Grid with 3 rows and 5 columns */\ngrid-size: 3 5;\n/* Grid with 4 columns and as many rows as needed */\ngrid-size: 4;\n
"},{"location":"styles/grid/grid_size/#python","title":"Python","text":"To programmatically change the grid size, the number of rows and columns must be specified separately:
widget.styles.grid_size_rows = 3\nwidget.styles.grid_size_columns = 6\n
"},{"location":"styles/grid/row_span/","title":"Row-span","text":"The row-span
style specifies how many rows a widget will span in a grid layout.
Note
This style only affects widgets that are direct children of a widget with layout: grid
.
\nrow-span: <integer>;\n
The row-span
style accepts a single non-negative <integer>
that quantifies how many rows the given widget spans.
The example below shows a 4 by 4 grid where many placeholders span over several rows.
Notice that grid cells are filled from left to right, top to bottom. After placing the placeholders #p1
, #p2
, #p3
, and #p4
, the next available cell is in the second row, fourth column, which is where the top of #p5
is.
MyApp #p4 #p3 #p2 #p1 #p5 #p6 #p7
from textual.app import App\nfrom textual.containers import Grid\nfrom textual.widgets import Placeholder\nclass MyApp(App):\ndef compose(self):\nyield Grid(\nPlaceholder(id=\"p1\"),\nPlaceholder(id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(id=\"p6\"),\nPlaceholder(id=\"p7\"),\n)\napp = MyApp(css_path=\"row_span.tcss\")\n
#p1 {\nrow-span: 4;\n}\n#p2 {\nrow-span: 3;\n}\n#p3 {\nrow-span: 2;\n}\n#p4 {\nrow-span: 1; /* Didn't need to be set explicitly. */\n}\n#p5 {\nrow-span: 3;\n}\n#p6 {\nrow-span: 2;\n}\n#p7 {\n/* Default value is 1. */\n}\nGrid {\ngrid-size: 4 4;\ngrid-gutter: 1 2;\n}\nPlaceholder {\nheight: 100%;\n}\n
"},{"location":"styles/grid/row_span/#css","title":"CSS","text":"row-span: 3\n
"},{"location":"styles/grid/row_span/#python","title":"Python","text":"widget.styles.row_span = 3\n
"},{"location":"styles/grid/row_span/#see-also","title":"See also","text":"column-span
to specify how many columns a widget spans.Textual supports the concept of inline \"links\" embedded in text which trigger an action when pressed. There are a number of styles which influence the appearance of these links within a widget.
Note
These CSS rules only target Textual action links. Internet hyperlinks are not affected by these styles.
Property Descriptionlink-background
The background color of the link text. link-color
The color of the link text. link-hover-background
The background color of the link text when the cursor is over it. link-hover-color
The color of the link text when the cursor is over it. link-hover-style
The style of the link text when the cursor is over it. link-style
The style of the link text (e.g. underline)."},{"location":"styles/links/#syntax","title":"Syntax","text":"\nlink-background: <color> [<percentage>];\n\nlink-color: <color> [<percentage>];\n\nlink-style: <text-style>;\n\nlink-hover-background: <color> [<percentage>];\n\nlink-hover-color: <color> [<percentage>];\n\nlink-hover-style: <text-style>;\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/links/#example","title":"Example","text":"In the example below, the first label illustrates default link styling. The second label uses CSS to customize the link color, background, and style.
Outputlinks.pylinks.tcssLinksApp Here\u00a0is\u00a0a\u00a0link\u00a0which\u00a0you\u00a0can\u00a0click! Here\u00a0is\u00a0a\u00a0link\u00a0which\u00a0you\u00a0can\u00a0click!
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nTEXT = \"\"\"\\\nHere is a [@click='app.bell']link[/] which you can click!\n\"\"\"\nclass LinksApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(TEXT)\nyield Static(TEXT, id=\"custom\")\napp = LinksApp(css_path=\"links.tcss\")\n
#custom {\nlink-color: black 90%;\nlink-background: dodgerblue;\nlink-style: bold italic underline;\n}\n
"},{"location":"styles/links/#additional-notes","title":"Additional Notes","text":"The link-background
style sets the background color of the link.
Note
link-background
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-background: <color> [<percentage>];\n
link-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of text enclosed in Textual action links.
The example below shows some links with their background color changed. It also shows that link-background
does not affect hyperlinks.
LinkBackgroundApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkBackgroundApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkBackgroundApp(css_path=\"link_background.tcss\")\n
link-background
rule.link-background
.link-background
.link-background
.#lbl1, #lbl2 {\nlink-background: red; /* (1)! */\n}\n#lbl3 {\nlink-background: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-background: $accent;\n}\n
link-background: red 70%;\nlink-background: $accent;\n
"},{"location":"styles/links/link_background/#python","title":"Python","text":"widget.styles.link_background = \"red 70%\"\nwidget.styles.link_background = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_background = Color(100, 30, 173)\n
"},{"location":"styles/links/link_background/#see-also","title":"See also","text":"link-color
to set the color of link text.The link-color
style sets the color of the link text.
Note
link-color
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-color: <color> [<percentage>];\n
link-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of text enclosed in Textual action links.
The example below shows some links with their color changed. It also shows that link-color
does not affect hyperlinks.
LinkColorApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkColorApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkColorApp(css_path=\"link_color.tcss\")\n
link-color
rule.link-color
.link-color
.link-color
.#lbl1, #lbl2 {\nlink-color: red; /* (1)! */\n}\n#lbl3 {\nlink-color: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-color: $accent;\n}\n
link-color: red 70%;\nlink-color: $accent;\n
"},{"location":"styles/links/link_color/#python","title":"Python","text":"widget.styles.link_color = \"red 70%\"\nwidget.styles.link_color = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_color = Color(100, 30, 173)\n
"},{"location":"styles/links/link_color/#see-also","title":"See also","text":"link-background
to set the background color of link text.The link-hover-background
style sets the background color of the link when the mouse cursor is over the link.
Note
link-hover-background
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-background: <color> [<percentage>];\n
link-hover-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of text enclosed in Textual action links when the mouse pointer is over it.
If not provided, a Textual action link will have link-hover-background
set to $accent
.
The example below shows some links that have their background colour changed when the mouse moves over it and it shows that there is a default color for link-hover-background
.
It also shows that link-hover-background
does not affect hyperlinks.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_background.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverBackgroundApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverBackgroundApp(css_path=\"link_hover_background.tcss\")\n
link-hover-background
rule.link-hover-background
.link-hover-background
.link-hover-background
.#lbl1, #lbl2 {\nlink-hover-background: red; /* (1)! */\n}\n#lbl3 {\nlink-hover-background: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\n/* Empty to show the default hover background */ /* (2)! */\n}\n
link-hover-background: red 70%;\nlink-hover-background: $accent;\n
"},{"location":"styles/links/link_hover_background/#python","title":"Python","text":"widget.styles.link_hover_background = \"red 70%\"\nwidget.styles.link_hover_background = \"$accent\"\n# You can also use a `Color` object directly:\nwidget.styles.link_hover_background = Color(100, 30, 173)\n
"},{"location":"styles/links/link_hover_background/#see-also","title":"See also","text":"link-background
to set the background color of link text.The link-hover-color
style sets the color of the link text when the mouse cursor is over the link.
Note
link-hover-color
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-color: <color> [<percentage>];\n
link-hover-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of text enclosed in Textual action links when the mouse pointer is over it.
If not provided, a Textual action link will have link-hover-color
set to white
.
The example below shows some links that have their colour changed when the mouse moves over it. It also shows that link-hover-color
does not affect hyperlinks.
Note
The background color also changes when the mouse moves over the links because that is the default behavior. That can be customised by setting link-hover-background
but we haven't done so in this example.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_color.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverColorApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverColorApp(css_path=\"link_hover_color.tcss\")\n
link-hover-color
rule.link-hover-color
.link-hover-color
.link-hover-color
.#lbl1, #lbl2 {\nlink-hover-color: red; /* (1)! */\n}\n#lbl3 {\nlink-hover-color: hsl(60,100%,50%) 50%;\n}\n#lbl4 {\nlink-hover-color: black;\n}\n
link-hover-color: red 70%;\nlink-hover-color: black;\n
"},{"location":"styles/links/link_hover_color/#python","title":"Python","text":"widget.styles.link_hover_color = \"red 70%\"\nwidget.styles.link_hover_color = \"black\"\n# You can also use a `Color` object directly:\nwidget.styles.link_hover_color = Color(100, 30, 173)\n
"},{"location":"styles/links/link_hover_color/#see-also","title":"See also","text":"link-color
to set the color of link text.The link-hover-style
style sets the text style for the link text when the mouse cursor is over the link.
Note
link-hover-style
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-hover-style: <text-style>;\n
link-hover-style
applies its <text-style>
to the text of Textual action links when the mouse pointer is over them.
If not provided, a Textual action link will have link-hover-style
set to bold
.
The example below shows some links that have their colour changed when the mouse moves over it. It also shows that link-hover-style
does not affect hyperlinks.
Note
The background color also changes when the mouse moves over the links because that is the default behavior. That can be customised by setting link-hover-background
but we haven't done so in this example.
Note
The GIF has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/link_hover_style.py
.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkHoverStyleApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkHoverStyleApp(css_path=\"link_hover_style.tcss\")\n
link-hover-style
rule.link-hover-style
.link-hover-style
.link-hover-style
.#lbl1, #lbl2 {\nlink-hover-style: bold italic; /* (1)! */\n}\n#lbl3 {\nlink-hover-style: reverse strike;\n}\n#lbl4 {\nlink-hover-style: bold;\n}\n
link-hover-style: bold;\nlink-hover-style: bold italic reverse;\n
"},{"location":"styles/links/link_hover_style/#python","title":"Python","text":"widget.styles.link_hover_style = \"bold\"\nwidget.styles.link_hover_style = \"bold italic reverse\"\n
"},{"location":"styles/links/link_hover_style/#see-also","title":"See also","text":"link-style
to set the style of link text.text-style
to set the style of text in a widget.The link-style
style sets the text style for the link text.
Note
link-style
only applies to Textual action links as described in the actions guide and not to regular hyperlinks.
\nlink-style: <text-style>;\n
link-style
will take all the values specified and will apply that styling to text that is enclosed by a Textual action link.
If not provided, a Textual action link will have link-style
set to underline
.
The example below shows some links with different styles applied to their text. It also shows that link-style
does not affect hyperlinks.
LinkStyleApp Visit\u00a0the\u00a0Textualize\u00a0website. Click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. You\u00a0can\u00a0also\u00a0click\u00a0here\u00a0for\u00a0the\u00a0bell\u00a0sound. Exit\u00a0this\u00a0application.
from textual.app import App\nfrom textual.widgets import Label\nclass LinkStyleApp(App):\ndef compose(self):\nyield Label(\n\"Visit the [link=https://textualize.io]Textualize[/link] website.\",\nid=\"lbl1\", # (1)!\n)\nyield Label(\n\"Click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl2\", # (2)!\n)\nyield Label(\n\"You can also click [@click=app.bell]here[/] for the bell sound.\",\nid=\"lbl3\", # (3)!\n)\nyield Label(\n\"[@click=app.quit]Exit this application.[/]\",\nid=\"lbl4\", # (4)!\n)\napp = LinkStyleApp(css_path=\"link_style.tcss\")\n
link-style
rule.link-style
.link-style
.link-style
.#lbl1, #lbl2 {\nlink-style: bold italic; /* (1)! */\n}\n#lbl3 {\nlink-style: reverse strike;\n}\n#lbl4 {\nlink-style: bold;\n}\n
link-style: bold;\nlink-style: bold italic reverse;\n
"},{"location":"styles/links/link_style/#python","title":"Python","text":"widget.styles.link_style = \"bold\"\nwidget.styles.link_style = \"bold italic reverse\"\n
"},{"location":"styles/links/link_style/#see-also","title":"See also","text":"text-style
to set the style of text in a widget.There are a number of styles to set the colors used in Textual scrollbars. You won't typically need to do this, as the default themes have carefully chosen colors, but you can if you want to.
Style Applies toscrollbar-background
Scrollbar background. scrollbar-background-active
Scrollbar background when the thumb is being dragged. scrollbar-background-hover
Scrollbar background when the mouse is hovering over it. scrollbar-color
Scrollbar \"thumb\" (movable part). scrollbar-color-active
Scrollbar thumb when it is active (being dragged). scrollbar-color-hover
Scrollbar thumb when the mouse is hovering over it. scrollbar-corner-color
The gap between the horizontal and vertical scrollbars."},{"location":"styles/scrollbar_colors/#syntax","title":"Syntax","text":"\nscrollbar-background: <color> [<percentage>];\n\nscrollbar-background-active: <color> [<percentage>];\n\nscrollbar-background-hover: <color> [<percentage>];\n\nscrollbar-color: <color> [<percentage>];\n\nscrollbar-color-active: <color> [<percentage>];\n\nscrollbar-color-hover: <color> [<percentage>];\n\nscrollbar-corner-color: <color> [<percentage>];\n
Visit each style's reference page to learn more about how the values are used.
"},{"location":"styles/scrollbar_colors/#example","title":"Example","text":"This example shows two panels that contain oversized text. The right panel sets scrollbar-background
, scrollbar-color
, and scrollbar-corner-color
, and the left panel shows the default colors for comparison.
ScrollbarApp I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turnAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn see\u00a0its\u00a0path.see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 will\u00a0remain.will\u00a0remain. I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turnAnd\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn see\u00a0its\u00a0path.\u2583\u2583see\u00a0its\u00a0path.\u2583\u2583 Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0 will\u00a0remain.will\u00a0remain. I\u00a0must\u00a0not\u00a0fear.I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer.Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0tFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0t I\u00a0will\u00a0face\u00a0my\u00a0fear.I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0tI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0t \u258d\u258d
from textual.app import App\nfrom textual.containers import Horizontal, ScrollableContainer\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarApp(App):\ndef compose(self):\nyield Horizontal(\nScrollableContainer(Label(TEXT * 10)),\nScrollableContainer(Label(TEXT * 10), classes=\"right\"),\n)\napp = ScrollbarApp(css_path=\"scrollbars.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Label {\nwidth: 150%;\nheight: 150%;\n}\n.right {\nscrollbar-background: red;\nscrollbar-color: green;\nscrollbar-corner-color: blue;\n}\nHorizontal > ScrollableContainer {\nwidth: 50%;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/","title":"Scrollbar-background","text":"The scrollbar-background
style sets the background color of the scrollbar.
\nscrollbar-background: <color> [<percentage>];\n
scrollbar-background
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#css","title":"CSS","text":"scrollbar-backround: blue;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#python","title":"Python","text":"widget.styles.scrollbar_background = \"blue\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background/#see-also","title":"See also","text":"scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color
to set the color of scrollbars.scrollbar-corner-color
to set the color of the corner where horizontal and vertical scrollbars meet.The scrollbar-background-active
style sets the background color of the scrollbar when the thumb is being dragged.
\nscrollbar-background-active: <color> [<percentage>];\n
scrollbar-background-active
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar when its thumb is being dragged.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#css","title":"CSS","text":"scrollbar-backround-active: red;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#python","title":"Python","text":"widget.styles.scrollbar_background_active = \"red\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_active/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.The scrollbar-background-hover
style sets the background color of the scrollbar when the cursor is over it.
\nscrollbar-background-hover: <color> [<percentage>];\n
scrollbar-background-hover
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the background color of a scrollbar when the cursor is over it.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#css","title":"CSS","text":"scrollbar-background-hover: purple;\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#python","title":"Python","text":"widget.styles.scrollbar_background_hover = \"purple\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#see-also","title":"See also","text":""},{"location":"styles/scrollbar_colors/scrollbar_background_hover/#see-also_1","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.The scrollbar-color
style sets the color of the scrollbar.
\nscrollbar-color: <color> [<percentage>];\n
scrollbar-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#css","title":"CSS","text":"scrollbar-color: cyan;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#python","title":"Python","text":"widget.styles.scrollbar_color = \"cyan\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.scrollbar-corner-color
to set the color of the corner where horizontal and vertical scrollbars meet.The scrollbar-color-active
style sets the color of the scrollbar when the thumb is being dragged.
\nscrollbar-color-active: <color> [<percentage>];\n
scrollbar-color-active
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar when its thumb is being dragged.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#css","title":"CSS","text":"scrollbar-color-active: yellow;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#python","title":"Python","text":"widget.styles.scrollbar_color_active = \"yellow\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_active/#see-also","title":"See also","text":"scrollbar-bakcground-active
to set the scrollbar bakcground color when the scrollbar is being dragged.scrollbar-color
to set the color of scrollbars.scrollbar-color-hover
to set the scrollbar color when the mouse pointer is over it.The scrollbar-color-hover
style sets the color of the scrollbar when the cursor is over it.
\nscrollbar-color-hover: <color> [<percentage>];\n
scrollbar-color-hover
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of a scrollbar when the cursor is over it.
Note
The GIF above has reduced quality to make it easier to load in the documentation. Try running the example yourself with textual run docs/examples/styles/scrollbars2.py
.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass Scrollbar2App(App):\ndef compose(self):\nyield Label(TEXT * 10)\napp = Scrollbar2App(css_path=\"scrollbars2.tcss\")\n
Screen {\nscrollbar-background: blue;\nscrollbar-background-active: red;\nscrollbar-background-hover: purple;\nscrollbar-color: cyan;\nscrollbar-color-active: yellow;\nscrollbar-color-hover: pink;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#css","title":"CSS","text":"scrollbar-color-hover: pink;\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#python","title":"Python","text":"widget.styles.scrollbar_color_hover = \"pink\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_color_hover/#see-also","title":"See also","text":"scrollbar-bakcground-hover
to set the scrollbar bakcground color when the mouse pointer is over it.scrollbar-color
to set the color of scrollbars.scrollbar-color-active
to set the scrollbar color when the scrollbar is being dragged.The scrollbar-corner-color
style sets the color of the gap between the horizontal and vertical scrollbars.
\nscrollbar-corner-color: <color> [<percentage>];\n
scrollbar-corner-color
accepts a <color>
(with an optional opacity level defined by a <percentage>
) that is used to define the color of the gap between the horizontal and vertical scrollbars of a widget.
The example below sets the scrollbar corner (bottom-right corner of the screen) to white.
Outputscrollbar_corner_color.pyscrollbar_corner_color.tcssScrollbarCornerColorApp I\u00a0must\u00a0not\u00a0fear.\u00a0Fear\u00a0is\u00a0the\u00a0mind-killer.\u00a0Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration.
from textual.app import App\nfrom textual.widgets import Label\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\n\"\"\"\nclass ScrollbarCornerColorApp(App):\ndef compose(self):\nyield Label(TEXT.replace(\"\\n\", \" \") + \"\\n\" + TEXT * 10)\napp = ScrollbarCornerColorApp(css_path=\"scrollbar_corner_color.tcss\")\n
Screen {\noverflow: auto auto;\nscrollbar-corner-color: white;\n}\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#css","title":"CSS","text":"scrollbar-corner-color: white;\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#python","title":"Python","text":"widget.styles.scrollbar_corner_color = \"white\"\n
"},{"location":"styles/scrollbar_colors/scrollbar_corner_color/#see-also","title":"See also","text":"scrollbar-background
to set the background color of scrollbars.scrollbar-color
to set the color of scrollbars.A reference to the builtin widgets.
See the links to the left of the page, or in the hamburger menu (three horizontal bars, top left).
"},{"location":"widgets/button/","title":"Button","text":"A simple button widget which can be pressed using a mouse click or by pressing Enter when it has focus.
The example below shows each button variant, and its disabled equivalent. Clicking any of the non-disabled buttons in the example app below will result in the app exiting and the details of the selected button being printed to the console.
Outputbutton.pybutton.tcssButtonsApp Standard\u00a0ButtonsDisabled\u00a0Buttons \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Default\u00a0\u00a0Default\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Primary!\u00a0\u00a0Primary!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Success!\u00a0\u00a0Success!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Warning!\u00a0\u00a0Warning!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0Error!\u00a0\u00a0Error!\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Button, Static\nclass ButtonsApp(App[str]):\nCSS_PATH = \"button.tcss\"\ndef compose(self) -> ComposeResult:\nyield Horizontal(\nVerticalScroll(\nStatic(\"Standard Buttons\", classes=\"header\"),\nButton(\"Default\"),\nButton(\"Primary!\", variant=\"primary\"),\nButton.success(\"Success!\"),\nButton.warning(\"Warning!\"),\nButton.error(\"Error!\"),\n),\nVerticalScroll(\nStatic(\"Disabled Buttons\", classes=\"header\"),\nButton(\"Default\", disabled=True),\nButton(\"Primary!\", variant=\"primary\", disabled=True),\nButton.success(\"Success!\", disabled=True),\nButton.warning(\"Warning!\", disabled=True),\nButton.error(\"Error!\", disabled=True),\n),\n)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.exit(str(event.button))\nif __name__ == \"__main__\":\napp = ButtonsApp()\nprint(app.run())\n
Button {\nmargin: 1 2;\n}\nHorizontal > VerticalScroll {\nwidth: 24;\n}\n.header {\nmargin: 1 0 0 2;\ntext-style: bold;\n}\n
"},{"location":"widgets/button/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description label
str
\"\"
The text that appears inside the button. variant
ButtonVariant
\"default\"
Semantic styling variant. One of default
, primary
, success
, warning
, error
. disabled
bool
False
Whether the button is disabled or not. Disabled buttons cannot be focused or clicked, and are styled in a way that suggests this."},{"location":"widgets/button/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/button/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/button/#additional-notes","title":"Additional Notes","text":"Button
has the height
set to 3 lines and a min-width
of 16 columns. To create a button with zero visible padding, you will need to change these values and also remove the border with border: none;
.class
","text":"def __init__(\nself,\nlabel=None,\nvariant=\"default\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Static
A simple clickable button.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
variant
ButtonVariant
The variant of the button.
'default'
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
"},{"location":"widgets/button/#textual.widgets._button.Button.ACTIVE_EFFECT_DURATION","title":"ACTIVE_EFFECT_DURATION class-attribute
instance-attribute
","text":"ACTIVE_EFFECT_DURATION = 0.3\n
When buttons are clicked they get the -active
class for this duration (in seconds)
class-attribute
instance-attribute
","text":"label: reactive[TextType] = self.validate_label(label)\n
The text label that appears within the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.variant","title":"variantclass-attribute
instance-attribute
","text":"variant = self.validate_variant(variant)\n
The variant name for the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.Pressed","title":"Pressedclass
","text":"def __init__(self, button):\n
Bases: Message
Event sent when a Button
is pressed.
Can be handled using on_button_pressed
in a subclass of Button
or in a parent widget in the DOM.
instance-attribute
","text":"button: Button = button\n
The button that was pressed.
"},{"location":"widgets/button/#textual.widgets._button.Button.Pressed.control","title":"controlproperty
","text":"control: Button\n
An alias for Pressed.button.
This will be the same value as Pressed.button.
"},{"location":"widgets/button/#textual.widgets._button.Button.action_press","title":"action_pressmethod
","text":"def action_press(self):\n
Activate a press of the button.
"},{"location":"widgets/button/#textual.widgets._button.Button.error","title":"errorclassmethod
","text":"def error(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating an error Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'error' variant.
method
","text":"def press(self):\n
Respond to a button press.
Returns Type DescriptionSelf
The button instance.
"},{"location":"widgets/button/#textual.widgets._button.Button.success","title":"successclassmethod
","text":"def success(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating a success Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'success' variant.
method
","text":"def validate_label(self, label):\n
Parse markup for self.label
"},{"location":"widgets/button/#textual.widgets._button.Button.warning","title":"warningclassmethod
","text":"def warning(\ncls,\nlabel=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Utility constructor for creating a warning Button variant.
Parameters Name Type Description Defaultlabel
TextType | None
The text that appears within the button.
None
disabled
bool
Whether the button is disabled or not.
False
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
Returns Type Description Button
A Button
widget of the 'warning' variant.
module-attribute
","text":"ButtonVariant = Literal[\n\"default\", \"primary\", \"success\", \"warning\", \"error\"\n]\n
The names of the valid button variants.
These are the variants that can be used with a Button
.
Added in version 0.13.0
A simple checkbox widget which stores a boolean value.
The example below shows check boxes in various states.
Outputcheckbox.pycheckbox.tcssCheckboxApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Arrakis\u00a0\ud83d\ude13\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Caladan\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Chusuk\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGiedi\u00a0Prime\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258cGinaz\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590X\u258c\u00a0Grumman\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2583\u2583 \u258a\u2590X\u258cKaitain\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import VerticalScroll\nfrom textual.widgets import Checkbox\nclass CheckboxApp(App[None]):\nCSS_PATH = \"checkbox.tcss\"\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nyield Checkbox(\"Arrakis :sweat:\")\nyield Checkbox(\"Caladan\")\nyield Checkbox(\"Chusuk\")\nyield Checkbox(\"[b]Giedi Prime[/b]\")\nyield Checkbox(\"[magenta]Ginaz[/]\")\nyield Checkbox(\"Grumman\", True)\nyield Checkbox(\"Kaitain\", id=\"initial_focus\")\nyield Checkbox(\"Novebruns\", True)\ndef on_mount(self):\nself.query_one(\"#initial_focus\", Checkbox).focus()\nif __name__ == \"__main__\":\nCheckboxApp().run()\n
Screen {\nalign: center middle;\n}\nVerticalScroll {\nwidth: auto;\nheight: auto;\nbackground: $boost;\npadding: 2;\n}\n
"},{"location":"widgets/checkbox/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the checkbox."},{"location":"widgets/checkbox/#messages","title":"Messages","text":"The checkbox widget defines the following bindings:
Key(s) Description enter, space Toggle the value."},{"location":"widgets/checkbox/#component-classes","title":"Component Classes","text":"The checkbox widget inherits the following component classes:
Class Descriptiontoggle--button
Targets the toggle button itself. toggle--label
Targets the text label of the toggle button."},{"location":"widgets/checkbox/#textual.widgets.Checkbox","title":"textual.widgets.Checkbox class
","text":" Bases: ToggleButton
A check box widget that represents a boolean value.
"},{"location":"widgets/checkbox/#textual.widgets._checkbox.Checkbox.Changed","title":"Changedclass
","text":" Bases: ToggleButton.Changed
Posted when the value of the checkbox changes.
This message can be handled using an on_checkbox_changed
method.
property
","text":"checkbox: Checkbox\n
The checkbox that was changed.
"},{"location":"widgets/checkbox/#textual.widgets._checkbox.Checkbox.Changed.control","title":"controlproperty
","text":"control: Checkbox\n
An alias for Changed.checkbox.
"},{"location":"widgets/collapsible/","title":"Collapsible","text":"Added in version 0.37
A container with a title that can be used to show (expand) or hide (collapse) content, either by clicking or focusing and pressing Enter.
You can add content to a Collapsible widget either by passing in children to the constructor, or with a context manager (with
statement).
Here is an example of using the constructor to add content:
def compose(self) -> ComposeResult:\nyield Collapsible(Label(\"Hello, world.\"))\n
Here's how the to use it with the context manager:
def compose(self) -> ComposeResult:\nwith Collapsible():\nyield Label(\"Hello, world.\")\n
The second form is generally preferred, but the end result is the same.
"},{"location":"widgets/collapsible/#title","title":"Title","text":"The default title \"Toggle\" can be customized by setting the title
parameter of the constructor:
def compose(self) -> ComposeResult:\nwith Collapsible(title=\"An interesting story.\"):\nyield Label(\"Interesting but verbose story.\")\n
"},{"location":"widgets/collapsible/#initial-state","title":"Initial State","text":"The initial state of the Collapsible
widget can be customized via the collapsed
parameter of the constructor:
def compose(self) -> ComposeResult:\nwith Collapsible(title=\"Contents 1\", collapsed=False):\nyield Label(\"Hello, world.\")\nwith Collapsible(title=\"Contents 2\", collapsed=True): # Default.\nyield Label(\"Hello, world.\")\n
"},{"location":"widgets/collapsible/#collapseexpand-symbols","title":"Collapse/Expand Symbols","text":"The symbols used to show the collapsed / expanded state can be customized by setting the parameters collapsed_symbol
and expanded_symbol
:
def compose(self) -> ComposeResult:\nwith Collapsible(collapsed_symbol=\">>>\", expanded_symbol=\"v\"):\nyield Label(\"Hello, world.\")\n
"},{"location":"widgets/collapsible/#examples","title":"Examples","text":"The following example contains three Collapsible
s in different states.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Paul \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Leto \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Jessica \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Leto #\u00a0Duke\u00a0Leto\u00a0I\u00a0Atreides Head\u00a0of\u00a0House\u00a0Atreides. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Jessica \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Paul \u00a0C\u00a0\u00a0Collapse\u00a0All\u00a0\u00a0E\u00a0\u00a0Expand\u00a0All\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Collapsible, Footer, Label, Markdown\nLETO = \"\"\"\\\n# Duke Leto I Atreides\nHead of House Atreides.\"\"\"\nJESSICA = \"\"\"\n# Lady Jessica\nBene Gesserit and concubine of Leto, and mother of Paul and Alia.\n\"\"\"\nPAUL = \"\"\"\n# Paul Atreides\nSon of Leto and Jessica.\n\"\"\"\nclass CollapsibleApp(App[None]):\n\"\"\"An example of collapsible container.\"\"\"\nBINDINGS = [\n(\"c\", \"collapse_or_expand(True)\", \"Collapse All\"),\n(\"e\", \"collapse_or_expand(False)\", \"Expand All\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Compose app with collapsible containers.\"\"\"\nyield Footer()\nwith Collapsible(collapsed=False, title=\"Leto\"):\nyield Label(LETO)\nyield Collapsible(Markdown(JESSICA), collapsed=False, title=\"Jessica\")\nwith Collapsible(collapsed=True, title=\"Paul\"):\nyield Markdown(PAUL)\ndef action_collapse_or_expand(self, collapse: bool) -> None:\nfor child in self.walk_children(Collapsible):\nchild.collapsed = collapse\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#setting-initial-state","title":"Setting Initial State","text":"The example below shows nested Collapsible
widgets and how to set their initial state.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25bc\u00a0Toggle \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u25b6\u00a0Toggle
from textual.app import App, ComposeResult\nfrom textual.widgets import Collapsible, Label\nclass CollapsibleApp(App[None]):\ndef compose(self) -> ComposeResult:\nwith Collapsible(collapsed=False):\nwith Collapsible():\nyield Label(\"Hello, world.\")\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#custom-symbols","title":"Custom Symbols","text":"The following example shows Collapsible
widgets with custom expand/collapse symbols.
CollapsibleApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 >>>\u00a0Togglev\u00a0Toggle Hello,\u00a0world.
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Collapsible, Label\nclass CollapsibleApp(App[None]):\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nwith Collapsible(\ncollapsed_symbol=\">>>\",\nexpanded_symbol=\"v\",\n):\nyield Label(\"Hello, world.\")\nwith Collapsible(\ncollapsed_symbol=\">>>\",\nexpanded_symbol=\"v\",\ncollapsed=False,\n):\nyield Label(\"Hello, world.\")\nif __name__ == \"__main__\":\napp = CollapsibleApp()\napp.run()\n
"},{"location":"widgets/collapsible/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description collapsed
bool
True
Controls the collapsed/expanded state of the widget."},{"location":"widgets/collapsible/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/collapsible/#bindings","title":"Bindings","text":"The collapsible widget defines the following binding on its title:
Key(s) Description enter Toggle the collapsible."},{"location":"widgets/collapsible/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/collapsible/#textual.widgets.Collapsible","title":"textual.widgets.Collapsibleclass
","text":"def __init__(\nself,\n*children,\ntitle=\"Toggle\",\ncollapsed=True,\ncollapsed_symbol=\"\u25b6\",\nexpanded_symbol=\"\u25bc\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A collapsible container.
Parameters Name Type Description Default*children
Widget
Contents that will be collapsed/expanded.
()
title
str
Title of the collapsed/expanded contents.
'Toggle'
collapsed
bool
Default status of the contents.
True
collapsed_symbol
str
Collapsed symbol before the title.
'\u25b6'
expanded_symbol
str
Expanded symbol before the title.
'\u25bc'
name
str | None
The name of the collapsible.
None
id
str | None
The ID of the collapsible in the DOM.
None
classes
str | None
The CSS classes of the collapsible.
None
disabled
bool
Whether the collapsible is disabled or not.
False
"},{"location":"widgets/content_switcher/","title":"ContentSwitcher","text":"Added in version 0.14.0
A widget for containing and switching display between multiple child widgets.
The example below uses a ContentSwitcher
in combination with two Button
s to create a simple tabbed view. Note how each Button
has an ID set, and how each child of the ContentSwitcher
has a corresponding ID; then a Button.Clicked
handler is used to set ContentSwitcher.current
to switch between the different views.
ContentSwitcherApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0DataTable\u00a0\u00a0Markdown\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u00a0Book\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Year\u00a0\u2502 \u2502\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01965\u00a0\u2502 \u2502\u00a0Dune\u00a0Messiah\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01969\u00a0\u2502 \u2502\u00a0Children\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01976\u00a0\u2502 \u2502\u00a0God\u00a0Emperor\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01981\u00a0\u2502 \u2502\u00a0Heretics\u00a0of\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01984\u00a0\u2502 \u2502\u00a0Chapterhouse:\u00a0Dune\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a01985\u00a0\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
from rich.align import VerticalCenter\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Button, ContentSwitcher, DataTable, Markdown\nMARKDOWN_EXAMPLE = \"\"\"# Three Flavours Cornetto\nThe Three Flavours Cornetto trilogy is an anthology series of British\ncomedic genre films directed by Edgar Wright.\n## Shaun of the Dead\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Strawberry | 2004-04-09 | Edgar Wright |\n## Hot Fuzz\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Classico | 2007-02-17 | Edgar Wright |\n## The World's End\n| Flavour | UK Release Date | Director |\n| -- | -- | -- |\n| Mint | 2013-07-19 | Edgar Wright |\n\"\"\"\nclass ContentSwitcherApp(App[None]):\nCSS_PATH = \"content_switcher.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal(id=\"buttons\"): # (1)!\nyield Button(\"DataTable\", id=\"data-table\") # (2)!\nyield Button(\"Markdown\", id=\"markdown\") # (3)!\nwith ContentSwitcher(initial=\"data-table\"): # (4)!\nyield DataTable(id=\"data-table\")\nwith VerticalScroll(id=\"markdown\"):\nyield Markdown(MARKDOWN_EXAMPLE)\ndef on_button_pressed(self, event: Button.Pressed) -> None:\nself.query_one(ContentSwitcher).current = event.button.id # (5)!\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(\"Book\", \"Year\")\ntable.add_rows(\n[\n(title.ljust(35), year)\nfor title, year in (\n(\"Dune\", 1965),\n(\"Dune Messiah\", 1969),\n(\"Children of Dune\", 1976),\n(\"God Emperor of Dune\", 1981),\n(\"Heretics of Dune\", 1984),\n(\"Chapterhouse: Dune\", 1985),\n)\n]\n)\nif __name__ == \"__main__\":\nContentSwitcherApp().run()\n
Horizontal
to hold the buttons, each with a unique ID.DataTable
in the ContentSwitcher
.Markdown
in the ContentSwitcher
.ContentSwitcher
. Remember that IDs are unique within parent, so the buttons and the widgets in the ContentSwitcher
can share IDs.Screen {\nalign: center middle;\npadding: 1;\n}\n#buttons {\nheight: 3;\nwidth: auto;\n}\nContentSwitcher {\nbackground: $panel;\nborder: round $primary;\nwidth: 90%;\nheight: 1fr;\n}\nDataTable {\nbackground: $panel;\n}\nMarkdownH2 {\nbackground: $primary;\ncolor: yellow;\nborder: none;\npadding: 0;\n}\n
When the user presses the \"Markdown\" button the view is switched:
ContentSwitcherApp \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u00a0DataTable\u00a0\u00a0Markdown\u00a0 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eThree\u00a0Flavours\u00a0Cornetto\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502The\u00a0Three\u00a0Flavours\u00a0Cornetto\u00a0trilogy\u00a0is\u00a0an\u00a0anthology\u00a0series\u2502 \u2502of\u00a0British\u00a0comedic\u00a0genre\u00a0films\u00a0directed\u00a0by\u00a0Edgar\u00a0Wright.\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Shaun\u00a0of\u00a0the\u00a0Dead\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a\u2502 \u2502\u258eStrawberry\u00a0\u00a0\u00a0\u00a0\u00a02004-04-09\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Edgar\u00a0Wright\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hot\u00a0Fuzz\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a\u2502 \u2502\u258eClassico\u00a0\u00a0\u00a0\u00a02007-02-17\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Edgar\u00a0Wright\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2502\u258e\u258a\u2502 \u2502\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2502 \u2502\u2502 \u2502\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0The\u00a0World's\u00a0End\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502 \u2502\u2502 \u2502\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2502 \u2502\u258e\u258a\u2502 \u2502\u258eFlavour\u00a0\u00a0\u00a0\u00a0UK\u00a0Release\u00a0Date\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Director\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u2502 \u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f
"},{"location":"widgets/content_switcher/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Descriptioncurrent
str
| None
None
The ID of the currently-visible child. None
means nothing is visible."},{"location":"widgets/content_switcher/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/content_switcher/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/content_switcher/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/content_switcher/#textual.widgets.ContentSwitcher","title":"textual.widgets.ContentSwitcherclass
","text":"def __init__(\nself,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\ninitial=None\n):\n
Bases: Container
A widget for switching between different children.
NoteAll child widgets that are to be switched between need a unique ID. Children that have no ID will be hidden and ignored.
Parameters Name Type Description Default*children
Widget
The widgets to switch between.
()
name
str | None
The name of the content switcher.
None
id
str | None
The ID of the content switcher in the DOM.
None
classes
str | None
The CSS classes of the content switcher.
None
disabled
bool
Whether the content switcher is disabled or not.
False
initial
str | None
The ID of the initial widget to show, None
or empty string for the first tab.
None
Note If initial
is not supplied no children will be shown to start with.
class-attribute
instance-attribute
","text":"current: reactive[str | None] = reactive[Optional[str]](\nNone, init=False\n)\n
The ID of the currently-displayed widget.
If set to None
then no widget is visible.
If set to an unknown ID, this will result in NoMatches
being raised.
property
","text":"visible_content: Widget | None\n
A reference to the currently-visible widget.
None
if nothing is visible.
method
","text":"def watch_current(self, old, new):\n
React to the current visible child choice being changed.
Parameters Name Type Description Defaultold
str | None
The old widget ID (or None
if there was no widget).
new
str | None
The new widget ID (or None
if nothing should be shown).
A table widget optimized for displaying a lot of data.
The following example shows how to fill a table with data. First, we use add_columns to include the lane
, swimmer
, country
, and time
columns in the table. After that, we use the add_rows method to insert the rows into the table.
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\ntable.add_rows(ROWS[1:])\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
To add a single row or column use add_row and add_column, respectively.
"},{"location":"widgets/data_table/#styling-and-justifying-cells","title":"Styling and justifying cells","text":"Cells can contain more than just plain strings - Rich renderables such as Text
are also supported. Text
objects provide an easy way to style and justify cell content:
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0Singapore50.39 \u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0PhelpsUnited\u00a0States51.14 \u00a0\u00a0\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0South\u00a0Africa51.14 \u00a0\u00a0\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary51.14 \u00a0\u00a0\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China51.26 \u00a0\u00a0\u00a08\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France51.58 \u00a0\u00a0\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0ShieldsUnited\u00a0States51.73 \u00a0\u00a0\u00a01Aleksandr\u00a0Sadovnikov\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Russia51.84 \u00a0\u00a010\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0Scotland51.84
from rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\nfor row in ROWS[1:]:\n# Adding styled and justified `Text` objects instead of plain strings.\nstyled_row = [\nText(str(cell), style=\"italic #03AC13\", justify=\"right\") for cell in row\n]\ntable.add_row(*styled_row)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/data_table/#keys","title":"Keys","text":"When adding a row to the table, you can supply a key to add_row. A key is a unique identifier for that row. If you don't supply a key, Textual will generate one for you and return it from add_row
. This key can later be used to reference the row, regardless of its current position in the table.
When working with data from a database, for example, you may wish to set the row key
to the primary key of the data to ensure uniqueness. The method add_column also accepts a key
argument and works similarly.
Keys are important because cells in a data table can change location due to factors like row deletion and sorting. Thus, using keys instead of coordinates allows us to refer to data without worrying about its current location in the table.
If you want to change the table based solely on coordinates, you can use the coordinate_to_cell_key method to convert a coordinate to a cell key, which is a (row_key, column_key)
pair.
The coordinate of the cursor is exposed via the cursor_coordinate
reactive attribute. Three types of cursors are supported: cell
, row
, and column
. Change the cursor type by assigning to the cursor_type
reactive attribute.
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
TableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 \u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 \u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 \u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 \u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 \u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 \u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 \u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 \u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 \u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from itertools import cycle\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\ncursors = cycle([\"column\", \"row\", \"cell\"])\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.cursor_type = next(cursors)\ntable.zebra_stripes = True\ntable.add_columns(*ROWS[0])\ntable.add_rows(ROWS[1:])\ndef key_c(self):\ntable = self.query_one(DataTable)\ntable.cursor_type = next(cursors)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
You can change the position of the cursor using the arrow keys, Page Up, Page Down, Home and End, or by assigning to the cursor_coordinate
reactive attribute.
Cells can be updated in the DataTable
by using the update_cell and update_cell_at methods.
To remove all data in the table, use the clear method. To remove individual rows, use remove_row. The remove_row
method accepts a key
argument, which identifies the row to be removed.
If you wish to remove the row below the cursor in the DataTable
, use coordinate_to_cell_key
to get the row key of the row under the current cursor_coordinate
, then supply this key to remove_row
:
# Get the keys for the row and column under the cursor.\nrow_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)\n# Supply the row key to `remove_row` to delete the row.\ntable.remove_row(row_key)\n
"},{"location":"widgets/data_table/#removing-columns","title":"Removing columns","text":"To remove individual columns, use remove_column. The remove_column
method accepts a key
argument, which identifies the column to be removed.
You can remove the column below the cursor using the same coordinate_to_cell_key
method described above:
# Get the keys for the row and column under the cursor.\n_, column_key = table.coordinate_to_cell_key(table.cursor_coordinate)\n# Supply the column key to `column_row` to delete the column.\ntable.remove_column(column_key)\n
"},{"location":"widgets/data_table/#fixed-data","title":"Fixed data","text":"You can fix a number of rows and columns in place, keeping them pinned to the top and left of the table respectively. To do this, assign an integer to the fixed_rows
or fixed_columns
reactive attributes of the DataTable
.
TableApp \u00a0A\u00a0\u00a0\u00a0B\u00a0\u00a0\u00a0\u00a0C\u00a0\u00a0\u00a0 \u00a01\u00a0\u00a0\u00a02\u00a0\u00a0\u00a0\u00a03\u00a0\u00a0\u00a0 \u00a02\u00a0\u00a0\u00a04\u00a0\u00a0\u00a0\u00a06\u00a0\u00a0\u00a0 \u00a079\u00a0\u00a0158\u00a0\u00a0237\u00a0 \u00a080\u00a0\u00a0160\u00a0\u00a0240\u00a0 \u00a081\u00a0\u00a0162\u00a0\u00a0243\u00a0 \u00a082\u00a0\u00a0164\u00a0\u00a0246\u00a0 \u00a083\u00a0\u00a0166\u00a0\u00a0249\u00a0 \u00a084\u00a0\u00a0168\u00a0\u00a0252\u00a0 \u00a085\u00a0\u00a0170\u00a0\u00a0255\u00a0 \u00a086\u00a0\u00a0172\u00a0\u00a0258\u00a0 \u00a087\u00a0\u00a0174\u00a0\u00a0261\u00a0 \u00a088\u00a0\u00a0176\u00a0\u00a0264\u00a0 \u00a089\u00a0\u00a0178\u00a0\u00a0267\u00a0 \u00a090\u00a0\u00a0180\u00a0\u00a0270\u00a0 \u00a091\u00a0\u00a0182\u00a0\u00a0273\u00a0 \u00a092\u00a0\u00a0184\u00a0\u00a0276\u00a0 \u00a093\u00a0\u00a0186\u00a0\u00a0279\u00a0 \u00a094\u00a0\u00a0188\u00a0\u00a0282\u00a0\u2587\u2587 \u00a095\u00a0\u00a0190\u00a0\u00a0285\u00a0 \u00a096\u00a0\u00a0192\u00a0\u00a0288\u00a0 \u00a097\u00a0\u00a0194\u00a0\u00a0291\u00a0 \u00a098\u00a0\u00a0196\u00a0\u00a0294\u00a0 \u00a099\u00a0\u00a0198\u00a0\u00a0297\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nclass TableApp(App):\nCSS = \"DataTable {height: 1fr}\"\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.focus()\ntable.add_columns(\"A\", \"B\", \"C\")\nfor number in range(1, 100):\ntable.add_row(str(number), str(number * 2), str(number * 3))\ntable.fixed_rows = 2\ntable.fixed_columns = 1\ntable.cursor_type = \"row\"\ntable.zebra_stripes = True\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
In the example above, we set fixed_rows
to 2
, and fixed_columns
to 1
, meaning the first two rows and the leftmost column do not scroll - they always remain visible as you scroll through the data table.
The DataTable
can be sorted using the sort method. In order to sort your data by a column, you must have supplied a key
to the add_column
method when you added it. You can then pass this key to the sort
method to sort by that column. Additionally, you can sort by multiple columns by passing multiple keys to sort
.
A \"label\" can be attached to a row using the add_row method. This will add an extra column to the left of the table which the cursor cannot interact with. This column is similar to the leftmost column in a spreadsheet containing the row numbers. The example below shows how to attach simple numbered labels to rows.
Labelled rowsdata_table_labels.pyTableApp \u00a0lane\u00a0\u00a0swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0time\u00a0\u00a0 1\u00a04\u00a0\u00a0\u00a0\u00a0\u00a0Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Singapore\u00a0\u00a0\u00a0\u00a0\u00a0\u00a050.39\u00a0 2\u00a02\u00a0\u00a0\u00a0\u00a0\u00a0Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.14\u00a0 3\u00a05\u00a0\u00a0\u00a0\u00a0\u00a0Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0South\u00a0Africa\u00a0\u00a0\u00a051.14\u00a0 4\u00a06\u00a0\u00a0\u00a0\u00a0\u00a0L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.14\u00a0 5\u00a03\u00a0\u00a0\u00a0\u00a0\u00a0Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.26\u00a0 6\u00a08\u00a0\u00a0\u00a0\u00a0\u00a0Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.58\u00a0 7\u00a07\u00a0\u00a0\u00a0\u00a0\u00a0Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0United\u00a0States\u00a0\u00a051.73\u00a0 8\u00a01\u00a0\u00a0\u00a0\u00a0\u00a0Aleksandr\u00a0Sadovnikov\u00a0\u00a0Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0 9\u00a010\u00a0\u00a0\u00a0\u00a0Darren\u00a0Burns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Scotland\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a051.84\u00a0
from rich.text import Text\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DataTable\nROWS = [\n(\"lane\", \"swimmer\", \"country\", \"time\"),\n(4, \"Joseph Schooling\", \"Singapore\", 50.39),\n(2, \"Michael Phelps\", \"United States\", 51.14),\n(5, \"Chad le Clos\", \"South Africa\", 51.14),\n(6, \"L\u00e1szl\u00f3 Cseh\", \"Hungary\", 51.14),\n(3, \"Li Zhuhao\", \"China\", 51.26),\n(8, \"Mehdy Metella\", \"France\", 51.58),\n(7, \"Tom Shields\", \"United States\", 51.73),\n(1, \"Aleksandr Sadovnikov\", \"Russia\", 51.84),\n(10, \"Darren Burns\", \"Scotland\", 51.84),\n]\nclass TableApp(App):\ndef compose(self) -> ComposeResult:\nyield DataTable()\ndef on_mount(self) -> None:\ntable = self.query_one(DataTable)\ntable.add_columns(*ROWS[0])\nfor number, row in enumerate(ROWS[1:], start=1):\nlabel = Text(str(number), style=\"#B0FC38 italic\")\ntable.add_row(*row, label=label)\napp = TableApp()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/data_table/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_header
bool
True
Show the table header show_row_labels
bool
True
Show the row labels (if applicable) fixed_rows
int
0
Number of fixed rows (rows which do not scroll) fixed_columns
int
0
Number of fixed columns (columns which do not scroll) zebra_stripes
bool
False
Display alternating colors on rows header_height
int
1
Height of header row show_cursor
bool
True
Show the cursor cursor_type
str
\"cell\"
One of \"cell\"
, \"row\"
, \"column\"
, or \"none\"
cursor_coordinate
Coordinate Coordinate(0, 0)
The current coordinate of the cursor hover_coordinate
Coordinate Coordinate(0, 0)
The coordinate the mouse cursor is above"},{"location":"widgets/data_table/#messages","title":"Messages","text":"The data table widget defines the following bindings:
Key(s) Description enter Select cells under the cursor. up Move the cursor up. down Move the cursor down. right Move the cursor right. left Move the cursor left."},{"location":"widgets/data_table/#component-classes","title":"Component Classes","text":"The data table widget provides the following component classes:
Class Descriptiondatatable--cursor
Target the cursor. datatable--hover
Target the cells under the hover cursor. datatable--fixed
Target fixed columns and fixed rows. datatable--fixed-cursor
Target highlighted and fixed columns or header. datatable--header
Target the header of the data table. datatable--header-cursor
Target cells highlighted by the cursor. datatable--header-hover
Target hovered header or row label cells. datatable--even-row
Target even rows (row indices start at 0). datatable--odd-row
Target odd rows (row indices start at 0)."},{"location":"widgets/data_table/#textual.widgets.DataTable","title":"textual.widgets.DataTable class
","text":"def __init__(\nself,\n*,\nshow_header=True,\nshow_row_labels=True,\nfixed_rows=0,\nfixed_columns=0,\nzebra_stripes=False,\nheader_height=1,\nshow_cursor=True,\ncursor_foreground_priority=\"css\",\ncursor_background_priority=\"renderable\",\ncursor_type=\"cell\",\ncell_padding=_DEFAULT_CELL_X_PADDING,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
, Generic[CellType]
A tabular widget that contains data.
Parameters Name Type Description Defaultshow_header
bool
Whether the table header should be visible or not.
True
show_row_labels
bool
Whether the row labels should be shown or not.
True
fixed_rows
int
The number of rows, counting from the top, that should be fixed and still visible when the user scrolls down.
0
fixed_columns
int
The number of columns, counting from the left, that should be fixed and still visible when the user scrolls right.
0
zebra_stripes
bool
Enables or disables a zebra effect applied to the background color of the rows of the table, where alternate colors are styled differently to improve the readability of the table.
False
header_height
int
The height, in number of cells, of the data table header.
1
show_cursor
bool
Whether the cursor should be visible when navigating the data table or not.
True
cursor_foreground_priority
Literal['renderable', 'css']
If the data associated with a cell is an arbitrary renderable with a set foreground color, this determines whether that color is prioritised over the cursor component class or not.
'css'
cursor_background_priority
Literal['renderable', 'css']
If the data associated with a cell is an arbitrary renderable with a set background color, this determines whether that color is prioritesed over the cursor component class or not.
'renderable'
cursor_type
CursorType
The type of cursor to be used when navigating the data table with the keyboard.
'cell'
cell_padding
int
The number of cells added on each side of each column. Setting this value to zero will likely make your table very heard to read.
_DEFAULT_CELL_X_PADDING
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\nBinding(\n\"right\", \"cursor_right\", \"Cursor Right\", show=False\n),\nBinding(\n\"left\", \"cursor_left\", \"Cursor Left\", show=False\n),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\n]\n
Key(s) Description enter Select cells under the cursor. up Move the cursor up. down Move the cursor down. right Move the cursor right. left Move the cursor left."},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"datatable--cursor\",\n\"datatable--hover\",\n\"datatable--fixed\",\n\"datatable--fixed-cursor\",\n\"datatable--header\",\n\"datatable--header-cursor\",\n\"datatable--header-hover\",\n\"datatable--odd-row\",\n\"datatable--even-row\",\n}\n
Class Description datatable--cursor
Target the cursor. datatable--hover
Target the cells under the hover cursor. datatable--fixed
Target fixed columns and fixed rows. datatable--fixed-cursor
Target highlighted and fixed columns or header. datatable--header
Target the header of the data table. datatable--header-cursor
Target cells highlighted by the cursor. datatable--header-hover
Target hovered header or row label cells. datatable--even-row
Target even rows (row indices start at 0). datatable--odd-row
Target odd rows (row indices start at 0)."},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cell_padding","title":"cell_padding class-attribute
instance-attribute
","text":"cell_padding = cell_padding\n
Horizontal padding between cells, applied on each side of each cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.columns","title":"columnsinstance-attribute
","text":"columns: dict[ColumnKey, Column] = {}\n
Metadata about the columns of the table, indexed by their key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_background_priority","title":"cursor_background_priorityinstance-attribute
","text":"cursor_background_priority = cursor_background_priority\n
Should we prioritize the cursor component class CSS background or the renderable background in the event where a cell contains a renderable with a background color.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_column","title":"cursor_columnproperty
","text":"cursor_column: int\n
The index of the column that the DataTable cursor is currently on.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_coordinate","title":"cursor_coordinateclass-attribute
instance-attribute
","text":"cursor_coordinate: Reactive[Coordinate] = Reactive(\nCoordinate(0, 0), repaint=False, always_update=True\n)\n
Current cursor Coordinate
.
This can be set programmatically or changed via the method move_cursor
.
instance-attribute
","text":"cursor_foreground_priority = cursor_foreground_priority\n
Should we prioritize the cursor component class CSS foreground or the renderable foreground in the event where a cell contains a renderable with a foreground color.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_row","title":"cursor_rowproperty
","text":"cursor_row: int\n
The index of the row that the DataTable cursor is currently on.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.cursor_type","title":"cursor_typeclass-attribute
instance-attribute
","text":"cursor_type: Reactive[CursorType] = cursor_type\n
The type of cursor of the DataTable
.
class-attribute
instance-attribute
","text":"fixed_columns = fixed_columns\n
The number of columns to fix (prevented from scrolling).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.fixed_rows","title":"fixed_rowsclass-attribute
instance-attribute
","text":"fixed_rows = fixed_rows\n
The number of rows to fix (prevented from scrolling).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.header_height","title":"header_heightclass-attribute
instance-attribute
","text":"header_height = header_height\n
The height of the header row (the row of column labels).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.hover_column","title":"hover_columnproperty
","text":"hover_column: int\n
The index of the column that the mouse cursor is currently hovering above.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.hover_coordinate","title":"hover_coordinateclass-attribute
instance-attribute
","text":"hover_coordinate: Reactive[Coordinate] = Reactive(\nCoordinate(0, 0), repaint=False, always_update=True\n)\n
The coordinate of the DataTable
that is being hovered.
property
","text":"hover_row: int\n
The index of the row that the mouse cursor is currently hovering above.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ordered_columns","title":"ordered_columnsproperty
","text":"ordered_columns: list[Column]\n
The list of Columns in the DataTable, ordered as they appear on screen.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ordered_rows","title":"ordered_rowsproperty
","text":"ordered_rows: list[Row]\n
The list of Rows in the DataTable, ordered as they appear on screen.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.row_count","title":"row_countproperty
","text":"row_count: int\n
The number of rows currently present in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.rows","title":"rowsinstance-attribute
","text":"rows: dict[RowKey, Row] = {}\n
Metadata about the rows of the table, indexed by their key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_cursor","title":"show_cursorclass-attribute
instance-attribute
","text":"show_cursor = show_cursor\n
Show/hide both the keyboard and hover cursor.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_header","title":"show_headerclass-attribute
instance-attribute
","text":"show_header = show_header\n
Show/hide the header row (the row of column labels).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.show_row_labels","title":"show_row_labelsclass-attribute
instance-attribute
","text":"show_row_labels = show_row_labels\n
Show/hide the column containing the labels of rows.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.zebra_stripes","title":"zebra_stripesclass-attribute
instance-attribute
","text":"zebra_stripes = zebra_stripes\n
Apply zebra effect on row backgrounds (light, dark, light, dark, ...).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted","title":"CellHighlightedclass
","text":"def __init__(self, data_table, value, coordinate, cell_key):\n
Bases: Message
Posted when the cursor moves to highlight a new cell.
This is only relevant when the cursor_type
is \"cell\"
. It's also posted when the cell cursor is re-enabled (by setting show_cursor=True
), and when the cursor type is changed to \"cell\"
. Can be handled using on_data_table_cell_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"cell_key: CellKey = cell_key\n
The key for the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.coordinate","title":"coordinateinstance-attribute
","text":"coordinate: Coordinate = coordinate\n
The coordinate of the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellHighlighted.value","title":"valueinstance-attribute
","text":"value: CellType = value\n
The value in the highlighted cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected","title":"CellSelectedclass
","text":"def __init__(self, data_table, value, coordinate, cell_key):\n
Bases: Message
Posted by the DataTable
widget when a cell is selected.
This is only relevant when the cursor_type
is \"cell\"
. Can be handled using on_data_table_cell_selected
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"cell_key: CellKey = cell_key\n
The key for the selected cell.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.coordinate","title":"coordinateinstance-attribute
","text":"coordinate: Coordinate = coordinate\n
The coordinate of the cell that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.CellSelected.value","title":"valueinstance-attribute
","text":"value: CellType = value\n
The value in the cell that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted","title":"ColumnHighlightedclass
","text":"def __init__(self, data_table, cursor_column, column_key):\n
Bases: Message
Posted when a column is highlighted.
This message is only posted when the cursor_type
is set to \"column\"
. Can be handled using on_data_table_column_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"column_key = column_key\n
The key of the column that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.cursor_column","title":"cursor_columninstance-attribute
","text":"cursor_column: int = cursor_column\n
The x-coordinate of the column that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected","title":"ColumnSelectedclass
","text":"def __init__(self, data_table, cursor_column, column_key):\n
Bases: Message
Posted when a column is selected.
This message is only posted when the cursor_type
is set to \"column\"
. Can be handled using on_data_table_column_selected
in a subclass of DataTable
or in a parent widget in the DOM.
instance-attribute
","text":"column_key = column_key\n
The key of the column that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.cursor_column","title":"cursor_columninstance-attribute
","text":"cursor_column: int = cursor_column\n
The x-coordinate of the column that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.ColumnSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected","title":"HeaderSelectedclass
","text":"def __init__(self, data_table, column_key, column_index, label):\n
Bases: Message
Posted when a column header/label is clicked.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.column_index","title":"column_indexinstance-attribute
","text":"column_index = column_index\n
The index for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.column_key","title":"column_keyinstance-attribute
","text":"column_key = column_key\n
The key for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.HeaderSelected.label","title":"labelinstance-attribute
","text":"label = label\n
The text of the label.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted","title":"RowHighlightedclass
","text":"def __init__(self, data_table, cursor_row, row_key):\n
Bases: Message
Posted when a row is highlighted.
This message is only posted when the cursor_type
is set to \"row\"
. Can be handled using on_data_table_row_highlighted
in a subclass of DataTable
or in a parent widget in the DOM.
property
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.cursor_row","title":"cursor_rowinstance-attribute
","text":"cursor_row: int = cursor_row\n
The y-coordinate of the cursor that highlighted the row.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowHighlighted.row_key","title":"row_keyinstance-attribute
","text":"row_key: RowKey = row_key\n
The key of the row that was highlighted.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected","title":"RowLabelSelectedclass
","text":"def __init__(self, data_table, row_key, row_index, label):\n
Bases: Message
Posted when a row label is clicked.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.control","title":"controlproperty
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.label","title":"labelinstance-attribute
","text":"label = label\n
The text of the label.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.row_index","title":"row_indexinstance-attribute
","text":"row_index = row_index\n
The index for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowLabelSelected.row_key","title":"row_keyinstance-attribute
","text":"row_key = row_key\n
The key for the column.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected","title":"RowSelectedclass
","text":"def __init__(self, data_table, cursor_row, row_key):\n
Bases: Message
Posted when a row is selected.
This message is only posted when the cursor_type
is set to \"row\"
. Can be handled using on_data_table_row_selected
in a subclass of DataTable
or in a parent widget in the DOM.
property
","text":"control: DataTable\n
Alias for the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.cursor_row","title":"cursor_rowinstance-attribute
","text":"cursor_row: int = cursor_row\n
The y-coordinate of the cursor that made the selection.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.data_table","title":"data_tableinstance-attribute
","text":"data_table = data_table\n
The data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.RowSelected.row_key","title":"row_keyinstance-attribute
","text":"row_key: RowKey = row_key\n
The key of the row that was selected.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the cursor one page down.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the cursor one page up.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_scroll_end","title":"action_scroll_endmethod
","text":"def action_scroll_end(self):\n
Scroll to the bottom of the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.action_scroll_home","title":"action_scroll_homemethod
","text":"def action_scroll_home(self):\n
Scroll to the top of the data table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_column","title":"add_columnmethod
","text":"def add_column(\nself, label, *, width=None, key=None, default=None\n):\n
Add a column to the table.
Parameters Name Type Description Defaultlabel
TextType
A str or Text object containing the label (shown top of column).
requiredwidth
int | None
Width of the column in cells or None to fit content.
None
key
str | None
A key which uniquely identifies this column. If None, it will be generated for you.
None
default
CellType | None
The value to insert into pre-existing rows.
None
Returns Type Description ColumnKey
Uniquely identifies this column. Can be used to retrieve this column regardless of its current location in the DataTable (it could have moved after being added due to sorting/insertion/deletion of other columns).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_columns","title":"add_columnsmethod
","text":"def add_columns(self, *labels):\n
Add a number of columns.
Parameters Name Type Description Default*labels
TextType
Column headers.
()
Returns Type Description list[ColumnKey]
A list of the keys for the columns that were added. See the add_column
method docstring for more information on how these keys are used.
method
","text":"def add_row(self, *cells, height=1, key=None, label=None):\n
Add a row at the bottom of the DataTable.
Parameters Name Type Description Default*cells
CellType
Positional arguments should contain cell data.
()
height
int | None
The height of a row (in lines). Use None
to auto-detect the optimal height.
1
key
str | None
A key which uniquely identifies this row. If None, it will be generated for you and returned.
None
label
TextType | None
The label for the row. Will be displayed to the left if supplied.
None
Returns Type Description RowKey
Unique identifier for this row. Can be used to retrieve this row regardless of its current location in the DataTable (it could have moved after being added due to sorting or insertion/deletion of other rows).
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.add_rows","title":"add_rowsmethod
","text":"def add_rows(self, rows):\n
Add a number of rows at the bottom of the DataTable.
Parameters Name Type Description Defaultrows
Iterable[Iterable[CellType]]
Iterable of rows. A row is an iterable of cells.
required Returns Type Descriptionlist[RowKey]
A list of the keys for the rows that were added. See the add_row
method docstring for more information on how these keys are used.
method
","text":"def clear(self, columns=False):\n
Clear the table.
Parameters Name Type Description Defaultcolumns
bool
Also clear the columns.
False
Returns Type Description Self
The DataTable
instance.
method
","text":"def coordinate_to_cell_key(self, coordinate):\n
Return the key for the cell currently occupying this coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to exam the current cell key of.
required Returns Type DescriptionCellKey
The key of the cell currently occupying this coordinate.
Raises Type DescriptionCellDoesNotExist
If the coordinate is not valid.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell","title":"get_cellmethod
","text":"def get_cell(self, row_key, column_key):\n
Given a row key and column key, return the value of the corresponding cell.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key of the cell.
requiredcolumn_key
ColumnKey | str
The column key of the cell.
required Returns Type DescriptionCellType
The value of the cell identified by the row and column keys.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell_at","title":"get_cell_atmethod
","text":"def get_cell_at(self, coordinate):\n
Get the value from the cell occupying the given coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to retrieve the value from.
required Returns Type DescriptionCellType
The value of the cell at the coordinate.
Raises Type DescriptionCellDoesNotExist
If there is no cell with the given coordinate.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_cell_coordinate","title":"get_cell_coordinatemethod
","text":"def get_cell_coordinate(self, row_key, column_key):\n
Given a row key and column key, return the corresponding cell coordinate.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key of the cell.
requiredcolumn_key
Column | str
The column key of the cell.
required Returns Type DescriptionCoordinate
The current coordinate of the cell identified by the row and column keys.
Raises Type DescriptionCellDoesNotExist
If the specified cell does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column","title":"get_columnmethod
","text":"def get_column(self, column_key):\n
Get the values from the column identified by the given column key.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The key of the column.
required Returns Type DescriptionIterable[CellType]
A generator which yields the cells in the column.
Raises Type DescriptionColumnDoesNotExist
If there is no column corresponding to the key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column_at","title":"get_column_atmethod
","text":"def get_column_at(self, column_index):\n
Get the values from the column at a given index.
Parameters Name Type Description Defaultcolumn_index
int
The index of the column.
required Returns Type DescriptionIterable[CellType]
A generator which yields the cells in the column.
Raises Type DescriptionColumnDoesNotExist
If there is no column with the given index.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_column_index","title":"get_column_indexmethod
","text":"def get_column_index(self, column_key):\n
Return the current index for the column identified by column_key.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The column key to find the current index of.
required Returns Type Descriptionint
The current index of the specified column key.
Raises Type DescriptionColumnDoesNotExist
If the column key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row","title":"get_rowmethod
","text":"def get_row(self, row_key):\n
Get the values from the row identified by the given row key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key of the row.
required Returns Type Descriptionlist[CellType]
A list of the values contained within the row.
Raises Type DescriptionRowDoesNotExist
When there is no row corresponding to the key.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_at","title":"get_row_atmethod
","text":"def get_row_at(self, row_index):\n
Get the values from the cells in a row at a given index. This will return the values from a row based on the rows current position in the table.
Parameters Name Type Description Defaultrow_index
int
The index of the row.
required Returns Type Descriptionlist[CellType]
A list of the values contained in the row.
Raises Type DescriptionRowDoesNotExist
If there is no row with the given index.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_height","title":"get_row_heightmethod
","text":"def get_row_height(self, row_key):\n
Given a row key, return the height of that row in terminal cells.
Parameters Name Type Description Defaultrow_key
RowKey
The key of the row.
required Returns Type Descriptionint
The height of the row, measured in terminal character cells.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.get_row_index","title":"get_row_indexmethod
","text":"def get_row_index(self, row_key):\n
Return the current index for the row identified by row_key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The row key to find the current index of.
required Returns Type Descriptionint
The current index of the specified row key.
Raises Type DescriptionRowDoesNotExist
If the row key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_column_index","title":"is_valid_column_indexmethod
","text":"def is_valid_column_index(self, column_index):\n
Return a boolean indicating whether the column_index is within table bounds.
Parameters Name Type Description Defaultcolumn_index
int
The column index to check.
required Returns Type Descriptionbool
True if the column index is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_coordinate","title":"is_valid_coordinatemethod
","text":"def is_valid_coordinate(self, coordinate):\n
Return a boolean indicating whether the given coordinate is valid.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to validate.
required Returns Type Descriptionbool
True if the coordinate is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.is_valid_row_index","title":"is_valid_row_indexmethod
","text":"def is_valid_row_index(self, row_index):\n
Return a boolean indicating whether the row_index is within table bounds.
Parameters Name Type Description Defaultrow_index
int
The row index to check.
required Returns Type Descriptionbool
True if the row index is within the bounds of the table.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.move_cursor","title":"move_cursormethod
","text":"def move_cursor(self, *, row=None, column=None, animate=False):\n
Move the cursor to the given position.
Exampledatatable = app.query_one(DataTable)\ndatatable.move_cursor(row=4, column=6)\n# datatable.cursor_coordinate == Coordinate(4, 6)\ndatatable.move_cursor(row=3)\n# datatable.cursor_coordinate == Coordinate(3, 6)\n
Parameters Name Type Description Default row
int | None
The new row to move the cursor to.
None
column
int | None
The new column to move the cursor to.
None
animate
bool
Whether to animate the change of coordinates.
False
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.refresh_column","title":"refresh_column method
","text":"def refresh_column(self, column_index):\n
Refresh the column at the given index.
Parameters Name Type Description Defaultcolumn_index
int
The index of the column to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def refresh_coordinate(self, coordinate):\n
Refresh the cell at a coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def refresh_row(self, row_index):\n
Refresh the row at the given index.
Parameters Name Type Description Defaultrow_index
int
The index of the row to refresh.
required Returns Type DescriptionSelf
The DataTable
instance.
method
","text":"def remove_column(self, column_key):\n
Remove a column (identified by a key) from the DataTable.
Parameters Name Type Description Defaultcolumn_key
ColumnKey | str
The key identifying the column to remove.
required Raises Type DescriptionColumnDoesNotExist
If the column key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.remove_row","title":"remove_rowmethod
","text":"def remove_row(self, row_key):\n
Remove a row (identified by a key) from the DataTable.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key identifying the row to remove.
required Raises Type DescriptionRowDoesNotExist
If the row key does not exist.
"},{"location":"widgets/data_table/#textual.widgets._data_table.DataTable.sort","title":"sortmethod
","text":"def sort(self, *columns, reverse=False):\n
Sort the rows in the DataTable
by one or more column keys.
columns
ColumnKey | str
One or more columns to sort by the values in.
()
reverse
bool
If True, the sort order will be reversed.
False
Returns Type Description Self
The DataTable
instance.
method
","text":"def update_cell(\nself, row_key, column_key, value, *, update_width=False\n):\n
Update the cell identified by the specified row key and column key.
Parameters Name Type Description Defaultrow_key
RowKey | str
The key identifying the row.
requiredcolumn_key
ColumnKey | str
The key identifying the column.
requiredvalue
CellType
The new value to put inside the cell.
requiredupdate_width
bool
Whether to resize the column width to accommodate for the new cell content.
False
Raises Type Description CellDoesNotExist
When the supplied row_key
and column_key
cannot be found in the table.
method
","text":"def update_cell_at(\nself, coordinate, value, *, update_width=False\n):\n
Update the content inside the cell currently occupying the given coordinate.
Parameters Name Type Description Defaultcoordinate
Coordinate
The coordinate to update the cell at.
requiredvalue
CellType
The new value to place inside the cell.
requiredupdate_width
bool
Whether to resize the column width to accommodate for the new cell content.
False
"},{"location":"widgets/data_table/#textual.widgets.data_table","title":"textual.widgets.data_table","text":""},{"location":"widgets/data_table/#textual.widgets.data_table.CursorType","title":"CursorType module-attribute
","text":"CursorType = Literal['cell', 'row', 'column', 'none']\n
The valid types of cursors for DataTable.cursor_type
.
class
","text":" Bases: Exception
The cell key/index was invalid.
Raised when the coordinates or cell key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.CellKey","title":"CellKeyclass
","text":" Bases: NamedTuple
A unique identifier for a cell in the DataTable.
Even if the cell changes visual location (i.e. moves to a different coordinate in the table), this key can still be used to retrieve it, regardless of where it currently is.
"},{"location":"widgets/data_table/#textual.widgets.data_table.Column","title":"Columnclass
","text":"Metadata for a column in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets._data_table.Column.get_render_width","title":"get_render_widthmethod
","text":"def get_render_width(self, data_table):\n
Width, in cells, required to render the column with padding included.
Parameters Name Type Description Defaultdata_table
DataTable[Any]
The data table where the column will be rendered.
required Returns Type Descriptionint
The width, in cells, required to render the column with padding included.
"},{"location":"widgets/data_table/#textual.widgets.data_table.ColumnDoesNotExist","title":"ColumnDoesNotExistclass
","text":" Bases: Exception
Raised when the column index or column key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.ColumnKey","title":"ColumnKeyclass
","text":" Bases: StringKey
Uniquely identifies a column in the DataTable.
Even if the visual location of the column changes due to sorting or other modifications, a key will always refer to the same column.
"},{"location":"widgets/data_table/#textual.widgets.data_table.DuplicateKey","title":"DuplicateKeyclass
","text":" Bases: Exception
The key supplied already exists.
Raised when the RowKey or ColumnKey provided already refers to an existing row or column in the DataTable. Keys must be unique.
"},{"location":"widgets/data_table/#textual.widgets.data_table.Row","title":"Rowclass
","text":"Metadata for a row in the DataTable.
"},{"location":"widgets/data_table/#textual.widgets.data_table.RowDoesNotExist","title":"RowDoesNotExistclass
","text":" Bases: Exception
Raised when the row index or row key provided does not exist in the DataTable (e.g. out of bounds index, invalid key)
"},{"location":"widgets/data_table/#textual.widgets.data_table.RowKey","title":"RowKeyclass
","text":" Bases: StringKey
Uniquely identifies a row in the DataTable.
Even if the visual location of the row changes due to sorting or other modifications, a key will always refer to the same row.
"},{"location":"widgets/digits/","title":"Digits","text":"Added in version 0.33.0
A widget to display numerical values in tall multi-line characters.
The digits 0-9 are supported, in addition to the following characters +
, -
, ^
, :
, and \u00d7
. Other characters will be displayed in a regular size font.
You can set the text to be displayed in the constructor, or call update()
to change the text after the widget has been mounted.
This widget will respect the text-align rule.
The following example displays a few digits of Pi:
Outputdigits.pyDigitApp \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557 \u2551\u257a\u2501\u2513\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u00a0\u2513\u00a0\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2578\u257a\u2501\u2513\u00a0\u250f\u2501\u2578\u250f\u2501\u2513\u250f\u2501\u2513\u257a\u2501\u2513\u2551 \u2551\u00a0\u2501\u252b\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u00a0\u2503\u00a0\u00a0\u2517\u2501\u2513\u2517\u2501\u252b\u250f\u2501\u251b\u00a0\u2523\u2501\u2513\u2517\u2501\u2513\u00a0\u2501\u252b\u00a0\u2517\u2501\u2513\u2523\u2501\u252b\u2517\u2501\u252b\u00a0\u00a0\u2503\u2551 \u2551\u257a\u2501\u251b.\u257a\u253b\u2578\u00a0\u00a0\u2579\u257a\u253b\u2578,\u257a\u2501\u251b\u257a\u2501\u251b\u2517\u2501\u2578,\u2517\u2501\u251b\u257a\u2501\u251b\u257a\u2501\u251b,\u257a\u2501\u251b\u2517\u2501\u251b\u257a\u2501\u251b\u00a0\u00a0\u2579\u2551 \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d
from textual.app import App, ComposeResult\nfrom textual.widgets import Digits\nclass DigitApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #pi {\n border: double green;\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Digits(\"3.141,592,653,5897\", id=\"pi\")\nif __name__ == \"__main__\":\napp = DigitApp()\napp.run()\n
Here's another example which uses Digits
to display the current time:
ClockApp \u00a0\u2513\u00a0\u257a\u2501\u2513\u00a0\u00a0\u00a0\u00a0\u2513\u00a0\u257b\u00a0\u257b\u00a0\u00a0\u00a0\u00a0\u2513\u00a0\u257a\u2501\u2513 \u00a0\u2503\u00a0\u00a0\u00a0\u2503\u00a0:\u00a0\u00a0\u2503\u00a0\u2517\u2501\u252b\u00a0:\u00a0\u00a0\u2503\u00a0\u00a0\u00a0\u2503 \u257a\u253b\u2578\u00a0\u00a0\u2579\u00a0\u00a0\u00a0\u257a\u253b\u2578\u00a0\u00a0\u2579\u00a0\u00a0\u00a0\u257a\u253b\u2578\u00a0\u00a0\u2579
from datetime import datetime\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Digits\nclass ClockApp(App):\nCSS = \"\"\"\n Screen {\n align: center middle;\n }\n #clock {\n width: auto;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Digits(\"\", id=\"clock\")\ndef on_ready(self) -> None:\nself.update_clock()\nself.set_interval(1, self.update_clock)\ndef update_clock(self) -> None:\nclock = datetime.now().time()\nself.query_one(Digits).update(f\"{clock:%T}\")\nif __name__ == \"__main__\":\napp = ClockApp()\napp.run()\n
"},{"location":"widgets/digits/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/digits/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/digits/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/digits/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/digits/#textual.widgets.Digits","title":"textual.widgets.Digitsclass
","text":"def __init__(\nself,\nvalue=\"\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A widget to display numerical values using a 3x3 grid of unicode characters.
name: The name of the widget.\nid: The ID of the widget in the DOM.\nclasses: The CSS classes of the widget.\ndisabled: Whether the widget is disabled or not.\n
"},{"location":"widgets/digits/#textual.widgets._digits.Digits.value","title":"value property
","text":"value: str\n
The current value displayed in the Digits.
"},{"location":"widgets/digits/#textual.widgets._digits.Digits.update","title":"updatemethod
","text":"def update(self, value):\n
Update the Digits with a new value.
Parameters Name Type Description Defaultvalue
str
New value to display.
required Raises Type DescriptionValueError
If the value isn't a str
.
A tree control to navigate the contents of your filesystem.
The example below creates a simple tree to navigate the current working directory.
from textual.app import App, ComposeResult\nfrom textual.widgets import DirectoryTree\nclass DirectoryTreeApp(App):\ndef compose(self) -> ComposeResult:\nyield DirectoryTree(\"./\")\nif __name__ == \"__main__\":\napp = DirectoryTreeApp()\napp.run()\n
"},{"location":"widgets/directory_tree/#filtering","title":"Filtering","text":"There may be times where you want to filter what appears in the DirectoryTree
. To do this inherit from DirectoryTree
and implement your own version of the filter_paths
method. It should take an iterable of Python Path
objects, and return those that pass the filter. For example, if you wanted to take the above code an filter out all of the \"hidden\" files and directories:
DirectoryTreeApp \ud83d\udcc2\u00a0. \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0__pycache__ \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0dist \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0docs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0examples \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0imgs \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0notes \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0questions \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0reference \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0sandbox \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0site \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0src \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tests \u2523\u2501\u2501\u00a0\ud83d\udcc1\u00a0tools \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CHANGELOG.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CODE_OF_CONDUCT.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0CONTRIBUTING.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0docs.md \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0faq.yml \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0keys.log \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0LICENSE \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0Makefile \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0mkdocs-common.yml \u2523\u2501\u2501\u00a0\ud83d\udcc4\u00a0mkdocs-nav-offline.yml
from pathlib import Path\nfrom typing import Iterable\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import DirectoryTree\nclass FilteredDirectoryTree(DirectoryTree):\ndef filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:\nreturn [path for path in paths if not path.name.startswith(\".\")]\nclass DirectoryTreeApp(App):\ndef compose(self) -> ComposeResult:\nyield FilteredDirectoryTree(\"./\")\nif __name__ == \"__main__\":\napp = DirectoryTreeApp()\napp.run()\n
"},{"location":"widgets/directory_tree/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_root
bool
True
Show the root node. show_guides
bool
True
Show guide lines between levels. guide_depth
int
4
Amount of indentation between parent and child."},{"location":"widgets/directory_tree/#messages","title":"Messages","text":"The directory tree widget inherits the bindings from the tree widget.
"},{"location":"widgets/directory_tree/#component-classes","title":"Component Classes","text":"The directory tree widget provides the following component classes:
Class Descriptiondirectory-tree--extension
Target the extension of a file name. directory-tree--file
Target files in the directory structure. directory-tree--folder
Target folders in the directory structure. directory-tree--hidden
Target hidden items in the directory structure. See also the component classes for Tree
.
class
","text":"def __init__(\nself,\npath,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Tree[DirEntry]
A Tree widget that presents files and directories.
Parameters Name Type Description Defaultpath
str | Path
Path to directory.
requiredname
str | None
The name of the widget, or None for no name.
None
id
str | None
The ID of the widget in the DOM, or None for no ID.
None
classes
str | None
A space-separated list of classes, or None for no classes.
None
disabled
bool
Whether the directory tree is disabled or not.
False
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"directory-tree--extension\",\n\"directory-tree--file\",\n\"directory-tree--folder\",\n\"directory-tree--hidden\",\n}\n
Class Description directory-tree--extension
Target the extension of a file name. directory-tree--file
Target files in the directory structure. directory-tree--folder
Target folders in the directory structure. directory-tree--hidden
Target hidden items in the directory structure. See also the component classes for Tree
.
class-attribute
instance-attribute
","text":"PATH: Callable[[str | Path], Path] = Path\n
Callable that returns a fresh path object.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.path","title":"pathclass-attribute
instance-attribute
","text":"path: var[str | Path] = path\n
The path that is the root of the directory tree.
NoteThis can be set to either a str
or a pathlib.Path
object, but the value will always be a pathlib.Path
object.
class
","text":"def __init__(self, node, path):\n
Bases: Message
Posted when a directory is selected.
Can be handled using on_directory_tree_directory_selected
in a subclass of DirectoryTree
or in a parent widget in the DOM.
node
TreeNode[DirEntry]
The tree node for the directory that was selected.
requiredpath
Path
The path of the directory that was selected.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.DirectorySelected.control","title":"controlproperty
","text":"control: Tree[DirEntry]\n
The Tree
that had a directory selected.
instance-attribute
","text":"node: TreeNode[DirEntry] = node\n
The tree node of the directory that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.DirectorySelected.path","title":"pathinstance-attribute
","text":"path: Path = path\n
The path of the directory that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected","title":"FileSelectedclass
","text":"def __init__(self, node, path):\n
Bases: Message
Posted when a file is selected.
Can be handled using on_directory_tree_file_selected
in a subclass of DirectoryTree
or in a parent widget in the DOM.
node
TreeNode[DirEntry]
The tree node for the file that was selected.
requiredpath
Path
The path of the file that was selected.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected.control","title":"controlproperty
","text":"control: Tree[DirEntry]\n
The Tree
that had a file selected.
instance-attribute
","text":"node: TreeNode[DirEntry] = node\n
The tree node of the file that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.FileSelected.path","title":"pathinstance-attribute
","text":"path: Path = path\n
The path of the file that was selected.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.clear_node","title":"clear_nodemethod
","text":"def clear_node(self, node):\n
Clear all nodes under the given node.
Returns Type DescriptionSelf
The Tree
instance.
method
","text":"def filter_paths(self, paths):\n
Filter the paths before adding them to the tree.
Parameters Name Type Description Defaultpaths
Iterable[Path]
The paths to be filtered.
required Returns Type DescriptionIterable[Path]
The filtered paths.
By default this method returns all of the paths provided. To create a filtered DirectoryTree
inherit from it and implement your own version of this method.
method
","text":"def process_label(self, label):\n
Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
Parameters Name Type Description Defaultlabel
TextType
Label.
required Returns Type DescriptionText
A Rich Text object.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.reload","title":"reloadmethod
","text":"def reload(self):\n
Reload the DirectoryTree
contents.
method
","text":"def reload_node(self, node):\n
Reload the given node's contents.
Parameters Name Type Description Defaultnode
TreeNode[DirEntry]
The node to reload.
required"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.render_label","title":"render_labelmethod
","text":"def render_label(self, node, base_style, style):\n
Render a label for the given node.
Parameters Name Type Description Defaultnode
TreeNode[DirEntry]
A tree node.
requiredbase_style
Style
The base style of the widget.
requiredstyle
Style
The additional style for the label.
required Returns Type DescriptionText
A Rich Text object containing the label.
"},{"location":"widgets/directory_tree/#textual.widgets._directory_tree.DirectoryTree.reset_node","title":"reset_nodemethod
","text":"def reset_node(self, node, label, data=None):\n
Clear the subtree and reset the given node.
Parameters Name Type Description Defaultlabel
TextType
The label for the node.
requireddata
DirEntry | None
Optional data for the node.
None
Returns Type Description Self
The Tree
instance.
method
","text":"def validate_path(self, path):\n
Ensure that the path is of the Path
type.
path
str | Path
The path to validate.
required Returns Type DescriptionPath
The validated Path value.
NoteThe result will always be a Python Path
object, regardless of the value given.
method
","text":"def watch_path(self):\n
Watch for changes to the path
of the directory tree.
If the path is changed the directory tree will be repopulated using the new value as the root.
"},{"location":"widgets/footer/","title":"Footer","text":"A simple footer widget which is docked to the bottom of its parent container. Displays available keybindings for the currently focused widget.
The example below shows an app with a single keybinding that contains only a Footer
widget. Notice how the Footer
automatically displays the keybinding.
FooterApp \u00a0Q\u00a0\u00a0Quit\u00a0the\u00a0app\u00a0\u00a0?\u00a0\u00a0Show\u00a0help\u00a0screen\u00a0\u00a0DELETE\u00a0\u00a0Delete\u00a0the\u00a0thing\u00a0
from textual.app import App, ComposeResult\nfrom textual.binding import Binding\nfrom textual.widgets import Footer\nclass FooterApp(App):\nBINDINGS = [\nBinding(key=\"q\", action=\"quit\", description=\"Quit the app\"),\nBinding(\nkey=\"question_mark\",\naction=\"help\",\ndescription=\"Show help screen\",\nkey_display=\"?\",\n),\nBinding(key=\"delete\", action=\"delete\", description=\"Delete the thing\"),\nBinding(key=\"j\", action=\"down\", description=\"Scroll down\", show=False),\n]\ndef compose(self) -> ComposeResult:\nyield Footer()\nif __name__ == \"__main__\":\napp = FooterApp()\napp.run()\n
"},{"location":"widgets/footer/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlight_key
str
None
Stores the currently highlighted key. This is typically the key the cursor is hovered over in the footer."},{"location":"widgets/footer/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/footer/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/footer/#component-classes","title":"Component Classes","text":"The footer widget provides the following component classes:
Class Descriptionfooter--description
Targets the descriptions of the key bindings. footer--highlight
Targets the highlighted key binding. footer--highlight-key
Targets the key portion of the highlighted key binding. footer--key
Targets the key portions of the key bindings."},{"location":"widgets/footer/#additional-notes","title":"Additional Notes","text":"show
argument of the Binding
to False
.key_display
argument of Binding
.class
","text":"def __init__(self):\n
Bases: Widget
A simple footer widget which docks itself to the bottom of the parent container.
"},{"location":"widgets/footer/#textual.widgets._footer.Footer.COMPONENT_CLASSES","title":"COMPONENT_CLASSESclass-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"footer--description\",\n\"footer--key\",\n\"footer--highlight\",\n\"footer--highlight-key\",\n}\n
Class Description footer--description
Targets the descriptions of the key bindings. footer--highlight
Targets the highlighted key binding. footer--highlight-key
Targets the key portion of the highlighted key binding. footer--key
Targets the key portions of the key bindings."},{"location":"widgets/footer/#textual.widgets._footer.Footer.watch_highlight_key","title":"watch_highlight_key async
","text":"def watch_highlight_key(self):\n
If highlight key changes we need to regenerate the text.
"},{"location":"widgets/header/","title":"Header","text":"A simple header widget which docks itself to the top of the parent container.
Note
The application title which is shown in the header is taken from the title
and sub_title
of the application.
The example below shows an app with a Header
.
HeaderApp \u2b58HeaderApp
from textual.app import App, ComposeResult\nfrom textual.widgets import Header\nclass HeaderApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\nif __name__ == \"__main__\":\napp = HeaderApp()\napp.run()\n
This example shows how to set the text in the Header
using App.title
and App.sub_title
:
HeaderApp \u2b58Header\u00a0Application\u00a0\u2014\u00a0With\u00a0title\u00a0and\u00a0sub-title
from textual.app import App, ComposeResult\nfrom textual.widgets import Header\nclass HeaderApp(App):\ndef compose(self) -> ComposeResult:\nyield Header()\ndef on_mount(self) -> None:\nself.title = \"Header Application\"\nself.sub_title = \"With title and sub-title\"\nif __name__ == \"__main__\":\napp = HeaderApp()\napp.run()\n
"},{"location":"widgets/header/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description tall
bool
True
Whether the Header
widget is displayed as tall or not. The tall variant is 3 cells tall by default. The non-tall variant is a single cell tall. This can be toggled by clicking on the header."},{"location":"widgets/header/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/header/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/header/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/header/#textual.widgets.Header","title":"textual.widgets.Headerclass
","text":"def __init__(\nself,\nshow_clock=False,\n*,\nname=None,\nid=None,\nclasses=None\n):\n
Bases: Widget
A header widget with icon and clock.
Parameters Name Type Description Defaultshow_clock
bool
True
if the clock should be shown on the right of the header.
False
name
str | None
The name of the header widget.
None
id
str | None
The ID of the header widget in the DOM.
None
classes
str | None
The CSS classes of the header widget.
None
"},{"location":"widgets/header/#textual.widgets._header.Header.screen_sub_title","title":"screen_sub_title property
","text":"screen_sub_title: str\n
The sub-title that this header will display.
This depends on Screen.sub_title
and App.sub_title
.
property
","text":"screen_title: str\n
The title that this header will display.
This depends on Screen.title
and App.title
.
class-attribute
instance-attribute
","text":"tall: Reactive[bool] = Reactive(False)\n
Set to True
for a taller header or False
for a single line header.
A single-line text input widget.
The example below shows how you might create a simple form using two Input
widgets.
InputApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aDarren\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aLast\u00a0Name\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Input\nclass InputApp(App):\ndef compose(self) -> ComposeResult:\nyield Input(placeholder=\"First Name\")\nyield Input(placeholder=\"Last Name\")\nif __name__ == \"__main__\":\napp = InputApp()\napp.run()\n
"},{"location":"widgets/input/#validating-input","title":"Validating Input","text":"You can supply one or more validators to the Input
widget to validate the value.
All the supplied validators will run when the value changes, the Input
is submitted, or focus moves out of the Input
. The values \"changed\"
, \"submitted\"
, and \"blur\"
, can be passed as an iterable to the Input
parameter validate_on
to request that validation occur only on the respective mesages. (See InputValidationOn
and Input.validate_on
.) For example, the code below creates an Input
widget that only gets validated when the value is submitted explicitly:
input = Input(validate_on=[\"submitted\"])\n
Validation is considered to have failed if any of the validators fail.
You can check whether the validation succeeded or failed inside an Input.Changed or Input.Submitted handler by looking at the validation_result
attribute on these events.
In the example below, we show how to combine multiple validators and update the UI to tell the user why validation failed. Click the tabs to see the output for validation failures and successes.
input_validation.pyValidation FailureValidation Successfrom textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.validation import Function, Number, ValidationResult, Validator\nfrom textual.widgets import Input, Label, Pretty\nclass InputApp(App):\n# (6)!\nCSS = \"\"\"\n Input.-valid {\n border: tall $success 60%;\n }\n Input.-valid:focus {\n border: tall $success;\n }\n Input {\n margin: 1 1;\n }\n Label {\n margin: 1 2;\n }\n Pretty {\n margin: 1 2;\n }\n \"\"\"\ndef compose(self) -> ComposeResult:\nyield Label(\"Enter an even number between 1 and 100 that is also a palindrome.\")\nyield Input(\nplaceholder=\"Enter a number...\",\nvalidators=[\nNumber(minimum=1, maximum=100), # (1)!\nFunction(is_even, \"Value is not even.\"), # (2)!\nPalindrome(), # (3)!\n],\n)\nyield Pretty([])\n@on(Input.Changed)\ndef show_invalid_reasons(self, event: Input.Changed) -> None:\n# Updating the UI to show the reasons why validation failed\nif not event.validation_result.is_valid: # (4)!\nself.query_one(Pretty).update(event.validation_result.failure_descriptions)\nelse:\nself.query_one(Pretty).update([])\ndef is_even(value: str) -> bool:\ntry:\nreturn int(value) % 2 == 0\nexcept ValueError:\nreturn False\n# A custom validator\nclass Palindrome(Validator): # (5)!\ndef validate(self, value: str) -> ValidationResult:\n\"\"\"Check a string is equal to its reverse.\"\"\"\nif self.is_palindrome(value):\nreturn self.success()\nelse:\nreturn self.failure(\"That's not a palindrome :/\")\n@staticmethod\ndef is_palindrome(value: str) -> bool:\nreturn value == value[::-1]\napp = InputApp()\nif __name__ == \"__main__\":\napp.run()\n
Number
is a built-in Validator
. It checks that the value in the Input
is a valid number, and optionally can check that it falls within a range.Function
lets you quickly define custom validation constraints. In this case, we check the value in the Input
is even.Palindrome
is a custom Validator
defined below.Input.Changed
event has a validation_result
attribute which contains information about the validation that occurred when the value changed.self.failure
corresponds to the message seen on UI.-invalid
CSS class (a red border), which is automatically applied to Input
when validation fails. We can also provide custom styling for the -valid
class, as seen here. In this case, we add a green border around the Input
to indicate successful validation.InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a-23\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e [ 'Must\u00a0be\u00a0between\u00a01\u00a0and\u00a0100.', 'Value\u00a0is\u00a0not\u00a0even.', \"That's\u00a0not\u00a0a\u00a0palindrome\u00a0:/\" ]
InputApp Enter\u00a0an\u00a0even\u00a0number\u00a0between\u00a01\u00a0and\u00a0100\u00a0that\u00a0is\u00a0also\u00a0a\u00a0palindrome. \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a44\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e []
Textual offers several built-in validators for common requirements, but you can easily roll your own by extending Validator, as seen for Palindrome
in the example above.
cursor_blink
bool
True
True if cursor blinking is enabled. value
str
\"\"
The value currently in the text input. cursor_position
int
0
The index of the cursor in the value string. placeholder
str
str
The dimmed placeholder text to display when the input is empty. password
bool
False
True if the input should be masked."},{"location":"widgets/input/#messages","title":"Messages","text":"The input widget defines the following bindings:
Key(s) Description left Move the cursor left. ctrl+left Move the cursor one word to the left. right Move the cursor right or accept the completion suggestion. ctrl+right Move the cursor one word to the right. backspace Delete the character to the left of the cursor. home,ctrl+a Go to the beginning of the input. end,ctrl+e Go to the end of the input. delete,ctrl+d Delete the character to the right of the cursor. enter Submit the current value of the input. ctrl+w Delete the word to the left of the cursor. ctrl+u Delete everything to the left of the cursor. ctrl+f Delete the word to the right of the cursor. ctrl+k Delete everything to the right of the cursor."},{"location":"widgets/input/#component-classes","title":"Component Classes","text":"The input widget provides the following component classes:
Class Descriptioninput--cursor
Target the cursor. input--placeholder
Target the placeholder text (when it exists). input--suggestion
Target the auto-completion suggestion (when it exists)."},{"location":"widgets/input/#additional-notes","title":"Additional Notes","text":"border: none;
in your CSS.class
","text":"def __init__(\nself,\nvalue=None,\nplaceholder=\"\",\nhighlighter=None,\npassword=False,\n*,\nsuggester=None,\nvalidators=None,\nvalidate_on=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A text input widget.
Parameters Name Type Description Defaultvalue
str | None
An optional default value for the input.
None
placeholder
str
Optional placeholder text for the input.
''
highlighter
Highlighter | None
An optional highlighter for the input.
None
password
bool
Flag to say if the field should obfuscate its content.
False
suggester
Suggester | None
Suggester
associated with this input instance.
None
validators
Validator | Iterable[Validator] | None
An iterable of validators that the Input value will be checked against.
None
validate_on
Iterable[InputValidationOn] | None
Zero or more of the values \"blur\", \"changed\", and \"submitted\", which determine when to do input validation. The default is to do validation for all messages.
None
name
str | None
Optional name for the input widget.
None
id
str | None
Optional ID for the widget.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the input is disabled or not.
False
"},{"location":"widgets/input/#textual.widgets._input.Input.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"left\", \"cursor_left\", \"cursor left\", show=False\n),\nBinding(\n\"ctrl+left\",\n\"cursor_left_word\",\n\"cursor left word\",\nshow=False,\n),\nBinding(\n\"right\", \"cursor_right\", \"cursor right\", show=False\n),\nBinding(\n\"ctrl+right\",\n\"cursor_right_word\",\n\"cursor right word\",\nshow=False,\n),\nBinding(\n\"backspace\",\n\"delete_left\",\n\"delete left\",\nshow=False,\n),\nBinding(\"home,ctrl+a\", \"home\", \"home\", show=False),\nBinding(\"end,ctrl+e\", \"end\", \"end\", show=False),\nBinding(\n\"delete,ctrl+d\",\n\"delete_right\",\n\"delete right\",\nshow=False,\n),\nBinding(\"enter\", \"submit\", \"submit\", show=False),\nBinding(\n\"ctrl+w\",\n\"delete_left_word\",\n\"delete left to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+u\",\n\"delete_left_all\",\n\"delete all to the left\",\nshow=False,\n),\nBinding(\n\"ctrl+f\",\n\"delete_right_word\",\n\"delete right to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+k\",\n\"delete_right_all\",\n\"delete all to the right\",\nshow=False,\n),\n]\n
Key(s) Description left Move the cursor left. ctrl+left Move the cursor one word to the left. right Move the cursor right or accept the completion suggestion. ctrl+right Move the cursor one word to the right. backspace Delete the character to the left of the cursor. home,ctrl+a Go to the beginning of the input. end,ctrl+e Go to the end of the input. delete,ctrl+d Delete the character to the right of the cursor. enter Submit the current value of the input. ctrl+w Delete the word to the left of the cursor. ctrl+u Delete everything to the left of the cursor. ctrl+f Delete the word to the right of the cursor. ctrl+k Delete everything to the right of the cursor."},{"location":"widgets/input/#textual.widgets._input.Input.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"input--cursor\",\n\"input--placeholder\",\n\"input--suggestion\",\n}\n
Class Description input--cursor
Target the cursor. input--placeholder
Target the placeholder text (when it exists). input--suggestion
Target the auto-completion suggestion (when it exists)."},{"location":"widgets/input/#textual.widgets._input.Input.cursor_screen_offset","title":"cursor_screen_offset property
","text":"cursor_screen_offset: Offset\n
The offset of the cursor of this input in screen-space. (x, y)/(column, row)
"},{"location":"widgets/input/#textual.widgets._input.Input.cursor_width","title":"cursor_widthproperty
","text":"cursor_width: int\n
The width of the input (with extra space for cursor at the end).
"},{"location":"widgets/input/#textual.widgets._input.Input.suggester","title":"suggesterinstance-attribute
","text":"suggester: Suggester | None = suggester\n
The suggester used to provide completions as the user types.
"},{"location":"widgets/input/#textual.widgets._input.Input.validate_on","title":"validate_oninstance-attribute
","text":"validate_on = (\nset(validate_on) & _POSSIBLE_VALIDATE_ON_VALUES\nif validate_on is not None\nelse _POSSIBLE_VALIDATE_ON_VALUES\n)\n
Set with event names to do input validation on.
Validation can only be performed on blur, on input changes and on input submission.
ExampleThis creates an Input
widget that only gets validated when the value is submitted explicitly:
input = Input(validate_on=[\"submitted\"])\n
"},{"location":"widgets/input/#textual.widgets._input.Input.Changed","title":"Changed class
","text":" Bases: Message
Posted when the value changes.
Can be handled using on_input_changed
in a subclass of Input
or in a parent widget in the DOM.
property
","text":"control: Input\n
Alias for self.input.
"},{"location":"widgets/input/#textual.widgets._input.Input.Changed.input","title":"inputinstance-attribute
","text":"input: Input\n
The Input
widget that was changed.
class-attribute
instance-attribute
","text":"validation_result: ValidationResult | None = None\n
The result of validating the value (formed by combining the results from each validator), or None if validation was not performed (for example when no validators are specified in the Input
s init)
instance-attribute
","text":"value: str\n
The value that the input was changed to.
"},{"location":"widgets/input/#textual.widgets._input.Input.Submitted","title":"Submittedclass
","text":" Bases: Message
Posted when the enter key is pressed within an Input
.
Can be handled using on_input_submitted
in a subclass of Input
or in a parent widget in the DOM.
property
","text":"control: Input\n
Alias for self.input.
"},{"location":"widgets/input/#textual.widgets._input.Input.Submitted.input","title":"inputinstance-attribute
","text":"input: Input\n
The Input
widget that is being submitted.
class-attribute
instance-attribute
","text":"validation_result: ValidationResult | None = None\n
The result of validating the value on submission, formed by combining the results for each validator. This value will be None if no validation was performed, which will be the case if no validators are supplied to the corresponding Input
widget.
instance-attribute
","text":"value: str\n
The value of the Input
being submitted.
method
","text":"def action_cursor_left(self):\n
Move the cursor one position to the left.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_left_word","title":"action_cursor_left_wordmethod
","text":"def action_cursor_left_word(self):\n
Move the cursor left to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_right","title":"action_cursor_rightmethod
","text":"def action_cursor_right(self):\n
Accept an auto-completion or move the cursor one position to the right.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_cursor_right_word","title":"action_cursor_right_wordmethod
","text":"def action_cursor_right_word(self):\n
Move the cursor right to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left","title":"action_delete_leftmethod
","text":"def action_delete_left(self):\n
Delete one character to the left of the current cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left_all","title":"action_delete_left_allmethod
","text":"def action_delete_left_all(self):\n
Delete all characters to the left of the cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_left_word","title":"action_delete_left_wordmethod
","text":"def action_delete_left_word(self):\n
Delete leftward of the cursor position to the start of a word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right","title":"action_delete_rightmethod
","text":"def action_delete_right(self):\n
Delete one character at the current cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right_all","title":"action_delete_right_allmethod
","text":"def action_delete_right_all(self):\n
Delete the current character and all characters to the right of the cursor position.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_delete_right_word","title":"action_delete_right_wordmethod
","text":"def action_delete_right_word(self):\n
Delete the current character and all rightward to the start of the next word.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_end","title":"action_endmethod
","text":"def action_end(self):\n
Move the cursor to the end of the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_home","title":"action_homemethod
","text":"def action_home(self):\n
Move the cursor to the start of the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.action_submit","title":"action_submitasync
","text":"def action_submit(self):\n
Handle a submit action.
Normally triggered by the user pressing Enter. This may also run any validators.
"},{"location":"widgets/input/#textual.widgets._input.Input.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the input.
"},{"location":"widgets/input/#textual.widgets._input.Input.insert_text_at_cursor","title":"insert_text_at_cursormethod
","text":"def insert_text_at_cursor(self, text):\n
Insert new text at the cursor, move the cursor to the end of the new text.
Parameters Name Type Description Defaulttext
str
New text to insert.
required"},{"location":"widgets/input/#textual.widgets._input.Input.validate","title":"validatemethod
","text":"def validate(self, value):\n
Run all the validators associated with this Input on the supplied value.
Runs all validators, combines the result into one. If any of the validators failed, the combined result will be a failure. If no validators are present, None will be returned. This also sets the -invalid
CSS class on the Input if the validation fails, and sets the -valid
CSS class on the Input if the validation succeeds.
ValidationResult | None
A ValidationResult indicating whether all validators succeeded or not. That is, if any validator fails, the result will be an unsuccessful validation.
"},{"location":"widgets/label/","title":"Label","text":"Added in version 0.5.0
A widget which displays static text, but which can also contain more complex Rich renderables.
The example below shows how you can use a Label
widget to display some text.
LabelApp Hello,\u00a0world!
from textual.app import App, ComposeResult\nfrom textual.widgets import Label\nclass LabelApp(App):\ndef compose(self) -> ComposeResult:\nyield Label(\"Hello, world!\")\nif __name__ == \"__main__\":\napp = LabelApp()\napp.run()\n
"},{"location":"widgets/label/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/label/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/label/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/label/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/label/#textual.widgets.Label","title":"textual.widgets.Labelclass
","text":" Bases: Static
A simple label widget for displaying text-oriented renderables.
"},{"location":"widgets/list_item/","title":"ListItem","text":"Added in version 0.6.0
ListItem
is the type of the elements in a ListView
.
The example below shows an app with a simple ListView
, consisting of multiple ListItem
s. The arrow keys can be used to navigate the list.
ListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
"},{"location":"widgets/list_item/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
bool
False
True if this ListItem is highlighted"},{"location":"widgets/list_item/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/list_item/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/list_item/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/list_item/#textual.widgets.ListItem","title":"textual.widgets.ListItemclass
","text":" Bases: Widget
A widget that is an item within a ListView
.
A ListItem
is designed for use within a ListView, please see ListView
's documentation for more details on use.
class-attribute
instance-attribute
","text":"highlighted = reactive(False)\n
Is this item highlighted?
"},{"location":"widgets/list_view/","title":"ListView","text":"Added in version 0.6.0
Displays a vertical list of ListItem
s which can be highlighted and selected. Supports keyboard navigation.
The example below shows an app with a simple ListView
.
ListViewExample One Two Three
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, ListItem, ListView\nclass ListViewExample(App):\nCSS_PATH = \"list_view.tcss\"\ndef compose(self) -> ComposeResult:\nyield ListView(\nListItem(Label(\"One\")),\nListItem(Label(\"Two\")),\nListItem(Label(\"Three\")),\n)\nyield Footer()\nif __name__ == \"__main__\":\napp = ListViewExample()\napp.run()\n
Screen {\nalign: center middle;\n}\nListView {\nwidth: 30;\nheight: auto;\nmargin: 2 2;\n}\nLabel {\npadding: 1 2;\n}\n
"},{"location":"widgets/list_view/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description index
int
0
The currently highlighted index."},{"location":"widgets/list_view/#messages","title":"Messages","text":"The list view widget defines the following bindings:
Key(s) Description enter Select the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/list_view/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/list_view/#textual.widgets.ListView","title":"textual.widgets.ListViewclass
","text":"def __init__(\nself,\n*children,\ninitial_index=0,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: VerticalScroll
A vertical list view widget.
Displays a vertical list of ListItem
s which can be highlighted and selected using the mouse or keyboard.
index
The index in the list that's currently highlighted.
Parameters Name Type Description Default*children
ListItem
The ListItems to display in the list.
()
initial_index
int | None
The index that should be highlighted when the list is first mounted.
0
name
str | None
The name of the widget.
None
id
str | None
The unique ID of the widget used in CSS/query selection.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the ListView is disabled or not.
False
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\n]\n
Key(s) Description enter Select the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.highlighted_child","title":"highlighted_child property
","text":"highlighted_child: ListItem | None\n
The currently highlighted ListItem, or None if nothing is highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Highlighted","title":"Highlightedclass
","text":"def __init__(self, list_view, item):\n
Bases: Message
Posted when the highlighted item changes.
Highlighted item is controlled using up/down keys. Can be handled using on_list_view_highlighted
in a subclass of ListView
or in a parent widget in the DOM.
class-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'item'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: ListView\n
The view that contains the item highlighted.
This is an alias for Highlighted.list_view
and is used by the on
decorator.
instance-attribute
","text":"item: ListItem | None = item\n
The highlighted item, if there is one highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Highlighted.list_view","title":"list_viewinstance-attribute
","text":"list_view: ListView = list_view\n
The view that contains the item highlighted.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Selected","title":"Selectedclass
","text":"def __init__(self, list_view, item):\n
Bases: Message
Posted when a list item is selected, e.g. when you press the enter key on it.
Can be handled using on_list_view_selected
in a subclass of ListView
or in a parent widget in the DOM.
class-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'item'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: ListView\n
The view that contains the item selected.
This is an alias for Selected.list_view
and is used by the on
decorator.
instance-attribute
","text":"item: ListItem = item\n
The selected item.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.Selected.list_view","title":"list_viewinstance-attribute
","text":"list_view: ListView = list_view\n
The view that contains the item selected.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_cursor_down","title":"action_cursor_downmethod
","text":"def action_cursor_down(self):\n
Highlight the next item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Highlight the previous item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.action_select_cursor","title":"action_select_cursormethod
","text":"def action_select_cursor(self):\n
Select the current item in the list.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.append","title":"appendmethod
","text":"def append(self, item):\n
Append a new ListItem to the end of the ListView.
Parameters Name Type Description Defaultitem
ListItem
The ListItem to append.
required Returns Type DescriptionAwaitMount
An awaitable that yields control to the event loop until the DOM has been updated with the new child item.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.clear","title":"clearmethod
","text":"def clear(self):\n
Clear all items from the ListView.
Returns Type DescriptionAwaitRemove
An awaitable that yields control to the event loop until the DOM has been updated to reflect all children being removed.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.extend","title":"extendmethod
","text":"def extend(self, items):\n
Append multiple new ListItems to the end of the ListView.
Parameters Name Type Description Defaultitems
Iterable[ListItem]
The ListItems to append.
required Returns Type DescriptionAwaitMount
An awaitable that yields control to the event loop until the DOM has been updated with the new child items.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.validate_index","title":"validate_indexmethod
","text":"def validate_index(self, index):\n
Clamp the index to the valid range, or set to None if there's nothing to highlight.
Parameters Name Type Description Defaultindex
int | None
The index to clamp.
required Returns Type Descriptionint | None
The clamped index.
"},{"location":"widgets/list_view/#textual.widgets._list_view.ListView.watch_index","title":"watch_indexmethod
","text":"def watch_index(self, old_index, new_index):\n
Updates the highlighting when the index changes.
"},{"location":"widgets/loading_indicator/","title":"LoadingIndicator","text":"Added in version 0.15.0
Displays pulsating dots to indicate when data is being loaded.
Simple usage example:
Outputloading_indicator.pyLoadingApp \u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf\u00a0\u25cf
from textual.app import App, ComposeResult\nfrom textual.widgets import LoadingIndicator\nclass LoadingApp(App):\ndef compose(self) -> ComposeResult:\nyield LoadingIndicator()\nif __name__ == \"__main__\":\napp = LoadingApp()\napp.run()\n
"},{"location":"widgets/loading_indicator/#changing-indicator-color","title":"Changing Indicator Color","text":"You can set the color of the loading indicator by setting its color
style.
Here's how you would do that with CSS:
LoadingIndicator {\ncolor: red;\n}\n
"},{"location":"widgets/loading_indicator/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/loading_indicator/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/loading_indicator/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/loading_indicator/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/loading_indicator/#textual.widgets.LoadingIndicator","title":"textual.widgets.LoadingIndicatorclass
","text":" Bases: Widget
Display an animated loading indicator.
"},{"location":"widgets/log/","title":"Log","text":"Added in version 0.32.0
A Log widget displays lines of text which may be appended to in realtime.
Call Log.write_line to write a line at a time, or Log.write_lines to write multiple lines at once. Call Log.clear to clear the Log widget.
Tip
See also RichLog which can write more than just text, and supports a number of advanced features.
The example below shows how to write text to a Log
widget:
LogApp And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain. I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.\u2584\u2584 I\u00a0must\u00a0not\u00a0fear. Fear\u00a0is\u00a0the\u00a0mind-killer. Fear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0obliteration. I\u00a0will\u00a0face\u00a0my\u00a0fear. I\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me. And\u00a0when\u00a0it\u00a0has\u00a0gone\u00a0past,\u00a0I\u00a0will\u00a0turn\u00a0the\u00a0inner\u00a0eye\u00a0to\u00a0see\u00a0its\u00a0path. Where\u00a0the\u00a0fear\u00a0has\u00a0gone\u00a0there\u00a0will\u00a0be\u00a0nothing.\u00a0Only\u00a0I\u00a0will\u00a0remain.
from textual.app import App, ComposeResult\nfrom textual.widgets import Log\nTEXT = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\nAnd when it has gone past, I will turn the inner eye to see its path.\nWhere the fear has gone there will be nothing. Only I will remain.\"\"\"\nclass LogApp(App):\n\"\"\"An app with a simple log.\"\"\"\ndef compose(self) -> ComposeResult:\nyield Log()\ndef on_ready(self) -> None:\nlog = self.query_one(Log)\nlog.write_line(\"Hello, World!\")\nfor _ in range(10):\nlog.write_line(TEXT)\nif __name__ == \"__main__\":\napp = LogApp()\napp.run()\n
"},{"location":"widgets/log/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description max_lines
int
None
Maximum number of lines in the log or None
for no maximum. auto_scroll
bool
False
Scroll to end of log when new lines are added."},{"location":"widgets/log/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/log/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/log/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/log/#textual.widgets.Log","title":"textual.widgets.Logclass
","text":"def __init__(\nself,\nhighlight=False,\nmax_lines=None,\nauto_scroll=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Bases: ScrollView
A widget to log text.
Parameters Name Type Description Defaulthighlight
bool
Enable highlighting.
False
max_lines
int | None
Maximum number of lines to display.
None
auto_scroll
bool
Scroll to end on new lines.
True
name
str | None
The name of the text log.
None
id
str | None
The ID of the text log in the DOM.
None
classes
str | None
The CSS classes of the text log.
None
disabled
bool
Whether the text log is disabled or not.
False
"},{"location":"widgets/log/#textual.widgets._log.Log.auto_scroll","title":"auto_scroll class-attribute
instance-attribute
","text":"auto_scroll: var[bool] = auto_scroll\n
Automatically scroll to new lines.
"},{"location":"widgets/log/#textual.widgets._log.Log.highlight","title":"highlightinstance-attribute
","text":"highlight = highlight\n
Enable highlighting.
"},{"location":"widgets/log/#textual.widgets._log.Log.highlighter","title":"highlighterinstance-attribute
","text":"highlighter = ReprHighlighter()\n
The Rich Highlighter object to use, if highlight=True
property
","text":"line_count: int\n
Number of lines of content.
"},{"location":"widgets/log/#textual.widgets._log.Log.lines","title":"linesproperty
","text":"lines: Sequence[str]\n
The raw lines in the TextLog.
Note that this attribute is read only. Changing the lines will not update the Log's contents.
"},{"location":"widgets/log/#textual.widgets._log.Log.max_lines","title":"max_linesclass-attribute
instance-attribute
","text":"max_lines: var[int | None] = max_lines\n
Maximum number of lines to show
"},{"location":"widgets/log/#textual.widgets._log.Log.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the Log.
Returns Type DescriptionSelf
The Log
instance.
method
","text":"def notify_style_update(self):\n
Called by Textual when styles update.
"},{"location":"widgets/log/#textual.widgets._log.Log.refresh_lines","title":"refresh_linesmethod
","text":"def refresh_lines(self, y_start, line_count=1):\n
Refresh one or more lines.
Parameters Name Type Description Defaulty_start
int
First line to refresh.
requiredline_count
int
Total number of lines to refresh.
1
"},{"location":"widgets/log/#textual.widgets._log.Log.write","title":"write method
","text":"def write(self, data, scroll_end=None):\n
Write to the log.
Parameters Name Type Description Defaultdata
str
Data to write.
requiredscroll_end
bool | None
Scroll to the end after writing, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The Log
instance.
method
","text":"def write_line(self, line):\n
Write content on a new line.
Parameters Name Type Description Defaultline
str
String to write to the log.
required Returns Type DescriptionSelf
The Log
instance.
method
","text":"def write_lines(self, lines, scroll_end=None):\n
Write an iterable of lines.
Parameters Name Type Description Defaultlines
Iterable[str]
An iterable of strings to write.
requiredscroll_end
bool | None
Scroll to the end after writing, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The Log
instance.
Added in version 0.11.0
A widget to display a Markdown document.
Tip
See MarkdownViewer for a widget that adds additional features such as a Table of Contents.
"},{"location":"widgets/markdown/#example","title":"Example","text":"The following example displays Markdown from a string.
Outputmarkdown.pyMarkdownExampleApp \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eMarkdown\u00a0Document\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 This\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0Markdown\u00a0widget. \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Markdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u25cf\u00a0Headers \u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u25cf\u00a0Tables!
from textual.app import App, ComposeResult\nfrom textual.widgets import Markdown\nEXAMPLE_MARKDOWN = \"\"\"\\\n# Markdown Document\nThis is an example of Textual's `Markdown` widget.\n## Features\nMarkdown syntax and extensions are supported.\n- Typography *emphasis*, **strong**, `inline code` etc.\n- Headers\n- Lists (bullet and ordered)\n- Syntax highlighted code blocks\n- Tables!\n\"\"\"\nclass MarkdownExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield Markdown(EXAMPLE_MARKDOWN)\nif __name__ == \"__main__\":\napp = MarkdownExampleApp()\napp.run()\n
"},{"location":"widgets/markdown/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/markdown/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/markdown/#component-classes","title":"Component Classes","text":"The markdown widget provides the following component classes:
These component classes target standard inline markdown styles. Changing these will potentially break the standard markdown formatting.
Class Descriptioncode_inline
Target text that is styled as inline code. em
Target text that is emphasized inline. s
Target text that is styled inline with strykethrough. strong
Target text that is styled inline with strong."},{"location":"widgets/markdown/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nmarkdown=None,\n*,\nname=None,\nid=None,\nclasses=None,\nparser_factory=None\n):\n
Bases: Widget
markdown
str | None
String containing Markdown or None to leave blank for now.
None
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
parser_factory
Callable[[], MarkdownIt] | None
A factory function to return a configured MarkdownIt instance. If None
, a \"gfm-like\" parser is used.
None
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
instance-attribute
","text":"COMPONENT_CLASSES = {'em', 'strong', 's', 'code_inline'}\n
These component classes target standard inline markdown styles. Changing these will potentially break the standard markdown formatting.
Class Descriptioncode_inline
Target text that is styled as inline code. em
Target text that is emphasized inline. s
Target text that is styled inline with strykethrough. strong
Target text that is styled inline with strong."},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked","title":"LinkClicked class
","text":"def __init__(self, markdown, href):\n
Bases: Message
A link in the document was clicked.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget containing the link clicked.
This is an alias for LinkClicked.markdown
and is used by the on
decorator.
instance-attribute
","text":"href: str = href\n
The link that was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.LinkClicked.markdown","title":"markdowninstance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget containing the link clicked.
class
","text":"def __init__(self, markdown, block_id):\n
Bases: Message
An item in the TOC was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsSelected.block_id","title":"block_idinstance-attribute
","text":"block_id: str = block_id\n
ID of the block that was selected.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsSelected.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget where the selected item is.
This is an alias for TableOfContentsSelected.markdown
and is used by the on
decorator.
instance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget where the selected item is.
class
","text":"def __init__(self, markdown, table_of_contents):\n
Bases: Message
The table of contents was updated.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.TableOfContentsUpdated.control","title":"controlproperty
","text":"control: Markdown\n
The Markdown
widget associated with the table of contents.
This is an alias for TableOfContentsUpdated.markdown
and is used by the on
decorator.
instance-attribute
","text":"markdown: Markdown = markdown\n
The Markdown
widget associated with the table of contents.
instance-attribute
","text":"table_of_contents: TableOfContentsType = table_of_contents\n
Table of contents.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.goto_anchor","title":"goto_anchormethod
","text":"def goto_anchor(self, anchor):\n
Try and find the given anchor in the current document.
Parameters Name Type Description Defaultanchor
str
The anchor to try and find.
required NoteThe anchor is found by looking at all of the headings in the document and finding the first one whose slug matches the anchor.
Note that the slugging method used is similar to that found on GitHub.
Returns Type Descriptionbool
True when the anchor was found in the current document, False otherwise.
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.load","title":"loadasync
","text":"def load(self, path):\n
Load a new Markdown document.
Parameters Name Type Description Defaultpath
Path
Path to the document.
required Raises Type DescriptionOSError
If there was some form of error loading the document.
NoteThe exceptions that can be raised by this method are all of those that can be raised by calling Path.read_text
.
staticmethod
","text":"def sanitize_location(location):\n
Given a location, break out the path and any anchor.
Parameters Name Type Description Defaultlocation
str
The location to sanitize.
required Returns Type DescriptionPath
A tuple of the path to the location cleaned of any anchor, plus
str
the anchor (or an empty string if none was found).
"},{"location":"widgets/markdown/#textual.widgets._markdown.Markdown.unhandled_token","title":"unhandled_tokenmethod
","text":"def unhandled_token(self, token):\n
Process an unhandled token.
Parameters Name Type Description Defaulttoken
Token
The token to handle.
required Returns Type DescriptionMarkdownBlock | None
Either a widget to be added to the output, or None
.
method
","text":"def update(self, markdown):\n
Update the document with new Markdown.
Parameters Name Type Description Defaultmarkdown
str
A string containing Markdown.
required Returns Type DescriptionAwaitMount
An optionally awaitable object. Await this to ensure that all children have been mounted.
"},{"location":"widgets/markdown_viewer/","title":"MarkdownViewer","text":"Added in version 0.11.0
A Widget to display Markdown content with an optional Table of Contents.
Note
This Widget adds browser-like functionality on top of the Markdown widget.
"},{"location":"widgets/markdown_viewer/#example","title":"Example","text":"The following example displays Markdown from a string and a Table of Contents.
Outputmarkdown.pyMarkdownExampleApp \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u25bc\u00a0\u2160\u00a0Markdown\u00a0Viewer\u258a\u258e\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Features\u258a\u258eMarkdown\u00a0Viewer\u258a \u251c\u2500\u2500\u00a0\u2161\u00a0Tables\u258a\u258e\u258a \u2514\u2500\u2500\u00a0\u2161\u00a0Code\u00a0Blocks\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aThis\u00a0is\u00a0an\u00a0example\u00a0of\u00a0Textual's\u00a0MarkdownViewer\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Features\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aMarkdown\u00a0syntax\u00a0and\u00a0extensions\u00a0are\u00a0supported. \u258a \u258a\u25cf\u00a0Typography\u00a0emphasis,\u00a0strong,\u00a0inline\u00a0code\u00a0etc. \u258a\u25cf\u00a0Headers \u258a\u25cf\u00a0Lists\u00a0(bullet\u00a0and\u00a0ordered) \u258a\u25cf\u00a0Syntax\u00a0highlighted\u00a0code\u00a0blocks \u258a\u25cf\u00a0Tables! \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258e\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Tables\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258aTables\u00a0are\u00a0displayed\u00a0in\u00a0a\u00a0DataTable\u00a0widget. \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a \u258a\u258eName\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0TypeDefaultDescription\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u00a0\u258a \u258a\u258eshow_headerboolTrueShow\u00a0the\u00a0table\u00a0header\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_rowsint0Number\u00a0of\u00a0fixed\u00a0rows\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258efixed_columnsint0Number\u00a0of\u00a0fixed\u00a0columns\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258ezebra_stripesboolFalseDisplay\u00a0alternating\u00a0colors\u00a0on\u00a0rows\u258a \u258a\u258eheader_heightint1Height\u00a0of\u00a0header\u00a0row\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258eshow_cursorboolTrueShow\u00a0a\u00a0cell\u00a0cursor\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a \u258a\u258e\u258a \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258a\u258e\u258a
from textual.app import App, ComposeResult\nfrom textual.widgets import MarkdownViewer\nEXAMPLE_MARKDOWN = \"\"\"\\\n# Markdown Viewer\nThis is an example of Textual's `MarkdownViewer` widget.\n## Features\nMarkdown syntax and extensions are supported.\n- Typography *emphasis*, **strong**, `inline code` etc.\n- Headers\n- Lists (bullet and ordered)\n- Syntax highlighted code blocks\n- Tables!\n## Tables\nTables are displayed in a DataTable widget.\n| Name | Type | Default | Description |\n| --------------- | ------ | ------- | ---------------------------------- |\n| `show_header` | `bool` | `True` | Show the table header |\n| `fixed_rows` | `int` | `0` | Number of fixed rows |\n| `fixed_columns` | `int` | `0` | Number of fixed columns |\n| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows |\n| `header_height` | `int` | `1` | Height of header row |\n| `show_cursor` | `bool` | `True` | Show a cell cursor |\n## Code Blocks\nCode blocks are syntax highlighted, with guidelines.\n```python\nclass ListViewExample(App):\n def compose(self) -> ComposeResult:\n yield ListView(\n ListItem(Label(\"One\")),\n ListItem(Label(\"Two\")),\n ListItem(Label(\"Three\")),\n )\n yield Footer()\n```\n\"\"\"\nclass MarkdownExampleApp(App):\ndef compose(self) -> ComposeResult:\nyield MarkdownViewer(EXAMPLE_MARKDOWN, show_table_of_contents=True)\nif __name__ == \"__main__\":\napp = MarkdownExampleApp()\napp.run()\n
"},{"location":"widgets/markdown_viewer/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description show_table_of_contents
bool True Wether a Table of Contents should be displayed with the Markdown."},{"location":"widgets/markdown_viewer/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/markdown_viewer/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/markdown_viewer/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/markdown_viewer/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nmarkdown=None,\n*,\nshow_table_of_contents=True,\nname=None,\nid=None,\nclasses=None,\nparser_factory=None\n):\n
Bases: VerticalScroll
A Markdown viewer widget.
Parameters Name Type Description Defaultmarkdown
str | None
String containing Markdown, or None to leave blank.
None
show_table_of_contents
bool
Show a table of contents in a sidebar.
True
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
parser_factory
Callable[[], MarkdownIt] | None
A factory function to return a configured MarkdownIt instance. If None
, a \"gfm-like\" parser is used.
None
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.document","title":"document property
","text":"document: Markdown\n
The Markdown document object.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.table_of_contents","title":"table_of_contentsproperty
","text":"table_of_contents: MarkdownTableOfContents\n
The table of contents widget
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.back","title":"backasync
","text":"def back(self):\n
Go back one level in the history.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.forward","title":"forwardasync
","text":"def forward(self):\n
Go forward one level in the history.
"},{"location":"widgets/markdown_viewer/#textual.widgets._markdown.MarkdownViewer.go","title":"goasync
","text":"def go(self, location):\n
Navigate to a new document path.
"},{"location":"widgets/option_list/","title":"OptionList","text":"Added in version 0.17.0
A widget for showing a vertical list of Rich renderable options.
An OptionList
can be constructed with a simple collection of string options:
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258aGemenon\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258aPicon\u258e \u258aSagittaron\u258e \u258aScorpia\u258e \u258aTauron\u258e \u258aVirgon\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(\n\"Aerilon\",\n\"Aquaria\",\n\"Canceron\",\n\"Caprica\",\n\"Gemenon\",\n\"Leonis\",\n\"Libran\",\n\"Picon\",\n\"Sagittaron\",\n\"Scorpia\",\n\"Tauron\",\n\"Virgon\",\n)\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#options-as-option-instances","title":"Options as Option
instances","text":"For finer control over the options, the Option
class can be used; this allows for setting IDs, setting initial disabled state, etc. The Separator
class can be used to add separator lines between options.
OptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aAerilon\u258e \u258aAquaria\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aCanceron\u258e \u258aCaprica\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aGemenon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aLeonis\u258e \u258aLibran\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aPicon\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258aSagittaron\u2584\u2584\u258e \u258aScorpia\u258e \u258a\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nfrom textual.widgets.option_list import Option, Separator\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(\nOption(\"Aerilon\", id=\"aer\"),\nOption(\"Aquaria\", id=\"aqu\"),\nSeparator(),\nOption(\"Canceron\", id=\"can\"),\nOption(\"Caprica\", id=\"cap\", disabled=True),\nSeparator(),\nOption(\"Gemenon\", id=\"gem\"),\nSeparator(),\nOption(\"Leonis\", id=\"leo\"),\nOption(\"Libran\", id=\"lib\"),\nSeparator(),\nOption(\"Picon\", id=\"pic\"),\nSeparator(),\nOption(\"Sagittaron\", id=\"sag\"),\nOption(\"Scorpia\", id=\"sco\"),\nSeparator(),\nOption(\"Tauron\", id=\"tau\"),\nSeparator(),\nOption(\"Virgon\", id=\"vir\"),\n)\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#options-as-rich-renderables","title":"Options as Rich renderables","text":"Because the prompts for the options can be Rich renderables, this means they can be any height you wish. As an example, here is an option list comprised of Rich tables:
Outputoption_list_tables.pyoption_list.tcssOptionListApp \u2b58OptionListApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aerilon\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u2583\u2583\u258e \u258a\u2502Demeter\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u25021.2\u00a0Billion\u00a0\u00a0\u00a0\u2502Gaoth\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Aquaria\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2502Hermes\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250275,000\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502None\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502\u258e \u258a\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\u258e \u258a\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0Data\u00a0for\u00a0Canceron\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258e \u258a\u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\u258e \u258a\u2503Patron\u00a0God\u00a0\u00a0\u00a0\u2503Population\u00a0\u00a0\u00a0\u00a0\u2503Capital\u00a0City\u00a0\u00a0\u2503\u258e \u258a\u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from __future__ import annotations\nfrom rich.table import Table\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, OptionList\nCOLONIES: tuple[tuple[str, str, str, str], ...] = (\n(\"Aerilon\", \"Demeter\", \"1.2 Billion\", \"Gaoth\"),\n(\"Aquaria\", \"Hermes\", \"75,000\", \"None\"),\n(\"Canceron\", \"Hephaestus\", \"6.7 Billion\", \"Hades\"),\n(\"Caprica\", \"Apollo\", \"4.9 Billion\", \"Caprica City\"),\n(\"Gemenon\", \"Hera\", \"2.8 Billion\", \"Oranu\"),\n(\"Leonis\", \"Artemis\", \"2.6 Billion\", \"Luminere\"),\n(\"Libran\", \"Athena\", \"2.1 Billion\", \"None\"),\n(\"Picon\", \"Poseidon\", \"1.4 Billion\", \"Queenstown\"),\n(\"Sagittaron\", \"Zeus\", \"1.7 Billion\", \"Tawa\"),\n(\"Scorpia\", \"Dionysus\", \"450 Million\", \"Celeste\"),\n(\"Tauron\", \"Ares\", \"2.5 Billion\", \"Hypatia\"),\n(\"Virgon\", \"Hestia\", \"4.3 Billion\", \"Boskirk\"),\n)\nclass OptionListApp(App[None]):\nCSS_PATH = \"option_list.tcss\"\n@staticmethod\ndef colony(name: str, god: str, population: str, capital: str) -> Table:\ntable = Table(title=f\"Data for {name}\", expand=True)\ntable.add_column(\"Patron God\")\ntable.add_column(\"Population\")\ntable.add_column(\"Capital City\")\ntable.add_row(god, population, capital)\nreturn table\ndef compose(self) -> ComposeResult:\nyield Header()\nyield OptionList(*[self.colony(*row) for row in COLONIES])\nyield Footer()\nif __name__ == \"__main__\":\nOptionListApp().run()\n
Screen {\nalign: center middle;\n}\nOptionList {\nwidth: 70%;\nheight: 80%;\n}\n
"},{"location":"widgets/option_list/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
int
| None
None
The index of the highlighted option. None
means nothing is highlighted."},{"location":"widgets/option_list/#messages","title":"Messages","text":"Both of the messages above inherit from the common base OptionList
, so refer to its documentation to see what attributes are available.
The option list widget defines the following bindings:
Key(s) Description down Move the highlight down. end Move the highlight to the last option. enter Select the current option. home Move the highlight to the first option. pagedown Move the highlight down a page of options. pageup Move the highlight up a page of options. up Move the highlight up."},{"location":"widgets/option_list/#component-classes","title":"Component Classes","text":"The option list provides the following component classes:
Class Descriptionoption-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/option_list/#textual.widgets.OptionList","title":"textual.widgets.OptionList class
","text":"def __init__(\nself,\n*content,\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\nwrap=True\n):\n
Bases: ScrollView
A vertical option list with bounce-bar highlighting.
Parameters Name Type Description Default*content
NewOptionListContent
The content for the option list.
()
name
str | None
The name of the option list.
None
id
str | None
The ID of the option list in the DOM.
None
classes
str | None
The CSS classes of the option list.
None
disabled
bool
Whether the option list is disabled or not.
False
wrap
bool
Should prompts be auto-wrapped?
True
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"down\", \"cursor_down\", \"Down\", show=False),\nBinding(\"end\", \"last\", \"Last\", show=False),\nBinding(\"enter\", \"select\", \"Select\", show=False),\nBinding(\"home\", \"first\", \"First\", show=False),\nBinding(\n\"pagedown\", \"page_down\", \"Page Down\", show=False\n),\nBinding(\"pageup\", \"page_up\", \"Page Up\", show=False),\nBinding(\"up\", \"cursor_up\", \"Up\", show=False),\n]\n
Key(s) Description down Move the highlight down. end Move the highlight to the last option. enter Select the current option. home Move the highlight to the first option. pagedown Move the highlight down a page of options. pageup Move the highlight up a page of options. up Move the highlight up."},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"option-list--option\",\n\"option-list--option-disabled\",\n\"option-list--option-highlighted\",\n\"option-list--option-highlighted-disabled\",\n\"option-list--option-hover\",\n\"option-list--option-hover-disabled\",\n\"option-list--option-hover-highlighted\",\n\"option-list--option-hover-highlighted-disabled\",\n\"option-list--separator\",\n}\n
Class Description option-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.highlighted","title":"highlighted class-attribute
instance-attribute
","text":"highlighted: reactive[int | None] = None\n
The index of the currently-highlighted option, or None
if no option is highlighted.
property
","text":"option_count: int\n
The count of options.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionHighlighted","title":"OptionHighlightedclass
","text":" Bases: OptionMessage
Message sent when an option is highlighted.
Can be handled using on_option_list_option_highlighted
in a subclass of OptionList
or in a parent node in the DOM.
class
","text":"def __init__(self, option_list, index):\n
Bases: Message
Base class for all option messages.
Parameters Name Type Description Defaultoption_list
OptionList
The option list that owns the option.
requiredindex
int
The index of the option that the message relates to.
required"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.control","title":"controlproperty
","text":"control: OptionList\n
The option list that sent the message.
This is an alias for OptionMessage.option_list
and is used by the on
decorator.
instance-attribute
","text":"option: Option = option_list.get_option_at_index(index)\n
The highlighted option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_id","title":"option_idinstance-attribute
","text":"option_id: str | None = self.option.id\n
The ID of the option that the message relates to.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_index","title":"option_indexinstance-attribute
","text":"option_index: int = index\n
The index of the option that the message relates to.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionMessage.option_list","title":"option_listinstance-attribute
","text":"option_list: OptionList = option_list\n
The option list that sent the message.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.OptionSelected","title":"OptionSelectedclass
","text":" Bases: OptionMessage
Message sent when an option is selected.
Can be handled using on_option_list_option_selected
in a subclass of OptionList
or in a parent node in the DOM.
method
","text":"def action_cursor_down(self):\n
Move the highlight down by one option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Move the highlight up by one option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_first","title":"action_firstmethod
","text":"def action_first(self):\n
Move the highlight to the first option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_last","title":"action_lastmethod
","text":"def action_last(self):\n
Move the highlight to the last option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the highlight down one page.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the highlight up one page.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.action_select","title":"action_selectmethod
","text":"def action_select(self):\n
Select the currently-highlighted option.
If no option is selected, then nothing happens. If an option is selected, a OptionList.OptionSelected message will be posted.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.add_option","title":"add_optionmethod
","text":"def add_option(self, item=None):\n
Add a new option to the end of the option list.
Parameters Name Type Description Defaultitem
NewOptionListContent
The new item to add.
None
Returns Type Description Self
The OptionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.add_options","title":"add_optionsmethod
","text":"def add_options(self, items):\n
Add new options to the end of the option list.
Parameters Name Type Description Defaultitems
Iterable[NewOptionListContent]
The new items to add.
required Returns Type DescriptionSelf
The OptionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
NoteAll options are checked for duplicate IDs before any option is added. A duplicate ID will cause none of the passed items to be added to the option list.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.clear_options","title":"clear_optionsmethod
","text":"def clear_options(self):\n
Clear the content of the option list.
Returns Type DescriptionSelf
The OptionList
instance.
method
","text":"def disable_option(self, option_id):\n
Disable the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to disable.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.disable_option_at_index","title":"disable_option_at_indexmethod
","text":"def disable_option_at_index(self, index):\n
Disable the option at the given index.
Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.enable_option","title":"enable_optionmethod
","text":"def enable_option(self, option_id):\n
Enable the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to enable.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.enable_option_at_index","title":"enable_option_at_indexmethod
","text":"def enable_option_at_index(self, index):\n
Enable the option at the given index.
Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option","title":"get_optionmethod
","text":"def get_option(self, option_id):\n
Get the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to get.
required Returns Type DescriptionOption
The option with the ID.
Raises Type DescriptionOptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option_at_index","title":"get_option_at_indexmethod
","text":"def get_option_at_index(self, index):\n
Get the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to get.
required Returns Type DescriptionOption
The option at that index.
Raises Type DescriptionOptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.get_option_index","title":"get_option_indexmethod
","text":"def get_option_index(self, option_id):\n
Get the index of the option with the given ID.
Parameters Name Type Description Defaultoption_id
The ID of the option to get the index of.
required Raises Type DescriptionOptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.remove_option","title":"remove_optionmethod
","text":"def remove_option(self, option_id):\n
Remove the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to remove.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.remove_option_at_index","title":"remove_option_at_indexmethod
","text":"def remove_option_at_index(self, index):\n
Remove the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to remove.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.replace_option_prompt","title":"replace_option_promptmethod
","text":"def replace_option_prompt(self, option_id, prompt):\n
Replace the prompt of the option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the option to replace the prompt of.
requiredprompt
RenderableType
The new prompt for the option.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If no option has the given ID.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.replace_option_prompt_at_index","title":"replace_option_prompt_at_indexmethod
","text":"def replace_option_prompt_at_index(self, index, prompt):\n
Replace the prompt of the option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the option to replace the prompt of.
requiredprompt
RenderableType
The new prompt for the option.
required Returns Type DescriptionSelf
The OptionList
instance.
OptionDoesNotExist
If there is no option with the given index.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.scroll_to_highlight","title":"scroll_to_highlightmethod
","text":"def scroll_to_highlight(self, top=False):\n
Ensure that the highlighted option is in view.
Parameters Name Type Description Defaulttop
bool
Scroll highlight to top of the list.
False
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.validate_highlighted","title":"validate_highlighted method
","text":"def validate_highlighted(self, highlighted):\n
Validate the highlighted
property value on access.
method
","text":"def watch_highlighted(self, highlighted):\n
React to the highlighted option having changed.
"},{"location":"widgets/option_list/#textual.widgets._option_list.OptionList.watch_show_vertical_scrollbar","title":"watch_show_vertical_scrollbarmethod
","text":"def watch_show_vertical_scrollbar(self):\n
Handle the vertical scrollbar visibility status changing.
show_vertical_scrollbar
is watched because it has an impact on the available width in which to render the renderables that make up the options in the list. If a vertical scrollbar appears or disappears we need to recalculate all the lines that make up the list.
class
","text":" Bases: Exception
Exception raised if a duplicate ID is used.
"},{"location":"widgets/option_list/#textual.widgets.option_list.Option","title":"Optionclass
","text":"def __init__(self, prompt, id=None, disabled=False):\n
Class that holds the details of an individual option.
Parameters Name Type Description Defaultprompt
RenderableType
The prompt for the option.
requiredid
str | None
The optional ID for the option.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.id","title":"id property
","text":"id: str | None\n
The optional ID for the option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.prompt","title":"promptproperty
","text":"prompt: RenderableType\n
The prompt for the option.
"},{"location":"widgets/option_list/#textual.widgets._option_list.Option.set_prompt","title":"set_promptmethod
","text":"def set_prompt(self, prompt):\n
Set the prompt for the option.
Parameters Name Type Description Defaultprompt
RenderableType
The new prompt for the option.
required"},{"location":"widgets/option_list/#textual.widgets.option_list.OptionDoesNotExist","title":"OptionDoesNotExistclass
","text":" Bases: Exception
Exception raised when a request has been made for an option that doesn't exist.
"},{"location":"widgets/option_list/#textual.widgets.option_list.Separator","title":"Separatorclass
","text":"Class used to add a separator to an OptionList.
"},{"location":"widgets/placeholder/","title":"Placeholder","text":"Added in version 0.6.0
A widget that is meant to have no complex functionality. Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.
The placeholder widget has variants that display different bits of useful information. Clicking a placeholder will cycle through its variants.
The example below shows each placeholder variant.
Outputplaceholder.pyplaceholder.tcssPlaceholderApp Placeholder\u00a0p2\u00a0here! This\u00a0is\u00a0a\u00a0custom\u00a0label\u00a0for\u00a0p1. #p4 #p3#p5Placeholde r Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0 26\u00a0x\u00a06amet,\u00a0consectetur\u00a027\u00a0x\u00a06 adipiscing\u00a0elit.\u00a0Etiam\u00a0 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0 Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 consectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a040\u00a0x\u00a06 feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0 gravida.\u00a0Phasellus\u00a0id\u00a0eleifend\u00a0ligula. Nullam\u00a0imperdiet\u00a0sem\u00a0tellus,\u00a0sed\u00a0 vehicula\u00a0nisl\u00a0faucibus\u00a0sit\u00a0amet.\u00a0Lorem\u00a0ipsum\u00a0dolor\u00a0sit\u00a0amet,\u00a0 Praesent\u00a0iaculis\u00a0tempor\u00a0ultricies.\u00a0Sedconsectetur\u00a0adipiscing\u00a0elit.\u00a0Etiam\u00a0 lacinia,\u00a0tellus\u00a0id\u00a0rutrum\u00a0lacinia,\u00a0feugiat\u00a0ac\u00a0elit\u00a0sit\u00a0amet\u00a0accumsan.\u00a0 sapien\u00a0sapien\u00a0congue\u00a0mauris,\u00a0sit\u00a0amet\u00a0Suspendisse\u00a0bibendum\u00a0nec\u00a0libero\u00a0quis\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Container, Horizontal, VerticalScroll\nfrom textual.widgets import Placeholder\nclass PlaceholderApp(App):\nCSS_PATH = \"placeholder.tcss\"\ndef compose(self) -> ComposeResult:\nyield VerticalScroll(\nContainer(\nPlaceholder(\"This is a custom label for p1.\", id=\"p1\"),\nPlaceholder(\"Placeholder p2 here!\", id=\"p2\"),\nPlaceholder(id=\"p3\"),\nPlaceholder(id=\"p4\"),\nPlaceholder(id=\"p5\"),\nPlaceholder(),\nHorizontal(\nPlaceholder(variant=\"size\", id=\"col1\"),\nPlaceholder(variant=\"text\", id=\"col2\"),\nPlaceholder(variant=\"size\", id=\"col3\"),\nid=\"c1\",\n),\nid=\"bot\",\n),\nContainer(\nPlaceholder(variant=\"text\", id=\"left\"),\nPlaceholder(variant=\"size\", id=\"topright\"),\nPlaceholder(variant=\"text\", id=\"botright\"),\nid=\"top\",\n),\nid=\"content\",\n)\nif __name__ == \"__main__\":\napp = PlaceholderApp()\napp.run()\n
Placeholder {\nheight: 100%;\n}\n#top {\nheight: 50%;\nwidth: 100%;\nlayout: grid;\ngrid-size: 2 2;\n}\n#left {\nrow-span: 2;\n}\n#bot {\nheight: 50%;\nwidth: 100%;\nlayout: grid;\ngrid-size: 8 8;\n}\n#c1 {\nrow-span: 4;\ncolumn-span: 8;\nheight: 100%;\n}\n#col1, #col2, #col3 {\nwidth: 1fr;\n}\n#p1 {\nrow-span: 4;\ncolumn-span: 4;\n}\n#p2 {\nrow-span: 2;\ncolumn-span: 4;\n}\n#p3 {\nrow-span: 2;\ncolumn-span: 2;\n}\n#p4 {\nrow-span: 1;\ncolumn-span: 2;\n}\n
"},{"location":"widgets/placeholder/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description variant
str
\"default\"
Styling variant. One of default
, size
, text
."},{"location":"widgets/placeholder/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/placeholder/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/placeholder/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/placeholder/#textual.widgets.Placeholder","title":"textual.widgets.Placeholderclass
","text":"def __init__(\nself,\nlabel=None,\nvariant=\"default\",\n*,\nname=None,\nid=None,\nclasses=None\n):\n
Bases: Widget
A simple placeholder widget to use before you build your custom widgets.
This placeholder has a couple of variants that show different data. Clicking the placeholder cycles through the available variants, but a placeholder can also be initialised in a specific variant.
The variants available are:
Variant Placeholder shows default Identifier label or the ID of the placeholder. size Size of the placeholder. text Lorem Ipsum text. Parameters Name Type Description Defaultlabel
str | None
The label to identify the placeholder. If no label is present, uses the placeholder ID instead.
None
variant
PlaceholderVariant
The variant of the placeholder.
'default'
name
str | None
The name of the placeholder.
None
id
str | None
The ID of the placeholder in the DOM.
None
classes
str | None
A space separated string with the CSS classes of the placeholder, if any.
None
"},{"location":"widgets/placeholder/#textual.widgets._placeholder.Placeholder.variant","title":"variant class-attribute
instance-attribute
","text":"variant: Reactive[\nPlaceholderVariant\n] = self.validate_variant(variant)\n
The current variant of the placeholder.
"},{"location":"widgets/placeholder/#textual.widgets._placeholder.Placeholder.cycle_variant","title":"cycle_variantmethod
","text":"def cycle_variant(self):\n
Get the next variant in the cycle.
Returns Type DescriptionSelf
The Placeholder
instance.
method
","text":"def validate_variant(self, variant):\n
Validate the variant to which the placeholder was set.
"},{"location":"widgets/pretty/","title":"Pretty","text":"Display a pretty-formatted object.
The example below shows a pretty-formatted dict
, but Pretty
can display any Python object.
PrettyExample { 'title':\u00a0'Back\u00a0to\u00a0the\u00a0Future', 'releaseYear':\u00a01985, 'director':\u00a0'Robert\u00a0Zemeckis', 'genre':\u00a0'Adventure,\u00a0Comedy,\u00a0Sci-Fi', 'cast':\u00a0[ {'actor':\u00a0'Michael\u00a0J.\u00a0Fox',\u00a0'character':\u00a0'Marty\u00a0McFly'}, {'actor':\u00a0'Christopher\u00a0Lloyd',\u00a0'character':\u00a0'Dr.\u00a0Emmett\u00a0Brown'} ] }
from textual.app import App, ComposeResult\nfrom textual.widgets import Pretty\nDATA = {\n\"title\": \"Back to the Future\",\n\"releaseYear\": 1985,\n\"director\": \"Robert Zemeckis\",\n\"genre\": \"Adventure, Comedy, Sci-Fi\",\n\"cast\": [\n{\"actor\": \"Michael J. Fox\", \"character\": \"Marty McFly\"},\n{\"actor\": \"Christopher Lloyd\", \"character\": \"Dr. Emmett Brown\"},\n],\n}\nclass PrettyExample(App):\ndef compose(self) -> ComposeResult:\nyield Pretty(DATA)\napp = PrettyExample()\nif __name__ == \"__main__\":\napp.run()\n
"},{"location":"widgets/pretty/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/pretty/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/pretty/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/pretty/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/pretty/#textual.widgets.Pretty","title":"textual.widgets.Prettyclass
","text":"def __init__(self, object, *, name=None, id=None, classes=None):\n
Bases: Widget
A pretty-printing widget.
Used to pretty-print any object.
Parameters Name Type Description Defaultobject
Any
The object to pretty-print.
requiredname
str | None
The name of the pretty widget.
None
id
str | None
The ID of the pretty in the DOM.
None
classes
str | None
The CSS classes of the pretty.
None
"},{"location":"widgets/pretty/#textual.widgets._pretty.Pretty.update","title":"update method
","text":"def update(self, object):\n
Update the content of the pretty widget.
Parameters Name Type Description Defaultobject
Any
The object to pretty-print.
required"},{"location":"widgets/progress_bar/","title":"ProgressBar","text":"A widget that displays progress on a time-consuming task.
The example below shows a progress bar in isolation. It shows the progress bar in:
total
progress hasn't been set yet;IndeterminateProgressBar \u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501--%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
IndeterminateProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250139%00:00:07 \u00a0S\u00a0\u00a0Start\u00a0
IndeterminateProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Center, Middle\nfrom textual.timer import Timer\nfrom textual.widgets import Footer, ProgressBar\nclass IndeterminateProgressBar(App[None]):\nBINDINGS = [(\"s\", \"start\", \"Start\")]\nprogress_timer: Timer\n\"\"\"Timer to simulate progress happening.\"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nwith Middle():\nyield ProgressBar()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Set up a timer to simulate progess happening.\"\"\"\nself.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)\ndef make_progress(self) -> None:\n\"\"\"Called automatically to advance the progress bar.\"\"\"\nself.query_one(ProgressBar).advance(1)\ndef action_start(self) -> None:\n\"\"\"Start the progress tracking.\"\"\"\nself.query_one(ProgressBar).update(total=100)\nself.progress_timer.resume()\nif __name__ == \"__main__\":\nIndeterminateProgressBar().run()\n
"},{"location":"widgets/progress_bar/#complete-app-example","title":"Complete App Example","text":"The example below shows a simple app with a progress bar that is keeping track of a fictitious funding level for an organisation.
OutputOutput (partial funding)Output (full funding)progress_bar.pyprogress_bar.tcssFunding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25010% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250135% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$15\u00a0received! Donation\u00a0for\u00a0$20\u00a0received!
Funding\u00a0tracking \u2b58Funding\u00a0tracking Funding:\u00a0\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100% \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 \u258a$$$\u258e\u00a0Donate\u00a0 \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 Donation\u00a0for\u00a0$15\u00a0received! Donation\u00a0for\u00a0$20\u00a0received! Donation\u00a0for\u00a0$65\u00a0received!
from textual.app import App, ComposeResult\nfrom textual.containers import Center, VerticalScroll\nfrom textual.widgets import Button, Header, Input, Label, ProgressBar\nclass FundingProgressApp(App[None]):\nCSS_PATH = \"progress_bar.tcss\"\nTITLE = \"Funding tracking\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Center():\nyield Label(\"Funding: \")\nyield ProgressBar(total=100, show_eta=False) # (1)!\nwith Center():\nyield Input(placeholder=\"$$$\")\nyield Button(\"Donate\")\nyield VerticalScroll(id=\"history\")\ndef on_button_pressed(self) -> None:\nself.add_donation()\ndef on_input_submitted(self) -> None:\nself.add_donation()\ndef add_donation(self) -> None:\ntext_value = self.query_one(Input).value\ntry:\nvalue = int(text_value)\nexcept ValueError:\nreturn\nself.query_one(ProgressBar).advance(value)\nself.query_one(VerticalScroll).mount(Label(f\"Donation for ${value} received!\"))\nself.query_one(Input).value = \"\"\nif __name__ == \"__main__\":\nFundingProgressApp().run()\n
100
steps and we hide the ETA countdown because we are not keeping track of a continuous, uninterrupted task.Container {\noverflow: hidden hidden;\nheight: auto;\n}\nCenter {\nmargin-top: 1;\nmargin-bottom: 1;\nlayout: horizontal;\n}\nProgressBar {\npadding-left: 3;\n}\nInput {\nwidth: 16;\n}\nVerticalScroll {\nheight: auto;\n}\n
"},{"location":"widgets/progress_bar/#custom-styling","title":"Custom Styling","text":"This shows a progress bar with custom styling. Refer to the section below for more information.
Indeterminate state39% doneCompletedprogress_bar_styled.pyprogress_bar_styled.tcssStyledProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501--%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
StyledProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u250139%00:00:07 \u00a0S\u00a0\u00a0Start\u00a0
StyledProgressBar \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501100%--:--:-- \u00a0S\u00a0\u00a0Start\u00a0
from textual.app import App, ComposeResult\nfrom textual.containers import Center, Middle\nfrom textual.timer import Timer\nfrom textual.widgets import Footer, ProgressBar\nclass StyledProgressBar(App[None]):\nBINDINGS = [(\"s\", \"start\", \"Start\")]\nCSS_PATH = \"progress_bar_styled.tcss\"\nprogress_timer: Timer\n\"\"\"Timer to simulate progress happening.\"\"\"\ndef compose(self) -> ComposeResult:\nwith Center():\nwith Middle():\nyield ProgressBar()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Set up a timer to simulate progess happening.\"\"\"\nself.progress_timer = self.set_interval(1 / 10, self.make_progress, pause=True)\ndef make_progress(self) -> None:\n\"\"\"Called automatically to advance the progress bar.\"\"\"\nself.query_one(ProgressBar).advance(1)\ndef action_start(self) -> None:\n\"\"\"Start the progress tracking.\"\"\"\nself.query_one(ProgressBar).update(total=100)\nself.progress_timer.resume()\nif __name__ == \"__main__\":\nStyledProgressBar().run()\n
Bar > .bar--indeterminate {\ncolor: $primary;\nbackground: $secondary;\n}\nBar > .bar--bar {\ncolor: $primary;\nbackground: $primary 30%;\n}\nBar > .bar--complete {\ncolor: $error;\n}\nPercentageStatus {\ntext-style: reverse;\ncolor: $secondary;\n}\nETAStatus {\ntext-style: underline;\n}\n
"},{"location":"widgets/progress_bar/#styling-the-progress-bar","title":"Styling the Progress Bar","text":"The progress bar is composed of three sub-widgets that can be styled independently:
Widget name ID DescriptionBar
#bar
The bar that visually represents the progress made. PercentageStatus
#percentage
Label that shows the percentage of completion. ETAStatus
#eta
Label that shows the estimated time to completion."},{"location":"widgets/progress_bar/#bar-component-classes","title":"Bar Component Classes","text":"The bar sub-widget provides the component classes that follow.
These component classes let you modify the foreground and background color of the bar in its different states.
Class Descriptionbar--bar
Style of the bar (may be used to change the color). bar--complete
Style of the bar when it's complete. bar--indeterminate
Style of the bar when it's in an indeterminate state."},{"location":"widgets/progress_bar/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description percentage
float | None
The read-only percentage of progress that has been made. This is None
if the total
hasn't been set. progress
float
0
The number of steps of progress already made. total
float | None
The total number of steps that we are keeping track of."},{"location":"widgets/progress_bar/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/progress_bar/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/progress_bar/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/progress_bar/#textual.widgets.ProgressBar","title":"textual.widgets.ProgressBarclass
","text":"def __init__(\nself,\ntotal=None,\n*,\nshow_bar=True,\nshow_percentage=True,\nshow_eta=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A progress bar widget.
The progress bar uses \"steps\" as the measurement unit.
Exampleclass MyApp(App):\ndef compose(self):\nyield ProgressBar(total=100)\ndef key_space(self):\nself.query_one(ProgressBar).advance(5)\n
Parameters Name Type Description Default total
float | None
The total number of steps in the progress if known.
None
show_bar
bool
Whether to show the bar portion of the progress bar.
True
show_percentage
bool
Whether to show the percentage status of the bar.
True
show_eta
bool
Whether to show the ETA countdown of the progress bar.
True
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.percentage","title":"percentage class-attribute
instance-attribute
","text":"percentage: reactive[float | None] = reactive[\nOptional[float]\n](None)\n
The percentage of progress that has been completed.
The percentage is a value between 0 and 1 and the returned value is only None
if the total progress of the bar hasn't been set yet.
progress_bar = ProgressBar()\nprint(progress_bar.percentage) # None\nprogress_bar.update(total=100)\nprogress_bar.advance(50)\nprint(progress_bar.percentage) # 0.5\n
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.progress","title":"progress class-attribute
instance-attribute
","text":"progress: reactive[float] = reactive(0.0)\n
The progress so far, in number of steps.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.total","title":"totalclass-attribute
instance-attribute
","text":"total: reactive[float | None] = total\n
The total number of steps associated with this progress bar, when known.
The value None
will render an indeterminate progress bar.
method
","text":"def advance(self, advance=1):\n
Advance the progress of the progress bar by the given amount.
Exampleprogress_bar.advance(10) # Advance 10 steps.\n
Parameters Name Type Description Default advance
float
Number of steps to advance progress by.
1
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.compute_percentage","title":"compute_percentage method
","text":"def compute_percentage(self):\n
Keep the percentage of progress updated automatically.
This will report a percentage of 1
if the total is zero.
method
","text":"def update(\nself, *, total=UNUSED, progress=UNUSED, advance=UNUSED\n):\n
Update the progress bar with the given options.
Exampleprogress_bar.update(\ntotal=200, # Set new total to 200 steps.\nprogress=50, # Set the progress to 50 (out of 200).\n)\n
Parameters Name Type Description Default total
None | float | UnusedParameter
New total number of steps.
UNUSED
progress
float | UnusedParameter
Set the progress to the given number of steps.
UNUSED
advance
float | UnusedParameter
Advance the progress by this number of steps.
UNUSED
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.validate_progress","title":"validate_progress method
","text":"def validate_progress(self, progress):\n
Clamp the progress between 0 and the maximum total.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.validate_total","title":"validate_totalmethod
","text":"def validate_total(self, total):\n
Ensure the total is not negative.
"},{"location":"widgets/progress_bar/#textual.widgets._progress_bar.ProgressBar.watch_total","title":"watch_totalmethod
","text":"def watch_total(self, total):\n
Re-validate progress.
"},{"location":"widgets/radiobutton/","title":"RadioButton","text":"Added in version 0.13.0
A simple radio button which stores a boolean value.
A radio button is best used with others inside a RadioSet
.
The example below shows radio buttons, used within a RadioSet
.
RadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.widgets import RadioButton, RadioSet\nclass RadioChoicesApp(App[None]):\nCSS_PATH = \"radio_button.tcss\"\ndef compose(self) -> ComposeResult:\nwith RadioSet():\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\", id=\"focus_me\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\ndef on_mount(self) -> None:\nself.query_one(RadioSet).focus()\nif __name__ == \"__main__\":\nRadioChoicesApp().run()\n
Screen {\nalign: center middle;\n}\nRadioSet {\nwidth: 50%;\n}\n
"},{"location":"widgets/radiobutton/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the radio button."},{"location":"widgets/radiobutton/#messages","title":"Messages","text":"The radio button widget defines the following bindings:
Key(s) Description enter, space Toggle the value."},{"location":"widgets/radiobutton/#component-classes","title":"Component Classes","text":"The checkbox widget inherits the following component classes:
Class Descriptiontoggle--button
Targets the toggle button itself. toggle--label
Targets the text label of the toggle button."},{"location":"widgets/radiobutton/#see-also","title":"See Also","text":"class
","text":" Bases: ToggleButton
A radio button widget that represents a boolean value.
NoteA RadioButton
is best used within a RadioSet.
class-attribute
instance-attribute
","text":"BUTTON_INNER = '\u25cf'\n
The character used for the inside of the button.
"},{"location":"widgets/radiobutton/#textual.widgets._radio_button.RadioButton.Changed","title":"Changedclass
","text":" Bases: ToggleButton.Changed
Posted when the value of the radio button changes.
This message can be handled using an on_radio_button_changed
method.
property
","text":"control: RadioButton\n
Alias for Changed.radio_button.
"},{"location":"widgets/radiobutton/#textual.widgets._radio_button.RadioButton.Changed.radio_button","title":"radio_buttonproperty
","text":"radio_button: RadioButton\n
The radio button that was changed.
"},{"location":"widgets/radioset/","title":"RadioSet","text":"Added in version 0.13.0
A container widget that groups RadioButton
s together.
The example below shows two radio sets, one built using a collection of radio buttons, the other a collection of simple strings.
Outputradio_set.pyradio_set.tcssRadioChoicesApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e\u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e\u258a\u2590\u25cf\u258c\u00a0Amanda\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e\u258a\u2590\u25cf\u258c\u00a0Connor\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e\u258a\u2590\u25cf\u258c\u00a0Duncan\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e\u258a\u2590\u25cf\u258c\u00a0Heather\u00a0MacLeod\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e\u258a\u2590\u25cf\u258c\u00a0Joe\u00a0Dawson\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e\u258a\u2590\u25cf\u258c\u00a0Kurgan,\u00a0The\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e\u258a\u2590\u25cf\u258c\u00a0Methos\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e\u258a\u2590\u25cf\u258c\u00a0Rachel\u00a0Ellenstein\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e\u258a\u2590\u25cf\u258c\u00a0Ram\u00edrez\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e\u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import RadioButton, RadioSet\nclass RadioChoicesApp(App[None]):\nCSS_PATH = \"radio_set.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\n# A RadioSet built up from RadioButtons.\nwith RadioSet(id=\"focus_me\"):\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\n# A RadioSet built up from a collection of strings.\nyield RadioSet(\n\"Amanda\",\n\"Connor MacLeod\",\n\"Duncan MacLeod\",\n\"Heather MacLeod\",\n\"Joe Dawson\",\n\"Kurgan, [bold italic red]The[/]\",\n\"Methos\",\n\"Rachel Ellenstein\",\n\"Ram\u00edrez\",\n)\ndef on_mount(self) -> None:\nself.query_one(\"#focus_me\").focus()\nif __name__ == \"__main__\":\nRadioChoicesApp().run()\n
Screen {\nalign: center middle;\n}\nHorizontal {\nalign: center middle;\nheight: auto;\n}\nRadioSet {\nwidth: 45%;\n}\n
"},{"location":"widgets/radioset/#reacting-to-changes-in-a-radio-set","title":"Reacting to Changes in a Radio Set","text":"Here is an example of using the message to react to changes in a RadioSet
:
RadioSetChangedApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u2590\u25cf\u258cBattlestar\u00a0Galactica\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a01984\u258e \u258a\u2590\u25cf\u258c\u00a0Dune\u00a02021\u258e \u258a\u2590\u25cf\u258c\u00a0Serenity\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Trek:\u00a0The\u00a0Motion\u00a0Picture\u258e \u258a\u2590\u25cf\u258c\u00a0Star\u00a0Wars:\u00a0A\u00a0New\u00a0Hope\u258e \u258a\u2590\u25cf\u258c\u00a0The\u00a0Last\u00a0Starfighter\u258e \u258a\u2590\u25cf\u258c\u00a0Total\u00a0Recall\u00a0\ud83d\udc49\u00a0\ud83d\udd34\u258e \u258a\u2590\u25cf\u258c\u00a0Wing\u00a0Commander\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e Pressed\u00a0button\u00a0label:\u00a0Battlestar\u00a0Galactica Pressed\u00a0button\u00a0index:\u00a00
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal, VerticalScroll\nfrom textual.widgets import Label, RadioButton, RadioSet\nclass RadioSetChangedApp(App[None]):\nCSS_PATH = \"radio_set_changed.tcss\"\ndef compose(self) -> ComposeResult:\nwith VerticalScroll():\nwith Horizontal():\nwith RadioSet(id=\"focus_me\"):\nyield RadioButton(\"Battlestar Galactica\")\nyield RadioButton(\"Dune 1984\")\nyield RadioButton(\"Dune 2021\")\nyield RadioButton(\"Serenity\", value=True)\nyield RadioButton(\"Star Trek: The Motion Picture\")\nyield RadioButton(\"Star Wars: A New Hope\")\nyield RadioButton(\"The Last Starfighter\")\nyield RadioButton(\n\"Total Recall :backhand_index_pointing_right: :red_circle:\"\n)\nyield RadioButton(\"Wing Commander\")\nwith Horizontal():\nyield Label(id=\"pressed\")\nwith Horizontal():\nyield Label(id=\"index\")\ndef on_mount(self) -> None:\nself.query_one(RadioSet).focus()\ndef on_radio_set_changed(self, event: RadioSet.Changed) -> None:\nself.query_one(\"#pressed\", Label).update(\nf\"Pressed button label: {event.pressed.label}\"\n)\nself.query_one(\"#index\", Label).update(\nf\"Pressed button index: {event.radio_set.pressed_index}\"\n)\nif __name__ == \"__main__\":\nRadioSetChangedApp().run()\n
VerticalScroll {\nalign: center middle;\n}\nHorizontal {\nalign: center middle;\nheight: auto;\n}\nRadioSet {\nwidth: 45%;\n}\n
"},{"location":"widgets/radioset/#messages","title":"Messages","text":"The RadioSet
widget defines the following bindings:
This widget has no component classes.
"},{"location":"widgets/radioset/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\n*buttons,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Container
Widget for grouping a collection of radio buttons into a set.
When a collection of RadioButton
s are grouped with this widget, they will be treated as a mutually-exclusive grouping. If one button is turned on, the previously-on button will be turned off.
buttons
str | RadioButton
The labels or RadioButton
s to group together.
()
name
str | None
The name of the radio set.
None
id
str | None
The ID of the radio set in the DOM.
None
classes
str | None
The CSS classes of the radio set.
None
disabled
bool
Whether the radio set is disabled or not.
False
Note When a str
label is provided, a RadioButton will be created from it.
class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"down,right\", \"next_button\", \"\", show=False),\nBinding(\"enter,space\", \"toggle\", \"Toggle\", show=False),\nBinding(\"up,left\", \"previous_button\", \"\", show=False),\n]\n
Key(s) Description enter, space Toggle the currently-selected button. left, up Select the previous radio button in the set. right, down Select the next radio button in the set."},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.pressed_button","title":"pressed_button property
","text":"pressed_button: RadioButton | None\n
The currently-pressed RadioButton
, or None
if none are pressed.
property
","text":"pressed_index: int\n
The index of the currently-pressed RadioButton
, or -1 if none are pressed.
class
","text":"def __init__(self, radio_set, pressed):\n
Bases: Message
Posted when the pressed button in the set changes.
This message can be handled using an on_radio_set_changed
method.
pressed
RadioButton
The radio button that was pressed.
required"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.Changed.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'pressed'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: RadioSet\n
A reference to the RadioSet
that was changed.
This is an alias for Changed.radio_set
and is used by the on
decorator.
instance-attribute
","text":"index = radio_set.pressed_index\n
The index of the RadioButton
that was pressed to make the change.
instance-attribute
","text":"pressed = pressed\n
The RadioButton
that was pressed to make the change.
instance-attribute
","text":"radio_set = radio_set\n
A reference to the RadioSet
that was changed.
method
","text":"def action_next_button(self):\n
Navigate to the next button in the set.
Note that this will wrap around to the start if at the end.
"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.action_previous_button","title":"action_previous_buttonmethod
","text":"def action_previous_button(self):\n
Navigate to the previous button in the set.
Note that this will wrap around to the end if at the start.
"},{"location":"widgets/radioset/#textual.widgets._radio_set.RadioSet.action_toggle","title":"action_togglemethod
","text":"def action_toggle(self):\n
Toggle the state of the currently-selected button.
"},{"location":"widgets/rich_log/","title":"RichLog","text":"A RichLog is a widget which displays scrollable content that may be appended to in realtime.
Call RichLog.write with a string or Rich Renderable to write content to the end of the RichLog. Call RichLog.clear to clear the content.
Tip
See also Log which is an alternative to RichLog
but specialized for simple text.
The example below shows an application showing a RichLog
with different kinds of data logged.
RichLogApp \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=next(iter_values) \u2502\u00a0\u00a0\u00a0exceptStopIteration: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0return \u2502\u00a0\u00a0\u00a0first=True\u2585\u2585 \u2502\u00a0\u00a0\u00a0forvalueiniter_values: \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0yieldfirst,False,previous_value \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0first=False \u2502\u00a0\u00a0\u00a0\u2502\u00a0\u00a0\u00a0previous_value=value \u2502\u00a0\u00a0\u00a0yieldfirst,True,previous_value \u250f\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513 \u2503lane\u2503swimmer\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503country\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2503time\u00a0\u2503 \u2521\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529 \u25024\u00a0\u00a0\u00a0\u2502Joseph\u00a0Schooling\u00a0\u00a0\u00a0\u00a0\u2502Singapore\u00a0\u00a0\u00a0\u00a0\u250250.39\u2502 \u25022\u00a0\u00a0\u00a0\u2502Michael\u00a0Phelps\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.14\u2502 \u25025\u00a0\u00a0\u00a0\u2502Chad\u00a0le\u00a0Clos\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502South\u00a0Africa\u00a0\u250251.14\u2502 \u25026\u00a0\u00a0\u00a0\u2502L\u00e1szl\u00f3\u00a0Cseh\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502Hungary\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.14\u2502 \u25023\u00a0\u00a0\u00a0\u2502Li\u00a0Zhuhao\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502China\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.26\u2502 \u25028\u00a0\u00a0\u00a0\u2502Mehdy\u00a0Metella\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502France\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.58\u2502 \u25027\u00a0\u00a0\u00a0\u2502Tom\u00a0Shields\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u2502United\u00a0States\u250251.73\u2502 \u25021\u00a0\u00a0\u00a0\u2502Aleksandr\u00a0Sadovnikov\u2502Russia\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u250251.84\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 Write\u00a0text\u00a0or\u00a0any\u00a0Rich\u00a0renderable! Key(key='H',\u00a0character='H',\u00a0name='upper_h',\u00a0is_printable=True) Key(key='i',\u00a0character='i',\u00a0name='i',\u00a0is_printable=True)
import csv\nimport io\nfrom rich.syntax import Syntax\nfrom rich.table import Table\nfrom textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import RichLog\nCSV = \"\"\"lane,swimmer,country,time\n4,Joseph Schooling,Singapore,50.39\n2,Michael Phelps,United States,51.14\n5,Chad le Clos,South Africa,51.14\n6,L\u00e1szl\u00f3 Cseh,Hungary,51.14\n3,Li Zhuhao,China,51.26\n8,Mehdy Metella,France,51.58\n7,Tom Shields,United States,51.73\n1,Aleksandr Sadovnikov,Russia,51.84\"\"\"\nCODE = '''\\\ndef loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:\n \"\"\"Iterate and generate a tuple with a flag for first and last value.\"\"\"\n iter_values = iter(values)\n try:\n previous_value = next(iter_values)\n except StopIteration:\n return\n first = True\n for value in iter_values:\n yield first, False, previous_value\n first = False\n previous_value = value\n yield first, True, previous_value\\\n'''\nclass RichLogApp(App):\ndef compose(self) -> ComposeResult:\nyield RichLog(highlight=True, markup=True)\ndef on_ready(self) -> None:\n\"\"\"Called when the DOM is ready.\"\"\"\ntext_log = self.query_one(RichLog)\ntext_log.write(Syntax(CODE, \"python\", indent_guides=True))\nrows = iter(csv.reader(io.StringIO(CSV)))\ntable = Table(*next(rows))\nfor row in rows:\ntable.add_row(*row)\ntext_log.write(table)\ntext_log.write(\"[bold magenta]Write text or any Rich renderable!\")\ndef on_key(self, event: events.Key) -> None:\n\"\"\"Write Key events to log.\"\"\"\ntext_log = self.query_one(RichLog)\ntext_log.write(event)\nif __name__ == \"__main__\":\napp = RichLogApp()\napp.run()\n
"},{"location":"widgets/rich_log/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlight
bool
False
Automatically highlight content. markup
bool
False
Apply Rich console markup. max_lines
int
None
Maximum number of lines in the log or None
for no maximum. min_width
int
78 Minimum width of renderables. wrap
bool
False
Enable word wrapping."},{"location":"widgets/rich_log/#messages","title":"Messages","text":"This widget sends no messages.
"},{"location":"widgets/rich_log/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/rich_log/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/rich_log/#textual.widgets.RichLog","title":"textual.widgets.RichLogclass
","text":"def __init__(\nself,\n*,\nmax_lines=None,\nmin_width=78,\nwrap=False,\nhighlight=False,\nmarkup=False,\nauto_scroll=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
A widget for logging text.
Parameters Name Type Description Defaultmax_lines
int | None
Maximum number of lines in the log or None
for no maximum.
None
min_width
int
Minimum width of renderables.
78
wrap
bool
Enable word wrapping (default is off).
False
highlight
bool
Automatically highlight content.
False
markup
bool
Apply Rich console markup.
False
auto_scroll
bool
Enable automatic scrolling to end.
True
name
str | None
The name of the text log.
None
id
str | None
The ID of the text log in the DOM.
None
classes
str | None
The CSS classes of the text log.
None
disabled
bool
Whether the text log is disabled or not.
False
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.auto_scroll","title":"auto_scroll class-attribute
instance-attribute
","text":"auto_scroll: var[bool] = auto_scroll\n
Automatically scroll to the end on write.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.highlight","title":"highlightclass-attribute
instance-attribute
","text":"highlight: var[bool] = highlight\n
Automatically highlight content.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.markup","title":"markupclass-attribute
instance-attribute
","text":"markup: var[bool] = markup\n
Apply Rich console markup.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.max_lines","title":"max_linesclass-attribute
instance-attribute
","text":"max_lines: var[int | None] = max_lines\n
Maximum number of lines in the log or None
for no maximum.
class-attribute
instance-attribute
","text":"min_width: var[int] = min_width\n
Minimum width of renderables.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.wrap","title":"wrapclass-attribute
instance-attribute
","text":"wrap: var[bool] = wrap\n
Enable word wrapping.
"},{"location":"widgets/rich_log/#textual.widgets._rich_log.RichLog.clear","title":"clearmethod
","text":"def clear(self):\n
Clear the text log.
Returns Type DescriptionSelf
The RichLog
instance.
method
","text":"def write(\nself,\ncontent,\nwidth=None,\nexpand=False,\nshrink=True,\nscroll_end=None,\n):\n
Write text or a rich renderable.
Parameters Name Type Description Defaultcontent
RenderableType | object
Rich renderable (or text).
requiredwidth
int | None
Width to render or None
to use optimal width.
None
expand
bool
Enable expand to widget width, or False
to use width
.
False
shrink
bool
Enable shrinking of content to fit width.
True
scroll_end
bool | None
Enable automatic scroll to end, or None
to use self.auto_scroll
.
None
Returns Type Description Self
The RichLog
instance.
A rule widget to separate content, similar to a <hr>
HTML tag.
The default orientation of a rule is horizontal.
The example below shows horizontal rules with all the available line styles.
Outputhorizontal_rules.pyhorizontal_rules.tcssHorizontalRulesApp \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0solid\u00a0(default)\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0heavy\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0thick\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0dashed\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d\u254d \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0double\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 \u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0ascii\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 ----------------------------------------------------------------
from textual.app import App, ComposeResult\nfrom textual.containers import Vertical\nfrom textual.widgets import Label, Rule\nclass HorizontalRulesApp(App):\nCSS_PATH = \"horizontal_rules.tcss\"\ndef compose(self) -> ComposeResult:\nwith Vertical():\nyield Label(\"solid (default)\")\nyield Rule()\nyield Label(\"heavy\")\nyield Rule(line_style=\"heavy\")\nyield Label(\"thick\")\nyield Rule(line_style=\"thick\")\nyield Label(\"dashed\")\nyield Rule(line_style=\"dashed\")\nyield Label(\"double\")\nyield Rule(line_style=\"double\")\nyield Label(\"ascii\")\nyield Rule(line_style=\"ascii\")\nif __name__ == \"__main__\":\napp = HorizontalRulesApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nVertical {\nheight: auto;\nwidth: 80%;\n}\nLabel {\nwidth: 100%;\ntext-align: center;\n}\n
"},{"location":"widgets/rule/#vertical-rule","title":"Vertical Rule","text":"The example below shows vertical rules with all the available line styles.
Outputvertical_rules.pyvertical_rules.tcssVerticalRulesApp solid\u00a0\u2502heavy\u00a0\u2503thick\u00a0\u2588dashed\u254fdouble\u2551ascii\u00a0| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551| \u2502\u2503\u2588\u254f\u2551|
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Label, Rule\nclass VerticalRulesApp(App):\nCSS_PATH = \"vertical_rules.tcss\"\ndef compose(self) -> ComposeResult:\nwith Horizontal():\nyield Label(\"solid\")\nyield Rule(orientation=\"vertical\")\nyield Label(\"heavy\")\nyield Rule(orientation=\"vertical\", line_style=\"heavy\")\nyield Label(\"thick\")\nyield Rule(orientation=\"vertical\", line_style=\"thick\")\nyield Label(\"dashed\")\nyield Rule(orientation=\"vertical\", line_style=\"dashed\")\nyield Label(\"double\")\nyield Rule(orientation=\"vertical\", line_style=\"double\")\nyield Label(\"ascii\")\nyield Rule(orientation=\"vertical\", line_style=\"ascii\")\nif __name__ == \"__main__\":\napp = VerticalRulesApp()\napp.run()\n
Screen {\nalign: center middle;\n}\nHorizontal {\nwidth: auto;\nheight: 80%;\n}\nLabel {\nwidth: 6;\nheight: 100%;\ntext-align: center;\n}\n
"},{"location":"widgets/rule/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description orientation
RuleOrientation
\"horizontal\"
The orientation of the rule. line_style
LineStyle
\"solid\"
The line style of the rule."},{"location":"widgets/rule/#messages","title":"Messages","text":"This widget sends no messages.
"},{"location":"widgets/rule/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/rule/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/rule/#textual.widgets.Rule","title":"textual.widgets.Ruleclass
","text":"def __init__(\nself,\norientation=\"horizontal\",\nline_style=\"solid\",\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A rule widget to separate content, similar to a <hr>
HTML tag.
orientation
RuleOrientation
The orientation of the rule.
'horizontal'
line_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.line_style","title":"line_style class-attribute
instance-attribute
","text":"line_style: Reactive[LineStyle] = line_style\n
The line style of the rule.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.orientation","title":"orientationclass-attribute
instance-attribute
","text":"orientation: Reactive[RuleOrientation] = orientation\n
The orientation of the rule.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.horizontal","title":"horizontalclassmethod
","text":"def horizontal(\ncls,\nline_style=\"solid\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Utility constructor for creating a horizontal rule.
Parameters Name Type Description Defaultline_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
Returns Type Description Rule
A rule widget with horizontal orientation.
"},{"location":"widgets/rule/#textual.widgets._rule.Rule.vertical","title":"verticalclassmethod
","text":"def vertical(\ncls,\nline_style=\"solid\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False,\n):\n
Utility constructor for creating a vertical rule.
Parameters Name Type Description Defaultline_style
LineStyle
The line style of the rule.
'solid'
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes of the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
Returns Type Description Rule
A rule widget with vertical orientation.
"},{"location":"widgets/rule/#textual.widgets.rule","title":"textual.widgets.rule","text":""},{"location":"widgets/rule/#textual.widgets.rule.LineStyle","title":"LineStylemodule-attribute
","text":"LineStyle = Literal[\n\"ascii\",\n\"blank\",\n\"dashed\",\n\"double\",\n\"heavy\",\n\"hidden\",\n\"none\",\n\"solid\",\n\"thick\",\n]\n
The valid line styles of the rule widget.
"},{"location":"widgets/rule/#textual.widgets.rule.RuleOrientation","title":"RuleOrientationmodule-attribute
","text":"RuleOrientation = Literal['horizontal', 'vertical']\n
The valid orientations of the rule widget.
"},{"location":"widgets/rule/#textual.widgets.rule.InvalidLineStyle","title":"InvalidLineStyleclass
","text":" Bases: Exception
Exception raised for an invalid rule line style.
"},{"location":"widgets/rule/#textual.widgets.rule.InvalidRuleOrientation","title":"InvalidRuleOrientationclass
","text":" Bases: Exception
Exception raised for an invalid rule orientation.
"},{"location":"widgets/select/","title":"Select","text":"Added in version 0.24.0
A Select widget is a compact control to allow the user to select between a number of possible options.
The options in a select control may be passed in to the constructor or set later with set_options. Options should be given as a sequence of tuples consisting of two values: the first is the string (or Rich Renderable) to display in the control and list of options, the second is the value of option.
The value of the currently selected option is stored in the value
attribute of the widget, and the value
attribute of the Changed message.
The Select
control is a typing Generic which allows you to set the type of the option values. For instance, if the data type for your values is an integer, you would type the widget as follows:
options = [(\"First\", 1), (\"Second\", 2)]\nmy_select: Select[int] = Select(options)\n
Note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
"},{"location":"widgets/select/#example","title":"Example","text":"The following example presents a Select
with a number of options.
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25bc\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
SelectApp \u2b58SelectApp \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u25b2\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258aSelect\u258e \u258aI\u00a0must\u00a0not\u00a0fear.\u258e \u258aFear\u00a0is\u00a0the\u00a0mind-killer.\u258e \u258aFear\u00a0is\u00a0the\u00a0little-death\u00a0that\u00a0brings\u00a0total\u00a0\u258e \u258aobliteration.\u258e \u258aI\u00a0will\u00a0face\u00a0my\u00a0fear.\u258e \u258aI\u00a0will\u00a0permit\u00a0it\u00a0to\u00a0pass\u00a0over\u00a0me\u00a0and\u00a0through\u00a0me.\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Header, Select\nLINES = \"\"\"I must not fear.\nFear is the mind-killer.\nFear is the little-death that brings total obliteration.\nI will face my fear.\nI will permit it to pass over me and through me.\"\"\".splitlines()\nclass SelectApp(App):\nCSS_PATH = \"select.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield Select((line, line) for line in LINES)\n@on(Select.Changed)\ndef select_changed(self, event: Select.Changed) -> None:\nself.title = str(event.value)\nif __name__ == \"__main__\":\napp = SelectApp()\napp.run()\n
Screen {\nalign: center top;\n}\nSelect {\nwidth: 60;\nmargin: 2;\n}\n
"},{"location":"widgets/select/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description expanded
bool
False
True to expand the options overlay. value
SelectType
| None
None
Current value of the Select."},{"location":"widgets/select/#messages","title":"Messages","text":"The Select widget defines the following bindings:
Key(s) Description enter,down,space,up Activate the overlay"},{"location":"widgets/select/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/select/#textual.widgets.Select","title":"textual.widgets.Selectclass
","text":"def __init__(\nself,\noptions,\n*,\nprompt=\"Select\",\nallow_blank=True,\nvalue=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[SelectType]
, Vertical
Widget to select from a list of possible options.
A Select displays the current selection. When activated with Enter the widget displays an overlay with a list of all possible options.
Parameters Name Type Description Defaultoptions
Iterable[tuple[str, SelectType]]
Options to select from.
requiredprompt
str
Text to show in the control when no option is select.
'Select'
allow_blank
bool
Allow the selection of a blank option.
True
value
SelectType | None
Initial value (should be one of the values in options
).
None
name
str | None
The name of the select control.
None
id
str | None
The ID of the control the DOM.
None
classes
str | None
The CSS classes of the control.
None
disabled
bool
Whether the control is disabled or not.
False
"},{"location":"widgets/select/#textual.widgets._select.Select.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [('enter,down,space,up', 'show_overlay')]\n
Key(s) Description enter,down,space,up Activate the overlay"},{"location":"widgets/select/#textual.widgets._select.Select.expanded","title":"expanded class-attribute
instance-attribute
","text":"expanded: var[bool] = var(False, init=False)\n
True to show the overlay, otherwise False.
"},{"location":"widgets/select/#textual.widgets._select.Select.prompt","title":"promptclass-attribute
instance-attribute
","text":"prompt: var[str] = prompt\n
The prompt to show when no value is selected.
"},{"location":"widgets/select/#textual.widgets._select.Select.value","title":"valueclass-attribute
instance-attribute
","text":"value: var[SelectType | None] = var[Optional[SelectType]](\nNone\n)\n
The value of the select.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed","title":"Changedclass
","text":"def __init__(self, select, value):\n
Bases: Message
Posted when the select value was changed.
This message can be handled using a on_select_changed
method.
property
","text":"control: Select\n
The Select that sent the message.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed.select","title":"selectinstance-attribute
","text":"select = select\n
The select widget.
"},{"location":"widgets/select/#textual.widgets._select.Select.Changed.value","title":"valueinstance-attribute
","text":"value = value\n
The value of the Select when it changed.
"},{"location":"widgets/select/#textual.widgets._select.Select.action_show_overlay","title":"action_show_overlaymethod
","text":"def action_show_overlay(self):\n
Show the overlay.
"},{"location":"widgets/select/#textual.widgets._select.Select.set_options","title":"set_optionsmethod
","text":"def set_options(self, options):\n
Set the options for the Select.
Parameters Name Type Description Defaultoptions
Iterable[tuple[RenderableType, SelectType]]
An iterable of tuples containing (STRING, VALUE).
required"},{"location":"widgets/selection_list/","title":"SelectionList","text":"Added in version 0.27.0
A widget for showing a vertical list of selectable options.
The SelectionList
control is a Generic
, which allows you to set the type of the selection values. For instance, if the data type for your values is an integer, you would type the widget as follows:
selections = [(\"First\", 1), (\"Second\", 2)]\nmy_selection_list: SelectionList[int] = SelectionList(*selections)\n
Note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
"},{"location":"widgets/selection_list/#examples","title":"Examples","text":"A selection list is designed to be built up of single-line prompts (which can be Rich renderables) and an associated unique value.
"},{"location":"widgets/selection_list/#selections-as-tuples","title":"Selections as tuples","text":"A selection list can be built with tuples, either of two or three values in length. Each tuple must contain a prompt and a value, and it can also optionally contain a flag for the initial selected state of the option.
Outputselection_list_tuples.pyselection_list.tcssSelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, SelectionList\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield SelectionList[int]( # (1)!\n(\"Falken's Maze\", 0, True),\n(\"Black Jack\", 1),\n(\"Gin Rummy\", 2),\n(\"Hearts\", 3),\n(\"Bridge\", 4),\n(\"Checkers\", 5),\n(\"Chess\", 6, True),\n(\"Poker\", 7),\n(\"Fighter Combat\", 8, True),\n)\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as int
, for the type of the values.Screen {\nalign: center middle;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 80%;\nheight: 80%;\n}\n
"},{"location":"widgets/selection_list/#selections-as-selection-objects","title":"Selections as Selection objects","text":"Alternatively, selections can be passed in as Selection
s:
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502 \u2502\u2590X\u258cHearts\u2502 \u2502\u2590X\u258cBridge\u2502 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Header, SelectionList\nfrom textual.widgets.selection_list import Selection\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nyield SelectionList[int]( # (1)!\nSelection(\"Falken's Maze\", 0, True),\nSelection(\"Black Jack\", 1),\nSelection(\"Gin Rummy\", 2),\nSelection(\"Hearts\", 3),\nSelection(\"Bridge\", 4),\nSelection(\"Checkers\", 5),\nSelection(\"Chess\", 6, True),\nSelection(\"Poker\", 7),\nSelection(\"Fighter Combat\", 8, True),\n)\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as int
, for the type of the values.Screen {\nalign: center middle;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 80%;\nheight: 80%;\n}\n
"},{"location":"widgets/selection_list/#handling-changes-to-the-selections","title":"Handling changes to the selections","text":"Most of the time, when using the SelectionList
, you will want to know when the collection of selected items has changed; this is ideally done using the SelectedChanged
message. Here is an example of using that message to update a Pretty
with the collection of selected values:
SelectionListApp \u2b58SelectionListApp \u250c\u2500\u00a0Shall\u00a0we\u00a0play\u00a0some\u00a0games?\u00a0\u2500\u2500\u2510\u250c\u2500\u00a0Selected\u00a0games\u00a0\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502\u2502\u2502[\u2502 \u2502\u2590X\u258cFalken's\u00a0Maze\u2502\u2502'secret_back_door',\u2502 \u2502\u2590X\u258cBlack\u00a0Jack\u2502\u2502'a_nice_game_of_chess',\u2502 \u2502\u2590X\u258cGin\u00a0Rummy\u2502\u2502'fighter_combat'\u2502 \u2502\u2590X\u258cHearts\u2502\u2502]\u2502 \u2502\u2590X\u258cBridge\u2502\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2502\u2590X\u258cCheckers\u2502 \u2502\u2590X\u258cChess\u2502 \u2502\u2590X\u258cPoker\u2502 \u2502\u2590X\u258cFighter\u00a0Combat\u2502 \u2502\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
from textual import on\nfrom textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.events import Mount\nfrom textual.widgets import Footer, Header, Pretty, SelectionList\nfrom textual.widgets.selection_list import Selection\nclass SelectionListApp(App[None]):\nCSS_PATH = \"selection_list_selected.tcss\"\ndef compose(self) -> ComposeResult:\nyield Header()\nwith Horizontal():\nyield SelectionList[str]( # (1)!\nSelection(\"Falken's Maze\", \"secret_back_door\", True),\nSelection(\"Black Jack\", \"black_jack\"),\nSelection(\"Gin Rummy\", \"gin_rummy\"),\nSelection(\"Hearts\", \"hearts\"),\nSelection(\"Bridge\", \"bridge\"),\nSelection(\"Checkers\", \"checkers\"),\nSelection(\"Chess\", \"a_nice_game_of_chess\", True),\nSelection(\"Poker\", \"poker\"),\nSelection(\"Fighter Combat\", \"fighter_combat\", True),\n)\nyield Pretty([])\nyield Footer()\ndef on_mount(self) -> None:\nself.query_one(SelectionList).border_title = \"Shall we play some games?\"\nself.query_one(Pretty).border_title = \"Selected games\"\n@on(Mount)\n@on(SelectionList.SelectedChanged)\ndef update_selected_view(self) -> None:\nself.query_one(Pretty).update(self.query_one(SelectionList).selected)\nif __name__ == \"__main__\":\nSelectionListApp().run()\n
SelectionList
is typed as str
, for the type of the values.Screen {\nalign: center middle;\n}\nHorizontal {\nwidth: 80%;\nheight: 80%;\n}\nSelectionList {\npadding: 1;\nborder: solid $accent;\nwidth: 1fr;\n}\nPretty {\nwidth: 1fr;\nborder: solid $accent;\n}\n
"},{"location":"widgets/selection_list/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description highlighted
int
| None
None
The index of the highlighted selection. None
means nothing is highlighted."},{"location":"widgets/selection_list/#messages","title":"Messages","text":"The following messages will be posted as the user interacts with the list:
The following message will be posted if the content of selected
changes, either by user interaction or by API calls:
The selection list widget defines the following bindings:
Key(s) Description space Toggle the state of the highlighted selection.It inherits from OptionList
and so also inherits the following bindings:
The selection list provides the following component classes:
Class Descriptionselection-list--button
Target the default button style. selection-list--button-selected
Target a selected button style. selection-list--button-highlighted
Target a highlighted button style. selection-list--button-selected-highlighted
Target a highlighted selected button style. It inherits from OptionList
and so also makes use of the following component classes:
option-list--option-disabled
Target disabled options. option-list--option-highlighted
Target the highlighted option. option-list--option-highlighted-disabled
Target a disabled option that is also highlighted. option-list--option-hover
Target an option that has the mouse over it. option-list--option-hover-disabled
Target a disabled option that has the mouse over it. option-list--option-hover-highlighted
Target a highlighted option that has the mouse over it. option-list--option-hover-highlighted-disabled
Target a disabled highlighted option that has the mouse over it. option-list--separator
Target the separators."},{"location":"widgets/selection_list/#textual.widgets.SelectionList","title":"textual.widgets.SelectionList class
","text":"def __init__(\nself,\n*selections,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[SelectionType]
, OptionList
A vertical selection list that allows making multiple selections.
Parameters Name Type Description Default*selections
Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]
The content for the selection list.
()
name
str | None
The name of the selection list.
None
id
str | None
The ID of the selection list in the DOM.
None
classes
str | None
The CSS classes of the selection list.
None
disabled
bool
Whether the selection list is disabled or not.
False
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [Binding('space', 'select')]\n
Key(s) Description space Toggle the state of the highlighted selection."},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"selection-list--button\",\n\"selection-list--button-selected\",\n\"selection-list--button-highlighted\",\n\"selection-list--button-selected-highlighted\",\n}\n
Class Description selection-list--button
Target the default button style. selection-list--button-selected
Target a selected button style. selection-list--button-highlighted
Target a highlighted button style. selection-list--button-selected-highlighted
Target a highlighted selected button style."},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.selected","title":"selected property
","text":"selected: list[SelectionType]\n
The selected values.
This is a list of all of the values associated with selections in the list that are currently in the selected state.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectedChanged","title":"SelectedChangedclass
","text":" Bases: Generic[MessageSelectionType]
, Message
Message sent when the collection of selected values changes.
This message is sent when any change to the collection of selected values takes place; either by user interaction or by API calls.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectedChanged.control","title":"controlproperty
","text":"control: SelectionList[MessageSelectionType]\n
An alias for selection_list
.
instance-attribute
","text":"selection_list: SelectionList[MessageSelectionType]\n
The SelectionList
that sent the message.
class
","text":" Bases: SelectionMessage
Message sent when a selection is highlighted.
Can be handled using on_selection_list_selection_highlighted
in a subclass of SelectionList
or in a parent node in the DOM.
class
","text":"def __init__(self, selection_list, index):\n
Bases: Generic[MessageSelectionType]
, Message
Base class for all selection messages.
Parameters Name Type Description Defaultselection_list
SelectionList
The selection list that owns the selection.
requiredindex
int
The index of the selection that the message relates to.
required"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.control","title":"controlproperty
","text":"control: OptionList\n
The selection list that sent the message.
This is an alias for SelectionMessage.selection_list
and is used by the on
decorator.
instance-attribute
","text":"selection: Selection[\nMessageSelectionType\n] = selection_list.get_option_at_index(index)\n
The highlighted selection.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.selection_index","title":"selection_indexinstance-attribute
","text":"selection_index: int = index\n
The index of the selection that the message relates to.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionMessage.selection_list","title":"selection_listinstance-attribute
","text":"selection_list: SelectionList[\nMessageSelectionType\n] = selection_list\n
The selection list that sent the message.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.SelectionToggled","title":"SelectionToggledclass
","text":" Bases: SelectionMessage
Message sent when a selection is toggled.
Can be handled using on_selection_list_selection_toggled
in a subclass of SelectionList
or in a parent node in the DOM.
This message is only sent if the selection is toggled by user interaction. See SelectedChanged
for a message sent when any change (selected or deselected, either by user interaction or by API calls) is made to the selected values.
method
","text":"def add_option(self, item=None):\n
Add a new selection option to the end of the list.
Parameters Name Type Description Defaultitem
NewOptionListContent | Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]
The new item to add.
None
Returns Type Description Self
The SelectionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
SelectionError
If the selection option is of the wrong form.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.add_options","title":"add_optionsmethod
","text":"def add_options(self, items):\n
Add new selection options to the end of the list.
Parameters Name Type Description Defaultitems
Iterable[NewOptionListContent | Selection | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]]
The new items to add.
required Returns Type DescriptionSelf
The SelectionList
instance.
DuplicateID
If there is an attempt to use a duplicate ID.
SelectionError
If one of the selection options is of the wrong form.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.clear_options","title":"clear_optionsmethod
","text":"def clear_options(self):\n
Clear the content of the selection list.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def deselect(self, selection):\n
Mark the given selection as not selected.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to mark as not selected.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def deselect_all(self):\n
Deselect all items.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def get_option(self, option_id):\n
Get the selection option with the given ID.
Parameters Name Type Description Defaultoption_id
str
The ID of the selection option to get.
required Returns Type DescriptionSelection[SelectionType]
The selection option with the ID.
Raises Type DescriptionOptionDoesNotExist
If no selection option has the given ID.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.get_option_at_index","title":"get_option_at_indexmethod
","text":"def get_option_at_index(self, index):\n
Get the selection option at the given index.
Parameters Name Type Description Defaultindex
int
The index of the selection option to get.
required Returns Type DescriptionSelection[SelectionType]
The selection option at that index.
Raises Type DescriptionOptionDoesNotExist
If there is no selection option with the index.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.SelectionList.select","title":"selectmethod
","text":"def select(self, selection):\n
Mark the given selection as selected.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to mark as selected.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def select_all(self):\n
Select all items.
Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def toggle(self, selection):\n
Toggle the selected state of the given selection.
Parameters Name Type Description Defaultselection
Selection[SelectionType] | SelectionType
The selection to toggle.
required Returns Type DescriptionSelf
The SelectionList
instance.
method
","text":"def toggle_all(self):\n
Toggle all items.
Returns Type DescriptionSelf
The SelectionList
instance.
module-attribute
","text":"MessageSelectionType = TypeVar('MessageSelectionType')\n
The type for the value of a Selection
in a SelectionList
message.
module-attribute
","text":"SelectionType = TypeVar('SelectionType')\n
The type for the value of a Selection
in a SelectionList
class
","text":"def __init__(\nself,\nprompt,\nvalue,\ninitial_state=False,\nid=None,\ndisabled=False,\n):\n
Bases: Generic[SelectionType]
, Option
A selection for a SelectionList
.
prompt
TextType
The prompt for the selection.
requiredvalue
SelectionType
The value for the selection.
requiredinitial_state
bool
The initial selected state of the selection.
False
id
str | None
The optional ID for the selection.
None
disabled
bool
The initial enabled/disabled state. Enabled by default.
False
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.Selection.initial_state","title":"initial_state property
","text":"initial_state: bool\n
The initial selected state for the selection.
"},{"location":"widgets/selection_list/#textual.widgets._selection_list.Selection.value","title":"valueproperty
","text":"value: SelectionType\n
The value for this selection.
"},{"location":"widgets/selection_list/#textual.widgets.selection_list.SelectionError","title":"SelectionErrorclass
","text":" Bases: TypeError
Type of an error raised if a selection is badly-formed.
"},{"location":"widgets/sparkline/","title":"Sparkline","text":"Added in version 0.27.0
A widget that is used to visually represent numerical data.
The example below illustrates the relationship between the data, its length, the width of the sparkline, and the number of bars displayed.
Tip
The sparkline data is split into equally-sized chunks. Each chunk is represented by a bar and the width of the sparkline dictates how many bars there are.
Outputsparkline_basic.pysparkline_basic.tcssSparklineBasicApp \u2582\u2584\u2588
from textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\ndata = [1, 2, 2, 1, 1, 4, 3, 1, 1, 8, 8, 2] # (1)!\nclass SparklineBasicApp(App[None]):\nCSS_PATH = \"sparkline_basic.tcss\"\ndef compose(self) -> ComposeResult:\nyield Sparkline( # (2)!\ndata, # (3)!\nsummary_function=max, # (4)!\n)\napp = SparklineBasicApp()\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nalign: center middle;\n}\nSparkline {\nwidth: 3; /* (1)! */\nmargin: 2;\n}\n
The example below shows a sparkline widget with different summary functions. The summary function is what determines the height of each bar.
Outputsparkline.pysparkline.tcssSparklineSummaryFunctionApp \u2582\u2584\u2582\u2584\u2583\u2583\u2586\u2585\u2583\u2582\u2583\u2582\u2583\u2582\u2584\u2587\u2583\u2583\u2587\u2585\u2584\u2583\u2584\u2584\u2583\u2582\u2583\u2582\u2583\u2584\u2584\u2588\u2586\u2582\u2583\u2583\u2585\u2583\u2583\u2584\u2583\u2587\u2583\u2583\u2583\u2584\u2584\u2586\u2583\u2583\u2585\u2582\u2585\u2583\u2584\u2583\u2583\u2584\u2583\u2585\u2586\u2582\u2582\u2583\u2586\u2582\u2583\u2584\u2585\u2584\u2583\u2584\u2584\u2581\u2583\u2582 \u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2582\u2582\u2582\u2582\u2582\u2582\u2581\u2581\u2581\u2581\u2581\u2582\u2581\u2582\u2582\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2581\u2581\u2582\u2582\u2581\u2581\u2581\u2581\u2582\u2581\u2581\u2582\u2581\u2582\u2581\u2581\u2582\u2581\u2581\u2581\u2581\u2581\u2581\u2582\u2582\u2582\u2581\u2582\u2581\u2581\u2581\u2581 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581
import random\nfrom statistics import mean\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\nrandom.seed(73)\ndata = [random.expovariate(1 / 3) for _ in range(1000)]\nclass SparklineSummaryFunctionApp(App[None]):\nCSS_PATH = \"sparkline.tcss\"\ndef compose(self) -> ComposeResult:\nyield Sparkline(data, summary_function=max) # (1)!\nyield Sparkline(data, summary_function=mean) # (2)!\nyield Sparkline(data, summary_function=min) # (3)!\napp = SparklineSummaryFunctionApp()\nif __name__ == \"__main__\":\napp.run()\n
Sparkline {\nwidth: 100%;\nmargin: 2;\n}\n
"},{"location":"widgets/sparkline/#changing-the-colors","title":"Changing the colors","text":"The example below shows how to use component classes to change the colors of the sparkline.
Outputsparkline_colors.pysparkline_colors.tcssSparklineColorsApp \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582 \u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2583\u2583\u2584\u2585\u2586\u2586\u2586\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2584\u2584\u2583\u2582\u2581\u2581\u2582\u2582\u2583\u2584\u2585\u2585\u2586\u2586\u2587\u2587\u2587\u2587\u2588\u2587\u2587\u2587\u2587\u2586\u2586\u2585\u2585\u2584\u2583\u2582
from math import sin\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import Sparkline\nclass SparklineColorsApp(App[None]):\nCSS_PATH = \"sparkline_colors.tcss\"\ndef compose(self) -> ComposeResult:\nnums = [abs(sin(x / 3.14)) for x in range(0, 360 * 6, 20)]\nyield Sparkline(nums, summary_function=max, id=\"fst\")\nyield Sparkline(nums, summary_function=max, id=\"snd\")\nyield Sparkline(nums, summary_function=max, id=\"trd\")\nyield Sparkline(nums, summary_function=max, id=\"frt\")\nyield Sparkline(nums, summary_function=max, id=\"fft\")\nyield Sparkline(nums, summary_function=max, id=\"sxt\")\nyield Sparkline(nums, summary_function=max, id=\"svt\")\nyield Sparkline(nums, summary_function=max, id=\"egt\")\nyield Sparkline(nums, summary_function=max, id=\"nnt\")\nyield Sparkline(nums, summary_function=max, id=\"tnt\")\napp = SparklineColorsApp()\nif __name__ == \"__main__\":\napp.run()\n
Sparkline {\nwidth: 100%;\nmargin: 1;\n}\n#fst > .sparkline--max-color {\ncolor: $success;\n}\n#fst > .sparkline--min-color {\ncolor: $warning;\n}\n#snd > .sparkline--max-color {\ncolor: $warning;\n}\n#snd > .sparkline--min-color {\ncolor: $success;\n}\n#trd > .sparkline--max-color {\ncolor: $error;\n}\n#trd > .sparkline--min-color {\ncolor: $warning;\n}\n#frt > .sparkline--max-color {\ncolor: $warning;\n}\n#frt > .sparkline--min-color {\ncolor: $error;\n}\n#fft > .sparkline--max-color {\ncolor: $accent;\n}\n#fft > .sparkline--min-color {\ncolor: $accent 30%;\n}\n#sxt > .sparkline--max-color {\ncolor: $accent 30%;\n}\n#sxt > .sparkline--min-color {\ncolor: $accent;\n}\n#svt > .sparkline--max-color {\ncolor: $error;\n}\n#svt > .sparkline--min-color {\ncolor: $error 30%;\n}\n#egt > .sparkline--max-color {\ncolor: $error 30%;\n}\n#egt > .sparkline--min-color {\ncolor: $error;\n}\n#nnt > .sparkline--max-color {\ncolor: $success;\n}\n#nnt > .sparkline--min-color {\ncolor: $success 30%;\n}\n#tnt > .sparkline--max-color {\ncolor: $success 30%;\n}\n#tnt > .sparkline--min-color {\ncolor: $success;\n}\n
"},{"location":"widgets/sparkline/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description data
Sequence[float] | None
None
The data represented by the sparkline. summary_function
Callable[[Sequence[float]], float]
max
The function that computes the height of each bar."},{"location":"widgets/sparkline/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/sparkline/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/sparkline/#component-classes","title":"Component Classes","text":"The sparkline widget provides the following component classes:
Use these component classes to define the two colors that the sparkline interpolates to represent its numerical data.
NoteThese two component classes are used exclusively for the color of the sparkline widget. Setting any style other than color
will have no effect.
sparkline--max-color
The color used for the larger values in the data. sparkline--min-color
The colour used for the smaller values in the data."},{"location":"widgets/sparkline/#textual.widgets.Sparkline","title":"textual.widgets.Sparkline class
","text":"def __init__(\nself,\ndata=None,\n*,\nsummary_function=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A sparkline widget to display numerical data.
Parameters Name Type Description Defaultdata
Sequence[float] | None
The initial data to populate the sparkline with.
None
summary_function
Callable[[Sequence[float]], float] | None
Summarises bar values into a single value used to represent each bar.
None
name
str | None
The name of the widget.
None
id
str | None
The ID of the widget in the DOM.
None
classes
str | None
The CSS classes for the widget.
None
disabled
bool
Whether the widget is disabled or not.
False
"},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"sparkline--max-color\",\n\"sparkline--min-color\",\n}\n
Use these component classes to define the two colors that the sparkline interpolates to represent its numerical data.
NoteThese two component classes are used exclusively for the color of the sparkline widget. Setting any style other than color
will have no effect.
sparkline--max-color
The color used for the larger values in the data. sparkline--min-color
The colour used for the smaller values in the data."},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.data","title":"data class-attribute
instance-attribute
","text":"data = data\n
The data that populates the sparkline.
"},{"location":"widgets/sparkline/#textual.widgets._sparkline.Sparkline.summary_function","title":"summary_functionclass-attribute
instance-attribute
","text":"summary_function = reactive[\nCallable[[Sequence[float]], float]\n](_max_factory)\n
The function that computes the value that represents each bar.
"},{"location":"widgets/static/","title":"Static","text":"A widget which displays static content. Can be used for Rich renderables and can also be the base for other types of widgets.
The example below shows how you can use a Static
widget as a simple text label (but see Label as a way of displaying text).
StaticApp Hello,\u00a0world!
from textual.app import App, ComposeResult\nfrom textual.widgets import Static\nclass StaticApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(\"Hello, world!\")\nif __name__ == \"__main__\":\napp = StaticApp()\napp.run()\n
"},{"location":"widgets/static/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/static/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/static/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/static/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/static/#see-also","title":"See Also","text":"class
","text":"def __init__(\nself,\nrenderable=\"\",\n*,\nexpand=False,\nshrink=False,\nmarkup=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A widget to display simple static content, or use as a base class for more complex widgets.
Parameters Name Type Description Defaultrenderable
RenderableType
A Rich renderable, or string containing console markup.
''
expand
bool
Expand content if required to fill container.
False
shrink
bool
Shrink content if required to fill container.
False
markup
bool
True if markup should be parsed and rendered.
True
name
str | None
Name of widget.
None
id
str | None
ID of Widget.
None
classes
str | None
Space separated list of class names.
None
disabled
bool
Whether the static is disabled or not.
False
"},{"location":"widgets/static/#textual.widgets._static.Static.update","title":"update method
","text":"def update(self, renderable=''):\n
Update the widget's content area with new text or Rich renderable.
Parameters Name Type Description Defaultrenderable
RenderableType
A new rich renderable. Defaults to empty renderable;
''
"},{"location":"widgets/switch/","title":"Switch","text":"A simple switch widget which stores a boolean value.
The example below shows switches in various states.
Outputswitch.pyswitch.tcssSwitchApp Example\u00a0switches \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e off:\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e on:\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e focused:\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e custom:\u00a0\u00a0\u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e
from textual.app import App, ComposeResult\nfrom textual.containers import Horizontal\nfrom textual.widgets import Static, Switch\nclass SwitchApp(App):\ndef compose(self) -> ComposeResult:\nyield Static(\"[b]Example switches\\n\", classes=\"label\")\nyield Horizontal(\nStatic(\"off: \", classes=\"label\"),\nSwitch(animate=False),\nclasses=\"container\",\n)\nyield Horizontal(\nStatic(\"on: \", classes=\"label\"),\nSwitch(value=True),\nclasses=\"container\",\n)\nfocused_switch = Switch()\nfocused_switch.focus()\nyield Horizontal(\nStatic(\"focused: \", classes=\"label\"), focused_switch, classes=\"container\"\n)\nyield Horizontal(\nStatic(\"custom: \", classes=\"label\"),\nSwitch(id=\"custom-design\"),\nclasses=\"container\",\n)\napp = SwitchApp(css_path=\"switch.tcss\")\nif __name__ == \"__main__\":\napp.run()\n
Screen {\nalign: center middle;\n}\n.container {\nheight: auto;\nwidth: auto;\n}\nSwitch {\nheight: auto;\nwidth: auto;\n}\n.label {\nheight: 3;\ncontent-align: center middle;\nwidth: auto;\n}\n#custom-design {\nbackground: darkslategrey;\n}\n#custom-design > .switch--slider {\ncolor: dodgerblue;\nbackground: darkslateblue;\n}\n
"},{"location":"widgets/switch/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description value
bool
False
The value of the switch."},{"location":"widgets/switch/#messages","title":"Messages","text":"The switch widget defines the following bindings:
Key(s) Description enter,space Toggle the switch state."},{"location":"widgets/switch/#component-classes","title":"Component Classes","text":"The switch widget provides the following component classes:
Class Descriptionswitch--slider
Targets the slider of the switch."},{"location":"widgets/switch/#additional-notes","title":"Additional Notes","text":"Switch
, set border: none;
and padding: 0;
.class
","text":"def __init__(\nself,\nvalue=False,\n*,\nanimate=True,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A switch widget that represents a boolean value.
Can be toggled by clicking on it or through its bindings.
The switch widget also contains component classes that enable more customization.
Parameters Name Type Description Defaultvalue
bool
The initial value of the switch.
False
animate
bool
True if the switch should animate when toggled.
True
name
str | None
The name of the switch.
None
id
str | None
The ID of the switch in the DOM.
None
classes
str | None
The CSS classes of the switch.
None
disabled
bool
Whether the switch is disabled or not.
False
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter,space\", \"toggle\", \"Toggle\", show=False)\n]\n
Key(s) Description enter,space Toggle the switch state."},{"location":"widgets/switch/#textual.widgets._switch.Switch.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {'switch--slider'}\n
Class Description switch--slider
Targets the slider of the switch."},{"location":"widgets/switch/#textual.widgets._switch.Switch.slider_pos","title":"slider_pos class-attribute
instance-attribute
","text":"slider_pos = reactive(0.0)\n
The position of the slider.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.value","title":"valueclass-attribute
instance-attribute
","text":"value = reactive(False, init=False)\n
The value of the switch; True
for on and False
for off.
class
","text":"def __init__(self, switch, value):\n
Bases: Message
Posted when the status of the switch changes.
Can be handled using on_switch_changed
in a subclass of Switch
or in a parent widget in the DOM.
value
bool
The value that the switch was changed to.
switch
Switch
The Switch
widget that was changed.
property
","text":"control: Switch\n
Alias for self.switch.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.action_toggle","title":"action_togglemethod
","text":"def action_toggle(self):\n
Toggle the state of the switch.
"},{"location":"widgets/switch/#textual.widgets._switch.Switch.toggle","title":"togglemethod
","text":"def toggle(self):\n
Toggle the switch value.
As a result of the value changing, a Switch.Changed
message will be posted.
Self
The Switch
instance.
Added in version 0.16.0
Switch between mutually exclusive content panes via a row of tabs.
This widget combines the Tabs and ContentSwitcher widgets to create a convenient way of navigating content.
Only a single child of TabbedContent is visible at once. Each child has an associated tab which will make it visible and hide the others.
"},{"location":"widgets/tabbed_content/#composing","title":"Composing","text":"There are two ways to provide the titles for the tab. You can pass them as positional arguments to the TabbedContent constructor:
def compose(self) -> ComposeResult:\nwith TabbedContent(\"Leto\", \"Jessica\", \"Paul\"):\nyield Markdown(LETO)\nyield Markdown(JESSICA)\nyield Markdown(PAUL)\n
Alternatively you can wrap the content in a TabPane widget, which takes the tab title as the first parameter:
def compose(self) -> ComposeResult:\nwith TabbedContent():\nwith TabPane(\"Leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\"):\nyield Markdown(PAUL)\n
"},{"location":"widgets/tabbed_content/#switching-tabs","title":"Switching tabs","text":"If you need to programmatically switch tabs, you should provide an id
attribute to the TabPane
s.
def compose(self) -> ComposeResult:\nwith TabbedContent():\nwith TabPane(\"Leto\", id=\"leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\n
You can then switch tabs by setting the active
reactive attribute:
# Switch to Jessica tab\nself.query_one(TabbedContent).active = \"jessica\"\n
Note
If you don't provide id
attributes to the tab panes, they will be assigned sequentially starting at tab-1
(then tab-2
etc).
The first child of TabbedContent
will be the initial active tab by default. You can pick a different initial tab by setting the initial
argument to the id
of the tab:
def compose(self) -> ComposeResult:\nwith TabbedContent(initial=\"jessica\"):\nwith TabPane(\"Leto\", id=\"leto\"):\nyield Markdown(LETO)\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\n
"},{"location":"widgets/tabbed_content/#example","title":"Example","text":"The following example contains a TabbedContent
with three tabs.
TabbedApp LetoJessicaPaul \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581 \u258e\u258a \u258eLady\u00a0Jessica\u258a \u258e\u258a \u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594 Bene\u00a0Gesserit\u00a0and\u00a0concubine\u00a0of\u00a0Leto,\u00a0and\u00a0mother\u00a0of\u00a0Paul\u00a0and\u00a0Alia. PaulAlia \u2501\u2578\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 First\u00a0child \u00a0L\u00a0\u00a0Leto\u00a0\u00a0J\u00a0\u00a0Jessica\u00a0\u00a0P\u00a0\u00a0Paul\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, Markdown, TabbedContent, TabPane\nLETO = \"\"\"\n# Duke Leto I Atreides\nHead of House Atreides.\n\"\"\"\nJESSICA = \"\"\"\n# Lady Jessica\nBene Gesserit and concubine of Leto, and mother of Paul and Alia.\n\"\"\"\nPAUL = \"\"\"\n# Paul Atreides\nSon of Leto and Jessica.\n\"\"\"\nclass TabbedApp(App):\n\"\"\"An example of tabbed content.\"\"\"\nBINDINGS = [\n(\"l\", \"show_tab('leto')\", \"Leto\"),\n(\"j\", \"show_tab('jessica')\", \"Jessica\"),\n(\"p\", \"show_tab('paul')\", \"Paul\"),\n]\ndef compose(self) -> ComposeResult:\n\"\"\"Compose app with tabbed content.\"\"\"\n# Footer to show keys\nyield Footer()\n# Add the TabbedContent widget\nwith TabbedContent(initial=\"jessica\"):\nwith TabPane(\"Leto\", id=\"leto\"): # First tab\nyield Markdown(LETO) # Tab content\nwith TabPane(\"Jessica\", id=\"jessica\"):\nyield Markdown(JESSICA)\nwith TabbedContent(\"Paul\", \"Alia\"):\nyield TabPane(\"Paul\", Label(\"First child\"))\nyield TabPane(\"Alia\", Label(\"Second child\"))\nwith TabPane(\"Paul\", id=\"paul\"):\nyield Markdown(PAUL)\ndef action_show_tab(self, tab: str) -> None:\n\"\"\"Switch to a new tab.\"\"\"\nself.get_child_by_type(TabbedContent).active = tab\nif __name__ == \"__main__\":\napp = TabbedApp()\napp.run()\n
"},{"location":"widgets/tabbed_content/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description active
str
\"\"
The id
attribute of the active tab. Set this to switch tabs."},{"location":"widgets/tabbed_content/#messages","title":"Messages","text":"This widget has no bindings.
"},{"location":"widgets/tabbed_content/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/tabbed_content/#see-also","title":"See also","text":"class
","text":"def __init__(\nself,\n*titles,\ninitial=\"\",\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A container with associated tabs to toggle content visibility.
Parameters Name Type Description Default*titles
TextType
Positional argument will be used as title.
()
initial
str
The id of the initial tab, or empty string to select the first tab.
''
name
str | None
The name of the button.
None
id
str | None
The ID of the button in the DOM.
None
classes
str | None
The CSS classes of the button.
None
disabled
bool
Whether the button is disabled or not.
False
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.active","title":"active class-attribute
instance-attribute
","text":"active: reactive[str] = reactive('', init=False)\n
The ID of the active tab, or empty string if none are active.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.tab_count","title":"tab_countproperty
","text":"tab_count: int\n
Total number of tabs.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.Cleared","title":"Clearedclass
","text":"def __init__(self, tabbed_content):\n
Bases: Message
Posted when there are no more tab panes.
Parameters Name Type Description Defaulttabbed_content
TabbedContent
The TabbedContent widget.
required"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.Cleared.control","title":"controlproperty
","text":"control: TabbedContent\n
The TabbedContent
widget that was cleared of all tab panes.
This is an alias for Cleared.tabbed_content
and is used by the on
decorator.
instance-attribute
","text":"tabbed_content = tabbed_content\n
The TabbedContent
widget that contains the tab activated.
class
","text":"def __init__(self, tabbed_content, tab):\n
Bases: Message
Posted when the active tab changes.
Parameters Name Type Description Defaulttabbed_content
TabbedContent
The TabbedContent widget.
requiredtab
Tab
The Tab widget that was selected (contains the tab label).
required"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.TabActivated.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'tab'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: TabbedContent\n
The TabbedContent
widget that contains the tab activated.
This is an alias for TabActivated.tabbed_content
and is used by the on
decorator.
instance-attribute
","text":"tab = tab\n
The Tab
widget that was selected (contains the tab label).
instance-attribute
","text":"tabbed_content = tabbed_content\n
The TabbedContent
widget that contains the tab activated.
method
","text":"def add_pane(self, pane, *, before=None, after=None):\n
Add a new pane to the tabbed content.
Parameters Name Type Description Defaultpane
TabPane
The pane to add.
requiredbefore
TabPane | str | None
Optional pane or pane ID to add the pane before.
None
after
TabPane | str | None
Optional pane or pane ID to add the pane after.
None
Returns Type Description AwaitTabbedContent
An awaitable object that waits for the pane to be added.
Raises Type DescriptionTabs.TabError
If there is a problem with the addition request.
NoteOnly one of before
or after
can be provided. If both are provided a Tabs.TabError
will be raised.
method
","text":"def clear_panes(self):\n
Remove all the panes in the tabbed content.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.disable_tab","title":"disable_tabmethod
","text":"def disable_tab(self, tab_id):\n
Disables the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to disable.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.enable_tab","title":"enable_tabmethod
","text":"def enable_tab(self, tab_id):\n
Enables the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to enable.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.hide_tab","title":"hide_tabmethod
","text":"def hide_tab(self, tab_id):\n
Hides the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to hide.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.remove_pane","title":"remove_panemethod
","text":"def remove_pane(self, pane_id):\n
Remove a given pane from the tabbed content.
Parameters Name Type Description Defaultpane_id
str
The ID of the pane to remove.
required Returns Type DescriptionAwaitTabbedContent
An awaitable object that waits for the pane to be removed.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.show_tab","title":"show_tabmethod
","text":"def show_tab(self, tab_id):\n
Shows the tab with the given ID.
Parameters Name Type Description Defaulttab_id
str
The ID of the TabPane
to show.
Tabs.TabError
If there are any issues with the request.
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabbedContent.validate_active","title":"validate_activemethod
","text":"def validate_active(self, active):\n
It doesn't make sense for active
to be an empty string.
active
str
Attribute to be validated.
required Returns Type Descriptionstr
Value of active
.
ValueError
If the active attribute is set to empty string when there are tabs available.
"},{"location":"widgets/tabbed_content/#textual.widgets.TabPane","title":"textual.widgets.TabPaneclass
","text":"def __init__(\nself,\ntitle,\n*children,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A container for switchable content, with additional title.
This widget is intended to be used with TabbedContent.
Parameters Name Type Description Defaulttitle
TextType
Title of the TabPane (will be displayed in a tab label).
required*children
Widget
Widget to go inside the TabPane.
()
name
str | None
Optional name for the TabPane.
None
id
str | None
Optional ID for the TabPane.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the TabPane is disabled or not.
False
"},{"location":"widgets/tabbed_content/#textual.widgets._tabbed_content.TabPane.Disabled","title":"Disabled class
","text":" Bases: TabPaneMessage
Sent when a tab pane is disabled via its reactive disabled
.
class
","text":" Bases: TabPaneMessage
Sent when a tab pane is enabled via its reactive disabled
.
class
","text":" Bases: Message
Base class for TabPane
messages.
property
","text":"control: TabPane\n
The tab pane that is the object of this message.
This is an alias for the attribute tab_pane
and is used by the on
decorator.
instance-attribute
","text":"tab_pane: TabPane\n
The TabPane
that is he object of this message.
Added in version 0.15.0
Displays a number of tab headers which may be activated with a click or navigated with cursor keys.
Construct a Tabs
widget with strings or Text objects as positional arguments, which will set the labels in the tabs. Here's an example with three tabs:
def compose(self) -> ComposeResult:\nyield Tabs(\"First tab\", \"Second tab\", Text.from_markup(\"[u]Third[/u] tab\"))\n
This will create Tab widgets internally, with auto-incrementing id
attributes (\"tab-1\"
, \"tab-2\"
etc). You can also supply Tab
objects directly in the constructor, which will allow you to explicitly set an id
. Here's an example:
def compose(self) -> ComposeResult:\nyield Tabs(\nTab(\"First tab\", id=\"one\"),\nTab(\"Second tab\", id=\"two\"),\n)\n
When the user switches to a tab by clicking or pressing keys, then Tabs
will send a Tabs.TabActivated message which contains the tab
that was activated. You can then use event.tab.id
attribute to perform any related actions.
Clear tabs by calling the clear method. Clearing the tabs will send a Tabs.TabActivated message with the tab
attribute set to None
.
Tabs may be added dynamically with the add_tab method, which accepts strings, Text, or Tab objects.
"},{"location":"widgets/tabs/#example","title":"Example","text":"The following example adds a Tabs
widget above a text label. Press A to add a tab, C to clear the tabs.
TabsApp \u00a0AtreidiesDuke\u00a0Leto\u00a0AtreidesLady\u00a0JessicaGurney\u00a0HalleckBaron\u00a0Vladimir \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2578\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u257a\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u258a\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u2594\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258aLady\u00a0Jessica\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u258e \u258a\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u2581\u258e \u00a0A\u00a0\u00a0Add\u00a0tab\u00a0\u00a0R\u00a0\u00a0Remove\u00a0active\u00a0tab\u00a0\u00a0C\u00a0\u00a0Clear\u00a0tabs\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import Footer, Label, Tabs\nNAMES = [\n\"Paul Atreidies\",\n\"Duke Leto Atreides\",\n\"Lady Jessica\",\n\"Gurney Halleck\",\n\"Baron Vladimir Harkonnen\",\n\"Glossu Rabban\",\n\"Chani\",\n\"Silgar\",\n]\nclass TabsApp(App):\n\"\"\"Demonstrates the Tabs widget.\"\"\"\nCSS = \"\"\"\n Tabs {\n dock: top;\n }\n Screen {\n align: center middle;\n }\n Label {\n margin:1 1;\n width: 100%;\n height: 100%;\n background: $panel;\n border: tall $primary;\n content-align: center middle;\n }\n \"\"\"\nBINDINGS = [\n(\"a\", \"add\", \"Add tab\"),\n(\"r\", \"remove\", \"Remove active tab\"),\n(\"c\", \"clear\", \"Clear tabs\"),\n]\ndef compose(self) -> ComposeResult:\nyield Tabs(NAMES[0])\nyield Label()\nyield Footer()\ndef on_mount(self) -> None:\n\"\"\"Focus the tabs when the app starts.\"\"\"\nself.query_one(Tabs).focus()\ndef on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:\n\"\"\"Handle TabActivated message sent by Tabs.\"\"\"\nlabel = self.query_one(Label)\nif event.tab is None:\n# When the tabs are cleared, event.tab will be None\nlabel.visible = False\nelse:\nlabel.visible = True\nlabel.update(event.tab.label)\ndef action_add(self) -> None:\n\"\"\"Add a new tab.\"\"\"\ntabs = self.query_one(Tabs)\n# Cycle the names\nNAMES[:] = [*NAMES[1:], NAMES[0]]\ntabs.add_tab(NAMES[0])\ndef action_remove(self) -> None:\n\"\"\"Remove active tab.\"\"\"\ntabs = self.query_one(Tabs)\nactive_tab = tabs.active_tab\nif active_tab is not None:\ntabs.remove_tab(active_tab.id)\ndef action_clear(self) -> None:\n\"\"\"Clear the tabs.\"\"\"\nself.query_one(Tabs).clear()\nif __name__ == \"__main__\":\napp = TabsApp()\napp.run()\n
"},{"location":"widgets/tabs/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Description active
str
\"\"
The ID of the active tab. Set this attribute to a tab ID to change the active tab."},{"location":"widgets/tabs/#messages","title":"Messages","text":"The Tabs widget defines the following bindings:
Key(s) Description left Move to the previous tab. right Move to the next tab."},{"location":"widgets/tabs/#component-classes","title":"Component Classes","text":"This widget has no component classes.
"},{"location":"widgets/tabs/#textual.widgets.Tabs","title":"textual.widgets.Tabsclass
","text":"def __init__(\nself,\n*tabs,\nactive=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Widget
A row of tabs.
Parameters Name Type Description Default*tabs
Tab | TextType
Positional argument should be explicit Tab objects, or a str or Text.
()
active
str | None
ID of the tab which should be active on start.
None
name
str | None
Optional name for the input widget.
None
id
str | None
Optional ID for the widget.
None
classes
str | None
Optional initial classes for the widget.
None
disabled
bool
Whether the input is disabled or not.
False
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\n\"left\", \"previous_tab\", \"Previous tab\", show=False\n),\nBinding(\"right\", \"next_tab\", \"Next tab\", show=False),\n]\n
Key(s) Description left Move to the previous tab. right Move to the next tab."},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.active","title":"active class-attribute
instance-attribute
","text":"active: reactive[str] = reactive('', init=False)\n
The ID of the active tab, or empty string if none are active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.active_tab","title":"active_tabproperty
","text":"active_tab: Tab | None\n
The currently active tab, or None if there are no active tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.tab_count","title":"tab_countproperty
","text":"tab_count: int\n
Total number of tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.Cleared","title":"Clearedclass
","text":"def __init__(self, tabs):\n
Bases: Message
Sent when there are no active tabs.
Parameters Name Type Description Defaulttabs
Tabs
The tabs widget.
required"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.Cleared.control","title":"controlproperty
","text":"control: Tabs\n
The tabs widget which was cleared.
This is an alias for Cleared.tabs
which is used by the on
decorator.
instance-attribute
","text":"tabs: Tabs = tabs\n
The tabs widget which was cleared.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabActivated","title":"TabActivatedclass
","text":" Bases: TabMessage
Sent when a new tab is activated.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabDisabled","title":"TabDisabledclass
","text":" Bases: TabMessage
Sent when a tab is disabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabEnabled","title":"TabEnabledclass
","text":" Bases: TabMessage
Sent when a tab is enabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabError","title":"TabErrorclass
","text":" Bases: Exception
Exception raised when there is an error relating to tabs.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabHidden","title":"TabHiddenclass
","text":" Bases: TabMessage
Sent when a tab is hidden.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage","title":"TabMessageclass
","text":"def __init__(self, tabs, tab):\n
Bases: Message
Parent class for all messages that have to do with a specific tab.
Parameters Name Type Description Defaulttabs
Tabs
The Tabs widget.
requiredtab
Tab
The tab that is the object of this message.
required"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage.ALLOW_SELECTOR_MATCH","title":"ALLOW_SELECTOR_MATCHclass-attribute
instance-attribute
","text":"ALLOW_SELECTOR_MATCH = {'tab'}\n
Additional message attributes that can be used with the on
decorator.
property
","text":"control: Tabs\n
The tabs widget containing the tab that is the object of this message.
This is an alias for the attribute tabs
and is used by the on
decorator.
instance-attribute
","text":"tab: Tab = tab\n
The tab that is the object of this message.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabMessage.tabs","title":"tabsinstance-attribute
","text":"tabs: Tabs = tabs\n
The tabs widget containing the tab.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.TabShown","title":"TabShownclass
","text":" Bases: TabMessage
Sent when a tab is shown.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.action_next_tab","title":"action_next_tabmethod
","text":"def action_next_tab(self):\n
Make the next tab active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.action_previous_tab","title":"action_previous_tabmethod
","text":"def action_previous_tab(self):\n
Make the previous tab active.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.add_tab","title":"add_tabmethod
","text":"def add_tab(self, tab, *, before=None, after=None):\n
Add a new tab to the end of the tab list.
Parameters Name Type Description Defaulttab
Tab | str | Text
A new tab object, or a label (str or Text).
requiredbefore
Tab | str | None
Optional tab or tab ID to add the tab before.
None
after
Tab | str | None
Optional tab or tab ID to add the tab after.
None
Returns Type Description AwaitMount
An awaitable object that waits for the tab to be mounted.
Raises Type DescriptionTabs.TabError
If there is a problem with the addition request.
NoteOnly one of before
or after
can be provided. If both are provided a Tabs.TabError
will be raised.
method
","text":"def clear(self):\n
Clear all the tabs.
Returns Type DescriptionAwaitRemove
An awaitable object that waits for the tabs to be removed.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.disable","title":"disablemethod
","text":"def disable(self, tab_id):\n
Disable the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to disable.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.enable","title":"enablemethod
","text":"def enable(self, tab_id):\n
Enable the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to enable.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.hide","title":"hidemethod
","text":"def hide(self, tab_id):\n
Hide the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to hide.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.remove_tab","title":"remove_tabmethod
","text":"def remove_tab(self, tab_or_id):\n
Remove a tab.
Parameters Name Type Description Defaulttab_or_id
Tab | str | None
The Tab to remove or its id.
required Returns Type DescriptionAwaitRemove
An awaitable object that waits for the tab to be removed.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.show","title":"showmethod
","text":"def show(self, tab_id):\n
Show the indicated tab.
Parameters Name Type Description Defaulttab_id
str
The ID of the Tab
to show.
Tab
The Tab
that was targeted.
TabError
If there are any issues with the request.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.validate_active","title":"validate_activemethod
","text":"def validate_active(self, active):\n
Check id assigned to active attribute is a valid tab.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tabs.watch_active","title":"watch_activemethod
","text":"def watch_active(self, previously_active, active):\n
Handle a change to the active tab.
"},{"location":"widgets/tabs/#textual.widgets.Tab","title":"textual.widgets.Tabclass
","text":"def __init__(\nself, label, *, id=None, classes=None, disabled=False\n):\n
Bases: Static
A Widget to manage a single tab within a Tabs widget.
Parameters Name Type Description Defaultlabel
TextType
The label to use in the tab.
requiredid
str | None
Optional ID for the widget.
None
classes
str | None
Space separated list of class names.
None
disabled
bool
Whether the tab is disabled or not.
False
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.label_text","title":"label_text property
","text":"label_text: str\n
Undecorated text of the label.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Clicked","title":"Clickedclass
","text":" Bases: TabMessage
A tab was clicked.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Disabled","title":"Disabledclass
","text":" Bases: TabMessage
A tab was disabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.Enabled","title":"Enabledclass
","text":" Bases: TabMessage
A tab was enabled.
"},{"location":"widgets/tabs/#textual.widgets._tabs.Tab.TabMessage","title":"TabMessageclass
","text":" Bases: Message
Tab-related messages.
These are mostly intended for internal use when interacting with Tabs
.
property
","text":"control: Tab\n
The tab that is the object of this message.
This is an alias for the attribute tab
and is used by the on
decorator.
instance-attribute
","text":"tab: Tab\n
The tab that is the object of this message.
"},{"location":"widgets/text_area/","title":"TextArea","text":"Added in version 0.38.0
A widget for editing text which may span multiple lines. Supports syntax highlighting for a selection of languages.
To enable syntax highlighting, you'll need to install the syntax
extra dependencies:
pip install \"textual[syntax]\"\n
poetry add \"textual[syntax]\"\n
This will install tree-sitter
and tree-sitter-languages
. These packages are distributed as binary wheels, so it may limit your applications ability to run in environments where these wheels are not supported.
In this example we load some initial text into the TextArea
, and set the language to \"python\"
to enable syntax highlighting.
TextAreaExample 1\u00a0\u00a0defhello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0print(\"hello\"+\u00a0name)\u00a0\u00a0\u00a0 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nTEXT = \"\"\"\\\ndef hello(name):\n print(\"hello\" + name)\ndef goodbye(name):\n print(\"goodbye\" + name)\n\"\"\"\nclass TextAreaExample(App):\ndef compose(self) -> ComposeResult:\nyield TextArea(TEXT, language=\"python\")\napp = TextAreaExample()\nif __name__ == \"__main__\":\napp.run()\n
To load content into the TextArea
after it has already been created, use the load_text
method.
To update the parser used for syntax highlighting, set the language
reactive attribute:
# Set the language to Markdown\ntext_area.language = \"markdown\"\n
Note
Syntax highlighting is unavailable on Python 3.7.
Note
More built-in languages will be added in the future. For now, you can add your own.
"},{"location":"widgets/text_area/#reading-content-from-textarea","title":"Reading content fromTextArea
","text":"There are a number of ways to retrieve content from the TextArea
:
TextArea.text
property returns all content in the text area as a string.TextArea.selected_text
property returns the text corresponding to the current selection.TextArea.get_text_range
method returns the text between two locations.In all cases, when multiple lines of text are retrieved, the document line separator will be used.
"},{"location":"widgets/text_area/#editing-content-inside-textarea","title":"Editing content insideTextArea
","text":"The content of the TextArea
can be updated using the replace
method. This method is the programmatic equivalent of selecting some text and then pasting.
Some other convenient methods are available, such as insert
, delete
, and clear
.
The cursor location is available via the cursor_location
property, which represents the location of the cursor as a tuple (row_index, column_index)
. These indices are zero-based. Writing a new value to cursor_location
will immediately update the location of the cursor.
>>> text_area = TextArea()\n>>> text_area.cursor_location\n(0, 0)\n>>> text_area.cursor_location = (0, 4)\n>>> text_area.cursor_location\n(0, 4)\n
cursor_location
is a simple way to move the cursor programmatically, but it doesn't let us select text.
To select text, we can use the selection
reactive attribute. Let's select the first two lines of text in a document by adding text_area.selection = Selection(start=(0, 0), end=(2, 0))
to our code:
TextAreaSelection 1\u00a0\u00a0defhello(name): 2\u00a0\u00a0print(\"hello\"+\u00a0name) 3\u00a0\u00a0 4\u00a0\u00a0defgoodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0print(\"goodbye\"+\u00a0name)\u00a0 6\u00a0\u00a0
from textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nfrom textual.widgets.text_area import Selection\nTEXT = \"\"\"\\\ndef hello(name):\n print(\"hello\" + name)\ndef goodbye(name):\n print(\"goodbye\" + name)\n\"\"\"\nclass TextAreaSelection(App):\ndef compose(self) -> ComposeResult:\ntext_area = TextArea(TEXT, language=\"python\")\ntext_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)!\nyield text_area\napp = TextAreaSelection()\nif __name__ == \"__main__\":\napp.run()\n
Note that selections can happen in both directions, so Selection((2, 0), (0, 0))
is also valid.
Tip
The end
attribute of the selection
is always equal to TextArea.cursor_location
. In other words, the cursor_location
attribute is simply a convenience for accessing text_area.selection.end
.
There are a number of additional utility methods available for interacting with the cursor.
"},{"location":"widgets/text_area/#location-information","title":"Location information","text":"A number of properties exist on TextArea
which give information about the current cursor location. These properties begin with cursor_at_
, and return booleans. For example, cursor_at_start_of_line
tells us if the cursor is at a start of line.
We can also check the location the cursor would arrive at if we were to move it. For example, get_cursor_right_location
returns the location the cursor would move to if it were to move right. A number of similar methods exist, with names like get_cursor_*_location
.
The move_cursor
method allows you to move the cursor to a new location while selecting text, or move the cursor and scroll to keep it centered.
# Move the cursor from its current location to row index 4,\n# column index 8, while selecting all the text between.\ntext_area.move_cursor((4, 8), select=True)\n
The move_cursor_relative
method offers a very similar interface, but moves the cursor relative to its current location.
There are some methods available which make common selections easier:
select_line
selects a line by index. Bound to F6 by default.select_all
selects all text. Bound to F7 by default.TextArea
ships with some builtin themes, and you can easily add your own.
Themes give you control over the look and feel, including syntax highlighting, the cursor, selection, gutter, and more.
"},{"location":"widgets/text_area/#using-builtin-themes","title":"Using builtin themes","text":"The initial theme of the TextArea
is determined by the theme
parameter.
# Create a TextArea with the 'dracula' theme.\nyield TextArea(\"print(123)\", language=\"python\", theme=\"dracula\")\n
You can check which themes are available using the available_themes
property.
>>> text_area = TextArea()\n>>> print(text_area.available_themes)\n{'dracula', 'github_light', 'monokai', 'vscode_dark'}\n
After creating a TextArea
, you can change the theme by setting the theme
attribute to one of the available themes.
text_area.theme = \"vscode_dark\"\n
On setting this attribute the TextArea
will immediately refresh to display the updated theme.
Using custom (non-builtin) themes is two-step process:
TextAreaTheme
.TextArea.register_theme
.Let's create a simple theme, \"my_cool_theme\"
, which colors the cursor blue, and the cursor line yellow. Our theme will also syntax highlight strings as red, and comments as magenta.
from rich.style import Style\nfrom textual.widgets.text_area import TextAreaTheme\n# ...\nmy_theme = TextAreaTheme(\n# This name will be used to refer to the theme...\nname=\"my_cool_theme\",\n# Basic styles such as background, cursor, selection, gutter, etc...\ncursor_style=Style(color=\"white\", bgcolor=\"blue\"),\ncursor_line_style=Style(bgcolor=\"yellow\"),\n# `syntax_styles` is for syntax highlighting.\n# It maps tokens parsed from the document to Rich styles.\nsyntax_styles={\n\"string\": Style(color=\"red\"),\n\"comment\": Style(color=\"magenta\"),\n}\n)\n
Attributes like cursor_style
and cursor_line_style
apply general language-agnostic styling to the widget.
The syntax_styles
attribute of TextAreaTheme
is used for syntax highlighting and depends on the language
currently in use. For more details, see syntax highlighting.
If you wish to build on an existing theme, you can obtain a reference to it using the TextAreaTheme.get_builtin_theme
classmethod:
from textual.widgets.text_area import TextAreaTheme\nmonokai = TextAreaTheme.get_builtin_theme(\"monokai\")\n
"},{"location":"widgets/text_area/#2-registering-a-theme","title":"2. Registering a theme","text":"Our theme can now be registered with the TextArea
instance.
text_area.register_theme(my_theme)\n
After registering a theme, it'll appear in the available_themes
:
>>> print(text_area.available_themes)\n{'dracula', 'github_light', 'monokai', 'vscode_dark', 'my_cool_theme'}\n
We can now switch to it:
text_area.theme = \"my_cool_theme\"\n
This immediately updates the appearance of the TextArea
:
TextAreaCustomThemes 1\u00a0\u00a0#\u00a0says\u00a0hello 2\u00a0\u00a0def\u00a0hello(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0print(\"hello\"\u00a0+\u00a0name)\u00a0\u00a0\u00a0 4\u00a0\u00a0 5\u00a0\u00a0#\u00a0says\u00a0goodbye 6\u00a0\u00a0def\u00a0goodbye(name):\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 7\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0print(\"goodbye\"\u00a0+\u00a0name)\u00a0 8\u00a0\u00a0
"},{"location":"widgets/text_area/#indentation","title":"Indentation","text":"The character(s) inserted when you press tab is controlled by setting the indent_type
attribute to either tabs
or spaces
.
If indent_type == \"spaces\"
, pressing Tab will insert up to indent_width
spaces in order to align with the next tab stop.
When content is loaded into TextArea
, the content is scanned from beginning to end and the first occurrence of a line separator is recorded.
This separator will then be used when content is later read from the TextArea
via the text
property. The TextArea
widget does not support exporting text which contains mixed line endings.
Similarly, newline characters pasted into the TextArea
will be converted.
You can check the line separator of the current document by inspecting TextArea.document.newline
:
>>> text_area = TextArea()\n>>> text_area.document.newline\n'\\n'\n
"},{"location":"widgets/text_area/#line-numbers","title":"Line numbers","text":"The gutter (column on the left containing line numbers) can be toggled by setting the show_line_numbers
attribute to True
or False
.
Setting this attribute will immediately repaint the TextArea
to reflect the new value.
TextArea
","text":"Sometimes, you may wish to subclass TextArea
to add some extra functionality. In this section, we'll briefly explore how we can extend the widget to achieve common goals.
You may wish to hook into certain key presses to inject some functionality. This can be done by over-riding _on_key
and adding the required functionality.
Let's extend TextArea
to add a feature which automatically closes parentheses and moves the cursor to a sensible location.
from textual import events\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\nclass ExtendedTextArea(TextArea):\n\"\"\"A subclass of TextArea with parenthesis-closing functionality.\"\"\"\ndef _on_key(self, event: events.Key) -> None:\nif event.character == \"(\":\nself.insert(\"()\")\nself.move_cursor_relative(columns=-1)\nevent.prevent_default()\nclass TextAreaKeyPressHook(App):\ndef compose(self) -> ComposeResult:\nyield ExtendedTextArea(language=\"python\")\napp = TextAreaKeyPressHook()\nif __name__ == \"__main__\":\napp.run()\n
This intercepts the key handler when \"(\"
is pressed, and inserts \"()\"
instead. It then moves the cursor so that it lands between the open and closing parentheses.
Typing def hello(
into the TextArea
results in the bracket automatically being closed:
TextAreaKeyPressHook 1\u00a0\u00a0def\u00a0hello()
"},{"location":"widgets/text_area/#advanced-concepts","title":"Advanced concepts","text":""},{"location":"widgets/text_area/#syntax-highlighting","title":"Syntax highlighting","text":"Syntax highlighting inside the TextArea
is powered by a library called tree-sitter
.
Each time you update the document in a TextArea
, an internal syntax tree is updated. This tree is frequently queried to find location ranges relevant to syntax highlighting. We give these ranges names, and ultimately map them to Rich styles inside TextAreaTheme.syntax_styles
.
To illustrate how this works, lets look at how the \"Monokai\" TextAreaTheme
highlights Markdown files.
When the language
attribute is set to \"markdown\"
, a highlight query similar to the one below is used (trimmed for brevity).
(heading_content) @heading\n(link) @link\n
This highlight query maps heading_content
nodes returned by the Markdown parser to the name @heading
, and link
nodes to the name @link
.
Inside our TextAreaTheme.syntax_styles
dict, we can map the name @heading
to a Rich style. Here's a snippet from the \"Monokai\" theme which does just that:
TextAreaTheme(\nname=\"monokai\",\nbase_style=Style(color=\"#f8f8f2\", bgcolor=\"#272822\"),\ngutter_style=Style(color=\"#90908a\", bgcolor=\"#272822\"),\n# ...\nsyntax_styles={\n# Colorise @heading and make them bold\n\"heading\": Style(color=\"#F92672\", bold=True),\n# Colorise and underline @link\n\"link\": Style(color=\"#66D9EF\", underline=True),\n# ...\n},\n)\n
To understand which names can be mapped inside syntax_styles
, we recommend looking at the existing themes and highlighting queries (.scm
files) in the Textual repository.
Tip
You may also wish to take a look at the contents of TextArea._highlights
on an active TextArea
instance to see which highlights have been generated for the open document.
To add support for a language to a TextArea
, use the register_language
method.
To register a language, we require two things:
Language
object which contains the grammar for the language.The easiest way to obtain a Language
object is using the py-tree-sitter-languages
package. Here's how we can use this package to obtain a reference to a Language
object representing Java:
from tree_sitter_languages import get_language\njava_language = get_language(\"java\")\n
Note
py-tree-sitter-languages
may not be available on some architectures (e.g. Macbooks with Apple Silicon running Python 3.7).
The exact version of the parser used when you call get_language
can be checked via the repos.txt
file in the version of py-tree-sitter-languages
you're using. This file contains links to the GitHub repos and commit hashes of the tree-sitter parsers. In these repos you can often find pre-made highlight queries at queries/highlights.scm
, and a file showing all the available node types which can be used in highlight queries at src/node-types.json
.
Since we're adding support for Java, lets grab the Java highlight query from the repo by following these steps:
repos.txt
file from the py-tree-sitter-languages
repo.tree-sitter-java
and go to the repo on GitHub (you may also need to go to the specific commit referenced in repos.txt
).queries/highlights.scm
to see the example highlight query for Java.Be sure to check the license in the repo to ensure it can be freely copied.
Warning
It's important to use a highlight query which is compatible with the parser in use, so pay attention to the commit hash when visiting the repo via repos.txt
.
We now have our Language
and our highlight query, so we can register Java as a language.
from pathlib import Path\nfrom tree_sitter_languages import get_language\nfrom textual.app import App, ComposeResult\nfrom textual.widgets import TextArea\njava_language = get_language(\"java\")\njava_highlight_query = (Path(__file__).parent / \"java_highlights.scm\").read_text()\njava_code = \"\"\"\\\nclass HelloWorld {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n\"\"\"\nclass TextAreaCustomLanguage(App):\ndef compose(self) -> ComposeResult:\ntext_area = TextArea(text=java_code)\ntext_area.cursor_blink = False\n# Register the Java language and highlight query\ntext_area.register_language(java_language, java_highlight_query)\n# Switch to Java\ntext_area.language = \"java\"\nyield text_area\napp = TextAreaCustomLanguage()\nif __name__ == \"__main__\":\napp.run()\n
Running our app, we can see that the Java code is highlighted. We can freely edit the text, and the syntax highlighting will update immediately.
TextAreaCustomLanguage 1\u00a0\u00a0class\u00a0HelloWorld\u00a0{\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 2\u00a0\u00a0publicstatic\u00a0void\u00a0main(String[]\u00a0args)\u00a0{\u00a0 3\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0System.out.println(\"Hello,\u00a0World!\");\u00a0 4\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0}\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 5\u00a0\u00a0}\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0 6\u00a0\u00a0
Recall that we map names (like @heading
) from the tree-sitter highlight query to Rich style objects inside the TextAreaTheme.syntax_styles
dictionary. If you notice some highlights are missing after registering a language, the issue may be:
TextAreaTheme
doesn't contain a mapping for the name in the highlight query. Adding a new to syntax_styles
should resolve the issue.Tip
The names assigned in tree-sitter highlight queries are often reused across multiple languages. For example, @string
is used in many languages to highlight strings.
language
str | None
None
The language to use for syntax highlighting. theme
str | None
TextAreaTheme.default()
The theme to use for syntax highlighting. selection
Selection
Selection()
The current selection. show_line_numbers
bool
True
Show or hide line numbers. indent_width
int
4
The number of spaces to indent and width of tabs. match_cursor_bracket
bool
True
Enable/disable highlighting matching brackets under cursor. cursor_blink
bool
True
Enable/disable blinking of the cursor when the widget has focus."},{"location":"widgets/text_area/#messages","title":"Messages","text":"The TextArea
widget defines the following bindings:
The TextArea
widget defines no component classes.
Styling should be done exclusively via TextAreaTheme
.
Input
- for single-line text input.TextAreaTheme
- for theming the TextArea
.py-tree-sitter-languages
repository (provides binary wheels for a large variety of tree-sitter languages).class
","text":"def __init__(\nself,\ntext=\"\",\n*,\nlanguage=None,\ntheme=None,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: ScrollView
text
str
The initial text to load into the TextArea.
''
language
str | None
The language to use.
None
theme
str | None
The theme to use.
None
name
str | None
The name of the TextArea
widget.
None
id
str | None
The ID of the widget, used to refer to it from Textual CSS.
None
classes
str | None
One or more Textual CSS compatible class names separated by spaces.
None
disabled
bool
True if the widget is disabled.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.BINDINGS","title":"BINDINGS class-attribute
instance-attribute
","text":"BINDINGS = [\nBinding(\n\"escape\",\n\"screen.focus_next\",\n\"Shift Focus\",\nshow=False,\n),\nBinding(\"up\", \"cursor_up\", \"cursor up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"cursor down\", show=False\n),\nBinding(\n\"left\", \"cursor_left\", \"cursor left\", show=False\n),\nBinding(\n\"right\", \"cursor_right\", \"cursor right\", show=False\n),\nBinding(\n\"ctrl+left\",\n\"cursor_word_left\",\n\"cursor word left\",\nshow=False,\n),\nBinding(\n\"ctrl+right\",\n\"cursor_word_right\",\n\"cursor word right\",\nshow=False,\n),\nBinding(\n\"home,ctrl+a\",\n\"cursor_line_start\",\n\"cursor line start\",\nshow=False,\n),\nBinding(\n\"end,ctrl+e\",\n\"cursor_line_end\",\n\"cursor line end\",\nshow=False,\n),\nBinding(\n\"pageup\",\n\"cursor_page_up\",\n\"cursor page up\",\nshow=False,\n),\nBinding(\n\"pagedown\",\n\"cursor_page_down\",\n\"cursor page down\",\nshow=False,\n),\nBinding(\n\"ctrl+shift+left\",\n\"cursor_word_left(True)\",\n\"cursor left word select\",\nshow=False,\n),\nBinding(\n\"ctrl+shift+right\",\n\"cursor_word_right(True)\",\n\"cursor right word select\",\nshow=False,\n),\nBinding(\n\"shift+home\",\n\"cursor_line_start(True)\",\n\"cursor line start select\",\nshow=False,\n),\nBinding(\n\"shift+end\",\n\"cursor_line_end(True)\",\n\"cursor line end select\",\nshow=False,\n),\nBinding(\n\"shift+up\",\n\"cursor_up(True)\",\n\"cursor up select\",\nshow=False,\n),\nBinding(\n\"shift+down\",\n\"cursor_down(True)\",\n\"cursor down select\",\nshow=False,\n),\nBinding(\n\"shift+left\",\n\"cursor_left(True)\",\n\"cursor left select\",\nshow=False,\n),\nBinding(\n\"shift+right\",\n\"cursor_right(True)\",\n\"cursor right select\",\nshow=False,\n),\nBinding(\"f6\", \"select_line\", \"select line\", show=False),\nBinding(\"f7\", \"select_all\", \"select all\", show=False),\nBinding(\n\"backspace\",\n\"delete_left\",\n\"delete left\",\nshow=False,\n),\nBinding(\n\"ctrl+w\",\n\"delete_word_left\",\n\"delete left to start of word\",\nshow=False,\n),\nBinding(\n\"delete,ctrl+d\",\n\"delete_right\",\n\"delete right\",\nshow=False,\n),\nBinding(\n\"ctrl+f\",\n\"delete_word_right\",\n\"delete right to start of word\",\nshow=False,\n),\nBinding(\n\"ctrl+x\", \"delete_line\", \"delete line\", show=False\n),\nBinding(\n\"ctrl+u\",\n\"delete_to_start_of_line\",\n\"delete to line start\",\nshow=False,\n),\nBinding(\n\"ctrl+k\",\n\"delete_to_end_of_line\",\n\"delete to line end\",\nshow=False,\n),\n]\n
Key(s) Description escape Focus on the next item. up Move the cursor up. down Move the cursor down. left Move the cursor left. ctrl+left Move the cursor to the start of the word. ctrl+shift+left Move the cursor to the start of the word and select. right Move the cursor right. ctrl+right Move the cursor to the end of the word. ctrl+shift+right Move the cursor to the end of the word and select. home,ctrl+a Move the cursor to the start of the line. end,ctrl+e Move the cursor to the end of the line. shift+home Move the cursor to the start of the line and select. shift+end Move the cursor to the end of the line and select. pageup Move the cursor one page up. pagedown Move the cursor one page down. shift+up Select while moving the cursor up. shift+down Select while moving the cursor down. shift+left Select while moving the cursor left. shift+right Select while moving the cursor right. backspace Delete character to the left of cursor. ctrl+w Delete from cursor to start of the word. delete,ctrl+d Delete character to the right of cursor. ctrl+f Delete from cursor to end of the word. ctrl+x Delete the current line. ctrl+u Delete from cursor to the start of the line. ctrl+k Delete from cursor to the end of the line. f6 Select the current line. f7 Select all text in the document."},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.available_languages","title":"available_languages property
","text":"available_languages: set[str]\n
A list of the names of languages available to the TextArea
.
The values in this list can be assigned to the language
reactive attribute of TextArea
.
The returned list contains the builtin languages plus those registered via the register_language
method. Builtin languages will be listed before user-registered languages, but there are no other ordering guarantees.
property
","text":"available_themes: set[str]\n
A list of the names of the themes available to the TextArea
.
The values in this list can be assigned theme
reactive attribute of TextArea
.
You can retrieve the full specification for a theme by passing one of the strings from this list into TextAreaTheme.get_by_name(theme_name: str)
.
Alternatively, you can directly retrieve a list of TextAreaTheme
objects (which contain the full theme specification) by calling TextAreaTheme.builtin_themes()
.
property
","text":"cursor_at_end_of_line: bool\n
True if and only if the cursor is at the end of a row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_end_of_text","title":"cursor_at_end_of_textproperty
","text":"cursor_at_end_of_text: bool\n
True if and only if the cursor is at the very end of the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_first_line","title":"cursor_at_first_lineproperty
","text":"cursor_at_first_line: bool\n
True if and only if the cursor is on the first line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_last_line","title":"cursor_at_last_lineproperty
","text":"cursor_at_last_line: bool\n
True if and only if the cursor is on the last line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_start_of_line","title":"cursor_at_start_of_lineproperty
","text":"cursor_at_start_of_line: bool\n
True if and only if the cursor is at column 0.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_at_start_of_text","title":"cursor_at_start_of_textproperty
","text":"cursor_at_start_of_text: bool\n
True if and only if the cursor is at location (0, 0)
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_blink","title":"cursor_blinkclass-attribute
instance-attribute
","text":"cursor_blink: Reactive[bool] = reactive(True)\n
True if the cursor should blink.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cursor_location","title":"cursor_locationwritable
property
","text":"cursor_location: Location\n
The current location of the cursor in the document.
This is a utility for accessing the end
of TextArea.selection
.
property
","text":"cursor_screen_offset: Offset\n
The offset of the cursor relative to the screen.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.document","title":"documentinstance-attribute
","text":"document: DocumentBase = Document(text)\n
The document this widget is currently editing.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.gutter_width","title":"gutter_widthproperty
","text":"gutter_width: int\n
The width of the gutter (the left column containing line numbers).
Returns Type Descriptionint
The cell-width of the line number column. If show_line_numbers
is False
returns 0.
instance-attribute
","text":"indent_type: Literal['tabs', 'spaces'] = 'spaces'\n
Whether to indent using tabs or spaces.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.indent_width","title":"indent_widthclass-attribute
instance-attribute
","text":"indent_width: Reactive[int] = reactive(4)\n
The width of tabs or the multiple of spaces to align to on pressing the tab
key.
If the document currently open contains tabs that are currently visible on screen, altering this value will immediately change the display width of the visible tabs.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.is_syntax_aware","title":"is_syntax_awareproperty
","text":"is_syntax_aware: bool\n
True if the TextArea is currently syntax aware - i.e. it's parsing document content.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.language","title":"languageclass-attribute
instance-attribute
","text":"language: Reactive[str | None] = language\n
The language to use.
This must be set to a valid, non-None value for syntax highlighting to work.
If the value is a string, a built-in language parser will be used if available.
If you wish to use an unsupported language, you'll have to register it first using TextArea.register_language
.
class-attribute
instance-attribute
","text":"match_cursor_bracket: Reactive[bool] = reactive(True)\n
If the cursor is at a bracket, highlight the matching bracket (if found).
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.selected_text","title":"selected_textproperty
","text":"selected_text: str\n
The text between the start and end points of the current selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.selection","title":"selectionclass-attribute
instance-attribute
","text":"selection: Reactive[Selection] = reactive(\nSelection(), always_update=True, init=False\n)\n
The selection start and end locations (zero-based line_index, offset).
This represents the cursor location and the current selection.
The Selection.end
always refers to the cursor location.
If no text is selected, then Selection.end == Selection.start
is True.
The text selected in the document is available via the TextArea.selected_text
property.
class-attribute
instance-attribute
","text":"show_line_numbers: Reactive[bool] = reactive(True)\n
True to show the line number column on the left edge, otherwise False.
Changing this value will immediately re-render the TextArea
.
property
","text":"text: str\n
The entire text content of the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.theme","title":"themeclass-attribute
instance-attribute
","text":"theme: Reactive[str | None] = theme\n
The name of the theme to use.
Themes must be registered using TextArea.register_theme
before they can be used.
Syntax highlighting is only possible when the language
attribute is set.
class
","text":" Bases: Message
Posted when the content inside the TextArea changes.
Handle this message using the on
decorator - @on(TextArea.Changed)
or a method named on_text_area_changed
.
property
","text":"control: TextArea\n
The TextArea
that sent this message.
instance-attribute
","text":"text_area: TextArea\n
The text_area
that sent this message.
class
","text":" Bases: Message
Posted when the selection changes.
This includes when the cursor moves or when text is selected.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.SelectionChanged.selection","title":"selectioninstance-attribute
","text":"selection: Selection\n
The new selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.SelectionChanged.text_area","title":"text_areainstance-attribute
","text":"text_area: TextArea\n
The text_area
that sent this message.
method
","text":"def action_cursor_down(self, select=False):\n
Move the cursor down one cell.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_left","title":"action_cursor_left method
","text":"def action_cursor_left(self, select=False):\n
Move the cursor one location to the left.
If the cursor is at the left edge of the document, try to move it to the end of the previous line.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_line_end","title":"action_cursor_line_end method
","text":"def action_cursor_line_end(self, select=False):\n
Move the cursor to the end of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_line_start","title":"action_cursor_line_startmethod
","text":"def action_cursor_line_start(self, select=False):\n
Move the cursor to the start of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_page_down","title":"action_cursor_page_downmethod
","text":"def action_cursor_page_down(self):\n
Move the cursor and scroll down one page.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_page_up","title":"action_cursor_page_upmethod
","text":"def action_cursor_page_up(self):\n
Move the cursor and scroll up one page.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_right","title":"action_cursor_rightmethod
","text":"def action_cursor_right(self, select=False):\n
Move the cursor one location to the right.
If the cursor is at the end of a line, attempt to go to the start of the next line.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_up","title":"action_cursor_up method
","text":"def action_cursor_up(self, select=False):\n
Move the cursor up one cell.
Parameters Name Type Description Defaultselect
bool
If True, select the text while moving.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_word_left","title":"action_cursor_word_left method
","text":"def action_cursor_word_left(self, select=False):\n
Move the cursor left by a single word, skipping trailing whitespace.
Parameters Name Type Description Defaultselect
bool
Whether to select while moving the cursor.
False
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_cursor_word_right","title":"action_cursor_word_right method
","text":"def action_cursor_word_right(self, select=False):\n
Move the cursor right by a single word, skipping leading whitespace.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_left","title":"action_delete_leftmethod
","text":"def action_delete_left(self):\n
Deletes the character to the left of the cursor and updates the cursor location.
If there's a selection, then the selected range is deleted.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_line","title":"action_delete_linemethod
","text":"def action_delete_line(self):\n
Deletes the lines which intersect with the selection.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_right","title":"action_delete_rightmethod
","text":"def action_delete_right(self):\n
Deletes the character to the right of the cursor and keeps the cursor at the same location.
If there's a selection, then the selected range is deleted.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_to_end_of_line","title":"action_delete_to_end_of_linemethod
","text":"def action_delete_to_end_of_line(self):\n
Deletes from the cursor location to the end of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_to_start_of_line","title":"action_delete_to_start_of_linemethod
","text":"def action_delete_to_start_of_line(self):\n
Deletes from the cursor location to the start of the line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_word_left","title":"action_delete_word_leftmethod
","text":"def action_delete_word_left(self):\n
Deletes the word to the left of the cursor and updates the cursor location.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_delete_word_right","title":"action_delete_word_rightmethod
","text":"def action_delete_word_right(self):\n
Deletes the word to the right of the cursor and keeps the cursor at the same location.
Note that the location that we delete to using this action is not the same as the location we move to when we move the cursor one word to the right. This action does not skip leading whitespace, whereas cursor movement does.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_select_all","title":"action_select_allmethod
","text":"def action_select_all(self):\n
Select all the text in the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.action_select_line","title":"action_select_linemethod
","text":"def action_select_line(self):\n
Select all the text on the current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.cell_width_to_column_index","title":"cell_width_to_column_indexmethod
","text":"def cell_width_to_column_index(self, cell_width, row_index):\n
Return the column that the cell width corresponds to on the given row.
Parameters Name Type Description Defaultcell_width
int
The cell width to convert.
requiredrow_index
int
The index of the row to examine.
required Returns Type Descriptionint
The column corresponding to the cell width on that row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.clamp_visitable","title":"clamp_visitablemethod
","text":"def clamp_visitable(self, location):\n
Clamp the given location to the nearest visitable location.
Parameters Name Type Description Defaultlocation
Location
The location to clamp.
required Returns Type DescriptionLocation
The nearest location that we could conceivably navigate to using the cursor.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.clear","title":"clearmethod
","text":"def clear(self):\n
Delete all text from the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.delete","title":"deletemethod
","text":"def delete(self, start, end, *, maintain_selection_offset=True):\n
Delete the text between two locations in the document.
Parameters Name Type Description Defaultstart
Location
The start location.
requiredend
Location
The end location.
requiredmaintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def edit(self, edit):\n
Perform an Edit.
Parameters Name Type Description Defaultedit
Edit
The Edit to perform.
required Returns Type DescriptionAny
Data relating to the edit that may be useful. The data returned
Any
may be different depending on the edit performed.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.find_matching_bracket","title":"find_matching_bracketmethod
","text":"def find_matching_bracket(self, bracket, search_from):\n
If the character is a bracket, find the matching bracket.
Parameters Name Type Description Defaultbracket
str
The character we're searching for the matching bracket of.
requiredsearch_from
Location
The location to start the search.
required Returns Type DescriptionLocation | None
The Location
of the matching bracket, or None
if it's not found.
Location | None
If the character is not available for bracket matching, None
is returned.
method
","text":"def get_column_width(self, row, column):\n
Get the cell offset of the column from the start of the row.
Parameters Name Type Description Defaultrow
int
The row index.
requiredcolumn
int
The column index (codepoint offset from start of row).
required Returns Type Descriptionint
The cell width of the column relative to the start of the row.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_down_location","title":"get_cursor_down_locationmethod
","text":"def get_cursor_down_location(self):\n
Get the location the cursor will move to if it moves down.
Returns Type DescriptionLocation
The location the cursor will move to if it moves down.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_left_location","title":"get_cursor_left_locationmethod
","text":"def get_cursor_left_location(self):\n
Get the location the cursor will move to if it moves left.
Returns Type DescriptionLocation
The location of the cursor if it moves left.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_line_end_location","title":"get_cursor_line_end_locationmethod
","text":"def get_cursor_line_end_location(self):\n
Get the location of the end of the current line.
Returns Type DescriptionLocation
The (row, column) location of the end of the cursors current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_line_start_location","title":"get_cursor_line_start_locationmethod
","text":"def get_cursor_line_start_location(self):\n
Get the location of the start of the current line.
Returns Type DescriptionLocation
The (row, column) location of the start of the cursors current line.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_right_location","title":"get_cursor_right_locationmethod
","text":"def get_cursor_right_location(self):\n
Get the location the cursor will move to if it moves right.
Returns Type DescriptionLocation
the location the cursor will move to if it moves right.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_up_location","title":"get_cursor_up_locationmethod
","text":"def get_cursor_up_location(self):\n
Get the location the cursor will move to if it moves up.
Returns Type DescriptionLocation
The location the cursor will move to if it moves up.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_word_left_location","title":"get_cursor_word_left_locationmethod
","text":"def get_cursor_word_left_location(self):\n
Get the location the cursor will jump to if it goes 1 word left.
Returns Type DescriptionLocation
The location the cursor will jump on \"jump word left\".
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_cursor_word_right_location","title":"get_cursor_word_right_locationmethod
","text":"def get_cursor_word_right_location(self):\n
Get the location the cursor will jump to if it goes 1 word right.
Returns Type DescriptionLocation
The location the cursor will jump on \"jump word right\".
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_target_document_location","title":"get_target_document_locationmethod
","text":"def get_target_document_location(self, event):\n
Given a MouseEvent, return the row and column offset of the event in document-space.
Parameters Name Type Description Defaultevent
MouseEvent
The MouseEvent.
required Returns Type DescriptionLocation
The location of the mouse event within the document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.get_text_range","title":"get_text_rangemethod
","text":"def get_text_range(self, start, end):\n
Get the text between a start and end location.
Parameters Name Type Description Defaultstart
Location
The start location.
requiredend
Location
The end location.
required Returns Type Descriptionstr
The text between start and end.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.insert","title":"insertmethod
","text":"def insert(\nself,\ntext,\nlocation=None,\n*,\nmaintain_selection_offset=True\n):\n
Insert text into the document.
Parameters Name Type Description Defaulttext
str
The text to insert.
requiredlocation
Location | None
The location to insert text, or None to use the cursor location.
None
maintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def load_document(self, document):\n
Load a document into the TextArea.
Parameters Name Type Description Defaultdocument
DocumentBase
The document to load into the TextArea.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.load_text","title":"load_textmethod
","text":"def load_text(self, text):\n
Load text into the TextArea.
This will replace the text currently in the TextArea.
Parameters Name Type Description Defaulttext
str
The text to load into the TextArea.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.move_cursor","title":"move_cursormethod
","text":"def move_cursor(\nself,\nlocation,\nselect=False,\ncenter=False,\nrecord_width=True,\n):\n
Move the cursor to a location.
Parameters Name Type Description Defaultlocation
Location
The location to move the cursor to.
requiredselect
bool
If True, select text between the old and new location.
False
center
bool
If True, scroll such that the cursor is centered.
False
record_width
bool
If True, record the cursor column cell width after navigating so that we jump back to the same width the next time we move to a row that is wide enough.
True
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.move_cursor_relative","title":"move_cursor_relative method
","text":"def move_cursor_relative(\nself,\nrows=0,\ncolumns=0,\nselect=False,\ncenter=False,\nrecord_width=True,\n):\n
Move the cursor relative to its current location.
Parameters Name Type Description Defaultrows
int
The number of rows to move down by (negative to move up)
0
columns
int
The number of columns to move right by (negative to move left)
0
select
bool
If True, select text between the old and new location.
False
center
bool
If True, scroll such that the cursor is centered.
False
record_width
bool
If True, record the cursor column cell width after navigating so that we jump back to the same width the next time we move to a row that is wide enough.
True
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.record_cursor_width","title":"record_cursor_width method
","text":"def record_cursor_width(self):\n
Record the current cell width of the cursor.
This is used where we navigate up and down through rows. If we're in the middle of a row, and go down to a row with no content, then we go down to another row, we want our cursor to jump back to the same offset that we were originally at.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.register_language","title":"register_languagemethod
","text":"def register_language(self, language, highlight_query):\n
Register a language and corresponding highlight query.
Calling this method does not change the language of the TextArea
. On switching to this language (via the language
reactive attribute), syntax highlighting will be performed using the given highlight query.
If a string name
is supplied for a builtin supported language, then this method will update the default highlight query for that language.
Registering a language only registers it to this instance of TextArea
.
language
str | 'Language'
A string referring to a builtin language or a tree-sitter Language
object.
highlight_query
str
The highlight query to use for syntax highlighting this language.
required"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.register_theme","title":"register_thememethod
","text":"def register_theme(self, theme):\n
Register a theme for use by the TextArea
.
After registering a theme, you can set themes by assigning the theme name to the TextArea.theme
reactive attribute. For example text_area.theme = \"my_custom_theme\"
where \"my_custom_theme\"
is the name of the theme you registered.
If you supply a theme with a name that already exists that theme will be overwritten.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.replace","title":"replacemethod
","text":"def replace(\nself,\ninsert,\nstart,\nend,\n*,\nmaintain_selection_offset=True\n):\n
Replace text in the document with new text.
Parameters Name Type Description Defaultinsert
str
The text to insert.
requiredstart
Location
The start location
requiredend
Location
The end location.
requiredmaintain_selection_offset
bool
If True, the active Selection will be updated such that the same text is selected before and after the selection, if possible. Otherwise, the cursor will jump to the end point of the edit.
True
Returns Type Description EditResult
An EditResult
containing information about the edit.
method
","text":"def scroll_cursor_visible(self, center=False, animate=False):\n
Scroll the TextArea
such that the cursor is visible on screen.
center
bool
True if the cursor should be scrolled to the center.
False
animate
bool
True if we should animate while scrolling.
False
Returns Type Description Offset
The offset that was scrolled to bring the cursor into view.
"},{"location":"widgets/text_area/#textual.widgets._text_area.TextArea.select_all","title":"select_allmethod
","text":"def select_all(self):\n
Select all of the text in the TextArea
.
method
","text":"def select_line(self, index):\n
Select all the text in the specified line.
Parameters Name Type Description Defaultindex
int
The index of the line to select (starting from 0).
required"},{"location":"widgets/text_area/#textual.widgets.text_area.Highlight","title":"Highlightmodule-attribute
","text":"Highlight = Tuple[StartColumn, EndColumn, HighlightName]\n
A tuple representing a syntax highlight within one line.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Location","title":"Locationmodule-attribute
","text":"Location = Tuple[int, int]\n
A location (row, column) within the document. Indexing starts at 0.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Document","title":"Documentclass
","text":"def __init__(self, text):\n
Bases: DocumentBase
A document which can be opened in a TextArea.
"},{"location":"widgets/text_area/#textual.document._document.Document.line_count","title":"line_countproperty
","text":"line_count: int\n
Returns the number of lines in the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.lines","title":"linesproperty
","text":"lines: list[str]\n
Get the document as a list of strings, where each string represents a line.
Newline characters are not included in at the end of the strings.
The newline character used in this document can be found via the Document.newline
property.
property
","text":"newline: Newline\n
Get the Newline used in this document (e.g. ' ', ' '. etc.)
"},{"location":"widgets/text_area/#textual.document._document.Document.text","title":"textproperty
","text":"text: str\n
Get the text from the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_line","title":"get_linemethod
","text":"def get_line(self, index):\n
Returns the line with the given index from the document.
Parameters Name Type Description Defaultindex
int
The index of the line in the document.
required Returns Type Descriptionstr
The string representing the line.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_size","title":"get_sizemethod
","text":"def get_size(self, tab_width):\n
The Size of the document, taking into account the tab rendering width.
Parameters Name Type Description Defaulttab_width
int
The width to use for tab indents.
required Returns Type DescriptionSize
The size (width, height) of the document.
"},{"location":"widgets/text_area/#textual.document._document.Document.get_text_range","title":"get_text_rangemethod
","text":"def get_text_range(self, start, end):\n
Get the text that falls between the start and end locations.
Returns the text between start
and end
, including the appropriate line separator character as specified by Document._newline
. Note that _newline
is set automatically to the first line separator character found in the document.
start
Location
The start location of the selection.
requiredend
Location
The end location of the selection.
required Returns Type Descriptionstr
The text between start (inclusive) and end (exclusive).
"},{"location":"widgets/text_area/#textual.document._document.Document.replace_range","title":"replace_rangemethod
","text":"def replace_range(self, start, end, text):\n
Replace text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The EditResult containing information about the completed replace operation.
"},{"location":"widgets/text_area/#textual.widgets.text_area.DocumentBase","title":"DocumentBaseclass
","text":" Bases: ABC
Describes the minimum functionality a Document implementation must provide in order to be used by the TextArea widget.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.line_count","title":"line_countabstractmethod
property
","text":"line_count: int\n
Returns the number of lines in the document.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.newline","title":"newlineabstractmethod
property
","text":"newline: Newline\n
Return the line separator used in the document.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.text","title":"textabstractmethod
property
","text":"text: str\n
The text from the document as a string.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_line","title":"get_lineabstractmethod
","text":"def get_line(self, index):\n
Returns the line with the given index from the document.
This is used in rendering lines, and will be called by the TextArea for each line that is rendered.
Parameters Name Type Description Defaultindex
int
The index of the line in the document.
required Returns Type Descriptionstr
The str instance representing the line.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_size","title":"get_sizeabstractmethod
","text":"def get_size(self, indent_width):\n
Get the size of the document.
The height is generally the number of lines, and the width is generally the maximum cell length of all the lines.
Parameters Name Type Description Defaultindent_width
int
The width to use for tab characters.
required Returns Type DescriptionSize
The Size of the document bounding box.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.get_text_range","title":"get_text_rangeabstractmethod
","text":"def get_text_range(self, start, end):\n
Get the text that falls between the start and end locations.
Parameters Name Type Description Defaultstart
Location
The start location of the selection.
requiredend
Location
The end location of the selection.
required Returns Type Descriptionstr
The text between start (inclusive) and end (exclusive).
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.query_syntax_tree","title":"query_syntax_treemethod
","text":"def query_syntax_tree(\nself, query, start_point=None, end_point=None\n):\n
Query the tree-sitter syntax tree.
The default implementation always returns an empty list.
To support querying in a subclass, this must be implemented.
Parameters Name Type Description Defaultquery
'Query'
The tree-sitter Query to perform.
requiredstart_point
tuple[int, int] | None
The (row, column byte) to start the query at.
None
end_point
tuple[int, int] | None
The (row, column byte) to end the query at.
None
Returns Type Description list[tuple['Node', str]]
A tuple containing the nodes and text captured by the query.
"},{"location":"widgets/text_area/#textual.document._document.DocumentBase.replace_range","title":"replace_rangeabstractmethod
","text":"def replace_range(self, start, end, text):\n
Replace the text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The new end location after the edit is complete.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Edit","title":"Editclass
","text":"Implements the Undoable protocol to replace text at some range within a document.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.from_location","title":"from_locationinstance-attribute
","text":"from_location: Location\n
The start location of the insert.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.maintain_selection_offset","title":"maintain_selection_offsetinstance-attribute
","text":"maintain_selection_offset: bool\n
If True, the selection will maintain its offset to the replacement range.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.text","title":"textinstance-attribute
","text":"text: str\n
The text to insert. An empty string is equivalent to deletion.
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.to_location","title":"to_locationinstance-attribute
","text":"to_location: Location\n
The end location of the insert
"},{"location":"widgets/text_area/#textual.widgets._text_area.Edit.after","title":"aftermethod
","text":"def after(self, text_area):\n
Possibly update the cursor location after the widget has been refreshed.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
this operation was performed on.
method
","text":"def do(self, text_area):\n
Perform the edit operation.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
to perform the edit on.
EditResult
An EditResult
containing information about the replace operation.
method
","text":"def undo(self, text_area):\n
Undo the edit operation.
Parameters Name Type Description Defaulttext_area
TextArea
The TextArea
to undo the insert operation on.
EditResult
An EditResult
containing information about the replace operation.
class
","text":"Contains information about an edit that has occurred.
"},{"location":"widgets/text_area/#textual.document._document.EditResult.end_location","title":"end_locationinstance-attribute
","text":"end_location: Location\n
The new end Location after the edit is complete.
"},{"location":"widgets/text_area/#textual.document._document.EditResult.replaced_text","title":"replaced_textinstance-attribute
","text":"replaced_text: str\n
The text that was replaced.
"},{"location":"widgets/text_area/#textual.widgets.text_area.LanguageDoesNotExist","title":"LanguageDoesNotExistclass
","text":" Bases: Exception
Raised when the user tries to use a language which does not exist. This means a language which is not builtin, or has not been registered.
"},{"location":"widgets/text_area/#textual.widgets.text_area.Selection","title":"Selectionclass
","text":" Bases: NamedTuple
A range of characters within a document from a start point to the end point. The location of the cursor is always considered to be the end
point of the selection. The selection is inclusive of the minimum point and exclusive of the maximum point.
class-attribute
instance-attribute
","text":"end: Location = (0, 0)\n
The end location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you finished dragging.
"},{"location":"widgets/text_area/#textual.document._document.Selection.is_empty","title":"is_emptyproperty
","text":"is_empty: bool\n
Return True if the selection has 0 width, i.e. it's just a cursor.
"},{"location":"widgets/text_area/#textual.document._document.Selection.start","title":"startclass-attribute
instance-attribute
","text":"start: Location = (0, 0)\n
The start location of the selection.
If you were to click and drag a selection inside a text-editor, this is where you started dragging.
"},{"location":"widgets/text_area/#textual.document._document.Selection.cursor","title":"cursorclassmethod
","text":"def cursor(cls, location):\n
Create a Selection with the same start and end point - a \"cursor\".
Parameters Name Type Description Defaultlocation
Location
The location to create the zero-width Selection.
required"},{"location":"widgets/text_area/#textual.widgets.text_area.SyntaxAwareDocument","title":"SyntaxAwareDocumentclass
","text":"def __init__(self, text, language):\n
Bases: Document
A wrapper around a Document which also maintains a tree-sitter syntax tree when the document is edited.
The primary reason for this split is actually to keep tree-sitter stuff separate, since it isn't supported in Python 3.7. By having the tree-sitter code isolated in this subclass, it makes it easier to conditionally import. However, it does come with other design flaws (e.g. Document is required to have methods which only really make sense on SyntaxAwareDocument).
If you're reading this and Python 3.7 is no longer supported by Textual, consider merging this subclass into the Document
superclass.
text
str
The initial text contained in the document.
requiredlanguage
str | Language
The language to use. You can pass a string to use a supported language, or pass in your own tree-sitter Language
object.
instance-attribute
","text":"language: Language | None = None\n
The tree-sitter Language or None if tree-sitter is unavailable.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.get_line","title":"get_linemethod
","text":"def get_line(self, line_index):\n
Return the string representing the line, not including new line characters.
Parameters Name Type Description Defaultline_index
int
The index of the line.
required Returns Type Descriptionstr
The string representing the line.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.prepare_query","title":"prepare_querymethod
","text":"def prepare_query(self, query):\n
Prepare a tree-sitter tree query.
Queries should be prepared once, then reused.
To execute a query, call query_syntax_tree
.
Query | None
The prepared query.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.query_syntax_tree","title":"query_syntax_treemethod
","text":"def query_syntax_tree(\nself, query, start_point=None, end_point=None\n):\n
Query the tree-sitter syntax tree.
The default implementation always returns an empty list.
To support querying in a subclass, this must be implemented.
Parameters Name Type Description Defaultquery
Query
The tree-sitter Query to perform.
requiredstart_point
tuple[int, int] | None
The (row, column byte) to start the query at.
None
end_point
tuple[int, int] | None
The (row, column byte) to end the query at.
None
Returns Type Description list[tuple['Node', str]]
A tuple containing the nodes and text captured by the query.
"},{"location":"widgets/text_area/#textual.document._syntax_aware_document.SyntaxAwareDocument.replace_range","title":"replace_rangemethod
","text":"def replace_range(self, start, end, text):\n
Replace text at the given range.
Parameters Name Type Description Defaultstart
Location
A tuple (row, column) where the edit starts.
requiredend
Location
A tuple (row, column) where the edit ends.
requiredtext
str
The text to insert between start and end.
required Returns Type DescriptionEditResult
The new end location after the edit is complete.
"},{"location":"widgets/text_area/#textual.widgets.text_area.TextAreaTheme","title":"TextAreaThemeclass
","text":"A theme for the TextArea
widget.
Allows theming the general widget (gutter, selections, cursor, and so on) and mapping of tree-sitter tokens to Rich styles.
For example, consider the following snippet from the markdown.scm
highlight query file. We've assigned the heading_content
token type to the name heading
.
(heading_content) @heading\n
Now, we can map this heading
name to a Rich style, and it will be styled as such in the TextArea
, assuming a parser which returns a heading_content
node is used (as will be the case when language=\"markdown\").
TextAreaTheme('my_theme', syntax_styles={'heading': Style(color='cyan', bold=True)})\n
We can register this theme with our TextArea
using the TextArea.register_theme
method, and headings in our markdown files will be styled bold cyan.
class-attribute
instance-attribute
","text":"base_style: Style | None = None\n
The background style of the text area. If None
the parent style will be used.
class-attribute
instance-attribute
","text":"bracket_matching_style: Style | None = None\n
The style to apply to matching brackets. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"cursor_line_gutter_style: Style | None = None\n
The style to apply to the gutter of the line the cursor is on. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"cursor_line_style: Style | None = None\n
The style to apply to the line the cursor is on.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.cursor_style","title":"cursor_styleclass-attribute
instance-attribute
","text":"cursor_style: Style | None = None\n
The style of the cursor. If None
, a legible Style will be generated.
class-attribute
instance-attribute
","text":"gutter_style: Style | None = None\n
The style of the gutter. If None
, a legible Style will be generated.
instance-attribute
","text":"name: str\n
The name of the theme.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.selection_style","title":"selection_styleclass-attribute
instance-attribute
","text":"selection_style: Style | None = None\n
The style of the selection. If None
a default selection Style will be generated.
class-attribute
instance-attribute
","text":"syntax_styles: dict[str, Style] = field(\ndefault_factory=dict\n)\n
The mapping of tree-sitter names from the highlight_query
to Rich styles.
classmethod
","text":"def builtin_themes(cls):\n
Get a list of all builtin TextAreaThemes.
Returns Type Descriptionlist[TextAreaTheme]
A list of all builtin TextAreaThemes.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.default","title":"defaultclassmethod
","text":"def default(cls):\n
Get the default syntax theme.
Returns Type DescriptionTextAreaTheme
The default TextAreaTheme (probably \"monokai\").
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.get_builtin_theme","title":"get_builtin_themeclassmethod
","text":"def get_builtin_theme(cls, theme_name):\n
Get a TextAreaTheme
by name.
Given a theme_name
, return the corresponding TextAreaTheme
object.
theme_name
str
The name of the theme.
required Returns Type Description'TextAreaTheme' | None
The TextAreaTheme
corresponding to the name or None
if the theme isn't
'TextAreaTheme' | None
found.
"},{"location":"widgets/text_area/#textual._text_area_theme.TextAreaTheme.get_highlight","title":"get_highlightmethod
","text":"def get_highlight(self, name):\n
Return the Rich style corresponding to the name defined in the tree-sitter highlight query for the current theme.
Parameters Name Type Description Defaultname
str
The name of the highlight.
required Returns Type DescriptionStyle | None
The Style
to use for this highlight, or None
if no style.
class
","text":" Bases: Exception
Raised when the user tries to use a theme which does not exist. This means a theme which is not builtin, or has not been registered.
"},{"location":"widgets/toast/","title":"Toast","text":"Added in version 0.30.0
A widget which displays a notification message.
Note that Toast
isn't designed to be used directly in your applications, but it is instead used by notify
to display a message when using Textual's built-in notification system.
You can customize the style of Toasts by targeting the Toast
CSS type. For example:
Toast {\npadding: 3;\n}\n
The three severity levels also have corresponding classes, allowing you to target the different styles of notification. They are:
-information
-warning
-error
If you wish to tailor the notifications for your application you can add rules to your CSS like this:
Toast.-information {\n/* Styling here. */\n}\nToast.-warning {\n/* Styling here. */\n}\nToast.-error {\n/* Styling here. */\n}\n
You can customize just the title wih the toast--title
class. The following would make the title italic for an information toast:
Toast.-information .toast--title {\ntext-style: italic;\n}\n
"},{"location":"widgets/toast/#example","title":"Example","text":"Outputtoast.py ToastApp \u258e\u258a \u258eIt's\u00a0an\u00a0older\u00a0code,\u00a0sir,\u00a0but\u00a0it\u00a0\u258a \u258echecks\u00a0out.\u258a \u258e\u258a \u258e\u258a \u258ePossible\u00a0trap\u00a0detected\u258a \u258eNow\u00a0witness\u00a0the\u00a0firepower\u00a0of\u00a0this\u258a \u258efully\u00a0ARMED\u00a0and\u00a0OPERATIONAL\u258a \u258ebattle\u00a0station!\u258a \u258e\u258a \u258e\u258a \u258eIt's\u00a0a\u00a0trap!\u258a \u258e\u258a \u258e\u258a \u258eIt's\u00a0against\u00a0my\u00a0programming\u00a0to\u00a0\u258a \u258eimpersonate\u00a0a\u00a0deity.\u258a \u258e\u258a
from textual.app import App\nclass ToastApp(App[None]):\ndef on_mount(self) -> None:\n# Show an information notification.\nself.notify(\"It's an older code, sir, but it checks out.\")\n# Show a warning. Note that Textual's notification system allows\n# for the use of Rich console markup.\nself.notify(\n\"Now witness the firepower of this fully \"\n\"[b]ARMED[/b] and [i][b]OPERATIONAL[/b][/i] battle station!\",\ntitle=\"Possible trap detected\",\nseverity=\"warning\",\n)\n# Show an error. Set a longer timeout so it's noticed.\nself.notify(\"It's a trap!\", severity=\"error\", timeout=10)\n# Show an information notification, but without any sort of title.\nself.notify(\"It's against my programming to impersonate a deity.\", title=\"\")\nif __name__ == \"__main__\":\nToastApp().run()\n
"},{"location":"widgets/toast/#reactive-attributes","title":"Reactive Attributes","text":"This widget has no reactive attributes.
"},{"location":"widgets/toast/#messages","title":"Messages","text":"This widget posts no messages.
"},{"location":"widgets/toast/#bindings","title":"Bindings","text":"This widget has no bindings.
"},{"location":"widgets/toast/#component-classes","title":"Component Classes","text":"The toast widget provides the following component classes:
Class Descriptiontoast--title
Targets the title of the toast."},{"location":"widgets/toast/#textual.widgets._toast","title":"textual.widgets._toast","text":"Widgets for showing notification messages in toasts.
"},{"location":"widgets/toast/#textual.widgets._toast.Toast","title":"Toastclass
","text":"def __init__(self, notification):\n
Bases: Static
A widget for displaying short-lived notifications.
Parameters Name Type Description Defaultnotification
Notification
The notification to show in the toast.
required"},{"location":"widgets/toast/#textual.widgets._toast.Toast.COMPONENT_CLASSES","title":"COMPONENT_CLASSESclass-attribute
","text":"COMPONENT_CLASSES: set[str] = {'toast--title'}\n
Class Description toast--title
Targets the title of the toast."},{"location":"widgets/toast/#textual.widgets._toast.ToastHolder","title":"ToastHolder class
","text":" Bases: Container
Container that holds a single toast.
Used to control the alignment of each of the toasts in the main toast container.
"},{"location":"widgets/toast/#textual.widgets._toast.ToastRack","title":"ToastRackclass
","text":" Bases: Container
A container for holding toasts.
"},{"location":"widgets/toast/#textual.widgets._toast.ToastRack.show","title":"showmethod
","text":"def show(self, notifications):\n
Show the notifications as toasts.
Parameters Name Type Description Defaultnotifications
Notifications
The notifications to show.
required"},{"location":"widgets/tree/","title":"Tree","text":"Added in version 0.6.0
A tree control widget.
The example below creates a simple tree.
Outputtree.pyTreeApp \u25bc\u00a0Dune \u2517\u2501\u2501\u00a0\u25bc\u00a0Characters \u2523\u2501\u2501\u00a0Paul \u2523\u2501\u2501\u00a0Jessica \u2517\u2501\u2501\u00a0Chani
from textual.app import App, ComposeResult\nfrom textual.widgets import Tree\nclass TreeApp(App):\ndef compose(self) -> ComposeResult:\ntree: Tree[dict] = Tree(\"Dune\")\ntree.root.expand()\ncharacters = tree.root.add(\"Characters\", expand=True)\ncharacters.add_leaf(\"Paul\")\ncharacters.add_leaf(\"Jessica\")\ncharacters.add_leaf(\"Chani\")\nyield tree\nif __name__ == \"__main__\":\napp = TreeApp()\napp.run()\n
Tree widgets have a \"root\" attribute which is an instance of a TreeNode. Call add() or add_leaf() to add new nodes underneath the root. Both these methods return a TreeNode for the child which you can use to add additional levels.
"},{"location":"widgets/tree/#reactive-attributes","title":"Reactive Attributes","text":"Name Type Default Descriptionshow_root
bool
True
Show the root node. show_guides
bool
True
Show guide lines between levels. guide_depth
int
4
Amount of indentation between parent and child."},{"location":"widgets/tree/#messages","title":"Messages","text":"The tree widget defines the following bindings:
Key(s) Description enter Select the current item. space Toggle the expand/collapsed space of the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/tree/#component-classes","title":"Component Classes","text":"The tree widget provides the following component classes:
Class Descriptiontree--cursor
Targets the cursor. tree--guides
Targets the indentation guides. tree--guides-hover
Targets the indentation guides under the cursor. tree--guides-selected
Targets the indentation guides that are selected. tree--highlight
Targets the highlighted items. tree--highlight-line
Targets the lines under the cursor. tree--label
Targets the (text) labels of the items."},{"location":"widgets/tree/#textual.widgets.Tree","title":"textual.widgets.Tree class
","text":"def __init__(\nself,\nlabel,\ndata=None,\n*,\nname=None,\nid=None,\nclasses=None,\ndisabled=False\n):\n
Bases: Generic[TreeDataType]
, ScrollView
A widget for displaying and navigating data in a tree.
Parameters Name Type Description Defaultlabel
TextType
The label of the root node of the tree.
requireddata
TreeDataType | None
The optional data to associate with the root node of the tree.
None
name
str | None
The name of the Tree.
None
id
str | None
The ID of the tree in the DOM.
None
classes
str | None
The CSS classes of the tree.
None
disabled
bool
Whether the tree is disabled or not.
False
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.BINDINGS","title":"BINDINGS class-attribute
","text":"BINDINGS: list[BindingType] = [\nBinding(\"enter\", \"select_cursor\", \"Select\", show=False),\nBinding(\"space\", \"toggle_node\", \"Toggle\", show=False),\nBinding(\"up\", \"cursor_up\", \"Cursor Up\", show=False),\nBinding(\n\"down\", \"cursor_down\", \"Cursor Down\", show=False\n),\n]\n
Key(s) Description enter Select the current item. space Toggle the expand/collapsed space of the current item. up Move the cursor up. down Move the cursor down."},{"location":"widgets/tree/#textual.widgets._tree.Tree.COMPONENT_CLASSES","title":"COMPONENT_CLASSES class-attribute
","text":"COMPONENT_CLASSES: set[str] = {\n\"tree--cursor\",\n\"tree--guides\",\n\"tree--guides-hover\",\n\"tree--guides-selected\",\n\"tree--highlight\",\n\"tree--highlight-line\",\n\"tree--label\",\n}\n
Class Description tree--cursor
Targets the cursor. tree--guides
Targets the indentation guides. tree--guides-hover
Targets the indentation guides under the cursor. tree--guides-selected
Targets the indentation guides that are selected. tree--highlight
Targets the highlighted items. tree--highlight-line
Targets the lines under the cursor. tree--label
Targets the (text) labels of the items."},{"location":"widgets/tree/#textual.widgets._tree.Tree.auto_expand","title":"auto_expand class-attribute
instance-attribute
","text":"auto_expand = var(True)\n
Auto expand tree nodes when clicked.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.cursor_line","title":"cursor_lineclass-attribute
instance-attribute
","text":"cursor_line = var(-1, always_update=True)\n
The line with the cursor, or -1 if no cursor.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.cursor_node","title":"cursor_nodeproperty
","text":"cursor_node: TreeNode[TreeDataType] | None\n
The currently selected node, or None
if no selection.
class-attribute
instance-attribute
","text":"guide_depth = reactive(4, init=False)\n
The indent depth of tree nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.hover_line","title":"hover_lineclass-attribute
instance-attribute
","text":"hover_line = var(-1)\n
The line number under the mouse pointer, or -1 if not under the mouse pointer.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.last_line","title":"last_lineproperty
","text":"last_line: int\n
The index of the last line.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.root","title":"rootinstance-attribute
","text":"root = self._add_node(None, text_label, data)\n
The root node of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.show_guides","title":"show_guidesclass-attribute
instance-attribute
","text":"show_guides = reactive(True)\n
Enable display of tree guide lines.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.show_root","title":"show_rootclass-attribute
instance-attribute
","text":"show_root = reactive(True)\n
Show the root of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeCollapsed","title":"NodeCollapsedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is collapsed.
Can be handled using on_tree_node_collapsed
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeCollapsed.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was collapsed.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeExpanded","title":"NodeExpandedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is expanded.
Can be handled using on_tree_node_expanded
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeExpanded.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was expanded.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeHighlighted","title":"NodeHighlightedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is highlighted.
Can be handled using on_tree_node_highlighted
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeHighlighted.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was highlighted.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeSelected","title":"NodeSelectedclass
","text":"def __init__(self, node):\n
Bases: Generic[EventTreeDataType]
, Message
Event sent when a node is selected.
Can be handled using on_tree_node_selected
in a subclass of Tree
or in a parent node in the DOM.
property
","text":"control: Tree[EventTreeDataType]\n
The tree that sent the message.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.NodeSelected.node","title":"nodeinstance-attribute
","text":"node: TreeNode[EventTreeDataType] = node\n
The node that was selected.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.UnknownNodeID","title":"UnknownNodeIDclass
","text":" Bases: Exception
Exception raised when referring to an unknown TreeNode
ID.
method
","text":"def action_cursor_down(self):\n
Move the cursor down one node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_cursor_up","title":"action_cursor_upmethod
","text":"def action_cursor_up(self):\n
Move the cursor up one node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_page_down","title":"action_page_downmethod
","text":"def action_page_down(self):\n
Move the cursor down a page's-worth of nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_page_up","title":"action_page_upmethod
","text":"def action_page_up(self):\n
Move the cursor up a page's-worth of nodes.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_scroll_end","title":"action_scroll_endmethod
","text":"def action_scroll_end(self):\n
Move the cursor to the bottom of the tree.
NoteHere bottom means vertically, not branch depth.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_scroll_home","title":"action_scroll_homemethod
","text":"def action_scroll_home(self):\n
Move the cursor to the top of the tree.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.action_select_cursor","title":"action_select_cursormethod
","text":"def action_select_cursor(self):\n
Cause a select event for the target node.
NoteIf auto_expand
is True
use of this action on a non-leaf node will cause both an expand/collapse event to occur, as well as a selected event.
method
","text":"def action_toggle_node(self):\n
Toggle the expanded state of the target node.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.clear","title":"clearmethod
","text":"def clear(self):\n
Clear all nodes under root.
Returns Type DescriptionSelf
The Tree
instance.
method
","text":"def get_label_width(self, node):\n
Get the width of the nodes label.
The default behavior is to call render_node
and return the cell length. This method may be overridden in a sub-class if it can be done more efficiently.
node
TreeNode[TreeDataType]
A node.
required Returns Type Descriptionint
Width in cells.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.get_node_at_line","title":"get_node_at_linemethod
","text":"def get_node_at_line(self, line_no):\n
Get the node for a given line.
Parameters Name Type Description Defaultline_no
int
A line number.
required Returns Type DescriptionTreeNode[TreeDataType] | None
A tree node, or None
if there is no node at that line.
method
","text":"def get_node_by_id(self, node_id):\n
Get a tree node by its ID.
Parameters Name Type Description Defaultnode_id
NodeID
The ID of the node to get.
required Returns Type DescriptionTreeNode[TreeDataType]
The node associated with that ID.
Raises Type DescriptionTree.UnknownID
Raised if the TreeNode
ID is unknown.
method
","text":"def process_label(self, label):\n
Process a str
or Text
value into a label.
Maybe overridden in a subclass to change how labels are rendered.
Parameters Name Type Description Defaultlabel
TextType
Label.
required Returns Type DescriptionText
A Rich Text object.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.refresh_line","title":"refresh_linemethod
","text":"def refresh_line(self, line):\n
Refresh (repaint) a given line in the tree.
Parameters Name Type Description Defaultline
int
Line number.
required"},{"location":"widgets/tree/#textual.widgets._tree.Tree.render_label","title":"render_labelmethod
","text":"def render_label(self, node, base_style, style):\n
Render a label for the given node. Override this to modify how labels are rendered.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType]
A tree node.
requiredbase_style
Style
The base style of the widget.
requiredstyle
Style
The additional style for the label.
required Returns Type DescriptionText
A Rich Text object containing the label.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.reset","title":"resetmethod
","text":"def reset(self, label, data=None):\n
Clear the tree and reset the root node.
Parameters Name Type Description Defaultlabel
TextType
The label for the root node.
requireddata
TreeDataType | None
Optional data for the root node.
None
Returns Type Description Self
The Tree
instance.
method
","text":"def scroll_to_line(self, line, animate=True):\n
Scroll to the given line.
Parameters Name Type Description Defaultline
int
A line number.
requiredanimate
bool
Enable animation.
True
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.scroll_to_node","title":"scroll_to_node method
","text":"def scroll_to_node(self, node, animate=True):\n
Scroll to the given node.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType]
Node to scroll in to view.
requiredanimate
bool
Animate scrolling.
True
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.select_node","title":"select_node method
","text":"def select_node(self, node):\n
Move the cursor to the given node, or reset cursor.
Parameters Name Type Description Defaultnode
TreeNode[TreeDataType] | None
A tree node, or None to reset cursor.
required"},{"location":"widgets/tree/#textual.widgets._tree.Tree.validate_cursor_line","title":"validate_cursor_linemethod
","text":"def validate_cursor_line(self, value):\n
Prevent cursor line from going outside of range.
Parameters Name Type Description Defaultvalue
int
The value to test.
required ReturnA valid version of the given value.
"},{"location":"widgets/tree/#textual.widgets._tree.Tree.validate_guide_depth","title":"validate_guide_depthmethod
","text":"def validate_guide_depth(self, value):\n
Restrict guide depth to reasonable range.
Parameters Name Type Description Defaultvalue
int
The value to test.
required ReturnA valid version of the given value.
"},{"location":"widgets/tree/#textual.widgets.tree.TreeNode","title":"textual.widgets.tree.TreeNodeclass
","text":"def __init__(\nself,\ntree,\nparent,\nid,\nlabel,\ndata=None,\n*,\nexpanded=True,\nallow_expand=True\n):\n
Bases: Generic[TreeDataType]
An object that represents a \"node\" in a tree control.
Parameters Name Type Description Defaulttree
Tree[TreeDataType]
The tree that the node is being attached to.
requiredparent
TreeNode[TreeDataType] | None
The parent node that this node is being attached to.
requiredid
NodeID
The ID of the node.
requiredlabel
Text
The label for the node.
requireddata
TreeDataType | None
Optional data to associate with the node.
None
expanded
bool
Should the node be attached in an expanded state?
True
allow_expand
bool
Should the node allow being expanded by the user?
True
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.allow_expand","title":"allow_expand writable
property
","text":"allow_expand: bool\n
Is this node allowed to expand?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.children","title":"childrenproperty
","text":"children: TreeNodes[TreeDataType]\n
The child nodes of a TreeNode.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.data","title":"datainstance-attribute
","text":"data = data\n
Optional data associated with the tree node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.id","title":"idproperty
","text":"id: NodeID\n
The ID of the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_expanded","title":"is_expandedproperty
","text":"is_expanded: bool\n
Is the node expanded?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_last","title":"is_lastproperty
","text":"is_last: bool\n
Is this the last child node of its parent?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.is_root","title":"is_rootproperty
","text":"is_root: bool\n
Is this node the root of the tree?
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.label","title":"labelwritable
property
","text":"label: TextType\n
The label for the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.line","title":"lineproperty
","text":"line: int\n
The line number for this node, or -1 if it is not displayed.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.parent","title":"parentproperty
","text":"parent: TreeNode[TreeDataType] | None\n
The parent of the node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.tree","title":"treeproperty
","text":"tree: Tree[TreeDataType]\n
The tree that this node is attached to.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.RemoveRootError","title":"RemoveRootErrorclass
","text":" Bases: Exception
Exception raised when trying to remove a tree's root node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.add","title":"addmethod
","text":"def add(\nself,\nlabel,\ndata=None,\n*,\nexpand=False,\nallow_expand=True\n):\n
Add a node to the sub-tree.
Parameters Name Type Description Defaultlabel
TextType
The new node's label.
requireddata
TreeDataType | None
Data associated with the new node.
None
expand
bool
Node should be expanded.
False
allow_expand
bool
Allow use to expand the node via keyboard or mouse.
True
Returns Type Description TreeNode[TreeDataType]
A new Tree node
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.add_leaf","title":"add_leafmethod
","text":"def add_leaf(self, label, data=None):\n
Add a 'leaf' node (a node that can not expand).
Parameters Name Type Description Defaultlabel
TextType
Label for the node.
requireddata
TreeDataType | None
Optional data.
None
Returns Type Description TreeNode[TreeDataType]
New node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.collapse","title":"collapsemethod
","text":"def collapse(self):\n
Collapse the node (hide its children).
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def collapse_all(self):\n
Collapse the node (hide its children) and all those below it.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def expand(self):\n
Expand the node (show its children).
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def expand_all(self):\n
Expand the node (show its children) and all those below it.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def remove(self):\n
Remove this node from the tree.
Raises Type DescriptionTreeNode.RemoveRootError
If there is an attempt to remove the root.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.remove_children","title":"remove_childrenmethod
","text":"def remove_children(self):\n
Remove any child nodes of this node.
"},{"location":"widgets/tree/#textual.widgets._tree.TreeNode.set_label","title":"set_labelmethod
","text":"def set_label(self, label):\n
Set a new label for the node.
Parameters Name Type Description Defaultlabel
TextType
A str
or Text
object with the new label.
method
","text":"def toggle(self):\n
Toggle the node's expanded state.
Returns Type DescriptionSelf
The TreeNode
instance.
method
","text":"def toggle_all(self):\n
Toggle the node's expanded state and make all those below it match.
Returns Type DescriptionSelf
The TreeNode
instance.
property
abstractmethod
+ property
¶property
abstractmethod
+ property
¶property
abstractmethod
+ property
¶