Merge pull request #703 from Textualize/docs-intro

Docs intro
This commit is contained in:
Will McGugan
2022-08-26 11:04:54 +01:00
committed by GitHub
170 changed files with 4620 additions and 1354 deletions

9
docs/events/blur.md Normal file
View File

@@ -0,0 +1,9 @@
# Blur
The `Blur` event is sent to a widget when it loses focus.
- [ ] Bubbles
## Attributes
_No other attributes_

View File

@@ -0,0 +1,9 @@
# DescendantBlur
The `DescendantBlur` event is sent to a widget when one of its children loses focus.
- [x] Bubbles
## Attributes
_No other attributes_

View File

@@ -0,0 +1,9 @@
# DescendantFocus
The `DescendantFocus` event is sent to a widget when one of its descendants receives focus.
- [x] Bubbles
## Attributes
_No other attributes_

9
docs/events/enter.md Normal file
View File

@@ -0,0 +1,9 @@
# Enter
The `Enter` event is sent to a widget when the mouse pointer first moves over a widget.
- [ ] Bubbles
## Attributes
_No other attributes_

9
docs/events/focus.md Normal file
View File

@@ -0,0 +1,9 @@
# Focus
The `Focus` event is sent to a widget when it receives input focus.
- [ ] Bubbles
## Attributes
_No other attributes_

9
docs/events/hide.md Normal file
View File

@@ -0,0 +1,9 @@
# Show
The `Hide` event is sent to a widget when it is hidden from view.
- [ ] Bubbles
## Attributes
_No additional attributes_

11
docs/events/key.md Normal file
View File

@@ -0,0 +1,11 @@
# Key
The `Key` event is sent to a widget when the user presses a key on the keyboard.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| --------- | ---- | ------------------------ |
| `key` | str | The key that was pressed |

9
docs/events/leave.md Normal file
View File

@@ -0,0 +1,9 @@
# Leave
The `Leave` event is sent to a widget when the mouse pointer moves off a widget.
- [ ] Bubbles
## Attributes
_No other attributes_

11
docs/events/load.md Normal file
View File

@@ -0,0 +1,11 @@
# Load
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.
- [ ] Bubbles
## Attributes
_No additional attributes_

View File

@@ -6,12 +6,6 @@ The mount event is typically used to set the initial state of a widget or to add
- [ ] Bubbles
## Parameters
## Attributes
`sender`
: The sender of the widget
## Code
::: textual.events.Mount
_No additional attributes_

View File

@@ -0,0 +1,11 @@
# MouseCapture
The `MouseCapture` event is sent to a widget when it is capturing mouse events from outside of its borders on the screen.
- [ ] Bubbles
## Attributes
| attribute | type | purpose |
| ---------------- | ------ | --------------------------------------------- |
| `mouse_position` | Offset | Mouse coordinates when the mouse was captured |

View File

@@ -0,0 +1,20 @@
# Click
The `Click` event is sent to a widget when the user clicks a mouse button.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- |
| `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 |

20
docs/events/mouse_down.md Normal file
View File

@@ -0,0 +1,20 @@
# MouseDown
The `MouseDown` event is sent to a widget when a mouse button is pressed.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- |
| `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 |

21
docs/events/mouse_move.md Normal file
View File

@@ -0,0 +1,21 @@
# MouseMove
The `MouseMove` event is sent to a widget when the mouse pointer is moved over a widget.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- |
| `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 |

View File

@@ -0,0 +1,11 @@
# MouseRelease
The `MouseRelease` event is sent to a widget when it is no longer receiving mouse events outside of its borders.
- [ ] Bubbles
## Attributes
| attribute | type | purpose |
| ---------------- | ------ | -------------------------------------------- |
| `mouse_position` | Offset | Mouse coordinates when the mouse was released |

View File

@@ -0,0 +1,12 @@
# MouseScrollDown
The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _down_.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| --------- | ---- | -------------------------------------- |
| `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget |

View File

@@ -0,0 +1,12 @@
# MouseScrollUp
The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _up_.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| --------- | ---- | -------------------------------------- |
| `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget |

21
docs/events/mouse_up.md Normal file
View File

@@ -0,0 +1,21 @@
# MouseUp
The `MouseUp` event is sent to a widget when the user releases a mouse button.
- [x] Bubbles
## Attributes
| attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- |
| `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 |

11
docs/events/paste.md Normal file
View File

@@ -0,0 +1,11 @@
# Paste
The `Paste` event is sent to a widget when the user pastes text.
- [ ] Bubbles
## Attributes
| attribute | type | purpose |
| --------- | ---- | ------------------------ |
| `text` | str | The text that was pasted |

View File

@@ -4,20 +4,10 @@ The `Resize` event is sent to a widget when its size changes and when it is firs
- [x] Bubbles
## Parameters
## Attributes
`event.size`
: The new size of the Widget.
`event.virtual_size`
: The virtual size (scrollable area) of the Widget.
`event.container_size`
: The size of the widget's container.
## Code
::: textual.events.Mount
| attribute | type | purpose |
| ---------------- | ---- | ------------------------------------------------- |
| `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) |

View File

@@ -0,0 +1,9 @@
# ScreenResume
The `ScreenResume` event is sent to a **Screen** when it becomes current.
- [ ] Bubbles
## Attributes
_No other attributes_

View File

@@ -0,0 +1,9 @@
# ScreenSuspend
The `ScreenSuspend` event is sent to a **Screen** when it is replaced by another screen.
- [ ] Bubbles
## Attributes
_No other attributes_

9
docs/events/show.md Normal file
View File

@@ -0,0 +1,9 @@
# Show
The `Show` event is sent to a widget when it becomes visible.
- [ ] Bubbles
## Attributes
_No additional attributes_

253
docs/examples/basic.css Normal file
View File

@@ -0,0 +1,253 @@
/* CSS file for basic.py */
* {
transition: color 300ms linear, background 300ms linear;
}
*:hover {
/* tint: 30% red;
/* outline: heavy red; */
}
App > Screen {
background: $surface;
color: $text-surface;
layers: base sidebar;
color: $text-background;
background: $background;
layout: vertical;
overflow: hidden;
}
#tree-container {
overflow-y: auto;
height: 20;
margin: 1 3;
background: $panel;
padding: 1 2;
}
DirectoryTree {
padding: 0 1;
height: auto;
}
DataTable {
/*border:heavy red;*/
/* tint: 10% green; */
/* opacity: 50%; */
padding: 1;
margin: 1 2;
height: 24;
}
#sidebar {
color: $text-panel;
background: $panel;
dock: left;
width: 30;
margin-bottom: 1;
offset-x: -100%;
transition: offset 500ms in_out_cubic;
layer: sidebar;
}
#sidebar.-active {
offset-x: 0;
}
#sidebar .title {
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
border-right: wide $background;
content-align: center middle;
}
#sidebar .user {
height: 8;
background: $panel-darken-1;
color: $text-panel-darken-1;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $panel-darken-2;
color: $text-surface;
border-right: wide $background;
content-align: center middle;
}
Tweet {
height:12;
width: 100%;
margin: 0 2;
background: $panel;
color: $text-panel;
layout: vertical;
/* border: outer $primary; */
padding: 1;
border: wide $panel;
overflow: auto;
/* scrollbar-gutter: stable; */
align-horizontal: center;
box-sizing: border-box;
}
.scrollable {
overflow-x: auto;
overflow-y: scroll;
margin: 1 2;
height: 24;
align-horizontal: center;
layout: vertical;
}
.code {
height: auto;
}
TweetHeader {
height:1;
background: $accent;
color: $text-accent
}
TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
height: auto;
padding: 0 1 0 0;
}
Tweet.scroll-horizontal TweetBody {
width: 350;
}
.button {
background: $accent;
color: $text-accent;
width:20;
height: 3;
/* border-top: hidden $accent-darken-3; */
border: tall $accent-darken-2;
/* border-left: tall $accent-darken-1; */
/* padding: 1 0 0 0 ; */
transition: background 400ms in_out_cubic, color 400ms in_out_cubic;
}
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
width: 20;
height: 3;
border: tall $accent-darken-1;
/* border-left: tall $accent-darken-3; */
}
#footer {
color: $text-accent;
background: $accent;
height: 1;
content-align: center middle;
dock:bottom;
}
#sidebar .content {
layout: vertical
}
OptionItem {
height: 3;
background: $panel;
border-right: wide $background;
border-left: blank;
content-align: center middle;
}
OptionItem:hover {
height: 3;
color: $text-primary;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
border-left: outer $secondary-darken-2;
}
Error {
width: 100%;
height:3;
background: $error;
color: $text-error;
border-top: tall $error-darken-2;
border-bottom: tall $error-darken-2;
padding: 0;
text-style: bold;
align-horizontal: center;
}
Warning {
width: 100%;
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: tall $warning-darken-2;
border-bottom: tall $warning-darken-2;
text-style: bold;
align-horizontal: center;
}
Success {
width: 100%;
height:auto;
box-sizing: border-box;
background: $success;
color: $text-success-fade-1;
border-top: hkey $success-darken-2;
border-bottom: hkey $success-darken-2;
text-style: bold ;
align-horizontal: center;
}
.horizontal {
layout: horizontal
}

234
docs/examples/demo.py Normal file
View File

@@ -0,0 +1,234 @@
from rich.console import RenderableType
from rich.syntax import Syntax
from rich.text import Text
from textual.app import App, ComposeResult
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer
from textual.layout import Container
CODE = '''
from __future__ import annotations
from typing import Iterable, TypeVar
T = TypeVar("T")
def loop_first(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
"""Iterate and generate a tuple with a flag for first value."""
iter_values = iter(values)
try:
value = next(iter_values)
except StopIteration:
return
yield True, value
for value in iter_values:
yield False, value
def loop_last(values: Iterable[T]) -> Iterable[tuple[bool, T]]:
"""Iterate and generate a tuple with a flag for last value."""
iter_values = iter(values)
try:
previous_value = next(iter_values)
except StopIteration:
return
for value in iter_values:
yield False, previous_value
previous_value = value
yield True, previous_value
def loop_first_last(values: Iterable[T]) -> Iterable[tuple[bool, bool, T]]:
"""Iterate and generate a tuple with a flag for first and last value."""
iter_values = iter(values)
try:
previous_value = next(iter_values)
except StopIteration:
return
first = True
for value in iter_values:
yield first, False, previous_value
first = False
previous_value = value
yield first, True, previous_value
'''
lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum."""
lorem = (
lorem_short
+ """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
)
lorem_short_text = Text.from_markup(lorem_short)
lorem_long_text = Text.from_markup(lorem * 2)
class TweetHeader(Widget):
def render(self) -> RenderableType:
return Text("Lorem Impsum", justify="center")
class TweetBody(Widget):
short_lorem = Reactive(False)
def render(self) -> Text:
return lorem_short_text if self.short_lorem else lorem_long_text
class Tweet(Widget):
pass
class OptionItem(Widget):
def render(self) -> Text:
return Text("Option")
class Error(Widget):
def render(self) -> Text:
return Text("This is an error message", justify="center")
class Warning(Widget):
def render(self) -> Text:
return Text("This is a warning message", justify="center")
class Success(Widget):
def render(self) -> Text:
return Text("This is a success message", justify="center")
class BasicApp(App, css_path="basic.css"):
"""A basic app demonstrating CSS"""
def on_load(self):
"""Bind keys here."""
self.bind("s", "toggle_class('#sidebar', '-active')", description="Sidebar")
self.bind("d", "toggle_dark", description="Dark mode")
self.bind("q", "quit", description="Quit")
self.bind("f", "query_test", description="Query test")
def compose(self):
yield Header()
table = DataTable()
self.scroll_to_target = Tweet(TweetBody())
yield Container(
Tweet(TweetBody()),
Widget(
Static(
Syntax(CODE, "python", line_numbers=True, indent_guides=True),
classes="code",
),
classes="scrollable",
),
table,
Error(),
Tweet(TweetBody(), classes="scrollbar-size-custom"),
Warning(),
Tweet(TweetBody(), classes="scroll-horizontal"),
Success(),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
Tweet(TweetBody(), classes="scroll-horizontal"),
)
yield Widget(
Widget(classes="title"),
Widget(classes="user"),
OptionItem(),
OptionItem(),
OptionItem(),
Widget(classes="content"),
id="sidebar",
)
yield Footer()
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
table.add_column("Baz", width=20)
table.add_column("Foo", width=20)
table.add_column("Bar", width=20)
table.add_column("Baz", width=20)
table.zebra_stripes = True
for n in range(100):
table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)])
def on_mount(self):
self.sub_title = "Widget demo"
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def action_toggle_dark(self):
self.dark = not self.dark
def action_query_test(self):
query = self.query("Tweet")
self.log(query)
self.log(query.nodes)
self.log(query)
self.log(query.nodes)
query.set_styles("outline: outer red;")
query = query.exclude(".scroll-horizontal")
self.log(query)
self.log(query.nodes)
# query = query.filter(".rubbish")
# self.log(query)
# self.log(query.first())
async def key_q(self):
await self.shutdown()
def key_x(self):
self.panic(self.tree)
def key_escape(self):
self.app.bell()
def key_t(self):
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
tweet_body = self.query("TweetBody").first()
tweet_body.short_lorem = not tweet_body.short_lorem
def key_v(self):
self.get_child(id="content").scroll_to_widget(self.scroll_to_target)
def key_space(self):
self.bell()
app = BasicApp()
if __name__ == "__main__":
app.run()
# from textual.geometry import Region
# from textual.color import Color
# print(Region.intersection.cache_info())
# print(Region.overlaps.cache_info())
# print(Region.union.cache_info())
# print(Region.split_vertical.cache_info())
# print(Region.__contains__.cache_info())
# from textual.css.scalar import Scalar
# print(Scalar.resolve_dimension.cache_info())
# from rich.style import Style
# from rich.cells import cached_cell_len
# print(Style._add.cache_info())
# print(cached_cell_len.cache_info())

View File

@@ -0,0 +1,18 @@
"""
Simulates a screenshot of the Textual devtools
"""
from textual.app import App
from textual.devtools.renderables import DevConsoleHeader
from textual.widgets import Static
class ConsoleApp(App):
def compose(self):
self.dark = True
yield Static(DevConsoleHeader())
app = ConsoleApp()

View File

@@ -0,0 +1,8 @@
from textual.app import App
class ExampleApp(App):
pass
app = ExampleApp()

View File

@@ -0,0 +1,11 @@
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
app = ExampleApp()

View File

@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.layout import Container, Horizontal
from textual.widgets import Header, Footer, Static, Button
QUESTION = "Do you want to learn about Textual CSS?"
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
yield Container(
Static(QUESTION, classes="question"),
Horizontal(
Button("Yes", variant="success"),
Button("No", variant="error"),
classes="buttons",
),
id="dialog",
)
app = ExampleApp()

View File

@@ -0,0 +1,30 @@
/* The top level dialog (a Container) */
#dialog {
margin: 4 8;
background: darkblue 20%;
color: darkblue;
border: tall darkblue;
padding: 1 2;
}
/* The button class */
Button {
width: 1fr;
}
/* Matches the question text */
.question {
text-style: bold;
height: 100%;
content-align: center middle;
}
/* Matches the button container */
.buttons {
width: 100%;
height: auto;
dock: bottom;
}

View File

@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.layout import Container, Horizontal
from textual.widgets import Header, Footer, Static, Button
QUESTION = "Do you want to learn about Textual CSS?"
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
yield Container(
Static(QUESTION, classes="question"),
Horizontal(
Button("Yes", variant="success"),
Button("No", variant="error"),
classes="buttons",
),
id="dialog",
)
app = ExampleApp(css_path="dom4.css")

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from textual.app import App
from textual.widget import Widget
class Clock(Widget):
"""A clock app."""
CSS = """
Clock {
content-align: center middle;
}
"""
def on_mount(self):
self.set_interval(1, self.refresh)
def render(self):
return datetime.now().strftime("%c")
class ClockApp(App):
def compose(self):
yield Clock()
app = ClockApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,54 @@
Stopwatch {
layout: horizontal;
background: $panel-darken-1;
height: 5;
min-width: 50;
margin: 1;
padding: 1;
}
TimeDisplay {
content-align: center middle;
opacity: 60%;
height: 3;
}
Button {
width: 16;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}
.started {
text-style: bold;
background: $success;
color: $text-success;
}
.started TimeDisplay {
opacity: 100%;
}
.started #start {
display: none
}
.started #stop {
display: block
}
.started #reset {
visibility: hidden
}

View File

@@ -0,0 +1,105 @@
from time import monotonic
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.reactive import Reactive
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = Reactive(monotonic)
time = Reactive.init(0.0)
total = Reactive(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
def update_time(self) -> None:
"""Method to update time to current."""
self.time = self.total + (monotonic() - self.start_time)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
def start(self) -> None:
"""Method to start (or resume) time updating."""
self.start_time = monotonic()
self.update_timer.resume()
def stop(self):
"""Method to stop the time display updating."""
self.update_timer.pause()
self.total += monotonic() - self.start_time
self.time = self.total
def reset(self):
"""Method to reset the time display to zero."""
self.total = 0
self.time = 0
class Stopwatch(Static):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
button_id = event.button.id
time_display = self.query_one(TimeDisplay)
if button_id == "start":
time_display.start()
self.add_class("started")
elif button_id == "stop":
time_display.stop()
self.remove_class("started")
elif button_id == "reset":
time_display.reset()
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay()
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Called to add widgets to the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")
def on_load(self) -> None:
"""Called when the app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
self.bind("a", "add_stopwatch", description="Add")
self.bind("r", "remove_stopwatch", description="Remove")
def action_add_stopwatch(self) -> None:
"""An action to add a timer."""
new_stopwatch = Stopwatch()
self.query_one("#timers").mount(new_stopwatch)
new_stopwatch.scroll_visible()
def action_remove_stopwatch(self) -> None:
"""Called to remove a timer."""
timers = self.query("Stopwatch")
if timers:
timers.last().remove()
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp(css_path="stopwatch.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,24 @@
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()
def on_load(self) -> None:
"""Called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1 @@
/* Blank for now */

View File

@@ -0,0 +1,41 @@
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
class Stopwatch(Static):
"""A stopwatch widget."""
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay("00:00:00.00")
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
def on_load(self) -> None:
"""Event handler called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,30 @@
Stopwatch {
layout: horizontal;
background: $panel-darken-1;
height: 5;
padding: 1;
margin: 1;
}
TimeDisplay {
content-align: center middle;
opacity: 60%;
height: 3;
}
Button {
width: 16;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}

View File

@@ -0,0 +1,41 @@
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
class Stopwatch(Static):
"""A stopwatch widget."""
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay("00:00:00.00")
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
def on_load(self) -> None:
"""Called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp(css_path="stopwatch03.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,53 @@
Stopwatch {
layout: horizontal;
background: $panel-darken-1;
height: 5;
min-width: 50;
margin: 1;
padding: 1;
}
TimeDisplay {
content-align: center middle;
opacity: 60%;
height: 3;
}
Button {
width: 16;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}
.started {
text-style: bold;
background: $success;
color: $text-success;
}
.started TimeDisplay {
opacity: 100%;
}
.started #start {
display: none
}
.started #stop {
display: block
}
.started #reset {
visibility: hidden
}

View File

@@ -0,0 +1,48 @@
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
class Stopwatch(Static):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
if event.button.id == "start":
self.add_class("started")
elif event.button.id == "stop":
self.remove_class("started")
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay("00:00:00.00")
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
def on_load(self) -> None:
"""Called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp(css_path="stopwatch04.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,68 @@
from time import monotonic
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.reactive import Reactive
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = Reactive(monotonic)
time = Reactive.init(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.set_interval(1 / 60, self.update_time)
def update_time(self) -> None:
"""Method to update the time to the current time."""
self.time = monotonic() - self.start_time
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
class Stopwatch(Static):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
if event.button.id == "start":
self.add_class("started")
elif event.button.id == "stop":
self.remove_class("started")
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay()
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
def on_load(self) -> None:
"""Event handler called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp(css_path="stopwatch04.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,91 @@
from time import monotonic
from textual.app import App, ComposeResult
from textual.layout import Container
from textual.reactive import Reactive
from textual.widgets import Button, Header, Footer, Static
class TimeDisplay(Static):
"""A widget to display elapsed time."""
start_time = Reactive(monotonic)
time = Reactive.init(0.0)
total = Reactive(0.0)
def on_mount(self) -> None:
"""Event handler called when widget is added to the app."""
self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)
def update_time(self) -> None:
"""Method to update time to current."""
self.time = self.total + (monotonic() - self.start_time)
def watch_time(self, time: float) -> None:
"""Called when the time attribute changes."""
minutes, seconds = divmod(time, 60)
hours, minutes = divmod(minutes, 60)
self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")
def start(self) -> None:
"""Method to start (or resume) time updating."""
self.start_time = monotonic()
self.update_timer.resume()
def stop(self):
"""Method to stop the time display updating."""
self.update_timer.pause()
self.total += monotonic() - self.start_time
self.time = self.total
def reset(self):
"""Method to reset the time display to zero."""
self.total = 0
self.time = 0
class Stopwatch(Static):
"""A stopwatch widget."""
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
button_id = event.button.id
time_display = self.query_one(TimeDisplay)
if button_id == "start":
time_display.start()
self.add_class("started")
elif button_id == "stop":
time_display.stop()
self.remove_class("started")
elif button_id == "reset":
time_display.reset()
def compose(self) -> ComposeResult:
"""Create child widgets of a stopwatch."""
yield Button("Start", id="start", variant="success")
yield Button("Stop", id="stop", variant="error")
yield Button("Reset", id="reset")
yield TimeDisplay()
class StopwatchApp(App):
"""A Textual app to manage stopwatches."""
def compose(self) -> ComposeResult:
"""Called to add widgets to the app."""
yield Header()
yield Footer()
yield Container(Stopwatch(), Stopwatch(), Stopwatch())
def on_load(self) -> None:
"""Event handler called when app first loads."""
self.bind("d", "toggle_dark", description="Dark mode")
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.dark = not self.dark
app = StopwatchApp(css_path="stopwatch04.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,14 @@
Static {
height: 1fr;
content-align: center middle;
color: white;
}
#static1 {
background: red;
}
#static2 {
background: rgb(0, 255, 0);
}
#static3 {
background: hsl(240, 100%, 50%);
}

View File

@@ -3,27 +3,10 @@ from textual.widgets import Static
class BackgroundApp(App):
CSS = """
Static {
height: 1fr;
content-align: center middle;
color: white;
}
#static1 {
background: red;
}
#static2 {
background: rgb(0, 255, 0);
}
#static3 {
background: hsl(240, 100%, 50%);
}
"""
def compose(self):
yield Static("Widget 1", id="static1")
yield Static("Widget 2", id="static2")
yield Static("Widget 3", id="static3")
app = BackgroundApp()
app = BackgroundApp(css_path="background.css")

View File

@@ -0,0 +1,25 @@
Screen {
background: white;
}
Screen > Static {
height: 5;
content-align: center middle;
color: white;
margin: 1;
box-sizing: border-box;
}
#static1 {
background: red 20%;
color: red;
border: solid red;
}
#static2 {
background: green 20%;
color: green;
border: dashed green;
}
#static3 {
background: blue 20%;
color: blue;
border: tall blue;
}

View File

@@ -3,38 +3,10 @@ from textual.widgets import Static
class BorderApp(App):
CSS = """
Screen {
background: white;
}
Screen > Static {
height: 5;
content-align: center middle;
color: white;
margin: 1;
box-sizing: border-box;
}
#static1 {
background: red 20%;
color: red;
border: solid red;
}
#static2 {
background: green 20%;
color: green;
border: dashed green;
}
#static3 {
background: blue 20%;
color: blue;
border: tall blue;
}
"""
def compose(self):
yield Static("My border is solid red", id="static1")
yield Static("My border is dashed green", id="static2")
yield Static("My border is tall blue", id="static3")
app = BorderApp()
app = BorderApp(css_path="border.css")

View File

@@ -0,0 +1,17 @@
Screen {
background: white;
color: black;
}
App Static {
background: blue 20%;
height: 5;
margin: 2;
padding: 1;
border: wide black;
}
#static1 {
box-sizing: border-box;
}
#static2 {
box-sizing: content-box;
}

View File

@@ -3,30 +3,9 @@ from textual.widgets import Static
class BoxSizingApp(App):
CSS = """
Screen {
background: white;
color: black;
}
Static {
background: blue 20%;
height: 5;
margin: 2;
padding: 1;
border: wide black;
}
#static1 {
box-sizing: border-box;
}
#static2 {
box-sizing: content-box;
}
"""
def compose(self):
yield Static("I'm using border-box!", id="static1")
yield Static("I'm using content-box!", id="static2")
app = BoxSizingApp()
app = BoxSizingApp(css_path="box_sizing.css")

View File

@@ -0,0 +1,13 @@
Static {
height:1fr;
content-align: center middle;
}
#static1 {
color: red;
}
#static2 {
color: rgb(0, 255, 0);
}
#static3 {
color: hsl(240, 100%, 50%)
}

View File

@@ -3,26 +3,10 @@ from textual.widgets import Static
class ColorApp(App):
CSS = """
Static {
height:1fr;
content-align: center middle;
}
#static1 {
color: red;
}
#static2 {
color: rgb(0, 255, 0);
}
#static3 {
color: hsl(240, 100%, 50%)
}
"""
def compose(self):
yield Static("I'm red!", id="static1")
yield Static("I'm rgb(0, 255, 0)!", id="static2")
yield Static("I'm hsl(240, 100%, 50%)!", id="static3")
app = ColorApp()
app = ColorApp(css_path="color.css")

View File

@@ -10,4 +10,3 @@ class ContentAlignApp(App):
app = ContentAlignApp(css_path="content_align.css")
app.run()

View File

@@ -0,0 +1,12 @@
Screen {
background: green;
}
Static {
height: 5;
background: white;
color: blue;
border: heavy blue;
}
Static.remove {
display: none;
}

View File

@@ -3,25 +3,10 @@ from textual.widgets import Static
class DisplayApp(App):
CSS = """
Screen {
background: green;
}
Static {
height: 5;
background: white;
color: blue;
border: heavy blue;
}
Static.remove {
display: none;
}
"""
def compose(self):
yield Static("Widget 1")
yield Static("Widget 2", classes="remove")
yield Static("Widget 3")
app = DisplayApp()
app = DisplayApp(css_path="display.css")

View File

@@ -0,0 +1,5 @@
Screen > Widget {
background: green;
height: 50%;
color: white;
}

View File

@@ -3,16 +3,8 @@ from textual.widget import Widget
class HeightApp(App):
CSS = """
Screen > Widget {
background: green;
height: 50%;
color: white;
}
"""
def compose(self):
yield Widget()
app = HeightApp()
app = HeightApp(css_path="height.css")

View File

@@ -0,0 +1,10 @@
Screen {
background: white;
color: black;
}
Static {
margin: 4 8;
background: blue 20%;
border: blue wide;
}

View File

@@ -11,23 +11,8 @@ Where the fear has gone there will be nothing. Only I will remain."""
class MarginApp(App):
CSS = """
Screen {
background: white;
color: black;
}
Static {
margin: 4 8;
background: blue 20%;
border: blue wide;
}
"""
def compose(self):
yield Static(TEXT)
app = MarginApp()
app = MarginApp(css_path="margin.css")

View File

@@ -0,0 +1,31 @@
Screen {
background: white;
color: black;
layout: horizontal;
}
Static {
width: 20;
height: 10;
content-align: center middle;
}
.paul {
offset: 8 2;
background: red 20%;
border: outer red;
color: red;
}
.duncan {
offset: 4 10;
background: green 20%;
border: outer green;
color: green;
}
.chani {
offset: 0 5;
background: blue 20%;
border: outer blue;
color: blue;
}

View File

@@ -3,44 +3,10 @@ from textual.widgets import Static
class OffsetApp(App):
CSS = """
Screen {
background: white;
color: black;
layout: horizontal;
}
Static {
width: 20;
height: 10;
content-align: center middle;
}
.paul {
offset: 8 2;
background: red 20%;
border: outer red;
color: red;
}
.duncan {
offset: 4 10;
background: green 20%;
border: outer green;
color: green;
}
.chani {
offset: 0 5;
background: blue 20%;
border: outer blue;
color: blue;
}
"""
def compose(self):
yield Static("Paul (offset 8 2)", classes="paul")
yield Static("Duncan (offset 4 10)", classes="duncan")
yield Static("Chani (offset 0 5)", classes="chani")
app = OffsetApp()
app = OffsetApp(css_path="offset.css")

View File

@@ -0,0 +1,9 @@
Screen {
background: white;
color: black;
}
Static {
margin: 4 8;
background: green 20%;
outline: wide green;
}

View File

@@ -12,20 +12,8 @@ Where the fear has gone there will be nothing. Only I will remain."""
class OutlineApp(App):
CSS = """
Screen {
background: white;
color: black;
}
Static {
margin: 4 8;
background: green 20%;
outline: wide green;
}
"""
def compose(self):
yield Static(TEXT)
app = OutlineApp()
app = OutlineApp(css_path="outline.css")

View File

@@ -0,0 +1,19 @@
Screen {
background: $background;
color: black;
}
Vertical {
width: 1fr;
}
Static {
margin: 1 2;
background: blue 20%;
border: blue wide;
height: auto;
}
#right {
overflow-y: hidden;
}

View File

@@ -12,28 +12,6 @@ Where the fear has gone there will be nothing. Only I will remain."""
class OverflowApp(App):
CSS = """
Screen {
background: $background;
color: black;
}
Vertical {
width: 1fr;
}
Static {
margin: 1 2;
background: blue 20%;
border: blue wide;
height: auto;
}
#right {
overflow-y: hidden;
}
"""
def compose(self):
yield Horizontal(
Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="left"),
@@ -41,4 +19,4 @@ class OverflowApp(App):
)
app = OverflowApp()
app = OverflowApp(css_path="overflow.css")

View File

@@ -0,0 +1,9 @@
Screen {
background: white;
color: blue;
}
Static {
padding: 4 8;
background: blue 20%;
}

View File

@@ -11,22 +11,8 @@ Where the fear has gone there will be nothing. Only I will remain."""
class PaddingApp(App):
CSS = """
Screen {
background: white;
color: blue;
}
Static {
padding: 4 8;
background: blue 20%;
}
"""
def compose(self):
yield Static(TEXT)
app = PaddingApp()
app = PaddingApp(css_path="padding.css")

View File

@@ -0,0 +1,15 @@
Screen {
background: white;
color: blue 80%;
layout: horizontal;
}
Static {
padding: 1 2;
width: 200;
}
.panel {
scrollbar-size: 10 4;
padding: 1 2;
}

View File

@@ -13,26 +13,8 @@ Where the fear has gone there will be nothing. Only I will remain.
class ScrollbarApp(App):
CSS = """
Screen {
background: white;
color: blue 80%;
layout: horizontal;
}
Static {
padding: 1 2;
width: 200;
}
.panel {
scrollbar-size: 10 4;
padding: 1 2;
}
"""
def compose(self):
yield layout.Vertical(Static(TEXT * 5), classes="panel")
app = ScrollbarApp()
app = ScrollbarApp(css_path="scrollbar_size.css")

View File

@@ -0,0 +1,23 @@
Screen {
background: #212121;
color: white 80%;
layout: horizontal;
}
Static {
padding: 1 2;
}
.panel1 {
width: 1fr;
scrollbar-color: green;
scrollbar-background: #bbb;
padding: 1 2;
}
.panel2 {
width: 1fr;
scrollbar-color: yellow;
scrollbar-background: purple;
padding: 1 2;
}

View File

@@ -13,37 +13,9 @@ Where the fear has gone there will be nothing. Only I will remain.
class ScrollbarApp(App):
CSS = """
Screen {
background: #212121;
color: white 80%;
layout: horizontal;
}
Static {
padding: 1 2;
}
.panel1 {
width: 1fr;
scrollbar-color: green;
scrollbar-background: #bbb;
padding: 1 2;
}
.panel2 {
width: 1fr;
scrollbar-color: yellow;
scrollbar-background: purple;
padding: 1 2;
}
"""
def compose(self):
yield layout.Vertical(Static(TEXT * 5), classes="panel1")
yield layout.Vertical(Static(TEXT * 5), classes="panel2")
app = ScrollbarApp()
app = ScrollbarApp(css_path="scrollbars.css")

View File

@@ -0,0 +1,18 @@
Screen {
layout: horizontal;
}
Static {
width:1fr;
}
#static1 {
background: red 30%;
text-style: bold;
}
#static2 {
background: green 30%;
text-style: italic;
}
#static3 {
background: blue 30%;
text-style: reverse;
}

View File

@@ -11,31 +11,10 @@ Where the fear has gone there will be nothing. Only I will remain."""
class TextStyleApp(App):
CSS = """
Screen {
layout: horizontal;
}
Static {
width:1fr;
}
#static1 {
background: red 30%;
text-style: bold;
}
#static2 {
background: green 30%;
text-style: italic;
}
#static3 {
background: blue 30%;
text-style: reverse;
}
"""
def compose(self):
yield Static(TEXT, id="static1")
yield Static(TEXT, id="static2")
yield Static(TEXT, id="static3")
app = TextStyleApp()
app = TextStyleApp(css_path="text_style.css")

View File

@@ -0,0 +1,7 @@
Static {
height: 3;
text-style: bold;
background: white;
color: black;
content-align: center middle;
}

View File

@@ -4,16 +4,6 @@ from textual.widgets import Static
class TintApp(App):
CSS = """
Static {
height: 3;
text-style: bold;
background: white;
color: black;
content-align: center middle;
}
"""
def compose(self):
color = Color.parse("green")
for tint_alpha in range(0, 101, 10):
@@ -22,4 +12,4 @@ class TintApp(App):
yield widget
app = TintApp()
app = TintApp(css_path="tint.css")

View File

@@ -0,0 +1,12 @@
Screen {
background: green;
}
Static {
height: 5;
background: white;
color: blue;
border: heavy blue;
}
Static.invisible {
visibility: hidden;
}

View File

@@ -3,25 +3,10 @@ from textual.widgets import Static
class VisibilityApp(App):
CSS = """
Screen {
background: green;
}
Static {
height: 5;
background: white;
color: blue;
border: heavy blue;
}
Static.invisible {
visibility: hidden;
}
"""
def compose(self):
yield Static("Widget 1")
yield Static("Widget 2", classes="invisible")
yield Static("Widget 3")
app = VisibilityApp()
app = VisibilityApp(css_path="visibility.css")

View File

@@ -0,0 +1,5 @@
Screen > Widget {
background: green;
width: 50%;
color: white;
}

View File

@@ -3,16 +3,8 @@ from textual.widget import Widget
class WidthApp(App):
CSS = """
Screen > Widget {
background: green;
width: 50%;
color: white;
}
"""
def compose(self):
yield Widget()
app = WidthApp()
app = WidthApp(css_path="width.css")

45
docs/getting_started.md Normal file
View File

@@ -0,0 +1,45 @@
All you need to get started building Textual apps.
## Requirements
Textual requires Python 3.7 or later. Textual runs on Linux, MacOS, Windows and probably any OS where Python also runs.
!!! info inline end "Your platform"
### :fontawesome-brands-linux: Linux (all distros)
All Linux distros come with a terminal emulator that can run Textual apps.
### :material-apple: MacOS
The default terminal app is limited to 256 colors. We recommend installing a newer terminal such as [iterm2](https://iterm2.com/), [Kitty](https://sw.kovidgoyal.net/kitty/), or [WezTerm](https://wezfurlong.org/wezterm/).
### :material-microsoft-windows: Windows
The new [Windows Terminal](https://apps.microsoft.com/store/detail/windows-terminal/9N0DX20HK701?hl=en-gb&gl=GB) runs Textual apps beautifully.
## Installation
You can install Textual via PyPI.
If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
```bash
pip install textual[dev]
```
If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:
```bash
pip install textual
```
## Textual CLI
If you installed the dev dependencies you have have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps.
```bash
textual --help
```
See [devtools](guide/devtools.md) for more about the `textual` command.

393
docs/guide/CSS.md Normal file
View File

@@ -0,0 +1,393 @@
# Textual CSS
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 section will get you up to speed.
## Stylesheets
CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a page. In the case of Textual, the stylesheet applies styles to widgets but otherwise it is the same idea.
!!! note
Depending on what you want to build with Textual, you may not need to learn Textual CSS at all. Widgets are packaged with CSS styles so apps with exclusively pre-built widgets may not need any additional CSS.
Textual CSS defines a set of rules which apply visual _styles_ to your application and widgets. These style can customize a large variety of visual settings, such as color, border, size, alignment; and more dynamic features such as animation and hover effects. As powerful as it is, CSS in Textual is quite straightforward.
CSS is typically stored in an external file with the extension `.css` alongside your Python code.
Let's look at some Textual CSS.
```sass
Header {
dock: top;
height: 3;
content-align: center middle;
background: blue;
color: white;
}
```
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.
```css hl_lines="1"
Header {
dock: top;
height: 3;
content-align: center middle;
background: blue;
color: white;
}
```
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`.
```css hl_lines="2 3 4 5 6"
Header {
dock: top;
height: 3;
content-align: center middle;
background: blue;
color: white;
}
```
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 semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons.
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
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 a 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.py"
```python
--8<-- "docs/examples/guide/dom1.py"
```
=== "Output"
```{.textual path="docs/examples/guide/dom1.py"}
```
When you run this code you will have an instance of an `ExampleApp` in memory. This app class will also 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:
<div class="excalidraw">
--8<-- "docs/images/dom1.excalidraw.svg"
</div>
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.py"
```python
--8<-- "docs/examples/guide/dom2.py"
```
=== "Output"
```{.textual path="docs/examples/guide/dom2.py"}
```
With a header and a footer widget the DOM looks the this:
<div class="excalidraw">
--8<-- "docs/images/dom2.excalidraw.svg"
</div>
!!! 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 for the 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 import and use a few more builtin widgets:
- `texual.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.
=== "dom3.py"
```python hl_lines="12 13 14 15 16 17 18 19 20"
--8<-- "docs/examples/guide/dom3.py"
```
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:
<div class="excalidraw">
--8<-- "docs/images/dom3.excalidraw.svg"
</div>
Here's the output from this example:
```{.textual path="docs/examples/guide/dom3.py"}
```
You may recognize some of the elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet.
## CSS files
To add a stylesheet we need to pass the path to a CSS file via the app classes' `css_path` argument:
```python hl_lines="23"
--8<-- "docs/examples/guide/dom4.py"
```
You may have noticed that some of the constructors have additional keyword argument: `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:
```sass
--8<-- "docs/examples/guide/dom4.css"
```
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:
```{.textual path="docs/examples/guide/dom4.py"}
```
### Why CSS?
It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to do everything 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:
```bash
textual run my_app.py --dev
```
Being able to iterate on the design without restarting the application makes it easier and faster to design beautiful interfaces.
## Selectors
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.
### Type selector
The _type_ selector matches the name of the (Python) class. For example, the following widget can be matched with a `Button` selector:
```python
from textual.widgets import Widget
class Button(Static):
pass
```
The following rule applies a border to this widget:
```sass
Button {
border: solid blue;
}
```
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`.
```sass
Static {
background: blue;
border: rounded white;
}
```
!!! 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".
### ID selector
Every Widget can have a single `id` attribute, which is set via the constructor. The ID should be unique to it's container.
Here's an example of a widget with an ID:
```python
yield Button(id="next")
```
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:
```sass
#next {
outline: red;
}
```
A Widget's `id` attribute can not be changed after the Widget has been constructed.
### Class-name selector
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:
```python
yield Button(classes="success")
```
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:
```python
yield Button(classes="error disabled")
```
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:
```sass
.success {
background: green;
color: white;
}
```
!!! 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.
```sass
.error.disabled {
background: darkred;
}
```
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.
- [add_class()][textual.dom.DOMNode.add_class] Adds one or more classes to a widget.
- [remove_class()][textual.dom.DOMNode.remove_class] Removes class name(s) from a widget.
- [toggle_class()][textual.dom.DOMNode.toggle_class] Removes a class name if it is present, or adds the name if it's not already present.
- [has_class()][textual.dom.DOMNode.has_class] Checks if a class(es) is set on a widget.
- [classes][textual.dom.DOMNode.classes] Is a frozen set of the class(es) set on a widget.
### Universal selector
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:
```sass
* {
outline: solid red;
}
```
### Pseudo classes
Pseudo classes can be used to match widgets in a particular state. Psuedo 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.
```sass
Button:hover {
background: green;
}
```
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:
- `:focus` Matches widgets which have input focus.
- `:focus-within` Matches widgets with a focused a child widget.
## Combinators
More sophisticated selectors can be created by combining simple selectors. The logic used to combine selectors is know as a _combinator_.
### Descendant combinator
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:
<div class="excalidraw">
--8<-- "docs/images/descendant_combinator.excalidraw.svg"
</div>
Let'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:
```sass hl_lines="1"
#dialog Button {
text-style: bold;
}
```
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"`:
```css
#dialog Horizontal Button {
text-style: bold;
}
```
### Child combinator
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:
<div class="excalidraw">
--8<-- "docs/images/child_combinator.excalidraw.svg"
</div>
We can use the following CSS to style all buttons which have a parent with an ID of `sidebar`:
```sass
#sidebar > Button {
text-style: underline;
}
```
## Specificity
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`.
### Important rules
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:
```sass hl_lines="2"
Button:hover {
background: blue !important;
}
```

51
docs/guide/devtools.md Normal file
View File

@@ -0,0 +1,51 @@
# Devtools
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 sub-commands. There will be even more helpful tools here in the future.
```bash
textual --help
```
## Run
You can run Textual apps with the `run` subcommand. If you supply a path to a Python file it will load and run the application.
```bash
textual run my_app.py
```
The `run` sub-command assumes you have an App instance called `app` in the global scope of your Python file. If the application is called something different, you can specify it with a colon following the filename:
```
textual run my_app.py:alternative_app
```
!!! note
If the Python file contains a call to app.run() then you can launch the file as you normally would any other Python program. Running your app via `textual run` will give you access to a few Textual features such as live editing of CSS files.
## Console
When running any terminal application, you can no longer use `print` when debugging (or log to the console). This is because anything you write to standard output would overwrite application content, making it unreadable. Fortunately Textual supplies a debug console of its own which has some super helpful features.
To use the console, open up 2 terminal emulators. In the first one, run the following:
```bash
textual console
```
This should look something like the following:
```{.textual title="textual console" path="docs/examples/getting_started/console.py", press="_,_"}
```
In the other console, run your application using `textual run` and the `--dev` switch:
```bash
textual run --dev my_app.py
```
Anything you `print` from your application will be displayed in the console window. You can also call the [`log()`][textual.message_pump.MessagePump.log] method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`.

5
docs/guide/events.md Normal file
View File

@@ -0,0 +1,5 @@
## Events
<div class="excalidraw">
--8<-- "docs/images/test.excalidraw.svg"
</div>

View File

@@ -1 +0,0 @@
# Guide

0
docs/guide/layout.md Normal file
View File

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,11 +1,76 @@
# Welcome
Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation, built with ❤️ by [Textualize.io](https://www.textualize.io)
Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. Built with ❤️ by [Textualize.io](https://www.textualize.io)
## Getting started
<hr>
Textual is a Python framework which you can install via Pypi.
Textual is a framework for building applications that run within your terminal. Such Text User Interfaces (TUIs) have a number of advantages over traditional web and desktop apps.
<div class="grid cards" markdown>
- :material-clock-fast:{ .lg .middle } :material-language-python:{. lg .middle } __Rapid development__
---
Uses your existing Python skills to build beautiful user interfaces.
- :material-raspberry-pi:{ .lg .middle } __Low requirements__
---
Low system requirements. Run Textual on a single board computer if you want to.
- :material-microsoft-windows:{ .lg .middle } :material-apple:{ .lg .middle } :fontawesome-brands-linux:{ .lg .middle } __Cross platform__
---
Textual runs just about everywhere.
- :material-network:{ .lg .middle } __Remote__
---
Textual apps can run over SSH.
- :fontawesome-solid-terminal:{ .lg .middle } __CLI Integration__
---
Textual apps can be launched and run from the command prompt.
- :material-scale-balance:{ .lg .middle } __Open Source, MIT__
---
Textual is licensed under MIT.
</div>
<hr>
<!-- TODO: More examples split in to tabs -->
=== "Example 1"
```{.textual path="docs/examples/demo.py" columns=100 lines=48}
```
=== "Example 2"
```{.textual path="docs/examples/introduction/timers.py"}
```
```bash
pip install textual
```

View File

@@ -2,150 +2,449 @@
Welcome to the Textual Introduction!
This is a very gentle introduction to creating Textual applications. By the end of this document you should have an understanding of the basic concepts involved in using the Textual framework.
By the end of this page you should have a solid understanding of app development with Textual.
## Pre-requisites
!!! quote
- Python 3.7 or later. If you have a choice, pick the most recent version.
- Installed `textual` from Pypi.
- Basic Python skills.
This page goes in to more detail than you may expect from an introduction. I like documentation to have complete working examples and I wanted the first app to be realistic.
&mdash; **Will McGugan** (creator of Rich and Textual)
## A Simple App
Let's looks at the simplest possible Textual app. It doesn't do much, but will demonstrate the basic steps you will need to create any application.
## Stopwatch Application
If you would like to follow along and run the examples, navigate to the `docs/examples/introduction` directory from the command prompt. We will be looking at `intro01.py`, which you can see here:
We're going to build a stopwatch application. It should show a list of stopwatches with a time display the user can start, stop, and reset. We also want the user to be able to add and remove stopwatches as required.
```python title="intro01.py"
--8<-- "docs/examples/introduction/intro01.py"
This will be a simple yet **fully featured** app &mdash; you could distribute this app if you wanted to!
Here's what the finished app will look like:
```{.textual path="docs/examples/introduction/stopwatch.py" press="tab,enter,_,tab,enter,_,tab,_,enter,_,tab,enter,_,_"}
```
Enter the following command to run the application:
### Get the code
If you want to try the finished Stopwatch app and follow along with the code, first make sure you have [Textual installed](getting_started.md) and then check out the [Textual](https://github.com/Textualize/textual) GitHub repository:
=== "HTTPS"
```bash
git clone https://github.com/Textualize/textual.git
```
=== "SSH"
```bash
git clone git@github.com:Textualize/textual.git
```
=== "GitHub CLI"
```bash
gh repo clone Textualize/textual
```
With the repository cloned, navigate to `docs/examples/introduction` and run `stopwatch.py`.
```bash
python intro01.py
cd textual/docs/examples/introduction
python stopwatch.py
```
The command prompt should disappear and you will see a blank screen. It will look something like the following:
## Type hints (in brief)
```{.textual path="docs/examples/introduction/intro01.py"}
!!! tip inline end
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](https://mypy.readthedocs.io/en/stable/) to catch potential bugs before your code runs.
The following function contains type hints:
```python
def repeat(text: str, count: int) -> str:
"""Repeat a string a given number of times."""
return text * count
```
Hit ++ctrl+c++ to exit and return to the command prompt.
- 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 code
The first step in all Textual applications is to import the `App` class from `textual.app` and extend it:
## The App class
```python hl_lines="1 2 3 4 5" title="intro01.py"
--8<-- "docs/examples/introduction/intro01.py"
The first step in building a Textual app is to import and extend the `App` class. Here's our basic app class with a few methods we will cover below.
```python title="stopwatch01.py"
--8<-- "docs/examples/introduction/stopwatch01.py"
```
This App class is responsible for loading data, setting up the screen, managing events etc. In a real app most of the core logic of your application will be contained within methods on this class.
If you run this code, you should see something like the following:
The last two lines create an instance of the application and call the `run()` method:
```python hl_lines="8 9" title="intro01.py"
--8<-- "docs/examples/introduction/intro01.py"
```{.textual path="docs/examples/introduction/stopwatch01.py"}
```
The `run` method will put your terminal in to "application mode" which disables the prompt and allows Textual to take over input and output. When you press ++ctrl+c++ the application will exit application mode and re-enable the command prompt.
Hit the ++d++ key to toggle dark mode.
## Handling Events
```{.textual path="docs/examples/introduction/stopwatch01.py" press="d" title="TimerApp + dark"}
```
Most real-world applications will want to interact with the user in some way. To do this we can make use of _event handler_ methods, which are called in response to things the user does such as pressing a key(s), moving the mouse, resizing the terminal, etc.
Hit ++ctrl+c++ to exit the app and return to the command prompt.
Each event type is represented by an event object, which is an instance of a class containing information you may need to respond the the event. For instance the `Key` event contains the key the user pressed and a `Mouse` event will contain the coordinates of the mouse cursor.
### A closer look at the App class
Let's examine stopwatch01.py in more detail.
```python title="stopwatch01.py" hl_lines="1 2"
--8<-- "docs/examples/introduction/stopwatch01.py"
```
The first line imports the Textual `App` class. The second line imports two builtin widgets: `Footer` which shows available keys and `Header` which shows a title and the current time.
Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build such widgets in this introduction.
```python title="stopwatch01.py" hl_lines="5-19"
--8<-- "docs/examples/introduction/stopwatch01.py"
```
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.
Currently, there are three methods in our stopwatch app.
- **`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 instances of the widget classes we imported, i.e. the header and the footer.
- **`on_load()`** is an _event handler_ method. Event handlers are called by Textual in response to external events like keys and mouse movements, and internal events needed to manage your application. Event handler methods begin with `on_` followed by the name of the event (in lower case). Hence, `on_load` is called in response to the Load event which is sent just after the app starts. We're using this event to call `App.bind()` which connects a key to an _action_.
- **`action_toggle_dark()`** defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The call to `bind()` in `on_load()` binds this the ++d++ key to this action. The body of this method flips the state of the `dark` Boolean to toggle dark mode.
!!! note
Although `intro01.py` did not explicitly define any event handlers, Textual still had to respond to events to catch ++ctrl+c++, otherwise you wouldn't be able to exit the app.
You may have noticed that `action_toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ updates the terminal. Textual is able to detect changes that should update the screen.
The next example demonstrates handling events. Try running `intro02.py` in the `docs/examples/introduction`:
```python title="intro02.py"
--8<-- "docs/examples/introduction/intro02.py"
```python title="stopwatch01.py" hl_lines="22-24"
--8<-- "docs/examples/introduction/stopwatch01.py"
```
When you run this app you should see a blue screen in your terminal, like the following:
The last few lines create an instance of the app at the module scope. Followed by a call to `run()` within a `__name__ == "__main__"` block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`.
```{.textual path="docs/examples/introduction/intro02.py"}
## Designing a UI with widgets
The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets.
Let's sketch out a design for our app:
<div class="excalidraw">
--8<-- "docs/images/stopwatch.excalidraw.svg"
</div>
We will need to build a `Stopwatch` widget composed of the following _child_ widgets:
- A "Start" button
- A "Stop" button
- A "Reset" button
- A time display
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 in HOURS:MINUTES:SECONDS format, 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.
```python title="stopwatch02.py" hl_lines="3 6-7 10-18 28"
--8<-- "docs/examples/introduction/stopwatch02.py"
```
If you hit any of the number keys ++0++-++9++, the background will change color and you should hear a beep. As before, pressing ++ctrl+c++ will exit the app and return you to your prompt.
### Extending widget classes
!!! note
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 `Container` from `textual.layout`. As the name suggests, `Container` is a Widget which contains other widgets. We will use this container to create a scrolling list of stopwatches.
The "beep" is your terminal's *bell*. Some terminals may be configured to play different noises or a visual indication of a bell rather than a noise.
We're extending Static as a foundation for our `TimeDisplay` widget. There are no methods on this class yet.
There are two event handlers in this app. Event handlers start with the text `on_` followed by the name of the event in lower case. Hence `on_mount` is called for the `Mount` event, and `on_key` is called for the `Key` event.
The Stopwatch class also extends Static to define a new widget. This class has a `compose()` method which yields its child widgets, consisting of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch as in the sketch.
The first event handler to run is `on_mount`. The `Mount` is sent to your application immediately after entering application mode.
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:
```python hl_lines="19 20" title="intro02.py"
--8<-- "docs/examples/introduction/intro02.py"
- **`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.
### Composing the widgets
To see our widgets we first need to yield them from the app's `compose()` method:
The new line in `Stopwatch.compose()` yields a single `Container` object which will create a scrolling list of stopwatches. When classes contain other widgets (like `Container`) 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.
### The unstyled app
Let's see what happens when we run "stopwatch02.py".
```{.textual path="docs/examples/introduction/stopwatch02.py" title="stopwatch02.py"}
```
This `on_mount` method sets the `background` attribute of `self.styles` to `"darkblue"` which makes the background blue when the application starts. There are a lot of other properties on the Styles object, which define how your app looks. We will explore what you can do with this object later.
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.
!!! note
## Writing Textual CSS
You may have noticed there is no function call to repaint the screen in this example. Textual is generally quite smart in detecting when a refresh is required, and updating the screen automatically.
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:
The second event handler will receive `Key` events whenever you press a key on the keyboard:
```python hl_lines="22 23 24 25" title="intro02.py"
--8<-- "docs/examples/introduction/intro02.py"
```python
self.styles.background = "blue"
self.styles.color = "white"
```
This method has an `event` positional argument which will receive the event object; in this case the `Key` event. The body of the method sets the background to a corresponding color in the `COLORS` list when you press one of the digit keys. It also calls `bell()` which is a method on App that plays your terminal's bell.
!!! info inline end
!!! note
Don't worry if you have never worked with CSS before. The dialect of CSS we use is greatly simplified over web based CSS and easy to learn!
Every event has a corresponding `Event` object, but Textual knows to only call the event handler with the event object if you have it in the argument list. It does this by inspecting the handler method prior to calling it. So if you don't need the event object, you may leave it out.
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.
## Widgets
Let's add a CSS file to our application.
Most Textual applications will make use of one or more `Widget` classes. A Widget is a self contained component responsible for defining how a given part of the screen should look. Widgets respond to events in much the same way as the App does.
Let's look at an app with a simple Widget to show the current time and date. Here is the code for `"clock01.py"` which is in the same directory as the previous examples:
```python title="clock01.py"
--8<-- "docs/examples/introduction/clock01.py"
```python title="stopwatch03.py" hl_lines="39"
--8<-- "docs/examples/introduction/stopwatch03.py"
```
Here's what you will see if you run this code:
```{.textual path="docs/examples/introduction/clock01.py"}
Adding the `css_path` attribute to the app constructor tells Textual to load the following file when it starts the app:
```sass title="stopwatch03.css"
--8<-- "docs/examples/introduction/stopwatch03.css"
```
This script imports App as before, but also the `Widget` class from `textual.widget`, which is the base class for all Widgets. To create a Clock widget we extend from the Widget base class:
If we run the app now, it will look *very* different.
```python title="clock01.py" hl_lines="7 8 9 10 11 12 13"
--8<-- "docs/examples/introduction/clock01.py"
```{.textual path="docs/examples/introduction/stopwatch03.py" title="stopwatch03.py"}
```
Widgets support many of the same events as the Application itself, and can be thought of as mini-applications in their own right. The Clock widget responds to a Mount event which is the first event received when a widget is _mounted_ (added to the App). The code in `Clock.on_mount` sets `styles.content_align` to tuple of `("center", "middle")` which tells Textual to display the Widget's content aligned to the horizontal center, and in the middle vertically. If you resize the terminal, you should find the time remains in the center.
This app looks much more like our sketch. Textual has read style information from `stopwatch03.css` and applied it to the widgets.
The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` method once per second.
### CSS basics
When Textual refreshes a widget it calls it's `render` method:
CSS files contain a number of _declaration blocks_. Here's the first such block from `stopwatch03.css` again:
```python title="clock01.py" hl_lines="12 13"
--8<-- "docs/examples/introduction/clock01.py"
```sass
Stopwatch {
layout: horizontal;
background: $panel-darken-1;
height: 5;
padding: 1;
margin: 1;
}
```
The Clocks `render` method uses the datetime module to format the current date and time. It returns a string, but can also return a _Rich renderable_. Don't worry if you aren't familiar with [Rich](https://github.com/Textualize/rich), we will cover that later.
The first line tells Textual that the styles should apply to the `Stopwatch` widget. The lines between the curly brackets contain the styles themselves.
Before a Widget can be displayed, it must first be mounted on the app. This is typically done within the applications Mount handler, so that an application's widgets are added when the application first starts:
Here's how this CSS code changes how the `Stopwatch` widget is displayed.
```python title="clock01.py" hl_lines="17 18"
--8<-- "docs/examples/introduction/clock01.py"
<div class="excalidraw">
--8<-- "docs/images/stopwatch_widgets.excalidraw.svg"
</div>
- `layout: horizontal` aligns child widgets horizontally from left to right.
- `background: $panel-darken-1` sets the background color to `$panel-darken-1`. 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.
- `padding: 1` sets a padding of 1 cell around the child widgets.
- `margin: 1` sets a margin of 1 cell around the Stopwatch widget to create a little space between widgets in the list.
Here's the rest of `stopwatch03.css` which contains further declaration blocks:
```sass
TimeDisplay {
content-align: center middle;
opacity: 60%;
height: 3;
}
Button {
width: 16;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}
```
In the case of the clock application, we call `mount` with an instance of the `Clock` widget.
The `TimeDisplay` block aligns text to the center (`content-align`), fades it slightly (`opacity`), and sets its height (`height`) to 3 lines.
That's all there is to this Clock example. It will display the current time until you hit ++ctrl+c++
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.
### Dynamic CSS
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:
```sass title="stopwatch04.css" hl_lines="33-53"
--8<-- "docs/examples/introduction/stopwatch04.css"
```
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:
```sass
.started #start {
display: none
}
```
The purpose of this CSS is to hide the start button when the stopwatch has started.
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.
### Manipulating classes
Modifying a widget's CSS classes it a convenient way to modify visuals without introducing a lot of display related code which tends to be hard to maintain.
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 adds an event handler for the `Button.Pressed` event.
```python title="stopwatch04.py" hl_lines="13-18"
--8<-- "docs/examples/introduction/stopwatch04.py"
```
The `on_button_pressed` event handler is called when the user clicks a button. This method adds the "started" class when the "start" button was clicked, and removes the class when the "stop" button is clicked.
If you run "stopwatch04.py" now you will be able to toggle between the two states by clicking the first button:
```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"}
```
## Reactive attributes
A recurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call [`refresh()`][textual.widget.Widget.refresh] to display new data. However, Textual prefers to do this automatically via _reactive_ attributes.
You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use this feature to create a timer that displays elapsed time and keeps it updated.
```python title="stopwatch04.py" hl_lines="1 5 12-27"
--8<-- "docs/examples/introduction/stopwatch05.py"
```
We have added two reactive attributes: `start_time` will contain the time in seconds when 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 or a callable that returns the default value. The default for `start_time` is `monotonic`. When `TimeDisplay` is mounted, the `start_time` attribute will be assigned the result of `monotonic()`.
The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start.
!!! info
The `time` attribute is created with `Reactive.init` which calls _watch methods_ when the widget is mounted. See below for an explanation of watch methods.
In the `on_mount` method the call to `set_interval` creates a timer object which runs `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`. 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 (making it a _watch method_), that method will be called when the attribute is modified.
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 in to a string and updates the widget with a call to `self.update`.
The end result is that the `Stopwatch` widgets show the time elapsed since the widget was created:
```{.textual path="docs/examples/introduction/stopwatch05.py" title="stopwatch05.py"}
```
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.
### Wiring buttons
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.
```python title="stopwatch06.py" hl_lines="14-44 50-61"
--8<-- "docs/examples/introduction/stopwatch06.py"
```
Here's a summary of the changes made to `TimeDisplay`.
- We've added a `total` reactive attribute to store the total time elapsed between clicking that start and stop buttons.
- The call to `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.
- We've stored the result of `set_interval` which returns a Timer object. We will use this later to _resume_ the timer when we start the Stopwatch.
- We've added `start()`, `stop()`, and `reset()` methods.
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:
```python
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Event handler called when a button is pressed."""
button_id = event.button.id
time_display = self.query_one(TimeDisplay)
if button_id == "start":
time_display.start()
self.add_class("started")
elif button_id == "stop":
time_display.stop()
self.remove_class("started")
elif button_id == "reset":
time_display.reset()
```
This code supplies missing features and makes our app useful. We've made the following changes.
- The first line retrieves the button's ID, which we will use to decide what to do in response.
- The second line calls `query_one` to get a reference to the `TimeDisplay` widget.
- We call the method on `TimeDisplay` that matches the pressed button.
- We add the "started" class when the Stopwatch is started, and remove it 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.
```{.textual path="docs/examples/introduction/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"}
```
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove timers.
## Dynamic widgets
It's convenient to build a user interface with the `compose` method. We may also want to add or remove widgets while the app is running.
To add a new child widget call `mount()` on the parent. To remove a widget, call its `remove()` method.
Let's use these to implement adding and removing stopwatches to our app.
```python title="stopwatch.py" hl_lines="83-84 86-90 92-96"
--8<-- "docs/examples/introduction/stopwatch.py"
```
We've added two new actions: `action_add_stopwatch` to add a new stopwatch, and `action_remove_stopwatch` to remove the last stopwatch. The `on_load` handler binds these actions to the ++a++ and ++r++ keys.
The `action_add_stopwatch` method creates and mounts a new `Stopwatch` instance. Note the call to `query_one` with a CSS selector of `"#timers"` which gets the timer's container via its ID (assigned in `compose`). 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 necessary).
The `action_remove_stopwatch` 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++.
```{.textual path="docs/examples/introduction/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,_,_,_,_,tab,_"}
```
## What next?
Congratulations on building your first Textual application! This introduction 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.

1
docs/reference/color.md Normal file
View File

@@ -0,0 +1 @@
::: textual.color

View File

@@ -0,0 +1 @@
::: textual.dom.DOMNode

View File

@@ -0,0 +1,5 @@
A message pump is a class that processes messages.
It is a base class for the `App`, `Screen`, and `Widget` classes.
::: textual.message_pump.MessagePump

1
docs/reference/timer.md Normal file
View File

@@ -0,0 +1 @@
::: textual.timer

View File

@@ -2,12 +2,28 @@
The `background` rule sets the background color of the widget.
## Syntax
```
background: COLOR [PERCENTAGE]
```
## Example
This example creates three widgets and applies a different background to each.
=== "background.py"
```python
--8<-- "docs/examples/styles/background.py"
```
=== "background.css"
```sass
--8<-- "docs/examples/styles/background.css"
```
=== "Output"
```{.textual path="docs/examples/styles/background.py"}

Some files were not shown because too many files have changed in this diff Show More