From 6740e5edf8bab0a6b0abc40775143fcd70193abb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Aug 2022 14:26:56 +0100 Subject: [PATCH 01/73] intro docs --- docs/examples/demo.css | 240 ++++++++++++++++++ docs/examples/demo.py | 214 ++++++++++++++++ .../introduction/{clock.py => clock02.py} | 0 docs/index.md | 38 ++- docs/introduction.md | 52 ++-- 5 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 docs/examples/demo.css create mode 100644 docs/examples/demo.py rename docs/examples/introduction/{clock.py => clock02.py} (100%) diff --git a/docs/examples/demo.css b/docs/examples/demo.css new file mode 100644 index 000000000..fef9e4248 --- /dev/null +++ b/docs/examples/demo.css @@ -0,0 +1,240 @@ +/* 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: sidebar; + + color: $text-background; + background: $background; + layout: vertical; + +} + +DataTable { + /*border:heavy red;*/ + /* tint: 10% green; */ + /* opacity: 50%; */ + padding: 1; + margin: 1 2; + height: 12; +} + +#sidebar { + color: $text-panel; + background: $panel; + dock: left; + width: 30; + 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; +} + +#header { + color: $text-secondary-background; + background: $secondary-background; + height: 1; + content-align: center middle; + + dock: top; +} + + +Tweet { + height:12; + width: 100%; + + + 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: 20; + 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 +} diff --git a/docs/examples/demo.py b/docs/examples/demo.py new file mode 100644 index 000000000..ab7779c1a --- /dev/null +++ b/docs/examples/demo.py @@ -0,0 +1,214 @@ +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 + +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="demo.css"): + """A basic app demonstrating CSS""" + + def on_load(self): + """Bind keys here.""" + self.bind("s", "toggle_class('#sidebar', '-active')") + + def compose(self) -> ComposeResult: + table = DataTable() + self.scroll_to_target = Tweet(TweetBody()) + + yield Static( + Text.from_markup( + "[b]This is a [u]Textual[/u] app, running in the terminal" + ), + id="header", + ) + yield from ( + 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(id="footer") + yield Widget( + Widget(classes="title"), + Widget(classes="user"), + OptionItem(), + OptionItem(), + OptionItem(), + Widget(classes="content"), + id="sidebar", + ) + + 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)]) + + async def on_key(self, event) -> None: + await self.dispatch_key(event) + + def key_d(self): + self.dark = not self.dark + + 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()) diff --git a/docs/examples/introduction/clock.py b/docs/examples/introduction/clock02.py similarity index 100% rename from docs/examples/introduction/clock.py rename to docs/examples/introduction/clock02.py diff --git a/docs/index.md b/docs/index.md index a751fcf0e..b04d46b1e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,43 @@ # 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 +Textual is a Python framework for building applications that run within your terminal. Known as TUIs (Text User Interfaces), such applications have a multitude of benefits: -Textual is a Python framework which you can install via Pypi. +- **Quick to develop:** Textual is a modern Python API. +- **Low requirements:** Run Textual apps anywhere with a Python interpreter, even single-board computers. +- **Cross platform:** The same code will run on Linux, Windows, MacOS and more. +- **Remote:** Fully featured UIs can run over SSH. +- **CLI integration:** Textual apps integrate with your shell and other CLI tools. + +Textual TUIs are quick and easy to build with pure Python (not to mention _fun_). + + + +```{.textual path="docs/examples/demo.py" columns=100 lines=48} + +``` + +## Installation + +You can install Textual via Pypi. + +If you plan on developing Textual apps, then you can 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 app + +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. See the help for more details: + +```python +textual --help +``` diff --git a/docs/introduction.md b/docs/introduction.md index 0cd199918..0bf82ca74 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ 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 good idea of the steps involved in creating an application with Textual. ## Pre-requisites @@ -12,7 +12,7 @@ This is a very gentle introduction to creating Textual applications. By the end ## 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. +Let's looks at the simplest possible Textual app. 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: @@ -26,7 +26,7 @@ Enter the following command to run the application: python intro01.py ``` -The command prompt should disappear and you will see a blank screen. It will look something like the following: +The command prompt should disappear and you will see a blank screen: ```{.textual path="docs/examples/introduction/intro01.py"} @@ -34,7 +34,7 @@ The command prompt should disappear and you will see a blank screen. It will loo Hit ++ctrl+c++ to exit and return to the command prompt. -### The code +### Application mode The first step in all Textual applications is to import the `App` class from `textual.app` and extend it: @@ -54,15 +54,15 @@ The `run` method will put your terminal in to "application mode" which disables ## Handling Events -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. +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 keys, moving the mouse, resizing the terminal, etc. -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. +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. !!! 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. -The next example demonstrates handling events. Try running `intro02.py` in the `docs/examples/introduction`: +The next example demonstrates handling events. Try running `intro02.py` in the `docs/examples/introduction` directory: ```python title="intro02.py" --8<-- "docs/examples/introduction/intro02.py" @@ -82,17 +82,17 @@ If you hit any of the number keys ++0++-++9++, the background will change color 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 first event handler to run is `on_mount`. The `Mount` is sent to your application immediately after entering application mode. +The first event handler to run is `on_mount`. The `Mount` event is sent to your application immediately after entering application mode. ```python hl_lines="19 20" title="intro02.py" --8<-- "docs/examples/introduction/intro02.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. +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 style properties which define how your app looks. We will explore those later. !!! note - 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. + You may have noticed there is no function call to repaint the screen in this example. Textual is generally smart enough to refresh the screen automatically. The second event handler will receive `Key` events whenever you press a key on the keyboard: @@ -100,7 +100,7 @@ The second event handler will receive `Key` events whenever you press a key on t --8<-- "docs/examples/introduction/intro02.py" ``` -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. +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()` to plays your terminal's bell sound. !!! note @@ -122,15 +122,15 @@ Here's what you will see if you run this code: ``` -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: +This script imports `App` and also the `Widget` class from `textual.widget`. To create a Clock widget we extend from the Widget base class: ```python title="clock01.py" hl_lines="7 8 9 10 11 12 13" --8<-- "docs/examples/introduction/clock01.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. +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 center align its contents. If you size the terminal you should see that the text remains centered. -The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` method once per second. +The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` method once per second, so our clock remains up-to-date. When Textual refreshes a widget it calls it's `render` method: @@ -138,9 +138,9 @@ When Textual refreshes a widget it calls it's `render` method: --8<-- "docs/examples/introduction/clock01.py" ``` -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 Clock's `render` method uses the datetime module to format the current date and time. It returns a string, but can also return a [Rich](https://github.com/Textualize/rich) _renderable_. Don't worry if you aren't familiar with Rich, we will cover that later. -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: +Before a Widget can be displayed, it must first be mounted on the app. This is typically done within the application's Mount handler: ```python title="clock01.py" hl_lines="17 18" --8<-- "docs/examples/introduction/clock01.py" @@ -149,3 +149,23 @@ Before a Widget can be displayed, it must first be mounted on the app. This is t In the case of the clock application, we call `mount` with an instance of the `Clock` widget. That's all there is to this Clock example. It will display the current time until you hit ++ctrl+c++ + +## Compose + +Mounting "child" widgets from from an `on_mount` event is such a common pattern that Textual offers a convenience method to do that. + +If you implement a `compose()` method on your App or Widget, Textual will invoke it to get your "sub-widgets". This method should return an _iterable_ such as a list, but you may find it easier to use the `yield` statement to turn it in to a Python generator: + +```python title="clock02.py" hl_lines="17 18" +--8<-- "docs/examples/introduction/clock02.py" +``` + +Here's the clock example again using `compose()` rather than `on_mount`. Any Widgets yielded from this method will be mounted on to the App or Widget. In this case we mount our Clock widget as before. + +More sophisticated apps will likely yield multiple widgets from `compose()`, and many widgets will also yield child widgets of their own. + +## Next Steps + +We've seen how Textual apps can respond to events, and how to mount widgets which are like mini-applications in their own right. These are key concepts in Textual which you can use to build more sophisticated apps. + +The Guide covers this in much more detail, and describes how arrange widgets on the screen and connect them with the core logic of your application. From f7ff893de85a7db642e085379d17676e2163629e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 10 Aug 2022 17:49:45 +0100 Subject: [PATCH 02/73] more docs --- docs/examples/guide/structure.py | 30 ++++++++++++++++++++++++++ docs/guide/CSS.md | 5 +++++ docs/guide/devtools.md | 37 ++++++++++++++++++++++++++++++++ docs/guide/events.md | 5 +++++ docs/guide/layout.md | 0 docs/images/test.excalidraw.svg | 16 ++++++++++++++ docs/index.md | 10 ++++++--- docs/introduction.md | 20 ++++++++++------- docs/stylesheets/custom.css | 8 +++++++ mkdocs.yml | 2 ++ 10 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 docs/examples/guide/structure.py create mode 100644 docs/guide/CSS.md create mode 100644 docs/guide/devtools.md create mode 100644 docs/guide/events.md create mode 100644 docs/guide/layout.md create mode 100644 docs/images/test.excalidraw.svg diff --git a/docs/examples/guide/structure.py b/docs/examples/guide/structure.py new file mode 100644 index 000000000..d1766420a --- /dev/null +++ b/docs/examples/guide/structure.py @@ -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() diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md new file mode 100644 index 000000000..41cdda82a --- /dev/null +++ b/docs/guide/CSS.md @@ -0,0 +1,5 @@ +# Textual CSS + +Textual uses a web-technology known as CSS (Cascading Style Sheets) to apply styles to widgets. If you are already familiar with CSS you will be right at home, but don't worry if you aren't; Textual CSS is far simpler than its web counterpart. + +## CSS Basics diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md new file mode 100644 index 000000000..ff4dd5356 --- /dev/null +++ b/docs/guide/devtools.md @@ -0,0 +1,37 @@ +# Textual 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. + +## 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 a Application 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 +``` + +## 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 typically overwrite application content, which generally makes an unreadable mess. Fortunately Textual supplies a debug console of it's own which has some super helpful features. + +To use the console, open up 2 console emulators. In the first one, run the following: + +```bash +textual console +``` + +In the other console, run your application using `textual run` and the `--dev` switch: + +```bash +textual run my_app.py --dev +``` + +You should notice that the console will display information regarding the running application, such as events which are sent. + +Anything you `print` from your application will be displayed in the console window. You can also call the `log()` method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`. diff --git a/docs/guide/events.md b/docs/guide/events.md new file mode 100644 index 000000000..50d552bbe --- /dev/null +++ b/docs/guide/events.md @@ -0,0 +1,5 @@ +## Events + +
+--8<-- "docs/images/test.excalidraw.svg" +
diff --git a/docs/guide/layout.md b/docs/guide/layout.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/test.excalidraw.svg b/docs/images/test.excalidraw.svg new file mode 100644 index 000000000..2e1b19e21 --- /dev/null +++ b/docs/images/test.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXFtz2shcdTAwMTJ+z69weV92q4wy90uqTp3y3Y4vcYJ9nOTslkuRXHUwMDA0yCCJlYSxSeW/n1x1MDAxNnbQXHUwMDA1JFxiXHUwMDE3m+xcdFx1MDAwZlx1MDAwZcxcZjOtnv66v+lp8vXVxsZm/NB1Nt9sbDr3ltlx7dDsb24l7XdOXHUwMDE4uYFcdTAwMGZdZPg5XG56oTVcdTAwMWPZiuNu9Ob1a89cZttO3O2YlmPcuVHP7ERxz3ZcdTAwMDPDXG68127seNG/k7/npuf8q1x1MDAxYnh2XHUwMDFjXHUwMDFh6VwiNcd24yB8XFzL6Tie48dcdTAwMTHM/l/4vLHxdfg3I13oWLHpNzvO8Fx1MDAwYsOujIBaXHUwMDE0W89cdTAwMDN/KCwnjEmmhVx1MDAxY1xycKM9WC52bOhtgMhO2pM0bV5uXHUwMDFmuHyv6+1aonvpfMDdnbdHrXTVhtvp1OOHzqMmTKvVXHUwMDBiMzJFcVx1MDAxOLSda9eOW9CPXHUwMDBi7aPvRVx1MDAwMSgh/VZcdTAwMTj0mi3fiaLcd4KuabnxQ9KG0Kj1UVx0bzbSlnv4pKSBXHUwMDA0RopwgqjGio16k+9cdTAwMGJuXGLOhUBaXHUwMDEwipDSXHUwMDA1uXaDXHUwMDBl7Fx1MDAwNMj1XHUwMDFiXHUwMDFhvlLJvphWu1x04vl2OoZZwmnwdEz/6WlcdOZcdTAwMDaC1ZEgfNTZctxmK4ZeRrChcKYncoZboFx1MDAxMGVcdTAwMTjJVKRkue6xPbSFv4pcbmyZYfdJUZtR8iEjalwi5X7RkLLGlNnk652bz5Z3KFx1MDAwZq/9vVx1MDAwYvfCa+/ti4fRXFw5yzPDMOhvjnq+Pb1LRet1bfPRnDCoXHUwMDE4M0mpRCx9oI7rt6HT73U6aVtgtVNcdTAwMGJcdTAwMWO2ftuaw/I55mWWT5hgiEvFZ7b8zifvuP+f29bhwfZpZHduoqbZqJdYfsGCZ7R7O4iT5Zdo+Fx1MDAxOGEjUTqTXHUwMDE4UUmy+kgmoFJcdTAwMWJMcc1cdTAwMDFcdTAwMWNcdTAwMTgrLVx1MDAxN7L8xvA1bvmAN1x1MDAwM1OSM++R4UOfkmNmjzVcdTAwMDUsaime2fC3z7vyulbf/9gjtUE9Olx1MDAxYSjW6E02/Ni5jzN2v/Vr2tJpc6O3Zl3w5XxKTs6MO2FcdTAwMDJcdTAwMTVbv7tcdTAwMTOssVx1MDAwNDvHJMXpNH9SreY1jaRcdTAwMTiJKofCkDY0oWwpXHUwMDBlJVx1MDAwZU0/6pohXHUwMDAwd4JTkWlcdTAwMTBcdTAwMWY5XHUwMDEzQsdcdTAwMWNcdGNcdTAwMDKEwSztWZojWaY9pttcdTAwMWX4cd1cdTAwMWRcZkNcdTAwMTTKtVx1MDAxZZie23nI7dzQUEHS3Y5rtX//I6vNyIFFh5Ypc8O3O24zseVNXHUwMDBiXHUwMDFlw1x0c2ZcdTAwMWW7QD9HXHUwMDAzPNe2s2HVXHUwMDAyXHUwMDE5TJgzPJ4lXHUwMDFjXHUwMDA2odt0fbNzWVx1MDAxMLFcdTAwMTJ4tmt6gW9Pwp6kpaFcdTAwMWMzpVx04lx1MDAxOM+OvV3zdK/zaad5Ojij5Gj/4vYqeLDWXHUwMDFke4pcdTAwMWFcdTAwMTJcdTAwMTGqIFxcM8SIIDnoccpcclwiXHUwMDE1XHUwMDE1QmHOJCeLQO83zE3zy4RYTjJcdTAwMTBcdTAwMWLBXHUwMDBlc1iaXHUwMDE3qO13XHUwMDAwckIw1oiuXHUwMDAwgFVBjO/Qh1x1MDAwYuo1dunpgWbNt1x1MDAwN/fbf6PFY+PPMu20kDt5wfVcdTAwMGK5gpaGXFzBXGLCcHSlM6O+WsvrinpViXpcdTAwMDVcZlx1MDAxZXGKtYbeXHUwMDA1UV9cdTAwMTlwXHSegHwmingnICvSROLlw3194u1OL45cdTAwMDP/T1x1MDAxZqyh6cRcdTAwMTPDLkO5by0t7E6JXFzFsFuQtFx1MDAxMoOVR2mge6RcZolcdTAwMWN8vNSa6JmReGH3XHUwMDBl6M6gf3c5XHUwMDE43Hw6vYvPXHUwMDFh+4MlI9E2o9ZyT9NMXHUwMDEwQ1x1MDAwYo0pXHUwMDEyXHUwMDE0QizOIVx1MDAxMSNuMKmRhFxijCTYpFhcdTAwMTlcdTAwMTTnOVBDbFx1MDAxNpLoTL7jWcJwYH48OlwiXHUwMDE3ylxud+JQmuYh6tTdxePliqalvcG7XGLdxadcdTAwMTdn9t/xp+7JzUldzpb3elx0cVx1MDAxN0jTvYS4S592XHUwMDFhyZm84PqRXHUwMDFjnUlIXHUwMDE3zzZKXGKqMdKzZ+ir1bymLIdcdFnhWqkwXHUwMDE4XHUwMDE2/DlcXOusaVx1MDAwNU1cdCNcbqXb8lx1MDAwZqQ5L5FWmMJcZuZKK5ThXHUwMDBlk8xdT1x1MDAxMXj0kXLr2UnN/Ye9Njras9TliXN7ftmv+WK3M1x1MDAxZvCKNH6mK1x1MDAwMjJcdTAwMWbyXHUwMDEwMlx1MDAwMDJUTUJcdTAwMWWc6lxy0IKmijA4XyjEXHUwMDE3QV5pVoFiZEglleCMgN7lOFx1MDAwNFx1MDAxOTUoQnLSXHUwMDE1XHUwMDE5ZUpjLl9cdTAwMDCJakEkYmVcdTAwMTBKwMaUhpMspngyMOlcdTAwMTgwbaexXHUwMDEx+DfW0PhcdTAwMDGSja1ccudcdTAwMGWk/uPNn34yMGkyXHUwMDFjz41/f+T/xkVcYo/s2JOhTPW4bnOY7jiNuFx1MDAwMtFx0C2Dc04hRezO/1x1MDAxNJVof6Q6XHUwMDEz4C4zhlVAu6REXG6Wtf5pYK/mXj9cdTAwMDR2Vlx1MDAwNvaKKDtcdTAwMWbW4WRuKFxyXHUwMDA3dCmxUkk2IYd2XHUwMDA2KMNJUoUo0EY26i1cdTAwMTHrcHAxNFx1MDAwMVmFZsOXXHUwMDE4RzvF4HbgtFx1MDAwMkMkXHUwMDE4Jlx1MDAxYsO8IFxmXHUwMDFjNKVznGaGYj53+I1iM4x3XFzfdv0mdKYsXHUwMDE2kG71knVcdTAwMTFsXGbDmHJMmaCY8ZH2k4czu4mRXHUwMDFhikiKYVxcctGU2byNUYnIcUWNxtPgXHUwMDExmd50fHuKSFx1MDAxY3aHMVwiidBcdTAwMTJcXCxcdTAwMWVcdTAwMTOJIFx1MDAwM3OuMIfdSl7p8bIo0+S4PiZTx4zi3cBcdTAwMDPQg+YvXHUwMDAy14+LXHUwMDFhXHUwMDFlqnI7wXjLMcd8XGY8U7av6FxmusmM+UNr+m4jRcvww+j9X1tcdTAwMTNHU2RcYlxim1x1MDAwNKnEXHUwMDE0KeEq+/VcdTAwMWHlXHUwMDA2hm1cdTAwMDKrUYRKjjCfOiE1OOwuelImXHUwMDEy+fmQNFx1MDAxMCFcdTAwMTjiMWKJwqdNV460x/mKIEvne5X994dvSUlpuVx1MDAxMeZCSs4on73q4lx1MDAxM5f1xsByXCJzr/5esr633W/K5zvNzOdnXHUwMDEzzUtNhUZcZiVcdTAwMWEm+VvSXHUwMDFh0Fx1MDAxZMIoQIvDMYLTXHUwMDA172pK6y4yXHUwMDE5u9S1jmVsXHUwMDE52FxiXHUwMDAwWM9xQfNcIr40e5RRudaKo8xcdTAwMTlIbDadaFwiXHUwMDAxembeM5KlXHUwMDEyXpXZWEZKk7FcdTAwMTBcdTAwMThcdTAwMTKvrWZPXHUwMDE4nLv2W7ttdY/fXHUwMDFlXHUwMDFk9z427XpcdTAwMTddX6x7wkBx8L5cXFBEaUJoRFx1MDAxZWFCXHUwMDFhVEhcdTAwMDRndFxyzFbjXCLFesmSPiySolx1MDAwNMnlM6diV5UtXHUwMDFjXHUwMDFjbL/r35xfvcdXTbone6dcdTAwMGY1b8ak6fc4s0Q3UYmpivpcdTAwMDIlyqNcdTAwMTbTXHUwMDEwblx1MDAwNVwis0et41x1MDAwZjuHjasjtN9Q+/zgeF9fb3/21lx1MDAxZVJMXHUwMDFhXHUwMDA0XHUwMDFlXHUwMDE0I4jQmst8lSynwoCDqqSKsmHQWsnp4MfrXHUwMDBiNEXAcTh/5vqCL6fb6KRR+0y9a+fgrFY/uLliavFcdTAwMWP5//m00zL6k1x1MDAxN3w5h1LGgWV5Rp8gXHUwMDAydJyo2VNcctVaXldnwkmFM1x1MDAwMWdqXGLYXHUwMDExiMxCLepMfrxsYTyhT1x04kIh9Y/O59et0HH8Z07oT1x0hUVyPJJxfnJcZiGqXGZ8XHUwMDEwvjjnmMxOju2GXGI+P7iXtzfmg31tXtyG5u1RXHT45qv6Xz70hDKIguMnREyFpc4n+ZKafyUo04pcYqqUWugubak1/5zAyYVKulx1MDAwMlxmVlx1MDAwNbGT5lm/drh31eN3l/37y755f2e+XTw2/pp2XHUwMDE1004jXGKTXHUwMDE3XFxDglCeJKOE0iQszk5cdTAwMTCqtbyuXHUwMDA0QdJcbi/FMEoupfTISy10XHUwMDE5sYwrf1x1MDAwNa5cdM5cdTAwMTl4XHUwMDA1v0haXHUwMDFmhpC/aHtmpjAl1E4ubczIOj9jwFx1MDAxNJVcdTAwMTdcdTAwMDIgzZRcdTAwMTCEzl5nbHnv2uK8fflBvP9cdTAwMTjX1Oll6513+Fx1MDAxM1Q3MoNzklxcXHUwMDA3XGJMeeZcdTAwMTczQ9qguCGRgsM2eKfkyn11eJyDOihcdPRcdTAwMWTrVVx1MDAxNFx1MDAwMVSFN1prvI/FXHSY+cdcdTAwMGb9Qe3Q69LPrcWj5qqmXVF144rEXVW+8ifbtGVPO41BTV5w/Vx1MDAxOFx1MDAxNCa69Fx1MDAwZVx1MDAwNIOL0ljMzqCqtbymXGaKXHRd4bBcdTAwMTlcdTAwMTZcdTAwMDZmXFw9g8OejUBJrSWRmv9cIlCrXCJQU3jHYlx1MDAwNKq0rFxuXHUwMDEzXFxcbkSJwDLZXHUwMDBmUKfqy6Q1LatcdTAwMTKaXHUwMDFiYGZcIvlcdCrwXHUwMDEwVKyqwlx1MDAwNsFMSUE5YZyzxTIuK62r4oiQ5P+EmePXW+tQV1UsXHUwMDA1mlLeXHUwMDA0XHUwMDBiI6ZcdTAwMThcdTAwMDFcdTAwMWIlmVx1MDAwM+jGqLqJXHUwMDE4VFx1MDAwMC3mICBcdTAwMDVBOctcZslVN02+Qn9cdTAwMWH8q7rpccL1rG569bTCptnt1mMwzJG1gO279lNcdTAwMDRIt2HzznX6O+Wp0FdPXHUwMDFinvgqJ9mMr99efftcdTAwMWZFQFx1MDAxMlx1MDAxYyJ9 + + + + Click()ButtonWidgetClick()def on_click(self, event): self.emit(Button.Pressed)MessagesScreen()Button.Pressed()Button.Pressed() \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index b04d46b1e..2fb8bc20f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,11 @@ Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. Built with ❤️ by [Textualize.io](https://www.textualize.io) -Textual is a Python framework for building applications that run within your terminal. Known as TUIs (Text User Interfaces), such applications have a multitude of benefits: +
+ +Textual is a Python framework for building applications that run within your terminal. + +Such text-based applications have a number of benefits: - **Quick to develop:** Textual is a modern Python API. - **Low requirements:** Run Textual apps anywhere with a Python interpreter, even single-board computers. @@ -20,7 +24,7 @@ Textual TUIs are quick and easy to build with pure Python (not to mention _fun_) ## Installation -You can install Textual via Pypi. +You can install Textual via PyPi. If you plan on developing Textual apps, then you can install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. @@ -28,7 +32,7 @@ If you plan on developing Textual apps, then you can install `textual[dev]`. The pip install textual[dev] ``` -If you only plan on running Textual apps, then you can drop the `[dev]` part: +If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: ```bash pip install textual diff --git a/docs/introduction.md b/docs/introduction.md index 0bf82ca74..6721a5d7e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -54,9 +54,9 @@ The `run` method will put your terminal in to "application mode" which disables ## Handling Events -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 keys, moving the mouse, resizing the terminal, etc. +Most real-world applications will need 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 keys, moving the mouse, resizing the terminal, etc. -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. +Each event type is represented by an instance of one of a number of Event objects. These event objects may contain additional information regarding 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. !!! note @@ -82,17 +82,21 @@ If you hit any of the number keys ++0++-++9++, the background will change color 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. +!!! note + + Event class names are transformed to _camel case_ when used in event handlers. So the `MouseMove` event will be handled by a method called `on_mouse_move`. + The first event handler to run is `on_mount`. The `Mount` event is sent to your application immediately after entering application mode. ```python hl_lines="19 20" title="intro02.py" --8<-- "docs/examples/introduction/intro02.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 style properties which define how your app looks. We will explore those later. +The above `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 style properties which define how your app looks. We will explore those later. !!! note - You may have noticed there is no function call to repaint the screen in this example. Textual is generally smart enough to refresh the screen automatically. + You may have noticed there is no function call to repaint the screen in this example. Textual is smart enough to know when the screen needs to be updated, and will do it automatically. The second event handler will receive `Key` events whenever you press a key on the keyboard: @@ -104,7 +108,7 @@ This method has an `event` positional argument which will receive the event obje !!! note - 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. + Every event has a corresponding `Event` object. Textual will call your event handler with an event object only 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. ## Widgets @@ -122,13 +126,13 @@ Here's what you will see if you run this code: ``` -This script imports `App` and also the `Widget` class from `textual.widget`. To create a Clock widget we extend from the Widget base class: +This script imports `App` as before and also the `Widget` class from `textual.widget`. To create a Clock widget we extend from the Widget base class. ```python title="clock01.py" hl_lines="7 8 9 10 11 12 13" --8<-- "docs/examples/introduction/clock01.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 center align its contents. If you size the terminal you should see that the text remains centered. +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 mount handler (`Clock.on_mount`) sets `styles.content_align` to `("center", "middle")` which tells Textual to center align its contents horizontally and vertically. If you size the terminal you should see that the text remains centered. The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` method once per second, so our clock remains up-to-date. @@ -168,4 +172,4 @@ More sophisticated apps will likely yield multiple widgets from `compose()`, and We've seen how Textual apps can respond to events, and how to mount widgets which are like mini-applications in their own right. These are key concepts in Textual which you can use to build more sophisticated apps. -The Guide covers this in much more detail, and describes how arrange widgets on the screen and connect them with the core logic of your application. +The Guide covers this in much more detail and describes how arrange widgets on the screen and connect them with the core logic of your application. diff --git a/docs/stylesheets/custom.css b/docs/stylesheets/custom.css index 0ccc3b2eb..5df58074a 100644 --- a/docs/stylesheets/custom.css +++ b/docs/stylesheets/custom.css @@ -12,3 +12,11 @@ h3 .doc-heading code { font-family: "Roboto Mono", "SFMono-Regular", Consolas, "Courier New", Courier, monospace; } + +body[data-md-color-primary="black"] .excalidraw svg { + filter: invert(100%) hue-rotate(180deg); +} + +body[data-md-color-primary="black"] .excalidraw svg rect { + fill: transparent; +} diff --git a/mkdocs.yml b/mkdocs.yml index d35b2d676..73021761a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,8 @@ nav: - "introduction.md" - Guide: - "guide/guide.md" + - "guide/events.md" + - "guide/devtools.md" - "actions.md" - Events: - "events/mount.md" From a1c0b173bd6a0cbfe3d5346353e0e54a5b8ef912 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 13 Aug 2022 21:39:31 +0100 Subject: [PATCH 03/73] more docs and diagrams --- docs/examples/{demo.css => basic.css} | 35 ++++++++---- docs/examples/demo.py | 47 +++++++++++----- docs/examples/guide/dom1.py | 8 +++ docs/examples/guide/dom2.py | 11 ++++ docs/examples/guide/dom3.py | 17 ++++++ docs/examples/guide/dom4.css | 0 docs/examples/guide/dom4.py | 18 ++++++ docs/examples/styles/background.css | 14 +++++ docs/examples/styles/background.py | 19 +------ docs/examples/styles/border.css | 25 +++++++++ docs/examples/styles/border.py | 30 +--------- docs/examples/styles/box_sizing.css | 17 ++++++ docs/examples/styles/box_sizing.py | 23 +------- docs/examples/styles/color.css | 13 +++++ docs/examples/styles/color.py | 18 +----- docs/examples/styles/display.css | 12 ++++ docs/examples/styles/display.py | 17 +----- docs/examples/styles/height.css | 5 ++ docs/examples/styles/height.py | 10 +--- docs/examples/styles/margin.css | 10 ++++ docs/examples/styles/margin.py | 17 +----- docs/examples/styles/offset.css | 31 ++++++++++ docs/examples/styles/offset.py | 36 +----------- docs/guide/CSS.md | 81 ++++++++++++++++++++++++++- docs/images/dom1.excalidraw.svg | 16 ++++++ docs/images/dom2.excalidraw.svg | 16 ++++++ docs/images/dom3.excalidraw.svg | 16 ++++++ docs/styles/background.md | 6 ++ docs/stylesheets/custom.css | 4 ++ mkdocs.yml | 1 + 30 files changed, 385 insertions(+), 188 deletions(-) rename docs/examples/{demo.css => basic.css} (93%) create mode 100644 docs/examples/guide/dom1.py create mode 100644 docs/examples/guide/dom2.py create mode 100644 docs/examples/guide/dom3.py create mode 100644 docs/examples/guide/dom4.css create mode 100644 docs/examples/guide/dom4.py create mode 100644 docs/examples/styles/background.css create mode 100644 docs/examples/styles/border.css create mode 100644 docs/examples/styles/box_sizing.css create mode 100644 docs/examples/styles/color.css create mode 100644 docs/examples/styles/display.css create mode 100644 docs/examples/styles/height.css create mode 100644 docs/examples/styles/margin.css create mode 100644 docs/examples/styles/offset.css create mode 100644 docs/images/dom1.excalidraw.svg create mode 100644 docs/images/dom2.excalidraw.svg create mode 100644 docs/images/dom3.excalidraw.svg diff --git a/docs/examples/demo.css b/docs/examples/basic.css similarity index 93% rename from docs/examples/demo.css rename to docs/examples/basic.css index fef9e4248..ab50375b6 100644 --- a/docs/examples/demo.css +++ b/docs/examples/basic.css @@ -16,21 +16,40 @@ App > Screen { background: $surface; color: $text-surface; - layers: sidebar; + layers: base sidebar; color: $text-background; background: $background; layout: vertical; + + overflow: hidden; } +#tree-container { + overflow-y: auto; + height: 20; + margin: 1 2; + 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: 12; + height: 24; } #sidebar { @@ -38,6 +57,7 @@ DataTable { background: $panel; dock: left; width: 30; + margin-bottom: 1; offset-x: -100%; transition: offset 500ms in_out_cubic; @@ -71,14 +91,7 @@ DataTable { content-align: center middle; } -#header { - color: $text-secondary-background; - background: $secondary-background; - height: 1; - content-align: center middle; - - dock: top; -} + Tweet { @@ -103,7 +116,7 @@ Tweet { overflow-x: auto; overflow-y: scroll; margin: 1 2; - height: 20; + height: 24; align-horizontal: center; layout: vertical; } diff --git a/docs/examples/demo.py b/docs/examples/demo.py index ab7779c1a..d4e583323 100644 --- a/docs/examples/demo.py +++ b/docs/examples/demo.py @@ -6,7 +6,8 @@ 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 +from textual.widgets import Static, DataTable, DirectoryTree, Header, Footer +from textual.layout import Container CODE = ''' from __future__ import annotations @@ -103,24 +104,23 @@ class Success(Widget): return Text("This is a success message", justify="center") -class BasicApp(App, css_path="demo.css"): +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')") + 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() - def compose(self) -> ComposeResult: table = DataTable() self.scroll_to_target = Tweet(TweetBody()) - yield Static( - Text.from_markup( - "[b]This is a [u]Textual[/u] app, running in the terminal" - ), - id="header", - ) - yield from ( + yield Container( Tweet(TweetBody()), Widget( Static( @@ -130,6 +130,7 @@ class BasicApp(App, css_path="demo.css"): classes="scrollable", ), table, + Widget(DirectoryTree("~/projects/textual"), id="tree-container"), Error(), Tweet(TweetBody(), classes="scrollbar-size-custom"), Warning(), @@ -141,7 +142,6 @@ class BasicApp(App, css_path="demo.css"): Tweet(TweetBody(), classes="scroll-horizontal"), Tweet(TweetBody(), classes="scroll-horizontal"), ) - yield Widget(id="footer") yield Widget( Widget(classes="title"), Widget(classes="user"), @@ -151,6 +151,7 @@ class BasicApp(App, css_path="demo.css"): Widget(classes="content"), id="sidebar", ) + yield Footer() table.add_column("Foo", width=20) table.add_column("Bar", width=20) @@ -162,12 +163,32 @@ class BasicApp(App, css_path="demo.css"): 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 key_d(self): + 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() diff --git a/docs/examples/guide/dom1.py b/docs/examples/guide/dom1.py new file mode 100644 index 000000000..10b05c591 --- /dev/null +++ b/docs/examples/guide/dom1.py @@ -0,0 +1,8 @@ +from textual.app import App + + +class ExampleApp(App): + pass + + +app = ExampleApp() diff --git a/docs/examples/guide/dom2.py b/docs/examples/guide/dom2.py new file mode 100644 index 000000000..8d5f900c0 --- /dev/null +++ b/docs/examples/guide/dom2.py @@ -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() diff --git a/docs/examples/guide/dom3.py b/docs/examples/guide/dom3.py new file mode 100644 index 000000000..36ed06b1f --- /dev/null +++ b/docs/examples/guide/dom3.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.layout import Container +from textual.widgets import Header, Footer, Static + + +class ExampleApp(App): + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Container( + Static(id="widget1"), + Static(id="widget2"), + Static(id="widget3"), + ) + + +app = ExampleApp() diff --git a/docs/examples/guide/dom4.css b/docs/examples/guide/dom4.css new file mode 100644 index 000000000..e69de29bb diff --git a/docs/examples/guide/dom4.py b/docs/examples/guide/dom4.py new file mode 100644 index 000000000..97309f385 --- /dev/null +++ b/docs/examples/guide/dom4.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.layout import Container +from textual.widget import Widget +from textual.widgets import Header, Footer + + +class ExampleApp(App): + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Container( + Widget(id="widget1"), + Widget(id="widget2"), + Widget(id="widget3"), + ) + + +app = ExampleApp() diff --git a/docs/examples/styles/background.css b/docs/examples/styles/background.css new file mode 100644 index 000000000..27f8649d2 --- /dev/null +++ b/docs/examples/styles/background.css @@ -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%); +} diff --git a/docs/examples/styles/background.py b/docs/examples/styles/background.py index 41f5d7e48..cef306ddc 100644 --- a/docs/examples/styles/background.py +++ b/docs/examples/styles/background.py @@ -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") diff --git a/docs/examples/styles/border.css b/docs/examples/styles/border.css new file mode 100644 index 000000000..762430dc8 --- /dev/null +++ b/docs/examples/styles/border.css @@ -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; +} diff --git a/docs/examples/styles/border.py b/docs/examples/styles/border.py index 2aa7af768..4dbc8ef4f 100644 --- a/docs/examples/styles/border.py +++ b/docs/examples/styles/border.py @@ -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") diff --git a/docs/examples/styles/box_sizing.css b/docs/examples/styles/box_sizing.css new file mode 100644 index 000000000..38f55482d --- /dev/null +++ b/docs/examples/styles/box_sizing.css @@ -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; +} diff --git a/docs/examples/styles/box_sizing.py b/docs/examples/styles/box_sizing.py index bc264d6c8..32fc56c6b 100644 --- a/docs/examples/styles/box_sizing.py +++ b/docs/examples/styles/box_sizing.py @@ -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") diff --git a/docs/examples/styles/color.css b/docs/examples/styles/color.css new file mode 100644 index 000000000..b5552495a --- /dev/null +++ b/docs/examples/styles/color.css @@ -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%) +} diff --git a/docs/examples/styles/color.py b/docs/examples/styles/color.py index 415dfbc28..26543b0a0 100644 --- a/docs/examples/styles/color.py +++ b/docs/examples/styles/color.py @@ -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") diff --git a/docs/examples/styles/display.css b/docs/examples/styles/display.css new file mode 100644 index 000000000..14bbf6fc4 --- /dev/null +++ b/docs/examples/styles/display.css @@ -0,0 +1,12 @@ +Screen { + background: green; +} +Static { + height: 5; + background: white; + color: blue; + border: heavy blue; +} +Static.remove { + display: none; +} diff --git a/docs/examples/styles/display.py b/docs/examples/styles/display.py index 463c76759..1e68c6e33 100644 --- a/docs/examples/styles/display.py +++ b/docs/examples/styles/display.py @@ -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") diff --git a/docs/examples/styles/height.css b/docs/examples/styles/height.css new file mode 100644 index 000000000..5baabb27d --- /dev/null +++ b/docs/examples/styles/height.css @@ -0,0 +1,5 @@ +Screen > Widget { + background: green; + height: 50%; + color: white; +} diff --git a/docs/examples/styles/height.py b/docs/examples/styles/height.py index f94baeeeb..00e3963c4 100644 --- a/docs/examples/styles/height.py +++ b/docs/examples/styles/height.py @@ -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") diff --git a/docs/examples/styles/margin.css b/docs/examples/styles/margin.css new file mode 100644 index 000000000..e1b01fa03 --- /dev/null +++ b/docs/examples/styles/margin.css @@ -0,0 +1,10 @@ +Screen { + background: white; + color: black; +} + +Static { + margin: 4 8; + background: blue 20%; + border: blue wide; +} diff --git a/docs/examples/styles/margin.py b/docs/examples/styles/margin.py index 6e5a3c59a..3e6129ead 100644 --- a/docs/examples/styles/margin.py +++ b/docs/examples/styles/margin.py @@ -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") diff --git a/docs/examples/styles/offset.css b/docs/examples/styles/offset.css new file mode 100644 index 000000000..d0a54a355 --- /dev/null +++ b/docs/examples/styles/offset.css @@ -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; +} diff --git a/docs/examples/styles/offset.py b/docs/examples/styles/offset.py index aee883375..d850b3778 100644 --- a/docs/examples/styles/offset.py +++ b/docs/examples/styles/offset.py @@ -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") diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 41cdda82a..f23e42b63 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -1,5 +1,82 @@ # Textual CSS -Textual uses a web-technology known as CSS (Cascading Style Sheets) to apply styles to widgets. If you are already familiar with CSS you will be right at home, but don't worry if you aren't; Textual CSS is far simpler than its web counterpart. +Textual uses CSS to apply style to widgets. If you have any exposure to web development you will have encountered CSS, bit don't worry if you haven't: this section will get you up to speed. -## CSS Basics +CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and the parts of a webpage to apply them to. In the case of Textual, the stylesheets apply styles to widgets. + +## 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. The DOM is essentially an arrangement of widgets in to a tree. + +Let's look at a super trivial Textual app. + +=== "dom1.py" + + ```python + --8<-- "docs/examples/guide/dom1.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/dom1.py"} + ``` + +When you run this app, you will have an instance of an app (ExampleAPP) in memory. The app class will also create a Screen object. In DOM terms, the Screen is a _child_ of the app. + +With the above example, the DOM will look like the following: + +
+--8<-- "docs/images/dom1.excalidraw.svg" +
+ +The above doesn't look much like a tree. Adding more widgets will create more _branches_ in the tree: + +=== "dom2.py" + + ```python + --8<-- "docs/examples/guide/dom2.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/dom2.py"} + ``` + +This examples adds a header and a footer widget, which makes our DOM look the following: + +
+--8<-- "docs/images/dom2.excalidraw.svg" +
+ +!!! 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 objects. If you were to print `app.screen.children` you would see something like `[Header(), Footer()]`. + +To further explore the DOM, lets add a few more levels. We are going to add a `textual.layout.Container` widget which (as the name suggests) is a container for other widgets. To that container we are going to add three `textual.widget.Widget` widgets. The `Widget` class is the base class for all widgets. Normally you would extend the `Widget` class to build a functional widget, but for our experiment that the base class will do. + +=== "dom3.py" + + ```python + --8<-- "docs/examples/guide/dom3.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/dom3.py"} + ``` + +You may notice that there is a scrollbar in the output now, and we see the text "Widget#widget1". We will explain why that is later. + +Here's the DOM created by the above code: + +
+--8<-- "docs/images/dom3.excalidraw.svg" +
+ +You'll notice that we defined the children of our container differently. The `Container` class, and most other widgets, will accept their children as positional arguments. These children are added to the DOM at the same time as their parents. + +```python hl_lines="10 11 12 13 14" +--8<-- "docs/examples/guide/dom3.py" +``` diff --git a/docs/images/dom1.excalidraw.svg b/docs/images/dom1.excalidraw.svg new file mode 100644 index 000000000..54634dc1d --- /dev/null +++ b/docs/images/dom1.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nM1Ya0/jOFx1MDAxNP3Or6i6X3YlXGKOY8fxSKtcdTAwMTXPpSywo1x1MDAwMVxyj9VcYrmJaT3Na1x1MDAxMpfHIP77XqdMXHUwMDFlbVxiZVx1MDAxN0ZEUZv4OtfX1+fc4+R+pdfr67tU9j/0+vLWXHUwMDE3oVxuMnHTXzXt1zLLVVx1MDAxMoNcdFx1MDAxN/d5Ms38oudY6zT/sL5cdTAwMWWJbFwidVx1MDAxYVxuX1rXKp+KMNfTQCWWn0TrSsso/8P8XHUwMDFliUj+niZRoDOrXHUwMDFhZE1cdTAwMDZKJ9lsLFx1MDAxOcpIxjpcdTAwMDfv/8B9r3df/NaiXHUwMDBilIiSOCi6XHUwMDE3hlp4njvfepTERaiUIVx1MDAwN3PqkbKDyrdhMC1cdTAwMDOwXkHAsrKYpn56sbN1pD45eiNcdTAwMWKyfNP5Nvy6d1WNeqXC8FjfhbM8XGJ/PM1kZc11lkzkqVxu9Fx1MDAxOOz2XFx7+VxcnkBcbqqnsmQ6XHUwMDFhxzLPXHUwMDFizySp8JW+M21cYpWtXCJcdTAwMWVcdTAwMTU+qpZbk1x1MDAwMeJamHmO5zpcdTAwMGV1XHUwMDEwqc23cECY5VLsXHUwMDEwh9G5mLaSXHUwMDEw1lx1MDAwMGL6XHUwMDA1XHUwMDE1R1x1MDAxNdVQ+JNcdTAwMTGEXHUwMDE2XHUwMDA3VVx1MDAxZlx1MDAwZvvcrs335sdMa1x1MDAwM46lXHUwMDFhjbVpxNjyXHUwMDEwcT1GZ75r+ZBF/m3P5pRcdTAwMTKMcWkxI6aDoFx1MDAwMMKX+fyNRZY+5qmfm5tatCbQnXlcdTAwMTTVkVRbY+dcIkv5vlx1MDAxYVxmvk7GfyX88CxcdTAwMWRcdTAwMGZcdTAwMGVLX1xy2Gl5q/ul4WG1y+2Ze1x1MDAxMm1cdTAwMGUv7evp9v6BPls7+8jRfrtbkWXJzfN+XHUwMDFiUawuO5HK7eNVlchpXHUwMDFhiFx1MDAxOfZt10XE5sjjXHUwMDBl4aU9VPFcdTAwMDSM8TRcZqu2xJ9UdFmpxbtA0kacdYba5CmG2thQXHUwMDE0XHUwMDEw4i1N0e7le69cdTAwMTSldidFObeAXG6GLP+HoTpcdTAwMTNxnopcZljQwlLWxlK+wErmeraDXFxcdTAwMWK9Piu7kMihOr1cdTAwMDSJ1YInsT5W31x1MDAwYjS5XHUwMDE2hWKEsIsw41x1MDAxY1HW6LUrXCJcdTAwMTXeNdawgCxEvnMrojSUXHUwMDFiafrrb/VcdTAwMTTnXHUwMDEyXCIpXFyTxjNcdTAwMWKhXHUwMDFhXHUwMDE5aPd9mJvMXHUwMDFhqNdcbkSu7Fx1MDAxMKkgXGJrXGL0IVx1MDAxMFx1MDAwMT6zwTKCk2RqpGJcdTAwMTGetMXZScZM+nqGxVx1MDAxNkZS+qRmYlx1MDAwNCDkUJXdpVx1MDAxOXn+PdGXXyfDk+PRwblzQsefkvPLd89IXHUwMDE3W8hlhHheXHUwMDFiI1x1MDAxZNuxXHUwMDEwI9h+U0pSukhJj0GlmFx1MDAxM+tHalx1MDAwMqRcdTAwMTHFXHUwMDFlcV+fml3KXHUwMDE27MfnQ0rOXHUwMDBmtlx1MDAwMrw33tldu9zDn9+jYM78nu5/vr45INuHXHUwMDA3XHUwMDE5XHL+vMNTTLbdV/CLT4PB3u7EP/Q2iH1cdTAwMTKFf+/EXHUwMDE3ozdcdTAwMTX49sS/QOCZkVZe7a/eSOBcdPXmW3+UXHUwMDEzwinUYUKX34J3o+3dVlx1MDAxM9ZZTVxisZhdaNzbXHUwMDE1XHUwMDEz0lJMsDNfREBcdTAwMWFhXHUwMDE3wp2fKu8vx2GbvGPUaO2Q82M/kzJ+SspZo/+rSfkzMjgv5WWMnZSbVZJcdTAwMTbOMfxcdTAwMTTlQCZAv+FcXF7Bu0vxO+Wc43BcdTAwMGJe7lx1MDAxMXNaOYdcdTAwMTm1XFzOjYJcdTAwMTNujjdjXHUwMDFlslxid5vkLlx06Fx1MDAxMIsz7FJcdTAwMTcvyLlcdTAwMDebXuDGf9loXHUwMDE3wf1sJuZaZHpTxYGKR2CslFxm2OhPzbhryEKO7VLCoVx1MDAxNlKOXHTyylmb6YnU7D0tXHUwMDAyckBcdTAwMWPYg1x1MDAxYYxWr5+98kNQ19b4sXMpqX1cdTAwMTlcdTAwMDfPXHUwMDA2hThUX8Tg1Vx1MDAwME7KmLdcdTAwMTBcdTAwMTW24LWh2HVcdTAwMTXfKmyHPVx1MDAxNVY7zVx1MDAxN8JcbkWut5IoUlx1MDAxYdL/MVGxnk9zkc9ccsPvsVx1MDAxNMG8XHUwMDE1plW3zVx1MDAxN4LUeGzu3KqrXsWU4qa8/rLa2nttXHUwMDExweaoYbfysFL/NzuQwmdfpOmxXHUwMDA2pJVrXHUwMDAwYFbBY+GuJta/VvJms+Xb0lVxmDRcdTAwMTYpNCVHmundP6w8/Fx1MDAwYlxiYlx1MDAxObwifQ== + + + + ExampleApp()Screen() \ No newline at end of file diff --git a/docs/images/dom2.excalidraw.svg b/docs/images/dom2.excalidraw.svg new file mode 100644 index 000000000..bd636267f --- /dev/null +++ b/docs/images/dom2.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1aa0/bSFx1MDAxNP3Or0DZL7tS4877UWm1glx1MDAxNpZ3aWFcdTAwMDNlVVWuPSReP2s7QKj633dsgu28XHUwMDFjXHUwMDEzXHUwMDEyNpXWQiGZmdy5nrnnzLk3/r6xudlKXHUwMDA3kWq92WypO8v0XHUwMDFjOzZvW6+y9lx1MDAxYlx1MDAxNSdOXHUwMDE46C6Uf07CfmzlI3tpXHUwMDFhJW9ev/bN2FVp5JmWMm6cpG96Sdq3ndCwQv+1kyo/+SN7PTF99XtcdTAwMTT6dlx1MDAxYVx1MDAxYuUkbWU7aVx1MDAxOD/MpTzlqyBNtPW/9efNze/5a8U72zH9MLDz4XlHxT0hx1tPwiB3XHUwMDE1Ulx1MDAwNLEkklx1MDAxNFx1MDAwM5zknZ4sVbbuvdZcdTAwMGWrsidralxyTi//7Jn/qItLwY5Odq7hh17nvJz12vG8s3TgPayDafX6sSp7kzRcdTAwMGVddeHYaS+bfKy9+F5cdTAwMTLqJSi/XHUwMDE1h/1uL1BJMvKdMDItJ1x1MDAxZOg2XG6KRjPo5ibKlrtsXHUwMDAxIDVcdTAwMTBcdTAwMTdYMIwpXHUwMDA2RJS3m31fSINRhFx05nTMo7ehp3dAe/RcdTAwMGLIr9Knr6bldrVjgV2OXHUwMDExyJKwcre3j/dZma+nnG4vzVx1MDAxYVx1MDAxMTJcdTAwMDQgTHD6YLuyXHUwMDFhKl99yCDjknAhip5sxmjfzsPg8/jq9cw4XHUwMDFhrlIryT5UvM1cdTAwMWPdXHUwMDE5j6FqXHUwMDFjVXbY/bgtrlx1MDAwZTBoR9euc9x7f+6eqm+FrZGgS9Vd2io6fryqM1x1MDAwYjto76vf+3JLOsHX+Pro+vh+f3u6WTOOw9umdpfu7sjoV00nLM1cdTAwMGXflfvTj2wzXHUwMDFkbilcdTAwMDNcdTAwMDTpXHUwMDAwpITgot9zXHUwMDAyV3dcdTAwMDZ9zyvbQsstMbhR8XdcdTAwMDL5I35WYVx1MDAwZsFM2DMuJEKYoca4r1/mNcU9XHUwMDAydbiHXHUwMDA0XHUwMDFhlOZcdTAwMTB8XHUwMDBl7tPYXGaSyIw1tqZgn0/Dvlx1MDAxY8c6oYBwKjFaPtSXXHUwMDE5h+V2h0F65tw/xJJBNcNcdTAwMDHEXHUwMDAw4lJcdTAwMDLKR0btmr7jXHJGdjBcdTAwMGZY7fnOnelHntqKol9/q65worQnuWky8p0tz+lmgd2y9L2peCTmU0efm8VcdTAwMDDfsW2vXHUwMDEyf5Z2xNQ24/0mZ1hcdTAwMTg7XScwvfNpftZCMVZW+lx1MDAxMIpT8EhcdTAwMTmcjcd87Vx1MDAwNGON8eh9ROdcdTAwMDcn51dXXHUwMDFjfGA+3btcbkkvXXc8YmhcdTAwMDDGXHRcdTAwMTFiXHUwMDFhXHUwMDFlXHUwMDExpVx1MDAwNuBcdTAwMDTBlVx1MDAwMpLSSUBcbq55YkxcdTAwMDA8XHUwMDFlwlx1MDAxNHLOKV9cdTAwMDEy6061I6ftfzlcdTAwMWFcdTAwMWO+9c47g0/3XHUwMDFkeb69dbK+h/DFQefm9oi8Oz6Kqf3nXHUwMDAw9Vx1MDAxMXnHlmBcdTAwMTdd2Pt7u651LLZcYjz3vfc7wVV3XHR2l76880TD9Fx0XHUwMDFiekturtt+6rZcdTAwMDdcdTAwMWbIXHUwMDE3c0dYd+q47y9hXHUwMDE1tlx1MDAwZb+ddtPww5dTR4pcdTAwMDO307uEnzrN7DZcdTAwMTE5XHUwMDE4gVx1MDAxMjUrXHUwMDEyOYTNXHUwMDE2OZhcbkkolOWIeaRaXHUwMDFmXHUwMDE260qqrJZUXHUwMDA1MzhcdTAwMDTymclNPaeSKZyKSmHxyKVQICQpYCtIaJZcdTAwMTmI01RcdTAwMGVcdTAwMDIjrTWq5syKlVxuZilcdTAwMWE+Mn5pimaOXHUwMDFhXHUwMDE4VzSFj7WYe8D8XHUwMDE00HExXHUwMDEzc1x1MDAxMFx1MDAxMEq0lm1eUKg/ktZcdTAwMTNzXHUwMDE4XGJDUlx1MDAwMTiejjnIXHImZSZkiMyulSFcdTAwMGZcdTAwMThEslFwXHUwMDE3XHUwMDAwxMSQXHUwMDFjMcrQpKrRniGh4bhcdTAwMDBcdTAwMTJz71x1MDAxNkWiwIwvgsQkNeN021x0bCfo6s7yLNNotPrZvG1gXHUwMDAwrNVcdTAwMWGRmlxmqURcdTAwMDSI4raz2zOjbGNcckKylIdSJlx0YkRWRlxmS2x1XHUwMDE5wnBwcai2VGDPdVxuSE2/gOtcZkn/UV5ip/Bcblx1MDAxOTp7ytVnXlx1MDAwN4KYz3JrOswn3PLMJH1cdTAwMWL6vpPq5T9ccp0gXHUwMDFkX+Z8PbcyfPeUaY/36tuq9o1cdTAwMTNBlFlcdTAwMWNVsOW7zVx1MDAxMir5h+L951dTR7cnQzi7KsFbWtio/l8oXHUwMDA3g1x1MDAwMM1OwpD2g2KEmlx1MDAxN0VOXHUwMDBmXHUwMDA3b4OrvnQv/Y8n9uG9+5f7z81/y1061uaQXHUwMDE31OSFMIJcdTAwMDRcdMr1a4XNM1x1MDAwM4RcdTAwMTBDY4Q9dldy0v84XHUwMDE304QlKIa0dOhFUjHn6F03OtlNXHUwMDBmLv0t92T70N/12zPU9/+p2NPtrmh5l252XoY3fcKG3j4jw3skxdnHblx1MDAwNqfKT0ArysQkJuOtXHUwMDE1ZoX64GWoeXmrfvvWlVkhrmVWzlxmJiBmkIFVM2uzjFxmccZYRqsvmpA9OVx1MDAxZZ+XkO1pXHUwMDE1o+JcdTAwMTdOyOYog/GErPDxXHUwMDE50oaBurSMIy2NafNSiJfsXHUwMDFlmGfx9o06oJ3j+z3w7XhcdTAwMWKsO1x1MDAwMDFhXHUwMDA2gZzAXHUwMDFjX9mvXHUwMDFiY9KGXHUwMDFiWId8MWBdlI3kOkmQ1VrWiyib40+nZF/sWIdcdTAwMDNcdTAwMTd+ci/aN1F/XHUwMDAw/1c2y1I2K1ren8XsPME0fcKG3q60dI04rXLNilx1MDAwNFx1MDAxM6SYjTdcdTAwMTeEzTHQvoAnKKb6/VtXwqawlrC5NCjEWFx1MDAwZVx1MDAwNdVcblx0u2FcdDvLPzlcdTAwMDGlmy8jmJ5cdTAwMTiPz1x1MDAxM0y7YZi+uGCaozfGXHUwMDA1U+FjLfRmVrBcdTAwMTma/UicoEBcdTAwMTJcZnDzXHUwMDEydn32tq7QXHUwMDAzwFx1MDAxMFRcYplVQ1x1MDAwNVx1MDAxNmhcdTAwMDR5WEslwXV+wIfIw6uDXHUwMDFlXHUwMDAyhqSMS0klg1JCMYlEgVxyqZNIJFx1MDAxONY+M4nGgUmAhFx1MDAxNEm0XHUwMDAwMJ9R0F48k5ld0G5Q8C2PuWqlmVBcdTAwMDBcdTAwMDGlXHUwMDAy6pVgXGLDyqjH6jdFXHUwMDE0wmH2KTBcdTAwMWVcdTAwMGWYX89cdTAwMWXxqT61XHUwMDE59YkhoONcZnFJXHUwMDAw1uGEJnyCyOA6USZMZ8Ukq1x1MDAxM6BcdKd+qmr27GDOrokwLu1tVP8/mc8gwLNcdI3RXGbmXFw011x1MDAxMvXqal1cdFxyXHUwMDBiXHUwMDAzScFcdTAwMDVikmnJUFx1MDAxZVRcdTAwMGaEpqVcdTAwMDTCNPuVnFxiQenqXGJNYoNBiHQ8M4xw5cHeks6kgVx1MDAwNOeSUo6ZoHLy2V/B9Z2QhTLCZ/HZokJj2XxcdTAwMDZcZk1j2lx1MDAxYlx1MDAwMfR2MSRk+ZxiwVx1MDAxZNyA+TOTXHUwMDEwPGzognxWrzxGfKJASL1OTGtcdTAwMDRcbinhXHUwMDEzLlx0gzJ9XGZcdTAwMDGYXHUwMDFmm1iIn5rNZlx1MDAwNXJ2TYTwLCrbXHUwMDE4mm+ZUXSW6ngrtkKHtGNcdTAwMGbVaXmPrVx1MDAxYkfdbk95vP46vzLBl69mxkIqu9PvPzZ+/Fx1MDAwYlx0sVx1MDAwYuIifQ== + + + + ExampleApp()Screen()Header()Footer() \ No newline at end of file diff --git a/docs/images/dom3.excalidraw.svg b/docs/images/dom3.excalidraw.svg new file mode 100644 index 000000000..d0ae9dc48 --- /dev/null +++ b/docs/images/dom3.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daVPbSFx1MDAxM/7Or6DYL/tWXHUwMDA17UzP1bNVW29xXHUwMDA2QjhcdTAwMTKShfDuVkrYXHUwMDAyO/hCXHUwMDE2V7b2v789XHUwMDAyLNmS5Vx1MDAwM8sxVXEl2JbGo9ZM99NP91x1MDAxY/pnaXl5JXroXHUwMDA0K78vr1x1MDAwNPdcdTAwMTW/Ua+G/t3KXHUwMDFid/w2XGK79XaLTkH8vdu+XHQrcclaXHUwMDE0dbq///Zb01x1MDAwZq+CqNPwK4F3W+/e+I1udFOtt71Ku/lbPVxumt3/ur9cdTAwMDd+M/ij025Wo9BLLrJcdTAwMWFU61E7fLxW0FximkEr6lLt/6Pvy8v/xH9T0lXrfrPdqsbF41x1MDAxMynxrFx1MDAxYzx60G7FonKJxnBcdTAwMDZK9UrUu5t0tSio0ulcdTAwMGKSOEjOuEMr65vXp+HOw1nrWMnt3T/h+Obcvk0ue1FvNI6jh8ZjQ/iV2k1cdTAwMTgkZ7tR2L5cbk7q1ajmrj5wvPe7bpvaIPlV2L65rLWCbrfvN+2OX6lHXHUwMDBmdEyx3kG/dVx1MDAxOVeRXHUwMDFjuY9cdTAwMGKAx4VcdTAwMDLFuJHKMpM0SPx77lx0xtFcdTAwMWFcdTAwMDFcXFx1MDAwMupBuTbaXHLqXGKS61x1MDAxN1x1MDAxNr9cdTAwMTLJzv3K1SWJ16omZVx1MDAxMCqWp+757vluUXhSXG5cdTAwMDC6jFx1MDAxNZZcdTAwMWG9V6RcdTAwMTbUL2uRK1x1MDAwM+Ahk1x1MDAxYY16vFRKlCDuXHUwMDEyroCD1Fx1MDAxYXXvjFx1MDAxM6CzW42V4+/BJq35Yeep6Va67ktKeCf31qBmpbUr1e2oP63Zb2entUq4y46Ozk4++Se6V1efKkbBfbTSO/Hvm6Jq9z/sfv+yfX9cdTAwMWGFm8Fq7bh7akBe5Ffrh2H7btx6S1x1MDAxMnfm1faVfjPuXHUwMDA1k2qfPiXdftOp+o/Gy7VmXHUwMDEySM2kXHUwMDE20DvfqLeu6GTrptFIjrUrV4m9L6XkzcBMn5xpjGF68GiCMVxmXHUwMDE1WIvjY0xxMy8qxphcIowhkPWA+kNyZl5cdTAwMGUyUei3ulx1MDAxZD8ky81cdTAwMDFcdTAwMWEzXHUwMDFhaCBcdTAwMGIsJJxcdTAwMDXGU85iZsAySj3FXHUwMDA06ploQbtcdTAwMTVcdTAwMWTXv8cqpj3FUTLQXGaMtUyZvlLbfrPeeOjr2FiPSfK1TufX/6Sbulx1MDAxYpBcYnGdqq/wWqN+6Vx1MDAxNH2lQjdcdTAwMTWEfTZcdTAwMTDVyWn3XG4069VqI6WPXHUwMDE1ksCnOsPdcfxnO6xf1lt+41OfgIU2XHUwMDE5XHUwMDA2lehRJ3NcZlNpXHUwMDFjapjM6SHTYmy7hPaHnVx1MDAwYnz43Dz/tLbbrFx1MDAxZNTU17ujRbdLbjxcdTAwMGJScNCPdtlnlkCqY0BcdTAwMTj6/+j7y7NKlWOGaDyyUimMypijRMtcdTAwMThyhbM3x1wiXHUwMDBm91FfXHUwMDA0b+/Mt9rHk+uT980zqbauXHUwMDFii+vnT979eXv3Xm7uv1x1MDAwZlX17Vx1MDAwM9yA3Fx1MDAxY+KQJ6pcdTAwMTdOqrs721eVfVxck/xTs3G41Tq7nEG9JTVvSdWa2/rO9vVcdTAwMTZvhltfq5vRLdxcdTAwMWU2ZtFcbpKFXHUwMDFk3rqtXHUwMDFjnGzvXHUwMDFk4MnmSXs/2ntRvaN4VH5cdTAwMDONKe75t+baUeNWc1x1MDAxNa2uV+3l3kH7/mE8ccfgZ1x1MDAxMlx1MDAxNSaQVVx1MDAxMj+TWlxyXHUwMDFlfXZcdTAwMDNgXHUwMDE5XHUwMDFhzVOEZJRcdTAwMWIo1rZcdTAwMDV1XHUwMDAzXHUwMDE0WVx1MDAxNbhcdTAwMDGJnqDusM/srDw3IPPYWOKCn+HfIChHZUpcYvNmqYx5bFxmWN/RXHUwMDAy9nVcXFx0g6A1jICZvvIzI2AjSMwgXHUwMDAx68lYaHePdp9jeGY4/VJGWqGNYWNcdTAwMWJesVx1MDAxN/0hhsfZSMuzykNOt2ryLI9cdTAwMWLrXHSXhXpcImCqNMtjnrSa2TTX6lx1MDAxOaCQnjWglYYsXHUwMDExU8KikFLD5JZcdTAwMThLN40lcqNcdTAwMTVcbmanscRu5IfRer1Vrbcu6WTiz8hcdTAwMWErN+66q8xjgmslXHRejFwiVGRJs7vb8zuu2yiGVFx1MDAxNEYqpa1cdTAwMDQtbarEUzqyKKB5KtxzrCtBqzpSKGZccumToUiO/imT2E5PKvAoyotcdHOcXHUwMDFk48JcZlx1MDAxMyvfzDNiNfxutNFuNutcdTAwMTE1/1G73opcdTAwMDabOW7PNWfftcCvXHUwMDBlnqXbSp9cdTAwMWJcdTAwMDSCjquxn3Qnn5ZcdTAwMTNLib/0Pv/9Jrf0alaF3SulvElcckvp96lcIkfOJVx1MDAxZjzc41xm2mpkXG5cdTAwMTJbXHUwMDFlXHUwMDA1Xc29XHUwMDEzOHqAnc2Nz98q619gXHJOT4JcdTAwMWZcdTAwMGJdclx1MDAxNHJcdC48JYhcckgrXHSfUqFY/HOBXHUwMDFlMmTWclxyyNjChI5aKuJ0XHUwMDFh5pwhZvdcdTAwMWLne239da+K17dcdTAwMWZcdTAwMGY6XHUwMDE3/OO+/Fx1MDAxOTnOKnIsqXlfV7WzXHUwMDBmSEdcdTAwMDWO+TeSVPuMs1x1MDAwNZya6FxylJ6A50yJwcM9pomKXHUwMDExjFx1MDAwMSZ8Ylx1MDAxNFxcXHUwMDE399+iwjVcdTAwMTbBtWGelVx1MDAxMolw8lx1MDAxOK5cdTAwMTcgxlx1MDAwM1wiPIapOWfcJ9XHl8V4O0SMgnDOMd5cYrYxXHUwMDE44/VkfFx1MDAwMVsydqj9XHQruUBlx0+0721cdTAwMWNdblx1MDAxZnDT1lx1MDAxN592vn74UqvU3v/gXHUwMDAxsJHmZ8B4TDFcbvE0Q2FS9CP+udSeXHUwMDA1UMxqYYi4s/JcdTAwMDK9XHTpXHUwMDEyUMdIiirmy5a+sm3c/3pzuvd9TW802frNR3Vz+pMtzYotldS8P6v9Idn7/Fx1MDAxYpmMhCHoSYaZpyNhJuVcdTAwMTdcdTAwMDecXHUwMDAwRWTkXHUwMDAzmFx1MDAxOT/NXtx9XHUwMDBi6lx1MDAwNFx1MDAwNFx1MDAxNDlcdTAwMDFj6Sy5XHUwMDAxXHUwMDE06JzAj2ZgwFxi+IkozjvLPqkyvoyBbbfb0dxcdTAwMTnYXGJcdTAwMDYzyMB6Mlx1MDAxNtrd0Cw76qF2x612lEOOP/uoOMhcXNDhLeSetYxJyyTjTNs+u1x1MDAxM8x6WljF0Fx1MDAwMirDpSzP8iyFWZZcdTAwMTTcauDS5Nkhd9N1pGCCXHUwMDEzQCDX3Fx1MDAwZdolV3RcdTAwMTRlOkYrP+dcdTAwMGVu+lx1MDAxMJ/OLofn3MfISSdcdTAwMGUvnVxml4ojs0pcdTAwMTGUumReMiS5nGTotVx1MDAwNLDPY5ZCPFx1MDAxNVx1MDAxOJ1z71x1MDAxM6o4VupcdTAwMTeK+opgW2qFYIyxWZk4eFx1MDAxYZRSiJpLXHUwMDBlVmZkelVcdPeh2uxeWT1O6ltKv09cZmdcXFg7eDhJ5nBqeWPV+NFkMWFbTEDTQpO2gVx1MDAxMlx1MDAxMoTVSvQnc1x1MDAwNOdcdTAwMWU3glTMkielt1x1MDAxMlx1MDAwMU1cdTAwMWJcdTAwMGaZXHUwMDAwQzpcclx1MDAxMnNcdTAwMDGNXCJfXHUwMDA3V8YoXHUwMDAx3M1ozFx1MDAwMJo0KFxiXuS8XHUwMDAxTVxinlxccqaDiMzjllx1MDAxYlx1MDAwN1KCblx1MDAxONBKkVx1MDAxOMdcdTAwMTNcdTAwMWFQs1x1MDAxOIqugTOJqJRIl5hmsG7UXHUwMDE4XCLznIujOyfvoVx1MDAxNFfSZERCT2k3PMKNc4RcdTAwMDJxmEj5XHUwMDA05lXD2VBVjk9mlHhCNCtOj1x1MDAwMVx1MDAxYppcdTAwMWWzgq4oUI5cdTAwMWZcdTAwMTmJSttXyn/7/ayCzfUv3/13m2Zn4edBkLsgR+5Gr4FcdTAwMGLGk/Z4zI9JT1JQRKeoiOZ8cYZcdTAwMTPd3DBcdTAwMDN8zlx1MDAxM1E/76rLi7PV+mlwcVf5/PC+I++3uj9cdTAwMTNks0qQldS8r6vasua3zlxc3FFcdLL8XHUwMDBijintXHUwMDBiprdcdTAwMTbW+/5CVr59hofK2cl5o1U9asrKOc6g3oZd38Dzt1x1MDAxYnJD6+D409795fb23Vxm6jVrb6+luN7eOjq8W1+Ve62r99ers5nmy602nHzuXHUwMDA0VKzQ11x1MDAwZU1AXHUwMDE2zNlBci8oUzRtpJstVuNFdbPAi9ysobBcdTAwMDKEkYyZst3smKPA2iqSRqemgc4hXHUwMDA3SXxLSD2/mb5cdTAwMWLP2cE5pyFHMMXBNGRazEJcdTAwMDNcdTAwMWNcdTAwMWW62+FcdTAwMTPtieVcdTAwMWEhJshEXHUwMDE2I/OCXHUwMDFhoGZIVJIhWKuFS11cckTuwrNCM0JE11x1MDAxYVCe/Vx1MDAxOY9CXHUwMDFkkOCWYlqBPGuMnDNPoFx1MDAxMprOXG5AQoSMcTKKcSU3dlxu45x+8q91f+WiJFwi3Vx1MDAxYU0uqcNcdTAwMTQ3wrDsTGFLXHUwMDFkisgsMlx1MDAxMExY85xcdTAwMDabMFx1MDAwZllsqsvpKJ9ax6IlU1x1MDAwMskpmskmXHUwMDFlgHlcXCstXGZS90qDxmRkek2B++owXXavrFx1MDAxNifVLaXfp1x1MDAwYtwxZcDZld1cdTAwMDKMnGT3iMr18em73Xux/e78y1x1MDAwN3//4nyfXHUwMDFk/uDdI0YjmmDM01xuiTxcdTAwMTljkEnRzym0XHUwMDAwjyxESVx1MDAwM2CBWzMg2CxzkVx0XFwmkXtcdTAwMTKYP6OWS6coTK1lmEvE/j26U8HbI92tvjPHd3uXt1x1MDAxYuuH6z8j9llF7CU17+uqtqyI/XW1wuwj9lFcdIb8XHUwMDFiXHUwMDE5U9xcdTAwMTckXHUwMDAynlx1MDAxZNjwQFx1MDAwNriWc1xirClcXFx1MDAxYeZcdTAwMDYtQyEm2kOpWC1cdTAwMTbWXHUwMDBiqkIvqPW8vKDK8YKpULRcdTAwMTdYk1x1MDAxYnSryEpwg0X6XGKAqWh22lxyTcZccqyPI59cdTAwMDLiX+vVP/5yhJU+879WhsTYsu/XM4uxR3C6zILafIlfQFHTM0iye5y4had8fNvUXHUwMDBm91+rXHUwMDFiXCJsrTeOb2tX61x1MDAxN3tcdTAwMDerm4tum1x1MDAwNlQ8YYSjXHUwMDAyZ5v9MTfFQVx1MDAxZVx1MDAwN2GdbpZsmqksVlx1MDAxMUG1nFx1MDAwYskgtVx1MDAxNnguXGb1y9rmVq0tfd3Qhydh5/BD1P0ofjLUWTHUkpr3dVVbXHUwMDE2Q31drVDWmNLMxVx1MDAxZEV88y84ri6UN6JcdTAwMDSEnsrMYUq7wqH5XHUwMDFmIF+hUE2ys19x/y2qd1x1MDAxNazIu1x1MDAxYT4v76pzvGuW+Fx1MDAwMudMS21Se/zNhfhKzuwkgdisia+YN/FcdTAwMWRBXHUwMDE1R1x1MDAxM1/xQuJriqa9K1x1MDAxMG63lfGHe1x1MDAwZnbO168+s93V7cNwq77aklx1MDAxZj4/bC26bT5u7WvFMOarXFxuXFxLZk3ZzNdmbTOH+So01C3p5Y9zYb6dndbdl6vD860qq22v7q5cdTAwMWWyxun+T+Y7K+ZbUvO+rmrLYr6vq1x1MDAxNcpivjNcdTAwMTd3XHUwMDE0882/4JjSvmCO1jjMV9tcdTAwMTS5KW0xJ1x1MDAxZr6jhtulyk2uXHUwMDFmf85ycf8trHc1Rd5V23l5V53jXXNSvtxKYzXyOad8XHUwMDA1QzaJPs6a+cK8me9cYqo4mvnCSOY7fIZcdTAwMTWDoeleo5XkWvHxSW/x2NSimqVcdTAwMTFcdTAwMWXnXHUwMDAyXHUwMDE5XHUwMDAzjZha5vL4OFx1MDAwYsk9dKEogFaY3l5m1mZcdFx1MDAxYz2rJVdMKWZcdTAwMTnkzLFC4VmSXHUwMDEwXHKXbstBlZn/aJixhlx1MDAwMpUpTHb6lVGTj1x1MDAxYabkXHUwMDE4a4bVyNlMT+slmcfc0z6ElPSPgDQ9vSq98Fx1MDAxMlx1MDAxNEVcdTAwMGVcdTAwMTTRXHRjXHUwMDE4qimXelx1MDAxNo/U9ITSnrZcdTAwMTalwXhD2L5Vj2mZkJNLILfg+lx1MDAxN8CqjEyvaorVUF12r4xcdTAwMTYn1S2l3ydcdTAwMDYzmXpUweB8bVRMamPH37SrmHQtKpZZ8LR0XHUwMDBmbjDk5fVcdTAwMDDFUFJ6glCd9IsxkVx1MDAwZbJnjWXogVx1MDAwNnRrqZGT8ufOtJKee16CRaZJ7TH1hI8nLFx1MDAxM1Zay+U0w1ovWeU5KVx1MDAxZC5cdTAwMGLLqCM5tY9bQ2iVNiY9LzO9zSsxRlJu1EooRWaH04FZMVx1MDAwN0mBXHUwMDE5l0itxLgml8mEzlx1MDAxN8qtVVx1MDAxN2ThbmNcdTAwMTJLsuuMUK9cbs2GarN7ZfR4VmiGXHUwMDA1XHUwMDAz8WTEhGd2gm3mi0dPXHUwMDE2XHUwMDE0zjQwXHUwMDBmhKTgUVx1MDAxOWOkXHUwMDFkpGbMU4RikkmmXHUwMDE5pleDzJyaWdJpUnlcdTAwMDOcXGaOpy7VwzNLwihgbj9cdTAwMWXjXHUwMDE2JmTwLMZcdTAwMTb3uLHkx/NcdTAwMDG0XHRHtkpcdTAwMDI09Iwy0j2DRVAr8lx1MDAxNCNIrzgn43LLpVHYmGxPXHUwMDA3ZsVDXHQpMGNKIFpCTqdCyuSKZFAoTU3IXHUwMDA1XHUwMDE4Rjj7qqFsuFwiu1dGhYdB2dLTXHUwMDA1VvxOx4WjQa8zSKXr1acgPLnLldt6cLee83i/i/jlYte4PVx1MDAxZFxmXHUwMDA17l7/+Xfp3/9cdTAwMDNJ1VXAIn0= + + + + App()Screen()Header()Footer()Container()Static(id="static1")Static(id="static3")Static(id="static2") \ No newline at end of file diff --git a/docs/styles/background.md b/docs/styles/background.md index 8571ef111..cdfe10486 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -8,6 +8,12 @@ The `background` rule sets the background color of the widget. --8<-- "docs/examples/styles/background.py" ``` +=== "background.css" + + ```sass + --8<-- "docs/examples/styles/background.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/background.py"} diff --git a/docs/stylesheets/custom.css b/docs/stylesheets/custom.css index 5df58074a..ea1639ef1 100644 --- a/docs/stylesheets/custom.css +++ b/docs/stylesheets/custom.css @@ -20,3 +20,7 @@ body[data-md-color-primary="black"] .excalidraw svg { body[data-md-color-primary="black"] .excalidraw svg rect { fill: transparent; } + +.excalidraw { + text-align: center; +} diff --git a/mkdocs.yml b/mkdocs.yml index 73021761a..c30215e78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,7 @@ nav: - "introduction.md" - Guide: - "guide/guide.md" + - "guide/CSS.md" - "guide/events.md" - "guide/devtools.md" - "actions.md" From 538e5d4701881157af3a784a5e90e7b90678cd98 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 15:43:00 +0100 Subject: [PATCH 04/73] lots more docs --- docs/examples/guide/dom3.py | 16 +- docs/examples/guide/dom4.css | 29 ++ docs/examples/guide/dom4.py | 19 +- docs/examples/styles/outline.css | 9 + docs/examples/styles/outline.py | 14 +- docs/examples/styles/overflow.css | 19 + docs/examples/styles/overflow.py | 24 +- docs/examples/styles/padding.css | 9 + docs/examples/styles/padding.py | 16 +- docs/examples/styles/scrollbar_size.css | 15 + docs/examples/styles/scrollbar_size.py | 20 +- docs/examples/styles/scrollbars.css | 23 ++ docs/examples/styles/scrollbars.py | 30 +- docs/examples/styles/text_style.css | 18 + docs/examples/styles/text_style.py | 23 +- docs/examples/styles/tint.css | 7 + docs/examples/styles/tint.py | 12 +- docs/examples/styles/visibility.css | 12 + docs/examples/styles/visibility.py | 17 +- docs/examples/styles/width.css | 5 + docs/examples/styles/width.py | 10 +- docs/guide/CSS.md | 328 +++++++++++++++++- docs/images/child_combinator.excalidraw.svg | 16 + .../descendant_combinator.excalidraw.svg | 16 + docs/images/dom3.excalidraw.svg | 6 +- docs/styles/background.md | 4 + docs/styles/border.md | 6 + docs/styles/box_sizing.md | 6 + docs/styles/color.md | 10 + docs/styles/display.md | 6 + docs/styles/height.md | 8 + docs/styles/margin.md | 8 + docs/styles/offset.md | 8 + docs/styles/outline.md | 6 + docs/styles/overflow.md | 8 +- docs/styles/padding.md | 6 + docs/styles/scrollbar.md | 6 + docs/styles/scrollbar_size.md | 6 + docs/styles/text_style.md | 6 + docs/styles/tint.md | 6 + docs/styles/visibility.md | 6 + docs/styles/width.md | 8 + sandbox/will/basic.css | 1 + src/textual/box_model.py | 19 +- src/textual/css/scalar.py | 5 + 45 files changed, 653 insertions(+), 199 deletions(-) create mode 100644 docs/examples/styles/outline.css create mode 100644 docs/examples/styles/overflow.css create mode 100644 docs/examples/styles/padding.css create mode 100644 docs/examples/styles/scrollbar_size.css create mode 100644 docs/examples/styles/scrollbars.css create mode 100644 docs/examples/styles/text_style.css create mode 100644 docs/examples/styles/tint.css create mode 100644 docs/examples/styles/visibility.css create mode 100644 docs/examples/styles/width.css create mode 100644 docs/images/child_combinator.excalidraw.svg create mode 100644 docs/images/descendant_combinator.excalidraw.svg diff --git a/docs/examples/guide/dom3.py b/docs/examples/guide/dom3.py index 36ed06b1f..fe474ef22 100644 --- a/docs/examples/guide/dom3.py +++ b/docs/examples/guide/dom3.py @@ -1,6 +1,8 @@ from textual.app import App, ComposeResult -from textual.layout import Container -from textual.widgets import Header, Footer, Static +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): @@ -8,9 +10,13 @@ class ExampleApp(App): yield Header() yield Footer() yield Container( - Static(id="widget1"), - Static(id="widget2"), - Static(id="widget3"), + Static(QUESTION, classes="question"), + Horizontal( + Button("Yes", variant="success"), + Button("No", variant="error"), + classes="buttons", + ), + id="dialog", ) diff --git a/docs/examples/guide/dom4.css b/docs/examples/guide/dom4.css index e69de29bb..7640e8d94 100644 --- a/docs/examples/guide/dom4.css +++ b/docs/examples/guide/dom4.css @@ -0,0 +1,29 @@ + +/* 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; +} + +.buttons { + width: 100%; + height: auto; + dock: bottom; +} + + diff --git a/docs/examples/guide/dom4.py b/docs/examples/guide/dom4.py index 97309f385..020133a26 100644 --- a/docs/examples/guide/dom4.py +++ b/docs/examples/guide/dom4.py @@ -1,7 +1,8 @@ from textual.app import App, ComposeResult -from textual.layout import Container -from textual.widget import Widget -from textual.widgets import Header, Footer +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): @@ -9,10 +10,14 @@ class ExampleApp(App): yield Header() yield Footer() yield Container( - Widget(id="widget1"), - Widget(id="widget2"), - Widget(id="widget3"), + Static(QUESTION, classes="question"), + Horizontal( + Button("Yes", variant="success"), + Button("No", variant="error"), + classes="buttons", + ), + id="dialog", ) -app = ExampleApp() +app = ExampleApp(css_path="dom4.css") diff --git a/docs/examples/styles/outline.css b/docs/examples/styles/outline.css new file mode 100644 index 000000000..487270c6a --- /dev/null +++ b/docs/examples/styles/outline.css @@ -0,0 +1,9 @@ +Screen { + background: white; + color: black; +} +Static { + margin: 4 8; + background: green 20%; + outline: wide green; +} diff --git a/docs/examples/styles/outline.py b/docs/examples/styles/outline.py index 0fc1a476c..e64b11dc2 100644 --- a/docs/examples/styles/outline.py +++ b/docs/examples/styles/outline.py @@ -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") diff --git a/docs/examples/styles/overflow.css b/docs/examples/styles/overflow.css new file mode 100644 index 000000000..13d1d2eb5 --- /dev/null +++ b/docs/examples/styles/overflow.css @@ -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; +} diff --git a/docs/examples/styles/overflow.py b/docs/examples/styles/overflow.py index 560541170..d3ea7c5ca 100644 --- a/docs/examples/styles/overflow.py +++ b/docs/examples/styles/overflow.py @@ -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") diff --git a/docs/examples/styles/padding.css b/docs/examples/styles/padding.css new file mode 100644 index 000000000..4c558895b --- /dev/null +++ b/docs/examples/styles/padding.css @@ -0,0 +1,9 @@ +Screen { + background: white; + color: blue; +} + +Static { + padding: 4 8; + background: blue 20%; +} diff --git a/docs/examples/styles/padding.py b/docs/examples/styles/padding.py index b65606cf1..4893838c1 100644 --- a/docs/examples/styles/padding.py +++ b/docs/examples/styles/padding.py @@ -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") diff --git a/docs/examples/styles/scrollbar_size.css b/docs/examples/styles/scrollbar_size.css new file mode 100644 index 000000000..b119f07a3 --- /dev/null +++ b/docs/examples/styles/scrollbar_size.css @@ -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; +} diff --git a/docs/examples/styles/scrollbar_size.py b/docs/examples/styles/scrollbar_size.py index 2caaafed6..0f8b34082 100644 --- a/docs/examples/styles/scrollbar_size.py +++ b/docs/examples/styles/scrollbar_size.py @@ -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") diff --git a/docs/examples/styles/scrollbars.css b/docs/examples/styles/scrollbars.css new file mode 100644 index 000000000..8399bd077 --- /dev/null +++ b/docs/examples/styles/scrollbars.css @@ -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; +} diff --git a/docs/examples/styles/scrollbars.py b/docs/examples/styles/scrollbars.py index 0a024e5e8..5f50ee208 100644 --- a/docs/examples/styles/scrollbars.py +++ b/docs/examples/styles/scrollbars.py @@ -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") diff --git a/docs/examples/styles/text_style.css b/docs/examples/styles/text_style.css new file mode 100644 index 000000000..bf953d42c --- /dev/null +++ b/docs/examples/styles/text_style.css @@ -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; +} diff --git a/docs/examples/styles/text_style.py b/docs/examples/styles/text_style.py index 42f9710d2..f9a59a76f 100644 --- a/docs/examples/styles/text_style.py +++ b/docs/examples/styles/text_style.py @@ -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") diff --git a/docs/examples/styles/tint.css b/docs/examples/styles/tint.css new file mode 100644 index 000000000..6fa6a3a67 --- /dev/null +++ b/docs/examples/styles/tint.css @@ -0,0 +1,7 @@ +Static { + height: 3; + text-style: bold; + background: white; + color: black; + content-align: center middle; +} diff --git a/docs/examples/styles/tint.py b/docs/examples/styles/tint.py index 1d6d5678b..44d816b0b 100644 --- a/docs/examples/styles/tint.py +++ b/docs/examples/styles/tint.py @@ -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") diff --git a/docs/examples/styles/visibility.css b/docs/examples/styles/visibility.css new file mode 100644 index 000000000..349bb1345 --- /dev/null +++ b/docs/examples/styles/visibility.css @@ -0,0 +1,12 @@ +Screen { + background: green; +} +Static { + height: 5; + background: white; + color: blue; + border: heavy blue; +} +Static.invisible { + visibility: hidden; +} diff --git a/docs/examples/styles/visibility.py b/docs/examples/styles/visibility.py index 6b30954f3..169cc8041 100644 --- a/docs/examples/styles/visibility.py +++ b/docs/examples/styles/visibility.py @@ -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") diff --git a/docs/examples/styles/width.css b/docs/examples/styles/width.css new file mode 100644 index 000000000..0f067e236 --- /dev/null +++ b/docs/examples/styles/width.css @@ -0,0 +1,5 @@ +Screen > Widget { + background: green; + width: 50%; + color: white; +} diff --git a/docs/examples/styles/width.py b/docs/examples/styles/width.py index 4a1a0d0e1..d70868231 100644 --- a/docs/examples/styles/width.py +++ b/docs/examples/styles/width.py @@ -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") diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index f23e42b63..556c88886 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -1,12 +1,64 @@ # Textual CSS -Textual uses CSS to apply style to widgets. If you have any exposure to web development you will have encountered CSS, bit don't worry if you haven't: this section will get you up to speed. +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. -CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and the parts of a webpage to apply them to. In the case of Textual, the stylesheets apply styles to widgets. +## Stylesheets + +CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about what parts of a webpage to apply them to. In the case of Textual, the stylesheets apply 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. + +```css +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. + +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 in the Python class `Header`. + +```css hl_lines="1" +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. + +```css hl_lines="2 3 4 5 6" +Header { + dock: top; + height: 3; + content-align: center middle; + background: blue; + color: white; +} +``` + +The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on a 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 naturally appropriate for a header. + +You may be able to guess what some of the the other rules do. We will cover those later. ## 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. The DOM is essentially an arrangement of widgets in to a tree. +The DOM, or _Document Object Model_, is a term borrowed from the web world. Textual doesn't use documents but the term has stuck. The DOM is an arrangement of widgets which form a tree. Some widgets may contain other widgets. For instance a list control widget will likely 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 super trivial Textual app. @@ -21,7 +73,7 @@ Let's look at a super trivial Textual app. ```{.textual path="docs/examples/guide/dom1.py"} ``` -When you run this app, you will have an instance of an app (ExampleAPP) in memory. The app class will also create a Screen object. In DOM terms, the Screen is a _child_ of the app. +When you run this code you will have an instance of an app (ExampleApp) in memory. This app class will also create a Screen object. In DOM terms, the Screen is a _child_ of the app. With the above example, the DOM will look like the following: @@ -29,7 +81,7 @@ With the above example, the DOM will look like the following: --8<-- "docs/images/dom1.excalidraw.svg" -The above doesn't look much like a tree. Adding more widgets will create more _branches_ in the tree: +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" @@ -42,7 +94,7 @@ The above doesn't look much like a tree. Adding more widgets will create more _b ```{.textual path="docs/examples/guide/dom2.py"} ``` -This examples adds a header and a footer widget, which makes our DOM look the following: +With a header and a footer widget the DOM look the this:
--8<-- "docs/images/dom2.excalidraw.svg" @@ -52,22 +104,22 @@ This examples adds a header and a footer widget, which makes our DOM look the fo 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 objects. If you were to print `app.screen.children` you would see something like `[Header(), Footer()]`. +Both Header and Footer are children of the Screen object. -To further explore the DOM, lets add a few more levels. We are going to add a `textual.layout.Container` widget which (as the name suggests) is a container for other widgets. To that container we are going to add three `textual.widget.Widget` widgets. The `Widget` class is the base class for all widgets. Normally you would extend the `Widget` class to build a functional widget, but for our experiment that the base class will do. +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 + ```python hl_lines="12 13 14 15 16 17 18 19 20" --8<-- "docs/examples/guide/dom3.py" ``` -=== "Output" - - ```{.textual path="docs/examples/guide/dom3.py"} - ``` - -You may notice that there is a scrollbar in the output now, and we see the text "Widget#widget1". We will explain why that is later. +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; for instance the Button widget doesn't need any children. Here's the DOM created by the above code: @@ -75,8 +127,248 @@ Here's the DOM created by the above code: --8<-- "docs/images/dom3.excalidraw.svg"
-You'll notice that we defined the children of our container differently. The `Container` class, and most other widgets, will accept their children as positional arguments. These children are added to the DOM at the same time as their parents. +Here's the output from this example: + +```{.textual path="docs/examples/guide/dom3.py"} -```python hl_lines="10 11 12 13 14" ---8<-- "docs/examples/guide/dom3.py" +``` + +You may recognize some of the elements, but it doesn't look quite right. 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 keywords 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: + +```python +--8<-- "docs/examples/guide/dom4.css" +``` + +The CSS contains a number of rules 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? + +One advantage of CSS is that it separates how your app _looks_ from how it _works_. Setting styles in Python can generate a lot of code which can make it hard to see the more important logic in your application. + +Another advantage of CSS is that you can _live edit_ the styles. If you run your application with the following command, any changes you make to the CSS file will be instantly updated: + +```bash +textual run my_app.py --dev +``` + +Being able to iterate on the design without restarting the Python code can make it much easier 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 rules to + +Selectors can target a kind of widget or a specific widget. For example you may want to style a particular button green only if it is within a dialog. Or you may want to draw a red box around a widget when it is underneath the mouse cursor. CSS selector allows you to do such things simply, without writing (Python) code. + +Let's look at the selectors supported by Textual CSS. + +### Type selector + +The _type_ selector matches the name of the (Python) class, which is literally the name of the class in your Python code. For example, the following widget can be matched with a `Button` selector: + +```python +from textual.widgets import Widget + +class Button(Static): + pass +``` + +To apply a border to this widget, we could have a rule such as the following: + +```css +Button { + border: solid blue; +} +``` + +The type selector will also match a widget's base classes. For instance, the `Button` Python class will will also match the `Static` selector because Widget extends Static in the Python code. Similarly, it will also match `Widget` which is the base class for all widgets. + +So the following selector will also match our `Button`: + +```css +Static { + background: blue; + border: rounded white; +} +``` + +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. + +### 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: + +```css +#next { + outline: red; +} +``` + +### 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 may share a particular style. + +CSS classes are set via the widgets `classses` 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 green. + +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 +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: + +```css +.success { + background: green; + color: white; +} +``` + +!!! note + + You can apply a class name to any class, 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_ `disables` class names. + +```css +.error.disabled { + background: darkred; +} +``` + +### Universal selectors + +The _universal_ selectors is specified by an asterisk and will match _all_ widgets. + +For example, the following will draw a red outline around all widgets: + +```css +* { + outline: solid red; +} +``` + +### Pseudo classes + +Pseudo classes can be used to match widgets a given state. 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. + +```css +Button:hover { + background: green; +} +``` + +Here are some other such 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 rule that combines 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 a parent that matches the first selector. + +Here's a section of DOM to illustrate this combinator: + +
+--8<-- "docs/images/descendant_combinator.excalidraw.svg" +
+ +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: + +```css 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: + +
+--8<-- "docs/images/child_combinator.excalidraw.svg" +
+ +We can use the following CSS to style all buttons which have a parent with an ID of `sidebar`: + +```css +#sidebar > Button { + text-style: underline; +} +``` + +## Specificity + +It is possible that several selectors match a given widget. If the same rule 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 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: + +```css hl_lines="2" +Button:hover { + background: blue !important; +} ``` diff --git a/docs/images/child_combinator.excalidraw.svg b/docs/images/child_combinator.excalidraw.svg new file mode 100644 index 000000000..c6d8e5c18 --- /dev/null +++ b/docs/images/child_combinator.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXFlz4khcdTAwMTJ+71/hYF56I1x1MDAwNk3dx0RMbPjCXHUwMDA37auN2+3enphcdTAwMTBIXHUwMDA22Vx1MDAwMmFJXHUwMDA244n575vCXHUwMDE4iUNcdTAwMDJsRMNu68FcdTAwMDaVKKVSlZnfl1lVf3/Y2iqEvbZd+H2rYD/VTNexfLNb+DU637H9wPFa0ET631x1MDAwM+/Rr/WvbIRhO/j9t9+apn9vh23XrNlGx1x0XHUwMDFlTTdcYlx1MDAxZi3HM2pe8zcntJvBv6O/p2bT/qPtNa3QN+KbXHUwMDE0bctcdD3/5V62azftVlx1MDAxOEDv/4HvW1t/9/8mpPPtWmi26q7d/0G/KSEgoXT89KnX6kuLXHUwMDE1V1pTIeIrnGBcdTAwMGZuXHUwMDE42lx1MDAxNjTfgtB23Fx1MDAxMp0qnD099Ion3YdKuVavep/9ynHz83V831vHdS/DnvuiXHUwMDBis9Z49Fx1MDAxM1JcdTAwMDWh793b145cdTAwMTU2oruPnVx1MDAxZv4u8EBccvGvfO+x3mjZQTDyXHUwMDFir23WnLBcdTAwMDfnOFx1MDAxYZ580cLvW/GZp+hcdTAwMDKFXGZGXHUwMDE0XHUwMDEzSlx0qjRcdTAwMTk2Rr+miFx1MDAxYkQqhlx1MDAxOUJMXHTNx6Ta9VxceFx1MDAxMyDVL6h/xHJVzdp9XHUwMDFkhGtZw2tC32xcdTAwMDVt04f3XHUwMDE1X9d9fV4hhudcdTAwMWG2U2+EcFKp+H52X+taSUlcYmJy2Fx1MDAxMN2kfWT1R8Cf40prmH57oJxCXHUwMDEwfUlcYlx1MDAxOMm2Pz58kkMo8WJPmv4jXHUwMDE13Z3tg8OeuvO+7NZDb3fY18h4XHUwMDBi7aewMGz459esbpvfrsqVg9udTnj15bh70X0+qber07s1fd/rztvv9fGXTvdcdTAwMTPbO/nkc+ugR1x1MDAxZVx021x1MDAxM0vol1xcW0eHpfvaidpmuNJ0z/Zb3+pL6Dcn9W5Wt59cdTAwMWX3XHUwMDBlkf569Px0q8qlkvX0l9np/lTucrptfZG3/s3nfW831Oc3Vlvza3q7vsr1XHUwMDFmdWm3J87/ujLpzXn9yuLXx+fvXHUwMDEyd0SKX+d9kLjbwafYwz62LfMlXHUwMDEygutcdTAwMDaHTFx1MDAwNZFcdTAwMTAyh+2u07qHxtaj68bnvNp9XHUwMDFjPD8k5J1cYtsjco5EbCbGT79GbEkx1ZiyWIhZXHUwMDAxO/v1rWvA5lx1MDAxOVx1MDAwMZsgQ64mYPMpXHUwMDAxm8dxeVx1MDAxMLAhVlx1MDAwYiYwU3r5XHUwMDExe5mDMX7nXiu8dJ77XGJQjJwtmU3H7Y28tv4oXHUwMDA1SXeh2XRatv/xeytqcKw/vlx1MDAxNyzHdL3698L31r+Salx1MDAwZWyQJuqek5F+tl2nXHUwMDFljfCCa9+OXHUwMDBl/dBcdTAwMDHsO2xuOpaVRLO113tcdTAwMWbNg0E936k7LdOtzCt5ppVmg2vOdJqpYo6l1NExt60+7uxott9DpdO77daBkmH31D9ed1tcdTAwMTXSIFxuIXBMk7ZcbsPUoIwpgkBTka3maKxcdTAwMWFNXHUwMDFhq1x1MDAxMuPGyjSTRHKcg61mRbu9h9Kdf35y+HBR2it3Wubd1edvzk90vSx0nZN6N6vbvND1Zmmh4jf2utWOXVx06bGLiXq+v2h/+r/TQl5k4LBnysZ++3zvitzXXHUwMDFlcJtfXHUwMDFjnVhL6LcmTs75XHUwMDA1bZZcdTAwMGUtv1xcPC3unJZcdTAwMWFkXHUwMDFk39oskjH9hnG3g08/nmRwydKQXHUwMDBiYZhcdTAwMTJgXHUwMDE5c+OWbC2vKW6RJFx1MDAwM7coaahcdTAwMTXhXHUwMDE2NVx1MDAwNbdMklxmLalGVP1vc4xDgO7PXHUwMDExWnc/Rudf4HrNNYPAXHUwMDBlXHUwMDAws1dcdTAwMWbD0GtcdTAwMDWrplx1MDAxYjNQ+TjdWOQhMk03m3kolJrWJ4hyKplE82dcdKpdfdAs1kNdLfaO7KDcOzg6q665XHUwMDA1syitz6VcdTAwMTRcXEaGQUZNWFxiYmAsXHUwMDE5wpyjyHp+NPOQXHUwMDFjISU5x8u34KxcYrht99jBxZF3V6/sXHUwMDE2t692v1x1MDAwNpVuSp7tJ/NYvN+c1LtZ3ebFPDZLXHUwMDBieTGPzdKCq3d2VfVgl+1cbmFfVspP9VJpjcdCXsRj6eLOXCJcdTAwMWXTb1x1MDAxOHc7+JRcbvZcdTAwMTRcdTAwMDbAy2RcXI7Oi3gksfZ4ylRgXCJcdJE0hr+zgEu2ntdcdTAwMTW48CzgorkhVlx1MDAwM1xcplGPRGb0lXogXGZcdTAwMTRcYrFcdTAwMWOAS+ZoXHUwMDE0VNJcdTAwMDVG4/uox05cdTAwMWaWf/xeuLFcdTAwMDGbT6dcdTAwMTeYjfxsyFx1MDAxZmrwOLb/doIxXHUwMDAzfI9cdTAwMTOMcVEzzXBcdTAwMDaJ0KnlXHUwMDBiXHUwMDAxvlx1MDAwMCmE5zfFx+fDTuvs+fmK0UrP5TXR+vZcXFpzU1x1MDAxNFpcdTAwMWJIK+DWL6Y4RiGwIbVESqP1oFx1MDAxMJRiLbhKZCNWQiGuj47OP908P+xcdTAwMWNcdTAwMTYvvWLXXGafv9gnPynEsihETurdrG7zolx1MDAxMJulhbwoxGZpIS9cbrFZWsir1LJ0cWcxk+k3jLtcdTAwMWR8ysSCXHUwMDEyy1x1MDAxOIjkxEwoSiRMx9FcdTAwMTDCjPBcdTAwMDWmXWVreT3BkEQsXHUwMDAzXGZcdTAwMDEtIatcdTAwMDFD89FcdTAwMTKsmVwiIKbMYaJ05lhUmrNcdTAwMDXG4pJ4yam3aloyXHUwMDAzz6fSklx1MDAxN0kzTfDFYU2xQYlVqlx0XG6qXHRcdTAwMTJ0/ulU2UXw9bRBgPdcdTAwMDZcdTAwMDH7XHUwMDAzYsLBXHUwMDFmST5ihFxmYUNIojh8YFpcdTAwMTEpcrNCZFx1MDAwMNtAXHUwMDE4cy2ZXHUwMDE2ilPNJq1SaPBcdIxcYimUXHUwMDAwQ1STRopcdTAwMTFXXHUwMDE46FxyXdxI+8K+uW5JMHqLkVx1MDAwNqHphztOy3JadWiMXHUwMDAz3es6nHmmIPbNuvZcdTAwMTj01YhcdTAwMDS8RSo5QUC3XHUwMDE1uNDEVXWzXHUwMDFkXHUwMDExPVx1MDAwM5TLXHUwMDE4QpG+KZF4cMEw4Fx1MDAxNuyWNVum7DplQqZcIlxiRSVcInBHwjFcdTAwMTacq7jIO5SKXHUwMDE4VEqhgW1cdTAwMTKNwM+KXHSpXFwzXGJ3vWbTXHRB+eee01xux5Xc1+Z2ZOxcctu0xlvhqZJt416hXHUwMDFk9ThKTuNPW7Hd9L9cZj//+ev0q9OHc3RMXGbkuL9cdTAwMGbJ/1x1MDAwYjs0THDqPFx1MDAwYobAyJXC86OKbEy4plx1MDAxZU1ig4M9KiRhdLNYXHUwMDFi/V9LZGguhIryXHUwMDFhmiRSoUtHXHUwMDE1mFx1MDAxYVQoxUDnnI3M+IgzLshgXG5cdTAwMTBcdTAwMTBcdTAwMDM5KYyHxKyPQVxyV1x1MDAxM3hdYDUr9GZcdTAwMDP4u0hiflFvNrfnXHUwMDAw/VCOhYpsiICTXHUwMDE1LGFFXHUwMDAzv4EhROEoY8xcdTAwMTBDnFxi9kZvlok+RmVcIlRrsFmFkFBcdTAwMTTHXHUwMDEzl2KZwPy5ZphcdTAwMTNcdTAwMWUtrlx1MDAwM8E32pmlj+XomFx1MDAxOMVL82WIpJZuIIhgXHUwMDAxkXd+X5ZdeVtTX8Y4MCSEeVx1MDAxNFx1MDAxNylExzFnRlxmhkWUpeVaSDku1vKcmdbwilx0+EzNMUQzXHUwMDE2O4fYlzFcdTAwMDNaqYTrMCaCTswow+DJXHUwMDE4XHUwMDE4MF61M1uwypibMytGmIBxTFx1MDAwMGlcdTAwMTPwaIyiKd6MXHUwMDFhXHUwMDEwt1x1MDAwMCVcdTAwMDE5hlxixlx1MDAwNX2bO8uu8YxKxTXAf6wlJuDUNMFcdTAwMTNCcVx1MDAwM2A1OFgpUSTXpEib5MyKqYM5OiaG8YLOLLP+XHUwMDA16kvzZ5hTXHUwMDA1IJkv4NBOtFu+Kt/eP5Vvri8t61x1MDAwYjEvP31ec4fGotU7XCJcblx1MDAxNlx1MDAwNII2Slx1MDAxOGY/XHUwMDFkIUD7XHUwMDFj7Fx1MDAxNWCbRnBFfugsUdOKXVx1MDAxOMhcdTAwMDa3pnJcdTAwMDKHYY0l8Fx1MDAxMmAmi/uu91TC7rqVO8e9alx1MDAxZVx1MDAwN13NSdW1wpL75f2p3pOLo+eb0tPX0N+zi43L4KskbFx1MDAxObP1N61cdTAwMTKWk3pz6lZ2nMPSwz5u+vt/WXthh3TO3GVogSG/jVud2ul1qXyqrveuvZOwvL7ard41t8/djsA8LO5Yul4+9Z566yvu8tedXHUwMDBm1PD1qEzc4FK1z05cdTAwMDLWaTfKR/zqXf3OqqtMV1Dc7Wt8TMNiq1pqwnVqXHUwMDA2XHUwMDA0gDJcdTAwMTFcXCRz+LOibPawWNMoXHUwMDFirTVJj7JaXHUwMDE4XHUwMDEw0DAgPZ1zlGVToiyJU7Ov0ZVSqWmEtpZcdTAwMWZd815sQtDI2YzKymXNt+3Wx5Sailx1MDAxY7l+aTWVXHUwMDE5XHUwMDE4cbymMpQx0/DSXHS7St1IXHUwMDAy84hZkEXKKdmecz0tj3NsXHUwMDAwXHUwMDFkh0dlhCrCR8sp8N1QUolcdTAwMTVYXHUwMDFlxsAjXHUwMDE1JVx1MDAxYZimklx1MDAwMslJQ2Q8qlx1MDAwM3AwvFx0vEtcdTAwMTBihGkt3oB317mMkm1cdTAwMGVbySRcdTAwMWbXUoBf4sDTXHUwMDA1JTxBXHUwMDEyXHUwMDA3pJhcdTAwMTmIg4pBe4OWXHUwMDA1XHUwMDE5+lx1MDAwMiVcdTAwMWSBkVBA0Fm0w1x1MDAwM51S0Vx1MDAwMVlcdTAwMTCQWcQ5V4xSrSdE2iSGnj54oyMxbOOOPiT/v21uKs0g54QhpcFcdTAwMWXmX6J60366dO5p9eaKVcKLg23SuT/7uubOi1x1MDAxMmmAXHJyIOlTYEO0cVx1MDAxZFZRMVi/kPNcdTAwMWa+c51cdTAwMTKMkdWvcDvZZ6VcdTAwMGKl9m5vrWq3V2l422ElhYD8nJ66eL85qXezus1t57qN0kJO3ea2c10+4uaVQchJ3Iv6Q/3Cvn4gWn5zr06bgdm2U/Ioy9pob+qDxN2+goNcdTAwMWadmCBcdTAwMTSn794liOpPd5w/M5H9/tZcdTAwMTRiUJJcdTAwMDUxXGJg3Vx1MDAxNUGM+fbaw1hyRCmg781LTbxzr73Aseyq6a9694tcdTAwMTnAea7N9kZEzzTVbEqQnJEzbq+YXHUwMDEwhlVy2+RZ9rpz3PIwXHUwMDAy/mXT4+pTpYyvdMlOsdcxu1x1MDAxYrXW8UlLb7VWjGaaK9ZcdTAwMDZDmlx1MDAxMfxirqPzXHUwMDBmmCCGoIIjJt6fzvhcdTAwMDWTqlJCqSm2KqdkL6ZseIGJRCw5hXUldOBi//hcYj3J8u3ZtYOvXHUwMDFi5/pb7+bgJ1x1MDAxZFhcdTAwMTZcdTAwMWTISb2b1W1edGCztJDXarXN0kJeq9VyXHUwMDEyN69cci8266Wtnlx1MDAxNE1/kLjbwaeMmXOIckziKJvbKjiaXG6yXGJAb81cdTAwMTWW80+Kyn5/P4RcdTAwMTTNXHUwMDAxs1xiy4JZUlx1MDAxYXhZMGtG3nVcbtCaslx1MDAwMblkTFx1MDAwM1bLXHUwMDAxaGVcckdMNFpkUeb7ONFgeVl07oVZfC9cdTAwMWO1gtB03VUzolx1MDAxObwhZV1cXKrgmVaaXtplKNVKMVx1MDAxM0hcdJxcdTAwMTiYM7f/y5y8sp6pXHUwMDBizrghXHUwMDE4lXpq6oJwYURcdItcdTAwMTVcdTAwMTgpY4Yk0TRcdTAwMTYyZXZcdTAwMDXTXHUwMDA2XHUwMDE3giExWdSlXHUwMDAygSGRt+zm8T9R0y1GySVNhNBcdTAwMTT8rMZcdTAwMTRPK6QySrmmckZRd1T+XHKqrVx1MDAxNqdccp/oSFxmnLiPXHUwMDBmyf+L+1xmnl5RRVx1MDAxNMlode38gT1cdTAwMTNBrafLYJhcdTAwMThcdTAwMTSM/TXbOWV1LcLRaMx7OlxiMlxiR5ow8NJEgFx1MDAwYkjMwYrX1kbzXGYkVlNmP1NJQTz0loD/jpVcdTAwMWL9gFx1MDAxZkOh5XuO7FTjqOdAXHUwMDA0ScRcdTAwMTlcdTAwMTVcdTAwMTJcdIbjvcmGnkNcdTAwMWGKXHUwMDAyXsXRtFx1MDAxOTimLJKYa1ZIdrDfXHUwMDFhWehcdTAwMGLiROuxiIrmXHUwMDE0kcT2u0OhJlf2bpKvSlx1MDAxZrXRXHUwMDExj9c0f/Vh0HPBbLcvQ1x1MDAxOFxcQ73D+HWsXHUwMDAxJIxcdTAwMWav0HHs7s6U1ORt/4iQU1+RkbOxo4f8+59cdTAwMGb//Fx1MDAxN3ArIFx1MDAwZSJ9 + + + + Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install") \ No newline at end of file diff --git a/docs/images/descendant_combinator.excalidraw.svg b/docs/images/descendant_combinator.excalidraw.svg new file mode 100644 index 000000000..d0dac0ecd --- /dev/null +++ b/docs/images/descendant_combinator.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPiSLZ+r1/hcD9M34m2OvelIyYm8IJ3u1xcuGxX3ZpwYJCNjFx1MDAxMFiSwXZH//c5XHQ2XHUwMDEyIMlcdTAwMDIjXHUwMDFh7i09eJFE6uTRWb4vT2by56e1tfXwuWOv/7G2bj/Vqq5T96u99d/M+a7tXHUwMDA3TtuDS6T/f9B+9Gv9O1x1MDAxYmHYXHT++P33VtVv2mHHrdZsq+tcdTAwMDSPVTdcYlx1MDAxZutO26q1W787od1cbv5tfp5UW/a/Ou1WPfSt6CFcdTAwMWJ23Vx02/7gWbZrt2wvXGag9f+F/9fW/uz/jEnn27Ww6t25dv9cdTAwMDP9SzFcdTAwMDGx5uOnT9peX1qsiWJcdTAwMTQhqoZ3OME2PDC063D5XHUwMDE2hLajK/X+s4J92bh3OPNLXHUwMDAytz7vuHvfS+XoubeO61bCZ3egi2qt8ejHpFxuQr/dtC+detgwT1x1MDAxZjs//Fxc0Fx1MDAwNjVEn/Lbj3dccs9cdTAwMGWCkc+0O9WaXHUwMDEzPsM5joYnXHUwMDA3WvhjLTrzZG6Q1Fx1MDAxMkIyxVx1MDAwNFwiXHUwMDEyejy8aj5OhbI05pRcdIU4XFxkY2JttV14XHUwMDE1INYvqH9Egt1Ua807kM6rXHUwMDBm71x0/apcdTAwMTd0qj68sOi+3luHhVx1MDAxOJ5r2M5dI4STKtJ9YPfVjlx0oiCIiPXLPKWzX+/bwH/G1dao+p1X9axcdTAwMDfmn5iERridcVx1MDAwM4pcdTAwMWJR7NVy73bjkuret0b9XHUwMDAwX988XHUwMDFkXHUwMDBigYNhWyNcdTAwMTZcdTAwMTfaT+H68MJfv2U12/r+9fB893azXHUwMDFifr046J31Xo7vOjfJzVZ9v93L2+7lwUW3d8S2j498Xt99Jo+EbYs5tEsu6/t75WbtWJVcdTAwMTg+b7mnO973uzm0W5B6V6vZo8ftPaSv9l+ebtVhuVxcf7qudns/lTufZvfsXVx1MDAxYXRrTul66za8L+/vlM4ujz6k3Fx1MDAxMSl+y9uRnOI2jrZ6iNqodd/p7Fx1MDAxZlx1MDAxZXVfrs+2w3zivv5cdTAwMTWFwsdOvTpIWlx1MDAxMGRcdTAwMTFTXHUwMDEwz7FcdTAwMTZRKHdcdTAwMWSvXHRcdTAwMTe9R9eNzrVrzSjPfYpcdDyRYUf6XHUwMDFmT65cdTAwMDTptORKJIGcw1x1MDAxOMmdW7PNYllzq8rKrYpZbDG5lSfkVi7HcyuB5Eok5lHSnVtqnacxRu+87YVcdTAwMTXnpVx1MDAwZtbEyNlyteW4zyOvrW+lIOlcdTAwMTZcXK46nu3/+sMzXHUwMDE3nPq/fqzXnarbvvux/sP7n7iWXHUwMDAzXHUwMDFipDHNczLSTsl17oyFr7v27ajph1x1MDAwZcDU4eWWU6/HgWft7dn7eeBi23fuXHUwMDFjr+qe55U800uzcTDHOM1VKUNcdTAwMTJjrFFuVz07f3Dtl9Lm4+Pm8b57fHrlXZXvl91VXHUwMDA1sjh0kkmc4KqcXG5LKyw5Rdy4Ki/OVWN6jmCwmHBV4CVUYKRcdTAwMGLw1ay0dF85uNs5b3tbzVpT+E/N/e/Nh5S09Fx1MDAxM1x1MDAwNk/fbkHqXa1mi4LBq6WFc7+x3bvp2uchPXAxUS/Ns87H8OoqamH+qH3QLn1+2PhGVLlcdTAwMTayTlCxn7dPt1x1MDAxZeRcdTAwMWParbfQxVb74EuvfHW45XY7/s7NXHUwMDFkLZRlJCs+avb1r7+fXHJwKsdPXHUwMDBmh9pcdTAwMTBDSEI6i/Lce1x1MDAxOCPb3pZcdTAwMTVj8CyMwbHFXHUwMDE2gzFUXHUwMDAyxpikXHUwMDAzXHUwMDFhSYZcdTAwMTSTXHUwMDA1jLQtXHUwMDBmXHUwMDFk2Fx1MDAwM5T9YoC1+6s5P0DWNbdcdTAwMWFcdTAwMDR2XHUwMDAw8PrmMVxm216waGbwXHUwMDBlglx1MDAxZWdcdTAwMDbTdFwi03mzSYLUNM2DhZaMI4pZblx1MDAwN/5cdTAwMWGenbefnq83e5unJ1/PX768fC6JXHUwMDE0XHUwMDA3XHUwMDFlc8Ri3Fx1MDAxN6P3/JdJZiHNXHRWWkrwX8ZGXHUwMDFjWFx1MDAxMm4pJTHwefByJtVHXHUwMDFj+Fx1MDAxN0xulFx1MDAxMkrNTFx1MDAxMTCjICWEVTJ//81KgUI0vHvdXHUwMDE1304qrHOnXHUwMDBm6eHRY/njSOAnRShUvavVbFFcdTAwMTRhtbRQXHUwMDE0RVgtLbh6c0vd7G6xLSHsyvnh0125vMS2sHiGkNyRnOLWXHUwMDBlT85Z58g/+eZv3dTto44+2v06rzqE5ETFcGdRzEOJ9FwiP0BxRYXA+Yv82XbxtzCPPNBFZ0JcdTAwMTeuLTov6DI998BRnehtfJNzjVx1MDAwNaOLLUVILihDU9jjx7jHZlx1MDAxZpf/+mP9m1xy4DyZX8RcdTAwMTD1XGK/qEF3bH92hvFcdTAwMGX8XHUwMDFlZ1x1MDAxOOOiZjpiNotQsUlcdTAwMWTj3qhcYsHw2kn+cVx1MDAwMInLp02263VcdTAwMWK7lZfgjj5cdTAwMWb7/tVy01xioYmFldLi1Vx1MDAxN0eHXHUwMDAxXGaL4NrU4ZaDRSDjhpyhXHUwMDA1s4iHQJdcdTAwMGZbp1x1MDAxNXZw1uRlr35Ptj9f/GRcdTAwMTHzYlx1MDAxMVx1MDAwNal3tZotikWsllx1MDAxNopiXHUwMDExq6WFoljEamlh/mWR98hJckeiZl//ylx1MDAwNG1cdTAwMTLTKG9cdTAwMTVFXCJ0+kxhhcxUJznFROHs17ekXHUwMDFjQmiZhVu4YVx1MDAxOHPCLfOgXHUwMDEwUlx1MDAwM31gKFx1MDAwNmlcdTAwMTZCIVx1MDAwNFx1MDAxMMrFlS+GuPykvWhcdTAwMDbxXHUwMDBl8k5lXHUwMDEwXHUwMDAzSTOdcFx1MDAxMFtcdTAwMTK8UKBUJyRIUsQlQNXcXphdXFxezlx1MDAxYaIwVUImqOKA6jXTYsRcdJngXHUwMDE2hVBcdTAwMDSQXHUwMDFmc82FkIV5IbIoXHUwMDA1clx1MDAwMI+RIIXiVLNJr1x1MDAxNNpcIlx1MDAxYeicXHUwMDE0XG6ih1STPFx1MDAxZmFJXHUwMDA0iX02t5P2ZV10jTFcYqt+uOl4dce7g4tRqntbiZJnZl/frWuPQV+LSHDJqeRcdTAwMDRcdTAwMTGl47NAjS6qXHUwMDFkk14s0C1jXGJcdTAwMTl1U1xisK83XGZT7rrt1d+XKbumXHUwMDE4k2lcdTAwMDOEolx1MDAxMlx1MDAxMXiimVx1MDAxN1xi8iE1IVx1MDAxNLGolEIrzolGJilMXGLlVoNwq91qOSHo/nPb8cJxXHUwMDFk95VZMr7esKv18avQqfi18aDQMS2O0sjor7XIa/r/XGb//s9vyXenXHUwMDFiszkmzDhq71P899TxXGbjXHUwMDE4r55cdTAwMThcZkFcXElBZf6Allxy35ZcdTAwMTVWXGJmSYWF1iaesdhIrPm8oMzimlxuQlx1MDAxOIdcdTAwMWLEuFxcc4RcdTAwMTWYWpRgY/GUgEmTXGLfROMjyIJcdTAwMTdCKMOUUTCJ2Fx1MDAxY41BPFNUI/BcdTAwMGI2w2jJXHUwMDA34tnUXHUwMDEweNp4ljt2IItRTlx1MDAxMSRjMF+qQSxcdTAwMWW7a1x1MDAxMDowtlx1MDAwNMbgSVxmMcSJYLPFs2z8MSpcdTAwMTOhXHUwMDFhkqJWXGJcdEVRkkxcdTAwMTBcdTAwMDE4XHUwMDE4XHUwMDFmJ1xcSkKIXFzteJZuy+aYsOJ5hTON0fjZKJrBsyjG+WeRZ1x1MDAxN8qWNZhRZXFCXHUwMDA0VppCMFx1MDAxYo9lwkKcgsVLXHUwMDAxab+4UKY1WDPVXHUwMDEwXHUwMDFjlIQ0nbT6Q0HQJVxuw3WCMVx1MDAxMXRi9lx1MDAxN1x1MDAwNlhCOOT+RYeyKUuChYWyXHJcdTAwMDNcbpjm8Ca5QJooJWJe9Fx1MDAxNjeoXHUwMDA1oVx1MDAwZfxMgvDI1I9mXHUwMDBiZtnlmFGpTDrUXHUwMDE4YiuBkKZcdJ5cdTAwMTCKW1x1MDAxOHFcYq9SXCIj16RIq1x1MDAxNMo2Uo3ZXHUwMDFjXHUwMDEzZjxlKMssVVx0kTrfTcE1QCNTkE23UUWnR53d0MH+XHQrtbbDy/rJkpNNZlx1MDAxNsWMhDBClIU1ZkiQXHUwMDAy56fyqO0oYEmLSMghclx1MDAwMnNBWmFUIV3APNWhNSWMlbbdp0qpee5gfc83XFxycPRcdTAwMTLUKlx1MDAxZlx1MDAxZoI9Ptt/+VZ+ulxu/W17o1FcdK4kYbf/XHUwMDBmK1RcdTAwMDWpt6BmZdfZKz/s4Ja/c13fXHUwMDBlu6R76s5DXHUwMDBiXGb5XHUwMDFk7HVrJ5flw1x1MDAxM3W5fdk+XHUwMDBlXHUwMDBml1e7N/et0me3KzBcdTAwMGY3Nuv67vBcdTAwMDSSWqH1g+SO5Fx1MDAxNPdcdTAwMDOLrDPb/Xpqn+mNXHUwMDA3u6l2vr3cf/ZOLzZuc1rDW9ZKR0iLWa7BefpqXHKqXHUwMDE1I1x1MDAxMqn8i7ezzW1Zk1x1MDAxZlx1MDAxZk9+XFxbXG7QRpGpjyWkvthIxNvSXGamgERcdTAwMTSR8YpemUHQyNmM0kal5tu292tKUUOO3D+3osY7KG28qDGUMdPHUvmyTMWXWFx1MDAxMISFXHUwMDE0U9RcdTAwMTSzg9ly+lx1MDAxOGfM4oZhSeiwxHK0mlx1MDAwMfZmccxcdTAwMGKFmlx1MDAxOFx1MDAwM4FTlGhcIplcdTAwMDJaLifdj3EzXHUwMDAwXHUwMDBm1k8moCdGgvXlnqHIuMz1i2w3WIuPrXFcclZcbqicaSYoSaDIzEJcdTAwMWNUXGbae70yJTWeopZiVlx1MDAxMCpgxlxmS6mpTlx1MDAxMlx1MDAwNZlcdTAwMDV4vL/Ajmo9IdEqMeN02zVHzGqjhj7Ff882fZOkk2JOOLxcdTAwMDGan1x1MDAxM5+de/tt/Xi407luXHUwMDFj3p5dX9jywV7ykEVcdNhcdTAwMTlcdTAwMWGNSm87pSnCXGJWkveDklx1MDAxOJNo4VulXHUwMDExwFxi4Jxyhtrqh3ZK++KTq2bjy0Nj/2Kju8f36t3t+2Tw+3Pm5vTtXHUwMDE2pN7VarawndJWSlx1MDAwYlx1MDAwNTVb1J5cdTAwMGJcdTAwMDWJO39cdTAwMTL/7sZuiVx1MDAxZMkp7sNcci1VSEncXHUwMDFlb59Xrtpf2O1TvblaY1x1MDAwM4So1FlcdTAwMGJac6aonmJcdTAwMDVHtlksK1xi4MkgQDGLLlxiXHUwMDA05NvTXHIrXHUwMDA1TFx1MDAxMlx1MDAwNCpg/cby7OKQuDVa4NTtm6q/+L1cdTAwMWIyQW2uXd1GRP9cdTAwMDBYzyjIXHUwMDBiIEFAgaaYXdTtVS6ITVx1MDAwZlx1MDAwZq5Omlxy7Ou770f2cYqj1vx2XHUwMDEwbDSqYa3x9zsrJlx1MDAxNtBcdTAwMTbjrGjMKfufJ8JSssAhXHUwMDA2mTCmkLDQimBCXHUwMDA10njBO7o1S+hq7/rgqX7aoye756x+JEreT7g+L7hekHpXq9mi4PpqaaGohVarpYWiXHUwMDE2Wlx1MDAxNSTu/LdrKEjc90hL8lx1MDAwM3NK+1x1MDAwMdKS2W6FV545P/D2r8U2uX1g3m7pIIVcdTAwMTJOT4ZcdTAwMDRcdTAwMWKBN8UtXGZLX89uds6lRE0x6zHbLpaUXHJhmVx0sFx1MDAxOLZokVx1MDAwMEskXHUwMDAwrEkmJLXQXG5ex2KLpoJcdTAwMTGl+Fx1MDAxNEb4MVwi9LrKypxcdTAwMWLQiVx1MDAxZuv7Xlx1MDAxMFZdd9E06Fx1MDAxZLaQsjwsVfBM30xfL5a6XFzMWKs05Di3Z2ZP6lhOz+RUWWpsysJbZbW/2qhIt2RmbuYgLEz6J9NcdTAwMTZcdTAwMTdibFx1MDAxZeFwXHUwMDFieqohqs7ChP5PXHUwMDE0VTeQJZQmQmiIrEJjimOVvWEpk1HKNbzA1yspVdVR+VeourmRZD/miFlO1Man+O+pw1x1MDAwNFx1MDAxM+Mn43OdMFx1MDAxNljmz+DZWGk541x1MDAwNEPcYlxcUVxyfZVUxGbXXGZWlTIrlt2LXHUwMDFj1URcdTAwMTa4otJm/bhcdTAwMTaCY8VcdTAwMTJyu8CWXHUwMDE5YWX69Yh9ccbrWFxu5pxLLWfZXHUwMDFk/1x1MDAwM1x1MDAxMWT2XFyfe+1C1tDiaFx1MDAwNDFrsFx1MDAxMGdUSCRcdTAwMThmsW9cblx1MDAxOMZcdTAwMTBpKUpcdTAwMTDFZKDJhIVcdTAwMDK5JmhkZ/q1kcWuIJDm3OhJKkFcdTAwMTNcdTAwMTa7Ti5uXaWglWG+5pgw3ClcdTAwMDNYekEmtVx1MDAxZUPgxSPIqPlxzr7/RV+454+41vNJcLHfPi61v8w+zEvGnS1HXGaLgtM0a69cYkOWXHUwMDAy41x1MDAxMvHR3P7WXHUwMDE0Slhcblxy5iB9JHL9clvlhJPE3bRiXG6O5m1Ozlx1MDAxNyNmtlx1MDAxYlxisuA5XHUwMDE5Re7iKIlGgn6U4uSfXHUwMDE32jLWtlx1MDAxNjbswE6kMzHYOFxynVx02500LjPSlXHiXHUwMDEyXHUwMDE3Zzb0wXX6d+9wRKXgU3hv9pteiPfOtnJcdTAwMTL6Oemkfe/VyFx1MDAwMlx1MDAxNEZcdTAwMTVcdTAwMTWDLT5SXdhcdTAwMTZSzu7CXHUwMDEyKFx1MDAxMVx1MDAxZONE0fYyzII0XHTBXHUwMDE0ULmEfKomp2QrwybpQjey+IDr5URcdTAwMWPZyWAkt1x1MDAxM4Q0ZYJgsGdI8pLE7lx1MDAxYS6WXHUwMDFjnZFY1FwiSSOMhvelpFmMj7hmXHQrNyUgXlx1MDAwNlxm6nVDVDkh0ypcdTAwMDGPXHUwMDA04zXHxqTdzlx0cmCduj5cdTAwMDTcQGKqRP4pIPZ3XHUwMDFmO8ebN279WpGe3fSa8uveSkBcdTAwMGXKIGhNXCJcdTAwMGU4YVx0PTZlfL6AQyVstzNcdDiEXHUwMDA2PqfVor9cdTAwMDOgyHF9yVx1MDAxOEY46lDReOOf9bb3j/Cfa2+J3lx0llx1MDAwMXYkSDVcdTAwMWL6ICT1SzpBzYarSZp/8CP7xS8z/CDMopQq8DetTd1mtIYhIMAy3Z/XVVx1MDAxMPxcdTAwMTDaklxcKjNcbkkoMPforUQ1XHJhXHKG/CZohVx1MDAxMpJDXHUwMDFlQ1x1MDAwYl2GMnBEOosj5kRcdTAwMWbZeWFtZLyDcLNcdTAwMTTCbIigXHUwMDE0I2RytENZ/bGOXHUwMDE5sUfuQVx1MDAwZVx1MDAxMMWkWbOzXHUwMDEzXHUwMDEz1HzPk1x1MDAxMlx1MDAxM7LggbxJ0qxcdTAwMTLqSLVZc2xcZs01XHJyfHpteL3a6VRCsK2h/sF8nfprpI56t9517N5mklf1XHUwMDBmXHUwMDEzXHUwMDAw+3o0YcY2ffzzr09//Vx1MDAxN7xqwPEifQ== + + + + Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install")match these*don't* match this \ No newline at end of file diff --git a/docs/images/dom3.excalidraw.svg b/docs/images/dom3.excalidraw.svg index d0ae9dc48..86fb5e78d 100644 --- a/docs/images/dom3.excalidraw.svg +++ b/docs/images/dom3.excalidraw.svg @@ -1,6 +1,6 @@ - + - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daVPbSFx1MDAxM/7Or6DYL/tWXHUwMDA17UzP1bNVW29xXHUwMDA2QjhcdTAwMTKShfDuVkrYXHUwMDAyO/hCXHUwMDE2V7b2v789XHUwMDAyLNmS5Vx1MDAwM8sxVXEl2JbGo9ZM99NP91x1MDAxY/pnaXl5JXroXHUwMDA0K78vr1x1MDAwNPdcdTAwMTW/Ua+G/t3KXHUwMDFid/w2XGK79XaLTkH8vdu+XHQrcclaXHUwMDE0dbq///Zb01x1MDAwZq+CqNPwK4F3W+/e+I1udFOtt71Ku/lbPVxumt3/ur9cdTAwMDd+M/ij025Wo9BLLrJcdTAwMWFU61E7fLxW0FximkEr6lLt/6Pvy8v/xH9T0lXrfrPdqsbF41x1MDAxMynxrFx1MDAxYzx60G7FonKJxnBcdTAwMDZK9UrUu5t0tSio0ulcdTAwMGKSOEjOuEMr65vXp+HOw1nrWMnt3T/h+Obcvk0ue1FvNI6jh8ZjQ/iV2k1cdTAwMTgkZ7tR2L5cbk7q1ajmrj5wvPe7bpvaIPlV2L65rLWCbrfvN+2OX6lHXHUwMDBmdEyx3kG/dVx1MDAxOVeRXHUwMDFjuY9cdTAwMGKAx4VcdTAwMDLFuJHKMpM0SPx77lx0xtFcdTAwMWFcdTAwMDFcXFx1MDAwMupBuTbaXHLqXGKS61x1MDAxN1x1MDAxNr9cdTAwMTLJzv3K1SWJ16omZVx1MDAxMCqWp+757vluUXhSXG5cdTAwMDC6jFx1MDAxNZZcdTAwMWG9V6RcdTAwMTbUL2uRK1x1MDAwM+Ahk1x1MDAxYY16vFRKlCDuXHUwMDEyroCD1Fx1MDAxYXXvjFx1MDAxM6CzW42V4+/BJq35Yeep6Va67ktKeCf31qBmpbUr1e2oP63Zb2entUq4y46Ozk4++Se6V1efKkbBfbTSO/Hvm6Jq9z/sfv+yfX9cdTAwMWGFm8Fq7bh7akBe5Ffrh2H7btx6S1x1MDAxMnfm1faVfjPuXHUwMDA1k2qfPiXdftOp+o/Gy7VmXHUwMDEySM2kXHUwMDE20DvfqLeu6GTrptFIjrUrV4m9L6XkzcBMn5xpjGF68GiCMVxmXHUwMDE1WIvjY0xxMy8qxphcIowhkPWA+kNyZl5cdTAwMGUyUei3ulx1MDAxZD8ky81cdTAwMDFcdTAwMWEzXHUwMDFhaCBcdTAwMGIsJJxcdTAwMDXGU85iZsAySj3FXHUwMDA06ploQbtcdTAwMTVcdTAwMWTXv8cqpj3FUTLQXGaMtUyZvlLbfrPeeOjr2FiPSfK1TufX/6Sbulx1MDAxYpBcYnGdqq/wWqN+6Vx1MDAxNH2lQjdcdTAwMTWEfTZcdTAwMTDVyWn3XG4069VqI6WPXHUwMDE1ksCnOsPdcfxnO6xf1lt+41OfgIU2XHUwMDE5XHUwMDA2lehRJ3NcZlNpXHUwMDFjapjM6SHTYmy7hPaHnVx1MDAwYnz43Dz/tLbbrFx1MDAxZNTU17ujRbdLbjxcdTAwMGJScNCPdtlnlkCqY0BcdTAwMTj6/+j7y7NKlWOGaDyyUimMypijRMtcdTAwMThyhbM3x1wiXHUwMDBm91FfXHUwMDA0b+/Mt9rHk+uT980zqbauXHUwMDFii+vnT979eXv3Xm7uv1x1MDAwZlX17Vx1MDAwM9yA3Fx1MDAxY+KQJ6pcdTAwMTdOqrs721eVfVxck/xTs3G41Tq7nEG9JTVvSdWa2/rO9vVcdTAwMTZvhltfq5vRLdxcdTAwMWU2ZtFcbpKFXHUwMDFk3rqtXHUwMDFjnGzvXHUwMDFk4MnmSXs/2ntRvaN4VH5cdTAwMDONKe75t+baUeNWc1x1MDAxNa2uV+3l3kH7/mE8ccfgZ1x1MDAxMlx1MDAxNSaQVVx1MDAxMj+TWlxyXHUwMDFlfXZcdTAwMDNgXHUwMDE5XHUwMDFhzVOEZJRcdTAwMWIo1rZcdTAwMDV1XHUwMDAzXHUwMDE0WVx1MDAxNbhcdTAwMDGJnqDusM/srDw3IPPYWOKCn+HfIChHZUpcYvNmqYx5bFxmWN/RXHUwMDAy9nVcXFx0g6A1jICZvvIzI2AjSMwgXHUwMDAx68lYaHePdp9jeGY4/VJGWqGNYWNcdTAwMWJesVx1MDAxN/0hhsfZSMuzykNOt2ryLI9cdTAwMWLrXHSXhXpcImCqNMtjnrSa2TTX6lx1MDAxOaCQnjWglYYsXHUwMDExU8KikFLD5JZcdTAwMThLN40lcqNcdTAwMTVcbmanscRu5IfRer1Vrbcu6WTiz8hcdTAwMWErN+66q8xjgmslXHRejFwiVGRJs7vb8zuu2yiGVFx1MDAxNEYqpa1cdTAwMDQtbarEUzqyKKB5KtxzrCtBqzpSKGZccumToUiO/imT2E5PKvAoyotcdHOcXHUwMDFk48JcZlx1MDAxMyvfzDNiNfxutNFuNutcdTAwMTE1/1G73opcdTAwMDabOW7PNWfftcCvXHUwMDBlnqXbSp9cdTAwMWJcdTAwMDSCjquxn3Qnn5ZcdTAwMTNLib/0Pv/9Jrf0alaF3SulvElcckvp96lcIkfOJVx1MDAxZjzc41xm2mpkXG5cdTAwMTJbXHUwMDFlXHUwMDA1Xc29XHUwMDEzOHqAnc2Nz98q619gXHJOT4JcdTAwMWZcdTAwMGJdclx1MDAxNHJcdC48JYhcckgrXHSfUqFY/HOBXHUwMDFlMmTWclxyyNjChI5aKuJ0XHUwMDFh5pwhZvdcdTAwMWLne239da+K17dcdTAwMWZcdTAwMGY6XHUwMDE3/OO+/Fx1MDAxOTnOKnIsqXlfV7WzXHUwMDBmSEdcdTAwMDWO+TeSVPuMs1x1MDAwNZya6FxylJ6A50yJwcM9pomKXHUwMDExjFx1MDAwMSZ8Ylx1MDAxNFxcXHUwMDE399+iwjVcdTAwMTbBtWGelVx1MDAxMolw8lx1MDAxOK5cdTAwMTcgxlx1MDAwM1wiPIapOWfcJ9XHl8V4O0SMgnDOMd5cYrYxXHUwMDE44/VkfFx1MDAwMVsydqj9XHQruUBlx0+0721cdTAwMWNdblx1MDAxZnDT1lx1MDAxN592vn74UqvU3v/gXHUwMDAxsJHmZ8B4TDFcbvE0Q2FS9CP+udSeXHUwMDA1UMxqYYi4s/JcdTAwMDK9XHTpXHUwMDEyUMdIiirmy5a+sm3c/3pzuvd9TW802frNR3Vz+pMtzYotldS8P6v9Idn7/Fx1MDAxYpmMhCHoSYaZpyNhJuVcdTAwMTdcdTAwMDecXHUwMDAwRWTkXHUwMDAzmFx1MDAxOT/NXtx9XHUwMDBi6lx1MDAwNFx1MDAwNFx1MDAxNDlcdTAwMDFj6Sy5XHUwMDAxXHUwMDE06JzAj2ZgwFxi+IkozjvLPqkyvoyBbbfb0dxcdTAwMTnYXGJcdTAwMDYzyMB6Mlx1MDAxNtrd0Cw76qF2x612lEOOP/uoOMhcXNDhLeSetYxJyyTjTNs+u1x1MDAxM8x6WljF0Fx1MDAwMirDpSzP8iyFWZZcdTAwMTTcauDS5Nkhd9N1pGCCXHUwMDEzQCDX3Fx1MDAwZdolV3RcdTAwMTRlOkYrP+dcdTAwMGVu+lx1MDAxMJ/OLofn3MfISSdcdTAwMGUvnVxml4ojs0pcdTAwMTGUumReMiS5nGTotVx1MDAwNLDPY5ZCPFx1MDAxNVx1MDAxOJ1z71x1MDAxM6o4VupcdTAwMTeK+opgW2qFYIyxWZk4eFx1MDAxYZRSiJpLXHUwMDBlVmZkelVcdPeh2uxeWT1O6ltKv09cZmdcXFg7eDhJ5nBqeWPV+NFkMWFbTEDTQpO2gVx1MDAxMlx1MDAxMoTVSvQnc1x1MDAwNOdcdTAwMWU3glTMkielt1x1MDAxMlx1MDAwMU1cdTAwMWJcdTAwMGaZXHUwMDAwQzpcclx1MDAxMnNcdTAwMDGNXCJfXHUwMDA3V8YoXHUwMDAx3M1ozFx1MDAwMJo0KFxiXuS8XHUwMDAxTVxinlxccqaDiMzjllx1MDAxYlx1MDAwN1KCblx1MDAxONBKkVx1MDAxOMdcdTAwMTNcdTAwMWFQs1x1MDAxOIqugTOJqJRIl5hmsG7UXHUwMDE4XCLznIujOyfvoVx1MDAxNFfSZERCT2k3PMKNc4RcdTAwMDJxmEj5XHUwMDA05lXD2VBVjk9mlHhCNCtOj1x1MDAwMVx1MDAxYppcdTAwMWWzgq4oUI5cdTAwMWZcdTAwMTmJSttXyn/7/ayCzfUv3/13m2Zn4edBkLsgR+5Gr4FcdTAwMGLGk/Z4zI9JT1JQRKeoiOZ8cYZcdTAwMTPd3DBcdTAwMDN8zlx1MDAxM1E/76rLi7PV+mlwcVf5/PC+I++3uj9cdTAwMTNks0qQldS8r6vasua3zlxc3FFcdLL8XHUwMDBijintXHUwMDBiprdcdTAwMTbW+/5CVr59hofK2cl5o1U9asrKOc6g3oZd38Dzt1x1MDAxYnJD6+D409795fb23Vxm6jVrb6+luN7eOjq8W1+Ve62r99ers5nmy602nHzuXHUwMDA0VKzQ11x1MDAwZU1AXHUwMDE2zNlBci8oUzRtpJstVuNFdbPAi9ysobBcdTAwMDKEkYyZst3smKPA2iqSRqemgc4hXHUwMDA3SXxLSD2/mb5cdTAwMWLP2cE5pyFHMMXBNGRazEJcdTAwMDNcdTAwMWNcdTAwMWW62+FcdTAwMTPtieVcdTAwMWEhJshEXHUwMDE2I/OCXHUwMDFhoGZIVJIhWKuFS11cckTuwrNCM0JE11x1MDAxYVCe/Vx1MDAxOY9CXHUwMDFkkOCWYlqBPGuMnDNPoFx1MDAxMprOXG5AQoSMcTKKcSU3dlxu45x+8q91f+WiJFwi3Vx1MDAxYU0uqcNcdTAwMTQ3wrDsTGFLXHUwMDFkisgsMlx1MDAxMExY85xcdTAwMDabMFx1MDAwZllsqsvpKJ9ax6IlU1x1MDAwMskpmskmXHUwMDFlgHlcXCstXGZS90qDxmRkek2B++owXXavrFx1MDAxNifVLaXfp1x1MDAwYtwxZcDZld1cdTAwMDKMnGT3iMr18em73Xux/e78y1x1MDAwN3//4nyfXHUwMDFk/uDdI0YjmmDM01xuiTxcdTAwMTljkEnRzym0XHUwMDAwjyxESVx1MDAwM2CBWzMg2CxzkVx0XFwmkXtcdTAwMTKYP6OWS6coTK1lmEvE/j26U8HbI92tvjPHd3uXt1x1MDAxYuuH6z8j9llF7CU17+uqtqyI/XW1wuwj9lFcdIb8XHUwMDFiXHUwMDE5U9xcdTAwMTckXHUwMDAynlx1MDAxZNjwQFx1MDAwNriWc1xirClcXFx1MDAxYeZcdTAwMDYtQyEm2kOpWC1cdTAwMTbWXHUwMDBiqkIvqPW8vKDK8YKpULRcdTAwMTdYk1x1MDAxYnSryEpwg0X6XGKAqWh22lxyTcZccqyPI59cdTAwMDLiX+vVP/5yhJU+879WhsTYsu/XM4uxR3C6zILafIlfQFHTM0iye5y4had8fNvUXHUwMDBm91+rXHUwMDFiXCJsrTeOb2tX61x1MDAxN3tcdTAwMDerm4tum1x1MDAwNlQ8YYSjXHUwMDAyZ5v9MTfFQVx1MDAxZVx1MDAwN2GdbpZsmqksVlx1MDAxMUG1nFx1MDAwYskgtVx1MDAxNnguXGb1y9rmVq0tfd3Qhydh5/BD1P0ofjLUWTHUkpr3dVVbXHUwMDE2Q31drVDWmNLMxVx1MDAxZEV88y84ri6UN6JcdTAwMDSEnsrMYUq7wqH5XHUwMDFmIF+hUE2ys19x/y2qd1x1MDAxNazIu1x1MDAxYT4v76pzvGuW+Fx1MDAwMudMS21Se/zNhfhKzuwkgdisia+YN/FcdTAwMWRBXHUwMDE1R1x1MDAxM1/xQuJriqa9K1x1MDAxMG63lfGHe1x1MDAwZnbO168+s93V7cNwq77aklx1MDAxZj4/bC26bT5u7WvFMOarXFxuXFxLZk3ZzNdmbTOH+So01C3p5Y9zYb6dndbdl6vD860qq22v7q5cdTAwMWWyxun+T+Y7K+ZbUvO+rmrLYr6vq1x1MDAxNcpivjNcdTAwMTd3XHUwMDE0882/4JjSvmCO1jjMV9tcdTAwMTS5KW0xJ1x1MDAxZr6jhtulyk2uXHUwMDFmf85ycf8trHc1Rd5V23l5V53jXXNSvtxKYzXyOad8XHUwMDA1QzaJPs6a+cK8me9cYqo4mvnCSOY7fIZcdTAwMTWDoeleo5XkWvHxSW/x2NSimqVcdTAwMTFcdTAwMWXnXHUwMDAyXHUwMDE5XHUwMDAzjZha5vL4OFx1MDAwYsk9dKEogFaY3l5m1mZcdFx1MDAxYz2rJVdMKWZcdTAwMTnkzLFC4VmSXHUwMDEwXHKXbstBlZn/aJixhlx1MDAwMpUpTHb6lVGTj1x1MDAxYabkXHUwMDE4a4bVyNlMT+slmcfc0z6ElPSPgDQ9vSq98Fx1MDAxMlx1MDAxNEVcdTAwMGVcdTAwMTTRXHRjXHUwMDE4qimXelx1MDAxNo/U9ITSnrZcdTAwMTalwXhD2L5Vj2mZkJNLILfg+lx1MDAxN8CqjEyvaorVUF12r4xcdTAwMTYn1S2l3ydcdTAwMDYzmXpUweB8bVRMamPH37SrmHQtKpZZ8LR0XHUwMDBmbjDk5fVcdTAwMDDFUFJ6glCd9IsxkVx1MDAwZbJnjWXogVx1MDAwNnRrqZGT8ufOtJKee16CRaZJ7TH1hI8nLFx1MDAxM1Zay+U0w1ovWeU5KVx1MDAxZC5cdTAwMGLLqCM5tY9bQ2iVNiY9LzO9zSsxRlJu1EooRWaH04FZMVx1MDAwN0mBXHUwMDE5l0itxLgml8mEzlx1MDAxN8qtVVx1MDAxN2ThbmNcdTAwMTJLsuuMUK9cbs2GarN7ZfR4VmiGXHUwMDA1XHUwMDAz8WTEhGd2gm3mi0dPXHUwMDE2XHUwMDE0zjQwXHUwMDBmhKTgUVx1MDAxOWOkXHUwMDFkpGbMU4RikkmmXHUwMDE5pleDzJyaWdJpUnlcdTAwMDOcXGaOpy7VwzNLwihgbj9cdTAwMWXjXHUwMDE2JmTwLMZcdTAwMTb3uLHkx/NcdTAwMDG0XHRHtkpcdTAwMDI09Iwy0j2DRVAr8lx1MDAxNCNIrzgn43LLpVHYmGxPXHUwMDA3ZsVDXHQpMGNKIFpCTqdCyuSKZFAoTU3IXHUwMDA1XHUwMDE4Rjj7qqFsuFwiu1dGhYdB2dLTXHUwMDA1VvxOx4WjQa8zSKXr1acgPLnLldt6cLee83i/i/jlYte4PVx1MDAxZFxmXHUwMDA17l7/+Xfp3/9cdTAwMDNJ1VXAIn0= + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1d+1PiyFx1MDAxNv59/oop95e9VWO2+/T7Vt265Vx1MDAwYnVcdTAwMWSfOFx1MDAwYnrnllx1MDAxNSFcdTAwMDIjLyGIurX/+z1cdTAwMWSVXHUwMDA0Qlx1MDAwMihB2DvU7qhJ6Jx0n/Od7+tX/vz0+fOa/9j21v75ec17KLn1Wrnj9te+2OP3XqdbazXxXHUwMDE0XHUwMDA0f3dbvU4puLLq++3uP3/7reF2bj2/XXdLnnNf6/bcetfvlWstp9Rq/FbzvUb33/bfI7fh/avdapT9jlx1MDAxM95k3SvX/Fbn+V5e3Wt4Tb+Lpf9cdTAwMDf//vz5z+DfiHXlmttoNcvB5cGJiHlGjlx1MDAxZT1qNVx1MDAwM1NccuGCXHUwMDAzIzC4oNbdxpv5Xlx1MDAxOc/eoMFeeMZcdTAwMWVa29y+K3b2XHUwMDFlL5t5wXP7f0C+d212w7ve1Or1vP9Yf65cdTAwMDe3VO11vPBs1++0br1CrexX8TxcdTAwMWQ5Pvhet4VVXHUwMDEwfqvT6lWqTa/bXHUwMDFk+k6r7ZZq/iNcdTAwMWVcdTAwMTNkcNBtVoJcIsIjXHUwMDBmwVx1MDAwNeBQJkBcdTAwMTCquDBE8cHp4PvUYYRqo1x1MDAxOFBcdTAwMGVajtq11apjO6Bdv5DgXHUwMDEzWnbtlm4raF6zXHUwMDFjXqOhZGjkmfuvT6uZwzlcdTAwMDPA21x1MDAxOGZcYojBJVWvVqn69lx1MDAxYVx1MDAwMEdcdTAwMTMutVx1MDAxMs+3ipjiXHUwMDA1TUJcdTAwMDVQ4FLqsEmtXHUwMDAx7f1y4Fx1MDAxYv9cdTAwMWSt0qrbab9U3VrX/lx1MDAxMTHe2r0z6lhR54o0u5bnXHUwMDFi5sdlsVrq7JOTk8vCuVuQg7KGPNH3XHUwMDFl/LXBib++pFx1MDAxNXt4uv90kXso+p1tb72a71x1MDAxNlx1MDAxNfCb8cW6nU6rP225XHUwMDE5mTv3Yoeu/jLtXHLDYl9+XHUwMDBim73XLrvPwUulJFxco1trqfXgfL3WvMWTzV69XHUwMDFlXHUwMDFla5Vuw3j/XHUwMDE0sTeGMkN2RiGG6NGjr1x1MDAxMCOAXHUwMDE50IqEXHUwMDBlO1x0YtJreVkhRqVBXGZFXHUwMDA0XHUwMDAywoFTot6PMX7HbXbbblx1MDAwN1x1MDAwM3dcZs6oyThcdTAwMDNxXFxB41xmXHUwMDEwakK754Yr8/TO0Fx1MDAwYlpNP1978oKyXHUwMDFjQTUnIFx0KGOIUENX5dxGrf441LCBXHUwMDFio+VcdTAwMWLt9q//iFZ110NcdTAwMTOCMsXQxVx1MDAxYvVaxfr5Wlx0XHUwMDFmyutcZoWAX8OUPbigUSuX61x1MDAxMX8soVx1MDAwNS6W2dmfJn22OrVKrenWz4dcZkxccsmOV/KffXJMXFxcbkWS4pIqIyhorvTUgVx0rdO9XHUwMDFi/fitcX2+sd+oXHUwMDFlVcVV/2TZXHUwMDAzkyrHXHUwMDAwZ1x1MDAxNORzYFx1MDAwZcUloO8oYFxu/3/O/dmFpVx1MDAxOFx1MDAxM4daOVx1MDAxOKacKVx1MDAxMYtHrlxyIZpcbj3/eEzLcGfyxtvtq1x1MDAxZtWzwl3ha+OSi527+vLm+cLvf9z3v/Ltw69cdTAwMWRR3n2EXHUwMDFl8O2EhDxTuVAo7+/lbkuHeoPT80b9eKd5WZlDuVx1MDAxOVVvRsWq+9pe7m6HNjo7V+Vt/1x1MDAxZe6P6/OoXHUwMDA1Tjpt2rwvXHUwMDFkXHUwMDE1clx1MDAwN0e6sF1oXHUwMDFk+lx1MDAwN+8qd1x1MDAxMo9cdTAwMWFfQVOae/2jsXFSv5dU+OubZVM5OGo9PE5n7rLwMy7V6NFBXHUwMDFlkIgykis2fVx1MDAxZUh3tyXNXHUwMDAzKK1S8lx1MDAwMNdcdTAwMGXD9jCv/Cy7PMDH8TFcdTAwMTbDf6VBWDKTgc7Lmo9cdTAwMDFcdTAwMTk6msK/8qWO5zWTKJhcdTAwMWG6fm5cdTAwMTRsXHUwMDAyi1x1MDAxOaVgXHUwMDAzXHUwMDFiU1x1MDAwM+858MdEnoakwFx1MDAwM+CCXHUwMDEzXHUwMDFkYdyT4i49i35I3FEyMfCMcDTlhqlxgYcs1GFcXFx1MDAwZlximMgs8IjDjSQmyrVcdTAwMDbxx7hjXHUwMDE0SCEhTsRcdTAwMDQzmnEuYfZAXGasW3Qgdn2342/WmuVas4Inw3yGwVjq2fuuXHUwMDEzhzAqXHUwMDA1R3RRXHUwMDAyQZGE1W5cdTAwMWbPbdtmQ1x1MDAxMSlQR1xuIVxyXHUwMDA3yU3kipfeyDRF83LxILGuec3yRKOIUehPXG6lXHUwMDFj/idUqExcdTAwMDZWgYMyLyDMQe9cdTAwMThlKsms8VFcdTAwMWUzq+52/a1Wo1HzsfpPWrWmP1rNQX1u2PCuem559Cw+VvTcKFx1MDAwZbRticOkO/ztc1x1MDAxOCnBXHUwMDFmg9//+2Xs1etxXHUwMDE3tp+I84YlfIr+fJN0pJSz0cOv0MUlw3yKXjo1dDVcdTAwMGVcbnDyXGJ721vffpQ2L2BcdTAwMDOKXHUwMDA172Ohi09CLkaZI1x1MDAxOJJcdTAwMDFuOOJTRIpcdTAwMDVfZ9rRRFx1MDAxM2OoXHUwMDA0TcjSSEfJXHUwMDA1YGTDgnuIycPW9UFLXlx1MDAxZJT13f3ZUfuGnlx1MDAxZPKfynFeyjGj6l2tYucvSCdcdMfxXHUwMDBmXHUwMDEyXHUwMDE2+4qzXHUwMDFmLfAoiYDEXGJaY1wipVxiUtN3wKe33rKCtU5cdTAwMDNrRVx1MDAxY8OxNZShXHUwMDAxWC+BwFx1MDAwM6Q7iohcdTAwMGbocDczeOP7XHUwMDA03lx1MDAxZdJcIq+zYIE3gWuMXG68gY3v4Epa0KTo41x1MDAxMt2NSja9zDvYOqnkjqhqyZvzvavTi2qp+vWDx78mhlx1MDAxZlx1MDAxMlBkQ1x1MDAwNFx1MDAwNZ4kmqlcYvlcYr7OiaMkRiVjWiFtJ9nJvFx1MDAxOclcdTAwMTJcYoPijrxB3L2HK12RnD686lx1MDAxNVx1MDAwZp425FaDbPbORK/4kyvNiytlVL0/i/2QvvvxXHUwMDBmMjNcdTAwMDWbJem9jYIpZUZcdTAwMGZcdTAwMGYmQVx1MDAxMKlcdTAwMTjRfHpcdTAwMGWW3nxLmlx1MDAwNFx1MDAxOE1LXHUwMDAyXG5cdTAwMWNtXGZRQFnWSWA6XHUwMDBlXHUwMDA2XHUwMDA0oVx1MDAxZqniR3SyL46D5Votf+FcdTAwMWNsXHUwMDAyh1x1MDAxOeVgXHUwMDAzXHUwMDFiUyMvuZNd8sTIk8LYvs7pIy9dZC7p6JamXHUwMDBlhlx1MDAxNuGGcEKJNEORx4hxJDOCaFx1MDAwM1ooynl2kWdQaFx1MDAxOabBSKBcXI2LQ2rn63BGXHUwMDE4wlx1MDAwMGgqqVx1MDAxOY1LKvCo5lGVtrA+9zfFZXKf+1x1MDAxNH3SYcqLdoZzQTUxQiCY2s68cETyc9hDLzmAeVx1MDAxZLJk7OWCyX3uQ0alq6Vho7CtXHUwMDEwuLlcdTAwMTRcdTAwMWFcdTAwMTRmu7hNXHUwMDE0XHUwMDFjXHRCXGKtJeVcdTAwMTRcZo/ZtFJcdTAwMWTuid5sP3E/XHUwMDBly/tcdTAwMTT9OTOcof8ndr1TJrF6XHKfYT5lOmVbTkCTTDvAqMK4tLOhR1x1MDAwMY1Sh1wi2+DUgLQ/Mlx1MDAwNDTJXHUwMDFkaWPMoFSkhof9SpFZ29QhdpYrXHUwMDA1jf9Hc81gNpfSXGZcdTAwMTBkVlx1MDAxZtBe0YA41FBlQYrhXHUwMDAzXHUwMDAzeiRcdTAwMGKD41x1MDAwNVxyXHUwMDE0Nlx1MDAxMuproGiMXHUwMDE2KP4jV7xlsG7SXHUwMDE4XCJxbIojXHUwMDFjMHtcYkFcdTAwMDVXMZO0g4RcdTAwMDBcdTAwMTDFlE2ETOskk8ZcdTAwMTOYlYazRFdcdTAwMGVOxpx4XmiGzZ9cdTAwMDRmKFx1MDAwNdArpJl+XHUwMDFjMX0u1pJOgUA1ZFeYIEkzXFxhK1xmz1xyt8OMhklCMckwxSC7rmnpXHUwMDE4XHUwMDE0XtyuMEF6xWSYZFwi5Fxmg1xiXHUwMDA1kUJYZaBRxsWwjGDcc6pM+IyrimVvJmdAQFAk2kZgXHUwMDE2UiQ+e8Jgi2rkb1x1MDAxYUOKMKNeqcGM3Kx0ly/+vv/Acr9fX5y6hzfXh+R4N8EmgsmQXCJhMVx1MDAwMVx1MDAxZFHAY0ZR7mhO0dHAXHUwMDAwIKc0sNJwtp7ozvZcdTAwMTN35Fx1MDAxOfEstcNcdTAwMWaISOzroUhJXHUwMDE4g1kmdqW385KiXHUwMDFh5n5cdTAwMDdcdTAwMTBcbjhcdTAwMTOgsFx1MDAxMUKkeJ5cdTAwMWVhXHUwMDFjhjrBKNQoQI1cdTAwMWGxa55cZi1EzLDHP0wpr7jFgGk0KFSkXHUwMDBi6ep/8vvC2z2R3fLvKt8/qNxvbVx1MDAxZW/+7OqfV1d/RtW7WsVmNU9/tWrhXHUwMDFk0/RcdTAwMTPKnTQyMf5BpjT3x7fqU1x1MDAxOfLFYt4tnpeqV4WN+lnCmo2ZXHUwMDFh7eBcXO+c9HKHXHUwMDA3Z113vXrQudl4qO9OV+5rXpwjXHUwMDBiS02xyatJVeJyXHUwMDA14NoopF7TS4Z0d1vW5FxuLC25XG6kcotJrmJMco0ssHxNrlx1MDAxMrOrnbKbQXbNeiSFyqGjKSMpW69jXHUwMDFjv35v2lx1MDAxM7Xyv77bjVx1MDAxN+qtyve1783xIyxcdTAwMDKGylx1MDAxOVxmoNS9m2Hnn2l8ZVx1MDAwMmNcdTAwMWNcdTAwMWRfmWj5O6gwJ4mDL+jAXHUwMDAwis6wvcTR3vXm7Teyv5477uzU1pv89NvjzrJcdTAwMDer5Kg17LwzrpRcclZcdTAwMThcblZhuGNQh2CsZlx1MDAxZKxcdTAwMTFNXHUwMDFlMuF4b6TQilx0XHUwMDEzXHUwMDFkgF1cYlx1MDAxNW7vNftcdTAwMTe3x9c7ZVLNre+vXHUwMDFmk3rx8CdcdTAwMTWeXHUwMDE3XHUwMDE1zqh6V6vYrKjwatXC/KlwRuZOYtjjb5g9XHUwMDEzTi13v3/WKPTFjr7eJFx1MDAxN/6RPKrRq4TJ7TOVu9voXHUwMDFll76dNd2LgtgvbNx5lav1/nTlLlxyc+cscVwirFCEK1x1MDAxMd1cdTAwMTllXHUwMDEyXHUwMDE5SHe3pSVcdTAwMDNcIoVcZkhiXHUwMDFjvlx1MDAxODKgx5CBMczdjklcdTAwMTi7XHUwMDA2/O/M3PeQXHUwMDEwP1lcdTAwMGVcXP/VXHUwMDFlfybBpbrb7XpdZMLXPd9vNbuLJvFcdTAwMTPIbmyi+lxmXHUwMDBm8Vx1MDAwZT4vk/eM4VRwXCJcdTAwMDUjU4dweX/f39l+2uHXrFuu7l+oRv/SXfZcdTAwMTBcdTAwMTZ2N1x1MDAwMCUkl1JcdTAwMDCG8PB4nZLS4YBS1273xIBnuJXTlHzeLmoxoFx1MDAxNzyJfbt597he8lxi9b8+Ulwi+24+d978SefnReczqt7VKjYrOr9atZBcdTAwMTWdX61aqJvNLX29u8W3pPTy51x1MDAwN1x1MDAwZpVcXG5KevxG9TH+Qf6P2Hx0ksdo11x1MDAxZWFMaVxyYno6n+5cdTAwMTfLylx1MDAwNVx1MDAwNEvjXHUwMDAyii6KXHUwMDBijKPzKsZcdTAwMDVcZppIuFAruKp0eja/XHUwMDE5MN1cYlx0/r524SHx/fL8173bqblNXHUwMDFmKXG3Vyrh0yXzejVcXPiceP1cdTAwMDTSO8rr3/Y472D4KD6TwtpQ6+UgZpiR97Tlnp6ap7PKZvGylds4h0b+dNmjWlx1MDAxOe1IXHUwMDAzxE5cdTAwMDWFmEhXUjh2wi5+1FIwfCCUMY46fcG7QbKvxW5+s974arbyxe5cdTAwMWb8x5Ms/tzTY25cdTAwMTQ/o+pdrWKzovirVVx1MDAwYllR/NWqhflT/IzMnaRcdTAwMWPG33BKa98xvvDy28crh2h/cmxcdTAwMDVcdTAwMTNcdTAwMDXgTNLplUN6+y0px9CEpXFcZkVcdTAwMTbFMaZTXHUwMDBlVFx1MDAxOY5qLjqz6v9DOlx1MDAxY7XGUG1cdTAwMGZcdTAwMDOrs2jdMIFKT6FcdTAwMWImPUtqMKeLXHUwMDA2XHUwMDAxyX1cdTAwMDFcdTAwMDJQz3NcdTAwMDPTr0ms9o+L9fouK/hf20y15GX+zr1e9ohmmjtcdTAwMTiqWmKYXHUwMDA0fVx1MDAwMWIopIWRjkBtpbnKXFw2jFm6M2ZgwFx1MDAxMIFBRt+yd+l7ZMN957Kfq2z+qKpK+3ZrY+Obq9tnP2XDvGRDRtW7WsVmJVx1MDAxYlarXHUwMDE2spJccqtSXHUwMDBik3j4+Fx1MDAxYi4hX1x1MDAxNjqRL2thqN1cdTAwMGV++uSaXs1Lm1xcTVpylYQuKrnqMcl1XGZfJswgX1ZvWFx1MDAwNrs6dDnvu8hgXyaNn37byZ/vXHUwMDFmXHUwMDFmfbF/jM4+uet5Xb+WOokmXHUwMDFi0jyBSca283/rXHUwMDEzpcZ14uJ3mVx1MDAxONacXHUwMDFhLYDNsJAlfcXQkoZ1sP0/U6hcdTAwMTCQjGL0XHUwMDBl78sqXHUwMDE4c4BJxlXmKlx1MDAxOIyDXHUwMDE1zijeXGLsmmFcdTAwMTVcdTAwMGZyyZ3xu0RyorRcdTAwMWHaROpvseR9luXlQjBit8IhWIfcaFx1MDAxYbnqdTtcIq00XHUwMDExxK7GJnY19stcdTAwMDVcdEveh1x1MDAxZmOVlp0ne5L9XGZ8KCznU/Tn7JtcdTAwMDFFXHUwMDEy/ygz0NpcbrxcdTAwMTmW16RPiV5SXGKRXHUwMDAwXHUwMDBlXHUwMDE3WNtCWM+iw3tcdTAwMDFcdCZcdTAwMWNcdEoxXHUwMDA1zDBcdTAwMDZmxK45QlxikY7dt9A6ONGM6DFcdTAwMTBcIrD57Yt77IxcdTAwMDBjKJD4XHUwMDBiRYDYxXJcdTAwMWM+4IVcIktcdTAwMDEmxOFKcFCSSqVcdTAwMTFRTPw1XHUwMDFm2lx1MDAwMWk4J4Tad3wgXHUwMDE3TMeSJJPS59eOmKSEXCJUMlwiqNAmvpdcdTAwMTF37I5mTHGBXHUwMDE2XHUwMDBiyVTMpFVcdTAwMDKxZFe2n7hcdTAwMTPPXHUwMDBizCRJ3jaDM/vuVD3DsED6QMmSoplcdTAwMTLSXHUwMDAxu1x1MDAxZj/XytjN3IZljlx1MDAwMcegrmBcdTAwMTK4nYmhR+ya42ZAxNFcdTAwMDKBk1x1MDAwMlD7hoKw2kM+JFx1MDAxY5RiRiq7ZT5nXHUwMDEw31ODW9/h5i1vjV1mLJtcdTAwMDE4wM7+4kiJhGG2VaPvSVx1MDAxYeyJSKyu5VxiL1x1MDAwNONN67ehWfrowDBbXHUwMDAzKkBSXHUwMDEwOliCQlh8IzTlIFx1MDAwMCuqXHUwMDA3RGU1gSzRi4OTo/47L1x1MDAxOItsZVx1MDAxN+NklGKiYDO8TTF9ouiyolx1MDAxOJNcdTAwMGV7fte1XHUwMDA0VFx1MDAwNsNbPVtcdTAwMTSzkk4pad/LrNiIXfNDMbxcdTAwMTFcYlRnRnOmkVSNQzHlgFx1MDAwNo2pXHUwMDA1qN2bKbZcdTAwMDSKoZnYZvxvRsimXHUwMDA2seC9YkhcdTAwMDFcdTAwMDBZqaaMKjlmL0dKXHUwMDFkgdyIXGIuUPNgXHUwMDEye+N2s+lzI0esYsq+XCJcdTAwMDVcdTAwMThcdTAwMTNKo9aKb2kmXHUwMDFkXGZuJIlcdTAwMDSI3Ss0btMqYdl6ojPbT8yNk8Ds08tcctbcdtv2dnmD1kC/rpVf+lx1MDAwMMOnXFy7r3n9zXjc/XJcdTAwMTN8bMdXUJ9cdTAwMTaJPPusf/716a//XHUwMDAxsk3fXHUwMDAxIn0= - App()Screen()Header()Footer()Container()Static(id="static1")Static(id="static3")Static(id="static2") \ No newline at end of file + App()Screen()Header()Footer()Container( id="dialog")Horizontal( classes="buttons")Button( "Yes", variant="success")Button( "No", variant="error")Static( QUESTION, classes="questions") \ No newline at end of file diff --git a/docs/styles/background.md b/docs/styles/background.md index cdfe10486..8f4c2d09b 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -2,6 +2,10 @@ The `background` rule sets the background color of the widget. +## Example + +This example creates three widgets and applies a different background to each. + === "background.py" ```python diff --git a/docs/styles/border.md b/docs/styles/border.md index 03992b324..5f0dd1c4a 100644 --- a/docs/styles/border.md +++ b/docs/styles/border.md @@ -42,6 +42,12 @@ This examples shows three widgets with different border styles. --8<-- "docs/examples/styles/border.py" ``` +=== "border.css" + + ```css + --8<-- "docs/examples/styles/border.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/border.py"} diff --git a/docs/styles/box_sizing.md b/docs/styles/box_sizing.md index 6ef19eadf..b71d3eebf 100644 --- a/docs/styles/box_sizing.md +++ b/docs/styles/box_sizing.md @@ -16,6 +16,12 @@ Both widgets in this example have the same height (5). The top widget has `box-s --8<-- "docs/examples/styles/box_sizing.py" ``` +=== "box_sizing.css" + + ```css + --8<-- "docs/examples/styles/box_sizing.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/box_sizing.py"} diff --git a/docs/styles/color.md b/docs/styles/color.md index 88d02b34a..c7f035d3a 100644 --- a/docs/styles/color.md +++ b/docs/styles/color.md @@ -2,12 +2,22 @@ The `color` rule sets the text color of a Widget. +## Example + +This example sets a different text color to three different widgets. + === "color.py" ```python --8<-- "docs/examples/styles/color.py" ``` +=== "color.css" + + ```css + --8<-- "docs/examples/styles/color.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/color.py"} diff --git a/docs/styles/display.md b/docs/styles/display.md index 53ecaaaf2..e8b75ca86 100644 --- a/docs/styles/display.md +++ b/docs/styles/display.md @@ -12,6 +12,12 @@ Note that the second widget is hidden by adding the "hidden" class which sets th --8<-- "docs/examples/styles/display.py" ``` +=== "display.css" + + ```css + --8<-- "docs/examples/styles/display.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/display.py"} diff --git a/docs/styles/height.md b/docs/styles/height.md index b7ba4a0db..240d2c2e1 100644 --- a/docs/styles/height.md +++ b/docs/styles/height.md @@ -4,12 +4,20 @@ The `height` rule sets a widget's height. By default, it sets the height of the ## Example +This examples applies a widget with a height of 50% of the screen. + === "height.py" ```python --8<-- "docs/examples/styles/height.py" ``` +=== "height.css" + + ```python + --8<-- "docs/examples/styles/height.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/height.py"} diff --git a/docs/styles/margin.md b/docs/styles/margin.md index 584657301..1d9105dda 100644 --- a/docs/styles/margin.md +++ b/docs/styles/margin.md @@ -12,12 +12,20 @@ Margin may also be set individually by setting `margin-top`, `margin-right`, `ma ## Example +In this example we add a large margin to a some static text. + === "margin.py" ```python --8<-- "docs/examples/styles/margin.py" ``` +=== "margin.css" + + ```css + --8<-- "docs/examples/styles/margin.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/margin.py"} diff --git a/docs/styles/offset.md b/docs/styles/offset.md index 1c794c3f7..c038c8131 100644 --- a/docs/styles/offset.md +++ b/docs/styles/offset.md @@ -4,12 +4,20 @@ The `offset` rule adds an offset to the widget's position. ## Example +In this example, we have 3 widgets with differing offsets. + === "offset.py" ```python --8<-- "docs/examples/styles/offset.py" ``` +=== "offset.css" + + ```css + --8<-- "docs/examples/styles/offset.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/offset.py"} diff --git a/docs/styles/outline.md b/docs/styles/outline.md index 8d438a095..5eb4e2a20 100644 --- a/docs/styles/outline.md +++ b/docs/styles/outline.md @@ -36,6 +36,12 @@ This examples shows a widget with an outline. Note how the outline occludes the --8<-- "docs/examples/styles/outline.py" ``` +=== "outline.css" + + ```css + --8<-- "docs/examples/styles/outline.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/outline.py"} diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md index 8d0adaf52..34bff5560 100644 --- a/docs/styles/overflow.md +++ b/docs/styles/overflow.md @@ -18,12 +18,18 @@ Here we split the screen in to left and right sections, each with three vertical The left side has `overflow-y: auto` (the default) and will automatically show a scrollbar. The right side has `overflow-y: hidden` which will prevent a scrollbar from being show. -=== "width.py" +=== "overflow.py" ```python --8<-- "docs/examples/styles/overflow.py" ``` +=== "overflow.css" + + ```css + --8<-- "docs/examples/styles/overflow.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/overflow.py"} diff --git a/docs/styles/padding.md b/docs/styles/padding.md index f8f54477b..8d7ce5d15 100644 --- a/docs/styles/padding.md +++ b/docs/styles/padding.md @@ -20,6 +20,12 @@ This example adds padding around static text. --8<-- "docs/examples/styles/padding.py" ``` +=== "padding.css" + + ```css + --8<-- "docs/examples/styles/padding.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/padding.py"} diff --git a/docs/styles/scrollbar.md b/docs/styles/scrollbar.md index 9a11b1cd9..001501e0f 100644 --- a/docs/styles/scrollbar.md +++ b/docs/styles/scrollbar.md @@ -21,6 +21,12 @@ In this example we have two panels with different scrollbar colors set for each. --8<-- "docs/examples/styles/scrollbars.py" ``` +=== "scrollbars.css" + + ```css + --8<-- "docs/examples/styles/scrollbars.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/scrollbars.py"} diff --git a/docs/styles/scrollbar_size.md b/docs/styles/scrollbar_size.md index f6653c254..af6d256d4 100644 --- a/docs/styles/scrollbar_size.md +++ b/docs/styles/scrollbar_size.md @@ -14,6 +14,12 @@ In this example we modify the size of the widgets scrollbar to be _much_ larger --8<-- "docs/examples/styles/scrollbar_size.py" ``` +=== "scrollbar_size.css" + + ```css + --8<-- "docs/examples/styles/scrollbar_size.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/scrollbar_size.py"} diff --git a/docs/styles/text_style.md b/docs/styles/text_style.md index 3dc1037fe..3b60c13aa 100644 --- a/docs/styles/text_style.md +++ b/docs/styles/text_style.md @@ -22,6 +22,12 @@ Each of the three text panels has a different text style. --8<-- "docs/examples/styles/text_style.py" ``` +=== "text_style.css" + + ```css + --8<-- "docs/examples/styles/text_style.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/text_style.py"} diff --git a/docs/styles/tint.md b/docs/styles/tint.md index d2d412955..dc07f4193 100644 --- a/docs/styles/tint.md +++ b/docs/styles/tint.md @@ -12,6 +12,12 @@ This examples shows a green tint with gradually increasing alpha. --8<-- "docs/examples/styles/tint.py" ``` +=== "tint.css" + + ```css + --8<-- "docs/examples/styles/tint.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/tint.py"} diff --git a/docs/styles/visibility.md b/docs/styles/visibility.md index a6c843bf8..b066d4791 100644 --- a/docs/styles/visibility.md +++ b/docs/styles/visibility.md @@ -12,6 +12,12 @@ Note that the second widget is hidden, while leaving a space where it would have --8<-- "docs/examples/styles/visibility.py" ``` +=== "visibility.css" + + ```css + --8<-- "docs/examples/styles/visibility.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/visibility.py"} diff --git a/docs/styles/width.md b/docs/styles/width.md index 666552002..bf3af35e7 100644 --- a/docs/styles/width.md +++ b/docs/styles/width.md @@ -4,12 +4,20 @@ The `width` rule sets a widget's width. By default, it sets the width of the con ## Example +This example adds a widget with 50% width of the screen. + === "width.py" ```python --8<-- "docs/examples/styles/width.py" ``` +=== "width.css" + + ```css + --8<-- "docs/examples/styles/width.css" + ``` + === "Output" ```{.textual path="docs/examples/styles/width.py"} diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index ab50375b6..4255f1734 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -109,6 +109,7 @@ Tweet { /* scrollbar-gutter: stable; */ align-horizontal: center; box-sizing: border-box; + } diff --git a/src/textual/box_model.py b/src/textual/box_model.py index e05ace9e3..0e951a804 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -43,15 +43,18 @@ def get_box_model( gutter = styles.gutter margin = styles.margin - is_auto_width = styles.width and styles.width.is_auto - is_auto_height = styles.height and styles.height.is_auto + styles_width = styles.width + styles_height = styles.height + + is_auto_width = styles_width and styles_width.is_auto + is_auto_height = styles_height and styles_height.is_auto # Container minus padding and border content_container = container - gutter.totals # The container including the content sizing_container = content_container if is_border_box else container - if styles.width is None: + if styles_width is None: # No width specified, fill available space content_width = Fraction(content_container.width - margin.width) elif is_auto_width: @@ -61,10 +64,10 @@ def get_box_model( ) else: # An explicit width - content_width = styles.width.resolve_dimension( + content_width = styles_width.resolve_dimension( sizing_container - styles.margin.totals, viewport, fraction_unit ) - if is_border_box: + if is_border_box and not styles_width.is_percent: content_width -= gutter.width if styles.min_width is not None: @@ -83,7 +86,7 @@ def get_box_model( content_width = max(Fraction(0), content_width) - if styles.height is None: + if styles_height is None: # No height specified, fill the available space content_height = Fraction(content_container.height - margin.height) elif is_auto_height: @@ -93,10 +96,10 @@ def get_box_model( ) else: # Explicit height set - content_height = styles.height.resolve_dimension( + content_height = styles_height.resolve_dimension( sizing_container - styles.margin.totals, viewport, fraction_unit ) - if is_border_box: + if is_border_box and not styles_height.is_percent: content_height -= gutter.height if styles.min_height is not None: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index e2308912e..c4e57da0e 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -189,6 +189,11 @@ class Scalar(NamedTuple): return "auto" return f"{int(value) if value.is_integer() else value}{self.symbol}" + @property + def is_cells(self) -> bool: + """Check if the Scalar is explicit cells.""" + return self.unit == Unit.CELLS + @property def is_percent(self) -> bool: """Check if the Scalar is a percentage unit.""" From f19076ea57ed9a2be448b70923131967b65e621b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 15:43:29 +0100 Subject: [PATCH 05/73] words --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 556c88886..eef68e42c 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -351,7 +351,7 @@ We can use the following CSS to style all buttons which have a parent with an ID It is possible that several selectors match a given widget. If the same rule 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 move to the next rule. +- 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. From c1ba3a56b49c16b6c9d30af29ec6800534d4ed9a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 15:46:00 +0100 Subject: [PATCH 06/73] comment --- docs/examples/guide/dom4.css | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/examples/guide/dom4.css b/docs/examples/guide/dom4.css index 7640e8d94..014142f7b 100644 --- a/docs/examples/guide/dom4.css +++ b/docs/examples/guide/dom4.css @@ -20,6 +20,7 @@ Button { content-align: center middle; } +/* Matches the button container */ .buttons { width: 100%; height: auto; From 48a4ec92a0ebeb6e431a32a536bfc17b8f8cab21 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 15:47:52 +0100 Subject: [PATCH 07/73] specificity docs --- docs/guide/CSS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index eef68e42c..371a3c2fd 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -349,11 +349,11 @@ We can use the following CSS to style all buttons which have a parent with an ID ## Specificity -It is possible that several selectors match a given widget. If the same rule is applied by more than one selector, then Textual needs a way to decide which rule _wins_. It does this by following these rules: +It is possible that several selectors match a given widget. If the same rule 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 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 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`. From 9af8c8214af684286452e94c986e95838f98ae75 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 16:24:33 +0100 Subject: [PATCH 08/73] words --- docs/guide/CSS.md | 18 ++++++++++-------- docs/guide/devtools.md | 12 +++++++++++- docs/images/child_combinator.excalidraw.svg | 6 +++--- mkdocs.yml | 3 ++- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 371a3c2fd..4147913b6 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -58,9 +58,11 @@ You may be able to guess what some of the the other rules do. We will cover thos ## 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. The DOM is an arrangement of widgets which form a tree. Some widgets may contain other widgets. For instance a list control widget will likely have item widgets, or a dialog widget may contain button widgets. These _child_ widgets form the branches of the tree. +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 that forms a tree of sorts. -Let's look at a super trivial Textual app. +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" @@ -119,7 +121,7 @@ To further explore the DOM, we're going to build a simple dialog with a question --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; for instance the Button widget doesn't need any children. +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; for instance a Button widget doesn't need any children. Here's the DOM created by the above code: @@ -175,9 +177,9 @@ Being able to iterate on the design without restarting the Python code can make ## Selectors -A selector is the text which precedes the curly braces in a set of rules. It tells textual which widgets it should apply rules to +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 specific widget. For example you may want to style a particular button green only if it is within a dialog. Or you may want to draw a red box around a widget when it is underneath the mouse cursor. CSS selector allows you to do such things simply, without writing (Python) code. +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 customization your user interface. Let's look at the selectors supported by Textual CSS. @@ -270,9 +272,9 @@ Class name selectors may be _chained_ together by appending another full stop an } ``` -### Universal selectors +### Universal selector -The _universal_ selectors is specified by an asterisk and will match _all_ widgets. +The _universal_ selectors is denoted by an asterisk and will match _all_ widgets. For example, the following will draw a red outline around all widgets: @@ -351,7 +353,7 @@ We can use the following CSS to style all buttons which have a parent with an ID It is possible that several selectors match a given widget. If the same rule 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 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. diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index ff4dd5356..efb3a52c9 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -1,7 +1,13 @@ -# Textual Devtools +# 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. @@ -16,6 +22,10 @@ The `run` sub-command assumes you have a Application instance called `app` in th 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 dev mode which auto (re) loads your CSS if you change it. + ## 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 typically overwrite application content, which generally makes an unreadable mess. Fortunately Textual supplies a debug console of it's own which has some super helpful features. diff --git a/docs/images/child_combinator.excalidraw.svg b/docs/images/child_combinator.excalidraw.svg index c6d8e5c18..47e3b0d8e 100644 --- a/docs/images/child_combinator.excalidraw.svg +++ b/docs/images/child_combinator.excalidraw.svg @@ -1,6 +1,6 @@ - + - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXFlz4khcdTAwMTJ+71/hYF56I1x1MDAwNk3dx0RMbPjCXHUwMDA37auN2+3enphcdTAwMTBIXHUwMDA22Vx1MDAwMmFJXHUwMDA244n575vCXHUwMDE4iUNcdTAwMDJsRMNu68FcdTAwMDaVKKVSlZnfl1lVf3/Y2iqEvbZd+H2rYD/VTNexfLNb+DU637H9wPFa0ET631x1MDAwM+/Rr/WvbIRhO/j9t9+apn9vh23XrNlGx1x0XHUwMDFlTTdcYlx1MDAxZi3HM2pe8zcntJvBv6O/p2bT/qPtNa3QN+KbXHUwMDE0bctcdD3/5V62azftVlx1MDAxOEDv/4HvW1t/9/8mpPPtWmi26q7d/0G/KSEgoXT89KnX6kuLXHUwMDE1V1pTIeIrnGBcdTAwMGZuXHUwMDE42lx1MDAxNjTfgtB23Fx1MDAxMp0qnD099Ion3YdKuVavep/9ynHz83V831vHdS/DnvuiXHUwMDBis9Z49Fx1MDAxM1JcdTAwMDWh793b145cdTAwMTU2oruPnVx1MDAxZv4u8EBccvGvfO+x3mjZQTDyXHUwMDFir23WnLBcdTAwMDfnOFx1MDAxYZ580cLvW/GZp+hcdTAwMDKFXGZGXHUwMDE0XHUwMDEzSlx0qjRcdTAwMTk2Rr+miFx1MDAxYkQqhlx1MDAxOUJMXHTNx6Ta9VxceFx1MDAxMyDVL6h/xHJVzdp9XHUwMDFkhGtZw2tC32xcdTAwMDVt04f3XHUwMDE1X9d9fV4hhudcdTAwMWG2U2+EcFKp+H52X+taSUlcYmJy2Fx1MDAxMN2kfWT1R8Cf40prmH57oJxCXHUwMDEwfUlcYlx1MDAxOMm2Pz58kkMo8WJPmv4jXHUwMDE13Z3tg8OeuvO+7NZDb3fY18h4XHUwMDBi7aewMGz459esbpvfrsqVg9udTnj15bh70X0+qber07s1fd/rztvv9fGXTvdcdTAwMTPbO/nkc+ugR1x1MDAxZVx021x1MDAxM0vol1xcW0eHpfvaidpmuNJ0z/Zb3+pL6Dcn9W5Wt59cdTAwMWX3XHUwMDBlkf569Px0q8qlkvX0l9np/lTucrptfZG3/s3nfW831Oc3Vlvza3q7vsr1XHUwMDFmdWm3J87/ujLpzXn9yuLXx+fvXHUwMDEyd0SKX+d9kLjbwafYwz62LfMlXHUwMDEygutcdTAwMDaHTFx1MDAwNZFcdTAwMTAyh+2u07qHxtaj68bnvNp9XHUwMDFjPD8k5J1cYtsjco5EbCbGT79GbEkx1ZiyWIhZXHUwMDAxO/v1rWvA5lx1MDAxOVx1MDAwMZsgQ64mYPMpXHUwMDAxm8dxeVx1MDAxMLAhVlx1MDAwYiYwU3r5XHUwMDExe5mDMX7nXiu8dJ77XGJQjJwtmU3H7Y28tv4oXHUwMDA1SXeh2XRatv/xeytqcKw/vlx1MDAxNyzHdL3698L31r+Salx1MDAwZWyQJuqek5F+tl2nXHUwMDFljfCCa9+OXHUwMDBl/dBcdTAwMDHsO2xuOpaVRLO113tcdTAwMWbNg0E936k7LdOtzCt5ppVmg2vOdJqpYo6l1NExt60+7uxott9DpdO77daBkmH31D9ed1tcdTAwMTXSIFxuIXBMk7ZcbsPUoIwpgkBTka3maKxcdTAwMWFNXHUwMDFhq1x1MDAxMuPGyjSTRHKcg61mRbu9h9Kdf35y+HBR2it3Wubd1edvzk90vSx0nZN6N6vbvND1Zmmh4jf2utWOXVx06bGLiXq+v2h/+r/TQl5k4LBnysZ++3zvitzXXHUwMDFlcJtfXHUwMDFjnVhL6LcmTs75XHUwMDA1bZZcdTAwMGUtv1xcPC3unJZcdTAwMWFkXHUwMDFk39oskjH9hnG3g08/nmRwydKQXHUwMDBiYZhcdTAwMTJgXHUwMDE5c+OWbC2vKW6RJFx1MDAwM7coaahcdTAwMTXhXHUwMDE2NVx1MDAwNbdMklxmLalGVP1vc4xDgO7PXHUwMDExWnc/Rudf4HrNNYPAXHUwMDBlXHUwMDAws1dcdTAwMWbD0GtcdTAwMDWrplx1MDAxYjNQ+TjdWOQhMk03m3kolJrWJ4hyKplE82dcdKpdfdAs1kNdLfaO7KDcOzg6q665XHUwMDA1syitz6VcdTAwMTRcXEaGQUZNWFxiYmAsXHUwMDE5wpyjyHp+NPOQXHUwMDFjISU5x8u34KxcYrht99jBxZF3V6/sXHUwMDE2t692v1x1MDAwNpVuSp7tJ/NYvN+c1LtZ3ebFPDZLXHUwMDBieTGPzdKCq3d2VfVgl+1cbmFfVspP9VJpjcdCXsRj6eLOXCJcdTAwMWXTb1x1MDAxOHc7+JRcbvZcdTAwMTRcdTAwMDbAy2RcXI7Oi3gksfZ4ylRgXCJcdJE0hr+zgEu2ntdcdTAwMTW48CzgorkhVlx1MDAwM1xcplGPRGb0lXogXGZcdTAwMTRcYrFcdTAwMWOAS+ZoXHUwMDE0VNJcdTAwMDVG4/uox05cdTAwMWaWf/xeuLFcdTAwMDGbT6dcdTAwMTeYjfxsyFx1MDAxZmrwOLb/doIxXHUwMDAzfI9cdTAwMTOMcVEzzXBcdTAwMDaJ0KnlXHUwMDBiXHUwMDAxvlx1MDAwMCmE5zfFx+fDTuvs+fmK0UrP5TXR+vZcXFpzU1x1MDAxNFpcdTAwMWJIK+DWL6Y4RiGwIbVESqP1oFx1MDAxMJRiLbhKZCNWQiGuj47OP908P+xcdTAwMWNcdTAwMTYvvWLXXGafv9gnPynEsihETurdrG7zolx1MDAxMJulhbwoxGZpIS9cbrFZWsir1LJ0cWcxk+k3jLtcdTAwMWR8ysSCXHUwMDEyy1x1MDAxOIjkxEwoSiRMx9FcdTAwMTDCjPBcdTAwMDWmXWVreT3BkEQsXHUwMDAzXGZcdTAwMDEtIatcdTAwMDFD89FcdTAwMTKsmVwiIKbMYaJ05lhUmrNcdTAwMDXG4pJ4yam3aloyXHUwMDAzz6fSklx1MDAxN0kzTfDFYU2xQYlVqlx0XG6qXHRcdTAwMTJ0/ulU2UXw9bRBgPdcdTAwMDZcdTAwMDH7XHUwMDAzYsLBXHUwMDFmST5ihFxmYUNIojh8YFpcdTAwMTEpcrNCZFx1MDAwMNtAXHUwMDE4cy2ZXHUwMDE2ilPNJq1SaPBcdIxcYimUXHUwMDAwQ1STRopcdTAwMTFXXHUwMDE46FxyXdxI+8K+uW5JMHqLkVx1MDAwNqHphztOy3JadWiMXHUwMDAz3es6nHmmIPbNuvZcdTAwMTj01YhcdTAwMDS8RSo5QUC3XHUwMDE1uNDEVXWzXHUwMDFkXHUwMDExPVx1MDAwM5TLXHUwMDE4QpG+KZF4cMEw4Fx1MDAxNuyWNVum7DplQqZcIlxiRSVcInBHwjFcdTAwMTacq7jIO5SKXHUwMDE4VEqhgW1cdTAwMTKNwM+KXHSpXFwzXGJ3vWbTXHRB+eee01xux5Xc1+Z2ZOxcctu0xlvhqZJt416hXHUwMDFk9ThKTuNPW7Hd9L9cZj//+ev0q9OHc3RMXGbkuL9cdTAwMGbJ/1x1MDAwYjs0THDqPFx1MDAwYobAyJXC86OKbEy4plx1MDAxZU1ig4M9KiRhdLNYXHUwMDFi/V9LZGguhIryXHUwMDFhmiRSoUtHXHUwMDE1mFx1MDAxYVQoxUDnnI3M+IgzLshgXG5cdTAwMTBcdTAwMTBcdTAwMDM5KYyHxKyPQVxyV1x1MDAxM3hdYDUr9GZcdTAwMDP4u0hiflFvNrfnXHUwMDAw/VCOhYpsiICTXHUwMDE1LGFFXHUwMDAzv4EhROEoY8xcdTAwMTBDnFxi9kZvlok+RmVcIlRrsFmFkFBcdTAwMTTHXHUwMDEzl2KZwPy5ZphcdTAwMTNcdTAwMWUtrlx1MDAwM8E32pmlj+XomFx1MDAxOMVL82WIpJZuIIhgXHUwMDAxkXd+X5ZdeVtTX8Y4MCSEeVx1MDAxNFx1MDAxNylExzFnRlxmhkWUpeVaSDku1vKcmdbwilx0+EzNMUQzXHUwMDE2O4fYlzFcdTAwMDNaqYTrMCaCTswow+DJXHUwMDE4XHUwMDE4MF61M1uwypibMytGmIBxTFx1MDAwMGlcdTAwMTPwaIyiKd6MXHUwMDFhXHUwMDEwt1x1MDAwMCVcdTAwMDE5hlxixlx1MDAwNX2bO8uu8YxKxTXAf6wlJuDUNMFcdTAwMTNCcVx1MDAwM2A1OFgpUSTXpEib5MyKqYM5OiaG8YLOLLP+XHUwMDA16kvzZ5hTXHUwMDA1IJkv4NBOtFu+Kt/eP5Vvri8t61x1MDAwYjEvP31ec4fGotU7XCJcblx1MDAxNlx1MDAwNII2Slx1MDAxOGY/XHUwMDFkIUD7XHUwMDFj7Fx1MDAxNWCbRnBFfugsUdOKXVx1MDAxOMhcdTAwMDa3pnJcdTAwMDKHYY0l8Fx1MDAxMmAmi/uu91TC7rqVO8e9alx1MDAxZVx1MDAwN13NSdW1wpL75f2p3pOLo+eb0tPX0N+zi43L4KskbFx1MDAxObP1N61cdTAwMTKWk3pz6lZ2nMPSwz5u+vt/WXthh3TO3GVogSG/jVud2ul1qXyqrveuvZOwvL7ard41t8/djsA8LO5Yul4+9Z566yvu8tedXHUwMDBm1PD1qEzc4FK1z05cdTAwMDLWaTfKR/zqXf3OqqtMV1Dc7Wt8TMNiq1pqwnVqXHUwMDA2XHUwMDA0gDJcdTAwMTFcXCRz+LOibPawWNMoXHUwMDFirTVJj7JaXHUwMDE4XHUwMDEw0DAgPZ1zlGVToiyJU7Ov0ZVSqWmEtpZcdTAwMWZd815sQtDI2YzKymXNt+3Wx5Sailx1MDAxY7l+aTWVXHUwMDE5XHUwMDE4cbymMpQx0/DSXHS7St1IXHUwMDAy84hZkEXKKdmecz0tj3NsXHUwMDAwXHUwMDFkh0dlhCrCR8sp8N1QUolcdTAwMTVYXHUwMDFlxsAjXHUwMDE1JVx1MDAxYZimklx1MDAwMslJQ2Q8qlx1MDAwM3AwvFx0vEtcdTAwMTBihGkt3oB317mMkm1cdTAwMGVbySRcdTAwMWbXUoBf4sDTXHUwMDA1JTxBXHUwMDEyXHUwMDA3pJhcdTAwMTmIg4pBe4OWXHUwMDA1XHUwMDE5+lx1MDAwMiVcdTAwMWSBkVBA0Fm0w1x1MDAwM51S0Vx1MDAwMVlcdTAwMTCQWcQ5V4xSrSdE2iSGnj54oyMxbOOOPiT/v21uKs0g54QhpcFcdTAwMWXmX6J60366dO5p9eaKVcKLg23SuT/7uubOi1x1MDAxMmmAXHJyIOlTYEO0cVx1MDAxZFZRMVi/kPNcdTAwMWa+c51cdTAwMTKMkdWvcDvZZ6VcdTAwMGKl9m5vrWq3V2l422ElhYD8nJ66eL85qXezus1t57qN0kJO3ea2c10+4uaVQchJ3Iv6Q/3Cvn4gWn5zr06bgdm2U/Ioy9pob+qDxN2+goNcdTAwMWadmCBcdTAwMTSn794liOpPd5w/M5H9/tZcdTAwMTRiUJJcdTAwMDUxXGJg3Vx1MDAxNUGM+fbaw1hyRCmg781LTbxzr73Aseyq6a9694tcdTAwMTnAea7N9kZEzzTVbEqQnJEzbq+YXHUwMDEwhlVy2+RZ9rpz3PIwXHUwMDAy/mXT4+pTpYyvdMlOsdcxu1x1MDAxYrXW8UlLb7VWjGaaK9ZcdTAwMDZDmlx1MDAxMfxirqPzXHUwMDBmmCCGoIIjJt6fzvhcdTAwMDWTqlJCqSm2KqdkL6ZseIGJRCw5hXUldOBi//hcYj3J8u3ZtYOvXHUwMDFi5/pb7+bgJ1x1MDAxZFhcdTAwMTZcdTAwMWTISb2b1W1edGCztJDXarXN0kJeq9VyXHUwMDEyN69cci8266Wtnlx1MDAxNE1/kLjbwaeMmXOIckziKJvbKjiaXG6yXGJAb81cdTAwMTWW80+Kyn5/P4RcdTAwMTTNXHUwMDAxs1xiy4JZUlx1MDAxYXhZMGtG3nVcbtCaslx1MDAwMblkTFx1MDAwM1bLXHUwMDAxaGVcckdMNFpkUeb7ONFgeVl07oVZfC9cdTAwMWO1gtB03VUzolx1MDAxObwhZV1cXKrgmVaaXtplKNVKMVx1MDAxM0hcdJxcdTAwMTiYM7f/y5y8sp6pXHUwMDBizrghXHUwMDE4lXpq6oJwYURcdItcdTAwMTVcdTAwMTgpY4Yk0TRcdTAwMTYyZXZcdTAwMDXTXHUwMDA2XHUwMDE3giExWdSlXHUwMDAygSGRt+zm8T9R0y1GySVNhNBcdTAwMTT8rMZcdTAwMTRPK6QySrmmckZRd1T+XHKqrVx1MDAxNqdccp/oSFxmnLiPXHUwMDBmyf+L+1xmnl5RRVx1MDAxNMlode38gT1cdTAwMTNBrafLYJhcdTAwMThcdTAwMTSM/TXbOWV1LcLRaMx7OlxiMlxiR5ow8NJEgFx1MDAwYkjMwYrX1kbzXGYkVlNmP1NJQTz0loD/jpVcdTAwMWL9gFx1MDAxZkOh5XuO7FTjqOdAXHUwMDA0ScRcdTAwMTlcdTAwMTVcdTAwMTJcdIbjvcmGnkNcdTAwMWGKXHUwMDAyXsXRtFx1MDAxOTimLJKYa1ZIdrDfXHUwMDFhWehcdTAwMGLiROuxiIrmXHUwMDE0kcT2u0OhJlf2bpKvSlx1MDAxZrXRXHUwMDExj9c0f/Vh0HPBbLcvQ1x1MDAxOFxcQ73D+HWsXHUwMDAxJIxcdTAwMWav0HHs7s6U1ORt/4iQU1+RkbOxo4f8+59cdTAwMGb//Fx1MDAxN3ArIFx1MDAwZSJ9 + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPayFx1MDAxNv2eX+HyfMmrXHUwMDFhNL0vUzX1ylx1MDAxYl6It1x1MDAxOMdxXqamXHUwMDA0yFi2QFjIYDw1//3dxlx1MDAxOFx0kFx1MDAwNDiIQCaaqlx1MDAxOCTRurp9l3P6dvf8/W5jYzPstZzN3zc2naeq7bm1wO5u/mrOd5yg7fpNuET639v+Y1Dt33lcdTAwMWKGrfbvv/3WsIN7J2x5dtWxOm770fba4WPN9a2q3/jNXHKdRvu/5t9cdTAwMTO74fzR8lx1MDAxYrUwsKKHXHUwMDE0nJpcdTAwMWL6wcuzXHUwMDFjz2k4zbBccq3/XHUwMDBmvm9s/N3/NyZd4FRDu1n3nP5cdTAwMGb6l2JcdTAwMDJcdTAwMTLKx0+f+M2+tJgxxKhiWFx1MDAwZe9w27vwwNCpweVcdTAwMWJcdTAwMTDaia6YU5unT1x1MDAwZr3CcfehXFyq1iv+x6B81Ph4XHUwMDE1PffG9byLsOe96MKu3j5cdTAwMDYxqdph4N87V24tvDVPXHUwMDFmOz/8XdtcdTAwMDc1RL9cbvzH+m3TabdHfuO37KpcdTAwMWL24Fx1MDAxY0fDky9a+H0jOvNkblDIYkQxoZSgSpPhRfNrirhFJOiAIcSU0HxMqlx1MDAxZN+Dnlx1MDAwMKl+Qf0jkqtiV+/rIFxcsza8J1xm7Ga7ZVx1MDAwN9Bf0X3d1/dcdTAwMTVieO7Wceu3IZxUKnqe09e6VlJcdTAwMTKCWNQn5iGtw1rfXHUwMDAy/lx1MDAxY1farVx1MDAxZLRcdTAwMDbK2WybLzFcdTAwMDGNbHvj5lx1MDAxMzehWMdcdTAwMWU3gkcquttb+1x1MDAwNz1153/aqYf+zrCtXHUwMDExe1x1MDAwYp2ncHN44Z9fs5ptfLkslfdvtjvh5aej7nn3+bjeqiQ3a1x1MDAwN4HfnbXdq6NPne5cdTAwMDe2e/wh4LX9XHUwMDFleSRsVyygXXJVOzwo3leP1Vx1MDAxNsPlhne61/xSX0C7Oal3vZr98Lh7gPTnw+enXHUwMDFiVSpcdTAwMTZrT3/Zne5P5S6m2eYneVx1MDAxM1xcf9zzd0J9dl1raX5Fb1ZXucGjLu70xNlflza9Pqtf1vjV0dk3iTtcIsWvs75I1OzgU1x1MDAxNGFcdTAwMWZbNfslXHUwMDEzQuiG5KBcdTAwMDX8p/Twuuc27+Fi89HzonN+9T5Knu9i8k6k7Vx1MDAxMTlHMjZT46eHXHUwMDE5XHUwMDFicoMkSNCZXHUwMDEzdnb3rWrC5lx1MDAxOVx0myBLLidh84SEzaO8PEjYkKtcdTAwMDVcdTAwMTOYxSxjYVx1MDAxOXuRxlx1MDAxOPW531xmL9znvj2JkbNFu+F6vZFu61spSLpcdTAwMDOXbbfpXHUwMDA07782zVx1MDAwNbf2x9fNmmt7fv3r5tfmf+JqbjsgjWmek5F2tjy3bix803NuRk0/dFx1MDAwMftcdTAwMGUvN9xaLY5mq6/PPpxcdTAwMDWD+oFbd5u2V55V8kwvzVx1MDAwNtec41RX5eDHWjOGZ/bVx+1tzfZ6qHhyt9XcVzLsnlx1MDAwNEer7qtCWkQhRDGd9FUmqUVcdTAwMTlTXHUwMDA0XHUwMDEx3vfVXHUwMDFjnVWjSWdVYtxZmWZcdTAwMTBBOc7BV7Oy3e5D8S44Oz54OC/uljpN++7y41x1MDAxN/cnul5cdTAwMTS6zkm969VsXuh6vbRQXHUwMDBlbne7lY5TXHUwMDBl6ZGHiXq+P299+NdpIS8ycNCz5e1e62z3ktxXXHUwMDFmcIufXHUwMDFmXHUwMDFl11x1MDAxNtBuVVx1MDAxY5/xc9ooXHUwMDFl1IJS4aSwfVK8JavYa9NIRvJcdTAwMDOjZlx1MDAwN5++P8ngUoyfXHUwMDFlXCJcdTAwMTdcIjHVUszBMrL1vKLIRZJcZuSipKWWhFxcVFx1MDAwMnKZpFx1MDAxOVpSjaj6sVnGXHUwMDAxgPdng9e99+b8XHUwMDBiYK96drvttFx1MDAwMbVXXHUwMDFlw9BvtpdNOKbg8nHCMc9LZDpvNvdQKHVgXzGwXHUwMDEzhYme2YErXb3fKNRDXSn0XHUwMDBlnXapt394WllxXHUwMDA3ZmZcXJ9LKbg0fkFGPVhcYmJhLFx1MDAxOcKcI+M835t6SI6QknHGuFx1MDAxNOqx5fTY/vmhf1cv71x1MDAxNLYudz63y92Ugbaf1GP+dnNS73o1m1x1MDAxN/VYLy3kRT3WS1x1MDAwYp7e3lGV/Vx1MDAxZLYjhHNRLj3Vi8VcdTAwMTW2hbyYx8LFncY8klx1MDAxZlx1MDAxODU7+PT9mYfSLJV5IFwiIFczOfuYabaeV1x1MDAxNbjwLOCiuSWWXHUwMDAzXFySmEdsaPSVeSBcZlxmXGKxXHUwMDFjgMtcdTAwMTRrjFx1MDAwMau8mcd2XHUwMDFmlb//unntXHUwMDAwNE9mXHUwMDE3mI38bEhcdTAwMWaq8DpO8HZ+MVx1MDAwNXyP84txUTPdMJtDQNem+lwiVlxuXHUwMDBijsXsJOLx+aDTPH1+vmS03PN4VTS/PFx1MDAxN1fcXHUwMDE3wcgspFx1MDAxNXDrXHUwMDE3X1x1MDAxY+NcdTAwMTDYklpcIqXRanBcYkqxXHUwMDE2XFzFRiOWwiGuXHUwMDBlXHUwMDBmzz5cXD8/bFx1MDAxZlx1MDAxNC78QtdcdTAwMGWfPznHPznEojhETupdr2bz4lx1MDAxMOulhbw4xHppIS9cdTAwMGWxXlrIq9iycHGnUZPkXHUwMDA3Rs1cdTAwMGU+LVx1MDAxMlxmZmKiNGpCXHUwMDExkuOnI2pcIjlSfJ6iSLaeV1x1MDAxM1x1MDAwZUnEMuBcdTAwMTAwXHUwMDEzslx1MDAxYzg0XHUwMDFiM8GaKVx1MDAwMmLKXHUwMDFjJkuvIDU58ZfNTKYg+lRm8lwiaaZcdTAwMTO+hKxcdTAwMDQvlFx1MDAwNKU6IcdSKCb57E6YXVx0X00nXHUwMDA0hG9cdTAwMTFwQLAyjlx1MDAxMJd8xFx1MDAwYlx1MDAxOcKWkERx+MC0XCKxQu6i3Vx1MDAxMFlAOFx1MDAxMMZcXEumheI0NnQzdEuhISgwXCKgZ1x1MDAwNHiimvRSjLjCwHDo/F7aXHUwMDE3dtle2lx1MDAwZe0g3HabNbdZh4tRrntdjDPLPMS+X1dcdTAwMWbbfTVcIlx1MDAwMb1IJSeIKOgyLWN31e2W4XpcdTAwMTYolzGEjL4pkXhwwzDnbjrN2nSZskuVMZlcbiBcdTAwMTSViMBcdTAwMTNcdMdA9LlSfEIqYlEphVx1MDAwNsJJNIJAKyak8ux2uOM3XHUwMDFhblxiyj/z3WY4ruS+NreMt986dm38KrxV/Np4WGiZXHUwMDE2R/lp9Gkj8pv+l+HnP39NvjvdnM0xYchRe+/if+eOaJjg1MlcdTAwMTbgXHUwMDE1nDGsZ1x1MDAxZvHMhoUrXHUwMDFh0SS2OIGghSRYN4uiSP/XXHUwMDEyWVx1MDAxYZCVMkNcdTAwMWKaxIZDXHUwMDE3XHUwMDBlKzC1qFCKQWDlbGTSRzTogiymXHUwMDAwXHUwMDAyMZCTgj3EJn5cZuq4mijMwWt+rGg2c+RcdTAwMDD9UI6FMj5EXHUwMDA0pFx1MDAxZlx1MDAxNvOiQdzAkKKwXHUwMDE5NWaIIU5cdTAwMDR7YzTLhFx1MDAxZqMyXHUwMDExqjX4rEJIKFxuXHUwMDFkPSlcdTAwMTO4P9dcZnPCzVxuO1x1MDAxMHytg1m6LZtjwopcdTAwMTdcdTAwMTbLIGWkwjOALIRcbj7HvJPs8tuKXHUwMDA2M8aBIyHMTWKkMjZA/lx1MDAxMs2IxbAwI7VcXFx1MDAwYinHxVpcXDTTXHUwMDFh+phA0NRcdTAwMWNDOoutXHUwMDFhioJcdTAwMTmz4CqVcFx1MDAxZsZE0IlZZVx1MDAxOEJcdTAwMTlcdTAwMDNcdTAwMGbG/9ZoVjCggHFMXHUwMDAwalx1MDAxM1xiaYyihHBGLUhcXFx1MDAwMJOAXHUwMDFlQ1xu44K+LZ5lXHUwMDE3ekal4lx1MDAxYfA/1lx1MDAxMlx1MDAxM4hqmuBcdKG4XHUwMDA1uFx1MDAxYVwirJTIyDUp0jpFs0KqMZtjwoznjGaZRTBcdTAwMTnjJWNcdTAwMDGNaCyQmme1nfZKl6Wb+6fS9dVFrfaJ2Fx1MDAxN1x1MDAxZj6ueDhjZlx1MDAwNY8wuYJAzkYyXG4j/eFcYlx1MDAwMbpcdTAwMDeEKlx1MDAwMbVpXHUwMDA0d+RcdTAwMDfOYlWtKICBbPBoKidgXHUwMDE4QGZcdLQknmyWUlx1MDAwYrvrlu9cXO+ycdTuak4qXi0sep++fbD3+Pzw+br49DlcZnadwu1F+7MkbFx1MDAxMTP2161cdTAwMTaWk3pzalZ23IPiw1x1MDAxZW5cdTAwMDR7f9V2w1x1MDAwZemceovQXHUwMDAyQ0FcdTAwMGI3O9WTq2LpRF3tXvnHYWl1tVu5a2ydeVx1MDAxZIF5WNiu6XrpxH/qra64i197PlDD58NcdTAwMTLx2lx1MDAxN6p1etxmndZt6ZBfflO70yoryVxuipp9zY5cdTAwMGJEYpmJNq2yXHUwMDAySDiVNCDgYkQxOnuWzTaLXHUwMDE1zbJmtUl6ltXCgoSGXHUwMDAx5+mcsyxLyLIk0v1rdqVUamqw1uKza96VlVj9YEpl5aJcdTAwMWE4TvN9Sk1Fjty/sJrKXHUwMDE0jDheU1x1MDAxOcqY6XjpfF2lbyZBgD4gTdjs2z9lR87V9DzOsVx1MDAwNWRcdTAwMWNcXItcdTAwMTGqXGJcdTAwMWatpsB3S0klluB5XHUwMDE4XHUwMDAzi1SUaOCZSopYrXnoiIybMlx1MDAwMFx1MDAwN8ebwLtcdTAwMDQhRpjW4lxyeHeVmXq2O2zEx/i4llx1MDAwMuJcdTAwMTJcdTAwMDeWLijhMYo4oMTMQlx1MDAxY1RcZtpcdTAwMWJcXJmTn89R0Vx1MDAxMVx1MDAxOFx0XHUwMDA19JxhXHSBMqGgXHUwMDAzsiCgsohzbpKb1lx1MDAxM1wirVx1MDAxMz9PN15zxMw2auhd/O/bpqfSdGpcdTAwMGVsUYE7izm217huPV2497RyfcnK4fn+XHUwMDE26dyffl7x4EWJtKggXHUwMDFjSHpcdTAwMDJsMJvXYWVqwfqFnH/33eugQ1x1MDAxOFn+XCK34z1WPFdq9+amVun2yrf+VlhOISA/J6jO325O6l2vZnPbvW6ttJBTs7ntXpePuHmNIOQk7nn9oX7uXFw9XHUwMDEwLb94lyeNtt1yUsZRXHUwMDE2tdle4otEzb6Cg+89MEEoSV1cdTAwMDFDMCFCKI5nh1x1MDAxONn9t6JcdTAwMTCDkiyIQVx1MDAwMOsuXHRizLbfXHUwMDFlxpIjSlx1MDAwMX2v39DEN+6313ZrTsVcdTAwMGWWvf/FXHUwMDE04DzThnsjome66pRcdTAwMTVr6ZtjMsaAiDE++3DG9lHTx1xi6JdDjypP5Vx1MDAxMr7URSfFXcfcbtRZx6csvdVZMZrqrVhbXGZpRvCLt45OPmCCWIJcbo6Y+PbRjF8wqSgllEpwVZkweJGw5Vx1MDAwNSZcdTAwMTKx+Fx1MDAwNNalsIHzvaND9CRLN6dXLr66PdNfetf7P9nAothATupdr2bzYlx1MDAwM+ulhbyWq62XXHUwMDE28lqulpO4eW15sV6dtnxOlPxcIjOKe1rd3T65bqn60Ze7Y7vje0/bjM0m7uDTd+daXHUwMDE00dTldUT3t9mLIYVp2C3bLL5cdTAwMGLVmlx1MDAwMb1cdTAwMTGWhd6ktPCi0NuU0dxcdTAwMDT8lrC1uWRMXHUwMDAzXHUwMDA0zFx1MDAwMb+tXHUwMDBl01x1MDAxYSxaM+de+MrXzcNmO7Q9b9k8a1xuXHUwMDFkSVltlyp4ppOmXHUwMDE3jFx1MDAxOUl1Ulx1MDAwNCleUDVcdTAwMDfDyp5cdTAwMTKzmlx1MDAwM1winHFLMCp14oBcYuHCMsMgS3BSxixJiOCCJMzZYNriQjAkJkvFVCBwJPKWXUJ+iEpxwVxmWWlcIoSmXHUwMDEwZzWmOKk8yyjlmsoppeJR+deoYltIMlx1MDAxZnPEXGYnauNd/O/8MYOn1mk55cpUjGfP65m4bDUjXHUwMDA2w8Si4OuvQ6hcdCt2XHUwMDExNsaY91x1MDAxY1x1MDAxM2RcdTAwMTFuZvQogYmAXGJcdTAwMTCb2Fx1MDAxNa3XNZNcdTAwMTckVlx0U6qppGZcdTAwMDe2t+T7VVx1MDAwZVx1MDAxY9njl6OBXHUwMDAzXHUwMDExJFx1MDAxMWdUSCRcdTAwMTiOZlx1MDAxN1xmXHUwMDAzh7RcdTAwMTQliGIzXHUwMDE3XHUwMDA3joR1XHUwMDE3M001yc71XHUwMDFiI4uHQVx1MDAxY7PGiygzUYlINLlOd3K18DqFqnSrNUdkr3OGq3RcdTAwMWWSselcdTAwMTnHRFx1MDAxMqlm5yFS2yd35O7sYLdXrX/0xd72WUWkxKtcdTAwMTVcdTAwMTlDJlx1MDAxNFx1MDAwMI7Go9jhZb8z9mrd31TmyVx1MDAxODkmSdPeJiFcZlx1MDAwNtJcdTAwMDH2IN8y2+1bho5Xi2YnXHUwMDExm9lnt142a05gXGLLRnjrtjde9lx1MDAxZE+e6qpGfjz7VNfQb6WxmZHXXHUwMDFhpy7Jor1ccnxA0EhcdTAwMDVcdTAwMWbIJGQ5x+L67P5fSW+mWFlcdTAwMTC2zNbiXHUwMDE0Y6FGx1x1MDAxNFx1MDAwNMA9zNToXG6qMY92hJRv92hNLU0pXHUwMDE1ZjNcdTAwMWUmaGxCXnxcdTAwMTVX4lx1MDAxYS6zmo/QkZLdXHUwMDBmXHUwMDAxOLJzwijggNxcdTAwMGXAXHUwMDExXHUwMDExXGKMWkNATli4XHUwMDBl0VrNwlW+XHUwMDE5a1x1MDAxOHGkJmbSqlZcdTAwMDSeiVTiYliwMqzM/zvDbFx1MDAwMrXe2CPVes1ReDXcNOTxbtDupt1qXYRgZcNeXHUwMDAwQ3Zrg5BcdTAwMWS93GbHdbrbXHTuddM/TFx1MDAwNOyr0Vx1MDAwNFx1MDAxYce84t//vPvn/yxzRNUifQ== - Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install") \ No newline at end of file + Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install")Underline this button \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index c30215e78..96e4db7fa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,9 +6,10 @@ nav: - "introduction.md" - Guide: - "guide/guide.md" + - "guide/devtools.md" - "guide/CSS.md" - "guide/events.md" - - "guide/devtools.md" + - "actions.md" - Events: - "events/mount.md" From ddcef6e7d513915e29472e292e5201618b4d1a72 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 16:28:14 +0100 Subject: [PATCH 09/73] words --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 4147913b6..7f65b7063 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -4,7 +4,7 @@ Textual uses CSS to apply style to widgets. If you have any exposure to web deve ## Stylesheets -CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about what parts of a webpage to apply them to. In the case of Textual, the stylesheets apply styles to widgets but otherwise it is the same idea. +CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how the apply to a page. In the case of Textual, the stylesheet applies styles to widgets but otherwise it is the same idea. !!! note From a3173b391b4dd82c8185c097438ca2de3f9bcd7e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 21:31:29 +0100 Subject: [PATCH 10/73] box mode --- src/textual/box_model.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index a9f1d1119..adfc49c36 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -43,18 +43,15 @@ def get_box_model( gutter = styles.gutter margin = styles.margin - styles_width = styles.width - styles_height = styles.height - - is_auto_width = styles_width and styles_width.is_auto - is_auto_height = styles_height and styles_height.is_auto + is_auto_width = styles.width and styles.width.is_auto + is_auto_height = styles.height and styles.height.is_auto # Container minus padding and border content_container = container - gutter.totals # The container including the content sizing_container = content_container if is_border_box else container - if styles_width is None: + if styles.width is None: # No width specified, fill available space content_width = Fraction(content_container.width - margin.width) elif is_auto_width: @@ -64,6 +61,7 @@ def get_box_model( ) else: # An explicit width + styles_width = styles.width content_width = styles_width.resolve_dimension( sizing_container - styles.margin.totals, viewport, fraction_unit ) @@ -86,7 +84,7 @@ def get_box_model( content_width = max(Fraction(0), content_width) - if styles_height is None: + if styles.height is None: # No height specified, fill the available space content_height = Fraction(content_container.height - margin.height) elif is_auto_height: @@ -95,6 +93,7 @@ def get_box_model( get_content_height(content_container, viewport, int(content_width)) ) else: + styles_height = styles.height # Explicit height set content_height = styles_height.resolve_dimension( sizing_container - styles.margin.totals, viewport, fraction_unit From 310af598f26ac5b8abcfd27deace7bb430761a00 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 21:35:49 +0100 Subject: [PATCH 11/73] basic fix --- sandbox/will/basic.css | 1 + 1 file changed, 1 insertion(+) diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index ab50375b6..b1ff2d65d 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -97,6 +97,7 @@ DataTable { Tweet { height:12; width: 100%; + margin: 0 2; background: $panel; From eeb6729bcf245022253cd3dfd6c212520f56bc4f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 14 Aug 2022 22:06:27 +0100 Subject: [PATCH 12/73] words --- docs/guide/CSS.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 7f65b7063..6e56ba305 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -135,7 +135,7 @@ Here's the output from this example: ``` -You may recognize some of the elements, but it doesn't look quite right. This is because we haven't added a stylesheet. +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 @@ -165,9 +165,11 @@ With the CSS in place, the output looks very different: 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? -One advantage of CSS is that it separates how your app _looks_ from how it _works_. Setting styles in Python can generate a lot of code which can make it hard to see the more important logic in your application. +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. -Another advantage of CSS is that you can _live edit_ the styles. If you run your application with the following command, any changes you make to the CSS file will be instantly updated: +A second advantage of CSS is that you can customize builtin and third-part 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 @@ -194,7 +196,7 @@ class Button(Static): pass ``` -To apply a border to this widget, we could have a rule such as the following: +The following rule applies a border to this widget: ```css Button { @@ -202,9 +204,7 @@ Button { } ``` -The type selector will also match a widget's base classes. For instance, the `Button` Python class will will also match the `Static` selector because Widget extends Static in the Python code. Similarly, it will also match `Widget` which is the base class for all widgets. - -So the following selector will also match our `Button`: +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`. ```css Static { @@ -213,7 +213,11 @@ Static { } ``` -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. +!!! 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 @@ -237,21 +241,21 @@ You can match an ID with a selector starting with a hash (`#`). Here is how you 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 may share a particular style. -CSS classes are set via the widgets `classses` parameter in the constructor. Here's an example: +CSS classes are set via the widgets `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 green. +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 -Button(classes="error disabled") +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: +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: ```css .success { @@ -264,7 +268,7 @@ To match a Widget with a given class in CSS you can precede the class name with You can apply a class name to any class, 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_ `disables` class names. +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. ```css .error.disabled { @@ -286,7 +290,7 @@ For example, the following will draw a red outline around all widgets: ### Pseudo classes -Pseudo classes can be used to match widgets a given state. 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. +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. ```css Button:hover { @@ -294,6 +298,8 @@ Button:hover { } ``` +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 such pseudo classes: - `:focus` Matches widgets which have input focus. From 5491b413467251d7285d82a190ceb1ee0b1f30ee Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 Aug 2022 07:51:14 +0100 Subject: [PATCH 13/73] words --- docs/guide/CSS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 6e56ba305..790a4235a 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -4,7 +4,7 @@ Textual uses CSS to apply style to widgets. If you have any exposure to web deve ## Stylesheets -CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how the apply to a page. In the case of Textual, the stylesheet applies styles to widgets but otherwise it is the same idea. +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 @@ -357,7 +357,7 @@ We can use the following CSS to style all buttons which have a parent with an ID ## Specificity -It is possible that several selectors match a given widget. If the same rule is applied by more than one selector then Textual needs a way to decide which rule _wins_. It does this by following these rules: +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. From 2650dd500de2f7d909d373e93173be35c4ded1b6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 16:11:12 +0100 Subject: [PATCH 14/73] Events --- docs/events/blur.md | 9 +++++++++ docs/events/descendant_blur.md | 9 +++++++++ docs/events/descendant_focus.md | 9 +++++++++ docs/events/enter.md | 9 +++++++++ docs/events/focus.md | 9 +++++++++ docs/events/hide.md | 9 +++++++++ docs/events/key.md | 11 +++++++++++ docs/events/leave.md | 9 +++++++++ docs/events/load.md | 11 +++++++++++ docs/events/mount.md | 10 ++-------- docs/events/mouse_capture.md | 11 +++++++++++ docs/events/mouse_click.md | 20 ++++++++++++++++++++ docs/events/mouse_down.md | 20 ++++++++++++++++++++ docs/events/mouse_move.md | 20 ++++++++++++++++++++ docs/events/mouse_release.md | 11 +++++++++++ docs/events/mouse_scroll_down.md | 12 ++++++++++++ docs/events/mouse_scroll_up.md | 12 ++++++++++++ docs/events/mouse_up.md | 20 ++++++++++++++++++++ docs/events/paste.md | 11 +++++++++++ docs/events/resize.md | 22 ++++++---------------- docs/events/screen_resume.md | 9 +++++++++ docs/events/screen_suspend.md | 9 +++++++++ docs/events/show.md | 9 +++++++++ docs/examples/basic.css | 4 ++-- docs/styles/background.md | 6 ++++++ docs/styles/border.md | 10 ++++++++++ docs/styles/box_sizing.md | 6 ++++++ docs/styles/color.md | 6 ++++++ docs/styles/display.md | 6 ++++++ docs/styles/height.md | 6 ++++++ docs/styles/margin.md | 8 ++++++++ docs/styles/max_height.md | 6 ++++++ docs/styles/max_width.md | 6 ++++++ docs/styles/min_height.md | 6 ++++++ docs/styles/min_width.md | 6 ++++++ docs/styles/offset.md | 10 +++++++++- docs/styles/outline.md | 10 ++++++++++ docs/styles/overflow.md | 8 ++++++++ docs/styles/padding.md | 8 ++++++++ docs/styles/scrollbar.md | 13 ++++++++++++- docs/styles/scrollbar_size.md | 6 ++++++ docs/styles/text_style.md | 6 ++++++ docs/styles/tint.md | 6 ++++++ docs/styles/visibility.md | 6 ++++++ docs/styles/width.md | 6 ++++++ mkdocs.yml | 20 ++++++++++++++++++++ src/textual/events.py | 4 ---- 47 files changed, 428 insertions(+), 32 deletions(-) create mode 100644 docs/events/blur.md create mode 100644 docs/events/descendant_blur.md create mode 100644 docs/events/descendant_focus.md create mode 100644 docs/events/enter.md create mode 100644 docs/events/focus.md create mode 100644 docs/events/hide.md create mode 100644 docs/events/key.md create mode 100644 docs/events/leave.md create mode 100644 docs/events/load.md create mode 100644 docs/events/mouse_capture.md create mode 100644 docs/events/mouse_click.md create mode 100644 docs/events/mouse_down.md create mode 100644 docs/events/mouse_move.md create mode 100644 docs/events/mouse_release.md create mode 100644 docs/events/mouse_scroll_down.md create mode 100644 docs/events/mouse_scroll_up.md create mode 100644 docs/events/mouse_up.md create mode 100644 docs/events/paste.md create mode 100644 docs/events/screen_resume.md create mode 100644 docs/events/screen_suspend.md create mode 100644 docs/events/show.md diff --git a/docs/events/blur.md b/docs/events/blur.md new file mode 100644 index 000000000..b3b0647ae --- /dev/null +++ b/docs/events/blur.md @@ -0,0 +1,9 @@ +# Blur + +The `Blur` event is sent to a widget when it loses focus. + +- [ ] Bubbles + +## Attributes + +_No other attributes_ diff --git a/docs/events/descendant_blur.md b/docs/events/descendant_blur.md new file mode 100644 index 000000000..ed768235b --- /dev/null +++ b/docs/events/descendant_blur.md @@ -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_ diff --git a/docs/events/descendant_focus.md b/docs/events/descendant_focus.md new file mode 100644 index 000000000..7d51673be --- /dev/null +++ b/docs/events/descendant_focus.md @@ -0,0 +1,9 @@ +# DescendantFocus + +The `DescendantFocus` event is sent to a widget when one of its children receives input focus. + +- [x] Bubbles + +## Attributes + +_No other attributes_ diff --git a/docs/events/enter.md b/docs/events/enter.md new file mode 100644 index 000000000..3c67cf193 --- /dev/null +++ b/docs/events/enter.md @@ -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_ diff --git a/docs/events/focus.md b/docs/events/focus.md new file mode 100644 index 000000000..693cc825e --- /dev/null +++ b/docs/events/focus.md @@ -0,0 +1,9 @@ +# Focus + +The `Focus` event is sent to a widget when it receives input focus. + +- [ ] Bubbles + +## Attributes + +_No other attributes_ diff --git a/docs/events/hide.md b/docs/events/hide.md new file mode 100644 index 000000000..659fbdeaf --- /dev/null +++ b/docs/events/hide.md @@ -0,0 +1,9 @@ +# Show + +The `Hide` event is sent to a widget when it is hidden from view. + +- [ ] Bubbles + +## Attributes + +_No additional attributes_ diff --git a/docs/events/key.md b/docs/events/key.md new file mode 100644 index 000000000..04a0efa49 --- /dev/null +++ b/docs/events/key.md @@ -0,0 +1,11 @@ +# Key + +The `Key` event is sent to a widget when the user presses key on the keyboard. + +- [x] Bubbles + +## Attributes + +| attribute | type | purpose | +| --------- | ---- | ------------------------ | +| `key` | str | The key that was pressed | diff --git a/docs/events/leave.md b/docs/events/leave.md new file mode 100644 index 000000000..94af5f28c --- /dev/null +++ b/docs/events/leave.md @@ -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_ diff --git a/docs/events/load.md b/docs/events/load.md new file mode 100644 index 000000000..661ec9899 --- /dev/null +++ b/docs/events/load.md @@ -0,0 +1,11 @@ +# Mount + +The `Load` event is sent to the app prior to switching the terminal in 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_ diff --git a/docs/events/mount.md b/docs/events/mount.md index 950d93a2e..2cec12d56 100644 --- a/docs/events/mount.md +++ b/docs/events/mount.md @@ -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_ diff --git a/docs/events/mouse_capture.md b/docs/events/mouse_capture.md new file mode 100644 index 000000000..0ee4b94af --- /dev/null +++ b/docs/events/mouse_capture.md @@ -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 | diff --git a/docs/events/mouse_click.md b/docs/events/mouse_click.md new file mode 100644 index 000000000..0b175a4a3 --- /dev/null +++ b/docs/events/mouse_click.md @@ -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 x 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 | Shift key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | diff --git a/docs/events/mouse_down.md b/docs/events/mouse_down.md new file mode 100644 index 000000000..c39b2e853 --- /dev/null +++ b/docs/events/mouse_down.md @@ -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 x 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 | Shift key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md new file mode 100644 index 000000000..765f2f64d --- /dev/null +++ b/docs/events/mouse_move.md @@ -0,0 +1,20 @@ +# 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 x 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 | Shift key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md new file mode 100644 index 000000000..e6f7cea3a --- /dev/null +++ b/docs/events/mouse_release.md @@ -0,0 +1,11 @@ +# MouseRelease + +The `MouseRelease` event is sent to a widget 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 release | diff --git a/docs/events/mouse_scroll_down.md b/docs/events/mouse_scroll_down.md new file mode 100644 index 000000000..815f43dc8 --- /dev/null +++ b/docs/events/mouse_scroll_down.md @@ -0,0 +1,12 @@ +# MouseScrollDown + +The `MouseScrollDown` event is sent to a widget when 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 | diff --git a/docs/events/mouse_scroll_up.md b/docs/events/mouse_scroll_up.md new file mode 100644 index 000000000..cb70b9ae2 --- /dev/null +++ b/docs/events/mouse_scroll_up.md @@ -0,0 +1,12 @@ +# MouseScrollUp + +The `MouseScrollUp` event is sent to a widget when 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 | diff --git a/docs/events/mouse_up.md b/docs/events/mouse_up.md new file mode 100644 index 000000000..a19dcf4bd --- /dev/null +++ b/docs/events/mouse_up.md @@ -0,0 +1,20 @@ +# 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 x 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 | Shift key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | diff --git a/docs/events/paste.md b/docs/events/paste.md new file mode 100644 index 000000000..ac2237a4e --- /dev/null +++ b/docs/events/paste.md @@ -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 | diff --git a/docs/events/resize.md b/docs/events/resize.md index 36a7e7562..f8f2ad062 100644 --- a/docs/events/resize.md +++ b/docs/events/resize.md @@ -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). | diff --git a/docs/events/screen_resume.md b/docs/events/screen_resume.md new file mode 100644 index 000000000..e6b0e9691 --- /dev/null +++ b/docs/events/screen_resume.md @@ -0,0 +1,9 @@ +# ScreenResume + +The `ScreenResume` event is sent to a **Screen** when it becomes current. + +- [ ] Bubbles + +## Attributes + +_No other attributes_ diff --git a/docs/events/screen_suspend.md b/docs/events/screen_suspend.md new file mode 100644 index 000000000..d79ed8e13 --- /dev/null +++ b/docs/events/screen_suspend.md @@ -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_ diff --git a/docs/events/show.md b/docs/events/show.md new file mode 100644 index 000000000..a06aed18a --- /dev/null +++ b/docs/events/show.md @@ -0,0 +1,9 @@ +# Show + +The `Show` event is sent to a widget when it becomes visible. + +- [ ] Bubbles + +## Attributes + +_No additional attributes_ diff --git a/docs/examples/basic.css b/docs/examples/basic.css index ab50375b6..e46e9b62a 100644 --- a/docs/examples/basic.css +++ b/docs/examples/basic.css @@ -29,7 +29,7 @@ App > Screen { #tree-container { overflow-y: auto; height: 20; - margin: 1 2; + margin: 1 3; background: $panel; padding: 1 2; } @@ -97,7 +97,7 @@ DataTable { Tweet { height:12; width: 100%; - + margin: 0 2; background: $panel; color: $text-panel; diff --git a/docs/styles/background.md b/docs/styles/background.md index 8f4c2d09b..38a1c59c8 100644 --- a/docs/styles/background.md +++ b/docs/styles/background.md @@ -2,6 +2,12 @@ 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. diff --git a/docs/styles/border.md b/docs/styles/border.md index 5f0dd1c4a..e9c9638cc 100644 --- a/docs/styles/border.md +++ b/docs/styles/border.md @@ -24,6 +24,16 @@ For example `heavy white` would display a heavy white line around a widget. Borders may also be set individually for the four edges of a widget with the `border-top`, `border-right`, `border-bottom` and `border-left` rules. +## Syntax + +``` +border: [] []; +border-top: ] []; +border-right: ] []; +border-bottom: ] []; +border-left: ] []; +``` + ## Border command The `textual` CLI has a subcommand which will let you explore the various border types: diff --git a/docs/styles/box_sizing.md b/docs/styles/box_sizing.md index b71d3eebf..549dcb395 100644 --- a/docs/styles/box_sizing.md +++ b/docs/styles/box_sizing.md @@ -6,6 +6,12 @@ The default value is `border-box` which means that padding and border are includ You can set `box-sizing` to `content-box` which tells Textual that padding and border should increase the size of the widget, leaving the content area unaffected. +## Syntax + +``` +box-sizing: [border-box|content-box]; +``` + ## Example Both widgets in this example have the same height (5). The top widget has `box-sizing: border-box` which means that padding and border reduces the space for content. The bottom widget has `box-sizing: content-box` which increases the size of the widget to compensate for padding and border. diff --git a/docs/styles/color.md b/docs/styles/color.md index c7f035d3a..24b80143c 100644 --- a/docs/styles/color.md +++ b/docs/styles/color.md @@ -2,6 +2,12 @@ The `color` rule sets the text color of a Widget. +## Syntax + +``` +color: []; +``` + ## Example This example sets a different text color to three different widgets. diff --git a/docs/styles/display.md b/docs/styles/display.md index e8b75ca86..de29cffd0 100644 --- a/docs/styles/display.md +++ b/docs/styles/display.md @@ -2,6 +2,12 @@ The `display` property defines if a Widget is displayed or not. The default value is `"block"` which will display the widget as normal. Setting the property to `"none"` will effectively make it invisible. +## Syntax + +``` +display: [none|block]; +``` + ## Example Note that the second widget is hidden by adding the "hidden" class which sets the display style to None. diff --git a/docs/styles/height.md b/docs/styles/height.md index 240d2c2e1..020b1ad69 100644 --- a/docs/styles/height.md +++ b/docs/styles/height.md @@ -2,6 +2,12 @@ The `height` rule sets a widget's height. By default, it sets the height of the content area, but if `box-sizing` is set to `border-box` it sets the height of the border area. +## Syntax + +``` +height: ; +``` + ## Example This examples applies a widget with a height of 50% of the screen. diff --git a/docs/styles/margin.md b/docs/styles/margin.md index 1d9105dda..6cfea022d 100644 --- a/docs/styles/margin.md +++ b/docs/styles/margin.md @@ -10,6 +10,14 @@ The `margin` rule adds space around the entire widget. Margin may be specified w Margin may also be set individually by setting `margin-top`, `margin-right`, `margin-bottom`, or `margin-left` to an single value. +## Syntax + +``` +margin: ; +margin: ; +margin: ; +``` + ## Example In this example we add a large margin to a some static text. diff --git a/docs/styles/max_height.md b/docs/styles/max_height.md index dcf10193e..9b766388a 100644 --- a/docs/styles/max_height.md +++ b/docs/styles/max_height.md @@ -2,6 +2,12 @@ The `max-height` rule sets a maximum width for a widget. +## Syntax + +``` +max-height: ; +``` + ## CSS ```sass diff --git a/docs/styles/max_width.md b/docs/styles/max_width.md index deac40f97..9a4ae931d 100644 --- a/docs/styles/max_width.md +++ b/docs/styles/max_width.md @@ -2,6 +2,12 @@ The `max-width` rule sets a maximum width for a widget. +## Syntax + +``` +max-width: ; +``` + ## CSS ```sass diff --git a/docs/styles/min_height.md b/docs/styles/min_height.md index 51426e397..b110e190e 100644 --- a/docs/styles/min_height.md +++ b/docs/styles/min_height.md @@ -2,6 +2,12 @@ The `min-height` rule sets a minimum height for a widget. +## Syntax + +``` +min-height: ; +``` + ## CSS ```sass diff --git a/docs/styles/min_width.md b/docs/styles/min_width.md index b6a4cf2c3..01cf2a49d 100644 --- a/docs/styles/min_width.md +++ b/docs/styles/min_width.md @@ -2,6 +2,12 @@ The `min-width` rules sets a minimum width for a widget. +## Syntax + +``` +min-width: ; +``` + ## CSS ```sass diff --git a/docs/styles/offset.md b/docs/styles/offset.md index c038c8131..059a1edbe 100644 --- a/docs/styles/offset.md +++ b/docs/styles/offset.md @@ -1,6 +1,14 @@ # Offset -The `offset` rule adds an offset to the widget's position. +The `offset` rule adds an offset to the widget's position. The offset is given as two values. + +Coordinates may be specified individually with `offset-x` and `offset-y`. + +## Syntax + +``` +offset: ; +``` ## Example diff --git a/docs/styles/outline.md b/docs/styles/outline.md index 5eb4e2a20..d3432e8de 100644 --- a/docs/styles/outline.md +++ b/docs/styles/outline.md @@ -26,6 +26,16 @@ For example `heavy white` would display a heavy white line around a widget. Outlines may also be set individually with the `outline-top`, `outline-right`, `outline-bottom` and `outline-left` rules. +## Syntax + +``` +outline: [] []; +outline-top: ] []; +outline-right: ] []; +outline-bottom: ] []; +outline-left: ] []; +``` + ## Example This examples shows a widget with an outline. Note how the outline occludes the text area. diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md index 34bff5560..e4b72485b 100644 --- a/docs/styles/overflow.md +++ b/docs/styles/overflow.md @@ -12,6 +12,14 @@ The default value for overflow is `"auto auto"` which will show scrollbars autom Overflow may also be set independently by setting the `overflow-x` rule for the horizontal bar, and `overflow-y` for the vertical bar. +## Syntax + +``` +overflow: [auto|hidden|scroll]; +overflow-x: [auto|hidden|scroll]; +overflow-y: [auto|hidden|scroll]; +``` + ## Example Here we split the screen in to left and right sections, each with three vertically scrolling widgets that do not fit in to the height of the terminal. diff --git a/docs/styles/padding.md b/docs/styles/padding.md index 8d7ce5d15..ee0ae9f1f 100644 --- a/docs/styles/padding.md +++ b/docs/styles/padding.md @@ -10,6 +10,14 @@ The padding rule adds space around the content of a widget. You can specify padd Padding may also be set individually by setting `padding-top`, `padding-right`, `padding-bottom`, or `padding-left` to an single value. +## Syntax + +``` +padding: ; +padding: ; +padding: ; +``` + ## Example This example adds padding around static text. diff --git a/docs/styles/scrollbar.md b/docs/styles/scrollbar.md index 523098c8d..57b4346d7 100644 --- a/docs/styles/scrollbar.md +++ b/docs/styles/scrollbar.md @@ -3,7 +3,7 @@ There are a number of rules to set the colors used in Textual scrollbars. You won't typically need to do this, as the default themes have carefully chosen colors, but you can if you want to. | Rule | Color | -|-------------------------------|---------------------------------------------------------| +| ----------------------------- | ------------------------------------------------------- | | `scrollbar-color` | Scrollbar "thumb" (movable part) | | `scrollbar-color-hover` | Scrollbar thumb when the mouse is hovering over it | | `scrollbar-color-active` | Scrollbar thumb when it is active (being dragged) | @@ -12,6 +12,17 @@ There are a number of rules to set the colors used in Textual scrollbars. You wo | `scrollbar-background-active` | Scrollbar background when the thumb is being dragged | | `scrollbar-corner-color` | The gap between the horizontal and vertical scrollbars | +## Example: + +``` +scrollbar-color: ; +scrollbar-color-hover: ; +scrollbar-color-active: ; +scrollbar-background: ; +scrollbar-background-hover: ; +scrollbar-background-active: ; +scrollbar-corner-color: ; +``` ## Example diff --git a/docs/styles/scrollbar_size.md b/docs/styles/scrollbar_size.md index af6d256d4..de42ae96e 100644 --- a/docs/styles/scrollbar_size.md +++ b/docs/styles/scrollbar_size.md @@ -4,6 +4,12 @@ The `scrollbar-size` rule changes the size of the scrollbars. It takes 2 integer The scrollbar dimensions may also be set individually with `scrollbar-size-horizontal` and `scrollbar-size-vertical`. +# Syntax + +``` +scrollbar-size: ; +``` + ## Example In this example we modify the size of the widgets scrollbar to be _much_ larger than usual. diff --git a/docs/styles/text_style.md b/docs/styles/text_style.md index 3b60c13aa..936220b3c 100644 --- a/docs/styles/text_style.md +++ b/docs/styles/text_style.md @@ -12,6 +12,12 @@ The `text-style` rule enables a number of different ways of displaying text. The Text styles may be set in combination. For example "bold underline" or "reverse underline strike". +## Syntax + +``` +text-style: ... +``` + ## Example Each of the three text panels has a different text style. diff --git a/docs/styles/tint.md b/docs/styles/tint.md index dc07f4193..dde7e3aaf 100644 --- a/docs/styles/tint.md +++ b/docs/styles/tint.md @@ -2,6 +2,12 @@ The tint rule blends a color with the widget. The color should likely have an _alpha_ component, or the end result would obscure the widget content. +## Syntax + +``` +tint: []; +``` + ## Example This examples shows a green tint with gradually increasing alpha. diff --git a/docs/styles/visibility.md b/docs/styles/visibility.md index b066d4791..ffacd4956 100644 --- a/docs/styles/visibility.md +++ b/docs/styles/visibility.md @@ -2,6 +2,12 @@ The `visibility` rule may be used to make a widget invisible while still reserving spacing for it. The default value is `"visible"` which will cause the Widget to be displayed as normal. Setting the value to `"hidden"` will cause the Widget to become invisible. +## Syntax + +``` +visibility: [hidden|visible]; +``` + ## Example Note that the second widget is hidden, while leaving a space where it would have been rendered. diff --git a/docs/styles/width.md b/docs/styles/width.md index bf3af35e7..499048483 100644 --- a/docs/styles/width.md +++ b/docs/styles/width.md @@ -2,6 +2,12 @@ The `width` rule sets a widget's width. By default, it sets the width of the content area, but if `box-sizing` is set to `border-box` it sets the width of the border area. +## Syntax + +``` +width: ; +``` + ## Example This example adds a widget with 50% width of the screen. diff --git a/mkdocs.yml b/mkdocs.yml index 96e4db7fa..d484acea6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,8 +12,28 @@ nav: - "actions.md" - Events: + - "events/blur.md" + - "events/descendant_blur.md" + - "events/descendant_focus.md" + - "events/enter.md" + - "events/enter.md" + - "events/focus.md" + - "events/hide.md" + - "events/key.md" + - "events/leave.md" + - "events/load.md" - "events/mount.md" + - "events/mouse_capture.md" + - "events/mouse_down.md" + - "events/mouse_move.md" + - "events/mouse_release.md" + - "events/mouse_scroll_down.md" + - "events/mouse_scroll_up.md" + - "events/mouse_up.md" - "events/resize.md" + - "events/screen_resume.md" + - "events/screen_suspend.md" + - "events/show.md" - Styles: - "styles/background.md" - "styles/border.md" diff --git a/src/textual/events.py b/src/textual/events.py index b3ffec6a0..7dc006b12 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -366,10 +366,6 @@ class Click(MouseEvent, bubble=True): pass -class DoubleClick(MouseEvent, bubble=True): - pass - - @rich.repr.auto class Timer(Event, verbosity=3, bubble=False): __slots__ = ["time", "count", "callback"] From 7ef6cb0b8511767d2b5faf9ea7da345c44695b32 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 Aug 2022 16:20:17 +0100 Subject: [PATCH 15/73] docs yml --- mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index d484acea6..e7ac4573e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,12 +24,14 @@ nav: - "events/load.md" - "events/mount.md" - "events/mouse_capture.md" + - "events/mouse_click.md" - "events/mouse_down.md" - "events/mouse_move.md" - "events/mouse_release.md" - "events/mouse_scroll_down.md" - "events/mouse_scroll_up.md" - "events/mouse_up.md" + - "events/paste.md" - "events/resize.md" - "events/screen_resume.md" - "events/screen_suspend.md" From e761e7ae8fc625a913f6b40b75bc691e4743644d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 18 Aug 2022 16:05:11 +0100 Subject: [PATCH 16/73] timer example --- docs/examples/introduction/timers.css | 55 ++++++++++++++++++ docs/examples/introduction/timers.py | 84 +++++++++++++++++++++++++++ src/textual/_arrange.py | 16 +++-- src/textual/dom.py | 2 +- src/textual/geometry.py | 4 +- src/textual/layouts/horizontal.py | 4 +- src/textual/widget.py | 2 +- 7 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 docs/examples/introduction/timers.css create mode 100644 docs/examples/introduction/timers.py diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css new file mode 100644 index 000000000..e4188de60 --- /dev/null +++ b/docs/examples/introduction/timers.css @@ -0,0 +1,55 @@ +TimerWidget { + layout: horizontal; + height: 5; + background: $panel-darken-1; + border: tall $panel-darken-2; + margin: 1; + padding: 0 1; + + transition: background 200ms linear; +} + +TimeDisplay { + content-align: center middle; + opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +TimerWidget.started { + opacity: 100%; + text-style: bold; + background: $success; + color: $text-success; + border: tall $success-darken-2; +} + +TimerWidget.started #start { + display: none +} + +TimerWidget.started #stop { + display: block +} + +TimerWidget.started #reset { + visibility: hidden +} + + +#stop { + dock: left; + display: none; +} + + +Button#reset { + dock: right; +} diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py new file mode 100644 index 000000000..9ae350b68 --- /dev/null +++ b/docs/examples/introduction/timers.py @@ -0,0 +1,84 @@ +from time import time + +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): + """Displays the time.""" + + time_delta = Reactive(0.0) + + def watch_time_delta(self, time_delta: float) -> None: + minutes, seconds = divmod(time_delta, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:02.2f}") + + +class TimerWidget(Static): + """The timer widget (display + buttons).""" + + start_time = Reactive(0.0) + total = Reactive(0.0) + started = Reactive(False) + + def on_mount(self) -> None: + self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) + + def update_elapsed(self) -> None: + time_delta = ( + self.total + time() - self.start_time if self.started else self.total + ) + self.query_one(TimeDisplay).time_delta = time_delta + + def compose(self) -> ComposeResult: + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield TimeDisplay() + yield Button("Reset", id="reset") + + def watch_started(self, started: bool) -> None: + if started: + self.start_time = time() + self.update_timer.resume() + self.add_class("started") + else: + self.update_timer.pause() + self.total += time() - self.start_time + self.remove_class("started") + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + self.started = button_id == "start" + if button_id == "reset": + self.started = False + self.total = 0.0 + self.update_elapsed() + + +class TimerApp(App): + def on_load(self) -> None: + self.bind("a", "add_timer", description="Add") + self.bind("r", "remove_timer", description="Remove") + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Container(TimerWidget(), TimerWidget(), TimerWidget()) + + def action_add_timer(self) -> None: + new_timer = TimerWidget() + self.query_one("Container").mount(new_timer) + self.call_later(new_timer.scroll_visible) + + def action_remove_timer(self) -> None: + timers = self.query("Container TimerWidget") + if timers: + timers.last().remove() + + +app = TimerApp(css_path="timers.css") +if __name__ == "__main__": + app.run() diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 6e133b1d3..984a32c28 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -54,6 +54,7 @@ def arrange( for widgets in dock_layers.values(): layout_widgets, dock_widgets = partition(get_dock, widgets) + arrange_widgets.update(dock_widgets) top = right = bottom = left = 0 @@ -73,18 +74,18 @@ def arrange( dock_region = Region( 0, height - widget_height, widget_width, widget_height ) - bottom = max(bottom, dock_region.height) + bottom = max(bottom, widget_height) elif edge == "top": dock_region = Region(0, 0, widget_width, widget_height) - top = max(top, dock_region.height) + top = max(top, widget_height) elif edge == "left": dock_region = Region(0, 0, widget_width, widget_height) - left = max(left, dock_region.width) + left = max(left, widget_width) elif edge == "right": dock_region = Region( width - widget_width, 0, widget_width, widget_height ) - right = max(right, dock_region.width) + right = max(right, widget_width) else: # Should not occur, mainly to keep Mypy happy raise AssertionError("invalid value for edge") # pragma: no-cover @@ -100,14 +101,17 @@ def arrange( layout_placements, arranged_layout_widgets = widget.layout.arrange( widget, layout_widgets, region.size ) + if arranged_layout_widgets: scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) arrange_widgets.update(arranged_layout_widgets) placement_offset = region.offset if placement_offset: layout_placements = [ - _WidgetPlacement(_region + placement_offset, widget, order, fixed) - for _region, widget, order, fixed in layout_placements + _WidgetPlacement( + _region + placement_offset, layout_widget, order, fixed + ) + for _region, layout_widget, order, fixed in layout_placements ] placements.extend(layout_placements) diff --git a/src/textual/dom.py b/src/textual/dom.py index 68a14b28c..19e9d00f2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -625,7 +625,7 @@ class DOMNode(MessagePump): query_selector = selector else: query_selector = selector.__name__ - query = DOMQuery(self.screen, filter=query_selector) + query = DOMQuery(self, filter=query_selector) if expect_type is None: return query.first() diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 176586c99..9a0da73fa 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -629,8 +629,8 @@ class Region(NamedTuple): return Region( x=x + left, y=y + top, - width=max(0, width - left - right), - height=max(0, height - top - bottom), + width=max(0, width - (left + right)), + height=max(0, height - (top + bottom)), ) @lru_cache(maxsize=4096) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 79ca32809..6d2b5cb74 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -46,9 +46,9 @@ class HorizontalLayout(Layout): x = Fraction(box_models[0].margin.left if box_models else 0) - displayed_children = cast("list[Widget]", parent.displayed_children) + displayed_children = [child for child in children if child.display] - for widget, box_model, margin in zip(displayed_children, box_models, margins): + for widget, box_model, margin in zip(children, box_models, margins): content_width, content_height, box_margin = box_model offset_y = ( widget.styles.align_height( diff --git a/src/textual/widget.py b/src/textual/widget.py index 92ef7018b..ccaa4d2b1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -210,7 +210,7 @@ class Widget(DOMNode): """ self.app._register(self, *anon_widgets, **widgets) - self.screen.refresh() + self.screen.refresh(layout=True) def compose(self) -> ComposeResult: """Yield child widgets for a container.""" From fd349aa658d9e6af513932e6b0076fb1e8187fa8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 09:33:36 +0100 Subject: [PATCH 17/73] fix for removing --- docs/examples/introduction/timers.css | 6 ++--- docs/examples/introduction/timers.py | 23 ++++++++++-------- src/textual/app.py | 34 ++++++++++++++++++++------- src/textual/events.py | 5 ++++ src/textual/message_pump.py | 26 ++++++++++++-------- src/textual/widget.py | 34 +++++++++------------------ src/textual/widgets/_static.py | 2 +- 7 files changed, 73 insertions(+), 57 deletions(-) diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index e4188de60..a5f2295fb 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -5,7 +5,6 @@ TimerWidget { border: tall $panel-darken-2; margin: 1; padding: 0 1; - transition: background 200ms linear; } @@ -23,6 +22,7 @@ Button { dock: left; } + TimerWidget.started { opacity: 100%; text-style: bold; @@ -43,13 +43,11 @@ TimerWidget.started #reset { visibility: hidden } - #stop { dock: left; display: none; } - -Button#reset { +#reset { dock: right; } diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index 9ae350b68..8ff76895e 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -1,4 +1,4 @@ -from time import time +from time import monotonic from textual.app import App, ComposeResult from textual.layout import Container @@ -14,7 +14,7 @@ class TimeDisplay(Static): def watch_time_delta(self, time_delta: float) -> None: minutes, seconds = divmod(time_delta, 60) hours, minutes = divmod(minutes, 60) - self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:02.2f}") + self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:05.2f}") class TimerWidget(Static): @@ -28,10 +28,9 @@ class TimerWidget(Static): self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) def update_elapsed(self) -> None: - time_delta = ( - self.total + time() - self.start_time if self.started else self.total + self.query_one(TimeDisplay).time_delta = ( + self.total + monotonic() - self.start_time if self.started else self.total ) - self.query_one(TimeDisplay).time_delta = time_delta def compose(self) -> ComposeResult: yield Button("Start", id="start", variant="success") @@ -41,13 +40,15 @@ class TimerWidget(Static): def watch_started(self, started: bool) -> None: if started: - self.start_time = time() + self.start_time = monotonic() self.update_timer.resume() self.add_class("started") + self.query_one("#stop").focus() else: self.update_timer.pause() - self.total += time() - self.start_time + self.total += monotonic() - self.start_time self.remove_class("started") + self.query_one("#start").focus() def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id @@ -59,6 +60,8 @@ class TimerWidget(Static): class TimerApp(App): + """Manage the timers.""" + def on_load(self) -> None: self.bind("a", "add_timer", description="Add") self.bind("r", "remove_timer", description="Remove") @@ -70,8 +73,8 @@ class TimerApp(App): def action_add_timer(self) -> None: new_timer = TimerWidget() - self.query_one("Container").mount(new_timer) - self.call_later(new_timer.scroll_visible) + self.query_one(Container).mount(new_timer) + new_timer.scroll_visible() def action_remove_timer(self) -> None: timers = self.query("Container TimerWidget") @@ -79,6 +82,6 @@ class TimerApp(App): timers.last().remove() -app = TimerApp(css_path="timers.css") +app = TimerApp(title="TimerApp", css_path="timers.css") if __name__ == "__main__": app.run() diff --git a/src/textual/app.py b/src/textual/app.py index 5e5decfb9..70020ceae 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -7,7 +7,7 @@ import os import platform import sys import warnings -from contextlib import redirect_stdout +from contextlib import redirect_stdout, redirect_stderr from datetime import datetime from pathlib import PurePath from time import perf_counter @@ -974,8 +974,10 @@ class App(Generic[ReturnType], DOMNode): if self.is_headless: await run_process_messages() else: - with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore - await run_process_messages() + redirector = StdoutRedirector(self.devtools, self._log_file) + with redirect_stderr(redirector): + with redirect_stdout(redirector): # type: ignore + await run_process_messages() finally: driver.stop_application_mode() except Exception as error: @@ -1070,9 +1072,12 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): A Widget to unregister """ + if self.focused is widget: + self.focused = None + if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) - widget._attach(None) + widget._detach() self._registry.discard(widget) async def _disconnect_devtools(self): @@ -1291,13 +1296,13 @@ class App(Generic[ReturnType], DOMNode): return False return True - async def on_update(self, message: messages.Update) -> None: + async def _on_update(self, message: messages.Update) -> None: message.stop() - async def on_layout(self, message: messages.Layout) -> None: + async def _on_layout(self, message: messages.Layout) -> None: message.stop() - async def on_key(self, event: events.Key) -> None: + async def _on_key(self, event: events.Key) -> None: if event.key == "tab": self.focus_next() elif event.key == "shift+tab": @@ -1305,15 +1310,26 @@ class App(Generic[ReturnType], DOMNode): else: await self.press(event.key) - async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: + async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None: log("shutdown request") await self.close_messages() - async def on_resize(self, event: events.Resize) -> None: + async def _on_resize(self, event: events.Resize) -> None: event.stop() self.screen._screen_resized(event.size) await self.screen.post_message(event) + async def _on_remove(self, event: events.Remove) -> None: + widget = event.widget + if widget.has_parent: + widget.parent.refresh(layout=True) + + remove_widgets = list(widget.walk_children(Widget, with_self=True)) + for child in remove_widgets: + self._unregister(child) + for child in remove_widgets: + await child.close_messages() + async def action_press(self, key: str) -> None: await self.press(key) diff --git a/src/textual/events.py b/src/textual/events.py index 7dc006b12..fceadd8bd 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -16,6 +16,7 @@ MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") if TYPE_CHECKING: from ._timer import Timer as TimerClass from ._timer import TimerCallback + from .widget import WIdget @rich.repr.auto @@ -128,6 +129,10 @@ class Unmount(Event, bubble=False): class Remove(Event, bubble=False): """Sent to a widget to ask it to remove itself from the DOM.""" + def __init__(self, sender: MessageTarget, widget: Widget) -> None: + self.widget = widget + super().__init__(sender) + class Show(Event, bubble=False): """Sent when a widget has become visible.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 02583de9b..38e013dd9 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -87,7 +87,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._disabled_messages: set[type[Message]] = set() self._pending_message: Message | None = None self._task: Task | None = None - self._child_tasks: WeakSet[Task] = WeakSet() + self._timers: WeakSet[Timer] = WeakSet() @property def task(self) -> Task: @@ -130,6 +130,10 @@ class MessagePump(metaclass=MessagePumpMeta): """ self._parent = parent + def _detach(self) -> None: + """Set the parent to None to remove the node from the tree.""" + self._parent = None + def check_message_enabled(self, message: Message) -> bool: return type(message) not in self._disabled_messages @@ -199,7 +203,8 @@ class MessagePump(metaclass=MessagePumpMeta): repeat=0, pause=pause, ) - self._child_tasks.add(timer.start()) + timer.start() + self._timers.add(timer) return timer def set_interval( @@ -220,7 +225,8 @@ class MessagePump(metaclass=MessagePumpMeta): repeat=repeat or None, pause=pause, ) - self._child_tasks.add(timer.start()) + timer.start() + self._timers.add(timer) return timer def call_later(self, callback: Callable, *args, **kwargs) -> None: @@ -248,13 +254,11 @@ class MessagePump(metaclass=MessagePumpMeta): if self._closed or self._closing: return self._closing = True + for timer in self._timers: + await timer.stop() + self._timers.clear() await self._message_queue.put(MessagePriority(None)) - cancel_tasks = list(self._child_tasks) - for task in cancel_tasks: - task.cancel() - for task in cancel_tasks: - await task - self._child_tasks.clear() + if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning await self._task @@ -265,11 +269,13 @@ class MessagePump(metaclass=MessagePumpMeta): async def process_messages(self) -> None: self._running = True try: - return await self._process_messages() + await self._process_messages() except CancelledError: pass finally: self._running = False + for timer in self._timers: + await timer.stop() async def _process_messages(self) -> None: """Process messages until the queue is closed.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index ccaa4d2b1..3feb3a827 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -859,16 +859,11 @@ class Widget(DOMNode): ) return delta - def scroll_visible(self) -> bool: - """Scroll the container to make this widget visible. - - Returns: - bool: True if the parent was scrolled. - """ + def scroll_visible(self) -> None: + """Scroll the container to make this widget visible.""" parent = self.parent if isinstance(parent, Widget): - return parent.scroll_to_widget(self) - return False + self.call_later(parent.scroll_to_widget, self) def __init_subclass__( cls, @@ -1126,9 +1121,7 @@ class Widget(DOMNode): def remove(self) -> None: """Remove the Widget from the DOM (effectively deleting it)""" - for child in self.children: - child.remove() - self.post_message_no_wait(events.Remove(self)) + self.app.post_message_no_wait(events.Remove(self, widget=self)) def render(self) -> RenderableType: """Get renderable for widget. @@ -1158,12 +1151,13 @@ class Widget(DOMNode): Args: event (events.Idle): Idle event. """ - if self._repaint_required: - self._repaint_required = False - self.screen.post_message_no_wait(messages.Update(self, self)) - if self._layout_required: - self._layout_required = False - self.screen.post_message_no_wait(messages.Layout(self)) + if self._parent is not None: + if self._repaint_required: + self._repaint_required = False + self.screen.post_message_no_wait(messages.Update(self, self)) + if self._layout_required: + self._layout_required = False + self.screen.post_message_no_wait(messages.Layout(self)) def focus(self) -> None: """Give input focus to this widget.""" @@ -1201,12 +1195,6 @@ class Widget(DOMNode): async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) - async def on_remove(self, event: events.Remove) -> None: - await self.close_messages() - assert self.parent - self.parent.refresh(layout=True) - self.app._unregister(self) - def _on_mount(self, event: events.Mount) -> None: widgets = list(self.compose()) if widgets: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index eb3359dbb..22cd90b72 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -29,4 +29,4 @@ class Static(Widget): def update(self, renderable: RenderableType) -> None: self.renderable = renderable - self.refresh(layout=True) + self.refresh() From 6ce6dc76f83b15743ca689f429daecfdf9546379 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 09:38:43 +0100 Subject: [PATCH 18/73] simplify --- docs/examples/introduction/timers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index 8ff76895e..043591935 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -54,7 +54,6 @@ class TimerWidget(Static): button_id = event.button.id self.started = button_id == "start" if button_id == "reset": - self.started = False self.total = 0.0 self.update_elapsed() From 2fbafe8322bccbaa94c70e00b4876bd52e23e116 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 19 Aug 2022 10:21:23 +0100 Subject: [PATCH 19/73] Small fixes --- docs/styles/border.md | 8 ++++---- docs/styles/outline.md | 8 ++++---- docs/styles/scrollbar_size.md | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/styles/border.md b/docs/styles/border.md index e9c9638cc..3ac9a119a 100644 --- a/docs/styles/border.md +++ b/docs/styles/border.md @@ -28,10 +28,10 @@ Borders may also be set individually for the four edges of a widget with the `bo ``` border: [] []; -border-top: ] []; -border-right: ] []; -border-bottom: ] []; -border-left: ] []; +border-top: [] []; +border-right: [] []; +border-bottom: [] []; +border-left: [] []; ``` ## Border command diff --git a/docs/styles/outline.md b/docs/styles/outline.md index d3432e8de..e77574b15 100644 --- a/docs/styles/outline.md +++ b/docs/styles/outline.md @@ -30,10 +30,10 @@ Outlines may also be set individually with the `outline-top`, `outline-right`, ` ``` outline: [] []; -outline-top: ] []; -outline-right: ] []; -outline-bottom: ] []; -outline-left: ] []; +outline-top: [] []; +outline-right: [] []; +outline-bottom: [] []; +outline-left: [] []; ``` ## Example diff --git a/docs/styles/scrollbar_size.md b/docs/styles/scrollbar_size.md index de42ae96e..a6bfbcce8 100644 --- a/docs/styles/scrollbar_size.md +++ b/docs/styles/scrollbar_size.md @@ -4,7 +4,7 @@ The `scrollbar-size` rule changes the size of the scrollbars. It takes 2 integer The scrollbar dimensions may also be set individually with `scrollbar-size-horizontal` and `scrollbar-size-vertical`. -# Syntax +## Syntax ``` scrollbar-size: ; From ee97553c189a559f56bf32603316a7bfb58257a6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 10:22:58 +0100 Subject: [PATCH 20/73] fix for watchers --- docs/examples/introduction/timers.css | 20 +++++++++------- docs/examples/introduction/timers.py | 6 ++--- src/textual/reactive.py | 11 +++------ src/textual/widget.py | 34 +++++++++++++-------------- 4 files changed, 35 insertions(+), 36 deletions(-) diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index a5f2295fb..5d6272b7e 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -8,12 +8,24 @@ TimerWidget { transition: background 200ms linear; } +TimerWidget.started { + text-style: bold; + background: $success; + color: $text-success; + border: tall $success-darken-2; +} + + TimeDisplay { content-align: center middle; opacity: 60%; height: 3; } +TimerWidget.started TimeDisplay { + opacity: 100%; +} + Button { width: 16; } @@ -23,14 +35,6 @@ Button { } -TimerWidget.started { - opacity: 100%; - text-style: bold; - background: $success; - color: $text-success; - border: tall $success-darken-2; -} - TimerWidget.started #start { display: none } diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index 043591935..fc1abe5a0 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -68,15 +68,15 @@ class TimerApp(App): def compose(self) -> ComposeResult: yield Header() yield Footer() - yield Container(TimerWidget(), TimerWidget(), TimerWidget()) + yield Container(TimerWidget(), TimerWidget(), TimerWidget(), id="timers") def action_add_timer(self) -> None: new_timer = TimerWidget() - self.query_one(Container).mount(new_timer) + self.query_one("#timers").mount(new_timer) new_timer.scroll_visible() def action_remove_timer(self) -> None: - timers = self.query("Container TimerWidget") + timers = self.query("#timers TimerWidget") if timers: timers.last().remove() diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 84d6ed990..a5d52e70a 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -4,7 +4,6 @@ from inspect import isawaitable from functools import partial from typing import ( Any, - Awaitable, Callable, Generic, Type, @@ -44,7 +43,6 @@ class Reactive(Generic[ReactiveType]): self._default = default self.layout = layout self.repaint = repaint - self._first = True def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: @@ -68,19 +66,16 @@ class Reactive(Generic[ReactiveType]): return getattr(obj, self.internal_name) def __set__(self, obj: Reactable, value: ReactiveType) -> None: - name = self.name current_value = getattr(obj, self.internal_name, None) validate_function = getattr(obj, f"validate_{name}", None) + first_set = getattr(obj, f"{self.internal_name}__first_set", True) if callable(validate_function): value = validate_function(value) - - if current_value != value or self._first: - - self._first = False + if current_value != value or first_set: + setattr(obj, f"{self.internal_name}__first_set", True) setattr(obj, self.internal_name, value) self.check_watchers(obj, name, current_value) - if self.layout or self.repaint: obj.refresh(repaint=self.repaint, layout=self.layout) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3feb3a827..9d4a2938a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1183,16 +1183,16 @@ class Widget(DOMNode): async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app.broker_event(event_name, event, default_namespace=self) - async def on_mouse_down(self, event: events.MouseUp) -> None: + async def _on_mouse_down(self, event: events.MouseUp) -> None: await self.broker_event("mouse.down", event) - async def on_mouse_up(self, event: events.MouseUp) -> None: + async def _on_mouse_up(self, event: events.MouseUp) -> None: await self.broker_event("mouse.up", event) - async def on_click(self, event: events.Click) -> None: + async def _on_click(self, event: events.Click) -> None: await self.broker_event("click", event) - async def on_key(self, event: events.Key) -> None: + async def _on_key(self, event: events.Key) -> None: await self.dispatch_key(event) def _on_mount(self, event: events.Mount) -> None: @@ -1201,23 +1201,23 @@ class Widget(DOMNode): self.mount(*widgets) self.screen.refresh(repaint=False, layout=True) - def on_leave(self, event: events.Leave) -> None: + def _on_leave(self, event: events.Leave) -> None: self.mouse_over = False - def on_enter(self, event: events.Enter) -> None: + def _on_enter(self, event: events.Enter) -> None: self.mouse_over = True - def on_focus(self, event: events.Focus) -> None: + def _on_focus(self, event: events.Focus) -> None: self.emit_no_wait(events.DescendantFocus(self)) self.has_focus = True self.refresh() - def on_blur(self, event: events.Blur) -> None: + def _on_blur(self, event: events.Blur) -> None: self.emit_no_wait(events.DescendantBlur(self)) self.has_focus = False self.refresh() - def on_descendant_focus(self, event: events.DescendantFocus) -> None: + def _on_descendant_focus(self, event: events.DescendantFocus) -> None: self.descendant_has_focus = True if "focus-within" in self.pseudo_classes: sender = event.sender @@ -1226,7 +1226,7 @@ class Widget(DOMNode): if child is sender: break - def on_descendant_blur(self, event: events.DescendantBlur) -> None: + def _on_descendant_blur(self, event: events.DescendantBlur) -> None: self.descendant_has_focus = False if "focus-within" in self.pseudo_classes: sender = event.sender @@ -1235,37 +1235,37 @@ class Widget(DOMNode): if child is sender: break - def on_mouse_scroll_down(self, event) -> None: + def _on_mouse_scroll_down(self, event) -> None: if self.allow_vertical_scroll: if self.scroll_down(animate=False): event.stop() - def on_mouse_scroll_up(self, event) -> None: + def _on_mouse_scroll_up(self, event) -> None: if self.allow_vertical_scroll: if self.scroll_up(animate=False): event.stop() - def on_scroll_to(self, message: ScrollTo) -> None: + def _on_scroll_to(self, message: ScrollTo) -> None: if self.is_scrollable: self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1) message.stop() - def on_scroll_up(self, event: ScrollUp) -> None: + def _on_scroll_up(self, event: ScrollUp) -> None: if self.is_scrollable: self.scroll_page_up() event.stop() - def on_scroll_down(self, event: ScrollDown) -> None: + def _on_scroll_down(self, event: ScrollDown) -> None: if self.is_scrollable: self.scroll_page_down() event.stop() - def on_scroll_left(self, event: ScrollLeft) -> None: + def _on_scroll_left(self, event: ScrollLeft) -> None: if self.is_scrollable: self.scroll_page_left() event.stop() - def on_scroll_right(self, event: ScrollRight) -> None: + def _on_scroll_right(self, event: ScrollRight) -> None: if self.is_scrollable: self.scroll_page_right() event.stop() From 6a22c96a9e1831a7ac5889738bec8d5386fd111f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 11:06:10 +0100 Subject: [PATCH 21/73] screen fix --- docs/examples/introduction/timers.css | 2 +- docs/examples/introduction/timers.py | 10 ++++++++++ src/textual/dom.py | 25 ++++++++++++++++++++++--- src/textual/reactive.py | 2 +- src/textual/widget.py | 6 +++++- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index 5d6272b7e..ff92cc771 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -5,7 +5,7 @@ TimerWidget { border: tall $panel-darken-2; margin: 1; padding: 0 1; - transition: background 200ms linear; + transition: background 300ms linear; } TimerWidget.started { diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index fc1abe5a0..d990631ef 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -12,6 +12,7 @@ class TimeDisplay(Static): time_delta = Reactive(0.0) def watch_time_delta(self, time_delta: float) -> None: + """Called when time_delta changes.""" minutes, seconds = divmod(time_delta, 60) hours, minutes = divmod(minutes, 60) self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:05.2f}") @@ -25,20 +26,24 @@ class TimerWidget(Static): started = Reactive(False) def on_mount(self) -> None: + """Called when widget is first added.""" self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) def update_elapsed(self) -> None: + """Updates elapsed time.""" self.query_one(TimeDisplay).time_delta = ( self.total + monotonic() - self.start_time if self.started else self.total ) def compose(self) -> ComposeResult: + """Composes the timer widget.""" yield Button("Start", id="start", variant="success") yield Button("Stop", id="stop", variant="error") yield TimeDisplay() yield Button("Reset", id="reset") def watch_started(self, started: bool) -> None: + """Called when the 'started' attribute changes.""" if started: self.start_time = monotonic() self.update_timer.resume() @@ -51,6 +56,7 @@ class TimerWidget(Static): self.query_one("#start").focus() def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" button_id = event.button.id self.started = button_id == "start" if button_id == "reset": @@ -62,20 +68,24 @@ class TimerApp(App): """Manage the timers.""" def on_load(self) -> None: + """Called when the app first loads.""" self.bind("a", "add_timer", description="Add") self.bind("r", "remove_timer", description="Remove") def compose(self) -> ComposeResult: + """Called to ad widgets to the app.""" yield Header() yield Footer() yield Container(TimerWidget(), TimerWidget(), TimerWidget(), id="timers") def action_add_timer(self) -> None: + """An action to add a timer.""" new_timer = TimerWidget() self.query_one("#timers").mount(new_timer) new_timer.scroll_visible() def action_remove_timer(self) -> None: + """Called to remove a timer.""" timers = self.query("#timers TimerWidget") if timers: timers.last().remove() diff --git a/src/textual/dom.py b/src/textual/dom.py index 19e9d00f2..630c50aee 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,7 +1,16 @@ from __future__ import annotations from inspect import getfile -from typing import ClassVar, Iterable, Iterator, Type, overload, TypeVar, TYPE_CHECKING +from typing import ( + cast, + ClassVar, + Iterable, + Iterator, + Type, + overload, + TypeVar, + TYPE_CHECKING, +) import rich.repr from rich.highlighter import ReprHighlighter @@ -29,6 +38,14 @@ if TYPE_CHECKING: from .widget import Widget +class DOMError(Exception): + pass + + +class NoScreen(DOMError): + pass + + class NoParent(Exception): pass @@ -197,7 +214,8 @@ class DOMNode(MessagePump): Returns: DOMNode | None: The node which is the direct parent of this node. """ - return self._parent + + return cast("DOMNode | None", self._parent) @property def screen(self) -> "Screen": @@ -209,7 +227,8 @@ class DOMNode(MessagePump): node = self while node and not isinstance(node, Screen): node = node._parent - assert isinstance(node, Screen) + if not isinstance(node, Screen): + raise NoScreen("{self} has no screen") return node @property diff --git a/src/textual/reactive.py b/src/textual/reactive.py index a5d52e70a..f16dc78c3 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -73,7 +73,7 @@ class Reactive(Generic[ReactiveType]): if callable(validate_function): value = validate_function(value) if current_value != value or first_set: - setattr(obj, f"{self.internal_name}__first_set", True) + setattr(obj, f"{self.internal_name}__first_set", False) setattr(obj, self.internal_name, value) self.check_watchers(obj, name, current_value) if self.layout or self.repaint: diff --git a/src/textual/widget.py b/src/textual/widget.py index 9d4a2938a..73cc137c6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -37,7 +37,7 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive -from ._timer import Timer +from .dom import NoScreen if TYPE_CHECKING: @@ -516,6 +516,8 @@ class Widget(DOMNode): """The region occupied by this widget, relative to the Screen.""" try: return self.screen.find_widget(self).region + except NoScreen: + return Region() except errors.NoWidget: return Region() @@ -526,6 +528,8 @@ class Widget(DOMNode): """ try: return self.screen.find_widget(self).virtual_region + except NoScreen: + return Region() except errors.NoWidget: return Region() From a6e4db6ff272c16d805f423230584787b7f79c04 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 11:28:13 +0100 Subject: [PATCH 22/73] tweak default screen style --- docs/examples/introduction/timers.css | 1 + src/textual/screen.py | 2 ++ src/textual/widget.py | 12 ++++++++---- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index ff92cc771..f7030e623 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -6,6 +6,7 @@ TimerWidget { margin: 1; padding: 0 1; transition: background 300ms linear; + min-width: 50; } TimerWidget.started { diff --git a/src/textual/screen.py b/src/textual/screen.py index b8f6af01f..993fdb3f5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,6 +33,8 @@ class Screen(Widget): CSS = """ Screen { + color: $text-background; + background: $background; layout: vertical; overflow-y: auto; } diff --git a/src/textual/widget.py b/src/textual/widget.py index 73cc137c6..af731ae51 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -821,10 +821,14 @@ class Widget(DOMNode): # Adjust the region by the amount we just scrolled it, and convert to # it's parent's virtual coordinate system. region = ( - region.translate(-scroll_offset) - .translate(-widget.scroll_offset) - .translate(container.virtual_region.offset) - ).intersection(container.virtual_region) + ( + region.translate(-scroll_offset) + .translate(-widget.scroll_offset) + .translate(container.virtual_region.offset) + ) + .grow(container.styles.margin) + .intersection(container.virtual_region) + ) widget = container return scrolled From ac24e77ecfff2be073a289096a9f3738dfe5c42c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 14:13:57 +0100 Subject: [PATCH 23/73] fix screenshots --- docs/examples/introduction/timers.css | 20 +++++----- docs/examples/introduction/timers.py | 2 +- docs/introduction.md | 5 +++ src/textual/_doc.py | 2 + src/textual/_segment_tools.py | 2 +- src/textual/_timer.py | 54 +++++++++++++++----------- src/textual/app.py | 9 +++-- src/textual/dom.py | 2 +- src/textual/drivers/headless_driver.py | 34 +++++++++++++++- src/textual/message_pump.py | 5 ++- src/textual/screen.py | 2 - src/textual/widgets/_header.py | 2 +- 12 files changed, 93 insertions(+), 46 deletions(-) diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index f7030e623..a9fc25a31 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -1,12 +1,12 @@ TimerWidget { layout: horizontal; - height: 5; background: $panel-darken-1; border: tall $panel-darken-2; + height: 5; + min-width: 50; margin: 1; padding: 0 1; transition: background 300ms linear; - min-width: 50; } TimerWidget.started { @@ -35,6 +35,14 @@ Button { dock: left; } +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} TimerWidget.started #start { display: none @@ -48,11 +56,3 @@ TimerWidget.started #reset { visibility: hidden } -#stop { - dock: left; - display: none; -} - -#reset { - dock: right; -} diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index d990631ef..183e50a66 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -91,6 +91,6 @@ class TimerApp(App): timers.last().remove() -app = TimerApp(title="TimerApp", css_path="timers.css") +app = TimerApp(css_path="timers.css") if __name__ == "__main__": app.run() diff --git a/docs/introduction.md b/docs/introduction.md index 6721a5d7e..1e855bfed 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -10,6 +10,11 @@ By the end of this page you should have a good idea of the steps involved in cre - Installed `textual` from Pypi. - Basic Python skills. + +```{.textual path="docs/examples/introduction/timers.py"} + +``` + ## A Simple App Let's looks at the simplest possible Textual app. diff --git a/src/textual/_doc.py b/src/textual/_doc.py index f201402e9..802dd2091 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -12,6 +12,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): os.environ["LINES"] = attrs.get("lines", "24") path = attrs.get("path") + print(f"screenshotting {path!r}") if path: cwd = os.getcwd() examples_path, filename = os.path.split(path) @@ -34,4 +35,5 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): app = app_vars["app"] app.run() svg = app._screenshot + return svg diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 9863a9b59..b2e4a13f7 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -188,6 +188,6 @@ def align_lines( get_line_length = Segment.get_line_length for line in lines: left_space = width - get_line_length(line) - yield [*line, Segment(" " * left_space, style)] + yield [Segment(" " * left_space, style), *line] yield from blank_lines(bottom_blank_lines) diff --git a/src/textual/_timer.py b/src/textual/_timer.py index 3231fe016..4edf75c43 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -61,6 +61,7 @@ class Timer: self._repeat = repeat self._skip = skip self._active = Event() + self._task: Task | None = None if not pause: self._active.set() @@ -82,17 +83,21 @@ class Timer: Returns: Task: A Task instance for the timer. """ - self._task = asyncio.create_task(self._run()) + self._task = asyncio.create_task(self.run()) return self._task def stop_no_wait(self) -> None: """Stop the timer.""" - self._task.cancel() + if self._task is not None: + self._task.cancel() + self._task = None async def stop(self) -> None: """Stop the timer, and block until it exits.""" - self._task.cancel() - await self._task + if self._task is not None: + self._active.set() + self._task.cancel() + self._task = None def pause(self) -> None: """Pause the timer.""" @@ -102,31 +107,35 @@ class Timer: """Result a paused timer.""" self._active.set() + async def run(self) -> None: + """Run the timer task.""" + try: + await self._run() + except CancelledError: + pass + async def _run(self) -> None: """Run the timer.""" count = 0 _repeat = self._repeat _interval = self._interval start = _clock.get_time_no_wait() - try: - while _repeat is None or count <= _repeat: - next_timer = start + ((count + 1) * _interval) - now = await _clock.get_time() - if self._skip and next_timer < now: - count += 1 - continue - now = await _clock.get_time() - wait_time = max(0, next_timer - now) - if wait_time: - await _clock.sleep(wait_time) + while _repeat is None or count <= _repeat: + next_timer = start + ((count + 1) * _interval) + now = await _clock.get_time() + if self._skip and next_timer < now: count += 1 - try: - await self._tick(next_timer=next_timer, count=count) - except EventTargetGone: - break - await self._active.wait() - except CancelledError: - pass + continue + now = await _clock.get_time() + wait_time = max(0, next_timer - now) + if wait_time: + await _clock.sleep(wait_time) + count += 1 + try: + await self._tick(next_timer=next_timer, count=count) + except EventTargetGone: + break + await self._active.wait() async def _tick(self, *, next_timer: float, count: int) -> None: """Triggers the Timer's action: either call its callback, or sends an event to its target""" @@ -140,5 +149,4 @@ class Timer: count=count, callback=self._callback, ) - await self.target.post_priority_message(event) diff --git a/src/textual/app.py b/src/textual/app.py index 70020ceae..c0c574bd6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -144,7 +144,7 @@ class App(Generic[ReturnType], DOMNode): log_color_system: Literal[ "auto", "standard", "256", "truecolor", "windows" ] = "auto", - title: str = "Textual Application", + title: str | None = None, css_path: str | PurePath | None = None, watch_css: bool = False, ): @@ -189,7 +189,10 @@ class App(Generic[ReturnType], DOMNode): self.animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) self.bindings = Bindings() - self._title = title + if title is None: + self._title = f"{self.__class__.__name__}" + else: + self._title = title self._log_console: Console | None = None self._log_file: TextIO | None = None @@ -1015,7 +1018,7 @@ class App(Generic[ReturnType], DOMNode): self._screenshot = svg # type: ignore await self.shutdown() - self.set_timer(screenshot_timer, on_screenshot) + self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") def on_mount(self) -> None: widgets = self.compose() diff --git a/src/textual/dom.py b/src/textual/dom.py index 630c50aee..357520005 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -105,7 +105,7 @@ class DOMNode(MessagePump): self._auto_refresh_timer = None if interval is not None: self._auto_refresh_timer = self.set_interval( - interval, self._automatic_refresh + interval, self._automatic_refresh, name=f"auto refresh {self!r}" ) self._auto_refresh = interval diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py index ede6c4e8a..cdde957b9 100644 --- a/src/textual/drivers/headless_driver.py +++ b/src/textual/drivers/headless_driver.py @@ -1,14 +1,44 @@ from __future__ import annotations - +import asyncio from ..driver import Driver +from ..geometry import Size +from .. import events class HeadlessDriver(Driver): """A do-nothing driver for testing.""" + def _get_terminal_size(self) -> tuple[int, int]: + width: int | None = 80 + height: int | None = 25 + import shutil + + try: + width, height = shutil.get_terminal_size() + except (AttributeError, ValueError, OSError): + try: + width, height = shutil.get_terminal_size() + except (AttributeError, ValueError, OSError): + pass + width = width or 80 + height = height or 25 + return width, height + def start_application_mode(self) -> None: - pass + loop = asyncio.get_running_loop() + + def send_size_event(): + terminal_size = self._get_terminal_size() + width, height = terminal_size + textual_size = Size(width, height) + event = events.Resize(self._target, textual_size, textual_size) + asyncio.run_coroutine_threadsafe( + self._target.post_message(event), + loop=loop, + ) + + send_size_event() def disable_input(self) -> None: pass diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 38e013dd9..09e05cf7b 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -254,7 +254,8 @@ class MessagePump(metaclass=MessagePumpMeta): if self._closed or self._closing: return self._closing = True - for timer in self._timers: + stop_timers = list(self._timers) + for timer in stop_timers: await timer.stop() self._timers.clear() await self._message_queue.put(MessagePriority(None)) @@ -274,7 +275,7 @@ class MessagePump(metaclass=MessagePumpMeta): pass finally: self._running = False - for timer in self._timers: + for timer in list(self._timers): await timer.stop() async def _process_messages(self) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index 993fdb3f5..b8f6af01f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,8 +33,6 @@ class Screen(Widget): CSS = """ Screen { - color: $text-background; - background: $background; layout: vertical; overflow-y: auto; } diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 39b3c8893..8374e4327 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -41,7 +41,7 @@ class HeaderClock(Widget): """ def on_mount(self) -> None: - self.set_interval(1, callback=self.refresh) + self.set_interval(1, callback=self.refresh, name=f"update header clock") def render(self): return Text(datetime.now().time().strftime("%X")) From b3feec1ef9185052628f01b2243bc7b34e0e04de Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 17:32:56 +0100 Subject: [PATCH 24/73] docs and fixes --- docs/examples/introduction/stopwatch01.py | 19 +++++ docs/examples/introduction/timers.css | 5 +- docs/examples/introduction/timers.py | 4 + docs/getting_started.md | 23 ++++++ docs/index.md | 37 ++++------ docs/introduction.md | 90 ++++++++++++++++++++++- mkdocs.yml | 1 + src/textual/_doc.py | 11 ++- src/textual/app.py | 28 +++++-- src/textual/screen.py | 2 + 10 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 docs/examples/introduction/stopwatch01.py create mode 100644 docs/getting_started.md diff --git a/docs/examples/introduction/stopwatch01.py b/docs/examples/introduction/stopwatch01.py new file mode 100644 index 000000000..928372d93 --- /dev/null +++ b/docs/examples/introduction/stopwatch01.py @@ -0,0 +1,19 @@ +from textual.app import App +from textual.widgets import Header, Footer + + +class TimerApp(App): + def compose(self): + yield Header() + yield Footer() + + def on_load(self): + self.bind("d", "toggle_dark", description="Dark mode") + + def action_toggle_dark(self): + self.dark = not self.dark + + +app = TimerApp() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index a9fc25a31..458d9045b 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -1,11 +1,11 @@ TimerWidget { layout: horizontal; background: $panel-darken-1; - border: tall $panel-darken-2; + height: 5; min-width: 50; margin: 1; - padding: 0 1; + padding: 1 1; transition: background 300ms linear; } @@ -13,7 +13,6 @@ TimerWidget.started { text-style: bold; background: $success; color: $text-success; - border: tall $success-darken-2; } diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index 183e50a66..b7d667780 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -71,6 +71,7 @@ class TimerApp(App): """Called when the app first loads.""" self.bind("a", "add_timer", description="Add") self.bind("r", "remove_timer", description="Remove") + self.bind("d", "toggle_dark", description="Dark mode") def compose(self) -> ComposeResult: """Called to ad widgets to the app.""" @@ -90,6 +91,9 @@ class TimerApp(App): if timers: timers.last().remove() + def action_toggle_dark(self) -> None: + self.dark = not self.dark + app = TimerApp(css_path="timers.css") if __name__ == "__main__": diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 000000000..fa5d6b9fe --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,23 @@ +## Installation + +You can install Textual via PyPi. + +If you plan on developing Textual apps, then you can 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 app + +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. See the help for more details: + +```python +textual --help +``` diff --git a/docs/index.md b/docs/index.md index 2fb8bc20f..61444695c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,42 +6,31 @@ Welcome to the [Textual](https://github.com/Textualize/textual) framework docume Textual is a Python framework for building applications that run within your terminal. -Such text-based applications have a number of benefits: +Text User Interfaces (TUIs) have a number of benefits: -- **Quick to develop:** Textual is a modern Python API. -- **Low requirements:** Run Textual apps anywhere with a Python interpreter, even single-board computers. +- **Quick to develop:** Really rapid app development with a modern Python API. +- **Low requirements:** Textual apps anywhere with a Python interpreter, even single-board computers. - **Cross platform:** The same code will run on Linux, Windows, MacOS and more. - **Remote:** Fully featured UIs can run over SSH. - **CLI integration:** Textual apps integrate with your shell and other CLI tools. Textual TUIs are quick and easy to build with pure Python (not to mention _fun_). +
+ + + -```{.textual path="docs/examples/demo.py" columns=100 lines=48} +=== "Example 1" -``` + ```{.textual path="docs/examples/demo.py" columns=100 lines=48} -## Installation + ``` -You can install Textual via PyPi. +=== "Example 2" -If you plan on developing Textual apps, then you can install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. + ```{.textual path="docs/examples/introduction/timers.py"} -```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 app - -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. See the help for more details: - -```python -textual --help -``` diff --git a/docs/introduction.md b/docs/introduction.md index 1e855bfed..ab2e434b0 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -4,6 +4,94 @@ Welcome to the Textual Introduction! By the end of this page you should have a good idea of the steps involved in creating an application with Textual. + +## Stopwatch Application + +We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop / resume / reset each stopwatch in addition to adding or removing them. + +This is a simple yet **fully featured** app — you could distribute this app if you wanted to! + +Here's what the finished app will look like: + + +```{.textual path="docs/examples/introduction/timers.py"} +``` + +## The App class + +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 which we will cover below. + +```python title="stopwatch01.py" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + +If you run this code, you should see something like the following: + + +```{.textual path="docs/examples/introduction/stopwatch01.py"} +``` + +Hit the ++d++ key to toggle dark mode. + +```{.textual path="docs/examples/introduction/stopwatch01.py" press="d" title="TimerApp + dark"} +``` + +Hit ++ctrl+c++ to exit the app and return to the command prompt. + +### Looking at the code + +Let's example 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-15" +--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. + +There are three methods in our stopwatch app currently. + +- **`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` it 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 action to the ++d++ key. The body of this method flips the state of the `dark` boolean to toggle dark mode. + +!!! note + + You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update. + + +```python title="stopwatch01.py" hl_lines="17-19" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + +The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and run it within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. + +## Timers + +=== "Timers Python" + + ```python title="timers.py" + --8<-- "docs/examples/introduction/timers.py" + ``` + +=== "Timers CSS" + + ```python title="timers.css" + --8<-- "docs/examples/introduction/timers.css" + ``` + + ## Pre-requisites - Python 3.7 or later. If you have a choice, pick the most recent version. @@ -37,7 +125,7 @@ The command prompt should disappear and you will see a blank screen: ``` -Hit ++ctrl+c++ to exit and return to the command prompt. +Hit ++Ctrl+c++ to exit and return to the command prompt. ### Application mode diff --git a/mkdocs.yml b/mkdocs.yml index e7ac4573e..fa0fdc5d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,7 @@ site_url: https://www.textualize.io/ nav: - "index.md" + - "getting_started.md" - "introduction.md" - Guide: - "guide/guide.md" diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 802dd2091..ce40361dd 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -6,11 +6,18 @@ import os def format_svg(source, language, css_class, options, md, attrs, **kwargs): """A superfences formatter to insert a SVG screenshot.""" + path = attrs.get("path") + press = attrs.get("press", "").split(",") + title = attrs.get("title") + os.environ["TEXTUAL"] = "headless" os.environ["TEXTUAL_SCREENSHOT"] = "0.15" + if title: + os.environ["TEXTUAL_SCREENSHOT_TITLE"] = title + else: + os.environ.pop("TEXTUAL_SCREENSHOT_TITLE", None) os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") - path = attrs.get("path") print(f"screenshotting {path!r}") if path: @@ -24,7 +31,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): exec(source, app_vars) app = app_vars["app"] - app.run() + app.run(press=press or None) svg = app._screenshot finally: diff --git a/src/textual/app.py b/src/textual/app.py index c0c574bd6..fb9ef621d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -481,12 +481,13 @@ class App(Generic[ReturnType], DOMNode): """Action to save a screenshot.""" self.save_screenshot(path) - def export_screenshot(self) -> str: + def export_screenshot(self, *, title: str | None = None) -> str: """Export a SVG screenshot of the current screen. Args: - path (str | None, optional): Path of the SVG to save, or None to - generate a path automatically. Defaults to None. + title (str | None, optional): The title of the exported screenshot or None + to use app title. Defaults to None. + """ console = Console( @@ -499,7 +500,7 @@ class App(Generic[ReturnType], DOMNode): ) screen_render = self.screen._compositor.render(full=True) console.print(screen_render) - return console.export_svg(title=self.title) + return console.export_svg(title=title or self.title) def save_screenshot(self, path: str | None = None) -> str: """Save a screenshot of the current screen. @@ -545,7 +546,10 @@ class App(Generic[ReturnType], DOMNode): ) def run( - self, quit_after: float | None = None, headless: bool = False + self, + quit_after: float | None = None, + headless: bool = False, + press: Iterable[str] | None = None, ) -> ReturnType | None: """The main entry point for apps. @@ -566,6 +570,16 @@ class App(Generic[ReturnType], DOMNode): async def run_app() -> None: if quit_after is not None: self.set_timer(quit_after, self.shutdown) + if press is not None: + + async def press_keys(): + assert press + for key in press: + await asyncio.sleep(0.01) + await self.press(key) + + self.call_later(press_keys) + await self.process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: @@ -1009,12 +1023,14 @@ class App(Generic[ReturnType], DOMNode): except ValueError: return + screenshot_title = os.environ.get("TEXTUAL_SCREENSHOT_TITLE") + if not screenshot_timer: return async def on_screenshot(): """Used by docs plugin.""" - svg = self.export_screenshot() + svg = self.export_screenshot(title=screenshot_title) self._screenshot = svg # type: ignore await self.shutdown() diff --git a/src/textual/screen.py b/src/textual/screen.py index b8f6af01f..a8463446a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,6 +33,8 @@ class Screen(Widget): CSS = """ Screen { + background: $background; + color: $text-background; layout: vertical; overflow-y: auto; } From 5d8fafc74d5a9e48b87909ddc91efd3ab045f015 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 17:51:30 +0100 Subject: [PATCH 25/73] background logic --- docs/examples/introduction/intro02.py | 1 + src/textual/app.py | 6 +++--- src/textual/renderables/blank.py | 8 ++------ src/textual/screen.py | 7 ++++--- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/examples/introduction/intro02.py b/docs/examples/introduction/intro02.py index 2f5d3ed64..514029ebb 100644 --- a/docs/examples/introduction/intro02.py +++ b/docs/examples/introduction/intro02.py @@ -22,6 +22,7 @@ class ExampleApp(App): def on_key(self, event): if event.key.isdigit(): self.styles.background = self.COLORS[int(event.key)] + self.refresh() self.bell() diff --git a/src/textual/app.py b/src/textual/app.py index fb9ef621d..fac988dc6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -127,8 +127,8 @@ class App(Generic[ReturnType], DOMNode): CSS = """ App { - background: $surface; - color: $text-surface; + background: $background; + color: $text-background; } """ @@ -614,7 +614,7 @@ class App(Generic[ReturnType], DOMNode): self.screen.refresh(layout=True) def render(self) -> RenderableType: - return Blank() + return Blank(self.styles.background) def get_child(self, id: str) -> DOMNode: """Shorthand for self.screen.get_child(id: str) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index c0ed42b2f..b83dbbc34 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -11,12 +11,8 @@ class Blank: """Draw solid background color.""" def __init__(self, color: Color | str = "transparent") -> None: - background = color if isinstance(color, Color) else Color.parse(color) - self._style = ( - Style() - if background.is_transparent - else Style.from_color(None, background.rich_color) - ) + background = Color.parse(color) + self._style = Style.from_color(bgcolor=background.rich_color) def __rich_console__( self, console: Console, options: ConsoleOptions diff --git a/src/textual/screen.py b/src/textual/screen.py index a8463446a..c960d33bd 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,8 +33,6 @@ class Screen(Widget): CSS = """ Screen { - background: $background; - color: $text-background; layout: vertical; overflow-y: auto; } @@ -71,7 +69,10 @@ class Screen(Widget): pass def render(self) -> RenderableType: - return Blank() + background = self.styles.background + if background.is_transparent: + return self.app.render() + return Blank(background) def get_offset(self, widget: Widget) -> Offset: """Get the absolute offset of a given Widget. From 1a2997aa730b7e31ae3788614418dc7f1602eaba Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 19 Aug 2022 22:06:08 +0100 Subject: [PATCH 26/73] introduction docs --- .../{timers.css => stopwatch.css} | 13 +- .../introduction/{timers.py => stopwatch.py} | 18 +- docs/examples/introduction/stopwatch01.py | 4 +- docs/examples/introduction/stopwatch02.css | 1 + docs/examples/introduction/stopwatch02.py | 33 +++ docs/getting_started.md | 3 + docs/images/stopwatch.excalidraw.svg | 16 ++ docs/introduction.md | 221 +++++------------- src/textual/widgets/_button.py | 1 + 9 files changed, 126 insertions(+), 184 deletions(-) rename docs/examples/introduction/{timers.css => stopwatch.css} (77%) rename docs/examples/introduction/{timers.py => stopwatch.py} (87%) create mode 100644 docs/examples/introduction/stopwatch02.css create mode 100644 docs/examples/introduction/stopwatch02.py create mode 100644 docs/images/stopwatch.excalidraw.svg diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/stopwatch.css similarity index 77% rename from docs/examples/introduction/timers.css rename to docs/examples/introduction/stopwatch.css index 458d9045b..9a3ca8371 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/stopwatch.css @@ -1,7 +1,6 @@ -TimerWidget { +Stopwatch { layout: horizontal; background: $panel-darken-1; - height: 5; min-width: 50; margin: 1; @@ -9,7 +8,7 @@ TimerWidget { transition: background 300ms linear; } -TimerWidget.started { +Stopwatch.started { text-style: bold; background: $success; color: $text-success; @@ -22,7 +21,7 @@ TimeDisplay { height: 3; } -TimerWidget.started TimeDisplay { +Stopwatch.started TimeDisplay { opacity: 100%; } @@ -43,15 +42,15 @@ Button { dock: right; } -TimerWidget.started #start { +Stopwatch.started #start { display: none } -TimerWidget.started #stop { +Stopwatch.started #stop { display: block } -TimerWidget.started #reset { +Stopwatch.started #reset { visibility: hidden } diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/stopwatch.py similarity index 87% rename from docs/examples/introduction/timers.py rename to docs/examples/introduction/stopwatch.py index b7d667780..83ba802ba 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/stopwatch.py @@ -9,16 +9,16 @@ from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): """Displays the time.""" - time_delta = Reactive(0.0) + time = Reactive(0.0) - def watch_time_delta(self, time_delta: float) -> None: + def watch_time(self, time: float) -> None: """Called when time_delta changes.""" - minutes, seconds = divmod(time_delta, 60) + minutes, seconds = divmod(time, 60) hours, minutes = divmod(minutes, 60) self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:05.2f}") -class TimerWidget(Static): +class Stopwatch(Static): """The timer widget (display + buttons).""" start_time = Reactive(0.0) @@ -31,7 +31,7 @@ class TimerWidget(Static): def update_elapsed(self) -> None: """Updates elapsed time.""" - self.query_one(TimeDisplay).time_delta = ( + self.query_one(TimeDisplay).time = ( self.total + monotonic() - self.start_time if self.started else self.total ) @@ -64,7 +64,7 @@ class TimerWidget(Static): self.update_elapsed() -class TimerApp(App): +class StopwatchApp(App): """Manage the timers.""" def on_load(self) -> None: @@ -77,11 +77,11 @@ class TimerApp(App): """Called to ad widgets to the app.""" yield Header() yield Footer() - yield Container(TimerWidget(), TimerWidget(), TimerWidget(), id="timers") + yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers") def action_add_timer(self) -> None: """An action to add a timer.""" - new_timer = TimerWidget() + new_timer = Stopwatch() self.query_one("#timers").mount(new_timer) new_timer.scroll_visible() @@ -95,6 +95,6 @@ class TimerApp(App): self.dark = not self.dark -app = TimerApp(css_path="timers.css") +app = StopwatchApp(css_path="stopwatch.css") if __name__ == "__main__": app.run() diff --git a/docs/examples/introduction/stopwatch01.py b/docs/examples/introduction/stopwatch01.py index 928372d93..1c8df2d9f 100644 --- a/docs/examples/introduction/stopwatch01.py +++ b/docs/examples/introduction/stopwatch01.py @@ -2,7 +2,7 @@ from textual.app import App from textual.widgets import Header, Footer -class TimerApp(App): +class StopwatchApp(App): def compose(self): yield Header() yield Footer() @@ -14,6 +14,6 @@ class TimerApp(App): self.dark = not self.dark -app = TimerApp() +app = StopwatchApp() if __name__ == "__main__": app.run() diff --git a/docs/examples/introduction/stopwatch02.css b/docs/examples/introduction/stopwatch02.css new file mode 100644 index 000000000..15b6c4523 --- /dev/null +++ b/docs/examples/introduction/stopwatch02.css @@ -0,0 +1 @@ +/* Blank for now */ diff --git a/docs/examples/introduction/stopwatch02.py b/docs/examples/introduction/stopwatch02.py new file mode 100644 index 000000000..08c816a43 --- /dev/null +++ b/docs/examples/introduction/stopwatch02.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.layout import Container +from textual.widgets import Button, Header, Footer, Static + + +class TimeDisplay(Static): + pass + + +class Stopwatch(Static): + def compose(self) -> ComposeResult: + 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): + def compose(self): + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def on_load(self): + self.bind("d", "toggle_dark", description="Dark mode") + + def action_toggle_dark(self): + self.dark = not self.dark + + +app = StopwatchApp(css_path="stopwatch02.css") +if __name__ == "__main__": + app.run() diff --git a/docs/getting_started.md b/docs/getting_started.md index fa5d6b9fe..299d9d9c0 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -21,3 +21,6 @@ If you installed the dev dependencies, you have have access to the `textual` CLI ```python textual --help ``` + +### Textual Console + diff --git a/docs/images/stopwatch.excalidraw.svg b/docs/images/stopwatch.excalidraw.svg new file mode 100644 index 000000000..da4919e66 --- /dev/null +++ b/docs/images/stopwatch.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXGlv4kpcdTAwMTb93r9cIsr7MiN1/GpfWlx1MDAxYY2aJGTfyNbJzFPkYFx1MDAwM1x1MDAwZXiJMYGk1f99yobGXHUwMDA2bDBr/Fx1MDAwNrXSicu4blWde89dqvzzy9bWdvDumdvftrbNXlVvWYavd7e/htffTL9tuY5qQtHfbbfjV6M7XHUwMDFiQeC1v/35p637TTPwWnrV1N6sdkdvtYOOYbla1bX/tFx1MDAwMtNu/zv8ea7b5r881zZcdTAwMDJfizvZMVxyK3D9fl9my7RNJ2irp/9H/b219TP6mZDON6uB7tRbZvSFqClcdTAwMTZcdTAwMTBcdTAwMDM2fvXcdVwiYVx1MDAwNZeCQobI8Fx1MDAwNqu9p7pcdTAwMGJMQ7XWlMhm3Fx1MDAxMl7aftZZ5UflsV67aV5eet27mzbp8bjXmtVqXVx1MDAwN++tSKq2q1x1MDAwNlx1MDAxM7e1XHUwMDAz322a95ZcdTAwMTE0VCtcdTAwMWO7nvUt3+3UXHUwMDFijtluj3zH9fSqXHUwMDE1vIfXXHUwMDAwXHUwMDE4Xu1Pwbet+EovXFwgTjTIXHUwMDAwpFxm0WFD+FUkuUZcYsUwcb0vzK7bUpOvhPlcdTAwMDNEn1icZ73arCuZXHUwMDFjI76HgCqgiUnoXHUwMDBlhqg61LBcdTAwMWProGFa9UbQXHUwMDE3XFzjUPBE32Y07Vx1MDAxMCHOXHRCXHUwMDE4XHJbwlx1MDAxZb0jI0LAX+NcdTAwMTPX0H1vMEHb7fCPhLShoPvj8ElCKLG0R+2Kt1v9OD65uyq/PVxcXe7Dy4Pj4bNG8Kb7vtvdXHUwMDFltvxcdTAwMWH8XHUwMDE2i9bxXGa9XHUwMDBmXCLIXHUwMDE4kFhIQlxijnHYspymanQ6rVZ8za02Y9xFV399XVx1MDAwMO9cdTAwMDTBLLxLKlx1MDAwNKGU5Me79Yw9eWjZvbNTUzr6xav3+qNcXHC8XHUwMDBiolx0gvko2DHEXHUwMDFhxlQmry9cdTAwMDL2mk5cdTAwMTFFk2BXXHUwMDFhNolxxibAzVx1MDAwNVx1MDAxM1BBgWxcbty/MVx1MDAxM5i9YFx1MDAxNM39XHUwMDE1LstcdTAwMWW+t3uPpzY82rtznuzD48bOXFzYZlx1MDAxNCBcdTAwMDRWhe1cdTAwMTE585lxqERgXHUwMDFjcSRy4zp91Om4bujVRsc3i4BsmYZsrPC+PLJcdTAwMDNfd9qe7is0paCbpqBcdTAwMWLhSdNNoFQmX6xcdTAwMDHdq1x1MDAwNGC8zq5cdTAwMTNcXFtcdTAwMWbhVCdcdTAwMWVcdTAwMTBeLeu21XpcdTAwMWZZqlximUrS68BNXG6qt03VY4RDPnLv95ZVXHUwMDBmkbtdVWMw/Vx1MDAxMVBcdTAwMDeWcnWGN9iWYSSNeVVcdKCrZ/pHeYyw61t1y9FbN0n5XHUwMDE251x1MDAwZlx1MDAwNnCmolx1MDAwMYilXHUwMDA0PGHrZimac7rb2zlHonVcdTAwMDQrlbudd3BT8d+LTSCMYY1iOukwhVx1MDAxY1wi0ajPslx1MDAwMIVUTYNcdTAwMTh6XlxuXHUwMDExXHUwMDEzSlx1MDAwNjlSolx0sjFcbumvZKeHz/ZcdTAwMGXE61X3wSg1a1f2+Y/dSrp/XHUwMDE0aUpMIV/TXHUwMDFmO4uZ0jssXHUwMDFlMyXN3bjDXHUwMDA1XHUwMDAw44DP4XBNn+WCXHUwMDEyXHUwMDEzY1wiQ2Mw08jSXHUwMDFhs1x1MDAxMmriXHUwMDAwXHUwMDAxpWFcdCf8/5CaKmbbXGY2yk0z7Ps4N/VcdTAwMDVcXJycYMJcdTAwMWWO6Vx1MDAxYeFcdTAwMTAgKVh+XTt9cXvk2bVvKzdcdTAwMGZnJy+Hxi1cdKrF5ibCpVx1MDAwNqhcdTAwMTj19lwiTUNcXFx1MDAwM4pcdTAwMWGWZqda9EnTM6zB8XTBUOEw1eSoazrQO65cdTAwMDJOXHUwMDA0MF2D2i3GKu9nvXvy9HFcdTAwMWJ4p27JZ72G88H2XHUwMDBiyCoyk1Qg41x1MDAxOFxuzFn+cCd90Fx1MDAwNWdcdTAwMTXFq1x1MDAxOVjHSKNcdTAwMDBLwJbLXFxN51x1MDAxNYInYZ7CK1RcdTAwMDJcdTAwMTXwsHV4Y8XhXHUwMDE1XHUwMDAwvoX/uIbgRtllhoVcdTAwMWVnl6SYi3NcZpaZXHQ0KFx1MDAxMEKcQs5yq96D17s+O9vbvz+5KZUhaHQuXHUwMDFi/ulnklxmzpMwpkByNuHOXHUwMDExKDTGKVx1MDAxN8umjLNCIMqYxjnHI0wykjJcdTAwMTZjXuYwKFJeJsFEbFRcdTAwMGJcdTAwMDVU8TCaQ1x1MDAwYlx1MDAxN1x1MDAwNyWl2VE5J5IjXHRIfj6Qp0J3XHUwMDBlzys+KV92XHUwMDFhdsO8uT0qelx1MDAxOUNQXHJcbkpcdFx1MDAxM2N0oJZdk6vI7WZcdTAwMTUy8uZ2w1x1MDAxY7+gPDGUjVx1MDAwNObfd2tvXHUwMDE1u9HYYcZHvWODk6eHXHUwMDEzPur6rDYwT++weC6UslwiWTrDlPdcdTAwMDBcdTAwMDCcw4WaPstcdTAwMDV1oZRVyFJcdTAwMWGqXCLzXHUwMDE1KM1qYnMsXHUwMDAwXHUwMDEwfFx1MDAxZFx1MDAxNb/i+FDXge5vNjafYeUn88ahgEv4TSiborCQiOE58sZu6ezVvEL8xLY7lXKt93Z6hbufq248T3BOwHhgXHUwMDEyaVx1MDAxYpHKJ1x1MDAwNWtWtoVcdTAwMDJ0SsLoRW662v4ku9dcdTAwMTcn/t3BWcOqXHUwMDE4h0dcdTAwMDI/XjTWSVrpXHUwMDFkXHUwMDE2j7RcdTAwMTCVmVrEOMCcUJA/xTV9mlx1MDAwYqpGKu7PUCNKNTVcdTAwMDVLRlx1MDAxZqtcYvslQUJSXG4/IZtcZudA4HKMNVxip4E2OqFr561cdTAwMTm2PyPqj8RcXJy9XHUwMDEwy3RcdTAwMTZcdTAwMDXDXGIgXHUwMDAy8juLL1xi6aVcdTAwMGZ0UcJ7XHUwMDFl8Wvw5qF6d/iZ8Vx1MDAxNZlcdTAwMWT0M40ySFx1MDAxMZ7YYKCMnrZ0xF/Tn1x1MDAwMaBpXHUwMDExP9eg0qVcdTAwMTFdXHUwMDFmKp7Sd4RcdTAwMDVcbnuP+oDjeohcdTAwMDBRXHUwMDA2XHUwMDEx081cdTAwMTVDZ/GMrt+wg5vHozNwdm/vXHUwMDA213Xv6Px6vr1iQkpcdTAwMThbnDXxXGZcdTAwMDTZXHRmgZQjMceuyPQxp8O96rvt9k5DXHUwMDBmqo3PXHUwMDA3vYCZoFx1MDAwZlx1MDAxZCZcZlx1MDAxOVlrkplSPlx0+pRcdTAwMDCJSVx1MDAwNKhSks0mmedcdTAwMDbicnRzaOpGkjY2wDQz7PQ401xmJFxcnGQozCRcdTAwMTmIII9yrrm1zrHfzq6b8OlcdTAwMTXJ7qNOwFx1MDAxYjx/WSizjFakb3S2vlx1MDAwMVxyXHUwMDExOFx1MDAxZVx0RV4hVPrGyFiIslx1MDAwMNEgJJ5NlkY0ZFLVcMpcdTAwMTY2jEhIR4UhlPtcdTAwMDDpXHUwMDBme087Puo1rEP2WD/AT/Z8hFwiXHUwMDE1u8fjWVfgwjNL85BhKIFAIH/8nz7qT6eUXHUwMDFjXHUwMDEwp5lcdTAwMTBXftQqID6DUVJgnlx1MDAxMr+E+2FcYkBcdTAwMWImlHmBuFx1MDAxY6GUXTfYMKHMsMnjhDKQcFx1MDAxOUKZknNTIERzKd1cdTAwMDV9qlx1MDAxZdbaxo9cdTAwMWGqli/v7fNeUL0reK2SaUxcYpJSrKSYa1x1MDAxM6nvYlx1MDAxNCtcdTAwMTHmkmHM1lCtTOeYQVx044BdXFw0bat3vUtOLvb3kEfeRXrObYFcdTAwMTMuiDC+mVoo49lHXHUwMDAxXHUwMDEwJ1xcxYNzZJrtl3N71zs9ObksXHUwMDE5rcNcdTAwMDezXrpuOkWvhXJccjOCxirx7337r0Gl+ktcdTAwMDfsy1x1MDAxNkNcdTAwMDWRaimI2HAxdK+iXHUwMDA3zydW4+Xq6vD2qU1eXHUwMDFiZeynYzxfXnlNj53l9aV3OI9CXG5cdTAwMWPmUNft9dEp6WouXHUwMDAwRILB/CHN9GkubJFcdTAwMTVlaiOnXHUwMDFhW4E2rqTKXG5cdTAwMDQlXHUwMDEy0Fxy74CeXHUwMDFihsv5fJuvss7gj1VXWVx0ySY/jCiClEKUW+FKp7fOU6Nj7IPXne835dot2OdcdTAwMWaFr1x1MDAwZkGNclx0U440Uya0de9cdTAwMGJdrMxcblx1MDAxMVx1MDAwM1x1MDAxMFO+jlx1MDAxNN404rqqXHUwMDFlo+fjXHUwMDAz123WXHL34+rgwymL/eX5cOWPncWH6Vx1MDAxZFx1MDAxNo9cdTAwMGaxyNw8ijBcdTAwMDVcXMEmf1x1MDAxOWn6LFx1MDAxN1U7WaZ2cqJcdLFmMsxXv1UsiNRSbfig6oa58LPqtzNIZeH6bWbmXHUwMDEx0yydI1x1MDAwMks218nw0sXLjtftPru8aetcdTAwMGZe2WLP2MjQuY1k1Wc7oJJRjVxuXGJStvlRXHUwMDE4XHUwMDFlXHUwMDBiktNf8oFcdNbJoodWXHUwMDAxStG3XHT6Q0RcdTAwMTlcdTAwMDSRXFyHomc8ptKfLH3fPb58XHUwMDEy349L4vj1hdJO7fHHyjIplFx1MDAwMry5slt4XFy8O5o7T9pcYjHyhaFcdGiZtWCKhVx1MDAxODlcIj9qXHUwMDFlRlx1MDAwNpJ2dr0vzFRL0J/UNPrNfvmJXHUwMDAyIMGEz/O2n+nLXFxMW0CBhqFgTGCMXHUwMDA1huPmQGhcdTAwMTCzcMNcdTAwMDRcck9LrscmcFx1MDAxMaagsFxmjyNcdTAwMTIueIqFoFCDXGJcdEXDXGZLXHUwMDA1+HF7oVxcaE7CnV7z24tIyEXrXHUwMDEzgomF6lx1MDAxM+0wqitZjmE5ddVcdTAwMTibjN9vrzrKQS6RrlY7oZQ7QGNSzZ6A4YY/nHxcdTAwMWJSOFx1MDAxN7pcdTAwMTdFXCJcdTAwMWFcbr1cdTAwMTiGw2lcdTAwMTREXHUwMDBlblx1MDAxOJqubdMxYplGh6G3g13Xtq1ATcClaznB+Fx1MDAxZNGIvod61jD1XHS9VU9Oto0rpFx1MDAxNz5x1DrHv23FkI3+XHUwMDE4/v7X19S7dzLxXHUwMDE0tY5DKX7cl+T/c5tcdTAwMTKEsjNbXHUwMDEySan0XHUwMDBi589sTWeigppcdTAwMTKuqbvSTmBSXHUwMDFj7mdmXHUwMDE4clxmQpu6XHUwMDE2M1wilSXjRP7+8JStMkRJyFx1MDAxMICIh5FcdTAwMTVAXHUwMDEzL8tQXHJSXHUwMDAyJlx1MDAxN8h5LWNHqFLbhfz8VdtcdTAwMTGgKUtPlGpcdTAwMDDIOFfkJ1x1MDAxMjf1zYjQiFx1MDAwMJHnON1+ZMkyvWg4XCJcdTAwMGKXPDxyoiDFpER0Ulx1MDAxNoQ1RFx1MDAwNydcdTAwMGYnpPlb2axM8IafXHTYzmmzMpNcdTAwMGZcdL5cdTAwMWM/8IQoZ4Th/KnBk4+Hg9dacNdFXHUwMDFkx6SWX6vA8n3BLVx1MDAxNlx1MDAxNlx1MDAxYUu3WMpcdTAwMTPShGDT34+wXFwgXHUwMDE0y1x1MDAxY7s5sXzDrXxcdTAwMWMgRMiGy2JLXHUwMDA0LFNcdTAwMDOhdVx1MDAwNVjrfVx1MDAxOaOgXGbMU6peUYD1X+dcdTAwMWaRhTKNf6bGWonU1Xz5mKWjraRki3lLOHsjiiRMXG7M5zi1Mn31XHUwMDBianqoirt+byVO7lTvv7FCaophXHUwMDE5ksph58mT2qs0QUxoov++1Yh4ROyBxJlcdTAwMTmNUsB5KCdnXHUwMDEykbRcdTAwMTdaYFx1MDAwMFx1MDAwNaaLpGqKXHUwMDFjek2ns1x1MDAxMTdcdTAwMDVCXCKVjVx1MDAwNpxcdMpJXHUwMDFjQlx1MDAwZt1cdTAwMTTlpfRPLEz3mP6+XHUwMDExVyaSws8khrLcly+DXHUwMDFltnXPu1x1MDAwZdSCXHUwMDBm10dhyjJcdTAwMDaWNFx1MDAxZeb2m2V2S9kvJfoymOrQXHUwMDAymOFgf/768ut/cMVj1yJ9 + + + + StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started) \ No newline at end of file diff --git a/docs/introduction.md b/docs/introduction.md index ab2e434b0..5a440de1d 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -14,7 +14,13 @@ This is a simple yet **fully featured** app — you could distribute this ap Here's what the finished app will look like: -```{.textual path="docs/examples/introduction/timers.py"} +```{.textual path="docs/examples/introduction/stopwatch.py"} +``` + +If you want to try this out before reading the rest of this introduction (we recommend it), navigate to "docs/examples/introduction" within the repository and run the following: + +```bash +python stopwatch.py ``` ## The App class @@ -75,194 +81,77 @@ There are three methods in our stopwatch app currently. --8<-- "docs/examples/introduction/stopwatch01.py" ``` -The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and run it within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. +The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and call `run()` within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. -## Timers +## Creating a custom widget -=== "Timers Python" +The header and footer were builtin widgets. For our stopwatch application we will need to build a custom widget for stopwatches. - ```python title="timers.py" - --8<-- "docs/examples/introduction/timers.py" - ``` +Let's sketch out what we are trying to achieve here: -=== "Timers CSS" - - ```python title="timers.css" - --8<-- "docs/examples/introduction/timers.css" - ``` +
+--8<-- "docs/images/stopwatch.excalidraw.svg" +
-## Pre-requisites +An individual stopwatch consists of several parts, which themselves can be widgets. -- Python 3.7 or later. If you have a choice, pick the most recent version. -- Installed `textual` from Pypi. -- Basic Python skills. +Out stopwatch widgets is going to need the following widgets: +- A "start" button +- A "stop" button +- A "reset" button +- A time display -```{.textual path="docs/examples/introduction/timers.py"} +Textual has a builtin `Button` widgets which takes care of the first three components. All we need to build is the time display which will show the elapsed time in HOURS:MINUTES:SECONDS format, and the stopwatch itself. -``` +Let's add those to our app: -## A Simple App - -Let's looks at the simplest possible Textual app. - -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: - -```python title="intro01.py" ---8<-- "docs/examples/introduction/intro01.py" +```python title="stopwatch02.py" hl_lines="3 6-7 10-15 22 31" +--8<-- "docs/examples/introduction/stopwatch02.py" ``` -Enter the following command to run the application: +### New widgets -```bash -python intro01.py +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 form a scrolling list of stopwatches. + +We're extending Static as a foundation for our `TimeDisplay` widget. There are no methods on this class yet. + +The Stopwatch also extends Static to define a new widget. This class has a `compose()` method which yields its _child_ widgets, consisting of of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch like the sketch. + +The Button constructor takes a label to be displayed to the user ("Start", "Stop", or "Reset") so they know what will happen when they click on it. There are two additional parameters to the Button constructor we are using: + +- **`id`** is an identifier so we can tell the buttons apart in code. We can also use this to style the buttons. 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 with we need to yield them from the app's `compose()` method: + +This new line in `Stopwatch.compose()` adds a single `Container` object which will create a scrolling list. The constructor for `Container` takes its _child_ widgets as positional arguments, to which we pass three instances of the `Stopwatch` we just built. + +### Setting the CSS path + +The `StopwatchApp` constructor has a new argument: `css_path` is set to the file `stopwatch02.css` which is blank: + +```python title="stopwatch02.css" +--8<-- "docs/examples/introduction/stopwatch02.css" ``` -The command prompt should disappear and you will see a blank screen: +### The unstyled app -```{.textual path="docs/examples/introduction/intro01.py"} +Let's see what happens when we run "stopwatch02.py": +```{.textual path="docs/examples/introduction/stopwatch02.py" title="stopwatch02.py"} ``` -Hit ++Ctrl+c++ to exit and return to the command prompt. +The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container, but it doesn't look much like the sketch. This is because we have yet to add any _styles_ to the CSS file. -### Application mode - -The first step in all Textual applications is to import the `App` class from `textual.app` and extend it: - -```python hl_lines="1 2 3 4 5" title="intro01.py" ---8<-- "docs/examples/introduction/intro01.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. - -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" -``` - -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. - -## Handling Events - -Most real-world applications will need 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 keys, moving the mouse, resizing the terminal, etc. - -Each event type is represented by an instance of one of a number of Event objects. These event objects may contain additional information regarding 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. +Textual uses CSS files to define what widgets look like. With CSS we can apply styles for color, borders, alignment, positioning, animation, and more. !!! 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. + 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! -The next example demonstrates handling events. Try running `intro02.py` in the `docs/examples/introduction` directory: +## Writing Textual CSS -```python title="intro02.py" ---8<-- "docs/examples/introduction/intro02.py" -``` - -When you run this app you should see a blue screen in your terminal, like the following: - -```{.textual path="docs/examples/introduction/intro02.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. - -!!! note - - 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. - -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. - -!!! note - - Event class names are transformed to _camel case_ when used in event handlers. So the `MouseMove` event will be handled by a method called `on_mouse_move`. - -The first event handler to run is `on_mount`. The `Mount` event is sent to your application immediately after entering application mode. - -```python hl_lines="19 20" title="intro02.py" ---8<-- "docs/examples/introduction/intro02.py" -``` - -The above `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 style properties which define how your app looks. We will explore those later. - -!!! note - - You may have noticed there is no function call to repaint the screen in this example. Textual is smart enough to know when the screen needs to be updated, and will do it automatically. - -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" -``` - -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()` to plays your terminal's bell sound. - -!!! note - - Every event has a corresponding `Event` object. Textual will call your event handler with an event object only 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. - -## Widgets - -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" -``` - -Here's what you will see if you run this code: - -```{.textual path="docs/examples/introduction/clock01.py"} - -``` - -This script imports `App` as before and also the `Widget` class from `textual.widget`. To create a Clock widget we extend from the Widget base class. - -```python title="clock01.py" hl_lines="7 8 9 10 11 12 13" ---8<-- "docs/examples/introduction/clock01.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 mount handler (`Clock.on_mount`) sets `styles.content_align` to `("center", "middle")` which tells Textual to center align its contents horizontally and vertically. If you size the terminal you should see that the text remains centered. - -The second line in `on_mount` calls `self.set_interval` which tells Textual to invoke the `self.refresh` method once per second, so our clock remains up-to-date. - -When Textual refreshes a widget it calls it's `render` method: - -```python title="clock01.py" hl_lines="12 13" ---8<-- "docs/examples/introduction/clock01.py" -``` - -The Clock's `render` method uses the datetime module to format the current date and time. It returns a string, but can also return a [Rich](https://github.com/Textualize/rich) _renderable_. Don't worry if you aren't familiar with Rich, we will cover that later. - -Before a Widget can be displayed, it must first be mounted on the app. This is typically done within the application's Mount handler: - -```python title="clock01.py" hl_lines="17 18" ---8<-- "docs/examples/introduction/clock01.py" -``` - -In the case of the clock application, we call `mount` with an instance of the `Clock` widget. - -That's all there is to this Clock example. It will display the current time until you hit ++ctrl+c++ - -## Compose - -Mounting "child" widgets from from an `on_mount` event is such a common pattern that Textual offers a convenience method to do that. - -If you implement a `compose()` method on your App or Widget, Textual will invoke it to get your "sub-widgets". This method should return an _iterable_ such as a list, but you may find it easier to use the `yield` statement to turn it in to a Python generator: - -```python title="clock02.py" hl_lines="17 18" ---8<-- "docs/examples/introduction/clock02.py" -``` - -Here's the clock example again using `compose()` rather than `on_mount`. Any Widgets yielded from this method will be mounted on to the App or Widget. In this case we mount our Clock widget as before. - -More sophisticated apps will likely yield multiple widgets from `compose()`, and many widgets will also yield child widgets of their own. - -## Next Steps - -We've seen how Textual apps can respond to events, and how to mount widgets which are like mini-applications in their own right. These are key concepts in Textual which you can use to build more sophisticated apps. - -The Guide covers this in much more detail and describes how arrange widgets on the screen and connect them with the core logic of your application. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index aa4ccf475..4a2b806b2 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -32,6 +32,7 @@ class Button(Widget, can_focus=True): CSS = """ Button { width: auto; + min-width: 10; height: 3; background: $panel; color: $text-panel; From 47a3536172d442bceaf336939960fb6b9bea3180 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 20 Aug 2022 10:38:00 +0100 Subject: [PATCH 27/73] more introduction docs --- docs/examples/introduction/stopwatch02.py | 2 +- docs/examples/introduction/stopwatch03.css | 30 ++++++ docs/examples/introduction/stopwatch03.py | 33 ++++++ docs/images/stopwatch.excalidraw.svg | 4 +- docs/images/stopwatch_widgets.excalidraw.svg | 16 +++ docs/introduction.md | 108 +++++++++++++++++-- 6 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 docs/examples/introduction/stopwatch03.css create mode 100644 docs/examples/introduction/stopwatch03.py create mode 100644 docs/images/stopwatch_widgets.excalidraw.svg diff --git a/docs/examples/introduction/stopwatch02.py b/docs/examples/introduction/stopwatch02.py index 08c816a43..cd44a7543 100644 --- a/docs/examples/introduction/stopwatch02.py +++ b/docs/examples/introduction/stopwatch02.py @@ -28,6 +28,6 @@ class StopwatchApp(App): self.dark = not self.dark -app = StopwatchApp(css_path="stopwatch02.css") +app = StopwatchApp() if __name__ == "__main__": app.run() diff --git a/docs/examples/introduction/stopwatch03.css b/docs/examples/introduction/stopwatch03.css new file mode 100644 index 000000000..3c1d2325d --- /dev/null +++ b/docs/examples/introduction/stopwatch03.css @@ -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; +} diff --git a/docs/examples/introduction/stopwatch03.py b/docs/examples/introduction/stopwatch03.py new file mode 100644 index 000000000..edf45089e --- /dev/null +++ b/docs/examples/introduction/stopwatch03.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.layout import Container +from textual.widgets import Button, Header, Footer, Static + + +class TimeDisplay(Static): + pass + + +class Stopwatch(Static): + def compose(self) -> ComposeResult: + 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): + def compose(self): + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def on_load(self): + self.bind("d", "toggle_dark", description="Dark mode") + + def action_toggle_dark(self): + self.dark = not self.dark + + +app = StopwatchApp(css_path="stopwatch03.css") +if __name__ == "__main__": + app.run() diff --git a/docs/images/stopwatch.excalidraw.svg b/docs/images/stopwatch.excalidraw.svg index da4919e66..365a10ff5 100644 --- a/docs/images/stopwatch.excalidraw.svg +++ b/docs/images/stopwatch.excalidraw.svg @@ -1,6 +1,6 @@ - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXGlv4kpcdTAwMTb93r9cIsr7MiN1/GpfWlx1MDAxYY2aJGTfyNbJzFPkYFx1MDAwM1x1MDAwZXiJMYGk1f99yobGXHUwMDA2bDBr/Fx1MDAwNrXSicu4blWde89dqvzzy9bWdvDumdvftrbNXlVvWYavd7e/htffTL9tuY5qQtHfbbfjV6M7XHUwMDFiQeC1v/35p637TTPwWnrV1N6sdkdvtYOOYbla1bX/tFx1MDAwMtNu/zv8ea7b5r881zZcdTAwMDJfizvZMVxyK3D9fl9my7RNJ2irp/9H/b219TP6mZDON6uB7tRbZvSFqClcdTAwMTZcdTAwMTBcdTAwMDM2fvXcdVwiYVx1MDAwNZeCQobI8Fx1MDAwNqu9p7pcdTAwMGJMQ7XWlMhm3Fx1MDAxMl7aftZZ5UflsV67aV5eet27mzbp8bjXmtVqXVx1MDAwN++tSKq2q1x1MDAwNlx1MDAxM7e1XHUwMDAz322a95ZcdTAwMTE0VCtcdTAwMWO7nvUt3+3UXHUwMDFijtluj3zH9fSqXHUwMDE1vIfXXHUwMDAwXHUwMDE4Xu1Pwbet+EovXFwgTjTIXHUwMDAwpFxm0WFD+FUkuUZcYsUwcb0vzK7bUpOvhPlcdTAwMDNEn1icZ73arCuZXHUwMDFjI76HgCqgiUnoXHUwMDBlhqg61LBcdTAwMWProGFa9UbQXHUwMDE3XFzjUPBE32Y07Vx1MDAxMCHOXHRCXHUwMDE4XHJbwlx1MDAxZb0jI0LAX+NcdTAwMTPX0H1vMEHb7fCPhLShoPvj8ElCKLG0R+2Kt1v9OD65uyq/PVxcXe7Dy4Pj4bNG8Kb7vtvdXHUwMDFltvxcdTAwMWH8XHUwMDE2i9bxXGa9XHUwMDBmXCLIXHUwMDE4kFhIQlxijnHYspymanQ6rVZ8za02Y9xFV399XVx1MDAwMO9cdTAwMDTBLLxLKlx1MDAwNKGU5Me79Yw9eWjZvbNTUzr6xav3+qNcXHC8XHUwMDBiolx0gvko2DHEXHUwMDFhxlQmry9cdTAwMDL2mk5cdTAwMTFFk2BXXHUwMDFhNolxxibAzVx1MDAwNVx1MDAxM1BBgWxcbty/MVx1MDAxM5i9YFx1MDAxNM39XHUwMDE1LstcdTAwMWW+t3uPpzY82rtznuzD48bOXFzYZlx1MDAxNCBcdTAwMDRWhe1cdTAwMTE585lxqERgXHUwMDFjcSRy4zp91Om4bujVRsc3i4BsmYZsrPC+PLJcdTAwMDNfd9qe7is0paCbpqBcdTAwMWLhSdNNoFQmX6xcdTAwMDHdq1x1MDAwNGC8zq5cdTAwMTNcXFtcdTAwMWbhVCdcdTAwMWVcdTAwMTBeLeu21XpcdTAwMWZZqlximUrS68BNXG6qt03VY4RDPnLv95ZVXHUwMDBmkbtdVWMw/Vx1MDAxMVBcdTAwMDeWcnWGN9iWYSSNeVVcdKCrZ/pHeYyw61t1y9FbN0n5XHUwMDE251x1MDAwZlx1MDAwNnCmolx1MDAwMYilXHUwMDA0PGHrZimac7rb2zlHonVcdTAwMDQrlbudd3BT8d+LTSCMYY1iOukwhVx1MDAxY1wi0ajPslx1MDAwMIVUTYNcdTAwMTh6XlxuXHUwMDExXHUwMDEzSlx1MDAwNjlSolx0sjFcbumvZKeHz/ZcdTAwMGXE61X3wSg1a1f2+Y/dSrp/XHUwMDE0aUpMIV/TXHUwMDFmO4uZ0jssXHUwMDFlMyXN3bjDXHUwMDA1XHUwMDAw44DP4XBNn+WCXHUwMDEyXHUwMDEzY1wiQ2Mw08jSXHUwMDFhs1x1MDAxMmriXHUwMDAwXHUwMDAxpWFcdCf8/5CaKmbbXGY2yk0z7Ps4N/VcdTAwMDVcXJycYMJcdTAwMWWO6Vx1MDAxYeFcdTAwMTAgKVh+XTt9cXvk2bVvKzdcdTAwMGZnJy+Hxi1cdKrF5ibCpVx1MDAwNqhcdTAwMTj19lwiTUNcXFx1MDAwM4pcdTAwMWGWZqda9EnTM6zB8XTBUOEw1eSoazrQO65cdTAwMDJOXHUwMDA0MF2D2i3GKu9nvXvy9HFcdTAwMWJ4p27JZ72G88H2XHUwMDBiyCoyk1Qg41x1MDAxOFxuzFn+cCd90Fx1MDAwNWdcdTAwMTXFq1x1MDAxOVjHSKNcdTAwMDBLwJbLXFxN51x1MDAxNYInYZ7CK1RcdTAwMDJcdTAwMTXwsHV4Y8XhXHUwMDE1XHUwMDAwvoX/uIbgRtllhoVcdTAwMWVnl6SYi3NcZpaZXHQ0KFx1MDAxMEKcQs5yq96D17s+O9vbvz+5KZUhaHQuXHUwMDFi/ulnklxmzpMwpkByNuHOXHUwMDExKDTGKVx1MDAxN8umjLNCIMqYxjnHI0wykjJcdTAwMTZjXuYwKFJeJsFEbFRcdTAwMGJcdTAwMDVU8TCaQ1x1MDAwYlx1MDAxN1x1MDAwNyWl2VE5J5IjXHRIfj6Qp0J3XHUwMDBlzys+KV92XHUwMDFhdsO8uT0qelx1MDAxOUNQXHJcbkpcdFx1MDAxM2N0oJZdk6vI7WZcdTAwMTUy8uZ2w1x1MDAxY7+gPDGUjVx1MDAwNObfd2tvXHUwMDE1u9HYYcZHvWODk6eHXHUwMDEzPur6rDYwT++weC6UslwiWTrDlPdcdTAwMDBcdTAwMDCcw4WaPstcdTAwMDV1oZRVyFJcdTAwMWGqXCLzXHUwMDE1KM1qYnMsXHUwMDAwXHUwMDEwfFx1MDAxZFx1MDAxNb/i+FDXge5vNjafYeUn88ahgEv4TSiborCQiOE58sZu6ezVvEL8xLY7lXKt93Z6hbufq248T3BOwHhgXHUwMDEyaVx1MDAxYpHKJ1x1MDAwNWtWtoVcdTAwMDJ0SsLoRW662v4ku9dcdTAwMTcn/t3BWcOqXHUwMDE4h0dcdTAwMDI/XjTWSVrpXHUwMDFkXHUwMDE2j7RcdTAwMTCVmVrEOMCcUJA/xTV9mlx1MDAwYqpGKu7PUCNKNTVcdTAwMDVLRlx1MDAxZqtcYvslQUJSXG4/IZtcZudA4HKMNVxip4E2OqFr561cdTAwMTm2PyPqj8RcXJy9XHUwMDEwy3RcdTAwMTZcdTAwMDXDXGIgXHUwMDAy8juLL1xi6aVcdTAwMGZ0UcJ7XHUwMDFl8Wvw5qF6d/iZ8Vx1MDAxNZlcdTAwMWT0M40ySFx1MDAxMZ7YYKCMnrZ0xF/Tn1x1MDAwMaBpXHUwMDExP9eg0qVcdTAwMTFdXHUwMDFmKp7Sd4RcdTAwMDVcbnuP+oDjeohcdTAwMDBRXHUwMDA2XHUwMDEx081cdTAwMTVDZ/GMrt+wg5vHozNwdm/vXHUwMDA213Xv6Px6vr1iQkpcdTAwMThbnDXxXGZcdTAwMDTZXHRmgZQjMceuyPQxp8O96rvt9k5DXHUwMDBmqo3PXHUwMDA3vYCZoFx1MDAwZlx1MDAxZCZcZlx1MDAxOVlrkplSPlx0+pRcdTAwMDCJSVx1MDAwNKhSks0mmedcdTAwMDbicnRzaOpGkjY2wDQz7PQ401xmJFxcnGQozCRcdTAwMTmIII9yrrm1zrHfzq6b8OlcdTAwMTXJ7qNOwFx1MDAxYjx/WSizjFakb3S2vlx1MDAwMVxyXHUwMDExOFx1MDAxZVx0RV4hVPrGyFiIslx1MDAwMNEgJJ5NlkY0ZFLVcMpcdTAwMTY2jEhIR4UhlPtcdTAwMDDpXHUwMDBme087Puo1rEP2WD/AT/Z8hFwiXHUwMDE1u8fjWVfgwjNL85BhKIFAIH/8nz7qT6eUXHUwMDFjXHUwMDEwp5lcdTAwMTBXftQqID6DUVJgnlx1MDAxMr+E+2FcYkBcdTAwMWImlHmBuFx1MDAxY6GUXTfYMKHMsMnjhDKQcFx1MDAxOUKZknNTIERzKd1cdTAwMDV9qlx1MDAxZdbaxo9cdTAwMWGqli/v7fNeUL0reK2SaUxcYpJSrKSYa1x1MDAxM6nvYlx1MDAxNCtcdTAwMTHmkmHM1lCtTOeYQVx044BdXFw0bat3vUtOLvb3kEfeRXrObYFcdTAwMTMuiDC+mVoo49lHXHUwMDAxXHUwMDEwJ1xcxYNzZJrtl3N71zs9ObksXHUwMDE5rcNcdTAwMDezXrpuOkWvhXJccjOCxirx7337r0Gl+ktcdTAwMDfsy1x1MDAxNkNcdTAwMDWRaimI2HAxdK+iXHUwMDA3zydW4+Xq6vD2qU1eXHUwMDFiZeynYzxfXnlNj53l9aV3OI9CXG5cdTAwMWPmUNft9dEp6WouXHUwMDAwRILB/CHN9GkubJFcdTAwMTVlaiOnXHUwMDFhW4E2rqTKXG5cdTAwMDQlXHUwMDEy0Fxy74CeXHUwMDFihsv5fJuvss7gj1VXWVx0ySY/jCiClEKUW+FKp7fOU6Nj7IPXne835dot2OdcdTAwMWaFr1x1MDAwZkGNclx0U440Uya0de9cdTAwMGJdrMxcblx1MDAxMVx1MDAwM1x1MDAxMFO+jlx1MDAxNN404rqqXHUwMDFlo+fjXHUwMDAz123WXHL34+rgwymL/eX5cOWPncWH6Vx1MDAxZFx1MDAxNo9cdTAwMGaxyNw8ijBcdTAwMDVcXMEmf1x1MDAxOWn6LFx1MDAxN1U7WaZ2cqJcdLFmMsxXv1UsiNRSbfig6oa58LPqtzNIZeH6bWbmXHUwMDEx0yydI1x1MDAwMks218nw0sXLjtftPru8aetcdTAwMGZe2WLP2MjQuY1k1Wc7oJJRjVxuXGJStvlRXHUwMDE4XHUwMDFlXHUwMDBiktNf8oFcdNbJoodWXHUwMDAxStG3XHT6Q0RcdTAwMTlcdTAwMDSRXFyHomc8ptKfLH3fPb58XHUwMDEy349L4vj1hdJO7fHHyjIplFx1MDAwMry5slt4XFy8O5o7T9pcYjHyhaFcdGiZtWCKhVx1MDAxODlcIj9qXHUwMDFlRlx1MDAwNpJ2dr0vzFRL0J/UNPrNfvmJXHUwMDAyIMGEz/O2n+nLXFxMW0CBhqFgTGCMXHUwMDA1huPmQGhcdTAwMTCzcMNcdTAwMDRcck9LrscmcFx1MDAxMaagsFxmjyNcdTAwMTIueIqFoFCDXGJcdEXDXGZLXHUwMDA1+HF7oVxcaE7CnV7z24tIyEXrXHUwMDEzgomF6lx1MDAxM+0wqitZjmE5ddVcdTAwMTibjN9vrzrKQS6RrlY7oZQ7QGNSzZ6A4YY/nHxcdTAwMWJSOFx1MDAxN7pcdTAwMTdFXCJcdTAwMWFcbr1cdTAwMTiGw2lcdTAwMTREXHUwMDBlblx1MDAxOJqubdMxYplGh6G3g13Xtq1ATcClaznB+Fx1MDAxZNGIvod61jD1XHS9VU9Oto0rpFx1MDAxNz5x1DrHv23FkI3+XHUwMDE4/v7X19S7dzLxXHUwMDE0tY5DKX7cl+T/c5tcdTAwMTKEsjNbXHUwMDEySan0XHUwMDBi589sTWeigppcdTAwMTKuqbvSTmBSXHUwMDFj7mdmXHUwMDE4clxmQpu6XHUwMDE2M1wilSXjRP7+8JStMkRJyFx1MDAxMICIh5FcdTAwMTVAXHUwMDEzL8tQXHJSXHUwMDAyJlx1MDAxN8h5LWNHqFLbhfz8VdtcdTAwMTGgKUtPlGpcdTAwMDDIOFfkJ1x1MDAxMjf1zYjQiFx1MDAwMJHnON1+ZMkyvWg4XCJcdTAwMGKXPDxyoiDFpER0Ulx1MDAxNoQ1RFx1MDAwNydcdTAwMGYnpPlb2axM8IafXHTYzmmzMpNcdTAwMGZcdL5cdTAwMWM/8IQoZ4Th/KnBk4+Hg9dacNdFXHUwMDFkx6SWX6vA8n3BLVx1MDAxNlx1MDAxNlx1MDAxYUu3WMpcdTAwMTPShGDT34+wXFwgXHUwMDE0y1x1MDAxY7s5sXzDrXxcdTAwMWMgRMiGy2JLXHUwMDA0LFNcdTAwMDOhdVx1MDAwNVjrfVx1MDAxOaOgXGbMU6peUYD1X+dcdTAwMWaRhTKNf6bGWonU1Xz5mKWjraRki3lLOHsjiiRMXG7M5zi1Mn31XHUwMDBianqoirt+byVO7lTvv7FCaophXHUwMDE5ksph58mT2qs0QUxoov++1Yh4ROyBxJlcdTAwMTmNUsB5KCdnXHUwMDEykbRcdTAwMTdaYFx1MDAwMFx1MDAwNaaLpGqKXHUwMDFjek2ns1x1MDAxMTdcdTAwMDVCXCKVjVx1MDAwNpxcdMpJXHUwMDFjQlx1MDAwZt1cdTAwMTTlpfRPLEz3mP6+XHUwMDExVyaSws8khrLcly+DXHUwMDFltnXPu1x1MDAwZdSCXHUwMDBm10dhyjJcdTAwMDaWNFx1MDAxZeb2m2V2S9kvJfoymOrQXHUwMDAymOFgf/768ut/cMVj1yJ9 + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVcXGlP40pcdTAwMTb93r9cdTAwMDIxX2akxq/2paXRqFx1MDAwM4R9XHUwMDBiW8PMXHUwMDEzMrFDXGbxguNA4Kn/+5SddOwktuM4S/tFLVx1MDAxYVxcjuu66tx77lJVf33Z2NhcZj48c/PbxqbZb+pcdTAwMWTL8PX3za/h9TfT71quo5pQ9HfX7fnN6M52XHUwMDEweN1vf/xh6/6LXHUwMDE5eFx1MDAxZL1pam9Wt6d3ukHPsFxcrenaf1iBaXf/XHUwMDEz/jzVbfPfnmtcdTAwMWKBr8WdbJmGXHUwMDE1uP6gL7Nj2qZcdTAwMTN01dP/q/7e2Pgr+pmQzjebge48dczoXHUwMDBiUVMsIFx1MDAwNmzy6qnrRMJcbi5cdTAwMDWFXGaR0VxyVndHdVx1MDAxN5iGam0pkc24Jby0+aizxo/G/VPr6uX83Hu/ueqSPo97bVmdzmXw0Ymk6rrqZeK2buC7L+atZVx1MDAwNG3VXG4nrmd9y3d7T23H7HbHvuN6etNcbj7Ca1x1MDAwMIyuXHUwMDBlhuDbRnylXHUwMDFmTlx1MDAxMCdcdTAwMWFkXHUwMDAwUoboqCH8KpJcXCOEYpi4Plx1MDAxMGbb7ajBV8L8XHUwMDAzRJ9YnEe9+fKkZHKM+Fx1MDAxZVx1MDAwMpqAJlx1MDAwNuF9+IqqQ1xyy4lcdTAwMGXapvXUXHUwMDBlXHUwMDA2gmtcdTAwMWNcbp7o24yGXHUwMDFkXCLEOUFcYqNRS9ijd2BEXGL4c3Lg2rrvXHJcdTAwMDdos1x1MDAxYv6RkDZcdTAwMTR0d1x1MDAxMj5JXGIlpvag2/C2m5+HRzdcdTAwMTf1t7uL8114vnc4etZcdTAwMTjedN933zdHLT+Hv8Wi9TxDXHUwMDFmgFxiMlx1MDAwNiRcdTAwMTaSXHUwMDEwgmNcdTAwMWN2LOdFNTq9Tie+5jZfYtxFV39+LYF3gmBcdTAwMTbeJVx1MDAxNYJQSorj3XrEnty37P7JsSlcdTAwMWT97NV7/VGvON5cdTAwMDXRXHUwMDA0wXxcdTAwMWPsXHUwMDE4YlxyYyqT18uAvaVTRNE02JWGTWOcsSlwc8FcdTAwMDRUUCDrXHUwMDAy9y/MXHUwMDA0Zj9cdTAwMThH82CG67KPb+3+/bFcclx1MDAwZnZunFx1MDAwN3v/sL01XHUwMDE3tlx1MDAxOVx1MDAwNVxigWVhe0zOYmZcdTAwMWMqXHUwMDExXHUwMDE4R1x1MDAxY4nCuE5/63RcXLf1Zrvnm1VAtkxDNlZ4X1x1MDAxY9mBrztdT/dcdTAwMTWaUtBNU9CN8LTpJlAqky9WgO5lXHUwMDAyMJ5n11x0Lq3PcKhcdTAwMTNcdTAwMGZcYq/WddvqfIxNVYRMJell4CZcdTAwMDXVu6bqMcIhXHUwMDFmu/d7x3pcbpG72VTvYPpjoFx1MDAwZSzl6oxusC3DSFx1MDAxYfOmXHUwMDEyQFfP9Fx1MDAwZopcdTAwMThh17eeLEfvXFwl5SvPXHUwMDFmjMlM/lAug0SCo8J65lx1MDAxY2/3t06R6Fx1MDAxY8BG42brXHUwMDAzXFw1/I9q81x1MDAwN2NEXHUwMDAzWIJJd4lgpPRcZoGF/aWmaVx1MDAxMEMvSiFiSskgR8q1XHUwMDEyZG1cdTAwMTQymMpeXHUwMDFmn+zsideL9zuj9tK6sE9/bDfS/aNIU2Jcbvma/thZzJTeYXFmXHUwMDEyWPmXWMaTtVwiZqJcdOM4yUycXHUwMDAzyVxiT8zsLI3JXHUwMDFm5ooyk7JcdTAwMTnpOkOp8rpcdTAwMTbXmaWQXHUwMDEzXHUwMDA3XGIoXHUwMDFkS7jhqyenXHUwMDEyXHUwMDE4XFyMnFx1MDAxYWbXXGbWyk4zTPwkO1xyXHUwMDA0LE9PMGFcdTAwMTEntI1wXGKQXHUwMDE0rHh4c/zs9smja183ru5Ojp73jWtcdTAwMTI0q01PhCtVo2Lc34v8QMQ1oMhhPKYuXHUwMDEz4kSfND3DXHUwMDFhnExcdTAwMTiMXHUwMDE0XHUwMDBlU02OO6dDveMq5FRcdTAwMTaArkDtyvHKx0n/ljx8Xlx1MDAwN96xW/NZv+18st1cbkY8kmRcdTAwMDFcdTAwMWQyjpVZ4ax4wJP+0lx1MDAxNadcdTAwMTVcdTAwMTVIZGBd+WJ0knCWzitcdTAwMDRPwzyFV6hcdTAwMDQq5GGr8MeqXHUwMDEz9Fx1MDAwMPAt/Mc1XHUwMDA018ouMyz0JLskxSzPMVhmptCgQFxicVxuOSusende//LkZGf39uiqVoeg3Ttv+8e/k2RwkZQxXHUwMDA1krOpnDGBQmOccrGqIIgypnHO8Vx1MDAxOJOMJY1cdTAwMDVcdTAwMWRPZY/CXCJcdTAwMDRcdTAwMTX/XHUwMDEwsVYtXHUwMDE0XHUwMDEwYonm0MLyoKRcdTAwMTRngpJcdTAwMTPJkVx1MDAwNKQ4XHUwMDFmyGOhO/unXHKf1M97bbttXl1cdTAwMWZUvZAhqFx1MDAwNlx1MDAwNaWEXHQ+XHUwMDE5mlx1MDAxM00uI7ubVcoomt1ccrP8gvLEq6wlNP++3Xpr2O32XHUwMDE2Mz6fejY4erg74uOuz3JD8/RcdTAwMGWr50IpK5KlM0x5XHUwMDBmXHUwMDAwwDlcXKj8Ua6oXHUwMDBipaxCltJQppElKM1yYnMsXHUwMDAwXHUwMDEwfFx1MDAxNTW/6vhQl4Hurzc2n2HlpzPHoYBcdTAwMGL4TSiborCQiOE5XHUwMDEyYW7t5NW8QPzItnuNeqv/dnyB33+vuvFcIsE5XHUwMDAxqZkwXCKVT1xuVqxspVx1MDAwMnRKwuhFrrve/iDfL8+O/Ju9k7bVMPZcdTAwMGZcdTAwMDS+P2uvkrTSO6xcdTAwMWVpIZpZgFGBP8CcUFA8xZU/zFx1MDAxNVUjXHUwMDE192eoXHUwMDExpVx1MDAxYV9xOrlY2C9cdFx1MDAxMpJSuM5s8lx1MDAxMIFwXHUwMDBlXHUwMDA0LsZYw3BcdTAwMWFo41x1MDAwM7py3pph+zOi/kjM8uyFWKazKFx1MDAxOEZcdTAwMDBcdTAwMTFQ3Fl8RkivfaKzXHUwMDFh3vGI34JXd82b/d9cdTAwMTlfkdlBP9Mog1x1MDAxNOGpJVx1MDAwNsroaVx1MDAwYkf8Lf1cdTAwMTFcdTAwMDCaXHUwMDE28XNccipdXHUwMDFh0/WR4il9R1igsPeoXHUwMDBmOKmHXGJcdTAwMTBlXHUwMDEwMV1fOXRcdTAwMTbP6PpcdTAwMTXbu7o/OFx1MDAwMSe39nZw+eRcdTAwMWScXs63WkxIXHRji7NcIp6BIDvBLJByJOZYXHUwMDE3mf7O6XBv+m63u9XWg2b794NewEzQh1x1MDAwZVx1MDAxM4aMrDTJTCmfXHUwMDA2fUqAxCRcdTAwMDJUKcl6k8xzXHUwMDAzcTG62Td1I0lcdTAwMWJrYJpcdTAwMTl2epJphlx1MDAxMpYnXHUwMDE5XG4zSVx1MDAwNlwiyKOca2Gtc+y3k8tcdTAwMTf48Irk+71OwFx1MDAxYjx9LpVZRkvSNzpb34CGXGKcjIRcIq9cdTAwMTAqfWNkXCJEKUE0XGKJR5OlXHUwMDExXHKZVjWcsohcciNcdTAwMTLSUWVcYuU2QPrdzsOWj/pta5/dP+3hXHUwMDA3ez5CkYrd4/dZVeDCM0vzkGEogUCgePyf/ta/nVJcbkCcZkJcXPlRy4D4XGZGSYF5SvxcdTAwMTKuhyFcdTAwMDCtmVDmXHUwMDA14mKEUnfdYM2EMsMmT1x1MDAxMspQwkVcYiUn56ZAiOZSujP60NxvdY1cdTAwMWYt1Kyf39qn/aB5U/FaJdOYXHUwMDEwJKVYSTHXplLf1ShWXCLMJcOYraBamc4xw1x1MDAxMsZcdTAwMWU7O3uxrf7lNjk6291BXHUwMDFl+Vx1MDAxMOk5t1x1MDAxMntcXFx1MDAxMGF8PbVQxrM3XHUwMDAzIE64ilx1MDAwN+fINNvPp/a2d3x0dF4zOvt35lPt8sWpei2Ua5hcdTAwMTE0UYn/XHUwMDE42H9ccirVXzhgX7RcdTAwMTgqiFRTQcSai6E7XHI9eDyy2s9cdTAwMTdcdTAwMTf711x1MDAwZl3y2q5jP1x1MDAxZOPF8soreuwsry+9w3lcdTAwMTRS4DCHumqvj+akq7lcdTAwMDBcdTAwMTBcdFx1MDAwNouHNPnDXFzZXCIrytRGTjW2XHUwMDA0bVxcSpVcdTAwMTVcYkokoGtdXHUwMDAxXVx1MDAwMoaL+Xzrr7LO4I9lV1lcdMkmP4wogpTC4jt0asfXzkO7Z+yC163vV/XWNdjln5WvXHUwMDBmQY1yXHRTNjVTJrRVr1x1MDAwYi1XZoWIXHUwMDAxiClfRVxuL4+4LpqH6PFwz3Vfnlxm9/Ni79Opi93F+XDpj53Fh+lcdTAwMWRWj1x1MDAwZrHIXFw8ijBcdTAwMDVcXMGmeFx1MDAxOSl/lKuqnSxTOznRhFgxXHUwMDE5XHUwMDE2q98qXHUwMDE2RGqq1rxVdc1cXPi76rczSKV0/TYz84hpls5cdTAwMTGBJZtrb3jt7HnLe39/dPmLrd95dYs9YiND59aSVZ/tgEpGNSogSFnmR2G4LUjmXHUwMDFm84FcdNZJ2W2rXHUwMDAwpejbXHUwMDE0/SGiXGaCSM5D1TNcdTAwMWW59Cdr37dcdTAwMGbPXHUwMDFmxPfDmjh8faa017r/sbRMXG6lXHUwMDAwr6/sXHUwMDE2blx1MDAxOH9cdTAwMWbPnSdthFx1MDAxOPvCyFx1MDAwNHTMVpBjIcY2yY+bh7FcdTAwMTdJ270+XHUwMDEwJtdcdTAwMTJcZlx1MDAwNjWNfrOPP1FcdTAwMDAkmPB5zvvJn+Zq2lx1MDAwMlxuNFxmXHUwMDA1Y1x1MDAwMmMsMJw0XHUwMDA3QoOYhVx1MDAwYiZouFtyNTaBizBcdTAwMDWFZbhcdTAwMWSR8OSBXHUwMDAx8cJcdTAwMGWoQYSEomGGpVx1MDAwMvykvVAuNCfhSq/57UUkZNn6hGCiVH2iXHUwMDFiRnU1yzEs50k1xibj1/lVXHUwMDA3XHUwMDA1yCXS1WYvlHJcdTAwMGJoTKrRXHUwMDEzMFxc8IeT5yGFY6F7USSiodCLYThcdTAwMWNGQeTwhpHp2jRcdTAwMWQjlmn8NfRusO3atlx1MDAxNahcdTAwMDE4dy0nmLwjeqPvoZ61TX1Kb9WTk22TXG7phU9cdTAwMWO3zvFvXHUwMDFiMWSjP0a///k19e6tTDxFrZNQilx1MDAxZvcl+f/cplx1MDAwNKHszJZEUir9wsUzW/lMVFFTwjV1V9pcdTAwMGVMisP1zFxmQ45BaFNXYkaksmScyF9cdTAwMWaeslSGKFx0XHUwMDE5XHUwMDAyXHUwMDEw8TCyXHUwMDAyaOq4XGbVICVgskTOa1x1MDAxMTtCldqW8vOXbUeApiw9UapcdTAwMDEg41xckZ9I3DQwI0IjXHUwMDAyRJ5jvv3IkiW/aDgmXHUwMDBilzzccqIgxaREdFpcdTAwMTaENUSHO1x1MDAwZqek+VvZrEzwhp8p2M5pszKTXHUwMDBmXHS+nNzwhChnhOHiqcGjz7u911Zw8456jkktv9WA9duKWywsNJZusZQnpFx0wfLPR1gsXHUwMDEwimWO3ZxYvtFSPlx1MDAwZVx1MDAxMFwiZM1lsVx1MDAwNVx1MDAwMpbcQGhVXHUwMDAx1mqPY1x1MDAxNJSBeUrVS1xusP7n/DOyUKbxr9RYK5G6mi9cdTAwMWazcLSVlKyct4SzXHUwMDE3okjCwpNw5ti1kj/7XHUwMDE1NT1UxV2/llx1MDAxMidXqlx1MDAwZk6skJpiWIakcth5cqf2Mk1cdTAwMTBcdTAwMTOaXHUwMDE4nLhcdTAwMWFcdTAwMTGPiD2QODOjUVxuOFx1MDAwZuXkTFwiknagXHUwMDA1XHUwMDA2UGBaJlVT5dArn87G3Fx1MDAxNFxiiVQ2XHUwMDFhcCYoJ3FcYj1yU5SXMtixkO8x/X0jrkwkhZ9pXGbN6b7k1jd5zuJcdTAwMWWoaF1IJIpnc9+E3fQ+3G6999yRXHUwMDA3dv2cg+1SlmSdp6lRXHJcdTAwMDCkQszpbC5ccjclrGg5W9FcdTAwMDNcYjFV1mE1S0fzPILT21OvfaVcdTAwMDb70rm+wVcnwdbxoZ3uXHUwMDExzFPIXFz6Y2dcdTAwMTUy0zss7r4oJlxy/cd5dlx1MDAwMeYqY1YskdyJMJX+4FhKguY4ai1/mCu6sEe56pmayFXYXFyBo1x1MDAxM1x1MDAxMMRcZofnXHUwMDExrfPgm1x1MDAxMlx1MDAxMFxczINe/7GGM3gj71jDL0Pl3dQ97zJQ4zZyStTUWMbw5eOx2nyzzPda9kl8X4a6XHUwMDFiKolcdTAwMTlOzF8/v/z8P1xiXCJcdTAwMWT6In0= - StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started) \ No newline at end of file + StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started)Reset \ No newline at end of file diff --git a/docs/images/stopwatch_widgets.excalidraw.svg b/docs/images/stopwatch_widgets.excalidraw.svg new file mode 100644 index 000000000..5dca9fb95 --- /dev/null +++ b/docs/images/stopwatch_widgets.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGlT40hcdTAwMTL93r/Cwe7Hdk3dR0dsbIChgaG5mqO7d2diQkjCViNbakvm8MT8901cdLBuY4xNm4lVXHUwMDEwgFVSVaoyX+XLrJT/fNdqrcV3obv2obXm3tqW7zlD62btfXL+2lx1MDAxZEZeMIAmmn6OgtHQTq/sxXFcdTAwMTh9+OWXvjW8cuPQt2xcdTAwMTdde9HI8qN45HhcdTAwMDGyg/4vXuz2o38nv1x1MDAwZqy++68w6DvxXHUwMDEwZYO0XceLg+H9WK7v9t1BXHUwMDFjQe//hc+t1p/p75x0Q9eOrUHXd9NcdTAwMWLSpkxAqWn57EEwSIVl2lAmqSaTXHUwMDBivGhcdTAwMTOGi11cdTAwMDdaL0FkN2tJTq2dnp9f9e6+XHUwMDFkn3S6t39cdTAwMDTc3/A+n5Fs1EvP90/iOz+VKlxu4GGytihcdTAwMWVcdTAwMDZX7lx1MDAxN8+Je9BKSueb7lx1MDAxYVx1MDAwNqNub+BGUeGeILRsL75LXHUwMDFlXHUwMDAxT07ez8CHVnbmNtGPYVxia2Y0Z0pMWpJbKedI6tzJe0k6gVx1MDAwZjNcdTAwMGaS/Fx1MDAwM6dHJsuFZV91QaCBk11ju1x1MDAwZXes7Jqbh+dcdTAwMTNSXCKlXHUwMDE0K4zac71uL06eXHUwMDA0Y6RcdTAwMDVcdTAwMTGS5kZ301knQjMpucTZ0yZjhrtOalx1MDAwML+X561nXHLDh/lZi5JcdTAwMGY5eVx1MDAxM1G3ytaTt6CcZte35eHhVd+7PenwvcOtTVx1MDAxYfI7PemrYG7WcFx1MDAxONysTVr+ej+tX+6PO0c3jjP6KK4x2dbtsO9/nK3fh/+yR1x1MDAxZYWOdW+bREpstMBCKqon7b43uILGwcj3s3OBfZWZ87ucwM+Dkc5ZWlx0RtxgsDBlzMwwuvtyvNUxu+3di+3401x1MDAxZnGwuf7Ht7ufXHQjMMgncMQwQ0pcdTAwMTglXHUwMDBiRpviSGJkuGCEvlxmSlx1MDAxY9tYqCqUiMRVXHUwMDA0SVlcdTAwMDFOYlx1MDAxMExhXCJeXHUwMDE3OJ/Wncue27fV2fZ3ur9zfni8e3xbb+Cxe1x1MDAxYs+Km7fSbeHq97NcdTAwMGX480BekDOHb0VcdTAwMWHxTYQgRlx1MDAxMDm7m5w+y0V89yy7N1x1MDAxYbqrgHDdhHBNXHUwMDExfznC46E1iEJrXGKoqkG5qEE5ZVx1MDAxNZRrwjHhkpvFo3yRNpjpOlx1MDAxOMQn3jiZbopcdTAwMGJnP1p9z78rqCs1TpD0JLaGcX4uI1x1MDAxN4ZMbVFcdTAwMTUuXve9bmK9azY8hDssXHUwMDE4duxcdTAwMDGdnFxc0PdcdTAwMWMn79lskMCCPoe7s3ikYOh1vYHln1x1MDAxNlx1MDAwNJzfm0rcjDaMqVx1MDAxNJRjNTPcLs87R+2D/e92XHUwMDE0nV7zL52rbXbo/VxcuKmn0MaNQkZcdTAwMDHPM1x1MDAxNV6qXHUwMDA0opRcdTAwMTZhuHi4MUTKmJ7gjlx0VFwizFx1MDAwZvCTXHUwMDAyXFwsl2pcdOib5rV+dH6cXHUwMDFkdunBRnggo1x1MDAwM1x1MDAxNq7v8976y53hW+n2KVx1MDAxZls/4Or5WMFcdTAwMWFDUVx1MDAwMlx1MDAxZZZJxujsTnb6NK8m6lx1MDAwNaZNqNdcdTAwMDQpwkuoWzTqOauCvcbJYlxmfIfklfE3dLJcdTAwMTh/SH9QcUaX7mqf8FZlV5tcdTAwMTdzfodrXHUwMDE4aYKe5FxcUaPV7OEricfHwaX+KI6v8d5Fe+ey2zXj1Vx1MDAwZV8lwKs2XHQkKVLSvDx6bUpcdTAwMDTVR6+6XHUwMDAyOVx1MDAwMUJcdTAwMTBDc2B8XHUwMDE1z0rWnWM2tP2uPmLYXHUwMDFm71njz/b+y13gW+n2Kc9aP+CM0r6h1FeTx9Y5XHUwMDE2Xs56UWokZSYz2CeXjanaW9GoWGpZv3BoYOlcdTAwMGJYOFx1MDAxNlx1MDAxMlx1MDAxNFx1MDAxM1xylFxcSP639tef3ch93aD4XHQ/V/bU91x1MDAwMs6FM66a42HKhKKEPiNcdTAwMWW2zU1b3vw63tzZMfZG2P66fbHprzrQXGZcdTAwMDeKU0DTPcxcZjLLpMSS1yCsXHUwMDEy91JcdTAwMDIqXHUwMDEwS/HOq1x1MDAwMzDRSvBcdTAwMTTVQ0zPXHSxOFxim/BVeJAymFx1MDAxZYWZXG6ne7dZhycjmvBkXGZcdTAwMTVKUj473Y10ZH23w/Obs30pdLBcdTAwMWZcdTAwMWSNembl4URcZlLlLFKKKGNcdTAwMTCBSSAv3PqciiqMuMa8sLs6QVx1MDAxNzdIXHUwMDBiUWx8hFx1MDAxOWaYXHUwMDAzrVx1MDAxMOr5OEule22cRUlcdTAwMWV0w1x1MDAxYjjeoFu+xVx1MDAxZDhcci2+XHUwMDE1xZ2g3/diXHUwMDEw4yjwXHUwMDA2cfmKtN/1xLp7rlXBXG70nG8rwyBMeiwy/+y/VmYn6YfJ/7+/r726XVVlejqnxayLd/m/z1x1MDAwNq0gsnx24lx1MDAwNDnmXHUwMDFjQD17dijY2/xOPlx1MDAwZa797e/H7lx1MDAwMVx1MDAxZrEtvi9XXHUwMDFmtFx1MDAxMqlyYcB9mYNB8PzLXHUwMDA1LUEwXHUwMDAwVlxuw+iSwu9MXHUwMDFkXHUwMDE5eDGSxIBFSNA+OMVcblx1MDAxNWVcZtyk0PPsz/xcdTAwMWbBS0Fws1qTo6zQZ4I5pVx1MDAwNzVYXHUwMDA2XCLXiGUjTFLjwmaPXHUwMDFjN1x1MDAxONk4wieCXW73Ozf/6Vx1MDAxZI5D/9eVXHUwMDA3M2BWYGZwxVx1MDAwM3OJsJRcdTAwMWMvc4OHYlx1MDAxOFxc6drtXHUwMDFkaCqJNUn8ck3BQH5cdTAwMDZ6syHfXHUwMDFhelx1MDAwYm1cdTAwMGKFblWJ6W2P6ltcdTAwMTBWqWDls1x1MDAxM6wqaSSsXHUwMDBms5Nlucv3jszh7fru4W7Q+1witkbOsVp9qCqkyphI/a6AoJSWyvhcdTAwMTZcdTAwMGVVXc0kZVitYJRcdTAwMDBDVtrIn8CR/4/R6tU12ivdN1x1MDAxMzin7t5wzctnXHUwMDFmXHUwMDExXG5KMYxqPXt2SG/g/tG+/Wn75uLzKNZb23a3c7lghDpW1HNcdTAwMTdcblGqXHUwMDE04qounqVcdTAwMDKxUqy58CRcdTAwMTGmSJQ9dlbNy1xmwmkoxE16ZLp4wKzB2Fx1MDAxME3wK5cmjkPdXHUwMDFi75OvO9+Ogo2wz3bUOs/yl1x1MDAwNfN7Vk3v5rfo6sw56O7cee3DXHUwMDFk7VxcR6eDzkI3Np6z0EyFVVPCleJGRIGaKFdS5lJIT0Fqz1x1MDAxY+3fnlx1MDAwZsbHwUdcdTAwMGKf7nb2TmPHWX1IXHUwMDE5jYypqUVcdTAwMDBTRcvmp0SaKpJqXHUwMDE4qVx1MDAxNNhQxl+7qvdcdTAwMDXQedrEXHRXOleJsey8bi9cdTAwMTh64yTz6rd86y5cdTAwMTg1bKI0ZHh997KIoMXkd6tCTcVxY9JcYqazXHTIUlx0joUms7vG6VpfUfLKXHJDtFxcOZTealx1MDAwNDIk2T9cdTAwMDKUU86XXGJmXCKRllx1MDAxNKxcdTAwMWGEXHUwMDAxXHUwMDBiVzVlRlx1MDAxODFcdTAwMDJOklx1MDAwMFHSXHUwMDFhlJKLMbNcbn5cdTAwMDFNks2xjflcdTAwMDJmXHUwMDBif2hun3V+ZpstI4/vbO3OQLhS/NqjRMo2QVx1MDAxY+bAMIk1T4xX69xVXStMXHUwMDE3bcRcZuOCXGJcZvpcdTAwMTaPMeDEYVx1MDAxNyn1i0VcIohpLFx1MDAxNVx1MDAxM1RcYqXAlOolXHUwMDEyXHUwMDE4KyEhguKYU1qR6U2ly1x1MDAxYW05OdpVM34m0W9kJKaxQkthTohiz4jCXHUwMDBmgzuLO9H19Zfx3bmOzm6xOdtb9YWMXG6NOOGVIJxcdTAwMTODhC5cdTAwMTckL3xcdMvcxDQ+wiTRmC2l1OLtUO6X8Vx1MDAxMdKyXd9v9a0hMIJV4FwiRYHm4yHCNFZKUaqowvRcdTAwMTn57unaXlH4MspcdTAwMTBcdTAwMTdcXFx1MDAwMThcZqBVZo97XHUwMDBmYoJMeU940SBcdTAwMTZcdTAwMThcdTAwMTGpXHUwMDE0JlxuYnFw6zU0hHOEy0n5XHS2qZFJwDFHlccqZNaanP10Z5DnXHUwMDFmXHUwMDE4USog8FOCJb48tyE78fZcbt1cdTAwMDeMS+ZcdTAwMWSJmzWGcCY1XHUwMDA1i6EsK3+aiMKQJIroXHUwMDFhWd5cdTAwMTLfaLTZ5GjnzHVBPIOr5p05iLUhYlJ69pUq+Fx1MDAxNG6ffu13Tva2NsdfutxcYra56GTiwlcqxVx1MDAxNYI4Q1x1MDAxNXbT00VcbrgswWTJb15l8z9ZlUQ1zS+VTjbTzStcdTAwMTONpVx1MDAxNi1cdTAwMTPK8/tcdTAwMTavRDRCy0mWpd9cdTAwMDZWqqbWxSiOg0F9nVsuLfM6dW5PyDhcdTAwMWZcdTAwMTkxZlxuxjlOtPGMctLpJrGiXHUwMDE411hcdTAwMDPfMIIkO9qGq6yXezai0XJLt2lcdTAwMTLLY82ESjhPUvJQRb1cdTAwMDRcdTAwMTEhklx1MDAxMFx1MDAxOEiRVEbWZD8hxKRGiVfOiCQwXUQ1TVx1MDAxM1xymO41ioxcdTAwMDRzoaRkQlx1MDAwYkY1XHUwMDE1mVwiJzyAYFx1MDAwNLOcaHM+UjL9S22K0lx1MDAwMLflSmtsYIJcdTAwMTRhplwiXGb4a5pcdTAwMDSIoDdGMVx1MDAxM287XHUwMDE50m4247S5YsFcdTAwMGJcIimUNpdcdTAwMDKSpEZUMzX7XHUwMDAy5l9dbv+wP5newa72+1x1MDAxZsVmcOZcZld9XHUwMDAx41x1MDAxYdh4qfIgXbpcdTAwMThFklx1MDAxMrzUqniiamr/qiRFY2U0qP+VX1r7XHUwMDE2fVxyZW/zx+2FP+j9uNnvXHUwMDBl+782fInJXHUwMDFjXHUwMDFjXHUwMDA1rF3OlVx1MDAwZZ6Lo2SaadmJan5cdTAwMWJ4UeufoTVw/baTfIfYoE1Wg6nMJOl8fIU0v2ZcdTAwMDZol8IwIWZPfk43j1x1MDAxNYW7kFx1MDAwMtWAnXK03C+CkM1lgkai0pfBTF5WNVx1MDAxMDlcdTAwMDGFfGVaMjcyZ6Ql0/1EiZZcYsW0Tt7M4lx1MDAwMmtMeO6yx0xcdLjGNGk9nZa82cLiiukkR3tiNU1U4N1Dh2tWXHUwMDE4nsSg4olGwIo852FcdTAwMDXNnmrt2nNvNmpeub5Mj2TJSWc2XHUwMDAxtps8259/vfvrf4F0XHUwMDFiRyJ9 + + + + Start00:00:00.00Reset5 lineshorizontal layout1 cell margin1 cell paddingaround buttonsbackground coloris $panel-darken-1 \ No newline at end of file diff --git a/docs/introduction.md b/docs/introduction.md index 5a440de1d..775dcd54c 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -58,7 +58,7 @@ The first line imports the Textual `App` class. The second line imports two buil 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-15" +```python title="stopwatch01.py" hl_lines="5-14" --8<-- "docs/examples/introduction/stopwatch01.py" ``` @@ -130,13 +130,6 @@ To see our widgets with we need to yield them from the app's `compose()` method: This new line in `Stopwatch.compose()` adds a single `Container` object which will create a scrolling list. The constructor for `Container` takes its _child_ widgets as positional arguments, to which we pass three instances of the `Stopwatch` we just built. -### Setting the CSS path - -The `StopwatchApp` constructor has a new argument: `css_path` is set to the file `stopwatch02.css` which is blank: - -```python title="stopwatch02.css" ---8<-- "docs/examples/introduction/stopwatch02.css" -``` ### The unstyled app @@ -145,13 +138,106 @@ Let's see what happens when we run "stopwatch02.py": ```{.textual path="docs/examples/introduction/stopwatch02.py" title="stopwatch02.py"} ``` -The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container, but it doesn't look much like the sketch. This is because we have yet to add any _styles_ to the CSS file. +The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container, but it doesn't look much like the sketch. This is because we have yet to apply any _styles_ to our new widget. -Textual uses CSS files to define what widgets look like. With CSS we can apply styles for color, borders, alignment, positioning, animation, and more. +## Writing Textual CSS + +Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on that styles object will change how Textual renders the widget. + +Here's how you might change the widget to use white text on a blue background: + +```python +self.styles.background = "blue" +self.styles.color = "white" +``` + +While its possible to set all styles for an app this way, Textual prefers to use CSS. + +CSS files are data files loaded by your app which contain information about what styles to apply to your widgets. !!! 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! -## Writing Textual CSS +To load a CSS file you can set the `css_path` attribute of your app. +```python title="stopwatch03.py" hl_lines="31" +--8<-- "docs/examples/introduction/stopwatch03.py" +``` + +This will tell Textual to load the following file when it starts the app: + +```css title="stopwatch03.css" +--8<-- "docs/examples/introduction/stopwatch03.css" +``` + +The only change was setting the css path. Our app will now look very different: + +```{.textual path="docs/examples/introduction/stopwatch03.py" title="stopwatch03.py"} +``` + +This app looks much more like our sketch. Textual has read style information from `stopwatch03.css` and applied it to the widgets. In effect setting attributes on `widget.styles`. + +CSS files contain a number of _declaration blocks_. Here's the first such block from `stopwatch03.css` again: + +```css +Stopwatch { + layout: horizontal; + background: $panel-darken-1; + height: 5; + padding: 1; + margin: 1; +} +``` + +The first line tells Textual that the styles should apply to the `Stopwatch` widget. The lines between the curly brackets contain the styles themselves. + +Here's how the Stopwatch block in the CSS impacts our `Stopwatch` widget: + +
+--8<-- "docs/images/stopwatch_widgets.excalidraw.svg" +
+ +- `layout: horizontal` aligns child widgets from left to right rather than top to bottom. +- `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: + +```css +TimeDisplay { + content-align: center middle; + opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} +``` + +The `TimeDisplay` block aligns text to the center (`content-align`), fades it slightly (`opacity`), and sets its height (`height`) to 3 lines. + +The `Button` block sets the width (`width`) of buttons to 16 cells (character widths). + +The last 3 blocks have a slightly different format. When the declaration begins with a `#` then the styles will be applied to any widget with a matching "id" attribute. We've set an id attribute 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 there is no point in displaying 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. From bda9790a2a751c7d87968ed62c9ca55aadeaec84 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 20 Aug 2022 20:29:19 +0100 Subject: [PATCH 28/73] stopwatch04 --- docs/examples/introduction/stopwatch.css | 24 ++++----- docs/examples/introduction/stopwatch.py | 36 ++++++------- docs/examples/introduction/stopwatch04.css | 54 +++++++++++++++++++ docs/examples/introduction/stopwatch04.py | 47 +++++++++++++++++ docs/introduction.md | 60 ++++++++++++--------- sandbox/will/order.py | 27 ++++++++++ src/textual/__init__.py | 1 + src/textual/_doc.py | 23 ++++---- src/textual/app.py | 11 ++-- src/textual/dom.py | 9 ++++ src/textual/events.py | 2 +- src/textual/message_pump.py | 61 +++++++--------------- 12 files changed, 245 insertions(+), 110 deletions(-) create mode 100644 docs/examples/introduction/stopwatch04.css create mode 100644 docs/examples/introduction/stopwatch04.py create mode 100644 sandbox/will/order.py diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 9a3ca8371..8657730c2 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -4,27 +4,15 @@ Stopwatch { height: 5; min-width: 50; margin: 1; - padding: 1 1; - transition: background 300ms linear; + padding: 1; } -Stopwatch.started { - text-style: bold; - background: $success; - color: $text-success; -} - - TimeDisplay { content-align: center middle; opacity: 60%; height: 3; } -Stopwatch.started TimeDisplay { - opacity: 100%; -} - Button { width: 16; } @@ -42,6 +30,16 @@ Button { dock: right; } +Stopwatch.started { + text-style: bold; + background: $success; + color: $text-success; +} + +Stopwatch.started TimeDisplay { + opacity: 100%; +} + Stopwatch.started #start { display: none } diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 83ba802ba..8d6e13e5c 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -25,23 +25,6 @@ class Stopwatch(Static): total = Reactive(0.0) started = Reactive(False) - def on_mount(self) -> None: - """Called when widget is first added.""" - self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) - - def update_elapsed(self) -> None: - """Updates elapsed time.""" - self.query_one(TimeDisplay).time = ( - self.total + monotonic() - self.start_time if self.started else self.total - ) - - def compose(self) -> ComposeResult: - """Composes the timer widget.""" - yield Button("Start", id="start", variant="success") - yield Button("Stop", id="stop", variant="error") - yield TimeDisplay() - yield Button("Reset", id="reset") - def watch_started(self, started: bool) -> None: """Called when the 'started' attribute changes.""" if started: @@ -63,6 +46,23 @@ class Stopwatch(Static): self.total = 0.0 self.update_elapsed() + def on_mount(self) -> None: + """Called when widget is first added.""" + self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) + + def update_elapsed(self) -> None: + """Updates elapsed time.""" + self.query_one(TimeDisplay).time = ( + self.total + monotonic() - self.start_time if self.started else self.total + ) + + def compose(self) -> ComposeResult: + """Composes the timer widget.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay() + class StopwatchApp(App): """Manage the timers.""" @@ -87,7 +87,7 @@ class StopwatchApp(App): def action_remove_timer(self) -> None: """Called to remove a timer.""" - timers = self.query("#timers TimerWidget") + timers = self.query("#timers Stopwatch") if timers: timers.last().remove() diff --git a/docs/examples/introduction/stopwatch04.css b/docs/examples/introduction/stopwatch04.css new file mode 100644 index 000000000..8657730c2 --- /dev/null +++ b/docs/examples/introduction/stopwatch04.css @@ -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; +} + +Stopwatch.started { + text-style: bold; + background: $success; + color: $text-success; +} + +Stopwatch.started TimeDisplay { + opacity: 100%; +} + +Stopwatch.started #start { + display: none +} + +Stopwatch.started #stop { + display: block +} + +Stopwatch.started #reset { + visibility: hidden +} + diff --git a/docs/examples/introduction/stopwatch04.py b/docs/examples/introduction/stopwatch04.py new file mode 100644 index 000000000..88ff7f278 --- /dev/null +++ b/docs/examples/introduction/stopwatch04.py @@ -0,0 +1,47 @@ +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): + pass + + +class Stopwatch(Static): + started = Reactive(False) + + def watch_started(self, started: bool) -> None: + if started: + self.add_class("started") + else: + self.remove_class("started") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" + button_id = event.button.id + self.started = button_id == "start" + + def compose(self) -> ComposeResult: + 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): + def compose(self): + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def on_load(self): + self.bind("d", "toggle_dark", description="Dark mode") + + def action_toggle_dark(self): + self.dark = not self.dark + + +app = StopwatchApp(css_path="stopwatch04.css") +if __name__ == "__main__": + app.run() diff --git a/docs/introduction.md b/docs/introduction.md index 775dcd54c..8f70954af 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -46,7 +46,7 @@ Hit ++ctrl+c++ to exit the app and return to the command prompt. ### Looking at the code -Let's example stopwatch01.py in more detail. +Let's examine stopwatch01.py in more detail. ```python title="stopwatch01.py" hl_lines="1 2" --8<-- "docs/examples/introduction/stopwatch01.py" @@ -70,7 +70,7 @@ There are three methods in our stopwatch app currently. - **`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` it 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 action to the ++d++ key. The body of this method flips the state of the `dark` boolean to toggle dark mode. +- **`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 @@ -85,7 +85,7 @@ The last lines in "stopwatch01.py" may be familiar to you. We create an instance ## Creating a custom widget -The header and footer were builtin widgets. For our stopwatch application we will need to build a custom widget for stopwatches. +The header and footer were builtin widgets. We will to build a custom widget for the stopwatches in our application. Let's sketch out what we are trying to achieve here: @@ -96,7 +96,7 @@ Let's sketch out what we are trying to achieve here: An individual stopwatch consists of several parts, which themselves can be widgets. -Out stopwatch widgets is going to need the following widgets: +The Stopwatch widget consists of the be built with the following _child_ widgets: - A "start" button - A "stop" button @@ -113,22 +113,22 @@ Let's add those to our app: ### New widgets -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 form a scrolling list of stopwatches. +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. We're extending Static as a foundation for our `TimeDisplay` widget. There are no methods on this class yet. -The Stopwatch also extends Static to define a new widget. This class has a `compose()` method which yields its _child_ widgets, consisting of of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch like the sketch. +The Stopwatch class also extends Static to define a new widget. This class has a `compose()` method which yields its child widgets, consisting of of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch as in the sketch. -The Button constructor takes a label to be displayed to the user ("Start", "Stop", or "Reset") so they know what will happen when they click on it. There are two additional parameters to the Button constructor we are using: +The Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). There are two additional parameters to the Button constructor we are using: -- **`id`** is an identifier so we can tell the buttons apart in code. We can also use this to style the buttons. 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. +- **`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 with we need to yield them from the app's `compose()` method: +To see our widgets with we first need to yield them from the app's `compose()` method: -This new line in `Stopwatch.compose()` adds a single `Container` object which will create a scrolling list. The constructor for `Container` takes its _child_ widgets as positional arguments, to which we pass three instances of the `Stopwatch` we just built. +The new line in `Stopwatch.compose()` yields a single `Container` object which will create a scrolling list. 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 as child widgets of the container. ### The unstyled app @@ -142,9 +142,9 @@ The elements of the stopwatch application are there. The buttons are clickable a ## Writing Textual CSS -Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on that styles object will change how Textual renders the widget. +Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on that styles object will change how Textual displays the widget. -Here's how you might change the widget to use white text on a blue background: +Here's how you might set white text and a blue background for a widget: ```python self.styles.background = "blue" @@ -153,34 +153,36 @@ self.styles.color = "white" While its possible to set all styles for an app this way, Textual prefers to use CSS. -CSS files are data files loaded by your app which contain information about what styles to apply to your widgets. +CSS files are data files loaded by your app which contain information about styles to apply to your widgets. !!! 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! -To load a CSS file you can set the `css_path` attribute of your app. +Let's add a CSS file to our application. ```python title="stopwatch03.py" hl_lines="31" --8<-- "docs/examples/introduction/stopwatch03.py" ``` -This will tell Textual to load the following file when it starts the app: +Adding the `css_path` attribute to the app constructor tells textual to load the following file when it starts the app: -```css title="stopwatch03.css" +```sass title="stopwatch03.css" --8<-- "docs/examples/introduction/stopwatch03.css" ``` -The only change was setting the css path. Our app will now look very different: +If we run the app now, it will look *very* different. ```{.textual path="docs/examples/introduction/stopwatch03.py" title="stopwatch03.py"} ``` -This app looks much more like our sketch. Textual has read style information from `stopwatch03.css` and applied it to the widgets. In effect setting attributes on `widget.styles`. +This app looks much more like our sketch. Textual has read style information from `stopwatch03.css` and applied it to the widgets. + +### CSS basics CSS files contain a number of _declaration blocks_. Here's the first such block from `stopwatch03.css` again: -```css +```sass Stopwatch { layout: horizontal; background: $panel-darken-1; @@ -198,7 +200,7 @@ Here's how the Stopwatch block in the CSS impacts our `Stopwatch` widget: --8<-- "docs/images/stopwatch_widgets.excalidraw.svg" -- `layout: horizontal` aligns child widgets from left to right rather than top to bottom. +- `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. @@ -207,7 +209,7 @@ Here's how the Stopwatch block in the CSS impacts our `Stopwatch` widget: Here's the rest of `stopwatch03.css` which contains further declaration blocks: -```css +```sass TimeDisplay { content-align: center middle; opacity: 60%; @@ -236,8 +238,18 @@ The `TimeDisplay` block aligns text to the center (`content-align`), fades it sl 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 any widget with a matching "id" attribute. We've set an id attribute on the Button widgets we yielded in compose. For instance the first button has `id="start"` which matches `#start` in the CSS. +The last 3 blocks have a slightly different format. When the declaration begins with a `#` then the styles will be applied widgets with a matching "id" attribute. We've set an ID attribute 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 there is no point in displaying 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. +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 dsplay 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. An _unstarted_ state with a Start and Reset button, and a _started_ state with a Stop button. + +There are other differences between the two states. It would be nice if the stopwatch turns green when it is started. And we could make the time text bold, so it is clear it is running. It's possible to do this in code, but + + +```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,enter"} +``` diff --git a/sandbox/will/order.py b/sandbox/will/order.py new file mode 100644 index 000000000..639baa126 --- /dev/null +++ b/sandbox/will/order.py @@ -0,0 +1,27 @@ +import asyncio +from textual.app import App +from textual import events +from textual.widget import Widget + + +class OrderWidget(Widget, can_focus=True): + def on_key(self, event) -> None: + self.log("PRESS", event.key) + + +class OrderApp(App): + def compose(self): + yield OrderWidget() + + async def on_mount(self): + async def send_keys(): + self.query_one(OrderWidget).focus() + chars = ["tab", "enter", "h", "e", "l", "l", "o"] + for char in chars: + self.log("SENDING", char) + await self.post_message(events.Key(self, key=char)) + + self.set_timer(1, lambda: asyncio.create_task(send_keys())) + + +app = OrderApp() diff --git a/src/textual/__init__.py b/src/textual/__init__.py index e78bb2009..197a92f40 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -6,6 +6,7 @@ __all__ = ["log", "panic"] def log(*args: object, verbosity: int = 0, **kwargs) -> None: + # TODO: There may be an early-out here for when there is no endpoint for logs from ._context import active_app app = active_app.get() diff --git a/src/textual/_doc.py b/src/textual/_doc.py index ce40361dd..22729d4fb 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -1,13 +1,19 @@ -import os +from __future__ import annotations +import os +from typing import cast, TYPE_CHECKING + +if TYPE_CHECKING: + from textual.app import App # This module defines our "Custom Fences", powered by SuperFences # @link https://facelessuser.github.io/pymdown-extensions/extensions/superfences/#custom-fences -def format_svg(source, language, css_class, options, md, attrs, **kwargs): +def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str: """A superfences formatter to insert a SVG screenshot.""" path = attrs.get("path") - press = attrs.get("press", "").split(",") + _press = attrs.get("press", None) + press = _press.split(",") if _press else [] title = attrs.get("title") os.environ["TEXTUAL"] = "headless" @@ -27,20 +33,19 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): os.chdir(examples_path) with open(filename, "rt") as python_code: source = python_code.read() - app_vars = {} + app_vars: dict[str, object] = {} exec(source, app_vars) - app = app_vars["app"] + app: App = cast("App", app_vars["app"]) app.run(press=press or None) svg = app._screenshot - finally: os.chdir(cwd) else: app_vars = {} exec(source, app_vars) - app = app_vars["app"] - app.run() + app = cast(App, app_vars["app"]) + app.run(press=press or None) svg = app._screenshot - + assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index fac988dc6..e4ecbe169 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -232,6 +232,7 @@ class App(Generic[ReturnType], DOMNode): if ((watch_css or self.debug) and self.css_path) else None ) + self._screenshot: str | None = None def __init_subclass__( cls, css_path: str | None = None, inherit_css: bool = True @@ -449,6 +450,8 @@ class App(Generic[ReturnType], DOMNode): """ if verbosity > self.log_verbosity: return + if self._log_console is None and not self.devtools.is_connected: + return if self.devtools.is_connected and not _textual_calling_frame: _textual_calling_frame = inspect.stack()[1] @@ -572,13 +575,15 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(quit_after, self.shutdown) if press is not None: - async def press_keys(): + async def press_keys(app: App): assert press + await asyncio.sleep(0.05) for key in press: + print(f"press {key!r}") + await app.post_message(events.Key(self, key)) await asyncio.sleep(0.01) - await self.press(key) - self.call_later(press_keys) + self.call_later(lambda: asyncio.create_task(press_keys(self))) await self.process_messages() diff --git a/src/textual/dom.py b/src/textual/dom.py index 357520005..83f963588 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -684,7 +684,10 @@ class DOMNode(MessagePump): *class_names (str): CSS class names to add. """ + old_classes = self._classes.copy() self._classes.update(class_names) + if old_classes == self._classes: + return try: self.app.stylesheet.update(self.app, animate=True) except NoActiveAppError: @@ -697,7 +700,10 @@ class DOMNode(MessagePump): *class_names (str): CSS class names to remove. """ + old_classes = self._classes.copy() self._classes.difference_update(class_names) + if old_classes == self._classes: + return try: self.app.stylesheet.update(self.app, animate=True) except NoActiveAppError: @@ -710,7 +716,10 @@ class DOMNode(MessagePump): *class_names (str): CSS class names to toggle. """ + old_classes = self._classes.copy() self._classes.symmetric_difference_update(class_names) + if old_classes == self._classes: + return try: self.app.stylesheet.update(self.app, animate=True) except NoActiveAppError: diff --git a/src/textual/events.py b/src/textual/events.py index fceadd8bd..5f1ecfcc1 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -16,7 +16,7 @@ MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") if TYPE_CHECKING: from ._timer import Timer as TimerClass from ._timer import TimerCallback - from .widget import WIdget + from .widget import Widget @rich.repr.auto diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 09e05cf7b..ddb31aaf0 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,23 +1,20 @@ from __future__ import annotations import asyncio -from typing import Any +from functools import total_ordering import inspect -from asyncio import CancelledError -from asyncio import PriorityQueue, QueueEmpty, Task -from functools import partial, total_ordering -from typing import TYPE_CHECKING, Awaitable, Iterable, Callable +from asyncio import CancelledError, Queue, QueueEmpty, Task +from functools import partial +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable from weakref import WeakSet -from . import events -from . import log -from .case import camel_to_snake -from ._timer import Timer, TimerCallback +from . import events, log, messages from ._callback import invoke -from ._context import active_app, NoActiveAppError -from .message import Message +from ._context import NoActiveAppError, active_app +from ._timer import Timer, TimerCallback +from .case import camel_to_snake from .events import Event -from . import messages +from .message import Message if TYPE_CHECKING: from .app import App @@ -35,25 +32,6 @@ class MessagePumpClosed(Exception): pass -@total_ordering -class MessagePriority: - """Wraps a messages with a priority, and provides equality.""" - - __slots__ = ["message", "priority"] - - def __init__(self, message: Message | None = None, priority: int = 0): - self.message = message - self.priority = priority - - def __eq__(self, other: object) -> bool: - assert isinstance(other, MessagePriority) - return self.priority == other.priority - - def __gt__(self, other: object) -> bool: - assert isinstance(other, MessagePriority) - return self.priority > other.priority - - class MessagePumpMeta(type): """Metaclass for message pump. This exists to populate a Message inner class of a Widget with the parent classes' name. @@ -79,7 +57,7 @@ class MessagePumpMeta(type): class MessagePump(metaclass=MessagePumpMeta): def __init__(self, parent: MessagePump | None = None) -> None: - self._message_queue: PriorityQueue[MessagePriority] = PriorityQueue() + self._message_queue: Queue[Message | None] = Queue() self._parent = parent self._running: bool = False self._closing: bool = False @@ -158,7 +136,7 @@ class MessagePump(metaclass=MessagePumpMeta): return self._pending_message finally: self._pending_message = None - message = (await self._message_queue.get()).message + message = await self._message_queue.get() if message is None: self._closed = True raise MessagePumpClosed("The message pump is now closed") @@ -173,7 +151,7 @@ class MessagePump(metaclass=MessagePumpMeta): """ if self._pending_message is None: try: - message = self._message_queue.get_nowait().message + message = self._message_queue.get_nowait() except QueueEmpty: pass else: @@ -247,7 +225,7 @@ class MessagePump(metaclass=MessagePumpMeta): def close_messages_no_wait(self) -> None: """Request the message queue to exit.""" - self._message_queue.put_nowait(MessagePriority(None)) + self._message_queue.put_nowait(None) async def close_messages(self) -> None: """Close message queue, and optionally wait for queue to finish processing.""" @@ -258,7 +236,7 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in stop_timers: await timer.stop() self._timers.clear() - await self._message_queue.put(MessagePriority(None)) + await self._message_queue.put(None) if self._task is not None and asyncio.current_task() != self._task: # Ensure everything is closed before returning @@ -358,9 +336,7 @@ class MessagePump(metaclass=MessagePumpMeta): for cls in self.__class__.__mro__: if message._no_default_action: break - method = cls.__dict__.get(private_method, None) or cls.__dict__.get( - method_name, None - ) + method = cls.__dict__.get(private_method) or cls.__dict__.get(method_name) if method is not None: yield cls, method.__get__(self, cls) @@ -420,7 +396,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True - await self._message_queue.put(MessagePriority(message)) + await self._message_queue.put(message) return True # TODO: This may not be needed, or may only be needed by the timer @@ -437,11 +413,12 @@ class MessagePump(metaclass=MessagePumpMeta): Returns: bool: True if the messages was processed, False if it wasn't. """ + # TODO: Allow priority messages to jump the queue if self._closing or self._closed: return False if not self.check_message_enabled(message): return False - await self._message_queue.put(MessagePriority(message, -1)) + await self._message_queue.put(message) return True def post_message_no_wait(self, message: Message) -> bool: @@ -457,7 +434,7 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False - self._message_queue.put_nowait(MessagePriority(message)) + self._message_queue.put_nowait(message) return True async def _post_message_from_child(self, message: Message) -> bool: From 4e4d0b1bb9d3f48ace3ac38e35fffbd3b3bdae38 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 20 Aug 2022 21:23:26 +0100 Subject: [PATCH 29/73] added press to run --- docs/introduction.md | 2 +- src/textual/app.py | 30 +++++++++++++++++++++++------- src/textual/cli/cli.py | 6 ++++-- src/textual/dom.py | 2 +- src/textual/widget.py | 22 +++++++++++++++------- 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index 8f70954af..7ef6610d4 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -248,7 +248,7 @@ You may have noticed that the stop button (`#stop` in the CSS) has `display: non We want our Stopwatch widget to have two states. An _unstarted_ state with a Start and Reset button, and a _started_ state with a Stop button. -There are other differences between the two states. It would be nice if the stopwatch turns green when it is started. And we could make the time text bold, so it is clear it is running. It's possible to do this in code, but +There are other visual differences between the two states. When a stopwatch is running it should have a green background and bold text. ```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,enter"} diff --git a/src/textual/app.py b/src/textual/app.py index e4ecbe169..52e19e878 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -57,6 +57,7 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size +from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen @@ -560,6 +561,7 @@ class App(Generic[ReturnType], DOMNode): quit_after (float | None, optional): Quit after a given number of seconds, or None to run forever. Defaults to None. headless (bool, optional): Run in "headless" mode (don't write to stdout). + press (str, optional): An iterable of keys to simulate being pressed. Returns: ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. @@ -574,18 +576,25 @@ class App(Generic[ReturnType], DOMNode): if quit_after is not None: self.set_timer(quit_after, self.shutdown) if press is not None: + app = self - async def press_keys(app: App): + async def press_keys() -> None: + """A task to send key events.""" assert press - await asyncio.sleep(0.05) + driver = app._driver + assert driver is not None for key in press: print(f"press {key!r}") - await app.post_message(events.Key(self, key)) - await asyncio.sleep(0.01) + driver.send_event(events.Key(self, key)) + await asyncio.sleep(0.02) - self.call_later(lambda: asyncio.create_task(press_keys(self))) + async def press_keys_task(): + """Press some keys in the background.""" + asyncio.create_task(press_keys()) - await self.process_messages() + await self.process_messages(ready_callback=press_keys_task) + else: + await self.process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: @@ -933,7 +942,9 @@ class App(Generic[ReturnType], DOMNode): self.error_console.print(renderable) self._exit_renderables.clear() - async def process_messages(self) -> None: + async def process_messages( + self, ready_callback: CallbackType | None = None + ) -> None: self._set_active() if self.devtools_enabled: @@ -975,6 +986,8 @@ class App(Generic[ReturnType], DOMNode): self.refresh() await self.animator.start() await self._ready() + if ready_callback is not None: + await ready_callback() await process_messages() await self.animator.stop() await self.close_all() @@ -1210,11 +1223,14 @@ class App(Generic[ReturnType], DOMNode): Returns: bool: True if the key was handled by a binding, otherwise False """ + print("press", key) try: binding = self.bindings.get_key(key) except NoBinding: + print("no binding") return False else: + print(binding) await self.action(binding.action) return True diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index f303246f6..61393fff7 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -116,7 +116,8 @@ def import_app(import_name: str) -> App: @run.command("run") @click.argument("import_name", metavar="FILE or FILE:APP") @click.option("--dev", "dev", help="Enable development mode", is_flag=True) -def run_app(import_name: str, dev: bool) -> None: +@click.option("--press", "press", help="Comma separated keys to simulate press") +def run_app(import_name: str, dev: bool, press: str) -> None: """Run a Textual app. The code to run may be given as a path (ending with .py) or as a Python @@ -156,7 +157,8 @@ def run_app(import_name: str, dev: bool) -> None: console.print(str(error)) sys.exit(1) - app.run() + press_keys = press.split(",") if press else None + app.run(press=press_keys) @run.command("borders") diff --git a/src/textual/dom.py b/src/textual/dom.py index 83f963588..e228ed916 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -228,7 +228,7 @@ class DOMNode(MessagePump): while node and not isinstance(node, Screen): node = node._parent if not isinstance(node, Screen): - raise NoScreen("{self} has no screen") + raise NoScreen(f"{self} has no screen") return node @property diff --git a/src/textual/widget.py b/src/textual/widget.py index af731ae51..72078327b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import Lock from itertools import islice from fractions import Fraction from operator import attrgetter @@ -124,6 +125,8 @@ class Widget(DOMNode): self._styles_cache = StylesCache() + self._lock = Lock() + super().__init__( name=name, id=id, @@ -1159,13 +1162,18 @@ class Widget(DOMNode): Args: event (events.Idle): Idle event. """ - if self._parent is not None: - if self._repaint_required: - self._repaint_required = False - self.screen.post_message_no_wait(messages.Update(self, self)) - if self._layout_required: - self._layout_required = False - self.screen.post_message_no_wait(messages.Layout(self)) + if self._parent is not None and not self._closing: + try: + screen = self.screen + except NoScreen: + pass + else: + if self._repaint_required: + self._repaint_required = False + screen.post_message_no_wait(messages.Update(self, self)) + if self._layout_required: + self._layout_required = False + screen.post_message_no_wait(messages.Layout(self)) def focus(self) -> None: """Give input focus to this widget.""" From 25a4812f7a3fed599a2c30f2cb9acafc5babbd54 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 21 Aug 2022 09:47:42 +0100 Subject: [PATCH 30/73] more introduction --- docs/examples/introduction/stopwatch.py | 5 +-- docs/examples/introduction/stopwatch02.py | 4 +- docs/examples/introduction/stopwatch03.py | 2 +- docs/examples/introduction/stopwatch04.css | 11 +++-- docs/examples/introduction/stopwatch04.py | 18 +++----- docs/introduction.md | 51 ++++++++++++++++++++-- src/textual/app.py | 22 +++++++--- 7 files changed, 77 insertions(+), 36 deletions(-) diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 8d6e13e5c..0664325cd 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -40,9 +40,8 @@ class Stopwatch(Static): def on_button_pressed(self, event: Button.Pressed) -> None: """Called when a button is pressed.""" - button_id = event.button.id - self.started = button_id == "start" - if button_id == "reset": + self.started = event.button.id == "start" + if event.button.id == "reset": self.total = 0.0 self.update_elapsed() diff --git a/docs/examples/introduction/stopwatch02.py b/docs/examples/introduction/stopwatch02.py index cd44a7543..da4fd5a72 100644 --- a/docs/examples/introduction/stopwatch02.py +++ b/docs/examples/introduction/stopwatch02.py @@ -1,4 +1,4 @@ -from textual.app import App, ComposeResult +from textual.app import App from textual.layout import Container from textual.widgets import Button, Header, Footer, Static @@ -8,7 +8,7 @@ class TimeDisplay(Static): class Stopwatch(Static): - def compose(self) -> ComposeResult: + def compose(self): yield Button("Start", id="start", variant="success") yield Button("Stop", id="stop", variant="error") yield Button("Reset", id="reset") diff --git a/docs/examples/introduction/stopwatch03.py b/docs/examples/introduction/stopwatch03.py index edf45089e..ebe02550b 100644 --- a/docs/examples/introduction/stopwatch03.py +++ b/docs/examples/introduction/stopwatch03.py @@ -8,7 +8,7 @@ class TimeDisplay(Static): class Stopwatch(Static): - def compose(self) -> ComposeResult: + def compose(self): yield Button("Start", id="start", variant="success") yield Button("Stop", id="stop", variant="error") yield Button("Reset", id="reset") diff --git a/docs/examples/introduction/stopwatch04.css b/docs/examples/introduction/stopwatch04.css index 8657730c2..93678369c 100644 --- a/docs/examples/introduction/stopwatch04.css +++ b/docs/examples/introduction/stopwatch04.css @@ -30,25 +30,24 @@ Button { dock: right; } -Stopwatch.started { +.started { text-style: bold; background: $success; color: $text-success; } -Stopwatch.started TimeDisplay { +.started TimeDisplay { opacity: 100%; } -Stopwatch.started #start { +.started #start { display: none } -Stopwatch.started #stop { +.started #stop { display: block } -Stopwatch.started #reset { +.started #reset { visibility: hidden } - diff --git a/docs/examples/introduction/stopwatch04.py b/docs/examples/introduction/stopwatch04.py index 88ff7f278..9a4d433a3 100644 --- a/docs/examples/introduction/stopwatch04.py +++ b/docs/examples/introduction/stopwatch04.py @@ -1,6 +1,5 @@ -from textual.app import App, ComposeResult +from textual.app import App from textual.layout import Container -from textual.reactive import Reactive from textual.widgets import Button, Header, Footer, Static @@ -9,20 +8,13 @@ class TimeDisplay(Static): class Stopwatch(Static): - started = Reactive(False) - - def watch_started(self, started: bool) -> None: - if started: + def on_button_pressed(self, event): + if event.button.id == "start": self.add_class("started") - else: + elif event.button.id == "stop": self.remove_class("started") - def on_button_pressed(self, event: Button.Pressed) -> None: - """Called when a button is pressed.""" - button_id = event.button.id - self.started = button_id == "start" - - def compose(self) -> ComposeResult: + def compose(self): yield Button("Start", id="start", variant="success") yield Button("Stop", id="stop", variant="error") yield Button("Reset", id="reset") diff --git a/docs/introduction.md b/docs/introduction.md index 7ef6610d4..fdd709deb 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -4,6 +4,10 @@ Welcome to the Textual Introduction! By the end of this page you should have a good idea of the steps involved in creating an application with Textual. +!!! quote + + You may find this page goes in to more detail than you might expect from an introduction. I like to have complete working examples in documentation and I don't want to leave anything _as an exercise for the reader_. — **Will McGugan** (creator of Rich and Textual) + ## Stopwatch Application @@ -17,8 +21,12 @@ Here's what the finished app will look like: ```{.textual path="docs/examples/introduction/stopwatch.py"} ``` +### Try the code + If you want to try this out before reading the rest of this introduction (we recommend it), navigate to "docs/examples/introduction" within the repository and run the following: +**TODO**: instructions how to checkout repo + ```bash python stopwatch.py ``` @@ -111,7 +119,7 @@ Let's add those to our app: --8<-- "docs/examples/introduction/stopwatch02.py" ``` -### New widgets +### Extending widget classes 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. @@ -246,10 +254,45 @@ You may have noticed that the stop button (`#stop` in the CSS) has `display: non ### Dynamic CSS -We want our Stopwatch widget to have two states. An _unstarted_ state with a Start and Reset button, and a _started_ state with a Stop button. +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. -There are other visual differences between the two states. When a stopwatch is running it should have a green background and bold text. +We can accomplish this with by defining 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: -```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,enter"} +```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 these styles. + +Some of the new styles have more than one selector separated by a space. The space indicates that the next selector should match a style. 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 is started. The `.started` selector matches any widget with a "started" CSS class. While "#start" matches a child widget with an id of "start". The rule "display: none" tells Textual to hide that widget. + +### Manipulating classes + +The easiest way to manipulate visuals with Textual is to modify CSS classes. This way your (Python) code can remain free 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 a event handler for the `Button.Pressed` event. + +```python title="stopwatch04.py" hl_lines="11-15" +--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"} +``` + + diff --git a/src/textual/app.py b/src/textual/app.py index 52e19e878..7829bc1a6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -122,6 +122,14 @@ class ScreenStackError(ScreenError): ReturnType = TypeVar("ReturnType") +class _NullFile: + def write(self, text: str) -> None: + pass + + def flush(self) -> None: + pass + + @rich.repr.auto class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" @@ -168,7 +176,7 @@ class App(Generic[ReturnType], DOMNode): self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) self.console = Console( - file=(open(os.devnull, "wt") if self.is_headless else sys.__stdout__), + file=(_NullFile() if self.is_headless else sys.__stdout__), markup=False, highlight=False, emoji=False, @@ -584,9 +592,12 @@ class App(Generic[ReturnType], DOMNode): driver = app._driver assert driver is not None for key in press: - print(f"press {key!r}") - driver.send_event(events.Key(self, key)) - await asyncio.sleep(0.02) + if key == "_": + await asyncio.sleep(0.02) + else: + print(f"press {key!r}") + driver.send_event(events.Key(self, key)) + await asyncio.sleep(0.02) async def press_keys_task(): """Press some keys in the background.""" @@ -1223,14 +1234,11 @@ class App(Generic[ReturnType], DOMNode): Returns: bool: True if the key was handled by a binding, otherwise False """ - print("press", key) try: binding = self.bindings.get_key(key) except NoBinding: - print("no binding") return False else: - print(binding) await self.action(binding.action) return True From 18f96d483c9d4fb3f3c398a1ca906ca98e4db275 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Aug 2022 11:26:39 +0100 Subject: [PATCH 31/73] fixes for reactive --- docs/examples/introduction/stopwatch.py | 2 +- docs/examples/introduction/stopwatch01.py | 13 ++-- docs/examples/introduction/stopwatch02.py | 20 ++++-- docs/examples/introduction/stopwatch03.py | 18 +++-- docs/examples/introduction/stopwatch04.py | 23 ++++-- docs/examples/introduction/stopwatch05.py | 67 +++++++++++++++++ docs/examples/introduction/stopwatch06.py | 88 +++++++++++++++++++++++ docs/introduction.md | 56 ++++++++++----- src/textual/_timer.py | 10 ++- src/textual/app.py | 2 +- src/textual/errors.py | 4 ++ src/textual/message_pump.py | 5 +- src/textual/reactive.py | 70 +++++++++++++++--- src/textual/screen.py | 2 +- src/textual/widget.py | 1 - src/textual/widgets/_static.py | 20 ++++++ 16 files changed, 342 insertions(+), 59 deletions(-) create mode 100644 docs/examples/introduction/stopwatch05.py create mode 100644 docs/examples/introduction/stopwatch06.py diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 0664325cd..4999d685e 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -15,7 +15,7 @@ class TimeDisplay(Static): """Called when time_delta changes.""" minutes, seconds = divmod(time, 60) hours, minutes = divmod(minutes, 60) - self.update(f"{hours:02.0f}:{minutes:02.0f}:{seconds:05.2f}") + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") class Stopwatch(Static): diff --git a/docs/examples/introduction/stopwatch01.py b/docs/examples/introduction/stopwatch01.py index 1c8df2d9f..be9dd040e 100644 --- a/docs/examples/introduction/stopwatch01.py +++ b/docs/examples/introduction/stopwatch01.py @@ -1,16 +1,21 @@ -from textual.app import App +from textual.app import App, ComposeResult from textual.widgets import Header, Footer class StopwatchApp(App): - def compose(self): + """A Textual app to manage stopwatches.""" + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" yield Header() yield Footer() - def on_load(self): + def on_load(self) -> None: + """Called when app first loads.""" self.bind("d", "toggle_dark", description="Dark mode") - def action_toggle_dark(self): + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" self.dark = not self.dark diff --git a/docs/examples/introduction/stopwatch02.py b/docs/examples/introduction/stopwatch02.py index da4fd5a72..1fca3ac90 100644 --- a/docs/examples/introduction/stopwatch02.py +++ b/docs/examples/introduction/stopwatch02.py @@ -1,14 +1,17 @@ -from textual.app import App +from textual.app import App, ComposeResult from textual.layout import Container from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): - pass + """A widget to display elapsed time.""" class Stopwatch(Static): - def compose(self): + """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") @@ -16,15 +19,20 @@ class Stopwatch(Static): class StopwatchApp(App): - def compose(self): + """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): + 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): + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" self.dark = not self.dark diff --git a/docs/examples/introduction/stopwatch03.py b/docs/examples/introduction/stopwatch03.py index ebe02550b..8ae5ce024 100644 --- a/docs/examples/introduction/stopwatch03.py +++ b/docs/examples/introduction/stopwatch03.py @@ -4,11 +4,14 @@ from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): - pass + """A widget to display elapsed time.""" class Stopwatch(Static): - def compose(self): + """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") @@ -16,15 +19,20 @@ class Stopwatch(Static): class StopwatchApp(App): - def compose(self): + """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): + def on_load(self) -> None: + """Called when app first loads.""" self.bind("d", "toggle_dark", description="Dark mode") - def action_toggle_dark(self): + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" self.dark = not self.dark diff --git a/docs/examples/introduction/stopwatch04.py b/docs/examples/introduction/stopwatch04.py index 9a4d433a3..7b7757ce1 100644 --- a/docs/examples/introduction/stopwatch04.py +++ b/docs/examples/introduction/stopwatch04.py @@ -1,20 +1,24 @@ -from textual.app import App +from textual.app import App, ComposeResult from textual.layout import Container from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): - pass + """A widget to display elapsed time.""" class Stopwatch(Static): - def on_button_pressed(self, event): + """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): + 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") @@ -22,15 +26,20 @@ class Stopwatch(Static): class StopwatchApp(App): - def compose(self): + """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): + def on_load(self) -> None: + """Called when app first loads.""" self.bind("d", "toggle_dark", description="Dark mode") - def action_toggle_dark(self): + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" self.dark = not self.dark diff --git a/docs/examples/introduction/stopwatch05.py b/docs/examples/introduction/stopwatch05.py new file mode 100644 index 000000000..346cda13d --- /dev/null +++ b/docs/examples/introduction/stopwatch05.py @@ -0,0 +1,67 @@ +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(0.0) + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time - self.start_time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.set_interval(1 / 30, self.update_time) + + def update_time(self) -> None: + self.time = monotonic() + + +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: + """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() diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py new file mode 100644 index 000000000..2609236d1 --- /dev/null +++ b/docs/examples/introduction/stopwatch06.py @@ -0,0 +1,88 @@ +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.""" + + total = Reactive(0.0) + start_time = Reactive(monotonic) + time = Reactive(0.0) + + 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 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 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 = self.start_time + + +class Stopwatch(Static): + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + time_display = self.query_one(TimeDisplay) + if event.button.id == "start": + time_display.start() + self.add_class("started") + self.query_one("#stop").focus() + elif event.button.id == "stop": + time_display.stop() + self.remove_class("started") + self.query_one("#start").focus() + elif event.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("00:00:00.00") + + +class StopwatchApp(App): + 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() diff --git a/docs/introduction.md b/docs/introduction.md index fdd709deb..5e058fa70 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -6,14 +6,18 @@ By the end of this page you should have a good idea of the steps involved in cre !!! quote - You may find this page goes in to more detail than you might expect from an introduction. I like to have complete working examples in documentation and I don't want to leave anything _as an exercise for the reader_. — **Will McGugan** (creator of Rich and Textual) + 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. + + — **Will McGugan** (creator of Rich and Textual) + + ## Stopwatch Application -We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop / resume / reset each stopwatch in addition to adding or removing them. +We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop, resume, and reset each stopwatch in addition to adding or removing them. -This is a simple yet **fully featured** app — you could distribute this app if you wanted to! +This will be a simple yet **fully featured** app — you could distribute this app if you wanted to! Here's what the finished app will look like: @@ -31,6 +35,25 @@ If you want to try this out before reading the rest of this introduction (we rec python stopwatch.py ``` +## Type hints (in brief) + +We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, its a way to express the types of your data, parameters, and returns. 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: + return text * count +``` + +- Parameter types follow a colon, so `text: str` means that `text` should be a string and `count: int` means that `count` should be an integer. +- Return types follow `->` So `-> str:` says that this method returns a string. + + +!!! note + + Type hints are entirely optional in Textual. We've included them in the example code but it's up to you wether you add them to your own projects. + ## The App class 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 which we will cover below. @@ -66,7 +89,7 @@ The first line imports the Textual `App` class. The second line imports two buil 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-14" +```python title="stopwatch01.py" hl_lines="5-19" --8<-- "docs/examples/introduction/stopwatch01.py" ``` @@ -85,7 +108,7 @@ There are three methods in our stopwatch app currently. You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update. -```python title="stopwatch01.py" hl_lines="17-19" +```python title="stopwatch01.py" hl_lines="22-24" --8<-- "docs/examples/introduction/stopwatch01.py" ``` @@ -93,18 +116,15 @@ The last lines in "stopwatch01.py" may be familiar to you. We create an instance ## Creating a custom widget -The header and footer were builtin widgets. We will to build a custom widget for the stopwatches in our application. +The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets. -Let's sketch out what we are trying to achieve here: +Let's sketch out a design for our app:
--8<-- "docs/images/stopwatch.excalidraw.svg"
- -An individual stopwatch consists of several parts, which themselves can be widgets. - -The Stopwatch widget consists of the be built with the following _child_ widgets: +We will need to build a `Stopwatch` widget composed of the following _child_ widgets: - A "start" button - A "stop" button @@ -113,9 +133,9 @@ The Stopwatch widget consists of the be built with the following _child_ widgets Textual has a builtin `Button` widgets which takes care of the first three components. All we need to build is the time display which will show the elapsed time in HOURS:MINUTES:SECONDS format, and the stopwatch itself. -Let's add those to our app: +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-15 22 31" +```python title="stopwatch02.py" hl_lines="3 6-7 10-18 28" --8<-- "docs/examples/introduction/stopwatch02.py" ``` @@ -169,7 +189,7 @@ CSS files are data files loaded by your app which contain information about styl Let's add a CSS file to our application. -```python title="stopwatch03.py" hl_lines="31" +```python title="stopwatch03.py" hl_lines="39" --8<-- "docs/examples/introduction/stopwatch03.py" ``` @@ -256,7 +276,7 @@ You may have noticed that the stop button (`#stop` in the CSS) has `display: non 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 by defining 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. +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: @@ -266,7 +286,7 @@ Here's the new 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 these styles. -Some of the new styles have more than one selector separated by a space. The space indicates that the next selector should match a style. Let's look at one of these styles: +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 { @@ -274,7 +294,7 @@ Some of the new styles have more than one selector separated by a space. The spa } ``` -The purpose of this CSS is to hide the start button when the stopwatch is started. The `.started` selector matches any widget with a "started" CSS class. While "#start" matches a child widget with an id of "start". The rule "display: none" tells Textual to hide that widget. +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". The rule is applied to the button, so `"display: none"` tells Textual to _hide_ the button. ### Manipulating classes @@ -284,7 +304,7 @@ You can add and remove CSS classes with the `add_class()` and `remove_class()` m The following code adds a event handler for the `Button.Pressed` event. -```python title="stopwatch04.py" hl_lines="11-15" +```python title="stopwatch04.py" hl_lines="13-18" --8<-- "docs/examples/introduction/stopwatch04.py" ``` diff --git a/src/textual/_timer.py b/src/textual/_timer.py index 4edf75c43..a1010449a 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -13,6 +13,7 @@ from rich.repr import Result, rich_repr from . import events from ._callback import invoke +from ._context import active_app from . import _clock from ._types import MessageTarget @@ -119,6 +120,7 @@ class Timer: count = 0 _repeat = self._repeat _interval = self._interval + await self._active.wait() start = _clock.get_time_no_wait() while _repeat is None or count <= _repeat: next_timer = start + ((count + 1) * _interval) @@ -131,16 +133,20 @@ class Timer: if wait_time: await _clock.sleep(wait_time) count += 1 + await self._active.wait() try: await self._tick(next_timer=next_timer, count=count) except EventTargetGone: break - await self._active.wait() async def _tick(self, *, next_timer: float, count: int) -> None: """Triggers the Timer's action: either call its callback, or sends an event to its target""" if self._callback is not None: - await invoke(self._callback) + try: + await invoke(self._callback) + except Exception as error: + app = active_app.get() + app.on_exception(error) else: event = events.Timer( self.sender, diff --git a/src/textual/app.py b/src/textual/app.py index 7829bc1a6..c500c65fe 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -251,7 +251,7 @@ class App(Generic[ReturnType], DOMNode): title: Reactive[str] = Reactive("Textual") sub_title: Reactive[str] = Reactive("") - dark = Reactive(False) + dark: Reactive[bool] = Reactive(False) @property def devtools_enabled(self) -> bool: diff --git a/src/textual/errors.py b/src/textual/errors.py index f97499028..dcbf724a1 100644 --- a/src/textual/errors.py +++ b/src/textual/errors.py @@ -7,3 +7,7 @@ class TextualError(Exception): class NoWidget(TextualError): """Specified widget was not found.""" + + +class RenderError(TextualError): + """An object could not be rendered.""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index ddb31aaf0..c75666fe1 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from functools import total_ordering import inspect from asyncio import CancelledError, Queue, QueueEmpty, Task from functools import partial @@ -15,6 +14,7 @@ from ._timer import Timer, TimerCallback from .case import camel_to_snake from .events import Event from .message import Message +from .reactive import Reactive if TYPE_CHECKING: from .app import App @@ -167,7 +167,7 @@ class MessagePump(metaclass=MessagePumpMeta): def set_timer( self, delay: float, - callback: TimerCallback = None, + callback: TimerCallback | None = None, *, name: str | None = None, pause: bool = False, @@ -260,6 +260,7 @@ class MessagePump(metaclass=MessagePumpMeta): """Process messages until the queue is closed.""" _rich_traceback_guard = True + await Reactive.initialize_object(self) while not self._closed: try: message = await self.get_message() diff --git a/src/textual/reactive.py b/src/textual/reactive.py index f16dc78c3..394b2a266 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -39,10 +39,55 @@ class Reactive(Generic[ReactiveType]): *, layout: bool = False, repaint: bool = True, + init: bool = False, ) -> None: + """Create a Reactive Widget attribute, + + Args: + default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + layout (bool, optional): Perform a layout on change. Defaults to False. + repaint (bool, optional): Perform a repaint on change. Defaults to True. + init (bool, optional): Call watchers on initialize (post mount). Defaults to False. + """ self._default = default - self.layout = layout - self.repaint = repaint + self._layout = layout + self._repaint = repaint + self._init = init + + @classmethod + def init( + cls, + default: ReactiveType | Callable[[], ReactiveType], + *, + layout: bool = False, + repaint: bool = True, + ) -> Reactive: + """A reactive variable that calls watchers and compute on initialize (post mount). + + Args: + default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + layout (bool, optional): Perform a layout on change. Defaults to False. + repaint (bool, optional): Perform a repaint on change. Defaults to True. + + Returns: + Reactive: _description_ + """ + return cls(default, layout=layout, repaint=repaint, init=True) + + @classmethod + async def initialize_object(cls, obj: object) -> None: + """Call any watchers / computes for the first time. + + Args: + obj (Reactable): An object with Reactive descriptors + """ + + startswith = str.startswith + for key in obj.__class__.__dict__.keys(): + if startswith(key, "_init_"): + name = key[6:] + default = getattr(obj, key) + setattr(obj, name, default() if callable(default) else default) def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: @@ -56,11 +101,14 @@ class Reactive(Generic[ReactiveType]): self.name = name self.internal_name = f"_reactive_{name}" - setattr( - owner, - self.internal_name, - self._default() if callable(self._default) else self._default, - ) + default = self._default + + if self._init: + setattr(owner, f"_init_{name}", default) + else: + setattr( + owner, self.internal_name, default() if callable(default) else default + ) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: return getattr(obj, self.internal_name) @@ -69,15 +117,15 @@ class Reactive(Generic[ReactiveType]): name = self.name current_value = getattr(obj, self.internal_name, None) validate_function = getattr(obj, f"validate_{name}", None) - first_set = getattr(obj, f"{self.internal_name}__first_set", True) + first_set = getattr(obj, f"__first_set_{self.internal_name}", True) if callable(validate_function): value = validate_function(value) if current_value != value or first_set: - setattr(obj, f"{self.internal_name}__first_set", False) + setattr(obj, f"__first_set_{self.internal_name}", False) setattr(obj, self.internal_name, value) self.check_watchers(obj, name, current_value) - if self.layout or self.repaint: - obj.refresh(repaint=self.repaint, layout=self.layout) + if self._layout or self._repaint: + obj.refresh(repaint=self._repaint, layout=self._layout) @classmethod def check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c960d33bd..42c90eb1d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -38,7 +38,7 @@ class Screen(Widget): } """ - dark = Reactive(False) + dark: Reactive[bool] = Reactive(False) def __init__(self, name: str | None = None, id: str | None = None) -> None: super().__init__(name=name, id=id) diff --git a/src/textual/widget.py b/src/textual/widget.py index 72078327b..f479158d1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -102,7 +102,6 @@ class Widget(DOMNode): self._repaint_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None - self._reactive_watches: dict[str, Callable] = {} self.highlight_style: Style | None = None self._vertical_scrollbar: ScrollBar | None = None diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 22cd90b72..1813d530f 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -2,10 +2,28 @@ from __future__ import annotations from rich.console import RenderableType +from rich.protocol import is_renderable +from ..errors import RenderError from ..widget import Widget +def _check_renderable(renderable: object): + """Check if a renderable conforms to the Rich Console protocol + (https://rich.readthedocs.io/en/latest/protocol.html) + + Args: + renderable (object): A potentially renderable object. + + Raises: + RenderError: If the object can not be rendered. + """ + if not is_renderable(renderable): + raise RenderError( + f"unable to render {renderable!r}; A string, Text, or other Rich renderable is required" + ) + + class Static(Widget): CSS = """ Static { @@ -23,10 +41,12 @@ class Static(Widget): ) -> None: super().__init__(name=name, id=id, classes=classes) self.renderable = renderable + _check_renderable(renderable) def render(self) -> RenderableType: return self.renderable def update(self, renderable: RenderableType) -> None: + _check_renderable(renderable) self.renderable = renderable self.refresh() From 4dd4d18d2b6f654e8b564324c1f761f26091111c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Aug 2022 16:10:49 +0100 Subject: [PATCH 32/73] screenshor plugin fix --- docs/examples/introduction/stopwatch05.py | 19 +++--- docs/examples/introduction/stopwatch06.py | 22 +++---- docs/introduction.md | 74 +++++++++++++++++++++++ src/textual/_doc.py | 47 +++++++------- src/textual/app.py | 35 +++++++++-- src/textual/widget.py | 15 +++++ 6 files changed, 161 insertions(+), 51 deletions(-) diff --git a/docs/examples/introduction/stopwatch05.py b/docs/examples/introduction/stopwatch05.py index 346cda13d..a3d6e3f7a 100644 --- a/docs/examples/introduction/stopwatch05.py +++ b/docs/examples/introduction/stopwatch05.py @@ -12,18 +12,19 @@ class TimeDisplay(Static): start_time = Reactive(monotonic) time = Reactive(0.0) - def watch_time(self, time: float) -> None: - """Called when the time attribute changes.""" - minutes, seconds = divmod(time - self.start_time, 60) - hours, minutes = divmod(minutes, 60) - self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") - def on_mount(self) -> None: """Event handler called when widget is added to the app.""" - self.set_interval(1 / 30, self.update_time) + self.set_interval(1 / 60, self.update_time) def update_time(self) -> None: - self.time = monotonic() + """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): @@ -41,7 +42,7 @@ class Stopwatch(Static): 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") + yield TimeDisplay() class StopwatchApp(App): diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 2609236d1..4b97e43e6 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -9,15 +9,9 @@ from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): """A widget to display elapsed time.""" - total = Reactive(0.0) start_time = Reactive(monotonic) time = Reactive(0.0) - - 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}") + total = Reactive(0.0) def on_mount(self) -> None: """Event handler called when widget is added to the app.""" @@ -27,6 +21,12 @@ class TimeDisplay(Static): """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() @@ -41,21 +41,21 @@ class TimeDisplay(Static): def reset(self): """Method to reset the time display to zero.""" self.total = 0 - self.time = self.start_time + self.time = 0 class Stopwatch(Static): + """A Textual app to manage stopwatches.""" + def on_button_pressed(self, event: Button.Pressed) -> None: """Event handler called when a button is pressed.""" time_display = self.query_one(TimeDisplay) if event.button.id == "start": time_display.start() self.add_class("started") - self.query_one("#stop").focus() elif event.button.id == "stop": time_display.stop() self.remove_class("started") - self.query_one("#start").focus() elif event.button.id == "reset": time_display.reset() @@ -64,7 +64,7 @@ class Stopwatch(Static): 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") + yield TimeDisplay() class StopwatchApp(App): diff --git a/docs/introduction.md b/docs/introduction.md index 5e058fa70..95bfd332e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -315,4 +315,78 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state ```{.textual path="docs/examples/introduction/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"} ``` +## Reactive attributes +A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call `refresh()` to display new data. However, Textual prefers to do this automatically via _reactive_ attributes. + +You can declare a reactive attribute with `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" +``` + +Here we have created two reactive attributes: `start_time` and `time`. These 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 + + `Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties. + +The first argument to `Reactive` may be a default value, or a callable that returns the default value. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. When `TimeDisplay` is mounted the `start_time` attribute will automatically be assigned the value returned by `monotonic()`. + +The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start. + +To update the time automatically we will use the `set_interval` method which tells Textual to call a function at given intervals. The `on_mount` method does this to call `self.update_time` 60 times a second. + +In `update_time` we calculate the time elapsed since the widget started and assign 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 all 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 buttons to the widget + +### Wiring the Stopwatch + +To make a useful stopwatch we will need to add a little more code to `TimeDisplay`, to be able to start, stop, and reset the timer. + +```python title="stopwatch06.py" hl_lines="14-44 50-60" +--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 Stop and Start. +- The call to `set_interval` has grown a `pause=True` attribute which starts the timer in pause mode. This is because we don't want to update the timer 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 clicked 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.""" + time_display = self.query_one(TimeDisplay) + if event.button.id == "start": + time_display.start() + self.add_class("started") + elif event.button.id == "stop": + time_display.stop() + self.remove_class("started") + elif event.button.id == "reset": + time_display.reset() +``` + +This code supplies the missing features and makes our app really useful. If you run it now you can start and stop timers independently. + +- The first line calls `query_one` to get a reference to the `TimeDisplay` widget. This method queries for a child widget. You may supply a Widget type or a CSS selector. +- We call the `TimeDisplay` method that matches the button pressed. +- We add the "started" class when the Stopwatch is started, and remove it when it is stopped. + + +```{.textual path="docs/examples/introduction/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"} +``` diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 22729d4fb..88770bd95 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -11,41 +11,36 @@ if TYPE_CHECKING: def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str: """A superfences formatter to insert a SVG screenshot.""" - path = attrs.get("path") + path: str = attrs["path"] _press = attrs.get("press", None) - press = _press.split(",") if _press else [] + press = [*_press.split(",")] if _press else ["_"] title = attrs.get("title") - os.environ["TEXTUAL"] = "headless" - os.environ["TEXTUAL_SCREENSHOT"] = "0.15" - if title: - os.environ["TEXTUAL_SCREENSHOT_TITLE"] = title - else: - os.environ.pop("TEXTUAL_SCREENSHOT_TITLE", None) os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") print(f"screenshotting {path!r}") - if path: - cwd = os.getcwd() - examples_path, filename = os.path.split(path) - try: - os.chdir(examples_path) - with open(filename, "rt") as python_code: - source = python_code.read() - app_vars: dict[str, object] = {} - exec(source, app_vars) - app: App = cast("App", app_vars["app"]) - app.run(press=press or None) - svg = app._screenshot - finally: - os.chdir(cwd) - else: - app_vars = {} + cwd = os.getcwd() + examples_path, filename = os.path.split(path) + try: + os.chdir(examples_path) + with open(filename, "rt") as python_code: + source = python_code.read() + app_vars: dict[str, object] = {} exec(source, app_vars) - app = cast(App, app_vars["app"]) - app.run(press=press or None) + + app: App = cast("App", app_vars["app"]) + app.run( + quit_after=5, + press=press or ["ctrl+c"], + headless=True, + screenshot=True, + screenshot_title=title, + ) svg = app._screenshot + finally: + os.chdir(cwd) + assert svg is not None return svg diff --git a/src/textual/app.py b/src/textual/app.py index c500c65fe..e1c1a2c33 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -559,9 +559,12 @@ class App(Generic[ReturnType], DOMNode): def run( self, + *, quit_after: float | None = None, headless: bool = False, press: Iterable[str] | None = None, + screenshot: bool = False, + screenshot_title: str | None = None, ) -> ReturnType | None: """The main entry point for apps. @@ -570,6 +573,8 @@ class App(Generic[ReturnType], DOMNode): to run forever. Defaults to None. headless (bool, optional): Run in "headless" mode (don't write to stdout). press (str, optional): An iterable of keys to simulate being pressed. + screenshot (str, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. + screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. Returns: ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. @@ -591,13 +596,20 @@ class App(Generic[ReturnType], DOMNode): assert press driver = app._driver assert driver is not None + await asyncio.sleep(0.05) for key in press: if key == "_": - await asyncio.sleep(0.02) + print("(pause)") + await asyncio.sleep(0.05) else: print(f"press {key!r}") driver.send_event(events.Key(self, key)) await asyncio.sleep(0.02) + if screenshot: + self._screenshot = self.export_screenshot( + title=screenshot_title + ) + await self.shutdown() async def press_keys_task(): """Press some keys in the background.""" @@ -866,6 +878,19 @@ class App(Generic[ReturnType], DOMNode): widget.post_message_no_wait(events.Focus(self)) widget.emit_no_wait(events.DescendantFocus(self)) + def _reset_focus(self, widget: Widget) -> None: + """Reset the focus when a widget is removed + + Args: + widget (Widget): A widget that is removed. + """ + for sibling in widget.siblings: + if sibling.can_focus: + sibling.focus() + break + else: + self.focused = None + async def _set_mouse_over(self, widget: Widget | None) -> None: """Called when the mouse is over another widget. @@ -1120,8 +1145,7 @@ class App(Generic[ReturnType], DOMNode): Args: widget (Widget): A Widget to unregister """ - if self.focused is widget: - self.focused = None + self._reset_focus(widget) if isinstance(widget._parent, Widget): widget._parent.children._remove(widget) @@ -1369,8 +1393,9 @@ class App(Generic[ReturnType], DOMNode): async def _on_remove(self, event: events.Remove) -> None: widget = event.widget - if widget.has_parent: - widget.parent.refresh(layout=True) + parent = widget.parent + if parent is not None: + parent.refresh(layout=True) remove_widgets = list(widget.walk_children(Widget, with_self=True)) for child in remove_widgets: diff --git a/src/textual/widget.py b/src/textual/widget.py index f479158d1..efcceacef 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -146,6 +146,17 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + @property + def siblings(self) -> list[Widget]: + """Get the widget's siblings (self is removed from the return list).""" + parent = self.parent + if parent is not None: + siblings = list(parent.children) + siblings.remove(self) + return siblings + else: + return [] + @property def allow_vertical_scroll(self) -> bool: """Check if vertical scroll is permitted.""" @@ -1285,6 +1296,10 @@ class Widget(DOMNode): self.scroll_page_right() event.stop() + def _on_hide(self, event: events.Hide) -> None: + if self.has_focus: + self.app._reset_focus(self) + def key_home(self) -> bool: if self.is_scrollable: self.scroll_home() From c891f6b70a0e885d2afe9a02bebb40e4af2864a6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Aug 2022 22:26:03 +0100 Subject: [PATCH 33/73] fix for call_later and scroll_to_widget --- docs/examples/introduction/stopwatch.py | 93 ++++++++++++----------- docs/examples/introduction/stopwatch06.py | 11 ++- docs/introduction.md | 53 +++++++------ src/textual/_compositor.py | 2 + src/textual/app.py | 6 +- src/textual/dom.py | 2 +- src/textual/events.py | 2 +- src/textual/widget.py | 12 ++- 8 files changed, 97 insertions(+), 84 deletions(-) diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 4999d685e..8cc865052 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -7,56 +7,61 @@ from textual.widgets import Button, Header, Footer, Static class TimeDisplay(Static): - """Displays the time.""" + """A widget to display elapsed time.""" - time = Reactive(0.0) + 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 time_delta changes.""" + """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): - """The timer widget (display + buttons).""" - - start_time = Reactive(0.0) - total = Reactive(0.0) - started = Reactive(False) - - def watch_started(self, started: bool) -> None: - """Called when the 'started' attribute changes.""" - if started: - self.start_time = monotonic() - self.update_timer.resume() - self.add_class("started") - self.query_one("#stop").focus() - else: - self.update_timer.pause() - self.total += monotonic() - self.start_time - self.remove_class("started") - self.query_one("#start").focus() + """A stopwatch widget.""" def on_button_pressed(self, event: Button.Pressed) -> None: - """Called when a button is pressed.""" - self.started = event.button.id == "start" - if event.button.id == "reset": - self.total = 0.0 - self.update_elapsed() - - def on_mount(self) -> None: - """Called when widget is first added.""" - self.update_timer = self.set_interval(1 / 30, self.update_elapsed, pause=True) - - def update_elapsed(self) -> None: - """Updates elapsed time.""" - self.query_one(TimeDisplay).time = ( - self.total + monotonic() - self.start_time if self.started else self.total - ) + """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: - """Composes the timer widget.""" + """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") @@ -68,8 +73,8 @@ class StopwatchApp(App): def on_load(self) -> None: """Called when the app first loads.""" - self.bind("a", "add_timer", description="Add") - self.bind("r", "remove_timer", description="Remove") + self.bind("a", "add_stopwatch", description="Add") + self.bind("r", "remove_stopwatch", description="Remove") self.bind("d", "toggle_dark", description="Dark mode") def compose(self) -> ComposeResult: @@ -78,13 +83,13 @@ class StopwatchApp(App): yield Footer() yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers") - def action_add_timer(self) -> None: + def action_add_stopwatch(self) -> None: """An action to add a timer.""" - new_timer = Stopwatch() - self.query_one("#timers").mount(new_timer) - new_timer.scroll_visible() + new_stopwatch = Stopwatch() + self.query_one("#timers").mount(new_stopwatch) + new_stopwatch.scroll_visible() - def action_remove_timer(self) -> None: + def action_remove_stopwatch(self) -> None: """Called to remove a timer.""" timers = self.query("#timers Stopwatch") if timers: diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 4b97e43e6..02a399249 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -45,18 +45,19 @@ class TimeDisplay(Static): class Stopwatch(Static): - """A Textual app to manage stopwatches.""" + """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 event.button.id == "start": + if button_id == "start": time_display.start() self.add_class("started") - elif event.button.id == "stop": + elif button_id == "stop": time_display.stop() self.remove_class("started") - elif event.button.id == "reset": + elif button_id == "reset": time_display.reset() def compose(self) -> ComposeResult: @@ -68,6 +69,8 @@ class Stopwatch(Static): class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + def compose(self) -> ComposeResult: """Create child widgets for the app.""" yield Header() diff --git a/docs/introduction.md b/docs/introduction.md index 95bfd332e..ad1ed62cf 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -22,7 +22,7 @@ This will be a simple yet **fully featured** app — you could distribute th Here's what the finished app will look like: -```{.textual path="docs/examples/introduction/stopwatch.py"} +```{.textual path="docs/examples/introduction/stopwatch.py" press="tab,enter,_,tab,enter,_,tab,_,enter,_,tab,enter,_,_"} ``` ### Try the code @@ -75,7 +75,7 @@ Hit the ++d++ key to toggle dark mode. Hit ++ctrl+c++ to exit the app and return to the command prompt. -### Looking at the code +### A closer look at the App class Let's examine stopwatch01.py in more detail. @@ -83,7 +83,6 @@ Let's examine stopwatch01.py in more detail. --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. @@ -114,7 +113,7 @@ There are three methods in our stopwatch app currently. The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and call `run()` within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. -## Creating a custom widget +## Designing a UI with widgets The header and footer are builtin widgets. For our Stopwatch application we will need to build custom widgets. @@ -161,7 +160,7 @@ The new line in `Stopwatch.compose()` yields a single `Container` object which w ### The unstyled app -Let's see what happens when we run "stopwatch02.py": +Let's see what happens when we run "stopwatch02.py". ```{.textual path="docs/examples/introduction/stopwatch02.py" title="stopwatch02.py"} ``` @@ -170,7 +169,7 @@ The elements of the stopwatch application are there. The buttons are clickable a ## Writing Textual CSS -Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on that styles object will change how Textual displays the widget. +Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on the styles object will update the screen. Here's how you might set white text and a blue background for a widget: @@ -331,62 +330,66 @@ Here we have created two reactive attributes: `start_time` and `time`. These att `Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties. -The first argument to `Reactive` may be a default value, or a callable that returns the default value. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. When `TimeDisplay` is mounted the `start_time` attribute will automatically be assigned the value returned by `monotonic()`. +The first argument to `Reactive` may be a default value or a callable that returns the default value. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. 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. -To update the time automatically we will use the `set_interval` method which tells Textual to call a function at given intervals. The `on_mount` method does this to call `self.update_time` 60 times a second. - -In `update_time` we calculate the time elapsed since the widget started and assign it to `self.time`. Which brings us to one of Reactive's super-powers. +In the `on_mount` handler method, the call to `set_interval` creates a timer object which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. 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 all the `Stopwatch` widgets show the time elapsed since the widget was created: +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 buttons to the widget -### Wiring the Stopwatch +### Wiring buttons -To make a useful stopwatch we will need to add a little more code to `TimeDisplay`, to be able to start, stop, and reset the timer. +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-60" + +```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 Stop and Start. -- The call to `set_interval` has grown a `pause=True` attribute which starts the timer in pause mode. This is because we don't want to update the timer 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 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 clicked a button. Let's look at that in detail: +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 event.button.id == "start": + if button_id == "start": time_display.start() self.add_class("started") - elif event.button.id == "stop": + elif button_id == "stop": time_display.stop() self.remove_class("started") - elif event.button.id == "reset": + elif button_id == "reset": time_display.reset() ``` -This code supplies the missing features and makes our app really useful. If you run it now you can start and stop timers independently. +This code supplies the missing features and makes our app useful. We've made the following changes. -- The first line calls `query_one` to get a reference to the `TimeDisplay` widget. This method queries for a child widget. You may supply a Widget type or a CSS selector. -- We call the `TimeDisplay` method that matches the button pressed. -- We add the "started" class when the Stopwatch is started, and remove it when it is stopped. +- The first line stores 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. This method queries for a child widget. You may supply a Widget type or a CSS selector. +- 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 and show the buttons that match the state. +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 let to implement is the ability to add and remove timers. diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 72b309d89..0c6015c0f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -463,8 +463,10 @@ class Compositor: except KeyError: raise errors.NoWidget("Widget is not in layout") + @timer("get_widget_at") def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given point or None.""" + # TODO: Optimize with some line based lookup contains = Region.contains for widget, cropped_region, region, *_ in self: if contains(cropped_region, x, y): diff --git a/src/textual/app.py b/src/textual/app.py index e1c1a2c33..c9a56cb80 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -149,7 +149,7 @@ class App(Generic[ReturnType], DOMNode): self, driver_class: Type[Driver] | None = None, log_path: str | PurePath = "", - log_verbosity: int = 1, + log_verbosity: int = 0, log_color_system: Literal[ "auto", "standard", "256", "truecolor", "windows" ] = "auto", @@ -991,7 +991,9 @@ class App(Generic[ReturnType], DOMNode): self.log(f"Couldn't connect to devtools ({self.devtools.url})") self.log("---") + self.log(driver=self.driver_class) + self.log(log_verbosity=self.log_verbosity) self.log(loop=asyncio.get_running_loop()) self.log(features=self.features) @@ -1106,7 +1108,7 @@ class App(Generic[ReturnType], DOMNode): parent.children._append(child) self._registry.add(child) child._attach(parent) - child.on_register(self) + child._post_register(self) child.start_messages() return True return False diff --git a/src/textual/dom.py b/src/textual/dom.py index e228ed916..d5b0d6679 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -170,7 +170,7 @@ class DOMNode(MessagePump): else: break - def on_register(self, app: App) -> None: + def _post_register(self, app: App) -> None: """Called when the widget is registered Args: diff --git a/src/textual/events.py b/src/textual/events.py index 5f1ecfcc1..330436b81 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -20,7 +20,7 @@ if TYPE_CHECKING: @rich.repr.auto -class Event(Message): +class Event(Message, verbosity=2): def __rich_repr__(self) -> rich.repr.Result: return yield diff --git a/src/textual/widget.py b/src/textual/widget.py index efcceacef..5ba5de0bf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -230,7 +230,7 @@ class Widget(DOMNode): return yield - def on_register(self, app: App) -> None: + def _post_register(self, app: App) -> None: """Called when the instance is registered. Args: @@ -699,6 +699,7 @@ class Widget(DOMNode): Returns: bool: True if the scroll position changed, otherwise False. """ + self.log(self, x, y, verbosity=0) scrolled_x = scrolled_y = False if animate: # TODO: configure animation speed @@ -861,7 +862,6 @@ class Widget(DOMNode): Returns: Offset: The distance that was scrolled. """ - window = self.content_region.at_offset(self.scroll_offset) if spacing is not None: window = window.shrink(spacing) @@ -1035,8 +1035,9 @@ class Widget(DOMNode): self.scroll_x = self.validate_scroll_x(self.scroll_x) self.scroll_y = self.validate_scroll_y(self.scroll_y) - self.refresh(layout=True) - self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) + # self.refresh(layout=True) + self.scroll_to(self.scroll_x, self.scroll_y) + # self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) else: self.refresh() @@ -1105,9 +1106,6 @@ class Widget(DOMNode): offset_x, offset_y = self.screen.get_offset(self) return self.screen.get_style_at(x + offset_x, y + offset_y) - def call_later(self, callback: Callable, *args, **kwargs) -> None: - self.app.call_later(callback, *args, **kwargs) - async def forward_event(self, event: events.Event) -> None: event.set_forwarded() await self.post_message(event) From b7b32f0429cfad749e3c2a94bb9aef0524c0cf1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Aug 2022 22:38:08 +0100 Subject: [PATCH 34/73] docstring --- docs/examples/introduction/stopwatch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 8cc865052..ee7f514aa 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -96,6 +96,7 @@ class StopwatchApp(App): timers.last().remove() def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" self.dark = not self.dark From b24c7a8f34476f3725ccc006a62d64dfbdd6cb7b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 11:25:03 +0100 Subject: [PATCH 35/73] introduction text --- docs/examples/introduction/stopwatch.py | 16 ++++----- docs/examples/introduction/stopwatch05.py | 2 +- docs/examples/introduction/stopwatch06.py | 2 +- docs/introduction.md | 40 +++++++++++++++++++++-- src/textual/app.py | 4 +-- src/textual/css/stylesheet.py | 13 ++++++-- src/textual/screen.py | 1 + src/textual/scrollbar.py | 14 ++++---- src/textual/widget.py | 6 ++-- 9 files changed, 70 insertions(+), 28 deletions(-) diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index ee7f514aa..3eb8336fa 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -69,13 +69,7 @@ class Stopwatch(Static): class StopwatchApp(App): - """Manage the timers.""" - - def on_load(self) -> None: - """Called when the app first loads.""" - self.bind("a", "add_stopwatch", description="Add") - self.bind("r", "remove_stopwatch", description="Remove") - self.bind("d", "toggle_dark", description="Dark mode") + """A Textual app to manage stopwatches.""" def compose(self) -> ComposeResult: """Called to ad widgets to the app.""" @@ -83,6 +77,12 @@ class StopwatchApp(App): 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() @@ -91,7 +91,7 @@ class StopwatchApp(App): def action_remove_stopwatch(self) -> None: """Called to remove a timer.""" - timers = self.query("#timers Stopwatch") + timers = self.query("Stopwatch") if timers: timers.last().remove() diff --git a/docs/examples/introduction/stopwatch05.py b/docs/examples/introduction/stopwatch05.py index a3d6e3f7a..957dc6f69 100644 --- a/docs/examples/introduction/stopwatch05.py +++ b/docs/examples/introduction/stopwatch05.py @@ -10,7 +10,7 @@ class TimeDisplay(Static): """A widget to display elapsed time.""" start_time = Reactive(monotonic) - time = Reactive(0.0) + time = Reactive.init(0.0) def on_mount(self) -> None: """Event handler called when widget is added to the app.""" diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 02a399249..13d2a8263 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -10,7 +10,7 @@ class TimeDisplay(Static): """A widget to display elapsed time.""" start_time = Reactive(monotonic) - time = Reactive(0.0) + time = Reactive.init(0.0) total = Reactive(0.0) def on_mount(self) -> None: diff --git a/docs/introduction.md b/docs/introduction.md index ad1ed62cf..db7a5fb32 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -326,14 +326,19 @@ You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use Here we have created two reactive attributes: `start_time` and `time`. These 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 +!!! info - `Reactive` is an example of a Python _descriptor_, which allows you to dynamically create properties. + 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. In the example, the default for `start_time` is `monotonic` which is a function that returns the time. When `TimeDisplay` is mounted, the `start_time` attribute will be assigned the result of `monotonic()`. +The first argument to `Reactive` may be a default value or a callable that returns the default value. In the example, 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` handler method, the call to `set_interval` creates a timer object which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. 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. @@ -393,3 +398,32 @@ If you run stopwatch06.py you will be able to use the stopwatches independently. ``` The only remaining feature of the Stopwatch app let 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 it's `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. diff --git a/src/textual/app.py b/src/textual/app.py index c9a56cb80..17674dd0d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -940,12 +940,12 @@ class App(Generic[ReturnType], DOMNode): is_renderable(renderable) for renderable in renderables ), "Can only call panic with strings or Rich renderables" - prerendered = [ + pre_rendered = [ Segments(self.console.render(renderable, self.console.options)) for renderable in renderables ] - self._exit_renderables.extend(prerendered) + self._exit_renderables.extend(pre_rendered) self.close_messages_no_wait() def on_exception(self, error: Exception) -> None: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 8a0053214..ca1a83660 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -320,13 +320,16 @@ class Stylesheet: animate (bool, optional): Animate changed rules. Defaults to ``False``. """ + # TODO: Need to optimize to make applying stylesheet more efficient + # I think we can pre-calculate which rules may be applicable to a given node + # Dictionary of rule attribute names e.g. "text_background" to list of tuples. # The tuples contain the rule specificity, and the value for that rule. # We can use this to determine, for a given rule, whether we should apply it # or not by examining the specificity. If we have two rules for the # same attribute, then we can choose the most specific rule and use that. rule_attributes: dict[str, list[tuple[Specificity6, object]]] - rule_attributes = defaultdict(list) + rule_attributes = {} _check_rule = self._check_rule @@ -338,7 +341,9 @@ class Stylesheet: for key, rule_specificity, value in rule.styles.extract_rules( base_specificity, is_default_rules, tie_breaker ): - rule_attributes[key].append((rule_specificity, value)) + rule_attributes.setdefault(key, []).append( + (rule_specificity, value) + ) # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) @@ -433,11 +438,13 @@ class Stylesheet: apply = self.apply for node in root.walk_children(): apply(node, animate=animate) - if isinstance(node, Widget): + if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: apply(node.vertical_scrollbar) if node.show_horizontal_scrollbar: apply(node.horizontal_scrollbar) + if node.show_horizontal_scrollbar and node.show_vertical_scrollbar: + apply(node.scrollbar_corner) if __name__ == "__main__": diff --git a/src/textual/screen.py b/src/textual/screen.py index 42c90eb1d..f0b84b173 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -13,6 +13,7 @@ from ._callback import invoke from .geometry import Offset, Region, Size from ._compositor import Compositor, MapGeometry from .messages import CallbackType +from ._profile import timer from .reactive import Reactive from .renderables.blank import Blank from ._timer import Timer diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 7ae1dcb45..689a09016 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -232,14 +232,14 @@ class ScrollBar(Widget): style=scrollbar_style, ) - def on_hide(self, event: events.Hide) -> None: + def _on_hide(self, event: events.Hide) -> None: if self.grabbed: self.release_mouse() - def on_enter(self, event: events.Enter) -> None: + def _on_enter(self, event: events.Enter) -> None: self.mouse_over = True - def on_leave(self, event: events.Leave) -> None: + def _on_leave(self, event: events.Leave) -> None: self.mouse_over = False async def action_scroll_down(self) -> None: @@ -254,18 +254,18 @@ class ScrollBar(Widget): def action_released(self) -> None: self.capture_mouse(False) - async def on_mouse_up(self, event: events.MouseUp) -> None: + async def _on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: self.release_mouse() - def on_mouse_capture(self, event: events.MouseCapture) -> None: + def _on_mouse_capture(self, event: events.MouseCapture) -> None: self.grabbed = event.mouse_position self.grabbed_position = self.position - def on_mouse_release(self, event: events.MouseRelease) -> None: + def _on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None - async def on_mouse_move(self, event: events.MouseMove) -> None: + async def _on_mouse_move(self, event: events.MouseMove) -> None: if self.grabbed and self.window_size: x: float | None = None y: float | None = None diff --git a/src/textual/widget.py b/src/textual/widget.py index 5ba5de0bf..35f8c2b43 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -12,6 +12,7 @@ from typing import ( ClassVar, Collection, Iterable, + Iterator, NamedTuple, ) @@ -626,7 +627,7 @@ class Widget(DOMNode): Returns: bool: True if this widget may be scrolled. """ - return self.is_container + return self.styles.layout is not None or bool(self.children) @property def layer(self) -> str: @@ -699,7 +700,6 @@ class Widget(DOMNode): Returns: bool: True if the scroll position changed, otherwise False. """ - self.log(self, x, y, verbosity=0) scrolled_x = scrolled_y = False if animate: # TODO: configure animation speed @@ -1035,7 +1035,7 @@ class Widget(DOMNode): self.scroll_x = self.validate_scroll_x(self.scroll_x) self.scroll_y = self.validate_scroll_y(self.scroll_y) - # self.refresh(layout=True) + self.refresh(layout=True) self.scroll_to(self.scroll_x, self.scroll_y) # self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) else: From 704cbeed104bf87defb89fe90f5295fadb4b8cbc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 11:26:11 +0100 Subject: [PATCH 36/73] simplify --- docs/examples/introduction/stopwatch.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 8657730c2..472dccc89 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -30,25 +30,25 @@ Button { dock: right; } -Stopwatch.started { +.started { text-style: bold; background: $success; color: $text-success; } -Stopwatch.started TimeDisplay { +.started TimeDisplay { opacity: 100%; } -Stopwatch.started #start { +.started #start { display: none } -Stopwatch.started #stop { +.started #stop { display: block } -Stopwatch.started #reset { +.started #reset { visibility: hidden } From 703ea61ba7439eaa5a176992d352c0c95a246783 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 11:31:56 +0100 Subject: [PATCH 37/73] remove run --- docs/examples/styles/content_align.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/styles/content_align.py b/docs/examples/styles/content_align.py index 930553f40..c0213c63e 100644 --- a/docs/examples/styles/content_align.py +++ b/docs/examples/styles/content_align.py @@ -10,4 +10,3 @@ class ContentAlignApp(App): app = ContentAlignApp(css_path="content_align.css") -app.run() From 23fcf46ce870872108ea8be9e56d3c0fb3ca392c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 12:17:11 +0100 Subject: [PATCH 38/73] console screenshot --- docs/examples/getting_started/console.py | 18 ++++++++++++++++ docs/getting_started.md | 5 +---- docs/guide/devtools.md | 12 +++++++---- docs/introduction.md | 2 +- src/textual/_compositor.py | 1 - src/textual/cli/cli.py | 10 ++++++++- src/textual/devtools/renderables.py | 26 ++++++++++++------------ 7 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 docs/examples/getting_started/console.py diff --git a/docs/examples/getting_started/console.py b/docs/examples/getting_started/console.py new file mode 100644 index 000000000..051632d9f --- /dev/null +++ b/docs/examples/getting_started/console.py @@ -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() diff --git a/docs/getting_started.md b/docs/getting_started.md index 299d9d9c0..de59399d5 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -18,9 +18,6 @@ pip install textual 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. See the help for more details: -```python +```bash textual --help ``` - -### Textual Console - diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index efb3a52c9..54237af87 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -28,20 +28,24 @@ textual run my_app.py:alternative_app ## 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 typically overwrite application content, which generally makes an unreadable mess. Fortunately Textual supplies a debug console of it's own which has some super helpful features. +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 it's own which has some super helpful features. -To use the console, open up 2 console emulators. In the first one, run the following: +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 my_app.py --dev ``` -You should notice that the console will display information regarding the running application, such as events which are sent. - Anything you `print` from your application will be displayed in the console window. You can also call the `log()` method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`. + diff --git a/docs/introduction.md b/docs/introduction.md index db7a5fb32..48065ab7e 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -15,7 +15,7 @@ By the end of this page you should have a good idea of the steps involved in cre ## Stopwatch Application -We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop, resume, and reset each stopwatch in addition to adding or removing them. +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. This will be a simple yet **fully featured** app — you could distribute this app if you wanted to! diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 0c6015c0f..3fab34aeb 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -463,7 +463,6 @@ class Compositor: except KeyError: raise errors.NoWidget("Widget is not in layout") - @timer("get_widget_at") def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given point or None.""" # TODO: Optimize with some line based lookup diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index 61393fff7..9626d005a 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -20,7 +20,15 @@ def run(): @run.command(help="Run the Textual Devtools console.") def console(): - _run_devtools() + from rich.console import Console + + console = Console() + console.clear() + console.show_cursor(False) + try: + _run_devtools() + finally: + console.show_cursor(True) class AppFail(Exception): diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index 1cf0b6320..de0c30929 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -6,22 +6,22 @@ from pathlib import Path from typing import Iterable from importlib_metadata import version -from rich.containers import Renderables -from rich.style import Style -from rich.text import Text if sys.version_info >= (3, 8): from typing import Literal else: from typing_extensions import Literal -from rich.console import Console from rich.align import Align -from rich.console import ConsoleOptions, RenderResult +from rich.console import Console, ConsoleOptions, RenderResult from rich.markup import escape from rich.rule import Rule from rich.segment import Segment, Segments +from rich.style import Style from rich.table import Table +from rich.text import Text + +from textual._border import Border DevConsoleMessageLevel = Literal["info", "warning", "error"] @@ -30,17 +30,17 @@ class DevConsoleHeader: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - lines = Renderables( - [ - f"[bold]Textual Development Console [magenta]v{version('textual')}", - "[magenta]Run a Textual app with the environment variable [b]TEXTUAL=devtools[/] to connect.", - "[magenta]Press [b]Ctrl+C[/] to quit.", - ] + preamble = Text.from_markup( + f"[bold]Textual Development Console [magenta]v{version('textual')}\n" + "[magenta]Run a Textual app with [reverse]textual run --dev my_app.py[/] to connect.\n" + "[magenta]Press [reverse]Ctrl+C[/] to quit." ) render_options = options.update(width=options.max_width - 4) - lines = console.render_lines(lines, render_options) - new_line = Segment("\n") + lines = console.render_lines(preamble, render_options) + + new_line = Segment.line() padding = Segment("▌", Style.parse("bright_magenta")) + for line in lines: yield padding yield from line From a135debdfe9718870ef382b793a414ee3f9091ad Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 14:41:02 +0100 Subject: [PATCH 39/73] words --- docs/examples/introduction/intro02.py | 1 - docs/introduction.md | 69 ++++++++++++++------------- 2 files changed, 36 insertions(+), 34 deletions(-) diff --git a/docs/examples/introduction/intro02.py b/docs/examples/introduction/intro02.py index 514029ebb..2f5d3ed64 100644 --- a/docs/examples/introduction/intro02.py +++ b/docs/examples/introduction/intro02.py @@ -22,7 +22,6 @@ class ExampleApp(App): def on_key(self, event): if event.key.isdigit(): self.styles.background = self.COLORS[int(event.key)] - self.refresh() self.bell() diff --git a/docs/introduction.md b/docs/introduction.md index 48065ab7e..749cf9eca 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -11,8 +11,6 @@ By the end of this page you should have a good idea of the steps involved in cre — **Will McGugan** (creator of Rich and Textual) - - ## Stopwatch Application 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. @@ -37,17 +35,18 @@ python stopwatch.py ## Type hints (in brief) -We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, its a way to express the types of your data, parameters, and returns. Type hinting allows tools like [Mypy](https://mypy.readthedocs.io/en/stable/) to catch potential bugs before your code runs. +We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, its 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 ``` -- Parameter types follow a colon, so `text: str` means that `text` should be a string and `count: int` means that `count` should be an integer. -- Return types follow `->` So `-> str:` says that this method returns a string. +- 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 that this method returns a string. !!! note @@ -56,7 +55,7 @@ def repeat(text: str, count: int) -> str: ## The App class -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 which we will cover below. +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" @@ -104,14 +103,14 @@ There are three methods in our stopwatch app currently. !!! note - You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update. + 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. ```python title="stopwatch01.py" hl_lines="22-24" --8<-- "docs/examples/introduction/stopwatch01.py" ``` -The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and call `run()` within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. +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`. ## Designing a UI with widgets @@ -125,12 +124,12 @@ Let's sketch out a design for our app: We will need to build a `Stopwatch` widget composed of the following _child_ widgets: -- A "start" button -- A "stop" button -- A "reset" button +- A "Start" button +- A "Stop" button +- A "Reset" button - A time display -Textual has a builtin `Button` widgets which takes care of the first three components. All we need to build is the time display which will show the elapsed time in HOURS:MINUTES:SECONDS format, and the stopwatch itself. +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. @@ -144,9 +143,9 @@ We've imported two new widgets in this code: `Button`, which creates a clickable We're extending Static as a foundation for our `TimeDisplay` widget. There are no methods on this class yet. -The Stopwatch class also extends Static to define a new widget. This class has a `compose()` method which yields its child widgets, consisting of of three `Button` objects and a single `TimeDisplay`. These are all we need to build a stopwatch as in the sketch. +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 Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). There are two additional parameters to the Button constructor we are using: +The Button constructor takes a label to be displayed in the button ("Start", "Stop", or "Reset"). Additionally some of the buttons set the following parameters: - **`id`** is an identifier we can use to tell the buttons apart in code and apply styles. More on that later. - **`variant`** is a string which selects a default style. The "success" variant makes the button green, and the "error" variant makes it red. @@ -155,7 +154,7 @@ The Button constructor takes a label to be displayed in the button ("Start", "St To see our widgets with 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. 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 as child widgets of the container. +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 @@ -165,13 +164,11 @@ Let's see what happens when we run "stopwatch02.py". ```{.textual path="docs/examples/introduction/stopwatch02.py" title="stopwatch02.py"} ``` -The elements of the stopwatch application are there. The buttons are clickable and you can scroll the container, but it doesn't look much like the sketch. This is because we have yet to apply any _styles_ to our new widget. +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. ## Writing Textual CSS -Every widget has a `styles` object which contains information regarding how that widget will look. Setting any of the attributes on the styles object will update the screen. - -Here's how you might set white text and a blue background for a widget: +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: ```python self.styles.background = "blue" @@ -221,7 +218,7 @@ Stopwatch { The first line tells Textual that the styles should apply to the `Stopwatch` widget. The lines between the curly brackets contain the styles themselves. -Here's how the Stopwatch block in the CSS impacts our `Stopwatch` widget: +Here's how this CSS code changes how the `Stopwatch` widget is displayed.
--8<-- "docs/images/stopwatch_widgets.excalidraw.svg" @@ -265,7 +262,7 @@ The `TimeDisplay` block aligns text to the center (`content-align`), fades it sl 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 widgets with a matching "id" attribute. We've set an ID attribute on the Button widgets we yielded in compose. For instance the first button has `id="start"` which matches `#start` in the CSS. +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. @@ -283,7 +280,7 @@ Here's the new CSS: --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 these styles. +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: @@ -293,11 +290,15 @@ Some of the new styles have more than one selector separated by a space. The spa } ``` -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". The rule is applied to the button, so `"display: none"` tells Textual to _hide_ the button. +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 -The easiest way to manipulate visuals with Textual is to modify CSS classes. This way your (Python) code can remain free of display related code which tends to be hard to maintain. +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. @@ -324,13 +325,15 @@ You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use --8<-- "docs/examples/introduction/stopwatch05.py" ``` -Here we have created two reactive attributes: `start_time` and `time`. These 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. +We have added two reactive attributes: `start_time` will contain the time in seconds when the stopwatch was started, and `time` will contain 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. In the example, the default for `start_time` is `monotonic`. When `TimeDisplay` is mounted, the `start_time` attribute will be assigned the result of `monotonic()`. +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. @@ -339,7 +342,7 @@ The `time` attribute has a simple float as the default value, so `self.time` wil 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` handler method, the call to `set_interval` creates a timer object which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers. +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. @@ -350,7 +353,7 @@ The end result is that the `Stopwatch` widgets show the time elapsed since the w ```{.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 buttons to the widget +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 @@ -385,12 +388,12 @@ The `on_button_pressed` method on `Stopwatch` has grown some code to manage the time_display.reset() ``` -This code supplies the missing features and makes our app useful. We've made the following changes. +This code supplies missing features and makes our app useful. We've made the following changes. -- The first line stores 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. This method queries for a child widget. You may supply a Widget type or a CSS selector. +- 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 and show the buttons that match the state. +- 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. @@ -411,7 +414,7 @@ Let's use these to implement adding and removing stopwatches to our app. --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. +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). From 5fe47da058564b72e5081a5658c054f55a5d85cd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 23 Aug 2022 14:47:08 +0100 Subject: [PATCH 40/73] Await animator --- tests/test_animator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_animator.py b/tests/test_animator.py index 926949b27..159600d7a 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -243,7 +243,7 @@ def test_bound_animator(): assert animator._animations[(id(animate_test), "foo")] == expected -def test_animator_on_complete_callback_not_fired_before_duration_ends(): +async def test_animator_on_complete_callback_not_fired_before_duration_ends(): callback = Mock() animate_test = AnimateTest() animator = MockAnimator(Mock()) @@ -251,7 +251,7 @@ def test_animator_on_complete_callback_not_fired_before_duration_ends(): animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) animator._time = 9 - animator() + await animator() assert not callback.called From 9cc48db79f97b589cbeba60f67df8b24bef865b3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 09:39:00 +0100 Subject: [PATCH 41/73] fix for test fail --- docs/examples/introduction/stopwatch.py | 2 +- docs/examples/introduction/stopwatch06.py | 2 +- docs/introduction.md | 2 +- tests/utilities/test_app.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 3eb8336fa..15dff3a1c 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -72,7 +72,7 @@ class StopwatchApp(App): """A Textual app to manage stopwatches.""" def compose(self) -> ComposeResult: - """Called to ad widgets to the app.""" + """Called to add widgets to the app.""" yield Header() yield Footer() yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers") diff --git a/docs/examples/introduction/stopwatch06.py b/docs/examples/introduction/stopwatch06.py index 13d2a8263..726a07dc4 100644 --- a/docs/examples/introduction/stopwatch06.py +++ b/docs/examples/introduction/stopwatch06.py @@ -72,7 +72,7 @@ class StopwatchApp(App): """A Textual app to manage stopwatches.""" def compose(self) -> ComposeResult: - """Create child widgets for the app.""" + """Called to add widgets to the app.""" yield Header() yield Footer() yield Container(Stopwatch(), Stopwatch(), Stopwatch()) diff --git a/docs/introduction.md b/docs/introduction.md index 749cf9eca..5d865ef7a 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -2,7 +2,7 @@ Welcome to the Textual Introduction! -By the end of this page you should have a good idea of the steps involved in creating an application with Textual. +By the end of this page you should have a solid understanding of app development with Textual. !!! quote diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index 23e21f00a..016ffb5cb 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -103,6 +103,7 @@ class AppTest(App): # End of simulated time: we just shut down ourselves: assert not run_task.done() await self.shutdown() + await run_task return get_running_state_context_manager() From 6ee4d41bb7a39238a18949f5648773562c6a1c9b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 11:19:29 +0100 Subject: [PATCH 42/73] docs --- docs/guide/CSS.md | 49 +++++++++------ docs/guide/devtools.md | 6 +- docs/reference/color.md | 1 + docs/reference/dom_node.md | 1 + mkdocs.yml | 18 +++--- src/textual/actions.py | 13 +++- src/textual/color.py | 56 +++++++++++++++--- src/textual/dom.py | 24 +++++--- src/textual/geometry.py | 78 ++++++++++++++++++++---- src/textual/scroll_view.py | 118 ------------------------------------- src/textual/widget.py | 71 +++++++++++++++++++--- 11 files changed, 253 insertions(+), 182 deletions(-) create mode 100644 docs/reference/color.md create mode 100644 docs/reference/dom_node.md delete mode 100644 src/textual/scroll_view.py diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 790a4235a..3f25742de 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -16,7 +16,7 @@ CSS is typically stored in an external file with the extension `.css` alongside Let's look at some Textual CSS. -```css +```sass Header { dock: top; height: 3; @@ -28,7 +28,7 @@ Header { This is an example of a CSS _rule set_. There may be many such sections in any given CSS file. -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 in the Python class `Header`. +Let's break this CSS code down a bit. ```css hl_lines="1" Header { @@ -40,7 +40,7 @@ Header { } ``` -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 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 in the Python class `Header`. ```css hl_lines="2 3 4 5 6" Header { @@ -52,6 +52,8 @@ Header { } ``` +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 a 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 naturally appropriate for a header. You may be able to guess what some of the the other rules do. We will cover those later. @@ -75,7 +77,7 @@ Let's look at a trivial Textual app. ```{.textual path="docs/examples/guide/dom1.py"} ``` -When you run this code you will have an instance of an app (ExampleApp) in memory. This app class will also create a Screen object. In DOM terms, the Screen is a _child_ of the app. +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 the app. With the above example, the DOM will look like the following: @@ -121,7 +123,7 @@ To further explore the DOM, we're going to build a simple dialog with a question --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; for instance a Button widget doesn't need any children. +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: @@ -149,7 +151,7 @@ You may have noticed that some of the constructors have additional keywords argu Here's the CSS file we are applying: -```python +```sass --8<-- "docs/examples/guide/dom4.css" ``` @@ -175,7 +177,7 @@ Finally, Textual CSS allows you to _live edit_ the styles in your app. If you ru textual run my_app.py --dev ``` -Being able to iterate on the design without restarting the Python code can make it much easier to design beautiful interfaces. +Being able to iterate on the design without restarting the Python code can make it easier and faster to design beautiful interfaces. ## Selectors @@ -198,7 +200,7 @@ class Button(Static): The following rule applies a border to this widget: -```css +```sass Button { border: solid blue; } @@ -206,7 +208,7 @@ Button { 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`. -```css +```sass Static { background: blue; border: rounded white; @@ -231,15 +233,17 @@ 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: -```css +```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 may share a particular style. +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 widgets `classes` parameter in the constructor. Here's an example: @@ -257,7 +261,7 @@ 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: -```css +```sass .success { background: green; color: white; @@ -270,19 +274,28 @@ To match a Widget with a given class in CSS you can precede the class name with 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. -```css +```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 its 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_ selectors is denoted by an asterisk and will match _all_ widgets. For example, the following will draw a red outline around all widgets: -```css +```sass * { outline: solid red; } @@ -292,7 +305,7 @@ For example, the following will draw a red outline around all widgets: 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. -```css +```sass Button:hover { background: green; } @@ -321,7 +334,7 @@ Here's a section of DOM to illustrate this combinator: 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: -```css hl_lines="1" +```sass hl_lines="1" #dialog Button { text-style: bold; } @@ -349,7 +362,7 @@ Let's use this to match the Button in the sidebar given the following DOM: We can use the following CSS to style all buttons which have a parent with an ID of `sidebar`: -```css +```sass #sidebar > Button { text-style: underline; } @@ -375,7 +388,7 @@ The specificity rules are usually enough to fix any conflicts in your stylesheet Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons: -```css hl_lines="2" +```sass hl_lines="2" Button:hover { background: blue !important; } diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index 54237af87..8a3b9b2c5 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -16,7 +16,7 @@ You can run Textual apps with the `run` subcommand. If you supply a path to a Py textual run my_app.py ``` -The `run` sub-command assumes you have a Application 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: +The `run` sub-command assumes you have a 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 @@ -24,7 +24,7 @@ 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 dev mode which auto (re) loads your CSS if you change it. + 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 @@ -44,7 +44,7 @@ This should look something like the following: In the other console, run your application using `textual run` and the `--dev` switch: ```bash -textual run my_app.py --dev +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()` method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`. diff --git a/docs/reference/color.md b/docs/reference/color.md new file mode 100644 index 000000000..0d1d71759 --- /dev/null +++ b/docs/reference/color.md @@ -0,0 +1 @@ +::: textual.color diff --git a/docs/reference/dom_node.md b/docs/reference/dom_node.md new file mode 100644 index 000000000..90e75c8d9 --- /dev/null +++ b/docs/reference/dom_node.md @@ -0,0 +1 @@ +::: textual.dom.DOMNode diff --git a/mkdocs.yml b/mkdocs.yml index f6c520f31..8cdfb1830 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,8 +6,8 @@ nav: - "getting_started.md" - "introduction.md" - Guide: - - "guide/guide.md" - "guide/devtools.md" + - "guide/guide.md" - "guide/CSS.md" - "guide/events.md" @@ -44,19 +44,19 @@ nav: - "styles/color.md" - "styles/content_align.md" - "styles/display.md" - - "styles/min_height.md" - - "styles/max_height.md" - - "styles/min_width.md" - - "styles/max_width.md" - "styles/height.md" - "styles/margin.md" + - "styles/max_height.md" + - "styles/max_width.md" + - "styles/min_height.md" + - "styles/min_width.md" - "styles/offset.md" - "styles/outline.md" - "styles/overflow.md" - "styles/padding.md" - - "styles/scrollbar.md" - - "styles/scrollbar_size.md" - "styles/scrollbar_gutter.md" + - "styles/scrollbar_size.md" + - "styles/scrollbar.md" - "styles/text_style.md" - "styles/tint.md" - "styles/visibility.md" @@ -64,10 +64,13 @@ nav: - Widgets: "/widgets/" - Reference: - "reference/app.md" + - "reference/color.md" + - "reference/dom_node.md" - "reference/events.md" - "reference/geometry.md" - "reference/widget.md" + markdown_extensions: - admonition - def_list @@ -115,6 +118,7 @@ theme: plugins: - search: +- autorefs: - mkdocstrings: default_handler: python handlers: diff --git a/src/textual/actions.py b/src/textual/actions.py index 41839834b..118ce2b51 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -12,7 +12,18 @@ class ActionError(Exception): re_action_params = re.compile(r"([\w\.]+)(\(.*?\))") -def parse(action: str) -> tuple[str, tuple[Any, ...]]: +def parse(action: str) -> tuple[str, tuple[object, ...]]: + """Parses an action string. + + Args: + action (str): String containing action. + + Raises: + ActionError: If the action has invalid syntax. + + Returns: + tuple[str, tuple[object, ...]]: Action name and parameters + """ params_match = re_action_params.match(action) if params_match is not None: action_name, action_params_str = params_match.groups() diff --git a/src/textual/color.py b/src/textual/color.py index 7b2be31bf..383ba21c3 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -39,16 +39,22 @@ class HLS(NamedTuple): """A color in HLS format.""" h: float + """Hue""" l: float + """Lightness""" s: float + """Saturation""" class HSV(NamedTuple): """A color in HSV format.""" h: float + """Hue""" s: float + """Saturation""" v: float + """Value""" class Lab(NamedTuple): @@ -103,9 +109,13 @@ class Color(NamedTuple): """A class to represent a single RGB color with alpha.""" r: int + """Red component (0-255)""" g: int + """Green component (0-255)""" b: int + """Blue component (0-255)""" a: float = 1.0 + """Alpha component (0-1)""" @classmethod def from_rich_color(cls, rich_color: RichColor) -> Color: @@ -146,12 +156,22 @@ class Color(NamedTuple): @property def is_transparent(self) -> bool: - """Check if the color is transparent, i.e. has 0 alpha.""" + """Check if the color is transparent, i.e. has 0 alpha. + + Returns: + bool: True if transparent, otherwise False. + + """ return self.a == 0 @property def clamped(self) -> Color: - """Get a color with all components saturated to maximum and minimum values.""" + """Get a color with all components saturated to maximum and minimum values. + + Returns: + Color: A color object. + + """ r, g, b, a = self _clamp = clamp color = Color( @@ -164,7 +184,11 @@ class Color(NamedTuple): @property def rich_color(self) -> RichColor: - """This color encoded in Rich's Color class.""" + """This color encoded in Rich's Color class. + + Returns: + RichColor: A color object as used by Rich. + """ r, g, b, _a = self return RichColor( f"#{r:02x}{g:02x}{b:02x}", _TRUECOLOR, None, ColorTriplet(r, g, b) @@ -172,25 +196,43 @@ class Color(NamedTuple): @property def normalized(self) -> tuple[float, float, float]: - """A tuple of the color components normalized to between 0 and 1.""" + """A tuple of the color components normalized to between 0 and 1. + + Returns: + tuple[float, float, float]: Normalized components. + + """ r, g, b, _a = self return (r / 255, g / 255, b / 255) @property def rgb(self) -> tuple[int, int, int]: - """Get just the red, green, and blue components.""" + """Get just the red, green, and blue components. + + Returns: + tuple[int, int, int]: Color components + """ r, g, b, _ = self return (r, g, b) @property def hls(self) -> HLS: - """Get the color as HLS.""" + """Get the color as HLS. + + Returns: + HLS: + """ r, g, b = self.normalized return HLS(*rgb_to_hls(r, g, b)) @property def brightness(self) -> float: - """Get the human perceptual brightness.""" + """Get the human perceptual brightness. + + Returns: + float: Brightness value (0-1). + + """ r, g, b = self.normalized brightness = (299 * r + 587 * g + 114 * b) / 1000 return brightness diff --git a/src/textual/dom.py b/src/textual/dom.py index d5b0d6679..05d4c7838 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -52,11 +52,7 @@ class NoParent(Exception): @rich.repr.auto class DOMNode(MessagePump): - """A node in a hierarchy of things forming the UI. - - Nodes are mountable and may be styled with CSS. - - """ + """The base class for object that can be in the Textual DOM (App and Widget)""" # Custom CSS CSS: ClassVar[str] = "" @@ -285,6 +281,12 @@ class DOMNode(MessagePump): @property def classes(self) -> frozenset[str]: + """A frozenset of the current classes set on the widget. + + Returns: + frozenset[str]: Set of class names. + + """ return frozenset(self._classes) @property @@ -312,7 +314,10 @@ class DOMNode(MessagePump): @property def display(self) -> bool: """ - Returns: ``True`` if this DOMNode is displayed (``display != "none"``), ``False`` otherwise. + Check if this widget should display or note. + + Returns: + bool: ``True`` if this DOMNode is displayed (``display != "none"``) otherwise ``False`` . """ return self.styles.display != "none" and not (self._closing or self._closed) @@ -484,7 +489,12 @@ class DOMNode(MessagePump): @property def displayed_children(self) -> list[DOMNode]: - """The children which don't have display: none set.""" + """The children which don't have display: none set. + + Returns: + list[DOMNode]: Children of this widget which will be displayed. + + """ return [child for child in self.children if child.display] def get_pseudo_classes(self) -> Iterable[str]: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 9a0da73fa..0e131f5b2 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -141,13 +141,22 @@ class Size(NamedTuple): @property def region(self) -> Region: - """Get a region of the same size.""" + """Get a region of the same size. + + Returns: + Region: A region with the same size at (0, 0) + + """ width, height = self return Region(0, 0, width, height) @property def line_range(self) -> range: - """Get a range covering lines.""" + """Get a range covering lines. + + Returns: + range: + """ return range(self.height) def __add__(self, other: object) -> Size: @@ -225,7 +234,7 @@ class Region(NamedTuple): y: int = 0 """Offset in the y-axis (vertical)""" width: int = 0 - """The widget of the region""" + """The width of the region""" height: int = 0 """The height of the region""" @@ -360,45 +369,85 @@ class Region(NamedTuple): @property def right(self) -> int: - """Maximum X value (non inclusive)""" + """Maximum X value (non inclusive). + + Returns: + int: x coordinate + + """ return self.x + self.width @property def bottom(self) -> int: - """Maximum Y value (non inclusive)""" + """Maximum Y value (non inclusive). + + Returns: + int: y coordinate + + """ return self.y + self.height @property def area(self) -> int: - """Get the area within the region.""" + """Get the area within the region. + + Returns: + int: area. + + """ return self.width * self.height @property def offset(self) -> Offset: - """Get the start point of the region.""" + """Get the start point of the region. + + Returns: + Offset: Top left offset. + + """ return Offset(self.x, self.y) @property def bottom_left(self) -> Offset: - """Bottom left offset of the region.""" + """Bottom left offset of the region. + + Returns: + Offset: Bottom left offset. + + """ x, y, _width, height = self return Offset(x, y + height) @property def top_right(self) -> Offset: - """Top right offset of the region.""" + """Top right offset of the region. + + Returns: + Offset: Top right. + + """ x, y, width, _height = self return Offset(x + width, y) @property def bottom_right(self) -> Offset: - """Bottom right of the region.""" + """Bottom right of the region. + + Returns: + Offset: Bottom right. + + """ x, y, width, height = self return Offset(x + width, y + height) @property def size(self) -> Size: - """Get the size of the region.""" + """Get the size of the region. + + Returns: + Size: Size of the region. + + """ return Size(self.width, self.height) @property @@ -423,7 +472,12 @@ class Region(NamedTuple): @property def reset_offset(self) -> Region: - """An region of the same size at (0, 0).""" + """An region of the same size at (0, 0). + + Returns: + Region: reset region. + + """ _, _, width, height = self return Region(0, 0, width, height) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py deleted file mode 100644 index 8312be117..000000000 --- a/src/textual/scroll_view.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -from typing import Collection - -from rich.console import RenderableType - - -from .geometry import Region, Size -from .widget import Widget - - -class ScrollView(Widget): - """ - A base class for a Widget that handles it's own scrolling (i.e. doesn't rely - on the compositor to render children). - - """ - - CSS = """ - - ScrollView { - overflow-y: auto; - overflow-x: auto; - } - - """ - - def __init__( - self, name: str | None = None, id: str | None = None, classes: str | None = None - ) -> None: - super().__init__(name=name, id=id, classes=classes) - - @property - def is_scrollable(self) -> bool: - """Always scrollable.""" - return True - - @property - def is_transparent(self) -> bool: - """Not transparent, i.e. renders something.""" - return False - - def on_mount(self): - self._refresh_scrollbars() - - def get_content_width(self, container: Size, viewport: Size) -> int: - """Gets the width of the content area. - - Args: - container (Size): Size of the container (immediate parent) widget. - viewport (Size): Size of the viewport. - - Returns: - int: The optimal width of the content. - """ - return self.virtual_size.width - - def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - """Gets the height (number of lines) in the content area. - - Args: - container (Size): Size of the container (immediate parent) widget. - viewport (Size): Size of the viewport. - width (int): Width of renderable. - - Returns: - int: The height of the content. - """ - return self.virtual_size.height - - def size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: - """Called when size is updated. - - Args: - size (Size): New size. - virtual_size (Size): New virtual size. - container_size (Size): New container size. - """ - virtual_size = self.virtual_size - if self._size != size: - self._size = size - self._container_size = container_size - - self._refresh_scrollbars() - width, height = self.container_size - if self.show_vertical_scrollbar: - self.vertical_scrollbar.window_virtual_size = virtual_size.height - self.vertical_scrollbar.window_size = height - if self.show_horizontal_scrollbar: - self.horizontal_scrollbar.window_virtual_size = virtual_size.width - self.horizontal_scrollbar.window_size = width - - self.scroll_x = self.validate_scroll_x(self.scroll_x) - self.scroll_y = self.validate_scroll_y(self.scroll_y) - self.refresh(layout=False) - self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) - - def render(self) -> RenderableType: - """Render the scrollable region (if `render_lines` is not implemented). - - Returns: - RenderableType: Renderable object. - """ - from rich.panel import Panel - - return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}") - - def watch_scroll_x(self, new_value: float) -> None: - """Called when horizontal bar is scrolled.""" - self.horizontal_scrollbar.position = int(new_value) - self.refresh(layout=False) - - def watch_scroll_y(self, new_value: float) -> None: - """Called when vertical bar is scrolled.""" - self.vertical_scrollbar.position = int(new_value) - self.refresh(layout=False) diff --git a/src/textual/widget.py b/src/textual/widget.py index 35f8c2b43..f53955e55 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -72,6 +72,10 @@ class RenderCache(NamedTuple): @rich.repr.auto class Widget(DOMNode): + """ + A Widget is the base class for Textual widgets. Extent this class (or a sub-class) when defining your own widgets. + + """ CSS = """ Widget{ @@ -488,6 +492,11 @@ class Widget(DOMNode): @property def scrollbar_gutter(self) -> Spacing: + """Spacing required to fit scrollbar(s) + + Returns: + Spacing: Scrollbar gutter spacing. + """ gutter = Spacing( 0, self.scrollbar_size_vertical, self.scrollbar_size_horizontal, 0 ) @@ -495,39 +504,73 @@ class Widget(DOMNode): @property def gutter(self) -> Spacing: - """Spacing for padding / border / scrollbars.""" + """Spacing for padding / border / scrollbars. + + Returns: + Spacing: Additional spacing around content area. + + """ return self.styles.gutter + self.scrollbar_gutter @property def size(self) -> Size: - """The size of the content area.""" + """The size of the content area. + + Returns: + Size: Content area size. + """ return self.content_region.size @property def outer_size(self) -> Size: - """The size of the widget (including padding and border).""" + """The size of the widget (including padding and border). + + Returns: + Size: Outer size. + """ return self._size @property def container_size(self) -> Size: - """The size of the container (parent widget).""" + """The size of the container (parent widget). + + Returns: + Size: Container size. + """ return self._container_size @property def content_region(self) -> Region: - """Gets an absolute region containing the content (minus padding and border).""" + """Gets an absolute region containing the content (minus padding and border). + + Returns: + Region: Screen region that contains a widget's content. + """ content_region = self.region.shrink(self.gutter) return content_region @property def content_offset(self) -> Offset: - """An offset from the Widget origin where the content begins.""" + """An offset from the Widget origin where the content begins. + + Returns: + Offset: Offset from widget's origin. + + """ x, y = self.gutter.top_left return Offset(x, y) @property def region(self) -> Region: - """The region occupied by this widget, relative to the Screen.""" + """The region occupied by this widget, relative to the Screen. + + Raises: + NoScreen: If there is no screen. + errors.NoWidget: If the widget is not on the screen. + + Returns: + Region: Region within screen occupied by widget. + """ try: return self.screen.find_widget(self).region except NoScreen: @@ -596,7 +639,12 @@ class Widget(DOMNode): @property def console(self) -> Console: - """Get the current console.""" + """Get the current console. + + Returns: + Console: A Rich console object. + + """ return active_app.get().console @property @@ -631,7 +679,12 @@ class Widget(DOMNode): @property def layer(self) -> str: - """Get the name of this widgets layer.""" + """Get the name of this widgets layer. + + Returns: + str: Name of layer. + + """ return self.styles.layer or "default" @property From 5fa6c1f92d55a83c6298d7be3ce837350889c583 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 11:36:12 +0100 Subject: [PATCH 43/73] replace scroll view --- src/textual/scroll_view.py | 118 +++++++++++++++++++++++++++++ src/textual/widgets/_data_table.py | 2 +- 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/textual/scroll_view.py diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py new file mode 100644 index 000000000..8312be117 --- /dev/null +++ b/src/textual/scroll_view.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Collection + +from rich.console import RenderableType + + +from .geometry import Region, Size +from .widget import Widget + + +class ScrollView(Widget): + """ + A base class for a Widget that handles it's own scrolling (i.e. doesn't rely + on the compositor to render children). + + """ + + CSS = """ + + ScrollView { + overflow-y: auto; + overflow-x: auto; + } + + """ + + def __init__( + self, name: str | None = None, id: str | None = None, classes: str | None = None + ) -> None: + super().__init__(name=name, id=id, classes=classes) + + @property + def is_scrollable(self) -> bool: + """Always scrollable.""" + return True + + @property + def is_transparent(self) -> bool: + """Not transparent, i.e. renders something.""" + return False + + def on_mount(self): + self._refresh_scrollbars() + + def get_content_width(self, container: Size, viewport: Size) -> int: + """Gets the width of the content area. + + Args: + container (Size): Size of the container (immediate parent) widget. + viewport (Size): Size of the viewport. + + Returns: + int: The optimal width of the content. + """ + return self.virtual_size.width + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + """Gets the height (number of lines) in the content area. + + Args: + container (Size): Size of the container (immediate parent) widget. + viewport (Size): Size of the viewport. + width (int): Width of renderable. + + Returns: + int: The height of the content. + """ + return self.virtual_size.height + + def size_updated( + self, size: Size, virtual_size: Size, container_size: Size + ) -> None: + """Called when size is updated. + + Args: + size (Size): New size. + virtual_size (Size): New virtual size. + container_size (Size): New container size. + """ + virtual_size = self.virtual_size + if self._size != size: + self._size = size + self._container_size = container_size + + self._refresh_scrollbars() + width, height = self.container_size + if self.show_vertical_scrollbar: + self.vertical_scrollbar.window_virtual_size = virtual_size.height + self.vertical_scrollbar.window_size = height + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.window_virtual_size = virtual_size.width + self.horizontal_scrollbar.window_size = width + + self.scroll_x = self.validate_scroll_x(self.scroll_x) + self.scroll_y = self.validate_scroll_y(self.scroll_y) + self.refresh(layout=False) + self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) + + def render(self) -> RenderableType: + """Render the scrollable region (if `render_lines` is not implemented). + + Returns: + RenderableType: Renderable object. + """ + from rich.panel import Panel + + return Panel(f"{self.scroll_offset} {self.show_vertical_scrollbar}") + + def watch_scroll_x(self, new_value: float) -> None: + """Called when horizontal bar is scrolled.""" + self.horizontal_scrollbar.position = int(new_value) + self.refresh(layout=False) + + def watch_scroll_y(self, new_value: float) -> None: + """Called when vertical bar is scrolled.""" + self.vertical_scrollbar.position = int(new_value) + self.refresh(layout=False) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 201a4228d..c4059c000 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -78,7 +78,7 @@ class Cell: class Coord(NamedTuple): - """An object to represent the cordinate of a cell within the data table.""" + """An object to represent the coordinate of a cell within the data table.""" row: int column: int From c0a631ac492580c2d8a311cdd69385cbc95a7fc0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 14:00:05 +0100 Subject: [PATCH 44/73] faster screenshots, docstrings --- docs/guide/guide.md | 1 - mkdocs.yml | 3 +- src/textual/app.py | 4 +- src/textual/geometry.py | 143 ++++++++++++++++++++++++++-------------- 4 files changed, 95 insertions(+), 56 deletions(-) delete mode 100644 docs/guide/guide.md diff --git a/docs/guide/guide.md b/docs/guide/guide.md deleted file mode 100644 index 8c0d02fad..000000000 --- a/docs/guide/guide.md +++ /dev/null @@ -1 +0,0 @@ -# Guide diff --git a/mkdocs.yml b/mkdocs.yml index 8cdfb1830..c9b58064b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,8 +6,7 @@ nav: - "getting_started.md" - "introduction.md" - Guide: - - "guide/devtools.md" - - "guide/guide.md" + - "guide/devtools.md" - "guide/CSS.md" - "guide/events.md" diff --git a/src/textual/app.py b/src/textual/app.py index 17674dd0d..04a9dc62d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -596,7 +596,7 @@ class App(Generic[ReturnType], DOMNode): assert press driver = app._driver assert driver is not None - await asyncio.sleep(0.05) + await asyncio.sleep(0.01) for key in press: if key == "_": print("(pause)") @@ -604,7 +604,7 @@ class App(Generic[ReturnType], DOMNode): else: print(f"press {key!r}") driver.send_event(events.Key(self, key)) - await asyncio.sleep(0.02) + await asyncio.sleep(0.01) if screenshot: self._screenshot = self.export_screenshot( title=screenshot_title diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 0e131f5b2..4820edf47 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -24,12 +24,13 @@ T = TypeVar("T", int, float) def clamp(value: T, minimum: T, maximum: T) -> T: - """Clamps a value between two other values. + """Adjust a value to it is not less than a minimum and not greater + than a maximum value. Args: - value (T): A value - minimum (T): Minimum value - maximum (T): maximum value + value (T): A value. + minimum (T): Minimum value. + maximum (T): maximum value. Returns: T: New value that is not less than the minimum or greater than the maximum. @@ -46,7 +47,11 @@ def clamp(value: T, minimum: T, maximum: T) -> T: class Offset(NamedTuple): """A cell offset defined by x and y coordinates. Offsets are typically relative to the - top left of the terminal or other container.""" + top left of the terminal or other container. + + Textual prefers the names `x` and `y`, but you could consider `x` to be the _column_ and `y` to be the _row_. + + """ x: int = 0 """Offset in the x-axis (horizontal)""" @@ -55,7 +60,12 @@ class Offset(NamedTuple): @property def is_origin(self) -> bool: - """Check if the point is at the origin (0, 0)""" + """Check if the point is at the origin (0, 0). + + Returns: + bool: True if the offset is the origin. + + """ return self == (0, 0) def __bool__(self) -> bool: @@ -89,11 +99,11 @@ class Offset(NamedTuple): """Blend (interpolate) to a new point. Args: - destination (Point): Point where progress is 1.0 - factor (float): A value between 0 and 1.0 + destination (Point): Point where factor would be 1.0. + factor (float): A value between 0 and 1.0. Returns: - Point: A new point on a line between self and destination + Point: A new point on a line between self and destination. """ x1, y1 = self x2, y2 = destination @@ -106,10 +116,10 @@ class Offset(NamedTuple): """Get the distance to another offset. Args: - other (Offset): An offset + other (Offset): An offset. Returns: - float: Distance to other offset + float: Distance to other offset. """ x1, y1 = self x2, y2 = other @@ -118,7 +128,7 @@ class Offset(NamedTuple): class Size(NamedTuple): - """An area defined by its width and height.""" + """The dimensions of a rectangular region.""" width: int = 0 """The width in cells.""" @@ -144,7 +154,7 @@ class Size(NamedTuple): """Get a region of the same size. Returns: - Region: A region with the same size at (0, 0) + Region: A region with the same size at (0, 0). """ width, height = self @@ -155,7 +165,7 @@ class Size(NamedTuple): """Get a range covering lines. Returns: - range: + range: A builtin range object. """ return range(self.height) @@ -174,11 +184,11 @@ class Size(NamedTuple): return NotImplemented def contains(self, x: int, y: int) -> bool: - """Check if a point is in the size. + """Check if a point is in area defined by the size. Args: - x (int): X coordinate (column) - y (int): Y coordinate (row) + x (int): X coordinate. + y (int): Y coordinate. Returns: bool: True if the point is within the region. @@ -187,7 +197,7 @@ class Size(NamedTuple): return width > x >= 0 and height > y >= 0 def contains_point(self, point: tuple[int, int]) -> bool: - """Check if a point is in the size. + """Check if a point is in the area defined by the size. Args: point (tuple[int, int]): A tuple of x and y coordinates. @@ -230,13 +240,13 @@ class Region(NamedTuple): """ x: int = 0 - """Offset in the x-axis (horizontal)""" + """Offset in the x-axis (horizontal).""" y: int = 0 - """Offset in the y-axis (vertical)""" + """Offset in the y-axis (vertical).""" width: int = 0 - """The width of the region""" + """The width of the region.""" height: int = 0 - """The height of the region""" + """The height of the region.""" @classmethod def from_union( @@ -268,10 +278,10 @@ class Region(NamedTuple): """Construct a Region form the top left and bottom right corners. Args: - x1 (int): Top left x - y1 (int): Top left y - x2 (int): Bottom right x - y2 (int): Bottom right y + x1 (int): Top left x. + y1 (int): Top left y. + x2 (int): Bottom right x. + y2 (int): Bottom right y. Returns: Region: A new region. @@ -283,7 +293,7 @@ class Region(NamedTuple): """Create a region from offset and size. Args: - offset (Point): Offset (top left point) + offset (Point): Offset (top left point). size (tuple[int, int]): Dimensions of region. Returns: @@ -372,7 +382,7 @@ class Region(NamedTuple): """Maximum X value (non inclusive). Returns: - int: x coordinate + int: x coordinate. """ return self.x + self.width @@ -382,7 +392,7 @@ class Region(NamedTuple): """Maximum Y value (non inclusive). Returns: - int: y coordinate + int: y coordinate. """ return self.y + self.height @@ -455,7 +465,7 @@ class Region(NamedTuple): """Get the top left and bottom right coordinates as a tuple of integers. Returns: - tuple[int, int, int, int]: A tuple of `(, , , )` + tuple[int, int, int, int]: A tuple of `(, , , )`. """ x, y, width, height = self return x, y, x + width, y + height @@ -560,8 +570,8 @@ class Region(NamedTuple): """Check if a point is in the region. Args: - x (int): X coordinate (column) - y (int): Y coordinate (row) + x (int): X coordinate. + y (int): Y coordinate. Returns: bool: True if the point is within the region. @@ -653,7 +663,7 @@ class Region(NamedTuple): """Grow a region by adding spacing. Args: - margin (Spacing): Defines how many cells to grow the Region by at each edge. + margin (tuple[int, int, in, int]): Grow space by `(, , , )`. Returns: Region: New region. @@ -672,7 +682,7 @@ class Region(NamedTuple): """Shrink a region by subtracting spacing. Args: - margin (Spacing): Defines how many cells to shrink the Region by at each edge. + margin (tuple[int, int, int, int]): Shrink space by `(, , , )`. Returns: Region: The new, smaller region. @@ -736,14 +746,14 @@ class Region(NamedTuple): ``` cut_x ↓ - ┌────────┐┌───┐ - │ ││ │ - │ 0 ││ 1 │ - │ ││ │ - cut_y → └────────┘└───┘ - ┌────────┐┌───┐ - │ 2 ││ 3 │ - └────────┘└───┘ + ┌────────┐ ┌───┐ + │ │ │ │ + │ 0 │ │ 1 │ + │ │ │ │ + cut_y → └────────┘ └───┘ + ┌────────┐ ┌───┐ + │ 2 │ │ 3 │ + └────────┘ └───┘ ``` Args: @@ -847,33 +857,64 @@ class Spacing(NamedTuple): @property def width(self) -> int: - """Total space in width.""" + """Total space in width. + + Returns: + int: Width. + + """ return self.left + self.right @property def height(self) -> int: - """Total space in height.""" + """Total space in height. + + Returns: + int: Height. + + """ return self.top + self.bottom @property def top_left(self) -> tuple[int, int]: - """Top left space.""" + """Top left space. + + Returns: + tuple[int, int]: `(, )` + + """ return (self.left, self.top) @property def bottom_right(self) -> tuple[int, int]: - """Bottom right space.""" + """Bottom right space. + + Returns: + tuple[int, int]: `(, )` + + """ return (self.right, self.bottom) @property def totals(self) -> tuple[int, int]: - """Returns a tuple of (, ).""" + """Get total horizontal and vertical space. + + Returns: + tuple[int, int]: `(, )` + + + """ top, right, bottom, left = self return (left + right, top + bottom) @property def css(self) -> str: - """Gets a string containing the spacing in CSS format.""" + """Gets a string containing the spacing in CSS format. + + Returns: + str: Spacing in CSS format. + + """ top, right, bottom, left = self if top == right == bottom == left: return f"{top}" @@ -920,7 +961,7 @@ class Spacing(NamedTuple): amount (int): The magnitude of spacing to apply to vertical edges Returns: - Spacing: ``Spacing(amount, 0, amount, 0)`` + Spacing: `Spacing(amount, 0, amount, 0)` """ return Spacing(amount, 0, amount, 0) @@ -933,7 +974,7 @@ class Spacing(NamedTuple): amount (int): The magnitude of spacing to apply to horizontal edges Returns: - Spacing: ``Spacing(0, amount, 0, amount)`` + Spacing: `Spacing(0, amount, 0, amount)` """ return Spacing(0, amount, 0, amount) @@ -945,7 +986,7 @@ class Spacing(NamedTuple): amount (int): The magnitude of spacing to apply to all edges Returns: - Spacing: ``Spacing(amount, amount, amount, amount)`` + Spacing: `Spacing(amount, amount, amount, amount)` """ return Spacing(amount, amount, amount, amount) From f4d5ebb37fa501408ccf30b7b176619e2c18d81f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 15:24:04 +0100 Subject: [PATCH 45/73] doc update --- poetry.lock | 2 +- src/textual/color.py | 5 +---- src/textual/geometry.py | 6 ++++-- src/textual/widget.py | 1 - 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0e2803c6b..0ed06bef4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -345,7 +345,7 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "8.3.9" +version = "8.4.1" description = "Documentation that simply works" category = "dev" optional = false diff --git a/src/textual/color.py b/src/textual/color.py index 383ba21c3..200414c2d 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -1,13 +1,10 @@ """ Manages Color in Textual. -All instances where the developer is presented with a color should use this class. The only +All instances where the developer is presented with a color will use this class. The only exception should be when passing things to a Rich renderable, which will need to use the `rich_color` attribute to perform a conversion. -I'm not entirely happy with burdening the user with two similar color classes. In a future -update we might add a protocol to convert automatically so the dev could use them interchangeably. - """ from __future__ import annotations diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 4820edf47..b7c6e535c 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -357,12 +357,13 @@ class Region(NamedTuple): @property def column_span(self) -> tuple[int, int]: - """Get the start and end column (x coord). + """Get the start and end columns (x coord). The end value is exclusive. Returns: tuple[int, int]: Pair of x coordinates (column numbers). + """ return (self.x, self.x + self.width) @@ -374,6 +375,7 @@ class Region(NamedTuple): Returns: tuple[int, int]: Pair of y coordinates (line numbers). + """ return (self.y, self.y + self.height) @@ -402,7 +404,7 @@ class Region(NamedTuple): """Get the area within the region. Returns: - int: area. + int: Area covered by this region. """ return self.width * self.height diff --git a/src/textual/widget.py b/src/textual/widget.py index f53955e55..83d223710 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -12,7 +12,6 @@ from typing import ( ClassVar, Collection, Iterable, - Iterator, NamedTuple, ) From a50ffe896a8bad81c8e4ee59e76294854e176412 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 16:03:22 +0100 Subject: [PATCH 46/73] docstrings --- src/textual/color.py | 8 +-- src/textual/events.py | 107 +++++++++++++++++++--------------------- src/textual/geometry.py | 2 +- 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/src/textual/color.py b/src/textual/color.py index 200414c2d..fd420e17c 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -9,9 +9,9 @@ exception should be when passing things to a Rich renderable, which will need to from __future__ import annotations -from colorsys import rgb_to_hls, hls_to_rgb -from functools import lru_cache import re +from colorsys import hls_to_rgb, rgb_to_hls +from functools import lru_cache from operator import itemgetter from typing import Callable, NamedTuple @@ -23,12 +23,12 @@ from rich.style import Style from rich.text import Text from textual.css.scalar import percentage_string_to_float -from textual.css.tokenize import COMMA, OPEN_BRACE, CLOSE_BRACE, DECIMAL, PERCENT +from textual.css.tokenize import CLOSE_BRACE, COMMA, DECIMAL, OPEN_BRACE, PERCENT from textual.suggestions import get_suggestion + from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp - _TRUECOLOR = ColorType.TRUECOLOR diff --git a/src/textual/events.py b/src/textual/events.py index 330436b81..f01c62142 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -1,15 +1,14 @@ from __future__ import annotations -from typing import Awaitable, Callable, Type, TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Awaitable, Callable, Type, TypeVar import rich.repr from rich.style import Style -from . import log -from .geometry import Offset, Size -from .message import Message from ._types import MessageTarget -from .keys import Keys, KEY_VALUES +from .geometry import Offset, Size +from .keys import KEY_VALUES, Keys +from .message import Message MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") @@ -85,7 +84,14 @@ class Action(Event): class Resize(Event, verbosity=2, bubble=False): - """Sent when the app or widget has been resized.""" + """Sent when the app or widget has been resized. + Args: + sender (MessageTarget): The sender of the event (the Screen). + size (Size): The new size of the Widget. + virtual_size (Size): The virtual size (scrollable size) of the Widget. + container_size (Size | None, optional): The size of the Widget's container widget. Defaults to None. + + """ __slots__ = ["size", "virtual_size", "container_size"] @@ -96,14 +102,6 @@ class Resize(Event, verbosity=2, bubble=False): virtual_size: Size, container_size: Size | None = None, ) -> None: - """ - - Args: - sender (MessageTarget): The sender of the event (the Screen). - size (Size): The new size of the Widget. - virtual_size (Size): The virtual size (scrollable size) of the Widget. - container_size (Size | None, optional): The size of the Widget's container widget. Defaults to None. - """ self.size = size self.virtual_size = virtual_size self.container_size = size if container_size is None else container_size @@ -153,15 +151,14 @@ class MouseCapture(Event, bubble=False): When a mouse has been captures, all further mouse events will be sent to the capturing widget. + + Args: + sender (MessageTarget): The sender of the event, (in this case the app). + mouse_position (Point): The position of the mouse when captured. + """ def __init__(self, sender: MessageTarget, mouse_position: Offset) -> None: - """ - - Args: - sender (MessageTarget): The sender of the event, (in this case the app). - mouse_position (Point): The position of the mouse when captured. - """ super().__init__(sender) self.mouse_position = mouse_position @@ -171,14 +168,14 @@ class MouseCapture(Event, bubble=False): @rich.repr.auto class MouseRelease(Event, bubble=False): - """Mouse has been released.""" + """Mouse has been released. + + Args: + sender (MessageTarget): The sender of the event, (in this case the app). + mouse_position (Point): The position of the mouse when released. + """ def __init__(self, sender: MessageTarget, mouse_position: Offset) -> None: - """ - Args: - sender (MessageTarget): The sender of the event, (in this case the app). - mouse_position (Point): The position of the mouse when released. - """ super().__init__(sender) self.mouse_position = mouse_position @@ -192,17 +189,16 @@ class InputEvent(Event): @rich.repr.auto class Key(InputEvent): - """Sent when the user hits a key on the keyboard""" + """Sent when the user hits a key on the keyboard. + + Args: + sender (MessageTarget): The sender of the event (the App) + key (str): The pressed key if a single character (or a longer string for special characters) + """ __slots__ = ["key"] def __init__(self, sender: MessageTarget, key: str) -> None: - """ - - Args: - sender (MessageTarget): The sender of the event (the App) - key (str): The pressed key if a single character (or a longer string for special characters) - """ super().__init__(sender) self.key = key.value if isinstance(key, Keys) else key @@ -215,14 +211,30 @@ class Key(InputEvent): isn't defined in key bindings is printable. Returns: - bool: True if the key is printable. False otherwise. + bool: True if the key is printable. """ return self.key == Keys.Space or self.key not in KEY_VALUES @rich.repr.auto class MouseEvent(InputEvent, bubble=True, verbosity=2): - """Sent in response to a mouse event""" + """Sent in response to a mouse event. + + Args: + sender (MessageTarget): The sender of the event. + x (int): The relative x coordinate. + y (int): The relative y coordinate. + delta_x (int): Change in x since the last message. + delta_y (int): Change in y since the last message. + button (int): Indexed of the pressed button. + shift (bool): True if the shift key is pressed. + meta (bool): True if the meta key is pressed. + ctrl (bool): True if the ctrl key is pressed. + screen_x (int, optional): The absolute x coordinate. + screen_y (int, optional): The absolute y coordinate. + style (Style, optional): The Rich Style under the mouse cursor. + + """ __slots__ = [ "x", @@ -253,22 +265,6 @@ class MouseEvent(InputEvent, bubble=True, verbosity=2): screen_y: int | None = None, style: Style | None = None, ) -> None: - """ - - Args: - sender (MessageTarget): The sender of the event. - x (int): The relative x coordinate. - y (int): The relative y coordinate. - delta_x (int): Change in x since the last message. - delta_y (int): Change in y since the last message. - button (int): Indexed of the pressed button. - shift (bool): True if the shift key is pressed. - meta (bool): True if the meta key is pressed. - ctrl (bool): True if the ctrl key is pressed. - screen_x (int, optional): The absolute x coordinate. - screen_y (int, optional): The absolute y coordinate. - style (Style, optional): The Rich Style under the mouse cursor. - """ super().__init__(sender) self.x = x self.y = y @@ -424,14 +420,13 @@ class Paste(Event, bubble=False): This event will only appear when running in a terminal emulator that supports bracketed paste mode. Textual will enable bracketed pastes when an app starts, and disable it when the app shuts down. + + Args: + sender (MessageTarget): The sender of the event, (in this case the app). + text: The text that has been pasted. """ def __init__(self, sender: MessageTarget, text: str) -> None: - """ - Args: - sender (MessageTarget): The sender of the event, (in this case the app). - text: The text that has been pasted - """ super().__init__(sender) self.text = text diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b7c6e535c..537932cfc 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -8,7 +8,7 @@ from __future__ import annotations import sys from functools import lru_cache -from operator import itemgetter, attrgetter +from operator import attrgetter, itemgetter from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast if sys.version_info >= (3, 10): From 7df1c123e9fbc8641052a30ba74282f9d9ec1870 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 16:25:51 +0100 Subject: [PATCH 47/73] docstrings --- src/textual/app.py | 57 ++++++++++++++++++++++++++----------- src/textual/file_monitor.py | 4 +++ src/textual/message.py | 13 ++++----- src/textual/screen.py | 12 ++++---- 4 files changed, 56 insertions(+), 30 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 04a9dc62d..972fddbb2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -132,7 +132,17 @@ class _NullFile: @rich.repr.auto class App(Generic[ReturnType], DOMNode): - """The base class for Textual Applications""" + """The base class for Textual Applications. + + Args: + driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. + log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "". + log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1. + title (str, optional): Default title of the application. Defaults to "Textual Application". + css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. + watch_css (bool, optional): Watch CSS for changes. Defaults to False. + + """ CSS = """ App { @@ -157,16 +167,6 @@ class App(Generic[ReturnType], DOMNode): css_path: str | PurePath | None = None, watch_css: bool = False, ): - """Textual application base class - - Args: - driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. - log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "". - log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1. - title (str, optional): Default title of the application. Defaults to "Textual Application". - css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. - watch_css (bool, optional): Watch CSS for changes. Defaults to False. - """ # N.B. This must be done *before* we call the parent constructor, because MessagePump's # constructor instantiates a `asyncio.PriorityQueue` and in Python versions older than 3.10 # this will create some first references to an asyncio loop. @@ -255,22 +255,42 @@ class App(Generic[ReturnType], DOMNode): @property def devtools_enabled(self) -> bool: - """Check if devtools are enabled.""" + """Check if devtools are enabled. + + Returns: + bool: True if devtools are enabled. + + """ return "devtools" in self.features @property def debug(self) -> bool: - """Check if debug mode is enabled.""" + """Check if debug mode is enabled. + + Returns: + bool: True if debug mode is enabled. + + """ return "debug" in self.features @property def is_headless(self) -> bool: - """Check if the app is running in 'headless' mode.""" + """Check if the app is running in 'headless' mode. + + Returns: + bool: True if the app is in headless mode. + + """ return "headless" in self.features @property def screen_stack(self) -> list[Screen]: - """Get a *copy* of the screen stack.""" + """Get a *copy* of the screen stack. + + Returns: + list[Screen]: List of screens. + + """ return self._screen_stack.copy() def exit(self, result: ReturnType | None = None) -> None: @@ -284,7 +304,12 @@ class App(Generic[ReturnType], DOMNode): @property def focus_chain(self) -> list[Widget]: - """Get widgets that may receive focus, in focus order.""" + """Get widgets that may receive focus, in focus order. + + Returns: + list[Widget]: List of Widgets in focus order. + + """ widgets: list[Widget] = [] add_widget = widgets.append root = self.screen diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py index 4b20e2981..58db93470 100644 --- a/src/textual/file_monitor.py +++ b/src/textual/file_monitor.py @@ -10,15 +10,19 @@ from ._callback import invoke @rich.repr.auto class FileMonitor: + """Monitors a file for changes and invokes a callback when it does.""" + def __init__(self, path: str | PurePath, callback: Callable) -> None: self.path = path self.callback = callback self._modified = self._get_modified() def _get_modified(self) -> float: + """Get the modified time for a file being watched.""" return os.stat(self.path).st_mtime def check(self) -> bool: + """Check the monitored file. Return True if it was changed.""" modified = self._get_modified() changed = modified != self._modified self._modified = modified diff --git a/src/textual/message.py b/src/textual/message.py index 0e0692171..869c85ac8 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -11,7 +11,12 @@ from ._types import MessageTarget @rich.repr.auto class Message: - """Base class for a message.""" + """Base class for a message. + + Args: + sender (MessageTarget): The sender of the message / event. + + """ __slots__ = [ "sender", @@ -30,12 +35,6 @@ class Message: namespace: ClassVar[str] = "" # Namespace to disambiguate messages def __init__(self, sender: MessageTarget) -> None: - """ - - Args: - sender (MessageTarget): The sender of the message / event. - """ - self.sender = sender self.name = camel_to_snake(self.__class__.__name__.replace("Message", "")) self.time = _clock.get_time_no_wait() diff --git a/src/textual/screen.py b/src/textual/screen.py index 28f986a2d..626b9290d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -2,24 +2,22 @@ from __future__ import annotations import sys -from rich.console import RenderableType import rich.repr +from rich.console import RenderableType from rich.style import Style - -from . import events, messages, errors +from . import errors, events, messages from ._callback import invoke - -from .geometry import Offset, Region, Size from ._compositor import Compositor, MapGeometry +from ._timer import Timer from ._types import CallbackType +from .geometry import Offset, Region, Size from .reactive import Reactive from .renderables.blank import Blank -from ._timer import Timer from .widget import Widget if sys.version_info >= (3, 8): - from typing import Final, Callable, Awaitable + from typing import Final else: from typing_extensions import Final From ee03e9ddd102f8d019223a5daa9f9ff0aa302e28 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 20:22:22 +0100 Subject: [PATCH 48/73] fancier docs --- docs/index.md | 56 ++++++- docs/introduction.md | 17 +-- mkdocs.yml | 6 + poetry.lock | 338 ++++++------------------------------------- 4 files changed, 110 insertions(+), 307 deletions(-) diff --git a/docs/index.md b/docs/index.md index 61444695c..a3fdfc8ea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,17 +4,57 @@ Welcome to the [Textual](https://github.com/Textualize/textual) framework docume
-Textual is a Python framework for building applications that run within your terminal. +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. -Text User Interfaces (TUIs) have a number of benefits: +
-- **Quick to develop:** Really rapid app development with a modern Python API. -- **Low requirements:** Textual apps anywhere with a Python interpreter, even single-board computers. -- **Cross platform:** The same code will run on Linux, Windows, MacOS and more. -- **Remote:** Fully featured UIs can run over SSH. -- **CLI integration:** Textual apps integrate with your shell and other CLI tools. +- :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. + + +
-Textual TUIs are quick and easy to build with pure Python (not to mention _fun_).
diff --git a/docs/introduction.md b/docs/introduction.md index 5d865ef7a..d1b09c0a6 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -35,6 +35,10 @@ python stopwatch.py ## Type hints (in brief) +!!! tip inline end + + Type hints are entirely optional in Textual. We've included them in the example code but it's up to you wether 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, its 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: @@ -49,10 +53,6 @@ def repeat(text: str, count: int) -> str: - Return types follow `->`. So `-> str:` indicates that this method returns a string. -!!! note - - Type hints are entirely optional in Textual. We've included them in the example code but it's up to you wether you add them to your own projects. - ## The App class 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. @@ -175,14 +175,13 @@ self.styles.background = "blue" self.styles.color = "white" ``` -While its possible to set all styles for an app this way, Textual prefers to use CSS. - -CSS files are data files loaded by your app which contain information about styles to apply to your widgets. - -!!! note +!!! info inline end 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! + +While its 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. + Let's add a CSS file to our application. ```python title="stopwatch03.py" hl_lines="39" diff --git a/mkdocs.yml b/mkdocs.yml index c9b58064b..70f47dec4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,9 +71,15 @@ nav: markdown_extensions: + - attr_list + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + - md_in_html - admonition - def_list - meta + - toc: permalink: true baselevel: 1 diff --git a/poetry.lock b/poetry.lock index 0ed06bef4..1871dd8ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -60,17 +60,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.4.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "black" @@ -113,7 +113,7 @@ python-versions = ">=3.6.1" [[package]] name = "charset-normalizer" -version = "2.1.0" +version = "2.1.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -155,7 +155,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "6.4.1" +version = "6.4.4" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -166,7 +166,7 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.4" +version = "0.3.5" description = "Distribution utilities" category = "dev" optional = false @@ -174,19 +174,19 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.7.1" +version = "3.8.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" [package.extras] -docs = ["furo (>=2021.8.17b43)", "sphinx (>=4.1)", "sphinx-autodoc-typehints (>=1.12)"] -testing = ["covdefaults (>=1.2.0)", "coverage (>=4)", "pytest (>=4)", "pytest-cov", "pytest-timeout (>=1.4.2)"] +docs = ["furo (>=2022.6.21)", "sphinx (>=5.1.1)", "sphinx-autodoc-typehints (>=1.19.1)"] +testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pytest-cov (>=3)", "pytest-timeout (>=2.1)"] [[package]] name = "frozenlist" -version = "1.3.0" +version = "1.3.1" description = "A list-like structure which implements collections.abc.MutableSequence" category = "main" optional = false @@ -222,7 +222,7 @@ async = ["aiofiles (>=0.7,<1.0)"] [[package]] name = "identify" -version = "2.5.1" +version = "2.5.3" description = "File identification library for Python" category = "dev" optional = false @@ -310,7 +310,7 @@ python-versions = ">=3.6" [[package]] name = "mkdocs" -version = "1.3.0" +version = "1.3.1" description = "Project documentation with Markdown." category = "dev" optional = false @@ -321,7 +321,7 @@ click = ">=3.3" ghp-import = ">=1.0" importlib-metadata = ">=4.3" Jinja2 = ">=2.10.2" -Markdown = ">=3.2.1" +Markdown = ">=3.2.1,<3.4" mergedeep = ">=1.3.4" packaging = ">=20.5" PyYAML = ">=3.10" @@ -533,12 +533,15 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pygments" -version = "2.12.0" +version = "2.13.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false python-versions = ">=3.6" +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pymdown-extensions" version = "9.5" @@ -601,7 +604,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"] [[package]] name = "pytest-asyncio" -version = "0.18.3" +version = "0.19.0" description = "Pytest support for asyncio" category = "dev" optional = false @@ -612,7 +615,7 @@ pytest = ">=6.1.0" typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] -testing = ["coverage (==6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (==0.931)", "pytest-trio (>=0.7.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)", "flaky (>=3.5.0)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] [[package]] name = "pytest-cov" @@ -686,7 +689,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "time-machine" -version = "2.7.1" +version = "2.8.1" description = "Travel through time in your tests." category = "dev" optional = false @@ -729,22 +732,21 @@ python-versions = ">=3.7" [[package]] name = "virtualenv" -version = "20.15.1" +version = "20.16.3" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.dependencies] -distlib = ">=0.3.1,<1" -filelock = ">=3.2,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -platformdirs = ">=2,<3" -six = ">=1.9.0,<2" +distlib = ">=0.3.5,<1" +filelock = ">=3.4.1,<4" +importlib-metadata = {version = ">=4.8.3", markers = "python_version < \"3.8\""} +platformdirs = ">=2.4,<3" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +docs = ["proselint (>=0.13)", "sphinx (>=5.1.1)", "sphinx-argparse (>=0.3.1)", "sphinx-rtd-theme (>=1)", "towncrier (>=21.9)"] +testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] [[package]] name = "watchdog" @@ -759,11 +761,11 @@ watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "yarl" -version = "1.7.2" +version = "1.8.1" description = "Yet another URL library" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] idna = ">=2.0" @@ -772,15 +774,15 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" -version = "3.8.0" +version = "3.8.1" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] [extras] dev = ["aiohttp", "click", "msgpack"] @@ -878,10 +880,7 @@ asynctest = [ {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] atomicwrites = [] -attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, -] +attrs = [] black = [ {file = "black-22.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f586c26118bc6e714ec58c09df0157fe2d9ee195c764f630eb0d8e7ccce72e69"}, {file = "black-22.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b270a168d69edb8b7ed32c193ef10fd27844e5c60852039599f9184460ce0807"}, @@ -915,10 +914,7 @@ cfgv = [ {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, ] -charset-normalizer = [ - {file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"}, - {file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"}, -] +charset-normalizer = [] click = [ {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, @@ -931,127 +927,16 @@ commonmark = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] -coverage = [ - {file = "coverage-6.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b"}, - {file = "coverage-6.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84"}, - {file = "coverage-6.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749"}, - {file = "coverage-6.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4"}, - {file = "coverage-6.4.1-cp310-cp310-win32.whl", hash = "sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df"}, - {file = "coverage-6.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6"}, - {file = "coverage-6.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4"}, - {file = "coverage-6.4.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3"}, - {file = "coverage-6.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6"}, - {file = "coverage-6.4.1-cp37-cp37m-win32.whl", hash = "sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e"}, - {file = "coverage-6.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54"}, - {file = "coverage-6.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9"}, - {file = "coverage-6.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428"}, - {file = "coverage-6.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83"}, - {file = "coverage-6.4.1-cp38-cp38-win32.whl", hash = "sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b"}, - {file = "coverage-6.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df"}, - {file = "coverage-6.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3"}, - {file = "coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72"}, - {file = "coverage-6.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264"}, - {file = "coverage-6.4.1-cp39-cp39-win32.whl", hash = "sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9"}, - {file = "coverage-6.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397"}, - {file = "coverage-6.4.1-pp36.pp37.pp38-none-any.whl", hash = "sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815"}, - {file = "coverage-6.4.1.tar.gz", hash = "sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c"}, -] -distlib = [ - {file = "distlib-0.3.4-py2.py3-none-any.whl", hash = "sha256:6564fe0a8f51e734df6333d08b8b94d4ea8ee6b99b5ed50613f731fd4089f34b"}, - {file = "distlib-0.3.4.zip", hash = "sha256:e4b58818180336dc9c529bfb9a0b58728ffc09ad92027a3f30b7cd91e3458579"}, -] -filelock = [ - {file = "filelock-3.7.1-py3-none-any.whl", hash = "sha256:37def7b658813cda163b56fc564cdc75e86d338246458c4c28ae84cabefa2404"}, - {file = "filelock-3.7.1.tar.gz", hash = "sha256:3a0fd85166ad9dbab54c9aec96737b744106dc5f15c0b09a6744a445299fcf04"}, -] -frozenlist = [ - {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"}, - {file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"}, - {file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"}, - {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"}, - {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"}, - {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"}, - {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"}, - {file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"}, - {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"}, - {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"}, - {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"}, - {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"}, - {file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"}, - {file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"}, - {file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"}, - {file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"}, - {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"}, - {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"}, - {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"}, - {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"}, - {file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"}, - {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"}, - {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"}, - {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"}, - {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"}, - {file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"}, - {file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"}, - {file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"}, - {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"}, - {file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"}, - {file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"}, - {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"}, - {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"}, - {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"}, - {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"}, - {file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"}, - {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"}, - {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"}, - {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"}, - {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"}, - {file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"}, - {file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"}, - {file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"}, - {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"}, - {file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"}, - {file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"}, - {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"}, - {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"}, - {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"}, - {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"}, - {file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"}, - {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"}, - {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"}, - {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"}, - {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"}, - {file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"}, - {file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"}, - {file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"}, - {file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"}, -] +coverage = [] +distlib = [] +filelock = [] +frozenlist = [] ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, ] griffe = [] -identify = [ - {file = "identify-2.5.1-py2.py3-none-any.whl", hash = "sha256:0dca2ea3e4381c435ef9c33ba100a78a9b40c0bab11189c7cf121f75815efeaa"}, - {file = "identify-2.5.1.tar.gz", hash = "sha256:3d11b16f3fe19f52039fb7e39c9c884b21cb1b586988114fbe42671f03de3e82"}, -] +identify = [] idna = [ {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, @@ -1118,10 +1003,7 @@ mergedeep = [ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] -mkdocs = [ - {file = "mkdocs-1.3.0-py3-none-any.whl", hash = "sha256:26bd2b03d739ac57a3e6eed0b7bcc86168703b719c27b99ad6ca91dc439aacde"}, - {file = "mkdocs-1.3.0.tar.gz", hash = "sha256:b504405b04da38795fec9b2e5e28f6aa3a73bb0960cb6d5d27ead28952bd35ea"}, -] +mkdocs = [] mkdocs-autorefs = [ {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, @@ -1309,10 +1191,7 @@ py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] -pygments = [ - {file = "Pygments-2.12.0-py3-none-any.whl", hash = "sha256:dc9c10fb40944260f6ed4c688ece0cd2048414940f1cea51b8b226318411c519"}, - {file = "Pygments-2.12.0.tar.gz", hash = "sha256:5eb116118f9612ff1ee89ac96437bb6b49e8f04d8a13b514ba26f620208e26eb"}, -] +pygments = [] pymdown-extensions = [ {file = "pymdown_extensions-9.5-py3-none-any.whl", hash = "sha256:ec141c0f4983755349f0c8710416348d1a13753976c028186ed14f190c8061c4"}, {file = "pymdown_extensions-9.5.tar.gz", hash = "sha256:3ef2d998c0d5fa7eb09291926d90d69391283561cf6306f85cd588a5eb5befa0"}, @@ -1329,11 +1208,7 @@ pytest-aiohttp = [ {file = "pytest-aiohttp-1.0.4.tar.gz", hash = "sha256:39ff3a0d15484c01d1436cbedad575c6eafbf0f57cdf76fb94994c97b5b8c5a4"}, {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"}, ] -pytest-asyncio = [ - {file = "pytest-asyncio-0.18.3.tar.gz", hash = "sha256:7659bdb0a9eb9c6e3ef992eef11a2b3e69697800ad02fb06374a210d85b29f91"}, - {file = "pytest_asyncio-0.18.3-1-py3-none-any.whl", hash = "sha256:16cf40bdf2b4fb7fc8e4b82bd05ce3fbcd454cbf7b92afc445fe299dabb88213"}, - {file = "pytest_asyncio-0.18.3-py3-none-any.whl", hash = "sha256:8fafa6c52161addfd41ee7ab35f11836c5a16ec208f93ee388f752bea3493a84"}, -] +pytest-asyncio = [] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, @@ -1386,48 +1261,7 @@ six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] -time-machine = [ - {file = "time-machine-2.7.1.tar.gz", hash = "sha256:be6c1f0421a77a046db8fae00886fb364f683a86612b71dd5c74b22891590042"}, - {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ae93d2f761435d192bc80c148438a0c4261979db0610cef08dfe2c8d21ca1c67"}, - {file = "time_machine-2.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:342b431154fbfb1889f8d7aa3d857373a837106bba395a5cc99123f11a7cea03"}, - {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4011ea76f6ad2f932f00cf9e77a25b575a024d6bc15bcf891a3f9916ceeb6e"}, - {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43ae8192d370a90d2246fca565a55633f592b314264c65c5c9151c361b715fb9"}, - {file = "time_machine-2.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cea12d0592ebbe738db952ce6fd272ed90e7bbb095e802f4f2145f8f0e322fa3"}, - {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:732d5fd2d442fa87538b5a6ca623cb205b9b048d2c9aaf79e5cfc7ec7f637848"}, - {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c34e1f49cad2fd41d42c4aabd3d69a32c79d9a8e0779064554843823cd1fb1e4"}, - {file = "time_machine-2.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6af3e81cf663b6d5660953ae59da2bb2ae802452ecbc9907272979ed06253659"}, - {file = "time_machine-2.7.1-cp310-cp310-win32.whl", hash = "sha256:10c2937d3556f4358205dac5c7cd2d33832b8b911f3deff050f59e1fe2be3231"}, - {file = "time_machine-2.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:200974e9bb8a1cb227ce579caafeaeebb0f9de81758c444cbccc0ea464313caf"}, - {file = "time_machine-2.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d5e2376b7922c9d96921709c7e730498b9c69da889f359a465d0c43117b62da3"}, - {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ad9117abe223cdc7b4a4432e0a0cfebb1b351a091ee996c653e90f27a734fce"}, - {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626ef686723147468e84da3edcd67ff757a463250fd35f8f6a8e5b899c43b43d"}, - {file = "time_machine-2.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9331946ed13acd50bc484f408e26b8eefa67e3dbca41927d2052f2148d3661d"}, - {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3d0612e0323047f29c23732963d9926f1a95e2ce334d86fecd37c803ac240fc6"}, - {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b474499ad0083252240bc5be13f8116cc2ca8a89d1ca4967ed74a7b5f0883f95"}, - {file = "time_machine-2.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7df0857709432585b62d2667c0e6e64029b652e2df776b9fb85223c60dce52c7"}, - {file = "time_machine-2.7.1-cp37-cp37m-win32.whl", hash = "sha256:77c8dfe8dc7f45bbfe73494c72f3728d99abec5a020460ad7ffee5247365eba4"}, - {file = "time_machine-2.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c1fd1c231377ce076f99c8c16999a95510690f8dbd35db0e5fbbc74a17f84b39"}, - {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:462924fb87826882fc7830098e621116599f9259d181a7bbf5a4e49f74ec325b"}, - {file = "time_machine-2.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:46bf3b4a52d43289b23f0015a9d8592ddf621a5058e566c275cb060347d430c1"}, - {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30afd5b978d8121334c80fa23119d7bd7c9f954169854edf5103e5c8b38358bb"}, - {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:633fb8c47f3cd64690591ca6981e4fdbcaa54c18d8a57a3cdc24638ca98f8216"}, - {file = "time_machine-2.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80b6093c3b70d1d1a66b65f18a6e53b233c8dd5d8ffe7ac59e9d048fb1d5e15c"}, - {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e62ed7d78694b7e0a2ab30b3dd52ebf26b03e17d6eda0f231fd77e24307a55a9"}, - {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0eaf024d16482ec211a579fd389cbbd4fedd8a1f0a0c41642508815f880ca3a9"}, - {file = "time_machine-2.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2688091ce0c16151faa80625efb34e3096731fbdee6d5284c48c984bce95c311"}, - {file = "time_machine-2.7.1-cp38-cp38-win32.whl", hash = "sha256:2e54bf0521b6e397fcaa03060feb187bbe5aa63ac51dbb97d5bc59fb0c4725f8"}, - {file = "time_machine-2.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:cee72d9e14d36e4b8da6af1d2d784f14da53f76aeb5066540a38318aa907b551"}, - {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:06322d41d45d86e2dc2520794c95129ff25b8620b33851ed40700c859ebf8c30"}, - {file = "time_machine-2.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:193b14daa3b3cf67e6b55d6e2d63c2eb7c1d3f49017704d4b43963b198656888"}, - {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367a89fb857f68cfa723e236cd47febaf201a3a625ad8423110fe0509d5fca8"}, - {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce350f7e8bd51a0bb064180486300283bec5cd1a21a318a8ffe5f7df11735f36"}, - {file = "time_machine-2.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68ff623d835760314e279aedc0d19a1dc4dec117c6bca388e1ff077c781256bd"}, - {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:05fecd818d41727d31109a0d039ce07c8311602b45ffc07bffd8ae8b6f266ee5"}, - {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1fe4e604c5effc290c1bbecd3ea98687690d0a88fd98ba93e0246bf19ae2a520"}, - {file = "time_machine-2.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff07a5635d42957f2bd7eb5ca6579f64de368c842e754a4d3414520693b75db9"}, - {file = "time_machine-2.7.1-cp39-cp39-win32.whl", hash = "sha256:8c6314e7e0ffd7af82c8026786d5551aff973e0c86ec1368b0590be9a7620cad"}, - {file = "time_machine-2.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:d50a2620d726788cbde97c58e0f6f61d10337d16d088a1fad789f50a1b5ff4d1"}, -] +time-machine = [] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, @@ -1491,81 +1325,5 @@ watchdog = [ {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"}, {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, ] -yarl = [ - {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"}, - {file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"}, - {file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"}, - {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"}, - {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"}, - {file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"}, - {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"}, - {file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"}, - {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"}, - {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"}, - {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"}, - {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"}, - {file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"}, - {file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"}, - {file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"}, - {file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"}, - {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"}, - {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"}, - {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"}, - {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"}, - {file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"}, - {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"}, - {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"}, - {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"}, - {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"}, - {file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"}, - {file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"}, - {file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"}, - {file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"}, - {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"}, - {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"}, - {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"}, - {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"}, - {file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"}, - {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"}, - {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"}, - {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"}, - {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"}, - {file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"}, - {file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"}, - {file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"}, - {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"}, - {file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"}, - {file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"}, - {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"}, - {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"}, - {file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"}, - {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"}, - {file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"}, - {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"}, - {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"}, - {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"}, - {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"}, - {file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"}, - {file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"}, - {file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"}, - {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"}, - {file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"}, - {file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"}, - {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"}, - {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"}, - {file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"}, - {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"}, - {file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"}, - {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"}, - {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"}, - {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"}, - {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"}, - {file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"}, - {file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"}, - {file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"}, - {file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"}, -] -zipp = [ - {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, - {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, -] +yarl = [] +zipp = [] From a13788572591f7ad25e5a8dc57eb1e74e38782f7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 20:42:14 +0100 Subject: [PATCH 49/73] clone instructions --- docs/introduction.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index d1b09c0a6..a17afbc0b 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -23,13 +23,32 @@ 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,_,_"} ``` -### Try the code +### Get the code -If you want to try this out before reading the rest of this introduction (we recommend it), navigate to "docs/examples/introduction" within the repository and run the following: +If you want to try the finished Stopwatch app and following along with the code first make sure you have [textual installed](getting_started.md) then check out the [Textual](https://github.com/Textualize/textual) GitHub repository: -**TODO**: instructions how to checkout repo +=== "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 +cd textual/docs/examples/introduction python stopwatch.py ``` From 77931d82ff2869f19d10b86375733d82d3290771 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 21:02:07 +0100 Subject: [PATCH 50/73] getting started --- docs/getting_started.md | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index de59399d5..5b5220806 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -1,8 +1,28 @@ +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 can install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. +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] @@ -16,8 +36,10 @@ pip install textual ## Textual CLI app -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. See the help for more details: +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](getting_started.md) for more about the `textual` command. From cc4427a4bd84f10d94924a226a5889f1bda7dcd7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 24 Aug 2022 21:09:09 +0100 Subject: [PATCH 51/73] fix link --- docs/getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index 5b5220806..de9f39d9e 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -42,4 +42,4 @@ If you installed the dev dependencies you have have access to the `textual` CLI textual --help ``` -See [devtools](getting_started.md) for more about the `textual` command. +See [devtools](guide/devtools.md) for more about the `textual` command. From b22436933acc0d7440ec300f971a249bd6105a5b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 09:08:35 +0100 Subject: [PATCH 52/73] lots of docstrings --- docs/getting_started.md | 4 +- docs/introduction.md | 6 +- docs/reference/message_pump.md | 5 + docs/reference/timer.md | 1 + mkdocs.yml | 2 + src/textual/_animator.py | 2 +- src/textual/_arrange.py | 4 +- src/textual/_node_list.py | 4 +- src/textual/app.py | 49 ++-- src/textual/css/stylesheet.py | 18 +- src/textual/dom.py | 6 +- src/textual/drivers/linux_driver.py | 2 +- src/textual/events.py | 4 +- src/textual/layouts/center.py | 2 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/vertical.py | 2 +- src/textual/message_pump.py | 84 ++++-- src/textual/screen.py | 2 +- src/textual/{_timer.py => timer.py} | 44 +-- src/textual/widget.py | 275 +++++++++++++++---- src/textual/widgets/text_input.py | 2 +- tests/css/test_styles.py | 2 +- tests/layouts/test_common_layout_features.py | 2 +- tests/test_dom.py | 6 +- tests/test_focus.py | 4 +- tests/test_query.py | 18 +- tests/utilities/test_app.py | 2 +- 27 files changed, 407 insertions(+), 147 deletions(-) create mode 100644 docs/reference/message_pump.md create mode 100644 docs/reference/timer.md rename src/textual/{_timer.py => timer.py} (76%) diff --git a/docs/getting_started.md b/docs/getting_started.md index de9f39d9e..0962c18c2 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -20,7 +20,7 @@ Textual requires Python 3.7 or later. Textual runs on Linux, MacOS, Windows and ## Installation -You can install Textual via PyPi. +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. @@ -34,7 +34,7 @@ If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: pip install textual ``` -## Textual CLI app +## 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. diff --git a/docs/introduction.md b/docs/introduction.md index a17afbc0b..a23b07188 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -45,7 +45,7 @@ If you want to try the finished Stopwatch app and following along with the code gh repo clone Textualize/textual ``` -With the repository cloned, navigate to `/docs/examples/introduction` and run `stopwatch.py`. +With the repository cloned, navigate to `docs/examples/introduction` and run `stopwatch.py`. ```bash cd textual/docs/examples/introduction @@ -69,7 +69,7 @@ def repeat(text: str, count: int) -> str: ``` - 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 that this method returns a string. +- Return types follow `->`. So `-> str:` indicates this method returns a string. ## The App class @@ -335,7 +335,7 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state ## Reactive attributes -A reoccurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call `refresh()` to display new data. However, Textual prefers to do this automatically via _reactive_ attributes. +A reoccurring 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. diff --git a/docs/reference/message_pump.md b/docs/reference/message_pump.md new file mode 100644 index 000000000..abd106c54 --- /dev/null +++ b/docs/reference/message_pump.md @@ -0,0 +1,5 @@ +A message pump is a class that processes messages. + +It is a base class for the App, Screen, and Widgets. + +::: textual.message_pump.MessagePump diff --git a/docs/reference/timer.md b/docs/reference/timer.md new file mode 100644 index 000000000..01b5fa1ac --- /dev/null +++ b/docs/reference/timer.md @@ -0,0 +1 @@ +::: textual.timer diff --git a/mkdocs.yml b/mkdocs.yml index 70f47dec4..5d17dd154 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,8 @@ nav: - "reference/dom_node.md" - "reference/events.md" - "reference/geometry.md" + - "reference/message_pump.md" + - "reference/timer.md" - "reference/widget.md" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 198c0e266..f20d5a650 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -10,7 +10,7 @@ from dataclasses import dataclass from . import _clock from ._callback import invoke from ._easing import DEFAULT_EASING, EASING -from ._timer import Timer +from .timer import Timer from ._types import MessageTarget, CallbackType if sys.version_info >= (3, 8): diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 984a32c28..33c37b513 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -64,7 +64,7 @@ def arrange( fraction_unit = Fraction( size.height if edge in ("top", "bottom") else size.width ) - box_model = dock_widget.get_box_model(size, viewport, fraction_unit) + box_model = dock_widget._get_box_model(size, viewport, fraction_unit) widget_width_fraction, widget_height_fraction, margin = box_model widget_width = int(widget_width_fraction) + margin.width @@ -98,7 +98,7 @@ def arrange( dock_spacing = Spacing(top, right, bottom, left) region = size.region.shrink(dock_spacing) - layout_placements, arranged_layout_widgets = widget.layout.arrange( + layout_placements, arranged_layout_widgets = widget._layout.arrange( widget, layout_widgets, region.size ) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 8bfcd584d..fa5570fc4 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterator, overload, TYPE_CHECKING +from typing import TYPE_CHECKING, Iterator, Sequence, overload import rich.repr @@ -9,7 +9,7 @@ if TYPE_CHECKING: @rich.repr.auto(angular=True) -class NodeList: +class NodeList(Sequence): """ A container for widgets that forms one level of hierarchy. diff --git a/src/textual/app.py b/src/textual/app.py index 972fddbb2..3cfd592a4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -300,7 +300,7 @@ class App(Generic[ReturnType], DOMNode): result (ReturnType | None, optional): Return value. Defaults to None. """ self._return_value = result - self.close_messages_no_wait() + self._close_messages_no_wait() @property def focus_chain(self) -> list[Widget]: @@ -458,6 +458,14 @@ class App(Generic[ReturnType], DOMNode): @property def screen(self) -> Screen: + """Get the current screen. + + Raises: + ScreenStackError: If there are no screens on the stack. + + Returns: + Screen: The currently active screen. + """ try: return self._screen_stack[-1] except IndexError: @@ -465,6 +473,11 @@ class App(Generic[ReturnType], DOMNode): @property def size(self) -> Size: + """Get the size of the terminal. + + Returns: + Size: SIze of the terminal + """ return Size(*self.console.size) def log( @@ -640,9 +653,9 @@ class App(Generic[ReturnType], DOMNode): """Press some keys in the background.""" asyncio.create_task(press_keys()) - await self.process_messages(ready_callback=press_keys_task) + await self._process_messages(ready_callback=press_keys_task) else: - await self.process_messages() + await self._process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: @@ -971,7 +984,7 @@ class App(Generic[ReturnType], DOMNode): ] self._exit_renderables.extend(pre_rendered) - self.close_messages_no_wait() + self._close_messages_no_wait() def on_exception(self, error: Exception) -> None: """Called with an unhandled exception. @@ -996,14 +1009,14 @@ class App(Generic[ReturnType], DOMNode): self._exit_renderables.append( Segments(self.console.render(traceback, self.console.options)) ) - self.close_messages_no_wait() + self._close_messages_no_wait() def _print_error_renderables(self) -> None: for renderable in self._exit_renderables: self.error_console.print(renderable) self._exit_renderables.clear() - async def process_messages( + async def _process_messages( self, ready_callback: CallbackType | None = None ) -> None: self._set_active() @@ -1038,11 +1051,11 @@ class App(Generic[ReturnType], DOMNode): self.set_interval(0.5, self.css_monitor, name="css monitor") self.log("[b green]STARTED[/]", self.css_monitor) - process_messages = super().process_messages + process_messages = super()._process_messages async def run_process_messages(): mount_event = events.Mount(sender=self) - await self.dispatch_message(mount_event) + await self._dispatch_message(mount_event) self.title = self._title self.stylesheet.update(self) @@ -1058,7 +1071,7 @@ class App(Generic[ReturnType], DOMNode): self._running = True try: load_event = events.Load(sender=self) - await self.dispatch_message(load_event) + await self._dispatch_message(load_event) driver: Driver driver_class = cast( @@ -1134,7 +1147,7 @@ class App(Generic[ReturnType], DOMNode): self._registry.add(child) child._attach(parent) child._post_register(self) - child.start_messages() + child._start_messages() return True return False @@ -1190,7 +1203,7 @@ class App(Generic[ReturnType], DOMNode): widget (Widget): The Widget to start. """ widget._attach(parent) - widget.start_messages() + widget._start_messages() widget.post_message_no_wait(events.Mount(sender=parent)) def is_mounted(self, widget: Widget) -> bool: @@ -1199,14 +1212,14 @@ class App(Generic[ReturnType], DOMNode): async def close_all(self) -> None: while self._registry: child = self._registry.pop() - await child.close_messages() + await child._close_messages() async def shutdown(self): await self._disconnect_devtools() driver = self._driver if driver is not None: driver.disable_input() - await self.close_messages() + await self._close_messages() def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: self.screen.refresh(repaint=repaint, layout=layout) @@ -1343,9 +1356,9 @@ class App(Generic[ReturnType], DOMNode): action_target = default_namespace or self action_name = target - await self.dispatch_action(action_target, action_name, params) + await self._dispatch_action(action_target, action_name, params) - async def dispatch_action( + async def _dispatch_action( self, namespace: object, action_name: str, params: Any ) -> None: log( @@ -1362,7 +1375,7 @@ class App(Generic[ReturnType], DOMNode): if callable(method): await invoke(method, *params) - async def broker_event( + async def _broker_event( self, event_name: str, event: events.Event, default_namespace: object | None ) -> bool: """Allow the app an opportunity to dispatch events to action system. @@ -1411,7 +1424,7 @@ class App(Generic[ReturnType], DOMNode): async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None: log("shutdown request") - await self.close_messages() + await self._close_messages() async def _on_resize(self, event: events.Resize) -> None: event.stop() @@ -1428,7 +1441,7 @@ class App(Generic[ReturnType], DOMNode): for child in remove_widgets: self._unregister(child) for child in remove_widgets: - await child.close_messages() + await child._close_messages() async def action_press(self, key: str) -> None: await self.press(key) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index ca1a83660..0ea08020c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -464,8 +464,8 @@ if __name__ == "__main__": app = App() main_view = View(id="main") help_view = View(id="help") - app.add_child(main_view) - app.add_child(help_view) + app._add_child(main_view) + app._add_child(help_view) widget1 = Widget(id="widget1") widget2 = Widget(id="widget2") @@ -475,21 +475,21 @@ if __name__ == "__main__": helpbar = Widget(id="helpbar") helpbar.add_class("float") - main_view.add_child(widget1) - main_view.add_child(widget2) - main_view.add_child(sidebar) + main_view._add_child(widget1) + main_view._add_child(widget2) + main_view._add_child(sidebar) sub_view = View(id="sub") sub_view.add_class("-subview") - main_view.add_child(sub_view) + main_view._add_child(sub_view) tooltip = Widget(id="tooltip") tooltip.add_class("float", "transient") - sub_view.add_child(tooltip) + sub_view._add_child(tooltip) help = Widget(id="markdown") - help_view.add_child(help) - help_view.add_child(helpbar) + help_view._add_child(help) + help_view._add_child(helpbar) from rich import print diff --git a/src/textual/dom.py b/src/textual/dom.py index 05d4c7838..86a509858 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -29,7 +29,7 @@ from .css.parse import parse_declarations from .css.styles import Styles, RenderStyles from .css.query import NoMatchingNodesError from .message_pump import MessagePump -from ._timer import Timer +from .timer import Timer if TYPE_CHECKING: from .app import App @@ -515,7 +515,7 @@ class DOMNode(MessagePump): node._set_dirty() node._layout_required = True - def add_child(self, node: Widget) -> None: + def _add_child(self, node: Widget) -> None: """Add a new child node. Args: @@ -524,7 +524,7 @@ class DOMNode(MessagePump): self.children._append(node) node._attach(self) - def add_children(self, *nodes: Widget, **named_nodes: Widget) -> None: + def _add_children(self, *nodes: Widget, **named_nodes: Widget) -> None: """Add multiple children to this node. Args: diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 11d6067de..23fd24c12 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -243,6 +243,6 @@ if __name__ == "__main__": class MyApp(App): async def on_mount(self, event: events.Mount) -> None: - self.set_timer(5, callback=self.close_messages) + self.set_timer(5, callback=self._close_messages) MyApp.run() diff --git a/src/textual/events.py b/src/textual/events.py index f01c62142..2d2a0594c 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -13,8 +13,8 @@ from .message import Message MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") if TYPE_CHECKING: - from ._timer import Timer as TimerClass - from ._timer import TimerCallback + from .timer import Timer as TimerClass + from .timer import TimerCallback from .widget import Widget diff --git a/src/textual/layouts/center.py b/src/textual/layouts/center.py index e1650f19f..d8da84e10 100644 --- a/src/textual/layouts/center.py +++ b/src/textual/layouts/center.py @@ -24,7 +24,7 @@ class CenterLayout(Layout): fraction_unit = Fraction(size.width) for widget in children: - width, height, margin = widget.get_box_model( + width, height, margin = widget._get_box_model( size, parent_size, fraction_unit ) margin_width = width + margin.width diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 6d2b5cb74..7360c0c2e 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -33,7 +33,7 @@ class HorizontalLayout(Layout): fraction_unit = Fraction(size.width, total_fraction or 1) box_models = [ - widget.get_box_model(size, parent_size, fraction_unit) + widget._get_box_model(size, parent_size, fraction_unit) for widget in cast("list[Widget]", children) ] diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index e896319a6..dcba4cacc 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -31,7 +31,7 @@ class VerticalLayout(Layout): fraction_unit = Fraction(size.height, total_fraction or 1) box_models = [ - widget.get_box_model(size, parent_size, fraction_unit) + widget._get_box_model(size, parent_size, fraction_unit) for widget in children ] diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c75666fe1..c772c7b09 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,3 +1,11 @@ +""" + +A message pump is a class that processes messages. + +It is a base class for the App, Screen, and Widgets. + +""" + from __future__ import annotations import asyncio @@ -10,7 +18,7 @@ from weakref import WeakSet from . import events, log, messages from ._callback import invoke from ._context import NoActiveAppError, active_app -from ._timer import Timer, TimerCallback +from .timer import Timer, TimerCallback from .case import camel_to_snake from .events import Event from .message import Message @@ -81,6 +89,9 @@ class MessagePump(metaclass=MessagePumpMeta): """ Get the current app. + Returns: + App: The current app. + Raises: NoActiveAppError: if no active app could be found for the current asyncio context """ @@ -90,14 +101,17 @@ class MessagePump(metaclass=MessagePumpMeta): raise NoActiveAppError() @property - def is_parent_active(self): - return self._parent and not self._parent._closed and not self._parent._closing + def is_parent_active(self) -> bool: + return bool( + self._parent and not self._parent._closed and not self._parent._closing + ) @property def is_running(self) -> bool: return self._running def log(self, *args, **kwargs) -> None: + """Write to logs or devtools.""" return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1]) def _attach(self, parent: MessagePump) -> None: @@ -123,7 +137,7 @@ class MessagePump(metaclass=MessagePumpMeta): """Enable processing of messages types.""" self._disabled_messages.difference_update(messages) - async def get_message(self) -> Message: + async def _get_message(self) -> Message: """Get the next event on the queue, or None if queue is closed. Returns: @@ -142,7 +156,7 @@ class MessagePump(metaclass=MessagePumpMeta): raise MessagePumpClosed("The message pump is now closed") return message - def peek_message(self) -> Message | None: + def _peek_message(self) -> Message | None: """Peek the message at the head of the queue (does not remove it from the queue), or return None if the queue is empty. @@ -172,6 +186,17 @@ class MessagePump(metaclass=MessagePumpMeta): name: str | None = None, pause: bool = False, ) -> Timer: + """Make a function call after a delay. + + Args: + delay (float): Time to wait before invoking callback. + callback (TimerCallback | None, optional): Callback to call after time has expired.. Defaults to None. + name (str | None, optional): Name of the timer (for debug). Defaults to None. + pause (bool, optional): Start timer paused. Defaults to False. + + Returns: + Timer: A timer object. + """ timer = Timer( self, delay, @@ -194,6 +219,18 @@ class MessagePump(metaclass=MessagePumpMeta): repeat: int = 0, pause: bool = False, ): + """Call a function at periodic intervals. + + Args: + interval (float): Time between calls. + callback (TimerCallback | None, optional): Function to call. Defaults to None. + name (str | None, optional): Name of the timer object. Defaults to None. + repeat (int, optional): Number of times to repeat the call or 0 for continuous. Defaults to 0. + pause (bool, optional): Start the timer paused. Defaults to False. + + Returns: + Timer: A timer object. + """ timer = Timer( self, interval, @@ -209,7 +246,7 @@ class MessagePump(metaclass=MessagePumpMeta): def call_later(self, callback: Callable, *args, **kwargs) -> None: """Schedule a callback to run after all messages are processed and the screen - has been refreshed. + has been refreshed. Positional and keyword arguments are passed to the callable. Args: callback (Callable): A callable. @@ -219,15 +256,15 @@ class MessagePump(metaclass=MessagePumpMeta): message = messages.InvokeLater(self, partial(callback, *args, **kwargs)) self.post_message_no_wait(message) - def on_invoke_later(self, message: messages.InvokeLater) -> None: + def _on_invoke_later(self, message: messages.InvokeLater) -> None: # Forward InvokeLater message to the Screen self.app.screen._invoke_later(message.callback) - def close_messages_no_wait(self) -> None: + def _close_messages_no_wait(self) -> None: """Request the message queue to exit.""" self._message_queue.put_nowait(None) - async def close_messages(self) -> None: + async def _close_messages(self) -> None: """Close message queue, and optionally wait for queue to finish processing.""" if self._closed or self._closing: return @@ -242,13 +279,14 @@ class MessagePump(metaclass=MessagePumpMeta): # Ensure everything is closed before returning await self._task - def start_messages(self) -> None: - self._task = asyncio.create_task(self.process_messages()) + def _start_messages(self) -> None: + """Start messages task.""" + self._task = asyncio.create_task(self._process_messages()) - async def process_messages(self) -> None: + async def _process_messages(self) -> None: self._running = True try: - await self._process_messages() + await self._process_messages_loop() except CancelledError: pass finally: @@ -256,14 +294,14 @@ class MessagePump(metaclass=MessagePumpMeta): for timer in list(self._timers): await timer.stop() - async def _process_messages(self) -> None: + async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True await Reactive.initialize_object(self) while not self._closed: try: - message = await self.get_message() + message = await self._get_message() except MessagePumpClosed: break except CancelledError: @@ -274,18 +312,18 @@ class MessagePump(metaclass=MessagePumpMeta): # Combine any pending messages that may supersede this one while not (self._closed or self._closing): try: - pending = self.peek_message() + pending = self._peek_message() except MessagePumpClosed: break if pending is None or not message.can_replace(pending): break try: - message = await self.get_message() + message = await self._get_message() except MessagePumpClosed: break try: - await self.dispatch_message(message) + await self._dispatch_message(message) except CancelledError: raise except Exception as error: @@ -307,7 +345,7 @@ class MessagePump(metaclass=MessagePumpMeta): log("CLOSED", self) - async def dispatch_message(self, message: Message) -> None: + async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. Args: @@ -458,6 +496,14 @@ class MessagePump(metaclass=MessagePumpMeta): return False async def emit(self, message: Message) -> bool: + """Send a message to the _parent_. + + Args: + message (Message): A message object. + + Returns: + bool: _True if the message was posted successfully. + """ if self._parent: return await self._parent._post_message_from_child(message) else: diff --git a/src/textual/screen.py b/src/textual/screen.py index 626b9290d..2cbd12440 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,7 +9,7 @@ from rich.style import Style from . import errors, events, messages from ._callback import invoke from ._compositor import Compositor, MapGeometry -from ._timer import Timer +from .timer import Timer from ._types import CallbackType from .geometry import Offset, Region, Size from .reactive import Reactive diff --git a/src/textual/_timer.py b/src/textual/timer.py similarity index 76% rename from src/textual/_timer.py rename to src/textual/timer.py index a1010449a..14cb2837a 100644 --- a/src/textual/_timer.py +++ b/src/textual/timer.py @@ -1,3 +1,10 @@ +""" + +Timer objects are created by [set_interval][textual.message_pump.MessagePump.set_interval] or + [set_interval][textual.message_pump.MessagePump.set_timer]. + +""" + from __future__ import annotations import asyncio @@ -26,6 +33,19 @@ class EventTargetGone(Exception): @rich_repr class Timer: + """A class to send timer-based events. + + Args: + event_target (MessageTarget): The object which will receive the timer events. + interval (float): The time between timer events. + sender (MessageTarget): The sender of the event. + name (str | None, optional): A name to assign the event (for debugging). Defaults to None. + callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None. + repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None. + skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True. + pause (bool, optional): Start the timer paused. Defaults to False. + """ + _timer_count: int = 1 def __init__( @@ -40,18 +60,6 @@ class Timer: skip: bool = True, pause: bool = False, ) -> None: - """A class to send timer-based events. - - Args: - event_target (MessageTarget): The object which will receive the timer events. - interval (float): The time between timer events. - sender (MessageTarget): The sender of the event. - name (str | None, optional): A name to assign the event (for debugging). Defaults to None. - callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None. - repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None. - skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True. - pause (bool, optional): Start the timer paused. Defaults to False. - """ self._target_repr = repr(event_target) self._target = weakref.ref(event_target) self._interval = interval @@ -84,7 +92,7 @@ class Timer: Returns: Task: A Task instance for the timer. """ - self._task = asyncio.create_task(self.run()) + self._task = asyncio.create_task(self._run_timer()) return self._task def stop_no_wait(self) -> None: @@ -101,14 +109,18 @@ class Timer: self._task = None def pause(self) -> None: - """Pause the timer.""" + """Pause the timer. + + A paused timer will not send events until it is resumed. + + """ self._active.clear() def resume(self) -> None: - """Result a paused timer.""" + """Resume a paused timer.""" self._active.set() - async def run(self) -> None: + async def _run_timer(self) -> None: """Run the timer task.""" try: await self._run() diff --git a/src/textual/widget.py b/src/textual/widget.py index 83d223710..4c23063c0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -135,7 +135,7 @@ class Widget(DOMNode): id=id, classes=self.DEFAULT_CLASSES if classes is None else classes, ) - self.add_children(*children) + self._add_children(*children) virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) @@ -152,7 +152,11 @@ class Widget(DOMNode): @property def siblings(self) -> list[Widget]: - """Get the widget's siblings (self is removed from the return list).""" + """Get the widget's siblings (self is removed from the return list). + + Returns: + list[Widget]: A list of siblings. + """ parent = self.parent if parent is not None: siblings = list(parent.children) @@ -163,14 +167,35 @@ class Widget(DOMNode): @property def allow_vertical_scroll(self) -> bool: - """Check if vertical scroll is permitted.""" + """Check if vertical scroll is permitted. + + May be overridden if you want different logic regarding allowing scrolling. + + Returns: + bool: True if the widget may scroll _vertically_. + """ return self.is_scrollable and self.show_vertical_scrollbar @property def allow_horizontal_scroll(self) -> bool: - """Check if horizontal scroll is permitted.""" + """Check if horizontal scroll is permitted. + + May be overridden if you want different logic regarding allowing scrolling. + + Returns: + bool: True if the widget may scroll _horizontally_. + """ return self.is_scrollable and self.show_horizontal_scrollbar + @property + def _allow_scroll(self) -> bool: + """Check if both axis may be scrolled. + + Returns: + bool: True if horizontal and vertical scrolling is enabled. + """ + return self.allow_horizontal_scroll and self.allow_vertical_scroll + def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. @@ -193,6 +218,7 @@ class Widget(DOMNode): return self._arrangement def _clear_arrangement_cache(self) -> None: + """Clear arrangement cache, forcing a new arrange operation.""" self._arrangement = None def watch_show_horizontal_scrollbar(self, value: bool) -> None: @@ -222,15 +248,30 @@ class Widget(DOMNode): the keys will be set as the Widget's id. Example: + ```python self.mount(Static("hello"), header=Header()) - + ``` """ self.app._register(self, *anon_widgets, **widgets) self.screen.refresh(layout=True) def compose(self) -> ComposeResult: - """Yield child widgets for a container.""" + """Called by Textual to create child widgets. + + Extend this to build a UI. + + Example: + ```python + def compose(self) -> ComposeResult: + yield Header() + yield Container( + TreeControl(), Viewer() + ) + yield Footer() + ``` + + """ return yield @@ -246,7 +287,7 @@ class Widget(DOMNode): css, path=path, is_default_css=True, tie_breaker=tie_breaker ) - def get_box_model( + def _get_box_model( self, container: Size, viewport: Size, fraction_unit: Fraction ) -> BoxModel: """Process the box model for this widget. @@ -254,6 +295,7 @@ class Widget(DOMNode): Args: container (Size): The size of the container widget (with a layout) viewport (Size): The viewport size. + fraction_unit (Fraction): The unit used for `fr` units. Returns: BoxModel: The size and margin for this widget. @@ -279,9 +321,9 @@ class Widget(DOMNode): int: The optimal width of the content. """ if self.is_container: - assert self.layout is not None + assert self._layout is not None return ( - self.layout.get_content_width(self, container, viewport) + self._layout.get_content_width(self, container, viewport) + self.scrollbar_size_vertical ) @@ -313,9 +355,9 @@ class Widget(DOMNode): """ if self.is_container: - assert self.layout is not None + assert self._layout is not None height = ( - self.layout.get_content_height( + self._layout.get_content_height( self, container, viewport, @@ -330,8 +372,8 @@ class Widget(DOMNode): return self._content_height_cache[1] renderable = self.render() - options = self.console.options.update_width(width).update(highlight=False) - segments = self.console.render(renderable, options) + options = self._console.options.update_width(width).update(highlight=False) + segments = self._console.render(renderable, options) # Cheaper than counting the lines returned from render_lines! height = sum(text.count("\n") for text, _, _ in segments) self._content_height_cache = (cache_key, height) @@ -611,7 +653,12 @@ class Widget(DOMNode): @property def focusable_children(self) -> list[Widget]: - """Get the children which may be focused.""" + """Get the children which may be focused. + + Returns: + list[Widget]: List of widgets that can receive focus. + + """ focusable = [ child for child in self.children if child.display and child.visible ] @@ -619,12 +666,18 @@ class Widget(DOMNode): @property def _focus_sort_key(self) -> tuple[int, int]: + """Key function to sort widgets in to tfocus order.""" x, y, _, _ = self.virtual_region top, _, _, left = self.styles.margin return y - top, x - left @property def scroll_offset(self) -> Offset: + """Get the current scroll offset. + + Returns: + Offset: Offset a container has been scrolled by. + """ return Offset(int(self.scroll_x), int(self.scroll_y)) @property @@ -637,7 +690,7 @@ class Widget(DOMNode): return self.is_scrollable and self.styles.background.is_transparent @property - def console(self) -> Console: + def _console(self) -> Console: """Get the current console. Returns: @@ -648,14 +701,29 @@ class Widget(DOMNode): @property def animate(self) -> BoundAnimator: + """Get an animator to animate attributes on this widget. + + Example: + ```python + self.animate("brightness", 0.5) + ``` + + Returns: + BoundAnimator: An animator bound to this widget. + """ if self._animate is None: self._animate = self.app.animator.bind(self) assert self._animate is not None return self._animate @property - def layout(self) -> Layout: - """Get the layout object if set in styles, or a default layout.""" + def _layout(self) -> Layout: + """Get the layout object if set in styles, or a default layout. + + Returns: + Layout: A layout object. + + """ return self.styles.layout or self._default_layout @property @@ -745,9 +813,11 @@ class Widget(DOMNode): """Scroll to a given (absolute) coordinate, optionally animating. Args: - x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None. - y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None. - animate (bool, optional): Animate to new scroll position. Defaults to False. + x (int | None, optional): X coordinate (column) to scroll to, or None for no change. Defaults to None. + y (int | None, optional): Y coordinate (row) to scroll to, or None for no change. Defaults to None. + animate (bool, optional): Animate to new scroll position. Defaults to True. + speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration. + duration (float | None, optional): Duration of animation, if animate is True and speed is False. Returns: bool: True if the scroll position changed, otherwise False. @@ -809,6 +879,8 @@ class Widget(DOMNode): x (int | None, optional): X distance (columns) to scroll, or ``None`` for no change. Defaults to None. y (int | None, optional): Y distance (rows) to scroll, or ``None`` for no change. Defaults to None. animate (bool, optional): Animate to new scroll position. Defaults to False. + speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration. + duration (float | None, optional): Duration of animation, if animate is True and speed is False. Returns: bool: True if the scroll position changed, otherwise False. @@ -822,34 +894,114 @@ class Widget(DOMNode): ) def scroll_home(self, *, animate: bool = True) -> bool: + """Scroll to home position. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + """ return self.scroll_to(0, 0, animate=animate, duration=1) def scroll_end(self, *, animate: bool = True) -> bool: + """Scroll to the end of the container. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to(0, self.max_scroll_y, animate=animate, duration=1) def scroll_left(self, *, animate: bool = True) -> bool: + """Scroll one cell left. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to(x=self.scroll_target_x - 1, animate=animate) def scroll_right(self, *, animate: bool = True) -> bool: + """Scroll on cell right. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to(x=self.scroll_target_x + 1, animate=animate) - def scroll_up(self, *, animate: bool = True) -> bool: + def scroll_down(self, *, animate: bool = True) -> bool: + """Scroll one line down. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to(y=self.scroll_target_y + 1, animate=animate) - def scroll_down(self, *, animate: bool = True) -> bool: + def scroll_up(self, *, animate: bool = True) -> bool: + """Scroll one line up. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to(y=self.scroll_target_y - 1, animate=animate) def scroll_page_up(self, *, animate: bool = True) -> bool: + """Scroll one page up. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to( y=self.scroll_target_y - self.container_size.height, animate=animate ) def scroll_page_down(self, *, animate: bool = True) -> bool: + """Scroll one page down. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to( y=self.scroll_target_y + self.container_size.height, animate=animate ) def scroll_page_left(self, *, animate: bool = True) -> bool: + """Scroll one page left. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to( x=self.scroll_target_x - self.container_size.width, animate=animate, @@ -857,6 +1009,15 @@ class Widget(DOMNode): ) def scroll_page_right(self, *, animate: bool = True) -> bool: + """Scroll one page right. + + Args: + animate (bool, optional): Animate scroll. Defaults to True. + + Returns: + bool: True if any scrolling was done. + + """ return self.scroll_to( x=self.scroll_target_x + self.container_size.width, animate=animate, @@ -1031,7 +1192,12 @@ class Widget(DOMNode): yield self.horizontal_scrollbar, scrollbar_region def get_pseudo_classes(self) -> Iterable[str]: - """Pseudo classes for a widget""" + """Pseudo classes for a widget. + + Returns: + Iterable[str]: Names of the pseudo classes. + + """ if self.mouse_over: yield "hover" if self.has_focus: @@ -1039,9 +1205,6 @@ class Widget(DOMNode): if self.descendant_has_focus: yield "focus-within" - def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: - watch(self, attribute_name, callback) - def post_render(self, renderable: RenderableType) -> RenderableType: """Applies style attributes to the default renderable. @@ -1098,11 +1261,11 @@ class Widget(DOMNode): width, height = self.size renderable = self.render() renderable = self.post_render(renderable) - options = self.console.options.update_dimensions(width, height).update( + options = self._console.options.update_dimensions(width, height).update( highlight=False ) - segments = self.console.render(renderable, options) + segments = self._console.render(renderable, options) lines = list( islice( Segment.split_and_crop_lines( @@ -1155,6 +1318,15 @@ class Widget(DOMNode): return lines def get_style_at(self, x: int, y: int) -> Style: + """Get the Rich style at a given screen offset. + + Args: + x (int): X coordinate relative to the screen. + y (int): Y coordinate relative to the screen. + + Returns: + Style: A rich Style object. + """ offset_x, offset_y = self.screen.get_offset(self) return self.screen.get_style_at(x + offset_x, y + offset_y) @@ -1170,7 +1342,16 @@ class Widget(DOMNode): This method sets an internal flag to perform a refresh, which will be done on the next idle event. Only one refresh will be done even if this method is called multiple times. + By default this method will cause the content of the widget to refresh, but not change its size. You can also + set `layout=True` to perform a layout. + + !!! warning + + It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will + do this automatically. + Args: + *regions (Region, optional): Additional screen regions to mark as dirty. repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True. layout (bool, optional): Also layout widgets in the view. Defaults to False. """ @@ -1257,7 +1438,7 @@ class Widget(DOMNode): self.app.capture_mouse(None) async def broker_event(self, event_name: str, event: events.Event) -> bool: - return await self.app.broker_event(event_name, event, default_namespace=self) + return await self.app._broker_event(event_name, event, default_namespace=self) async def _on_mouse_down(self, event: events.MouseUp) -> None: await self.broker_event("mouse.down", event) @@ -1322,27 +1503,27 @@ class Widget(DOMNode): event.stop() def _on_scroll_to(self, message: ScrollTo) -> None: - if self.is_scrollable: + if self._allow_scroll: self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1) message.stop() def _on_scroll_up(self, event: ScrollUp) -> None: - if self.is_scrollable: + if self.allow_vertical_scroll: self.scroll_page_up() event.stop() def _on_scroll_down(self, event: ScrollDown) -> None: - if self.is_scrollable: + if self.allow_vertical_scroll: self.scroll_page_down() event.stop() def _on_scroll_left(self, event: ScrollLeft) -> None: - if self.is_scrollable: + if self.allow_horizontal_scroll: self.scroll_page_left() event.stop() def _on_scroll_right(self, event: ScrollRight) -> None: - if self.is_scrollable: + if self.allow_horizontal_scroll: self.scroll_page_right() event.stop() @@ -1351,49 +1532,49 @@ class Widget(DOMNode): self.app._reset_focus(self) def key_home(self) -> bool: - if self.is_scrollable: + if self._allow_scroll: self.scroll_home() return True return False def key_end(self) -> bool: - if self.is_scrollable: + if self._allow_scroll: self.scroll_end() return True return False def key_left(self) -> bool: - if self.is_scrollable: + if self.allow_horizontal_scroll: self.scroll_left() return True return False def key_right(self) -> bool: - if self.is_scrollable: + if self.allow_horizontal_scroll: self.scroll_right() return True return False def key_down(self) -> bool: - if self.is_scrollable: - self.scroll_up() - return True - return False - - def key_up(self) -> bool: - if self.is_scrollable: + if self.allow_vertical_scroll: self.scroll_down() return True return False + def key_up(self) -> bool: + if self.allow_vertical_scroll: + self.scroll_up() + return True + return False + def key_pagedown(self) -> bool: - if self.is_scrollable: + if self.allow_vertical_scroll: self.scroll_page_down() return True return False def key_pageup(self) -> bool: - if self.is_scrollable: + if self.allow_vertical_scroll: self.scroll_page_up() return True return False diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py index aaa41b7a2..4e9c71f62 100644 --- a/src/textual/widgets/text_input.py +++ b/src/textual/widgets/text_input.py @@ -9,7 +9,7 @@ from rich.text import Text from textual import events, _clock from textual._text_backend import TextEditorBackend -from textual._timer import Timer +from textual.timer import Timer from textual._types import MessageTarget from textual.app import ComposeResult from textual.geometry import Size, clamp diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py index 3ad0585cd..1ca614448 100644 --- a/tests/css/test_styles.py +++ b/tests/css/test_styles.py @@ -268,7 +268,7 @@ async def test_scrollbar_gutter( text_widget = TextWidget() text_widget.styles.height = "auto" - container.add_child(text_widget) + container._add_child(text_widget) class MyTestApp(AppTest): def compose(self) -> ComposeResult: diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py index 7fba1efb7..a660c15e8 100644 --- a/tests/layouts/test_common_layout_features.py +++ b/tests/layouts/test_common_layout_features.py @@ -21,7 +21,7 @@ def test_nodes_take_display_property_into_account_when_they_display_their_childr screen = Screen() screen.styles.layout = layout - screen.add_child(widget) + screen._add_child(widget) displayed_children = screen.displayed_children assert isinstance(displayed_children, list) diff --git a/tests/test_dom.py b/tests/test_dom.py index 985b63fae..1a597a383 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -32,10 +32,10 @@ def parent(): child1 = DOMNode(id="child1") child2 = DOMNode(id="child2") grandchild1 = DOMNode(id="grandchild1") - child1.add_child(grandchild1) + child1._add_child(grandchild1) - parent.add_child(child1) - parent.add_child(child2) + parent._add_child(child1) + parent._add_child(child2) yield parent diff --git a/tests/test_focus.py b/tests/test_focus.py index 8d62ab901..78542aed9 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -20,7 +20,7 @@ async def test_focus_chain(): # Check empty focus chain assert not app.focus_chain - app.screen.add_children( + app.screen._add_children( Focusable(id="foo"), NonFocusable(id="bar"), Focusable(Focusable(id="Paul"), id="container1"), @@ -37,7 +37,7 @@ async def test_focus_next_and_previous(): app = App() app._set_active() app.push_screen(Screen()) - app.screen.add_children( + app.screen._add_children( Focusable(id="foo"), NonFocusable(id="bar"), Focusable(Focusable(id="Paul"), id="container1"), diff --git a/tests/test_query.py b/tests/test_query.py index 1be7d027d..be6108046 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -11,8 +11,8 @@ def test_query(): app = App() main_view = View(id="main") help_view = View(id="help") - app.add_child(main_view) - app.add_child(help_view) + app._add_child(main_view) + app._add_child(help_view) widget1 = Widget(id="widget1") widget2 = Widget(id="widget2") @@ -22,21 +22,21 @@ def test_query(): helpbar = Widget(id="helpbar") helpbar.add_class("float") - main_view.add_child(widget1) - main_view.add_child(widget2) - main_view.add_child(sidebar) + main_view._add_child(widget1) + main_view._add_child(widget2) + main_view._add_child(sidebar) sub_view = View(id="sub") sub_view.add_class("-subview") - main_view.add_child(sub_view) + main_view._add_child(sub_view) tooltip = Widget(id="tooltip") tooltip.add_class("float", "transient") - sub_view.add_child(tooltip) + sub_view._add_child(tooltip) help = Widget(id="markdown") - help_view.add_child(help) - help_view.add_child(helpbar) + help_view._add_child(help) + help_view._add_child(helpbar) # repeat tests to account for caching for repeat in range(3): diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index 016ffb5cb..e23952b23 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -70,7 +70,7 @@ class AppTest(App): waiting_duration_after_yield: float = 0, ) -> AsyncContextManager[ClockMock]: async def run_app() -> None: - await self.process_messages() + await self._process_messages() @contextlib.asynccontextmanager async def get_running_state_context_manager(): From 4a6b1996de6bca9541932eb85c72cc5cebd396be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 09:18:15 +0100 Subject: [PATCH 53/73] diagram tweak --- docs/images/descendant_combinator.excalidraw.svg | 8 ++++---- src/textual/widgets/_header.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/images/descendant_combinator.excalidraw.svg b/docs/images/descendant_combinator.excalidraw.svg index d0dac0ecd..1f5a7ce8d 100644 --- a/docs/images/descendant_combinator.excalidraw.svg +++ b/docs/images/descendant_combinator.excalidraw.svg @@ -1,16 +1,16 @@ - eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPiSLZ+r1/hcD9M34m2OvelIyYm8IJ3u1xcuGxX3ZpwYJCNjFx1MDAxMFiSwXZH//c5XHQ2XHUwMDEyIMlcdTAwMDIjXHUwMDFh7i09eJFE6uTRWb4vT2by56e1tfXwuWOv/7G2bj/Vqq5T96u99d/M+a7tXHUwMDA3TtuDS6T/f9B+9Gv9O1x1MDAxYmHYXHT++P33VtVv2mHHrdZsq+tcdTAwMDSPVTdcYlx1MDAxZutO26q1W787od1cbv5tfp5UW/a/Ou1WPfSt6CFcdTAwMWJ23Vx02/7gWbZrt2wvXGag9f+F/9fW/uz/jEnn27Ww6t25dv9cdTAwMDP9SzFcdTAwMDGx5uOnT9peX1qsiWJcdTAwMTQhqoZ3OME2PDC063D5XHUwMDE2hLajK/X+s4J92bh3OPNLXHUwMDAytz7vuHvfS+XoubeO61bCZ3egi2qt8ejHpFxuQr/dtC+detgwT1x1MDAxZjs//Fxc0Fx1MDAwNjVEn/Lbj3dccs9cdTAwMGWCkc+0O9WaXHUwMDEzPsM5joYnXHUwMDA3WvhjLTrzZG6Q1Fx1MDAxMkIyxVx1MDAwNFwiXHUwMDEyejy8aj5OhbI05pRcdIU4XFxkY2JttV14XHUwMDE1INYvqH9Egt1Ua807kM6rXHUwMDBm71x0/apcdTAwMTd0qj68sOi+3luHhVx1MDAxOJ5r2M5dI4STKtJ9YPfVjlx0oiCIiPXLPKWzX+/bwH/G1dao+p1X9axcdTAwMDfmn5iERridcVx1MDAwM4pcdTAwMWJR7NVy73bjkuret0b9XHUwMDAwX988XHUwMDFkXHUwMDBigYNhWyNcdTAwMTZcdTAwMTfaT+H68MJfv2U12/r+9fB893azXHUwMDFifr046J31Xo7vOjfJzVZ9v93L2+7lwUW3d8S2j498Xt99Jo+EbYs5tEsu6/t75WbtWJVcdTAwMTg+b7mnO973uzm0W5B6V6vZo8ftPaSv9l+ebtVhuVxcf7qudns/lTufZvfsXVx1MDAxYXRrTul66za8L+/vlM4ujz6k3Fx1MDAxMSl+y9uRnOI2jrZ6iNqodd/p7Fx1MDAxZlx1MDAxZXVfrs+2w3zivv5cdTAwMTWFwsdOvTpIWlx1MDAxMGRcdTAwMTFTXHUwMDEwz7FcdTAwMTZRKHdcdTAwMWSvXHRcdTAwMTe9R9eNzrVrzSjPfYpcdDyRYUf6XHUwMDFmT65cdTAwMDTptORKJIGcw1x1MDAxOMmdW7PNYllzq8rKrYpZbDG5lSfkVi7HcyuB5Eok5lHSnVtqnacxRu+87YVcdTAwMTXnpVx1MDAwZtbEyNlyteW4zyOvrW+lIOlcdTAwMTZcXK46nu3/+sMzXHUwMDE3nPq/fqzXnarbvvux/sP7n7iWXHUwMDAzXHUwMDFipDHNczLSTsl17oyFr7v27ajph1x1MDAwZcDU4eWWU6/HgWft7dn7eeBi23fuXHUwMDFjr+qe55U800uzcTDHOM1VKUNcdTAwMTJjrFFuVz07f3Dtl9Lm4+Pm8b57fHrlXZXvl91VXHUwMDA1sjh0kkmc4KqcXG5LKyw5Rdy4Ki/OVWN6jmCwmHBV4CVUYKRcdTAwMGLw1ay0dF85uNs5b3tbzVpT+E/N/e/Nh5S09Fx1MDAxM1x1MDAwNk/fbkHqXa1mi4LBq6WFc7+x3bvp2uchPXAxUS/Ns87H8OoqamH+qH3QLn1+2PhGVLlcdTAwMTayTlCxn7dPt1x1MDAxZeRcdTAwMWParbfQxVb74EuvfHW45XY7/s7NXHUwMDFkLZRlJCs+avb1r7+fXHJwKsdPXHUwMDBmh9pcdTAwMTBDSEI6i/Lce1x1MDAxOCPb3pZcdTAwMTVj8CyMwbHFXHUwMDE2gzFUXHUwMDAyxpikXHUwMDAzXHUwMDFhSYZcdTAwMTSTXHUwMDA1jLQtXHUwMDBmXHUwMDFk2Fx1MDAwM5T9YoC1+6s5P0DWNbdcdTAwMWFcdTAwMDR2XHUwMDAw8PrmMVxm216waGbwXHUwMDBlglx1MDAxZWdcdTAwMDbTdFwi03mzSYLUNM2DhZaMI4pZblx1MDAwN/5cdTAwMWGenbefnq83e5unJ1/PX768fC6JXHUwMDE0XHUwMDA3XHUwMDFlc8Ri3Fx1MDAxN6P3/JdJZiHNXHRWWkrwX8ZGXHUwMDFjWFx1MDAxMm4pJTHwefByJtVHXHUwMDFj+Fx1MDAxN0xulFx1MDAxMkrNTFx1MDAxMTCjICWEVTJ//81KgUI0vHvdXHUwMDE1304qrHOnXHUwMDBm6eHRY/njSOAnRShUvavVbFFcdTAwMTRhtbRQXHUwMDE0RVgtLbh6c0vd7G6xLSHsyvnh0125vMS2sHiGkNyRnOLWXHUwMDBlT85Z58g/+eZv3dTto44+2v06rzqE5ETFcGdRzEOJ9FwiP0BxRYXA+Yv82XbxtzCPPNBFZ0JcdTAwMTeuLTov6DI998BRnehtfJNzjVx1MDAwNaOLLUVILihDU9jjx7jHZlx1MDAxZpf/+mP9m1xy4DyZX8RcdTAwMTD1XGK/qEF3bH92hvFcdTAwMGX8XHUwMDFlZ1x1MDAxOOOiZjpiNotQsUlcdTAwMWTj3qhcYsHw2kn+cVx1MDAwMInLp02263VcdTAwMWK7lZfgjj5cdTAwMWb7/tVy01xioYmFldLi1Vx1MDAxN0eHXHUwMDAxXGaL4NrU4ZaDRSDjhpyhXHUwMDA1s4iHQJdcdTAwMGZbp1x1MDAxNXZw1uRlr35Ptj9f/GRcdTAwMTHzYlx1MDAxMVx1MDAwNal3tZotikWsllx1MDAxNopiXHUwMDExq6WFoljEamlh/mWR98hJckeiZl//ylx1MDAwNG1cdTAwMTLTKG9cdTAwMTVFXCJ0+kxhhcxUJznFROHs17ekXHUwMDFjQmiZhVu4YVx1MDAxOHPCLfOgXHUwMDEwUlx1MDAwM31gKFx1MDAwNmlcdTAwMTZCIVx1MDAwNFx1MDAxMMrFlS+GuPykvWhcdTAwMDbxXHUwMDBl8k5lXHUwMDEwXHUwMDAzSTOdcFx1MDAxMFtcdTAwMTK8UKBUJyRIUsQlQNXcXphdXFxezlx1MDAxYaIwVUImqOKA6jXTYsRcdJngXHUwMDE2hVBcdTAwMDSQXHUwMDFmc82FkIV5IbIoXHUwMDA1clx1MDAwMI+RIIXiVLNJr1x1MDAxNNpcIlx1MDAxYeicXHUwMDE0XG6ih1STPFx1MDAxZmFJXHUwMDA0iX02t5P2ZV10jTFcYqt+uOl4dce7g4tRqntbiZJnZl/frWuPQV+LSHDJqeRcdTAwMDRcdTAwMTGl47NAjS6qXHUwMDFkk14s0C1jXGJcdTAwMTl1U1xisK83XGZT7rrt1d+XKbumXHUwMDE4k2lcdTAwMDOEolx1MDAxMlx1MDAxMXiimVx1MDAxN1xi8iE1IVx1MDAxNLGolEIrzolGJilMXGLlVoNwq91qOSHo/nPb8cJxXHUwMDFk95VZMr7esKv18avQqfi18aDQMS2O0sjor7XIa/r/XGb//s9vyXenXHUwMDFiszkmzDhq71P899TxXGbjXHUwMDE4r55cdTAwMThcZkFcXElBZf6Allxy35ZcdTAwMTVWXGJmSYWF1iaesdhIrPm8oMzimlxuQlx1MDAxOIdcdTAwMWLEuFxcc4RcdTAwMTWYWpRgY/GUgEmTXGLfROMjyIJcdTAwMTdCKMOUUTCJ2Fx1MDAxY41BPFNUI/BcdTAwMGI2w2jJXHUwMDA34tnUXHUwMDEweNp4ljt2IItRTlx1MDAxMSRjMF+qQSxcdTAwMWW7a1x1MDAxMDowtlx1MDAwNMbgSVxmMcSJYLPFs2z8MSpcdTAwMTOhXHUwMDFhkqJWXGJcdEVRkkxcdTAwMTBcdTAwMDE4XHUwMDE4XHUwMDFmJ1xcSkKIXFzteJZuy+aYsOJ5hTON0fjZKJrBsyjG+WeRZ1x1MDAxN8qWNZhRZXFCXHUwMDA0VppCMFx1MDAxYo9lwkKcgsVLXHUwMDAxab+4UKY1WDPVXHUwMDEwXHUwMDFjlIQ0nbT6Q0HQJVxuw3WCMVx1MDAxMXRi9lx1MDAxN1x1MDAwNlhCOOT+RYeyKUuChYWyXHJcdTAwMDNcbpjm8Ca5QJooJWJe9Fx1MDAxNjeoXHUwMDA1oVx1MDAwZfxMgvDI1I9mXHUwMDBiZtnlmFGpTDrUXHUwMDE4YiuBkKZcdJ5cdTAwMTCKW1x1MDAxOHFcYq9SXCIj16RIq1x1MDAxNMo2Uo3ZXHUwMDFjXHUwMDEzZjxlKMssVVx0kTrfTcE1QCNTkE23UUWnR53d0MH+XHQrtbbDy/rJkpNNZlx1MDAxNsWMhDBClIU1ZkiQXHUwMDAy56fyqO0oYEmLSMghclx1MDAwMnNBWmFUIV3APNWhNSWMlbbdp0qpee5gfc83XFxycPRcdTAwMTLUKlx1MDAxZlx1MDAxZoI9Ptt/+VZ+ulxu/W17o1FcdK4kYbf/XHUwMDBmK1RcdTAwMDWpt6BmZdfZKz/s4Ja/c13fXHUwMDBlu6R76s5DXHUwMDBiXGb5XHUwMDFk7HVrJ5flw1x1MDAxM3W5fdk+XHUwMDBlXHUwMDBml1e7N/et0me3KzBcdTAwMGY3Nuv67vBcdTAwMDSSWqH1g+SO5Fx1MDAxNPdcdTAwMDOLrDPb/Xpqn+mNXHUwMDA3u6l2vr3cf/ZOLzZuc1rDW9ZKR0iLWa7BefpqXHKqXHUwMDE1I1x1MDAxMqn8i7ezzW1Zk1x1MDAxZlx1MDAxZk9+XFxbXG7QRpGpjyWkvthIxNvSXGamgERcdTAwMTSR8YpemUHQyNmM0kal5tu292tKUUOO3D+3osY7KG28qDGUMdPHUvmyTMWXWFx1MDAxMISFXHUwMDE0U9RcdTAwMTSzg9ly+lx1MDAxOGfM4oZhSeiwxHK0mlx1MDAwMfZmccxcdTAwMGKFmlx1MDAxOFx1MDAwM4FTlGhcIplcdTAwMDJaLifdj3EzXHUwMDAwXHUwMDBm1k8moCdGgvXlnqHIuMz1i2w3WIuPrXFcclZcbqicaSYoSaDIzEJcdTAwMWNUXGbae70yJTWeopZiVlx1MDAxMCpgxlxmS6mpTlx1MDAxMlx1MDAwNZlcdTAwMDV4vL/Ajmo9IdEqMeN02zVHzGqjhj7Ff882fZOkk2JOOLxcdTAwMDGan1x1MDAxM5+de/tt/Xi407luXHUwMDFj3p5dX9jywV7ykEVcdNhcdTAwMTlcdTAwMWGNSm87pSnCXGJWkveDklx1MDAxOJNo4VulXHUwMDExwFxi4Jxyhtrqh3ZK++KTq2bjy0Nj/2Kju8f36t3t+2Tw+3Pm5vTtXHUwMDE2pN7VarawndJWSlx1MDAwYlx1MDAwNTVb1J5cdTAwMGJcdTAwMDWJO39cdTAwMTL/7sZuiVx1MDAxZMkp7sNcci1VSEncXHUwMDFlb59Xrtpf2O1TvblaY1x1MDAwM4So1FlcdTAwMGJac6aonmJcdTAwMDVHtlksK1xi4MkgQDGLLlxiXHUwMDA05NvTXHIrXHUwMDA1TFx1MDAxMlx1MDAwNCpg/cby7OKQuDVa4NTtm6q/+L1cdTAwMWIyQW2uXd1GRP9cdTAwMDBYzyjIXHUwMDBiIEFAgaaYXdTtVS6ITVx1MDAwZlx1MDAwZq5Omlxy7Ou770f2cYqj1vx2XHUwMDEwbDSqYa3x9zsrJlx1MDAxNtBcdTAwMTbjrGjMKfufJ8JSssAhXHUwMDA2mTCmkLDQimBCXHUwMDA10njBO7o1S+hq7/rgqX7aoye756x+JEreT7g+L7hekHpXq9mi4PpqaaGohVarpYWiXHUwMDE2Wlx1MDAxNSTu/LdrKEjc90hL8lx1MDAwM3NK+1x1MDAwMdKS2W6FV545P/D2r8U2uX1g3m7pIIVcdTAwMTJOT4ZcdTAwMDRcdTAwMWKBN8UtXGZLX89uds6lRE0x6zHbLpaUXHJhmVx0sFx1MDAxOLZokVx1MDAwMEskXHUwMDAwrEkmJLXQXG5ex2KLpoJcdTAwMTGl+Fx1MDAxNEb4MVwi9LrKypxcdTAwMWLQiVx1MDAxZuv7Xlx1MDAxMFZdd9E06Fx1MDAxZLaQsjwsVfBM30xfL5a6XFzMWKs05Di3Z2ZP6lhOz+RUWWpsysJbZbW/2qhIt2RmbuYgLEz6J9NcdTAwMTZcdTAwMTdibFx1MDAxZeFwXHUwMDFieqohqs7ChP5PXHUwMDE0VTeQJZQmQmiIrEJjimOVvWEpk1HKNbzA1yspVdVR+VeourmRZD/miFlO1Man+O+pw1x1MDAwNFx1MDAxM+Mn43OdMFx1MDAxNljmz+DZWGk541x1MDAwNEPcYlxcUVxyfZVUxGbXXGZWlTIrlt2LXHUwMDFj1URcdTAwMTa4otJm/bhcdTAwMTaCY8VcdTAwMTJyu8CWXHUwMDE5YWX69Yh9ccbrWFxu5pxLLWfZXHUwMDFk/1x1MDAwM1x1MDAxMWT2XFyfe+1C1tDiaFx1MDAwNDFrsFx1MDAxMGdUSCRcdTAwMThmsW9cblx1MDAxOMZcdTAwMTBpKUpcdTAwMTDFZKDJhIVcdTAwMDK5JmhkZ/q1kcWuIJDm3OhJKkFcdTAwMTNcdTAwMTa7Ti5uXaWglWG+5pgw3ClcdTAwMDNYekEmtVx1MDAxZUPgxSPIqPlxzr7/RV+454+41vNJcLHfPi61v8w+zEvGnS1HXGaLgtM0a69cYkOWXHUwMDAy41x1MDAxMvHR3P7WXHUwMDE0Slhcblxy5iB9JHL9clvlhJPE3bRiXG6O5m1Ozlx1MDAxNyNmtlx1MDAxYlxisuA5XHUwMDE5Re7iKIlGgn6U4uSfXHUwMDE32jLWtlx1MDAxNjbswE6kMzHYOFxynVx02500LjPSlXHiXHUwMDEyXHUwMDE3Zzb0wXX6d+9wRKXgU3hv9pteiPfOtnJcdTAwMTL6Oemkfe/VyFx1MDAwMlx1MDAxNEZcdTAwMTVcdTAwMTWDLT5SXdhcdTAwMTZSzu7CXHUwMDEyKFx1MDAxMVx1MDAxZONE0fYyzII0XHTBXHUwMDE0ULmEfKomp2QrwybpQjey+IDr5URcdTAwMWPZyWAkt1x1MDAxM4Q0ZYJgsGdI8pLE7lx1MDAxYS6WXHUwMDFjnZFY1FwiSSOMhvelpFmMj7hmXHQrNyUgXlx1MDAwNlxm6nVDVDkh0ypcdTAwMDGPXHUwMDA04zXHxqTdzlx0cmCduj5cdTAwMDTcQGKqRP4pIPZ3XHUwMDFmO8ebN279WpGe3fSa8uveSkBcdTAwMGXKIGhNXCJcdTAwMGU4YVx0PTZlfL6AQyVstzNcdDiEXHUwMDA2PqfVor9cdTAwMDOgyHF9yVx1MDAxOEY46lDReOOf9bb3j/Cfa2+J3lx0llx1MDAwMXYkSDVcdTAwMWL6ICT1SzpBzYarSZp/8CP7xS8z/CDMopQq8DetTd1mtIYhIMAy3Z/XVVx1MDAxMPxcdTAwMTDaklxcKjNcbkkoMPforUQ1XHJhXHKG/CZohVx1MDAxMpJDXHUwMDFlQ1x1MDAwYl2GMnBEOosj5kRcdTAwMWbZeWFtZLyDcLNcdTAwMTTCbIigXHUwMDE0I2RytENZ/bGOXHUwMDE5sUfuQVx1MDAwZVx1MDAxMMWkWbOzXHUwMDEzXHUwMDEz1HzPk1x1MDAxMlx1MDAxM7LggbxJ0qxcdTAwMTLqSLVZc2xcZs01XHJyfHpteL3a6VRCsK2h/sF8nfprpI56t9517N5mklf1XHUwMDBmXHUwMDEzXHUwMDAw+3o0YcY2ffzzr09//Vx1MDAxN7xqwPEifQ== + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1dWXPiSFx1MDAxMn7vX+HwPOzsRKOp+5iIiVxyfODbbjdu293bXHUwMDEzXHUwMDBljGSQXHUwMDExXHUwMDEylmSwPTH/favARlx1MDAwMiRZXHUwMDFjYmCnefAhQSmVyuP7KiuLPz9sbGyGz1x1MDAxZGvzt41N66lec2zTr/U2P+rjXctcdTAwMGZsz1WnUP//wHv06/13NsOwXHUwMDEz/Pbrr+2a37LCjlOrW0bXXHUwMDBlXHUwMDFla05cdTAwMTA+mrZn1L32r3ZotYP/6J+ntbb1e8drm6FvRFx1MDAxNylZplx1MDAxZHr+4FqWY7UtN1xm1Oj/Vf9vbPzZ/1x1MDAxOZPOt+phzW04Vv9cdTAwMDP9UzFcdTAwMDFcdTAwMTEg44dPPbcvLVx1MDAxNFx1MDAxY1x1MDAwYimAkMN32MGOumBomer0nVx1MDAxMtqKzpj9a1x1MDAwNVx1MDAwN7x5b1Pil1x1MDAxOWx/2nX2v5Ur0XXvbMephs/OQFx1MDAxN7V689GPSVx1MDAxNYS+17KubDNs6quPXHUwMDFkXHUwMDFmfi7wlFx1MDAxYaJP+d5jo+laQTDyXHUwMDE5r1Or2+GzPlx1MDAwNsDw6EBccr9tREee1H+UY4MxTlx1MDAwNGFcdTAwMDBxQfDwrP48ZtJcdTAwMDBEQCoxoOokXHUwMDE5k2vbc9SzUHL9ZDHOXHUwMDAxiCS7rdVbXHIlnmtcdTAwMGXfXHUwMDEz+jU36NR89cSi9/Xe7pix4bGmZTeaoTooRHQ9q693iFx1MDAwMFaCMFx1MDAxYd2FvkrnwOxcdTAwMWLBXHUwMDFm43pr1vzOq342XHUwMDAz/U9MQi3c7rhcdTAwMDXFrSj2bKl7V7rCsve1aVx1MDAxZcKb26dcdTAwMTPGYDBcdTAwMWNrxORC6yncXHUwMDFjnvjrY9aw7W9fji727ra64ZfLw9557+Wk0blNXHUwMDFltub7Xi/vuFeHl93eMdk5OfapufeMXHUwMDFlXHUwMDEx2WFcdTAwMGJcdTAwMThcdTAwMTddmVx1MDAwN/uVVv1ElFx0vGg7Z7vut8ZcdTAwMDLGLUi96zXs8ePOPpDXXHUwMDA3L0934qhSMZ9uat3eXHUwMDBm5S5m2H1rXHUwMDBmXHUwMDA33bpdvtm+XHUwMDBi7ytcdTAwMDe75fOr47mUO1wixce8N5JT3Obxdlx1MDAwZmBcdTAwMGK07zudg6Pj7svN+U6YT9zXv6JQ+Ngxa4OspYIsJJBcIiZcdTAwMDSPwq1juy110n10nOiYV29Fie5DTOCJXHUwMDE0O3L/I9lcdTAwMTWK8cNv2ZVzKiHjMMpZ7yXXbLNY2eQqspKrIFx1MDAwNl1OcqVcdMmV8vHkilR2RVx1MDAxY9LosS0sty7SXHUwMDFho4fuuWHVfunDNTZytFJr287zyHPrm6mSdFudrtmu5f/83dUnbPP375umXXO8xvfN7+6/41pcdTAwMGUsJY1cdTAwMWWeopFxyo7d0Ca+6Vh3o7ZcdTAwMWbaXG6oXHUwMDBlT7dt04xDz/rbtVx1MDAwZvJcdTAwMDBGz7dcdTAwMWK2W3Mu8kqe6abZSJhClOarkCtcdTAwMTAsOVx1MDAxMjy3s55fPDjWS3nr8XHr5MA5Obt2ryv3f6+z0nd9lVx1MDAwMYNCXHRcYodcdL5KMTOkgJwqV9W+SlN9XHUwMDE19F9z+KpcdTAwMDSTvirYhK9cdTAwMDKAMYNAXHUwMDE24KxZiem+etjYvfDc7Va9xfyn1sG31kNKYvpcdTAwMDGEp1x1MDAxZrcg9a7XsEVcdTAwMDHh9dLChd/c6d12rYtcdTAwMTBcdTAwMWY6XHUwMDEwiZfWeWc+xLqOWlg8blx1MDAxZoyLn1x1MDAxZkpfkajUQ9JcdKrW887Z9lx1MDAwM1/AuGZcdTAwMWJcXG57h597leujbafb8XdvXHUwMDFiuFCekaz4aNjXvzJcdTAwMTFcdTAwMThmJMr/RfFcdTAwMDGKU/lcdTAwMDCkilx1MDAwYlxiRkGUV9/DXHUwMDE42fa2qlx1MDAxOINmYVxmXG5ccrJcdTAwMWOMIVx1MDAxMjDGJFx1MDAxZpCAXHUwMDEzIFxiL2CubZHWOFx1MDAxZlx1MDAxZthXMPtFI2vnZ318XHUwMDAwretOLVxirEDh69vHMPTcYNnU4Fx1MDAxZFx1MDAwND1OXHKmuYlM581mXHRcXKbOlzPMXHUwMDA1Izj/bPmX8PzCe3q+2eptnZ1+uXj5/PKpzFL8d8xcdTAwMGb/LjpPODGApFxiXG7JuXJfQkb8lyNqKCpcdTAwMGJcdFx1MDAxM8rJXHRcdTAwMTfz+O9PXHUwMDEw3VxuoajxzFxmXHUwMDAxXHUwMDEyrKRcdTAwMDScocW7b1ZcdTAwMDZkrOneyy77elolnYY8wkfHj5X5gcBcdTAwMGaGUKh612vYolx1MDAxOMJ6aaEohrBeWnDk1ra43dsm24xZ1Yujp0alssK2sHyCkHwjOcWtXHUwMDFmnV6QzrF/+tXfvjWt44483vuyuELEUoiHiNWWx4lcdTAwMDeTXHUwMDEwXCKJRX7ikW1cdTAwMTcrWokgXFxmQlx1MDAxNypccrwo6DI99YBcdTAwMTFsfJvepLpARPCyS1x1MDAxMUulXHUwMDFlW31Y/vP3za+WwubJ9Fx1MDAwMpKRj1xy+UNd3Y7lz04w3oHf41x1MDAwNGNcXNRMR8wmXHUwMDExXCK2pGbMXHUwMDFiXHUwMDExXHUwMDA0XGZDOUVZkMPKWYvsud3mXvUlaODnXHUwMDEz379ebVx1MDAxNsEkMqBSXHUwMDAye3XF0UlcdTAwMDBNXCKo1GW41SBcdTAwMTFAeyElYMkk4iGQlaP2WZVcdTAwMWOet2jFNe/RzqfLXHUwMDFmJGJRJKIg9a7XsEWRiPXSQlEkYr20UFx1MDAxNIlYLy0svijyXHUwMDFlN0m+kWjY17/+flx1MDAwZSFlOodAurTBXHUwMDEwyT/5mf38VpRDMMmzgFx1MDAwYtVcZmNBwGVcdTAwMTFcdTAwMTSCS0VcdTAwMWZcYohhmv9nXG5x6i2bQbxcdTAwMDO9U1x1MDAxOcRA0kwvXHUwMDFjXHUwMDA0l1x1MDAwNDdkIL2EXGIh1oYp83thdm15NUuITFx1MDAxN1x0XHRjXHUwMDE0YFwigEQjPkhcdTAwMThVJ1x1MDAxMZVcdTAwMTJDKinjhfkgMDBcdTAwMDZUcixcdTAwMTCmXGIozdNJn2TSgMopKIVCxVx1MDAwZS7wuItcIlx1MDAwMDliKFZVyu2ifVlndVHC4UxcdTAwMGJcdTAwMGWDsOaHW7Zr2m5DnYwy3VsnSp51fX2nrj9cdTAwMDZ9LVx1MDAwMkY5xVxcqVx1MDAxMFx0XHUwMDE5X1x1MDAwM6p1UetoTmZgSVxiXHUwMDAxQFExiFV4fX3DMONuWq75vkzZXHUwMDA1xZhMJSVcdTAwMTTmXHUwMDAwqStcIlxuoZIv8rmhUMjAnDMpKEVcdTAwMTLolDAhlFNcdTAwMGLCba/dtkOl+0+e7YbjOu4rs6w9vWnVzPGz6qbi58ZDQkePOMpcIqO/Nlwin+n/M/z7j4/J7043Zv2aMONovFx1MDAwZvHfU0czXGJcdTAwMDFcdTAwMWU/PKynXHUwMDAySFx1MDAwNIQyesN70SxcdTAwMWK8rSqmYMTgXHUwMDAyMimFJJLEVozrzzNMXGYqMUOIUPVcdTAwMDY2LtdcdTAwMDIxXHUwMDA1xFx1MDAwNkZQXHUwMDFiPEbKolGk92h2XHUwMDA0XHUwMDE4XGZzhFx0xFx1MDAwNKugXHUwMDE1W58xXGJnXHUwMDAyS6DcgswwVzJXOJtcdTAwMTVx5FxmZ7lDXHUwMDA3MFxiplx1MDAxOCCgMCDAkkNcdTAwMWPzo9fIXHUwMDAxocH0Ulx1MDAxZklcdTAwMDBcdTAwMDE6XHUwMDE0z1x1MDAxNs6ywceoTFxiS8l1L1x1MDAxZmBcdTAwMDKDJJlUXHUwMDAwoFKrknKOXHUwMDEw4utcdTAwMWTO0m1ZvyaseFHRLD5tO4HNJCfKmfNcdTAwMDez7CrZqlx1MDAwNjMsXGaKXHUwMDEwg0JiXHUwMDE1zMZjXHUwMDE5M1x1MDAwMMVcdTAwMWF8MJX1i1x1MDAwYmVSKmvGkimszFWWTmr9XHUwMDEwKuhcIqFbXFxcdTAwMTGEKn5MrPyCXG6VIKpS/z81lJU0JiCSqidJmYLZQrCYXHUwMDE3vcVccmyoUKf8jFx1MDAwM4XFlabfkMGUwSy7XHUwMDE2MyqVTodSuVx1MDAxM0QqpElcdTAwMDQnhKJcdTAwMDZcdTAwMDRUhVfOgZZrUqR1XG5lpVRj1q9cdDOeMpRl1qlcdTAwMTQySVxyZyq39d08f9HYadbA2XFnL7Shf0rK7Z3wyjxdcapJdEfMSFxmQ0hcdTAwMThQQlx1MDAwMlx1MDAxOCpwcSpNoJKCXHUwMDFiiKtcdTAwMDTCJ0BcdTAwMTfGkGDFhJfcXHUwMDEw7jlP1XLrwobynpZcdTAwMWN0ePxcdTAwMTLUq/PPwJ6cXHUwMDFmvHytPF2H/o5ValaDa47I3T+wQFWQelx1MDAwYlx1MDAxYZZ37f3Kwy5s+7s35k7YRd0zZ1x1MDAxMVogwO9At1s/vapcdTAwMWOdiqudK+8kPFpd7d7et8ufnC6DNCxtmbJxdKqyWqHlg+RcdTAwMWLJKe5cdTAwMWM91pnjfjmzzmXpwWqJ3a8v95/cs8vSXU5reEtb70CkXGJHXHUwMDE3VO6gNH2elWFFXHUwMDFmXHUwMDE0ssjfXHUwMDBlmm1uq5r86Hjyo9JcdTAwMTBcbm5cdTAwMTSZ+khC6kNcdTAwMTPTpopaKFx1MDAxNlFExluk4UXPNypsIDByNKOwUa37luX+nFLS4CPvX1hJ41x1MDAxZJQ2XtJcdTAwMTjKmOljqYSZp1ZcdTAwMTSFXHUwMDAy+EDPUud2sexYtpouRlx0MahmWFx1MDAxYzLEYUxcdTAwMWT93Vx1MDAxMaA0KKSFXCJNXGJcciggZlSRKSooTZj0IypcZqQgT6hcdTAwMWVRX+5cdTAwMTkqjPOWL/gsjpiTJGd7wUZ8bo1KrtNcdTAwMDElkjCMXHUwMDEyKDIxXHUwMDAwXHUwMDA1XFxcdTAwMDfN1zNTUuMpSilMPVx1MDAwZu04XHUwMDA0ci6jRqS4KEA339F+c1x1MDAxZJZyQqJ1YsbptqtfMauNXHUwMDA2+lx1MDAxMP8929pNlE6KXHTlXGIjPsVcdTAwMWPf+YV74MnHo93OTfPo7vzm0uJcdTAwMGbWiscsjJShgdGwNNgoTShwQFx1MDAxMFx1MDAxNJz2o1x1MDAxMlx1MDAxYpNogVEr305pSGFcdTAwMDTlnXyG2upcXFx1MDAxYqV99tF1q/n5oXlwWeru032zu3OfXGZ+fyzcnH7cgtS7XsNcdTAwMTa2UdpaaaGgYYvacKEgcVx1MDAxN0/i393XLfFGcor7cIvLVVRmdyc7XHUwMDE31WvvM7l7MlvrNTeAkEhdtVx1MDAwMLFUp5mg+SdcdTAwMDey7WJVUVx1MDAwME1GXHUwMDAxglx1MDAxOHhJKCDflm5QXGJcdTAwMDSgXHUwMDEyqID+jaInXHUwMDBi5tzTLbBN67bmL3/nhkxUm2tTt1x1MDAxMdHngOtZJXmqWFx1MDAxOVx1MDAwNGCKXd26veolsvDR4fVpq1x0fdn4dmydpHhq3feCoNSshfXm3++tXHUwMDEwXHUwMDE5irlob1x1MDAwNWNe2f88YobgXHUwMDA1zjJwPumpXHSdVlxiXCLMgIRL3tCtVVx1MDAwNtf7N4dP5llcdTAwMGaf7l1cdTAwMTDzmJXdXHUwMDFmgH1RgL0g9a7XsEVcdTAwMDH29dJCUZ1W66WFojqtXG5cdTAwMTJ38ds1XHUwMDE0JO57tCX5gjmlnYO2ZI5bpdVnSlx1MDAwZt2DXHUwMDFitoPuXHUwMDFliLtXPkwhhStLh6RM7WdXUiBAOEf5XHUwMDBiOdl2saJ0XGLyTIBFoIGLXHUwMDA0WCxcdTAwMDFgTVIhLplUkFx1MDAxN69h2XTqfjB9bMAnvm9cdTAwMWW4QVhznGXzoHfYQkp7WKrgmb6Z3i+W3rQpOVx1MDAwMphROMWOdZnrOlbTNSlcdTAwMTaGXHUwMDE4W7XwVl3tN1x1MDAxY1x1MDAxNemXRK/PXHUwMDFjxIVJXHUwMDA3JdKgjI0tJVx1MDAxY+5Dj1VUXHUwMDE1s1CheVdcdTAwMWbP5KqLLqyWgMGERIxJXHUwMDE1WpmEOKr3bkTlTIIxlVGFL6WyOir/XHUwMDFhVThLSfajXzHLicb4XHUwMDEw/z11nIgtZlx1MDAxYe/DYlxcb6RI85c1s7HSaoZcdFx1MDAwMqhBqMBcdTAwMTKqZIpZbHnBoK2UXHUwMDE4sexe5LQm0F83JaTuXHUwMDFml0xFZ0FcdTAwMTJyO4OGnmIl8vVcdTAwMTX74ozXuVx1MDAxNEgp5ZLPsjn+Klx1MDAwN5DsucXRXHUwMDAwotuwXHUwMDAwJZjplUSQxL4oYFx1MDAxOEK4IbBKglx1MDAxMFxyNJnQK5BrjUZ2pt9cdTAwMThpd1VcdTAwMDJJSlx0XHUwMDEyglx1MDAwYoZcdTAwMTPaXSfbW9cpZmWYr35NXHUwMDE47pTxK70kk1qRwSp5UEbzdypcdTAwMWP4n+Wlc/FcYus9XHUwMDFmXHUwMDA1l1x1MDAwN95J2fs8+ywvXHUwMDFh97VcdTAwMWMhLIpN03RfIVx1MDAwMlxmoWyLxSdz+ztTXGJmXGIwWIU0T+D66a5GXHUwMDExRYm7aaHoPqOFm5MrxpBe76ZcdTAwMDRZ8qKM4jdxXFzewtC2traNsGlcdTAwMDVWXCKbiYHGadhM6HXSqMzIrYzzlrg4s2FcdTAwMGYq0796R1LdcVx1MDAxZW98fs99s1x1MDAxZvVS3He25knMXHUwMDEzvLTvvlx1MDAxMlx1MDAxOFhcbiwwXHUwMDAzXHUwMDFjXHUwMDAzmr6pRY6vycrwYa5cdTAwMThcdTAwMTFcdTAwMWWjRNH2MsRQaVx1MDAxMlx1MDAwMVxyyrnKp5N7WWi6XCJcdTAwMTBe9lZcdTAwMTZcdTAwMDUjjuxsMJLbXHUwMDExXHUwMDAwXHUwMDEyXHUwMDEzhiBVhEVSjmLvXHUwMDFh9ktKQvHMi0Fz90lqYaR6XoLrfnxAJUlo3uRcbvFcdTAwMTJFoF43ROVcdTAwMTMyrVx1MDAxM/BIMF79Kk3a7YIgXHUwMDA3lOlcdTAwMWRcIpwjpU+FP3NHLeubXHUwMDBm7ZOtW8e8XHUwMDExqGe13Fx1MDAxNv+yv1x1MDAxNqBcdTAwMDNcdTAwMTNcdTAwMTW1JjGHOmAw2bf1oiCHiEJNXHUwMDA25GBSXHUwMDExOimW/SVcdTAwMDCrNbE/XHUwMDFm4vjF9Nx/hb9svKV6O1hcdTAwMDXgkSDVbPhcdTAwMDPFZsTH9+NcdTAwMDVUXHUwMDA1T8R5fk/OfvCrjD9cdTAwMTAxMMaC6u2zIIjvXHUwMDEw1XdoXHUwMDE1YYnsr+wqXGJ/MGlwyoWKXHUwMDE5XHUwMDEyYUXdo4npqKjBjMGU31x1MDAwNLFcdTAwMTCMU5XIwLJbUVxuhlx1MDAxZtl5YWNkwlx1MDAwMynlYaQ3RVx1MDAxMIIgNDndIYz+ZMeM4CP3LIdcdTAwMTJF51lcYlx1MDAwMVwiXGbr73lcdTAwMTJsQlx1MDAxNjiQN0madYJcdTAwMWSpNqtfpaG5pmGOXHUwMDBmr1x1MDAwM2/WOp1qqGxrqH9lvrb5XHUwMDFhqaO72+zaVm8ryav6L1x1MDAxZFx1MDAwMPt61GHG0vf4519cdTAwMWb++lx1MDAxZow/wb0ifQ== - Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install")match these*don't* match this \ No newline at end of file + Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install")match these*don't* match this \ No newline at end of file diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 8374e4327..6b0f58aa0 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -15,7 +15,7 @@ class HeaderIcon(Widget): HeaderIcon { dock: left; padding: 0 1; - width: 10; + width: 8; content-align: left middle; } """ From 8f51d2a52ed22960d6c2dbbb263865e0b1c82ad8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 09:27:30 +0100 Subject: [PATCH 54/73] docstring for log --- docs/guide/devtools.md | 2 +- src/textual/app.py | 15 +++++++++++---- src/textual/message_pump.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index 8a3b9b2c5..b06b9b339 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -47,5 +47,5 @@ In the other console, run your application using `textual run` and the `--dev` s 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()` method on App and Widget objects for advanced formatting. Try it with `self.log(self.tree)`. +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)`. diff --git a/src/textual/app.py b/src/textual/app.py index 3cfd592a4..f564ae148 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -487,13 +487,20 @@ class App(Generic[ReturnType], DOMNode): _textual_calling_frame: inspect.FrameInfo | None = None, **kwargs, ) -> None: - """Write to logs. + """Write to logs or devtools. + + Positional args will logged. Keyword args will be prefixed with the key. + + Example: + ```python + data = [1,2,3] + self.log("Hello, World", state=data) + self.log(self.tree) + self.log(locals()) + ``` Args: - *objects (Any): Positional arguments are converted to string and written to logs. verbosity (int, optional): Verbosity level 0-3. Defaults to 1. - _textual_calling_frame (inspect.FrameInfo | None): The frame info to include in - the log message sent to the devtools server. """ if verbosity > self.log_verbosity: return diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c772c7b09..896529cfa 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -110,9 +110,33 @@ class MessagePump(metaclass=MessagePumpMeta): def is_running(self) -> bool: return self._running - def log(self, *args, **kwargs) -> None: - """Write to logs or devtools.""" - return self.app.log(*args, **kwargs, _textual_calling_frame=inspect.stack()[1]) + def log( + self, + *args: Any, + verbosity: int = 1, + **kwargs, + ) -> None: + """Write to logs or devtools. + + Positional args will logged. Keyword args will be prefixed with the key. + + Example: + ```python + data = [1,2,3] + self.log("Hello, World", state=data) + self.log(self.tree) + self.log(locals()) + ``` + + Args: + verbosity (int, optional): Verbosity level 0-3. Defaults to 1. + """ + return self.app.log( + *args, + **kwargs, + verbosity=verbosity, + _textual_calling_frame=inspect.stack()[1], + ) def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. From 777223b03c273a17ff31391991147274d81ddd4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:21:45 +0100 Subject: [PATCH 55/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/events/key.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/events/key.md b/docs/events/key.md index 04a0efa49..cc7e8fb3f 100644 --- a/docs/events/key.md +++ b/docs/events/key.md @@ -1,6 +1,6 @@ # Key -The `Key` event is sent to a widget when the user presses key on the keyboard. +The `Key` event is sent to a widget when the user presses a key on the keyboard. - [x] Bubbles From 2d22f65f2829f2f092c9e42a930a17243fae50ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:22:07 +0100 Subject: [PATCH 56/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/events/load.md | 2 +- docs/events/mouse_click.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/events/load.md b/docs/events/load.md index 661ec9899..0d01fba79 100644 --- a/docs/events/load.md +++ b/docs/events/load.md @@ -1,4 +1,4 @@ -# Mount +# Load The `Load` event is sent to the app prior to switching the terminal in to application mode. diff --git a/docs/events/mouse_click.md b/docs/events/mouse_click.md index 0b175a4a3..00f6d3b47 100644 --- a/docs/events/mouse_click.md +++ b/docs/events/mouse_click.md @@ -11,10 +11,10 @@ The `Click` event is sent to a widget when the user clicks a mouse button. | `x` | int | Mouse x coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget | | `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in 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 | Shift 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 | From 0a570b8397289f90374a6143688da7c7275a4b04 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:22:22 +0100 Subject: [PATCH 57/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/events/mouse_release.md | 2 +- docs/events/mouse_scroll_down.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md index e6f7cea3a..6ddf87487 100644 --- a/docs/events/mouse_release.md +++ b/docs/events/mouse_release.md @@ -1,6 +1,6 @@ # MouseRelease -The `MouseRelease` event is sent to a widget it is no longer receiving mouse events outside of its borders. +The `MouseRelease` event is sent to a widget when it is no longer receiving mouse events outside of its borders. - [ ] Bubbles diff --git a/docs/events/mouse_scroll_down.md b/docs/events/mouse_scroll_down.md index 815f43dc8..14535f51e 100644 --- a/docs/events/mouse_scroll_down.md +++ b/docs/events/mouse_scroll_down.md @@ -1,6 +1,6 @@ # MouseScrollDown -The `MouseScrollDown` event is sent to a widget when scroll wheel (or trackpad equivalent) is moved _down_. +The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _down_. - [x] Bubbles From a3d3823c2ba45391fd6a566fbffff4aba22c8c39 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:22:49 +0100 Subject: [PATCH 58/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/events/mouse_scroll_up.md | 2 +- docs/events/resize.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/events/mouse_scroll_up.md b/docs/events/mouse_scroll_up.md index cb70b9ae2..ead79e2d8 100644 --- a/docs/events/mouse_scroll_up.md +++ b/docs/events/mouse_scroll_up.md @@ -1,6 +1,6 @@ # MouseScrollUp -The `MouseScrollUp` event is sent to a widget when scroll wheel (or trackpad equivalent) is moved _up_. +The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _up_. - [x] Bubbles diff --git a/docs/events/resize.md b/docs/events/resize.md index f8f2ad062..4ccd2e4d4 100644 --- a/docs/events/resize.md +++ b/docs/events/resize.md @@ -8,6 +8,6 @@ The `Resize` event is sent to a widget when its size changes and when it is firs | 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). | +| `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) | From 1ee962516a678695606bb66214f5cbcf0f35e5d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:25:46 +0100 Subject: [PATCH 59/73] Update docs/guide/CSS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 3f25742de..b0719e430 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -54,7 +54,7 @@ Header { 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 a 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 naturally appropriate for a header. +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 naturally appropriate for a header. You may be able to guess what some of the the other rules do. We will cover those later. From 81202d15eb38d47c212d10615e0ffc24cc777d93 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:28:38 +0100 Subject: [PATCH 60/73] Update docs/guide/CSS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index b0719e430..2eb20b6ea 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -62,7 +62,7 @@ You may be able to guess what some of the the other rules do. We will cover thos 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 that forms a tree of sorts. -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. +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. From 9d4ec4bd0f00abef7b1ac16058ae730a33d49582 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:28:51 +0100 Subject: [PATCH 61/73] Update docs/guide/CSS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 2eb20b6ea..4bb1ea910 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -98,7 +98,7 @@ This doesn't look much like a tree yet. Let's add a header and a footer to this ```{.textual path="docs/examples/guide/dom2.py"} ``` -With a header and a footer widget the DOM look the this: +With a header and a footer widget the DOM looks the this:
--8<-- "docs/images/dom2.excalidraw.svg" From 0cc48076c7f3477163b152e1d6945a62ac7673fb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:29:28 +0100 Subject: [PATCH 62/73] rephrase --- docs/events/mouse_down.md | 4 ++-- docs/events/mouse_move.md | 5 +++-- docs/events/mouse_up.md | 5 +++-- docs/guide/CSS.md | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/events/mouse_down.md b/docs/events/mouse_down.md index c39b2e853..8cfda384c 100644 --- a/docs/events/mouse_down.md +++ b/docs/events/mouse_down.md @@ -11,10 +11,10 @@ The `MouseDown` event is sent to a widget when a mouse button is pressed. | `x` | int | Mouse x coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget | | `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in 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 | Shift 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 | diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md index 765f2f64d..21cc56bce 100644 --- a/docs/events/mouse_move.md +++ b/docs/events/mouse_move.md @@ -11,10 +11,11 @@ The `MouseMove` event is sent to a widget when the mouse pointer is moved over a | `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 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 | Shift 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 | + diff --git a/docs/events/mouse_up.md b/docs/events/mouse_up.md index a19dcf4bd..656f3fde2 100644 --- a/docs/events/mouse_up.md +++ b/docs/events/mouse_up.md @@ -11,10 +11,11 @@ The `MouseUp` event is sent to a widget when the user releases a mouse button. | `x` | int | Mouse x coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget | | `delta_x` | int | Change in x since last mouse event | -| `delta_y` | int | Change in 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 | Shift 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 | + diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 4bb1ea910..7742191fd 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -60,7 +60,7 @@ You may be able to guess what some of the the other rules do. We will cover thos ## 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 that forms a tree of sorts. +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. From df2d12510adcb065a3e01f119aa77ca11122f40e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:30:11 +0100 Subject: [PATCH 63/73] Update docs/guide/CSS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 7742191fd..7e5e96c65 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -147,7 +147,7 @@ To add a stylesheet we need to pass the path to a CSS file via the app classes' --8<-- "docs/examples/guide/dom4.py" ``` -You may have noticed that some of the constructors have additional keywords argument: `id` and `classes`. These are used by the CSS to identify parts of the DOM. We will cover these in the next section. +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: From a15cb4413d5470bdf4cf255be6ca9f93269ee44b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 10:30:22 +0100 Subject: [PATCH 64/73] Update docs/guide/CSS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 7e5e96c65..fcb25fb71 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -155,7 +155,7 @@ Here's the CSS file we are applying: --8<-- "docs/examples/guide/dom4.css" ``` -The CSS contains a number of rules 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. +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: From f19b90195010abaedbac7597afd49dc45ac20f6c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 11:24:44 +0100 Subject: [PATCH 65/73] fix for scrolling --- src/textual/_xterm_parser.py | 2 +- src/textual/widget.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 94400e2c5..55883a7fa 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -63,7 +63,7 @@ class XTermParser(Parser[events.Event]): event: events.Event if buttons & 64: event = ( - events.MouseScrollDown if button == 1 else events.MouseScrollUp + events.MouseScrollUp if button == 1 else events.MouseScrollDown )(sender, x, y) else: event = ( diff --git a/src/textual/widget.py b/src/textual/widget.py index 4c23063c0..5f10378f3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -194,7 +194,9 @@ class Widget(DOMNode): Returns: bool: True if horizontal and vertical scrolling is enabled. """ - return self.allow_horizontal_scroll and self.allow_vertical_scroll + return self.is_scrollable and ( + self.allow_horizontal_scroll or self.allow_vertical_scroll + ) def _arrange(self, size: Size) -> DockArrangeResult: """Arrange children. From 6e451d42177f7fa3ce2c1325385cfa241d05e5e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 13:03:07 +0100 Subject: [PATCH 66/73] fix for mouse scroll tests --- src/textual/events.py | 9 +++- tests/test_xterm_parser.py | 101 ++++++++++++++++++++++--------------- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index 2d2a0594c..c8bad966e 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -359,8 +359,13 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True): self.y = y -class MouseScrollUp(MouseScrollDown, verbosity=3, bubble=True): - pass +class MouseScrollUp(InputEvent, verbosity=3, bubble=True): + __slots__ = ["x", "y"] + + def __init__(self, sender: MessageTarget, x: int, y: int) -> None: + super().__init__(sender) + self.x = x + self.y = y class Click(MouseEvent, bubble=True): diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index e407f4d97..cdeafe1ef 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -37,7 +37,7 @@ def parser(): return XTermParser(sender=mock.sentinel, more_data=lambda: False) -@pytest.mark.parametrize("chunk_size", [2,3,4,5,6]) +@pytest.mark.parametrize("chunk_size", [2, 3, 4, 5, 6]) def test_varying_parser_chunk_sizes_no_missing_data(parser, chunk_size): end = "\x1b[8~" text = "ABCDEFGH" @@ -54,7 +54,7 @@ def test_varying_parser_chunk_sizes_no_missing_data(parser, chunk_size): def test_bracketed_paste(parser): - """ When bracketed paste mode is enabled in the terminal emulator and + """When bracketed paste mode is enabled in the terminal emulator and the user pastes in some text, it will surround the pasted input with the escape codes "\x1b[200~" and "\x1b[201~". The text between these codes corresponds to a single `Paste` event in Textual. @@ -90,7 +90,7 @@ def test_bracketed_paste_amongst_other_codes(parser): def test_cant_match_escape_sequence_too_long(parser): - """ The sequence did not match, and we hit the maximum sequence search + """The sequence did not match, and we hit the maximum sequence search length threshold, so each character should be issued as a key-press instead. """ sequence = "\x1b[123456789123456789123" @@ -109,15 +109,22 @@ def test_cant_match_escape_sequence_too_long(parser): assert events[index].key == character -@pytest.mark.parametrize("chunk_size", [ - pytest.param(2, marks=pytest.mark.xfail(reason="Fails when ESC at end of chunk")), - 3, - pytest.param(4, marks=pytest.mark.xfail(reason="Fails when ESC at end of chunk")), - 5, - 6, -]) +@pytest.mark.parametrize( + "chunk_size", + [ + pytest.param( + 2, marks=pytest.mark.xfail(reason="Fails when ESC at end of chunk") + ), + 3, + pytest.param( + 4, marks=pytest.mark.xfail(reason="Fails when ESC at end of chunk") + ), + 5, + 6, + ], +) def test_unknown_sequence_followed_by_known_sequence(parser, chunk_size): - """ When we feed the parser an unknown sequence followed by a known + """When we feed the parser an unknown sequence followed by a known sequence. The characters in the unknown sequence are delivered as keys, and the known escape sequence that follows is delivered as expected. """ @@ -174,16 +181,19 @@ def test_double_escape(parser): assert [event.key for event in events] == ["escape"] -@pytest.mark.parametrize("sequence, event_type, shift, meta", [ - # Mouse down, with and without modifiers - ("\x1b[<0;50;25M", MouseDown, False, False), - ("\x1b[<4;50;25M", MouseDown, True, False), - ("\x1b[<8;50;25M", MouseDown, False, True), - # Mouse up, with and without modifiers - ("\x1b[<0;50;25m", MouseUp, False, False), - ("\x1b[<4;50;25m", MouseUp, True, False), - ("\x1b[<8;50;25m", MouseUp, False, True), -]) +@pytest.mark.parametrize( + "sequence, event_type, shift, meta", + [ + # Mouse down, with and without modifiers + ("\x1b[<0;50;25M", MouseDown, False, False), + ("\x1b[<4;50;25M", MouseDown, True, False), + ("\x1b[<8;50;25M", MouseDown, False, True), + # Mouse up, with and without modifiers + ("\x1b[<0;50;25m", MouseUp, False, False), + ("\x1b[<4;50;25m", MouseUp, True, False), + ("\x1b[<8;50;25m", MouseUp, False, True), + ], +) def test_mouse_click(parser, sequence, event_type, shift, meta): """ANSI codes for mouse should be converted to Textual events""" events = list(parser.feed(sequence)) @@ -201,12 +211,15 @@ def test_mouse_click(parser, sequence, event_type, shift, meta): assert event.shift is shift -@pytest.mark.parametrize("sequence, shift, meta, button", [ - ("\x1b[<32;15;38M", False, False, 1), # Click and drag - ("\x1b[<35;15;38M", False, False, 0), # Basic cursor movement - ("\x1b[<39;15;38M", True, False, 0), # Shift held down - ("\x1b[<43;15;38M", False, True, 0), # Meta held down -]) +@pytest.mark.parametrize( + "sequence, shift, meta, button", + [ + ("\x1b[<32;15;38M", False, False, 1), # Click and drag + ("\x1b[<35;15;38M", False, False, 0), # Basic cursor movement + ("\x1b[<39;15;38M", True, False, 0), # Shift held down + ("\x1b[<43;15;38M", False, True, 0), # Meta held down + ], +) def test_mouse_move(parser, sequence, shift, meta, button): events = list(parser.feed(sequence)) @@ -222,12 +235,15 @@ def test_mouse_move(parser, sequence, shift, meta, button): assert event.button == button -@pytest.mark.parametrize("sequence", [ - "\x1b[<64;18;25M", - "\x1b[<68;18;25M", - "\x1b[<72;18;25M", -]) -def test_mouse_scroll_down(parser, sequence): +@pytest.mark.parametrize( + "sequence", + [ + "\x1b[<64;18;25M", + "\x1b[<68;18;25M", + "\x1b[<72;18;25M", + ], +) +def test_mouse_scroll_up(parser, sequence): """Scrolling the mouse with and without modifiers held down. We don't currently capture modifier keys in scroll events. """ @@ -237,24 +253,27 @@ def test_mouse_scroll_down(parser, sequence): event = events[0] - assert isinstance(event, MouseScrollDown) + assert isinstance(event, MouseScrollUp) assert event.x == 17 assert event.y == 24 -@pytest.mark.parametrize("sequence, shift, meta", [ - ("\x1b[<65;18;25M", False, False), - ("\x1b[<69;18;25M", True, False), - ("\x1b[<73;18;25M", False, True), -]) -def test_mouse_scroll_up(parser, sequence, shift, meta): +@pytest.mark.parametrize( + "sequence", + [ + "\x1b[<65;18;25M", + "\x1b[<69;18;25M", + "\x1b[<73;18;25M", + ], +) +def test_mouse_scroll_down(parser, sequence): events = list(parser.feed(sequence)) assert len(events) == 1 event = events[0] - assert isinstance(event, MouseScrollUp) + assert isinstance(event, MouseScrollDown) assert event.x == 17 assert event.y == 24 From a09f5726ecae47e35e65223f675fe241297b53a7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 15:37:52 +0100 Subject: [PATCH 67/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/CSS.md | 16 ++++++++-------- docs/guide/devtools.md | 4 ++-- docs/reference/message_pump.md | 2 +- docs/styles/margin.md | 2 +- docs/styles/max_height.md | 2 +- docs/styles/overflow.md | 2 +- docs/styles/padding.md | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index fcb25fb71..5a79ba362 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -181,9 +181,9 @@ Being able to iterate on the design without restarting the Python code can make ## 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. +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 customization your user interface. +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. @@ -245,7 +245,7 @@ A Widget's `id` attribute can not be changed after the Widget has been construct 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 widgets `classes` parameter in the constructor. Here's an example: +CSS classes are set via the widget's `classes` parameter in the constructor. Here's an example: ```python yield Button(classes="success") @@ -280,18 +280,18 @@ Class name selectors may be _chained_ together by appending another full stop an } ``` -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. +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 its not already present. +- [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_ selectors is denoted by an asterisk and will match _all_ widgets. +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: @@ -340,9 +340,9 @@ Let's say we want to make the text of the buttons in the dialog bold, but we _do } ``` -The `#dialog Button` selector matches all buttons that are below the widget with an id of "dialog". No other buttons will be matched. +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"`: +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 { diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index b06b9b339..487700c8e 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -16,7 +16,7 @@ You can run Textual apps with the `run` subcommand. If you supply a path to a Py textual run my_app.py ``` -The `run` sub-command assumes you have a 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: +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 @@ -28,7 +28,7 @@ textual run my_app.py:alternative_app ## 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 it's own which has some super helpful features. +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: diff --git a/docs/reference/message_pump.md b/docs/reference/message_pump.md index abd106c54..79b0dc458 100644 --- a/docs/reference/message_pump.md +++ b/docs/reference/message_pump.md @@ -1,5 +1,5 @@ A message pump is a class that processes messages. -It is a base class for the App, Screen, and Widgets. +It is a base class for the `App`, `Screen`, and `Widget` classes. ::: textual.message_pump.MessagePump diff --git a/docs/styles/margin.md b/docs/styles/margin.md index 6cfea022d..329ff425d 100644 --- a/docs/styles/margin.md +++ b/docs/styles/margin.md @@ -20,7 +20,7 @@ margin: ; ## Example -In this example we add a large margin to a some static text. +In this example we add a large margin to some static text. === "margin.py" diff --git a/docs/styles/max_height.md b/docs/styles/max_height.md index 9b766388a..faecdffd9 100644 --- a/docs/styles/max_height.md +++ b/docs/styles/max_height.md @@ -1,6 +1,6 @@ # Max-height -The `max-height` rule sets a maximum width for a widget. +The `max-height` rule sets a maximum height for a widget. ## Syntax diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md index e4b72485b..af109c3b0 100644 --- a/docs/styles/overflow.md +++ b/docs/styles/overflow.md @@ -24,7 +24,7 @@ overflow-y: [auto|hidden|scroll]; Here we split the screen in to left and right sections, each with three vertically scrolling widgets that do not fit in to the height of the terminal. -The left side has `overflow-y: auto` (the default) and will automatically show a scrollbar. The right side has `overflow-y: hidden` which will prevent a scrollbar from being show. +The left side has `overflow-y: auto` (the default) and will automatically show a scrollbar. The right side has `overflow-y: hidden` which will prevent a scrollbar from being shown. === "overflow.py" diff --git a/docs/styles/padding.md b/docs/styles/padding.md index ee0ae9f1f..6336a3d5f 100644 --- a/docs/styles/padding.md +++ b/docs/styles/padding.md @@ -8,7 +8,7 @@ The padding rule adds space around the content of a widget. You can specify padd | `padding: 1 2;` | Two values sets the padding for the top/bottom and left/right edges | | `padding: 1 2 3 4;` | Four values sets top, right, bottom, and left padding independently | -Padding may also be set individually by setting `padding-top`, `padding-right`, `padding-bottom`, or `padding-left` to an single value. +Padding may also be set individually by setting `padding-top`, `padding-right`, `padding-bottom`, or `padding-left` to a single value. ## Syntax From f28913e25fae5c4bd632990ce904e7d83c84c74a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 15:44:55 +0100 Subject: [PATCH 68/73] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/introduction.md | 26 +++++++++++++------------- docs/styles/margin.md | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index a23b07188..d53be48f5 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -25,7 +25,7 @@ Here's what the finished app will look like: ### Get the code -If you want to try the finished Stopwatch app and following along with the code first make sure you have [textual installed](getting_started.md) then check out the [Textual](https://github.com/Textualize/textual) GitHub repository: +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" @@ -56,9 +56,9 @@ python stopwatch.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 wether you add them to your own projects. + 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, its 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. +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: @@ -112,13 +112,13 @@ Widgets are re-usable components responsible for managing a part of the screen. 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. -There are three methods in our stopwatch app currently. +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` it 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_. +- **`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. +- **`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 @@ -171,7 +171,7 @@ The Button constructor takes a label to be displayed in the button ("Start", "St ### Composing the widgets -To see our widgets with we first need to yield them from the app's `compose()` method: +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. @@ -199,7 +199,7 @@ self.styles.color = "white" 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! -While its 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. +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. Let's add a CSS file to our application. @@ -207,7 +207,7 @@ Let's add a CSS file to our application. --8<-- "docs/examples/introduction/stopwatch03.py" ``` -Adding the `css_path` attribute to the app constructor tells textual to load the following file when it starts the app: +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" @@ -284,7 +284,7 @@ The last 3 blocks have a slightly different format. When the declaration begins 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 dsplay 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. +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 @@ -320,7 +320,7 @@ Modifying a widget's CSS classes it a convenient way to modify visuals without i 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 a event handler for the `Button.Pressed` event. +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" @@ -418,13 +418,13 @@ 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 let to implement is the ability to add and remove timers. +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 it's `remove()` method. +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. diff --git a/docs/styles/margin.md b/docs/styles/margin.md index 329ff425d..8f34b0fc6 100644 --- a/docs/styles/margin.md +++ b/docs/styles/margin.md @@ -8,7 +8,7 @@ The `margin` rule adds space around the entire widget. Margin may be specified w | `margin: 1 2;` | Two values sets the margin for the top/bottom and left/right edges | | `margin: 1 2 3 4;` | Four values sets top, right, bottom, and left margins independently | -Margin may also be set individually by setting `margin-top`, `margin-right`, `margin-bottom`, or `margin-left` to an single value. +Margin may also be set individually by setting `margin-top`, `margin-right`, `margin-bottom`, or `margin-left` to a single value. ## Syntax From 72327d6392d11476c05c124767dca31ad6748525 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:17:16 +0100 Subject: [PATCH 69/73] Update docs/styles/height.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/styles/height.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/styles/height.md b/docs/styles/height.md index 020b1ad69..589806815 100644 --- a/docs/styles/height.md +++ b/docs/styles/height.md @@ -10,7 +10,7 @@ height: ; ## Example -This examples applies a widget with a height of 50% of the screen. +This examples creates a widget with a height of 50% of the screen. === "height.py" From 732af87936765fe9a47f1e5221c4e119420e030d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:40:04 +0100 Subject: [PATCH 70/73] Apply suggestions from code review Co-authored-by: darrenburns --- docs/events/descendant_focus.md | 2 +- docs/events/load.md | 4 ++-- docs/events/mouse_release.md | 2 +- docs/guide/CSS.md | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/events/descendant_focus.md b/docs/events/descendant_focus.md index 7d51673be..6652ad04f 100644 --- a/docs/events/descendant_focus.md +++ b/docs/events/descendant_focus.md @@ -1,6 +1,6 @@ # DescendantFocus -The `DescendantFocus` event is sent to a widget when one of its children receives input focus. +The `DescendantFocus` event is sent to a widget when one of its descendants receives focus. - [x] Bubbles diff --git a/docs/events/load.md b/docs/events/load.md index 0d01fba79..e90a3b9ee 100644 --- a/docs/events/load.md +++ b/docs/events/load.md @@ -1,8 +1,8 @@ # Load -The `Load` event is sent to the app prior to switching the terminal in to application mode. +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. +The load event is typically used to do any setup actions required by the app that don't change the display. - [ ] Bubbles diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md index 6ddf87487..3b70ad2d5 100644 --- a/docs/events/mouse_release.md +++ b/docs/events/mouse_release.md @@ -8,4 +8,4 @@ The `MouseRelease` event is sent to a widget when it is no longer receiving mous | attribute | type | purpose | | ---------------- | ------ | -------------------------------------------- | -| `mouse_position` | Offset | Mouse coordinates when the mouse was release | +| `mouse_position` | Offset | Mouse coordinates when the mouse was released | diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 5a79ba362..8ffb06928 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -54,7 +54,7 @@ Header { 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 naturally appropriate for a header. +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. You may be able to guess what some of the the other rules do. We will cover those later. @@ -77,7 +77,7 @@ Let's look at a trivial Textual app. ```{.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 the app. +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: @@ -169,7 +169,7 @@ It is reasonable to ask why use CSS at all? Python is a powerful and expressive 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-part widgets just as easily as you can your own app or widgets. +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: @@ -177,7 +177,7 @@ Finally, Textual CSS allows you to _live edit_ the styles in your app. If you ru textual run my_app.py --dev ``` -Being able to iterate on the design without restarting the Python code can make it easier and faster to design beautiful interfaces. +Being able to iterate on the design without restarting the application makes it easier and faster to design beautiful interfaces. ## Selectors @@ -270,7 +270,7 @@ To match a Widget with a given class in CSS you can precede the class name with !!! note - You can apply a class name to any class, which means that widgets of different types could share classes. + 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. @@ -313,7 +313,7 @@ Button:hover { 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 such pseudo classes: +Here are some other pseudo classes: - `:focus` Matches widgets which have input focus. - `:focus-within` Matches widgets with a focused a child widget. @@ -324,7 +324,7 @@ More sophisticated selectors can be created by combining simple selectors. The r ### Descendant combinator -If you separate two selectors with a space it will match widgets with the second selector that have a parent that matches the first selector. +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: From f7d8c6ad4a9d289baa3eeadff7f2a2e2b13bf812 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:58:17 +0100 Subject: [PATCH 71/73] Apply suggestions from code review Co-authored-by: darrenburns --- docs/introduction.md | 8 ++++---- src/textual/app.py | 6 +++--- src/textual/dom.py | 2 +- src/textual/message_pump.py | 2 +- src/textual/reactive.py | 2 +- src/textual/timer.py | 2 +- src/textual/widget.py | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/introduction.md b/docs/introduction.md index d53be48f5..062bb1958 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -335,7 +335,7 @@ If you run "stopwatch04.py" now you will be able to toggle between the two state ## Reactive attributes -A reoccurring 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. +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. @@ -343,7 +343,7 @@ You can declare a reactive attribute with `textual.reactive.Reactive`. Let's use --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 time to be displayed on the Stopwatch. +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. @@ -358,7 +358,7 @@ The `time` attribute has a simple float as the default value, so `self.time` wil !!! 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. + 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. @@ -371,7 +371,7 @@ The end result is that the `Stopwatch` widgets show the time elapsed since the w ```{.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. +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 diff --git a/src/textual/app.py b/src/textual/app.py index f564ae148..38afb8677 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -138,7 +138,7 @@ class App(Generic[ReturnType], DOMNode): driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "". log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1. - title (str, optional): Default title of the application. Defaults to "Textual Application". + title (str | None, optional): Title of the application. If ``None``, the title is set to the name of the ``App`` subclass. Defaults to ``None``. css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. watch_css (bool, optional): Watch CSS for changes. Defaults to False. @@ -476,7 +476,7 @@ class App(Generic[ReturnType], DOMNode): """Get the size of the terminal. Returns: - Size: SIze of the terminal + Size: Size of the terminal """ return Size(*self.console.size) @@ -618,7 +618,7 @@ class App(Generic[ReturnType], DOMNode): to run forever. Defaults to None. headless (bool, optional): Run in "headless" mode (don't write to stdout). press (str, optional): An iterable of keys to simulate being pressed. - screenshot (str, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. + screenshot (bool, optional): Take a screenshot after pressing keys (svg data stored in self._screenshot). Defaults to False. screenshot_title (str | None, optional): Title of screenshot, or None to use App title. Defaults to None. Returns: diff --git a/src/textual/dom.py b/src/textual/dom.py index 86a509858..f2c3ca31e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -314,7 +314,7 @@ class DOMNode(MessagePump): @property def display(self) -> bool: """ - Check if this widget should display or note. + Check if this widget should display or not. Returns: bool: ``True`` if this DOMNode is displayed (``display != "none"``) otherwise ``False`` . diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 896529cfa..7f8d0bb91 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -214,7 +214,7 @@ class MessagePump(metaclass=MessagePumpMeta): Args: delay (float): Time to wait before invoking callback. - callback (TimerCallback | None, optional): Callback to call after time has expired.. Defaults to None. + callback (TimerCallback | None, optional): Callback to call after time has expired. Defaults to None. name (str | None, optional): Name of the timer (for debug). Defaults to None. pause (bool, optional): Start timer paused. Defaults to False. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 394b2a266..dcc9757c3 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -70,7 +70,7 @@ class Reactive(Generic[ReactiveType]): repaint (bool, optional): Perform a repaint on change. Defaults to True. Returns: - Reactive: _description_ + Reactive: A Reactive instance which calls watchers or initialize. """ return cls(default, layout=layout, repaint=repaint, init=True) diff --git a/src/textual/timer.py b/src/textual/timer.py index 14cb2837a..824c9b356 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -41,7 +41,7 @@ class Timer: sender (MessageTarget): The sender of the event. name (str | None, optional): A name to assign the event (for debugging). Defaults to None. callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None. - repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None. + repeat (int | None, optional): The number of times to repeat the timer, or None to repeat forever. Defaults to None. skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True. pause (bool, optional): Start the timer paused. Defaults to False. """ diff --git a/src/textual/widget.py b/src/textual/widget.py index 5f10378f3..32cda2fb2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -189,7 +189,7 @@ class Widget(DOMNode): @property def _allow_scroll(self) -> bool: - """Check if both axis may be scrolled. + """Check if both axes may be scrolled. Returns: bool: True if horizontal and vertical scrolling is enabled. @@ -668,7 +668,7 @@ class Widget(DOMNode): @property def _focus_sort_key(self) -> tuple[int, int]: - """Key function to sort widgets in to tfocus order.""" + """Key function to sort widgets in to focus order.""" x, y, _, _ = self.virtual_region top, _, _, left = self.styles.margin return y - top, x - left From ff579ea526d0f55266b4cf41335816d38fc43557 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:58:46 +0100 Subject: [PATCH 72/73] Review changes --- docs/examples/demo.py | 1 - docs/guide/CSS.md | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/examples/demo.py b/docs/examples/demo.py index d4e583323..fcea3e753 100644 --- a/docs/examples/demo.py +++ b/docs/examples/demo.py @@ -130,7 +130,6 @@ class BasicApp(App, css_path="basic.css"): classes="scrollable", ), table, - Widget(DirectoryTree("~/projects/textual"), id="tree-container"), Error(), Tweet(TweetBody(), classes="scrollbar-size-custom"), Warning(), diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 8ffb06928..b56517626 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -56,8 +56,6 @@ The lines inside the curly braces contains CSS _rules_, which consist of a rule 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. -You may be able to guess what some of the the other rules do. We will cover those later. - ## 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. @@ -189,7 +187,7 @@ Let's look at the selectors supported by Textual CSS. ### Type selector -The _type_ selector matches the name of the (Python) class, which is literally the name of the class in your Python code. For example, the following widget can be matched with a `Button` 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 @@ -320,7 +318,7 @@ Here are some other pseudo classes: ## Combinators -More sophisticated selectors can be created by combining simple selectors. The rule that combines selectors is know as a _combinator_. +More sophisticated selectors can be created by combining simple selectors. The logic used to combine selectors is know as a _combinator_. ### Descendant combinator From 2fedbaf3ddd91831114f9427ebccbb259ec4ed2b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 11:02:14 +0100 Subject: [PATCH 73/73] tweak --- docs/guide/CSS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index b56517626..a65b24b00 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -40,7 +40,7 @@ Header { } ``` -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 in the Python class `Header`. +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 {