diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 7c92f61f1..aa22b9d0a 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1 @@ # These are supported funding model platforms - -ko_fi: textualize diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 3096e1a20..ee439baf0 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -7,8 +7,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9"] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10"] defaults: run: shell: bash @@ -25,7 +25,7 @@ jobs: version: 1.1.6 virtualenvs-in-project: true - name: Install dependencies - run: poetry install + run: poetry install --extras "dev" if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' - name: Format check with black run: | @@ -39,11 +39,9 @@ jobs: run: | source $VENV pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing - - name: Upload code coverage - uses: codecov/codecov-action@v1.0.10 + - name: Upload snapshot report + if: always() + uses: actions/upload-artifact@v3 with: - token: ${{ secrets.CODECOV_TOKEN }} - file: ./coverage.xml - name: rich - flags: unittests - env_vars: OS,PYTHON + name: snapshot-report-textual + path: tests/snapshot_tests/output/snapshot_report.html diff --git a/.gitignore b/.gitignore index bc0dfdb71..4580aac9a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .pytype .DS_Store .vscode +.idea mypy_report docs/build docs/source/_build @@ -112,3 +113,6 @@ venv.bak/ # mypy .mypy_cache/ + +# Snapshot testing report output directory +tests/snapshot_tests/output diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2c607c2f..9e507b1e2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,12 +2,15 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.3.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + args: ['--unsafe'] - repo: https://github.com/psf/black - rev: 21.8b0 + rev: 22.3.0 hooks: - id: black + exclude: ^tests/ +exclude: ^tests/snapshot_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index aa775f83b..18b8fd739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.2.0] - 2022-10-23 + +### Added + +- CSS support +- Too numerous to mention ## [0.1.18] - 2022-04-30 ### Changed diff --git a/Makefile b/Makefile index 80cde06d3..429996c68 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,18 @@ test: pytest --cov-report term-missing --cov=textual tests/ -vv +unit-test: + pytest --cov-report term-missing --cov=textual tests/ -vv -m "not integration_test" +test-snapshot-update: + pytest --cov-report term-missing --cov=textual tests/ -vv --snapshot-update typecheck: mypy src/textual format: black src format-check: - black --check . + black --check src +docs-serve: + mkdocs serve +docs-build: + mkdocs build +docs-deploy: + mkdocs gh-deploy diff --git a/README.md b/README.md index cb504a8cc..e3ddcba12 100644 --- a/README.md +++ b/README.md @@ -1,375 +1,121 @@ # Textual -![screenshot](./imgs/textual.png) +![Textual splash image](./imgs/textual.png) -Textual is a TUI (Text User Interface) framework for Python inspired by modern web development. Currently a **Work in Progress**. - -> **Warning** -> -> We ([Textualize.io](https://www.textualize.io)) are hard at work on the **css** branch. We will maintain the 0.1.0 branch for the near future but may not be able to accept API changes. If you would like to contribute code via a PR, please raise a discussion first, to avoid disappointment. +Textual is a Python framework for creating interactive applications that run in your terminal. -Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions. +## About -[![Join the chat at https://gitter.im/textual-ui/community](https://badges.gitter.im/textual-ui/community.svg)](https://gitter.im/textual-ui/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +Textual adds interactivity to [Rich](https://github.com/Textualize/rich) with a Python API inspired by modern web development. + +On modern terminal software (installed by default on most systems), Textual apps can use **16.7 million** colors with mouse support and smooth flicker-free animation. A powerful layout engine and re-usable components makes it possible to build apps that rival the desktop and web experience. ## Compatibility -Textual currently runs on **MacOS / Linux / Windows**. +Textual runs on Linux, macOS, and Windows. Textual requires Python 3.7 or above. -## How it works +## Installing -Textual uses [Rich](https://github.com/willmcgugan/rich) to render rich text, so anything that Rich can render may be used in Textual. - -Event handling in Textual is asynchronous (using `async` and `await` keywords). Widgets (UI components) can independently update and communicate with each other via message passing. - -Textual has more in common with modern web development than it does with [curses](); layout is done with CSS grid and (soon) the theme may be customized with CSS. Other techniques are borrowed from JS frameworks such as Vue and React. - -## Installation - -You can install Textual via pip (`pip install textual`), or by checking out the repo and installing with [poetry](https://python-poetry.org/docs/). +Install Textual via pip: ``` -poetry install +pip install textual[dev] ``` -Once installed you can run the following command for a quick test, or see examples (below): +The addition of `[dev]` installs Textual development tools. See the [docs](https://textual.textualize.io/getting_started/) if you need help getting started. + +## Demo + +Run the following command to see a little of what Textual can do: ``` -python -m textual.app +python -m textual ``` -Textual requires Python 3.7 or above. +![Textual demo](./imgs/demo.svg) + +## Documentation + +Head over to the [Textual documentation](http://textual.textualize.io/) to start building! ## Examples -Until I've written the documentation, the [examples](https://github.com/willmcgugan/textual/tree/main/examples/) may be the best way to learn Textual. - -You can see some of these examples in action in the [Developer Video Log](#developer-video-log). - -- [animation.py](https://github.com/willmcgugan/textual/tree/main/examples/animation.py) Demonstration of 60fps animation easing function -- [calculator.py](https://github.com/willmcgugan/textual/tree/main/examples/calculator.py) A "clone" of the MacOS calculator using Grid layout -- [code_viewer.py](https://github.com/willmcgugan/textual/tree/main/examples/code_viewer.py) A demonstration of a tree view which loads syntax highlighted code -- [grid.py](https://github.com/willmcgugan/textual/tree/main/examples/grid.py) A simple demonstration of adding widgets in a Grid layout -- [grid_auto.py](https://github.com/willmcgugan/textual/tree/main/examples/grid_auto.py) A demonstration of automatic Grid layout -- [simple.py](https://github.com/willmcgugan/textual/tree/main/examples/simple.py) A very simple Textual app with scrolling Markdown view - -## Building Textual applications - -_This guide is a work in progress_ - -Let's look at the simplest Textual app which does _something_: - -```python -from textual.app import App +The Textual repository comes with a number of examples you can experiment with or use as a template for your own projects. -class Beeper(App): - def on_key(self): - self.console.bell() +
+ ๐ŸŽฌ Code browser +
+ + This is the [code_browser.py](./examples/code_browser.py) example which clocks in at 61 lines (*including* docstrings and blank lines). + +https://user-images.githubusercontent.com/554369/197188237-88d3f7e4-4e5f-40b5-b996-c47b19ee2f49.mov + +
-Beeper.run() +
+ ๐Ÿ“ท Calculator +
+ +This is [calculator.py](./examples/calculator.py) which demonstrates Textual grid layouts. + +![calculator screenshot](./imgs/calculator.svg) +
+ + +
+ ๐Ÿ“ท Stopwatch +
+ + This is the Stopwatch example from the tutorial. + +### Light theme + +![stopwatch light screenshot](./imgs/stopwatch_light.svg) + +### Dark theme + +![stopwatch dark screenshot](./imgs/stopwatch_dark.svg) + +
+ + + +## Reference commands + +The `textual` command has a few sub-commands to preview Textual styles. + +
+ ๐ŸŽฌ Easing reference +
+ +This is the *easing* reference which demonstrates the easing parameter on animation, with both movement and opacity. You can run it with the following command: + +```bash +textual easing ``` -Here we can see a textual app with a single `on_key` method which will handle key events. Pressing any key will result in playing the terminal bell (generally an irritating beep). Hit Ctrl+C to exit. -Event handlers in Textual are defined by convention, not by inheritance (there's no base class with all the handlers defined). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_` if it exists. - -Let's look at a _slightly_ more interesting example: - -```python -from textual.app import App +https://user-images.githubusercontent.com/554369/196157100-352852a6-2b09-4dc8-a888-55b53570aff9.mov -class ColorChanger(App): - def on_key(self, event): - if event.key.isdigit(): - self.background = f"on color({event.key})" +
- -ColorChanger.run(log="textual.log") +
+ ๐ŸŽฌ Borders reference +
+ +This is the borders reference which demonstrates some of the borders styles in Textual. You can run it with the following command: + +```bash +textual borders ``` -You'll notice that the `on_key` method above contains an additional `event` parameter which wasn't present on the beeper example. If the `event` argument is present, Textual will call the handler with an event object. Every event has an associated handler object, in this case it is a KeyEvent which contains additional information regarding which key was pressed. -The key event handler above will set the background attribute if you press the keys 0-9, which turns the terminal to the corresponding [ansi color](https://rich.readthedocs.io/en/latest/appendix/colors.html). +https://user-images.githubusercontent.com/554369/196158235-4b45fb78-053d-4fd5-b285-e09b4f1c67a8.mov -Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute to a [Rich style](https://rich.readthedocs.io/en/latest/style.html) is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual. To make changes to the terminal interface you modify the _state_ and let Textual update the UI. -## Widgets - -To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can develop your own. - -Let's look at an app which contains widgets. We will be using the built-in `Placeholder` widget which you can use to design application layouts before you implement the real content. - -```python -from textual.app import App -from textual.widgets import Placeholder - - -class SimpleApp(App): - - async def on_mount(self) -> None: - await self.view.dock(Placeholder(), edge="left", size=40) - await self.view.dock(Placeholder(), Placeholder(), edge="top") - - -SimpleApp.run(log="textual.log") -``` - -This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events, and is typically used for initialization. You may have noticed that `on_mount` is an `async` function. Since Textual is an asynchronous framework we will need this if we need to call most other methods. - -The `on_mount` method makes two calls to `self.view.dock` which adds widgets to the terminal. - -Here's the first line in the mount handler: - -```python -await self.view.dock(Placeholder(), edge="left", size=40) -``` - -Note this method is asynchronous like almost all API methods in Textual. We are awaiting `self.view.dock` which takes a newly constructed Placeholder widget, and docks it on to the `"left"` edge of the terminal with a size of 40 characters. In a real app you might use this to display a side-bar. - -The following line is similar: - -```python -await self.view.dock(Placeholder(), Placeholder(), edge="top") -``` - -You will notice that this time we are docking _two_ Placeholder objects onto the `"top"` edge. We haven't set an explicit size this time so Textual will divide the remaining size amongst the two new widgets. - -The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see events being processed and other debug information. - -If you run the above example, you will see something like the following: - -![widgets](./imgs/widgets.png) - -If you move the mouse over the terminal you will notice that widgets receive mouse events. You can click any of the placeholders to give it input focus. - -The dock layout feature is very flexible, but for more sophisticated layouts we can use the grid API. See the [calculator.py](https://github.com/willmcgugan/textual/blob/main/examples/calculator.py) example which makes use of Grid. - -### Creating Widgets - -You can create your own widgets by subclassing the `textual.widget.Widget` class and implementing a `render()` method which should return anything that can be rendered with [Rich](https://rich.readthedocs.io/en/latest/introduction.html), including a plain string which will be interpreted as [console markup](https://rich.readthedocs.io/en/latest/markup.html). - -Let's look at an example with a custom widget: - -```python -from rich.panel import Panel - -from textual.app import App -from textual.reactive import Reactive -from textual.widget import Widget - - -class Hover(Widget): - - mouse_over = Reactive(False) - - def render(self) -> Panel: - return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) - - def on_enter(self) -> None: - self.mouse_over = True - - def on_leave(self) -> None: - self.mouse_over = False - - -class HoverApp(App): - """Demonstrates custom widgets""" - - async def on_mount(self) -> None: - hovers = (Hover() for _ in range(10)) - await self.view.dock(*hovers, edge="top") - - -HoverApp.run(log="textual.log") -``` - -The `Hover` class is a custom widget which displays a panel containing the classic text "Hello World". The first line in the Hover class may seem a little mysterious at this point: - -```python -mouse_over = Reactive(False) -``` - -This adds a `mouse_over` attribute to your class which is a bool with a default of `False`. Adding attributes like this makes them _reactive_: any changes will result in the widget updating. - -The following `render()` method is where you define how the widget should be displayed. In the Hover widget we return a [Panel](https://rich.readthedocs.io/en/latest/panel.html) containing rich text with a background that changes depending on the value of `mouse_over`. The goal here is to add a mouse hover effect to the widget, which we can achieve by handling two events: `Enter` and `Leave`. These events are sent when the mouse enters or leaves the widget. - -Here are the two event handlers again: - -```python - def on_enter(self) -> None: - self.mouse_over = True - - def on_leave(self) -> None: - self.mouse_over = False -``` - -Both event handlers set the `mouse_over` attribute which will result in the widget's `render()` method being called. - -The `HoverApp` has a `on_mount` handler which creates 10 Hover widgets and docks them on the top edge to create a vertical stack: - -```python - async def on_mount(self) -> None: - hovers = (Hover() for _ in range(10)) - await self.view.dock(*hovers, edge="top") -``` - -If you run this script you will see something like the following: - -![widgets](./imgs/custom.gif) - -If you move your mouse over the terminal you should see that the widget under the mouse cursor changes to a red background. - -### Actions and key bindings - -Actions in Textual are white-listed functions that may be bound to keys. Let's look at a trivial example of binding a key to an action. Here is an app which exits when we hit the Q key: - -```python -from textual.app import App - - -class Quitter(App): - async def on_load(self, event): - await self.bind("q", "quit") - - -Quitter.run() -``` - -If you run this you will get a blank terminal which will return to the prompt when you press Q. - -Binding is done in the Load event handler. The `bind` method takes the key (in this case "q") and binds it to an action ("quit"). The quit action is built in to Textual and simply exits the app. - -To define your own actions, add a method that begins with `action_`, which may take parameters. Let's create a simple action that changes the color of the terminal and binds keys to it: - -```python -from textual.app import App - - -class Colorizer(App): - - async def on_load(self, event): - await self.bind("r", "color('red')") - await self.bind("g", "color('green')") - await self.bind("b", "color('blue')") - - async def action_color(self, color:str) -> None: - self.background = f"on {color}" - - -Colorizer.run() -``` - -If you run this app you can hit the keys R, G, or B to change the color of the background. - -In the `on_load` method we have bound the keys R, G, and B to the `color` action with a single parameter. When you press any of these three keys Textual will call the method `action_color` with the appropriate parameter. - -You could be forgiven for thinking that `"color('red')"` is Python code which Textual evaluates. This is not the case. The action strings are parsed and may not include expressions or arbitrary code. The reason that strings are used over a callable is that (in a future update) key bindings may be loaded from a configuration file. - -### More on Events - -_TODO_ - -### Watchers - -_TODO_ - -### Animation - -_TODO_ - -### Timers and Intervals - -Textual has a `set_timer` and a `set_interval` method which work much like their Javascript counterparts. The `set_timer` method will invoke a callable after a given period of time, and `set_interval` will invoke a callable repeatedly. Unlike Javascript these methods expect the time to be in seconds (_not_ milliseconds). - -Let's create a simple terminal based clock with the `set_interval` method: - -```python -from datetime import datetime - -from rich.align import Align - -from textual.app import App -from textual.widget import Widget - - -class Clock(Widget): - def on_mount(self): - self.set_interval(1, self.refresh) - - def render(self): - time = datetime.now().strftime("%c") - return Align.center(time, vertical="middle") - - -class ClockApp(App): - async def on_mount(self): - await self.view.dock(Clock()) - - -ClockApp.run() - -``` - -If you run this app you will see the current time in the center of the terminal until you hit Ctrl+C. - -The Clock widget displays the time using [rich.align.Align](https://rich.readthedocs.io/en/latest/reference/align.html) to position it in the center. In the clock's Mount handler there is the following call to `set_interval`: - -```python -self.set_interval(1, self.refresh) -``` - -This tells Textual to call a function (in this case `self.refresh` which updates the widget) once a second. When a widget is refreshed it calls `Clock.render` again to display the latest time. - -## Developer Video Log - -Since Textual is a visual medium, I'll be documenting new features and milestones here. - -### Update 1 - Basic scrolling - -[![Textual update 1](https://yt-embed.herokuapp.com/embed?v=zNW7U36GHlU&img=0)](http://www.youtube.com/watch?v=zNW7U36GHlU) - -### Update 2 - Keyboard toggle - -[![Textual update 2](https://yt-embed.herokuapp.com/embed?v=bTYeFOVNXDI&img=0)](http://www.youtube.com/watch?v=bTYeFOVNXDI) - -### Update 3 - New scrollbars and smooth scrolling - -[![Textual update 3](https://yt-embed.herokuapp.com/embed?v=4LVl3ClrXIs&img=0)](http://www.youtube.com/watch?v=4LVl3ClrXIs) - -### Update 4 - Animation system with easing function - -Now with a system to animate changes to values, going from the initial to the final value in small increments over time . Here applied to the scroll position. The animation system supports CSS like _easing functions_. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down. - -[![Textual update 4](https://yt-embed.herokuapp.com/embed?v=k2VwOp1YbSk&img=0)](http://www.youtube.com/watch?v=k2VwOp1YbSk) - -### Update 5 - New Layout system - -A new update system allows for overlapping layers. Animation is now synchronized with the display which makes it very smooth! - -[![Textual update 5](https://yt-embed.herokuapp.com/embed?v=XxRnfx2WYRw&img=0)](http://www.youtube.com/watch?v=XxRnfx2WYRw) - -### Update 6 - New Layout API - -New version (0.1.4) with API updates and the new layout system. - -[![Textual update 6](https://yt-embed.herokuapp.com/embed?v=jddccDuVd3E&img=0)](http://www.youtube.com/watch?v=jddccDuVd3E) - -### Update 7 - New Grid Layout - -**11 July 2021** - -Added a new layout system modelled on CSS grid. The example demonstrates how once created a grid will adapt to the available space. - -[![Textual update 7](https://yt-embed.herokuapp.com/embed?v=Zh9CEvu73jc&img=0)](http://www.youtube.com/watch?v=Zh9CEvu73jc) - -## Update 8 - Tree control and scroll views - -**6 Aug 2021** - -Added a tree control and refactored the renderer to allow for widgets within a scrollable view - -[![Textual update 8](https://yt-embed.herokuapp.com/embed?v=J-dzzD6NQJ4&img=0)](http://www.youtube.com/watch?v=J-dzzD6NQJ4) + +
diff --git a/docs.md b/docs.md new file mode 100644 index 000000000..63c47ed6a --- /dev/null +++ b/docs.md @@ -0,0 +1,18 @@ +# Documentation Workflow + +* Ensure you're inside a *Python 3.10+* virtual environment +* Run the live-reload server using `mkdocs serve` from the project root +* Create new pages by adding new directories and Markdown files inside `docs/*` + +## Commands + +- `mkdocs serve` - Start the live-reloading docs server. +- `mkdocs build` - Build the documentation site. +- `mkdocs -h` - Print help message and exit. + +## Project layout + + mkdocs.yml # The configuration file. + docs/ + index.md # The documentation homepage. + ... # Other markdown pages, images and other files. diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 000000000..a3d735267 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +textual.textualize.io diff --git a/docs/custom_theme/main.html b/docs/custom_theme/main.html new file mode 100644 index 000000000..8e2960ddd --- /dev/null +++ b/docs/custom_theme/main.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block extrahead %} + + + + +{% endblock %} diff --git a/docs/events/blur.md b/docs/events/blur.md new file mode 100644 index 000000000..067e7bde9 --- /dev/null +++ b/docs/events/blur.md @@ -0,0 +1,14 @@ +# Blur + +The `Blur` event is sent to a widget when it loses focus. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.Blur diff --git a/docs/events/click.md b/docs/events/click.md new file mode 100644 index 000000000..8be36002f --- /dev/null +++ b/docs/events/click.md @@ -0,0 +1,25 @@ +# Click + +The `Click` event is sent to a widget when the user clicks a mouse button. + +- [x] Bubbles +- [ ] Verbose + +## Attributes + +| attribute | type | purpose | +|------------|------|-------------------------------------------| +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | +| `delta_x` | int | Change in x since last mouse event | +| `delta_y` | int | Change in y since last mouse event | +| `button` | int | Index of mouse button | +| `shift` | bool | Shift key pressed if True | +| `meta` | bool | Meta key pressed if True | +| `ctrl` | bool | Ctrl key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | + +## Code + +::: textual.events.Click diff --git a/docs/events/descendant_blur.md b/docs/events/descendant_blur.md new file mode 100644 index 000000000..bfe0799f6 --- /dev/null +++ b/docs/events/descendant_blur.md @@ -0,0 +1,14 @@ +# DescendantBlur + +The `DescendantBlur` event is sent to a widget when one of its children loses focus. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.DescendantBlur diff --git a/docs/events/descendant_focus.md b/docs/events/descendant_focus.md new file mode 100644 index 000000000..9090cd65d --- /dev/null +++ b/docs/events/descendant_focus.md @@ -0,0 +1,14 @@ +# DescendantFocus + +The `DescendantFocus` event is sent to a widget when one of its descendants receives focus. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.DescendantFocus diff --git a/docs/events/enter.md b/docs/events/enter.md new file mode 100644 index 000000000..5fbcda727 --- /dev/null +++ b/docs/events/enter.md @@ -0,0 +1,14 @@ +# Enter + +The `Enter` event is sent to a widget when the mouse pointer first moves over a widget. + +- [ ] Bubbles +- [x] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.Enter diff --git a/docs/events/focus.md b/docs/events/focus.md new file mode 100644 index 000000000..54f4b2a48 --- /dev/null +++ b/docs/events/focus.md @@ -0,0 +1,14 @@ +# Focus + +The `Focus` event is sent to a widget when it receives input focus. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.Focus diff --git a/docs/events/hide.md b/docs/events/hide.md new file mode 100644 index 000000000..2f7655b4e --- /dev/null +++ b/docs/events/hide.md @@ -0,0 +1,14 @@ +# Hide + +The `Hide` event is sent to a widget when it is hidden from view. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No additional attributes_ + +## Code + +::: textual.events.Hide diff --git a/docs/events/index.md b/docs/events/index.md new file mode 100644 index 000000000..5b3430c34 --- /dev/null +++ b/docs/events/index.md @@ -0,0 +1,3 @@ +# Events + +A reference to Textual [events](../guide/events.md). diff --git a/docs/events/key.md b/docs/events/key.md new file mode 100644 index 000000000..ae7e33250 --- /dev/null +++ b/docs/events/key.md @@ -0,0 +1,17 @@ +# Key + +The `Key` event is sent to a widget when the user presses a key on the keyboard. + +- [x] Bubbles +- [ ] Verbose + +## Attributes + +| attribute | type | purpose | +| --------- | ----------- | ----------------------------------------------------------- | +| `key` | str | Name of the key that was pressed. | +| `char` | str or None | The character that was pressed, or None it isn't printable. | + +## Code + +::: textual.events.Key diff --git a/docs/events/leave.md b/docs/events/leave.md new file mode 100644 index 000000000..5a72463a9 --- /dev/null +++ b/docs/events/leave.md @@ -0,0 +1,14 @@ +# Leave + +The `Leave` event is sent to a widget when the mouse pointer moves off a widget. + +- [ ] Bubbles +- [x] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.Leave diff --git a/docs/events/load.md b/docs/events/load.md new file mode 100644 index 000000000..2702a7906 --- /dev/null +++ b/docs/events/load.md @@ -0,0 +1,16 @@ +# Load + +The `Load` event is sent to the app prior to switching the terminal to application mode. + +The load event is typically used to do any setup actions required by the app that don't change the display. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No additional attributes_ + +## Code + +::: textual.events.Load diff --git a/docs/events/mount.md b/docs/events/mount.md new file mode 100644 index 000000000..1b2377b77 --- /dev/null +++ b/docs/events/mount.md @@ -0,0 +1,16 @@ +# Mount + +The `Mount` event is sent to a widget and Application when it is first mounted. + +The mount event is typically used to set the initial state of a widget or to add new children widgets. + +- [ ] Bubbles +- [x] Verbose + +## Attributes + +_No additional attributes_ + +## Code + +::: textual.events.Mount diff --git a/docs/events/mouse_capture.md b/docs/events/mouse_capture.md new file mode 100644 index 000000000..167478636 --- /dev/null +++ b/docs/events/mouse_capture.md @@ -0,0 +1,16 @@ +# MouseCapture + +The `MouseCapture` event is sent to a widget when it is capturing mouse events from outside of its borders on the screen. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +| attribute | type | purpose | +| ---------------- | ------ | --------------------------------------------- | +| `mouse_position` | Offset | Mouse coordinates when the mouse was captured | + +## Code + +::: textual.events.MouseCapture diff --git a/docs/events/mouse_down.md b/docs/events/mouse_down.md new file mode 100644 index 000000000..69ed3ca2f --- /dev/null +++ b/docs/events/mouse_down.md @@ -0,0 +1,25 @@ +# MouseDown + +The `MouseDown` event is sent to a widget when a mouse button is pressed. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +| attribute | type | purpose | +| ---------- | ---- | ----------------------------------------- | +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | +| `delta_x` | int | Change in x since last mouse event | +| `delta_y` | int | Change in y since last mouse event | +| `button` | int | Index of mouse button | +| `shift` | bool | Shift key pressed if True | +| `meta` | bool | Meta key pressed if True | +| `ctrl` | bool | Ctrl key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | + +## Code + +::: textual.events.MouseDown diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md new file mode 100644 index 000000000..12cdca5f9 --- /dev/null +++ b/docs/events/mouse_move.md @@ -0,0 +1,25 @@ +# MouseMove + +The `MouseMove` event is sent to a widget when the mouse pointer is moved over a widget. + +- [ ] Bubbles +- [x] Verbose + +## Attributes + +| attribute | type | purpose | +|------------|------|-------------------------------------------| +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | +| `delta_x` | int | Change in x since last mouse event | +| `delta_y` | int | Change in y since last mouse event | +| `button` | int | Index of mouse button | +| `shift` | bool | Shift key pressed if True | +| `meta` | bool | Meta key pressed if True | +| `ctrl` | bool | Ctrl key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | + +## Code + +::: textual.events.MouseMove diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md new file mode 100644 index 000000000..89d1fe4ed --- /dev/null +++ b/docs/events/mouse_release.md @@ -0,0 +1,16 @@ +# MouseRelease + +The `MouseRelease` event is sent to a widget when it is no longer receiving mouse events outside of its borders. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +| attribute | type | purpose | +|------------------|--------|-----------------------------------------------| +| `mouse_position` | Offset | Mouse coordinates when the mouse was released | + +## Code + +::: textual.events.MouseRelease diff --git a/docs/events/mouse_scroll_down.md b/docs/events/mouse_scroll_down.md new file mode 100644 index 000000000..7228cc0bb --- /dev/null +++ b/docs/events/mouse_scroll_down.md @@ -0,0 +1,17 @@ +# MouseScrollDown + +The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _down_. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +| attribute | type | purpose | +|-----------|------|----------------------------------------| +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | + +## Code + +::: textual.events.MouseScrollDown diff --git a/docs/events/mouse_scroll_up.md b/docs/events/mouse_scroll_up.md new file mode 100644 index 000000000..2114b5f41 --- /dev/null +++ b/docs/events/mouse_scroll_up.md @@ -0,0 +1,17 @@ +# MouseScrollUp + +The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad equivalent) is moved _up_. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +| attribute | type | purpose | +|-----------|------|----------------------------------------| +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | + +## Code + +::: textual.events.MouseScrollUp diff --git a/docs/events/mouse_up.md b/docs/events/mouse_up.md new file mode 100644 index 000000000..5b965132e --- /dev/null +++ b/docs/events/mouse_up.md @@ -0,0 +1,25 @@ +# MouseUp + +The `MouseUp` event is sent to a widget when the user releases a mouse button. + +- [x] Bubbles +- [x] Verbose + +## Attributes + +| attribute | type | purpose | +|------------|------|-------------------------------------------| +| `x` | int | Mouse x coordinate, relative to Widget | +| `y` | int | Mouse y coordinate, relative to Widget | +| `delta_x` | int | Change in x since last mouse event | +| `delta_y` | int | Change in y since last mouse event | +| `button` | int | Index of mouse button | +| `shift` | bool | Shift key pressed if True | +| `meta` | bool | Meta key pressed if True | +| `ctrl` | bool | Ctrl key pressed if True | +| `screen_x` | int | Mouse x coordinate relative to the screen | +| `screen_y` | int | Mouse y coordinate relative to the screen | + +## Code + +::: textual.events.MouseUp diff --git a/docs/events/paste.md b/docs/events/paste.md new file mode 100644 index 000000000..fdae43e5c --- /dev/null +++ b/docs/events/paste.md @@ -0,0 +1,16 @@ +# Paste + +The `Paste` event is sent to a widget when the user pastes text. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +| attribute | type | purpose | +|-----------|------|--------------------------| +| `text` | str | The text that was pasted | + +## Code + +::: textual.events.Paste diff --git a/docs/events/resize.md b/docs/events/resize.md new file mode 100644 index 000000000..2ffe554af --- /dev/null +++ b/docs/events/resize.md @@ -0,0 +1,18 @@ +# Resize + +The `Resize` event is sent to a widget when its size changes and when it is first made visible. + +- [x] Bubbles +- [ ] Verbose + +## Attributes + +| 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) | + +## Code + +::: textual.events.Resize diff --git a/docs/events/screen_resume.md b/docs/events/screen_resume.md new file mode 100644 index 000000000..4852149a1 --- /dev/null +++ b/docs/events/screen_resume.md @@ -0,0 +1,14 @@ +# ScreenResume + +The `ScreenResume` event is sent to a **Screen** when it becomes current. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.ScreenResume diff --git a/docs/events/screen_suspend.md b/docs/events/screen_suspend.md new file mode 100644 index 000000000..b716832ed --- /dev/null +++ b/docs/events/screen_suspend.md @@ -0,0 +1,14 @@ +# ScreenSuspend + +The `ScreenSuspend` event is sent to a **Screen** when it is replaced by another screen. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No other attributes_ + +## Code + +::: textual.events.ScreenSuspend diff --git a/docs/events/show.md b/docs/events/show.md new file mode 100644 index 000000000..b669430cc --- /dev/null +++ b/docs/events/show.md @@ -0,0 +1,14 @@ +# Show + +The `Show` event is sent to a widget when it becomes visible. + +- [ ] Bubbles +- [ ] Verbose + +## Attributes + +_No additional attributes_ + +## Code + +::: textual.events.Show diff --git a/docs/examples/actions/colorizer.py b/docs/examples/actions/colorizer.py deleted file mode 100644 index f0c3cc0f0..000000000 --- a/docs/examples/actions/colorizer.py +++ /dev/null @@ -1,14 +0,0 @@ -from textual.app import App - - -class Colorizer(App): - async def on_load(self): - await self.bind("r", "color('red')") - await self.bind("g", "color('green')") - await self.bind("b", "color('blue')") - - def action_color(self, color: str) -> None: - self.background = f"on {color}" - - -Colorizer.run() diff --git a/docs/examples/actions/quiter.py b/docs/examples/actions/quiter.py deleted file mode 100644 index 53ff19356..000000000 --- a/docs/examples/actions/quiter.py +++ /dev/null @@ -1,9 +0,0 @@ -from textual.app import App - - -class Quiter(App): - async def on_load(self): - await self.bind("q", "quit") - - -Quiter.run() diff --git a/docs/examples/app/event01.py b/docs/examples/app/event01.py new file mode 100644 index 000000000..0f3bc3cdc --- /dev/null +++ b/docs/examples/app/event01.py @@ -0,0 +1,30 @@ +from textual.app import App +from textual import events + + +class EventApp(App): + + COLORS = [ + "white", + "maroon", + "red", + "purple", + "fuchsia", + "olive", + "yellow", + "navy", + "teal", + "aqua", + ] + + def on_mount(self) -> None: + self.screen.styles.background = "darkblue" + + def on_key(self, event: events.Key) -> None: + if event.key.isdecimal(): + self.screen.styles.background = self.COLORS[int(event.key)] + + +if __name__ == "__main__": + app = EventApp() + app.run() diff --git a/docs/examples/app/question01.py b/docs/examples/app/question01.py new file mode 100644 index 000000000..e61fba393 --- /dev/null +++ b/docs/examples/app/question01.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Button + + +class QuestionApp(App[str]): + def compose(self) -> ComposeResult: + yield Static("Do you love Textual?") + yield Button("Yes", id="yes", variant="primary") + yield Button("No", id="no", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(event.button.id) + + +if __name__ == "__main__": + app = QuestionApp() + reply = app.run() + print(reply) diff --git a/docs/examples/app/question02.css b/docs/examples/app/question02.css new file mode 100644 index 000000000..1f1a3b84b --- /dev/null +++ b/docs/examples/app/question02.css @@ -0,0 +1,17 @@ +Screen { + layout: grid; + grid-size: 2; + grid-gutter: 2; + padding: 2; +} +#question { + width: 100%; + height: 100%; + column-span: 2; + content-align: center bottom; + text-style: bold; +} + +Button { + width: 100%; +} diff --git a/docs/examples/app/question02.py b/docs/examples/app/question02.py new file mode 100644 index 000000000..36b23722b --- /dev/null +++ b/docs/examples/app/question02.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Button + + +class QuestionApp(App[str]): + CSS_PATH = "question02.css" + + def compose(self) -> ComposeResult: + yield Static("Do you love Textual?", id="question") + yield Button("Yes", id="yes", variant="primary") + yield Button("No", id="no", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(event.button.id) + + +if __name__ == "__main__": + app = QuestionApp() + reply = app.run() + print(reply) diff --git a/docs/examples/app/question03.py b/docs/examples/app/question03.py new file mode 100644 index 000000000..777e5a9eb --- /dev/null +++ b/docs/examples/app/question03.py @@ -0,0 +1,38 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Button + + +class QuestionApp(App[str]): + CSS = """ + Screen { + layout: grid; + grid-size: 2; + grid-gutter: 2; + padding: 2; + } + #question { + width: 100%; + height: 100%; + column-span: 2; + content-align: center bottom; + text-style: bold; + } + + Button { + width: 100%; + } + """ + + def compose(self) -> ComposeResult: + yield Static("Do you love Textual?", id="question") + yield Button("Yes", id="yes", variant="primary") + yield Button("No", id="no", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(event.button.id) + + +if __name__ == "__main__": + app = QuestionApp() + reply = app.run() + print(reply) diff --git a/docs/examples/app/simple01.py b/docs/examples/app/simple01.py new file mode 100644 index 000000000..03a13218e --- /dev/null +++ b/docs/examples/app/simple01.py @@ -0,0 +1,5 @@ +from textual.app import App + + +class MyApp(App): + pass diff --git a/docs/examples/app/simple02.py b/docs/examples/app/simple02.py new file mode 100644 index 000000000..e087ac2d2 --- /dev/null +++ b/docs/examples/app/simple02.py @@ -0,0 +1,10 @@ +from textual.app import App + + +class MyApp(App): + pass + + +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/docs/examples/app/widgets01.py b/docs/examples/app/widgets01.py new file mode 100644 index 000000000..c8abd8f48 --- /dev/null +++ b/docs/examples/app/widgets01.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.widgets import Welcome + + +class WelcomeApp(App): + def compose(self) -> ComposeResult: + yield Welcome() + + def on_button_pressed(self) -> None: + self.exit() + + +if __name__ == "__main__": + app = WelcomeApp() + app.run() diff --git a/docs/examples/app/widgets02.py b/docs/examples/app/widgets02.py new file mode 100644 index 000000000..43a3bf047 --- /dev/null +++ b/docs/examples/app/widgets02.py @@ -0,0 +1,15 @@ +from textual.app import App +from textual.widgets import Welcome + + +class WelcomeApp(App): + def on_key(self) -> None: + self.mount(Welcome()) + + def on_button_pressed(self) -> None: + self.exit() + + +if __name__ == "__main__": + app = WelcomeApp() + app.run() diff --git a/docs/examples/events/custom01.py b/docs/examples/events/custom01.py new file mode 100644 index 000000000..043b32ffc --- /dev/null +++ b/docs/examples/events/custom01.py @@ -0,0 +1,48 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.message import Message, MessageTarget +from textual.widgets import Static + + +class ColorButton(Static): + """A color button.""" + + class Selected(Message): + """Color selected message.""" + + def __init__(self, sender: MessageTarget, color: Color) -> None: + self.color = color + super().__init__(sender) + + def __init__(self, color: Color) -> None: + self.color = color + super().__init__() + + def on_mount(self) -> None: + self.styles.margin = (1, 2) + self.styles.content_align = ("center", "middle") + self.styles.background = Color.parse("#ffffff33") + self.styles.border = ("tall", self.color) + + async def on_click(self) -> None: + # The emit method sends an event to a widget's parent + await self.emit(self.Selected(self, self.color)) + + def render(self) -> str: + return str(self.color) + + +class ColorApp(App): + def compose(self) -> ComposeResult: + yield ColorButton(Color.parse("#008080")) + yield ColorButton(Color.parse("#808000")) + yield ColorButton(Color.parse("#E9967A")) + yield ColorButton(Color.parse("#121212")) + + def on_color_button_selected(self, message: ColorButton.Selected) -> None: + self.screen.styles.animate("background", message.color, duration=0.5) + + +if __name__ == "__main__": + app = ColorApp() + app.run() diff --git a/docs/examples/events/dictionary.css b/docs/examples/events/dictionary.css new file mode 100644 index 000000000..9b5e489ad --- /dev/null +++ b/docs/examples/events/dictionary.css @@ -0,0 +1,23 @@ +Screen { + background: $panel; +} + +Input { + dock: top; + width: 100%; + height: 1; + padding: 0 1; + margin: 1 1 0 1; +} + +#results { + width: auto; + min-height: 100%; +} + +#results-container { + background: $background 50%; + overflow: auto; + margin: 1 2; + height: 100%; +} diff --git a/docs/examples/events/dictionary.py b/docs/examples/events/dictionary.py new file mode 100644 index 000000000..24544c39e --- /dev/null +++ b/docs/examples/events/dictionary.py @@ -0,0 +1,44 @@ +import asyncio + +try: + import httpx +except ImportError: + raise ImportError("Please install httpx with 'pip install httpx' ") + +from rich.json import JSON +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import Static, Input + + +class DictionaryApp(App): + """Searches ab dictionary API as-you-type.""" + + CSS_PATH = "dictionary.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Search for a word") + yield Vertical(Static(id="results"), id="results-container") + + async def on_input_changed(self, message: Input.Changed) -> None: + """A coroutine to handle a text changed message.""" + if message.value: + # Look up the word in the background + asyncio.create_task(self.lookup_word(message.value)) + else: + # Clear the results + self.query_one("#results", Static).update() + + async def lookup_word(self, word: str) -> None: + """Looks up a word.""" + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + async with httpx.AsyncClient() as client: + results = (await client.get(url)).text + + if word == self.query_one(Input).value: + self.query_one("#results", Static).update(JSON(results)) + + +if __name__ == "__main__": + app = DictionaryApp() + app.run() 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/examples/guide/actions/actions01.py b/docs/examples/guide/actions/actions01.py new file mode 100644 index 000000000..54b366f95 --- /dev/null +++ b/docs/examples/guide/actions/actions01.py @@ -0,0 +1,16 @@ +from textual.app import App +from textual import events + + +class ActionsApp(App): + def action_set_background(self, color: str) -> None: + self.screen.styles.background = color + + def on_key(self, event: events.Key) -> None: + if event.key == "r": + self.action_set_background("red") + + +if __name__ == "__main__": + app = ActionsApp() + app.run() diff --git a/docs/examples/guide/actions/actions02.py b/docs/examples/guide/actions/actions02.py new file mode 100644 index 000000000..e59d04182 --- /dev/null +++ b/docs/examples/guide/actions/actions02.py @@ -0,0 +1,16 @@ +from textual.app import App +from textual import events + + +class ActionsApp(App): + def action_set_background(self, color: str) -> None: + self.screen.styles.background = color + + async def on_key(self, event: events.Key) -> None: + if event.key == "r": + await self.action("set_background('red')") + + +if __name__ == "__main__": + app = ActionsApp() + app.run() diff --git a/docs/examples/guide/actions/actions03.py b/docs/examples/guide/actions/actions03.py new file mode 100644 index 000000000..ff68a58e9 --- /dev/null +++ b/docs/examples/guide/actions/actions03.py @@ -0,0 +1,22 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """ +[b]Set your background[/b] +[@click=set_background('red')]Red[/] +[@click=set_background('green')]Green[/] +[@click=set_background('blue')]Blue[/] +""" + + +class ActionsApp(App): + def compose(self) -> ComposeResult: + yield Static(TEXT) + + def action_set_background(self, color: str) -> None: + self.screen.styles.background = color + + +if __name__ == "__main__": + app = ActionsApp() + app.run() diff --git a/docs/examples/guide/actions/actions04.py b/docs/examples/guide/actions/actions04.py new file mode 100644 index 000000000..c233400e7 --- /dev/null +++ b/docs/examples/guide/actions/actions04.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """ +[b]Set your background[/b] +[@click=set_background('red')]Red[/] +[@click=set_background('green')]Green[/] +[@click=set_background('blue')]Blue[/] +""" + + +class ActionsApp(App): + BINDINGS = [ + ("r", "set_background('red')", "Red"), + ("g", "set_background('green')", "Green"), + ("b", "set_background('blue')", "Blue"), + ] + + def compose(self) -> ComposeResult: + yield Static(TEXT) + + def action_set_background(self, color: str) -> None: + self.screen.styles.background = color + + +if __name__ == "__main__": + app = ActionsApp() + app.run() diff --git a/docs/examples/guide/actions/actions05.css b/docs/examples/guide/actions/actions05.css new file mode 100644 index 000000000..3306d8a88 --- /dev/null +++ b/docs/examples/guide/actions/actions05.css @@ -0,0 +1,11 @@ +Screen { + layout: grid; + grid-size: 1; + grid-gutter: 2 4; + grid-rows: 1fr; +} + +ColorSwitcher { + height: 100%; + margin: 2 4; +} diff --git a/docs/examples/guide/actions/actions05.py b/docs/examples/guide/actions/actions05.py new file mode 100644 index 000000000..05a7d6406 --- /dev/null +++ b/docs/examples/guide/actions/actions05.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """ +[b]Set your background[/b] +[@click=set_background('cyan')]Cyan[/] +[@click=set_background('magenta')]Magenta[/] +[@click=set_background('yellow')]Yellow[/] +""" + + +class ColorSwitcher(Static): + def action_set_background(self, color: str) -> None: + self.styles.background = color + + +class ActionsApp(App): + CSS_PATH = "actions05.css" + BINDINGS = [ + ("r", "set_background('red')", "Red"), + ("g", "set_background('green')", "Green"), + ("b", "set_background('blue')", "Blue"), + ] + + def compose(self) -> ComposeResult: + yield ColorSwitcher(TEXT) + yield ColorSwitcher(TEXT) + + def action_set_background(self, color: str) -> None: + self.screen.styles.background = color + + +if __name__ == "__main__": + app = ActionsApp() + app.run() diff --git a/docs/examples/guide/animator/animation01.py b/docs/examples/guide/animator/animation01.py new file mode 100644 index 000000000..d4a504726 --- /dev/null +++ b/docs/examples/guide/animator/animation01.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class AnimationApp(App): + def compose(self) -> ComposeResult: + self.box = Static("Hello, World!") + self.box.styles.background = "red" + self.box.styles.color = "black" + self.box.styles.padding = (1, 2) + yield self.box + + def on_mount(self): + self.box.styles.animate("opacity", value=0.0, duration=2.0) + + +if __name__ == "__main__": + app = AnimationApp() + app.run() diff --git a/docs/examples/guide/animator/animation01_static.py b/docs/examples/guide/animator/animation01_static.py new file mode 100644 index 000000000..fde4b6e62 --- /dev/null +++ b/docs/examples/guide/animator/animation01_static.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class AnimationApp(App): + def compose(self) -> ComposeResult: + self.box = Static("Hello, World!") + self.box.styles.background = "red" + self.box.styles.color = "black" + self.box.styles.padding = (1, 2) + yield self.box + + +if __name__ == "__main__": + app = AnimationApp() + app.run() diff --git a/docs/examples/guide/dom1.py b/docs/examples/guide/dom1.py new file mode 100644 index 000000000..b45ebdb8c --- /dev/null +++ b/docs/examples/guide/dom1.py @@ -0,0 +1,10 @@ +from textual.app import App + + +class ExampleApp(App): + pass + + +if __name__ == "__main__": + app = ExampleApp() + app.run() diff --git a/docs/examples/guide/dom2.py b/docs/examples/guide/dom2.py new file mode 100644 index 000000000..35b670327 --- /dev/null +++ b/docs/examples/guide/dom2.py @@ -0,0 +1,13 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer + + +class ExampleApp(App): + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + + +if __name__ == "__main__": + app = ExampleApp() + app.run() diff --git a/docs/examples/guide/dom3.py b/docs/examples/guide/dom3.py new file mode 100644 index 000000000..bd68f24b4 --- /dev/null +++ b/docs/examples/guide/dom3.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal +from textual.widgets import Button, Footer, Header, Static + +QUESTION = "Do you want to learn about Textual CSS?" + + +class ExampleApp(App): + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Container( + Static(QUESTION, classes="question"), + Horizontal( + Button("Yes", variant="success"), + Button("No", variant="error"), + classes="buttons", + ), + id="dialog", + ) + + +if __name__ == "__main__": + app = ExampleApp() + app.run() diff --git a/docs/examples/guide/dom4.css b/docs/examples/guide/dom4.css new file mode 100644 index 000000000..d9169ee17 --- /dev/null +++ b/docs/examples/guide/dom4.css @@ -0,0 +1,30 @@ + +/* The top level dialog (a Container) */ +#dialog { + margin: 4 8; + background: $panel; + color: $text; + border: tall $background; + padding: 1 2; +} + +/* The button class */ +Button { + width: 1fr; +} + +/* Matches the question text */ +.question { + text-style: bold; + height: 100%; + content-align: center middle; +} + +/* Matches the button container */ +.buttons { + width: 100%; + height: auto; + dock: bottom; +} + + diff --git a/docs/examples/guide/dom4.py b/docs/examples/guide/dom4.py new file mode 100644 index 000000000..3191138d4 --- /dev/null +++ b/docs/examples/guide/dom4.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal +from textual.widgets import Header, Footer, Static, Button + +QUESTION = "Do you want to learn about Textual CSS?" + + +class ExampleApp(App): + CSS_PATH = "dom4.css" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Container( + Static(QUESTION, classes="question"), + Horizontal( + Button("Yes", variant="success"), + Button("No", variant="error"), + classes="buttons", + ), + id="dialog", + ) + + +if __name__ == "__main__": + app = ExampleApp() + app.run() diff --git a/docs/examples/guide/input/binding01.css b/docs/examples/guide/input/binding01.css new file mode 100644 index 000000000..9c8b6390f --- /dev/null +++ b/docs/examples/guide/input/binding01.css @@ -0,0 +1,7 @@ +Bar { + height: 5; + content-align: center middle; + text-style: bold; + margin: 1 2; + color: $text; +} diff --git a/docs/examples/guide/input/binding01.py b/docs/examples/guide/input/binding01.py new file mode 100644 index 000000000..12711f637 --- /dev/null +++ b/docs/examples/guide/input/binding01.py @@ -0,0 +1,31 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widgets import Footer, Static + + +class Bar(Static): + pass + + +class BindingApp(App): + + CSS_PATH = "binding01.css" + BINDINGS = [ + ("r", "add_bar('red')", "Add Red"), + ("g", "add_bar('green')", "Add Green"), + ("b", "add_bar('blue')", "Add Blue"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + def action_add_bar(self, color: str) -> None: + bar = Bar(color) + bar.styles.background = Color.parse(color).with_alpha(0.5) + self.mount(bar) + self.call_later(self.screen.scroll_end, animate=False) + + +if __name__ == "__main__": + app = BindingApp() + app.run() diff --git a/docs/examples/guide/input/key01.py b/docs/examples/guide/input/key01.py new file mode 100644 index 000000000..44d88d558 --- /dev/null +++ b/docs/examples/guide/input/key01.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextLog +from textual import events + + +class InputApp(App): + """App to display key events.""" + + def compose(self) -> ComposeResult: + yield TextLog() + + def on_key(self, event: events.Key) -> None: + self.query_one(TextLog).write(event) + + +if __name__ == "__main__": + app = InputApp() + app.run() diff --git a/docs/examples/guide/input/key02.py b/docs/examples/guide/input/key02.py new file mode 100644 index 000000000..c04a7cd26 --- /dev/null +++ b/docs/examples/guide/input/key02.py @@ -0,0 +1,21 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextLog +from textual import events + + +class InputApp(App): + """App to display key events.""" + + def compose(self) -> ComposeResult: + yield TextLog() + + def on_key(self, event: events.Key) -> None: + self.query_one(TextLog).write(event) + + def key_space(self) -> None: + self.bell() + + +if __name__ == "__main__": + app = InputApp() + app.run() diff --git a/docs/examples/guide/input/key03.css b/docs/examples/guide/input/key03.css new file mode 100644 index 000000000..601612492 --- /dev/null +++ b/docs/examples/guide/input/key03.css @@ -0,0 +1,17 @@ +Screen { + layout: grid; + grid-size: 2 2; + grid-columns: 1fr; +} + +KeyLogger { + border: blank; +} + +KeyLogger:hover { + border: wide $secondary; +} + +KeyLogger:focus { + border: wide $accent; +} diff --git a/docs/examples/guide/input/key03.py b/docs/examples/guide/input/key03.py new file mode 100644 index 000000000..0b4f8ea04 --- /dev/null +++ b/docs/examples/guide/input/key03.py @@ -0,0 +1,25 @@ +from textual.app import App, ComposeResult +from textual.widgets import TextLog +from textual import events + + +class KeyLogger(TextLog): + def on_key(self, event: events.Key) -> None: + self.write(event) + + +class InputApp(App): + """App to display key events.""" + + CSS_PATH = "key03.css" + + def compose(self) -> ComposeResult: + yield KeyLogger() + yield KeyLogger() + yield KeyLogger() + yield KeyLogger() + + +if __name__ == "__main__": + app = InputApp() + app.run() diff --git a/docs/examples/guide/input/mouse01.css b/docs/examples/guide/input/mouse01.css new file mode 100644 index 000000000..95685d023 --- /dev/null +++ b/docs/examples/guide/input/mouse01.css @@ -0,0 +1,24 @@ +Screen { + layers: log ball; +} + +TextLog { + layer: log; +} + +PlayArea { + background: transparent; + layer: ball; + +} +Ball { + layer: ball; + width: auto; + height: 1; + background: $secondary; + border: tall $secondary; + color: $background; + box-sizing: content-box; + text-style: bold; + padding: 0 4; +} diff --git a/docs/examples/guide/input/mouse01.py b/docs/examples/guide/input/mouse01.py new file mode 100644 index 000000000..cd2b3621b --- /dev/null +++ b/docs/examples/guide/input/mouse01.py @@ -0,0 +1,30 @@ +from textual import events +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import Static, TextLog + + +class PlayArea(Container): + def on_mount(self) -> None: + self.capture_mouse() + + def on_mouse_move(self, event: events.MouseMove) -> None: + self.screen.query_one(TextLog).write(event) + self.query_one(Ball).offset = event.offset - (8, 2) + + +class Ball(Static): + pass + + +class MouseApp(App): + CSS_PATH = "mouse01.css" + + def compose(self) -> ComposeResult: + yield TextLog() + yield PlayArea(Ball("Textual")) + + +if __name__ == "__main__": + app = MouseApp() + app.run() diff --git a/docs/examples/guide/layout/combining_layouts.css b/docs/examples/guide/layout/combining_layouts.css new file mode 100644 index 000000000..0681bb2fb --- /dev/null +++ b/docs/examples/guide/layout/combining_layouts.css @@ -0,0 +1,50 @@ +#app-grid { + layout: grid; + grid-size: 2; /* two columns */ + grid-columns: 1fr; + grid-rows: 1fr; +} + +#left-pane > Static { + background: $boost; + color: auto; + margin-bottom: 1; + padding: 1; +} + +#left-pane { + row-span: 2; + background: $panel; + border: dodgerblue; +} + +#top-right { + background: $panel; + border: mediumvioletred; +} + +#top-right > Static { + width: auto; + height: 100%; + margin-right: 1; + background: $boost; +} + +#bottom-right { + layout: grid; + grid-size: 3; + grid-columns: 1fr; + grid-rows: 1fr; + grid-gutter: 1; + background: $panel; + border: greenyellow; +} + +#bottom-right-final { + column-span: 2; +} + +#bottom-right > Static { + height: 100%; + background: $boost; +} diff --git a/docs/examples/guide/layout/combining_layouts.py b/docs/examples/guide/layout/combining_layouts.py new file mode 100644 index 000000000..d832bd628 --- /dev/null +++ b/docs/examples/guide/layout/combining_layouts.py @@ -0,0 +1,37 @@ +from textual.containers import Container, Horizontal, Vertical +from textual.app import ComposeResult, App +from textual.widgets import Static, Header + + +class CombiningLayoutsExample(App): + CSS_PATH = "combining_layouts.css" + + def compose(self) -> ComposeResult: + yield Header() + yield Container( + Vertical( + *[Static(f"Vertical layout, child {number}") for number in range(15)], + id="left-pane", + ), + Horizontal( + Static("Horizontally"), + Static("Positioned"), + Static("Children"), + Static("Here"), + id="top-right", + ), + Container( + Static("This"), + Static("panel"), + Static("is"), + Static("using"), + Static("grid layout!", id="bottom-right-final"), + id="bottom-right", + ), + id="app-grid", + ) + + +if __name__ == "__main__": + app = CombiningLayoutsExample() + app.run() diff --git a/docs/examples/guide/layout/dock_layout1_sidebar.css b/docs/examples/guide/layout/dock_layout1_sidebar.css new file mode 100644 index 000000000..2d8d1e299 --- /dev/null +++ b/docs/examples/guide/layout/dock_layout1_sidebar.css @@ -0,0 +1,7 @@ +#sidebar { + dock: left; + width: 15; + height: 100%; + color: #0f2b41; + background: dodgerblue; +} diff --git a/docs/examples/guide/layout/dock_layout1_sidebar.py b/docs/examples/guide/layout/dock_layout1_sidebar.py new file mode 100644 index 000000000..81eb94805 --- /dev/null +++ b/docs/examples/guide/layout/dock_layout1_sidebar.py @@ -0,0 +1,22 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """\ +Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. + +Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars. + +""" + + +class DockLayoutExample(App): + CSS_PATH = "dock_layout1_sidebar.css" + + def compose(self) -> ComposeResult: + yield Static("Sidebar", id="sidebar") + yield Static(TEXT * 10, id="body") + + +if __name__ == "__main__": + app = DockLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/dock_layout2_sidebar.css b/docs/examples/guide/layout/dock_layout2_sidebar.css new file mode 100644 index 000000000..b90fa8cd7 --- /dev/null +++ b/docs/examples/guide/layout/dock_layout2_sidebar.css @@ -0,0 +1,14 @@ +#another-sidebar { + dock: left; + width: 30; + height: 100%; + background: deeppink; +} + +#sidebar { + dock: left; + width: 15; + height: 100%; + color: #0f2b41; + background: dodgerblue; +} diff --git a/docs/examples/guide/layout/dock_layout2_sidebar.py b/docs/examples/guide/layout/dock_layout2_sidebar.py new file mode 100644 index 000000000..0da8f78c3 --- /dev/null +++ b/docs/examples/guide/layout/dock_layout2_sidebar.py @@ -0,0 +1,23 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """\ +Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. + +Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars. + +""" + + +class DockLayoutExample(App): + CSS_PATH = "dock_layout2_sidebar.css" + + def compose(self) -> ComposeResult: + yield Static("Sidebar2", id="another-sidebar") + yield Static("Sidebar1", id="sidebar") + yield Static(TEXT * 10, id="body") + + +app = DockLayoutExample() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/guide/layout/dock_layout3_sidebar_header.css b/docs/examples/guide/layout/dock_layout3_sidebar_header.css new file mode 100644 index 000000000..2d8d1e299 --- /dev/null +++ b/docs/examples/guide/layout/dock_layout3_sidebar_header.css @@ -0,0 +1,7 @@ +#sidebar { + dock: left; + width: 15; + height: 100%; + color: #0f2b41; + background: dodgerblue; +} diff --git a/docs/examples/guide/layout/dock_layout3_sidebar_header.py b/docs/examples/guide/layout/dock_layout3_sidebar_header.py new file mode 100644 index 000000000..5967bda7f --- /dev/null +++ b/docs/examples/guide/layout/dock_layout3_sidebar_header.py @@ -0,0 +1,23 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Header + +TEXT = """\ +Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. + +Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars. + +""" + + +class DockLayoutExample(App): + CSS_PATH = "dock_layout3_sidebar_header.css" + + def compose(self) -> ComposeResult: + yield Header(id="header") + yield Static("Sidebar1", id="sidebar") + yield Static(TEXT * 10, id="body") + + +if __name__ == "__main__": + app = DockLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout1.css b/docs/examples/guide/layout/grid_layout1.css new file mode 100644 index 000000000..3324c7400 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout1.css @@ -0,0 +1,9 @@ +Screen { + layout: grid; + grid-size: 3 2; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout1.py b/docs/examples/guide/layout/grid_layout1.py new file mode 100644 index 000000000..943f18cb7 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout1.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout1.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout2.css b/docs/examples/guide/layout/grid_layout2.css new file mode 100644 index 000000000..1749a75c2 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout2.css @@ -0,0 +1,9 @@ +Screen { + layout: grid; + grid-size: 3; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout2.py b/docs/examples/guide/layout/grid_layout2.py new file mode 100644 index 000000000..407c081e1 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout2.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout2.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + yield Static("Seven", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout3_row_col_adjust.css b/docs/examples/guide/layout/grid_layout3_row_col_adjust.css new file mode 100644 index 000000000..af01d5137 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout3_row_col_adjust.css @@ -0,0 +1,10 @@ +Screen { + layout: grid; + grid-size: 3; + grid-columns: 2fr 1fr 1fr; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout3_row_col_adjust.py b/docs/examples/guide/layout/grid_layout3_row_col_adjust.py new file mode 100644 index 000000000..c75a58da1 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout3_row_col_adjust.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout3_row_col_adjust.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout4_row_col_adjust.css b/docs/examples/guide/layout/grid_layout4_row_col_adjust.css new file mode 100644 index 000000000..7e377c941 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout4_row_col_adjust.css @@ -0,0 +1,11 @@ +Screen { + layout: grid; + grid-size: 3; + grid-columns: 2fr 1fr 1fr; + grid-rows: 25% 75%; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout4_row_col_adjust.py b/docs/examples/guide/layout/grid_layout4_row_col_adjust.py new file mode 100644 index 000000000..f11a2d5b0 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout4_row_col_adjust.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout4_row_col_adjust.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout5_col_span.css b/docs/examples/guide/layout/grid_layout5_col_span.css new file mode 100644 index 000000000..0c78063f6 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout5_col_span.css @@ -0,0 +1,14 @@ +Screen { + layout: grid; + grid-size: 3; +} + +#two { + column-span: 2; + tint: magenta 40%; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout5_col_span.py b/docs/examples/guide/layout/grid_layout5_col_span.py new file mode 100644 index 000000000..d7fe1cb83 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout5_col_span.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout5_col_span.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two [b](column-span: 2)", classes="box", id="two") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/grid_layout6_row_span.css b/docs/examples/guide/layout/grid_layout6_row_span.css new file mode 100644 index 000000000..43084fedc --- /dev/null +++ b/docs/examples/guide/layout/grid_layout6_row_span.css @@ -0,0 +1,15 @@ +Screen { + layout: grid; + grid-size: 3; +} + +#two { + column-span: 2; + row-span: 2; + tint: magenta 40%; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/grid_layout6_row_span.py b/docs/examples/guide/layout/grid_layout6_row_span.py new file mode 100644 index 000000000..54630b081 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout6_row_span.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout6_row_span.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two [b](column-span: 2 and row-span: 2)", classes="box", id="two") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +app = GridLayoutExample() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/guide/layout/grid_layout7_gutter.css b/docs/examples/guide/layout/grid_layout7_gutter.css new file mode 100644 index 000000000..a495b2642 --- /dev/null +++ b/docs/examples/guide/layout/grid_layout7_gutter.css @@ -0,0 +1,11 @@ +Screen { + layout: grid; + grid-size: 3; + grid-gutter: 1; + background: lightgreen; +} + +.box { + background: darkmagenta; + height: 100%; +} diff --git a/docs/examples/guide/layout/grid_layout7_gutter.py b/docs/examples/guide/layout/grid_layout7_gutter.py new file mode 100644 index 000000000..db916858c --- /dev/null +++ b/docs/examples/guide/layout/grid_layout7_gutter.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class GridLayoutExample(App): + CSS_PATH = "grid_layout7_gutter.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + yield Static("Four", classes="box") + yield Static("Five", classes="box") + yield Static("Six", classes="box") + + +if __name__ == "__main__": + app = GridLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/horizontal_layout.css b/docs/examples/guide/layout/horizontal_layout.css new file mode 100644 index 000000000..3ea0c4e20 --- /dev/null +++ b/docs/examples/guide/layout/horizontal_layout.css @@ -0,0 +1,9 @@ +Screen { + layout: horizontal; +} + +.box { + height: 100%; + width: 1fr; + border: solid green; +} diff --git a/docs/examples/guide/layout/horizontal_layout.py b/docs/examples/guide/layout/horizontal_layout.py new file mode 100644 index 000000000..40997293f --- /dev/null +++ b/docs/examples/guide/layout/horizontal_layout.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class HorizontalLayoutExample(App): + CSS_PATH = "horizontal_layout.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + + +if __name__ == "__main__": + app = HorizontalLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/horizontal_layout_overflow.css b/docs/examples/guide/layout/horizontal_layout_overflow.css new file mode 100644 index 000000000..6d52aa1f6 --- /dev/null +++ b/docs/examples/guide/layout/horizontal_layout_overflow.css @@ -0,0 +1,9 @@ +Screen { + layout: horizontal; + overflow-x: auto; +} + +.box { + height: 100%; + border: solid green; +} diff --git a/docs/examples/guide/layout/horizontal_layout_overflow.py b/docs/examples/guide/layout/horizontal_layout_overflow.py new file mode 100644 index 000000000..b5be0e96d --- /dev/null +++ b/docs/examples/guide/layout/horizontal_layout_overflow.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class HorizontalLayoutExample(App): + CSS_PATH = "horizontal_layout_overflow.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + + +if __name__ == "__main__": + app = HorizontalLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/layers.css b/docs/examples/guide/layout/layers.css new file mode 100644 index 000000000..4465b2d91 --- /dev/null +++ b/docs/examples/guide/layout/layers.css @@ -0,0 +1,22 @@ +Screen { + align: center middle; + layers: below above; +} + +Static { + width: 28; + height: 8; + color: auto; + content-align: center middle; +} + +#box1 { + background: darkcyan; + layer: above; +} + +#box2 { + layer: below; + background: orange; + offset: 12 6; +} diff --git a/docs/examples/guide/layout/layers.py b/docs/examples/guide/layout/layers.py new file mode 100644 index 000000000..06afbd29a --- /dev/null +++ b/docs/examples/guide/layout/layers.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class LayersExample(App): + CSS_PATH = "layers.css" + + def compose(self) -> ComposeResult: + yield Static("box1 (layer = above)", id="box1") + yield Static("box2 (layer = below)", id="box2") + + +if __name__ == "__main__": + app = LayersExample() + app.run() diff --git a/docs/examples/guide/layout/utility_containers.css b/docs/examples/guide/layout/utility_containers.css new file mode 100644 index 000000000..006604130 --- /dev/null +++ b/docs/examples/guide/layout/utility_containers.css @@ -0,0 +1,10 @@ +Static { + content-align: center middle; + background: crimson; + border: solid darkred; + height: 1fr; +} + +.column { + width: 1fr; +} diff --git a/docs/examples/guide/layout/utility_containers.py b/docs/examples/guide/layout/utility_containers.py new file mode 100644 index 000000000..eadf58b4c --- /dev/null +++ b/docs/examples/guide/layout/utility_containers.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + + +class UtilityContainersExample(App): + CSS_PATH = "utility_containers.css" + + def compose(self) -> ComposeResult: + yield Horizontal( + Vertical( + Static("One"), + Static("Two"), + classes="column", + ), + Vertical( + Static("Three"), + Static("Four"), + classes="column", + ), + ) + + +if __name__ == "__main__": + app = UtilityContainersExample() + app.run() diff --git a/docs/examples/guide/layout/vertical_layout.css b/docs/examples/guide/layout/vertical_layout.css new file mode 100644 index 000000000..30d557c60 --- /dev/null +++ b/docs/examples/guide/layout/vertical_layout.css @@ -0,0 +1,8 @@ +Screen { + layout: vertical; +} + +.box { + height: 1fr; + border: solid green; +} diff --git a/docs/examples/guide/layout/vertical_layout.py b/docs/examples/guide/layout/vertical_layout.py new file mode 100644 index 000000000..233407ac3 --- /dev/null +++ b/docs/examples/guide/layout/vertical_layout.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class VerticalLayoutExample(App): + CSS_PATH = "vertical_layout.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + + +if __name__ == "__main__": + app = VerticalLayoutExample() + app.run() diff --git a/docs/examples/guide/layout/vertical_layout_scrolled.css b/docs/examples/guide/layout/vertical_layout_scrolled.css new file mode 100644 index 000000000..3dafcd616 --- /dev/null +++ b/docs/examples/guide/layout/vertical_layout_scrolled.css @@ -0,0 +1,8 @@ +Screen { + layout: vertical; +} + +.box { + height: 14; + border: solid green; +} diff --git a/docs/examples/guide/layout/vertical_layout_scrolled.py b/docs/examples/guide/layout/vertical_layout_scrolled.py new file mode 100644 index 000000000..984040ef7 --- /dev/null +++ b/docs/examples/guide/layout/vertical_layout_scrolled.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class VerticalLayoutScrolledExample(App): + CSS_PATH = "vertical_layout_scrolled.css" + + def compose(self) -> ComposeResult: + yield Static("One", classes="box") + yield Static("Two", classes="box") + yield Static("Three", classes="box") + + +if __name__ == "__main__": + app = VerticalLayoutScrolledExample() + app.run() diff --git a/docs/examples/guide/reactivity/computed01.css b/docs/examples/guide/reactivity/computed01.css new file mode 100644 index 000000000..7d1ca2740 --- /dev/null +++ b/docs/examples/guide/reactivity/computed01.css @@ -0,0 +1,13 @@ +#color-inputs { + dock: top; + height: auto; +} + +Input { + width: 1fr; +} + +#color { + height: 100%; + border: tall $secondary; +} diff --git a/docs/examples/guide/reactivity/computed01.py b/docs/examples/guide/reactivity/computed01.py new file mode 100644 index 000000000..dcef731ff --- /dev/null +++ b/docs/examples/guide/reactivity/computed01.py @@ -0,0 +1,47 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.widgets import Input, Static + + +class ComputedApp(App): + CSS_PATH = "computed01.css" + + red = reactive(0) + green = reactive(0) + blue = reactive(0) + color = reactive(Color.parse("transparent")) + + def compose(self) -> ComposeResult: + yield Horizontal( + Input("0", placeholder="Enter red 0-255", id="red"), + Input("0", placeholder="Enter green 0-255", id="green"), + Input("0", placeholder="Enter blue 0-255", id="blue"), + id="color-inputs", + ) + yield Static(id="color") + + def compute_color(self) -> Color: # (1)! + return Color(self.red, self.green, self.blue).clamped + + def watch_color(self, color: Color) -> None: # (2) + self.query_one("#color").styles.background = color + + def on_input_changed(self, event: Input.Changed) -> None: + try: + component = int(event.value) + except ValueError: + self.bell() + else: + if event.input.id == "red": + self.red = component + elif event.input.id == "green": + self.green = component + else: + self.blue = component + + +if __name__ == "__main__": + app = ComputedApp() + app.run() diff --git a/docs/examples/guide/reactivity/refresh01.css b/docs/examples/guide/reactivity/refresh01.css new file mode 100644 index 000000000..08cd17d91 --- /dev/null +++ b/docs/examples/guide/reactivity/refresh01.css @@ -0,0 +1,9 @@ +Input { + dock: top; + margin-top: 1; +} + +Name { + height: 100%; + content-align: center middle; +} diff --git a/docs/examples/guide/reactivity/refresh01.py b/docs/examples/guide/reactivity/refresh01.py new file mode 100644 index 000000000..d01e1031c --- /dev/null +++ b/docs/examples/guide/reactivity/refresh01.py @@ -0,0 +1,29 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Input + + +class Name(Widget): + """Generates a greeting.""" + + who = reactive("name") + + def render(self) -> str: + return f"Hello, {self.who}!" + + +class WatchApp(App): + CSS_PATH = "refresh01.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter your name") + yield Name() + + def on_input_changed(self, event: Input.Changed) -> None: + self.query_one(Name).who = event.value + + +if __name__ == "__main__": + app = WatchApp() + app.run() diff --git a/docs/examples/guide/reactivity/refresh02.css b/docs/examples/guide/reactivity/refresh02.css new file mode 100644 index 000000000..55ba7f0f8 --- /dev/null +++ b/docs/examples/guide/reactivity/refresh02.css @@ -0,0 +1,10 @@ +Input { + dock: top; + margin-top: 1; +} + +Name { + width: auto; + height: auto; + border: heavy $secondary; +} diff --git a/docs/examples/guide/reactivity/refresh02.py b/docs/examples/guide/reactivity/refresh02.py new file mode 100644 index 000000000..28da2549c --- /dev/null +++ b/docs/examples/guide/reactivity/refresh02.py @@ -0,0 +1,29 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Input + + +class Name(Widget): + """Generates a greeting.""" + + who = reactive("name", layout=True) # (1)! + + def render(self) -> str: + return f"Hello, {self.who}!" + + +class WatchApp(App): + CSS_PATH = "refresh02.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter your name") + yield Name() + + def on_input_changed(self, event: Input.Changed) -> None: + self.query_one(Name).who = event.value + + +if __name__ == "__main__": + app = WatchApp() + app.run() diff --git a/docs/examples/guide/reactivity/validate01.css b/docs/examples/guide/reactivity/validate01.css new file mode 100644 index 000000000..5bb65cbc8 --- /dev/null +++ b/docs/examples/guide/reactivity/validate01.css @@ -0,0 +1,4 @@ +#buttons { + dock: top; + height: auto; +} diff --git a/docs/examples/guide/reactivity/validate01.py b/docs/examples/guide/reactivity/validate01.py new file mode 100644 index 000000000..348a9f1d8 --- /dev/null +++ b/docs/examples/guide/reactivity/validate01.py @@ -0,0 +1,38 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.widgets import Button, TextLog + + +class ValidateApp(App): + CSS_PATH = "validate01.css" + + count = reactive(0) + + def validate_count(self, count: int) -> int: + """Validate value.""" + if count < 0: + count = 0 + elif count > 10: + count = 10 + return count + + def compose(self) -> ComposeResult: + yield Horizontal( + Button("+1", id="plus", variant="success"), + Button("-1", id="minus", variant="error"), + id="buttons", + ) + yield TextLog(highlight=True) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "plus": + self.count += 1 + else: + self.count -= 1 + self.query_one(TextLog).write(f"{self.count=}") + + +if __name__ == "__main__": + app = ValidateApp() + app.run() diff --git a/docs/examples/guide/reactivity/watch01.css b/docs/examples/guide/reactivity/watch01.css new file mode 100644 index 000000000..1b1fce667 --- /dev/null +++ b/docs/examples/guide/reactivity/watch01.css @@ -0,0 +1,21 @@ +Input { + dock: top; + margin-top: 1; +} + +#colors { + grid-size: 2 1; + grid-gutter: 2 4; + grid-columns: 1fr; + margin: 0 1; +} + +#old { + height: 100%; + border: wide $secondary; +} + +#new { + height: 100%; + border: wide $secondary; +} diff --git a/docs/examples/guide/reactivity/watch01.py b/docs/examples/guide/reactivity/watch01.py new file mode 100644 index 000000000..5d2cacffd --- /dev/null +++ b/docs/examples/guide/reactivity/watch01.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.color import Color, ColorParseError +from textual.containers import Grid +from textual.reactive import reactive +from textual.widgets import Input, Static + + +class WatchApp(App): + CSS_PATH = "watch01.css" + + color = reactive(Color.parse("transparent")) # (1)! + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a color") + yield Grid(Static(id="old"), Static(id="new"), id="colors") + + def watch_color(self, old_color: Color, new_color: Color) -> None: # (2)! + self.query_one("#old").styles.background = old_color + self.query_one("#new").styles.background = new_color + + def on_input_submitted(self, event: Input.Submitted) -> None: + try: + input_color = Color.parse(event.value) + except ColorParseError: + pass + else: + self.query_one(Input).value = "" + self.color = input_color # (3)! + + +if __name__ == "__main__": + app = WatchApp() + app.run() diff --git a/docs/examples/guide/screens/modal01.css b/docs/examples/guide/screens/modal01.css new file mode 100644 index 000000000..8c7bea071 --- /dev/null +++ b/docs/examples/guide/screens/modal01.css @@ -0,0 +1,16 @@ +#dialog { + grid-size: 2; + grid-gutter: 1 2; + margin: 1 2; +} + +#question { + column-span: 2; + content-align: center bottom; + width: 100%; + height: 100%; +} + +Button { + width: 100%; +} diff --git a/docs/examples/guide/screens/modal01.py b/docs/examples/guide/screens/modal01.py new file mode 100644 index 000000000..2ae1dd990 --- /dev/null +++ b/docs/examples/guide/screens/modal01.py @@ -0,0 +1,37 @@ +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.screen import Screen +from textual.widgets import Static, Header, Footer, Button + + +class QuitScreen(Screen): + def compose(self) -> ComposeResult: + yield Grid( + Static("Are you sure you want to quit?", id="question"), + Button("Quit", variant="error", id="quit"), + Button("Cancel", variant="primary", id="cancel"), + id="dialog", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "quit": + self.app.exit() + else: + self.app.pop_screen() + + +class ModalApp(App): + CSS_PATH = "modal01.css" + BINDINGS = [("q", "request_quit", "Quit")] + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + + def action_request_quit(self) -> None: + self.push_screen(QuitScreen()) + + +if __name__ == "__main__": + app = ModalApp() + app.run() diff --git a/docs/examples/guide/screens/screen01.css b/docs/examples/guide/screens/screen01.css new file mode 100644 index 000000000..0ee028ebe --- /dev/null +++ b/docs/examples/guide/screens/screen01.css @@ -0,0 +1,18 @@ +BSOD { + align: center middle; + background: blue; + color: white; +} + +BSOD>Static { + width: 70; +} + +#title { + content-align-horizontal: center; + text-style: reverse; +} + +#any-key { + content-align-horizontal: center; +} diff --git a/docs/examples/guide/screens/screen01.py b/docs/examples/guide/screens/screen01.py new file mode 100644 index 000000000..7b83cedee --- /dev/null +++ b/docs/examples/guide/screens/screen01.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static + + +ERROR_TEXT = """ +An error has occurred. To continue: + +Press Enter to return to Windows, or + +Press CTRL+ALT+DEL to restart your computer. If you do this, +you will lose any unsaved information in all open applications. + +Error: 0E : 016F : BFF9B3D4 +""" + + +class BSOD(Screen): + BINDINGS = [("escape", "app.pop_screen", "Pop screen")] + + def compose(self) -> ComposeResult: + yield Static(" Windows ", id="title") + yield Static(ERROR_TEXT) + yield Static("Press any key to continue [blink]_[/]", id="any-key") + + +class BSODApp(App): + CSS_PATH = "screen01.css" + SCREENS = {"bsod": BSOD()} + BINDINGS = [("b", "push_screen('bsod')", "BSOD")] + + +if __name__ == "__main__": + app = BSODApp() + app.run() diff --git a/docs/examples/guide/screens/screen02.css b/docs/examples/guide/screens/screen02.css new file mode 100644 index 000000000..0ee028ebe --- /dev/null +++ b/docs/examples/guide/screens/screen02.css @@ -0,0 +1,18 @@ +BSOD { + align: center middle; + background: blue; + color: white; +} + +BSOD>Static { + width: 70; +} + +#title { + content-align-horizontal: center; + text-style: reverse; +} + +#any-key { + content-align-horizontal: center; +} diff --git a/docs/examples/guide/screens/screen02.py b/docs/examples/guide/screens/screen02.py new file mode 100644 index 000000000..f422a410e --- /dev/null +++ b/docs/examples/guide/screens/screen02.py @@ -0,0 +1,37 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static + + +ERROR_TEXT = """ +An error has occurred. To continue: + +Press Enter to return to Windows, or + +Press CTRL+ALT+DEL to restart your computer. If you do this, +you will lose any unsaved information in all open applications. + +Error: 0E : 016F : BFF9B3D4 +""" + + +class BSOD(Screen): + BINDINGS = [("escape", "app.pop_screen", "Pop screen")] + + def compose(self) -> ComposeResult: + yield Static(" Windows ", id="title") + yield Static(ERROR_TEXT) + yield Static("Press any key to continue [blink]_[/]", id="any-key") + + +class BSODApp(App): + CSS_PATH = "screen02.css" + BINDINGS = [("b", "push_screen('bsod')", "BSOD")] + + def on_mount(self) -> None: + self.install_screen(BSOD(), name="bsod") + + +if __name__ == "__main__": + app = BSODApp() + app.run() diff --git a/docs/examples/guide/structure.py b/docs/examples/guide/structure.py new file mode 100644 index 000000000..fe1d3dea6 --- /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.""" + + DEFAULT_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() + + +if __name__ == "__main__": + app = ClockApp() + app.run() diff --git a/docs/examples/guide/styles/border01.py b/docs/examples/guide/styles/border01.py new file mode 100644 index 000000000..6cc5aada5 --- /dev/null +++ b/docs/examples/guide/styles/border01.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class BorderApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "darkblue" + self.widget.styles.width = "50%" + self.widget.styles.border = ("heavy", "yellow") + + +if __name__ == "__main__": + app = BorderApp() + app.run() diff --git a/docs/examples/guide/styles/box_sizing01.py b/docs/examples/guide/styles/box_sizing01.py new file mode 100644 index 000000000..af1e5ef62 --- /dev/null +++ b/docs/examples/guide/styles/box_sizing01.py @@ -0,0 +1,38 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class BoxSizing(App): + def compose(self) -> ComposeResult: + self.widget1 = Static(TEXT) + yield self.widget1 + self.widget2 = Static(TEXT) + yield self.widget2 + + def on_mount(self) -> None: + self.widget1.styles.background = "purple" + self.widget2.styles.background = "darkgreen" + self.widget1.styles.width = 30 + self.widget2.styles.width = 30 + self.widget2.styles.height = 6 + self.widget1.styles.height = 6 + self.widget2.styles.height = 6 + self.widget1.styles.border = ("heavy", "white") + self.widget2.styles.border = ("heavy", "white") + self.widget1.styles.padding = 1 + self.widget2.styles.padding = 1 + self.widget2.styles.box_sizing = "content-box" + + +if __name__ == "__main__": + app = BoxSizing() + app.run() diff --git a/docs/examples/guide/styles/colors.py b/docs/examples/guide/styles/colors.py new file mode 100644 index 000000000..5abfaf861 --- /dev/null +++ b/docs/examples/guide/styles/colors.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class WidgetApp(App): + def compose(self) -> ComposeResult: + self.widget = Static("Textual") + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "darkblue" + self.widget.styles.border = ("heavy", "white") + + +if __name__ == "__main__": + app = WidgetApp() + app.run() diff --git a/docs/examples/guide/styles/colors01.py b/docs/examples/guide/styles/colors01.py new file mode 100644 index 000000000..401f9becb --- /dev/null +++ b/docs/examples/guide/styles/colors01.py @@ -0,0 +1,24 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widgets import Static + + +class ColorApp(App): + def compose(self) -> ComposeResult: + self.widget1 = Static("Textual One") + yield self.widget1 + self.widget2 = Static("Textual Two") + yield self.widget2 + self.widget3 = Static("Textual Three") + yield self.widget3 + + def on_mount(self) -> None: + self.widget1.styles.background = "#9932CC" + self.widget2.styles.background = "hsl(150,42.9%,49.4%)" + self.widget2.styles.color = "blue" + self.widget3.styles.background = Color(191, 78, 96) + + +if __name__ == "__main__": + app = ColorApp() + app.run() diff --git a/docs/examples/guide/styles/colors02.py b/docs/examples/guide/styles/colors02.py new file mode 100644 index 000000000..80b480e4e --- /dev/null +++ b/docs/examples/guide/styles/colors02.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.widgets import Static + + +class ColorApp(App): + def compose(self) -> ComposeResult: + self.widgets = [Static("") for n in range(10)] + yield from self.widgets + + def on_mount(self) -> None: + for index, widget in enumerate(self.widgets, 1): + alpha = index * 0.1 + widget.update(f"alpha={alpha:.1f}") + widget.styles.background = Color(191, 78, 96, a=alpha) + + +if __name__ == "__main__": + app = ColorApp() + app.run() diff --git a/docs/examples/guide/styles/dimensions01.py b/docs/examples/guide/styles/dimensions01.py new file mode 100644 index 000000000..ed479f0f3 --- /dev/null +++ b/docs/examples/guide/styles/dimensions01.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class DimensionsApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "purple" + self.widget.styles.width = 30 + self.widget.styles.height = 10 + + +if __name__ == "__main__": + app = DimensionsApp() + app.run() diff --git a/docs/examples/guide/styles/dimensions02.py b/docs/examples/guide/styles/dimensions02.py new file mode 100644 index 000000000..339aade99 --- /dev/null +++ b/docs/examples/guide/styles/dimensions02.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class DimensionsApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "purple" + self.widget.styles.width = 30 + self.widget.styles.height = "auto" + + +if __name__ == "__main__": + app = DimensionsApp() + app.run() diff --git a/docs/examples/guide/styles/dimensions03.py b/docs/examples/guide/styles/dimensions03.py new file mode 100644 index 000000000..4d361227e --- /dev/null +++ b/docs/examples/guide/styles/dimensions03.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class DimensionsApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "purple" + self.widget.styles.width = "50%" + self.widget.styles.height = "80%" + + +if __name__ == "__main__": + app = DimensionsApp() + app.run() diff --git a/docs/examples/guide/styles/dimensions04.py b/docs/examples/guide/styles/dimensions04.py new file mode 100644 index 000000000..405b5545e --- /dev/null +++ b/docs/examples/guide/styles/dimensions04.py @@ -0,0 +1,30 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class DimensionsApp(App): + def compose(self) -> ComposeResult: + self.widget1 = Static(TEXT) + yield self.widget1 + self.widget2 = Static(TEXT) + yield self.widget2 + + def on_mount(self) -> None: + self.widget1.styles.background = "purple" + self.widget2.styles.background = "darkgreen" + self.widget1.styles.height = "2fr" + self.widget2.styles.height = "1fr" + + +if __name__ == "__main__": + app = DimensionsApp() + app.run() diff --git a/docs/examples/guide/styles/margin01.py b/docs/examples/guide/styles/margin01.py new file mode 100644 index 000000000..7036cb725 --- /dev/null +++ b/docs/examples/guide/styles/margin01.py @@ -0,0 +1,32 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class MarginApp(App): + def compose(self) -> ComposeResult: + self.widget1 = Static(TEXT) + yield self.widget1 + self.widget2 = Static(TEXT) + yield self.widget2 + + def on_mount(self) -> None: + self.widget1.styles.background = "purple" + self.widget2.styles.background = "darkgreen" + self.widget1.styles.border = ("heavy", "white") + self.widget2.styles.border = ("heavy", "white") + self.widget1.styles.margin = 2 + self.widget2.styles.margin = 2 + + +if __name__ == "__main__": + app = MarginApp() + app.run() diff --git a/docs/examples/guide/styles/outline01.py b/docs/examples/guide/styles/outline01.py new file mode 100644 index 000000000..cd77d0b8c --- /dev/null +++ b/docs/examples/guide/styles/outline01.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class OutlineApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "darkblue" + self.widget.styles.width = "50%" + self.widget.styles.outline = ("heavy", "yellow") + + +if __name__ == "__main__": + app = OutlineApp() + app.run() diff --git a/docs/examples/guide/styles/padding01.py b/docs/examples/guide/styles/padding01.py new file mode 100644 index 000000000..92c68948a --- /dev/null +++ b/docs/examples/guide/styles/padding01.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class PaddingApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "purple" + self.widget.styles.width = 30 + self.widget.styles.padding = 2 + + +if __name__ == "__main__": + app = PaddingApp() + app.run() diff --git a/docs/examples/guide/styles/padding02.py b/docs/examples/guide/styles/padding02.py new file mode 100644 index 000000000..50bf0b940 --- /dev/null +++ b/docs/examples/guide/styles/padding02.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class PaddingApp(App): + def compose(self) -> ComposeResult: + self.widget = Static(TEXT) + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "purple" + self.widget.styles.width = 30 + self.widget.styles.padding = (2, 4) + + +if __name__ == "__main__": + app = PaddingApp() + app.run() diff --git a/docs/examples/guide/styles/screen.py b/docs/examples/guide/styles/screen.py new file mode 100644 index 000000000..5a7b85fd7 --- /dev/null +++ b/docs/examples/guide/styles/screen.py @@ -0,0 +1,12 @@ +from textual.app import App + + +class ScreenApp(App): + def on_mount(self) -> None: + self.screen.styles.background = "darkblue" + self.screen.styles.border = ("heavy", "white") + + +if __name__ == "__main__": + app = ScreenApp() + app.run() diff --git a/docs/examples/guide/styles/widget.py b/docs/examples/guide/styles/widget.py new file mode 100644 index 000000000..5abfaf861 --- /dev/null +++ b/docs/examples/guide/styles/widget.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class WidgetApp(App): + def compose(self) -> ComposeResult: + self.widget = Static("Textual") + yield self.widget + + def on_mount(self) -> None: + self.widget.styles.background = "darkblue" + self.widget.styles.border = ("heavy", "white") + + +if __name__ == "__main__": + app = WidgetApp() + app.run() diff --git a/docs/examples/guide/widgets/fizzbuzz01.css b/docs/examples/guide/widgets/fizzbuzz01.css new file mode 100644 index 000000000..ed041d2dc --- /dev/null +++ b/docs/examples/guide/widgets/fizzbuzz01.css @@ -0,0 +1,10 @@ +Screen { + align: center middle; +} + +FizzBuzz { + width: auto; + height: auto; + background: $primary; + color: $text; +} diff --git a/docs/examples/guide/widgets/fizzbuzz01.py b/docs/examples/guide/widgets/fizzbuzz01.py new file mode 100644 index 000000000..129abdd07 --- /dev/null +++ b/docs/examples/guide/widgets/fizzbuzz01.py @@ -0,0 +1,30 @@ +from rich.table import Table + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class FizzBuzz(Static): + def on_mount(self) -> None: + table = Table("Number", "Fizz?", "Buzz?") + for n in range(1, 16): + fizz = not n % 3 + buzz = not n % 5 + table.add_row( + str(n), + "fizz" if fizz else "", + "buzz" if buzz else "", + ) + self.update(table) + + +class FizzBuzzApp(App): + CSS_PATH = "fizzbuzz01.css" + + def compose(self) -> ComposeResult: + yield FizzBuzz() + + +if __name__ == "__main__": + app = FizzBuzzApp() + app.run() diff --git a/docs/examples/guide/widgets/fizzbuzz02.css b/docs/examples/guide/widgets/fizzbuzz02.css new file mode 100644 index 000000000..a8fe581c1 --- /dev/null +++ b/docs/examples/guide/widgets/fizzbuzz02.css @@ -0,0 +1,10 @@ +Screen { + align: center middle; +} + +FizzBuzz { + width: auto; + height: auto; + background: $primary; + color: $text; +} diff --git a/docs/examples/guide/widgets/fizzbuzz02.py b/docs/examples/guide/widgets/fizzbuzz02.py new file mode 100644 index 000000000..58618aba5 --- /dev/null +++ b/docs/examples/guide/widgets/fizzbuzz02.py @@ -0,0 +1,35 @@ +from rich.table import Table + +from textual.app import App, ComposeResult +from textual.geometry import Size +from textual.widgets import Static + + +class FizzBuzz(Static): + def on_mount(self) -> None: + table = Table("Number", "Fizz?", "Buzz?", expand=True) + for n in range(1, 16): + fizz = not n % 3 + buzz = not n % 5 + table.add_row( + str(n), + "fizz" if fizz else "", + "buzz" if buzz else "", + ) + self.update(table) + + def get_content_width(self, container: Size, viewport: Size) -> int: + """Force content width size.""" + return 50 + + +class FizzBuzzApp(App): + CSS_PATH = "fizzbuzz02.css" + + def compose(self) -> ComposeResult: + yield FizzBuzz() + + +if __name__ == "__main__": + app = FizzBuzzApp() + app.run() diff --git a/docs/examples/guide/widgets/hello01.css b/docs/examples/guide/widgets/hello01.css new file mode 100644 index 000000000..87b9fc77f --- /dev/null +++ b/docs/examples/guide/widgets/hello01.css @@ -0,0 +1,3 @@ +Screen { + align: center middle; +} diff --git a/docs/examples/guide/widgets/hello01.py b/docs/examples/guide/widgets/hello01.py new file mode 100644 index 000000000..63918910b --- /dev/null +++ b/docs/examples/guide/widgets/hello01.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult, RenderResult +from textual.widget import Widget + + +class Hello(Widget): + """Display a greeting.""" + + def render(self) -> RenderResult: + return "Hello, [b]World[/b]!" + + +class CustomApp(App): + def compose(self) -> ComposeResult: + yield Hello() + + +if __name__ == "__main__": + app = CustomApp() + app.run() diff --git a/docs/examples/guide/widgets/hello02.css b/docs/examples/guide/widgets/hello02.css new file mode 100644 index 000000000..6a9503d69 --- /dev/null +++ b/docs/examples/guide/widgets/hello02.css @@ -0,0 +1,13 @@ +Screen { + align: center middle; +} + +Hello { + width: 40; + height: 9; + padding: 1 2; + background: $panel; + color: $text; + border: $secondary tall; + content-align: center middle; +} diff --git a/docs/examples/guide/widgets/hello02.py b/docs/examples/guide/widgets/hello02.py new file mode 100644 index 000000000..ffab9fd1b --- /dev/null +++ b/docs/examples/guide/widgets/hello02.py @@ -0,0 +1,21 @@ +from textual.app import App, ComposeResult, RenderResult +from textual.widget import Widget + + +class Hello(Widget): + """Display a greeting.""" + + def render(self) -> RenderResult: + return "Hello, [b]World[/b]!" + + +class CustomApp(App): + CSS_PATH = "hello02.css" + + def compose(self) -> ComposeResult: + yield Hello() + + +if __name__ == "__main__": + app = CustomApp() + app.run() diff --git a/docs/examples/guide/widgets/hello03.css b/docs/examples/guide/widgets/hello03.css new file mode 100644 index 000000000..1e46fd415 --- /dev/null +++ b/docs/examples/guide/widgets/hello03.css @@ -0,0 +1,12 @@ +Screen { + align: center middle; +} + +Hello { + width: 40; + height: 9; + padding: 1 2; + background: $panel; + border: $secondary tall; + content-align: center middle; +} diff --git a/docs/examples/guide/widgets/hello03.py b/docs/examples/guide/widgets/hello03.py new file mode 100644 index 000000000..62708a3e2 --- /dev/null +++ b/docs/examples/guide/widgets/hello03.py @@ -0,0 +1,48 @@ +from itertools import cycle + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +hellos = cycle( + [ + "Hola", + "Bonjour", + "Guten tag", + "Salve", + "Nวn hวŽo", + "Olรก", + "Asalaam alaikum", + "Konnichiwa", + "Anyoung haseyo", + "Zdravstvuyte", + "Hello", + ] +) + + +class Hello(Static): + """Display a greeting.""" + + def on_mount(self) -> None: + self.next_word() + + def on_click(self) -> None: + self.next_word() + + def next_word(self) -> None: + """Get a new hello and update the content area.""" + hello = next(hellos) + self.update(f"{hello}, [b]World[/b]!") + + +class CustomApp(App): + CSS_PATH = "hello03.css" + + def compose(self) -> ComposeResult: + yield Hello() + + +if __name__ == "__main__": + app = CustomApp() + app.run() diff --git a/docs/examples/guide/widgets/hello04.css b/docs/examples/guide/widgets/hello04.css new file mode 100644 index 000000000..87b9fc77f --- /dev/null +++ b/docs/examples/guide/widgets/hello04.css @@ -0,0 +1,3 @@ +Screen { + align: center middle; +} diff --git a/docs/examples/guide/widgets/hello04.py b/docs/examples/guide/widgets/hello04.py new file mode 100644 index 000000000..450f26697 --- /dev/null +++ b/docs/examples/guide/widgets/hello04.py @@ -0,0 +1,59 @@ +from itertools import cycle + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +hellos = cycle( + [ + "Hola", + "Bonjour", + "Guten tag", + "Salve", + "Nวn hวŽo", + "Olรก", + "Asalaam alaikum", + "Konnichiwa", + "Anyoung haseyo", + "Zdravstvuyte", + "Hello", + ] +) + + +class Hello(Static): + """Display a greeting.""" + + DEFAULT_CSS = """ + Hello { + width: 40; + height: 9; + padding: 1 2; + background: $panel; + border: $secondary tall; + content-align: center middle; + } + """ + + def on_mount(self) -> None: + self.next_word() + + def on_click(self) -> None: + self.next_word() + + def next_word(self) -> None: + """Get a new hello and update the content area.""" + hello = next(hellos) + self.update(f"{hello}, [b]World[/b]!") + + +class CustomApp(App): + CSS_PATH = "hello03.css" + + def compose(self) -> ComposeResult: + yield Hello() + + +if __name__ == "__main__": + app = CustomApp() + app.run() diff --git a/docs/examples/guide/widgets/hello05.css b/docs/examples/guide/widgets/hello05.css new file mode 100644 index 000000000..1e46fd415 --- /dev/null +++ b/docs/examples/guide/widgets/hello05.css @@ -0,0 +1,12 @@ +Screen { + align: center middle; +} + +Hello { + width: 40; + height: 9; + padding: 1 2; + background: $panel; + border: $secondary tall; + content-align: center middle; +} diff --git a/docs/examples/guide/widgets/hello05.py b/docs/examples/guide/widgets/hello05.py new file mode 100644 index 000000000..1430138b8 --- /dev/null +++ b/docs/examples/guide/widgets/hello05.py @@ -0,0 +1,45 @@ +from itertools import cycle + +from textual.app import App, ComposeResult +from textual.widgets import Static + + +hellos = cycle( + [ + "Hola", + "Bonjour", + "Guten tag", + "Salve", + "Nวn hวŽo", + "Olรก", + "Asalaam alaikum", + "Konnichiwa", + "Anyoung haseyo", + "Zdravstvuyte", + "Hello", + ] +) + + +class Hello(Static): + """Display a greeting.""" + + def on_mount(self) -> None: + self.action_next_word() + + def action_next_word(self) -> None: + """Get a new hello and update the content area.""" + hello = next(hellos) + self.update(f"[@click='next_word']{hello}[/], [b]World[/b]!") + + +class CustomApp(App): + CSS_PATH = "hello05.css" + + def compose(self) -> ComposeResult: + yield Hello() + + +if __name__ == "__main__": + app = CustomApp() + app.run() diff --git a/docs/examples/messages_and_events/beep.py b/docs/examples/messages_and_events/beep.py deleted file mode 100644 index 7cbd54de0..000000000 --- a/docs/examples/messages_and_events/beep.py +++ /dev/null @@ -1,9 +0,0 @@ -from textual.app import App - - -class Beeper(App): - def on_key(self): - self.console.bell() - - -Beeper.run() diff --git a/docs/examples/messages_and_events/beep_typed.py b/docs/examples/messages_and_events/beep_typed.py deleted file mode 100644 index 179965378..000000000 --- a/docs/examples/messages_and_events/beep_typed.py +++ /dev/null @@ -1,10 +0,0 @@ -from textual.app import App -from textual import events - - -class Beeper(App): - async def on_key(self, event: events.Key) -> None: - self.console.bell() - - -Beeper.run() diff --git a/docs/examples/messages_and_events/color_changer.py b/docs/examples/messages_and_events/color_changer.py deleted file mode 100644 index 7d43a65e7..000000000 --- a/docs/examples/messages_and_events/color_changer.py +++ /dev/null @@ -1,10 +0,0 @@ -from textual.app import App - - -class ColorChanger(App): - def on_key(self, event): - if event.key.isdigit(): - self.background = f"on color({event.key})" - - -ColorChanger.run(log="textual.log") diff --git a/docs/examples/styles/README.md b/docs/examples/styles/README.md new file mode 100644 index 000000000..8c8043582 --- /dev/null +++ b/docs/examples/styles/README.md @@ -0,0 +1,9 @@ +These are the examples from the documentation, used to generate screenshots. + +You can run them with the textual CLI. + +For example: + +``` +textual run text_style.py +``` diff --git a/docs/examples/styles/align.css b/docs/examples/styles/align.css new file mode 100644 index 000000000..a49af0571 --- /dev/null +++ b/docs/examples/styles/align.css @@ -0,0 +1,13 @@ +Screen { + align: center middle; +} + +.box { + width: 40; + height: 5; + margin: 1; + padding: 1; + background: green; + color: white 90%; + border: heavy white; +} diff --git a/docs/examples/styles/align.py b/docs/examples/styles/align.py new file mode 100644 index 000000000..6abee37ce --- /dev/null +++ b/docs/examples/styles/align.py @@ -0,0 +1,11 @@ +from textual.app import App +from textual.widgets import Static + + +class AlignApp(App): + def compose(self): + yield Static("Vertical alignment with [b]Textual[/]", classes="box") + yield Static("Take note, browsers.", classes="box") + + +app = AlignApp(css_path="align.css") 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 new file mode 100644 index 000000000..cef306ddc --- /dev/null +++ b/docs/examples/styles/background.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class BackgroundApp(App): + def compose(self): + yield Static("Widget 1", id="static1") + yield Static("Widget 2", id="static2") + yield Static("Widget 3", id="static3") + + +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 new file mode 100644 index 000000000..4dbc8ef4f --- /dev/null +++ b/docs/examples/styles/border.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class BorderApp(App): + 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(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 new file mode 100644 index 000000000..32fc56c6b --- /dev/null +++ b/docs/examples/styles/box_sizing.py @@ -0,0 +1,11 @@ +from textual.app import App +from textual.widgets import Static + + +class BoxSizingApp(App): + def compose(self): + yield Static("I'm using border-box!", id="static1") + yield Static("I'm using content-box!", id="static2") + + +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 new file mode 100644 index 000000000..26543b0a0 --- /dev/null +++ b/docs/examples/styles/color.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class ColorApp(App): + 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(css_path="color.css") diff --git a/docs/examples/styles/content_align.css b/docs/examples/styles/content_align.css new file mode 100644 index 000000000..7024809b0 --- /dev/null +++ b/docs/examples/styles/content_align.css @@ -0,0 +1,20 @@ +#box1 { + content-align: left top; + background: red; +} + +#box2 { + content-align: center middle; + background: green; +} + +#box3 { + content-align: right bottom; + background: blue; +} + +Static { + height: 1fr; + padding: 1; + color: white; +} diff --git a/docs/examples/styles/content_align.py b/docs/examples/styles/content_align.py new file mode 100644 index 000000000..c0213c63e --- /dev/null +++ b/docs/examples/styles/content_align.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class ContentAlignApp(App): + def compose(self): + yield Static("With [i]content-align[/] you can...", id="box1") + yield Static("...[b]Easily align content[/]...", id="box2") + yield Static("...Horizontally [i]and[/] vertically!", id="box3") + + +app = ContentAlignApp(css_path="content_align.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 new file mode 100644 index 000000000..1e68c6e33 --- /dev/null +++ b/docs/examples/styles/display.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class DisplayApp(App): + def compose(self): + yield Static("Widget 1") + yield Static("Widget 2", classes="remove") + yield Static("Widget 3") + + +app = DisplayApp(css_path="display.css") diff --git a/docs/examples/styles/grid.css b/docs/examples/styles/grid.css new file mode 100644 index 000000000..aa31e81c1 --- /dev/null +++ b/docs/examples/styles/grid.css @@ -0,0 +1,20 @@ +Screen { + layout: grid; + grid-size: 3 4; + grid-rows: 1fr; + grid-columns: 1fr; + grid-gutter: 1; +} + +Static { + color: auto; + background: lightblue; + height: 100%; + padding: 1 2; +} + +#static1 { + tint: magenta 40%; + row-span: 3; + column-span: 2; +} diff --git a/docs/examples/styles/grid.py b/docs/examples/styles/grid.py new file mode 100644 index 000000000..338af444c --- /dev/null +++ b/docs/examples/styles/grid.py @@ -0,0 +1,18 @@ +from textual.app import App +from textual.widgets import Static + + +class GridApp(App): + def compose(self): + yield Static("Grid cell 1\n\nrow-span: 3;\ncolumn-span: 2;", id="static1") + yield Static("Grid cell 2", id="static2") + yield Static("Grid cell 3", id="static3") + yield Static("Grid cell 4", id="static4") + yield Static("Grid cell 5", id="static5") + yield Static("Grid cell 6", id="static6") + yield Static("Grid cell 7", id="static7") + + +app = GridApp(css_path="grid.css") +if __name__ == "__main__": + app.run() 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 new file mode 100644 index 000000000..00e3963c4 --- /dev/null +++ b/docs/examples/styles/height.py @@ -0,0 +1,10 @@ +from textual.app import App +from textual.widget import Widget + + +class HeightApp(App): + def compose(self): + yield Widget() + + +app = HeightApp(css_path="height.css") diff --git a/docs/examples/styles/layout.css b/docs/examples/styles/layout.css new file mode 100644 index 000000000..b0f7e3385 --- /dev/null +++ b/docs/examples/styles/layout.css @@ -0,0 +1,18 @@ +#vertical-layout { + layout: vertical; + background: darkmagenta; + height: auto; +} + +#horizontal-layout { + layout: horizontal; + background: darkcyan; + height: auto; +} + +Static { + margin: 1; + width: 12; + color: black; + background: yellowgreen; +} diff --git a/docs/examples/styles/layout.py b/docs/examples/styles/layout.py new file mode 100644 index 000000000..f7c04e984 --- /dev/null +++ b/docs/examples/styles/layout.py @@ -0,0 +1,22 @@ +from textual.app import App +from textual.containers import Container +from textual.widgets import Static + + +class LayoutApp(App): + def compose(self): + yield Container( + Static("Layout"), + Static("Is"), + Static("Vertical"), + id="vertical-layout", + ) + yield Container( + Static("Layout"), + Static("Is"), + Static("Horizontal"), + id="horizontal-layout", + ) + + +app = LayoutApp(css_path="layout.css") diff --git a/docs/examples/styles/links.css b/docs/examples/styles/links.css new file mode 100644 index 000000000..5a040ea06 --- /dev/null +++ b/docs/examples/styles/links.css @@ -0,0 +1,5 @@ +#custom { + link-color: black 90%; + link-background: dodgerblue; + link-style: bold italic underline; +} diff --git a/docs/examples/styles/links.py b/docs/examples/styles/links.py new file mode 100644 index 000000000..cf45563cc --- /dev/null +++ b/docs/examples/styles/links.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = """\ +Here is a [@click='app.bell']link[/] which you can click! +""" + + +class LinksApp(App): + def compose(self) -> ComposeResult: + yield Static(TEXT) + yield Static(TEXT, id="custom") + + +app = LinksApp(css_path="links.css") + +if __name__ == "__main__": + app.run() 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 new file mode 100644 index 000000000..3e6129ead --- /dev/null +++ b/docs/examples/styles/margin.py @@ -0,0 +1,18 @@ +from textual.app import App +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class MarginApp(App): + def compose(self): + yield Static(TEXT) + + +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 new file mode 100644 index 000000000..d850b3778 --- /dev/null +++ b/docs/examples/styles/offset.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class OffsetApp(App): + 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(css_path="offset.css") diff --git a/docs/examples/styles/opacity.css b/docs/examples/styles/opacity.css new file mode 100644 index 000000000..15d059480 --- /dev/null +++ b/docs/examples/styles/opacity.css @@ -0,0 +1,31 @@ +#zero-opacity { + opacity: 0%; +} + +#quarter-opacity { + opacity: 25%; +} + +#half-opacity { + opacity: 50%; +} + +#three-quarter-opacity { + opacity: 75%; +} + +#full-opacity { + opacity: 100%; +} + +Screen { + background: antiquewhite; +} + +Static { + height: 1fr; + border: outer dodgerblue; + background: lightseagreen; + content-align: center middle; + text-style: bold; +} diff --git a/docs/examples/styles/opacity.py b/docs/examples/styles/opacity.py new file mode 100644 index 000000000..d723b1d84 --- /dev/null +++ b/docs/examples/styles/opacity.py @@ -0,0 +1,14 @@ +from textual.app import App +from textual.widgets import Static + + +class OpacityApp(App): + def compose(self): + yield Static("opacity: 0%", id="zero-opacity") + yield Static("opacity: 25%", id="quarter-opacity") + yield Static("opacity: 50%", id="half-opacity") + yield Static("opacity: 75%", id="three-quarter-opacity") + yield Static("opacity: 100%", id="full-opacity") + + +app = OpacityApp(css_path="opacity.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 new file mode 100644 index 000000000..e64b11dc2 --- /dev/null +++ b/docs/examples/styles/outline.py @@ -0,0 +1,19 @@ +from textual.app import App +from textual.widgets import Static + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class OutlineApp(App): + def compose(self): + yield Static(TEXT) + + +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..27eaa81c1 --- /dev/null +++ b/docs/examples/styles/overflow.css @@ -0,0 +1,20 @@ +Screen { + background: $background; + color: black; +} + +Vertical { + width: 1fr; +} + +Static { + margin: 1 2; + background: green 80%; + border: green wide; + color: white 90%; + height: auto; +} + +#right { + overflow-y: hidden; +} diff --git a/docs/examples/styles/overflow.py b/docs/examples/styles/overflow.py new file mode 100644 index 000000000..b9a6c3383 --- /dev/null +++ b/docs/examples/styles/overflow.py @@ -0,0 +1,22 @@ +from textual.app import App +from textual.widgets import Static +from textual.containers import Horizontal, Vertical + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class OverflowApp(App): + def compose(self): + yield Horizontal( + Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="left"), + Vertical(Static(TEXT), Static(TEXT), Static(TEXT), id="right"), + ) + + +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 new file mode 100644 index 000000000..4893838c1 --- /dev/null +++ b/docs/examples/styles/padding.py @@ -0,0 +1,18 @@ +from textual.app import App +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class PaddingApp(App): + def compose(self): + yield Static(TEXT) + + +app = PaddingApp(css_path="padding.css") diff --git a/docs/examples/styles/scrollbar_gutter.css b/docs/examples/styles/scrollbar_gutter.css new file mode 100644 index 000000000..ed62eb852 --- /dev/null +++ b/docs/examples/styles/scrollbar_gutter.css @@ -0,0 +1,8 @@ +Screen { + scrollbar-gutter: stable; +} + +#text-box { + color: floralwhite; + background: darkmagenta; +} diff --git a/docs/examples/styles/scrollbar_gutter.py b/docs/examples/styles/scrollbar_gutter.py new file mode 100644 index 000000000..b847b3434 --- /dev/null +++ b/docs/examples/styles/scrollbar_gutter.py @@ -0,0 +1,18 @@ +from textual.app import App +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class ScrollbarGutterApp(App): + def compose(self): + yield Static(TEXT, id="text-box") + + +app = ScrollbarGutterApp(css_path="scrollbar_gutter.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 new file mode 100644 index 000000000..97facad70 --- /dev/null +++ b/docs/examples/styles/scrollbar_size.py @@ -0,0 +1,20 @@ +from textual.app import App +from textual.containers import Vertical +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain. +""" + + +class ScrollbarApp(App): + def compose(self): + yield Vertical(Static(TEXT * 5), classes="panel") + + +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 new file mode 100644 index 000000000..f426c0d57 --- /dev/null +++ b/docs/examples/styles/scrollbars.py @@ -0,0 +1,21 @@ +from textual.app import App +from textual.containers import Vertical +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain. +""" + + +class ScrollbarApp(App): + def compose(self): + yield Vertical(Static(TEXT * 5), classes="panel1") + yield Vertical(Static(TEXT * 5), classes="panel2") + + +app = ScrollbarApp(css_path="scrollbars.css") diff --git a/docs/examples/styles/text_align.css b/docs/examples/styles/text_align.css new file mode 100644 index 000000000..c594254d6 --- /dev/null +++ b/docs/examples/styles/text_align.css @@ -0,0 +1,24 @@ +#one { + text-align: left; + background: lightblue; + +} + +#two { + text-align: center; + background: indianred; +} + +#three { + text-align: right; + background: palegreen; +} + +#four { + text-align: justify; + background: palevioletred; +} + +Static { + padding: 1; +} diff --git a/docs/examples/styles/text_align.py b/docs/examples/styles/text_align.py new file mode 100644 index 000000000..27e2892fa --- /dev/null +++ b/docs/examples/styles/text_align.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.widgets import Static + +TEXT = ( + "I must not fear. Fear is the mind-killer. Fear is the little-death that " + "brings total obliteration. I will face my fear. I will permit it to pass over " + "me and through me." +) + + +class TextAlign(App): + def compose(self) -> ComposeResult: + left = Static("[b]Left aligned[/]\n" + TEXT, id="one") + yield left + + right = Static("[b]Center aligned[/]\n" + TEXT, id="two") + yield right + + center = Static("[b]Right aligned[/]\n" + TEXT, id="three") + yield center + + full = Static("[b]Justified[/]\n" + TEXT, id="four") + yield full + + +app = TextAlign(css_path="text_align.css") diff --git a/docs/examples/styles/text_opacity.css b/docs/examples/styles/text_opacity.css new file mode 100644 index 000000000..882cc8acb --- /dev/null +++ b/docs/examples/styles/text_opacity.css @@ -0,0 +1,25 @@ +#zero-opacity { + text-opacity: 0%; +} + +#quarter-opacity { + text-opacity: 25%; +} + +#half-opacity { + text-opacity: 50%; +} + +#three-quarter-opacity { + text-opacity: 75%; +} + +#full-opacity { + text-opacity: 100%; +} + +Static { + height: 1fr; + text-align: center; + text-style: bold; +} diff --git a/docs/examples/styles/text_opacity.py b/docs/examples/styles/text_opacity.py new file mode 100644 index 000000000..a2e9dcff2 --- /dev/null +++ b/docs/examples/styles/text_opacity.py @@ -0,0 +1,14 @@ +from textual.app import App +from textual.widgets import Static + + +class TextOpacityApp(App): + def compose(self): + yield Static("text-opacity: 0%", id="zero-opacity") + yield Static("text-opacity: 25%", id="quarter-opacity") + yield Static("text-opacity: 50%", id="half-opacity") + yield Static("text-opacity: 75%", id="three-quarter-opacity") + yield Static("text-opacity: 100%", id="full-opacity") + + +app = TextOpacityApp(css_path="text_opacity.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 new file mode 100644 index 000000000..f9a59a76f --- /dev/null +++ b/docs/examples/styles/text_style.py @@ -0,0 +1,20 @@ +from textual.app import App +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class TextStyleApp(App): + def compose(self): + yield Static(TEXT, id="static1") + yield Static(TEXT, id="static2") + yield Static(TEXT, id="static3") + + +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 new file mode 100644 index 000000000..44d816b0b --- /dev/null +++ b/docs/examples/styles/tint.py @@ -0,0 +1,15 @@ +from textual.app import App +from textual.color import Color +from textual.widgets import Static + + +class TintApp(App): + def compose(self): + color = Color.parse("green") + for tint_alpha in range(0, 101, 10): + widget = Static(f"tint: green {tint_alpha}%;") + widget.styles.tint = color.with_alpha(tint_alpha / 100) + yield widget + + +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 new file mode 100644 index 000000000..169cc8041 --- /dev/null +++ b/docs/examples/styles/visibility.py @@ -0,0 +1,12 @@ +from textual.app import App +from textual.widgets import Static + + +class VisibilityApp(App): + def compose(self): + yield Static("Widget 1") + yield Static("Widget 2", classes="invisible") + yield Static("Widget 3") + + +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 new file mode 100644 index 000000000..d70868231 --- /dev/null +++ b/docs/examples/styles/width.py @@ -0,0 +1,10 @@ +from textual.app import App +from textual.widget import Widget + + +class WidthApp(App): + def compose(self): + yield Widget() + + +app = WidthApp(css_path="width.css") diff --git a/docs/examples/timers/clock.py b/docs/examples/timers/clock.py deleted file mode 100644 index 3d758cd78..000000000 --- a/docs/examples/timers/clock.py +++ /dev/null @@ -1,23 +0,0 @@ -from datetime import datetime - -from rich.align import Align - -from textual.app import App -from textual.widget import Widget - - -class Clock(Widget): - def on_mount(self): - self.set_interval(1, self.refresh) - - def render(self): - time = datetime.now().strftime("%c") - return Align.center(time, vertical="middle") - - -class ClockApp(App): - async def on_mount(self): - await self.view.dock(Clock()) - - -ClockApp.run() diff --git a/docs/examples/tutorial/stopwatch.css b/docs/examples/tutorial/stopwatch.css new file mode 100644 index 000000000..2bc514b00 --- /dev/null +++ b/docs/examples/tutorial/stopwatch.css @@ -0,0 +1,53 @@ +Stopwatch { + layout: horizontal; + background: $boost; + height: 5; + min-width: 50; + margin: 1; + padding: 1; +} + +TimeDisplay { + content-align: center middle; + text-opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} + +.started { + text-style: bold; + background: $success; + color: $text; +} + +.started TimeDisplay { + text-opacity: 100%; +} + +.started #start { + display: none +} + +.started #stop { + display: block +} + +.started #reset { + visibility: hidden +} diff --git a/docs/examples/tutorial/stopwatch.py b/docs/examples/tutorial/stopwatch.py new file mode 100644 index 000000000..ffb87ea4c --- /dev/null +++ b/docs/examples/tutorial/stopwatch.py @@ -0,0 +1,107 @@ +from time import monotonic + +from textual.app import App, ComposeResult +from textual.containers 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) + total = reactive(0.0) + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True) + + def update_time(self) -> None: + """Method to update time to current.""" + self.time = self.total + (monotonic() - self.start_time) + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + def start(self) -> None: + """Method to start (or resume) time updating.""" + self.start_time = monotonic() + self.update_timer.resume() + + def stop(self): + """Method to stop the time display updating.""" + self.update_timer.pause() + self.total += monotonic() - self.start_time + self.time = self.total + + def reset(self): + """Method to reset the time display to zero.""" + self.total = 0 + self.time = 0 + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + button_id = event.button.id + time_display = self.query_one(TimeDisplay) + if button_id == "start": + time_display.start() + self.add_class("started") + elif button_id == "stop": + time_display.stop() + self.remove_class("started") + elif button_id == "reset": + time_display.reset() + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay() + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "stopwatch.css" + + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("a", "add_stopwatch", "Add"), + ("r", "remove_stopwatch", "Remove"), + ] + + def compose(self) -> ComposeResult: + """Called to add widgets to the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers") + + def action_add_stopwatch(self) -> None: + """An action to add a timer.""" + new_stopwatch = Stopwatch() + self.query_one("#timers").mount(new_stopwatch) + new_stopwatch.scroll_visible() + + def action_remove_stopwatch(self) -> None: + """Called to remove a timer.""" + timers = self.query("Stopwatch") + if timers: + timers.last().remove() + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch01.py b/docs/examples/tutorial/stopwatch01.py new file mode 100644 index 000000000..9f9a76043 --- /dev/null +++ b/docs/examples/tutorial/stopwatch01.py @@ -0,0 +1,22 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch02.css b/docs/examples/tutorial/stopwatch02.css new file mode 100644 index 000000000..15b6c4523 --- /dev/null +++ b/docs/examples/tutorial/stopwatch02.css @@ -0,0 +1 @@ +/* Blank for now */ diff --git a/docs/examples/tutorial/stopwatch02.py b/docs/examples/tutorial/stopwatch02.py new file mode 100644 index 000000000..8baa3831e --- /dev/null +++ b/docs/examples/tutorial/stopwatch02.py @@ -0,0 +1,39 @@ +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import Button, Header, Footer, Static + + +class TimeDisplay(Static): + """A widget to display elapsed time.""" + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay("00:00:00.00") + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch03.css b/docs/examples/tutorial/stopwatch03.css new file mode 100644 index 000000000..b911dfeae --- /dev/null +++ b/docs/examples/tutorial/stopwatch03.css @@ -0,0 +1,30 @@ +Stopwatch { + layout: horizontal; + background: $boost; + height: 5; + padding: 1; + margin: 1; +} + +TimeDisplay { + content-align: center middle; + text-opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} diff --git a/docs/examples/tutorial/stopwatch03.py b/docs/examples/tutorial/stopwatch03.py new file mode 100644 index 000000000..0c455fecb --- /dev/null +++ b/docs/examples/tutorial/stopwatch03.py @@ -0,0 +1,40 @@ +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import Button, Header, Footer, Static + + +class TimeDisplay(Static): + """A widget to display elapsed time.""" + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay("00:00:00.00") + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "stopwatch03.css" + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch04.css b/docs/examples/tutorial/stopwatch04.css new file mode 100644 index 000000000..2bc514b00 --- /dev/null +++ b/docs/examples/tutorial/stopwatch04.css @@ -0,0 +1,53 @@ +Stopwatch { + layout: horizontal; + background: $boost; + height: 5; + min-width: 50; + margin: 1; + padding: 1; +} + +TimeDisplay { + content-align: center middle; + text-opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} + +.started { + text-style: bold; + background: $success; + color: $text; +} + +.started TimeDisplay { + text-opacity: 100%; +} + +.started #start { + display: none +} + +.started #stop { + display: block +} + +.started #reset { + visibility: hidden +} diff --git a/docs/examples/tutorial/stopwatch04.py b/docs/examples/tutorial/stopwatch04.py new file mode 100644 index 000000000..2394fd20c --- /dev/null +++ b/docs/examples/tutorial/stopwatch04.py @@ -0,0 +1,47 @@ +from textual.app import App, ComposeResult +from textual.containers import Container +from textual.widgets import Button, Header, Footer, Static + + +class TimeDisplay(Static): + """A widget to display elapsed time.""" + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + if event.button.id == "start": + self.add_class("started") + elif event.button.id == "stop": + self.remove_class("started") + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay("00:00:00.00") + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "stopwatch04.css" + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch05.py b/docs/examples/tutorial/stopwatch05.py new file mode 100644 index 000000000..6543ac5eb --- /dev/null +++ b/docs/examples/tutorial/stopwatch05.py @@ -0,0 +1,67 @@ +from time import monotonic + +from textual.app import App, ComposeResult +from textual.containers 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 on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.set_interval(1 / 60, self.update_time) + + def update_time(self) -> None: + """Method to update the time to the current time.""" + self.time = monotonic() - self.start_time + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + if event.button.id == "start": + self.add_class("started") + elif event.button.id == "stop": + self.remove_class("started") + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay() + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "stopwatch04.css" + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/tutorial/stopwatch06.py b/docs/examples/tutorial/stopwatch06.py new file mode 100644 index 000000000..e446d9798 --- /dev/null +++ b/docs/examples/tutorial/stopwatch06.py @@ -0,0 +1,90 @@ +from time import monotonic + +from textual.app import App, ComposeResult +from textual.containers 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) + total = reactive(0.0) + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True) + + def update_time(self) -> None: + """Method to update time to current.""" + self.time = self.total + (monotonic() - self.start_time) + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + def start(self) -> None: + """Method to start (or resume) time updating.""" + self.start_time = monotonic() + self.update_timer.resume() + + def stop(self): + """Method to stop the time display updating.""" + self.update_timer.pause() + self.total += monotonic() - self.start_time + self.time = self.total + + def reset(self): + """Method to reset the time display to zero.""" + self.total = 0 + self.time = 0 + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + button_id = event.button.id + time_display = self.query_one(TimeDisplay) + if button_id == "start": + time_display.start() + self.add_class("started") + elif button_id == "stop": + time_display.stop() + self.remove_class("started") + elif button_id == "reset": + time_display.reset() + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay() + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = "stopwatch04.css" + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + """Called to add widgets to the app.""" + yield Header() + yield Footer() + yield Container(Stopwatch(), Stopwatch(), Stopwatch()) + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + +if __name__ == "__main__": + app = StopwatchApp() + app.run() diff --git a/docs/examples/widgets/button.css b/docs/examples/widgets/button.css new file mode 100644 index 000000000..5f1c906da --- /dev/null +++ b/docs/examples/widgets/button.css @@ -0,0 +1,12 @@ +Button { + margin: 1 2; +} + +Horizontal > Vertical { + width: 24; +} + +.header { + margin: 1 0 0 2; + text-style: bold; +} diff --git a/docs/examples/widgets/button.py b/docs/examples/widgets/button.py new file mode 100644 index 000000000..4c3509c32 --- /dev/null +++ b/docs/examples/widgets/button.py @@ -0,0 +1,35 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Button, Static + + +class ButtonsApp(App[str]): + CSS_PATH = "button.css" + + def compose(self) -> ComposeResult: + yield Horizontal( + Vertical( + Static("Standard Buttons", classes="header"), + Button("Default"), + Button("Primary!", variant="primary"), + Button.success("Success!"), + Button.warning("Warning!"), + Button.error("Error!"), + ), + Vertical( + Static("Disabled Buttons", classes="header"), + Button("Default", disabled=True), + Button("Primary!", variant="primary", disabled=True), + Button.success("Success!", disabled=True), + Button.warning("Warning!", disabled=True), + Button.error("Error!", disabled=True), + ), + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(str(event.button)) + + +if __name__ == "__main__": + app = ButtonsApp() + print(app.run()) diff --git a/docs/examples/widgets/checkbox.css b/docs/examples/widgets/checkbox.css new file mode 100644 index 000000000..77c9fb368 --- /dev/null +++ b/docs/examples/widgets/checkbox.css @@ -0,0 +1,28 @@ +Screen { + align: center middle; +} + +.container { + height: auto; + width: auto; +} + +Checkbox { + height: auto; + width: auto; +} + +.label { + height: 3; + content-align: center middle; + width: auto; +} + +#custom-design { + background: darkslategrey; +} + +#custom-design > .checkbox--switch { + color: dodgerblue; + background: darkslateblue; +} diff --git a/docs/examples/widgets/checkbox.py b/docs/examples/widgets/checkbox.py new file mode 100644 index 000000000..ff2b27196 --- /dev/null +++ b/docs/examples/widgets/checkbox.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Checkbox, Static + + +class CheckboxApp(App): + def compose(self) -> ComposeResult: + yield Static("[b]Example checkboxes\n", classes="label") + yield Horizontal( + Static("off: ", classes="label"), Checkbox(), classes="container" + ) + yield Horizontal( + Static("on: ", classes="label"), + Checkbox(value=True), + classes="container", + ) + + focused_checkbox = Checkbox() + focused_checkbox.focus() + yield Horizontal( + Static("focused: ", classes="label"), focused_checkbox, classes="container" + ) + + yield Horizontal( + Static("custom: ", classes="label"), + Checkbox(id="custom-design"), + classes="container", + ) + + +app = CheckboxApp(css_path="checkbox.css") +if __name__ == "__main__": + app.run() diff --git a/docs/examples/widgets/custom.py b/docs/examples/widgets/custom.py deleted file mode 100644 index 6856bcb20..000000000 --- a/docs/examples/widgets/custom.py +++ /dev/null @@ -1,32 +0,0 @@ -from rich.panel import Panel - -from textual.app import App -from textual.reactive import Reactive -from textual.widget import Widget - - -class Hover(Widget): - - mouse_over = Reactive(False) - - def render(self) -> Panel: - return Panel("Hello [b]World[/b]", style=("on red" if self.mouse_over else "")) - - def on_enter(self) -> None: - self.mouse_over = True - - def on_leave(self) -> None: - self.mouse_over = False - - -class HoverApp(App): - """Demonstrates smooth animation""" - - async def on_mount(self) -> None: - """Build layout here.""" - - hovers = (Hover() for _ in range(10)) - await self.view.dock(*hovers, edge="top") - - -HoverApp.run(log="textual.log") diff --git a/docs/examples/widgets/footer.py b/docs/examples/widgets/footer.py new file mode 100644 index 000000000..47d9c9aa6 --- /dev/null +++ b/docs/examples/widgets/footer.py @@ -0,0 +1,15 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer + + +class FooterApp(App): + BINDINGS = [Binding(key="q", action="quit", description="Quit the app")] + + def compose(self) -> ComposeResult: + yield Footer() + + +if __name__ == "__main__": + app = FooterApp() + app.run() diff --git a/docs/examples/widgets/header.py b/docs/examples/widgets/header.py new file mode 100644 index 000000000..d6617101a --- /dev/null +++ b/docs/examples/widgets/header.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header + + +class HeaderApp(App): + def compose(self) -> ComposeResult: + yield Header() + + +if __name__ == "__main__": + app = HeaderApp() + app.run() diff --git a/docs/examples/widgets/input.py b/docs/examples/widgets/input.py new file mode 100644 index 000000000..3b315c571 --- /dev/null +++ b/docs/examples/widgets/input.py @@ -0,0 +1,13 @@ +from textual.app import App, ComposeResult +from textual.widgets import Input + + +class InputApp(App): + def compose(self) -> ComposeResult: + yield Input(placeholder="First Name") + yield Input(placeholder="Last Name") + + +if __name__ == "__main__": + app = InputApp() + app.run() diff --git a/docs/examples/widgets/placeholders.py b/docs/examples/widgets/placeholders.py deleted file mode 100644 index 30e566b5b..000000000 --- a/docs/examples/widgets/placeholders.py +++ /dev/null @@ -1,15 +0,0 @@ -from textual.app import App -from textual.widgets import Placeholder - - -class SimpleApp(App): - """Demonstrates smooth animation""" - - async def on_mount(self) -> None: - """Build layout here.""" - - await self.view.dock(Placeholder(), edge="left", size=40) - await self.view.dock(Placeholder(), Placeholder(), edge="top") - - -SimpleApp.run(log="textual.log") diff --git a/docs/examples/widgets/static.py b/docs/examples/widgets/static.py new file mode 100644 index 000000000..691334e4a --- /dev/null +++ b/docs/examples/widgets/static.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class StaticApp(App): + def compose(self) -> ComposeResult: + yield Static("Hello, world!") + + +if __name__ == "__main__": + app = StaticApp() + app.run() diff --git a/docs/examples/widgets/table.py b/docs/examples/widgets/table.py new file mode 100644 index 000000000..87b2c0ce8 --- /dev/null +++ b/docs/examples/widgets/table.py @@ -0,0 +1,29 @@ +import csv +import io + +from textual.app import App, ComposeResult +from textual.widgets import DataTable + +CSV = """lane,swimmer,country,time +4,Joseph Schooling,Singapore,50.39 +2,Michael Phelps,United States,51.14 +5,Chad le Clos,South Africa,51.14 +6,Lรกszlรณ Cseh,Hungary,51.14 +3,Li Zhuhao,China,51.26 +8,Mehdy Metella,France,51.58 +7,Tom Shields,United States,51.73 +1,Aleksandr Sadovnikov,Russia,51.84""" + + +class TableApp(App): + def compose(self) -> ComposeResult: + yield DataTable() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + rows = csv.reader(io.StringIO(CSV)) + table.add_columns(*next(rows)) + table.add_rows(rows) + + +app = TableApp() diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 000000000..5d695ce8a --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,94 @@ +All you need to get started building Textual apps. + +## Requirements + +Textual requires Python 3.7 or later (if you have a choice, pick the most recent Python). Textual runs on Linux, macOS, Windows and probably any OS where Python also runs. + +!!! info inline end "Your platform" + + ### :fontawesome-brands-linux: Linux (all distros) + + All Linux distros come with a terminal emulator that can run Textual apps. + + ### :material-apple: macOS + + The default terminal app is limited to 256 colors. We recommend installing a newer terminal such as [iterm2](https://iterm2.com/), [Kitty](https://sw.kovidgoyal.net/kitty/), or [WezTerm](https://wezfurlong.org/wezterm/). + + ### :material-microsoft-windows: Windows + + The new [Windows Terminal](https://apps.microsoft.com/store/detail/windows-terminal/9N0DX20HK701?hl=en-gb&gl=GB) runs Textual apps beautifully. + +## Installation + +You can install Textual via PyPI. + +If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. + +``` +pip install "textual[dev]==0.2.0" +``` + +If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: + +``` +pip install textual==0.2.0 +``` + +## Demo + +Once you have Textual installed, run the following to get an impression of what it can do: + +```bash +python -m textual +``` + +If Textual is installed you should see the following: + +```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,_,_,_,_,_,_,tab,w,i,l,l"} +``` + +## Examples + + +The Textual repository comes with a number of example apps. To try out the examples, first clone the Textual repository: + +=== "HTTPS" + + ```bash + git clone https://github.com/Textualize/textual.git + ``` + +=== "SSH" + + ```bash + git clone git@github.com:Textualize/textual.git + ``` + +=== "GitHub CLI" + + ```bash + gh repo clone Textualize/textual + ``` + + +With the repository cloned, navigate to the `/examples/` directory where you fill find a number of Python files you can run from the command line: + +```bash +cd textual/examples/ +python code_browser.py ../ +``` + + +## Textual CLI + +If you installed the dev dependencies you have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps. + +```bash +textual --help +``` + +See [devtools](guide/devtools.md) for more about the `textual` command. + +## Need help? + +See the [help](./help.md) page for how to get help with Textual, or to report bugs. diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md new file mode 100644 index 000000000..4ca7785c3 --- /dev/null +++ b/docs/guide/CSS.md @@ -0,0 +1,429 @@ +# Textual CSS + +Textual uses CSS to apply style to widgets. If you have any exposure to web development you will have encountered CSS, but don't worry if you haven't: this chapter will get you up to speed. + +## Stylesheets + +CSS stands for _Cascading Stylesheets_. A stylesheet is a list of styles and rules about how those styles should be applied to a web page. In the case of Textual, the stylesheet applies [styles](./styles.md) to widgets but otherwise it is the same idea. + +When Textual loads CSS it sets attributes on your widgets' `style` object. The effect is the same as if you had set attributes in Python. + +CSS is typically stored in an external file with the extension `.css` alongside your Python code. + +Let's look at some Textual CSS. + +```sass +Header { + dock: top; + height: 3; + content-align: center middle; + background: blue; + color: white; +} +``` + +This is an example of a CSS _rule set_. There may be many such sections in any given CSS file. + +Let's break this CSS code down a bit. + +```sass hl_lines="1" +Header { + dock: top; + height: 3; + content-align: center middle; + background: blue; + color: white; +} +``` + +The first line is a _selector_ which tells Textual which widget(s) to modify. In the above example, the styles will be applied to a widget defined by the Python class `Header`. + +```sass hl_lines="2 3 4 5 6" +Header { + dock: top; + height: 3; + content-align: center middle; + background: blue; + color: white; +} +``` + +The lines inside the curly braces contains CSS _rules_, which consist of a rule name and rule value separated by a colon and ending in a semi-colon. Such rules are typically written one per line, but you could add additional rules as long as they are separated by semi-colons. + +The first rule in the above example reads `"dock: top;"`. The rule name is `dock` which tells Textual to place the widget on an edge of the screen. The text after the colon is `top` which tells Textual to dock to the _top_ of the screen. Other valid values for `dock` are "right", "bottom", or "left"; but "top" is most appropriate for a header. + +## The DOM + +The DOM, or _Document Object Model_, is a term borrowed from the web world. Textual doesn't use documents but the term has stuck. In Textual CSS, the DOM is an arrangement of widgets you can visualize as a tree-like structure. + +Some widgets contain other widgets: for instance, a list control widget will likely also have item widgets, or a dialog widget may contain button widgets. These _child_ widgets form the branches of the tree. + +Let's look at a trivial Textual app. + +=== "dom1.py" + + ```python + --8<-- "docs/examples/guide/dom1.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/dom1.py"} + ``` + +This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`. + +With the above example, the DOM will look like the following: + +
+--8<-- "docs/images/dom1.excalidraw.svg" +
+ +This doesn't look much like a tree yet. Let's add a header and a footer to this application, which will create more _branches_ of the tree: + +=== "dom2.py" + + ```python hl_lines="7 8" + --8<-- "docs/examples/guide/dom2.py" + ``` + + +=== "Output" + + ```{.textual path="docs/examples/guide/dom2.py"} + ``` + +With a header and a footer widget the DOM looks the this: + +
+--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 of individual components. + +Both Header and Footer are children of the Screen object. + +To further explore the DOM, we're going to build a simple dialog with a question and two buttons. To do this we're going to import and use a few more builtin widgets: + +- `textual.layout.Container` For our top-level dialog. +- `textual.layout.Horizontal` To arrange widgets left to right. +- `textual.widgets.Static` For simple content. +- `textual.widgets.Button` For a clickable button. + +=== "dom3.py" + + ```python hl_lines="12 13 14 15 16 17 18 19 20" + --8<-- "docs/examples/guide/dom3.py" + ``` + +We've added a Container to our DOM which (as the name suggests) is a container for other widgets. The container has a number of other widgets passed as positional arguments which will be added as the children of the container. Not all widgets accept child widgets in this way. A Button widget doesn't require any children, for example. + +Here's the DOM created by the above code: + +
+--8<-- "docs/images/dom3.excalidraw.svg" +
+ +Here's the output from this example: + +```{.textual path="docs/examples/guide/dom3.py"} + +``` + +You may recognize some of the elements in the above screenshot, but it doesn't quite look like a dialog. This is because we haven't added a stylesheet. + +## CSS files + +To add a stylesheet set the `CSS_PATH` classvar to a relative path: + +```python hl_lines="9" +--8<-- "docs/examples/guide/dom4.py" +``` + +You may have noticed that some of the constructors have additional keyword arguments: `id` and `classes`. These are used by the CSS to identify parts of the DOM. We will cover these in the next section. + +Here's the CSS file we are applying: + +```sass +--8<-- "docs/examples/guide/dom4.css" +``` + +The CSS contains a number of rule sets with a selector and a list of rules. You can also add comments with text between `/*` and `*/` which will be ignored by Textual. Add comments to leave yourself reminders or to temporarily disable selectors. + +With the CSS in place, the output looks very different: + +```{.textual path="docs/examples/guide/dom4.py"} + +``` + +### Why CSS? + +It is reasonable to ask why use CSS at all? Python is a powerful and expressive language. Wouldn't it be easier to set styles in your `.py` files? + +A major advantage of CSS is that it separates how your app _looks_ from how it _works_. Setting styles in Python can generate a lot of spaghetti code which can make it hard to see the important logic in your application. + +A second advantage of CSS is that you can customize builtin and third-party widgets just as easily as you can your own app or widgets. + +Finally, Textual CSS allows you to _live edit_ the styles in your app. If you run your application with the following command, any changes you make to the CSS file will be instantly updated in the terminal: + +```bash +textual run my_app.py --dev +``` + +Being able to iterate on the design without restarting the application makes it easier and faster to design beautiful interfaces. + +## Selectors + +A selector is the text which precedes the curly braces in a set of rules. It tells Textual which widgets it should apply the rules to. + +Selectors can target a kind of widget or a very specific widget. For instance you could have a selector that modifies all buttons, or you could target an individual button used in one dialog. This gives you a lot of flexibility in customizing your user interface. + +Let's look at the selectors supported by Textual CSS. + +### Type selector + +The _type_ selector matches the name of the (Python) class. For example, the following widget can be matched with a `Button` selector: + +```python +from textual.widgets import Static + +class Button(Static): + pass +``` + +The following rule applies a border to this widget: + +```sass +Button { + border: solid blue; +} +``` + +The type selector will also match a widget's base classes. Consequently a `Static` selector will also style the button because the `Button` Python class extends `Static`. + +```sass +Static { + background: blue; + border: rounded white; +} +``` + +!!! note + + The fact that the type selector matches base classes is a departure from browser CSS which doesn't have the same concept. + +You may have noticed that the `border` rule exists in both Static and Button. When this happens, Textual will use the most recently defined sub-class within a list of bases. So Button wins over Static, and Static wins over Widget (the base class of all widgets). Hence if both rules were in a stylesheet, the buttons would be "solid blue" and not "rounded white". + +### ID selector + +Every Widget can have a single `id` attribute, which is set via the constructor. The ID should be unique to its container. + +Here's an example of a widget with an ID: + +```python +yield Button(id="next") +``` + +You can match an ID with a selector starting with a hash (`#`). Here is how you might draw a red outline around the above button: + +```sass +#next { + outline: red; +} +``` + +A Widget's `id` attribute can not be changed after the Widget has been constructed. + +### Class-name selector + +Every widget can have a number of class names applied. The term "class" here is borrowed from web CSS, and has a different meaning to a Python class. You can think of a CSS class as a tag of sorts. Widgets with the same tag will share styles. + +CSS classes are set via the widget's `classes` parameter in the constructor. Here's an example: + +```python +yield Button(classes="success") +``` + +This button will have a single class called `"success"` which we could target via CSS to make the button a particular color. + +You may also set multiple classes separated by spaces. For instance, here is a button with both an `error` class and a `disabled` class: + +```python +yield Button(classes="error disabled") +``` + +To match a Widget with a given class in CSS you can precede the class name with a dot (`.`). Here's a rule with a class selector to match the `"success"` class name: + +```sass +.success { + background: green; + color: white; +} +``` + +!!! note + + You can apply a class name to any widget, which means that widgets of different types could share classes. + +Class name selectors may be _chained_ together by appending another full stop and class name. The selector will match a widget that has _all_ of the class names set. For instance, the following sets a red background on widgets that have both `error` _and_ `disabled` class names. + +```sass +.error.disabled { + background: darkred; +} +``` + +Unlike the `id` attribute, a widget's classes can be changed after the widget was created. Adding and removing CSS classes is the recommended way of changing the display while your app is running. There are a few methods you can use to manage CSS classes. + +- [add_class()][textual.dom.DOMNode.add_class] Adds one or more classes to a widget. +- [remove_class()][textual.dom.DOMNode.remove_class] Removes class name(s) from a widget. +- [toggle_class()][textual.dom.DOMNode.toggle_class] Removes a class name if it is present, or adds the name if it's not already present. +- [has_class()][textual.dom.DOMNode.has_class] Checks if one or more classes are set on a widget. +- [classes][textual.dom.DOMNode.classes] Is a frozen set of the class(es) set on a widget. + + +### Universal selector + +The _universal_ selector is denoted by an asterisk and will match _all_ widgets. + +For example, the following will draw a red outline around all widgets: + +```sass +* { + outline: solid red; +} +``` + +### Pseudo classes + +Pseudo classes can be used to match widgets in a particular state. Pseudo classes are set automatically by Textual. For instance, you might want a button to have a green background when the mouse cursor moves over it. We can do this with the `:hover` pseudo selector. + +```sass +Button:hover { + background: green; +} +``` + +The `background: green` is only applied to the Button underneath the mouse cursor. When you move the cursor away from the button it will return to its previous background color. + +Here are some other pseudo classes: + +- `:focus` Matches widgets which have input focus. +- `:focus-within` Matches widgets with a focused a child widget. + +## Combinators + +More sophisticated selectors can be created by combining simple selectors. The logic used to combine selectors is know as a _combinator_. + +### Descendant combinator + +If you separate two selectors with a space it will match widgets with the second selector that have an ancestor that matches the first selector. + +Here's a section of DOM to illustrate this combinator: + +
+--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: + +```sass hl_lines="1" +#dialog Button { + text-style: bold; +} +``` + +The `#dialog Button` selector matches all buttons that are below the widget with an ID of "dialog". No other buttons will be matched. + +As with all selectors, you can combine as many as you wish. The following will match a `Button` that is under a `Horizontal` widget _and_ under a widget with an id of `"dialog"`: + +```css +#dialog Horizontal Button { + text-style: bold; +} +``` + +### Child combinator + +The child combinator is similar to the descendant combinator but will only match an immediate child. To create a child combinator, separate two selectors with a greater than symbol (`>`). Any whitespace around the `>` will be ignored. + +Let's use this to match the Button in the sidebar given the following DOM: + +
+--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`: + +```sass +#sidebar > Button { + text-style: underline; +} +``` + +## Specificity + +It is possible that several selectors match a given widget. If the same style is applied by more than one selector then Textual needs a way to decide which rule _wins_. It does this by following these rules: + +- The selector with the most IDs wins. For instance `#next` beats `.button` and `#dialog #next` beats `#next`. If the selectors have the same number of IDs then move to the next rule. + +- The selector with the most class names wins. For instance `.button.success` beats `.success`. For the purposes of specificity, pseudo classes are treated the same as regular class names, so `.button:hover` counts as _2_ class names. If the selectors have the same number of class names then move to the next rule. + +- The selector with the most types wins. For instance `Container Button` beats `Button`. + +### Important rules + +The specificity rules are usually enough to fix any conflicts in your stylesheets. There is one last way of resolving conflicting selectors which applies to individual rules. If you add the text `!important` to the end of a rule then it will "win" regardless of the specificity. + +!!! warning + + Use `!important` sparingly (if at all) as it can make it difficult to modify your CSS in the future. + +Here's an example that makes buttons blue when hovered over with the mouse, regardless of any other selectors that match Buttons: + +```sass hl_lines="2" +Button:hover { + background: blue !important; +} +``` + +## CSS Variables + +You can define variables to reduce repetition and encourage consistency in your CSS. +Variables in Textual CSS are prefixed with `$`. +Here's an example of how you might define a variable called `$border`: + +```scss +$border: wide green; +``` + +With our variable assigned, we can write `$border` and it will be substituted with `wide green`. +Consider the following snippet: + +```scss +#foo { + border: $border; +} +``` + +This will be translated into: + +```scss +#foo { + border: wide green; +} +``` + +Variables allow us to define reusable styling in a single place. +If we decide we want to change some aspect of our design in the future, we only have to update a single variable. + +!!! note + + Variables can only be used in the _values_ of a CSS declaration. You cannot, for example, refer to a variable inside a selector. + +Variables can refer to other variables. +Let's say we define a variable `$success: lime;`. +Our `$border` variable could then be updated to `$border: wide $success;`, which will +be translated to `$border: wide lime;`. diff --git a/docs/guide/actions.md b/docs/guide/actions.md new file mode 100644 index 000000000..80644b206 --- /dev/null +++ b/docs/guide/actions.md @@ -0,0 +1,160 @@ +# Actions + +Actions are allow-listed functions with a string syntax you can embed in links and bind to keys. In this chapter we will discuss how to create actions and how to run them. + +## Action methods + +Action methods are methods on your app or widgets prefixed with `action_`. Aside from the prefix these are regular methods which you could call directly if you wished. + +!!! information + + Action methods may be coroutines (defined with the `async` keyword). + +Let's write an app with a simple action. + +```python title="actions01.py" hl_lines="6-7 11" +--8<-- "docs/examples/guide/actions/actions01.py" +``` + +The `action_set_background` method is an action which sets the background of the screen. The key handler above will call this action if you press the ++r++ key. + +Although it is possible (and occasionally useful) to call action methods in this way, they are intended to be parsed from an _action string_. For instance, the string `"set_background('red')"` is an action string which would call `self.action_set_background('red')`. + +The following example replaces the immediate call with a call to [action()][textual.widgets.Widget.action] which parses an action string and dispatches it to the appropriate method. + +```python title="actions02.py" hl_lines="9-11" +--8<-- "docs/examples/guide/actions/actions02.py" +``` + +Note that the `action()` method is a coroutine so `on_key` needs to be prefixed with the `async` keyword. + +You will not typically need this in a real app as Textual will run actions in links or key bindings. Before we discuss these, let's have a closer look at the syntax for action strings. + +## Syntax + +Action strings have a simple syntax, which for the most part replicates Python's function call syntax. + +!!! important + + As much as they *look* like Python code, Textual does **not** call Python's `eval` function or similar to compile action strings. + +Action strings have the following format: + +- The name of an action on is own will call the action method with no parameters. For example, an action string of `"bell"` will call `action_bell()`. +- Actions may be followed by braces containing Python objects. For example, the action string `set_background("red")` will call `action_set_background("red")`. +- Actions may be prefixed with a _namespace_ (see below) follow by a dot. + +
+--8<-- "docs/images/actions/format.excalidraw.svg" +
+ +### Parameters + +If the action string contains parameters, these must be valid Python literals. Which means you can include numbers, strings, dicts, lists etc. but you can't include variables or references to any other python symbol. + +Consequently `"set_background('blue')"` is a valid action string, but `"set_background(new_color)"` is not — because `new_color` is a variable and not a literal. + +## Links + +Actions may be embedded as links within console markup. You can create such links with a `@click` tag. + +The following example mounts simple static text with embedded action links. + +=== "actions03.py" + + ```python title="actions03.py" hl_lines="4-9 13-14" + --8<-- "docs/examples/guide/actions/actions03.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/actions/actions03.py"} + ``` + +When you click any of the links, Textual runs the `"set_background"` action to change the background to the given color. + +## Bindings + +Textual will also run actions bound to keys. The following example adds key [bindings](./input.md#bindings) for the ++r++, ++g++, and ++b++ keys which call the `"set_background"` action. + +=== "actions04.py" + + ```python title="actions04.py" hl_lines="13-17" + --8<-- "docs/examples/guide/actions/actions04.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/actions/actions04.py" press="g"} + ``` + +If you run this example, you can change the background by pressing keys in addition to clicking links. + +## Namespaces + +Textual will look for action methods on the widget or app where they are used. If we were to create a [custom widget](./widgets.md#custom-widgets) it can have its own set of actions. + +The following example defines a custom widget with its own `set_background` action. + +=== "actions05.py" + + ```python title="actions05.py" hl_lines="13-14" + --8<-- "docs/examples/guide/actions/actions05.py" + ``` + +=== "actions05.css" + + ```sass title="actions05.css" + --8<-- "docs/examples/guide/actions/actions05.css" + ``` + +There are two instances of the custom widget mounted. If you click the links in either of them it will changed the background for that widget only. The ++r++, ++g++, and ++b++ key bindings are set on the App so will set the background for the screen. + +You can optionally prefix an action with a _namespace_, which tells Textual to run actions for a different object. + +Textual supports the following action namespaces: + +- `app` invokes actions on the App. +- `screen` invokes actions on the screen. + +In the previous example if you wanted a link to set the background on the app rather than the widget, we could set a link to `app.set_background('red')`. + + +## Builtin actions + +Textual supports the following builtin actions which are defined on the app. + + +### Bell + +::: textual.app.App.action_bell + options: + show_root_heading: false + +### Push screen + +::: textual.app.App.action_push_screen + + +### Pop screen + +::: textual.app.App.action_pop_screen + + +### Screenshot + +::: textual.app.App.action_screenshot + + +### Switch screen + +::: textual.app.App.action_switch_screen + + +### Toggle_dark + +::: textual.app.App.action_toggle_dark + +### Quit + +::: textual.app.App.action_quit diff --git a/docs/guide/animation.md b/docs/guide/animation.md new file mode 100644 index 000000000..99b9783a4 --- /dev/null +++ b/docs/guide/animation.md @@ -0,0 +1,87 @@ +# Animation + +Ths chapter discusses how to use Textual's animation system to create visual effects such as movement, blending, and fading. + + +## Animating styles + +Textual's animator can change an attribute from one value to another in fixed increments over a period of time. You can apply animations to [styles](styles.md) such as `offset` to move widgets around the screen, and `opacity` to create fading effects. + +Apps and widgets both have an [animate][textual.app.App.animate] method which will animate properties on those objects. Additionally, `styles` objects have an identical `animate` method which will animate styles. + +Let's look at an example of how we can animate the opacity of a widget to make it fade out. +The following example app contains a single `Static` widget which is immediately animated to an opacity of `0.0` (making it invisible) over a duration of two seconds. + +```python hl_lines="14" +--8<-- "docs/examples/guide/animator/animation01.py" +``` + +The animator updates the value of the `opacity` attribute on the `styles` object in small increments over two seconds. Here's what the output will look like after each half a second. + + +=== "After 0s" + + ```{.textual path="docs/examples/guide/animator/animation01_static.py"} + ``` + +=== "After 0.5s" + + ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:500"} + ``` + + +=== "After 1s" + + ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1000"} + ``` + +=== "After 1.5s" + + ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:1500"} + ``` + +=== "After 2s" + + ```{.textual path="docs/examples/guide/animator/animation01.py" press="wait:2000"} + ``` + +## Duration and Speed + +When requesting an animation you can specify a *duration* or *speed*. +The duration is how long the animation should take in seconds. The speed is how many units a value should change in one second. +For instance, if you animate a value at 0 to 10 with a speed of 2, it will complete in 5 seconds. + +## Easing functions + +The easing function determines the journey a value takes on its way to the target value. +It could move at a constant pace, or it might start off slow then accelerate towards its final value. +Textual supports a number of [easing functions](https://easings.net/). + +
+--8<-- "docs/images/animation/animation.excalidraw.svg" +
+ + +Run the following from the command prompt to preview them. + +```bash +textual easing +``` + +You can specify which easing method to use via the `easing` parameter on the `animate` method. The default easing method is `"in_out_cubic"` which accelerates and then decelerates to produce a pleasing organic motion. + +!!! note + + The `textual easing` preview requires the `dev` extras to be installed (using `pip install textual[dev]`). + + +## Completion callbacks + +You can pass a callable to the animator via the `on_complete` parameter. Textual will run the callable when the animation has completed. + +## Delaying animations + +You can delay the start of an animation with the `delay` parameter of the `animate` method. +This parameter accepts a `float` value representing the number of seconds to delay the animation by. +For example, `self.box.styles.animate("opacity", value=0.0, duration=2.0, delay=5.0)` delays the start of the animation by five seconds, +meaning the animation will start after 5 seconds and complete 2 seconds after that. diff --git a/docs/guide/app.md b/docs/guide/app.md new file mode 100644 index 000000000..3f8b3dd46 --- /dev/null +++ b/docs/guide/app.md @@ -0,0 +1,187 @@ +# App Basics + +In this chapter we will cover how to use Textual's App class to create an application. Just enough to get you up to speed. We will go in to more detail in the following chapters. + +## The App class + +The first step in building a Textual app is to import the [App][textual.app.App] class and create a subclass. Let's look at the simplest app class: + +```python +--8<-- "docs/examples/app/simple01.py" +``` + + +### The run method + +To run an app we create an instance and call [run()][textual.app.App.run]. + +```python hl_lines="8-10" title="simple02.py" +--8<-- "docs/examples/app/simple02.py" +``` + +Apps don't get much simpler than this—don't expect it to do much. + +!!! tip + + The `__name__ == "__main__":` condition is true only if you run the file with `python` command. This allows us to import `app` without running the app immediately. It also allows the [devtools run](devtools.md#run) command to run the app in development mode. See the [Python docs](https://docs.python.org/3/library/__main__.html#idiomatic-usage) for more information. + +If we run this app with `python simple02.py` you will see a blank terminal, something like the following: + +```{.textual path="docs/examples/app/simple02.py"} +``` + +When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*). + +If you hit ++ctrl+c++ Textual will exit application mode and return you to the command prompt. Any content you had in the terminal prior to application mode will be restored. + +!!! tip + + A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the ++option++ key. See the documentation for your terminal software for how to select text in application mode. + +## Events + +Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with `on_` followed by the name of the event. + +One such event is the *mount* event which is sent to an application after it enters application mode. You can respond to this event by defining a method called `on_mount`. + +!!! info + + You may have noticed we use the term "send" and "sent" in relation to event handler methods in preference to "calling". This is because Textual uses a message passing system where events are passed (or *sent*) between components. See [events](./events.md) for details. + +Another such event is the *key* event which is sent when the user presses a key. The following example contains handlers for both those events: + +```python title="event01.py" +--8<-- "docs/examples/app/event01.py" +``` + +The `on_mount` handler sets the `self.screen.styles.background` attribute to `"darkblue"` which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code. + +```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"} +``` + +The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list. + +!!! note + + It is unusual (but not unprecedented) for a method's parameters to affect how it is called. Textual accomplishes this by inspecting the method prior to calling it. + +Some events contain additional information you can inspect in the handler. The [Key][textual.events.Key] event has a `key` attribute which is the name of the key that was pressed. The `on_key` method above uses this attribute to change the background color if any of the keys from ++0++ to ++9++ are pressed. + +### Async events + +Textual is powered by Python's [asyncio](https://docs.python.org/3/library/asyncio.html) framework which uses the `async` and `await` keywords. + +Textual knows to *await* your event handlers if they are coroutines (i.e. prefixed with the `async` keyword). Regular functions are generally fine unless you plan on integrating other async libraries (such as [httpx](https://www.python-httpx.org/) for reading data from the internet). + +!!! tip + + For a friendly introduction to async programming in Python, see FastAPI's [concurrent burgers](https://fastapi.tiangolo.com/async/) article. + + +## Widgets + +Widgets are self-contained components responsible for generating the output for a portion of the screen. Widgets respond to events in much the same way as the App. Most apps that do anything interesting will contain at least one (and probably many) widgets which together form a User Interface. + +Widgets can be as simple as a piece of text, a button, or a fully-fledged component like a text editor or file browser (which may contain widgets of their own). + +### Composing + +To add widgets to your app implement a [`compose()`][textual.app.App.compose] method which should return an iterable of `Widget` instances. A list would work, but it is convenient to yield widgets, making the method a *generator*. + +The following example imports a builtin `Welcome` widget and yields it from `App.compose()`. + +```python title="widgets01.py" +--8<-- "docs/examples/app/widgets01.py" +``` + +When you run this code, Textual will *mount* the `Welcome` widget which contains Markdown content and a button: + +```{.textual path="docs/examples/app/widgets01.py"} +``` + +Notice the `on_button_pressed` method which handles the [Button.Pressed][textual.widgets.Button] event sent by a button contained in the `Welcome` widget. The handler calls [App.exit()][textual.app.App.exit] to exit the app. + +### Mounting + +While composing is the preferred way of adding widgets when your app starts it is sometimes necessary to add new widget(s) in response to events. You can do this by calling [mount()][textual.widget.Widget.mount] which will add a new widget to the UI. + +Here's an app which adds a welcome widget in response to any key press: + +```python title="widgets02.py" +--8<-- "docs/examples/app/widgets02.py" +``` + +When you first run this you will get a blank screen. Press any key to add the welcome widget. You can even press a key multiple times to add several widgets. + +```{.textual path="docs/examples/app/widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"} +``` + +### Exiting + +An app will run until you call [App.exit()][textual.app.App.exit] which will exit application mode and the [run][textual.app.App.run] method will return. If this is the last line in your code you will return to the command prompt. + +The exit method will also accept an optional positional value to be returned by `run()`. The following example uses this to return the `id` (identifier) of a clicked button. + +```python title="question01.py" +--8<-- "docs/examples/app/question01.py" +``` + +Running this app will give you the following: + +```{.textual path="docs/examples/app/question01.py"} +``` + +Clicking either of those buttons will exit the app, and the `run()` method will return either `"yes"` or `"no"` depending on button clicked. + +#### Return type + +You may have noticed that we subclassed `App[str]` rather than the usual `App`. + +```python title="question01.py" hl_lines="5" +--8<-- "docs/examples/app/question01.py" +``` + +The addition of `[str]` tells mypy that `run()` is expected to return a string. It may also return `None` if [App.exit()][textual.app.App.exit] is called without a return value, so the return type of `run` will be `str | None`. Replace the `str` in `[str]` with the type of the value you intend to call the exit method with. + +!!! note + + Type annotations are entirely optional (but recommended) with Textual. + +## CSS + +Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy). + +The chapter on [Textual CSS](CSS.md) describes how to use CSS in detail. For now let's look at how your app references external CSS files. + +The following example enables loading of CSS by adding a `CSS_PATH` class variable: + +```python title="question02.py" hl_lines="6" +--8<-- "docs/examples/app/question02.py" +``` + +If the path is relative (as it is above) then it is taken as relative to where the app is defined. Hence this example references `"question01.css"` in the same directory as the Python code. Here is that CSS file: + +```sass title="question02.css" +--8<-- "docs/examples/app/question02.css" +``` + +When `"question02.py"` runs it will load `"question02.css"` and update the app and widgets accordingly. Even though the code is almost identical to the previous sample, the app now looks quite different: + +```{.textual path="docs/examples/app/question02.py"} +``` + +### Classvar CSS + +While external CSS files are recommended for most applications, and enable some cool features like *live editing*, you can also specify the CSS directly within the Python code. + +To do this set a `CSS` class variable on the app to a string containing your CSS. + +Here's the question app with classvar CSS: + +```python title="question03.py" hl_lines="6-24" +--8<-- "docs/examples/app/question03.py" +``` + +## What's next + +In the following chapter we will learn more about how to apply styles to your widgets and app. diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md new file mode 100644 index 000000000..59e9a805f --- /dev/null +++ b/docs/guide/devtools.md @@ -0,0 +1,124 @@ +# Devtools + +!!! note inline end + + If you don't have the `textual` command on your path, you may have forgotten to install with the `dev` switch. + + See [getting started](../getting_started.md#installation) for details. + +Textual comes with a command line application of the same name. The `textual` command is a super useful tool that will help you to build apps. + +Take a moment to look through the available sub-commands. There will be even more helpful tools here in the future. + +```bash +textual --help +``` + + +## Run + +You can run Textual apps with the `run` subcommand. If you supply a path to a Python file it will load and run the application. + +```bash +textual run my_app.py +``` + +The `run` sub-command will first look for a `App` instance called `app` in the global scope of your Python file. If there is no `app`, it will create an instance of the first `App` class it finds and run that. + +Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example: + +```bash +textual run my_app.py:alternative_app +``` + +!!! note + + If the Python file contains a call to app.run() then you can launch the file as you normally would any other Python program. Running your app via `textual run` will give you access to a few Textual features such as live editing of CSS files. + + +## Live editing + +If you combine the `run` command with the `--dev` switch your app will run in *development mode*. + +```bash +textual run --dev my_app.py +``` + +One of the the features of *dev* mode is live editing of CSS files: any changes to your CSS will be reflected in the terminal a few milliseconds later. + +This is a great feature for iterating on your app's look and feel. Open the CSS in your editor and have your app running in a terminal. Edits to your CSS will appear almost immediately after you save. + +## Console + +When building a typical terminal application you are generally unable to use `print` when debugging (or log to the console). This is because anything you write to standard output will overwrite application content. Textual has a solution to this in the form of a debug console which restores `print` and adds a few additional features to help you debug. + +To use the console, open up **two** terminal emulators. Run the following in one of the terminals: + +```bash +textual console +``` + +You should see the Textual devtools welcome message: + +```{.textual title="textual console" path="docs/examples/getting_started/console.py", press="_,_"} +``` + +In the other console, run your application with `textual run` and the `--dev` switch: + +```bash +textual run --dev my_app.py +``` + +Anything you `print` from your application will be displayed in the console window. Textual will also write log messages to this window which may be helpful when debugging your application. + + +### Verbosity + +Textual writes log messages to inform you about certain events, such as when the user presses a key or clicks on the terminal. To avoid swamping you with too much information, some events are marked as "verbose" and will be excluded from the logs. If you want to see these log messages, you can add the `-v` switch. + +```bash +textual console -v +``` + +## Textual log + +In addition to simple strings, Textual console supports [Rich](https://rich.readthedocs.io/en/latest/) formatting. To write rich logs, import `log` as follows: + +```python +from textual import log +``` + +This method will pretty print data structures (like lists and dicts) as well as [Rich renderables](https://rich.readthedocs.io/en/stable/protocol.html). Here are some examples: + +```python +log("Hello, World") # simple string +log(locals()) # Log local variables +log(children=self.children, pi=3.141592) # key/values +log(self.tree) # Rich renderables +``` + +Textual log messages may contain [console Markup](https://rich.readthedocs.io/en/stable/markup.html): + +```python +log("[bold red]DANGER![/] We're having too much fun") +``` + +### Log method + +There's a convenient shortcut to `log` available on the `App` and `Widget` objects. This is useful in event handlers. Here's an example: + +```python +from textual.app import App + +class LogApp(App): + + def on_load(self): + self.log("In the log handler!", pi=3.141529) + + def on_mount(self): + self.log(self.tree) + +if __name__ == "__main__": + LogApp.run() + +``` diff --git a/docs/guide/events.md b/docs/guide/events.md new file mode 100644 index 000000000..51821aeca --- /dev/null +++ b/docs/guide/events.md @@ -0,0 +1,189 @@ +# Events and Messages + +We've used event handler methods in many of the examples in this guide. This chapter explores [events](../events/index.md) and messages (see below) in more detail. + +## Messages + +Events are a particular kind of *message* sent by Textual in response to input and other state changes. Events are reserved for use by Textual but you can also create custom messages for the purpose of coordinating between widgets in your app. + +More on that later, but for now keep in mind that events are also messages, and anything that is true of messages is true of events. + +## Message Queue + +Every [App][textual.app.App] and [Widget][textual.widget.Widget] object contains a *message queue*. You can think of a message queue as orders at a restaurant. The chef takes an order and makes the dish. Orders that arrive while the chef is cooking are placed in a line. When the chef has finished a dish they pick up the next order in the line. + +Textual processes messages in the same way. Messages are picked off a queue and processed (cooked) by a handler method. This guarantees messages and events are processed even if your code can not handle them right way. + +This processing of messages is done within an asyncio Task which is started when you mount the widget. The task monitors a queue for new messages and dispatches them to the appropriate handler when they arrive. + +!!! tip + + The FastAPI docs have an [excellent introduction](https://fastapi.tiangolo.com/async/) to Python async programming. + +By way of an example, let's consider what happens if you were to type "Text" in to a `Input` widget. When you hit the ++t++ key, Textual creates a [key][textual.events.Key] event and sends it to the widget's message queue. Ditto for ++e++, ++x++, and ++t++. + +The widget's task will pick the first message from the queue (a key event for the ++t++ key) and call the `on_key` method with the event as the first argument. In other words it will call `Input.on_key(event)`, which updates the display to show the new letter. + +
+--8<-- "docs/images/events/queue.excalidraw.svg" +
+ +When the `on_key` method returns, Textual will get the next event from the the queue and repeat the process for the remaining keys. At some point the queue will be empty and the widget is said to be in an *idle* state. + +!!! note + + This example illustrates a point, but a typical app will be fast enough to have processed a key before the next event arrives. So it is unlikely you will have so many key events in the message queue. + +
+--8<-- "docs/images/events/queue2.excalidraw.svg" +
+ + +## Default behaviors + +You may be familiar with Python's [super](https://docs.python.org/3/library/functions.html#super) function to call a function defined in a base class. You will not have to use this in event handlers as Textual will automatically call handler methods defined in a widget's base class(es). + +For instance, let's say we are building the classic game of Pong and we have written a `Paddle` widget which extends [Static][textual.widgets.Static]. When a [Key][textual.events.Key] event arrives, Textual calls `Paddle.on_key` (to respond to ++left++ and ++right++ keys), then `Static.on_key`, and finally `Widget.on_key`. + +### Preventing default behaviors + +If you don't want this behavior you can call [prevent_default()][textual.message.Message.prevent_default] on the event object. This tells Textual not to call any more handlers on base classes. + +!!! warning + + You won't need `prevent_default` very often. Be sure to know what your base classes do before calling it, or you risk disabling some core features builtin to Textual. + +## Bubbling + +Messages have a `bubble` attribute. If this is set to `True` then events will be sent to a widget's parent after processing. Input events typically bubble so that a widget will have the opportunity to respond to input events if they aren't handled by their children. + +The following diagram shows an (abbreviated) DOM for a UI with a container and two buttons. With the "No" button [focused](#), it will receive the key event first. + +
+--8<-- "docs/images/events/bubble1.excalidraw.svg" +
+ +After Textual calls `Button.on_key` the event _bubbles_ to the button's parent and will call `Container.on_key` (if it exists). + +
+--8<-- "docs/images/events/bubble2.excalidraw.svg" +
+ +As before, the event bubbles to its parent (the App class). + +
+--8<-- "docs/images/events/bubble3.excalidraw.svg" +
+ +The App class is always the root of the DOM, so there is no where for the event to bubble to. + +### Stopping bubbling + +Event handlers may stop this bubble behavior by calling the [stop()][textual.message.Message.stop] method on the event or message. You might want to do this if a widget has responded to the event in an authoritative way. For instance when a text input widget responds to a key event it stops the bubbling so that the key doesn't also invoke a key binding. + +## Custom messages + +You can create custom messages for your application that may be used in the same way as events (recall that events are simply messages reserved for use by Textual). + +The most common reason to do this is if you are building a custom widget and you need to inform a parent widget about a state change. + +Let's look at an example which defines a custom message. The following example creates color buttons which—when clicked—send a custom message. + +=== "custom01.py" + + ```python title="custom01.py" hl_lines="10-15 27-29 42-43" + --8<-- "docs/examples/events/custom01.py" + ``` +=== "Output" + + ```{.textual path="docs/examples/events/custom01.py"} + ``` + + +Note the custom message class which extends [Message][textual.message.Message]. The constructor stores a [color][textual.color.Color] object which handler methods will be able to inspect. + +The message class is defined within the widget class itself. This is not strictly required but recommended, for these reasons: + +- It reduces the amount of imports. If you import `ColorButton`, you have access to the message class via `ColorButton.Selected`. +- It creates a namespace for the handler. So rather than `on_selected`, the handler name becomes `on_color_button_selected`. This makes it less likely that your chosen name will clash with another message. + + +## Sending events + +In the previous example we used [emit()][textual.message_pump.MessagePump.emit] to send an event to it's parent. We could also have used [emit_no_wait()][textual.message_pump.MessagePump.emit_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used. + +There are other ways of sending (posting) messages, which you may need to use less frequently. + +- [post_message][textual.message_pump.MessagePump.post_message] To post a message to a particular widget. +- [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`. + + +## Message handlers + +Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail. + +### Handler naming + +Textual uses the following scheme to map messages classes on to a Python method. + +- Start with `"on_"`. +- Add the messages namespace (if any) converted from CamelCase to snake_case plus an underscore `"_"` +- Add the name of the class converted from CamelCase to snake_case. + +
+--8<-- "docs/images/events/naming.excalidraw.svg" +
+ +### Handler arguments + +Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from custom01.py above) contains a `message` parameter. The body of the code makes use of the message to set a preset color. + +```python + def on_color_button_selected(self, message: ColorButton.Selected) -> None: + self.screen.styles.animate("background", message.color, duration=0.5) +``` + +If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this: + +```python + def on_color_button_selected(self) -> None: + self.app.bell() +``` + +This pattern is a convenience that saves writing out a parameter that may not be used. + +### Async handlers + +Message handlers may be coroutines. If you prefix your handlers with the `async` keyword, Textual will `await` them. This lets your handler use the `await` keyword for asynchronous APIs. + +If your event handlers are coroutines it will allow multiple events to be processed concurrently, but bear in mind an individual widget (or app) will not be able to pick up a new message from its message queue until the handler has returned. This is rarely a problem in practice; as long has handlers return within a few milliseconds the UI will remain responsive. But slow handlers might make your app hard to use. + +!!! info + + To re-use the chef analogy, if an order comes in for beef wellington (which takes a while to cook), orders may start to pile up and customers may have to wait for their meal. The solution would be to have another chef work on the wellington while the first chef picks up new orders. + +Network access is a common cause of slow handlers. If you try to retrieve a file from the internet, the message handler may take anything up to a few seconds to return, which would prevent the widget or app from updating during that time. The solution is to launch a new asyncio task to do the network task in the background. + +Let's look at an example which looks up word definitions from an [api](https://dictionaryapi.dev/) as you type. + +!!! note + + You will need to install [httpx](https://www.python-httpx.org/) with `pip install httpx` to run this example. + +=== "dictionary.py" + + ```python title="dictionary.py" hl_lines="27" + --8<-- "docs/examples/events/dictionary.py" + ``` +=== "dictionary.css" + + ```python title="dictionary.css" + --8<-- "docs/examples/events/dictionary.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/events/dictionary.py" press="t,e,x,t,_,_,_,_,_,_,_,_,_,_,_"} + ``` + +Note the highlighted line in the above code which calls `asyncio.create_task` to run a coroutine in the background. Without this you would find typing in to the text box to be unresponsive. diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 000000000..412a09b13 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,9 @@ +# Textual Guide + +Welcome to the Textual Guide! An in-depth reference on how to build apps with Textual. + +## Example code + +Most of the code in this guide is fully working—you could cut and paste it if you wanted to. + +Although it is probably easier to check out the [Textual repository](https://github.com/Textualize/textual) and navigate to the `docs/examples/guide` directory and run the examples from there. diff --git a/docs/guide/input.md b/docs/guide/input.md new file mode 100644 index 000000000..968e19c7e --- /dev/null +++ b/docs/guide/input.md @@ -0,0 +1,207 @@ +# Input + +This chapter will discuss how to make your app respond to input in the form of key presses and mouse actions. + +!!! quote + + More Input! + + — Johnny Five + +## Keyboard input + +The most fundamental way to receive input is via [Key](./events/key) events. Let's write an app to show key events as you type. + +=== "key01.py" + + ```python title="key01.py" hl_lines="12-13" + --8<-- "docs/examples/guide/input/key01.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"} + ``` + +Note the key event handler on the app which logs all key events. If you press any key it will show up on the screen. + +### Attributes + +There are two main attributes on a key event. The `key` attribute is the _name_ of the key which may be a single character, or a longer identifier. Textual ensures that the `key` attribute could always be used in a method name. + +Key events also contain a `char` attribute which contains a single character if it is printable, or ``None`` if it is not printable (like a function key which has no corresponding character). + +To illustrate the difference between `key` and `char`, try `key01.py` with the space key. You should see something like the following: + +```{.textual path="docs/examples/guide/input/key01.py", press="space,_"} + +``` + +Note that the `key` attribute contains the word "space" while the `char` attribute contains a literal space. + +### Key methods + +Textual offers a convenient way of handling specific keys. If you create a method beginning with `key_` followed by the name of a key, then that method will be called in response to the key. + +Let's add a key method to the example code. + +```python title="key02.py" hl_lines="15-16" +--8<-- "docs/examples/guide/input/key02.py" +``` + +Note the addition of a `key_space` method which is called in response to the space key, and plays the terminal bell noise. + +!!! note + + Consider key methods to be a convenience for experimenting with Textual features. In nearly all cases, key [bindings](#bindings) and [actions](../guide/actions.md) are preferable. + +## Input focus + +Only a single widget may receive key events at a time. The widget which is actively receiving key events is said to have input _focus_. + +The following example shows how focus works in practice. + +=== "key03.py" + + ```python title="key03.py" hl_lines="16-20" + --8<-- "docs/examples/guide/input/key03.py" + ``` + +=== "key03.css" + + ```python title="key03.css" hl_lines="15-17" + --8<-- "docs/examples/guide/input/key03.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!,_"} + ``` + +The app splits the screen in to quarters, with a `TextLog` widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that thw widget has focus. Key events will be sent to the focused widget only. + +!!! tip + + the `:focus` CSS pseudo-selector can be used to apply a style to the focused widget. + +You can move focus by pressing the ++tab++ key to focus the next widget. Pressing ++shift+tab++ moves the focus in the opposite direction. + +### Controlling focus + +Textual will handle keyboard focus automatically, but you can tell Textual to focus a widget by calling the widget's [focus()][textual.widget.Widget.focus] method. + +### Focus events + +When a widget receives focus, it is sent a [Focus](../events/focus.md) event. When a widget loses focus it is sent a [Blur](../events/blur.md) event. + +## Bindings + +Keys may be associated with [actions](../guide/actions.md) for a given widget. This association is known as a key _binding_. + +To create bindings, add a `BINDINGS` class variable to your app or widget. This should be a list of tuples of three strings. +The first value is the key, the second is the action, the third value is a short human readable description. + +The following example binds the keys ++r++, ++g++, and ++b++ to an action which adds a bar widget to the screen. + +=== "binding01.py" + + ```python title="binding01.py" hl_lines="13-17" + --8<-- "docs/examples/guide/input/binding01.py" + ``` + +=== "binding01.css" + + ```python title="binding01.css" + --8<-- "docs/examples/guide/input/binding01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/input/binding01.py", press="r,g,b,b"} + ``` + +Note how the footer displays bindings and makes them clickable. + +!!! tip + + Multiple keys can be bound to a single action by comma-separating them. + For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`. + +### Binding class + +The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options. + +### Why use bindings? + +Bindings are particularly useful for configurable hot-keys. Bindings can also be inspected in widgets such as [Footer](../widgets/footer.md). + +In a future version of Textual it will also be possible to specify bindings in a configuration file, which will allow users to override app bindings. + +## Mouse Input + +Textual will send events in response to mouse movement and mouse clicks. These events contain the coordinates of the mouse cursor relative to the terminal or widget. + +!!! information + + The trackpad (and possibly other pointer devices) are treated the same as the mouse in terminals. + +Terminal coordinates are given by a pair values named `x` and `y`. The X coordinate is an offset in characters, extending from the left to the right of the screen. The Y coordinate is an offset in _lines_, extending from the top of the screen to the bottom. + +Coordinates may be relative to the screen, so `(0, 0)` would be the top left of the screen. Coordinates may also be relative to a widget, where `(0, 0)` would be the top left of the widget itself. + + +
+--8<-- "docs/images/input/coords.excalidraw.svg" +
+ +### Mouse movements + +When you move the mouse cursor over a widget it will receive [MouseMove](../events/mouse_move.md) events which contain the coordinate of the mouse and information about what modifier keys (++ctrl++, ++shift++ etc) are held down. + +The following example shows mouse movements being used to _attach_ a widget to the mouse cursor. + +=== "mouse01.py" + + ```python title="mouse01.py" hl_lines="11-13" + --8<-- "docs/examples/guide/input/mouse01.py" + ``` + +=== "mouse01.css" + + ```python title="mouse01.css" + --8<-- "docs/examples/guide/input/mouse01.css" + ``` + +If you run `mouse01.py` you should find that it logs the mouse move event, and keeps a widget pinned directly under the cursor. + +The `on_mouse_move` handler sets the [offset](../styles/offset.md) style of the ball (a rectangular one) to match the mouse coordinates. + +### Mouse capture + +In the `mouse01.py` example there was a call to `capture_mouse()` in the mount handler. Textual will send mouse move events to the widget directly under the cursor. You can tell Textual to send all mouse events to a widget regardless of the position of the mouse cursor by calling [capture_mouse][textual.widget.Widget.capture_mouse]. + +Call [release_mouse][textual.widget.Widget.release_mouse] to restore the default behavior. + +!!! warning + + If you capture the mouse, be aware you might get negative mouse coordinates if the cursor is to the left of the widget. + +Textual will send a [MouseCapture](../events/mouse_capture.md) event when the mouse is captured, and a [MouseRelease](../events/mouse_release.md) event when it is released. + +### Enter and Leave events + +Textual will send a [Enter](../events/enter.md) event to a widget when the mouse cursor first moves over it, and a [Leave](../events/leave) event when the cursor moves off a widget. + +### Click events + +There are three events associated with clicking a button on your mouse. When the button is initially pressed, Textual sends a [MouseDown](../events/mouse_down.md) event, followed by [MouseUp](../events/mouse_up.md) when the button is released. Textual then sends a final [Click](../events/click.md) event. + +If you want your app to respond to a mouse click you should prefer the Click event (and not MouseDown or MouseUp). This is because a future version of Textual may support other pointing devices which don't have up and down states. + +### Scroll events + +Most mice have a scroll wheel which you can use to scroll the window underneath the cursor. Scrollable containers in Textual will handle these automatically, but you can handle [MouseScrollDown](../events/mouse_scroll_down.md) and [MouseScrollUp](../events/mouse_scroll_up) if you want build your own scrolling functionality. + +!!! information + + Terminal emulators will typically convert trackpad gestures in to scroll events. diff --git a/docs/guide/layout.md b/docs/guide/layout.md new file mode 100644 index 000000000..495cb6f86 --- /dev/null +++ b/docs/guide/layout.md @@ -0,0 +1,551 @@ +# Layout + +In Textual, the *layout* defines how widgets will be arranged (or *laid out*) inside a container. +Textual supports a number of layouts which can be set either via a widget's `styles` object or via CSS. +Layouts can be used for both high-level positioning of widgets on screen, and for positioning of nested widgets. + +## Vertical + +The `vertical` layout arranges child widgets vertically, from top to bottom. + +
+--8<-- "docs/images/layout/vertical.excalidraw.svg" +
+ +The example below demonstrates how children are arranged inside a container with the `vertical` layout. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/vertical_layout.py"} + ``` + +=== "vertical_layout.py" + + ```python + --8<-- "docs/examples/guide/layout/vertical_layout.py" + ``` + +=== "vertical_layout.css" + + ```sass hl_lines="2" + --8<-- "docs/examples/guide/layout/vertical_layout.css" + ``` + +Notice that the first widget yielded from the `compose` method appears at the top of the display, +the second widget appears below it, and so on. +Inside `vertical_layout.css`, we've assigned `layout: vertical` to `Screen`. +`Screen` is the parent container of the widgets yielded from the `App.compose` method, and can be thought of as the terminal window itself. + +!!! note + + The `layout: vertical` CSS isn't *strictly* necessary in this case, since Screens use a `vertical` layout by default. + +We've assigned each child `.box` a height of `1fr`, which ensures they're each allocated an equal portion of the available height. + +You might also have noticed that the child widgets are the same width as the screen, despite nothing in our CSS file suggesting this. +This is because widgets expand to the width of their parent container (in this case, the `Screen`). + +Just like other styles, `layout` can be adjusted at runtime by modifying the `styles` of a `Widget` instance: + +```python +widget.styles.layout = "vertical" +``` + +Using `fr` units guarantees that the children fill the available height of the parent. +However, if the total height of the children exceeds the available space, then Textual will automatically add +a scrollbar to the parent `Screen`. + +!!! note + + A scrollbar is added automatically because `Screen` contains the declaration `overflow-y: auto;`. + +For example, if we swap out `height: 1fr;` for `height: 10;` in the example above, the child widgets become a fixed height of 10, and a scrollbar appears (assuming our terminal window is sufficiently small): + +```{.textual path="docs/examples/guide/layout/vertical_layout_scrolled.py"} +``` + +[//]: # (TODO: Add link to "focus" docs in paragraph below.) + +With the parent container in focus, we can use our mouse wheel, trackpad, or keyboard to scroll it. + +## Horizontal + +The `horizontal` layout arranges child widgets horizontally, from left to right. + +
+--8<-- "docs/images/layout/horizontal.excalidraw.svg" +
+ +The example below shows how we can arrange widgets horizontally, with minimal changes to the vertical layout example above. + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/horizontal_layout.py"} + ``` + +=== "horizontal_layout.py" + + ```python + --8<-- "docs/examples/guide/layout/horizontal_layout.py" + ``` + +=== "horizontal_layout.css" + + ```sass hl_lines="2" + --8<-- "docs/examples/guide/layout/horizontal_layout.css" + ``` + + +We've changed the `layout` to `horizontal` inside our CSS file. +As a result, the widgets are now arranged from left to right instead of top to bottom. + +We also adjusted the height of the child `.box` widgets to `100%`. +As mentioned earlier, widgets expand to fill the _width_ of their parent container. +They do not, however, expand to fill the container's height. +Thus, we need explicitly assign `height: 100%` to achieve this. + +A consequence of this "horizontal growth" behaviour is that if we remove the width restriction from the above example (by deleting `width: 1fr;`), each child widget will grow to fit the width of the screen, +and only the first widget will be visible. +The other two widgets in our layout are offscreen, to the right-hand side of the screen. +In the case of `horizontal` layout, Textual will _not_ automatically add a scrollbar. + +To enable horizontal scrolling, we can use the `overflow-x: auto;` declaration: + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/horizontal_layout_overflow.py"} + ``` + +=== "horizontal_layout_overflow.py" + + ```python + --8<-- "docs/examples/guide/layout/horizontal_layout_overflow.py" + ``` + +=== "horizontal_layout_overflow.css" + + ```sass hl_lines="3" + --8<-- "docs/examples/guide/layout/horizontal_layout_overflow.css" + ``` + +With `overflow-x: auto;`, Textual automatically adds a horizontal scrollbar since the width of the children +exceeds the available horizontal space in the parent container. + +## Utility containers + +Textual comes with several "container" widgets. +These are [Vertical][textual.containers.Vertical], [Horizontal][textual.containers.Horizontal], and [Grid][textual.containers.Grid] which have the corresponding layout. + +The example below shows how we can combine these containers to create a simple 2x2 grid. +Inside a single `Horizontal` container, we place two `Vertical` containers. +In other words, we have a single row containing two columns. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/utility_containers.py"} + ``` + +=== "utility_containers.py" + + ```python + --8<-- "docs/examples/guide/layout/utility_containers.py" + ``` + +=== "utility_containers.css" + + ```sass hl_lines="2" + --8<-- "docs/examples/guide/layout/utility_containers.css" + ``` + +You may be tempted to use many levels of nested utility containers in order to build advanced, grid-like layouts. +However, Textual comes with a more powerful mechanism for achieving this known as _grid layout_, which we'll discuss next. + +## Grid + +The `grid` layout arranges widgets within a grid. +Widgets can span multiple rows and columns to create complex layouts. +The diagram below hints at what can be achieved using `layout: grid`. + +
+--8<-- "docs/images/layout/grid.excalidraw.svg" +
+ +!!! note + + Grid layouts in Textual have little in common with browser-based CSS Grid. + +To get started with grid layout, define the number of columns and rows in your grid with the `grid-size` CSS property and set `layout: grid`. Widgets are inserted into the "cells" of the grid from left-to-right and top-to-bottom order. + +The following example creates a 3 x 2 grid and adds six widgets to it + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout1.py"} + ``` + +=== "grid_layout1.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout1.py" + ``` + +=== "grid_layout1.css" + + ```sass hl_lines="2 3" + --8<-- "docs/examples/guide/layout/grid_layout1.css" + ``` + + +If we were to yield a seventh widget from our `compose` method, it would not be visible as the grid does not contain enough cells to accommodate it. We can tell Textual to add new rows on demand to fit the number of widgets, by omitting the number of rows from `grid-size`. The following example creates a grid with three columns, with rows created on demand: + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout2.py"} + ``` + +=== "grid_layout2.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout2.py" + ``` + +=== "grid_layout2.css" + + ```sass hl_lines="3" + --8<-- "docs/examples/guide/layout/grid_layout2.css" + ``` + +Since we specified that our grid has three columns (`grid-size: 3`), and we've yielded seven widgets in total, +a third row has been created to accommodate the seventh widget. + +Now that we know how to define a simple uniform grid, let's look at how we can +customize it to create more complex layouts. + +### Row and column sizes + +You can adjust the width of columns and the height of rows in your grid using the `grid-columns` and `grid-rows` properties. +These properties can take multiple values, letting you specify dimensions on a column-by-column or row-by-row basis. + +Continuing on from our earlier 3x2 example grid, let's adjust the width of the columns using `grid-columns`. +We'll make the first column take up half of the screen width, with the other two columns sharing the remaining space equally. + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout3_row_col_adjust.py"} + ``` + +=== "grid_layout3_row_col_adjust.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout3_row_col_adjust.py" + ``` + +=== "grid_layout3_row_col_adjust.css" + + ```sass hl_lines="4" + --8<-- "docs/examples/guide/layout/grid_layout3_row_col_adjust.css" + ``` + + +Since our `grid-size` is 3 (meaning it has three columns), our `grid-columns` declaration has three space-separated values. +Each of these values sets the width of a column. +The first value refers to the left-most column, the second value refers to the next column, and so on. +In the example above, we've given the left-most column a width of `2fr` and the other columns widths of `1fr`. +As a result, the first column is allocated twice the width of the other columns. + +Similarly, we can adjust the height of a row using `grid-rows`. +In the following example, we use `%` units to adjust the first row of our grid to `25%` height, +and the second row to `75%` height (while retaining the `grid-columns` change from above). + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout4_row_col_adjust.py"} + ``` + +=== "grid_layout4_row_col_adjust.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout4_row_col_adjust.py" + ``` + +=== "grid_layout4_row_col_adjust.css" + + ```sass hl_lines="5" + --8<-- "docs/examples/guide/layout/grid_layout4_row_col_adjust.css" + ``` + + +If you don't specify enough values in a `grid-columns` or `grid-rows` declaration, the values you _have_ provided will be "repeated". +For example, if your grid has four columns (i.e. `grid-size: 4;`), then `grid-columns: 2 4;` is equivalent to `grid-columns: 2 4 2 4;`. +If it instead had three columns, then `grid-columns: 2 4;` would be equivalent to `grid-columns: 2 4 2;`. + +### Cell spans + +Cells may _span_ multiple rows or columns, to create more interesting grid arrangements. + +To make a single cell span multiple rows or columns in the grid, we need to be able to select it using CSS. +To do this, we'll add an ID to the widget inside our `compose` method so we can set the `row-span` and `column-span` properties using CSS. + +Let's add an ID of `#two` to the second widget yielded from `compose`, and give it a `column-span` of 2 to make that widget span two columns. +We'll also add a slight tint using `tint: magenta 40%;` to draw attention to it. + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout5_col_span.py"} + ``` + +=== "grid_layout5_col_span.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout5_col_span.py" + ``` + +=== "grid_layout5_col_span.css" + + ```sass hl_lines="6-9" + --8<-- "docs/examples/guide/layout/grid_layout5_col_span.css" + ``` + + + +Notice that the widget expands to fill columns to the _right_ of its original position. +Since `#two` now spans two cells instead of one, all widgets that follow it are shifted along one cell in the grid to accommodate. +As a result, the final widget wraps on to a new row at the bottom of the grid. + +!!! note + + In the example above, setting the `column-span` of `#two` to be 3 (instead of 2) would have the same effect, since there are only 2 columns available (including `#two`'s original column). + +We can similarly adjust the `row-span` of a cell to have it span multiple rows. +This can be used in conjunction with `column-span`, meaning one cell may span multiple rows and columns. +The example below shows `row-span` in action. +We again target widget `#two` in our CSS, and add a `row-span: 2;` declaration to it. + + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout6_row_span.py"} + ``` + +=== "grid_layout6_row_span.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout6_row_span.py" + ``` + +=== "grid_layout6_row_span.css" + + ```sass hl_lines="8" + --8<-- "docs/examples/guide/layout/grid_layout6_row_span.css" + ``` + + + +Widget `#two` now spans two columns and two rows, covering a total of four cells. +Notice how the other cells are moved to accommodate this change. +The widget that previously occupied a single cell now occupies four cells, thus displacing three cells to a new row. + +### Gutter + +The spacing between cells in the grid can be adjusted using the `grid-gutter` CSS property. +By default, cells have no gutter, meaning their edges touch each other. +Gutter is applied across every cell in the grid, so `grid-gutter` must be used on a widget with `layout: grid` (_not_ on a child/cell widget). + +To illustrate gutter let's set our `Screen` background color to `lightgreen`, and the background color of the widgets we yield to `darkmagenta`. +Now if we add `grid-gutter: 1;` to our grid, one cell of spacing appears between the cells and reveals the light green background of the `Screen`. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/grid_layout7_gutter.py"} + ``` + +=== "grid_layout7_gutter.py" + + ```python + --8<-- "docs/examples/guide/layout/grid_layout7_gutter.py" + ``` + +=== "grid_layout7_gutter.css" + + ```sass hl_lines="4" + --8<-- "docs/examples/guide/layout/grid_layout7_gutter.css" + ``` + +Notice that gutter only applies _between_ the cells in a grid, pushing them away from each other. +It doesn't add any spacing between cells and the edges of the parent container. + +!!! tip + + You can also supply two values to the `grid-gutter` property to set vertical and horizontal gutters respectively. + Since terminal cells are typically two times taller than they are wide, + it's common to set the horizontal gutter equal to double the vertical gutter (e.g. `grid-gutter: 1 2;`) in order to achieve visually consistent spacing around grid cells. + +## Docking + +Widgets may be *docked*. +Docking a widget removes it from the layout and fixes its position, aligned to either the top, right, bottom, or left edges of a container. +Docked widgets will not scroll out of view, making them ideal for sticky headers, footers, and sidebars. + +
+--8<-- "docs/images/layout/dock.excalidraw.svg" +
+ +To dock a widget to an edge, add a `dock: ;` declaration to it, where `` is one of `top`, `right`, `bottom`, or `left`. +For example, a sidebar similar to that shown in the diagram above can be achieved using `dock: left;`. +The code below shows a simple sidebar implementation. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"} + ``` + +=== "dock_layout1_sidebar.py" + + ```python + --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.py" + ``` + +=== "dock_layout1_sidebar.css" + + ```sass hl_lines="2" + --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.css" + ``` + +If we run the app above and scroll down, the body text will scroll but the sidebar does not (note the position of the scrollbar in the output shown above). + +Docking multiple widgets to the same edge will result in overlap. +The first widget yielded from `compose` will appear below widgets yielded after it. +Let's dock a second sidebar, `#another-sidebar`, to the left of the screen. +This new sidebar is double the width of the one previous one, and has a `deeppink` background. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/dock_layout2_sidebar.py" press="pagedown,down,down,_,_,_,_,_"} + ``` + +=== "dock_layout2_sidebar.py" + + ```python hl_lines="16" + --8<-- "docs/examples/guide/layout/dock_layout2_sidebar.py" + ``` + +=== "dock_layout2_sidebar.css" + + ```sass hl_lines="1-6" + --8<-- "docs/examples/guide/layout/dock_layout2_sidebar.css" + ``` + +Notice that the original sidebar (`#sidebar`) appears on top of the newly docked widget. +This is because `#sidebar` was yielded _after_ `#another-sidebar` inside the `compose` method. + +Of course, we can also dock widgets to multiple edges within the same container. +The built-in `Header` widget contains some internal CSS which docks it to the top. +We can yield it inside `compose`, and without any additional CSS, we get a header fixed to the top of the screen. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/dock_layout3_sidebar_header.py"} + ``` + +=== "dock_layout3_sidebar_header.py" + + ```python hl_lines="14" + --8<-- "docs/examples/guide/layout/dock_layout3_sidebar_header.py" + ``` + +=== "dock_layout3_sidebar_header.css" + + ```sass + --8<-- "docs/examples/guide/layout/dock_layout3_sidebar_header.css" + ``` + +If we wished for the sidebar to appear below the header, it'd simply be a case of yielding the sidebar before we yield the header. + +## Layers + +Textual has a concept of _layers_ which gives you finely grained control over the order widgets are placed. + +When drawing widgets, Textual will first draw on _lower_ layers, working its way up to higher layers. +As such, widgets on higher layers will be drawn on top of those on lower layers. + +Layer names are defined with a `layers` style on a container (parent) widget. +Descendants of this widget can then be assigned to one of these layers using a `layer` style. + +The `layers` style takes a space-separated list of layer names. +The leftmost name is the lowest layer, and the rightmost is the highest layer. +Therefore, if you assign a descendant to the rightmost layer name, it'll be drawn on the top layer and will be visible above all other descendants. + +An example `layers` declaration looks like: `layers: one two three;`. +To add a widget to the topmost layer in this case, you'd add a declaration of `layer: three;` to it. + +In the example below, `#box1` is yielded before `#box2`. +Given our earlier discussion on yield order, you'd expect `#box2` to appear on top. +However, in this case, both `#box1` and `#box2` are assigned to layers which define the reverse order, so `#box1` is on top of `#box2` + + +[//]: # (NOTE: the example below also appears in the layers and layer style reference) + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/layers.py"} + ``` + +=== "layers.py" + + ```python + --8<-- "docs/examples/guide/layout/layers.py" + ``` + +=== "layers.css" + + ```sass hl_lines="3 15 19" + --8<-- "docs/examples/guide/layout/layers.css" + ``` + +## Offsets + +Widgets have a relative offset which is added to the widget's location, _after_ its location has been determined via its parent's layout. +This means that if a widget hasn't had its offset modified using CSS or Python code, it will have an offset of `(0, 0)`. + +
+--8<-- "docs/images/layout/offset.excalidraw.svg" +
+ +The offset of a widget can be set using the `offset` CSS property. +`offset` takes two values. + +* The first value defines the `x` (horizontal) offset. Positive values will shift the widget to the right. Negative values will shift the widget to the left. +* The second value defines the `y` (vertical) offset. Positive values will shift the widget down. Negative values will shift the widget up. + +[//]: # (TODO Link the word animation below to animation docs) + +## Putting it all together + +The sections above show how the various layouts in Textual can be used to position widgets on screen. +In a real application, you'll make use of several layouts. + +The example below shows how an advanced layout can be built by combining the various techniques described on this page. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/combining_layouts.py"} + ``` + +=== "combining_layouts.py" + + ```python + --8<-- "docs/examples/guide/layout/combining_layouts.py" + ``` + +=== "combining_layouts.css" + + ```sass + --8<-- "docs/examples/guide/layout/combining_layouts.css" + ``` + +Textual layouts make it easy to design and build real-life applications with relatively little code. diff --git a/docs/guide/queries.md b/docs/guide/queries.md new file mode 100644 index 000000000..ff58ca277 --- /dev/null +++ b/docs/guide/queries.md @@ -0,0 +1,170 @@ +# DOM Queries + +In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors). + +Selectors are a very useful idea and can do more that apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how! + +## Query one + +The [query_one][textual.dom.DOMNode.query_one] method gets a single widget in an app or other widget. If you call it with a selector it will return the first matching widget. + +Let's say we have a widget with an ID of `send` and we want to get a reference to it in our app. We could do this with the following: + +```python +send_button = self.query_one("#send") +``` + +If there is no widget with an ID of `send`, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. Otherwise it will return the matched widget. + +You can also add a second parameter for the expected type. + +```python +send_button = self.query_one("#send", Button) +``` + +If the matched widget is *not* a button (i.e. if `isinstance(widget, Button)` equals `False`), Textual will raise a [WrongType][textual.css.query.WrongType] exception. + +!!! tip + + The second parameter allows type-checkers like MyPy to know the exact return type. Without it, MyPy would only know the result of `query_one` is a Widget (the base class). + +## Making queries + +Apps and widgets have a [query][textual.dom.DOMNode.query] method which finds (or queries) widgets. This method returns a [DOMQuery][textual.css.query.DOMQuery] object which is a list-like container of widgets. + +If you call `query` with no arguments, you will get back a `DOMQuery` containing all widgets. This method is *recursive*, meaning it will also return child widgets (as many levels as required). + +Here's how you might iterate over all the widgets in your app: + +```python +for widget in self.query(): + print(widget) +``` + +Called on the `app`, this will retrieve all widgets in the app. If you call the same method on a widget, it will return the children of that widget. + +!!! note + + All the query and related methods work on both App and Widget sub-classes. + +### Query selectors + +You can call `query` with a CSS selector. Let's look a few examples: + +If we want to find all the button widgets, we could do something like the following: + +```python +for button in self.query("Button"): + print(button) +``` + +Any selector that works in CSS will work with the `query` method. For instance, if we want to find all the disabled buttons in a Dialog widget, we could do this: + +```python +for button in self.query("Dialog Button.disabled"): + print(button) +``` + +!!! info + + The selector `Dialog Button.disabled` says find all the `Button` with a CSS class of `disabled` that are a child of a `Dialog` widget. + +### Results + +Query objects have a [results][textual.css.query.DOMQuery.results] method which is an alternative way of iterating over widgets. If you supply a type (i.e. a Widget class) then this method will generate only objects of that type. + +The following example queries for widgets with the `disabled` CSS class and iterates over just the Button objects. + +```python +for button in self.query(".disabled").results(Button): + print(button) +``` + +!!! tip + + This method allows type-checkers like MyPy to know the exact type of the object in the loop. Without it, MyPy would only know that `button` is a `Widget` (the base class). + +## Query objects + +We've seen that the [query][textual.dom.DOMNode.query] method returns a [DOMQuery][textual.css.query.DOMQuery] object you can iterate over in a for loop. Query objects behave like Python lists and support all of the same operations (such as `query[0]`, `len(query)` ,`reverse(query)` etc). They also have a number of other methods to simplify retrieving and modifying widgets. + +## First and last + +The [first][textual.css.query.DOMQuery.first] and [last][textual.css.query.DOMQuery.last] methods return the first or last matching widget from the selector, respectively. + +Here's how we might find the _last_ button in an app: + +```python +last_button = self.query("Button").last() +``` + +If there are no buttons, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. Otherwise it will return a button widget. + +Both `first()` and `last()` accept an `expect_type` argument that should be the class of the widget you are expecting. Let's say we want to get the last widget with class `.disabled`, and we want to check it really is a button. We could do this: + +```python +disabled_button = self.query(".disabled").last(Button) +``` + +The query selects all widgets with a `disabled` CSS class. The `last` method gets the last disabled widget and checks it is a `Button` and not any other kind of widget. + +If the last widget is *not* a button, Textual will raise a [WrongType][textual.css.query.WrongType] exception. + +!!! tip + + Specifying the expected type allows type-checkers like MyPy to know the exact return type. + +## Filter + +Query objects have a [filter][textual.css.query.DOMQuery.filter] method which further refines a query. This method will return a new query object with widgets that match both the original query _and_ the new selector + +Let's say we have a query which gets all the buttons in an app, and we want a new query object with just the disabled buttons. We could write something like this: + +```python +# Get all the Buttons +buttons_query = self.query("Button") +# Buttons with 'disabled' CSS class +disabled_buttons = buttons_query.filter(".disabled") +``` + +Iterating over `disabled_buttons` will give us all the disabled buttons. + +## Exclude + +Query objects have an [exclude][textual.css.query.DOMQuery.exclude] method which is the logical opposite of [filter][textual.css.query.DOMQuery.filter]. The `exclude` method removes any widgets from the query object which match a selector. + +Here's how we could get all the buttons which *don't* have the `disabled` class set. + +```python +# Get all the Buttons +buttons_query = self.query("Button") +# Remove all the Buttons with the 'disabled' CSS class +enabled_buttons = buttons_query.exclude(".disabled") +``` + +## Loop-free operations + +Once you have a query object, you can loop over it to call methods on the matched widgets. Query objects also support a number of methods which make an update to every matched widget without an explicit loop. + +For instance, let's say we want to disable all buttons in an app. We could do this by calling [add_class()][textual.css.query.DOMQuery.add_class] on a query object. + +```python +self.query("Button").add_class("disabled") +``` + +This single line is equivalent to the following: + +```python +for widget in self.query("Button"): + widget.add_class("disabled") +``` + +Here are the other loop-free methods on query objects: + +- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. +- [add_class][textual.css.query.DOMQuery.add_class] Adds a CSS class (or classes) to matched widgets. +- [remove_class][textual.css.query.DOMQuery.remove_class] Removes a CSS class (or classes) from matched widgets. +- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. +- [remove][textual.css.query.DOMQuery.remove] Removes matched widgets from the DOM. +- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. + diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md new file mode 100644 index 000000000..b4c0bdebc --- /dev/null +++ b/docs/guide/reactivity.md @@ -0,0 +1,233 @@ +# Reactivity + +Textual's reactive attributes are attributes _with superpowers_. In this chapter we will look at how reactive attributes can simplify your apps. + +!!! quote + + With great power comes great responsibility. + + — Uncle Ben + +## Reactive attributes + +Textual provides an alternative way of adding attributes to your widget or App, which doesn't require adding them to your class constructor (`__init__`). To create these attributes import [reactive][textual.reactive.reactive] from `textual.reactive`, and assign them in the class scope. + +The following code illustrates how to create reactive attributes: + +```python +from textual.reactive import reactive +from textual.widget import Widget + +class Reactive(Widget): + + name = reactive("Paul") # (1)! + count = reactive(0) # (2)! + is_cool = reactive(True) # (3)! +``` + +1. Create a string attribute with a default of `"Paul"` +2. Creates an integer attribute with a default of `0`. +3. Creates a boolean attribute with a default of `True`. + +The `reactive` constructor accepts a default value as the first positional argument. + +!!! information + + Textual uses Python's _descriptor protocol_ to create reactive attributes, which is the same protocol used by the builtin `property` decorator. + +You can get and set these attributes in the same way as if you had assigned them in an `__init__` method. For instance `self.name = "Jessica"`, `self.count += 1`, or `print(self.is_cool)`. + +### Dynamic defaults + +You can also set the default to a function (or other callable). Textual will call this function to get the default value. The following code illustrates a reactive value which will be automatically assigned the current time when the widget is created: + +```python +from time import time +from textual.reactive import reactive +from textual.widget import Widget + +class Timer(Widget): + + start_time = reactive(time) # (1)! +``` + +1. The `time` function returns the current time in seconds. + +### Typing reactive attributes + +There is no need to specify a type hint if a reactive attribute has a default value, as type checkers will assume the attribute is the same type as the default. + +You may want to add explicit type hints if the attribute type is a superset of the default type. For instance if you want to make an attribute optional. Here's how you would create a reactive string attribute which may be `None`: + +```python + name: reactive[str | None] = reactive("Paul") +``` + +## Smart refresh + +The first superpower we will look at is "smart refresh". When you modify a reactive attribute, Textual will make note of the fact that it has changed and refresh automatically. + +!!! information + + If you modify multiple reactive attributes, Textual will only do a single refresh to minimize updates. + +Let's look at an example which illustrates this. In the following app, the value of an input is used to update a "Hello, World!" type greeting. + +=== "refresh01.py" + + ```python hl_lines="7-13 24" + --8<-- "docs/examples/guide/reactivity/refresh01.py" + ``` + +=== "refresh01.css" + + ```sass + --8<-- "docs/examples/guide/reactivity/refresh01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/refresh01.py" press="tab,T,e,x,t,u,a,l"} + ``` + +The `Name` widget has a reactive `who` attribute. When the app modifies that attribute, a refresh happens automatically. + +!!! information + + Textual will check if a value has really changed, so assigning the same value wont prompt an unnecessary refresh. + +###ย Disabling refresh + +If you *don't* want an attribute to prompt a refresh or layout but you still want other reactive superpowers, you can use [var][textual.reactive.var] to create an attribute. You can import `var` from `textual.reactive`. + +The following code illustrates how you create non-refreshing reactive attributes. + +```python +class MyWidget(Widget): + count = var(0) # (1)! +``` + +1. Changing `self.count` wont cause a refresh or layout. + +### Layout + +The smart refresh feature will update the content area of a widget, but will not change its size. If modifying an attribute should change the size of the widget, you can set `layout=True` on the reactive attribute. This ensures that your CSS layout will update accordingly. + +The following example modifies "refresh01.py" so that the greeting has an automatic width. + +=== "refresh02.py" + + ```python hl_lines="10" + --8<-- "docs/examples/guide/reactivity/refresh02.py" + ``` + + 1. This attribute will update the layout when changed. + +=== "refresh02.css" + + ```sass hl_lines="7-9" + --8<-- "docs/examples/guide/reactivity/refresh02.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/refresh02.py" press="tab,n,a,m,e"} + ``` + +If you type in to the input now, the greeting will expand to fit the content. If you were to set `layout=False` on the reactive attribute, you should see that the box remains the same size when you type. + +## Validation + +The next superpower we will look at is _validation_, which can check and potentially modify a value you assign to a reactive attribute. + +If you add a method that begins with `validate_` followed by the name of your attribute, it will be called when you assign a value to that attribute. This method should accept the incoming value as a positional argument, and return the value to set (which may be the same or a different value). + +A common use for this is to restrict numbers to a given range. The following example keeps a count. There is a button to increase the count, and a button to decrease it. The validation ensures that the count will never go above 10 or below zero. + +=== "validate01.py" + + ```python hl_lines="12-18 30 32" + --8<-- "docs/examples/guide/reactivity/validate01.py" + ``` + +=== "validate01.css" + + ```sass + --8<-- "docs/examples/guide/reactivity/validate01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/validate01.py"} + ``` + +If you click the buttons in the above example it will show the current count. When `self.count` is modified in the button handler, Textual runs `validate_count` which performs the validation to limit the value of count. + +## Watch methods + +Watch methods are another superpower. Textual will call watch methods when reactive attributes are modified. Watch methods begin with `watch_` followed by the name of the attribute. If the watch method accepts a positional argument, it will be called with the new assigned value. If the watch method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value. + +The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44"`. + +=== "watch01.py" + + ```python hl_lines="17-19 28" + --8<-- "docs/examples/guide/reactivity/watch01.py" + ``` + + 1. Creates a reactive [color][textual.color.Color] attribute. + 2. Called when `self.color` is changed. + 3. New color is assigned here. + +=== "watch01.css" + + ```sass + --8<-- "docs/examples/guide/reactivity/watch01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/watch01.py" press="tab,d,a,r,k,o,r,c,h,i,d"} + ``` + +The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values. + +## Compute methods + +Compute methods are the final superpower offered by the `reactive` descriptor. Textual runs compute methods to calculate the value of a reactive attribute. Compute methods begin with `compute_` followed by the name of the reactive value. + +You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes. + +The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes. + +=== "computed01.py" + + ```python hl_lines="25-26 28-29" + --8<-- "docs/examples/guide/reactivity/computed01.py" + ``` + + 1. Combines color components in to a Color object. + 2. The compute method is called when the _result_ of `compute_color` changes. + +=== "computed01.css" + + ```sass + --8<-- "docs/examples/guide/reactivity/computed01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/computed01.py"} + ``` + +Note the `compute_color` method which combines the color components into a [Color][textual.color.Color] object. It will be recalculated when any of the `red` , `green`, or `blue` attributes are modified. + +When the result of `compute_color` changes, Textual will also call `watch_color` since `color` still has the [watch method](#watch-methods) superpower. + +!!! note + + Textual will first attempt to call the compute method for a reactive attribute, followed by the validate method, and finally the watch method. + +!!! note + + It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes. diff --git a/docs/guide/screens.md b/docs/guide/screens.md new file mode 100644 index 000000000..28a048e25 --- /dev/null +++ b/docs/guide/screens.md @@ -0,0 +1,151 @@ +# Screens + +This chapter covers Textual's screen API. We will discuss how to create screens and switch between them. + +## What is a screen? + +Screens are containers for widgets that occupy the dimensions of your terminal. There can be many screens in a given app, but only one screen is visible at a time. + +Textual requires that there be at least one screen object and will create one implicitly in the App class. If you don't change the screen, any widgets you [mount][textual.widget.Widget.mount] or [compose][textual.widget.Widget.compose] will be added to this default screen. + +!!! tip + + Try printing `widget.parent` to see what object your widget is connected to. + +
+--8<-- "docs/images/dom1.excalidraw.svg" +
+ +## Creating a screen + +You can create a screen by extending the [Screen][textual.screen.Screen] class which you can import from `textual.screen`. The screen may be styled in the same way as other widgets, with the exception that you can't modify the screen's dimensions (as these will always be the size of your terminal). + +Let's look at a simple example of writing a screen class to simulate Window's [blue screen of death](https://en.wikipedia.org/wiki/Blue_screen_of_death). + +=== "screen01.py" + + ```python title="screen01.py" hl_lines="18-24 29" + --8<-- "docs/examples/guide/screens/screen01.py" + ``` + +=== "screen01.css" + + ```sass title="screen01.css" + --8<-- "docs/examples/guide/screens/screen01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/screens/screen01.py" press="b,_"} + ``` + +If you run this you will see an empty screen. Hit the ++b++ key to show a blue screen of death. Hit ++escape++ to return to the default screen. + +The `BSOD` class above defines a screen with a key binding and compose method. These should be familiar as they work in the same way as apps. + +The app class has a new `SCREENS` class variable. Textual uses this class variable to associate a name with screen object (the name is used to reference screens in the screen API). Also in the app is a key binding associated with the action `"push_screen('bsod')"`. The screen class has a similar action `"pop_screen"` bound to the ++escape++ key. We will cover these actions below. + +## Named screens + +You can associate a screen with a name by defining a `SCREENS` class variable in your app, which should be a `dict` that maps names on to `Screen` objects. The name of the screen may be used interchangeably with screen objects in much of the screen API. + +You can also _install_ new named screens dynamically with the [install_screen][textual.app.App.install_screen] method. The following example installs the `BSOD` screen in a mount handler rather than from the `SCREENS` variable. + +=== "screen02.py" + + ```python title="screen02.py" hl_lines="31-32" + --8<-- "docs/examples/guide/screens/screen02.py" + ``` + +=== "screen02.css" + + ```sass title="screen02.css" + --8<-- "docs/examples/guide/screens/screen02.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/screens/screen02.py" press="b,_"} + ``` + +Although both do the same thing, we recommend `SCREENS` for screens that exist for the lifetime of your app. + +### Uninstalling screens + +Screens defined in `SCREENS` or added with [install_screen][textual.app.App.install_screen] are _installed_ screens. Textual will keep these screens in memory for the lifetime of your app. + +If you have installed a screen, but you later want it to be removed and cleaned up, you can call [uninstall_screen][textual.app.App.uninstall_screen]. + +## Screen stack + +Textual keeps track of a _stack_ of screens. You can think of the screen stack as a stack of paper, where only the very top sheet is visible. If you remove the top sheet the paper underneath becomes visible. Screens work in a similar way. + +The active screen (top of the stack) will render the screen and receive input events. The following API methods on the App class can manipulate this stack, and let you decide which screen the user can interact with. + +### Push screen + +The [push_screen][textual.app.App.push_screen] method puts a screen on top of the stack and makes that screen active. You can call this method with the name of an installed screen, or a screen object. + +
+--8<-- "docs/images/screens/push_screen.excalidraw.svg" +
+ +#### Action + +You can also push screens with the `"app.push_screen"` action, which requires the name of an installed screen. + +### Pop screen + +The [pop_screen][textual.app.App.pop_screen] method removes the top-most screen from the stack, and makes the new top screen active. + +!!! note + + The screen stack must always have at least one screen. If you attempt to remove the last screen, Textual will raise a [ScreenStackError][textual.app.ScreenStackError] exception. + +
+--8<-- "docs/images/screens/pop_screen.excalidraw.svg" +
+ + +When you pop a screen it will be removed and deleted unless it has been installed or there is another copy of the screen on the stack. + +#### Action + +You can also pop screens with the `"app.pop_screen"` action. + +### Switch screen + +The [switch_screen][textual.app.App.switch_screen] method replaces the top of the stack with a new screen. + +
+--8<-- "docs/images/screens/switch_screen.excalidraw.svg" +
+ +Like [pop_screen](#pop-screen), if the screen being replaced is not installed it will be removed and deleted. + +#### Action + +You can also switch screens with the `"app.switch_screen"` action which accepts the name of the screen to switch to. + +## Modal screens + +Screens can be used to implement modal dialogs. The following example pushes a screen when you hit the ++q++ key to ask you if you really want to quit. + +=== "modal01.py" + + ```python title="modal01.py" hl_lines="18 20 32" + --8<-- "docs/examples/guide/screens/modal01.py" + ``` + +=== "modal01.css" + + ```sass title="modal01.css" + --8<-- "docs/examples/guide/screens/modal01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/screens/modal01.py" press="q,_"} + ``` + +Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`. This makes the quit screen active. if you click cancel, the quit screen calls `pop_screen` to return the default screen. This also removes and deletes the `QuitScreen` object. diff --git a/docs/guide/styles.md b/docs/guide/styles.md new file mode 100644 index 000000000..95dff6435 --- /dev/null +++ b/docs/guide/styles.md @@ -0,0 +1,332 @@ +# Styles + +In this chapter we will explore how you can apply styles to your application to create beautiful user interfaces. + +## Styles object + +Every Textual widget class provides a `styles` object which contains a number of attributes. These attributes tell Textual how the widget should be displayed. Setting any of these attributes will update the screen accordingly. + +!!! note + + These docs use the term *screen* to describe the contents of the terminal, which will typically be a window on your desktop. + +Let's look at a simple example which sets styles on `screen` (a special widget that represents the screen). + +```python title="screen.py" hl_lines="6-7" +--8<-- "docs/examples/guide/styles/screen.py" +``` + +The first line sets the [background](../styles/background.md) style to `"darkblue"` which will change the background color to dark blue. There are a few other ways of setting color which we will explore later. + +The second line sets [border](../styles/border.md) to a tuple of `("heavy", "white")` which tells Textual to draw a white border with a style of `"heavy"`. Running this code will show the following: + +```{.textual path="docs/examples/guide/styles/screen.py"} +``` + +## Styling widgets + +Setting styles on screen is useful, but to create most user interfaces we will also need to apply styles to other widgets. + +The following example adds a static widget which we will apply some styles to: + +```python title="widget.py" hl_lines="7 11-12" +--8<-- "docs/examples/guide/styles/widget.py" +``` + +The compose method stores a reference to the widget before yielding it. In the mount handler we use that reference to set the same styles on the widget as we did for the screen example. Here is the result: + +```{.textual path="docs/examples/guide/styles/widget.py"} +``` + +Widgets will occupy the full width of their container and as many lines as required to fit in the vertical direction. + +Note how the combined height of the widget is three rows in the terminal. This is because a border adds two rows (and two columns). If you were to remove the line that sets the border style, the widget would occupy a single row. + +!!! information + + Widgets will wrap text by default. If you were to replace `"Textual"` with a long paragraph of text, the widget will expand downwards to fit. + +## Colors + +There are a number of style attributes which accept colors. The most commonly used are [color](../styles/color.md) which sets the default color of text on a widget, and [background](..styles/background/md) which sets the background color (beneath the text). + +You can set a color value to one of a number of pre-defined color constants, such as `"crimson"`, `"lime"`, and `"palegreen"`. You can find a full list in the [Color reference](../reference/color.md#textual.color--named-colors). + +Here's how you would set the screen background to lime: + +```python +self.screen.styles.background = "lime" +``` + +In addition to color names, you can also use any of the following ways of expressing a color: + +- RGB hex colors starts with a `#` followed by three pairs of one or two hex digits; one for the red, green, and blue color components. For example, `#f00` is an intense red color, and `#9932CC` is *dark orchid*. +- RGB decimal color start with `rgb` followed by a tuple of three numbers in the range 0 to 255. For example `rgb(255,0,0)` is intense red, and `rgb(153,50,204)` is *dark orchid*. +- HSL colors start with `hsl` followed by a angle between 0 and 360 and two percentage values, representing Hue, Saturation and Lightness. For example `hsl(0,100%,50%)` is intense red and `hsl(280,60%,49%)` is *dark orchid*. + + +The background and color styles also accept a [Color][textual.color.Color] object which can be used to create colors dynamically. + +The following example adds three widgets and sets their color styles. + +```python title="colors01.py" hl_lines="16-19" +--8<-- "docs/examples/guide/styles/colors01.py" +``` + +Here is the output: + +```{.textual path="docs/examples/guide/styles/colors01.py"} +``` + +### Alpha + +Textual represents color internally as a tuple of three values for the red, green, and blue components. + +Textual supports a common fourth value called *alpha* which can make a color translucent. If you set alpha on a background color, Textual will blend the background with the color beneath it. If you set alpha on the text color, then Textual will blend the text with the background color. + +There are a few ways you can set alpha on a color in Textual. + +- You can set the alpha value of a color by adding a fourth digit or pair of digits to a hex color. The extra digits form an alpha component which ranges from 0 for completely transparent to 255 (completely opaque). Any value between 0 and 255 will be translucent. For example `"#9932CC7f"` is a dark orchid which is roughly 50% translucent. +- You can also set alpha with the `rgba` format, which is identical to `rgb` with the additional of a fourth value that should be between 0 and 1, where 0 is invisible and 1 is opaque. For example `"rgba(192,78,96,0.5)"`. +- You can add the `a` parameter on a [Color][textual.color.Color] object. For example `Color(192, 78, 96, a=0.5)` creates a translucent dark orchid. + +The following example shows what happens when you set alpha on background colors: + +```python title="colors01.py" hl_lines="12-15" +--8<-- "docs/examples/guide/styles/colors02.py" +``` + +Notice that at an alpha of 0.1 the background almost matches the screen, but at 1.0 it is a solid color. + +```{.textual path="docs/examples/guide/styles/colors02.py"} +``` + +## Dimensions + +Widgets occupy a rectangular region of the screen, which may be as small as a single character or as large as the screen (potentially *larger* if [scrolling](../styles/overflow.md) is enabled). + +### Box Model + +The following styles influence the dimensions of a widget. + +- [width](../styles/width.md) and [height](../styles/width.md) define the size of the widget. +- [padding](../styles/padding.md) adds optional space around the content area. +- [border](../styles/border.md) draws an optional rectangular border around the padding and the content area. + +Additionally, the [margin](../styles/margin.md) style adds space around a widget's border, which isn't technically part of the widget, but provides visual separation between widgets. + +Together these styles compose the widget's *box model*. The following diagram shows how these settings are combined: + +
+--8<-- "docs/images/styles/box.excalidraw.svg" +
+ +### Width and height + +Setting the width restricts the number of columns used by a widget, and setting the height restricts the number of rows. Let's look at an example which sets both dimensions. + +```python title="dimensions01.py" hl_lines="21-22" +--8<-- "docs/examples/guide/styles/dimensions01.py" +``` + +This code produces the following result. + +```{.textual path="docs/examples/guide/styles/dimensions01.py"} +``` + +Note how the text wraps in the widget, and is cropped because it doesn't fit in the space provided. + +#### Auto dimensions + +In practice, we generally want the size of a widget to adapt to its content, which we can do by setting a dimension to `"auto"`. + +Let's set the height to auto and see what happens. + +```python title="dimensions02.py" hl_lines="22" +--8<-- "docs/examples/guide/styles/dimensions02.py" +``` + +If you run this you will see the height of the widget now grows to accommodate the full text: + +```{.textual path="docs/examples/guide/styles/dimensions02.py"} +``` + +#### Units + +Textual offers a few different *units* which allow you to specify dimensions relative to the screen or container. Relative units can better make use of available space if the user resizes the terminal. + +- Percentage units are given as a string containing a number followed by a percentage symbol, e.g. `"50%"`. Setting a dimension to a percentage unit will cause it to fit in that percentage of the available space. For instance, setting width to `"50%"` will cause the width of the widget to be half the available space. +- View units are similar to percentage units, but explicitly reference a dimension. The `vw` unit sets a dimension to a percentage of the terminal *width*, and `vh` sets a dimension to a percentage of the terminal *height*. +- The `w` unit sets a dimension to a percentage of the available width (which may be smaller than the terminal size if the widget is within another widget). +- The `h` unit sets a dimension to a percentage of the available height. + + +The following example demonstrates applying percentage units: + +```python title="dimensions03.py" hl_lines="21-22" +--8<-- "docs/examples/guide/styles/dimensions03.py" +``` + +With the width set to `"50%"` and the height set to `"80%"`, the widget will keep those relative dimensions when resizing the terminal window: + + +=== "60 x 20" + + ```{.textual path="docs/examples/guide/styles/dimensions03.py" columns="60" lines="20"} + ``` + +=== "80 x 30" + + ```{.textual path="docs/examples/guide/styles/dimensions03.py" columns="80" lines="30"} + ``` + +=== "120 x 40" + + ```{.textual path="docs/examples/guide/styles/dimensions03.py" columns="120" lines="40"} + ``` + +Percentage units can be problematic for some relative values. For instance, if we want to divide the screen into thirds, we would have to set a dimension to `33.3333333333%` which is awkward. Textual supports `fr` units which are often better than percentage-based units for these situations. + +When specifying `fr` units for a given dimension, Textual will divide the available space by the sum of the `fr` units on that dimension. That space will then be divided amongst the widgets as a proportion of their individual `fr` values. + +Let's look at an example. We will create two widgets, one with a height of `"2fr"` and one with a height of `"1fr"`. + +```python title="dimensions04.py" hl_lines="24-25" +--8<-- "docs/examples/guide/styles/dimensions04.py" +``` + +The total `fr` units for height is 3. The first widget will have a screen height of two thirds because its height style is set to `2fr`. The second widget's height style is `1fr` so its screen height will be one third. Here's what that looks like. + +```{.textual path="docs/examples/guide/styles/dimensions04.py"} +``` + +### Maximum and minimums + +The same units may also be used to set limits on a dimension. The following styles set minimum and maximum sizes and can accept any of the values used in width and height. + +- [min-width](../styles/min_width.md) sets a minimum width. +- [max-width](../styles/max_width.md) sets a maximum width. +- [min-height](../styles/min_height.md) sets a minimum height. +- [max-height](../styles/max_height.md) sets a maximum height. + +### Padding + +Padding adds space around your content which can aid readability. Setting [padding](../styles/padding.md) to an integer will add that number additional rows and columns around the content area. The following example sets padding to 2: + +```python title="padding01.py" hl_lines="22" +--8<-- "docs/examples/guide/styles/padding01.py" +``` + +Notice the additional space around the text: + +```{.textual path="docs/examples/guide/styles/padding01.py"} +``` + +You can also set padding to a tuple of *two* integers which will apply padding to the top/bottom and left/right edges. The following example sets padding to `(2, 4)` which adds two rows to the top and bottom of the widget, and 4 columns to the left and right of the widget. + +```python title="padding02.py" hl_lines="22" +--8<-- "docs/examples/guide/styles/padding02.py" +``` + +Compare the output of this example to the previous example: + +```{.textual path="docs/examples/guide/styles/padding02.py"} +``` + +You can also set padding to a tuple of *four* values which applies padding to each edge individually. The first value is the padding for the top of the widget, followed by the right of the widget, then bottom, then left. + +### Border + +The [border](../styles/border.md) style draws a border around a widget. To add a border set `styles.border` to a tuple of two values. The first value is the border type, which should be a string. The second value is the border color which will accept any value that works with [color](../styles/color.md) and [background](../styles/background.md). + +The following example adds a border around a widget: + +```python title="border01.py" hl_lines="22" +--8<-- "docs/examples/guide/styles/border01.py" +``` + +Here is the result: + +```{.textual path="docs/examples/guide/styles/border01.py"} +``` + +There are many other border types. Run the following from the command prompt to preview them. + + +```bash +textual borders +``` + +### Outline + +[Outline](../styles/outline.md) is similar to border and is set in the same way. The difference is that outline will not change the size of the widget, and may overlap the content area. The following example sets an outline on a widget: + +```python title="outline01.py" hl_lines="22" +--8<-- "docs/examples/guide/styles/outline01.py" +``` + +Notice how the outline overlaps the text in the widget. + +```{.textual path="docs/examples/guide/styles/outline01.py"} +``` + +Outline can be useful to emphasize a widget, but be mindful that it may obscure your content. + +### Box sizing + +When you set padding or border it reduces the size of the widget's content area. In other words, setting padding or border won't change the width or height of the widget. + +This is generally desirable when you arrange things on screen as you can add border or padding without breaking your layout. Occasionally though you may want to keep the size of the content area constant and grow the size of the widget to fit padding and border. The [box-sizing](../styles/box_sizing.md) style allows you to switch between these two modes. + +If you set `box_sizing` to `"content-box"` then space required for padding and border will be added to the widget dimensions. The default value of `box_sizing` is `"border-box"`. Compare the box model diagram for `content-box` to the to the box model for `border-box`. + +=== "content-box" + +
+ --8<-- "docs/images/styles/content_box.excalidraw.svg" +
+ +=== "border-box" + +
+ --8<-- "docs/images/styles/border_box.excalidraw.svg" +
+ + +The following example creates two widgets with a width of 30, a height of 6, and a border and padding of 1. +The first widget has the default `box_sizing` (`"border-box"`). +The second widget sets `box_sizing` to `"content-box"`. + +```python title="box_sizing01.py" hl_lines="33" +--8<-- "docs/examples/guide/styles/box_sizing01.py" +``` + +The padding and border of the first widget is subtracted from the height leaving only 2 lines in the content area. The second widget also has a height of 6, but the padding and border adds additional height so that the content area remains 6 lines. + +```{.textual path="docs/examples/guide/styles/box_sizing01.py"} +``` + +### Margin + +Margin is similar to padding in that it adds space, but unlike padding, [margin](../styles/margin.md) is outside of the widget's border. It is used to add space between widgets. + +The following example creates two widgets, each with a margin of 2. + +```python title="margin01.py" hl_lines="26-27" +--8<-- "docs/examples/guide/styles/margin01.py" +``` + +Notice how each widget has an additional two rows and columns around the border. + +```{.textual path="docs/examples/guide/styles/margin01.py"} +``` + +!!! note + + In the above example both widgets have a margin of 2, but there are only 2 lines of space between the widgets. This is because margins of consecutive widgets *overlap*. In other words when there are two widgets next to each other Textual picks the greater of the two margins. + +## More styles + +We've covered the most fundamental styles used by Textual apps, but there are many more which you can use to customize many aspects of how your app looks. See the [Styles reference](../styles/index.md) for a comprehensive list. + +In the next chapter we will discuss Textual CSS which is a powerful way of applying styles to widgets that keeps your code free of style attributes. diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md new file mode 100644 index 000000000..d30e30c96 --- /dev/null +++ b/docs/guide/widgets.md @@ -0,0 +1,203 @@ +# Widgets + +In this chapter we will explore widgets in more detail, and how you can create custom widgets of your own. + + +## What is a widget? + +A widget is a component of your UI responsible for managing a rectangular region of the screen. Widgets may respond to [events](./events.md) in much the same way as an app. In many respects, widgets are like mini-apps. + +!!! information + + Every widget runs in its own asyncio task. + +## Custom widgets + +There is a growing collection of [builtin widgets](../widgets/index.md) in Textual, but you can build entirely custom widgets that work in the same way. + +The first step in building a widget is to import and extend a widget class. This can either be [Widget][textual.widget.Widget] which is the base class of all widgets, or one of its subclasses. + +Let's create a simple custom widget to display a greeting. + + +```python title="hello01.py" hl_lines="5-9" +--8<-- "docs/examples/guide/widgets/hello01.py" +``` + +The three highlighted lines define a custom widget class with just a [render()][textual.widget.Widget.render] method. Textual will display whatever is returned from render in the content area of your widget. We have returned a string in the code above, but there are other possible return types which we will cover later. + +Note that the text contains tags in square brackets, i.e. `[b]`. This is [console markup](https://rich.readthedocs.io/en/latest/markup.html) which allows you to embed various styles within your content. If you run this you will find that `World` is in bold. + +```{.textual path="docs/examples/guide/widgets/hello01.py"} +``` + +This (very simple) custom widget may be [styled](./styles.md) in the same was as builtin widgets, and targeted with CSS. Let's add some CSS to this app. + + +=== "hello02.py" + + ```python title="hello02.py" hl_lines="13" + --8<-- "docs/examples/guide/widgets/hello02.py" + ``` + +=== "hello02.css" + + ```sass title="hello02.css" + --8<-- "docs/examples/guide/widgets/hello02.css" + ``` + +The addition of the CSS has completely transformed our custom widget. + +```{.textual path="docs/examples/guide/widgets/hello02.py"} +``` + +## Static widget + +While you can extend the Widget class, a subclass will typically be a better starting point. The [Static][textual.widgets.Static] class is a widget subclass which caches the result of render, and provides an [update()][textual.widgets.Static.update] method to update the content area. + +Let's use Static to create a widget which cycles through "hello" in various languages. + +=== "hello03.py" + + ```python title="hello03.py" hl_lines="24-36" + --8<-- "docs/examples/guide/widgets/hello03.py" + ``` + +=== "hello03.css" + + ```sass title="hello03.css" + --8<-- "docs/examples/guide/widgets/hello03.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/hello03.py"} + ``` + +Note that there is no `render()` method on this widget. The Static class is handling the render for us. Instead we call `update()` when we want to update the content within the widget. + +The `next_word` method updates the greeting. We call this method from the mount handler to get the first word, and from a click handler to cycle through the greetings when we click the widget. + +### Default CSS + +When building an app it is best to keep your CSS in an external file. This allows you to see all your CSS in one place, and to enable live editing. However if you intend to distribute a widget (via PyPI for instance) it can be convenient to bundle the code and CSS together. You can do this by adding a `DEFAULT_CSS` class variable inside your widget class. + +Textual's builtin widgets bundle CSS in this way, which is why you can see nicely styled widgets without having to copy any CSS code. + +Here's the Hello example again, this time the widget has embedded default CSS: + +=== "hello04.py" + + ```python title="hello04.py" hl_lines="27-36" + --8<-- "docs/examples/guide/widgets/hello04.py" + ``` + +=== "hello04.css" + + ```sass title="hello04.css" + --8<-- "docs/examples/guide/widgets/hello04.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/hello04.py"} + ``` + + +#### Default specificity + +CSS defined within `DEFAULT_CSS` has an automatically lower [specificity](./CSS.md#specificity) than CSS read from either the App's `CSS` class variable or an external stylesheet. In practice this means that your app's CSS will take precedence over any CSS bundled with widgets. + + +## Text links + +Text in a widget may be marked up with links which perform an action when clicked. Links in console markup use the following format: + +``` +"Click [@click='app.bell']Me[/]" +``` + +The `@click` tag introduces a click handler, which runs the `app.bell` action. + +Let's use markup links in the hello example so that the greeting becomes a link which updates the widget. + + +=== "hello05.py" + + ```python title="hello05.py" hl_lines="24-33" + --8<-- "docs/examples/guide/widgets/hello05.py" + ``` + +=== "hello05.css" + + ```sass title="hello05.css" + --8<-- "docs/examples/guide/widgets/hello05.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/hello05.py" press="_"} + ``` + +If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the `next_word` action which updates the next word. + + +## Rich renderables + +In previous examples we've set strings as content for Widgets. You can also use special objects called [renderables](https://rich.readthedocs.io/en/latest/protocol.html) for advanced visuals. You can use any renderable defined in [Rich](https://github.com/Textualize/rich) or third party libraries. + +Lets make a widget that uses a Rich table for its content. The following app is a solution to the classic [fizzbuzz](https://en.wikipedia.org/wiki/Fizz_buzz) problem often used to screen software engineers in job interviews. The problem is this: Count up from 1 to 100, when the number is divisible by 3, output "fizz"; when the number is divisible by 5, output "buzz"; and when the number is divisible by both 3 and 5 output "fizzbuzz". + +This app will "play" fizz buzz by displaying a table of the first 15 numbers and columns for fizz and buzz. + +=== "fizzbuzz01.py" + + ```python title="fizzbuzz01.py" hl_lines="18" + --8<-- "docs/examples/guide/widgets/fizzbuzz01.py" + ``` + +=== "fizzbuzz01.css" + + ```sass title="fizzbuzz01.css" hl_lines="32-35" + --8<-- "docs/examples/guide/widgets/fizzbuzz01.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/fizzbuzz01.py"} + ``` + +## Content size + +Textual will auto-detect the dimensions of the content area from rich renderables if width or height is set to `auto`. You can override auto dimensions by implementing [get_content_width()][textual.widget.Widget.get_content_width] or [get_content_height()][textual.widget.Widget.get_content_height]. + +Let's modify the default width for the fizzbuzz example. By default, the table will be just wide enough to fix the columns. Let's force it to be 50 characters wide. + + +=== "fizzbuzz02.py" + + ```python title="fizzbuzz02.py" hl_lines="10 21-23" + --8<-- "docs/examples/guide/widgets/fizzbuzz02.py" + ``` + +=== "fizzbuzz02.css" + + ```sass title="fizzbuzz02.css" + --8<-- "docs/examples/guide/widgets/fizzbuzz02.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/fizzbuzz02.py"} + ``` + +Note that we've added `expand=True` to tell the `Table` to expand beyond the optimal width, so that it fills the 50 characters returned by `get_content_width`. + + +## Compound widgets + +TODO: Explanation of compound widgets + +## Line API + +TODO: Explanation of line API diff --git a/docs/help.md b/docs/help.md new file mode 100644 index 000000000..6f2d86939 --- /dev/null +++ b/docs/help.md @@ -0,0 +1,20 @@ +--- +hide: + - navigation +--- + +# Help + +Here's where to go if you need help with Textual. + +## Bugs and feature requests + +Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [roadmap](./roadmap.md) first. + +## Forum + +Visit the [Textual forum](https://community.textualize.io/) for Textual (and Rich) discussions. + +## Discord Server + +For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr). diff --git a/docs/images/actions/format.excalidraw.svg b/docs/images/actions/format.excalidraw.svg new file mode 100644 index 000000000..b74fbad3f --- /dev/null +++ b/docs/images/actions/format.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVaW2/bNlx1MDAxNH7vr1xivIduQK3y8M5cdTAwMDLDkNuKtFl6SdJ0XHUwMDFkhkGV6Fi1LGmScluR/75DJbFkxXZsx8kyIXAsklx1MDAxMlx1MDAwZlx1MDAwZs/3nVx1MDAwYv392dpap7zIbOfVWseeXHUwMDA3flx1MDAxY4W5f9Z54dpPbV5EaYJdtLov0pM8qEb2yzIrXr18OfTzgS2z2Fx1MDAwZqx3XHUwMDFhXHUwMDE1J35cXJQnYZR6QTp8XHUwMDE5lXZY/OI+9/yh/TlLh2GZe/UkXVx1MDAxYkZlml/NZWM7tElZ4Nv/wPu1te/VZ0O63Fx1MDAwNqWfXHUwMDFjx7Z6oOqqXHUwMDA1VES0W/fSpFx1MDAxMlx1MDAxNoQm1ChQajRcIiq2cL7ShtjdQ5lt3eOaOlx1MDAxZr6c9T92//HXeSo/fzvc3Oz725/qaXtRXHUwMDFj75dcdTAwMTdxJVaR4mrqvqLM04E9isKy7+ZutY+eXG79om9cdTAwMWKP5enJcT+xhVs/XHUwMDE5taaZXHUwMDFmROWFe1x1MDAxMalbr5TQXHUwMDFjd+62iHBPUy5cdTAwMDSVhlx1MDAxOcpGne5xajyptDJGa8XAXGLgLcE201x1MDAxOHdcdTAwMDJcdTAwMDX7gVRXLdlXP1x1MDAxOFx1MDAxY6N4SViP6fmC4jyyXHUwMDFldXa9YGk80CC4oVxcSqJlPU/fRsf9XHUwMDEyh3DuXHRmiKT1jlx1MDAxNbbaXG5DgSlupFx1MDAxOXW4ibOdsLKKP9u67Pt5dq2yTiVgQ2h3u902qaZZNXY7se+2j3bPw3LvaHt3a7d7MVx1MDAxNFx1MDAwN2b0rjFcdTAwMWL08zw964x6Lq+/1aKdZKF/ZVcgJeeodC6YqC0vjpJcdTAwMDF2JidxXFy3pcGgNsWq9fLFXHUwMDEyXHUwMDE4XHUwMDAwwshUXHUwMDEwXHUwMDE4YJQoQ+dcdTAwMDfBodj66O++zz92L96dXHL6XyCM5ZenXHUwMDBlXHUwMDAypT3FXHRcdTAwMTdcdTAwMWGhQCVt2FhcdTAwMDVcdTAwMDPtYVx1MDAxYpVMXHUwMDEypcBokPeCXHUwMDAx0K9ay0kwoFxiOM6N4kpcYqNcdTAwMTUsglx1MDAwMmCEK6VcdTAwMDU8Mlxm9lx1MDAwNjD4evr69MPml/M9+lv5XHUwMDFl5M5fK4OBxMU+XHUwMDEyXGaAT4dcdTAwMDE3XHUwMDA0jVx1MDAwMlx1MDAwMOaGXHUwMDAx/7xcdTAwMGXs8+DtRry1l4b7mdVvksMnXHUwMDBlXHUwMDAzXFygp5HrNdHGLVaNo0ChM+CK4ntcdTAwMTRChZt7gYBcdTAwMDfS9sQkXHUwMDEwXHUwMDAwUE9cdTAwMDNjaOSaKYq+aSFcdTAwMThcYsm4RCHF48KgSzdcdTAwMDb7hkT5/plMdXFA5MHu69XBQKNXXFxcdTAwMTVcZkp7Xk5EXHUwMDAwct9UXHUwMDA0XHUwMDEwyVx1MDAxOFx1MDAxN1x1MDAwNOZ3XHUwMDA04dveutjaevO7POSDzUMt4r9cdTAwMGbTlVwioPVUXHUwMDEzXHUwMDAwsFx1MDAxNFx1MDAwMFDdnpTCXHUwMDAwRdNcdTAwMTOKyjFcdTAwMDCAXHUwMDE2XHUwMDFlQ3rmoFxiekUj7+dcdTAwMDZmIMBMYP7bpq4lcIrRXHUwMDE5LG7phbt5knFcdTAwMGYzyixC+LU9pUm5XHUwMDFm/WOrmHas9Vd/XHUwMDE4xVx1MDAxN2NGUUFcdTAwMDBcdTAwMDV8l5Vo5H68lmCqUaCl2OaeXHUwMDE1XHUwMDE256+sX489uVx1MDAxZUfHXHUwMDBlMJ3Y9saRVEaYpYy6y7Sh41x1MDAwMCXx8XX5TtheUZpHx1x1MDAxMUpxMF2qpVx1MDAwMI1x+3Q8XHUwMDBizkBqMT+eTVx1MDAxMoP/Nt/RKflwXlx1MDAxY+yGcL71+mnjmVx04UmmXGIlwNBXqPHsXHUwMDA2uPKAMkFcZsewj90zqpvl0Golz4AzQ1JBbmH14EeB88PGb4rrhlN5aDivXHUwMDA3XHUwMDBlOFx1MDAxNWxcdTAwMTbCcYCKsvlcdTAwMDMguSnQUlx1MDAxMNbCtFtHXHUwMDEwXHUwMDA2iVlcdTAwMDJIPn9QSvzuMP37nLPy2+5OfiZcdTAwMDby8Nvm04Ywl8Zj2lx1MDAwNXVcdTAwMWHjTpef3fLJklx1MDAxOFx1MDAwNDFaXHUwMDFjqGZcdTAwMDSzWlx1MDAxMCOFzFx1MDAwM2JcdTAwMDOCXG7M3Fx1MDAxZdknP2z0+Vx1MDAxZvnkzM9cdTAwMTE3XGLM4mmAeZJgM0F9pepJkTafXHUwMDFlaWP2olx1MDAxNFVcctzfherZXHUwMDAx2Vx1MDAwMqhuY2dZVKs76y1cZlFcdTAwMGKMYqLJpFa6sZWVTSjmgeKCSiUx4aQzXHUwMDAy7Vx1MDAxMH036S1cdTAwMGJq5lx0TlxmwYlcYlx1MDAwN8x4xVx1MDAwNEetjUdccnZLKVx0XHUwMDEx0IghriFPMSXmmKku4bfvSjhXWSBsyOHn5UaUhFFyjJ01m9xcdTAwMTTTd+ZI39wq/axKXHUwMDFhcatQPdwwzbVq9PfS4MStoks86spdUlx1MDAxMalQlVxmdXk96nIklE3Cu0WaXV9cdTAwMWaJZDCHk5QrhZsloHaP4zJcdTAwMTFqXHUwMDE44J/AUNhVttktmWK/KDfT4TAqUfPv0ygp21x1MDAxYa5Uue5Q3rf+Lf7ANTX72nSQuTeOM3v9ba1GTHUz+v7ni4mjp1uyu7q3jLh+37Pm/8WZzGjWbr5hMlxumJ1yVPP8OcbsYPQpUlx1MDAxOTPGXHUwMDAzV1x1MDAxYmGaUIG23YpPmPKEQtunklx1MDAxM4lmJlqC1TQlXHUwMDAyw0m4dHziUUE4ZnWSMCMwXHUwMDAwmVA1XHUwMDEzzNNcdTAwMThHoaTKXGKuTENJV1SmMCNkgKB5XFwqWzpJmJPKZmeuXHLeQO05h8RBacxcdTAwMTh5Y0STzKRcdTAwMTGoYoJcdTAwMDRCXHUwMDE1Q+e0XHUwMDFjmc0+J6n5lXhCXHUwMDEzQVx1MDAxY98z9DL1vrboXGZcdTAwMDdhmFx1MDAwNoxJVCdy3/+azqZbs7u6t1xmeVV0hvQp280jOmNcZlWMucjcbDY7Kv9cdTAwMGbYTN95XHUwMDAy4Fx1MDAwZWJcdEZeLrdslz+V9ihSXHUwMDE51UZcdTAwMDOlzeJSm8qY5L1ALUtlxFx1MDAwM4NuTDrPjJmf1mzCcbBhXHUwMDFlikmFoe40gjeLN9dcdTAwMDdcdTAwMDGG4FuU4Fx1MDAwZnBcdTAwMTCwylL9omQ2O4dcdTAwMWbxhvJcdTAwMThzkSlobjjlnDVGNGmDaIyQjNCIMkOlXHUwMDA2s1x1MDAxY5vNPu5qRovS7arE0Fx1MDAxZThhdIpUKFx1MDAxMVx1MDAwM7fxXHUwMDFhjVFh9v+/ZrNcdTAwMTlcdTAwMDbtru4tW16QzqZcdTAwMTWP2PRcdTAwMTNNzShcdTAwMTdEwvxkZuS23GbBh4NPnzc+XHUwMDFkvTmKN/OMTCGzvlx1MDAxZvRPcjuNzlZVPTJ35pmAcbGghGFcdTAwMTiKITEh4+f6XGY8XHUwMDA21J1rOVx1MDAwZqu0udfPW8rcT4rMz1x1MDAxMVx1MDAxM7c5jcOE8lGD125Ii1OqMVA0S5DW0z3TXHUwMDExtFlcXF+yfqTHWkf1ozr7uKlcdTAwMWb5WeZcdTAwMTW2/Kveolx1MDAxZp/nNnz+08QqUuOXLY9xtDNduGc32qw06Vx1MDAwNu6XqMdcdTAwMTHnoiFE4bUy6ik6p5E925j0W6vqcm+tWMPh0zoz+H757PJfPFx1MDAxMrdyIn0= + + + + Optional namespaceAction nameOptional parametersapp.set_background('red') \ No newline at end of file diff --git a/docs/images/animation/animation.excalidraw.svg b/docs/images/animation/animation.excalidraw.svg new file mode 100644 index 000000000..da9a23f25 --- /dev/null +++ b/docs/images/animation/animation.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1XWVPjRlx1MDAxMH7nV1DeVyzmPrYqlYJcdTAwMDVcdTAwMTJcdTAwMDJLXHUwMDBlczib2ofBXHUwMDFh21x1MDAxM8uSkMZcdTAwMDaW4r+nJVx1MDAxM48vwrnUblX0YGumZ3q+6e6vu3Wztr7e8Ne5bbxfb9irjklcXFxcmMvGRjU/tkXpslx1MDAxNESkXHUwMDFll9mo6NQr+97n5fvNzaEpXHUwMDA21ueJ6dho7MqRSUo/il1cdTAwMTZ1suGm83ZY/lj9XHUwMDFlmaH9Ic+GsS+icEjTxs5nxeQsm9ihTX1cdNr/gvH6+k39O4POXHUwMDE0RTZcdTAwMDFWT1x1MDAwN3BKL05cdTAwMWVlaY1cdTAwMTMjrjSjWuDpXG5X7sBR3sYg7lx1MDAwMlxcXHUwMDFiJNVU42Tvl7TZ/nX76rh1fDDO+4j+dNVcdKd2XZK0/HUysYLp9EeFXHLS0lx1MDAxN9nAnrnY9/812sz8dF9syj5cdTAwMDCYiots1OuntqwuXHUwMDFmkGa56Th/XV9cdTAwMDNNZ03aq5WEmStcdTAwMThRwSMqXHUwMDE0XHUwMDE3XGZRQZhUU2m1n2lcdTAwMWNxXHUwMDAxcsyRRIxcdTAwMTK5gOxDloBcdTAwMWZcdTAwMDDZO1Q/XHUwMDAx2rnpXGZ6gC+Np2t8YdIyN1x1MDAwNXgrrLu8uzOONGZIXHUwMDEwPpX0rev1fWVcdTAwMGXCXCIlg6C0tVx1MDAxM7DCmiuhUZBUXHUwMDA35vtxXHUwMDFkXHUwMDBin1x1MDAxN43YN0V+Z6tGXHJsXHUwMDA2bDXcnVx0pLB5lMdm4nQsXHUwMDA0x1xcXHUwMDEzWtlqKk9cXDpcdTAwMDBhOkqSMJd1XHUwMDA2K+Kk9Kbw2y6NXdpb3GLT+Fx1MDAxZUliSv8hXHUwMDFiXHUwMDBlnVx1MDAwN1x1MDAxOL9lLvWLK2q9W1WQ962JV2ielS2yIa80XHUwMDA28lRPeFtcdTAwMGbRUlx1MDAwZqbvnzdWrm4uebGevXNg2L02+3+78UTa4pkwXFzkraCaIaxkYPZDvKUnh1x1MDAwN7y9d/7n3uFheaJHgra3+t9cdTAwMDFvRUQgXHUwMDE2pdRaXHUwMDAwbdlcdTAwMTJvMaFIYES1XHUwMDE0mi9cdTAwMDB7PdpShFwiKSmjclx1MDAwNXFxJCFtUEyWuEs5Ykpr9sbUhdAgwY7/U3dmwVxuR1ZPM/jwifRcdTAwMDXr2pXsRfL+skuYpkpjXHUwMDFj8utD9G3r38nu2cfty7//6F1cZoa7R1x1MDAwM3aQvDJ9y1xm+o7Xr7pUXHUwMDAwNSWjeJagNX2VjJCQXFxTRojmSC9cdTAwMDBcdTAwMGL0VYJY3XlcdH1pJKSCUsqpRFqH8lx1MDAxZqovXHUwMDAyMFx1MDAwNHMhJWGKXHUwMDEyvURmgpnAXHUwMDAyIf6mbFZcbmCrXHUwMDAw+Htj85zsVamMMYso4lpqSoRcdTAwMTYqNLHV06QogsZcdTAwMTZxJjWD5lx0yVx1MDAwN/VpXHUwMDEyKVwiJHTDoJJzzOb0YYVcIkisWFIou1x1MDAxMlx1MDAwYvVwqrkn6Gp1y/H2xMTj7ZVflXjEfVmnSjpcdTAwMDRcbkPoKlx1MDAxZUo6O+3Wyadd0vz482D3tDX+1L44z/Hzklx1MDAwZV5kx1dLOuDsiDBcdTAwMDZdPuJcdTAwMDRcbvB8q89cdHiRay5cdTAwMTTl0PD/R855acvAXHUwMDAyrJBklrt7wYggirFw2UcnlbJcdTAwMWE8M6lQKlmIlCcklW6W+pb7Ulx1MDAwN1x1MDAxNJqb3TNDl1xcz/mtjtHKUG5oZy1Z2rpsVlx1MDAxZjdza7dcdTAwMTLXS+uqarvzwe1cdTAwMWR8XHUwMDE1T8U+m7l3XHUwMDA3zjagrthfyjdZ4XouNcnxLI5nsYrcW8zhq1x1MDAxMVPIXG6Pp9Vps9y/lGdcdTAwMTe90+ukvfOlO+52bfNbp1x1MDAxNYH0x1x1MDAxNaZcXCqFkObznTiQKVx1MDAxMlxcK2jEXHUwMDE5NFLQkn81XnHyKF5BktaKMvyMYv1cdTAwMTJesSrLvlx1MDAxOa/GJlx1MDAxOX1cdTAwMTPEmlx1MDAwMJkwa+2uXCI2TJ63PNhcdTAwMDdcdTAwMTZMeFx1MDAwNi5w8d0lg7rG2NnL7eUoeNetn0przdaKXHUwMDE4tnLAze3a7T8uXFzYXHUwMDFjIn0= + + + + timevalue \ No newline at end of file diff --git a/docs/images/child_combinator.excalidraw.svg b/docs/images/child_combinator.excalidraw.svg new file mode 100644 index 000000000..47e3b0d8e --- /dev/null +++ b/docs/images/child_combinator.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPayFx1MDAxNv2eX+HyfMmrXHUwMDFhNL0vUzX1ylx1MDAxYl6It1x1MDAxOMdxXqamXHUwMDA0yFi2QFjIYDw1//3dxlx1MDAxOFx0kFx1MDAwNDiIQCaaqlx1MDAxOCTRurp9l3P6dvf8/W5jYzPstZzN3zc2naeq7bm1wO5u/mrOd5yg7fpNuET639v+Y1Dt33lcdTAwMWKGrfbvv/3WsIN7J2x5dtWxOm770fba4WPN9a2q3/jNXHKdRvu/5t9cdTAwMTO74fzR8lx1MDAxYrUwsKKHXHUwMDE0nJpcdTAwMWL6wcuzXHUwMDFjz2k4zbBccq3/XHUwMDBmvm9s/N3/NyZd4FRDu1n3nP5cdTAwMGb6l2JcdTAwMDJcdTAwMTLKx0+f+M2+tJgxxKhiWFx1MDAwZe9w27vwwNCpweVcdTAwMWJcdTAwMTDaia6YU5unT1x1MDAwZr3CcfehXFyq1iv+x6B81Ph4XHUwMDE1PffG9byLsOe96MKu3j5cdTAwMDYxqdph4N87V24tvDVPXHUwMDFmOz/8XdtcdTAwMDc1RL9cbvzH+m3TabdHfuO37KpcdTAwMWL24Fx1MDAxY0fDky9a+H0jOvNkblDIYkQxoZSgSpPhRfNrirhFJOiAIcSU0HxMqlx1MDAxZN+Dnlx1MDAwMKl+Qf0jkqtiV+/rIFxcsza8J1xm7Ga7ZVx1MDAwN9Bf0X3d1/dcdTAwMTVieO7Wceu3IZxUKnqe09e6VlJcdTAwMTKCWNQn5iGtw1rfXHUwMDAy/lx1MDAxY1farVx1MDAxZLRcdTAwMDbK2WybLzFcdTAwMDGNbHvj5lx1MDAxMzehWMdcdTAwMWU3gkcquttb+1x1MDAwNz1153/aqYf+zrCtXHUwMDExe1x1MDAwYp2ncHN44Z9fs5ptfLkslfdvtjvh5aej7nn3+bjeqiQ3a1x1MDAwN4HfnbXdq6NPne5cdTAwMDe2e/wh4LX9XHUwMDFleSRsVyygXXJVOzwo3leP1Vx1MDAxNsPlhne61/xSX0C7Oal3vZr98Lh7gPTnw+enXHUwMDFiVSpcdTAwMTZrT3/Zne5P5S6m2eYneVx1MDAxM1xcf9zzd0J9dl1raX5Fb1ZXucGjLu70xNlflza9Pqtf1vjV0dk3iTtcIsWvs75I1OzgU1x1MDAxNGFcdTAwMWZbNfslXHUwMDEzQuiG5KBcdTAwMDX8p/Twuuc27+Fi89HzonN+9T5Knu9i8k6k7Vx1MDAxMTlHMjZT46eHXHUwMDE5XHUwMDFicoMkSNCZXHUwMDEzdnb3rWrC5lx1MDAxOVx0myBLLidh84SEzaO8PEjYkKtcdTAwMDVcdTAwMTOYxSxjYVx1MDAxOXuRxlx1MDAxOPW531xmL9znvj2JkbNFu+F6vZFu61spSLpcdTAwMDOXbbfpXHUwMDA07782zVx1MDAwNbf2x9fNmmt7fv3r5tfmf+JqbjsgjWmek5F2tjy3bix803NuRk0/dFx1MDAwMftcdTAwMGUvN9xaLY5mq6/PPpxcdTAwMDWD+oFbd5u2V55V8kwvzVx1MDAwNtec41RX5eDHWjOGZ/bVx+1tzfZ6qHhyt9XcVzLsnlx1MDAwNEer7qtCWkQhRDGd9FUmqUVcdTAwMTlTXHUwMDA0XHUwMDEx3vfVXHUwMDFjnVWjSWdVYtxZmWZcdTAwMTBBOc7BV7Oy3e5D8S44Oz54OC/uljpN++7y41x1MDAxN/cnul5cdTAwMTS6zkm969VsXuh6vbRQXHUwMDBlbne7lY5TXHUwMDBl6ZGHiXq+P299+NdpIS8ycNCz5e1e62z3ktxXXHUwMDFmcIufXHUwMDFmXHUwMDFl11x1MDAxNtBuVVx1MDAxY5/xc9ooXHUwMDFl1IJS4aSwfVK8JavYa9NIRvJcdTAwMDOjZlx1MDAwN5++P8ngUoyfXHUwMDFlXCJcdTAwMTdcIjHVUszBMrL1vKLIRZJcZuSipKWWhFxcVFx1MDAwMnKZpFx1MDAxOVpSjaj6sVnGXHUwMDAxgPdng9e99+b8XHUwMDBiYK96drvttFx1MDAwMbVXXHUwMDFlw9BvtpdNOKbg8nHCMc9LZDpvNvdQKHVgXzGwXHUwMDEzhYme2YErXb3fKNRDXSn0XHUwMDBlnXapt394WllxXHUwMDA3ZmZcXJ9LKbg0fkFGPVhcYmJhLFx1MDAxOcKcI+M835t6SI6QknHGuFx1MDAxNOqx5fTY/vmhf1cv71x1MDAxNLYudz63y92Ugbaf1GP+dnNS73o1m1x1MDAxN/VYLy3kRT3WS1x1MDAwYp7e3lGV/Vx1MDAxZLYjhHNRLj3Vi8VcdTAwMTW2hbyYx8LFncY8klx1MDAxZlx1MDAxODU7+PT9mYfSLJV5IFwiIFczOfuYabaeV1x1MDAxNbjwLOCiuSWWXHUwMDAzXFySmEdsaPSVeSBcZlxmXGKxXHUwMDFjgMtcdTAwMTRrjFx1MDAwMau8mcd2XHUwMDFmlb//unntXHUwMDAwNE9mXHUwMDE3mI38bEhcdTAwMWaq8DpO8HZ+MVx1MDAwNXyP84txUTPdMJtDQNem+lwiVlxuXHUwMDBijsXsJOLx+aDTPH1+vmS03PN4VTS/PFx1MDAxN1fcXHUwMDE3wcgspFx1MDAxNXDrXHUwMDE3X1x1MDAxY+NcdTAwMTDYklpcIqXRanBcYkqxXHUwMDE2XFzFRiOWwiGuXHUwMDBlXHUwMDBmzz5cXD8/bFx1MDAxZlx1MDAxNC78QtdcdTAwMGWfPznHPznEojhETupdr2bz4lx1MDAxMOulhbw4xHppIS9cdTAwMGWxXlrIq9iycHGnUZPkXHUwMDA3Rs1cdTAwMGU+LVx1MDAxMlxmZmKiNGpCXHUwMDExkuOnI2pcIjlSfJ6iSLaeV1x1MDAxM1x1MDAwZUnEMuBcdTAwMTAwXHUwMDEzslx1MDAxYzg0XHUwMDFiM8GaKVx1MDAwMmLKXHUwMDFjJkuvIDU58ZfNTKYg+lRm8lwiaaZcdTAwMTO+hKxcdTAwMDQvlFx1MDAwNKU6IcdSKCb57E6YXVx0X00nXHUwMDA0hG9cdTAwMTFwQLAyjlx1MDAxMJd8xFx1MDAwYlx1MDAxOcKWkERx+MC0XCKxQu6i3Vx1MDAxMFlAOFx1MDAxMMZcXEumheI0NnQzdEuhISgwXCKgZ1x1MDAwNHiimvRSjLjCwHDo/F7aXHUwMDE3dtle2lx1MDAwZe0g3HabNbdZh4tRrntdjDPLPMS+X1dcdTAwMWbbfTVcIlx1MDAwMb1IJSeIKOgyLWN31e2W4XpcdTAwMTYolzGEjL4pkXhwwzDnbjrN2nSZskuVMZlcbiBcdTAwMTSViMBcdTAwMTNcdMdA9LlSfEIqYlEphVx1MDAwNsJJNIJAKyak8ux2uOM3XHUwMDFhblxiyj/z3WY4ruS+NreMt986dm38KrxV/Np4WGiZXHUwMDE2R/lp9Gkj8pv+l+HnP39NvjvdnM0xYchRe+/if+eOaJjg1MlcdTAwMTbgXHUwMDE1nDGsZ1x1MDAxZvHMhoUrXHUwMDFh0SS2OIGghSRYN4uiSP/XXHUwMDEyWVx1MDAxYZCVMkNcdTAwMWKaxIZDXHUwMDE3XHUwMDBlKzC1qFCKQWDlbGTSRzTogiymXHUwMDAwXHUwMDAyMZCTgj3EJn5cZuq4mijMwWt+rGg2c+RcdTAwMDD9UI6FMj5EXHUwMDA0pFx1MDAxZlx1MDAxNvOiQdzAkKKwXHUwMDE5NWaIIU5cdTAwMDR7YzTLhFx1MDAxZqMyXHUwMDExqjX4rEJIKFxuXHUwMDFkPSlcdTAwMTO4P9dcZnPCzVxuO1x1MDAxMHytg1m6LZtjwopcdTAwMTdcdTAwMTbLIGWkwjOALIRcbj7HvJPs8tuKXHUwMDA2M8aBIyHMTWKkMjZA/lx1MDAxMs2IxbAwI7VcXFx1MDAwYinHxVpcXDTTXHUwMDFh+phA0NRcdTAwMWNDOoutXHUwMDFhioJcdTAwMTmz4CqVcFx1MDAxZsZE0IlZZVx1MDAxOEJcdTAwMTlcdTAwMDNcdTAwMGbG/9ZoVjCggHFMXHUwMDAwalx1MDAxM1xiaYyihHBGLUhcXFx1MDAwMJOAXHUwMDFlQ1xu44K+LZ5lXHUwMDE3ekal4lx1MDAxYfA/1lx1MDAxMlx1MDAxM4hqmuBcdKG4XHUwMDA1uFx1MDAxYVwirJTIyDUp0jpFs0KqMZtjwoznjGaZRTBcdTAwMTnjJWNcdTAwMDGNaCyQmme1nfZKl6Wb+6fS9dVFrfaJ2Fx1MDAxN1x1MDAxZj6ueDhjZlx1MDAwNY8wuYJAzkYyXG4j/eFcYlx1MDAwMbpcdTAwMDeEKlx1MDAwMbVpXHUwMDA0d+RcdTAwMDfOYlWtKICBbPBoKidgXHUwMDE4QGZcdLQknmyWUlx1MDAwYrvrlu9cXO+ycdTuak4qXi0sep++fbD3+Pzw+br49DlcZnadwu1F+7MkbFx1MDAxMTP2161cdTAwMTaWk3pzalZ23IPiw1x1MDAxZW5cdTAwMDR7f9V2w1x1MDAwZemceovQXHUwMDAyQ0FcdTAwMGI3O9WTq2LpRF3tXvnHYWl1tVu5a2ydeVx1MDAxZIF5WNiu6XrpxH/qra64i197PlDD58NcdTAwMTLx2lx1MDAxN6p1etxmndZt6ZBfflO70yoryVxuipp9zY5cdTAwMGJEYpmJNq2yXHUwMDAySDiVNCDgYkQxOnuWzTaLXHUwMDE1zbJmtUl6ltXCgoSGXHUwMDAx5+mcsyxLyLIk0v1rdqVUamqw1uKza96VlVj9YEpl5aJcdTAwMWE4TvN9Sk1Fjty/sJrKXHUwMDE0jDheU1x1MDAxOcqY6XjpfF2lbyZBgD4gTdjs2z9lR87V9DzOsVx1MDAwNWRcdTAwMWNcXItcdTAwMTGqXGJcdTAwMWatpsB3S0klluB5XHUwMDE4XHUwMDAzi1SUaOCZSopYrXnoiIybMlx1MDAwMFx1MDAwN8ebwLtcdTAwMDQhRpjW4lxyeHeVmXq2O2zEx/i4llx1MDAwMuJcdTAwMTJcdTAwMDeWLijhMYo4oMTMQlx1MDAxY1RcZtpcdTAwMWJcXJmTn89R0Vx1MDAxMVx1MDAxOFx0XHUwMDA19JxhXHSBMqGgXHUwMDAzsiCgsohzbpKb1lx1MDAxM1wirVx1MDAxMz9PN15zxMw2auhd/O/bpqfSdGpcdTAwMGVsUYE7izm217huPV2497RyfcnK4fn+XHUwMDE26dyffl7x4EWJtKggXHUwMDFjSHpcdTAwMDJsMJvXYWVqwfqFnH/33eugQ1x1MDAxOFn+XCK34z1WPFdq9+amVun2yrf+VlhOISA/J6jO325O6l2vZnPbvW6ttJBTs7ntXpePuHmNIOQk7nn9oX7uXFw9XHUwMDEwLb94lyeNtt1yUsZRXHUwMDE2tdle4otEzb6Cg+89MEEoSV1cdTAwMDFDMCFCKI5nh1x1MDAxONn9t6JcdTAwMTCDkiyIQVx1MDAwMOsuXHRizLbfXHUwMDFlxpIjSlx1MDAwMX2v39DEN+6313ZrTsVcdTAwMGWWvf/FXHUwMDE04DzThnsjome66pRcdTAwMTVr6ZtjMsaAiDE++3DG9lHTx1xi6JdDjypP5Vx1MDAxMr7URSfFXcfcbtRZx6csvdVZMZrqrVhbXGZpRvCLt45OPmCCWIJcbo6Y+PbRjF8wqSgllEpwVZkweJGw5Vx1MDAwNSZcdTAwMTKx+Fx1MDAwNNalsIHzvaND9CRLN6dXLr66PdNfetf7P9nAothATupdr2bzYlx1MDAwM+ulhbyWq62XXHUwMDE28lqulpO4eW15sV6dtnxOlPxcIjOKe1rd3T65bqn60Ze7Y7vje0/bjM0m7uDTd+daXHUwMDE00dTldUT3t9mLIYVp2C3bLL5cdTAwMGLVmlx1MDAwMb1cdTAwMTGWhd6ktPCi0NuU0dxcdTAwMDT8lrC1uWRMXHUwMDAzXHUwMDA0zFx1MDAwMb+tXHUwMDBl01x1MDAxYSxaM+de+MrXzcNmO7Q9b9k8a1xuXHUwMDFkSVltlyp4ppOmXHUwMDE3jFx1MDAxOUl1Ulx1MDAwNCleUDVcdTAwMDfDyp5cdTAwMTKzmlx1MDAwM1winHFLMCp14oBcYuHCMsMgS3BSxixJiOCCJMzZYNriQjAkJkvFVCBwJPKWXUJ+iEpxwVxmWWlcIoSmXHUwMDEwZzWmOKk8yyjlmsoppeJR+deoYltIMlx1MDAxZnPEXGYnauNd/O/8MYOn1mk55cpUjGfP65m4bDUjXHUwMDA2w8Si4OuvQ6hcdCt2XHUwMDExNsaY91x1MDAxY1x1MDAxM2RcdTAwMTFuZvQogYmAXGJcdTAwMTCb2Fx1MDAxNa3XNZNcdTAwMTckVlx0U6qppGZcdTAwMDe2t+T7VVx1MDAwZVx1MDAxY9njl6OBXHUwMDAzXHUwMDExJFx1MDAxMWdUSCRcdTAwMTiOZlx1MDAxN1xmXHUwMDAzh7RcdTAwMTQliGIzXHUwMDE3XHUwMDA3joR1XHUwMDE3M001yc71XHUwMDFiI4uHQVx1MDAxY7PGiygzUYlINLlOd3K18DqFqnSrNUdkr3OGq3RcdTAwMWWSselcdTAwMTnHRFx1MDAxMqlm5yFS2yd35O7sYLdXrX/0xd72WUWkxKtcdTAwMTVcdTAwMTlDJlx1MDAxNFx1MDAwMI7Go9jhZb8z9mrd31TmyVx1MDAxODkmSdPeJiFcZlx1MDAwNtJcdTAwMDH2IN8y2+1bho5Xi2YnXHUwMDExm9lnt142a05gXGLLRnjrtjde9lx1MDAxZE+e6qpGfjz7VNfQb6WxmZHXXHUwMDFhpy7Jor1ccnxA0EhcdTAwMDVcdTAwMWbIJGQ5x+L67P5fSW+mWFlcdTAwMTC2zNbiXHUwMDE0Y6FGx1x1MDAxNFx1MDAwNMA9zNToXG6qMY92hJRv92hNLU0pXHUwMDE1ZjNcdTAwMWUmaGxCXnxcdTAwMTVX4lx1MDAxYS6zmo/QkZLdXHUwMDBmXHUwMDAxOLJzwijggNxcdTAwMGXAXHUwMDExXHUwMDExXGKMWkNATli4XHUwMDBl0VrNwlW+XHUwMDE5a1x1MDAxOHGkJmbSqlZcdTAwMDSeiVTiYliwMqzM/zvDbFx1MDAwMrXe2CPVes1ReDXcNOTxbtDupt1qXYRgZcNeXHUwMDAwQ3Zrg5BcdTAwMWS93GbHdbrbXHTuddM/TFx1MDAwNOyr0Vx1MDAwNFx1MDAxYce84t//vPvn/yxzRNUifQ== + + + + Container( id="dialog")Horizontal( classes="buttons")Button("Yes")Button("No")Screen()Container( id="sidebar")Button( "Install")Underline this button \ No newline at end of file diff --git a/docs/images/css_stopwatch.excalidraw.svg b/docs/images/css_stopwatch.excalidraw.svg new file mode 100644 index 000000000..931702ccf --- /dev/null +++ b/docs/images/css_stopwatch.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVaa1PiSFx1MDAxNP3ur7CYr5rp98OqrS1fOO4oXCLq6ri1ZYWkIZFAMFx0XHUwMDAzOOV/305wJYFEXHUwMDEwXHUwMDExXHUwMDE5Synp7vS96b6nz+nb/Wtjc7NcdTAwMTRccruqtLNZUlx1MDAwM8v0XFw7MPulrbj8p1xuQtfv6CqUfFx1MDAwZv1eYCUtnSjqhjtfv7bNoKWirmdayvjphj3TXHUwMDBio57t+oblt7+6kWqHf8afXHUwMDE1s63+6PptO1xujLGRbWW7kVx1MDAxZoxsKU+1VSdcbnXv/+jvm5u/ks+Ud4GyXCKz0/RU8kBSNXZcdTAwMTBTNlla8TuJs1x1MDAxMENcdTAwMDExxlx1MDAwMr20cMNcdTAwMDNtL1K2rm5on9W4Ji4qoW23o/reoSjzk6FPh+JQXHUwMDBl62OzXHLX8y6ioZe4XHUwMDE1+vptxnVhXHUwMDE0+C117dqRXHUwMDEz254oL3oq8HtNp6PCMPOM3zUtN1x1MDAxYcZlXHUwMDAwvJSOxmBnc1xcMohniFx1MDAwMlx1MDAwM0GJ9GtcbojoS138NELSoIhiXHRYqmbk0b7v6SnQXHUwMDFlfVx1MDAwMcnP2Ke6abWa2rGOPW5DgFx1MDAwNShcdTAwMWa36T+/J2XIwJJQnDbtKLfpRCPvXHJcdTAwMGVcdTAwMDVP2VbJ2ENcdTAwMDRcdTAwMDRHXHUwMDEwkfHUxFx1MDAxNrvHdlx1MDAxMlx1MDAwN/9Ojp5jXHUwMDA23edRKoXxl5S3saOHk0GUXHUwMDBlpNT87pFcdTAwMTCE/UFj96T5sCtxXHUwMDEz/+Wdf3/pK1x1MDAxM3VmXHUwMDEw+P3SS83T839j13pd21x1MDAxY0VcdTAwMTJkXGZxyKCk+u+l3nM7LV3Z6XneuMy3WuPgS0qftlx1MDAxNoh6wkFh1EtcdTAwMGUklZSLuaM+qNTR7vZcdL4/sitn1UrtR3hb21vzqGfAkFx1MDAwMFx0jFx1MDAwMJ6KekJcclx1MDAwMlx1MDAxOYD0vVHfMDV60HTU686ng52xqSiHUlxuXHUwMDAyQGqyVlx1MDAxMuWHu4d30VEzVJWWffvQOFx1MDAwNPBuu5dcdTAwMWblkVx1MDAxYUSpIN/K7zbTemteg5+HnYyfXHUwMDE5siBFsEFcYnKs10syN2peXHUwMDFm5SxqXHUwMDFj03J6gVpcdTAwMDfc0GLcMGbIZeAmXG7MTtg1XHUwMDAzXHUwMDFkqznYoTnYQXhcbjtSYqqdoWL52FlmXHUwMDFjjufb70RcdTAwMTfuY1x1MDAxMksgU1o22643zExZXHUwMDEyoNrTi8hPO2qGSltMVnGeabvruc04gEuWflx1MDAwN1x1MDAxNWRiO3K1rnpp0HZtO81cdTAwMTmWdsDUfVx1MDAwNsfzLPV+4Dbdjuldpv1bnKZQ6jUmaUpALUpcdTAwMDCZX5v1XHUwMDBlr1x1MDAwM+fS/SZJvyo6dXl601x1MDAxMt/Wm6VcYqVcdTAwMDaEnHHNxlNoo9JcdTAwMDBcdTAwMTJyyTL6aFx1MDAxMZZKfvKQhlxySIq0XHUwMDE51lx1MDAwYoEgOEebMY6JXlxiXHRbPvJeY63WXfPQbLdcdTAwMWQk78hjXHUwMDE5n4fSuyrQZsthrXyD68dakMhCXHUwMDE0SUxcdTAwMDSjLKUwZsHo9WFeU9pcIlruXHUwMDE1XHUwMDAySVNcdTAwMWFcdTAwMTV4MpiXTVtcdTAwMDRPY2iatqhcdTAwMDZcdTAwMWRcdTAwMTP4I7CzPqxcdTAwMDXATvJrZFx1MDAwN/TDyWtcdTAwMDZcdTAwMDNMklfazcU5jMvirVx1MDAxNlx1MDAxMZzogYfzi0bYP6uehFF9t7ZrXHUwMDFmPJRZ11xu67X1JjGGpcFFNoVcdTAwMTA/uc1cclx1MDAxZOlUvlx1MDAxM3VfLGVcdTAwMTPbnHeTJSZcdTAwMTHHXHUwMDA0xVxcolXvsS49//6x45fvavVqZ7/Svq5W9uvvYavfq9tZ3Jpv8Fxy3Eo5kzKlXHUwMDBlP4hbXHUwMDE5K1aoVCAuxVx1MDAxYsD9+iivKbUyQnLhXHKJISAj781cdTAwMWMuZy9IuVx1MDAxNrJcdTAwMWN/XHUwMDAwxpdcdTAwMTmB72PVmlxuVbRSPp1BRpN8OnJwcSalQFx1MDAxNGGNx0hcdTAwMTNcZr9cdTAwMDFrXHUwMDAz+O3+7j4kofqhRexpMPjR3f5MXCLFcyXqqeSMTjEpgsYyXHUwMDA0bCGVUsZcZs71ti5tIJOlXHUwMDE3dFwi6/N/nlx1MDAxZSMtcEBqXHUwMDExXFyJno1XXHUwMDFl+Fx1MDAwNuQtXHUwMDFllIyjoqDEXHUwMDE4MoTwXHUwMDFiUlx1MDAxNOft87NK1ynT/burRlx1MDAxN3lcdTAwMTXsW0frre6SRDqmlGTSXHUwMDEwSVTG26qJNOEyj47mTaJTXHUwMDBl9NKPwYrTXHUwMDExe41WebD7/Vie3J62L09cdTAwMDfgxqyU36/EfpduZ1x0vHyD6yfwaHHKX6+4klxuXCLnz528PsprKvCSlH8uwqGuWEJcdTAwMDJyXHUwMDE5XHUwMDEyXHUwMDBmaX2nXaFopYmTVUu8i8hcZlYr8WYw0nS+P3ZwcTYlpJBNIcNcdTAwMTJLXHUwMDFkb/NrvL9r29fSqdVOq4KU/dtcdTAwMDPn4LzZ/FxcuPG5Uv5cdTAwMTTg6VxylTCYpO9l01x1MDAxOVhbLOFPmdB2Kf6Ao7bXOOvCeri+8/f/dlx1MDAwNvXHq+F5w2pcdTAwMGWCo/dT4e/S7SyGzTe4flxmi3nhXHIsxiiIz3Hnh/zro7ymkI9cdTAwMGYnciGvXHRcdTAwMTZ+KLnOdyghpN5eM/BcdTAwMTH4Xlx1MDAxZm79rEOJXHUwMDE5JLXwoURcdTAwMTHcXGIovsNCMJGI0vlcdTAwMDXtzZk68Fx1MDAxYex+31x1MDAxOYKa1+Ku6Fx1MDAwNnTdXHUwMDA1LYlcdTAwMTMpOVlcdTAwMTQujUmVu3TAXHUwMDAxlFx1MDAwM7hpRiWAUckkXm3aXHUwMDA0QcRTm+pVXFxe6ZuR5eTjTeTjzVON6Fx1MDAxNbRlLsRkoZZ5kbybKiNnXHUwMDE2Qlx1MDAxNS1GXHUwMDE1RHqXKKVEb4BVs/5Ar1x1MDAxYSdcdTAwMDdu9fjspkrCI+ZcdTAwMDRrXHUwMDBmK6hcdTAwMTUqZtn7usmjnFx1MDAxOIxcIvyx5+tcdTAwMTLOhSwmXHUwMDEwkECI1e1cdTAwMTOJ5jLGqcSrRJbehil7czGEzWS0xTE26dZcYmtcdTAwMWLPKrRkdru6TVx1MDAxNDs3Qp6eXHUwMDFj135+/XHXpZ+u6u9cdTAwMTXfZNp4xm9cZlx1MDAxNFx1MDAxNU/Nr6eNp/9cdTAwMDDD6SGzIn0= + + + + Stop00:00:00.00ResetStart00:00:00.00StopwatchStarted Stopwatch \ 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..1f5a7ce8d --- /dev/null +++ b/docs/images/descendant_combinator.excalidraw.svg @@ -0,0 +1,16 @@ + + + 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 diff --git a/docs/images/dom.excalidraw.png b/docs/images/dom.excalidraw.png new file mode 100644 index 000000000..69d7f0d22 Binary files /dev/null and b/docs/images/dom.excalidraw.png differ 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..86fb5e78d --- /dev/null +++ b/docs/images/dom3.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1d+1PiyFx1MDAxNv59/oop95e9VWO2+/T7Vt265Vx1MDAwYnVcdTAwMWSfOFx1MDAwYnrnllx1MDAxNSFcdTAwMDIjLyGIurX/+z1cdTAwMWSVXHUwMDA0Qlx1MDAwMihB2DvU7qhJ6Jx0n/Od7+tX/vz0+fOa/9j21v75ec17KLn1Wrnj9te+2OP3XqdbazXxXHUwMDE0XHUwMDA0f3dbvU4puLLq++3uP3/7reF2bj2/XXdLnnNf6/bcetfvlWstp9Rq/FbzvUb33/bfI7fh/avdapT9jlx1MDAxM95k3SvX/Fbn+V5e3Wt4Tb+Lpf9cdTAwMDf//vz5z+DfiHXlmttoNcvB5cGJiHlGjlx1MDAxZT1qNVx1MDAwM1NccuGCXHUwMDAzIzC4oNbdxpv5Xlx1MDAxOc/eoMFeeMZcdTAwMWVa29y+K3b2XHUwMDFlL5t5wXP7f0C+d212w7ve1Or1vP9Yf65cdTAwMDe3VO11vPBs1++0br1CrexX8TxcdTAwMWQ5Pvhet4VVXHUwMDEwfqvT6lWqTa/bXHUwMDFk+k6r7ZZq/iNcdTAwMWVcdTAwMTNkcNBtVoJcIsIjXHUwMDBmwVx1MDAwNeBQJkBcdTAwMTCquDBE8cHp4PvUYYRqo1x1MDAxOFBcdTAwMGVajtq11apjO6Bdv5DgXHUwMDEzWnbtlm4raF6zXHUwMDFjXqOhZGjkmfuvT6uZwzlcdTAwMDPA21x1MDAxOGZcYojBJVWvVqn69lx1MDAxYVx1MDAwMEdcdTAwMTMutVx1MDAxMs+3ipjiXHUwMDA1TUJcdTAwMDVQ4FLqsEmtXHUwMDAx7f1y4Fx1MDAxYv9cdTAwMWSt0qrbab9U3VrX/lx1MDAxMTHe2r0z6lhR54o0u5bnXHUwMDFi5sdlsVrq7JOTk8vCuVuQg7KGPNH3XHUwMDFl/LXBib++pFx1MDAxNXt4uv90kXso+p1tb72a71x1MDAxNlx1MDAxNfCb8cW6nU6rP225XHUwMDE5mTv3Yoeu/jLtXHLDYl9+XHUwMDBim73XLrvPwUulJFxco1trqfXgfL3WvMWTzV69XHUwMDFlXHUwMDFla5Vuw3j/XHUwMDE0sTeGMkN2RiGG6NGjr1x1MDAxMCOAXHUwMDE50IqEXHUwMDBlO1x0YtJreVkhRqVBXGZFXHUwMDA0XHUwMDAywoFTot6PMX7HbXbbblx1MDAwN1x1MDAwM3dcZs6oyThcdTAwMDNxXFxB41xmXHUwMDEwakK754Yr8/TO0Fx1MDAwYlpNP1978oKyXHUwMDFjQTUnIFx0KGOIUENX5dxGrf441LCBXHUwMDFio+VcdTAwMWLt9q//iFZ110NcdTAwMTOCMsXQxVx1MDAxYvVaxfr5Wlx0XHUwMDFmyutcZoWAX8OUPbigUSuX61x1MDAxMX8soVx1MDAwNS6W2dmfJn22OrVKrenWz4dcZkxccsmOV/KffXJMXFxcbkWS4pIqIyhorvTUgVx0rdO9XHUwMDFi/fitcX2+sd+oXHUwMDFlVcVV/2TZXHUwMDAzkyrHXHUwMDAwZ1x1MDAxNORzYFx1MDAwZcUloO8oYFxu/3/O/dmFpVx1MDAxOFx1MDAxM4daOVx1MDAxOKacKVx1MDAxMYtHrlxyIZpcbj3/eEzLcGfyxtvtq1x1MDAxZtWzwl3ha+OSi527+vLm+cLvf9z3v/Ltw69cdTAwMWRR3n2EXHUwMDFl8O2EhDxTuVAo7+/lbkuHeoPT80b9eKd5WZlDuVx1MDAxOVVvRsWq+9pe7m6HNjo7V+Vt/1x1MDAxZe6P6/OoXHUwMDA1Tjpt2rwvXHUwMDFkXHUwMDE1clx1MDAwN0e6sF1oXHUwMDFk+lx1MDAwN+8qd1x1MDAxMo9cdTAwMWFfQVOae/2jsXFSv5dU+OubZVM5OGo9PE5n7rLwMy7V6NFBXHUwMDFlkIgykis2fVx1MDAxZUh3tyXNXHUwMDAzKK1S8lx1MDAwMNdcdTAwMGXD9jCv/Cy7PMDH8TFcdTAwMTbDf6VBWDKTgc7Lmo9cdTAwMDFcdTAwMTk6msK/8qWO5zWTKJhcdTAwMWG6fm5cdTAwMTRsXHUwMDAyi1x1MDAxOaVgXHUwMDAzXHUwMDFiU1x1MDAwM+858MdEnoakwFx1MDAwM+CCXHUwMDEzXHUwMDFkYdyT4i49i35I3FEyMfCMcDTlhqlxgYcs1GFcXFx1MDAwZlximMgs8IjDjSQmyrVcdTAwMDbxx7hjXHUwMDE0SCEhTsRcdTAwMDQzmnEuYfZAXGasW3Qgdn2342/WmuVas4Inw3yGwVjq2fuuXHUwMDEzhzAqXHUwMDA1R3RRXHUwMDAyQZGE1W5cdTAwMWbPbdtmQ1x1MDAxMSlQR1xuIVxyXHUwMDA3yU3kipfeyDRF83LxILGuec3yRKOIUehPXG6lXHUwMDFj/idUqExcdTAwMDZWgYMyLyDMQe9cdTAwMThlKsms8VFcdTAwMWUzq+52/a1Wo1HzsfpPWrWmP1rNQX1u2PCuem559Cw+VvTcKFx1MDAwZbRticOkO/ztc1x1MDAxOCnBXHUwMDFmg9//+2Xs1etxXHUwMDE3tp+I84YlfIr+fJN0pJSz0cOv0MUlw3yKXjo1dDVcdTAwMGVcbnDyXGJ721vffpQ2L2BcdTAwMDOKXHUwMDA172Ohi09CLkaZI1x1MDAxOJJcdTAwMDFuOOJTRIpcdTAwMDVfZ9rRRFx1MDAxM2OoXHUwMDA0TcjSSEfJXHUwMDA1YGTDgnuIycPW9UFLXlx1MDAxZJT13f3ZUfuGnlx1MDAxZPKfynFeyjGj6l2tYucvSCdcdMfxXHUwMDBmXHUwMDEyXHUwMDE2+4qzXHUwMDFmLfAoiYDEXGJaY1wipVxiUtN3wKe33rKCtU5cdTAwMDNrRVx1MDAxY8OxNZShXHUwMDAxWC+BwFx1MDAwM6Q7iohcdTAwMGbocDczeOP7XHUwMDA03lx1MDAxZdJcIq+zYIE3gWuMXG68gY3v4Epa0KTo41x1MDAxMt2NSja9zDvYOqnkjqhqyZvzvavTi2qp+vWDx78mhlx1MDAxZlx1MDAxMlBkQ1x1MDAwNFx1MDAwNZ4kmqlcYvlcYr7OiaMkRiVjWiFtJ9nJvFx1MDAxOclcdTAwMTJcYoPijrxB3L2HK12RnD686lx1MDAxNVx1MDAwZp425FaDbPbORK/4kyvNiytlVL0/i/2QvvvxXHUwMDBmMjNcdTAwMDWbJem9jYIpZUZcdTAwMGZcdTAwMGYmQVx1MDAxMKlcdTAwMTjRfHpcdTAwMGWW3nxLmlx1MDAwNFx1MDAxOE1LXHUwMDAyXG5cdTAwMWNtXGZRQFnWSWA6XHUwMDBlXHUwMDA2XHUwMDA0oVx1MDAxZqniR3SyL46D5Votf+FcdTAwMWNsXHUwMDAyh1x1MDAxOeVgXHUwMDAzXHUwMDFiUyMvuZNd8sTIk8LYvs7pIy9dZC7p6JamXHUwMDBlhlx1MDAxNuGGcEKJNEORx4hxJDOCaFx1MDAwM1ooynl2kWdQaFx1MDAxOabBSKBcXI2LQ2rn63BGXHUwMDE4wlx1MDAwMGgqqVx1MDAxOY1LKvCo5lGVtrA+9zfFZXKf+1x1MDAxNH3SYcqLdoZzQTUxQiCY2s68cETyc9hDLzmAeVx1MDAxZLJk7OWCyX3uQ0alq6Vho7CtXHUwMDEwuLlcdTAwMTRcdTAwMWFcdTAwMTRmu7hNXHUwMDE0XHUwMDFjXHRCXGKtJeVcdTAwMTRcZo/ZtFJcdTAwMWTuid5sP3E/XHUwMDBly/tcdTAwMTT9OTOcof8ndr1TJrF6XHKfYT5lOmVbTkCTTDvAqMK4tLOhR1x1MDAwMY1Sh1wi2+DUgLQ/Mlx1MDAwNDTJXHUwMDFkaWPMoFSkhof9SpFZ29QhdpYrXHUwMDA1jf9Hc81gNpfSXGZcdTAwMTBkVlx1MDAxZtBe0YA41FBlQYrhXHUwMDAzXHUwMDAzeiRcdTAwMGKD41x1MDAwNVxyXHUwMDE0Nlx1MDAxMuproGiMXHUwMDE2KP4jV7xlsG7SXHUwMDE4XCJxbIojXHUwMDFjMHtcYkFcdTAwMDVXMZO0g4RcdTAwMDBcdTAwMTDFlE2ETOskk8ZcdTAwMTOYlYazRFdcdTAwMGVOxpx4XmiGzZ9cdTAwMDRmKFx1MDAwNdArpJl+XHUwMDFjMX0u1pJOgUA1ZFeYIEkzXFxhK1xmz1xyt8OMhklCMckwxSC7rmnpXHUwMDE4XHUwMDE0XtyuMEF6xWSYZFwi5Fxmg1xiXHUwMDA1kUJYZaBRxsWwjGDcc6pM+IyrimVvJmdAQFAk2kZgXHUwMDE2UiQ+e8Jgi2rkb1x1MDAxYUOKMKNeqcGM3Kx0ly/+vv/Acr9fX5y6hzfXh+R4N8EmgsmQXCJhMVx1MDAwMVx1MDAxZFHAY0ZR7mhO0dHAXHUwMDAwIKc0sNJwtp7ozvZcdTAwMTN35Fx1MDAxOfEstcNcdTAwMWaISOzroUhJXHUwMDE4g1kmdqW385KiXHUwMDFh5n5cdTAwMDdcdTAwMTBcbjhcdTAwMTOgsFx1MDAxMUKkeJ5cdTAwMWVhXHUwMDFjhjrBKNQoQI1cdTAwMWGxa55cZi1EzLDHP0wpr7jFgGk0KFSkXHUwMDBi6ep/8vvC2z2R3fLvKt8/qNxvbVx1MDAxZW/+7OqfV1d/RtW7WsVmNU9/tWrhXHUwMDFk0/RcdTAwMTPKnTQyMf5BpjT3x7fqU1x1MDAxOfLFYt4tnpeqV4WN+lnCmo2ZXHUwMDFh7eBcXO+c9HKHXHUwMDA3Z113vXrQudl4qO9OV+5rXpwjXHUwMDBiS02xyatJVeJyXHUwMDA14NoopF7TS4Z0d1vW5FxuLC25XG6kcotJrmJMco0ssHxNrlx1MDAxMrOrnbKbQXbNeiSFyqGjKSMpW69jXHUwMDFjv35v2lx1MDAxM7Xyv77bjVx1MDAxN+qtyve1783xIyxcdTAwMDKGylx1MDAxOVxmoNS9m2Hnn2l8ZVx1MDAwMmNcdTAwMWNcdTAwMWRfmWj5O6gwJ4mDL+jAXHUwMDAwis6wvcTR3vXm7Teyv5477uzU1pv89NvjzrJcdTAwMDer5Kg17LwzrpRcclZcdTAwMThcblZhuGNQh2CsZlx1MDAxZKxcdTAwMTFNXHUwMDFlMuF4b6TQilx0XHUwMDEzXHUwMDFkgF1cYlx1MDAxNW7vNftcdTAwMTe3x9c7ZVLNre+vXHUwMDFmk3rx8CdcdTAwMTWeXHUwMDE3XHUwMDE1zqh6V6vYrKjwatXC/KlwRuZOYtjjb5g9XHUwMDEzTi13v3/WKPTFjr7eJFx1MDAxN/6RPKrRq4TJ7TOVu9voXHUwMDFll76dNd2LgtgvbNx5lav1/nTlLlxyc+cscVwirFCEK1x1MDAxMd1cdTAwMTllXHUwMDEyXHUwMDE5SHe3pSVcdTAwMDNcIoVcZkhiXHUwMDFjvlx1MDAxODKgx5CBMczdjklcdTAwMTi7XHUwMDA2/O/M3PeQXHUwMDEwP1lcdTAwMGVcXP/VXHUwMDFlfybBpbrb7XpdZMLXPd9vNbuLJvFcdTAwMTPIbmyi+lxmXHUwMDBm8Vx1MDAwZT4vk/eM4VRwXCJcdTAwMDUjU4dweX/f39l+2uHXrFuu7l+oRv/SXfZcdTAwMTBcdTAwMTZ2N1x1MDAwMCUkl1JcdTAwMDCG8PB4nZLS4YBS1273xIBnuJXTlHzeLmoxoFx1MDAxNzyJfbt597he8lxi9b8+Ulwi+24+d978SefnReczqt7VKjYrOr9atZBcdTAwMTWdX61aqJvNLX29u8W3pPTy51x1MDAwN1x1MDAwZpVcXG5KevxG9TH+Qf6P2Hx0ksdo11x1MDAxZWFMaVxyYno6n+5cdTAwMTfLylx1MDAwNVx1MDAwNEvjXHUwMDAyii6KXHUwMDBijKPzKsZcdTAwMDVcZppIuFAruKp0eja/XHUwMDE5MN1cYlx0/r524SHx/fL8173bqblNXHUwMDFmKXG3Vyrh0yXzejVcXPiceP1cdTAwMDTSO8rr3/Y472D4KD6TwtpQ6+UgZpiR97Tlnp6ap7PKZvGylds4h0b+dNmjWlx1MDAxOe1IXHUwMDAzxE5cdTAwMDWFmEhXUjh2wi5+1FIwfCCUMY46fcG7QbKvxW5+s974arbyxe5cdTAwMWb8x5Ms/tzTY25cdTAwMTQ/o+pdrWKzovirVVx1MDAwYllR/NWqhflT/IzMnaRcdTAwMWPG33BKa98xvvDy28crh2h/cmxcdTAwMDVcdTAwMTNcdTAwMDXgTNLplUN6+y0px9CEpXFcZkVcdTAwMTbFMaZTXHUwMDBlVFx1MDAxOY5qLjqz6v9DOlx1MDAxY7XGUG1cdTAwMGZcdTAwMDOrs2jdMIFKT6FcdTAwMWImPUtqMKeLXHUwMDA2XHUwMDAxyX1cdTAwMDFcdTAwMDJQz3NcdTAwMDPTr0ms9o+L9fouK/hf20y15GX+zr1e9ohmmjtcdTAwMTiqWmKYXHUwMDA0fVx1MDAwMWIopIWRjkBtpbnKXFw2jFm6M2ZgwFx1MDAxMIFBRt+yd+l7ZMN957Kfq2z+qKpK+3ZrY+Obq9tnP2XDvGRDRtW7WsVmJVx1MDAxYlarXHUwMDE2spJccqtSXHUwMDBik3j4+Fx1MDAxYi4hX1x1MDAxNjqRL2thqN1cdTAwMGV++uSaXs1Lm1xcTVpylYQuKrnqMcl1XGZfJswgX1ZvWFx1MDAwNrs6dDnvu8hgXyaNn37byZ/vXHUwMDFmXHUwMDFmfbF/jM4+uet5Xb+WOokmXHUwMDFi0jyBSca283/rXHUwMDEzpcZ14uJ3mVx1MDAxONacXHUwMDFhLYDNsJAlfcXQkoZ1sP0/U6hcdTAwMTCQjGL0XHUwMDBl78sqXHUwMDE4c4BJxlXmKlx1MDAxOIyDXHUwMDE1zijeXGLsmmFcdTAwMTVcdTAwMGZyyZ3xu0RyorRcdTAwMWHaROpvseR9luXlQjBit8IhWIfcaFx1MDAxYbnqdTtcIq00XHUwMDExxK7GJnY19stcdTAwMDVcdEveh1x1MDAxZmOVlp0ne5L9XGZ8KCznU/Tn7JtcdTAwMDFFXHUwMDEy/ygz0NpcbrxcdTAwMTmW16RPiV5SXGKRXHUwMDAwXHUwMDBlXHUwMDE3WNtCWM+iw3tcdTAwMDFcdCZcdTAwMWNcdEoxXHUwMDA1zDBcdTAwMDZmxK45QlxikY7dt9A6ONGM6DFcdTAwMTBcIrD57Yt77IxcdTAwMDBjKJD4XHUwMDBiRYDYxXJcdTAwMWM+4IVcIktcdTAwMDEmxOFKcFCSSqVcdTAwMTFRTPw1XHUwMDFm2lx1MDAwMWk4J4Tad3wgXHUwMDE3TMeSJJPS59eOmKSEXCJUMlwiqNAmvpdcdTAwMTF37I5mTHGBXHUwMDE2XHUwMDBiyVTMpFVcdTAwMDKxZFe2n7hcdTAwMTPPXHUwMDBizCRJ3jaDM/vuVD3DsED6QMmSoplcdTAwMTLSXHUwMDAxu1x1MDAxZj/XytjN3IZljlx1MDAwMcegrmBcdTAwMTK4nYmhR+ya42ZAxNFcdTAwMDKBk1x1MDAwMlD7hoKw2kM+JFx1MDAxY5RiRiq7ZT5nXHUwMDEw31ODW9/h5i1vjV1mLJtcdTAwMDE4wM7+4kiJhGG2VaPvSVx1MDAxYeyJSKyu5VxiL1x1MDAwNONN67ehWfrowDBbXHUwMDAzKkBSXHUwMDEwOliCQlh8IzTlIFx1MDAwMCuqXHUwMDA3RGU1gSzRi4OTo/47L1x1MDAxOItsZVx1MDAxN+NklGKiYDO8TTF9ouiyolx1MDAxOJNcdTAwMGV7fte1XHUwMDA0VFx1MDAwNsNbPVtcdTAwMTSzkk4pad/LrNiIXfNDMbxcdTAwMTFcYlRnRnOmkVSNQzHlgFx1MDAwNo2pXHUwMDA1qN2bKbZcdTAwMDSKoZnYZvxvRsimXHUwMDA2seC9YkhcdTAwMDFcdTAwMDBZqaaMKjlmL0dKXHUwMDFkgdyIXGIuUPNgXHUwMDEye+N2s+lzI0esYsq+XCJcdTAwMDVcdTAwMThcdTAwMTNKo9aKb2kmXHUwMDFkXGZuJIlcdTAwMDSI3Ss0btMqYdl6ojPbT8yNk8Ds08tcctbcdtv2dnmD1kC/rpVf+lx1MDAwMMOnXFy7r3n9zXjc/XJcdTAwMTN8bMdXUJ9cdTAwMTaJPPusf/716a//XHUwMDAxsk3fXHUwMDAxIn0= + + + + App()Screen()Header()Footer()Container( id="dialog")Horizontal( classes="buttons")Button( "Yes", variant="success")Button( "No", variant="error")Static( QUESTION, classes="questions") \ No newline at end of file diff --git a/docs/images/events.excalidraw.png b/docs/images/events.excalidraw.png new file mode 100644 index 000000000..aa815a20c Binary files /dev/null and b/docs/images/events.excalidraw.png differ diff --git a/docs/images/events/bubble1.excalidraw.svg b/docs/images/events/bubble1.excalidraw.svg new file mode 100644 index 000000000..f4b79d101 --- /dev/null +++ b/docs/images/events/bubble1.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1bbVPa2lx1MDAxNv7ur3C4X3pnarrfX85M54yioFSxVk+tPZ5xYlx1MDAxMiElJDRcdCDt9L/fXHUwMDE1UFx1MDAxMt5cdTAwMDIqcHBu80Eh2eysvfZ6nv2slZ2fW9vbhbjXclxuf2xcdTAwMTece8v0XFw7NLuFt8n5jlx1MDAxM0Zu4MMl0v9cdTAwMWVcdTAwMDXt0Oq3rMdxK/rj3bumXHUwMDE5Npy45ZmWY3TcqG16Udy23cCwguY7N3aa0Z/J36rZdN63gqZcdTAwMWSHRnqTXHUwMDFkx3bjIFx1MDAxY9zL8Zym48dcdTAwMTH0/jd8397+2f+bsc52zWbg2/3m/Vx1MDAwYql5nODxs9XA75uKOUKcMCT1sIVcdTAwMWLtw91ix4bLd2Cxk15JTlx1MDAxNXrRt6vuXHUwMDE34pu8yI/l/snt58reSXrbO9fzzuOeN3CEadXboZNejeIwaDiXrlx1MDAxZNeTu4+dXHUwMDFm/i5cbsBcdTAwMDfpr8KgXav7Tlx1MDAxNI38JmiZllx1MDAxYveSc1xiXHLPmn6t30d65j6ZIcVcciQ0JZprLDVVw6vJ77UhMeJcdTAwMTQrjSTmmo3bVVxmPJhcYrDrP6h/pJbdmlajXHUwMDA25vl22kZcdTAwMTFL48yYu4+jVdRgjFx1MDAxMlwiwVxmqlx1MDAxMeHDJnXHrdXjpFxyIYZCTCjJXHUwMDA3t8qY4vSnhGtFqVx1MDAxNul8JbdvXHUwMDFk2f3Q+GfcoXUzbD04rlx1MDAxMCVfMqYnVlx1MDAxZozHVTa2MpNOxFWl7Fx1MDAxZlQ+fyzvX1rfj3ul8/tw2NdIIMbOfVxcXHUwMDE4Xvj19ne3M7tcdTAwMWRp/XbRXHUwMDFiLmituL+6b1x1MDAxZvPDkPpnJdwrnXlfet3p1pphXHUwMDE4dDP9PnxKo6ndss1cdTAwMDEjYCEoSyiDS8SG1z3Xb8BFv+156bnAaqQkspUxeIK7RsafIS5G0fjZR+JcIohSXHUwMDA0WOApiOZcdTAwMTFX/vRtKnFplENcXFx1MDAwMlx1MDAxOVx1MDAwMlx1MDAxMyBcdTAwMGLBXHUwMDA2zPVcdTAwMTLiikPTj1pmXGJ8MIW85HzyXCJcdTAwMTNkRVx1MDAxMJFMJ3S2fLrKj05O5Vx1MDAxM6IzXHKCwI/P3Vx1MDAxZv2lUVx1MDAxOFx1MDAxYyuGiIBBaI24XHUwMDFjaVUym67XXHUwMDFimdd+XHUwMDE4g+W7rdab/2ZdXHUwMDFkOWDCYLlcdTAwMWRpvOu5tSTOXHUwMDBiXHUwMDE2XGbKXHRHIFx1MDAxMLsgXHUwMDA0hlxymq5te5lwtMBcdTAwMDJcdTAwMTP6XGaPXHUwMDE2WZOD0K25vuldjFx1MDAxOJhcdTAwMGLJXHUwMDAxJUzBpFwiszGJwfGYscyiNVx1MDAwZpP5JLWhmKRSXHUwMDFhhDIsZKJcdTAwMTVENjL6XHUwMDFkMGJwwCsgUilBJScrQyU1XHUwMDE0cCSTQlxugShXelxuKqk2XHUwMDE0xVopLjSGcJ5cdTAwMDAp5lxcwigofjpG+6Y+XHUwMDFio09bQTJ2mGG85/q269fgYrr0ParkRTDRR7HVTqzcXHUwMDAxhlx1MDAwNYBzjlxiU8BcdTAwMWNEZFx1MDAxYdXMVlx1MDAxMvRcdTAwMDZcdTAwMTeKSFx1MDAwNbHNlCZYPDRcdTAwMTiuwFx1MDAwNce355tUvPlcdTAwMDbS8NI8s8t2T/rt3a87++VZJjGsMdZIYyqJXHUwMDEyRLFcdKswg+mHmcNEXHUwMDEzglx1MDAwNPydMMszo7hcdTAwMTg0m25cZs7/XHUwMDE4uH487uS+N3dcdTAwMTO011x1MDAxZNNcdTAwMWW/XG7Dyl5cdTAwMWKnhVbS46h4TD9tp7Dpf1x1MDAxOX7+5+3U1juzozk5JuI47W8r+39cdTAwMTajhY5cdTAwMTVcdTAwMGbwPIXVXGK4eFx1MDAxNq1cdTAwMDGSXHRcdTAwMDaxsbjSyJ/nXHJlNaKUQSVcdTAwMTJIJ1x1MDAwYp5iZIzVNJBcdTAwMWWiSFx1MDAwM2q1TtbF1WlccpHOxZDGVKp8XHUwMDFleFx1MDAwYphVipHVZi2pkGp8iM5kpV5FOLBkLL+dWl9PXp5cXHjNXHUwMDFmn2/+wlx1MDAxZr2DYlxye3Xkur1ytJhcXM/t97LyudM9ZvsnxyG3yz3SJmxfLKFfcmlcdTAwMWZcdTAwMWSWXHUwMDFh1onaZfii6Z1cdTAwMWX4X2tL6HdF7n1d3TbE5VGn1PqEb9pRvdHhJXRXtf/vnPuCXGZ2vebOy+On33BBa0vlxoUon3+7u6yfdSp+3Tv+XHUwMDE27CzBXHUwMDBi91fki/zavjmpoHL5XHUwMDE2k2bN6Z0tqT7AJFx1MDAxMZCOrro+QIhW46dcdTAwMWZXbYq51ELRxXOR/LDY1FVb07xVXHUwMDFiUjJDrmnV5lNW7Uxq9LBqKyk4XHUwMDE4K+g6K1x1MDAwMkxohTR6QjxOr1xiLFpcdTAwMDEoPqbnb6795IJrv79OKvReULsuXFz704tcdTAwMDOZPHGkOOA5d6PR/6TSwFx1MDAxYy06Xlx1MDAxYZhr+fM1NkWKzkIrRlxcI0GzYTFcdTAwMGaunVx1MDAxYoJlLf7c2bPo0W3ZP2qflXv/Llxc+Ty0YkFcZqIxpDmSUsGoXHUwMDFhRSucMrhmSlx1MDAwMVx1MDAxMFx1MDAwNWFMrlx1MDAwZaxcdTAwMTkwpFx1MDAxMluMg5VgjDBkY5lq31o09sVheFOMnfaN3K1Wrq7qny5+XHUwMDFjfvitsZelsVfk3tfV7ao09uvywqo09uvygqf3iuq2XFxkRSGc84tcdTAwMGb3tVJpg72w/JRgXlx1MDAwNjN9IGm3XHUwMDBmn3JcdTAwMTSYwpqwpyiwXFyhMSsjoCSjcMc1XHUwMDA2wVRRgejiXHUwMDFhI3/+NlVjyHyNodelMdRcdTAwMTSNISc0htKYScroXG52NMxcdTAwMGJH/IRwfFlCsNeO48B/k5xcdTAwMWLo6uvClVx1MDAxM11cdTAwMTfeXHUwMDBlvnXM0DX9XHUwMDE4pHbUtixcdTAwMTjd7CxBjna+pCxhjphcdTAwMWXPXHUwMDEynjecXFxE56dcdTAwMGV49lNHjFx1MDAwNYQ8hPvimf7ljYjrxdZt1W+ffqrY9ztcdTAwMDeXZ/VNz/SpVlx1MDAwNoaoXHUwMDE1QkHSz4SczFx1MDAxZJCAbjjTjJOV7lx1MDAwNVgsecCISkQ5ZWtOXHUwMDFl0MfK4Un7qnL48axcdTAwMTL39lx1MDAwZXo8OPF/J1x1MDAwZstKXHUwMDFlVuTe19XtqpKH1+WFVSVcdTAwMGavy1x1MDAwYqtKXHUwMDFlXpdcdTAwMTde8DjhmTnJ9IGk3T58yntKwUEkp09cdTAwMTBWlpPgmTlcdFGCXHUwMDEzQcjie1x1MDAwYvKnb0O1XHUwMDBiQzRfu+i1aZdcdTAwMDWTXHUwMDEypSmiXHUwMDAyr0C6LDNcdTAwMWWXnpRUgylcIt5cdTAwMDG8huvOSOZo9Fx1MDAwNTKSeWPJXHUwMDA188z9j1jJmc9cdTAwMWMx0kJLxjPhPVx1MDAwZs75tLmhcKaKXHUwMDE5QiCtklx1MDAwMoLi2YpK/6Gjklx1MDAwNlx1MDAxNZJizYHeOFErrDFgXGaWXGJGXHUwMDE4p5RQwtkkulx1MDAwNTe45lopRGjyglx1MDAwN1x1MDAxZFx1MDAwNztnyXNi8ZyNRM/fXHUwMDAw+Vx1MDAwMrAvuFx1MDAwMXLh3YbIYJgqrFx1MDAwNeVIJvOVaTPYaUhcZlxmPoZlilxuLlx1MDAxNOTakztcclx1MDAxN9pcdTAwMDCZXHUwMDBm6lx1MDAxMZO4XHUwMDEwyVx1MDAwZUBcdTAwMDSrXHUwMDA1pJFcbk/YXHUwMDA0M4+0XHUwMDA0vIE9SMBcZuNcdJte0+7H2ZGcXHUwMDFjXHUwMDEzMZx2t5X9/1xmOpstTmBcdTAwMDKoktn8fVx1MDAxZZvlXHUwMDE3pjeVzSQxINDAXHUwMDEzXGYpxHXqj1x1MDAwMZkpQzCMOaeKXHUwMDBi/qJXw+ZQXHUwMDE5NVx1MDAxOMijxFx1MDAwNM4kKKUpVEZcZlx1MDAwMcJcdTAwMDR0iVJcdTAwMTIxltnv/Vh0oUpcYs1AvKyXzJgmXHUwMDE5sfQvktlcdTAwMGVQXHUwMDA3lVx1MDAwMCTMQEpKJsgkdYCjKbhZUVxuLCM0aM7n0Vl+1XTMqMQmXHUwMDA07UEkSEz0hFEw/UQgcCeiXHUwMDE45CdWr5rOdmaHc3JMXHUwMDA28lx1MDAxM1x0LbdcXCwzr7OOcVx1MDAxYeeYwaLCXHUwMDE3L1x1MDAxNvNcdTAwMWatfetL7XDvwlx1MDAxM7FC3S46vmttOqdcdEZcckWJgHDjXHUwMDEyKzZZK9ZUKFhQYGXNvlT2nPddmSWcO87YJKVcdTAwMTGR9pxcdTAwMTaKM1vQXHUwMDFlOEtCqqVhTp7xXGJoXHUwMDFlZ1xyw2pKyaLt69L9j6/V71cq+u53j3ixcVJ9eSWkKE87ptM+/G7+1Y0vT2k1qsYzXHUwMDFl+i6pXHUwMDEyMn0gabePiJrN36BcdTAwMDKUeMrjsFxccM6qhEiSkzrBIVx1MDAwNVx1MDAxNotcdTAwMDMzf/o2XHUwMDE2mCpcdTAwMGaYmsHStCRg5qpccsKnQJNMpEZcdTAwMThcdTAwMDNXcs7Uup/OPjFcdTAwMWPTWU9cdTAwMGIhmUeGI4WQdJCPhVx1MDAxMKeT2GR8cHpvXHUwMDFhTu/9deHiujDjXHUwMDA1Tj3y46W9wDlnkVx1MDAxOa92TDd4gMmtXHUwMDA3oFx1MDAxN8xW6zxcdTAwMDa/XHUwMDBlXHUwMDA1XGZMnWs/OCf1ZaHjOt29Kbx+1z+SXvs4T1x1MDAwMOUkXHUwMDEz9/PX1q//XHUwMDAxXHUwMDA3vCMgIn0= + + + + App()Container( id="dialog")Button( "Yes", variant="success")Button( "No", variant="error")events.Key(key="T") \ No newline at end of file diff --git a/docs/images/events/bubble2.excalidraw.svg b/docs/images/events/bubble2.excalidraw.svg new file mode 100644 index 000000000..e3e4d2079 --- /dev/null +++ b/docs/images/events/bubble2.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXG1T4spcdTAwMTL+7q+wOF/2Vq3ZeX85VVu3XHUwMDE01PXdVVddr6e2YohcdTAwMTBcdFx0Jlx1MDAwMcSt/e+ngyxJeFx0iODi3U1ZXHUwMDAymaHT09P9zNOdXHTfV1ZXXHUwMDBiUadhXHUwMDE3/l4t2Fx1MDAwZpbpOuXAbFx1MDAxN97H51t2XHUwMDEwOr5cdTAwMDdNpPs59JuB1e1ZjaJG+PeHXHUwMDBmdTOo2VHDNS3baDlh03TDqFl2fMPy61x1MDAxZpzIrof/jf9cdTAwMWaadftjw6+Xo8BILrJml53IXHUwMDBmnq5lu3bd9qJcdTAwMTCk/1x1MDAwZj6vrn7v/k9pV3bMuu+Vu927XHKJekKiwbOHvtdVVWpMJNOc9js4YVx0Llx1MDAxNtllaL1cdTAwMDWF7aQlPlVwS+dccr0vNre3qvdRdHrepHf1teSqt47rnkZcdTAwMWT3yVx1MDAwZaZVbVx1MDAwNnbSXHUwMDFhRoFfsy+cclSFdjxwvv+90Fx1MDAwN1x1MDAxMyTfXG78ZqXq2WGY+Y7fMC0n6sTnUDI+06t0ZSRnXHUwMDFl4lx1MDAxZVx1MDAxOFx1MDAxYlgozjiTQlLBkvF2es1cXCiKNSWSQC86oFnRd2EmQLO/UPdIdLsxrVpcdTAwMDVcdTAwMTT0yklcdTAwMWZFLI1To27/XHUwMDFjr6JcdTAwMDZjlFx1MDAxMEmJplx1MDAxYVx1MDAxMd7vUrWdSjWK+1x1MDAxMGIoxISS/OlSKSPZ3UlRQlDCiEh0jK/f2Cl3neOfQZtWzaDRs10hjD+kdI/V3lx1MDAxY/SstHel5t1sX3ok2tza31x1MDAxMZfhiSutVl3gvqyMK0b2Q1ToN/x4nyf24XBcdTAwMTe7Zpl9uSi192osujQv9uhosWZcdTAwMTD47WnlLkjd31xcbKb3+2kvmIjtvUuctNkom09Yg8GtXHUwMDE54kRwOPrtruPVoNFrum5yzrdqXHQ8raT0XHUwMDFkXHUwMDAyxYyeKUTkaixcIjKlkYbwTHSYhIj5Vl5aRFx1MDAxNLmIKIjBJII5QfLliFx1MDAxOFx1MDAwNaZcdTAwMTc2zFx1MDAwMHBmXHUwMDA0KsrJqEiGUJAqzVxilkLPXHUwMDFmXHUwMDA15+mdiVx1MDAxN/hedOo82l1ZXHUwMDA2x4ohXCJcdTAwMTCRWiMuM722zLrjdjJcdTAwMTPbdWPQfL3RePeftKVDXHUwMDFiVOjK5JnO665Tif28YMGg7CBcdTAwMTNcdTAwMDKRXHUwMDAzXGaj36HulMtuylx1MDAxZi3QwFx1MDAwNJnBzjSrvVx1MDAxZjhcdTAwMTXHM92zjIK5IfmE4iNiUlx1MDAwYjYuJqmG6eaYTc9S8peVJY1JgpBBXHUwMDE0xKRElFx0hjHLxCSh2kCEMM41XHUwMDA355dcXCwsJpGhpeSKgzaMyVx1MDAxMVx1MDAwMcm4QVx1MDAxNVx1MDAxMlxuaU0wk1SKwVx1MDAwMMVUUIlcdTAwMTVHM/CUrqazRiggXGKZJULDyFxmolxyxys7Xlx1MDAwNVx1MDAxYZN17yf5niZcIroxbDXDrlxyXHUwMDExo1xu0JMpXHUwMDAwUoKFZDzVrWI24oXIoFx1MDAxOMGUY1x1MDAwNeingIj3OvRcdTAwMTfggu2VJyvFOo9cdTAwMWN92l5v75OrWqd0XCK+XFw0ySil1kArXHJTg5HATFxizaRcdTAwMWVWXG5TQ1x1MDAxMVx1MDAwNViHOcFYa4yHtHLNMCr69bpcdTAwMTOB9Y99x4tcdTAwMDat3DXnelx1MDAxY+xV2yxcdTAwMGa2wqjSbYOo0IglZilp8m41XHSb7of++3/ej+492pnjY9iNXHUwMDEzYSvp13FoXHUwMDE22Fb0XHUwMDE0zCNcdTAwMTCNUDpcdTAwMTbSMIQvl1qpXHUwMDA0KiZhWv4kLymmQXppYIUhX0GUUKF1lmdcdTAwMTCtXGZCMSyGXHUwMDE4QStTfECzOfJcZpFgVFx1MDAxZseUXHUwMDFhwi2CtITVJoV6r5Jfbd7W6N3mbadxb53csk7rlG6ffVre/Opi97zV3melg/2Al7c7pElYScxBLrko73zaqllcdTAwMDdqneGzunu06V1V5iB3QeZ9W2Jr4mKntdU4wd+aYbXW4lvo9rD8x7hLLfaRblx1MDAxZu/fsiNyXiu5XHUwMDFi9p44q1x1MDAxZOzOo0Bycllj1YOz/a/0+HHnq/ulxYLSi+ROKlx1MDAwZYw2UFwi9ueCm0PuuEB44cVcdTAwMDFC2fhlWyhcdTAwMTmTXGKS5J2Tlu18v1jWZZuSvGWbXHUwMDAyRZSvtGzzXHUwMDExy3YqZe4v20RLyqRIxvtcblx1MDAwNVx1MDAwMaZcdTAwMTTCXHUwMDFhP8MjR1x1MDAxN1x1MDAwNKYtXHUwMDAwXHUwMDE0f2bn7669uMEpf7yOK/+uX7kuXFx7o2tcdTAwMDOcZOT0U3/Xvs36/7MqXHUwMDAzXHUwMDEz2OhgZWCi5rPTbHDG8fc3XGLTXGLy0OnD9ZN9ZNPHc+fh6/bxSXX7xrNcdTAwMWXto19cdTAwMWKufGK0XHUwMDFhXHUwMDE4XHUwMDExXHUwMDAx2CgohFx1MDAwMYCTyEQrXHUwMDEz3Fx1MDAxMMCyIVx1MDAxOedap1x1MDAwYlhzXHUwMDBmVo2Gg1VcctdcdTAwMDY0xZhS9dr3ME7XnMvS/tq3b0FJ31xir3VPXHUwMDAzbP3h2PPi2Fx1MDAwYjLv21x1MDAxMrsojv22rOBeXHUwMDE2vfXO1n2FXHUwMDE3afXIosXjqIh+PyvojaK62S6yolx1MDAxMPbp2d5DZWurvbxWWFSqMXd1J2VcdTAwMWGjL5iI7b2bJ6/LpS/jMlxySphcdTAwMWE8nTBcdTAwMTeluKY0YbqTmEu+mZeTuYgsc8lmXHUwMDE5TKLX4i1qXHUwMDA0b1x1MDAxOXFPQ1NcdTAwMWRXilOT8n+YZGw0o8j33sXnnrj6deGrXHUwMDFkXlx1MDAxN94/fWqZgWN6XHUwMDEx0PewaVkwuvGZh8xcbp9T5jGBoVx1MDAwZmZcdTAwMWWzXHInN54npCNCjlx1MDAwYmrOXHUwMDE44/g5NzLXLiufxVWntLPjXHUwMDFjuvbnzm7n7mHpq1x1MDAwN4RcbkNThFx1MDAxNCVcXGlAuYG4hnxcdTAwMDTBeYw4U9BXysVcdTAwMDX2dFx0XHSDMVx0qVx1MDAxN1A7yKWK+uiIq8+HzZ3L5teaf2Fe3W3JP+nIvNKRXHUwMDA1mfeNiV1QOvK2rLCodOSNWWFB6cjbssL8b3wsSN1JWc7oXHUwMDBiJmJ775Ygy+Fjs1x1MDAxY6KB4Ovn3E7JN/OyXHUwMDEyXCKGc1x0XHUwMDExJDqvRYimzHRcdTAwMDRcdTAwMTJUKM5cdTAwMTdQoV3qTOfQXHUwMDFmkVx1MDAxOdiAXHUwMDAzwWunOVx1MDAxM5j/XHUwMDE0ac6kseRG89h9mlx1MDAwNOHxt0fBwbHkWj5j93QuXHUwMDFjL2s8XHUwMDEzajBcdLFEhGSMZm+3UKVcZsA0QZiIN/nSXHUwMDA1XHUwMDA2M8aGXHUwMDEwglx1MDAxMcYpJYAtbDi2IdeK94tCZFx1MDAwMdBcIonpUKgrgCNGOZmhqDH7Rs1cdTAwMTeE+pRcdTAwMWI1p95cdTAwMTOJXGaGYfhgXHUwMDAyKjhHhKhUp6dcdTAwMWSRxMBgZULiXHUwMDFlQilGSa/HM/dp5sd0RicuXHUwMDA0LFx1MDAxNTzeXHRMJUpcdTAwMTC6r1x1MDAxM8w90lJoXHT6IFx1MDAwMXP8tndpjvfl+Fx1MDAxOPLiRNxK+vXZaIY104Onkz2aSkpYtNn0ezTzS+jLWYMlYHkpkFx1MDAwNk+iUjCW2lx1MDAxM/lcdTAwMDRn2oh3XG5cdTAwMGLEqKZcdTAwMDIrNqDYPPFcZmBVXHUwMDExrVx1MDAxMEw2k0IlO1x1MDAxN1x1MDAxMjwjhlx1MDAwMColMFdKXCLGdKoq3Fx1MDAwMzREXHUwMDAwk1x1MDAxNVEzlHNmXHUwMDA3NPjHuUx86Vx1MDAxN1x1MDAwMtpcdTAwMWGgXHUwMDA3zKRgmDGAdSZIqtNcdTAwMTN4gJ0pWFlRXG44I7RmM248z6/FXHUwMDBl6Fx1MDAxNKuEgFx1MDAxZmDALZxQ/tXUvnNcIrrPXHUwMDFjUayUxupNXHUwMDAz2tp4b46PYT9+JqTlXHUwMDE2oWFix6NcdTAwMWFnXHUwMDA0ol1PX4Qu31x1MDAxY1/c3947l6XPx7e6yuT5oW3+WlSjk1BccixvSLA6XHUwMDAx6ytcdTAwMDbeNLwnRlx1MDAwYlx1MDAwZcu6XHUwMDEyWCP2omdp/mKWsG85Y8OQRkSCpklcdTAwMDE6wbVcdTAwMWVmcZhcdTAwMTNccvrMsOl8XHUwMDEyZPXdakTNorTl7IR8XHUwMDE37Z5cdTAwMWNcdTAwMWY+3lhHlebGWvHlJZaiPGqZdvPTvfmlXHUwMDFkXVx1MDAxY9HD8DDam0OJZe7qTiqxjL7glNo6n3evLm4qdrBn1qnliK90s/FtOiv8XHUwMDA0gDz6XGZ/iWstqHQj5djKXHJGgoLLajZ9qpc/fctcbiMyXHUwMDE3RjQ3WFx1MDAxYUZcdTAwMTaX7KWraMmDscPpXHUwMDFjQLtcdTAwMDI6J39B5Wam5+5SlVx1MDAxYoIyZ/uVm2QoPys3divWydizO+9qdufjdeHsujDmyVid+fLcnoydsCZcdTAwMGWWZ0YrPPtcdTAwMDKvUyvWYE1cdTAwMTWmgrDnZC3ty9ZR5+BTZJ5vr31ztrfq7EBtLntcclx1MDAwNlJcdTAwMTFDQqIo4u0jXFxcdTAwMTA+UIVBkDJcbowhhWNAhNGLXHUwMDAy8+VcdTAwMGI8dIOo1LM8r/6SXHUwMDA13lx1MDAxNlx1MDAxYid3ev2uuVtad+W3dus4OKsv71x1MDAwMr8gdecudlx1MDAxMm9cdTAwMTh9wd+HNyg9dkc+iZ+Fxlx1MDAxMLfT3/LJn76lhSeRXHUwMDBiT5RcdTAwMWJoXvA0XHUwMDE33iBjXHUwMDEywyVdwHOvf3hDwlx1MDAxYiastXPgXHJja52pZ6NcdTAwMDb3pXFcdTAwMDSrXHUwMDEzXHUwMDE208dkPkg9KybJq8UkV8JAkGjHv66VpfFcdTAwMWNcdTAwMTkqrktROX6TqS3iavDsgSjhXHUwMDFhXHUwMDA0UTzqXHUwMDA3brgwQC/IXHUwMDFmXHUwMDA0RfG2XzlcXNVkQmtMkX7d2zRwSZz6XHUwMDE1g/lXNfN59Gr6llxiXHUwMDAxnGRIi/hX0SBcdTAwMTVcdTAwMWLxc1x1MDAxYVx1MDAxOEBVXHUwMDAzmGI4YmuyXodnVjXzY3RAJ+B1Or6nT1x1MDAwNJNsSCNhXHUwMDAwwnVnlOk4W6ZDXHUwMDFhvama5rBcdTAwMGZ3T1x1MDAwZrtvXCJpJf36bFwiMb6MyblShKR/X2ZcdTAwMTJmSeteXHUwMDFkXFzV/eKRf3/X1vqiendcdTAwMTUuP2ZJXHUwMDAz+Fx1MDAwM9aKgWdjNlx1MDAwMFxcSFx1MDAxYZpJQYVWWDGxuC3yPFlcdTAwMWJcdTAwMTJcdTAwMTYxjFI0vlx1MDAxZFx0gfeqv8slXHUwMDA00UrNhFJTsIjhfSM3zZub9Fx1MDAxMp+mXHIq03vavSCR31x1MDAxOMdcdTAwMTgyo1x1MDAxOKRcdTAwMDc9TZ5ia6VcdTAwMTe3XHUwMDA1s9E4jcBCfYiDSXDKvWEm8lxuLcdub4zIdm+7Ryy1XHUwMDFir3Fk2PFcdTAwMTR8/7Hy419iJnwqIn0= + + + + App()Container( id="dialog")Button( "Yes", variant="success")Button( "No", variant="error")events.Key(key="T")events.Key(key="T")bubble \ No newline at end of file diff --git a/docs/images/events/bubble3.excalidraw.svg b/docs/images/events/bubble3.excalidraw.svg new file mode 100644 index 000000000..afc97dc2f --- /dev/null +++ b/docs/images/events/bubble3.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT4lhcdTAwMTP+7q+w2C/zVlxy2XO/bNXWW4p3XHUwMDFkXHUwMDFkXHUwMDA1dZzXLStAhFxmgWBcdTAwMTK8zNb+97dcdTAwMGYqXHS3XHUwMDEwlTgwO1QpkFx1MDAxYzqdPt1Pnu7Tyd8rq6uF6KHrXHUwMDE0/lgtOPc123PrgX1X+Gi23zpB6PpcdTAwMWTYRfrfQ79cdTAwMTfU+iObUdRccv/4/fe2XHUwMDFktJyo69k1x7p1w57thVGv7vpWzW//7kZOO/yv+X9ot50/u367XHUwMDFlXHUwMDA1VnyQolN3Iz94PJbjOW2nXHUwMDEzhSD9f/B9dfXv/v+EdnXXbvuden94f0esXHUwMDFl13p066Hf6auKmWBMak3jXHUwMDExbrhcdTAwMDFHi5w67L5cdTAwMDaNnXiP2VRAZVxc/LZ3vr7+5UpUrtTa3XkobuPDXrueV45cdTAwMWW8R0PYtWYvcOK9YVx1MDAxNPgt59ytR01z9JHtg9+FPtgg/lXg91x1MDAxYc2OXHUwMDEzhkO/8bt2zY1cdTAwMWXMNoRcdTAwMDZb7U6jLyPeclx1MDAwZt+KmEhLIaQ5ZZxJitRgd19cdTAwMDDllmJKI0a1YFxu61x1MDAxMcVKvlx1MDAwNzNcdTAwMDGK/Yb6r1i1ql1rNUC/Tj1cdTAwMWWjSE3jxEnfPZ+uolx1MDAxNmOUXHUwMDEwSVx0WFx1MDAxY1x1MDAxMT5cdTAwMTjSdNxGMzJjXGJcdTAwMDE9mVCSP1x1MDAxZSphI6c/J1hJolx05yreY1x1MDAxNOju1vve8deoTZt20H2yXSE0X1x1MDAxMspcdTAwMWK9N0ddK+leiXnvKSEu9q/0fveb+vL15vtR8M0hXHUwMDAzWUO+XHUwMDE4OfdRYbDjn4+/xP4kYodGf8x6wKzaXHUwMDFlRrf35Vx1MDAwYreoP92cVnfOTlx1MDAxZZwvXHUwMDA3k7W1g8C/S8h9+lx1MDAxNPt+r1u3XHUwMDFmIVxmXHUwMDBiQVx1MDAxOeJEUarkYL/ndlqws9PzvHibX2vFqLeSUHhcZmyHzj+JtEhMRVrEXHUwMDE4w0igOOhnIW369C0u0pI0pFXCklx1MDAxY1x1MDAxM1x1MDAwMFn2ZqSNXHUwMDAyu1x1MDAxM3btXHUwMDAw4GtcdTAwMDLaytloS8bRXHUwMDE1SSFcYvxcIlZsbug6T/eMvcDvRGX3e9/FhMWxYohcYkTgmo64XHUwMDFjXHUwMDFhtWW3Xe9haGL7flxmmq91u1x1MDAxZv6TNHXogFxufZl8aPCa5zaMo1x1MDAxN2pwUk4wXHUwMDE0XHUwMDAzkVx1MDAwYtRlMKDt1utewlx1MDAxZmuggVxyMoPdLCzCXHUwMDBm3Ibbsb3KkIKpMfmIXHRcdTAwMTOCXHUwMDEyI6KmRaVcIkozJjDOXHUwMDFllKkotahBSYVFXGJFhHCFNNA9OVx1MDAxNJREUPBcdTAwMWNcdTAwMDAnJoFYYIJZflFpSawxVlxcXHRkXFyejFx1MDAwNyUjXHUwMDE2XHUwMDE1lFJcYlxcRVx1MDAxNUpQ0+dcdTAwMTiVmFx1MDAxMcpwwjczx2hf1dfG6Fx1MDAxMJq9IEbDyFx1MDAwZaJ1t1N3O1xy2Fx1MDAxOV/7nnl9lpjoR3GtZ7QsXCJcdTAwMGJcIlxcMiVcdTAwMTUgLONcdTAwMWMmViXGNeyusaOFsWCYXHUwMDFhsFVgcfI0YHBcdTAwMTUuOJ36bK22909Pd13S+7z9cNw624nOTsrlL5O0XHUwMDAypVx1MDAwMDypQDA/iiNcdTAwMDPwMeZcdTAwMGW00lx1MDAxNlKUXHUwMDBiialWiFA5ppRnh1HJb7fdXGKs/9l3O9GolfvmXFwz4d507ProXjip5L5RXFzoXHUwMDFhicNkN/60XHUwMDFhx03/y+DzX1x1MDAxZieOLk51Z/NcdTAwMWFz5FjcSvJ9XHUwMDFholx1MDAwNU4teoznXHSoRijlo5ufUY0gpYngmtLMsJY+y4tcbmuYUEsyXG54QJnJ31hsXHUwMDEyI4FiZmkuIaWD2Vx1MDAxMZrlSDZETPxcdTAwMDZApmKweEIuXHKhylx1MDAxMCM5kIs0Zu2fXHUwMDE3T2+6p9WOOjhu3n1cdTAwMGbsi1wiZ29PL7pF50SSXHLt7jKt9nuHLba9s5eNsKfKPd87u707YFx1MDAxYp9cdTAwMGVcdTAwMDJe334gPcI2xFx1MDAxY+SS8/ruzlar9kmtMVxcaXtHm52vjTnIzcm8yyW2Jc53b7e6J/iqXHUwMDE3Nlu3fFx1MDAwYl1cdTAwMWbW/3XGfUNcdTAwMGX7XHUwMDEzWSHqXu00Nq+0s1x1MDAxOVRxsX5cdTAwMWKG3l4wXHUwMDA3KyCnsvnJ82XjqHm/s394sulT725cdTAwMTGtO6tOMvmAsdhnejCVjDLNgcfH5Dmnelx1MDAwNrDsqSRcdTAwMDNcdTAwMGItXHT8yewkI93OXHUwMDBiSzKwSiVcdTAwMTlcdTAwMTRZ7H1IXHUwMDA2n0AyXHUwMDEySf5cdTAwMTPJgFx1MDAxY85cdTAwMTQxWDwv71DBeHRI/Fx1MDAwMoecXFzByFqxKD2XXHUwMDEzPlxcdsxcdTAwMGW3/uelWVx1MDAwM/H8xmXhsjO5mMHJkJxBrcJzrofd/0WljFx1MDAxOdR5tJQxU/PUSE3NXHQopmRauCpIXyVjQmaO1kP3XHUwMDA2bzU66/dcdTAwMDeoev39SFKvVKr/2GjlM4OVMG0pXHUwMDAyXHUwMDE5L1wiWFxuotVwsDJcdTAwMDU5XHUwMDE505JJzjWwcZFfsGo0XHUwMDFlrEqMXHUwMDA2K1NcdTAwMDIyYczeOSNwTnmVVuAlO8Xt1s3DWsA3rn5lXHUwMDA088pcYnIy73KJzSsjWC4r5JVcdTAwMTEsl1x1MDAxNTy9XlLV7Vx1MDAxMitcdOGUK/v3ja2teTD3nNTNK4FZlkmblb9MPmAs9unTXHUwMDBmz18oYdNbXySmlFx1MDAwYqqzM6J0Oy8sI2LpjEi+XHUwMDE3I1JcdTAwMTNcdTAwMTiRXHUwMDFjY0RKaES1UD91+rLei1wiv/PBbHvMXHUwMDAyLlx1MDAwYlx1MDAxN054Wfj4+O3WXHUwMDBlXFy7XHUwMDEzQWJcdTAwMTD2ajU4u+k5jVx1MDAxY1x1MDAxNj6nnGZcdTAwMDb3XHUwMDFmzWledzqpIT0j0Vx1MDAxMVPjmmjNMKTj2fss1Oej3fXjXHUwMDBi965q22F5u/LlU/PsfvHLXHUwMDEy1IJ4RVxcYoI1XHUwMDExmqmRuMaWwtysWULSQ4XKL64zZjpCaa6QeOe2tWtSo7h3Ujyon8pcdTAwMTLueC1ug9Bfmc6cMp2czLtcXGLzynSWy1xueWU6y2WFvDKd5bJCXis1y2KFWVx01ORcdTAwMDPGYp8+LUBcdTAwMDLFU1x1MDAxMijJJWGSZb93IN3Oi8q01FxmoiXei2hlS6BcdTAwMTjTjCgt/m1cdNShPyHhcFx1MDAwMF2C986eZiRcdTAwMTRcdTAwMTmyp1nnklx1MDAxYcxTO2FcdMJpy7mYMcp59rwpXHUwMDFk5Fx1MDAxNzWaibRcdTAwMTinlEqJXHUwMDA0I5QmXG5ccv1wRtzChGAlsemkppLnXHUwMDE3z1x1MDAxOFtCgFx1MDAxMkZcdTAwMWZcdTAwMDJQy8bDW3CLa66V6ZXUSGI6XHUwMDFh7Vx1MDAwMExCICXly6P99b2wL7/6JPSY1lx1MDAwYlx1MDAxYveRMkw5XHUwMDAxX6RiSmsrsTCYjVx1MDAxMDNCKMVoou8yS/fq0+DZnbCxTmBlXHUwMDBlXHQsolxmfFx1MDAwMsWoO9BcdCZcdTAwMTNpKbRcdTAwMDR9kIBJw9N0mlxmXHUwMDBmYzotUyPsdFc2rzEnjsWtJN9f3tqvp9d3KTiQubsxO56lV/1cdTAwMTdcdTAwMTXPqDbBgFx1MDAxOdicXHUwMDEygHBcdTAwMWRfQ1x1MDAxZvFMWFRcdTAwMGJcYiNNNVx1MDAxNXnecIOpXHUwMDA1XGZIK1x1MDAwNJPNpFBcdTAwMTN6+1x1MDAwNbGEpkhcdTAwMDBfUlx1MDAxMoGyXHR0fWIvXFxcIlx1MDAwZVx1MDAwMPOKXHUwMDA18Vx1MDAwNcWzXCKAXHUwMDA3NVxyOTA5nEgmkmD1iFx1MDAxZGA4XG5mU2ZBQlxinWgxylx0z4xORiWzJoBcdTAwMDG2cNySXHUwMDFjXHUwMDAzXHUwMDFhtYhAYFx1MDAxZkSxUlx1MDAxYatpOk0uXHUwMDE2LzWeXHUwMDE1pzuzeY278Vx1MDAwYlx1MDAxMS21uq3o9LtcYvshTjDJjmqVXHUwMDA3t1k+uHPuSifOt/Jaw1x0i9/2fyyq0VmgRihcdTAwMWVes1x1MDAxYcm4jPmxJNLgXHUwMDE5SvY0veZ2bVZcdTAwMTPONWdsXHUwMDFj0UhcIpmLK9sxTDzfj4SVuWmKklx1MDAxY+5HXHUwMDFh+NWEqkXt+vz4QNb3mq1cdTAwMWLvXHUwMDBivjj8fmJvbb29XHUwMDE4UpJHt7bT27mxT++i8yN6XHUwMDE4XHUwMDFlRvuTxb6odpOTunNcdTAwMTc7q3Yz+YCx2GdcdTAwMDBIudpcdTAwMDDyJlx1MDAxNk1yqt1IOb10g1x1MDAwMdZcdTAwMTBcdTAwMDeyllx1MDAxOUbSzbyoMFwiUmBcdTAwMDRi1sJcdFx1MDAxOHlcdTAwMTOKpDIjwifgXGJcdTAwMTlL5bBkXFzBgX5A4eZV1CdRuCFoaOugcFx1MDAxM5/Kc+HGuTU6WfvOw4eW8/DnZaFyWZhy67FcdTAwMWX68dxuPZ5xQVx1MDAxY63OTFb49Vd3nUjzx8KSYE2xTjzaY1ZYejdl9nnz/qHZ3tpu672d43DXX1/wsFx1MDAwNPSxuGKacHPNXHUwMDE0hI801Fx1MDAxM2KZZmVI/Vx1MDAxOXBmJN90N/LbL+8ms9LoVVx1MDAwYtdvubpfXHR0UdtZP946p3vVs/2Tln/8eWNxr+45qbssYmeRhslcdTAwMDfMqG2vWj/fYdXKzV6RrYn2UbVabspsc5aBjEhNaO5PRlE65W5lSLA4ZSp7TpM+fYuKejxcdTAwMTX1OLbk3FBvXHUwMDFldISYslx1MDAwMEf8XZ+EXHUwMDAy7kg5euuTUJaJjsy4gudNR8wjiaZGJqZKcYNcdTAwMGaZI3N9jbZcdTAwMTTfaWzfn5XOfMlC7yxoLXpcclx1MDAxNZtcdTAwMWGpXHUwMDEwXHUwMDFjklx1MDAwMY20XHUwMDEymlxyhSbm3JJcXHJwTZO64eRcIsyPICRcXDNO2KtcdTAwMTZ430JIPt/qYD3c6V03v29ffN3arGzc3PdcdTAwMTaXkOSk7nKJ3f3aq5xViN495Vx1MDAxN2frd07P35XeXCJcdTAwMWF3XHUwMDE2f5p8wFx1MDAxZs+fKFVEXHUwMDEynHsxXHUwMDA3J59cdTAwMDQ5XG7TXHUwMDE0XHUwMDAwQSRJ3CyUTp++RUVpjFNRWnGLzFxypedSz4G0kSMk87i5c55cdTAwMGW57Fxmalx1MDAwNueYXHUwMDAzg0p5ttz09WdJXHRcdTAwMTOcveDRcqkw9aKoJO9cdTAwMTaV5vZcItPywKnCiGAx0lx1MDAxZEeRtFx1MDAwNMdSIVxykavV9NuLXHUwMDFjIeVbgtI8fkxRhDnlXGaSK8YxmfC4XHUwMDA0XHUwMDAxWVx1MDAxNoP9XHUwMDE0eC2nVNCx9jmTnUlcdTAwMDRcdTAwMTDznlx1MDAxZDVPUfuq/rmMT5dLzzNWh5/jXHUwMDA2zF9rJMCI2DzCLNHN8bw4zCxBuVx1MDAxNppIhVx1MDAxOWj+3NPxwqfLpcfu6tCKNaJEKTgq4qbpR3MxrpayXHUwMDAwdDVkrlxcKUSVYGNaLdUqdJpP91x1MDAwN4y7cyxzJfn+Yr6RuJyNXHUwMDAwXHUwMDFiU5pcIk1Zdrpx4GyfXpVON4vnVcq/ke1cdTAwMTLZrp0sPLBJYVx1MDAxMbP2jzhcdTAwMDZGQYZxjUjz5DlOzJ2KXG6ihOXXJpgowMRkY6xvxviAIFi9a7VcdTAwMDbYXHUwMDE45TSvxaPxrt9qr1pNXHUwMDEygSS5UEOjs3byRn53XHUwMDFhr1x1MDAxODqLUVx1MDAxMvGkyWNsrTxFcMHudstcdTAwMTFYaFx1MDAwMHgwXHRu/ek0Y3mFW9e5W59QXHUwMDFhuO6/jNR+vJrIcMxcdTAwMTT8/c/KP/9cdTAwMDeoXHUwMDAzMlx1MDAwNiJ9 + + + + App()Container( id="dialog")Button( "Yes", variant="success")Button( "No", variant="error")events.Key(key="T")events.Key(key="T")events.Key(key="T")bubble \ No newline at end of file diff --git a/docs/images/events/naming.excalidraw.svg b/docs/images/events/naming.excalidraw.svg new file mode 100644 index 000000000..5a9a2fb4c --- /dev/null +++ b/docs/images/events/naming.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVaa0/byFx1MDAxYf7eX1x1MDAxMeV8OStcdTAwMTV37pdKq1x1MDAxNbC0hZSKXHUwMDE2SludripjT1x1MDAxMlx1MDAxN8c29oTLVvz3845cdLGdXHUwMDFiJFx1MDAwNJaNXHUwMDA0iWcm9ut3nud5L86vXHUwMDE3rVbbXmWm/brVNpeBXHUwMDFmR2HuX7RfuvFzk1x1MDAxN1GawFx1MDAxNCmPi3SYXHUwMDA35cq+tVnx+tWrgZ+fXHUwMDFhm8V+YLzzqFx1MDAxOPpxYYdhlHpBOnhcdTAwMTVZMyj+cP8/+Fx1MDAwM/N7llx1MDAwZUKbe9VFNkxcdTAwMTjZNL+5lonNwCS2gLP/XHUwMDBmjlutX+X/mnW5XHSsn/RiU36hnKpcZqSMT45+SJPSWEyEIEghQcYrouJPuJ41IUx3wWZTzbih9uHRWXbGf36Sxu/wY3HOWYfH1WW7UVx1MDAxY1x1MDAxZtqruDSrSOFuqrnC5ump+Vx1MDAxMoW27649MT7+VuhcdTAwMTd9U/tanlx1MDAwZXv9xFx1MDAxNO7+0Xg0zfwgslfuRKhcdTAwMWG9cUJ93WXpXHUwMDAx5mkqJEOSY0mVqlx1MDAxY+JOQFx1MDAxNfVcdTAwMTRiWlx1MDAxMy2xkpLwXHTTttNcdTAwMTj2XHUwMDAyTPtcdTAwMGYqX5VtJ35w2lx1MDAwM1x1MDAwM5OwWtP1OeHg2GrVxeiWufaI1FIqxqjQRHAxXtI3Ua9vYVxymMqphlx1MDAxZKlcdTAwMTlhyt2QREmMqVx1MDAxYY+7XHUwMDBiZ7thiYu/Jr3Z9/Ns5LR2aWDNaHe4U1x1MDAwM1X15WFcdTAwMTb6N3uPhaCMYiYwl3Q8XHUwMDFmR8kpTCbDOK7G0uC0gks5ev1yXHUwMDA1nHIh5uGUaoKpVETcXHUwMDFiprvKbH3K2LtT8/VL9mZjiFx1MDAwZnW8/8xhypDwsFx1MDAwMjcgXHUwMDAxb0hKOVx1MDAwMVPmwYZIqplmgmPxIJRicqKUmIVSgpCHXHUwMDA1lkpywrRAWC2DUsw1sEgyjNaP09FEXHUwMDA1rNqGZ2pPXHUwMDA1vv3z8it786M3/FZcdTAwMWO9XHUwMDFkRuNzNVDo53l60Vx1MDAxZc9cXI8+zWdcdTAwMDHilMHNPlx0XHUwMDBiXHUwMDE0V/NYoCRBVFx1MDAwYszuzYKtg86eke92XHUwMDBlXHUwMDA2W+Kk0z00XHUwMDE355vZM2eBQMqTXHUwMDFjIaEoXHUwMDA1KFwiPEVcdTAwMDLFXGIjXHUwMDFhlNyJwsNYwFx1MDAwMmG6fFx1MDAxNlx1MDAwYjDhXHUwMDFlgFx1MDAxOPM6xu+Bf65cdTAwMTQhgj6GTC+C//bnz2FHXuxcdTAwMDdcdTAwMWb3tnl+2T/svDtGa4M/k6qGulx1MDAwN8Lfmks7XHUwMDBi+Vx1MDAxOOm5XHUwMDAxXHUwMDAwXHUwMDEyXHUwMDE1XGKcXGYtgX1fXHUwMDFl51x1MDAwN/tZxFx1MDAwZtOf4uJIbnx7n+C1Yn/iW3Xo45WgT1x1MDAxMfVcdTAwMTjSWiHIWIRqyj+XIMtcdTAwMWMxqTlnXHUwMDFhXHUwMDA06bGAr+g03jmaxLnETElIY8TyOC/cwYo4XHUwMDE3Nk63szOqTj7GfPPqfNOc7H5dXHUwMDEzzlx0oYIqslx1MDAwNM4rNKWJPYz+NmX4bIy+8Vx1MDAwN1F81YBESVx1MDAwMDBw3z81Rcv2TWtgbD9ccr8nPnwqXG6/Z1p9P1x0Y5PXN7EwYJC7XHUwMDAyo41TbcZRz/GnXHUwMDFkm26TWDaCemI8bdOa01x1MDAwMzDNh9Plu+HkLaZ51ItcdTAwMTI/PlrCzJVcYi/lXFy+Q6AjIFx1MDAwN7yWRdzF9zefd97KLOb9y1x1MDAxZPb+7eCg8377p37efGeMe1x1MDAwNFx1MDAwM6khmDHBVDPUQcrhQSjhXHUwMDA0SSYg4j1apNOVqC4gPKFgiUtcdTAwMGKflvCPmddcdTAwMTFcdTAwMDI7oKpcdTAwMWJ6dMKPWJNAzV9cdTAwMDBOzPfkv+nQmrxcdTAwMTXEflH8tlx1MDAxNNtcdTAwMDPwXl0g1sf3u6xcXInsXHUwMDAyycnRMdlcdTAwMTXVUNjo+3M97n56XHUwMDFmn3X2985+fOufpztcdTAwMWaO9o7X24RYO9dcdTAwMDUlnlx1MDAwMFx1MDAxYbuyTihBmlxc54J6RFx1MDAxMq2g2Fx1MDAwNtFTXHUwMDBm60As4DqrrruA61hLXHJ/XHUwMDFjVcHwScj+mFksRHdcdTAwMDVC+2Rkd429Vtr9ntzGypI9z4Pic2xbSOxcdTAwMWKHz6pYiZ5cdTAwMWO9ZTaTpKyH7t9eXFyc3y3BbDKJ0Vx1MDAxNZkt70zahfa05sQ1ypieiOGcS09Csq6ohDBcdTAwMGbEnsvrUDOFuqtXq641XHUwMDA0nlaUKC2FnNFZxIR6XHUwMDFhMSpcdTAwMTVcdTAwMThcdTAwMDJcdTAwMDU0q1x1MDAwNPm2duVQ5nGxXHUwMDAy6VfvMN4k3ct0XHUwMDE4a3b4ud2KkjBKejBZ6cltx3z3XHUwMDFlhWBJ5GDorNxAXHUwMDFlRYxzjClcdTAwMTJQ84Jtsras52cjT3NcdTAwMDFcdTAwMWJcdTAwMGVBS1x1MDAxM4bxaMH12CyThHdcdTAwMWLV/Vx1MDAxNvTzqyP68biTXHUwMDExxTa2gy8mnmVcdTAwMTTyXHUwMDE0XHUwMDE4xFx1MDAxMFx1MDAxNH1cdTAwMTIkWVA9bVx1MDAxMvdcdTAwMTBsLFx1MDAxM6497HrYesomoLfdTlx1MDAwN4PIgu9cdTAwMGbSKLGTPi6duek43jf+lIDAPdXnJsUgc2dsqnv1qVVcdTAwMTGmPFx1MDAxOH/+6+XM1fOx7F5cdTAwMWLTMK5O+KL+vrSQQVx1MDAxMEeTw5WSXHTYdCnv339YnLg+RyXjWHuuv4ggzVx1MDAwN6+z6sJlOaKQx6FcIlx1MDAwM4oghvWChyQ80FxmhatKXHUwMDE5XHUwMDA1XHUwMDFiXHUwMDE4p5hcbsY10bXYUXXfqMcoXHUwMDE0RIpgxFx1MDAxMVx1MDAxMzVVXHUwMDFk5S+YKIFcdTAwMTnI8dNKXHUwMDE5g1x1MDAwZlUwXFy/lC2ucZtS5kpoTDjDXG70SkheY9FINzQkpJhqXHUwMDA0rlx1MDAwNDditpqSLX7S0rRcdEmCXHUwMDA1lLRKYoqRXHUwMDE0fMomXHUwMDA1abBEXHUwMDAyYVxydkGWPG3Uv0nKNuaCuZydwvHalIyrudVcdTAwMTZWIJ5Y1blxl5QtTsv/XHUwMDAxKVN3VltaeKrsW0OgRnDHXHUwMDEzWZlcdTAwMDAoUijFXHUwMDFj7sX8xlxubFs3kKsqXHUwMDE5cTVcdTAwMWRkN1x1MDAxYVx1MDAwNFWDINVcdTAwMTKuKinDwnNSi7iCilx1MDAwYik5lZRp95iB1DvfT5OVrVos3VPKXHUwMDE2l/CNXHUwMDA0XGLCsqZcXFNcbuVcdTAwMDSkZOCQXHUwMDFhjUa6IT1QXGbwn1x1MDAwMGAzwlx1MDAxOF1NzFx1MDAxNj8wa1olmGBaXHUwMDBipIWSkFxyztJXXHL7XHUwMDBmlTSDRIVCMf3v1rL5cC6np5G8pJrN61x1MDAxY1x1MDAxMTz3iSiBbFx1MDAwNMKJXFyidbQ48W5qWd9cdTAwMGb6w9zMU7N1NY/0nSUmV56mXHUwMDAwJii1QdpcdTAwMTVrylx1MDAxOVXSQ1gypLSm5IE/XGawuZ9cdTAwMTSZn1x1MDAwMyVm5GZcdTAwMTJcIlx1MDAxNilcdTAwMDFfnmlGbkaxJ1x1MDAxNSbAjNGrZs2oyoRbgJJ4lVx1MDAxZlxiPNcnR5hJcEu1tyv2loTHXHUwMDE0XHUwMDE0Nje+hZdsrFx1MDAxYfeamt1cImdwmvxcYtxcdTAwMDb+OFx1MDAxOVpcdTAwMGJcdTAwMDdcdTAwMDVQILCmkYOPu02Ez92hp3icNNfWXHUwMDE3t74u/dz2s+zQgpfHalxyMInCkauqK7TPI3OxNetnWOXLnbVcdTAwMTRcdTAwMWPHbONA8uv6xfX/XHUwMDAx2ibQXHUwMDAzIn0= + + + + Makes the methoda message handlerMessage namespace(outer class)Name ofmessage classon_color_button_selected \ No newline at end of file diff --git a/docs/images/events/queue.excalidraw.svg b/docs/images/events/queue.excalidraw.svg new file mode 100644 index 000000000..45230f41b --- /dev/null +++ b/docs/images/events/queue.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9zq+g2C97q4Iyj57XVm3dXG6Eh8NcdTAwMWJCSPbuXHUwMDE2JWzZViw/sGRcZknlv99cdTAwMWVcdTAwMDGWLEu2McaYutdcdFx1MDAxOEujUVvTp/ucnpF+rqyurkV3XHUwMDFkb+2P1TXvtuxcdTAwMDZ+pev2197Z7TdeN/TbLdzF4s9hu9ctxy3rUdRcdP94/77pdlx1MDAxYl7UXHTcsufc+GHPXHLCqFfx20653XzvR14z/Lf9feg2vT877WYl6jrJSda9ilx1MDAxZrW79+fyXHUwMDAyr+m1olx1MDAxMHv/XHUwMDBmfl5d/Vx1MDAxOf9OWVx1MDAxN/gtL25cdTAwMWJvTWzjhGW3XHUwMDFltluxnYxpJYFcbjlo4IdcdTAwMWbxTJFXwb1VtNZL9thNa3ew7a9v1GuNw6P92uXn453d/r5Mzlr1g+Asulx1MDAwYu4vgluu97opm8Ko2254XHUwMDE3fiWqP16z1PbBcVx1MDAxNTeso1x1MDAwMYPd3XavVm95of3udLC13XHLfnRnt1x1MDAxMTLY6rZqcSfJllv8XHUwMDA0TDlcXFFiuFx1MDAxMoNcdTAwMWT2UC7AoZRRZncxo1x1MDAxNM9cdTAwMTi12Vx1MDAwZXBcdTAwMDTQqN9I/EqsunLLjVx1MDAxYZrWqlxm2kRdt1x1MDAxNXbcLo5T0q7/+HVcdTAwMWRJQSpcbmBwREApXHUwMDE4NKl7fq1cdTAwMWVZczRzXHUwMDE4lZRcbs2pXHUwMDAyloxL6MVDXCK0XHUwMDA2LiE51FrQKVVit/gne0HrbrfzcN3WYktT1tuPWymfSlx1MDAwZe51Ku69XHUwMDAzUCk5XHUwMDEzaCmXLLmg6GdcctzZ6lx1MDAwNUGyrV1u5PhMXHUwMDE4ud1ow29V/FYte4jXqlx1MDAxNOxcdNww2mw3m36EZlx1MDAxY7f9VpRtXHUwMDEx9/uh2233655byem5cF/HdpeAyL6Sv1ZcdTAwMTO3iT9cZv7+511u6+Ihta+RwUy6W0m//3r3RDxLkt36iGdcdTAwMDVUXHUwMDEyTVJccibhueyT7cMvnXWz8dm/8b5cdTAwMDU7p+ftT0uPZ0Ul4llcdTAwMWJcIllcdTAwMTbP3MGQxlx0XHUwMDA3/Mf0y8HZOIRxjfiQglBcbjRcdTAwMGbNyjhMMcE5XHUwMDA1wVjyVVx1MDAxZsBMNTWMS6lcdTAwMTKY/1x1MDAxZs6vXGLnwiG1r+xgPlx1MDAxMcxdr1x1MDAxY937clx1MDAwZaKFVEWIplxuXHUwMDA3jFx1MDAwYlx1MDAwNXpqSJ9/b1xis8v5XHUwMDA1bFx1MDAxZVx1MDAxZV1Hd5dHSjZngzTNuuDjcWFcdTAwMWIpylxcMzRwR2mBVCSDaClExognYfg3KEuvKlx1MDAwMHJcdTAwMTKyTK7pXHUwMDAwszpcdTAwMTXFXHUwMDFlUMqBMMFcdTAwMDQkXHUwMDA2z1xypVx1MDAwMyf6mfK0gc9E3m1cdTAwMTJ4Ulx1MDAwM1xcPWq4ncjb0Xe1T0dbknxcdTAwMDH6w19cdTAwMWK0+/Uuv9v7gzfV0Y3r9Xav3fN+dHHEXHUwMDBmw8Nob/gsj+d3LepS/T46+lx1MDAxY0PLWMxcZn3/NFxcaCFcXDDVcmGQ1k6NlvyLufRo0Vx1MDAwNWjR4DxcdTAwMGIv4ymsyEFcZuNZxFx1MDAwMKXKMMlnSGuh/bDotFZtt6Iz/0csiMjQ1m236Vx1MDAwNzGvXHUwMDE4bI6d0mrBXHUwMDFia5Oz59393vDu/vx77fPfa/9KX9rQi1x0XHUwMDFj2meGXHUwMDBl/lx1MDAxMPi1Vky9sFx1MDAwM6875OCRj9pv0KDpVyrphFFGi1xc7LNbmibOt7t+zW+5weexXHUwMDA2z560kPNcdTAwMTYmLcGUXCKYJNnUMCTVjdb2xkWTXHUwMDFk7PbPvm9dnVx1MDAxZJWbfPlhSFx1MDAxZKo5Q6bJjaaSXHUwMDBmYVFcYuEg61dcdTAwMDb5qFx1MDAwNinUc3A5hzymKCVcblxy0U+H5Wx57H5kw89cdTAwMDcnXHUwMDFmLjpy0z24U7J1XHUwMDFhfe3fVvNcdTAwMTNOjK2JeWxSesw/4fKlMfSeQlx1MDAwMCHTXHUwMDA0LZWeXsiNv8xLXHUwMDBiIDlcdTAwMGVAmMzMvFx1MDAwMDSPxEa1llx1MDAwNNW+nIFcbr7hzOYtOrNNSFx1MDAwNlx1MDAxMzObNzGz3VPbXHUwMDFjUFwiqSpcdTAwMDIlXHUwMDAwXHUwMDAzxSVMXHLJ8VR7SSEpNHU4IZwrKrVG2TNcdTAwMDRJhTnN4GZNWTHNxGtULavx6azqoqRiUo5iXHUwMDExUypcdTAwMDDmKq2I1Pa/XHUwMDE4hSaAY00kWnMlXHUwMDA1g5HSikBcdTAwMWLwXHUwMDFi0DdaWUny3WPdf1x1MDAxYcpcdTAwMTdDu9yzVq5cdTAwMTNcdTAwMDdcdTAwMDNcdTAwMTWOlK3uXHUwMDBiW1xiXHUwMDAzwVPtam4njmeDwXzYNci5w/WcXCJ7dvrkR61xcvDtU/PM6NBcdTAwMWNdnlx1MDAxZbM8e9BcdTAwMWPCLP1AUqRMbFx1MDAxNchcdTAwMTF7XHUwMDE4sVU9Jlx1MDAwNGZcdTAwMDKQXHUwMDFj4/6IWXMuJmVcdTAwMDPBXFzrScWebF8jPpx0t5J+n4mcayOzW1x1MDAxM26BmVRSdIqpXHUwMDAz2aHZuNBcdTAwMDY2TrpcdTAwMDem8uV6//r6Q+922Vx1MDAwM1x1MDAxOVx1MDAwMHFQgiB70KhGbVx1MDAxMW0okiFcdTAwMWVcdTAwMWNFiFx1MDAxMdxcdTAwMDBcdTAwMGVcdTAwMDFJz7W8XHUwMDBlPVx1MDAxN1x1MDAxOHpcdTAwMTFcdTAwMWJcdTAwMGIrM92Prdzp175931xc3708//bps/6q6mqDPYeev1C3k1h//lx0p7T2W2f/ekdvVIOvXHUwMDE3f7ntvV23tHnWfltFMVxyxTVkQe0sr2HTU5fxw7e0iFx1MDAxN2NcdTAwMTGvucPmhvh56Fx0rVx1MDAwNDpH+ov9L8iJ20XLiVx06WuinLidKCdcbivVKchlQGlcYqXUwPRcdTAwMDJfbrPaycc72DpcdTAwMTW9nql978nLzt2yQ1JcdTAwMTnioGRcdTAwMWWpU1x1MDAwYtxupFYvNbeD3DdcdTAwMDeAXCJcdTAwMGJANEzb4MhcdTAwMTZcXFx1MDAxM1uufPNcdTAwMTR80yGIWlx1MDAwM1x1MDAwZvB6uDVv9brn9bx8XFzroYNcdTAwMDawXHK8ajRcdTAwMDbVUbtTXHUwMDA06aEvk8XvsEFjcVtYXHUwMDA2oHRMOqXcIJtcdTAwMTdPwO748V5S7GqgXHUwMDBlXHUwMDAwXHUwMDE4SbmmXFyZXGaCNThcZlx1MDAxOH1BXGZT7lCFSoZcYsZcdTAwMDQjyYBcZlx1MDAxMG2UwzhcdTAwMDGJTYhSSo+sl1x1MDAwMkzzgmj2XHUwMDAypPo161x1MDAwMONzwWq2XHUwMDBlgNFXgWGUaaF5stZg9VF3S0ehKJSzVlx1MDAwMcbn12FrcDhccjNKcCEwL+SWXHUwMDAwXGJcdTAwMDAj2MagXHUwMDFlXHUwMDAypkdsekslgPVCJ473jvhv0t9K+r0oflV8t9lOe2lqdkFcdTAwMTSuXHUwMDEyY4Rb5snp9Ms+x1x1MDAxN3qWNIBh8HK0XHUwMDE0dnEnXHUwMDE1SpiMXHUwMDFlwKjgMGUwglx1MDAwMTJcdTAwMTHgJmPY0+LYfUEzt1x1MDAwMKByuFxiZcaRoCG9IPUhZiFOXHUwMDE5oF2zyILnkJJnrFxmeZff7yTNLi5PXCLYgNOrvd3u7dFx+aJeOjmeVrP/pc7Pt672zlx1MDAwZvC6d3+cd8PN2+rB/DiUVjzh7i+k2XnxQlx1MDAxNiaBaOtPU0M0/2IuPUTNWIgqjvKBXHUwMDAzkZZoPFx1MDAxN6LjanR5cmF0/lx1MDAwZrRWXHUwMDEyg/lbWX89k2Bvty5R9/5cdTAwMWXL4MVcbvVcdFkmS/SHXHKdXHSBjFx1MDAxNM/BI/9QWpjpk+TJlemTm6PNXHUwMDEyLdPti7vatquDcNlcdTAwMTGokIMgK2RcXFKquExcdTAwMTWm4/k+TVx1MDAxZGOsXG5cdTAwMDDQ3OiXypGU58zyjep1ZVxiVUy/XHUwMDAw/F4x0yxUrW9ZsKzW3Vx1MDAxNuKwm1x1MDAwZu7FqvVhg8aCuFCtKz6O6zKM2IRNL9bHXHUwMDBm97LCWHNcdTAwMDcpP1hcdTAwMWOPwlhcdTAwMTPqXGKhha1+XHUwMDEzolPMZr4wNlxmo1x1MDAwNWpNlN2a4agksXNcdTAwMDBqQVx1MDAxZFwiXHUwMDA0xWCj8Vx1MDAwN1LceLDIRtgpU/1cdTAwMDIludeU7OOTw2p6qlxcoTI2wOyEoFxyeCzV6GHeXHUwMDFlXHUwMDFjylx1MDAxNzNtz/GyKM5cdGpx1KlKiFFjhMM1XHUwMDAwupZUWinJzYhRb0qxXHUwMDE3+rB9jXhv0t1K+n2mSXtKivVcdTAwMDBcdTAwMDVcdTAwMTTtXHUwMDA0zZk+jqldz5elXm1cdTAwMDM2LkNZ+Ytz9/Ro2eNcdTAwMThyfMdII6XAK0+0XHUwMDFjjmNcXChcdTAwMDe4jitIhEry2ktqhTBcdTAwMDQlXHUwMDAxWfD0Qalx2bioXHUwMDA2/CvtnqhcdTAwMGa3lZ0621x1MDAwZZ4/Z/9Wup1UVsg/4YuSsrGgL1x1MDAxMlx1MDAxZkZcdTAwMTXSXHUwMDE2VJqc2rtKp7+RZfxlXla4XHUwMDAzjIO7lo6ZXHUwMDE33OcxYc+IXHUwMDE0KJhmwftcdTAwMWKesI9cdTAwMTY9YT8hc02csI+ed2eLLr6zhVx1MDAxOUzDVlx1MDAwZk9ccstcdTAwMDNSql5cdTAwMWNdRKWQ7ZQvvG+eOP9SKoBludtcdTAwMGXD9bpcdTAwMWKV669cdTAwMGZNpHNcdTAwMGUyPs24Xb+YWmJcdTAwMWJ7XHJoh1x1MDAxMW0pIVx1MDAxNUSl65TzhqZQXGbZruFcdTAwMDaZJSbr1PrsoVx1MDAxYq1cdMdcdTAwMTbI2URcXLdcdTAwMTlcdTAwMDUuoFx1MDAwZeTmXHUwMDE1bkmjT1x1MDAwMO7sPitk4UM+7O3wXHUwMDFhuez0qaTSb7tl3W5tXHUwMDA3VyGr1Fp998dOUSrJ+N2Uj1x1MDAwNHhcdTAwMTFvxcG3t0pcdTAwMWJOhcy6q33+XHUwMDA3XHUwMDExhkrQRKpcdTAwMTdcXPolXHUwMDE0cVx1MDAwMKhRQJhGRZ6TVzhx7FJoSbhBXHUwMDExh/pqhFcqXHUwMDAzXHUwMDAyMM+8wsKwubmrXHUwMDE3XHUwMDA0fifMd1ZerHNcYlx1MDAxN1ZdmelXJ1+VTndcci25n8pcdTAwMDErl1x1MDAwM6M/qkZvXHUwMDE2b11gbMWAhY5cbkQwpDU8VVa9VzlcdTAwMWElNXqytM8jXHUwMDAwMY9qTd5cdTAwMTIphyiD0TteXHUwMDFmQSCnVkPtgklbXHUwMDE2tus8XHUwMDE4KtBRXHUwMDA1JCVSXCLD2VxmJdk34ammcH5AKo5xRD1hXHUwMDEyvXlrbrauv3/8us+jq1x1MDAxYrfqXHUwMDFk7+1cdTAwMTXd47okjootlIP5X2AsI4qAyD5uRWEoI1xc2ck5JVKqeTZPvVwiRLyQp1x1MDAwMrpcdTAwMGXG2te4dW9cdTAwMTGOKkFcdTAwMTQ5KqPx04fQW6f21HOXlEo/tpplJk5O9XW9XFzz5e6yeyrHXHUwMDFjT1x1MDAxOUFMYlx1MDAwZeFcIsNcdTAwMDCEdOzDkYjUklx1MDAxMmDPm8ii7ErrvHvX5lx1MDAxMlNcdTAwMDHiqDpLXHUwMDA1fGlcXHX8o1x1MDAwM3Rx4UNrJGmM8enDqiq3dvfDm1x1MDAxZF1vymr3MvhQqvpmtsLH4vgqRUnlUKGZNMo+XHUwMDA3avg2S8GIozVFSYNcdTAwMTRcdTAwMDFkXHUwMDE2RfNjq9xcdTAwMTBHWSdcdTAwMTSA0VvTvFx0XHUwMDFi7iBcdTAwMTNcdTAwMDVqn+oh7TrQrL9yaiTmhtd43Fx1MDAwN1+Iu45Zxc9cdTAwMDE4t3Cd2lt31zuNnb1bV1x1MDAxZl/pfr9zXHUwMDFjnFx1MDAxZpr9ZVx1MDAwZq2KO0rZKVx1MDAxMeuvOlWHi52VY9RcdTAwMDP0JFx1MDAwZZpcdTAwMTIqnuWtXHUwMDBmRfmc0KpcdTAwMWNcdTAwMTR3qGONQHVl1ySPuirTjp2HQkuJoExcdTAwMTgyqqwk2FuDXHUwMDA0vEJsfbqzrjxcdTAwMTSp19xO5yzCLtdcdTAwMWXn9NBqv/JQ2Uu6Wbvxvf5GXHUwMDFls4pftiRcdTAwMTZcdTAwMDPAeplnbf75a+XXf1x1MDAwMfpa2G0ifQ== + + + + events.Key(key="T")events.Key(key="e")events.Key(key="x")Message queueon_key(event)Event handlerevents.Key(key="t") \ No newline at end of file diff --git a/docs/images/events/queue2.excalidraw.svg b/docs/images/events/queue2.excalidraw.svg new file mode 100644 index 000000000..d4ed6be00 --- /dev/null +++ b/docs/images/events/queue2.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daXPbOLb93r9cIpX+8l5Vi1xy3Fx1MDAwYlxcXHUwMDAwUzX1yrvlxI5cdTAwMTfF25splyxRiy1LskSvXf3f50JxLGohJVlL5ExUldgmaVx1MDAxMCbP3Vx1MDAwZi7++u3Dh4/RUzP8+I9cdTAwMGZcdTAwMWbDx0K+Vi228lx1MDAwZlx1MDAxZv/wx+/DVrvaqPMp6Pzcbty1XG6dKytR1Gz/488/b/Kt6zBq1vKFMLivtu/ytXZ0V6w2gkLj5s9qXHUwMDE03rT/z/+/l79cdP/ZbNxcdTAwMTSjVtC9SSYsVqNG69u9wlp4XHUwMDEz1qM2j/7//POHXHUwMDBmf3X+j82uVq2HnWs7R7tzU6D7j+416p15XCKR4tOye0G1vc53isJcIp8t8WzD7lx1MDAxOX/oY+FY1pu721x1MDAwN42709rGTqV1+bTXyHTvWqrWakfRU+3bQ8hcdTAwMTcqd63YnNpRq3FcdTAwMWSeVItR5fszi1x1MDAxZH/9vWK+XeFcdLyebjXuypV62PZ/u3w92mjmXHUwMDBi1ejJXHUwMDFmXHUwMDEz4vVovl7uXGbSPfLIP2VcdTAwMDBFYFx1MDAxZFx1MDAxOaW1QjSWXk/7XHUwMDAxMnxSKyc0WWONUYawb25rjVx1MDAxYb9cYp7b76Lz6U7uMl+4LvNcZuvF12uiVr7ebuZb/Lq61z18/6tcdTAwMDOSioxUyqFcdTAwMDDFd3u9pFx1MDAxMlbLlci/XHUwMDE2XHUwMDBiXHUwMDAxSJJSW5RGQXe27bDzZow2xlxup8zrXHQ/hWa22IHHv/tcdTAwMWZsJd9qvjy/j52pxqbvf9yIYav7y3fNYv5cdTAwMWJcdTAwMTAkXHUwMDExgjZcdTAwMWFcdTAwMTGlfT3PeLvmk/W7Wq17rFG4XHUwMDFlgp12lG9Fq9V6sVov9/9KWC8mnKnl29Fa4+amXHUwMDFh8TT2XHUwMDFi1XrUf0Vn3JVWq/FQXHTzxSEjJ55r+uG6wuQ/3e8+dOHT+eH1+3//MfTq5HfqP1x1MDAwM2+zO9xv8a9//zGZXFzHxbZPrqU1XHUwMDEy/VvrXHUwMDAyZJRgXHUwMDE3XHUwMDFmt+uHNbm/cqLXilLKp1P8urX0gk1cdTAwMTiAQaeVllx1MDAwNqxm+e5cdTAwMTVsXHUwMDE5KFx1MDAwM1JoaYGUk6J/arOTa1xmLFx0x4IpXHUwMDE0KiMsdoW2K9fGXHUwMDA1pNFJLdFcYsDYbL7LtZaSQHfVzy+5/oFynfxOO2f73+aEct1cblx1MDAwYtE3VFx1MDAwZlx1MDAxMW5cdTAwMGKq/+h34TYgyFkruiBcdTAwMTkl23pcdTAwMTez6vhuf0tcdTAwMWWXXHUwMDBln45WN6urXHUwMDBme2+TbdmPwe+/126w0zJbmy1VQGCsklpb/qe74uJHXHUwMDAwZ1x1MDAwMudcdTAwMDRcdTAwMTFYh85p0zeziUT7d1WgsKSVXHUwMDFhYrCpK1x1MDAxYa+SbFx1MDAwN0TXXHUwMDE5XCJWMNrNXnRfcfVXXGZ9L6/2+jS7cnZcdTAwMWGFZ1x1MDAwZW9Xwp2zbPSw1vxcdTAwMTiH6SveovAx+vh64u8/foZhe67+Y9xcdTAwMWJ2h/0uqDNUjqky3zPPmLhcdTAwMWIjksRdXHUwMDFhUsiuKcmx5T39MS+tvFx1MDAwYpcm71xiJlCzkvd0XHUwMDE3XVx1MDAwZpF4wH6JZ/3EXHUwMDEzVSrmc4wt8m3/w6KtdalRj46qz/7Rg+g5upm/qdY6XHUwMDBm+fVwXHUwMDA3qT7mvfdzXG4+hU//c1x1MDAxZD79819cdTAwMWbDf3383/izbYdcdTAwMWRcdTAwMDeV5+d6fnmlVi3XOyEjXHUwMDBmXHUwMDEwtnpQXHUwMDFmVTnGfb3gplosxq1ggWeU5zFb2XGsV6NVLVfr+VoudcJvt8RSOpckmyDY9+T34cZcdTAwMGagm0BQyX6y95VTKp5+NjuGtnaWXzalXHUwMDBljJLkUKFjV1x1MDAwN/pssVCBs8ZZJVxmsG+ippHN6W0xsVtG1mo5uWBOY4tLXHUwMDE2Tyh7sEVCXHUwMDFlZTZcdTAwMGLbUT17tT290fwvXHUwMDFmdpSJXHUwMDFmfsPlM/FSxtI1/TZeXHUwMDE44jBC4vg+ffpzXl49XCJS9VxiqkDNSo/MxMZrhWTi7+W/wcQ/LtrEjzCKI03845QmXHUwMDFlY2++XzSVcFx1MDAxY+lrM777fWLv7k5LVXyqf3Lbd+XqMUVN/Vx1MDAwZURTXHUwMDA11pJBpYXSZGLa6ttcYi4wXHUwMDE2vOdttXFcdTAwMTLtNLI5vY2X6KQlqWBcdTAwMGU58DT7tpYt3lZutrPQus3kSoXyVrNcdTAwMDJcdTAwMGbTm81fw85j2FG+w/BcdTAwMWIuoe9cdTAwMDAmMVx1MDAxZFxiXHUwMDAyyLDcqq5uXHUwMDFlpaDSn/PSKijWQGlcblxuXVx1MDAwMDNTULNwXHUwMDFlkDg45Hm8ISX4jp2HaNHOw1xiczvSeYimdFx1MDAxZYSEJNmUvqpsSajx81x1MDAwM6JcXCg+fd543nzOrZ6a1np0YrYuXHUwMDEzZLPQarTbmUo+KlQmrsXNXFw+Ob5cdKRcdTAwMDGSTrGEOid7xVNcdTAwMDdWXHUwMDBiw+60clx1MDAwNlx1MDAwNcr5pe/Yq1xiWFx1MDAxMtBnTaWMXHUwMDE1Sbt1OFx1MDAxMoFlh0YpQuzUXHUwMDA2+0WXWMU4K/Fcclx1MDAxOYRpRJescjSB6L5cdTAwMWS1XHUwMDA2XHUwMDEyXHUwMDEzzmA58lwimKB2/PDl7Ph+t+lobbN+vXffzl2VaDdcdTAwMDGzfbj7cWjFwFxuzViUgpW0xD6wXHUwMDAyaTYhPi5nXHUwMDE42XmCXHUwMDE1XHUwMDAzIUn74rU0zlxmMyw2IFx1MDAxMqhYj1x1MDAwMCFcdTAwMDNlXHUwMDAwrdJcIlnygcpyW5pUuIa1WrXZXHUwMDFlXG5W0olMXHUwMDA3p4W39+P7Puu3bq0sXHUwMDFh1fUoV6GV+pdqWTxfvVx1MDAwNauL83wk6MBcYumENlx1MDAwNlx1MDAxND952Vx1MDAwM1apXHUwMDAzjUaQY8xqXHUwMDA2K01Fcvi9lNegYVx1MDAxMKksMEpcdTAwMDBoR+xxgoonZ16hKiHQRMqxUlx1MDAxNYBcYjFcdTAwMTftO1RcdTAwMWRJY1x1MDAxNDtoPydUjUgsXHUwMDE2+EJcdTAwMDJpJeT4Sb6TxtZOY2vvYHf/5jh7lFx1MDAxN7lneX605GDV4K1vJzRX6ND2eekqQKk1Y4RcdTAwMDVcdTAwMTdcZsXM3dvAeimEnlx1MDAxYljRSkOCZe8nXHUwMDA1KyWnvdjYgEOYgGWSoULrRu+F1fPTs8v80VWrjodPS1x1MDAwZVYrXHUwMDAy0pbh4Yz3XHUwMDA2bS9WkVxyL1tV7SyHcYyT6VJeXHUwMDEyLtmvmlx1MDAxN1ZZliSHxPie9Wqqx0o6UbNqUFx1MDAxMlmnjFx1MDAxZmWd5MzVYSV/U9k938k837ab1y6iXHUwMDA0rL6V7ThztIKgXHUwMDAwpFJAXHUwMDA2vVbqJTFLclx1MDAwMTgvs6y1PMV5jlxcRyNcdTAwMDPHYZZX4GRUTD2+XHUwMDAyVstcdTAwMDDNNzWPXG6MjnHQvytX45xcdTAwMDWcS23l5cTQJOP9cVbVVm+fNr82M/fb59tfT7ZKZ/NMMlx1MDAwZb9hd9iX7354klx1MDAxMZO9bClYN1x1MDAxMlx1MDAxODF+TJj+mJc0x1xiUqZJmKFAWWG0L1xisveC81x1MDAwYlx1MDAwYpFGSlx1MDAxOFx1MDAwZZhcdTAwMDBcdTAwMDaLs07rd8hH8sszXHUwMDE4Y2jIXHUwMDAya1x1MDAwYqN7LkpJP+aGplx1MDAxYVH0XFz4mkmshaVe6E+WaEw3XHUwMDFh/YnG3Fx1MDAxNElFJWWSLJJcdTAwMDEhrFx1MDAxNuOHvMeZ6PK2urlXOz0pn12VVk4zN/nscjtmLFuBJasth6LaOWN7ef3SYoDs7CiUgFx1MDAxY/xOtVxc56VcdTAwMTY5xDEzgWGnT7BMKVx1MDAxZrfi8OSMXHUwMDEywnFIKzTH6U5cZjpmyidtOEpZbqlMxWryXHUwMDEylORo11x1MDAxYZRKqFx0XHUwMDAyiIddsV68P195PG89Xrebh1FUeWjN2Cmby9oy1tVWO4UgjZZcdTAwMWPi92BVkVx1MDAwZZBDXGb5Ld6FfiFasqVlUmhfTsS3xLy/1qB0zv1cdTAwMDRryyhZsI2w1lx1MDAxOFx1MDAwYuPzYS42L53UT7BfrFx1MDAxZcv1c7neLlx1MDAxZp4svVxcXHUwMDEz+Fx1MDAxNaPgWIU5xyFMb9LVSzU/dDZR5Dx+xfyoalx1MDAxOEjl2CfloE5rXHUwMDAwXHUwMDE4YoX8UiQgskpY9lx1MDAxNy1cckg1O1fOL2Waw+qUX0LtP5MuLEt4pZ2T/S9zQplcdTAwMWVBdVOJnqVcdTAwMTRoXHUwMDA1mHh9YZRor+7fXVx1MDAxY8r26flDTWy3rlx1MDAwZlx0sVx1MDAwMctcdTAwMWXlZVhjXHUwMDA2noPKVpmkYC+lN84z2lx1MDAwNb6K7UChXHUwMDE22s3Cu5yG6SZYs/ty5IKZboXWYeZi5XRtu1x1MDAwNNW7jWN+zc/H+9OTvH5ccjuPYUcloYbfsDvsd82yqIhcIpnpZm3/4S6bXHUwMDA2lNPG2lx02lWkPufl1U8yVT9ZXHUwMDFi0Kz00yyIbpI9VH415i39KH505uk90eRHWNu50+S1SF7B4pRcdTAwMTakXHUwMDEwx09LRe58y17WWpXN58+5q0+5i82tKImJsUyyyY4/kSSQVkiUvYkpPlx1MDAxMXSWt4DlmExN10dmetfBXGKNPnhcXLDnXHUwMDEwfn740l5dfb4o7aysRcdcdTAwMWKHub3bcHqj+WvYdzTsKIdk+Fxyl9AhUTpR6ZGViJMszE9/ysur8kSaynNcInCzUnkz8Ua01Vx1MDAwNMLJJc+4z9hcdTAwMWJZOO9+hP2eO+9cdTAwMWUokXfv0Fx1MDAxOHJajC+ZT1dP5d3jXG6gK2Tz+0dcctkw2fz7oN3rXHUwMDAwkP1cdTAwMDF2v1x1MDAxZItnf57SYEBWKOUkkmDJmF+eUmtcdTAwMTlcYoNgXHUwMDEwWVx1MDAxM0DM6YhcdTAwMTHvNV9iXHUwMDFjkTZcdTAwMWPokVx1MDAxOFJ+UKCs027BoYRcdTAwMDGn1Fx1MDAwNML7dty6WGewfthKUlx1MDAxYeI07pFd29a/NL9cdTAwMWWph3ZOZa5lTX6+XFy/uFh25r3HXHUwMDAwaWLAcrTvpO1cdTAwMDMsXHUwMDA0YKywXGZcdTAwMDGNen7VMq1cXKDQovJcdTAwMGLGKL6ONF7cdc5cdTAwMDFbXHUwMDEyy3hGUv1oRY7OeziSXHUwMDBiwapcdTAwMDJQM1slkkJcdTAwMGa1NpFcdTAwMWVcboJDPclcbmV8XHL7aaWZrZePi5vnj7uVtdrGp690t+QsXHUwMDA0NjGBdVx1MDAxNlx1MDAwMY10IJXp5TIri1x1MDAwMVx1MDAwZqJQa4NcdTAwMWNo4bRcXOZE4r0mzzpSgr84I4d0XHUwMDE2lFx1MDAxMFx1MDAxOKklq1/tXHUwMDFjuXjrk1dcdTAwMWFcdTAwMDKAdKhcdTAwMTesV1x1MDAxN4VVh8mpXHRcdTAwMTZRYmtcdTAwMDPjpybOro4q2Vx1MDAxM1co1Vx1MDAwYitcdTAwMDeZ3U27+1x1MDAxNZeed69cdTAwMDKPUCNAsONDivqwKlx1MDAwM9ZyymotSKrp8lx1MDAxMims+1x1MDAxOSCVpc5cdTAwMTH/XHUwMDE1uGD/fWFQdYlcdTAwMTU4VEJIXHUwMDAxMD7N8su5OVxcKa1cdTAwMWPU90pfi632Sq7cLN4tOVItXHUwMDA0WmhALaxfx1xyXHUwMDAzQFx1MDAwNb8kz1x1MDAxYlxcS1O2Yk4j3c9cdTAwMDCqRiqrLL2BKrM0QFx1MDAxZOGrJrZcdTAwMWSQmpBgolx1MDAxOOvgYCVcdTAwMTNlbr/uPt6b/U+nlXpNbL+xXHLpXCJJ9zbo8Pek0X49f1/HXCJCXHUwMDFikFGsblx1MDAxOcxg5Pz8VTRcdTAwMTSwl+GZhOBLwkNcdTAwMDCrPVx1MDAxMY1naDtsIVx1MDAxMFx1MDAwM/6qNJZxTKRcdTAwMTbMub+9P6jIkthcdTAwMTD7N7vV0+yXyu7Jp+5b6sHjJEnLmVx1MDAwZjsqaTn8ht1hX75cdTAwMWIlvHNPWpJJ5mVcdTAwMDJcdTAwMTkt0NnxbUz6Y17SrCWbj1S5VTogXCIgwYrMoJnjYlx1MDAxOVx1MDAxYSm38VYlL4LK8bFmV1xyXHUwMDE3767Hqkhv5PLLQFx1MDAxYcdxXHUwMDExsNUkNp+q56o0Mn9cdTAwMTh/zHNm84+wRlx1MDAwM2z+cIpcXKWRid1cdTAwMTaMXHUwMDA1/uBcdTAwMDTiWL86XHUwMDE3XHUwMDBlz8zuZeuomTvcKOWz0cZyu3ygfdHU8lx1MDAxN5IgXHUwMDA0qD5Z9JlcdTAwMThAJdnP8vHJVFx1MDAxZV9cbptcdTAwMWZcYtHPQrGDXHUwMDE505DxfI/hXHUwMDBiNMuqRf5ucNFcdTAwMWFcYt/01yyc6eCthptALlOhmsjmd4lcdTAwMGLAQLKDXHUwMDAziGp8mJrjzY2mOP1aKT2stHdcdTAwMTGuXHUwMDFmSup+6Vm/nZ1iiP/zvTbQ8S/0dlx1MDAwNnHCXHUwMDA1gFKC8ett3Vx1MDAxYzeUmFxym99cdTAwMGbgJP5cIv4uXHUwMDA38fdcdTAwMDex+V1ipYyjXG5jhJ2gJ8Xh5Vx1MDAxNT5cItjbp88n7UyYscfl+vrSyzVh4Fh/WVx1MDAwZehZtEH0eoNeqn3mRYBcdTAwMDZcdTAwMTNfXHUwMDA3O3suv7K+14+z7Fx1MDAxNzlcdTAwMTVPxCWS+c1ghVtpYJy8qS/FL6nunJsxnT/prXbOXHUwMDBlvM9cdMU6vVxmTsmdvKTfzcw5sFx1MDAxM3ScuXiWpcszudW42C192s/WcpWd99G7Vvt9kzjqY1x1MDAwZlxydN/CbemMX4PnwDdQ0qzyZuBhTkHLXHUwMDAz4djnXHUwMDA1WjAtb1/kLteal+p0o3L7eFx1MDAxODZcdTAwMGJPutTtstxcdTAwMDO5SbIwv4b9NeyoxNnwXHUwMDFidof9rlx1MDAwN2dofFJValwi209TYubMt4OUksZffJD+lJdWl7IvlKZLwVx1MDAxN8VnpUtnwvdcdTAwMTNcdTAwMWOzcyxml7xLV/fFv0++31xi12BcdTAwMDF8v+RilFG+i9dcdTAwMDR+XHUwMDBlbd5dXHUwMDE3zvbWqfglX4n29s/3dlx1MDAxM1uWLlx1MDAxN99cdTAwMGZE4FxmXGKjhe9MKmNdsTqRjPeE/Fx1MDAxZbdW+35mMD82rla+RYe0VnC8KmxM+mJ0P1xiXHUwMDFjz5M0XHREgYP1KIlcbv062Fx1MDAwNYuu8f06ZmVL0iuoyV3LJOtQ313WjV9B3dws2Mdy9sneX8rdtdXSefnpxC053Vx1MDAwZnyvZd+e1oKxMr4v6je8YmB8/zZcdTAwMGXBnW9cZjlHvFJcdTAwMDDI01CgXHUwMDFjuWHL6MFcdTAwMDZcdTAwMTZcdTAwMDU4w+FcdTAwMWNcdTAwMDdxclx1MDAxMK+ao1xiJFx0i6amWICZ4TWNmpKywMuT01x1MDAxY6hcdJrsrWx9Pmt8LbSL5nzncdvIjcuzXFz0XHUwMDE2tC6Qm4K+0Vwi61dcdTAwMDPot1x1MDAxMOjrXsp/f1x1MDAwMM5cbuf33o5b/1k32mV/XHUwMDA2pee/alx1MDAxYq9cdTAwMDHGqSmkpG88pFl76njm/rUntFwiYlxys3By6qKgSoksKraBrN7dXHUwMDA0u9qf4Vnt071dWa3uX1x1MDAxZdRcdTAwMWWbd4XjZpKnvixI1a7TXHUwMDE23PBLVtag6stoXHUwMDAyXHUwMDA2orPNXHUwMDE2P1x0RtN0O/KltdmdXHUwMDAxVFx02Vwiszgtmke1IKhKkbJTu2CsXHUwMDAyXHUwMDFim/FcdTAwMGJra7dnj2H2aKNw0FxcvTtcbqN2e+UmKVx1MDAwMb8sYLXsK7K2s8Yho9U521x1MDAwN1ZcYpRU7Eii9WCdikSV2md3XHUwMDA2YDVglVx1MDAxNvGC/ftcdTAwMDNrusuarFn5PVx1MDAwMitcdTAwMWRN41x1MDAwN1rR7v6FuSiXTu7yrvX5oZy5XFw3XHUwMDA3S0/665CHtGVb70j5/d178CqFsYHjqMY3aFx1MDAxMHK69oMjSH8qkMR4XHUwMDA08MyJYYtUtPRcdTAwMGK8wCm/XHUwMDE3sVSDi1RYu0jPXHUwMDFmgDm0hn45MTTJuHef39v7slx1MDAwZke3mYeHwu1D+2v1tjF97vK9XGY7KiU6/IbdYV++XHUwMDFipVx1MDAxM3BWOiFxa3KV2JBcdTAwMDVcdTAwMTjbXHUwMDFjSdP4XHUwMDBiK9Kf8pKmREHadG1gtd9Lxlx1MDAwN4ZcdTAwMWHQzrNcdTAwMTVcdTAwMWONVFx1MDAwN8O4hFx1MDAxY15r86adXHUwMDEyp7NYXHUwMDE4S5a/uS+wY1fW51x1MDAwZXhAXHUwMDE3V7UjuYSPXHUwMDBiJFx1MDAxM46wcoNkwsdp2IRp7F70q7xgXHUwMDAyPkflrPJ8eYqVK5cp5FpHn3dL9XJ5ud1JXHUwMDA2XHUwMDAzXHUwMDA3N563Yp3xLYJVv0D6XHUwMDEyhjWecKuJY/qp/MlcdTAwMTRCIYuWIa2VUsbK2HL0eEZJd1x1MDAxYTmBYdUh41x1MDAxYkl9zyhJQyB/SOwzs/XOibyj5Iy95qjUM9HHhmmzoj+3r0ru6GrroJJTh3hw/by29LSjXGaA8cuZlFx1MDAxM35xiO03XHUwMDFk6L03v+LL75dn58c7mlx0mZA63YTe5EdOyzqahPtcdTAwMWGbx0/NOvoxXFxCflx1MDAxZonuIN/MeUU3vlRHVysnbnPzZrN+9OX86Gz1dOP8aHXppdrIQHg2l187KGz/JvZcdTAwMWShdlxuXHRcdTAwMWRcdTAwMTA//DluxFwi/G5cdTAwMTVcdTAwMWOFalwi8LtFqGE2XGI5WGXH1SpU7Fx1MDAxYmhcdTAwMWOQa09qd7pni/Nfgv1cdTAwMDNcdTAwMDU7k/Ja/WfghU4o2iPK7GmbLfL9/Fx1MDAxMo2x5Xun0ag8rjdk5n5/7aZyer5xXHUwMDFh3W++jzK7XHRYvK1Fllx1MDAwZYfxXHUwMDFkKF9k3O/J6Jv+k1x1MDAxMobN99xk3PcpXHUwMDAxLYxfy2ZcdTAwMDTqYYxhwsDybKzwsZ52XHUwMDAzIaBf1sUgolx1MDAwNW9cdTAwMGVmJbpJXCLAt6PWqcSQXGL4Vfq93cavsl9X7E21JU736qtHxbtnVXu8v3xTgn2xcFx1MDAwNe3/Ut3pKY5cdTAwMDMmSTFcXK3SSrJTXHUwMDAwen6kLU9cdTAwMGLhgFx1MDAwNpTPXHUwMDEzW6GG0EJ8W1x1MDAxZOFcdTAwMTkqwneCwngg8lx1MDAxYVx1MDAxNfl9j1x1MDAwNIpcdTAwMDWnLCa1SKmATeus41x1MDAxMuvsfomCJ++MX1x1MDAwZrpoP118udtsVWBlz62V98rm4K603Fx1MDAwMTzrhYBcdTAwMTHg9SqrWCn7o1wi0lx1MDAwMVjR2fC4t0nYjNvqsL8qXHUwMDFjKE/NxyHrXHUwMDAxfTXIoNDaoensXHQ2WFxyQmfYu47npX4qoDqdXFxcdTAwMGLyPVx1MDAxNk18O4GRXHUwMDFkoGzt881u5ejE1Y+b+TD6VMmJJc808fNcdTAwMGV8Po3YzKPyPn0/Ulx1MDAxNYdcdTAwMDLWOWPQTFu4TGurMz1SwTdcdTAwMDNmQ/iTXCJVxnfY6ocqR8BW9qzaXHUwMDFkzbTbyVx1MDAxZEa3ollcXL9ccq9BrV/Jh+aSY9WpQGnyvo6RxJI7oFUx4JDUNyxjZ9HAdGo1rbHO1GBcdTAwMDVgpLr4XHUwMDFlQ+9cdTAwMGarI2ihKVxcZn5cIuArumOD9TjKStrP5chd01x1MDAxOYX526eVqkhcdTAwMDDrXHUwMDEy1dhd4DpcdTAwMTU1wSGnjL3rb5vag1xiUDFI+Jx05KZSrSNq7DqQ7GyA51OzN+KGRFhaXHUwMDA27KZ4rq5veyjt4EJr3+uCT8/DY305MbRsfV3BXHUwMDFjfcpcdTAwMWTa7fVjzK5uhZtccnszfTX8v3zYUbX74TfsXHUwMDBl+/LdwnRNYlx1MDAxZiCZzOf1IZiRQo+vZtJcdTAwMWbzslx1MDAxNu9BpatcdTAwMTlcdTAwMGWcXHUwMDE1XHUwMDA3rEJcdTAwMTjfXHUwMDFicY5qhkarmSHFe3ROk1Y/YvvQN+Vme1x1MDAxYlx1MDAwMVx0tvNGKfZcdTAwMDaI/1xi6rkqvXhcdTAwMWbFn/Scq/cj7OeQ6n00RfneQvJcdTAwMGVHXHUwMDE2pfKZj/EzrNnzfFttnn2l/f1wd0OYnZvPoV1uR1x1MDAxNYVcbsjvU+1cdTAwMTe/KG1s31xuQ41s+i1pY3zLrFx1MDAxZVx1MDAwZeaM+1x1MDAwMVx0XHUwMDAyo3zlnuLNe+OJKl+0l4o/woe6XHUwMDAzjqpB1Fx1MDAxNlx1MDAxMN5FUPXbi0X6mG82j1wiXHUwMDFlkk9/gy7Pulp8kdruMFx1MDAxZu+r4cPqsEC18/FcItBcdTAwMTFcdTAwMDBcdTAwMGay0M/5r79/+/s/t9XlXHUwMDAwIn0= + + + + events.Key(key="e")events.Key(key="x")events.Key(key="t")Tevents.Key(key="x")events.Key(key="t")Teevents.Key(key="t")TexText \ No newline at end of file diff --git a/docs/images/input/coords.excalidraw.svg b/docs/images/input/coords.excalidraw.svg new file mode 100644 index 000000000..fb925e22c --- /dev/null +++ b/docs/images/input/coords.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1ba0/bSFx1MDAxNP3eX1x1MDAxMaVfdqUynfej0mpcdTAwMDVcdTAwMDGaQHktXHUwMDAxXG6rVeUmTmxw7GA7XHUwMDA0qPjve+1A7DxcdKHJhlXzXHUwMDAxyIxcdTAwMWbXM+ecOfeO+fGuUCjGd227+KlQtG9rlufWQ6tb/JC039hh5Fx1MDAwNj500fR7XHUwMDE0dMJaeqRcdTAwMTPH7ejTx48tK7yy47Zn1Wx040ZcdTAwMWTLi+JO3VxyUC1ofXRju1x1MDAxNf2Z/Ny3WvZcdTAwMWbtoFWPQ5TdZM2uu3FcdTAwMTD27mV7dsv241xirv43fC9cdTAwMTR+pD9z0YV2Lbb8pmenJ6RdWYBcdTAwMDRcdTAwMTM63LxcdTAwMWb4abREcIO1xFxc9I9wo024YWzXobtcdTAwMDFB21lP0lRcXN/ddcqh3GlFe7dcdTAwMDd1p4nXP5Nqdt+G63nH8Z2XxlVcdTAwMGKDKFpzrLjmZEdEcVx1MDAxOFxc2WduPXaehi/X3j83XG5gKLKzwqDTdHw7SkaB9FuDtlVz47v0KXG/tTdcdTAwMTSfXG5Zyy18Y1xcIS1cdTAwMTVcdTAwMTFEsuShs0dOzqdKIK2l4ZphTnVuQHpxlVx1MDAwMlx1MDAwZuZcdTAwMDPieo/TT1x1MDAxNtl3q3bVhPD8ev+YOLT8qG2FMGvZcd3HJ1x1MDAxNpwjySjjXGZcdTAwMGJCSHYjx3abTpxEKjHSXHUwMDA0XHUwMDFiziVjgmgqs2DsdGKMVIJjzni/I4mgXamnIPlneExcdTAwMWQrbD+OXTFKvuSiT1x1MDAwMt/KISw7udOuWz1cdTAwMWNcdTAwMTApOVFKJSGpfr/n+lfQ6Xc8L2tcdTAwMGJqV1x1MDAxOXTS1odcdTAwMGZzgFZRPFx0s1RcdTAwMWKYKMrUzJC9pXW+tS3Pr02zVbnc2zrnNz6ZXHUwMDAw2SHY/ZdgXHUwMDE1XHUwMDE4XHUwMDBiLFx1MDAxODbKaDlcdTAwMDJWhinBXG5LSoyii0QrQ5hIQbVQRFx1MDAxOaVH4Uo1klx1MDAxMjNcdTAwMGU6QiVcdTAwMDNoj8BVSmNcYjVcdTAwMDS/Ybjanue2o7FglUJMXHUwMDAyq1x1MDAxMdhcYi3MzFg9XzPfhFPdPmp2tXdcdTAwMTFEe/5e4/M8WCXLw6owiFx1MDAxOclcdTAwMTnAQ1x1MDAxYWL0IFa1QEJcdTAwMDEoXHUwMDE4pVx1MDAxOGuB+Wuw+r5hXHQq6ChOXHRDXHUwMDFjUyqMpPCLa81HgUooXHUwMDEyXHUwMDAwXHJcdTAwMDOaiiljNFx1MDAwN45HoFxuiFx1MDAxNEiVQ/D/XG6ocKOJToBcdTAwMTgjYU0hbGaofnXx93XiXHUwMDFj8Z3908PWXHUwMDA2/evM7norXHUwMDBlVS1cdTAwMTGjilx1MDAxOSap1ppcdTAwMTA1hFWOXHUwMDAwXHUwMDFjSmiDXHUwMDA11yQnu/Nh9TtI+KKwSlx1MDAxOSxcZkyI/6moKskmYlx1MDAxNVx1MDAxNlx1MDAxYWpcdTAwMTjFs2M12rwpy3W3s3dcdTAwMWWas9qFiNbL5Hi1scpJXHUwMDAyRlx1MDAwNoNONFx1MDAwMJaTIagypDBcdTAwMDYzZFx1MDAxOKgue5VcdTAwMDN4T+h38FSLQiphXHSjXHUwMDE4e8tItcIw6I5Nr9jExZ8rpjCTuVx1MDAwNfE5mKqD+0atpiW/3qVhyexcdTAwMTFIXHUwMDAzK1x1MDAxM2DqWDWnXHUwMDEz2v89UJlUSEkh6WBGxVx1MDAxOEFcdTAwMTgyLblAd4pcdTAwMTFVXG4yKTUmi5JitPNcdJDwUMJoMUf6lFx1MDAwNjcnILlcdTAwMDBcdTAwMWL9XHUwMDAyQObisMJ4w/Xrrt9cdTAwMWM+xfbrWc9cdTAwMTNsXHUwMDBi/apBpecqO9s7+5ub3Vx1MDAxM6dy24lOon1cdTAwMTmfZrhKkFx1MDAxNdQ6UTqghFx1MDAxOUHBrFx1MDAwM+UlOFx1MDAwMpI7qGm1XHUwMDEzVCNB01F97HjIoreiuFx1MDAxNLRablxmz31cdTAwMTi4fjxcdTAwMWNs+iDrXHSVXHUwMDFj26qPeZR83zDn2slcdTAwMTWzKkjyyf4qZKBMv/T//ufD2KPXRqGTfHKgya7wLv/7xVx1MDAwMqHkcGM/k4XEXG6QSNTsXHUwMDAyXHUwMDEx3G59bVxcnljd06tSuXFz0vWv/7pYfYGAXGZcdTAwMTFWMTUkXHUwMDEw1CDJsVx1MDAwNpVkXHUwMDFhw2rOhlwi+olZrEGQepixOkFcdTAwMTDBZEC9nlRCc2a4WrZMXHUwMDE4pvNcdP0yZeLwRl+FR+t39dYhO7mp4jiu7tTHy1x1MDAwNCZcdTAwMTTUjIO6q0RLiaa5w3pCQTCSvZF900oxip3ks9aHzVx1MDAwYnVcIrZv43EykUPZkExcYkGYJHmj/5xKTJ/HXHUwMDE1VVx0zjT43Vx1MDAwMY6mKkFcdTAwMDTSSi/WR+Sy3qysNSpcYlx1MDAwMGdcIlx1MDAxOPD051x1MDAxYtk+in7kQDaT6Fx1MDAwZqCrR4R+z8NcdTAwMTMkp7lcdTAwMTLKSTZjL5CbRuDHx+59byVcdTAwMWJo3bZarnc3gIRcdTAwMTT2SdEgP0mRXHK3SzM6PXDguuc2XHUwMDEzTlx1MDAxND27MUiW2K1ZXr87XHUwMDBlckNag1x1MDAxYltwubAyXCJcdTAwMTdB6DZd3/Kq/SDmoqiavI+iXHLVIIM4O+LZQt9US7aiXHUwMDFjXHUwMDA1XHUwMDFkQorgXHUwMDExknKMXHUwMDExJKl60Gz/bJJmsUwjqUnqO1xc5EzVUkg6PXVcdTAwMWLA1zwknTd1mIukd6tA0rvpJJ26fcTYRNMtlZGJY8mW2+eYKr/qzeq5+ra5xaPTXHUwMDFhoSdl91x1MDAxMs/H1OVtIFx0IdBwRs45RYxyOuDE5yps1pVNOOejXHUwMDFjZYlcdTAwMTCMddnSoLEuW1HCtFFkuVuZWsFgyFx1MDAxN1x1MDAxMGoqXHUwMDE2J+Z+Uk0sXHUwMDBlJU5CQs7D5cxA1MI76HSFdqtB6aLEq3LvXHUwMDFiu1xc9SVcdTAwMDOEXHUwMDE4XHQ96uu4ZFxic2rkK8G4iPpQsrPKwOjllvNlZH5cdTAwMWFzpfBcdTAwMGJA+fMyv/tNZ9uznS1TvdhVpVp5o0qP4tzq9atA9PhZQIFIYjXc2lx1MDAxN1x0WMhcYoXRnl0kqvbpl8P7vc+4XHUwMDFhhpVyffdr4/42Wn2RMIhcdTAwMWI6Ulwi4onfZJpqsshXXHUwMDFj5ilcdTAwMGVRXGYzwzWZx2a+TYnoVlx1MDAwZU7Lbjlyb732l42Se3h3fXw1oTiEXHUwMDA12Fx1MDAwM5gzpmBhXHUwMDE3ODd7hV/VodzDzpx6MjptN5Qkb4O9IPecPpUrqlx1MDAxMZKDg1SDfqHnalx1MDAxNdJcdTAwMGKWiNnqQzrZc9RCLcDK9mE0JvOcrvhcdTAwMDPwennmXHSCkzexv8pDUzia26JcdTAwMWbmqOGgXHUwMDExlJDZs87pjmxFOSpcdTAwMTRF4JuH8k7BKFqF0pDSsCwl3nS5/JyetlxyQGvl+flcdTAwMTYqQ5P4SfFEfjLNXHUwMDA1y7+g8lx1MDAxYztcdTAwMWT7vGxFYSm4a1xcXHUwMDFjXHUwMDFkXHUwMDE4vGNcImfl92HpSFxyJt1gwVx1MDAwNqnXloSeMdizsFx1MDAxM1wiXHUwMDEz4PfZkl++1FxmgzYtjUC/4Vx1MDAwZlx1MDAwNfz7KrDoMZK5qKRy7zRcctdXXHUwMDE5JpqJXHUwMDE3uNHKmXW4WflyXHUwMDE4XHUwMDFknYlcbrlukMvd49aqc4lrgyC/XHUwMDE53JZM7SjTi99cbpmRUZxcdTAwMWLMtDHLffFOU54vr/9iVGFcdTAwMTbzOHFt4jR5I1x1MDAwNM9eXHUwMDAxKlx1MDAxMa5cdTAwMGVD3N611i/3jsXJie9/2171/VxuqSmSckx6J4hChL9293/KloVcdTAwMWPzTutcdTAwMTguKYqTfzpcdTAwMTJcdTAwMGLYVpzGJVx1MDAwM7dd3upcdTAwMDTz3rTjVeDSYyQ9Lr17tMBFq90+jmGEik9lKphcdTAwMDS3/viY2fWKN67d3Vx1MDAxOIeC9JNcXDXlZ8JcdTAwMDU7mYJcdTAwMWZcdTAwMGbvXHUwMDFl/lx1MDAwNeEmVVx1MDAxOCJ9 + + + + XyXy(0, 0)(0, 0)Widget \ No newline at end of file diff --git a/docs/images/layout/align.excalidraw.svg b/docs/images/layout/align.excalidraw.svg new file mode 100644 index 000000000..fd2f8e94f --- /dev/null +++ b/docs/images/layout/align.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2ZW1PbOFx1MDAxNMff+Vx1MDAxNJn0tXGtozszOzuUy5altEOBXHUwMDAy2d1hXHUwMDE0W0nU+FbbIUCH776yk8FJSCBAmmY3XHUwMDBmmVhcdTAwMTfrSOd3pP9RfmzUavX8JtH1zVpdX3sqMH6qXHUwMDA29bdF+ZVOM1x1MDAxM0e2XG7K5yzup17ZspvnSbb57l2o0p7Ok0B52rkyWV9cdTAwMDVZ3vdN7Hhx+M7kOsx+L74/qVD/lsShn6dONUhD+yaP0+FYOtChjvLMvv0v+1xcq/0ov8esS7WXq6hcdTAwMTPoskNZVVx1MDAxOYhcdTAwMTibLv1cdTAwMTRHpbHAXHUwMDEwYEJcdTAwMTm9b2CyXHUwMDFkO1xcrn1b27Ym66qmKKpnW1x1MDAwM6EvTz5cdTAwMWOdnr3fa1x1MDAxZJxkUVx1MDAwMnvVqG1cdTAwMTNcdTAwMDTH+U1QWuWlcZY1uir3ulWLLE/jnj4zft4tbJsqv++bxXYhql5p3O90I51lXHUwMDEzfeJEeSa/KcpcXPe+dLhcdTAwMTCbtark2j41sHQkRoy4XGaq6Vx1MDAxNn1cdTAwMDGowySarFx1MDAxOJqzXHUwMDFkXHUwMDA31lx01pw3bvmpXGZqKa/XsVZF/n2bPFVRlqjUuqpqN1x1MDAxOE2USOZgLuTEIF1tOt3c1mJcdTAwMTCOIJiPja9LXHUwMDE3MExBuEJWXHUwMDFlLFx1MDAwNk32/Vx1MDAxMoZ/plevq9JktEr1rHhcdTAwMTgzuLB1d4ykqnM/8dXQ41x1MDAxNlx1MDAxNVx1MDAwMClcdEOYVMtcdTAwMWOYqGcro35cdTAwMTBUZbHXqyApS+/evlx1MDAwME6MYVx1MDAxZZyYcNc67Fx1MDAxOXBcdTAwMWX+eXmjm4KdXFxsN1x1MDAwZre/tHZC+s28XHUwMDFjTlghnMJhhJFJXHUwMDA2hnBcdTAwMTKHulj+ZDiJM4dMYFx1MDAwZVx1MDAwMuTKXHUwMDE5bFwiXHUwMDBltKhcdTAwMDOyOjixnafgiMGy4NRBYJJsJpqA5Dw0keQu48DIwmgmX1x1MDAwN9ffZPiRXFxk/T3VXHUwMDEwaciO8jloTuH1y3ZMwFx1MDAwZZZAuUDTOyZGzit5fNNWXHUwMDE0KDxkXHUwMDExgVx1MDAwM2gqXHUwMDEw7mlEyCFcdTAwMTSPmzNiXHUwMDExXHUwMDEzbMstXHUwMDFiq9wnhSDUXHUwMDA1vFxuXHUwMDE0OZ6LXCJcdTAwMDEhXHUwMDAwU1xuXHUwMDBis8j7/n7nNHa1Pr+J6EHe3PrgrzmLyEHUKpVcdTAwMDe7I3ZcdTAwMWSKXHUwMDEwR4K/lsaW69Jl0YiElVx1MDAxOcSVzP1/4oiBz8NRWlx1MDAxMjlnXHUwMDBis9jS+GBcdTAwMDBR0NKn6UHb97RcdTAwMWY1d9abRXskMjnp9+FJbVx1MDAwNSbikr2WRVx1MDAwNC0h2NJYtFwiym7g7D+9NT6e31x1MDAxMHdcdTAwMWWN4Fwiq53AJXhhIHezXXLFaf/w6svOIblIzPlutrXeQDaAO5Iz+iCxYfZcdTAwMDCniD52VHtcdTAwMTJcdTAwMTSol0tHJMlDXHUwMDE4XHUwMDA1OLTQ7kDk8PNcdTAwMDBKZOWmRDbtWT6Uo4qKolx0knJ9Xc1izO2oXHUwMDFm7feDi+/87PiA9D73veOji9v6fbu70a9HkyZhsV9cdPEwllx1MDAxM00nTcz6m3G+MO9KZb3P9HTPoEP5/Xa7Sa9JStebd4RsUsJcXPRcdTAwMDB4zMB5Qlxm2L1VYb1q3u1cdTAwMGVk5YCF/iekSi/jPaTNVFxydHT058fuye5547ZcdTAwMWSe9Z7HOyfAV8Q7XHUwMDEy83hHYNeWXG54XHUwMDA28a7ZYrHMzVx1MDAwMf/jsnnph3nnrIPWm3hcdTAwMGKWQ9hkXHUwMDE2XvS0M3eeuFx1MDAxYii0qGr9XHUwMDAy4IFTzsZud34x8OeXn8yHILyNzuLUfFx1MDAxZPS6unlknlx0vMDAllx1MDAwNfyEnVx1MDAxM1x1MDAxN2JzpbWk3Eo5gVx1MDAxNiZ99qG21qQ3XHUwMDAwZmtcdTAwMTkuXHUwMDFkhtnjm/tcdTAwMDJi5k273Z5cdTAwMDG5mFx1MDAwMTl1p6lGrnCxxf5nbOPLVFx1MDAxNpWH4yg/NrdDKTxRuqdCXHUwMDEz3Ey4qSSyUFx1MDAwNIHpRI2uXHKTW9tQXHUwMDA1m7W/o0C38/F1zbS1onjtmLIu+m9cdTAwMTV9i5d4dl46nVx1MDAwMDw3nlxu7lx1MDAxYoTG98d3e69cdTAwMTjLvjPdX0SGW+M6JlLByVxcm0uLX1x1MDAxNoFzxVWhOFx1MDAwMGOQi4fg7HN2rUNcdTAwMTBcdTAwMDGaI6+4sElmue+/SmC9Klx1MDAwNjHh5Z3zSq9Wni92llx1MDAxZoLTXHUwMDExtYIgfFwiN3g6XGJHXHUwMDA2vCxcZtG8MFx1MDAxNFx1MDAwMPYkJGLxpH724b/WUVxilMyWfJw4jE9dyr9A9L3uILRWMcQpXvH/ks9VYMuPwrRcXJNVXHUwMDA24Vx1MDAxM+nK00E4NHlcdTAwMTiDXHUwMDFiI4VbV0lynNvVtV2GXHUwMDExaVx1MDAxZGj80Vx1MDAxMlUrWr8yevB+Nj1cdTAwMDVAXHUwMDFio7guQkhcdTAwMTfu+3G3cfcvaFx1MDAxZGxTIn0= + + + + align-horizontal: leftalign-horizontal: centeralign-horizontal: right \ No newline at end of file diff --git a/docs/images/layout/center.excalidraw.svg b/docs/images/layout/center.excalidraw.svg new file mode 100644 index 000000000..b9e15e7a9 --- /dev/null +++ b/docs/images/layout/center.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNWXW2/bNlx1MDAxNMff+ylcZve1UXhcdTAwMTdcdTAwMTlgXHUwMDE4nHRZ0mxuV6fpsmFcdTAwMThcdTAwMTiJllx0U5eJtHNDvvsoObUsx1x1MDAwZbzUMDI9XGLQ4eVcdTAwMWPy/Pg/4v2bTqfrblx1MDAwYtU96HTVTSSNjkt53X1X2aeqtDrPfFx1MDAxM6q/bT4po7rnyLnCXHUwMDFl7O+nslx1MDAxYytXXHUwMDE4XHUwMDE5qWCq7URcdTAwMWHrJrHOgyhP97VTqf2xevdlqn4o8jR2ZdA42VOxdnk586WMSlXmrJ/9T//d6dzX74XoSlx1MDAxNTmZJUbVXHUwMDAz6qaFXHUwMDAwXHUwMDAxW7b286xcdTAwMGWWUoxpXGJDMO+g7XvvzqnYt1x1MDAwZX3IqmmpTN2Pn0+MMlx1MDAxZk4tXHUwMDFj9tXPXHUwMDE3VCfclo3XoTZm4G5NXHUwMDFkVVTm1u6NpItGTVx1MDAwZuvKfKy+6tiNfFx1MDAxZrhkn4+1ud+IZlSZT5JRpqxtjclcdTAwMGJcdTAwMTlpd1vZQLOE2UZcdTAwMWN0XHUwMDFhy43/wkBcdTAwMDRIIMF4SOdcctVQRFnAXHUwMDA1oiGHiC6Fc5RcdTAwMWKfXHUwMDA0XHUwMDFmzltQP01AVzJcdTAwMWEnPqosnvdxpcxsIUufqqbf9eNCiWBcdTAwMDFcdTAwMGW5XHUwMDAwbMHJSOlk5KrgXHUwMDEwXHUwMDBmOMFcdTAwMGKRWVWnQISQYoZ5s5bKaXFcdTAwMWHXMPy1vHsjWVx1MDAxNo+71LXVx0LAVaw/LZDUXGaeXHUwMDE0sZxlXHUwMDFjMoaQXHUwMDEwzHuFeN5udDb2jdnEmMaWR+NcdTAwMDaS2vrw7lx1MDAwNXBCLtbBiYDgXHUwMDA0YkrJxnT+dszkP72LL/BzL75y4PBTf7CWziXC2lxcop1yXHUwMDE5glx1MDAxMOBF+r5xXHShgKSFzPa5JMFcdTAwMWEokfePIFx1MDAxMCuwRJwwgVx1MDAwNWW7w1x1MDAxMvtlUlx1MDAxNHK0LSyVMbqwq6Gk4VooXHUwMDExXHUwMDAxXGJ7IdmYSWhcXFx1MDAxNNvxydHFuexHe1NzqT9+eFx0kzvUSlx1MDAwNFx1MDAwMr/h5KlWMuphpeC7tfLtUFJE0VNcdTAwMWUhXG5cdTAwMTAkbS2cXHUwMDEzXHRhQGjroDzyXHUwMDE4+lPCXHUwMDE5RWR3PFwiwSmFLNyaTD7DI4JwXHUwMDFkj1x1MDAxMFx1MDAxMVx1MDAwZVx1MDAxOSB0cyBvpoOz+DzLit4vJ1x0j/hcdTAwMTFOXHUwMDBm81dcdTAwMGUkIUFcdTAwMThS3FaqXHUwMDE5kSSgXHUwMDAyhu26/jJcIq+8wmyLSFx1MDAwMlx1MDAwMWV0p1xuuVNcIlx1MDAxOV1LJIWCXHUwMDAyXHUwMDA0Nq/al45cXJP4MFx1MDAxMX+MP/V+7+9l788y/sqBXGZBgCHHtFVcdTAwMWRnQOJAXGK+JJ4vXHUwMDAxXHUwMDEyoivO2baAhIRcdTAwMDJIOcbof0zks7+SbKEoLzPJIeC+ZjG2MZQ4Oc56N3eEqPIqncqvf5dwcLZcdTAwMDbKkYxGk1K9XHUwMDAyLFx1MDAxOVx1MDAwYsBcbpXEOFxmQkp5XHUwMDFi15fXbbZcdTAwMDJL7zb45qKeiK2gk/tb0PLReOSTcsaxXHUwMDFmvlPF9GXD/83RbfHp1I1bhWbI15HJWMh8XHKHm5dvfX7X+7KX0PjXy/MzejhMU927fe1g+ppcdTAwMTCQZakkSFx1MDAwNP5g0u+WymdvOIw85XCFPFwi4C+9XHUwMDAy7PSi/d/VsUlznrmBvpvdk1vWY5lqc9vKVI2lj9TnPVFucSut8j5nXG7Z6t0zOqnA7Vx1MDAxYTVsXHUwMDEz7XQkzbzZ5Vx1MDAwYiuPvHfppytP4+VV5KVOdCbNeTuS2WHy73qPurIoXHUwMDA2zu+Q7zE7Wj5cdDp+XFxmM193qtX14Spxqp9q1vqAVmdBVSm4f3jz8C/DXFz9NyJ9 + + + + Widget \ No newline at end of file diff --git a/docs/images/layout/dock.excalidraw.svg b/docs/images/layout/dock.excalidraw.svg new file mode 100644 index 000000000..fde040991 --- /dev/null +++ b/docs/images/layout/dock.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2aWXPbNlx1MDAxMIDf8ys0ymvM4D4y0+nItpKocWwnjuOj0+nQJCTSokiWpCxLXHUwMDE5//cuaVXURVx1MDAxZrKqqtGDLWFcdGBcdHy72Fx1MDAwNfDjVa1Wz4axqb+r1c2tY1x1MDAwN76b2IP6m7z8xiSpXHUwMDFmhSBcIsXvNOonTvGkl2Vx+u7t256ddE1cdTAwMTZcdTAwMDe2Y6xcdTAwMWI/7dtBmvVdP7KcqPfWz0wv/TX/e2j3zC9x1HOzxCo72TGun0XJfV8mMD1cdTAwMTNmKbT+O/yu1X5cdTAwMTR/p7RLjJPZYScwRYVCVCrIXHSbLz2MwkJZzDXHTCuMJk/46T70l1x1MDAxOVx1MDAxN8Rt0NmUkryovk9cdTAwMWKNLDiPm7vvveD4YtQ6b3mdstu2XHUwMDFmXHUwMDA0J9kwKNRykihNdzw7c7zyiTRLoq45893MyzWYK5/UTSNcdTAwMTiJslZcdTAwMTL1O15o0nSmTlx1MDAxNNuOn1xy8zJUvsL9SLyrlSW3+TBcYksppLimkk9cdTAwMDR5VVwiLc0wQ4LwOWX2olx1MDAwMOZcdTAwMDCUeY2KT6nOle10O6BT6E6eyVx1MDAxMjtMYzuBmSqfXHUwMDFijF+TaWFRqfRMJ57xO15cdTAwMDZSSpSl2LReqSkmQFLMXHUwMDE4RVxcTVx1MDAwNHmnccstWPhjfuw8O4nHY1RP81x1MDAxZlNcbue6NqdAKiv3Y9e+n28sXHUwMDA0YVx1MDAxYyOtJKJcdTAwMTN54IddXHUwMDEwhv0gKMtcIqdbXCJSlN69WYFNrGklm4wjXCI1k09nM+yfXHUwMDFj73qX3/rdXHUwMDEz3jvY/Vx1MDAxNCFf7lWwOcfXLJVkk1RKhlxiZUuoJJjNUbF2KplVgSRcdTAwMTFcdTAwMTYmQMIyKJHGXHUwMDFhXHUwMDFjXHUwMDA3+lx1MDAxZkNpgsCP0+VIXG5VhaQg4D84werJRLZUvG9uj6L9a9p2/mzEPlx1MDAxYuhwXHUwMDE1XCI35yeFtKhSQmI1RyRcdTAwMDVUheZcdTAwMTS/zE++btuccLJIIyaLxE94xNhic13f08hcdTAwMDVSQlx1MDAwYkV+Tlx1MDAxYVx0IVU0YiykJuA7no6jz0dnXel+uP14ddL63CW94Pw82G5cdTAwMWM1tjSli6s25ZbgL1xcslx1MDAwMcUrhPi6UCyCXGLNlPhJUZSiXG5FmFx1MDAxZKQwsPhkXHUwMDEym8R8O/R6XzzxvXf05f1+s5mNXHUwMDBltptEjKXF5Cx0Y1x1MDAxMl9cdTAwMWE7vsbkXG587rpA1FgqJqT8P6/QXHUwMDBmh41cdTAwMTC5VLpFSlx1MDAxOVx1MDAxM1x1MDAxMOc/PW68uTj8XHUwMDFj68ug2T/5yptcdTAwMTk7v4pdv1x1MDAwMkbPdrx+Yv57XHUwMDFjuVx1MDAwNjTIXFxKkVfldJ3rtFhGJdWWXHUwMDEwRcZ031x1MDAxMF2Ek1wiYtFZ5cZ0XG4miCZywys2jFx1MDAwNkViM3TKXHUwMDA3Mm4qXGJoJJ+R1Vx1MDAxY1x1MDAxZYlcdTAwMTZpXFz3XHUwMDAzX35cdTAwMTmq9vfWzaCRbjudXHUwMDA0c0stYJjXXHUwMDE11FKQ2NGXZjZjn7mMT8iYLYZcdTAwMTHmS1NcdTAwMWJJQCghZJRMXHUwMDE3n3lAMVx1MDAwNF2SK0o2SygkXHUwMDE3SMrNXHUwMDEwqpSsXCKU5IqoZ1x1MDAxMbpz9MGcfo/OzcDP1M7p8V+O23K2n1BlgUdcdTAwMTBswYFiziyBJCMzKdB6XHUwMDExJVpZfFVAXHUwMDA1olQwyjaLJ5jFxvBElVmPRlhTNZ1cdTAwMTY9Rif969PnVn/onO3tfrn0mmfNTvL5z+2nXHUwMDEzsu2lyzthzFKU6lx1MDAxN29ccj1Cp0RcdTAwMTDN4dVcdTAwMWMoQ+A/MaGbXHJAYc2hXHUwMDAyb4RQgmTlLlx1MDAxMURHVEJcdTAwMWH4jGyo8enyMOy6UUz6R3vu7c23bD+Mt1x1MDAxZFFOmSU5X7KjLoWl+Dr8JyHqyiwlXHUwMDE0wotcIrZcdTAwMThcdTAwMTOotVjkXHUwMDE0XHUwMDBibTGxdFx1MDAwZlx1MDAxM1x1MDAxMiRcblx1MDAxM1x1MDAwNPRveIlcdTAwMTeYboZQrFHl1rpSmmBKpnKox1x1MDAwMFx1MDAxNa33e8e/XHRcdTAwMTnstM2+STJFXHUwMDBm+61tXHUwMDA3NF/hXHRcdTAwMTWUqPlcdTAwMTiUQs4uOV5DkvSQXHUwMDBmXHUwMDA1K1x1MDAxOJ83XHUwMDE1XHItI1RbsJapXHUwMDEyYzpcdTAwMGYqpVx1MDAxNFDlerMnQDBykESvi1M7SaLBUi/KKlx1MDAxMWWUS4LwM1x1MDAwZSbbl1xyXHUwMDE571x1MDAwN5/E6FwiuKTy4243XHUwMDFjXHUwMDBlVkN0c8c/WECaRDBmRFx1MDAxMIWEwnKGU5afTT5cZil3NEPuqpDmm1xiXHUwMDEwXHUwMDAyc5jyonu2yChB2lr0n1x1MDAwMoPqXG6vcjJZKLdcdTAwMWGXlMKQPsd/TulhJ9muXHUwMDFmun7YXHUwMDAx4T+M1ian661cdTAwMDKig49fvUBcdTAwMGVcdGmedk4v5ECnX9HpRNdcdTAwMWOjyOnnWu4gXHUwMDBiwlxywlx1MDAxNbhcdTAwMTdCNSyDik891rHjYv4teZ/ojiV3XHUwMDEzfUzoltrMvoCdZntRr+dn8OrHkVx1MDAxZmbzT1x1MDAxNO/SyI3KM7Y7L4WWp2Xz1lx1MDAxN+ctlldcdPJP+a1W4ln8mHz/483Sp3cq+SmkXHUwMDA1OmVcdTAwMWKvpv9XOYvM3GZLfVx1MDAwNa5cZrgkg0BcdTAwMTRcdTAwMDLO0sE+5itcdTAwMWWe5i1dznCxnC1cdTAwMWNcdTAwMTQzXG6xOnn5WUi1k4BwfolbWPBcdFx1MDAxME9cdIhcdP+N449cdEM/pvB6kt+fYeveXHUwMDE0JpK7f4D8N1x1MDAxY047XG6zXHUwMDEzf1TsqKCZ0vd2z1x1MDAwZoYzXHUwMDE4XHUwMDE00OeXa4q2ajDwXHUwMDFkk01PV2qg61wiuVAzlVx1MDAxYYHfya2jXHUwMDFlmPas2WS+Y1x1MDAwN1x1MDAxM3FcdTAwMTZNXHKvXHUwMDAzStjQXFzSWnBcdTAwMWVR4nf80Fx1MDAwZb4tVWglw8XVW02QIVxuSVx1MDAxOFJPz5RcdTAwMGVcdTAwMDJzdINij35cdTAwMThdOFe/XVx1MDAwZkbBaMWt+s2t8lRLaz6NXHUwMDA3O7aUnj+/WfNcdTAwMDVcdTAwMGbMS4VcdTAwMWawXFyMXHUwMDE01lx1MDAxMHuwXHKb7uHxdYNeKXamlZdcXCeDXHUwMDBmo1x1MDAwMTtYm+lyjMRz9qteZrpcdTAwMDf2MOpnY0tJt8F25zRaMUTn1Vx1MDAxN7RcdTAwMTCh+DnXs1x1MDAxZZ7uLbVdxoVFXHUwMDE5yq/hIaXx1GWL+1x1MDAwMJ1Z+rG7g0q2+dXqRqykhbjGeqzA1MZTadJcdTAwMTBcdTAwMWFUXdeCcsGJ5qusyy9cdNVz81OrmN9TQ/VcdTAwMDdXgtlQXHUwMDFkVpj8SFxcUYihJFXlJJahOrG0QkyonzdWr+SokE4jVFx1MDAxNbK/XHUwMDFhN1634/gkg/meTFx1MDAwZiDlu2OfWb5h/cY3g91lR8vFJ3dJxSjnpm/y9/xx9+rub5B4Q/4ifQ== + + + + Docked widgetLayout widgets \ No newline at end of file diff --git a/docs/images/layout/grid.excalidraw.svg b/docs/images/layout/grid.excalidraw.svg new file mode 100644 index 000000000..aed2f9d24 --- /dev/null +++ b/docs/images/layout/grid.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2ZW3PaRlx1MDAxNMff8yk85DVsds/eM9PpONimwZc40NZ2O52MkFx1MDAxNiQjkCxcdOM4k+/elUhcdTAwMTFcYpNQyrhcdTAwMDQ9aEZnb8e7P875n/XnXHUwMDE3XHUwMDA3XHUwMDA3texTbGpvXHUwMDBlaubBdcLAS5xJ7VVuvzdJXHUwMDFhRCPbXHUwMDA0xXdcdTAwMWGNXHUwMDEzt+jpZ1mcvnn9eugkXHUwMDAzk8Wh41x1MDAxYXRcdTAwMWakYydMs7FcdTAwMTdEyI2Gr4PMXGbTn/P3hTM0P8XR0MtcdTAwMTJULlI3XpBFyXQtXHUwMDEzmqFcdTAwMTllqZ39T/t9cPC5eM95l1x1MDAxODdzRv3QXHUwMDE0XHUwMDAziqbSQVx1MDAwZbRqvYhGhbNSYYEpV3rWIUiP7HKZ8Wxrz7psypbcVLtpnVx1MDAxZl596NavLlx1MDAxZlx1MDAxZVx1MDAxYVx1MDAxZlx1MDAwNmN8d/rQKlftXHUwMDA1YdjJPoWFV25cdTAwMTKlad13Mtcve6RZXHUwMDEyXHLMVeBlvu1DKvbZ2DSyXHUwMDFiUY5KonHfXHUwMDFmmTRdXHUwMDE4XHUwMDEzxY5cdTAwMWJkn3JcdTAwMWLGM+t0I95cdTAwMWOUloeiXHUwMDA3Q1RSzISSfNZSjFx1MDAxNVxuXHUwMDExxjglwCvuNKLQXHUwMDFlgnXnJS6e0qGu41x1MDAwZfrWq5E365MlziiNncRcdTAwMWVV2W/y9Vx1MDAwZmVa2OWVxmJuXHUwMDEx31x1MDAwNH0/s61cdTAwMTRcdTAwMTRSjM45lpriXGKIXHUwMDEyUlGQsjzCfNX4nVfQ8Fd1+3wnib9uUy3NP+Y8zp09nkOpXHUwMDFjPI49Z3rkRFxioFhcYo0xLfcvXGZGXHUwMDAz2zhcdTAwMWGHYWmL3EFJSWH98mpcdTAwMDM6iYaVdFxuKblQhKxN51x1MDAxOVHtWH7sezeP/lHnulx1MDAwMVx1MDAwZbm+XUFnhbBFLuFZuVx1MDAwNFxuXGbIMpdcdTAwMTIxXCL1XHUwMDAysNvnkqFcdTAwMTVQgkBcdTAwMDRcYtZPYSmVXHUwMDEyhCtJfmAsTVx1MDAxOFx1MDAwNnH6NJRCroKSgKCSYGB8bSpvvZPTNlx1MDAxN73r5nEr6pCscXtydrZcdJXPXHUwMDE4LVx0Q0phxVx1MDAxN06/XHUwMDE4K1x1MDAwNdJcdTAwMTJcdTAwMDRcdTAwMTf/LVq+7DlcdTAwMWM4LFx1MDAxM0lcdTAwMDBcdTAwMDFhi9FwxiQhqFx1MDAxYainRCrGXGLYUfr5gSTPXHUwMDAwJFx1MDAwMFlccqSywYNpvj6QN2MxOUs8t3nphONcdTAwMTNcdTAwMTI770izu+NAUo1cdTAwMThwqebPfsojR1x1MDAxNUw3o7GLMd9cdTAwMTaNVHPFlFx1MDAwNrmnNM5tRpVGLG101DZGrk2j4dK9ad+178bvo7fHl0o49cbJjtMobNZcXFx1MDAxNpJcdTAwMTbFLVx1MDAwNEZcdTAwMDJdm123hVwiyVx1MDAxMzVVWvxcdTAwMGapemssfltBXHUwMDEyvlJCMlxuQtn6Zv1cdTAwMDKn58i02ei/u3J9OrlodY/Os4+rkrXvuP44MTvAI1x1MDAxMMujkMtMgqVmSV1unq7FU1xcaoxA0pzL6UR0XHUwMDE5T1x0/0hZpotnXHRTomxxROeF195hOldmV6OmxsDtr1x1MDAxNNbnNFx1MDAxOep2M2xcdTAwMWReXHUwMDBl07f3LGh15MVxvOucUmo5kFhiWk3jgDXKz2KxXHUwMDE22S6oXG4jOc8p2YRT4FQrofT+ckphdfGDqS3KQdP1Of2j0W1cdTAwMDa3V4T83nPdX25cdTAwMWb751x1MDAxN0fOrnNq01x1MDAwNlJSaL7MqY2nXFxhvChEt1x1MDAxY1CZLb6YzJXElEK5XGZqrkB4cZc1XVx1MDAwYosqqfZcdTAwMTclOFx1MDAwNjJcdTAwMTf3941UXHUwMDAyeCWpXHUwMDAyXHUwMDEzRYGT9XXo+aVw621y18x4NOanp93eIVx1MDAxOe06qXnmZ5zj5cKIYkBWjW8h9U9cdTAwMDXp06mfXCLNXGLLg/bmqZ8qZXOCwPurUC2H30j9Np3k97xrg0rYUVx1MDAwMHU2oYG+gUFrXHUwMDEyTn5cdTAwMTk+7DqolDKElV4sX6ac2lpKq8rF/HY53U7mXHUwMDE33Go0YPub+blcdTAwMTIrMZVC2OPjZP1bps71yfk5jDJcdTAwMGbjTP7620X9LIuPdlx1MDAxZNOikpJM4KWLT6ol4lpXWjbhXHUwMDE0QHXNk5yCJohcbrlcdTAwMTCx/1xyofm9PFx1MDAxMXR/XHUwMDAzKdPf0Ka5XHUwMDFlgLl7ju9cdTAwMDH6cVx1MDAxON++T4Pe44fw0L+Pzvw2ucG7XHUwMDBlKLNxlC39X2ZcdTAwMDboVqTpakBcdFx1MDAxM0hPr2FXStPvclxuWFx1MDAxMK2A/Vx1MDAxOJf19l1MWnPiuJPZKW3zlFrrdeB1gkezME3tPjCTt09cdP7iqb34yn7Ol8l9/vzlxZe/XHUwMDAxUO5ccsMifQ== + + + + \ No newline at end of file diff --git a/docs/images/layout/horizontal.excalidraw.svg b/docs/images/layout/horizontal.excalidraw.svg new file mode 100644 index 000000000..25ce496f2 --- /dev/null +++ b/docs/images/layout/horizontal.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2aa0/bSFx1MDAxNIa/8ytQ+rVM536ptFpcdTAwMTEuLTQtlNAtdFVVjj1JZnFsYztcdGnFf9+xQ+PE2GxcYlHKatdCSTzX45nnXHUwMDFjvzPDj63t7UY6iXTj9XZD37iOb7zYXHUwMDE5N15m6SNcdTAwMWQnJlxmbFx1MDAxNs7vk3BcdTAwMTi7ecl+mkbJ61evXHUwMDA2Tnyl08h3XFxcckYmXHUwMDE5On6SXHUwMDBlPVx1MDAxM1x1MDAwMjdcdTAwMWO8MqlcdTAwMWUkv2efXHUwMDFmnIH+LVxuXHUwMDA3Xlx1MDAxYYOik1x1MDAxZO2ZNIynfWlfXHUwMDBmdJAmtvU/7f329o/8c866WLupXHUwMDEz9HydV8izXG5cdTAwMDNcdTAwMTFH5dRcdTAwMGZhkFx1MDAxYitcdTAwMDRnTFBOZ1x1MDAwNUyyb7tLtWdzu9ZkXeRkSY1wrHY6o1x1MDAxMbuiTv8vfja8uWw3o6LXrvH9djrxc6vcOEySnb6Tuv2iRJLG4ZX+bLy0n9lWSp/VTUI7XHUwMDEwRa04XHUwMDFj9vqBTpKFOmHkuCadZGlcdTAwMTDOUqdcdTAwMDPxertIubF3XHUwMDFjXHUwMDAxXHUwMDA0XHUwMDEx45jNkvOKXHUwMDA0XHUwMDAyXHUwMDBlXHUwMDA1xUhcblYyZi/07Vx1MDAxNFhjXsD8KszpOO5Vz9pcdTAwMTR4szJp7Fx1MDAwNEnkxHaiinLju8ekilx1MDAwM1wipILz3fe16fVTm0uwXHUwMDA0kpL5/nU+XHUwMDAxXGJhXHUwMDAyle2ZzHKyXqMjL2fha3nw+k5cdTAwMWPdXHJSI8lu5izOjD2YXHUwMDAzqag8jDxnOuGIc4yVXCJcbjNajJ5vgiubXHUwMDE5XGZ9v0hcdTAwMGLdq4KRPPX25SpsXHUwMDEyXsemopxcdTAwMGKO4fJs9odB29//LryTZvzHXHUwMDA1jpLjT+ZdXHKbJb5cdTAwMTapxJukktPFuc8rYlx1MDAwNVx1MDAwNJSqRMXaqaSgXHUwMDA2ScxcdTAwMDHCXGKqKii55Vx1MDAxMVNcdTAwMDHR5qAkXHUwMDEwYiigQOuCUvu+iZJqJFx1MDAxMalDkiNCqP1cdTAwMTNLI/lpsPd2ctChqOd8eXt4zVvHcFx1MDAxZq2C5OZcdTAwMDKlwFx1MDAwMImFaDiNk1xuMIZcdTAwMDV5KpEvulx1MDAwZcNcZt+nXHUwMDExYYBRyVx1MDAxN2Y8XCJcdTAwMDQoI1xi36NcdTAwMTFbw1xiUYJvNERaKyGkXHUwMDFioZGLOlx1MDAxYe1gXHREkIBqaVx1MDAxY9E5vWmpTtg62lx1MDAxOUYnh63v3eB493njaN+cwlx1MDAwZYLi94mUgFx1MDAxMlXGYiVcIjtcdTAwMTCytVx1MDAxMVx0MWFIWiY3TyTeXHUwMDAwkVx1MDAxONfKSeuLUmJCKVmayPeuuL5pXlx1MDAxY6TiYHzcla0v+/1o/3lcdTAwMTOJsOWC2ZBTISaFXHUwMDE1ckzAJyOJcEdKvi4kXHUwMDExQlx1MDAxMCumXHUwMDE4+1x1MDAxNyP5oI7k9WtcdTAwMWOroZVgXG7hpZmM3r9PzpvDvdZxcn74RSr+5qzztobJvuP2h7H+9VRcblx1MDAwMaxywVx1MDAxMpWZXHUwMDE0XGYwWKZ19Vx1MDAxNzevopJZuShcdFOVWGIuXHUwMDAxrMKSWIVPIedqk1QqSlx1MDAxMCNiXVSm+iatVpGyXHUwMDE2SIVskGRKLS8jj1x1MDAwZttJ2mpCdlx1MDAxMnjJ8FrH74KLi+dO5DROksVcdTAwMTVGVlx1MDAxNUtcYlxixvjJSD64upnb1ChQrFjNWFxirYhcIlx1MDAxYlxczWShUVwiwsgjICzmOlxm0rb5rnOlsZB66FxmjD9ZmK6cTmupnfyeTufHMtG2z5xGuVB61ze9jN+Gr7uLYKfGdfxZdlx1MDAxYc49uWt7d2xz8ZFXfoowNj1cdTAwMTM4/vmiJatHeilonWNcdFx1MDAxYuelJIIv7Ve4NWiN+67eXHUwMDFm6dbHyWRMh2ejo+fuV5gxUN5cdTAwMWGYRnr7XG6AVns+OdJP1UdlpIdcdTAwMWPYt7pYeM3MRXpcdTAwMDZ4aZPtp59Jqz6gIHijXHUwMDEyRFFcdTAwMWJq4WP8bHUwXHUwMDE1YnVgXCJMoWBYLs3ldde9hEcn15+Cvve5N1x1MDAxYZs2Pfz23Lkkklx1MDAwMJS90O8pXHUwMDEwXHUwMDA0eFmarIIlxrKjq7HkXHUwMDE4SJ53QVV2iSo4XHUwMDE1kFx1MDAwNFcqXHUwMDExpCRWVJBccitcdTAwMTEsXHUwMDA15OuCs1aJqHouKYNcdTAwMWOJR+xnnYs3x6NT7/SjY051e6/3wfF26/aznlxymFhIIGBpVTZcdTAwMTVcIlx1MDAxMqh1bCGsQYhQqlxis4vHTetcdTAwMTAqXHUwMDFmJYb/Szqk1qPkXHUwMDAzXHUwMDEyhNs4w+dm8Vx1MDAxZlx1MDAwZtQ+XHUwMDFmOJff6NklvDa9sVaBOPlr8tw9imJcdTAwMDHKbvPToaDgpf3jX6TsieJ2sUfxpj0qO7T636Oyr3tcdTAwMWXlxHE4rnQpWOtSdrVoXHUwMDAzOH/EnuK3uHnK946uXHUwMDA35uDj8I1vlHmz667mUlx1MDAxYjxcdJSA4vu73FxmXCLwkCtJ0WWdp1x1MDAxY1x1MDAwMTJA+KK7XHUwMDE2+4mAK1U6XHUwMDE4v/MtaHOs4EUr7HHn1q3mW1xmMkVcdTAwMWVz6jJnh1x1MDAxM6dNXHUwMDEzeCbolavowKvJ8Z0k3Vx1MDAwYlx1MDAwN1x1MDAwM5NaM05DXHUwMDEzpOVcdTAwMTJ5u7tcdTAwMTnVfe3cc1x1MDAxMdvyfF5cdTAwMTn/KGux+K+O7Cp+bVx1MDAxN3zkN7PfX19Wlq6Yyewq5rBoYGv++3brrsmGXHUwMDEzRe3UXHUwMDBluDVo6rh2To13XHUwMDE3kYrnaoyMXHUwMDFlN6v2XHUwMDA38ytcdTAwMGJcdTAwMDC5+2d+prOn+3G7dfs38GbaXHUwMDA3In0= + + + + WidgetWidgetWidget \ No newline at end of file diff --git a/docs/images/layout/offset.excalidraw.svg b/docs/images/layout/offset.excalidraw.svg new file mode 100644 index 000000000..7e56bed90 --- /dev/null +++ b/docs/images/layout/offset.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN2ZWVPbSFx1MDAxMIDf+Vx1MDAxNZTzXHUwMDFhK3NcdTAwMWap2triXlxiS8JcdTAwMTVcYlspSkhjWWtZUqQx2Enx37clXHUwMDFjS744XGZhXHR6sK3p0Uxr5utr/GNpeblhXHUwMDA3qWm8X26YvudGoZ+5V423RfulyfIwiUFEyvs86WVe2bNtbZq/f/eu62ZcdTAwMWRj08j1jHNcdTAwMTnmPTfKbc9cdTAwMGZcdTAwMTPHS7rvQmu6+Z/F557bNX+kSde3mVNN0jR+aJPsZi5cdTAwMTOZroltXHUwMDBlo/9cdTAwMDP3y8s/ys+adpnxrFx1MDAxYlx1MDAwN5EpXHUwMDFmKEWVgpywyda9JC6VJVxcaM1cdTAwMDRBo1x1MDAwZWG+XHUwMDBl01njg7RcdTAwMDUqm0pSNDU2/L/pRvvbv70g+1x1MDAxNlxmVulgTZ82q1lbYVx1MDAxNFx1MDAxZNpBVGrlZUmeN9uu9dpVj9xmScechL5tQ1x1MDAxZjzRPno2T2AhqqeypFx1MDAxN7Rjk+djzySp64V2ULSh6lx1MDAxNW5cdTAwMTbi/XLV0i96UO4ohVx1MDAxNNdU8pGkeJYw7miGXHUwMDE5XHUwMDEyhE+os5ZEsFx0oM5cdTAwMWJUXpVCXHUwMDE3rtdcdECr2Fx1MDAxZvWxmVx1MDAxYuepm8FWVf2uhi/KtHCoVHpskrZcdIO2XHUwMDA1KSXKUayuWG7KLSCIYKWkpnokKWZNt/2Shq+Ty9d2s3S4TI28uKlpXFwou1FDqXq4l/ruzZZjIWA5XGJcdTAwMTDBdbV+UVx1MDAxOHdAXHUwMDE496Koaku8TkVJ2Xr9dlx1MDAwMTqxpvPoxJxgyTGW5N54XHUwMDFl2Y39081wV7ubXHUwMDFiJjv1z9OrvXl4TiA2XHUwMDBlJnlWMCVDhLJZYFx1MDAxMswmwHhyMJkzh0pcIlx1MDAxY0ww0jO4xEpQyonC9Dfm0kRRmOazqVx1MDAxNGoulVJcdTAwMTJcdTAwMDFb8lx1MDAwMCqT1Ytm/7CXx53Vg72TY4hcdTAwMDWfcGdcdTAwMTEqn9FdMvBXSlx0idUklZw5UmhO8ePc5ZuWy1x0J9NEYjJN/YhJjFx1MDAxZDYx9VxykUIrXHUwMDBlNqTx61x1MDAwNJKQuW5SIcxcdTAwMDVcdTAwMTX8/jz2XHUwMDBljoONwzPvUH6x4V/euWKnweZcdTAwMGLnUVwiR4PLmY7enDqTcXUxXHUwMDFhL1x1MDAxMOJPRaNUQkjxat0jkWKue0RCYK5FrcedOaV111vrQaY656q/x21rv1x1MDAxYixcdTAwMTS0n1x1MDAxMUdI55hcdTAwMTS8Tt1PXHUwMDFhXHUwMDFmjVwiJlx1MDAxN+B5n1xuRXDgiEtBlfyNWbw9hYQ0cS6OTCHN1Vx1MDAwM2hcXNvaOuqibbZ1XHUwMDE2fVx1MDAxOFxmdnbyj0r259DYdr12LzP/P49Eclx1MDAwN3EhxouYkkilXHUwMDFjOonq4uFazOJSI4fIMn+9XHUwMDE5iE7jKYnDsNRCSabLa1xuU1akXHUwMDE0XHUwMDE0iV9Q6VxmXHUwMDA1XHUwMDE1V7Xt3jnZXHUwMDBm6cpcdTAwMGU/kGebO1v+Jv34feXbaKwxXGLdLEuuXHUwMDFhI8n18NdcdTAwMGIxXHUwMDAyRvlcXCNcdTAwMTBcdTAwMTLyOMJqSe1dVtBstdbTXcp2dz+fb1xyzk74v51IvXQroFx1MDAxYUNcdTAwMTlccu+JucRIsYlUgVx1MDAxMeJcYqxcdTAwMWZf6Vx1MDAwZj30bFugo8OEhW2BgsdiYFxmr8pcdTAwMTRcbv+An8dcdTAwMTSEnpueXHUwMDEwpLDWhUO8tylE6+GXo1x1MDAwM2mClt7vtN0zkXhb+Us3hVwiICguXHUwMDE5marfXHUwMDE4l1x1MDAwZSNS0sdcdTAwMWUr3Fx1MDAxNlx1MDAxMVx1MDAxNHNcdTAwMTRReGhcdTAwMDNcYolcdTAwMDWsXHUwMDAwXHUwMDA24Fx1MDAwNKL373z0dWMnM931/FxmmjHQXHUwMDAxq/s769tccvdBhD7fuVx1MDAxN9RcdTAwMTDFiShDgKnCUvFxTGmR0eC7jlx1MDAxOZRs8YvFXHUwMDBmv6BYccCPa4Uxhe3neFx1MDAxYdPicJgzpYnSklx1MDAwYkbVJKZQknIsXHUwMDE5W6DUKzVdXHUwMDEw08J8yVx1MDAwMzCt6eFmdjWM/TBcdTAwMGVAWMWBn/8zbN8jXHIuqEq8Xrn7XHUwMDBllpD4gVx1MDAxOeNcItxcdTAwMDG8tU6Bm5ZEO1BcdTAwMTiVNfpQdD1Sx8T+3crcno3UlGlcIlx1MDAwN4FLwVx1MDAwNPJdpTVcdTAwMDRRxKbUUY7kRCvGOIJccuVCTilcdTAwMTW5uV1Lut3QwqJ/SsLYTi5uuYorhXW3jetPSuGl6rJJN5BcdTAwMTYjjofj6tdyZSflzej317cze89luLim6K1GW6p/z/Nf1vTtLPfF0S3uiyNYf0iB7u2/Lq8+dE6PV7+vN9m2/Cy7l3ZggpdcdTAwMWVhsdLgv1x1MDAwNNNMICaAqGpFSv8llFx1MDAwM9ZQxC+tMUTbx4TaW51YLauvju6nj1x1MDAwMSTkpFJcdTAwMTDyzMdcdTAwMDBcdTAwMTTXI9lcdTAwMDP8VCuJ7WH4/SZpXHUwMDFia910u2E0XHUwMDE427iSU9D0Y6uVXHUwMDFiW1/L3MCcJZdqrPdKXHUwMDE0XHUwMDA2cZnemdY44jb03GgktkntzT2Y3YXhsu0pk0+yMFxiYzc6XHUwMDFh1+RcdTAwMTFZLNdkro1xjFx1MDAwNUVS3d/G9pvHXHUwMDFl2ewrlVE//JCGe8dcdTAwMWLu2Vx1MDAxM9uYn1x1MDAxNP7yaZNcdTAwMDTmcKRnnLRRSVx1MDAxZHDv4tf+bfsk5Vx1MDAxYyZcZnKEwlx1MDAxYV5TPVfkyVx1MDAwZq/nloaDNtw0PbQw5Cjqw5qE/tDgq2FcdTAwMWGXoblanVV8lFehcmldXHUwMDA1wKZYkVx1MDAxZtdL1/9cdTAwMDE0elVbIn0= + + + + Offset \ No newline at end of file diff --git a/docs/images/layout/vertical.excalidraw.svg b/docs/images/layout/vertical.excalidraw.svg new file mode 100644 index 000000000..fff71ecf9 --- /dev/null +++ b/docs/images/layout/vertical.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO2ZW2/aSFx1MDAxNIDf8ytcIvrauHO/VFqtXHUwMDAyTdrck9KkTVdVNLFcdTAwMDdwMLbXXHUwMDFlXHUwMDEyoOp/37FJMVx1MDAxOFx1MDAxY1FcdTAwMWFRtrt+MPjM7XjmO2fOXHUwMDE5f93a3q6ZYaxrr7dreuCqwPdcdTAwMTL1UHuZye91kvpRaItQ/pxG/cTNa3aMidPXr171VNLVJlx1MDAwZZSrnXs/7asgNX3Pj1x1MDAxYzfqvfKN7qV/ZvdT1dN/xFHPM4lTXGayoz3fRMl4LFx1MDAxZOieXHUwMDBlTWp7/8s+b29/ze9T2iXaNSpsXHUwMDA3Om+QXHUwMDE3XHUwMDE1XG5CLsrS0yjMlYVcdTAwMDJRxFx1MDAxMGZsUsNP39jxjPZsccvqrIuSTFS7rNdcdTAwMGbj+/6RSNBdfFF/84lcdTAwMGZIUlxm2/KDoGmGQa6Wm0RputNRxu1cdTAwMTQ1UpNEXf3R90wn06Akn7RNIztcdTAwMTNFqyTqtzuhTtOZNlGsXFzfXGYzXHUwMDE5XHUwMDAwXHUwMDEz6XgmXm9cdTAwMTeSQbZOQjqSUFxmXHUwMDExncjzloI6XHUwMDE4gVx1MDAxOflYl0ZcdTAwMTTYJbC6vFx1MDAwMPlVaHOr3G7bqlx1MDAxNHqTOiZRYVx1MDAxYavELlRR7+HxLYlkXHUwMDBl5kJcdTAwMDI2NUhH++2OsaVcdTAwMThcdEdcdTAwMTDMp8bX+fxD24ZcdTAwMGLBWVGSjVx1MDAxYVx1MDAxZng5XHUwMDBiX8pz11FJ/DhHtTR7mNI4U3ZvXG6konE/9tR4vSFjXGJJiVx1MDAwMVx1MDAxN6yYvMBcdTAwMGa7tjDsXHUwMDA3QSGL3G6BSC799nJcdTAwMTU2KapiU2AkhSRkeTRcdTAwMTk4XCJcdTAwMThGh+qm8/Gi07jY8y/gTVx1MDAwNZolvGahROuDUlx1MDAwModIXHUwMDA0XHUwMDA1L0NJXHUwMDFjgGd5eX4oiVNBJGJcdTAwMGVEXHUwMDEwyFx1MDAwNUxcIkgpwFx1MDAxOK5cdTAwMTFJXGZcdTAwMDCUjHD0XFxI6iDw43QxkKjSWVxujJl1XHUwMDE0kixccuS+PLl517x6f/k+uvp09Fx1MDAwZXXdYd1bXHUwMDA1yPV5SVxmoFx1MDAwMyBlZSdpWSmJV8DxRUtRu+HMo1xikYMgmfWBXHUwMDEzXHUwMDE4IXRKbvu7e+SIQYq5/Fx1MDAxN3vHp1BcdTAwMTSVvpFbs4VcdTAwMThRsDSKny/SgTm6eXu81/Aurlx1MDAxM91NSXC04ShcIupQXHUwMDA2KGFz3lFcIuu5XHUwMDEwnt0zV+LxXHUwMDE2XHUwMDAw+lxcPFwiwFx0XHUwMDAzkEn8e1x1MDAwMmnjxCogXHQj2DpqKZZcdTAwMDbyslx1MDAxZTY/XHUwMDFm3lx1MDAwNVx1MDAwZnz0rn/gN+7vXFx8sOFAUuhAYO9cdTAwMGLcI3IwXHUwMDEwVP4skFx1MDAxMN1cbsGeXHUwMDBiSMKJpIKJ3zZ8xE+kNvaShEO8PJLnXHUwMDFmklH3nDY8vvu3fHOTXHUwMDA0QI3OKpDsKLfTT/RcdTAwMDZAXHSBXHUwMDAz5YJcdTAwMTDSukeHlZBZfc+mXHUwMDBivCQhwsk8nlxcSCW1Sc04r1wiMrtcdTAwMDQr44lcdTAwMTBEmGNcdTAwMDLXiifN7Eg8XHUwMDE3nkZcdTAwMGbMQl9Z6SohQ4xhgsDyiVxybV2NTsO3g+udYDS63mVR86ZcdTAwMTFvOpg2S5jlkZKf4fDJVIaRef5cdTAwMTbEi1x1MDAxNkKbSeBcco9cdTAwMTeLdY1C0/RHOlx1MDAwZi1mpPuq51x1MDAwN8OZpclBtJrahW5rMz2VqbZjjk97ZmrvXHUwMDA2fjtDtVx1MDAxNujWLMPGd1UwKTbR1Ju7dnRlu0tcdTAwMGW88ltEid/2Q1x1MDAxNXyY1WR1705cdTAwMTCv9u6SXHUwMDAwIaRcXD5cdTAwMDKW51x1MDAxMFx1MDAxY1x1MDAwZY7htZa3rZM9fkz76d6mXHUwMDFiXHUwMDExXHUwMDA20mFcdTAwMDLPXHUwMDA2XHUwMDE2w9ztXHUwMDEzR1xiJH7y1OpcdTAwMDVcdTAwMDEuoJwtXGI5XGIlXHUwMDBlp7jihFx1MDAwMELqXGLISFaaXHUwMDBmXHUwMDAz56yNXCJJKON0vdFcdTAwMDdcdTAwMDXUboZriT4oJlV8YmRcdTAwMTM0xvnyeKrPO83hzd6nncuTw+O6XHUwMDFj+Gr/Q3Pj8bTBXHUwMDA3gVxczmVoWWhApSzFXHUwMDA2K1x1MDAwMeoy3aKLXHUwMDAxtUFxJaCEOyjXazzIPJ9cYoBcZm+I15utUWhDtWfjUyVJ9LD4XHUwMDFjqzpX43ZcdTAwMGKU8Fx1MDAwN+KP+5OUjc5cdTAwMGUur0x8XHUwMDE2JOfDs1x1MDAwN4YvVmNzfUerwsa/slx1MDAxY1x1MDAwMH8/yypHrWUybVx1MDAxYaawfprMqlxcXHI4nHO8OFXDXGI5XHUwMDA0US5cdTAwMTZcdTAwMWZnXHRcblxiWyFcdTAwMWPONVt3eJJcdTAwMWGVmLpcdTAwMWZ6ftguN9GhV1FcdTAwMTKo1DSiXs83Vo3zyFx1MDAwZk25Rt7vblx1MDAwNnZHq7kow/Y8XVa2gDjrsfhSll3Fv+1cdTAwMDKR/GHy/8vLxbXnVjK7ptew6GFr+vdHs1x1MDAwNVwiy8JJoGMxlVhQtLy1plx1MDAwN/qwvdc+xfuXcrA/knetNiSbvpNYN+0ghNjc0Vxutnnk/LeIX5M/XHUwMDAwTG3ehjb9POW/lEBUWZR84ps3QcKGrj/wzftYJo2O2b3bXHUwMDFk6uOr3TPVc+/ev918i+JcdTAwMGVcdTAwMTFg/vScWIviXGbymVx1MDAxM6NfZFFYXHUwMDEwXHS53aP/t6i1W9TW475XU3HcNHaGbI2xfdlF8L3H1yz6q937+qG+6IQwv7JecyvN7EFnS/D129a3f1x1MDAwMLFE1Vx1MDAwMCJ9 + + + + WidgetWidgetWidget \ No newline at end of file diff --git a/docs/images/message_pump.excalidraw.png b/docs/images/message_pump.excalidraw.png new file mode 100644 index 000000000..3990ff9c9 Binary files /dev/null and b/docs/images/message_pump.excalidraw.png differ diff --git a/docs/images/screens/pop_screen.excalidraw.svg b/docs/images/screens/pop_screen.excalidraw.svg new file mode 100644 index 000000000..b87957f6a --- /dev/null +++ b/docs/images/screens/pop_screen.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXOtT20hcdTAwMTL/nr+C4r7sVcWz0z3vrbq6XHUwMDAyXHUwMDEyXHUwMDEyQnhsyObB3VZK2MLW4ddaMsZs5X+/XHUwMDFlhSDJQorBxnHiXHUwMDBmXHUwMDE4a+RRa+bX3b9+yH8/2djYTKbDcPO3jc3wqlx1MDAxOXSj1iiYbD71xy/DUVx1MDAxY1xy+jSE6ed4MFx1MDAxZTXTMztJMox/+/XXXjC6XGKTYTdohuwyisdBN07GrWjAmoPer1FcdTAwMTL24n/7v4dBL/zXcNBrJSOWXaRcdTAwMTG2omQw+nKtsFx1MDAxYvbCflx1MDAxMtPs/6HPXHUwMDFiXHUwMDFif6d/c9K1oqA36LfS09OBnHhazFx1MDAxZT1cdTAwMWP0U1FBXHUwMDBiLZW28vaEKH5GXHUwMDE3S8JcdTAwMTaNnpPAYTbiXHUwMDBmbcrRNNFcdTAwMDae897Fx119cmhcdTAwMGZOxyfZVc+jbvckmXZTmeJcdTAwMDHdSjZcdTAwMTYno8FF+D5qJVx1MDAxZH/pmeNV31x1MDAxYVxyxu1OP4zjwndcdTAwMDbDoFx1MDAxOSVTOqb47cGg306nyI5cXNGnXHUwMDA2cs6M0Vx1MDAxNqTiXHUwMDEy6G7V7fiXXHRcdTAwMDQz1lx1MDAxOFx1MDAwNUJcdTAwMWEhXHUwMDA1qFx1MDAxOcl2XHUwMDA2XdpcdTAwMDeS7Fx1MDAxZjx9ZbKdXHUwMDA1zYs2XHTYb2XngFxugrPz7JzJzf1Kp5i0Uphs+k5cdTAwMTi1O4nfIauZNcBdfjRcdTAwMGXTTXCgpJNaZlvkrzjca6Vg+HN2XHUwMDE1O8FoeLNam7H/kJPWXHUwMDBi+nxcdTAwMTZJeTTl9lm8grPdXHUwMDEwYKe1v/3X85NcdTAwMDP5+2CrfztXXHUwMDAxesFoNJhs3o58vvkvXHUwMDEzbTxsXHUwMDA1X1x1MDAxMFx1MDAwNVpLa43TXHUwMDEyTVx1MDAwNspu1L+gwf64282OXHKaXHUwMDE3XHUwMDE5XGLTo5+f3lx1MDAxYvp0mSroo+OO0KD03NBcdTAwMGbHU3ux39vnfHz+ctLeiyb6hfue0Fx1MDAwN/5N7IPTTFx1MDAxOSNRc1x1MDAwZVx1MDAwMoyyXHUwMDA17EuBXGalQYKeddo4vlx1MDAxOPbPgzPO1Vx1MDAxMrGPQipwlq9cdTAwMTb7vd45n2zx5NlhNFxmwz9eXHUwMDFlbb86iJeEfVx1MDAwYlxccG6Whf0kvEruXHUwMDAyvkVdXHUwMDA1fFx1MDAxMNZx5NLh3Mh/d941l1fDy5fT3taHwfjj8PiF2F1v5CMqprRBXHUwMDA0dEZ6XHUwMDBiWlx1MDAwML7lwMhcdTAwMDRJclxi1iHkrMBDcG+c4udYxj1wW1x1MDAwNryBWZhrgdL7pp/IxDtcdTAwMGJK2PvAPEPToJ+cRNepjbaFo7tBL+pOXHUwMDBikEjxT1x1MDAwMp40R2HY34D/9n/pRK1W2P9nfsfikK7vJ9TFb251o7bXls1ueF5UoyRcIlx1MDAxZXY7nFxmcmvcJElcdTAwMDKabrTXmr2jwShqR/2g+7Zaqlpt/rLMd6gzUVx1MDAxM5w9nNNnIHojxPz6XFy/8/fQZ5zF5uPps3HMSFx1MDAwMGm4pXdbVGfjJFx1MDAwM81cdTAwMWRcdTAwMWGU6EjjXHUwMDFmRZ1cdTAwMWQyrogwS2MsR9TuXHUwMDBl5XZMIzk6KVFcdTAwMDHXOlx1MDAwM/BXlyaFv4VcdTAwMDeoeipkjao/ijLGSTBKtqN+K+q3aTCzXCJfQ5K9OVx1MDAxY0Sqvs2xl5IzhVxcS8FpI5UgL1x1MDAwNLmT2sHQLyFcdTAwMDMgTqLJZKNVTtibXHUwMDEzPt9cblx1MDAxNfZb31x1MDAxNqk+UMmJ1OBcZml5nPV7pjhqilx1MDAxM0pCSaZcdTAwMDVIXHUwMDA3nITiTlmnSlJ1gzjZXHUwMDE59HpRQmt/PIj6yexcdTAwMWGni7nldbxcdTAwMTNcdTAwMDYl40F3lVx1MDAxZps1XHUwMDA2Qz9j0aZn/21k2pJ+uP3/z6d3nt2ohHI6WkJxNt+T/PtcdTAwMDNcdTAwMTi5sJWGXGYtqVx1MDAwNlx1MDAxMZdM8b/JyM9G2NpcdTAwMGKuw/1nW1x1MDAwN89f9LWNIVhvXlwinGOOTFx1MDAxNVxiNN5y68xcdTAwMTJ8XHRGLeNcdTAwMDLAOeK+ZCaEmJHsIcGo1suj5ESVkKJcYs5cdTAwMWaBrNRYMECFuIqIkYLtSkeLZLhcdTAwMDVwNb+jPdzpXHUwMDFjXGZfXHUwMDFjvdtcdTAwMWRcdTAwMWNfj9xhcjQ+XHUwMDFmivVcdTAwMDao5IJcdJ9cdFGWdFWqYrJEXG7NuOPGXHUwMDEyQqVcdTAwMDZcXFxmnsuOXHUwMDE3SVx1MDAxZaE4mTS7fHDWMenXZ8rufYyvOsPx+MNWdPomuVx1MDAxOMllXHUwMDA1jIQ3yHG7x4O+XHUwMDE1qlxu+k6AUkS65jfN76eHb9o9t3+0r/76NH32abtzKI+WivxWXHUwMDEwd8IlQ98xolx1MDAxZYJcdTAwMTONXHUwMDA0YpGuXHUwMDAwfaGQXHUwMDExKZFcdTAwMDY5cDBcXC9GMi02XHUwMDFkhGqZ6CdcdTAwMTNpSTS+4nSJXHLDl+PX19fds1P96VxcJJGYnvL50P+0bt5Y7U+v4+3jyejgdO/Vofj0x9SeLWHe8+H7ydvG5Ni+v+7Fp1x1MDAxN83wI3ZcdTAwMGaWMC9cdTAwMWab//VO3mLyprl9XHUwMDE1NT/uvlx1MDAxMc1oWfE0J+S5pTnAqrSRrktcdTAwMWKRWjiKvOzcNuAsnuKOONpcdTAwMDXV3u6Mmq33h+r6dL3DTGlcdTAwMWSzSkvNOZleNZMuXHUwMDA1QaNOcrKERJ3JXHUwMDE3zlxudj9cdTAwMTNA6npcdTAwMTbewc1ErqZxq/myrO9CWqIrhj+Ct6tBolx1MDAwMan1fZCYbXiW2Vx1MDAxMVx1MDAxNOWlgYfnwDZcdTAwMTdJXHUwMDE38jzZVb7meYLhkFxyXHUwMDA3w09xmln55e4sj9CF7z12lqckU63qVeZ4dHVkROxXXHUwMDEz71Zyft2rN57L0L1H8L8gmERUXHUwMDE0ZiOZOzFTq7CGIVx1MDAxN9pcYqPJXHUwMDE3w2Ip2yrd40RvXHUwMDAxkVgwXHUwMDAx06E1QpZ1XHUwMDExuPGCmpRvXHUwMDAyXHUwMDE5XHUwMDA0V9ZNQjUpTC7JvopEXHUwMDBmcFqcx0z01NO6jUJWxVEsXHUwMDBmjmyoRE2rmUsx3CRVXHUwMDE0U1x1MDAxNFx1MDAwMyuOdFx1MDAwMtpvZnqKt/EjZVtqQJWOl/GUTfkk/35vo2JyRYVZo0JwXHUwMDAx8iD3yFx1MDAxYtczp/U0Ko5LZi1ZXGZDsaySZtaoXGJmXHUwMDA0V4pWnlxmXHUwMDBizEZcdTAwMWLLMipoOWghfSmfLpIrRuVsimWSglx1MDAwZetcdTAwMWNcdTAwMTiCXG6WykSgjZXOc5PV2lx1MDAxNKCYXCJbte9oU4DRXHUwMDBl0PIopykqJlx1MDAxZVJOXHUwMDFlXHUwMDAzZ8QmrNTkQoThXFyan9SmVEPKv1x1MDAxYWU0LcuiXHUwMDE08l+zXHUwMDE5XFyurZS+nDm3SalPnaynSdHKMCO55lx1MDAxMiVIk+tk8d/XXHUwMDAyXHUwMDE4+X3iMd6x4YItXHUwMDE1VSaFdMFcdTAwMTkjyWhJWnXSiez+b02KU1xmpXbKcsFccqrcrtxYXHUwMDE0dPRdhfpcdTAwMDFcdTAwMDHEQiSFK5fJ8nCDMqt8P4FaN6r3NVx1MDAxZC5t6T3Vuq5XSldTXHUwMDA1Tv5cdTAwMTM06vmpgn6m8FX0cnLZfvfhQrevT49+j79rn+C31ZrIMzBcbj6QTCatr5lpXHUwMDE5UUAkTSluXHUwMDFkUSaXL8ivR2VGeTOvVptcZlhdJ59cdTAwMTbVXlx1MDAwN4Rwvk1s/ui4cbU/tn+1wsvOycdLXHUwMDExTl87PNhZe3Rq5uNcdTAwMDdB2DMobLFwSG6XkS9cIuRcbq2EQbdcdTAwMTA6l16YcUIgUTfzgHB4kdR0I57s7CB2I9lcdTAwMTZcdTAwMWZa4z+O48nFslKyVlpuga9cdTAwMDD7NuegS1xy3IAkjJyfcMX93dFe7+L5azHF8L1otTvDg9Z6Q79cdTAwMDHWMrCKmKXTzmlcdTAwMGVcdTAwMDXoXHUwMDBiKYlcZiuL3KLv6l1cYvlfyjLLLElcdTAwMTJHXHUwMDA0XHUwMDEy7CcpypxtPT9/XHUwMDFmbPHD9rv9t1x1MDAwN83rQZDE46X1xqK0uDSNqlxmYWpcbp2gfHVbajN/W3h92Wd9I1x1MDAxOFxupYVcdTAwMDRSXHUwMDFiXHUwMDAxekafiOiQWfHxi6b9WKzOWVx1MDAxZMBcdTAwMThFXHUwMDExlKPolYiLJZ9VVizincxnZZRcIq/mfLmjpF5cdTAwMTTqy2Jss5pcdTAwMTCG1P1BNZBl50Q448AlxXBKXHUwMDE5jeA4N3d2r/neNk3RKsWlZJBuTvjZklwijWpQfVx1MDAxOS7hKZvxSf79vnVTqWD26C071cjR3ec5k6vXoVx1MDAxY530XHUwMDFiz/u6od1+6/CV6thcboPSXHSanfEoXFxcdTAwMDNcdTAwMWZN+GJcdTAwMDZ89yTB0T/jUEy0XHUwMDFhJ1x1MDAxOHF0X1x1MDAwZVx1MDAxMMpavlD1Jlx1MDAxOVx1MDAwNf14XHUwMDE4jEhd7jAsucxpTdO9XHUwMDE0qJw1YsWM9DGfLfHZXHUwMDAy41bedI9r2XSPizfdc1fdXHJBtsOSf7zHk5P1O38vtV5dP0SDXHUwMDAySlx1MDAwNspYIynYXHUwMDE3zuqZxnuLjLyhptPQmVxccWXpWlxyilx0XHUwMDAwKYiekW1x7q5cdTAwMTJcblx1MDAwMpM+XHLCLYVMoPJcdTAwMWRaN3TBu1x1MDAwN4fwkCzJXCJ0gTyzclx1MDAwZtHLOelCvcvYKDa7k+8z5CONTOvo5bIscEaLJITl3JdR9Ndi5D1cdTAwMWLw61x1MDAxZpcsUFx1MDAxOFx1MDAxMCCNpZ3zXHUwMDE5XHUwMDAyJY0uyWRcdTAwMTjSgCR648jGXHUwMDE5xJJMP1x1MDAxMk+pXHUwMDA2s381yjheXHUwMDE2TVx1MDAxMZWJXHUwMDA0XHUwMDA0n2+mUHV+nlwiPlxcyNa1fHn54tnhm9Z04sK+qirdrFx1MDAwZk9cdTAwMDHBmSFoI7FD32ePRYOGqH1cdTAwMDXRKCAuY6VSXHUwMDBipVx1MDAxM75BVO5o8ypcdTAwMTNcdTAwMTWyXHUwMDFjUlx0IdWPw1Se1s37mFx1MDAxOVx1MDAwNLK0Wq+eXHUwMDAxXHTiXHUwMDFhl1FcdTAwMWOddcN1okBcdTAwMDWxXHUwMDFlxoFcdTAwMTRWXHUwMDA2NuAsoZK4/vxN4fVbv65cdTAwMTTIP/IglFTeLKBRYqYtXHUwMDFjkFx0Ylx1MDAxNVx1MDAxNoUherTYXHUwMDAzO7X2XHUwMDAyXHUwMDFkOUokj01Ow3IpsyvdWlx1MDAwZu3du/TJXHUwMDAwXHS0MVjOl/jSXHUwMDAxRaSr7iF5sFrOSYDqfVGRXHUwMDAwgdCeXHUwMDA3Ulx1MDAxNFxuXHUwMDE2yeHlclx1MDAwNF9cdTAwMTmQZNJvJaBcdTAwMTJcdTAwMWOMeegziPW59qJUXFxcbmPJISljtNEu94NcdTAwMWa3YllmNHFcdTAwMDPaWue4MkKWpPqRSFAlnP2rXHUwMDA05CUxIFGdqCFhUEtcdTAwMGbXue3ZyfXlKzWNj6ZHL+LB5OPWZft479O6MyCK1ohcdTAwMDGh4MSCjK9ZXHUwMDE0XHUwMDEzNUJcdTAwMThGXHUwMDExgnRElFxibvlfUPlOmVx1MDAxYSBcdTAwMWHmIFx1MDAxN1x1MDAxNfzotUPHXHUwMDA1t8LlbmiFmZo15Cm4OE8xrlqvwVx1MDAxN1xyrTXzN6/Ub/2a8lx1MDAxNFxuKsH/mFx1MDAwZjk3w41cdTAwMTC5eCFtXHUwMDEwXHUwMDAwzdAnckxcdTAwMWHai8VcdTAwMWXdrNVr7dtotPDP9/tcdTAwMDdcdTAwMDfhXHUwMDBlLdeGOeu7XHUwMDE2NddWlVx1MDAxYdP8U2zKoV3l7yQspJVz0pR6h7FRKOs4RS9OXHUwMDFiXHUwMDA1WphcXGfyRpZcdTAwMTORXGLCao1cdTAwMWH8XHUwMDEzYuWfJJiLpNT3wszIRFxcSCvyyEJq8lx1MDAxZqIkXHUwMDEzkmfx7Wv+4XY0d7X0/0hcdTAwMTSlXHUwMDEyyOlgXHUwMDExwlVcdTAwMDTlyc3s/jGhk4TwdrtcdTAwMTVcdTAwMDTpqHVjyrNb3LyMwsn2XT056cvbx3QxvVx1MDAxNVxu/Y3+/fnJ5/9cdTAwMDPV4pXXIn0= + + + + Screen 1(hidden)app.pop_screen()Screen 2(hidden)Screen 3(visible)Screen 2(visible) \ No newline at end of file diff --git a/docs/images/screens/push_screen.excalidraw.svg b/docs/images/screens/push_screen.excalidraw.svg new file mode 100644 index 000000000..7bfde1748 --- /dev/null +++ b/docs/images/screens/push_screen.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nOVcXG1z2khcdTAwMTL+nl/h8n3Zq1xu2nnr6Zmturpcbk5cdTAwMWN7XHUwMDEzx/Fb/HK35ZJBgNaAMFx1MDAxMrbxVv779chcdTAwMGVcdTAwMTIvXCJgMEtyVGyMRkitmaeffrpnJn+92tjYTPqdYPO3jc3gvuI3w2rXv9t87Y7fXHUwMDA23TiM2tQk0s9x1OtW0jNcdTAwMWJJ0ol/+/XXlt+9XHUwMDBlkk7Tr1x1MDAwNN5tXHUwMDE49/xmnPSqYeRVotavYVx1MDAxMrTif7vfn/xW8K9O1KomXS+7SSmohknUfbxX0FxmWkE7ienq/6HPXHUwMDFiXHUwMDFif6W/c9ZVQ79cdTAwMTW1q+npaUPOPLSjRz9F7dRUY5hgWqFcdTAwMWGcXHUwMDEwxm/pZklQpdZcdTAwMWFcdTAwMTlcdTAwMWNkLe7QJjQv9MPZWalafuj/WTqqmebJXHUwMDE3nt21XHUwMDE2NptHSb+Z2lx1MDAxNEf0KFlbnHSj6+A0rCZccmrlI8eLvtWNevVGO4jjoe9EXHUwMDFkv1x1MDAxMiZ9d4yxwVG/XU+vkVx1MDAxZLmnT4prjzFccmgtN1x1MDAwMCx7WPd9oYzHuFVWgFx1MDAxMlpxgSOGbUVNXHUwMDFhXHUwMDA2MuxcdTAwMWYsfWWmXfmV6zrZ165m53Dw/aua1tlZd09cdTAwMGasLHjKKIkwaGpcdTAwMDRhvZE4I4z2XGZyZvOtcZCOXHUwMDAyV1ZbhsCzXHUwMDE2d9PObjWFw1x1MDAxZqP92PC7naf+2ozdh5zBztZ3o1jK4yk30sFu9fz86Fg97Fx1MDAxZVx1MDAxZX5IToKqvdy6XHUwMDFmXFxrXGJ8frdcdTAwMWLdbVx1MDAwZVq+Pv2VmdbrVP1HTHGtlWVcdTAwMTJcZkeW9XQzbF9TY7vXbGbHosp1XHUwMDA2w/To19dzg19cdFVcdTAwMDR+Qo7Qmis5O/rvoHFwvV+PXHUwMDBmPnROXHK7XHUwMDEwd1dvP/TWXHUwMDFj/YJ5wjKu6FGNNiiH4c9Re1xujVx1MDAwNcGNsoh8MfjX/CvGYIng18BcdTAwMDRYo/lqwVx1MDAxZuvz8Kh8X98qb5v7crdzqe3t4VLAb1xmXHUwMDFhLkGwZYE/XHTuk0nIXHUwMDA3XHKFyDdGXHUwMDEx+Fx0XHUwMDBmMyNfto9iXHUwMDFktMzno3q3vHuO8mRfrDnvXHUwMDFiXHSe0VJoXHUwMDAzipxcdTAwMWPsXHUwMDEw8ClcdTAwMTB4TCgujaBcdTAwMWZccrBcdTAwMTDu0Vx1MDAwMquJcdxzZsZcdTAwMDGPfFxm5lx1MDAxNH6YlIrJn4fjhbZKSJhcdTAwMDPmXHUwMDE5mqJ2clx1MDAxND5cdTAwMDQpOVxmXHUwMDFk3fZbYbM/XHUwMDA0iVx1MDAxNP9k4FGlXHUwMDFiXHUwMDA07Vxy/t/2L42wWlxy2v/MXHUwMDBmWVx1MDAxY9D93Vx1MDAwNfXwN980w7rzls1mUFx1MDAxYnajJCQpNmhOolxcXHUwMDFmV8hcdTAwMTKfLtfdrY4+UdRccuth229cdTAwMWVcdTAwMTdb9Sxv1kZcdTAwMTR7M1xigUTgXHUwMDE5/L/nzVx1MDAxNyfbd35n//D+Zlx1MDAwN45bV+clXHUwMDE5Xa+5Nyu0XHUwMDFlSoZWUawg+squ4r5P4cVcdTAwMTDgkFx0ozSSWHpcdTAwMTFvXHUwMDE2MiPMgTfnjj15s2aS5FxymiyU/uAxy1x0Nupblj3pipxZbPxCiVN41VxmJjuzgKFvrsiZ81ZNdebHbp7gzVxc5eLCqDtTTFKkj0Xm8N9z5+kjP4c7i1Fsvpg7g9RcdTAwMWVcdTAwMThtKTZLhZSaXHKrUqU8y8iVXHUwMDA1Q2lAczli2HL8XHUwMDE50JOcXiR/XHUwMDE13Vx1MDAwYsxcdTAwMDT3RuY5cVxmkqhF0082bt9it6A2inXyXHUwMDE5+Vlq51x1MDAxNHf/nkPOk0Hl7PC7STlsV8N2nVx1MDAxYTMm+VZm2J0hRqQuXFzpOSuZp4FzSYNI6Vx1MDAwNVx09ixRdV3hd5zN0mOGRFx1MDAwZadMg34o6Xo64+vAqqBd/b5N0/OvIZtcdTAwMTgpXFxOXHRccpL2s1xcXHQ7Zlx1MDAxNNmkZZrzcCYkmT5mU9OPk62o1VxuXHUwMDEz6vrPUdhORrs47cs3zs1cdTAwMWKBP8ZcdTAwMWb0TPm2UT7ouCtcdTAwMGXTevbXRuYw6YfB33+8nnh2MZbda1xmxdnlXuXf52Yyul0hkZGsZsRcdTAwMDSQucz3iGy6XHUwMDFlXUtcIjPUtYpcdTAwMGLrKkdEMyp72DTLkOhRzoeugEN5iFUjdi2HxzR6hmv6p4GowfKMTFx1MDAwNzRmXGJcdTAwMDCaXHUwMDEyaVdH4qBzicZcdTAwMTONSUtcdTAwMDJcdTAwMTJgxSSGXCLPqMsnselp61x1MDAxMGFI5ViCXHUwMDEzYl1HWZM76YnEhCdcdTAwMTEoWilwWog/l8Sml1BzNpXIKGlpXFy4pZtcdTAwMDGiVuNGecagdIqBMkgk++GHZrFSIZTT1jFcdTAwMTTPSWNTXG6F0rDRo1x1MDAwM1wi05K7bFx1MDAxNmZPsPzfXHUwMDBm3vfffLx4+yG86PcvL9tcdTAwMGbNXHUwMDAztt5cdFx1MDAxNiFcdTAwMWY8XHUwMDA1oDSjIE3cnYmhtE5cdTAwMGXCQ8s1UlJPRJZcdTAwMTNsa1ImJ7BokCBeoExeXFzLI6fjdq5cIsdz8Vx0qjBjUOT3zpjZq3kx6NPLclx1MDAxZuO9m/2y6uzU7vq4t/bwtJ6SXHUwMDAyXFzh0lx1MDAwMojhQMuN8Fx1MDAxOKN2XHUwMDEyQYyyXG47qlx1MDAwMP7eOjblXGJcdTAwMDBMw1x1MDAwYpSxp1TgrFwiPWJXXHUwMDAwTi1kXHUwMDExOEFKrTnD2UXgVrVcdTAwMWQlW5c7+zbeerhcdTAwMGXOeVJcdTAwMGab61x1MDAwZU4hPFLXRlJ4XHUwMDAyru1wdaqktYdcdTAwMTTTSGExJPDgYjLQiIrlwVx1MDAxMqkzzcCZYSueYTRBsNP7+PDQvLrQlzWZhLJ/wXJCaCz/XHUwMDE4tHx9Pe26+/H+obi77iad29Ln2t71l/v34tNcdTAwMTKuW8Wbm+B3/Vx0opCfRNt7N3uldydLuO5N+fN+VZ2etu7Ptt9WoFx1MDAwYu/fXHUwMDA0/WVV4Y1GsHJZXHUwMDFjUFSelpJcdTAwMTdcdTAwMTFcdTAwMDBSVkHaWsqZXHTg0n93/mf/rnZcdTAwMTi/3T/dub1Oro72dtY7XHUwMDBipDOMR1x1MDAxOZ5kLolcdTAwMTJsZJa1RKmFJ4hcYkmju0lYu2BcIkhZ01UwQTxBbrZ74PpqzOGVm/cmmbdSqYRcYozPXHUwMDE1jbJcdTAwMTHPSsiUT3NDbKtJoXKj7dA5g4JyhrVvXHUwMDA1Zb/T8Tq9uHFcdTAwMTmnNdxfXHUwMDFl3+TksnKupL+KsnKhbVNdsbAkI6coRZe1XHUwMDAym10oTqe8Zbhi1Y9cdTAwMWLBsoOx9ijUSlxutIYh48PB2IBcdTAwMDecXHUwMDE5amTkhsYsNvFb5IncXHUwMDEzzsW04kZxd5dcdI7JuSRSsKBpRCwl72JsJom7iVx1MDAwMiZI187vqc8vy5BuJLvnWaCQs2Omssx0ibeRL8tYo5hQSlCqRfwhclXNp1xuXGJ4Wlxu5UaUXHUwMDEz/VEnPp1QUJVcdTAwMTl+ilx1MDAxZqg0UoyotHVcZkvZ9V7l3+emXHUwMDEzZVxuQztHkETtYlx1MDAwZXE/XeusKaFcdTAwMDBcdTAwMDePUkpcdEaCXCKVn027pISiSFkjXG5cdTAwMDDNgVx1MDAxOGexuapcIkJcdTAwMTFcdTAwMWVQnm+UIHfgUptcdFx1MDAxYZ9z7qFby6WsK7vz3ErHgdJHXHUwMDFhM3jWcqpF+IS6jWfG/I18UlwiQrGCuVVBjjGktpg765FQKHhcYkB3krbaMGV/UkIpRJR7jWNpWXxC2UAhnyhcdTAwMDZMXG4+R6F1eq63pnxcIjR6qJTRmlx1MDAxMiPQw3RcIoT1pFx1MDAxNVppXHUwMDFhXHUwMDE4o5larJJVLFAsY5xuT9lcbpOcTZj6pifxSEXRXHUwMDE1LKUsbv52lE9QXHUwMDExMii1y1x1MDAxYVZCJ0ipy0vOXHUwMDFhzSFPSJxJKy2iXHUwMDA2olxuXHUwMDFh1DE6sVx1MDAxZadcdTAwMGUkkyl40Kjit6nXn41OXG5cdTAwMDGVNo5BaU46mbbEu3ihK7FcdTAwMWKSaJqjMv7pTInTqFxcOjg9tlx1MDAwZqdYPWbnX2prXnwkXHTmKTBGK0U9j7lcdTAwMTn5lE8096hcdTAwMDeMJFVohFx1MDAxNHKx0sNLbHBAXHUwMDEy+EOZ2ErKj0dcdTAwMTf7b5p7R8FJr1x1MDAwM+XD6qfT3avP0bLKbujkRcbcL1h6l4XhVEmwXHUwMDFhQc++kKxxU2p3XHUwMDBmto7fn15Hl5dxq9xTbz+sO/qBMlx1MDAxZlx1MDAwNsg5PaxQo/tcdTAwMWIs86RbdUSpXHUwMDEx0zQq6zUvxLlgXHUwMDE0RC0+I4IuMDGElph4XHUwMDFlQf5cXHSiLlxctmyF0qTF5+Dmz7+fd9iX3bPem/P6Wb9S7d10+s8qRq1cdTAwMTSdgjRcdTAwMDJ1ttRcdTAwMWElz3XH41x1MDAwNZRcdTAwMDdcXKBcdTAwMTKkXHUwMDExXHUwMDAw0C6WOy59Yki4WXVjV7wrYYF5oe9cdTAwMTKzJI3CzNKIuWg+RPDiJMcyRVx1MDAwMlxi7ezbzirJznZ8dVx1MDAxYpr7sniItpvxzVFcdTAwMTfWPckxaDw3XHUwMDExQsLEas1gWJWUXHUwMDA0+Vx1MDAwNaBUaaqthF5s31lcdTAwMTHy81x1MDAxYlCmrNjnXGYkokSxYqSrOHp7v7d3wpPjg6D8caeCeLK/XHUwMDE0pFPmzjVH8ayqy1wiS/blWi7Zl1x1MDAwYi/ZXHUwMDA3LC6DOpaXgs1cdTAwMTHKplx1MDAwZvx6TnFazTyBXHUwMDAyJMFcbpRcdTAwMWTZRq09bi0pXHUwMDE5Zlx1MDAwNXk7vkwgk5LuopVA6TJcdTAwMWGYtFx1MDAxZMcqz1xiSomsUW5cdTAwMTNcdTAwMGVcdTAwMWL3dVx1MDAwMaBcZua3XHUwMDFmr2Kp69xxJ2fHTEWL6UFiI1+0oFx1MDAwNF1cdTAwMGLLSSy7moTiuZNcdTAwMDZLXVx1MDAwNWjDnO3I3L7EpzN+tqJFqVx1MDAxMFHuNYal7HKv8u9zq1x1MDAwM1moio3klKHjXHUwMDFje/mOXHUwMDBm3vdKeHi6/eXjx/pd/fL0T41FS01cdTAwMWJ+pdHrXHUwMDA266CL0Vx1MDAxNYRAWMNcdTAwMTQoPZK1XHUwMDAxqWYrgCSElJbU68uIg9zE1jRtXHUwMDAwlMhz58Gr1Vx1MDAwNrUusWjnXGaO3zW2XHUwMDBmv1x1MDAxY4ja4bugM5s2eD3tui9a9rDWTZCtTHM8bqldXHUwMDA3nfFkyfO0XHUwMDA1isL/ocWme+D4XHUwMDFjM6zTcTNcdTAwMTdcdTAwMWasUFxcaItcdTAwMWVBR9Cjulx1MDAxZPt2ZFx1MDAxYo1cdTAwMTJcdTAwMWVoXHUwMDA13M2aUFx1MDAxZbtYXHUwMDExpzBZXHUwMDAwj9PFXHLnkrJxJifJXHUwMDBiro1cdTAwMDeIRksrXHUwMDA1XHUwMDEy6Mc20qCbu2GYXHUwMDFisZXMiTzb8WaUXHUwMDE308PMxvCuXHUwMDE1t0NDS+pAt0JcdTAwMDAmLNrgdJLmMt1qwMBcdTAwMDD8rFx1MDAwMqNcdTAwMThT7lVcdTAwMWGH05xcdTAwMTKjkFM0K+RcdTAwMTTQbjGylLNrjOkxY105XHUwMDA1085329qY0E5VjXCKJVx1MDAwNWKYNcYoXHUwMDE0L7Qm2yjPpYaoLaOsg7p9nFLIXHUwMDBlt1KNJCjRXHUwMDFiSW4xXoejhEpxQlHWef9/nFwiaFx1MDAxOK2rXHUwMDE4XHUwMDE56lx1MDAwYs3HOUW6rXCIlIQq1EaQfpzOKUVWTZ9cdTAwMDJcdTAwMWOxijFlJLOKI1GZlVx1MDAxM5jOc5UwTckxxVx1MDAwNUvZxI+9Qa9cdTAwMTDP7lVcdTAwMWGHclx1MDAxMZ29erqDW/x6lFx1MDAxMO5cdTAwMDZcdTAwMDNC0Fx1MDAwZatPSjB7zM3bMLgrT5qRSV9OeKVcdTAwMWTquChwXHUwMDBm+9fXV1//XHUwMDA3mNhRliJ9 + + + + Screen 1(hidden)Screen 2 (visible)app.push_screen(screen3)Screen 3 (visible)hidden \ No newline at end of file diff --git a/docs/images/screens/switch_screen.excalidraw.svg b/docs/images/screens/switch_screen.excalidraw.svg new file mode 100644 index 000000000..beddc0d3b --- /dev/null +++ b/docs/images/screens/switch_screen.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nN1cXG1T20hcdTAwMTL+nl9BcV/2qsLsTPe8btXVXHUwMDE1XHUwMDAxNlx1MDAwMVx1MDAxMpNccuH17opcdTAwMTK2bGsxsrFlXGZs5b9fjyCWLL9gg3DIKlXY1sjj1szTTz/dM8pfb1ZWVpPbTrj628pqeFNcclpRrVx1MDAxYlxmVt/689dht1x1MDAxN7VjaoL0c6/d71bTK5tJ0un99uuvl0H3XCJMOq2gXHUwMDFhsuuo11x1MDAwZlq9pF+L2qzavvw1SsLL3r/930pwXHUwMDE5/qvTvqwlXZb9yFpYi5J29/63wlZ4XHUwMDE5xkmPev9cdTAwMGZ9Xln5K/2bs65cdTAwMTZcdTAwMDWX7biWXp425MwzUDxbacepqWiElFxiKrsg6m3SjyVhjVrrZHCYtfhTq7fHrZPex6uT643+2ra4qrq7KKlmv1qPWq395LaV2tRr061kbb2k275cYo+iWtKkVlE4P+1b3Xa/0YzDXm/kO+1OUI2SWzqn+PBkXHUwMDEwN9IusjM39GlccpVgxmgrpOJSKG3VsN13IDUyY41RXHUwMDAypUGJQlx1MDAxNSzbaLdoXHUwMDFlyLJ/8PTIbDtcdTAwMGaqXHUwMDE3XHIyMK5l11xiXHUwMDE1XHUwMDA05/XsmsHD/UqnmLRcdTAwMTJN1n0zjFx1MDAxYc3Ez5DVzFx1MDAxYcFdvrVcdTAwMTemk1x1MDAwMCA5OpR22OB/sbNdS8Hwv+IoNoNu52G0Vnv+Q85ab+hWXHUwMDExSXk05eY5TCpR2N89QLt7uub0detcZuKPw75GoFx1MDAxN3S77cHqsOXbw7vMtH6nXHUwMDE23CNKaC2tdU5cIupsMltRfEGNcb/Vys61q1x1MDAxN1x1MDAxOVxi07Pf3i5cZn2Jalx1MDAxYfSFXHUwMDExllx1MDAxYm5cdTAwMTTOjf0/wi+wKW/OKp9cdTAwMGU+XHUwMDFjXHLuTreCne0vP1x1MDAxMvuCP1x1MDAwZX5pmDJGguZcXKAwelx1MDAwNPvogKHlXHUwMDFjXGJfTlx1MDAxYsefh/16cM65Klx1MDAxMftkmHBOw5LB/2Vf7Z00vsRna/31w62d/atccn5cdTAwMTmUXHUwMDAyfsdRcO2kKVx1MDAwYvxJeJNMQr6yelx1MDAxYfKNdMid4PNcdTAwMDM/6H95t3n2Z3vzc3x4dHP8YaNcIlx1MDAwZVx1MDAwZl478J1hloNBXHUwMDAx3FhrR4FvhGJIzi+ERUW+XHUwMDAxz8K9cYrXYVx1MDAxY/eC23HAXHUwMDFiUYS5UVxuLU1cdCxcdTAwMTflL0fxXHUwMDFl5dKAxlx1MDAwNVCeoalcdTAwMWQn+9FdmHLDyNnfg8uodTtcdTAwMDKJXHUwMDE0/mTgfrVcdTAwMWKG8Yr4b/xLM6rVwvif+Vx1MDAxOeuF9Pu+Qz36zfVW1PDOstpcbuujXpREpMOGzUk7N8ZVsiSg7rrbteJcdTAwMWS1u1EjioPW1+lWPc2ZXHUwMDAxi2eHYVxmOEVUoXF+bz4wx1f609ZBv9M53ZKbXHUwMDFmq+8/71x1MDAxZL52b7aOkXJDq1BobnKKNlxyYyiYoJNaodNKKlMwrFx1MDAxY29cdTAwMDbMOGTozblzXHUwMDBm3uxcZqCRSmZ38DeIWUpQzFi2N8PKL5Q2ReetcLI3g1x1MDAxYfnmkrw5b9VMb75cdTAwMWbmXHTuLCjgTPVnXHJcdTAwMTSXtMuJocf8efbML+DPxSD4gv5cZsYxdEpLZVx1MDAwNXJNideoQ2vrm41cdTAwMTDg6FJcdTAwMTC6YFo5XHUwMDFlrYk2hFBcdTAwMTKApsQ6nvHG0L9cdTAwMWRnXHUwMDE20GpDjsCdmaRRSVxccKKdJ0Tv1MxcdTAwMTn+PsMjlbJOLKJcInN2XHUwMDA03eRdXHUwMDE016K4QY1cdTAwMTmVfK8ybM9cdTAwMTElUlx1MDAxZq72vZWcXHTllPSJsybcWpVcdEs/XHUwMDE2QcdcdTAwMWLNQFhJk01aXmr/7+GKb0Ozwrj2uFGzM7CcUWucgeHcijTrp+R/kk2KQqexUjjpaO6tXHUwMDE5s6lcdTAwMTX0ko325WWU0Nh/bkdxUlx1MDAxY+N0MNe9ozfDYIxB6J7ybUVG6PhcdTAwMWVHiT17t5K5TPph+P5/bydePVx1MDAxNcv+XHUwMDE4Q3HW25v868JUZkFOT7BcdTAwMTUnW3CBPGO2XCJ9pUxmgUnUymjDSX7wQnXJSMtIXHUwMDBmWFx1MDAxNFx1MDAwMlx1MDAxMVxi/C9DZI5ZckJjuOFWTiQy5Vx1MDAxOGiN5H9cdTAwMDLISclZx5iMfIE6cFnDMojs6YnCnEQ2O3lcdTAwMWQhMs0taUhKXHRBccvR5i6651xmw1BrcjJCtsy50YIsNruGOmJcdTAwMTGXxPIkJo1cdTAwMTREU1x1MDAxM0js5+asabD1x9o4Ylx1MDAxN2StXHUwMDE5hUG0tnj2O29cdTAwMDHXXHUwMDE0yEDLjNlcdTAwMWXjrePT885e5Th+31x1MDAxZlxcRWu1TrR+dNF83Vx1MDAxOVx1MDAxNfi6oPWFXHUwMDExMFx1MDAwMimBzO72vihuXHUwMDE5+aZwzlx1MDAxMYNbmUsvn15cdTAwMTTXurzSoCNgSJJemdmlZVmPXHUwMDE1rrPg8nKFa6LGafjUToLBXFx6+Vx1MDAxODrXb+tcdTAwMDfBzqf1+LyyTTnCIN6CratS0VlcdTAwMGJ6zbBcXHgqYL5cXG2c4Vx1MDAwZYBcdTAwMTdcdTAwMTN+R0GXlI5cdTAwMDalwaJ2ZdStVYmVayFcdTAwMDWCUy+CzyFcdTAwMGJOqFx1MDAwMtTtYSVuXHUwMDFjbFx1MDAxY21tXZ9WKlx1MDAxZiuN3km/nCqAplx1MDAxMETiQC1cdTAwMDH9gtT4dFnprFEqP+WP0vOHs697h5+PzuqdXHUwMDAzPKq15Hn7Knnl9KzSlFx0lNdcdTAwMWFcblRu+S/twCGzaDSBTNNYQK75Kfi3UHVcIixz3caBXCI5vOxFS1x1MDAxYoZcdTAwMWb6XHUwMDFm7+5a56f6rI5JhLenfD70v53V77vbzul54/Bi1+3dbVx1MDAwZrqbUbR7Wi+h3+NrXHUwMDFi697B7mDQaZze1bu1qLr+R1x0/fbE+sGOXHUwMDFl3Fx1MDAwZWrrXCLYUdHazWkvLIdcdTAwMDW8KJDKubJYYFrJXHUwMDFi3dRcdTAwMDDo9Tl3Ml9Ee4xcdTAwMDFcdTAwMGX1p+jaXHUwMDFkh1eV3ubmn1j5uvm+rZ7CXHUwMDAwy0ssXHUwMDAxNVOCXCJcdTAwMWNcdTAwMTjKLa1Uo1x1MDAwYliCI6OMUyNpOGVcdTAwMTQ+s+ZccmDPw1x08kzpXHSppJxcdTAwMTDuaEpcdTAwMTRlTi9Q9J4lx5RxsFxiXHUwMDE0s1x1MDAxOc/K0mhcdTAwMTjZXHKgvVxmttqNXFwzLFJn+vd7kTrodFhvQJlb86yXVoZ/uX/BycXq3ELBMorVM6yb6Y/Ti9ZcdTAwMWPMNI90vvRHOkzM7ZCzma9cZod8XHRNqli6sMNcdH7kl06MeCRKzVxmSLBcXFhDI1JcdTAwMTRcdTAwMGLlOCQw37lUoFxyWIE4qdRjXHUwMDA1Q4da+1VlXHUwMDE0+dTtwVvRSikt8ieE5+dUeoBTQvlcdTAwMTRvnbPSM1vnreTrKs761X+Lylx1MDAxOcmlyVx1MDAxNVx1MDAxZVx1MDAxZVxuK4o5wf1FSFOluJFcdTAwMGZcdTAwMTdMKfWM3sVPVIKZjid/XHUwMDE0kZT19ib/+oRcdTAwMTWwnHuMhXdcdTAwMGUkLFx1MDAwNZ9f4M/WO6+TTYw0zO+70ki3i4iFXHUwMDA1MFx0TKVcdTAwMDOhTH7TVplUwpklia5IO5BzgsZcdFwi3zpcdTAwMDagUFJYXHUwMDE0xlx1MDAxYZlcdTAwMWKfXHUwMDA3Klx1MDAxMZo0XGKiWm7R2C836SetR5dNJWuCUTwgMVwiOWViXG5cdTAwMTByXHUwMDE33VOJZKTPNEpBI619reJvSiVrU1x1MDAwMeWPMSiVxiXcTS9cdTAwMTZcdTAwMTC1SePdaG4umZ3rvU4ukY5cdTAwMDaXK2JVXHUwMDFmzLRcdTAwMWRcdTAwMTUmNOSMa1x1MDAwZVx1MDAwNECjKVuXXHUwMDA1w8piXHUwMDEzpL7RXGItXHUwMDE1IV2bSUVcdTAwMDPHmfa0RmlcdTAwMDVcdTAwMDClNFx1MDAxM6SJcSgo21nyajrkPffHSlx1MDAxM6JcdTAwMTMgceaXRSjqar9vfTKlOO5cZo20X71cdTAwMWVfuv57UMpcZlD5Y1xmTlx1MDAwYnLKrJ3jZvqWO0cj75TUXHUwMDE57TxKKr/38fxz/Wj75FCfSLu373ai3dddgbSWM+VcdTAwMDR4tuZo5Gj5gSQzI6bn1ilcdTAwMDSH8nn7Z8tfXHUwMDFlks44Q1x1MDAxZbLkesSyloesnFx1MDAxZfJoSEg1gpj/mZ6dXHJcdTAwMGW7h4Ov8CFpVFx1MDAwNvWrvT+7W+9fOzolo2nXVlx1MDAwYidcdTAwMDGwXHUwMDEw8biXXCJCXHUwMDFiR6qMW8WfXHUwMDE38souj5NmJlx1MDAwNlEvUSxcdTAwMWJcdTAwMTLgXHUwMDEyq+NcdTAwMWLHXHUwMDFmZaRO63+Yg9v1rb3Eid8/XZRSbS7dpaZusJ7+iJyg8FwiXHSQXHUwMDBiXHUwMDE0t1x1MDAwME+qX9Q+bm13XHUwMDE0XHUwMDFjvW+a5ufaa5eQVvpcctbKbzJBa/Lrb2k6KvzOXHUwMDE3J8ibKFx1MDAxZlXuZfxcdCaloONcdTAwMWKshd/jrVx1MDAxNVx1MDAxN0t+XuLlcO6LY2ilWPrzXHUwMDEy+Cp3WOPzd1hLmPr4k/D79Ei32Pn3Jc6e+Ve5fOS0ZqBImVHex7WDwv5cdK2Ypmb/JIWw+efryvRnbVx1MDAxOGl30ipSS+B20sNQ1jFOXHUwMDEzXHUwMDAyXHUwMDAwXHUwMDBl1Eih68HXwVxuxV1+rW8ppeone+Oc+eDsXHUwMDEwMZJcdTAwMGZcbktJMeU3KFx1MDAxZFx0WpywXHUwMDA3XHUwMDEwaKa11ztaXHUwMDFhTjP+PVxyWnBb4mxcdTAwMTm4MrK5WlC+ZZVcdTAwMTbaot+GkD1dN7Rcblx1MDAxOKWupHGs81dKbse3fP9MmehULPujiOKsszf510VVicht8iqSXHUwMDE4aVx1MDAxMrA0vvPnoFti33aSRudT+6ZDaDpcYmrx2bspJNZcZqrNfjf88TpfkPDgQlxiIDCholxmf1ToXHUwMDFigcwh+ke+QPuc7zk8lnSDuNdcdLrkXHUwMDEwXHUwMDEztEkuxc20ybi058I/s/xcIlx1MDAwYuGztMmL7vuS4DRmkm9pT391w8v2dV7m/nBlktn0NF2SX+YperT0iSwp7Pk9evakL+TRS9zXXCItU9zHdK7AXHUwMDA3hsLzXHUwMDEyfl+LzzMoeZbGuGetos/0aFx1MDAwMaSB/Fx1MDAxNlNOXHUwMDFhSXOZ+48gsno1MFx1MDAwNc5vMjHoXHUwMDFmRCu6u0/wpXJmmdXq5/jjnOpkdqgoKFx1MDAwMUupo6LwbqSQOlx1MDAxYqJMnnCGXHUwMDE0XHUwMDE40Vx1MDAxMI+npamnqZPZu5hHXHUwMDE0XHUwMDEzd1xuaL6Ms1r6xYRxmyhqkDiRaK2jTFx1MDAwMIT6uZ/9mlx1MDAwZWV/rFx1MDAxNVE8TZ68efhcdTAwMDG/eWg/IcxccqeDYFx1MDAxZNVcdTAwMWWIPLvL1esoXHUwMDFjvJu0nzo9PEem4+mZKPT3+te3N9/+XHUwMDBmJe3LnyJ9 + + + + Screen 1(hidden)Screen 2 (visible)app.switch_screen(screen3)Screen 3 (visible)Screen 2 removed \ No newline at end of file diff --git a/docs/images/stopwatch.excalidraw.svg b/docs/images/stopwatch.excalidraw.svg new file mode 100644 index 000000000..365a10ff5 --- /dev/null +++ b/docs/images/stopwatch.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nNVcXGlP40pcdTAwMTb93r9cdTAwMDIxX2akxq/2paXRqFx1MDAwM4R9XHUwMDBiW8PMXHUwMDEzMrFDXGbxguNA4Kn/+5SddOwktuM4S/tFLVx1MDAxYVxcjuu66tx77lJVf33Z2NhcZj48c/PbxqbZb+pcdTAwMWTL8PX3za/h9TfT71quo5pQ9HfX7fnN6M52XHUwMDEweN1vf/xh6/6LXHUwMDE5eFx1MDAxZL1pam9Wt6d3ukHPsFxcrenaf1iBaXf/XHUwMDEz/jzVbfPfnmtcdTAwMWKBr8WdbJmGXHUwMDE1uP6gL7Nj2qZcdTAwMTN01dP/q/7e2Pgr+pmQzjebge48dczoXHUwMDBiUVMsIFx1MDAwNmzy6qnrRMJcbi5cdTAwMDWFXGaR0VxyVndHdVx1MDAxN5iGam0pkc24Jby0+aizxo/G/VPr6uX83Hu/ueqSPo97bVmdzmXw0Ymk6rrqZeK2buC7L+atZVx1MDAwNG3VXG4nrmd9y3d7T23H7HbHvuN6etNcbj7Ca1x1MDAwMIyuXHUwMDBlhuDbRnylXHUwMDFmTlx1MDAxMCdcdTAwMWFkXHUwMDAwUoboqCH8KpJcXCOEYpi4Plx1MDAxMGbb7ajBV8L8XHUwMDAzRJ9YnEe9+fKkZHKM+Fx1MDAxZVx1MDAwMpqAJlx1MDAwNuF9+IqqQ1xyy4lcdTAwMGXapvXUXHUwMDBlXHUwMDA2gmtcdTAwMWNcbp7o24yGXHUwMDFkXCLEOUFcYqNRS9ijd2BEXGL4c3Lg2rrvXHJcdTAwMDdos1x1MDAxYv6RkDZcdTAwMTR0d1x1MDAxMj5JXGIlpvag2/C2m5+HRzdcdTAwMTf1t7uL8114vnc4etZcdTAwMTjedN933zdHLT+Hv8Wi9TxDXHUwMDFmgFxiMlx1MDAwNiRcdTAwMTaSXHUwMDEwgmNcdTAwMWN2LOdFNTq9Tie+5jZfYtxFV39+LYF3gmBcdTAwMTbeJVx1MDAxNYJQSorj3XrEnty37P7JsSlcdTAwMWT97NV7/VGvON5cdTAwMDXRXHUwMDA0wXxcdTAwMWPsXHUwMDE4YlxyYyqT18uAvaVTRNE02JWGTWOcsSlwc8FcdTAwMDRUUCDrXHUwMDAy9y/MXHUwMDA0Zj9cdTAwMThH82CG67KPb+3+/bFcclx1MDAwZnZunFx1MDAwN3v/sL01XHUwMDE3tlx1MDAxOVx1MDAwNVxigWVhe0zOYmZcdTAwMWMqXHUwMDExXHUwMDE4R1x1MDAxY4nCuE5/63RcXLf1Zrvnm1VAtkxDNlZ4X1x1MDAxY9mBrztdT/dcdTAwMTWaUtBNU9CN8LTpJlAqky9WgO5lXHUwMDAyMJ5n11x0Lq3PcKhcdTAwMTNcdTAwMGZcYq/WddvqfIxNVYRMJell4CZcdTAwMDXVu6bqMcIhXHUwMDFmu/d7x3pcbpG72VTvYPpjoFx1MDAwZSzl6oxusC3DSFx1MDAxYfOmXHUwMDEyQFfP9Fx1MDAwZopcdTAwMThh17eeLEfvXFwl5SvPXHUwMDFmjMlM/lAug0SCo8J65lx1MDAxY2/3t06R6Fx1MDAxY8BG42brXHUwMDAzXFw1/I9q81x1MDAwN2NEXHUwMDAzWIJJd4lgpPRcZoGF/aWmaVx1MDAxMEMvSiFiSskgR8q1XHUwMDEyZG1cdTAwMTQymMpeXHUwMDFmn+zsideL9zuj9tK6sE9/bDfS/aNIU2Jcbvma/thZzJTeYXFmXHUwMDEyWPmXWMaTtVwiZqJcdOM4yUycXHUwMDAzyVxiT8zsLI3JXHUwMDFm5ooyk7JcdTAwMTnpOkOp8rpcdTAwMTbXmaWQXHUwMDEzXHUwMDA3XGIoXHUwMDFkS7jhqyenXHUwMDEyXHUwMDE4XFyMnFx1MDAxYWbXXGbWyk4zTPwkO1xyXHUwMDA0LE9PMGFcdTAwMTEntI1wXGKQXHUwMDE0rHh4c/zs9smja183ru5Ojp73jWtcdTAwMTI0q01PhCtVo2Lc34v8QMQ1oMhhPKYuXHUwMDEz4kSfND3DXHUwMDFhnExcdTAwMTiMXHUwMDE0XHUwMDBlU02OO6dDveMq5FRcdTAwMTaArkDtyvHKx0n/ljx8Xlx1MDAwN96xW/NZv+18st1cbkY8kmRcdTAwMDFcdTAwMWQyjpVZ4ax4wJP+0lx1MDAxNadcdTAwMTVcdTAwMTVIZGBd+WJ0knCWzitcdTAwMDRPwzyFV6hcdTAwMDQq5GGr8MeqXHUwMDEz9Fx1MDAwMPAt/Mc1XHUwMDA018ouMyz0JLskxSzPMVhmptCgQFxicVxuOSusende//LkZGf39uiqVoeg3Ttv+8e/k2RwkZQxXHUwMDA1krOpnDGBQmOccrGqIIgypnHO8Vx1MDAxOJOMJY1cdTAwMDVcdTAwMWRPZY/CXCJcdTAwMDRcdTAwMTX/XHUwMDEwsVYtXHUwMDE0XHUwMDEwYonm0MLyoKRcdTAwMTRngpJcdTAwMTPJkVx1MDAwNKQ4XHUwMDFmyGOhO/unXHKf1M97bbttXl1cdTAwMWZUvZAhqFx1MDAwNlx1MDAwNaWEXHQ+XHUwMDE5mlx1MDAxM00uI7ubVcoomt1ccrP8gvLEq6wlNP++3Xpr2O32XHUwMDE2Mz6fejY4erg74uOuz3JD8/RcdTAwMGWr50IpK5KlM0x5XHUwMDBmXHUwMDAwwDlcXKj8Ua6oXHUwMDBipaxCltJQppElKM1yYnMsXHUwMDAwXHUwMDEwfFx1MDAxNTW/6vhQl4Hurzc2n2HlpzPHoYBcdTAwMGL4TSiborCQiOE5XHUwMDEyYW7t5NW8QPzItnuNeqv/dnyB33+vuvFcIsE5XHUwMDAxqZkwXCKVT1xuVqxspVx1MDAwMnRKwuhFrrve/iDfL8+O/Ju9k7bVMPZcdTAwMGZcdTAwMDS+P2uvkrTSO6xcdTAwMWVpIZpZgFGBP8CcUFA8xZU/zFx1MDAxNVUjXHUwMDE192eoXHUwMDExpVx1MDAxYV9xOrlY2C9cdFx1MDAxMpJSuM5s8lx1MDAxMIFwXHUwMDBlXHUwMDA0LsZYw3BcdTAwMWFo41x1MDAwM7py3pph+zOi/kjM8uyFWKazKFx1MDAxOEZcdTAwMDBcdTAwMTFQ3Fl8RkivfaKzXHUwMDFh3vGI34JXd82b/d9cdTAwMTlfkdlBP9Mog1x1MDAxNOGpJVx1MDAwNsroaVx1MDAwYkf8Lf1cdTAwMTFcdTAwMDCaXHUwMDE28XNccipdXHUwMDFh0/WR4il9R1igsPeoXHUwMDBmOKmHXGJcdTAwMTBlXHUwMDEwMV1fOXRcdTAwMTbP6PpcdTAwMTXbu7o/OFx1MDAwMSe39nZw+eRcdTAwMWScXs63WkxIXHRji7NcIp6BIDvBLJByJOZYXHUwMDE3mf7O6XBv+m63u9XWg2b794NewEzQh1x1MDAwZVx1MDAxM4aMrDTJTCmfXHUwMDA2fUqAxCRcdTAwMDJUKcl6k8xzXHUwMDAzcTG62Td1I0lcdTAwMWJrYJpcdTAwMTl2epJphlx1MDAxMpYnXHUwMDE5XG4zSVx1MDAwNlwiyKOca2Gtc+y3k8tcdTAwMTf48Irk+71OwFx1MDAxYjx9LpVZRkvSNzpb34CGXGKcjIRcIq9cdTAwMTAqfWNkXCJEKUE0XGKJR5OlXHUwMDExXHKZVjWcsohcciNcdTAwMTLSUWVcYuU2QPrdzsOWj/pta5/dP+3hXHUwMDA3ez5CkYrd4/dZVeDCM0vzkGEogUCgePyf/ta/nVJcbkCcZkJcXPlRy4D4XGZGSYF5SvxcdTAwMTKuhyFcdTAwMDCtmVDmXHUwMDA14mKEUnfdYM2EMsMmT1x1MDAxMspQwkVcYiUn56ZAiOZSujP60NxvdY1cdTAwMWYt1Kyf39qn/aB5U/FaJdOYXHUwMDEwJKVYSTHXplLf1ShWXCLMJcOYraBamc4xw1x1MDAxMsZcdTAwMWU7O3uxrf7lNjk6291BXHUwMDFl+Vx1MDAxMOk5t1x1MDAxMntcXFx1MDAxMGF8PbVQxrM3XHUwMDAzIE64ilx1MDAwN+fINNvPp/a2d3x0dF4zOvt35lPt8sWpei2Ua5hcdTAwMTE0UYn/XHUwMDE42H9ccirVXzhgX7RcdTAwMTgqiFRTQcSai6E7XHI9eDyy2s9cdTAwMTdcdTAwMTf711x1MDAwZl3y2q5jP1x1MDAxZOPF8soreuwsry+9w3lcdTAwMTRS4DCHumqvj+akq7lcdTAwMDBcdTAwMTBcdFx1MDAwNouHNPnDXFzZXCIrytRGTjW2XHUwMDA0bVxcSpVcdTAwMTVcYkokoGtdXHUwMDAxXVx1MDAwMoaL+Xzrr7LO4I9lV1lcdMkmP4wogpTC4jt0asfXzkO7Z+yC163vV/XWNdjln5WvXHUwMDBmQY1yXHRTNjVTJrRVr1x1MDAwYi1XZoWIXHUwMDAxiClfRVxuL4+4LpqH6PFwz3Vfnlxm9/Ni79Opi93F+XDpj53Fh+lcdTAwMWRWj1x1MDAwZrHIXFw8ijBcdTAwMDVcXMGmeFx1MDAxOSl/lKuqnSxTOznRhFgxXHUwMDE5XHUwMDE2q98qXHUwMDE2RGqq1rxVdc1cXPi76rczSKV0/TYz84hpls5cdTAwMTGBJZtrb3jt7HnLe39/dPmLrd95dYs9YiND59aSVZ/tgEpGNSogSFnmR2G4LUjmXHUwMDFm84FcdNZJ2W2rXHUwMDAwpejbXHUwMDE0/SGiXGaCSM5D1TNcdTAwMWW59Cdr37dcdTAwMGbPXHUwMDFmxPfDmjh8faa017r/sbRMXG6lXHUwMDAwr6/sXHUwMDE2blx1MDAxOH9cdTAwMWbPnSdthFx1MDAxOPvCyFx1MDAwNHTMVpBjIcY2yY+bh7FcdTAwMTdJ270+XHUwMDEwJtdcdTAwMTJcZlx1MDAwNjWNfrOPP1FcdTAwMDAkmPB5zvvJn+Zq2lx1MDAwMlxuNFxmXHUwMDA1Y1x1MDAwMmMsMJw0XHUwMDA3QoOYhVx1MDAwYiZouFtyNTaBizBcdTAwMDWFZbhcdTAwMWSR8OSBXHUwMDAx8cJcdTAwMGWoQYSEomGGpVx1MDAwMvykvVAuNCfhSq/57UUkZNn6hGCiVH2iXHUwMDFiRnU1yzEs50k1xibj1/lVXHUwMDA3XHUwMDA1yCXS1WYvlHJcdTAwMGJoTKrRXHUwMDEzMFxc8IeT5yGFY6F7USSiodCLYThcdTAwMWNGQeTwhpHp2jRcdTAwMWQjlmn8NfRusO3atlx1MDAxNahcdTAwMDE4dy0nmLwjeqPvoZ61TX1Kb9WTk22TXG7phU9cdTAwMWO3zvFvXHUwMDFiMWSjP0a///k19e6tTDxFrZNQilx1MDAxZvcl+f/cplx1MDAwNKHszJZEUir9wsUzW/lMVFFTwjV1V9pcdTAwMGVMisP1zFxmQ45BaFNXYkaksmScyF9cdTAwMWaeslSGKFx0XHUwMDE5XHUwMDAyXHUwMDEw8TCyXHUwMDAyaOq4XGbVICVgskTOa1x1MDAxMTtCldqW8vOXbUeApiw9UapcdTAwMDEg41xckZ9I3DQwI0IjXHUwMDAyRJ5jvv3IkiW/aDgmXHUwMDBilzzccqIgxaREdFpcdTAwMTaENUSHO1x1MDAwZqek+VvZrEzwhp8p2M5pszKTXHUwMDBmXHS+nNzwhChnhOHiqcGjz7u911Zw8456jkktv9WA9duKWywsNJZusZQnpFx0wfLPR1gsXHUwMDEwimWO3ZxYvtFSPlx1MDAwZVx1MDAxMFwiZM1lsVx1MDAwNVx1MDAwMpbcQGhVXHUwMDAx1mqPY1x1MDAxNJSBeUrVS1xusP7n/DOyUKbxr9RYK5G6mi9cdTAwMWazcLSVlKyct4SzXHUwMDE3okjCwpNw5ti1kj/7XHUwMDE1NT1UxV2/llx1MDAxMidXqlx1MDAwZk6skJpiWIakcth5cqf2Mk1cdTAwMTBcdTAwMTOaXHUwMDE4nLhcdTAwMWFcdTAwMTGPiD2QODOjUVxuOFx1MDAwZuXkTFwiknagXHUwMDA1XHUwMDA2UGBaJlVT5dArn87G3Fx1MDAxNFxiiVQ2XHUwMDFhcCYoJ3FcYj1yU5SXMtixkO8x/X0jrkwkhZ9pXGbN6b7k1jd5zuJcdTAwMWWoaF1IJIpnc9+E3fQ+3G6999yRXHUwMDA3dv2cg+1SlmSdp6lRXHJcdTAwMDCkQszpbC5ccjclrGg5W9FcdTAwMDNcYjFV1mE1S0fzPILT21OvfaVcdTAwMDb70rm+wVcnwdbxoZ3uXHUwMDExzFPIXFz6Y2dcdTAwMTUy0zss7r4oJlxy/cd5dlx1MDAwMeYqY1YskdyJMJX+4FhKguY4ai1/mCu6sEe56pmayFXYXFyBo1x1MDAxM1x1MDAxMMRcZofnXHUwMDExrfPgm1x1MDAxMlx1MDAxMFxczINe/7GGM3gj71jDL0Pl3dQ97zJQ4zZyStTUWMbw5eOx2nyzzPda9kl8X4a6XHUwMDFiKolcdTAwMTlOzF8/v/z8P1xiXCJcdTAwMWT6In0= + + + + StopReset00:00:07.21Start00:00:00.00HeaderFooterStart00:00:00.00StopwatchStopwatch(started)Reset \ 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/images/styles/border_box.excalidraw.svg b/docs/images/styles/border_box.excalidraw.svg new file mode 100644 index 000000000..1d488e24a --- /dev/null +++ b/docs/images/styles/border_box.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT28hcdTAwMTL9vr+CYr/G2nn0vFJ161x1MDAxNs9cdTAwMTBcdTAwMTJcYpCsgdzaSim2sFx1MDAwNbJlZFx1MDAxMSBb+e+3RybWw5KwjU2c7CqpXHUwMDA0NLLU1vQ5fbrn8fdva2vr8f3AW3+5tu7dtdzAb0fu7fpcdTAwMGJ7/otcdTAwMTdccv2wj00s+X1cdTAwMTjeRK3kym5cdTAwMWNcdTAwMGaGL//4o+dGV148XGLclud88Yc3bjCMb9p+6LTC3lx1MDAxZn7s9Yb/tf9cdTAwMWW6Pe8/g7DXjiMnfUjDa/txXHUwMDE4jZ7lXHUwMDA1Xs/rx0O8+//w97W1v5N/M9ZFXit2+53ASz6QNKVcdTAwMDaCkMWzh2E/MVZIXHUwMDAyyihcdTAwMTi3+8NtfFrstbHxXHUwMDAyLfbSXHUwMDE2e2r9rue/8+NmcPL57IB5XHUwMDE3LvGvXHUwMDA2nfShXHUwMDE3flx1MDAxMLyP74PRi3Bb3ZsoY9IwjsIr79Rvx11sp4Xz48+1w9hcdTAwMWEwbo7Cm06371xyh7lcdTAwMGaFXHUwMDAzt+XH9/ZcdTAwMWMh47Ojl/ByLT1zh79x7nBKtNRCcVwiOOhxq/082GaiOVx1MDAxN1xmXHUwMDE4SC5cdTAwMGKGbYVcdTAwMDH2XHUwMDA0XHUwMDFh9jtJjtSyz27rqoPm9dvja+LI7Vx1MDAwZlx1MDAwN26E/ZVed/vwlVx1MDAwNTFcdTAwMGVXmkmmXHUwMDA114an36br+Z1ubI1h1CFaXHUwMDE4rsToaVx1MDAxOWu8pFskp5wrotm4wZoweN1O/OOv4lvtutHg4eWtXHUwMDBm7S9cdTAwMTnzreU7RefKOlim51xy8TZO7k7J3edBXHUwMDEzneY0eHfahPG9ct7oRlF4uz5u+fbwU2razaDtjnyMSslcdTAwMTlcdTAwMTB8+6D5uD3w+1fY2L9cdIL0XFzYukrdMjn77cVcdTAwMWNokIZVoYFqYjRcdTAwMTDK2NR4ODm97MZcdTAwMWLhtup97Hxccj5+eCvfvb2vwMMwRGzPjIbCp1x1MDAxZVx1MDAwM1x1MDAwM39cZlx1MDAwYpSgt1x1MDAwYsZcYjFcdTAwMDZ9jGb8yH5eoP9RySRBJzXEyKJdKVx1MDAxOMRcdTAwMDVvt6BcdTAwMTZcZr9DS3pcdTAwMTdiXHUwMDEyXGJcXChHXHUwMDBiXHUwMDAxUisxiVx1MDAwMSaMw4yRmiA0XHRcdTAwMTOTXHUwMDE44JxqLkHA82Kg9e7tefds78/2vTH9m0M6UN22/1x1MDAxM2JcdTAwMDA0VGKAUMJcdTAwMDDUXGYxYftcXJxcXDc9ev9n8+xEXHUwMDBm995cdTAwMDSXXHUwMDFmTueLXHSsXG5cdTAwMDVtd9hdbEyg6GTC8jBwXHLAqVB5XHUwMDFjKOooQzlcdTAwMDEjXGZjylTiwJNKPSUooH9PQoBmYtSDzzNcboDxKXv5szj94U5M3vpcdTAwMWRcdTAwMTF/jravTuLgXCLce/Wm3Olj7y7O+PyLutteXGZOXulX/a2d/YbYi09aXHUwMDAxOT6/m1x1MDAwZUtcdTAwMTX3zVnxYtov8uMgmrMzKyiVrkInSCRFaVx1MDAxNJlcdTAwMWGcXHUwMDA3XHUwMDFmVaB29oP30cX713s3w43b1n60YME2Y4iaQq8x6TCigVx1MDAxOcWI4kJCXHUwMDBlm/hcblx1MDAxY661tGKNXHUwMDBiMLxcdTAwMTKbT1x1MDAxNWySTUKTiVwiMlx1MDAwMcMkXG6yZUSjRTpj2ulhP37vf7XvnZHc2V2351x1MDAwN/e5fku81DqSXHUwMDFidfx+9lVcdTAwMGU9fOZIN+Wu3lxi/I714/XAu8g7eOxjfjNujsPMN2/h0128XfS6XfxcdTAwMTZh5OOT3eBD3pK5sMVcdTAwMTWtwlx1MDAxNkXppylcdTAwMTAxfeR7c35w+nZn+PntznHcUTuvOifdT3NGvrmyITJcdTAwMWa6qHFAXHUwMDAxXHUwMDEwXGZrhGNOkkeX4tIxkoNAiYgvg6uloSujMWrQRVxyXHUwMDEzXHUwMDEyreR68fCqXHUwMDBiUFFHXHUwMDFlNFx1MDAwM/r5LrzqkyDUp2Hv4vCHib2ngffIbbf9fmdcdTAwMTXQ+92U+UJj5itcdTAwMTfhK4iNXHUwMDE3POMmj8G3XtmsKnyphDrhisjF5OlZhCuUXGJXllx0xlx1MDAwZvhcdTAwMDVcblx1MDAxOMzR8X+d8MgnXHUwMDEwtoXNaNVcdTAwMWG+K7dcdTAwMWNmplx1MDAxY2Yt/JRcdTAwMTfVXHUwMDAwree329k0Lo+1x7KvXCL8cnbWYrA+gzSqXHUwMDEyiEQxRtksXHUwMDE5ZGfbb59s3HXce/ewXHUwMDE37t1cZm72gFRcdTAwMDCxXHUwMDE1hcNho+vGrW5cdTAwMTVcdTAwMTihXG6MXHUwMDBiXHUwMDE3qkkxXHUwMDA1OCgjXHUwMDA0l0LlXHUwMDBii4JqR6AyJJpQplxyiEooTlFLqYXi4/VcdTAwMTSDykaWXHUwMDA0V8JcdTAwMTlVmstnriY2L11/6+p6p8ng3WB/+PngVu2cPyn7XHUwMDFi3fe8/fVi19/a273YdU9PT10uP7Y+rWaFZvT8MmwhdqrAhXxcdTAwMGWU80xF7DFs1b/pmbFVWaFZOLZcdTAwMThcdTAwMDOHalx1MDAxNKuGIbKooDxcdTAwMDcujSrWgNFcdTAwMDQ1LJGELS9cdOTUYWAoXHUwMDAzRTlQLUuq9txcdTAwMDLdSI1g0txcdTAwMTBcdTAwMDZFnKH1wsBcXCliYulzx8Bh7Ebxpt9PpNrLXGbSMFx1MDAwZbZukk51XGJKXGZMXHUwMDEwNL5cXFsqTkG23nFcdTAwMDdJXHUwMDE3O1x1MDAxNN1VXHUwMDE5aS9cdTAwMDOTdtHaeExrXHUwMDE0w+KtY3bNdjY25T5rvTmmh/y8t/1cdTAwMWSaY8Cve/12rUlccuIwXHJcdTAwMDYlXHUwMDExJ1rjX6kmjGKOMWhcdTAwMGVnXHUwMDA2r1NSXG5VZVR5UJowKnCH8VbY6/lW51x1MDAxZIV+Py6+4uRdbli0dz13Qlx1MDAxZuOXyrZcdTAwMTVpYWDvmKfT9Ke1XHUwMDE0Mskv45//elF6daUn26Mx4cTp7X7L/j+raEfXXHUwMDE3xdNjsYCRx8pYPT2hlTvLs1x1MDAxMtqcwlx1MDAxZDRihitqhLKygFx1MDAxNataysGEXFxK7Fx1MDAxZMy6dbVaeHJVK33ZdXk31cJIhO3PI1xyllx1MDAxMMJnyVxuJvPuzTBqZ8X9j0u7XHUwMDFmLJlPkVBUlpVcYmbSKIXeP/2gab1IW8yA0cLRXHUwMDBiRDtSXHUwMDEwJE4g3GiTwedIjmAzZkVcdTAwMWP9TGnk0KWhXHUwMDE3g5yQ0lx1MDAwNjdkcNCspESNPM/xXHUwMDFhjpaCLVx1MDAxM9CJpNygnVx1MDAxNNRcdTAwMWMltVWQI8VcdTAwMDD6mCpQxGilJGDXXGKt02g0Vlx1MDAwNdrRkmEqRFxmkahUICtmfnVRUOlP9mhMutKMqqCaVLjkxdNjUsHwx7iZZWZS/fyUXHUwMDE1Jlx1MDAxNVx1MDAwMIZZu1accZpGkIRTXHUwMDE4qlWCnlx1MDAwYlRcdTAwMTCioLqU93ROkYZcdTAwMTJI1LN9Xiqcc6SiNNOIXHUwMDEwNFx1MDAwNclDTiQ50tY5XHUwMDA05mT/XHUwMDAwViHYb0YgNFx1MDAxOFVcdTAwMDJJJX1cdTAwMWJp/lNFXCLlk+h+clx1MDAxMqlyIHtMus6MJJJoplx1MDAxMlx1MDAwZdGiejyAII1cdTAwMGL0yOnHynfc+91B0CHNe9o6+nIs2NZ1pz9cdTAwMWaFPN9YOShw8ItcIj2D4sJQmtclglJcdTAwMDdTPWCUXCLhSrk8XVwiMN2XmOyXliCpU6xOfs8yXHUwMDEwPVxcSzPPdMbVJo38zVx1MDAxNovlXFzbQoFcXNKL9lx1MDAxOPffgoCLwKxcdTAwMDIuYlpcYok6e2rcfo1JTM8uL4/5K7155DF+pE/C1cetclx1MDAwNFKmRKFjU6g8bJUmXHUwMDBlalx1MDAwMi2JoFx1MDAxY4XZ8opcdTAwMDGA2ldQkVx1MDAxZFx1MDAxYlx1MDAxOKOWODyRhSWwTSZcdTAwMGZgXHUwMDE2NMeg3r+wTdpcdTAwMTZcbtvJXrRHI+3ARal2wyonp1FJmJI0XHUwMDFik1x1MDAxZkNu97T1seG7uumfiNthXHUwMDE0XGbgar9q4G91kGvszFHCXGZTzNhZNPmBXHTJXHUwMDE0akOpNGWYWGUnky9etVx1MDAxYrt4g9tcdTAwMDVcdTAwMDE2PYCSup5cdTAwMWShxORBg5DKlutFxpxcdTAwMTGUNbdF2J82XHUwMDAwl2f8XHUwMDA0iOBcXFx1MDAxYqFcdTAwMTRXoFVWi49HJ1BcdTAwMTNJ4Fx1MDAxOG64ydVcdTAwMDRyYl1cXEf87O71wac+XHUwMDA0fPPr8aG5fffpkbGJZXLIUmV8o9qlkuZJb1pcdTAwMTSvUKKqS4xIYVxcIK9MX2I87X9yNztcdTAwMWJcdTAwMDfXsblcdTAwMGVft44/6aG6Wn1iUY62Yy+Gg9CSZtZtJcSiqMOMXaYkUVx1MDAxNDC+vFx1MDAxMU9MXHUwMDE5gFx1MDAxM84lXHUwMDEwUJiz8ZJygKHoKlxuXHUwMDEzXHUwMDBlwTRXYrJcdTAwMTiAmFI8y0k/O62gMqaYvFwiWVwiLlxieiOdZFx1MDAxNe1IXHUwMDBlXHUwMDE4XHUwMDE4XGYjnFxuUVlH/EexSrU72aPoSDMyStWoo1bV1UXQXHUwMDA0I7NR009cdTAwMTUs769V51x1MDAxMyRcZoxvmml0VU15fqWX5MqRQlx1MDAwM1x1MDAwM8XxXmp583xTXHUwMDE41I43XHUwMDAyp9RcdTAwMGXaz05cdTAwMThPXHUwMDE5bqyPXHUwMDE0OVebaSZSvbStve93v19cdTAwMDbNzTWMuTfqtYxcdTAwMDP8qGHMXHUwMDA3S2pcdTAwMTmhquaQXHUwMDFioyzOq1wiRlx1MDAxYqpcdTAwMTlMP1x1MDAwZmEn2N30olx1MDAwZsHB9kWjcfl+b/NAdVc+d7GpXHUwMDBiQt1cYm2IMpLnU1x1MDAxN0GoYydvUmaFs1x1MDAxMMurOlx1MDAxMEdcdTAwMTLDkPIxg0LuZ7REYYBwXGJBMapcdTAwMDU2M010SfHQ2HnFdJ6lXHUwMDAxqyAyfrUqRHWv2qMx2aEzxvoqZGeHxlxuwFaCSsblXGZcdTAwMDOJW/os+qT3z/eGTdlvXHUwMDFmeTJqbp6tOq5BMocwzKE0SClcdTAwMDXPZ1x1MDAwZWCsLtVSXHUwMDAyaFBMXHUwMDE2J0gvXHUwMDEy11x1MDAxYWWVlkJJSqTWZSVcdLA7XHUwMDFjoFxmVIKJZMnVXHUwMDA0rDm26rmUwFNRndryL6rHXHUwMDE3VPapPVx1MDAxYVx1MDAxM905I6irS1x1MDAwMjWLXGYwvbKlRlx1MDAwZdOXXHUwMDA0ro/8+y3SNedcdTAwMWa67Y7vn53e7u6t/ChcdTAwMDFcdTAwMDNwXGaVRktjXGLDfLqwdYnmXHUwMDBlxUxTKWqzTbnEcVx1MDAwMlx1MDAwNKVDsEeYpkTR7Gq8zCCf5lx1MDAwMrBdc66YoXRiXHI7xYTDUFwi5plTuFxu0C4vNqKjUtSPJNlMXHUwMDA3aHZcdTAwMDZNWmy0U42JXHUwMDExXHUwMDA0OFx1MDAxM7Sy2rjt9ptnPGTn9+JcdTAwMGJcdTAwMWKqo2PYV41ftS7QqPSpUeuEOy2MWZioLFx1MDAwZVx1MDAxOCWTXYCmXHUwMDE3XGaXYuvd61x1MDAxYlxugygkV1+uXHUwMDA2zd1ccrq/6sRiXHUwMDE3XHUwMDAxK1wiXHUwMDExiShcdTAwMDeULTlcdTAwMTaIhTmMMrthXHUwMDEyXHUwMDAzu6JrebzC7OQ7u9hYKjvJSZcsXG62w1hcdTAwMTQoXHUwMDA2ICGl0lxcZ1x1MDAwMsN3ZtHG2PDzI1KBJTFcdTAwMGJxiCDSXHUwMDBls1x1MDAwMoKEI7WUzDGiXHUwMDBlZVxcM0zXXGJcdTAwMDVM6SqXM/yjmKXaqezRKPGnXHUwMDE5qaWq6qhqJFx1MDAwYrpcdTAwMGJcdTAwMTUzTEcq77FcdTAwMTXnXHUwMDE1JoQjNJN25Vx1MDAwMFxiY1xuq7ZAXHUwMDFhh2qFnopdw1x1MDAxOC9cdTAwMWG2wKJj+uC6oqO09Icq65lcdTAwMTc51IvRnKvNVHWsj0W19/3u98sgu7mqjiPnzTjAjyo6jlxmmU9qQMbFi8NcdTAwMTBUXHUwMDE4JG0mp6851m+atLKTnFxyKjmuXHTlRmCQyqcwXG6sXHUwMDEwwVx1MDAxNIeB3dOnZoJcIlx1MDAwN+7Ck2pcdTAwMTNcdTAwMDSNtSNRo9XYwpTtP8JcdTAwMWMjXHUwMDA0hlx1MDAwNTtcdTAwMDVcdTAwMGWN5ZM5XGbmWUJcdTAwMTIxzz5cXKugNGZbPGGXeVKN6krb7TUwnGaS/1x1MDAwN1xyYreLsVwiXVx1MDAwMrZcdTAwMTKAjIovSJDSrVxuJiTIzyQ0XHUwMDFhNU6VtE/604xKozqJ0ZVFT8G5xD9meq1Rv9nNXG5cdTAwMTNcdTAwMGKVnNi1lPhfcVxupeCONFx1MDAwNLVcYv4k6qZQPp1YNCQrXHUwMDAxJNGWXHUwMDE3oIRX7PIwRmzJSlx1MDAxOM3snKtcIq/gWck5Uz9gifiySiPUQaEnQWNcdTAwMTKjqZJUlVRGhGPXXHUwMDFlUVBcYlx1MDAxZCFFblx1MDAxZEWOPco325pgj18jgWlU+5Q9Jr1pRlap3dzFsMokxqC254ZPTyyt4y+dm6NBcDxodT9s0r1cdTAwMGZfL5tVeyyt2NYu0tFcbr8tui9nupDH2G1yXHI6LbW+bZipnjzx9K1dqMOBVi2sYII5ljUsXHUwMDAzlk/VtjsoamaL88+b4vy8W7zMQoe/fb9xctN1dzB4XHUwMDFm4y3HhIjv2m8/pD7pbda/+N7tZsmGyVx1MDAxN8lhTU5egsWHZ9/0399++/Z/3Vx1MDAxNVx1MDAwZVx1MDAwMiJ9 + + + + MarginPaddingContent areaBorderHeightWidth \ No newline at end of file diff --git a/docs/images/styles/box.excalidraw.svg b/docs/images/styles/box.excalidraw.svg new file mode 100644 index 000000000..56ffe1758 --- /dev/null +++ b/docs/images/styles/box.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGtT2txcdTAwMTb+3l/h+H4tu/t+6cyZM9VqrbfWevfMO51cYlx1MDAwMaJAaFx1MDAxMlx1MDAxNHyn//2sXHUwMDFklCSERFCw6Dn50EpcdTAwMTKSxd7redaz1r78825lZTVcdTAwMWF03dWPK6tuv+q0vFrg3K6+t+dv3CD0/Fx1MDAwZVxcovHn0O9cdTAwMDXV+M5mXHUwMDE0dcOPXHUwMDFmPrSd4NqNui2n6qJcdTAwMWIv7DmtMOrVPFx1MDAxZlX99lx1MDAwNy9y2+G/7b/7Ttv9V9dv16JcdTAwMDAlL6m4NS/yg+G73JbbdjtRXGJP/1x1MDAwZnxeWfkn/jdlXeBWI6fTaLnxXHUwMDE34kuJgZzT8bP7fic2llBFleCck9FcdTAwMWRe+Fx1MDAxOd5cdTAwMTe5NbhcXFx1MDAwN5vd5Io9tTo46Tt6Y//TbvubXHUwMDFmnISHXHUwMDFkv16tJa+te63WYTRoXHKbwqk2e0HKqDBcbvxr99SrRU379rHzo+/V/MhcdTAwMWEwulx1MDAxY/i9RrPjhmHmS37XqXrRwJ7DeHR22FxmXHUwMDFmV5IzfdtcdTAwMDZYXCIhpMGYXHUwMDEwybA0cnQ5flx1MDAwMMdIY2FcYiZGKEFcdTAwMTVcdTAwMWYzbd1vQW+AaX/h+Ehsu3Sq11xyMLBTXHUwMDFi3Vx1MDAxM1x1MDAwNU4n7DpcdTAwMDH0WXLf7f2PXHUwMDE22CCmNJVUXHUwMDBiplxyS35P0/VcdTAwMWHNyFx1MDAxYUtcdMJaXHUwMDE4psTwbYmxoVx1MDAxYndcZtjJXGI8JfmR1oTu11rsI3+Pt2vTXHS69823XHUwMDFh2lx1MDAwZinzreVcdTAwMWLjXHUwMDBllnayVN9/r+v+vt5vXa5HV0dH52dVXHUwMDE11ndGz8p4pFx1MDAxM1x1MDAwNP7t6ujK7/u/XHUwMDEy03rdmjP0MlwiJSOSSo0l06PrLa9zXHJcdTAwMTc7vVYrOedXr1x1MDAxM8eMz/5+/1x1MDAwNERIZVxuXHUwMDExYYji4Fx1MDAwNFRNjYjdra+tur9f3aq3XHUwMDA3P1x1MDAxYd7N7lx1MDAwMd1cZlx1MDAwYlx1MDAxMFx1MDAxMfqA75nxMPatx+DAXHUwMDFlRYNcdTAwMDI0XHUwMDE4TVx1MDAxOVx1MDAxNVx1MDAxYyshWFx1MDAxNlxylGokJSZKXHS4R6cvj6NB1FmtykvR8Fx1MDAxN69Kty7ySGBCIS1cdTAwMDSXWok8XGKoMIhcdTAwMWFcdTAwMDNewVx1MDAxOcNU5EFAXHUwMDE5XHUwMDEzklxirl9cdTAwMTZcdTAwMDTVb7vnzbOt49rAmE5vn3RVs+a9Qlx1MDAxMHBZXGZcdTAwMDImMeVUSjE1XGKud6+u2mHw6zRw+4HZONu46PHPT1x1MDAwYlx1MDAwYrRcYlx1MDAwNjUnbM43LFxiRpFSXFxIYaiSWpAsXHUwMDBlXHUwMDE0uKDWWlx1MDAxOUOoIIqwQlx1MDAxY7hSqedEXHUwMDA18O88XHUwMDA0SMq1XHUwMDFmiJ9pXGJh0DX8pZz+wZdcIrdcdTAwMWZlvXzY8TtcdTAwMTef9k5+bW1drG1dXHUwMDA0P2818/prTsrn309+7PDLd1x1MDAwM7355Yiz3Z1+c933mt+rXHUwMDFi11+WXHUwMDEzS5nfn5Z/KZCMwchIQjhcdTAwMDeKmlx1MDAxYUU3XHUwMDFlZnzT7Kvewefq9aU52D6W3+YsrmZcZiaPg0hcdTAwMWGDuLA/lEpmtKFcdTAwMTlcdTAwMTBcdMKQ1pJcdTAwMTgjtNRcdTAwMTBNXHUwMDE2pqwkzUOIilx1MDAxY4KwkFQxiHHzR9A8nTHpdL9cdTAwMTNcdTAwMWR6d7bdKc6c3XTaXmuQ6bfYS8HSPSdoeJ10W4YuvDMmd525+1PLa1g/Xm259ayDR1x1MDAxZWQjo8uRn/rlVXi7XHUwMDAzj1x1MDAwYr7Wxn+FXHUwMDFmePBmp3WUteRJ2GKiXHUwMDEwW1x1MDAwNGSaUcDabGpwXHLCzs7hQe1qw2l+3dq9lp2Dm7D5gplcdTAwMGJ+XCK6IERRK4K0XHLLXFxk0Fx1MDAwNalcdTAwMWJcdTAwMDLgYSpccmRcdTAwMTOMXHUwMDFhtTB4pXKicngpSFxcXGZ+YVXmqeb2TXV7/etGh31yu+etSPw8mmskSdTSosH73anVvE5jXHUwMDE50PtgytNCI1bjZ1x1MDAxZuBcdTAwMGLaXHUwMDFkXHUwMDEyXFwpplx1MDAwZo2Tdcayo1cwVVwiMFx1MDAxOSdIvZDA5Fx1MDAxM1x1MDAwNCZNvW9cYl9gXHUwMDFiyLYgUL98cFxcXHUwMDE0vlhcdTAwMGVf63BcdTAwMTmsWoGmciaDzExcdTAwMDZZXHUwMDE1vuVcdTAwMDYlMGt7tVo628pcIu2xJGlcdTAwMWN8XHUwMDE5O0tcdTAwMTFYnuhpXlx1MDAwNEPCpWCCSjJ9tWPnR6O5dbm5b1x1MDAwNs2rk8b+USDO+rdcdTAwMDU4rFx1MDAwNn5cdTAwMThWmk5UbVx1MDAxNmFxvNC2OJlcdTAwMWFcdTAwMTc9tFx1MDAxMYRgI1xyIYxnsEipRJBZXHRNjZREM1lcXFx1MDAwMpyi6FGKxcdcdTAwMGJcdTAwMWaGcCzzwVVcYlxyvVx1MDAwNVx1MDAwMvZlY+vF55/O2t2nauv48MugvXf16cfBXHUwMDE1ny62lmZ/e9v9XHLj17c3RXBUOe+37rw9df3HYnYpwIbvn1x1MDAwNC4qcVx1MDAxMbooMUopkGVTg6u8pWdcdTAwMDZXYSVl7uBcdTAwMTJGIU4heGCNtVx1MDAwMcfO5oBcdTAwMTSucmhcdGIgXHUwMDAzNGRxOSAjiHJcYqZcdTAwMWNiKSeQdubxxTRcdTAwMTJcdTAwMDRMZFx1MDAxNFBuMOXjKCNYWv+RqXxyapjFpr50XHUwMDEwXGYjJ4jWvE6s1D6mkPYwcjSMPj0xOOmJdXzt4cPTtmxf3ahcdTAwMTM/XHUwMDA1N1xim9Ve7Fx1MDAwMlxiY26w4Fx1MDAxYfqCXHUwMDFhbFL3NJxu3ESIKMNcdTAwMTVcdTAwMTAp3Mah3+/vXHUwMDE4XHUwMDAxftXt1Fx1MDAxZTepPJikTKpgRDU3jFxi8DBwMS1VziiKjFx1MDAwMXMgXHSC+5SUQuWMajlhtO63257Ved99r1x1MDAxM403cdyWnyzam66Tk8fwo9LXxmmha5+YpdPkr5VcdTAwMDQy8YfR33+/n3h3oSvbo5Lz4uRx79L/z6rZ7buK+Ixhrlx1MDAxOZMzpNzlLvdcInz2RN1O49ZXoFx1MDAxNojmNFW5XHUwMDE51rQ4XHUwMDAyNU9cdTAwMTlIXHUwMDA1cDSlx+yaY00rXHT1JUm35sImXHUwMDEwL1dcdTAwMTR+ti5YQPyeJSfI59xrflBLS/s/l3LfW/I0OWL5sVxivuCwilE9vdQv12fzXHUwMDE505k7cpUmXGJcdTAwMTRcYoGMmyvKx1Q+5DqIgFx1MDAxMiFcdTAwMDRcdTAwMTBDXHUwMDE0Xdw4P1x1MDAwNCwhpVxyVMDdXFzTXHTFaWB4XHUwMDA29zCOXHUwMDE52GnrezkpXCKYYoxi/lx1MDAwNGQvg1x1MDAxNFx1MDAxOVx1MDAwZp5zUFx1MDAwNMNYr5GWXHUwMDE0Q8tcdTAwMTgsQX/wtERJaVx1MDAwNoWNVkpyyZTQkHK9akFQ6FH2qOSdaUZFUMwpjJeMXHUwMDE0K2MoVnz6Ql75JJIlZlx1MDAxNWh6XHUwMDAzv5RA2/Ox/IZhXHUwMDA0XHQ7tFx1MDAxM8EgkPi4XfNkXHUwMDE1XHTv4LFcdTAwMTZcdTAwMDbP5olcZs7QitJcdTAwMTRslWCtJFxc5jNcdTAwMWNcdTAwMDHGgqh5ylx1MDAxONjrpJXyWWsp0oCO5EZcdTAwMDCSKFFcdTAwMDI4I2m8JPl55SxS5EH2yPvOjCxcdTAwMTJrplx0JKJTXHUwMDEyepxDpGBcdTAwMDY4LTUp6zFcdTAwMGVZ29w4ucWuUP0tsbl95ZxXfTlY9nFyYFxyXHUwMDA09IHt1Cqp+Vx1MDAxOIdAXHUwMDFlh1xmqFx1MDAxMiPhXHUwMDA2I1x1MDAwNF/kXHUwMDE0RESkMpPLj1x1MDAwNI1XJlx1MDAxZkhDXHUwMDEzhTGHZPOtkUb2YfPFcubaXFyBPKFcdTAwMTftMeq/OVx1MDAwMVfIYuBqJY2tqE9fXHUwMDEwYNu/blx1MDAwZqtcdTAwMDbj3unNSbOmyMHaN7b0wIWmVpBdXHUwMDFiqZSwXHUwMDAzXGJcdTAwMTngcq3tbF3OoNlcdTAwMDEgIDdcdTAwMTdcdTAwMDZcXFx1MDAwZbJXXHUwMDEwkVx1MDAxZVx1MDAxOVx1MDAxOOFcdTAwMTYjXHUwMDE2K8NcdMDlXHUwMDAwXFyRKVP8XHUwMDFmuH9cdTAwMTC4+V60RyXpwHlcdPd0oT1cdTAwMDddyCVcdTAwMTQ1eHrofu7XpXvxq7Le9DaOf7Y/fz6+/LGx9NBVXHUwMDE0KfBHyqmiXHUwMDEyXHUwMDA0XVx1MDAwNrqMcWQ4t9OIXHUwMDE1aFx1MDAxZbLIaoBRRnHGXGJjoKxcZp9Q14PcXHUwMDAxXHSMNYeeseV3kUoj7mc8cylcdTAwMTSh4pVcdTAwMDK5SJz7Z8e7J25w1T350Vx1MDAxZmzc7eytNUNZMFxugDlcdTAwMTaMQdBRiimuVaoqnoxNUFx1MDAwMklcdTAwMWFcdTAwMTNwp5mQ879cdTAwMTiFLFTHV4pdKr6c96Z50VxuKFg2fvqBV5TClNmumZpWSO07ub08qG5+ObxsrK/fnlx1MDAwN+J4b/lphSBbeLKDUYaQ8Wk9SiNlsJ3xg+2s2MVccndcdTAwMTJkbbAlXHUwMDA3zOFl6XZcdTAwMWbRiiHgKKBbiKCaKZErXHUwMDA2KEq4XHUwMDA2J3mlsv7ZpFx1MDAwMjKaQKbLJFx1MDAwNVxmYYEpyXOKRra4Q5WhmFx1MDAxMSHMXHUwMDFi5ZRid7LHuCPNyCdFI466eFx1MDAwMoWCfrEj0dOLlPJeX1Y24Vx1MDAxY0lNtDBKXGLI/rOFXHUwMDAxxlx1MDAwNbI+Z9dCcWpSq3bmXlx1MDAxN0hcdTAwMWVdMthoQDpcdTAwMTKhzVx1MDAwYk/wLY9cdTAwMTNcdTAwMTlPm2lcdTAwMTJSuawtfe6D2y+C5Z40iLk17LWUXHUwMDAz/KlBzHtLSlx0oajiYEwhIVx1MDAxMMUhrIKomX698vlp4/i6urfryruoe0hMv1x1MDAxMX3dWnZG4DZtXHUwMDExTFPQd5ZcdTAwMDGzaVx1MDAwYsVcdTAwMDYkrq38gPbA6ULiXHUwMDAyhlx1MDAxYqC9gfCp4cD8lEzQXHUwMDE3QE9cdTAwMTiDXHUwMDEw1Vx1MDAwMi5TjXW+XHUwMDAwXHUwMDAxeZfAdsnz65RcdTAwMThvrVx1MDAwMFHcq/ao5Dt0xkhfXHUwMDA07LQ8XHUwMDFkn5sgjLaZw/TDiL1m55Z4XHUwMDBl+bV5Snb5trk46vhF85CXXHUwMDA215pcdESltPPqMNCcyuZcclx1MDAxNEtEQIcyw5nGgJrFVVx1MDAxMjHSRmktXHUwMDA1SCwstZ5Uj+B2XHUwMDE3XHUwMDAyUIFK2GXimuSHXHUwMDExJaRcdTAwMGWcavXmZie8VlxcXHUwMDE3dao9Krn+nFx1MDAxMdbFXHUwMDA1gZJcdTAwMTVcdTAwMDbUzlx1MDAxMOCQXHUwMDExT4/s5vb15UG3Ujlccu+2XHUwMDBl1ytsoPYrRZOgl1x1MDAwNtmSaGR3XHUwMDE2XHUwMDAxXHUwMDE1z5igKrtMj1x1MDAxOMu5XHUwMDE4Wt72S3pa8twrXHUwMDAyTFwiXHUwMDEwXGbA21x1MDAwNOjFpEZdUyN8QENcdTAwMWOua8ZcdTAwMTRcdTAwMDVcdJFbZc4451pcdTAwMTP6SuN1UUngSNd37i62XHUwMDFiP9yzjbWdwDn9eXjzs6DOSOxcXFx1MDAwZYhJXHUwMDEyMmHDSXqyTVJntHOMsVx1MDAxMZgzm1x1MDAxMt3f8NaKXHUwMDAylUKXXHUwMDFhXs1509xoJb1mLbdxXHUwMDExwVSotPZ9jFakS25PXHUwMDBm1k7Xe7+O62FwuH6MXbr0tGIoMsDXkFx1MDAwYmBlXGZcdTAwMWKjXHUwMDE1bZBcItb9qFLMyOLlg8+mXHUwMDE1amfcXHUwMDAxfeF4jDNN+JlcdTAwMTFIwlx0RJ94sJHp9GLke8VAiMBcdTAwMDJj/r/KLFx1MDAxOMHPl1RBXG5cdTAwMGKQsqFxwvxcIoKInZwllMKEQ7fn1zG8XHJmKXYqe1Qm+NOM1FJUclQlK1wiXHK1vjLDfMbyvl9WXrFNXHUwMDBmylx1MDAxZNI7q81otuRIlERSc6OJsfNcdTAwMWQoW1x1MDAxY7GI5NFlu1xuQMakiZb6XHS88ZyiY7lcdTAwMTTN+NpMRcfyWFT63Fx1MDAwN8dfXHUwMDA02z2p6Dh03pRcdTAwMDP8qZrj0JCnaVxyXjxVgjOioEVnmJ1YvqvR0s5wZlxiYo3dQ4RplopLw0lOQiFiXGaVIDMoXHUwMDExophcdTAwMTBcdTAwMThnXHUwMDBlf1ZlXHUwMDAyXHUwMDEzO81cdTAwMTAzXGJcdTAwMGbAw8JM2nfEqlwiXHUwMDAxYcFcdTAwMTZBIUayXFxcbmOkgUeItzdZsUiClO8tMFx1MDAxMlx1MDAxN1x1MDAxMlx1MDAxOWm1uuTSjk7zlJhP0lx1MDAxZrtcdTAwMDCUXHUwMDAw30L+w6lcdTAwMDHflzlcdTAwMDXymnRGpcSn4ut5d5pRaFx1MDAxNOcwqmRcdTAwMDcjIZmgRE5PLOWb3CwtsVDEscF2koFcdTAwMTFybPKkXHUwMDA0satBXGZcdTAwMTOFXHUwMDAxPCUrKZ/PK5rH0/ol1liI9JKqpDKikVx1MDAwNPlHIKM1oI7YhFx1MDAwNVlgpmBY0SdswbBcZrxSuD6idG+sLDdASlwiuYYkRoNOJGpCZUTYtTKScFx1MDAwNUhcdTAwMTOg3lx1MDAxZiqNby2BqVx1MDAxNDuVPfLuNCOtlG7rYkjhts5cdTAwMTRDjMaE0umzmIprjs+jy7NQRc2Ts1x1MDAwZebru79cdTAwMGVcbqhlubZ10Vx1MDAxONJlu1kpt9BMVyaG27pcdTAwMThky3eGXHRNKFBNMcM8f1tcdTAwMTeCXHUwMDE4J0VcdTAwMGIrqKCIMUj9qWRcdTAwMDVcdTAwMTO1IeBcdTAwMWFcdTAwMGXukrTEsq/jLs1yXHUwMDE2ur+LgoQwtfXmtPu7vLt/6KrT7Vx1MDAxZUbwyFx1MDAxMSlCW3u1++wneczqjeferk3Y1bhcdTAwMWVcdTAwMWbW5LhcdTAwMTEsQlxc29L//H73+7/nXHUwMDBiXHUwMDAzXCIifQ== + + + + MarginPaddingContent areaBorderHeightWidth \ No newline at end of file diff --git a/docs/images/styles/content_box.excalidraw.svg b/docs/images/styles/content_box.excalidraw.svg new file mode 100644 index 000000000..ac94e2e6d --- /dev/null +++ b/docs/images/styles/content_box.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1cXGlT28pcdTAwMTL9nl9Bcb/Gysz0bJ2qV6/Yl1x1MDAxMCAsXHTJq1spYVx1MDAwYizwXHUwMDE2W8bArfz31yNcdTAwMTZJliWwMY6TukpCsEaWRjN9Tp/uWf55s7CwXHUwMDE43XSCxfdcdTAwMGKLwXXVb4S1rj9YfOvOX1x1MDAwNd1e2G5RkYg/99r9bjW+slx1MDAxZUWd3vt375p+9zKIOlxyv1x1MDAxYXhXYa/vN3pRv1x1MDAxNra9arv5LoyCZu+/7ueu31xm/tNpN2tR10tcdTAwMWVSXHRqYdTu3j0raFx1MDAwNM2gXHUwMDE19eju/6PPXHUwMDBiXHUwMDBi/8Q/U7XrXHUwMDA21chvnTeC+Fx1MDAwYnFRUkGpzPDZ3XYrrqxUXFxLieyxPOyt0tOioEaFZ1TjIClxp1x1MDAxNtVJX+2Y48anXHUwMDBmK8tcdTAwMDfXjWZ40lo+Tlx1MDAxZXpcdTAwMTY2XHUwMDFhh9FN464h/Gq9301VqVx1MDAxN3Xbl8GXsFx1MDAxNtWpnFx1MDAwZp1//F6tXHUwMDFkuVxuPFx1MDAxNnfb/fN6K+j1Ml9qd/xqXHUwMDE43bhzLKn/XSO8X0jOXFzTp1xuXGJcdTAwMGa1NVxcXHUwMDFhRW9rRNIg7lx1MDAwNkJ5Qlx1MDAxYtDKMlx1MDAwYkYrXHUwMDA1Q1VbaTeoL6hqf7H4SOp26lcvz6mCrdrjNVHXb/U6fpd6LLlucP/SiqFcdTAwMDfGXG4trFx1MDAwMouQvE89XGLP65HrXHUwMDEzwT1mXHUwMDE1glF3T9NJbYK4Y1x1MDAwNENtuNaQvKWrQ2erXHUwMDE2m8jfw1xyW/e7nfv2W+y5XHUwMDBmqfq7qq9ccttX2sZSnf/j+uvG9tnJPr+48LdVyGorXHUwMDFmLlx1MDAwZVx1MDAxZe+VMUi/221cdTAwMGZcdTAwMTZcdTAwMWZLft7/llSt36n5d2bmXkRIxplcdTAwMTQqeaFG2Lqkwla/0UjOtauXiWXGZ3++nVx1MDAwMFx1MDAxMFx1MDAxYaFcdTAwMTBcdTAwMTCAXHUwMDFjJTPPR0TQ6e5o4P5t31x1MDAxZixvfP+6Jzu6WYCIXpvQPTZcdTAwMWWGvvVcdTAwMTRcdTAwMWPgKTSA8VxiXHUwMDAymlx1MDAxYq1RSc6MzKCBc/C44Fx1MDAxYYAjWSAyVYhcdTAwMDZ1XHUwMDA2taosRcNfsqqDM5VHXHUwMDAyKONZpSTBUuVBIFx1MDAxNHpcdTAwMDJcdLNMXHUwMDAyMDKMXHUwMDFjXGK4Je5ijIOdLVxiqns7X+snm8e1XHUwMDFixFZ/l3dMvVx1MDAxNv6GIJBWXHUwMDE3gYBcYlxuXGZcIpfPXHUwMDA2XHUwMDAxRqfB7sdGY1x1MDAxYsz+5kr9495BuLkxmVtcdTAwMTCFbsHv1afrXHUwMDE2XHUwMDEwPCEtKs6UloZZkcWBXHUwMDA2jyhcdTAwMThAKaG4JNdQiINAXHUwMDFi81x1MDAxMq+Q7vNHXGJwaYdtXHUwMDFlXHUwMDA01YLqNWOT34K1o5Poy97H9kmwXHUwMDFmNcLLz8fV76NNPlxurqOUxb8tu+3h0fopu8L95tGn6HJ5aXn1eFuL5yGp9L5Tr27m6rfPfeCvw32mnmmlamxcdTAwMTHkpSaq1TiG37tcdTAwMTZcdTAwMWSz/vXHSudQXpyurP6wrZrembJcdTAwMTJcdTAwMWPT8z2NeOdWUFgrNaAwRmJcdTAwMDbxgNZcdTAwMDPpXHUwMDA0IEeUWlxmU9H0ZKBcdTAwMTZ5vFx1MDAwYjVcZndcdTAwMGVcbiW5YvNcbjpvmsaYdHq7XHUwMDE1XHUwMDFkhrdBrFEzZ9f9Zti4yfRbbKVU049+9zxspduyXHUwMDE30DOD2Mdnrl5qhOfOjlx1MDAxN1x1MDAxYsFZ1sCjkFx1MDAwMqfH4qidevMqPd2n23W3asNv0e6G9GS/cZStyUTYXHUwMDAyI1xusSWsZEKq57vTpUF978dutHVzc1xm6+2br6dflr75M4yy2ITgXCLhiNJyi1JcdTAwMDCyrDtcdTAwMDVhXHR65G+F0lrSP/Nq6EpcdNoydGkjKVx1MDAxYzMptz9cdTAwMTNvurRdw9Wr3dMts1x1MDAwNlx1MDAxZjuDXHK5PMDOL1x1MDAxM5Avw+6+X6uFrfN5XHUwMDAw70NVJvOMglx1MDAwZp99QC93XHUwMDAyUY+RXCIpl1x1MDAxZvNcbl5yfSVaWFxi7YlcdTAwMTlpYTlCXHUwMDBii9Tz7tHLhdRcbq36g3wj5PC1QsVUq1x1MDAwNWorfzTIcDTIqvStoFtcdTAwMDKzZlirpVx1MDAwM8Ms0p5cbuiGwZepZylcdTAwMDLLY1IsXHUwMDE0qFxcOM/CpFx1MDAxNM9cdTAwMDZitX/bWPmxXHUwMDE0+T40XHUwMDBlj1pcdTAwMWbPw5NOrVx1MDAwMIjVbrvXq9T9qFovXHUwMDAyoyxcdTAwMDLj1FWqS9BQkIekQJXT5CqDRc6ZR1wilYFVxlpGTqxcdTAwMTCLz8jPlGLx6Vx1MDAxY1xyXHUwMDEyXHUwMDE56Lxv1Vx1MDAxNtEgn3F+ctnfq96eXW037GF7XHUwMDBm+X57U/k4hYCyxj5/qHZPbtesXHUwMDFhyNslXFw7XHK7dj5TPnfPXHUwMDFmXHUwMDA1rZLgT3AgzjdqXGYnV97UY2OrMOkzdWxxMmlkoLlLsFx1MDAxYiVTuVx1MDAxNHdcdTAwMDPJOVx1MDAxNVtcdTAwMGLUXHUwMDFhWnElhlE/PZVcbpw8LpJcdTAwMWIzXHUwMDFjJLd6xFBcdTAwMDBYj0JRbUFw61x1MDAwNLVcdTAwMWPGXHUwMDE5xalWKlx1MDAxNJMgLa7qrL1gL/K70XLYiqXa+1x1MDAxNNjIXHUwMDEzVvtxt3qMSWRKWmpdgVxmXHUwMDEznC2e+524kz1uUFx1MDAxYdTuMomJOFh4XHUwMDFjK7vzYitwc7Mp+ss7XHUwMDFi+uhi/Zj8WCNYekDnI+ZcdTAwMTeDVq20Slx1MDAxNeZRXGKHwFx1MDAxNZBp0N8kdnmslPBcdTAwMTCpOiCQrjNaK1NUqdFuKVepht+LVtrNZuiU3n47bEXDTVx1MDAxY7flklx1MDAwM3w98HP6mF4qXTbMXGZcdTAwMWR3xyyjJr8tJKCJPzz+/vfbkVdcdTAwMTeasjsqOStObvcm/f+4op2DKkxhc1x0zKF7XGZKXHUwMDFibSwzpbTJpLvlnlx1MDAxMZI4nNpfMMwltdBcdTAwMTOCsKKEJTzxYrXw4qRW0lx1MDAxYiVht+JEwZKzXHUwMDE5R90v0Fx1MDAwNq/gw8eJXG7yUfdyu1tLi/tfXHUwMDE3dN/XZDJJwpUsnJjA3aBcdTAwMWaF3qlBqqfwW67SpjNcdTAwMDY1deyCXHUwMDE0XHUwMDFlkMKnuJskPcEziVx1MDAwMe/kXGLzNLNGXHUwMDEzsIFcdTAwMWPecEJgeuAlXHUwMDE3p7R2ro34W1oxXCJBTSxPjKtBMpD0x/JcXFAuXGYyhlx1MDAwMn9TNTLsP59cdTAwMTJcdTAwMDWGoTVGS01q0VKclFx1MDAxM1x1MDAwNdazmjqOSJm5aTYyrWX+dE1QaFDuqORtaUxRUMwqXHUwMDE0y1x1MDAxNFx1MDAwNjqMXHUwMDE0PSGNP19cdTAwMTWUz3mZY1bhhFxyXHUwMDE0SiphWHaCh+SS7NJYRnJNkVx1MDAwNac4dvqsotHB06lngopMJf3TtGKssFx1MDAwNFx1MDAxMUZcdTAwMTdwqfNRXHUwMDBlt4bCXHUwMDAxnCRT/9vxXG7ziClcdTAwMTSBQ3CjiFaS5khcdTAwMDKgXCJcdTAwMWFcdTAwMTk9O+83p5FcIlx1MDAwYnJH3nbGpJFYNo1gXHUwMDExhOJMpDUomLYqueIpXHUwMDEy0cd0Tn7b3T9cdTAwMWaovY3o1lZcdTAwMDbt7mQkMruxcsmUZ610Q+HkwFxmXHUwMDFmykJq4Vx1MDAxMblQkVx1MDAxNKhcZvLXXHUwMDFizpPoWdTU4kZcbklcdTAwMDYgR1xm7zGPWSlcdTAwMDGsJDVlNaRH9Vx1MDAxZcSJdrGRSc0km1x0iVx1MDAxMM+mXHUwMDA2l16DRLI3my62M2VTXHUwMDA1dnGvuqMyokOnXHUwMDA0bVxyavjsI7RcdTAwMTGIbUhrPz/qODw+Xr3+hD++NU5cdTAwMDbtlVUxOPig+nNcdTAwMGZtLjwmjFFcdTAwMWFJXHUwMDA0gM7mQIH0gbGK2oFcdTAwMTSEpk+vlzKQpJBcdTAwMTVX6Vx1MDAxMYRcdTAwMTSkIVx1MDAxNo9mxLxcdTAwMThO6Fx1MDAxMnbWUFx1MDAxNoCQxDz/QvnhyPeiOypJXHUwMDA3Tkvbo8XhsylpT0EygHq+tF/fXGYvwP9yeHKw9v3G9PR688BuzT10XHUwMDAxPDBAYVxmMi7lkFems1x1MDAxZfltyzl55Hio/lx1MDAxNaW9Mlx1MDAwNlx1MDAxMJVcdTAwMDYg8YVyRPaPOF5cdTAwMTC9W+DGpfRVzidzelx1MDAwN6TXkDNW9lxc8pQpTWX8XCIjxPXF8vbpkeZXl37YPFx1MDAxZfRcdTAwMWHLXHUwMDExT09cdTAwMWRNZ1x1MDAxMShcdTAwMDJcdTAwMTbIKFZcdTAwMDOOViPafFx1MDAxZYFTME2UJ8m6XHUwMDE1oHrAU8FcYsZrksirav1KsU3FxTlzmlx1MDAxNq9wLorlvlx1MDAxMoxcdTAwMTGNyedrgsp1/fvR5dbFWuvbh481pj/rw09zPzVWUiylwFquSbNcIiP1lWVcdTAwMTZccp5QXHUwMDAy3ChcdTAwMGXxrFx1MDAxNq+3Ror8XHUwMDA2Z4JcZl4yN8/BwKj5fORzgOojjXGLXHUwMDE1VC5nXHUwMDAwZCDaoJpgqvyLiIXE6lxcXHUwMDEwXHUwMDBi8zhDNMqNgVx0hdqkoPSYndQgUbhoXHUwMDE2uFL4h7JKiTm5Y9iQxuSUosFJa1xus5DWcCZcdTAwMTlcdTAwMWIjXHRZ3uvzSihMeU5cdTAwMTRKaS1YJYaUikBPXHUwMDEzj1x1MDAxM4ujdvmU11MqKlx1MDAwMWTJuCRIt1x1MDAxYc6KXHRiipeMS5b7ioypjTVnqVxc3Jbe98HuS2hutuOdm3e9ljKAXzXeeV+TUkYoyjtcdTAwMTBcdTAwMWZcdTAwMTdSQiwySM2PkXhY2j483Fplllx1MDAxZKxcdTAwMWT8WGnsbKnjrVx0XHUwMDA3JmbHXHTIPa1Rc5JcdTAwMTkk45TKJlx1MDAxZVB6XHUwMDFjhdVCXHUwMDE40NrK11t/wz3rVsBcdTAwMDI9xFxyQOmU/EtcdTAwMTSG8khcdTAwMDVZXHUwMDA0i0ZKrUx+XHQquVxmdGNcdTAwMTMzjl6IU8FMgr4/PFx1MDAwZlEp7ta4ON+jYzr7XCJoW1W4+EdwXHUwMDA2XGJcIj2g/uTKOlx1MDAxZV2cXHUwMDFlX0U7387WQrH+5Wrtw+lg3pFNze2RXHUwMDE3Z8LSoVx1MDAxZIqz0HbJIGFcdTAwMTW5e+Pmmr4mtEFow9yQXHUwMDExcVxijExLMM+6OaBWaYoxOeaCh3heoeapqGI2uDYgXne88bfFdUGfxqW57lx1MDAxY1x1MDAxM9UlXHUwMDEzlFThsiDBlFx1MDAwMW7ZXHUwMDE4XHUwMDEzXGZbS2ebn7+sRlx1MDAxYqe3N7jx+Vx1MDAxY7aD02kvXGaa/nxp48JEQ3yqKebnILJcdTAwMTOUXGZRKvlrZrTlbjFcdTAwMWRcdTAwMGVVbHq4ttTLwjDDrTI8PTybXHUwMDFhMDBWulx1MDAwNFx1MDAxYVx1MDAwMFx1MDAxOIGc59bPc0GBl8suzTgrQO2X0lxi089cbny++bz27Wj95OKK0LpcdTAwMTNt3X5a2d0uSDdyQOGmlFx1MDAxOVCMJNaIqcxuzlx1MDAxOTlHaigmKTTmf2y6scik4sK8NU2NVqB4taFVaMmN4vPXXG53t0Cavf7tdme9YTe+Nb/uhLti3lnFLVx1MDAxNSbbc0pcZrQxmF1taKRHoolcdTAwMTlFP5hg/PXEgqWIg7ulZZLiLyPtiEQj97hcdTAwMTVcdTAwMWNccuNuzbKFXHUwMDE0wz2QilIxoGZMKsJcdTAwMWGchlp4MakwjyCkXGJJWjO00pr0eox7SqFWXHUwMDE0JFx1MDAwMJWhdpTMilx1MDAwN+X9p3FKoUG5o5KzpTEppSjZaIrXTYJbiWdcdTAwMTV//mSl8n6fU0LhXHUwMDE2PWDAOVx1MDAxMDzIv1x1MDAwZi3r0tTyUlPUrIxb7FaypdWLk42JPirbe4BcIiVyrVx1MDAwMFx1MDAxM0RcdTAwMTgvyTaWS9CMrY2VbSx3QqX3fTD8XHUwMDEypptttvHOeFNcdTAwMDbwq5KNd1x1MDAxNZlMY5BwK5RcdTAwMThWU4huxtjdq3yjprmdXHUwMDAzXHKeUlJQjOYmobCko+M5TtJlXCKZXHUwMDExQFElXHRcdTAwMTBePJxJN/Lli4YzyYJJSVx1MDAxMztcdItSKVx1MDAxY7U9ifBQkY7Qd9tUMsjHLmi15NJMsunjbzdcdLriVoFyt5OMdfNL2ajhS+2h5syCllTKpEzJ96yQXHUwMDE5vZdBToD8TjKjUmJUcXnensaUXHUwMDFhxdFLSnRcdTAwMGUxXHUwMDBiUiBFfTVcdTAwMDazlO+FM7fMXCI8a4lcdTAwMTZAo0tGXHLNnpTK01x1MDAxMq1LXHUwMDBmoYSS9ZYvJ1x1MDAxNivjdVx1MDAwMlx1MDAxNH0wRcH6qOjFepriSW7BXHUwMDA1llx1MDAxNvJrtrj7srRcdTAwMTNtJjtcdTAwMGa8Mpo9JDOaXkpcdTAwMTjLjeYmtfjoIVwioV5cIk/odlx1MDAwNFaotMoss8jQx+jNuHL08WfEL5Vio3JH3pzGpJXS7V+wZFx1MDAxNlx1MDAxNrqlMEaPseji5lxmev1PW8dw+vX6y/fT6569Piqa3jlX279I5lHrut0nQIGgTshcdTAwMGWluP15rZvFgi5nJYUuli4v3/+Fe6SUXGZcdTAwMTZtXHUwMDAwIzxcdTAwMDBhXHUwMDE41aFgpjZcdTAwMTlcdTAwMTEhkU8yU/vfnWCet1x1MDAxM8yb+5su+p3OYUS3fCRFauuwdlx1MDAxZv8kt1m8XG6DwfKInZrP4sNVOW5cdTAwMDSHkMC19D8/3/z8P1KdJ/cifQ== + + + + MarginPaddingContent areaBorderHeightWidth \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 9433d3fb3..66e83adb7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,25 +1,83 @@ -# Welcome to Textual documentation -Textual is a framework for rapidly creating _text user interfaces_ (TUIs from here on) with Python. +# Introduction -A TUI is an application that lives within a terminal, which can have mouse and keyboard support and user interface elements like windows and panels, but is rendered purely with text. They have a number of advantages over GUI applications: they can be launched from the command line, and return to the command line, and they work over ssh. +Welcome to the [Textual](https://github.com/Textualize/textual) framework documentation. Built with โค๏ธ by [Textualize.io](https://www.textualize.io) -Creating a TUI can be challenging. It may be easier to create a GUI or web application than it is to build a TUI with traditional techniques. Often projects that could use one or the other never manage to ship either. +
-Textual seeks to lower the difficulty level of building a TUI by borrowing developments from the web world and to a lesser extent desktop applications. The goal is for it to be as easy to develop a TUI for your project as it would be to add a command line interface. +Textual is a framework for building applications that run within your terminal. Text User Interfaces (TUIs) have a number of advantages over web and desktop apps. -Textual also offers a number of enhancements over traditional TUI applications by taking advantage of improvements to terminal software and the hardware it runs on. Terminals are a far cry from their roots in ancient hardware and dial-up modems, yet much of the software that runs on them hasn't kept pace. +
-## Commands +- :material-clock-fast:{ .lg .middle } :material-language-python:{. lg .middle } __Rapid development__ -- `mkdocs new [dir-name]` - Create a new project. -- `mkdocs serve` - Start the live-reloading docs server. -- `mkdocs build` - Build the documentation site. -- `mkdocs -h` - Print help message and exit. + --- + + 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 path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,_,_,_,_,_,_,_,_"} +``` + +```{.textual path="examples/pride.py"} +``` + +```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter,_,_"} +``` + + +```{.textual path="docs/examples/events/dictionary.py" columns="100" lines="30" press="tab,_,t,e,x,t,_,_,_,_,_,_,_,_,_,_,_,_,_"} +``` + + +```{.textual path="docs/examples/guide/layout/combining_layouts.py" columns="100", lines="30"} +``` + +```{.textual path="docs/examples/app/widgets01.py"} +``` -## Project layout - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. diff --git a/docs/messages.md b/docs/messages.md deleted file mode 100644 index 1f42bb19d..000000000 --- a/docs/messages.md +++ /dev/null @@ -1,42 +0,0 @@ -# Messages & Events - -Each component of a Textual application has it its heart a queue of messages and a task which monitors this queue and calls Python code in response. The queue and task are collectively known as a _message pump_. - -You will most often deal with _events_ which are a particular type of message that are created in response to user actions, such as key presses and mouse clicks, but also internal events such as timers. These events typically originate from a Driver class which sends them to an App class which is where you write code to respond to those events. - -Lets write an _app_ which responds to a key event. This is probably the simplest Textual application that I can conceive of: - -```python -from textual.app import App - - -class Beeper(App): - async def on_key(self, event): - self.console.bell() - - -Beeper.run() -``` - -If you run the above code, Textual will switch the terminal in to _application mode_. The terminal will go blank and the app will start processing events. If you hit any key you should hear a beep. Hit ctrl+C (control key and C key at the same time) to exit application mode and return to the terminal. - -Although simple, this app follows the same pattern as more sophisticated applications. It starts by deriving a class from `App`; in this case `Beeper`. Calling the classmethod `run()` starts the application. - -In our Beeper class there is a single event handler `on_key` which is called in response to a `Key` event. The method name is assumed by concatenating `on_` with the event name, hence `on_key` for a Key event, `on_timer` for a Timer event, etc. In Beeper, the on_key event calls `self.console.bell()` which is what plays the beep noise (if supported by your terminal). - -The `on_key` method is preceded by the keyword `async` making it an asynchronous method. Textual is an asynchronous framework so event handlers and most methods are async. - -Our Beeper app is missing typing information. Although completely optional, I recommend adding typing information which will help catch bugs (using tools such as [Mypy](https://mypy.readthedocs.io/en/stable/)). Here is the Beeper class with added typing: - -```python -from textual.app import App -from textual import events - - -class Beeper(App): - async def on_key(self, event: events.Key) -> None: - self.console.bell() - - -Beeper.run() -``` diff --git a/docs/reference/app.md b/docs/reference/app.md index 0321acaaf..3a797ce06 100644 --- a/docs/reference/app.md +++ b/docs/reference/app.md @@ -1 +1 @@ -::: textual.app.App +::: textual.app diff --git a/docs/reference/binding.md b/docs/reference/binding.md new file mode 100644 index 000000000..55aa039ab --- /dev/null +++ b/docs/reference/binding.md @@ -0,0 +1 @@ +::: textual.binding.Binding diff --git a/docs/reference/button.md b/docs/reference/button.md new file mode 100644 index 000000000..26d6b63cf --- /dev/null +++ b/docs/reference/button.md @@ -0,0 +1 @@ +::: textual.widgets.Button diff --git a/docs/reference/checkbox.md b/docs/reference/checkbox.md new file mode 100644 index 000000000..6c9c434f2 --- /dev/null +++ b/docs/reference/checkbox.md @@ -0,0 +1 @@ +::: textual.widgets.Checkbox 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/containers.md b/docs/reference/containers.md new file mode 100644 index 000000000..f65b50868 --- /dev/null +++ b/docs/reference/containers.md @@ -0,0 +1 @@ +::: textual.containers diff --git a/docs/reference/data_table.md b/docs/reference/data_table.md new file mode 100644 index 000000000..c8ac87cde --- /dev/null +++ b/docs/reference/data_table.md @@ -0,0 +1 @@ +::: textual.widgets.DataTable 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/docs/reference/footer.md b/docs/reference/footer.md new file mode 100644 index 000000000..604c2ef6a --- /dev/null +++ b/docs/reference/footer.md @@ -0,0 +1 @@ +::: textual.widgets.Footer diff --git a/docs/reference/geometry.md b/docs/reference/geometry.md new file mode 100644 index 000000000..6f31de5e4 --- /dev/null +++ b/docs/reference/geometry.md @@ -0,0 +1 @@ +::: textual.geometry diff --git a/docs/reference/header.md b/docs/reference/header.md new file mode 100644 index 000000000..e6cfc0e44 --- /dev/null +++ b/docs/reference/header.md @@ -0,0 +1 @@ +::: textual.widgets.Header diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 000000000..8e129acd1 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,3 @@ +# Reference + +A reference to the Textual public APIs. diff --git a/docs/reference/input.md b/docs/reference/input.md new file mode 100644 index 000000000..259ea86f9 --- /dev/null +++ b/docs/reference/input.md @@ -0,0 +1 @@ +::: textual.widgets.Input diff --git a/docs/reference/message.md b/docs/reference/message.md new file mode 100644 index 000000000..fcb6f76c3 --- /dev/null +++ b/docs/reference/message.md @@ -0,0 +1 @@ +::: textual.message.Message diff --git a/docs/reference/message_pump.md b/docs/reference/message_pump.md new file mode 100644 index 000000000..79b0dc458 --- /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 `Widget` classes. + +::: textual.message_pump.MessagePump diff --git a/docs/reference/query.md b/docs/reference/query.md new file mode 100644 index 000000000..313755809 --- /dev/null +++ b/docs/reference/query.md @@ -0,0 +1 @@ +::: textual.css.query diff --git a/docs/reference/reactive.md b/docs/reference/reactive.md new file mode 100644 index 000000000..759c7af26 --- /dev/null +++ b/docs/reference/reactive.md @@ -0,0 +1 @@ +::: textual.reactive diff --git a/docs/reference/screen.md b/docs/reference/screen.md new file mode 100644 index 000000000..c7054aef5 --- /dev/null +++ b/docs/reference/screen.md @@ -0,0 +1 @@ +::: textual.screen diff --git a/docs/reference/static.md b/docs/reference/static.md new file mode 100644 index 000000000..709b442ef --- /dev/null +++ b/docs/reference/static.md @@ -0,0 +1 @@ +::: textual.widgets.Static 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/docs/reference/widget.md b/docs/reference/widget.md new file mode 100644 index 000000000..aa67df889 --- /dev/null +++ b/docs/reference/widget.md @@ -0,0 +1 @@ +::: textual.widget.Widget diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 000000000..aed058b89 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,68 @@ +# Roadmap + +We ([textualize.io](https://www.textualize.io/)) are actively building and maintaining Textual. + +We have many new features in the pipeline. This page will keep track of that work. + +## Features + +High-level features we plan on implementing. + +- [ ] Accessibility + * [x] Monochrome mode + * [ ] High contrast theme + * [ ] Color blind themes +- [ ] Command interface + * [ ] Command menu + * [ ] Fuzzy search +- [ ] Configuration (.toml based extensible configuration format) +- [x] Devtools + * [ ] Browser-inspired devtools interface with integrated DOM view, log, and REPL +- [ ] Reactive state +- [x] Themes + * [ ] Customize via config + * [ ] Builtin theme editor + +## Widgets + +Widgets are key to making user friendly interfaces. The builtin widgets should cover many common (and some uncommon) use-cases. The following is a list of the widgets we have built or are planning to build. + +- [x] Buttons + * [x] Error / warning variants +- [ ] Color picker +- [x] Checkbox +- [ ] Content switcher +- [x] DataTable + * [x] Cell select + * [ ] Row / Column select + * [ ] API to update cells / rows + * [ ] Lazy loading API +- [ ] Date picker +- [ ] Drop-down menus +- [ ] Form Widget + * [ ] Serialization / Deserialization + * [ ] Export to `attrs` objects + * [ ] Export to `PyDantic` objects +- [ ] Image support + - [ ] Half block + - [ ] Braile + - [ ] Sixels, and other image extensions +- [x] Input + * [ ] Validation + * [ ] Error / warning states + * [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc +- [ ] Markdown viewer (more dynamic than Rich markdown, with scrollable code areas / collapseable sections) +- [ ] Plots + - [ ] bar chart + - [ ] line chart + - [ ] Candlestick chars +- [ ] Progress bars + * [ ] Style variants (solid, thin etc) +- [ ] Radio boxes +- [ ] Sparklines +- [ ] Tabs +- [ ] TextArea (multi-line input) + * [ ] Basic controls + * [ ] Syntax highlighting + * [ ] Indentation guides + * [ ] Smart features for various languages diff --git a/docs/styles/align.md b/docs/styles/align.md new file mode 100644 index 000000000..15bbcbed9 --- /dev/null +++ b/docs/styles/align.md @@ -0,0 +1,68 @@ +# Align + +The `align` style aligns children within a container. + +## Syntax + +``` +align: ; +align-horizontal: ; +align-vertical: ; +``` + + +### Values + +#### `HORIZONTAL` + +| Value | Description | +| ---------------- | -------------------------------------------------- | +| `left` (default) | Align content on the left of the horizontal axis | +| `center` | Align content in the center of the horizontal axis | +| `right` | Align content on the right of the horizontal axis | + +#### `VERTICAL` + +| Value | Description | +| --------------- | ------------------------------------------------ | +| `top` (default) | Align content at the top of the vertical axis | +| `middle` | Align content in the middle of the vertical axis | +| `bottom` | Align content at the bottom of the vertical axis | + + +## Example + +=== "align.py" + + ```python + --8<-- "docs/examples/styles/align.py" + ``` + +=== "align.css" + + ```scss hl_lines="2" + --8<-- "docs/examples/styles/align.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/align.py"} + + ``` + +## CSS + +```sass +/* Align child widgets to the center. */ +align: center middle; +/* Align child widget to th top right */ +align: right top; +``` + +## Python +```python +# Align child widgets to the center +widget.styles.align = ("center", "middle") +# Align child widgets to the top right +widget.styles.align = ("right", "top") +``` diff --git a/docs/styles/background.md b/docs/styles/background.md new file mode 100644 index 000000000..6c41885c7 --- /dev/null +++ b/docs/styles/background.md @@ -0,0 +1,57 @@ +# Background + +The `background` rule sets the background color of the widget. + +## Syntax + +``` +background: []; +``` + +## Example + +This example creates three widgets and applies a different background to each. + +=== "background.py" + + ```python + --8<-- "docs/examples/styles/background.py" + ``` + +=== "background.css" + + ```sass + --8<-- "docs/examples/styles/background.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/background.py"} + ``` + +## CSS + +```sass +/* Blue background */ +background: blue; + +/* 20% red background */ +background: red 20%; + +/* RGB color */ +background: rgb(100,120,200); +``` + +## Python + +You can use the same syntax as CSS, or explicitly set a `Color` object for finer-grained control. + +```python +# Set blue background +widget.styles.background = "blue" + +from textual.color import Color +# Set with a color object +widget.styles.background = Color.parse("pink") +widget.styles.background = Color(120, 60, 100) +``` diff --git a/docs/styles/border.md b/docs/styles/border.md new file mode 100644 index 000000000..757ce988b --- /dev/null +++ b/docs/styles/border.md @@ -0,0 +1,86 @@ +# Border + +The `border` rule enables the drawing of a box around a widget. A border is set with a border value (see below) followed by a color. + +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: [] []; +``` + +### Values + +| Border value | Description | +|--------------|---------------------------------------------------------| +| `"ascii"` | A border with plus, hyphen, and vertical bar | +| `"blank"` | A blank border (reserves space for a border) | +| `"dashed"` | Dashed line border | +| `"double"` | Double lined border | +| `"heavy"` | Heavy border | +| `"hidden"` | Alias for "none" | +| `"hkey"` | Horizontal key-line border | +| `"inner"` | Thick solid border | +| `"none"` | Disabled border | +| `"outer"` | Think solid border with additional space around content | +| `"round"` | Rounded corners | +| `"solid"` | Solid border | +| `"tall"` | Solid border with extras space top and bottom | +| `"vkey"` | Vertical key-line border | +| `"wide"` | Solid border with additional space left and right | + +For example, `heavy white` would display a heavy white line around a widget. + +## Border command + +The `textual` CLI has a subcommand which will let you explore the various border types: + +``` +textual borders +``` + +## Example + +This examples shows three widgets with different border styles. + +=== "border.py" + + ```python + --8<-- "docs/examples/styles/border.py" + ``` + +=== "border.css" + + ```css + --8<-- "docs/examples/styles/border.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/border.py"} + ``` + +## CSS + +```sass +/* Set a heavy white border */ +border: heavy white; + +/* set a red border on the left */ +border-left: outer red; +``` + +## Python + +```python +# Set a heavy white border +widget.border = ("heavy", "white") + +# Set a red border on the left +widget.border_left = ("outer", "red") +``` diff --git a/docs/styles/box_sizing.md b/docs/styles/box_sizing.md new file mode 100644 index 000000000..bc089ad44 --- /dev/null +++ b/docs/styles/box_sizing.md @@ -0,0 +1,59 @@ +# Box-sizing + +The `box-sizing` property determines how the width and height of a widget are calculated. + +## Syntax + +``` +box-sizing: [border-box|content-box]; +``` + +### Values + +| Values | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `border-box` (default) | Padding and border are included in the width and height. If you add padding and/or border the widget will not change in size, but you will have less space for content. | +| `content-box` | Padding and border will increase the size of the widget, leaving the content area unaffected. | + +## 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. + +=== "box_sizing.py" + + ```python + --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"} + ``` + +## CSS + +```sass +/* Set box sizing to border-box (default) */ +box-sizing: border-box; + +/* Set box sizing to content-box */ +box-sizing: content-box; +``` + +## Python + +```python +# Set box sizing to border-box (default) +widget.box_sizing = "border-box" + +# Set box sizing to content-box +widget.box_sizing = "content-box" +``` diff --git a/docs/styles/color.md b/docs/styles/color.md new file mode 100644 index 000000000..0fae0a0b0 --- /dev/null +++ b/docs/styles/color.md @@ -0,0 +1,60 @@ +# Color + +The `color` rule sets the text color of a Widget. + +## Syntax + +``` +color: | auto []; +``` + +## 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"} + ``` + +## CSS + +```sass +/* Blue text */ +color: blue; + +/* 20% red text */ +color: red 20%; + +/* RGB color */ +color: rgb(100,120,200); + +/* Automatically choose color with suitable contrast for readability */ +color: auto; +``` + +## Python + +You can use the same syntax as CSS, or explicitly set a `Color` object. + +```python +# Set blue text +widget.styles.color = "blue" + +from textual.color import Color +# Set with a color object +widget.styles.color = Color.parse("pink") + +``` diff --git a/docs/styles/content_align.md b/docs/styles/content_align.md new file mode 100644 index 000000000..35a4a4c57 --- /dev/null +++ b/docs/styles/content_align.md @@ -0,0 +1,65 @@ +# Content-align + +The `content-align` style aligns content _inside_ a widget. + +You can specify the alignment of content on both the horizontal and vertical axes. + +## Syntax + +``` +content-align: ; +``` + +### Values + +#### `HORIZONTAL` + +| Value | Description | +| ---------------- | -------------------------------------------------- | +| `left` (default) | Align content on the left of the horizontal axis | +| `center` | Align content in the center of the horizontal axis | +| `right` | Align content on the right of the horizontal axis | + +#### `VERTICAL` + +| Value | Description | +| --------------- | ------------------------------------------------ | +| `top` (default) | Align content at the top of the vertical axis | +| `middle` | Align content in the middle of the vertical axis | +| `bottom` | Align content at the bottom of the vertical axis | + +## Example + +=== "content_align.py" + + ```python + --8<-- "docs/examples/styles/content_align.py" + ``` + +=== "content_align.css" + + ```scss + --8<-- "docs/examples/styles/content_align.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/content_align.py"} + ``` + +## CSS + +```sass +/* Align content in the very center of a widget */ +content-align: center middle; +/* Align content at the top right of a widget */ +content-align: right top; +``` + +## Python +```python +# Align content in the very center of a widget +widget.styles.content_align = ("center", "middle") +# Align content at the top right of a widget +widget.styles.content_align = ("right", "top") +``` diff --git a/docs/styles/display.md b/docs/styles/display.md new file mode 100644 index 000000000..5462367d4 --- /dev/null +++ b/docs/styles/display.md @@ -0,0 +1,67 @@ +# Display + +The `display` property defines whether a widget is displayed or not. + +## Syntax + +``` +display: [none|block]; +``` + +### Values + +| Value | Description | +|-------------------|---------------------------------------------------------------------------| +| `block` (default) | Display the widget as normal | +| `none` | The widget not be displayed, and space will no longer be reserved for it. | + +## Example + +Note that the second widget is hidden by adding the `"remove"` class which sets the display style to None. + +=== "display.py" + + ```python + --8<-- "docs/examples/styles/display.py" + ``` + +=== "display.css" + + ```css + --8<-- "docs/examples/styles/display.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/display.py"} + ``` + +## CSS + +```sass +/* Widget is on screen */ +display: block; + +/* Widget is not on the screen */ +display: none; +``` + +## Python + +```python +# Hide the widget +self.styles.display = "none" + +# Show the widget again +self.styles.display = "block" +``` + +There is also a shortcut to show / hide a widget. The `display` property on `Widget` may be set to `True` or `False` to show or hide the widget. + +```python +# Hide the widget +widget.display = False + +# Show the widget +widget.display = True +``` diff --git a/docs/styles/dock.md b/docs/styles/dock.md new file mode 100644 index 000000000..875358614 --- /dev/null +++ b/docs/styles/dock.md @@ -0,0 +1,45 @@ +# Dock + +The `dock` property is used to fix a widget to the edge of a container (which may be the entire terminal window). + +## Syntax + +``` +dock: top|right|bottom|left; +``` + +## Example + +The example below shows a `left` docked sidebar. +Notice that even though the content is scrolled, the sidebar remains fixed. + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"} + ``` + +=== "dock_layout1_sidebar.py" + + ```python + --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.py" + ``` + +=== "dock_layout1_sidebar.css" + + ```sass hl_lines="2" + --8<-- "docs/examples/guide/layout/dock_layout1_sidebar.css" + ``` + +## CSS + +```sass +/* Dock the widget on the left edge of its parent container */ +dock: left; +``` + +## Python + +```python +# Dock the widget on the left edge of its parent container +widget.styles.dock = "left" +``` diff --git a/docs/styles/grid.md b/docs/styles/grid.md new file mode 100644 index 000000000..9f7bbc1fd --- /dev/null +++ b/docs/styles/grid.md @@ -0,0 +1,54 @@ +# Grid + +There are a number of properties relating to the Textual `grid` layout. + +For an in-depth look at the grid layout, visit the grid [guide](../guide/layout.md#grid). + +| Property | Description | +|----------------|------------------------------------------------| +| `grid-size` | Number of columns and rows in the grid layout. | +| `grid-rows` | Height of grid rows. | +| `grid-columns` | Width of grid columns. | +| `grid-gutter` | Spacing between grid cells. | +| `row-span` | Number of rows a cell spans. | +| `column-span` | Number of columns a cell spans. | + +## Syntax + +```sass +grid-size: []; +/* columns first, then rows */ +grid-rows: . . .; +grid-columns: . . .; +grid-gutter: ; +row-span: ; +column-span: ; +``` + +## Example + +The example below shows all the properties above in action. +The `grid-size: 3 4;` declaration sets the grid to 3 columns and 4 rows. +The first cell of the grid, tinted magenta, shows a cell spanning multiple rows and columns. +The spacing between grid cells is because of the `grid-gutter` declaration. + +=== "Output" + + ```{.textual path="docs/examples/styles/grid.py"} + ``` + +=== "grid.py" + + ```python + --8<-- "docs/examples/styles/grid.py" + ``` + +=== "grid.css" + + ```sass + --8<-- "docs/examples/styles/grid.css" + ``` + +!!! warning + + The properties listed on this page will only work when the layout is `grid`. diff --git a/docs/styles/height.md b/docs/styles/height.md new file mode 100644 index 000000000..589806815 --- /dev/null +++ b/docs/styles/height.md @@ -0,0 +1,51 @@ +# Height + +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 creates 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"} + ``` + +## CSS + +```sass +/* Explicit cell height */ +height: 10; + +/* Percentage height */ +height: 50%; + +/* Automatic height */ +width: auto +``` + +## Python + +```python +self.styles.height = 10 +self.styles.height = "50% +self.styles.height = "auto" +``` diff --git a/docs/styles/index.md b/docs/styles/index.md new file mode 100644 index 000000000..67b6f6c85 --- /dev/null +++ b/docs/styles/index.md @@ -0,0 +1,3 @@ +# Styles + +A reference to Widget [styles](../guide/styles.md). diff --git a/docs/styles/layer.md b/docs/styles/layer.md new file mode 100644 index 000000000..383e745b2 --- /dev/null +++ b/docs/styles/layer.md @@ -0,0 +1,50 @@ +# Layer + +The `layer` property is used to assign widgets to a layer. +The value of the `layer` property must be the name of a layer defined using a `layers` declaration. +Layers control the order in which widgets are painted on screen. +More information on layers can be found in the [guide](../guide/layout.md#layers). + +## Syntax + +``` +layer: ; +``` + +## Example + +In the example below, `#box1` is yielded before `#box2`. +However, since `#box1` is on the higher layer, it is drawn on top of `#box2`. + +[//]: # (NOTE: the example below also appears in the guide and 'layers.md'.) + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/layers.py"} + ``` + +=== "layers.py" + + ```python + --8<-- "docs/examples/guide/layout/layers.py" + ``` + +=== "layers.css" + + ```sass hl_lines="3 15 19" + --8<-- "docs/examples/guide/layout/layers.css" + ``` + +## CSS + +```sass +/* Draw the widget on the layer called 'below' */ +layer: below; +``` + +## Python + +```python +# Draw the widget on the layer called 'below' +widget.layer = "below" +``` diff --git a/docs/styles/layers.md b/docs/styles/layers.md new file mode 100644 index 000000000..9eed3156d --- /dev/null +++ b/docs/styles/layers.md @@ -0,0 +1,50 @@ +# Layers + +The `layers` property allows you to define an ordered set of layers. +These `layers` can later be referenced using the `layer` property. +Layers control the order in which widgets are painted on screen. +More information on layers can be found in the [guide](../guide/layout.md#layers). + +## Syntax + +``` +layers: ...; +``` + +## Example + +In the example below, `#box1` is yielded before `#box2`. +However, since `#box1` is on the higher layer, it is drawn on top of `#box2`. + +[//]: # (NOTE: the example below also appears in the guide and 'layer.md'.) + +=== "Output" + + ```{.textual path="docs/examples/guide/layout/layers.py"} + ``` + +=== "layers.py" + + ```python + --8<-- "docs/examples/guide/layout/layers.py" + ``` + +=== "layers.css" + + ```sass hl_lines="3 15 19" + --8<-- "docs/examples/guide/layout/layers.css" + ``` + +## CSS + +```sass +/* Bottom layer is called 'below', layer above it is called 'above' */ +layers: below above; +``` + +## Python + +```python +# Bottom layer is called 'below', layer above it is called 'above' +widget.layers = ("below", "above") +``` diff --git a/docs/styles/layout.md b/docs/styles/layout.md new file mode 100644 index 000000000..a3021f753 --- /dev/null +++ b/docs/styles/layout.md @@ -0,0 +1,52 @@ +# Layout + +The `layout` property defines how a widget arranges its children. + +See [layout](../guide/layout.md) guide for more information. + +## Syntax + +``` +layout: [grid|horizontal|vertical]; +``` + +### Values + +| Value | Description | +| -------------------- | ----------------------------------------------------------------------------- | +| `grid` | Child widgets will be arranged in a grid. | +| `horizontal` | Child widgets will be arranged along the horizontal axis, from left to right. | +| `vertical` (default) | Child widgets will be arranged along the vertical axis, from top to bottom. | + +## Example + +Note how the `layout` property affects the arrangement of widgets in the example below. + +=== "layout.py" + + ```python + --8<-- "docs/examples/styles/layout.py" + ``` + +=== "layout.css" + + ```sass + --8<-- "docs/examples/styles/layout.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/layout.py"} + ``` + +## CSS + +```sass +layout: horizontal; +``` + +## Python + +```python +widget.layout = "horizontal" +``` diff --git a/docs/styles/links.md b/docs/styles/links.md new file mode 100644 index 000000000..225126aad --- /dev/null +++ b/docs/styles/links.md @@ -0,0 +1,57 @@ +# Links + +Textual supports the concept of inline "links" embedded in text which trigger an action when pressed. + +There are a number of styles which influence the appearance of these links within a widget. + +| Property | Description | +|-------------------------|-------------------------------------------------------------| +| `link-color` | The color of link text. | +| `link-background` | The background color of link text. | +| `link-style` | The style of link text (e.g. underline). | +| `link-hover-color` | The color of link text with the cursor above it. | +| `link-hover-background` | The background color of link text with the cursor above it. | +| `link-hover-style` | The style of link text with the cursor above it. | + +## Syntax + +```scss +link-color: ; +link-background: ; +link-style: ...; +link-hover-color: ; +link-hover-background: ; +link-hover-style: ...; +``` + +## Example + +In the example below, the first `Static` illustrates default link styling. +The second `Static` uses CSS to customize the link color, background, and style. + +=== "Output" + + ```{.textual path="docs/examples/styles/links.py"} + ``` + +=== "links.py" + + ```python + --8<-- "docs/examples/styles/links.py" + ``` + +=== "links.css" + + ```sass + --8<-- "docs/examples/styles/links.css" + ``` + +## Additional Notes + +* Inline links are not widgets, and thus cannot be focused. + +## See Also + +* An [introduction to links](../guide/actions.md#links) in the Actions guide. + +[//]: # (TODO: Links are documented twice in the guide, and one will likely be removed. Check the link above still works after that.) diff --git a/docs/styles/margin.md b/docs/styles/margin.md new file mode 100644 index 000000000..21cab8c61 --- /dev/null +++ b/docs/styles/margin.md @@ -0,0 +1,54 @@ +# Margin + +The `margin` rule adds space around the entire widget. Margin may be specified with 1, 2 or 4 values. + +| Example | Description | +|--------------------|---------------------------------------------------------------------| +| `margin: 1;` | A single value sets a margin of 1 around all 4 edges | +| `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 a single value. + +## Syntax + +``` +margin: ; +margin: ; +margin: ; +``` + +## Example + +In this example we add a large margin to 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"} + ``` + +## CSS + +```sass +/* Set margin of 2 on the top and bottom edges, and 4 on the left and right */ +margin: 2 4; +``` + +## Python + +```python +# In Python you can set the margin as a tuple of integers +widget.styles.margin = (2, 3) +``` diff --git a/docs/styles/max_height.md b/docs/styles/max_height.md new file mode 100644 index 000000000..e80d996fe --- /dev/null +++ b/docs/styles/max_height.md @@ -0,0 +1,31 @@ +# Max-height + +The `max-height` rule sets a maximum height for a widget. + +## Syntax + +``` +max-height: ; +``` + +## CSS + +```sass + +/* Set a maximum height of 10 rows */ +max-height: 10; + +/* Set a maximum height of 25% of the screen height */ +max-height: 25vh; +``` + +## Python + +```python +# Set the maximum height to 10 rows +widget.styles.max_height = 10 + +# Set the maximum height to 25% of the screen height +widget.styles.max_height = "25vh" + +``` diff --git a/docs/styles/max_width.md b/docs/styles/max_width.md new file mode 100644 index 000000000..c6ce4ef67 --- /dev/null +++ b/docs/styles/max_width.md @@ -0,0 +1,30 @@ +# Max-width + +The `max-width` rule sets a maximum width for a widget. + +## Syntax + +``` +max-width: ; +``` + +## CSS + +```sass + +/* Set a maximum width of 10 cells */ +max-width: 10; + +/* Set a maximum width of 25% of the screen width */ +max-width: 25vw; +``` + +## Python + +```python +# Set the maximum width to 10 cells +widget.styles.max_width = 10 + +# Set the maximum width to 25% of the screen width +widget.styles.max_width = "25vw" +``` diff --git a/docs/styles/min_height.md b/docs/styles/min_height.md new file mode 100644 index 000000000..b110e190e --- /dev/null +++ b/docs/styles/min_height.md @@ -0,0 +1,31 @@ +# Min-height + +The `min-height` rule sets a minimum height for a widget. + +## Syntax + +``` +min-height: ; +``` + +## CSS + +```sass + +/* Set a minimum height of 10 rows */ +min-height: 10; + +/* Set a minimum height of 25% of the screen height */ +min-height: 25vh; +``` + +## Python + +```python +# Set the minimum height to 10 rows +self.styles.min_height = 10 + +# Set the minimum height to 25% of the screen height +self.styles.min_height = "25vh" + +``` diff --git a/docs/styles/min_width.md b/docs/styles/min_width.md new file mode 100644 index 000000000..81331469a --- /dev/null +++ b/docs/styles/min_width.md @@ -0,0 +1,30 @@ +# Min-width + +The `min-width` rules sets a minimum width for a widget. + +## Syntax + +``` +min-width: ; +``` + +## CSS + +```sass + +/* Set a minimum width of 10 cells */ +min-width: 10; + +/* Set a minimum width of 25% of the screen width */ +min-width: 25vw; +``` + +## Python + +```python +# Set the minimum width to 10 cells +widget.styles.min_width = 10 + +# Set the minimum width to 25% of the screen width +widget.styles.min_width = "25vw" +``` diff --git a/docs/styles/offset.md b/docs/styles/offset.md new file mode 100644 index 000000000..059a1edbe --- /dev/null +++ b/docs/styles/offset.md @@ -0,0 +1,46 @@ +# Offset + +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 + +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"} + ``` + +## CSS + +```sass +/* Move the widget 2 cells in the x direction, and 4 in the y direction. */ +offset: 2 4; +``` + +## Python + +```python +# Move the widget 2 cells in the x direction, and 4 in the y direction. +widget.styles.offset = (2, 4) +``` diff --git a/docs/styles/opacity.md b/docs/styles/opacity.md new file mode 100644 index 000000000..1ccff5458 --- /dev/null +++ b/docs/styles/opacity.md @@ -0,0 +1,54 @@ +# Opacity + +The `opacity` property can be used to make a widget partially or fully transparent. + + +## Syntax + +``` +opacity: ; +``` + +### Values + +As a fractional property, `opacity` can be set to either a float (between 0 and 1), +or a percentage, e.g. `45%`. +Float values will be clamped between 0 and 1. +Percentage values will be clamped between 0% and 100%. + +## Example + +This example shows, from top to bottom, increasing opacity values. + +=== "opacity.py" + + ```python + --8<-- "docs/examples/styles/opacity.py" + ``` + +=== "opacity.css" + + ```scss + --8<-- "docs/examples/styles/opacity.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/opacity.py"} + ``` + +## CSS + +```sass +/* Fade the widget to 50% against its parent's background */ +Widget { + opacity: 50%; +} +``` + +## Python + +```python +# Fade the widget to 50% against its parent's background +widget.styles.opacity = "50%" +``` diff --git a/docs/styles/outline.md b/docs/styles/outline.md new file mode 100644 index 000000000..284c669a8 --- /dev/null +++ b/docs/styles/outline.md @@ -0,0 +1,83 @@ +# Outline + +The `outline` rule enables the drawing of a box around a widget. Similar to `border`, but unlike border, outline will +draw _over_ the content area. This rule can be useful for emphasis if you want to display an outline for a brief time to +draw the user's attention to it. + +An outline is set with a border value (see table below) followed by a color. + +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: [] []; +``` + +### Values + +| Border value | Description | +|--------------|---------------------------------------------------------| +| `"ascii"` | A border with plus, hyphen, and vertical bar | +| `"blank"` | A blank border (reserves space for a border) | +| `"dashed"` | Dashed line border | +| `"double"` | Double lined border | +| `"heavy"` | Heavy border | +| `"hidden"` | Alias for "none" | +| `"hkey"` | Horizontal key-line border | +| `"inner"` | Thick solid border | +| `"none"` | Disabled border | +| `"outer"` | Think solid border with additional space around content | +| `"round"` | Rounded corners | +| `"solid"` | Solid border | +| `"tall"` | Solid border with extras space top and bottom | +| `"vkey"` | Vertical key-line border | +| `"wide"` | Solid border with additional space left and right | + +For example, `heavy white` would display a heavy white line around a widget. + +## Example + +This example shows a widget with an outline. Note how the outline occludes the text area. + +=== "outline.py" + + ```python + --8<-- "docs/examples/styles/outline.py" + ``` + +=== "outline.css" + + ```css + --8<-- "docs/examples/styles/outline.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/outline.py"} + ``` + +## CSS + +```sass +/* Set a heavy white outline */ +outline:heavy white; + +/* set a red outline on the left */ +outline-left:outer red; +``` + +## Python + +```python +# Set a heavy white outline +widget.outline = ("heavy", "white) + +# Set a red outline on the left +widget.outline_left = ("outer", "red) +``` diff --git a/docs/styles/overflow.md b/docs/styles/overflow.md new file mode 100644 index 000000000..7b49ec4f1 --- /dev/null +++ b/docs/styles/overflow.md @@ -0,0 +1,72 @@ +# Overflow + +The `overflow` rule specifies if and when scrollbars should be displayed on the `x` and `y` axis. +The rule takes two overflow values; one for the horizontal bar (x-axis), followed by the vertical bar (y-axis). + +The default value for overflow is `"auto auto"` which will show scrollbars automatically for both scrollbars if content doesn't fit within container. + +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]; +``` + +### Values + +| Value | Description | +|------------------|---------------------------------------------------------| +| `auto` (default) | Automatically show the scrollbar if content doesn't fit | +| `hidden` | Never show the scrollbar | +| `scroll` | Always show the scrollbar | + +## 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. + +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" + + ```python + --8<-- "docs/examples/styles/overflow.py" + ``` + +=== "overflow.css" + + ```css + --8<-- "docs/examples/styles/overflow.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/overflow.py"} + ``` + +## CSS + +```sass +/* Automatic scrollbars on both axes (the default) */ +overflow: auto auto; + +/* Hide the vertical scrollbar */ +overflow-y: hidden; + +/* Always show the horizontal scrollbar */ +overflow-x: scroll; +``` + +## Python + +```python +# Hide the vertical scrollbar +widget.styles.overflow_y = "hidden" + +# Always show the horizontal scrollbar +widget.styles.overflow_x = "scroll" + +``` diff --git a/docs/styles/padding.md b/docs/styles/padding.md new file mode 100644 index 000000000..6336a3d5f --- /dev/null +++ b/docs/styles/padding.md @@ -0,0 +1,54 @@ +# Padding + +The padding rule adds space around the content of a widget. You can specify padding with 1, 2 or 4 numbers. + +| example | | +| ------------------- | ------------------------------------------------------------------- | +| `padding: 1;` | A single value sets a padding of 1 around all 4 edges | +| `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 a single value. + +## Syntax + +``` +padding: ; +padding: ; +padding: ; +``` + +## Example + +This example adds padding around static text. + +=== "padding.py" + + ```python + --8<-- "docs/examples/styles/padding.py" + ``` + +=== "padding.css" + + ```css + --8<-- "docs/examples/styles/padding.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/padding.py"} + ``` + +## CSS + +```sass +/* Set padding of 2 on the top and bottom edges, and 4 on the left and right */ +padding: 2 4; +``` + +## Python + +```python +# In Python you can set the padding as a tuple of integers +widget.styles.padding = (2, 3) +``` diff --git a/docs/styles/scrollbar.md b/docs/styles/scrollbar.md new file mode 100644 index 000000000..3a4ec1f33 --- /dev/null +++ b/docs/styles/scrollbar.md @@ -0,0 +1,63 @@ +# Scrollbar colors + +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) | +| `scrollbar-background` | Scrollbar background | +| `scrollbar-background-hover` | Scrollbar background when the mouse is hovering over it | +| `scrollbar-background-active` | Scrollbar background when the thumb is being dragged | +| `scrollbar-corner-color` | The gap between the horizontal and vertical scrollbars | + +## Syntax + +``` +scrollbar-color: ; +scrollbar-color-hover: ; +scrollbar-color-active: ; +scrollbar-background: ; +scrollbar-background-hover: ; +scrollbar-background-active: ; +scrollbar-corner-color: ; +``` + +## Example + +In this example we have two panels with different scrollbar colors set for each. + +=== "scrollbars.py" + + ```python + --8<-- "docs/examples/styles/scrollbars.py" + ``` + +=== "scrollbars.css" + + ```css + --8<-- "docs/examples/styles/scrollbars.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/scrollbars.py"} + ``` + +## CSS + +```sass +/* Set widget scrollbar color to yellow */ +Widget { + scrollbar-color: yellow; +} +``` + +## Python + +```python +# Set the scrollbar color to yellow +widget.styles.scrollbar_color = "yellow" +``` diff --git a/docs/styles/scrollbar_gutter.md b/docs/styles/scrollbar_gutter.md new file mode 100644 index 000000000..7c2ed5e44 --- /dev/null +++ b/docs/styles/scrollbar_gutter.md @@ -0,0 +1,53 @@ +# Scrollbar-gutter + +The `scrollbar-gutter` rule allows authors to reserve space for the vertical scrollbar. + +Setting the value to `stable` prevents unwanted layout changes when the scrollbar becomes visible. + +## Syntax + +``` +scrollbar-gutter: [auto|stable]; +``` + +### Values + +| Value | Description | +|------------------|--------------------------------------------------| +| `auto` (default) | No space is reserved for the vertical scrollbar. | +| `stable` | Space is reserved for the vertical scrollbar. | + +## Example + +In the example below, notice the gap reserved for the scrollbar on the right side of the +terminal window. + +=== "scrollbar_gutter.py" + + ```python + --8<-- "docs/examples/styles/scrollbar_gutter.py" + ``` + +=== "scrollbar_gutter.css" + + ```scss + --8<-- "docs/examples/styles/scrollbar_gutter.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/scrollbar_gutter.py"} + ``` + +## CSS + +```sass +/* Reserve space for vertical scrollbar */ +scrollbar-gutter: stable; +``` + +## Python + +```python +self.styles.scrollbar_gutter = "stable" +``` diff --git a/docs/styles/scrollbar_size.md b/docs/styles/scrollbar_size.md new file mode 100644 index 000000000..ccc9c5e80 --- /dev/null +++ b/docs/styles/scrollbar_size.md @@ -0,0 +1,49 @@ +# Scrollbar-size + +The `scrollbar-size` rule changes the size of the scrollbars. It takes 2 integers for horizontal and vertical scrollbar size respectively. + +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 widget's scrollbar to be _much_ larger than usual. + +=== "scrollbar_size.py" + + ```python + --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"} + ``` + +## CSS + +```sass +/* Set horizontal scrollbar to 10, and vertical scrollbar to 4 */ +Widget { + scrollbar-size: 10 4; +} +``` + +## Python + +```python +# Set horizontal scrollbar to 10, and vertical scrollbar to 4 +widget.styles.horizontal_scrollbar = 10 +widget.styles.vertical_scrollbar = 10 +``` diff --git a/docs/styles/text_align.md b/docs/styles/text_align.md new file mode 100644 index 000000000..1fc9b515c --- /dev/null +++ b/docs/styles/text_align.md @@ -0,0 +1,57 @@ +# Text-align + +The `text-align` rule aligns text within a widget. + +## Syntax + +``` +text-align: [left|start|center|right|end|justify]; +``` + +### Values + +| Value | Description | +|-----------|----------------------------------| +| `left` | Left aligns text in the widget | +| `start` | Left aligns text in the widget | +| `center` | Center aligns text in the widget | +| `right` | Right aligns text in the widget | +| `end` | Right aligns text in the widget | +| `justify` | Justifies text in the widget | + +## Example + +This example shows, from top to bottom: `left`, `center`, `right`, and `justify` text alignments. + +=== "text_align.py" + + ```python + --8<-- "docs/examples/styles/text_align.py" + ``` + +=== "text_align.css" + + ```css + --8<-- "docs/examples/styles/text_align.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/text_align.py"} + ``` + +## CSS + +```sass +/* Set text in all Widgets to be right aligned */ +Widget { + text-align: right; +} +``` + +## Python + +```python +# Set text in the widget to be right aligned +widget.styles.text_align = "right" +``` diff --git a/docs/styles/text_opacity.md b/docs/styles/text_opacity.md new file mode 100644 index 000000000..5a0e1c6c5 --- /dev/null +++ b/docs/styles/text_opacity.md @@ -0,0 +1,53 @@ +# Text-opacity + +The `text-opacity` blends the color of the content of a widget with the color of the background. + +## Syntax + +``` +text-opacity: ; +``` + +### Values + +As a fractional property, `text-opacity` can be set to either a float (between 0 and 1), +or a percentage, e.g. `45%`. +Float values will be clamped between 0 and 1. +Percentage values will be clamped between 0% and 100%. + +## Example + +This example shows, from top to bottom, increasing text-opacity values. + +=== "text_opacity.py" + + ```python + --8<-- "docs/examples/styles/text_opacity.py" + ``` + +=== "text_opacity.css" + + ```css + --8<-- "docs/examples/styles/text_opacity.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/text_opacity.py"} + ``` + +## CSS + +```sass +/* Set the text to be "half-faded" against the background of the widget */ +Widget { + text-opacity: 50%; +} +``` + +## Python + +```python +# Set the text to be "half-faded" against the background of the widget +widget.styles.text_opacity = "50%" +``` diff --git a/docs/styles/text_style.md b/docs/styles/text_style.md new file mode 100644 index 000000000..b54a1f2aa --- /dev/null +++ b/docs/styles/text_style.md @@ -0,0 +1,55 @@ +# Text-style + +The `text-style` rule enables a number of different ways of displaying text. + +Text styles may be set in combination. +For example `bold underline` or `reverse underline strike`. + +## Syntax + +``` +text-style: ...; +``` + +### Values + +| Value | Description | +|-------------|----------------------------------------------------------------| +| `bold` | **bold text** | +| `italic` | _italic text_ | +| `reverse` | reverse video text (foreground and background colors reversed) | +| `underline` | underline text | +| `strike` | strikethrough text | + +## Example + +Each of the three text panels has a different text style. + +=== "text_style.py" + + ```python + --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"} + ``` + +## CSS + +```sass +text-style: italic; +``` + +## Python + +```python +widget.styles.text_style = "italic" +``` diff --git a/docs/styles/tint.md b/docs/styles/tint.md new file mode 100644 index 000000000..dde7e3aaf --- /dev/null +++ b/docs/styles/tint.md @@ -0,0 +1,51 @@ +# Tint + +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. + +=== "tint.py" + + ```python + --8<-- "docs/examples/styles/tint.py" + ``` + +=== "tint.css" + + ```css + --8<-- "docs/examples/styles/tint.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/tint.py"} + ``` + +## CSS + +```sass +/* A red tint (could indicate an error) */ +tint: red 20% + +/* A green tint */ +tint: rgba(0, 200, 0, 0.3); +``` + +# Python + +```python +# A red tint +from textual.color import Color +widget.styles.tint = Color.parse("red").with_alpha(0.2); + +# A green tint +widget.styles.tint = "rgba(0, 200, 0, 0.3): +``` diff --git a/docs/styles/visibility.md b/docs/styles/visibility.md new file mode 100644 index 000000000..d864487b0 --- /dev/null +++ b/docs/styles/visibility.md @@ -0,0 +1,67 @@ +# Visibility + +The `visibility` rule may be used to make a widget invisible while still reserving spacing for it. + +## Syntax + +``` +visibility: [visible|hidden]; +``` + +### Values + +| Value | Description | +|---------------------|----------------------------------------| +| `visible` (default) | The widget will be displayed as normal | +| `hidden` | The widget will be invisible | + +## Example + +Note that the second widget is hidden, while leaving a space where it would have been rendered. + +=== "visibility.py" + + ```python + --8<-- "docs/examples/styles/visibility.py" + ``` + +=== "visibility.css" + + ```css + --8<-- "docs/examples/styles/visibility.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/visibility.py"} + ``` + +## CSS + +```sass +/* Widget is on screen */ +visibility: visible; + +/* Widget is not on the screen */ +visibility: hidden; +``` + +## Python + +```python +# Widget is invisible +self.styles.visibility = "hidden" + +# Widget is visible +self.styles.visibility = "visible" +``` + +There is also a shortcut to set a Widget's visibility. The `visible` property on `Widget` may be set to `True` or `False`. + +```python +# Make a widget invisible +widget.visible = False + +# Make the widget visible again +widget.visible = True +``` diff --git a/docs/styles/width.md b/docs/styles/width.md new file mode 100644 index 000000000..499048483 --- /dev/null +++ b/docs/styles/width.md @@ -0,0 +1,51 @@ +# Width + +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. + +=== "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"} + ``` + +## CSS + +```sass +/* Explicit cell width */ +width: 10; + +/* Percentage width */ +width: 50%; + +/* Automatic width */ +width: auto +``` + +## Python + +```python +self.styles.width = 10 +self.styles.width = "50% +self.styles.width = "auto" +``` diff --git a/docs/stylesheets/custom.css b/docs/stylesheets/custom.css index 0ccc3b2eb..ea1639ef1 100644 --- a/docs/stylesheets/custom.css +++ b/docs/stylesheets/custom.css @@ -12,3 +12,15 @@ 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; +} + +.excalidraw { + text-align: center; +} diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 000000000..7a69c787e --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,457 @@ +--- +hide: + - navigation +--- + +# Tutorial + +Welcome to the Textual Tutorial! + +By the end of this page you should have a solid understanding of app development with Textual. + +!!! quote + + If you want people to build things, make it fun. + + — **Will McGugan** (creator of Rich and Textual) + + +## Stopwatch Application + +We're going to build a stopwatch application. This application should show a list of stopwatches with buttons to start, stop, and reset the stopwatches. We also want the user to be able to add and remove stopwatches as required. + +This will be a simple yet **fully featured** app — you could distribute this app if you wanted to! + +Here's what the finished app will look like: + + +```{.textual path="docs/examples/tutorial/stopwatch.py" press="tab,enter,_,tab,enter,_,tab,_,enter,_,tab,enter,_,_"} +``` + +### Get the code + +If you want to try the finished Stopwatch app and follow along with the code, first make sure you have [Textual installed](getting_started.md) then check out the [Textual](https://github.com/Textualize/textual) repository: + +=== "HTTPS" + + ```bash + git clone https://github.com/Textualize/textual.git + ``` + +=== "SSH" + + ```bash + git clone git@github.com:Textualize/textual.git + ``` + +=== "GitHub CLI" + + ```bash + gh repo clone Textualize/textual + ``` + + +With the repository cloned, navigate to `docs/examples/tutorial` and run `stopwatch.py`. + +```bash +cd textual/docs/examples/tutorial +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 whether you add them to your own projects. + +We're a big fan of Python type hints at Textualize. If you haven't encountered type hinting, it's a way to express the types of your data, parameters, and return values. Type hinting allows tools like [mypy](https://mypy.readthedocs.io/en/stable/) to catch 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` indicates that `text` requires a string and `count: int` means that `count` requires an integer. + +Return types follow `->`. So `-> str:` indicates this method returns a string. + + +## The App class + +The first step in building a Textual app is to import and extend the `App` class. Here's a basic app class we will use as a starting point for the stopwatch app. + +```python title="stopwatch01.py" +--8<-- "docs/examples/tutorial/stopwatch01.py" +``` + +If you run this code, you should see something like the following: + + +```{.textual path="docs/examples/tutorial/stopwatch01.py"} +``` + +Hit the ++d++ key to toggle between light and dark mode. + +```{.textual path="docs/examples/tutorial/stopwatch01.py" press="d" title="TimerApp + dark"} +``` + +Hit ++ctrl+c++ to exit the app and return to the command prompt. + +### A closer look at the App class + +Let's examine `stopwatch01.py` in more detail. + +```python title="stopwatch01.py" hl_lines="1 2" +--8<-- "docs/examples/tutorial/stopwatch01.py" +``` + +The first line imports the Textual `App` class, which we will use as the base class for our App. The second line imports two builtin widgets: `Footer` which shows a bar at the bottom of the screen with current keys, and `Header` which shows a title and the current time at the top of the screen. Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build widgets in this tutorial. + +The following lines define the app itself: + +```python title="stopwatch01.py" hl_lines="5-17" +--8<-- "docs/examples/tutorial/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. + +Here's what the above app defines: + +- `BINDINGS` is a list of tuples that maps (or *binds*) keys to actions in your app. The first value in the tuple is the key; the second value is the name of the action; the final value is a short description. We have a single binding which maps the ++d++ key on to the "toggle_dark" action. See [key bindings](./guide/input.md#bindings) in the guide for details. + +- `compose()` is where we construct a user interface with widgets. The `compose()` method may return a list of widgets, but it is generally easier to _yield_ them (making this method a generator). In the example code we yield an instance of each of the widget classes we imported, i.e. `Header()` and `Footer()`. + +- `action_toggle_dark()` defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The `BINDINGS` list above tells Textual to run this action when the user hits the ++d++ key. See [actions](./guide/actions.md) in the guide for details. + +```python title="stopwatch01.py" hl_lines="20-22" +--8<-- "docs/examples/tutorial/stopwatch01.py" +``` + +The final three lines create an instance of the app and calls the [run()][textual.app.App.run] method which puts your terminal in to *application mode* and runs the app until you exit with ++ctrl+c++. This happens within a `__name__ == "__main__"` block so we could run the app with `python stopwatch01.py` or import it as part of a larger project. + +## Designing a UI with widgets + +Textual comes with a number of builtin widgets, like Header and Footer, which are versatile and re-usable. We will need to build some custom widgets for the stopwatch. Before we dive in to that, let's first sketch a design for the app — so we know what we're aiming for. + +
+--8<-- "docs/images/stopwatch.excalidraw.svg" +
+ +### Custom widgets + +We need a `Stopwatch` widget composed of the following _child_ widgets: + +- A "Start" button +- A "Stop" button +- A "Reset" button +- A time display + +Textual has a builtin `Button` widget which takes care of the first three components. All we need to build is the time display widget which will show the elapsed time and the stopwatch widget itself. + +Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go. + +```python title="stopwatch02.py" hl_lines="2-3 6-7 10-18 30" +--8<-- "docs/examples/tutorial/stopwatch02.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.containers` which (as the name suggests) is a `Widget` which contains other widgets. + +We've defined an empty `TimeDisplay` widget by extending `Static`. We will flesh this out later. + +The Stopwatch widget class also extends `Static`. This class has a `compose()` method which yields child widgets, consisting of three `Button` objects and a single `TimeDisplay` object. These widgets will form the stopwatch in our sketch. + +#### The buttons + +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. + +### Composing the widgets + +To add widgets to our application we first need to yield them from the app's `compose()` method: + +The new line in `Stopwatch.compose()` yields a single `Container` object which will create a scrolling list of stopwatches. When classes contain other widgets (like `Container`) they will typically accept their child widgets as positional arguments. We want to start the app with three stopwatches, so we construct three `Stopwatch` instances and pass them to the container's constructor. + + +### The unstyled app + +Let's see what happens when we run `stopwatch02.py`. + +```{.textual path="docs/examples/tutorial/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 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 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" +self.styles.color = "white" +``` + +While it's possible to set all styles for an app this way, it is rarely necessary. Textual has support for CSS (Cascading Style Sheets), a technology used by web browsers. CSS files are data files loaded by your app which contain information about styles to apply to your widgets. + +!!! info + + The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn. + + +CSS makes it easy to iterate on the design of your app and enables [live-editing](./guide/devtools.md#live-editing) — you can edit CSS and see the changes without restarting the app! + + +Let's add a CSS file to our application. + +```python title="stopwatch03.py" hl_lines="24" +--8<-- "docs/examples/tutorial/stopwatch03.py" +``` + +Adding the `CSS_PATH` class variable tells Textual to load the following file when the app starts: + +```sass title="stopwatch03.css" +--8<-- "docs/examples/tutorial/stopwatch03.css" +``` + +If we run the app now, it will look *very* different. + +```{.textual path="docs/examples/tutorial/stopwatch03.py" title="stopwatch03.py"} +``` + +This app looks much more like our sketch. Let's look at how Textual uses `stopwatch03.css` to apply styles. + +### CSS basics + +CSS files contain a number of _declaration blocks_. Here's the first such block from `stopwatch03.css` again: + +```sass +Stopwatch { + layout: horizontal; + background: $boost; + 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 this CSS code changes how the `Stopwatch` widget is displayed. + +
+--8<-- "docs/images/stopwatch_widgets.excalidraw.svg" +
+ +- `layout: horizontal` aligns child widgets horizontally from left to right. +- `background: $boost` sets the background color to `$boost`. The `$` prefix picks a pre-defined color from the builtin theme. There are other ways to specify colors such as `"blue"` or `rgb(20,46,210)`. +- `height: 5` sets the height of our widget to 5 lines of text. +- `padding: 1` sets a padding of 1 cell around the child widgets. +- `margin: 1` sets a margin of 1 cell around the `Stopwatch` widget to create a little space between widgets in the list. + + +Here's the rest of `stopwatch03.css` which contains further declaration blocks: + +```sass +TimeDisplay { + content-align: center middle; + opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} +``` + +The `TimeDisplay` block aligns text to the center (`content-align`), fades it slightly (`opacity`), and sets its height (`height`) to 3 lines. + +The `Button` block sets the width (`width`) of buttons to 16 cells (character widths). + +The last 3 blocks have a slightly different format. When the declaration begins with a `#` then the styles will be applied to widgets with a matching "id" attribute. We've set an ID on the `Button` widgets we yielded in `compose`. For instance the first button has `id="start"` which matches `#start` in the CSS. + +The buttons have a `dock` style which aligns the widget to a given edge. The start and stop buttons are docked to the left edge, while the reset button is docked to the right edge. + +You may have noticed that the stop button (`#stop` in the CSS) has `display: none;`. This tells Textual to not show the button. We do this because we don't want to display the stop button when the timer is *not* running. Similarly we don't want to show the start button when the timer is running. We will cover how to manage such dynamic user interfaces in the next section. + +### Dynamic CSS + +We want our `Stopwatch` widget to have two states: a default state with a Start and Reset button; and a _started_ state with a Stop button. When a stopwatch is started it should also have a green background and bold text. + +
+--8<-- "docs/images/css_stopwatch.excalidraw.svg" +
+ + +We can accomplish this with a CSS _class_. Not to be confused with a Python class, a CSS class is like a tag you can add to a widget to modify its styles. + +Here's the new CSS: + +```sass title="stopwatch04.css" hl_lines="33-53" +--8<-- "docs/examples/tutorial/stopwatch04.css" +``` + +These new rules are prefixed with `.started`. The `.` indicates that `.started` refers to a CSS class called "started". The new styles will be applied only to widgets that have this CSS class. + +Some of the new styles have more than one selector separated by a space. The space indicates that the rule should match the second selector if it is a child of the first. Let's look at one of these styles: + +```sass +.started #start { + display: none +} +``` + +The `.started` selector matches any widget with a `"started"` CSS class. While `#start` matches a child widget with an ID of `"start"`. So it matches the Start button only for Stopwatches in a started state. + +The rule is `"display: none"` which tells Textual to hide the button. + +### Manipulating classes + +Modifying a widget's CSS classes is a convenient way to update visuals without introducing a lot of messy display related code. + +You can add and remove CSS classes with the [add_class()][textual.dom.DOMNode.add_class] and [remove_class()][textual.dom.DOMNode.remove_class] methods. We will use these methods to connect the started state to the Start / Stop buttons. + +The following code will start or stop the stopwatches in response to clicking a button. + +```python title="stopwatch04.py" hl_lines="13-18" +--8<-- "docs/examples/tutorial/stopwatch04.py" +``` + +The `on_button_pressed` method is an *event handler*. Event handlers are methods called by Textual in response to an *event* such as a key press, mouse click, etc. Event handlers begin with `on_` followed by the name of the event they will handler. Hence `on_button_pressed` will handle the button pressed event. + +If you run `stopwatch04.py` now you will be able to toggle between the two states by clicking the first button: + +```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,_,enter,_,_,_"} +``` + +## Reactive attributes + +A recurring theme in Textual is that you rarely need to explicitly update a widget. It is possible: you can call [refresh()][textual.widget.Widget.refresh] to display new data. However, Textual prefers to do this automatically via _reactive_ attributes. + +You can declare a reactive attribute with [reactive][textual.reactive.reactive]. Let's use this feature to create a timer that displays elapsed time and keeps it updated. + +```python title="stopwatch05.py" hl_lines="1 5 12-27" +--8<-- "docs/examples/tutorial/stopwatch05.py" +``` + +We have added two reactive attributes: `start_time` will contain the time in seconds when the stopwatch was started, and `time` will contain the time to be displayed on the `Stopwatch`. + +Both attributes will be available on `self` as if you had assigned them in `__init__`. If you write to either of these attributes the widget will update automatically. + +!!! info + + The `monotonic` function in this example is imported from the standard library `time` module. It is similar to `time.time` but won't go backwards if the system clock is changed. + +The first argument to `reactive` may be a default value or a callable that returns the default value. The default for `start_time` is `monotonic`. When `TimeDisplay` is added to the app, the `start_time` attribute will be set to the result of `monotonic()`. + +The `time` attribute has a simple float as the default value, so `self.time` will be `0` on start. + + +The `on_mount` method is an event handler called then the widget is first added to the application (or _mounted_). In this method we call [set_interval()][textual.message_pump.MessagePump.set_interval] to create a timer which calls `self.update_time` sixty times a second. This `update_time` method calculates the time elapsed since the widget started and assigns it to `self.time`. Which brings us to one of Reactive's super-powers. + +If you implement a method that begins with `watch_` followed by the name of a reactive attribute (making it a _watch method_), that method will be called when the attribute is modified. + +Because `watch_time` watches the `time` attribute, when we update `self.time` 60 times a second we also implicitly call `watch_time` which converts the elapsed time in to a string and updates the widget with a call to `self.update`. + +The end result is that the `Stopwatch` widgets show the time elapsed since the widget was created: + +```{.textual path="docs/examples/tutorial/stopwatch05.py" title="stopwatch05.py"} +``` + +We've seen how we can update widgets with a timer, but we still need to wire up the buttons so we can operate stopwatches independently. + +### Wiring buttons + +We need to be able to start, stop, and reset each stopwatch independently. We can do this by adding a few more methods to the `TimeDisplay` class. + + +```python title="stopwatch06.py" hl_lines="14 18 22 30-44 50-61" +--8<-- "docs/examples/tutorial/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 the 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()][textual.timer.Timer.resume] is called). This is because we don't want the time to update until the user hits the start button. +- The `update_time` method now adds `total` to the current time to account for the time between any previous clicks of the start and stop buttons. +- 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. + +In addition, the `on_button_pressed` method on `Stopwatch` has grown some code to manage the time display when the user clicks a button. Let's look at that in detail: + +```python + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + button_id = event.button.id + time_display = self.query_one(TimeDisplay) + if button_id == "start": + time_display.start() + self.add_class("started") + elif button_id == "stop": + time_display.stop() + self.remove_class("started") + elif button_id == "reset": + time_display.reset() +``` + +This code supplies missing features and makes our app useful. We've made the following changes. + +- The first line retrieves `id` attribute of the button that was pressed. We can use this 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 (`self.add_class("started")`), and remove it (`self.remove_class("started")`) when it is stopped. This will update the Stopwatch visuals via CSS. + +If you run `stopwatch06.py` you will be able to use the stopwatches independently. + +```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"} +``` + +The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches. + +## Dynamic widgets + +The Stopwatch app creates widgets when it starts via the `compose` method. We will also need to create new widgets while the app is running, and remove widgets we no longer need. We can do this by calling [mount()][textual.widget.Widget.mount] to add a widget, and [remove()][textual.widget.Widget.remove] to remove a widget. + +Let's use these methods to implement adding and removing stopwatches to our app. + +```python title="stopwatch.py" hl_lines="78-79 88-92 94-98" +--8<-- "docs/examples/tutorial/stopwatch.py" +``` + +Here's a summary of the changes: + +- The `Container` object in `StopWatchApp` grew a `"timers"` ID. +- Added `action_add_stopwatch` to add a new stopwatch. +- Added `action_remove_stopwatch` to remove a stopwatch. +- Added keybindings for the actions. + +The `action_add_stopwatch` method creates and mounts a new stopwatch. Note the call to [query_one()][textual.dom.DOMNode.query_one] with a CSS selector of `"#timers"` which gets the timer's container via its ID. Once mounted, the new Stopwatch will appear in the terminal. That last line in `action_add_stopwatch` calls [scroll_visible()][textual.widget.Widget.scroll_visible] which will scroll the container to make the new `Stopwatch` visible (if required). + +The `action_remove_stopwatch` function calls [query()][textual.dom.DOMNode.query] with a CSS selector of `"Stopwatch"` which gets all the `Stopwatch` widgets. If there are stopwatches then the action calls [last()][textual.css.query.DOMQuery.last] to get the last stopwatch, and [remove()][textual.css.query.DOMQuery.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/tutorial/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,_,_,_,_,tab,_"} +``` + +## What next? + +Congratulations on building your first Textual application! This tutorial has covered a lot of ground. If you are the type that prefers to learn a framework by coding, feel free. You could tweak `stopwatch.py` or look through the examples. + +Read the guide for the full details on how to build sophisticated TUI applications with Textual. diff --git a/docs/widgets/button.md b/docs/widgets/button.md new file mode 100644 index 000000000..f8aad75de --- /dev/null +++ b/docs/widgets/button.md @@ -0,0 +1,58 @@ +# Button + + +A simple button widget which can be pressed using a mouse click or by pressing ++return++ +when it has focus. + +- [x] Focusable +- [ ] Container + +## Example + +The example below shows each button variant, and its disabled equivalent. +Clicking any of the non-disabled buttons in the example app below will result the app exiting and the details of the selected button being printed to the console. + +=== "Output" + + ```{.textual path="docs/examples/widgets/button.py"} + ``` + +=== "button.py" + + ```python + --8<-- "docs/examples/widgets/button.py" + ``` + +=== "button.css" + + ```css + --8<-- "docs/examples/widgets/button.css" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +| ---------- | ------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `label` | `str` | `""` | The text that appears inside the button. | +| `variant` | `str` | `"default"` | Semantic styling variant. One of `default`, `primary`, `success`, `warning`, `error`. | +| `disabled` | `bool` | `False` | Whether the button is disabled or not. Disabled buttons cannot be focused or clicked, and are styled in a way that suggests this. | + +## Messages + +### Pressed + +The `Button.Pressed` message is sent when the button is pressed. + +- [x] Bubbles + +#### Attributes + +_No other attributes_ + +## Additional Notes + +* The spacing between the text and the edges of a button are due to border, _not_ padding. To create a button with zero visible padding, use the `border: none;` declaration. + +## See Also + +* [Button](../reference/button.md) code reference diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md new file mode 100644 index 000000000..1404a43c5 --- /dev/null +++ b/docs/widgets/checkbox.md @@ -0,0 +1,57 @@ +# Checkbox + +A simple checkbox widget which stores a boolean value. + +- [x] Focusable +- [ ] Container + +## Example + +The example below shows checkboxes in various states. + +=== "Output" + + ```{.textual path="docs/examples/widgets/checkbox.py"} + ``` + +=== "checkbox.py" + + ```python + --8<-- "docs/examples/widgets/checkbox.py" + ``` + +=== "checkbox.css" + + ```css + --8<-- "docs/examples/widgets/checkbox.css" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +|---------|--------|---------|------------------------------------| +| `value` | `bool` | `False` | The default value of the checkbox. | + +## Messages + +### Pressed + +The `Checkbox.Changed` message is sent when the checkbox is toggled. + +- [x] Bubbles + +#### Attributes + +| attribute | type | purpose | +|-----------|--------|--------------------------------| +| `value` | `bool` | The new value of the checkbox. | + +## Additional Notes + +- To remove the spacing around a checkbox, set `border: none;` and `padding: 0;`. +- The `.checkbox--switch` component class can be used to change the color and background of the switch. +- When focused, the ++enter++ or ++space++ keys can be used to toggle the checkbox. + +## See Also + +- [Checkbox](../reference/checkbox.md) code reference diff --git a/docs/widgets/data_table.md b/docs/widgets/data_table.md new file mode 100644 index 000000000..ce7f25bf5 --- /dev/null +++ b/docs/widgets/data_table.md @@ -0,0 +1,38 @@ +# DataTable + +A data table widget. + +- [x] Focusable +- [ ] Container + +## Example + +The example below populates a table with CSV data. + +=== "Output" + + ```{.textual path="docs/examples/widgets/table.py"} + ``` + +=== "table.py" + + ```python + --8<-- "docs/examples/widgets/table.py" + ``` + + +## Reactive Attributes + +| Name | Type | Default | Description | +| --------------- | ------ | ------- | ---------------------------------- | +| `show_header` | `bool` | `True` | Show the table header | +| `fixed_rows` | `int` | `0` | Number of fixed rows | +| `fixed_columns` | `int` | `0` | Number of fixed columns | +| `zebra_stripes` | `bool` | `False` | Display alternating colors on rows | +| `header_height` | `int` | `1` | Height of header row | +| `show_cursor` | `bool` | `True` | Show a cell cursor | + + +## See Also + +* [DataTable][textual.widgets.DataTable] code reference diff --git a/docs/widgets/footer.md b/docs/widgets/footer.md new file mode 100644 index 000000000..ee1d4eeb7 --- /dev/null +++ b/docs/widgets/footer.md @@ -0,0 +1,42 @@ +# Footer + +A simple footer widget which is docked to the bottom of its parent container. Displays +available keybindings for the currently focused widget. + +- [ ] Focusable +- [ ] Container + +## Example + +The example below shows an app with a single keybinding that contains only a `Footer` +widget. Notice how the `Footer` automatically displays the keybinding. + +=== "Output" + + ```{.textual path="docs/examples/widgets/footer.py"} + ``` + +=== "footer.py" + + ```python + --8<-- "docs/examples/widgets/footer.py" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +| --------------- | ----- | ------- | --------------------------------------------------------------------------------------------------------- | +| `highlight_key` | `str` | `None` | Stores the currently highlighted key. This is typically the key the cursor is hovered over in the footer. | + +## Messages + +This widget sends no messages. + +## Additional Notes + +* You can prevent keybindings from appearing in the footer by setting the `show` argument of the `Binding` to `False`. +* You can customize the text that appears for the key itself in the footer using the `key_display` argument of `Binding`. + +## See Also + +* [Footer](../reference/footer.md) code reference diff --git a/docs/widgets/header.md b/docs/widgets/header.md new file mode 100644 index 000000000..685ce04ee --- /dev/null +++ b/docs/widgets/header.md @@ -0,0 +1,35 @@ +# Header + +A simple header widget which docks itself to the top of the parent container. + +- [ ] Focusable +- [ ] Container + +## Example + +The example below shows an app with a `Header`. + +=== "Output" + + ```{.textual path="docs/examples/widgets/header.py"} + ``` + +=== "header.py" + + ```python + --8<-- "docs/examples/widgets/header.py" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +| ------ | ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tall` | `bool` | `True` | Whether the `Header` widget is displayed as tall or not. The tall variant is 3 cells tall by default. The non-tall variant is a single cell tall. This can be toggled by clicking on the header. | + +## Messages + +This widget sends no messages. + +## See Also + +* [Header](../reference/header.md) code reference diff --git a/docs/widgets/index.md b/docs/widgets/index.md new file mode 100644 index 000000000..90c80104e --- /dev/null +++ b/docs/widgets/index.md @@ -0,0 +1,3 @@ +# Widgets + +A reference to the builtin [widgets](../guide/widgets.md). diff --git a/docs/widgets/input.md b/docs/widgets/input.md new file mode 100644 index 000000000..43d728d54 --- /dev/null +++ b/docs/widgets/input.md @@ -0,0 +1,67 @@ +# Input + +A single-line text input widget. + +- [x] Focusable +- [ ] Container + +## Example + +The example below shows how you might create a simple form using two `Input` widgets. + +=== "Output" + + ```{.textual path="docs/examples/widgets/input.py" press="tab,D,a,r,r,e,n"} + ``` + +=== "input.py" + + ```python + --8<-- "docs/examples/widgets/input.py" + ``` + +## Reactive Attributes + +| Name | Type | Default | Description | +| ----------------- | ------ | ------- | --------------------------------------------------------------- | +| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. | +| `value` | `str` | `""` | The value currently in the text input. | +| `cursor_position` | `int` | `0` | The index of the cursor in the value string. | +| `placeholder` | `str` | `str` | The dimmed placeholder text to display when the input is empty. | +| `password` | `bool` | `False` | True if the input should be masked. | + +## Messages + +### Changed + +The `Input.Changed` message is sent when the value in the text input changes. + +- [x] Bubbles + +#### Attributes + +| attribute | type | purpose | +| --------- | ----- | -------------------------------- | +| `value` | `str` | The new value in the text input. | + + +### Submitted + +The `Input.Submitted` message is sent when you press ++enter++ with the text field submitted. + +- [x] Bubbles + +#### Attributes + +| attribute | type | purpose | +|-----------|-------|----------------------------------| +| `value` | `str` | The new value in the text input. | + + +## Additional Notes + +* The spacing around the text content is due to border. To remove it, set `border: none;` in your CSS. + +## See Also + +* [Input](../reference/input.md) code reference diff --git a/docs/widgets/static.md b/docs/widgets/static.md new file mode 100644 index 000000000..3ed68ac95 --- /dev/null +++ b/docs/widgets/static.md @@ -0,0 +1,34 @@ +# Static + +A widget which displays static content. +Can be used for simple text labels, but can also contain more complex Rich renderables. + +- [ ] Focusable +- [x] Container + +## Example + +The example below shows how you can use a `Static` widget as a simple text label. + +=== "Output" + + ```{.textual path="docs/examples/widgets/static.py"} + ``` + +=== "static.py" + + ```python + --8<-- "docs/examples/widgets/static.py" + ``` + +## Reactive Attributes + +This widget has no reactive attributes. + +## Messages + +This widget sends no messages. + +## See Also + +* [Static](../reference/static.md) code reference diff --git a/docs/widgets/tree_control.md b/docs/widgets/tree_control.md new file mode 100644 index 000000000..1155acfcc --- /dev/null +++ b/docs/widgets/tree_control.md @@ -0,0 +1 @@ +# TreeControl diff --git a/examples/README.md b/examples/README.md index efcf44856..0463bd2eb 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,10 @@ -# Examples +# Textual Examples -Run any of these examples to demonstrate a Textual features. +This directory contains example Textual applications. -The example code will generate a log file called "textual.log". Tail this file to gain insight in to what Textual is doing. +To run them, navigate to the examples directory and enter `python` followed buy the name of the Python file. ``` -tail -F textual.log +cd textual/examples +python pride.py ``` diff --git a/examples/animation.py b/examples/animation.py deleted file mode 100644 index bf5f9ae8e..000000000 --- a/examples/animation.py +++ /dev/null @@ -1,38 +0,0 @@ -from textual.app import App -from textual.reactive import Reactive -from textual.widgets import Footer, Placeholder - - -class SmoothApp(App): - """Demonstrates smooth animation. Press 'b' to see it in action.""" - - async def on_load(self) -> None: - """Bind keys here.""" - await self.bind("b", "toggle_sidebar", "Toggle sidebar") - await self.bind("q", "quit", "Quit") - - show_bar = Reactive(False) - - def watch_show_bar(self, show_bar: bool) -> None: - """Called when show_bar changes.""" - self.bar.animate("layout_offset_x", 0 if show_bar else -40) - - def action_toggle_sidebar(self) -> None: - """Called when user hits 'b' key.""" - self.show_bar = not self.show_bar - - async def on_mount(self) -> None: - """Build layout here.""" - footer = Footer() - self.bar = Placeholder(name="left") - - await self.view.dock(footer, edge="bottom") - await self.view.dock(Placeholder(), Placeholder(), edge="top") - await self.view.dock(self.bar, edge="left", size=40, z=1) - - self.bar.layout_offset_x = -40 - - # self.set_timer(10, lambda: self.action("quit")) - - -SmoothApp.run(log="textual.log", log_verbosity=2) diff --git a/examples/big_table.py b/examples/big_table.py deleted file mode 100644 index a11ce385b..000000000 --- a/examples/big_table.py +++ /dev/null @@ -1,33 +0,0 @@ -from rich.table import Table - -from textual import events -from textual.app import App -from textual.widgets import ScrollView - - -class MyApp(App): - """An example of a very simple Textual App""" - - async def on_load(self, event: events.Load) -> None: - await self.bind("q", "quit", "Quit") - - async def on_mount(self, event: events.Mount) -> None: - - self.body = body = ScrollView(auto_width=True) - - await self.view.dock(body) - - async def add_content(): - table = Table(title="Demo") - - for i in range(20): - table.add_column(f"Col {i + 1}", style="magenta") - for i in range(100): - table.add_row(*[f"cell {i},{j}" for j in range(20)]) - - await body.update(table) - - await self.call_later(add_content) - - -MyApp.run(title="Simple App", log="textual.log") diff --git a/examples/calculator.css b/examples/calculator.css new file mode 100644 index 000000000..3a65ed1c0 --- /dev/null +++ b/examples/calculator.css @@ -0,0 +1,32 @@ +Screen { + overflow: auto; +} + +#calculator { + layout: grid; + grid-size: 4; + grid-gutter: 1 2; + grid-columns: 1fr; + grid-rows: 2fr 1fr 1fr 1fr 1fr 1fr; + margin: 1 2; + min-height: 25; + min-width: 26; +} + +Button { + width: 100%; + height: 100%; +} + +#numbers { + column-span: 4; + content-align: right middle; + padding: 0 1; + height: 100%; + background: $primary-lighten-2; + color: $text; +} + +#number-0 { + column-span: 2; +} diff --git a/examples/calculator.py b/examples/calculator.py index 7e514adc6..60d982f96 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,215 +1,145 @@ -""" - -A Textual app to create a fully working calculator, modelled after MacOS Calculator. - -""" - from decimal import Decimal -from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.padding import Padding -from rich.text import Text - -from textual.app import App -from textual.reactive import Reactive -from textual.views import GridView -from textual.widget import Widget -from textual.widgets import Button, ButtonPressed - -try: - from pyfiglet import Figlet -except ImportError: - print("Please install pyfiglet to run this example") - raise +from textual.app import App, ComposeResult +from textual import events +from textual.containers import Container +from textual.css.query import NoMatches +from textual.reactive import var +from textual.widgets import Button, Static -class FigletText: - """A renderable to generate figlet text that adapts to fit the container.""" +class CalculatorApp(App): + """A working 'desktop' calculator.""" - def __init__(self, text: str) -> None: - self.text = text + CSS_PATH = "calculator.css" - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - """Build a Rich renderable to render the Figlet text.""" - size = min(options.max_width / 2, options.max_height) - if size < 4: - yield Text(self.text, style="bold") - else: - if size < 7: - font_name = "mini" - elif size < 8: - font_name = "small" - elif size < 10: - font_name = "standard" - else: - font_name = "big" - font = Figlet(font=font_name, width=options.max_width) - yield Text(font.renderText(self.text).rstrip("\n"), style="bold") + numbers = var("0") + show_ac = var(True) + left = var(Decimal("0")) + right = var(Decimal("0")) + value = var("") + operator = var("plus") - -class Numbers(Widget): - """The digital display of the calculator.""" - - value = Reactive("0") - - def render(self) -> RenderableType: - """Build a Rich renderable to render the calculator display.""" - return Padding( - Align.right(FigletText(self.value), vertical="middle"), - (0, 1), - style="white on rgb(51,51,51)", - ) - - -class Calculator(GridView): - """A working calculator app.""" - - DARK = "white on rgb(51,51,51)" - LIGHT = "black on rgb(165,165,165)" - YELLOW = "white on rgb(255,159,7)" - - BUTTON_STYLES = { - "AC": LIGHT, - "C": LIGHT, - "+/-": LIGHT, - "%": LIGHT, - "/": YELLOW, - "X": YELLOW, - "-": YELLOW, - "+": YELLOW, - "=": YELLOW, + NAME_MAP = { + "asterisk": "multiply", + "slash": "divide", + "underscore": "plus-minus", + "full_stop": "point", + "plus_minus_sign": "plus-minus", + "percent_sign": "percent", + "equals_sign": "equals", + "enter": "equals", } - display = Reactive("0") - show_ac = Reactive(True) - - def watch_display(self, value: str) -> None: - """Called when self.display is modified.""" - # self.numbers is a widget that displays the calculator result - # Setting the attribute value changes the display - # This allows us to write self.display = "100" to update the display - self.numbers.value = value + def watch_numbers(self, value: str) -> None: + """Called when numbers is updated.""" + # Update the Numbers widget + self.query_one("#numbers", Static).update(value) def compute_show_ac(self) -> bool: - """Compute show_ac reactive value.""" - # Condition to show AC button over C - return self.value in ("", "0") and self.display == "0" + """Compute switch to show AC or C button""" + return self.value in ("", "0") and self.numbers == "0" def watch_show_ac(self, show_ac: bool) -> None: - """When the show_ac attribute change we need to update the buttons.""" - # Show AC and hide C or vice versa - self.c.visible = not show_ac - self.ac.visible = show_ac + """Called when show_ac changes.""" + self.query_one("#c").display = not show_ac + self.query_one("#ac").display = show_ac - def on_mount(self) -> None: - """Event when widget is first mounted (added to a parent view).""" - - # Attributes to store the current calculation - self.left = Decimal("0") - self.right = Decimal("0") - self.value = "" - self.operator = "+" - - # The calculator display - self.numbers = Numbers() - self.numbers.style_border = "bold" - - def make_button(text: str, style: str) -> Button: - """Create a button with the given Figlet label.""" - return Button(FigletText(text), style=style, name=text) - - # Make all the buttons - self.buttons = { - name: make_button(name, self.BUTTON_STYLES.get(name, self.DARK)) - for name in "+/-,%,/,7,8,9,X,4,5,6,-,1,2,3,+,.,=".split(",") - } - - # Buttons that have to be treated specially - self.zero = make_button("0", self.DARK) - self.ac = make_button("AC", self.LIGHT) - self.c = make_button("C", self.LIGHT) - self.c.visible = False - - # Set basic grid settings - self.grid.set_gap(2, 1) - self.grid.set_gutter(1) - self.grid.set_align("center", "center") - - # Create rows / columns / areas - self.grid.add_column("col", max_size=30, repeat=4) - self.grid.add_row("numbers", max_size=15) - self.grid.add_row("row", max_size=15, repeat=5) - self.grid.add_areas( - clear="col1,row1", - numbers="col1-start|col4-end,numbers", - zero="col1-start|col2-end,row5", - ) - # Place out widgets in to the layout - self.grid.place(clear=self.c) - self.grid.place( - *self.buttons.values(), clear=self.ac, numbers=self.numbers, zero=self.zero + def compose(self) -> ComposeResult: + """Add our buttons.""" + yield Container( + Static(id="numbers"), + Button("AC", id="ac", variant="primary"), + Button("C", id="c", variant="primary"), + Button("+/-", id="plus-minus", variant="primary"), + Button("%", id="percent", variant="primary"), + Button("รท", id="divide", variant="warning"), + Button("7", id="number-7"), + Button("8", id="number-8"), + Button("9", id="number-9"), + Button("ร—", id="multiply", variant="warning"), + Button("4", id="number-4"), + Button("5", id="number-5"), + Button("6", id="number-6"), + Button("-", id="minus", variant="warning"), + Button("1", id="number-1"), + Button("2", id="number-2"), + Button("3", id="number-3"), + Button("+", id="plus", variant="warning"), + Button("0", id="number-0"), + Button(".", id="point"), + Button("=", id="equals", variant="warning"), + id="calculator", ) - def handle_button_pressed(self, message: ButtonPressed) -> None: - """A message sent by the button widget""" + def on_key(self, event: events.Key) -> None: + """Called when the user presses a key.""" - assert isinstance(message.sender, Button) - button_name = message.sender.name + def press(button_id: str) -> None: + try: + self.query_one(f"#{button_id}", Button).press() + except NoMatches: + pass + self.set_focus(None) + + key = event.key + if key.isdecimal(): + press(f"number-{key}") + elif key == "c": + press("c") + press("ac") + else: + press(self.NAME_MAP.get(key, key)) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" + + button_id = event.button.id + assert button_id is not None def do_math() -> None: """Does the math: LEFT OPERATOR RIGHT""" - self.log(self.left, self.operator, self.right) try: - if self.operator == "+": + if self.operator == "plus": self.left += self.right - elif self.operator == "-": + elif self.operator == "minus": self.left -= self.right - elif self.operator == "/": + elif self.operator == "divide": self.left /= self.right - elif self.operator == "X": + elif self.operator == "multiply": self.left *= self.right - self.display = str(self.left) + self.numbers = str(self.left) self.value = "" - self.log("=", self.left) except Exception: - self.display = "Error" + self.numbers = "Error" - if button_name.isdigit(): - self.display = self.value = self.value.lstrip("0") + button_name - elif button_name == "+/-": - self.display = self.value = str(Decimal(self.value or "0") * -1) - elif button_name == "%": - self.display = self.value = str(Decimal(self.value or "0") / Decimal(100)) - elif button_name == ".": + if button_id.startswith("number-"): + number = button_id.partition("-")[-1] + self.numbers = self.value = self.value.lstrip("0") + number + elif button_id == "plus-minus": + self.numbers = self.value = str(Decimal(self.value or "0") * -1) + elif button_id == "percent": + self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) + elif button_id == "point": if "." not in self.value: - self.display = self.value = (self.value or "0") + "." - elif button_name == "AC": + self.numbers = self.value = (self.value or "0") + "." + elif button_id == "ac": self.value = "" self.left = self.right = Decimal(0) - self.operator = "+" - self.display = "0" - elif button_name == "C": + self.operator = "plus" + self.numbers = "0" + elif button_id == "c": self.value = "" - self.display = "0" - elif button_name in ("+", "-", "/", "X"): + self.numbers = "0" + elif button_id in ("plus", "minus", "divide", "multiply"): self.right = Decimal(self.value or "0") do_math() - self.operator = button_name - elif button_name == "=": + self.operator = button_id + elif button_id == "equals": if self.value: self.right = Decimal(self.value) do_math() -class CalculatorApp(App): - """The Calculator Application""" - - async def on_mount(self) -> None: - """Mount the calculator widget.""" - await self.view.dock(Calculator()) - - -CalculatorApp.run(title="Calculator Test", log="textual.log") +if __name__ == "__main__": + CalculatorApp().run() diff --git a/examples/code_browser.css b/examples/code_browser.css new file mode 100644 index 000000000..2a58b68e1 --- /dev/null +++ b/examples/code_browser.css @@ -0,0 +1,29 @@ +Screen { + background: $surface-darken-1; +} + +#tree-view { + display: none; + scrollbar-gutter: stable; + width: auto; +} + +CodeBrowser.-show-tree #tree-view { + display: block; + dock: left; + height: 100%; + max-width: 50%; + background: #151C25; +} + +DirectoryTree { + padding-right: 1; +} + +#code-view { + overflow: auto scroll; + min-width: 100%; +} +#code { + width: auto; +} diff --git a/examples/code_browser.py b/examples/code_browser.py new file mode 100644 index 000000000..90303a5d4 --- /dev/null +++ b/examples/code_browser.py @@ -0,0 +1,70 @@ +""" +Code browser example. + +Run with: + + python code_browser.py PATH + +""" + +import sys + +from rich.syntax import Syntax +from rich.traceback import Traceback +from textual.app import App, ComposeResult +from textual.containers import Container, Vertical +from textual.reactive import var +from textual.widgets import DirectoryTree, Footer, Header, Static + + +class CodeBrowser(App): + """Textual code browser app.""" + + CSS_PATH = "code_browser.css" + BINDINGS = [ + ("f", "toggle_files", "Toggle Files"), + ("q", "quit", "Quit"), + ] + + show_tree = var(True) + + def watch_show_tree(self, show_tree: bool) -> None: + """Called when show_tree is modified.""" + self.set_class(show_tree, "-show-tree") + + def compose(self) -> ComposeResult: + """Compose our UI.""" + path = "./" if len(sys.argv) < 2 else sys.argv[1] + yield Header() + yield Container( + Vertical(DirectoryTree(path), id="tree-view"), + Vertical(Static(id="code", expand=True), id="code-view"), + ) + yield Footer() + + def on_directory_tree_file_click(self, event: DirectoryTree.FileClick) -> None: + """Called when the user click a file in the directory tree.""" + code_view = self.query_one("#code", Static) + try: + syntax = Syntax.from_path( + event.path, + line_numbers=True, + word_wrap=False, + indent_guides=True, + theme="github-dark", + ) + except Exception: + code_view.update(Traceback(theme="github-dark", width=None)) + self.sub_title = "ERROR" + else: + code_view.update(syntax) + self.query_one("#code-view").scroll_home(animate=False) + self.sub_title = event.path + + def action_toggle_files(self) -> None: + """Called in response to key binding.""" + self.show_tree = not self.show_tree + + +if __name__ == "__main__": + CodeBrowser().run() diff --git a/examples/code_viewer.py b/examples/code_viewer.py deleted file mode 100644 index 4aa10fc64..000000000 --- a/examples/code_viewer.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -import sys -from rich.console import RenderableType - -from rich.syntax import Syntax -from rich.traceback import Traceback - -from textual.app import App -from textual.widgets import Header, Footer, FileClick, ScrollView, DirectoryTree - - -class MyApp(App): - """An example of a very simple Textual App""" - - async def on_load(self) -> None: - """Sent before going in to application mode.""" - - # Bind our basic keys - await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") - await self.bind("q", "quit", "Quit") - - # Get path to show - try: - self.path = sys.argv[1] - except IndexError: - self.path = os.path.abspath( - os.path.join(os.path.basename(__file__), "../../") - ) - - async def on_mount(self) -> None: - """Call after terminal goes in to application mode""" - - # Create our widgets - # In this a scroll view for the code and a directory tree - self.body = ScrollView() - self.directory = DirectoryTree(self.path, "Code") - - # Dock our widgets - await self.view.dock(Header(), edge="top") - await self.view.dock(Footer(), edge="bottom") - - # Note the directory is also in a scroll view - await self.view.dock( - ScrollView(self.directory), edge="left", size=48, name="sidebar" - ) - await self.view.dock(self.body, edge="top") - - async def handle_file_click(self, message: FileClick) -> None: - """A message sent by the directory tree when a file is clicked.""" - - syntax: RenderableType - try: - # Construct a Syntax object for the path in the message - syntax = Syntax.from_path( - message.path, - line_numbers=True, - word_wrap=True, - indent_guides=True, - theme="monokai", - ) - except Exception: - # Possibly a binary file - # For demonstration purposes we will show the traceback - syntax = Traceback(theme="monokai", width=None, show_locals=True) - self.app.sub_title = os.path.basename(message.path) - await self.body.update(syntax) - - -# Run our app class -MyApp.run(title="Code Viewer", log="textual.log") diff --git a/examples/dictionary.css b/examples/dictionary.css new file mode 100644 index 000000000..6bca8b9f5 --- /dev/null +++ b/examples/dictionary.css @@ -0,0 +1,26 @@ +Screen { + background: $panel; +} + +Input { + dock: top; + margin: 1 0; +} + +#results { + width: auto; + min-height: 100%; + padding: 0 1; +} + +#results-container { + background: $background 50%; + margin: 0 0 1 0; + height: 100%; + overflow: hidden auto; + border: tall $background; +} + +#results-container:focus { + border: tall $accent; +} diff --git a/examples/dictionary.py b/examples/dictionary.py new file mode 100644 index 000000000..737bcb283 --- /dev/null +++ b/examples/dictionary.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import asyncio + +try: + import httpx +except ImportError: + raise ImportError("Please install httpx with 'pip install httpx' ") + +from rich.markdown import Markdown + +from textual.app import App, ComposeResult +from textual.containers import Content +from textual.widgets import Static, Input + + +class DictionaryApp(App): + """Searches ab dictionary API as-you-type.""" + + CSS_PATH = "dictionary.css" + + def compose(self) -> ComposeResult: + yield Input(placeholder="Search for a word") + yield Content(Static(id="results"), id="results-container") + + def on_mount(self) -> None: + """Called when app starts.""" + # Give the input focus, so we can start typing straight away + self.query_one(Input).focus() + + async def on_input_changed(self, message: Input.Changed) -> None: + """A coroutine to handle a text changed message.""" + if message.value: + # Look up the word in the background + asyncio.create_task(self.lookup_word(message.value)) + else: + # Clear the results + self.query_one("#results", Static).update() + + async def lookup_word(self, word: str) -> None: + """Looks up a word.""" + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + async with httpx.AsyncClient() as client: + results = (await client.get(url)).json() + + if word == self.query_one(Input).value: + markdown = self.make_word_markdown(results) + self.query_one("#results", Static).update(Markdown(markdown)) + + def make_word_markdown(self, results: object) -> str: + """Convert the results in to markdown.""" + lines = [] + if isinstance(results, dict): + lines.append(f"# {results['title']}") + lines.append(results["message"]) + elif isinstance(results, list): + for result in results: + lines.append(f"# {result['word']}") + lines.append("") + for meaning in result.get("meanings", []): + lines.append(f"_{meaning['partOfSpeech']}_") + lines.append("") + for definition in meaning.get("definitions", []): + lines.append(f" - {definition['definition']}") + lines.append("---") + + return "\n".join(lines) + + +if __name__ == "__main__": + app = DictionaryApp() + app.run() diff --git a/examples/easing.py b/examples/easing.py deleted file mode 100644 index 47f85654e..000000000 --- a/examples/easing.py +++ /dev/null @@ -1,45 +0,0 @@ -from textual._easing import EASING -from textual.app import App -from textual.reactive import Reactive - -from textual.views import DockView -from textual.widgets import Placeholder, TreeControl, ScrollView, TreeClick - - -class EasingApp(App): - """An app to demonstrate easing.""" - - side = Reactive(False) - easing = Reactive("linear") - - def watch_side(self, side: bool) -> None: - """Animate when the side changes (False for left, True for right).""" - width = self.easing_view.size.width - animate_x = (width - self.placeholder.size.width) if side else 0 - self.placeholder.animate( - "layout_offset_x", animate_x, easing=self.easing, duration=1 - ) - - async def on_mount(self) -> None: - """Called when application mode is ready.""" - - self.placeholder = Placeholder() - self.easing_view = DockView() - self.placeholder.style = "white on dark_blue" - - tree = TreeControl("Easing", {}) - for easing_key in sorted(EASING.keys()): - await tree.add(tree.root.id, easing_key, {"easing": easing_key}) - await tree.root.expand() - - await self.view.dock(ScrollView(tree), edge="left", size=32) - await self.view.dock(self.easing_view) - await self.easing_view.dock(self.placeholder, edge="left", size=32) - - async def handle_tree_click(self, message: TreeClick[dict]) -> None: - """Called in response to a tree click.""" - self.easing = message.node.data.get("easing", "linear") - self.side = not self.side - - -EasingApp().run(log="textual.log") diff --git a/examples/five_by_five.css b/examples/five_by_five.css new file mode 100644 index 000000000..8901d777a --- /dev/null +++ b/examples/five_by_five.css @@ -0,0 +1,88 @@ +$animation-type: linear; +$animatin-speed: 175ms; + +Game { + align: center middle; + layers: gameplay messages; +} + +GameGrid { + layout: grid; + grid-size: 5 5; + layer: gameplay; +} + +GameHeader { + background: $primary-background; + color: $text; + height: 1; + dock: top; + layer: gameplay; +} + +GameHeader #app-title { + width: 60%; +} + +GameHeader #moves { + width: 20%; +} + +GameHeader #progress { + width: 20%; +} + +Footer { + height: 1; + dock: bottom; + layer: gameplay; +} + +GameCell { + width: 100%; + height: 100%; + background: $surface; + border: round $surface-darken-1; + transition: background $animatin-speed $animation-type, color $animatin-speed $animation-type; +} + +GameCell:hover { + background: $panel-lighten-1; + border: round $panel; +} + +GameCell.filled { + background: $secondary; + border: round $secondary-darken-1; +} + +GameCell.filled:hover { + background: $secondary-lighten-1; + border: round $secondary; +} + +WinnerMessage { + width: 50%; + height: 25%; + layer: messages; + visibility: hidden; + content-align: center middle; + text-align: center; + background: $success; + color: $text; + border: round; + padding: 2; +} + +.visible { + visibility: visible; +} + +Help { + background: $primary; + color: $text; + border: round $primary-lighten-3; + padding: 2; +} + +/* five_by_five.css ends here */ diff --git a/examples/five_by_five.md b/examples/five_by_five.md new file mode 100644 index 000000000..6fcc887bb --- /dev/null +++ b/examples/five_by_five.md @@ -0,0 +1,17 @@ +# 5x5 + +## Introduction + +An annoying puzzle for the terminal, built with +[Textual](https://www.textualize.io/). + +## Objective + +The object of the game is to fill all of the squares. When you click on a +square, it, and the squares above, below and to the sides will be toggled. + +It is possible to solve the puzzle in as few as 14 moves. + +Good luck! + +[//]: # (README.md ends here) diff --git a/examples/five_by_five.py b/examples/five_by_five.py new file mode 100644 index 000000000..84a50d639 --- /dev/null +++ b/examples/five_by_five.py @@ -0,0 +1,329 @@ +"""Simple version of 5x5, developed for/with Textual.""" + +from pathlib import Path +from typing import cast +import sys + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final + +from textual.containers import Horizontal +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widget import Widget +from textual.widgets import Footer, Button, Static +from textual.css.query import DOMQuery +from textual.reactive import reactive +from textual.binding import Binding + +from rich.markdown import Markdown + + +class Help(Screen): + """The help screen for the application.""" + + #: Bindings for the help screen. + BINDINGS = [("escape,space,q,question_mark", "pop_screen", "Close")] + + def compose(self) -> ComposeResult: + """Compose the game's help. + + Returns: + ComposeResult: The result of composing the help screen. + """ + yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) + + +class WinnerMessage(Static): + """Widget to tell the user they have won.""" + + #: The minimum number of moves you can solve the puzzle in. + MIN_MOVES: Final = 14 + + @staticmethod + def _plural(value: int) -> str: + return "" if value == 1 else "s" + + def show(self, moves: int) -> None: + """Show the winner message. + + Args: + moves (int): The number of moves required to win. + """ + self.update( + "W I N N E R !\n\n\n" + f"You solved the puzzle in {moves} move{self._plural(moves)}." + + ( + ( + f" It is possible to solve the puzzle in {self.MIN_MOVES}, " + f"you were {moves - self.MIN_MOVES} move{self._plural(moves - self.MIN_MOVES)} over." + ) + if moves > self.MIN_MOVES + else " Well done! That's the minimum number of moves to solve the puzzle!" + ) + ) + self.add_class("visible") + + def hide(self) -> None: + """Hide the winner message.""" + self.remove_class("visible") + + +class GameHeader(Widget): + """Header for the game. + + Comprises of the title (``#app-title``), the number of moves ``#moves`` + and the count of how many cells are turned on (``#progress``). + """ + + #: Keep track of how many moves the player has made. + moves = reactive(0) + + #: Keep track of how many cells are filled. + filled = reactive(0) + + def compose(self) -> ComposeResult: + """Compose the game header. + + Returns: + ComposeResult: The result of composing the game header. + """ + yield Horizontal( + Static(self.app.title, id="app-title"), + Static(id="moves"), + Static(id="progress"), + ) + + def watch_moves(self, moves: int): + """Watch the moves reactive and update when it changes. + + Args: + moves (int): The number of moves made. + """ + self.query_one("#moves", Static).update(f"Moves: {moves}") + + def watch_filled(self, filled: int): + """Watch the on-count reactive and update when it changes. + + Args: + filled (int): The number of cells that are currently on. + """ + self.query_one("#progress", Static).update(f"Filled: {filled}") + + +class GameCell(Button): + """Individual playable cell in the game.""" + + @staticmethod + def at(row: int, col: int) -> str: + """Get the ID of the cell at the given location. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + Returns: + str: A string ID for the cell. + """ + return f"cell-{row}-{col}" + + def __init__(self, row: int, col: int) -> None: + """Initialise the game cell. + + Args: + row (int): The row of the cell. + col (int): The column of the cell. + + """ + super().__init__("", id=self.at(row, col)) + self.row = row + self.col = col + + +class GameGrid(Widget): + """The main playable grid of game cells.""" + + def compose(self) -> ComposeResult: + """Compose the game grid. + + Returns: + ComposeResult: The result of composing the game grid. + """ + for row in range(Game.SIZE): + for col in range(Game.SIZE): + yield GameCell(row, col) + + +class Game(Screen): + """Main 5x5 game grid screen.""" + + #: The size of the game grid. Clue's in the name really. + SIZE = 5 + + #: The bindings for the main game grid. + BINDINGS = [ + Binding("n", "new_game", "New Game"), + Binding("question_mark", "push_screen('help')", "Help", key_display="?"), + Binding("q", "quit", "Quit"), + Binding("up,w,k", "navigate(-1,0)", "Move Up", False), + Binding("down,s,j", "navigate(1,0)", "Move Down", False), + Binding("left,a,h", "navigate(0,-1)", "Move Left", False), + Binding("right,d,l", "navigate(0,1)", "Move Right", False), + Binding("space", "move", "Toggle", False), + ] + + @property + def filled_cells(self) -> DOMQuery[GameCell]: + """DOMQuery[GameCell]: The collection of cells that are currently turned on.""" + return cast(DOMQuery[GameCell], self.query("GameCell.filled")) + + @property + def filled_count(self) -> int: + """int: The number of cells that are currently filled.""" + return len(self.filled_cells) + + @property + def all_filled(self) -> bool: + """bool: Are all the cells filled?""" + return self.filled_count == self.SIZE * self.SIZE + + def game_playable(self, playable: bool) -> None: + """Mark the game as playable, or not. + + Args: + playable (bool): Should the game currently be playable? + """ + for cell in self.query(GameCell): + cell.disabled = not playable + + def cell(self, row: int, col: int) -> GameCell: + """Get the cell at a given location. + + Args: + row (int): The row of the cell to get. + col (int): The column of the cell to get. + + Returns: + GameCell: The cell at that location. + """ + return self.query_one(f"#{GameCell.at(row,col)}", GameCell) + + def compose(self) -> ComposeResult: + """Compose the game screen. + + Returns: + ComposeResult: The result of composing the game screen. + """ + yield GameHeader() + yield GameGrid() + yield Footer() + yield WinnerMessage() + + def toggle_cell(self, row: int, col: int) -> None: + """Toggle an individual cell, but only if it's in bounds. + + If the row and column would place the cell out of bounds for the + game grid, this function call is a no-op. That is, it's safe to call + it with an invalid cell coordinate. + + Args: + row (int): The row of the cell to toggle. + col (int): The column of the cell to toggle. + """ + if 0 <= row <= (self.SIZE - 1) and 0 <= col <= (self.SIZE - 1): + self.cell(row, col).toggle_class("filled") + + _PATTERN: Final = (-1, 1, 0, 0, 0) + + def toggle_cells(self, cell: GameCell) -> None: + """Toggle a 5x5 pattern around the given cell. + + Args: + cell (GameCell): The cell to toggle the cells around. + """ + for row, col in zip(self._PATTERN, reversed(self._PATTERN)): + self.toggle_cell(cell.row + row, cell.col + col) + self.query_one(GameHeader).filled = self.filled_count + + def make_move_on(self, cell: GameCell) -> None: + """Make a move on the given cell. + + All relevant cells around the given cell are toggled as per the + game's rules. + + Args: + cell (GameCell): The cell to make a move on + """ + self.toggle_cells(cell) + self.query_one(GameHeader).moves += 1 + if self.all_filled: + self.query_one(WinnerMessage).show(self.query_one(GameHeader).moves) + self.game_playable(False) + + def on_button_pressed(self, event: GameCell.Pressed) -> None: + """React to a press of a button on the game grid. + + Args: + event (GameCell.Pressed): The event to react to. + """ + self.make_move_on(cast(GameCell, event.button)) + + def action_new_game(self) -> None: + """Start a new game.""" + self.query_one(GameHeader).moves = 0 + self.filled_cells.remove_class("filled") + self.query_one(WinnerMessage).hide() + middle = self.cell(self.SIZE // 2, self.SIZE // 2) + self.toggle_cells(middle) + self.set_focus(middle) + self.game_playable(True) + + def action_navigate(self, row: int, col: int) -> None: + """Navigate to a new cell by the given offsets. + + Args: + row (int): The row of the cell to navigate to. + col (int): The column of the cell to navigate to. + """ + if isinstance(self.focused, GameCell): + self.set_focus( + self.cell( + (self.focused.row + row) % self.SIZE, + (self.focused.col + col) % self.SIZE, + ) + ) + + def action_move(self) -> None: + """Make a move on the current cell.""" + if isinstance(self.focused, GameCell): + self.focused.press() + + def on_mount(self) -> None: + """Get the game started when we first mount.""" + self.action_new_game() + + +class FiveByFive(App[None]): + """Main 5x5 application class.""" + + #: The name of the stylesheet for the app. + CSS_PATH = "five_by_five.css" + + #: The pre-loaded screens for the application. + SCREENS = {"help": Help()} + + #: App-level bindings. + BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")] + + # Set the title + TITLE = "5x5 -- A little annoying puzzle" + + def on_mount(self) -> None: + """Set up the application on startup.""" + self.push_screen(Game()) + + +if __name__ == "__main__": + FiveByFive().run() diff --git a/examples/grid.py b/examples/grid.py deleted file mode 100644 index 3fda31d10..000000000 --- a/examples/grid.py +++ /dev/null @@ -1,34 +0,0 @@ -from textual.app import App -from textual.widgets import Placeholder - - -class GridTest(App): - async def on_mount(self) -> None: - """Make a simple grid arrangement.""" - - grid = await self.view.dock_grid(edge="left", name="left") - - grid.add_column(fraction=1, name="left", min_size=20) - grid.add_column(size=30, name="center") - grid.add_column(fraction=1, name="right") - - grid.add_row(fraction=1, name="top", min_size=2) - grid.add_row(fraction=2, name="middle") - grid.add_row(fraction=1, name="bottom") - - grid.add_areas( - area1="left,top", - area2="center,middle", - area3="left-start|right-end,bottom", - area4="right,top-start|middle-end", - ) - - grid.place( - area1=Placeholder(name="area1"), - area2=Placeholder(name="area2"), - area3=Placeholder(name="area3"), - area4=Placeholder(name="area4"), - ) - - -GridTest.run(title="Grid Test", log="textual.log") diff --git a/examples/grid_auto.py b/examples/grid_auto.py deleted file mode 100644 index 54dfb970d..000000000 --- a/examples/grid_auto.py +++ /dev/null @@ -1,22 +0,0 @@ -from textual.app import App -from textual import events -from textual.widgets import Placeholder - - -class GridTest(App): - async def on_mount(self, event: events.Mount) -> None: - """Create a grid with auto-arranging cells.""" - - grid = await self.view.dock_grid() - - grid.add_column("col", fraction=1, max_size=20) - grid.add_row("row", fraction=1, max_size=10) - grid.set_repeat(True, True) - grid.add_areas(center="col-2-start|col-4-end,row-2-start|row-3-end") - grid.set_align("stretch", "center") - - placeholders = [Placeholder() for _ in range(20)] - grid.place(*placeholders, center=Placeholder()) - - -GridTest.run(title="Grid Test", log="textual.log") diff --git a/examples/pride.py b/examples/pride.py new file mode 100644 index 000000000..9aa709216 --- /dev/null +++ b/examples/pride.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class PrideApp(App): + """Displays a pride flag.""" + + COLORS = ["red", "orange", "yellow", "green", "blue", "purple"] + + def compose(self) -> ComposeResult: + for color in self.COLORS: + stripe = Static() + stripe.styles.height = "1fr" + stripe.styles.background = color + yield stripe + + +if __name__ == "__main__": + PrideApp().run() diff --git a/examples/richreadme.md b/examples/richreadme.md deleted file mode 100644 index 880d2544d..000000000 --- a/examples/richreadme.md +++ /dev/null @@ -1,444 +0,0 @@ -[![Downloads](https://pepy.tech/badge/rich/month)](https://pepy.tech/project/rich) -[![PyPI version](https://badge.fury.io/py/rich.svg)](https://badge.fury.io/py/rich) -[![codecov](https://codecov.io/gh/willmcgugan/rich/branch/master/graph/badge.svg)](https://codecov.io/gh/willmcgugan/rich) -[![Rich blog](https://img.shields.io/badge/blog-rich%20news-yellowgreen)](https://www.willmcgugan.com/tag/rich/) -[![Twitter Follow](https://img.shields.io/twitter/follow/willmcgugan.svg?style=social)](https://twitter.com/willmcgugan) - -![Logo](https://github.com/willmcgugan/rich/raw/master/imgs/logo.svg) - -[ไธญๆ–‡ readme](https://github.com/willmcgugan/rich/blob/master/README.cn.md) โ€ข [Lengua espaรฑola readme](https://github.com/willmcgugan/rich/blob/master/README.es.md) โ€ข [Deutsche readme](https://github.com/willmcgugan/rich/blob/master/README.de.md) โ€ข [Lรคs pรฅ svenska](https://github.com/willmcgugan/rich/blob/master/README.sv.md) โ€ข [ๆ—ฅๆœฌ่ชž readme](https://github.com/willmcgugan/rich/blob/master/README.ja.md) โ€ข [ํ•œ๊ตญ์–ด readme](https://github.com/willmcgugan/rich/blob/master/README.kr.md) - -Rich is a Python library for _rich_ text and beautiful formatting in the terminal. - -The [Rich API](https://rich.readthedocs.io/en/latest/) makes it easy to add color and style to terminal output. Rich can also render pretty tables, progress bars, markdown, syntax highlighted source code, tracebacks, and more โ€” out of the box. - -![Features](https://github.com/willmcgugan/rich/raw/master/imgs/features.png) - -For a video introduction to Rich see [calmcode.io](https://calmcode.io/rich/introduction.html) by [@fishnets88](https://twitter.com/fishnets88). - -See what [people are saying about Rich](https://www.willmcgugan.com/blog/pages/post/rich-tweets/). - -## Compatibility - -Rich works with Linux, OSX, and Windows. True color / emoji works with new Windows Terminal, classic terminal is limited to 16 colors. Rich requires Python 3.6.1 or later. - -Rich works with [Jupyter notebooks](https://jupyter.org/) with no additional configuration required. - -## Installing - -Install with `pip` or your favorite PyPi package manager. - -``` -pip install rich -``` - -Run the following to test Rich output on your terminal: - -``` -python -m rich -``` - -## Rich Print - -To effortlessly add rich output to your application, you can import the [rich print](https://rich.readthedocs.io/en/latest/introduction.html#quick-start) method, which has the same signature as the builtin Python function. Try this: - -```python -from rich import print - -print("Hello, [bold magenta]World[/bold magenta]!", ":vampire:", locals()) -``` - -![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/print.png) - -## Rich REPL - -Rich can be installed in the Python REPL, so that any data structures will be pretty printed and highlighted. - -```python ->>> from rich import pretty ->>> pretty.install() -``` - -![REPL](https://github.com/willmcgugan/rich/raw/master/imgs/repl.png) - -## Using the Console - -For more control over rich terminal content, import and construct a [Console](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console) object. - -```python -from rich.console import Console - -console = Console() -``` - -The Console object has a `print` method which has an intentionally similar interface to the builtin `print` function. Here's an example of use: - -```python -console.print("Hello", "World!") -``` - -As you might expect, this will print `"Hello World!"` to the terminal. Note that unlike the builtin `print` function, Rich will word-wrap your text to fit within the terminal width. - -There are a few ways of adding color and style to your output. You can set a style for the entire output by adding a `style` keyword argument. Here's an example: - -```python -console.print("Hello", "World!", style="bold red") -``` - -The output will be something like the following: - -![Hello World](https://github.com/willmcgugan/rich/raw/master/imgs/hello_world.png) - -That's fine for styling a line of text at a time. For more finely grained styling, Rich renders a special markup which is similar in syntax to [bbcode](https://en.wikipedia.org/wiki/BBCode). Here's an example: - -```python -console.print("Where there is a [bold cyan]Will[/bold cyan] there [u]is[/u] a [i]way[/i].") -``` - -![Console Markup](https://github.com/willmcgugan/rich/raw/master/imgs/where_there_is_a_will.png) - -You can use a Console object to generate sophisticated output with minimal effort. See the [Console API](https://rich.readthedocs.io/en/latest/console.html) docs for details. - -## Rich Inspect - -Rich has an [inspect](https://rich.readthedocs.io/en/latest/reference/init.html?highlight=inspect#rich.inspect) function which can produce a report on any Python object, such as class, instance, or builtin. - -```python ->>> my_list = ["foo", "bar"] ->>> from rich import inspect ->>> inspect(my_list, methods=True) -``` - -![Log](https://github.com/willmcgugan/rich/raw/master/imgs/inspect.png) - -See the [inspect docs](https://rich.readthedocs.io/en/latest/reference/init.html#rich.inspect) for details. - -# Rich Library - -Rich contains a number of builtin _renderables_ you can use to create elegant output in your CLI and help you debug your code. - -Click the following headings for details: - -
-Log - -The Console object has a `log()` method which has a similar interface to `print()`, but also renders a column for the current time and the file and line which made the call. By default Rich will do syntax highlighting for Python structures and for repr strings. If you log a collection (i.e. a dict or a list) Rich will pretty print it so that it fits in the available space. Here's an example of some of these features. - -```python -from rich.console import Console -console = Console() - -test_data = [ - {"jsonrpc": "2.0", "method": "sum", "params": [None, 1, 2, 4, False, True], "id": "1",}, - {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]}, - {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}, -] - -def test_log(): - enabled = False - context = { - "foo": "bar", - } - movies = ["Deadpool", "Rise of the Skywalker"] - console.log("Hello from", console, "!") - console.log(test_data, log_locals=True) - - -test_log() -``` - -The above produces the following output: - -![Log](https://github.com/willmcgugan/rich/raw/master/imgs/log.png) - -Note the `log_locals` argument, which outputs a table containing the local variables where the log method was called. - -The log method could be used for logging to the terminal for long running applications such as servers, but is also a very nice debugging aid. - -
-
-Logging Handler - -You can also use the builtin [Handler class](https://rich.readthedocs.io/en/latest/logging.html) to format and colorize output from Python's logging module. Here's an example of the output: - -![Logging](https://github.com/willmcgugan/rich/raw/master/imgs/logging.png) - -
- -
-Emoji - -To insert an emoji in to console output place the name between two colons. Here's an example: - -```python ->>> console.print(":smiley: :vampire: :pile_of_poo: :thumbs_up: :raccoon:") -๐Ÿ˜ƒ ๐Ÿง› ๐Ÿ’ฉ ๐Ÿ‘ ๐Ÿฆ -``` - -Please use this feature wisely. - -
- -
-Tables - -Rich can render flexible [tables](https://rich.readthedocs.io/en/latest/tables.html) with unicode box characters. There is a large variety of formatting options for borders, styles, cell alignment etc. - -![table movie](https://github.com/willmcgugan/rich/raw/master/imgs/table_movie.gif) - -The animation above was generated with [table_movie.py](https://github.com/willmcgugan/rich/blob/master/examples/table_movie.py) in the examples directory. - -Here's a simpler table example: - -```python -from rich.console import Console -from rich.table import Table - -console = Console() - -table = Table(show_header=True, header_style="bold magenta") -table.add_column("Date", style="dim", width=12) -table.add_column("Title") -table.add_column("Production Budget", justify="right") -table.add_column("Box Office", justify="right") -table.add_row( - "Dev 20, 2019", "Star Wars: The Rise of Skywalker", "$275,000,000", "$375,126,118" -) -table.add_row( - "May 25, 2018", - "[red]Solo[/red]: A Star Wars Story", - "$275,000,000", - "$393,151,347", -) -table.add_row( - "Dec 15, 2017", - "Star Wars Ep. VIII: The Last Jedi", - "$262,000,000", - "[bold]$1,332,539,889[/bold]", -) - -console.print(table) -``` - -This produces the following output: - -![table](https://github.com/willmcgugan/rich/raw/master/imgs/table.png) - -Note that console markup is rendered in the same way as `print()` and `log()`. In fact, anything that is renderable by Rich may be included in the headers / rows (even other tables). - -The `Table` class is smart enough to resize columns to fit the available width of the terminal, wrapping text as required. Here's the same example, with the terminal made smaller than the table above: - -![table2](https://github.com/willmcgugan/rich/raw/master/imgs/table2.png) - -
- -
-Progress Bars - -Rich can render multiple flicker-free [progress](https://rich.readthedocs.io/en/latest/progress.html) bars to track long-running tasks. - -For basic usage, wrap any sequence in the `track` function and iterate over the result. Here's an example: - -```python -from rich.progress import track - -for step in track(range(100)): - do_step(step) -``` - -It's not much harder to add multiple progress bars. Here's an example taken from the docs: - -![progress](https://github.com/willmcgugan/rich/raw/master/imgs/progress.gif) - -The columns may be configured to show any details you want. Built-in columns include percentage complete, file size, file speed, and time remaining. Here's another example showing a download in progress: - -![progress](https://github.com/willmcgugan/rich/raw/master/imgs/downloader.gif) - -To try this out yourself, see [examples/downloader.py](https://github.com/willmcgugan/rich/blob/master/examples/downloader.py) which can download multiple URLs simultaneously while displaying progress. - -
- -
-Status - -For situations where it is hard to calculate progress, you can use the [status](https://rich.readthedocs.io/en/latest/reference/console.html#rich.console.Console.status) method which will display a 'spinner' animation and message. The animation won't prevent you from using the console as normal. Here's an example: - -```python -from time import sleep -from rich.console import Console - -console = Console() -tasks = [f"task {n}" for n in range(1, 11)] - -with console.status("[bold green]Working on tasks...") as status: - while tasks: - task = tasks.pop(0) - sleep(1) - console.log(f"{task} complete") -``` - -This generates the following output in the terminal. - -![status](https://github.com/willmcgugan/rich/raw/master/imgs/status.gif) - -The spinner animations were borrowed from [cli-spinners](https://www.npmjs.com/package/cli-spinners). You can select a spinner by specifying the `spinner` parameter. Run the following command to see the available values: - -``` -python -m rich.spinner -``` - -The above command generate the following output in the terminal: - -![spinners](https://github.com/willmcgugan/rich/raw/master/imgs/spinners.gif) - -
- -
-Tree - -Rich can render a [tree](https://rich.readthedocs.io/en/latest/tree.html) with guide lines. A tree is ideal for displaying a file structure, or any other hierarchical data. - -The labels of the tree can be simple text or anything else Rich can render. Run the following for a demonstration: - -``` -python -m rich.tree -``` - -This generates the following output: - -![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/tree.png) - -See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.py) example for a script that displays a tree view of any directory, similar to the linux `tree` command. - -
- -
-Columns - -Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns: - -```python -import os -import sys - -from rich import print -from rich.columns import Columns - -directory = os.listdir(sys.argv[1]) -print(Columns(directory)) -``` - -The following screenshot is the output from the [columns example](https://github.com/willmcgugan/rich/blob/master/examples/columns.py) which displays data pulled from an API in columns: - -![columns](https://github.com/willmcgugan/rich/raw/master/imgs/columns.png) - -
- -
-Markdown - -Rich can render [markdown](https://rich.readthedocs.io/en/latest/markdown.html) and does a reasonable job of translating the formatting to the terminal. - -To render markdown import the `Markdown` class and construct it with a string containing markdown code. Then print it to the console. Here's an example: - -```python -from rich.console import Console -from rich.markdown import Markdown - -console = Console() -with open("README.md") as readme: - markdown = Markdown(readme.read()) -console.print(markdown) -``` - -This will produce output something like the following: - -![markdown](https://github.com/willmcgugan/rich/raw/master/imgs/markdown.png) - -
- -
-Syntax Highlighting - -Rich uses the [pygments](https://pygments.org/) library to implement [syntax highlighting](https://rich.readthedocs.io/en/latest/syntax.html). Usage is similar to rendering markdown; construct a `Syntax` object and print it to the console. Here's an example: - -```python -from rich.console import Console -from rich.syntax import Syntax - -my_code = ''' -def iter_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 -''' -syntax = Syntax(my_code, "python", theme="monokai", line_numbers=True) -console = Console() -console.print(syntax) -``` - -This will produce the following output: - -![syntax](https://github.com/willmcgugan/rich/raw/master/imgs/syntax.png) - -
- -
-Tracebacks - -Rich can render [beautiful tracebacks](https://rich.readthedocs.io/en/latest/traceback.html) which are easier to read and show more code than standard Python tracebacks. You can set Rich as the default traceback handler so all uncaught exceptions will be rendered by Rich. - -Here's what it looks like on OSX (similar on Linux): - -![traceback](https://github.com/willmcgugan/rich/raw/master/imgs/traceback.png) - -
- -All Rich renderables make use of the [Console Protocol](https://rich.readthedocs.io/en/latest/protocol.html), which you can also use to implement your own Rich content. - -# Rich for enterprise - -Available as part of the Tidelift Subscription. - -The maintainers of Rich and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/pypi-rich?utm_source=pypi-rich&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) - -# Project using Rich - -Here are a few projects using Rich: - -- [BrancoLab/BrainRender](https://github.com/BrancoLab/BrainRender) - a python package for the visualization of three dimensional neuro-anatomical data -- [Ciphey/Ciphey](https://github.com/Ciphey/Ciphey) - Automated decryption tool -- [emeryberger/scalene](https://github.com/emeryberger/scalene) - a high-performance, high-precision CPU and memory profiler for Python -- [hedythedev/StarCli](https://github.com/hedythedev/starcli) - Browse GitHub trending projects from your command line -- [intel/cve-bin-tool](https://github.com/intel/cve-bin-tool) - This tool scans for a number of common, vulnerable components (openssl, libpng, libxml2, expat and a few others) to let you know if your system includes common libraries with known vulnerabilities. -- [nf-core/tools](https://github.com/nf-core/tools) - Python package with helper tools for the nf-core community. -- [cansarigol/pdbr](https://github.com/cansarigol/pdbr) - pdb + Rich library for enhanced debugging -- [plant99/felicette](https://github.com/plant99/felicette) - Satellite imagery for dummies. -- [seleniumbase/SeleniumBase](https://github.com/seleniumbase/SeleniumBase) - Automate & test 10x faster with Selenium & pytest. Batteries included. -- [smacke/ffsubsync](https://github.com/smacke/ffsubsync) - Automagically synchronize subtitles with video. -- [tryolabs/norfair](https://github.com/tryolabs/norfair) - Lightweight Python library for adding real-time 2D object tracking to any detector. -- [ansible/ansible-lint](https://github.com/ansible/ansible-lint) Ansible-lint checks playbooks for practices and behaviour that could potentially be improved -- [ansible-community/molecule](https://github.com/ansible-community/molecule) Ansible Molecule testing framework -- +[Many more](https://github.com/willmcgugan/rich/network/dependents)! - - diff --git a/examples/simple.py b/examples/simple.py deleted file mode 100644 index 3e7d2f246..000000000 --- a/examples/simple.py +++ /dev/null @@ -1,39 +0,0 @@ -from rich.markdown import Markdown - -from textual import events -from textual.app import App -from textual.widgets import Header, Footer, Placeholder, ScrollView - - -class MyApp(App): - """An example of a very simple Textual App""" - - async def on_load(self, event: events.Load) -> None: - """Bind keys with the app loads (but before entering application mode)""" - await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar") - await self.bind("q", "quit", "Quit") - await self.bind("escape", "quit", "Quit") - - async def on_mount(self, event: events.Mount) -> None: - """Create and dock the widgets.""" - - # A scrollview to contain the markdown file - body = ScrollView(gutter=1) - - # Header / footer / dock - await self.view.dock(Header(), edge="top") - await self.view.dock(Footer(), edge="bottom") - await self.view.dock(Placeholder(), edge="left", size=30, name="sidebar") - - # Dock the body in the remaining space - await self.view.dock(body, edge="right") - - async def get_markdown(filename: str) -> None: - with open(filename, "r", encoding="utf8") as fh: - readme = Markdown(fh.read(), hyperlinks=True) - await body.update(readme) - - await self.call_later(get_markdown, "richreadme.md") - - -MyApp.run(title="Simple App", log="textual.log") diff --git a/imgs/calculator.svg b/imgs/calculator.svg new file mode 100644 index 000000000..dbe6a8841 --- /dev/null +++ b/imgs/calculator.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CalculatorApp + + + + + + + + + + + + +0 + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + AC  +/-  %  รท  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 7  8  9  ร—  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 4  5  6  -  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 1  2  3  +  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + 0  .  =  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + + diff --git a/imgs/codebrowser.svg b/imgs/codebrowser.svg new file mode 100644 index 000000000..1181ec037 --- /dev/null +++ b/imgs/codebrowser.svg @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CodeBrowser + + + + + + + + + + +โญ˜CodeBrowser โ€” ./calculator.py15:39:34 + +๐Ÿ“‚ .  9  +โ”œโ”€โ”€ ๐Ÿ“ .mypy_cache 10 classCalculatorApp(App):โ–†โ–† +โ”œโ”€โ”€ ๐Ÿ“ __pycache__ 11 โ”‚   """A working 'desktop' calculator.""" +โ”œโ”€โ”€ ๐Ÿ“„ README.md 12 โ”‚    +โ”œโ”€โ”€ ๐Ÿ“„ borders.py 13 โ”‚   numbers = var("0") +โ”œโ”€โ”€ ๐Ÿ“„ calculator.css 14 โ”‚   show_ac = var(True)โ–ƒโ–ƒ +โ”œโ”€โ”€ ๐Ÿ“„ calculator.py 15 โ”‚   left = var(Decimal("0")) +โ”œโ”€โ”€ ๐Ÿ“„ code_browser.css 16 โ”‚   right = var(Decimal("0")) +โ”œโ”€โ”€ ๐Ÿ“„ code_browser.py 17 โ”‚   value = var("") +โ””โ”€โ”€ ๐Ÿ“„ pride.py 18 โ”‚   operator = var("plus") + 19 โ”‚    + 20 โ”‚   KEY_MAP = { + 21 โ”‚   โ”‚   "+""plus", + 22 โ”‚   โ”‚   "-""minus", + 23 โ”‚   โ”‚   ".""point", + 24 โ”‚   โ”‚   "*""multiply", + 25 โ”‚   โ”‚   "/""divide", + 26 โ”‚   โ”‚   "_""plus-minus", + 27 โ”‚   โ”‚   "%""percent", + 28 โ”‚   โ”‚   "=""equals", + 29 โ”‚   } + 30 โ”‚    + 31 โ”‚   defwatch_numbers(self, value: str) ->None: + 32 โ”‚   โ”‚   """Called when numbers is updated.""" + 33 โ”‚   โ”‚   # Update the Numbers widget + F  Toggle Files  Q  Quit  + + + diff --git a/imgs/custom.gif b/imgs/custom.gif deleted file mode 100644 index 57cdb6cf0..000000000 Binary files a/imgs/custom.gif and /dev/null differ diff --git a/imgs/custom.png b/imgs/custom.png deleted file mode 100644 index 7c35a4a4d..000000000 Binary files a/imgs/custom.png and /dev/null differ diff --git a/imgs/demo.svg b/imgs/demo.svg new file mode 100644 index 000000000..1af1922ca --- /dev/null +++ b/imgs/demo.svg @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Textual Demo + + + + + + + + + + โ–•Textual Demo10:20:03 +โ–• +         Textual Demo     โ–• +โ–•                            Widgets                                    +โ–• +โ–• +We hope you enjoy using Textual.โ–•widgets are powerful interactive components.                             +โ–• +Here are some links. You can click โ–•ur own or use the builtin widgets.                                       +these!โ–• +โ–• Text / Password input.                                                  +Textual Docsโ–•n Clickable button with a number of styles.                              +โ–•box A checkbox to toggle between states.                                 +Textual GitHub Repositoryโ–•able A spreadsheet-like widget for navigating data. Cells may contain    +โ–•or Rich renderables.                                                     +Rich GitHub Repositoryโ–•ontrol An generic tree with expandable nodes.                            +โ–•toryTree A tree of file and folders.                                     +โ–•any more planned ... +Built with โ™ฅ  by Textualize.ioโ–• +โ–•โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– +โ–•โ–‹ +โ–•โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Žโ–‹ +โ–•rnameโ–ŠUsernameโ–Žโ–‹ +โ–•โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–Žโ–‹ +โ–•โ–‹ +โ–•โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Žโ–‹โ–‡โ–‡ +โ–•swordโ–ŠPasswordโ–Žโ–‹ +โ–•โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–Žโ–‹ +โ–•โ–‹ +โ–•โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–‹ +โ–• Login โ–‹ +โ–•โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–‹ +โ–•โ–‹ +โ–•โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” +โ–• +โ–• Baz                   Foo                   Bar                   +โ–• Cell (0, 2)           Cell (0, 3)           Cell (0, 4)           +โ–• Cell (1, 2)           Cell (1, 3)           Cell (1, 4)           +โ–• Cell (2, 2)           Cell (2, 3)           Cell (2, 4)           +โ–• Cell (3, 2)           Cell (3, 3)           Cell (3, 4)           +โ–• Cell (4, 2)           Cell (4, 3)           Cell (4, 4)           +โ–• Cell (5, 2)           Cell (5, 3)           Cell (5, 4)           +โ–• Cell (6, 2)           Cell (6, 3)           Cell (6, 4)           +โ–• Cell (7, 2)           Cell (7, 3)           Cell (7, 4)           +โ–• Cell (8, 2)           Cell (8, 3)           Cell (8, 4)           +โ–• Cell (9, 2)           Cell (9, 3)           Cell (9, 4)          โ–‚โ–‚ +โ–• Cell (10, 2)          Cell (10, 3)          Cell (10, 4)          +โ–• Cell (11, 2)          Cell (11, 3)          Cell (11, 4)          +โ–• Cell (12, 2)          Cell (12, 3)          Cell (12, 4)          +โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Žโ–• Cell (13, 2)          Cell (13, 3)          Cell (13, 4)          +โ–Šโ–ŽDark modeโ–•โ–‰ +โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–Žโ–• +โ–• + CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  CTRL+Q  Quit  + + + diff --git a/imgs/stopwatch_dark.svg b/imgs/stopwatch_dark.svg new file mode 100644 index 000000000..018fe69c3 --- /dev/null +++ b/imgs/stopwatch_dark.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StopwatchApp + + + + + + + + + + +โญ˜StopwatchApp16:11:30 + + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:15.21 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:13.96 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–ƒโ–ƒ + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + D  Toggle dark mode  A  Add  R  Remove  + + + diff --git a/imgs/stopwatch_light.svg b/imgs/stopwatch_light.svg new file mode 100644 index 000000000..f2652cea1 --- /dev/null +++ b/imgs/stopwatch_light.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StopwatchApp + + + + + + + + + + +โญ˜StopwatchApp16:11:33 + + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:18.33 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Stop 00:00:17.08 +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–ƒโ–ƒ + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + +โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–” + Start 00:00:00.00 Reset  +โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ–โ– + + + D  Toggle dark mode  A  Add  R  Remove  + + + diff --git a/imgs/textual.png b/imgs/textual.png index d8a9e5975..63054eac6 100644 Binary files a/imgs/textual.png and b/imgs/textual.png differ diff --git a/imgs/widgets.png b/imgs/widgets.png deleted file mode 100644 index bca927bbc..000000000 Binary files a/imgs/widgets.png and /dev/null differ diff --git a/mkdocs.yml b/mkdocs.yml index 690375300..2f826fce3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,34 +1,189 @@ site_name: Textual -site_url: https://example.com/ +site_url: https://textual.textualize.io/ +repo_url: https://github.com/textualize/textual/ +edit_uri: edit/css/docs/ - -extra_css: - - stylesheets/custom.css - -theme: - name: "material" +nav: + - Introduction: + - "index.md" + - "getting_started.md" + - "tutorial.md" + - Guide: + - "guide/index.md" + - "guide/devtools.md" + - "guide/app.md" + - "guide/styles.md" + - "guide/CSS.md" + - "guide/queries.md" + - "guide/layout.md" + - "guide/events.md" + - "guide/input.md" + - "guide/actions.md" + - "guide/reactivity.md" + - "guide/widgets.md" + - "guide/animation.md" + - "guide/screens.md" + - "roadmap.md" + - Events: + - "events/index.md" + - "events/blur.md" + - "events/descendant_blur.md" + - "events/descendant_focus.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/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" + - "events/show.md" + - Styles: + - "styles/index.md" + - "styles/align.md" + - "styles/background.md" + - "styles/border.md" + - "styles/box_sizing.md" + - "styles/color.md" + - "styles/content_align.md" + - "styles/display.md" + - "styles/dock.md" + - "styles/grid.md" + - "styles/height.md" + - "styles/layer.md" + - "styles/layers.md" + - "styles/layout.md" + - "styles/links.md" + - "styles/margin.md" + - "styles/max_height.md" + - "styles/max_width.md" + - "styles/min_height.md" + - "styles/min_width.md" + - "styles/offset.md" + - "styles/opacity.md" + - "styles/outline.md" + - "styles/overflow.md" + - "styles/padding.md" + - "styles/scrollbar.md" + - "styles/scrollbar_gutter.md" + - "styles/scrollbar_size.md" + - "styles/text_align.md" + - "styles/text_style.md" + - "styles/text_opacity.md" + - "styles/tint.md" + - "styles/visibility.md" + - "styles/width.md" + - Widgets: + - "widgets/index.md" + - "widgets/button.md" + - "widgets/checkbox.md" + - "widgets/data_table.md" + - "widgets/footer.md" + - "widgets/header.md" + - "widgets/input.md" + - "widgets/static.md" + - "widgets/tree_control.md" + - Reference: + - "reference/app.md" + - "reference/button.md" + - "reference/color.md" + - "reference/containers.md" + - "reference/dom_node.md" + - "reference/events.md" + - "reference/footer.md" + - "reference/geometry.md" + - "reference/header.md" + - "reference/index.md" + - "reference/message_pump.md" + - "reference/message.md" + - "reference/query.md" + - "reference/reactive.md" + - "reference/screen.md" + - "reference/static.md" + - "reference/timer.md" + - "reference/widget.md" + - "help.md" markdown_extensions: - - pymdownx.highlight - - pymdownx.superfences + - 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 + - pymdownx.keys + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences: + custom_fences: + - name: textual + class: textual + format: !!python/name:textual._doc.format_svg + - name: rich + class: rich + format: !!python/name:textual._doc.rich - pymdownx.inlinehilite - pymdownx.superfences - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - pymdownx.snippets + - markdown.extensions.attr_list + +theme: + name: material + custom_dir: docs/custom_theme + features: + - navigation.tabs + - navigation.indexes + - navigation.tabs.sticky + - content.code.annotate + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + accent: purple + toggle: + icon: material/weather-sunny + name: Switch to dark modeTask was destroyed but it is pending! + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + toggle: + icon: material/weather-night + name: Switch to light mode plugins: -- search - - +- search: +- autorefs: - mkdocstrings: default_handler: python handlers: python: - rendering: + options: show_source: false - selection: filters: - "!^_" - "^__init__$" @@ -36,3 +191,7 @@ plugins: watch: - src/textual + + +extra_css: + - stylesheets/custom.css diff --git a/notes/layout.md b/notes/layout.md deleted file mode 100644 index 70cd13af0..000000000 --- a/notes/layout.md +++ /dev/null @@ -1,5 +0,0 @@ -# Layout - -## rich.layout.Layout - -The Layout class is responsible for arranging widget within a defined area. There are several concrete Layout objects with different strategies for positioning widgets. diff --git a/notes/snapshot_testing.md b/notes/snapshot_testing.md new file mode 100644 index 000000000..651adfb10 --- /dev/null +++ b/notes/snapshot_testing.md @@ -0,0 +1,43 @@ +# Snapshot Testing + + +## What is snapshot testing? + +Some tests that run for Textual are snapshot tests. +When you first run a snapshot test, a screenshot of an app is taken and saved to disk. +Next time you run it, another screenshot is taken and compared with the original one. + +If the screenshots don't match, it means something has changed. +It's up to you to tell the test system whether that change is expected or not. + +This allows us to easily catch regressions in how Textual outputs to the terminal. + +Snapshot tests run alongside normal unit tests. + +## How do I write a snapshot test? + +1. Inject the `snap_compare` fixture into your test. +2. Pass in the path to the file which contains the Textual app. + +```python +def test_grid_layout_basic_overflow(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout2.py") +``` + +`snap_compare` can take additional arguments such as `press`, which allows +you to simulate key presses etc. +See the signature of `snap_compare` for more info. + +## A snapshot test failed, what do I do? + +When a snapshot test fails, a report will be created on your machine, and you +can use this report to visually compare the output from your test with the historical output for that test. + +This report will be visible at the bottom of the terminal after the `pytest` session completes, +or, if running in CI, it will be available as an artifact attached to the GitHub Actions summary. + +If you're happy that the new output of the app is correct, you can run `pytest` with the +`--snapshot-update` flag. This flag will update the snapshots for any test that is executed in the run, +so to update a snapshot for a single test, run only that test. + +With your snapshot on disk updated to match the new output, running the test again should result in a pass. diff --git a/poetry.lock b/poetry.lock index a7dfb330e..1b4d60834 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,39 +1,83 @@ [[package]] -name = "atomicwrites" -version = "1.4.0" -description = "Atomic file writes." -category = "dev" +name = "aiohttp" +version = "3.8.3" +description = "Async http client/server framework (asyncio)" +category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" + +[package.dependencies] +aiosignal = ">=1.1.2" +async-timeout = ">=4.0.0a3,<5.0" +asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""} +attrs = ">=17.3.0" +charset-normalizer = ">=2.0,<3.0" +frozenlist = ">=1.1.1" +multidict = ">=4.5,<7.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} +yarl = ">=1.0,<2.0" + +[package.extras] +speedups = ["aiodns", "brotli", "cchardet"] + +[[package]] +name = "aiosignal" +version = "1.2.0" +description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +frozenlist = ">=1.1.0" + +[[package]] +name = "async-timeout" +version = "4.0.2" +description = "Timeout context manager for asyncio programs" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""} + +[[package]] +name = "asynctest" +version = "0.13.0" +description = "Enhance the standard unittest package with features for testing asyncio libraries" +category = "main" +optional = false +python-versions = ">=3.5" [[package]] name = "attrs" -version = "21.4.0" +version = "22.1.0" description = "Classes Without Boilerplate" -category = "dev" +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" -version = "22.3.0" +version = "22.10.0" description = "The uncompromising code formatter." category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.7" [package.dependencies] click = ">=8.0.0" mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} @@ -43,6 +87,22 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "cached-property" +version = "1.5.2" +description = "A decorator for caching properties in classes." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "cfgv" version = "3.3.1" @@ -51,11 +111,22 @@ category = "dev" optional = false python-versions = ">=3.6.1" +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "click" -version = "8.1.3" +version = "8.1.2" description = "Composable command line interface toolkit" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -65,12 +136,20 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.5" description = "Cross-platform colored terminal text." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "colored" +version = "1.4.3" +description = "Simple library for color and formatting to terminal" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "commonmark" version = "0.9.1" @@ -84,7 +163,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "6.3.2" +version = "6.5.0" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -95,7 +174,7 @@ toml = ["tomli"] [[package]] name = "distlib" -version = "0.3.4" +version = "0.3.6" description = "Distribution utilities" category = "dev" optional = false @@ -103,19 +182,27 @@ python-versions = "*" [[package]] name = "filelock" -version = "3.6.0" +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.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.7" [[package]] name = "ghp-import" -version = "2.0.2" +version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." category = "dev" optional = false @@ -127,9 +214,23 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["twine", "markdown", "flake8", "wheel"] +[[package]] +name = "griffe" +version = "0.22.2" +description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +cached-property = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +async = ["aiofiles (>=0.7,<1.0)"] + [[package]] name = "identify" -version = "2.5.0" +version = "2.5.6" description = "File identification library for Python" category = "dev" optional = false @@ -138,11 +239,19 @@ python-versions = ">=3.7" [package.extras] license = ["ukkonen"] +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + [[package]] name = "importlib-metadata" -version = "4.11.3" +version = "4.13.0" description = "Read metadata from Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -151,9 +260,9 @@ typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -165,11 +274,11 @@ python-versions = "*" [[package]] name = "jinja2" -version = "3.1.2" +version = "3.0.3" description = "A very fast and expressive template engine." category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6" [package.dependencies] MarkupSafe = ">=2.0" @@ -179,7 +288,7 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown" -version = "3.3.6" +version = "3.3.7" description = "Python implementation of Markdown." category = "dev" optional = false @@ -209,96 +318,135 @@ python-versions = ">=3.6" [[package]] name = "mkdocs" -version = "1.3.0" +version = "1.4.1" description = "Project documentation with Markdown." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -click = ">=3.3" +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} ghp-import = ">=1.0" -importlib-metadata = ">=4.3" -Jinja2 = ">=2.10.2" -Markdown = ">=3.2.1" +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.2.1,<3.4" mergedeep = ">=1.3.4" packaging = ">=20.5" -PyYAML = ">=3.10" +pyyaml = ">=5.1" pyyaml-env-tag = ">=0.1" +typing-extensions = {version = ">=3.10", markers = "python_version < \"3.8\""} watchdog = ">=2.0" [package.extras] +min-versions = ["watchdog (==2.0)", "typing-extensions (==3.10)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "packaging (==20.5)", "mergedeep (==1.3.4)", "markupsafe (==2.0.1)", "markdown (==3.2.1)", "jinja2 (==2.11.1)", "importlib-metadata (==4.3)", "ghp-import (==1.0)", "colorama (==0.4)", "click (==7.0)", "babel (==2.9.0)"] i18n = ["babel (>=2.9.0)"] [[package]] name = "mkdocs-autorefs" -version = "0.2.1" +version = "0.4.1" description = "Automatically link across pages in MkDocs." category = "dev" optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.7" [package.dependencies] -Markdown = ">=3.3,<4.0" -mkdocs = ">=1.1,<2.0" +Markdown = ">=3.3" +mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "7.3.0" -description = "A Material Design theme for MkDocs" +version = "8.5.7" +description = "Documentation that simply works" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.7" [package.dependencies] +jinja2 = ">=3.0.2" markdown = ">=3.2" -mkdocs = ">=1.2.2" -mkdocs-material-extensions = ">=1.0" -Pygments = ">=2.4" -pymdown-extensions = ">=7.0" +mkdocs = ">=1.4.0" +mkdocs-material-extensions = ">=1.0.3" +pygments = ">=2.12" +pymdown-extensions = ">=9.4" +requests = ">=2.26" [[package]] name = "mkdocs-material-extensions" -version = "1.0.3" -description = "Extension pack for Python Markdown." +version = "1.1" +description = "Extension pack for Python Markdown and MkDocs Material." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mkdocstrings" -version = "0.15.2" +version = "0.19.0" description = "Automatic documentation from sources, for MkDocs." category = "dev" optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.7" [package.dependencies] -Jinja2 = ">=2.11.1,<4.0" -Markdown = ">=3.3,<4.0" -MarkupSafe = ">=1.1,<3.0" -mkdocs = ">=1.1.1,<2.0.0" -mkdocs-autorefs = ">=0.1,<0.3" -pymdown-extensions = ">=6.3,<9.0" -pytkdocs = ">=0.2.0,<0.12.0" +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.2" +mkdocs-autorefs = ">=0.3.1" +mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""} +pymdown-extensions = ">=6.3" + +[package.extras] +crystal = ["mkdocstrings-crystal (>=0.3.4)"] +python = ["mkdocstrings-python (>=0.5.2)"] +python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] + +[[package]] +name = "mkdocstrings-python" +version = "0.7.1" +description = "A Python handler for mkdocstrings." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +griffe = ">=0.11.1" +mkdocstrings = ">=0.19" + +[[package]] +name = "msgpack" +version = "1.0.4" +description = "MessagePack serializer" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "multidict" +version = "6.0.2" +description = "multidict implementation" +category = "main" +optional = false +python-versions = ">=3.7" [[package]] name = "mypy" -version = "0.910" +version = "0.982" description = "Optional static typing for Python" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [package.dependencies] -mypy-extensions = ">=0.4.3,<0.5.0" -toml = "*" -typed-ast = {version = ">=1.4.0,<1.5.0", markers = "python_version < \"3.8\""} -typing-extensions = ">=3.7.4" +mypy-extensions = ">=0.4.3" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.10" [package.extras] dmypy = ["psutil (>=4.0)"] -python2 = ["typed-ast (>=1.4.0,<1.5.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] [[package]] name = "mypy-extensions" @@ -308,13 +456,21 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "nodeenv" -version = "1.6.0" +version = "1.7.0" description = "Node.js virtual environment builder" category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" [[package]] name = "packaging" @@ -329,11 +485,11 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pathspec" -version = "0.9.0" +version = "0.10.1" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.7" [[package]] name = "platformdirs" @@ -364,7 +520,7 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "2.18.1" +version = "2.20.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -389,26 +545,29 @@ 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 = "8.2" +version = "9.6" description = "Extension pack for Python Markdown." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -Markdown = ">=3.2" +markdown = ">=3.2" [[package]] name = "pyparsing" -version = "3.0.8" +version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false @@ -419,14 +578,13 @@ diagrams = ["railroad-diagrams", "jinja2"] [[package]] name = "pytest" -version = "6.2.5" +version = "7.1.3" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} @@ -434,10 +592,41 @@ iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" py = ">=1.8.2" -toml = "*" +tomli = ">=1.0.0" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-aiohttp" +version = "1.0.4" +description = "Pytest plugin for aiohttp support" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +aiohttp = ">=3.8.1" +pytest = ">=6.1.0" +pytest-asyncio = ">=0.17.2" + +[package.extras] +testing = ["coverage (==6.2)", "mypy (==0.931)"] + +[[package]] +name = "pytest-asyncio" +version = "0.20.1" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +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)"] [[package]] name = "pytest-cov" @@ -466,17 +655,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" [package.dependencies] six = ">=1.5" -[[package]] -name = "pytkdocs" -version = "0.9.0" -description = "Load Python objects documentation." -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" - -[package.extras] -tests = ["coverage (>=5.2.1,<6.0.0)", "invoke (>=1.4.1,<2.0.0)", "marshmallow (>=3.5.2,<4.0.0)", "mypy (>=0.782,<0.783)", "pydantic (>=1.5.1,<2.0.0)", "pytest (>=6.0.1,<7.0.0)", "pytest-cov (>=2.10.1,<3.0.0)", "pytest-randomly (>=3.4.1,<4.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=2.1.0,<3.0.0)"] - [[package]] name = "pyyaml" version = "6.0" @@ -496,9 +674,27 @@ python-versions = ">=3.6" [package.dependencies] pyyaml = "*" +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "rich" -version = "12.3.0" +version = "12.6.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false @@ -520,6 +716,29 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "syrupy" +version = "3.0.2" +description = "Pytest Snapshot Test Utility" +category = "dev" +optional = false +python-versions = ">=3.7,<4" + +[package.dependencies] +colored = ">=1.3.92,<2.0.0" +pytest = ">=5.1.0,<8.0.0" + +[[package]] +name = "time-machine" +version = "2.8.2" +description = "Travel through time in your tests." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +python-dateutil = "*" + [[package]] name = "toml" version = "0.10.2" @@ -538,42 +757,54 @@ python-versions = ">=3.7" [[package]] name = "typed-ast" -version = "1.4.3" +version = "1.5.4" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "typing-extensions" -version = "4.2.0" +version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" category = "main" optional = false python-versions = ">=3.7" +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "virtualenv" -version = "20.14.1" +version = "20.16.5" 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" -version = "2.1.7" +version = "2.1.9" description = "Filesystem events monitoring" category = "dev" optional = false @@ -582,147 +813,101 @@ python-versions = ">=3.6" [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "yarl" +version = "1.8.1" +description = "Yet another URL library" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +idna = ">=2.0" +multidict = ">=4.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + [[package]] name = "zipp" -version = "3.8.0" +version = "3.9.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" +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 (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + +[extras] +dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "64a233663b86b9bcda33a8fef2e4d3c732905b688c0e655eaaa8c7845ee08096" +content-hash = "84203bb5193474eb9204f4f808739cb25e61f02a38d0062ea2ea71d3703573c1" [metadata.files] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +aiohttp = [] +aiosignal = [ + {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, + {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, ] -attrs = [ - {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, - {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +async-timeout = [ + {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, + {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, ] -black = [ - {file = "black-22.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2497f9c2386572e28921fa8bec7be3e51de6801f7459dffd6e62492531c47e09"}, - {file = "black-22.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5795a0375eb87bfe902e80e0c8cfaedf8af4d49694d69161e5bd3206c18618bb"}, - {file = "black-22.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3556168e2e5c49629f7b0f377070240bd5511e45e25a4497bb0073d9dda776a"}, - {file = "black-22.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67c8301ec94e3bcc8906740fe071391bce40a862b7be0b86fb5382beefecd968"}, - {file = "black-22.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:fd57160949179ec517d32ac2ac898b5f20d68ed1a9c977346efbac9c2f1e779d"}, - {file = "black-22.3.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:cc1e1de68c8e5444e8f94c3670bb48a2beef0e91dddfd4fcc29595ebd90bb9ce"}, - {file = "black-22.3.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2fc92002d44746d3e7db7cf9313cf4452f43e9ea77a2c939defce3b10b5c82"}, - {file = "black-22.3.0-cp36-cp36m-win_amd64.whl", hash = "sha256:a6342964b43a99dbc72f72812bf88cad8f0217ae9acb47c0d4f141a6416d2d7b"}, - {file = "black-22.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:328efc0cc70ccb23429d6be184a15ce613f676bdfc85e5fe8ea2a9354b4e9015"}, - {file = "black-22.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06f9d8846f2340dfac80ceb20200ea5d1b3f181dd0556b47af4e8e0b24fa0a6b"}, - {file = "black-22.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:ad4efa5fad66b903b4a5f96d91461d90b9507a812b3c5de657d544215bb7877a"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8477ec6bbfe0312c128e74644ac8a02ca06bcdb8982d4ee06f209be28cdf163"}, - {file = "black-22.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:637a4014c63fbf42a692d22b55d8ad6968a946b4a6ebc385c5505d9625b6a464"}, - {file = "black-22.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:863714200ada56cbc366dc9ae5291ceb936573155f8bf8e9de92aef51f3ad0f0"}, - {file = "black-22.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dbe6e6d2988049b4655b2b739f98785a884d4d6b85bc35133a8fb9a2233176"}, - {file = "black-22.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:cee3e11161dde1b2a33a904b850b0899e0424cc331b7295f2a9698e79f9a69a0"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5891ef8abc06576985de8fa88e95ab70641de6c1fca97e2a15820a9b69e51b20"}, - {file = "black-22.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:30d78ba6bf080eeaf0b7b875d924b15cd46fec5fd044ddfbad38c8ea9171043a"}, - {file = "black-22.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ee8f1f7228cce7dffc2b464f07ce769f478968bfb3dd1254a4c2eeed84928aad"}, - {file = "black-22.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ee227b696ca60dd1c507be80a6bc849a5a6ab57ac7352aad1ffec9e8b805f21"}, - {file = "black-22.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:9b542ced1ec0ceeff5b37d69838106a6348e60db7b8fdd245294dc1d26136265"}, - {file = "black-22.3.0-py3-none-any.whl", hash = "sha256:bc58025940a896d7e5356952228b68f793cf5fcb342be703c3a2669a1488cb72"}, - {file = "black-22.3.0.tar.gz", hash = "sha256:35020b8886c022ced9282b51b5a875b6d1ab0c387b31a065b84db7c33085ca79"}, +asynctest = [ + {file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"}, + {file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"}, ] +attrs = [] +black = [] +cached-property = [ + {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, + {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, +] +certifi = [] 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 = [] click = [ - {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, - {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, + {file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, + {file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, ] colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, + {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, + {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] +colored = [] 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.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, - {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, - {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, - {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, - {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, - {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, - {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, - {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, - {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, - {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, - {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, - {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, - {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, - {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, - {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, - {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, - {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, - {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, - {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, - {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, - {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, - {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, - {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, -] -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.6.0-py3-none-any.whl", hash = "sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"}, - {file = "filelock-3.6.0.tar.gz", hash = "sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85"}, -] +coverage = [] +distlib = [] +filelock = [] +frozenlist = [] ghp-import = [ - {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, - {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, -] -identify = [ - {file = "identify-2.5.0-py2.py3-none-any.whl", hash = "sha256:3acfe15a96e4272b4ec5662ee3e231ceba976ef63fd9980ed2ce9cc415df393f"}, - {file = "identify-2.5.0.tar.gz", hash = "sha256:c83af514ea50bf2be2c4a3f2fb349442b59dc87284558ae9ff54191bff3541d2"}, -] -importlib-metadata = [ - {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, - {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, + {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 = [] +idna = [] +importlib-metadata = [] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] markdown = [ - {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, - {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, + {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, + {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, ] markupsafe = [ {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, @@ -770,67 +955,151 @@ 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.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"}, - {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, -] -mkdocs-material = [ - {file = "mkdocs-material-7.3.0.tar.gz", hash = "sha256:07db0580fa96c3473aee99ec3fb4606a1a5a1e4f4467e64c0cd1ba8da5b6476e"}, - {file = "mkdocs_material-7.3.0-py2.py3-none-any.whl", hash = "sha256:b183c27dc0f44e631bbc32c51057f61a3e2ba8b3c1080e59f944167eeba9ff1d"}, -] -mkdocs-material-extensions = [ - {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, - {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, + {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, + {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] +mkdocs-material = [] +mkdocs-material-extensions = [] mkdocstrings = [ - {file = "mkdocstrings-0.15.2-py3-none-any.whl", hash = "sha256:8d6cbe64c07ae66739010979ca01d49dd2f64d1a45009f089d217b9cd2a65e36"}, - {file = "mkdocstrings-0.15.2.tar.gz", hash = "sha256:c2fee9a3a644647c06eb2044fdfede1073adfd1a55bf6752005d3db10705fe73"}, + {file = "mkdocstrings-0.19.0-py3-none-any.whl", hash = "sha256:3217d510d385c961f69385a670b2677e68e07b5fea4a504d86bf54c006c87c7d"}, + {file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"}, ] -mypy = [ - {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, - {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, - {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, - {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, - {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, - {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, - {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, - {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, - {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, - {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, - {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, - {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, - {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, - {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, - {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, - {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, - {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, - {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, - {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, - {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, - {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, - {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, - {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, +mkdocstrings-python = [ + {file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"}, + {file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"}, ] +msgpack = [ + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:112b0f93202d7c0fef0b7810d465fde23c746a2d482e1e2de2aafd2ce1492c88"}, + {file = "msgpack-1.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:002b5c72b6cd9b4bafd790f364b8480e859b4712e91f43014fe01e4f957b8467"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35bc0faa494b0f1d851fd29129b2575b2e26d41d177caacd4206d81502d4c6a6"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4733359808c56d5d7756628736061c432ded018e7a1dff2d35a02439043321aa"}, + {file = "msgpack-1.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb514ad14edf07a1dbe63761fd30f89ae79b42625731e1ccf5e1f1092950eaa6"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c23080fdeec4716aede32b4e0ef7e213c7b1093eede9ee010949f2a418ced6ba"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:49565b0e3d7896d9ea71d9095df15b7f75a035c49be733051c34762ca95bbf7e"}, + {file = "msgpack-1.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:aca0f1644d6b5a73eb3e74d4d64d5d8c6c3d577e753a04c9e9c87d07692c58db"}, + {file = "msgpack-1.0.4-cp310-cp310-win32.whl", hash = "sha256:0dfe3947db5fb9ce52aaea6ca28112a170db9eae75adf9339a1aec434dc954ef"}, + {file = "msgpack-1.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dea20515f660aa6b7e964433b1808d098dcfcabbebeaaad240d11f909298075"}, + {file = "msgpack-1.0.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e83f80a7fec1a62cf4e6c9a660e39c7f878f603737a0cdac8c13131d11d97f52"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c11a48cf5e59026ad7cb0dc29e29a01b5a66a3e333dc11c04f7e991fc5510a9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1276e8f34e139aeff1c77a3cefb295598b504ac5314d32c8c3d54d24fadb94c9"}, + {file = "msgpack-1.0.4-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c9566f2c39ccced0a38d37c26cc3570983b97833c365a6044edef3574a00c08"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fcb8a47f43acc113e24e910399376f7277cf8508b27e5b88499f053de6b115a8"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:76ee788122de3a68a02ed6f3a16bbcd97bc7c2e39bd4d94be2f1821e7c4a64e6"}, + {file = "msgpack-1.0.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:0a68d3ac0104e2d3510de90a1091720157c319ceeb90d74f7b5295a6bee51bae"}, + {file = "msgpack-1.0.4-cp36-cp36m-win32.whl", hash = "sha256:85f279d88d8e833ec015650fd15ae5eddce0791e1e8a59165318f371158efec6"}, + {file = "msgpack-1.0.4-cp36-cp36m-win_amd64.whl", hash = "sha256:c1683841cd4fa45ac427c18854c3ec3cd9b681694caf5bff04edb9387602d661"}, + {file = "msgpack-1.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a75dfb03f8b06f4ab093dafe3ddcc2d633259e6c3f74bb1b01996f5d8aa5868c"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9667bdfdf523c40d2511f0e98a6c9d3603be6b371ae9a238b7ef2dc4e7a427b0"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11184bc7e56fd74c00ead4f9cc9a3091d62ecb96e97653add7a879a14b003227"}, + {file = "msgpack-1.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac5bd7901487c4a1dd51a8c58f2632b15d838d07ceedaa5e4c080f7190925bff"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1e91d641d2bfe91ba4c52039adc5bccf27c335356055825c7f88742c8bb900dd"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2a2df1b55a78eb5f5b7d2a4bb221cd8363913830145fad05374a80bf0877cb1e"}, + {file = "msgpack-1.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:545e3cf0cf74f3e48b470f68ed19551ae6f9722814ea969305794645da091236"}, + {file = "msgpack-1.0.4-cp37-cp37m-win32.whl", hash = "sha256:2cc5ca2712ac0003bcb625c96368fd08a0f86bbc1a5578802512d87bc592fe44"}, + {file = "msgpack-1.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:eba96145051ccec0ec86611fe9cf693ce55f2a3ce89c06ed307de0e085730ec1"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7760f85956c415578c17edb39eed99f9181a48375b0d4a94076d84148cf67b2d"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:449e57cc1ff18d3b444eb554e44613cffcccb32805d16726a5494038c3b93dab"}, + {file = "msgpack-1.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d603de2b8d2ea3f3bcb2efe286849aa7a81531abc52d8454da12f46235092bcb"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f5d88c99f64c456413d74a975bd605a9b0526293218a3b77220a2c15458ba9"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916c78f33602ecf0509cc40379271ba0f9ab572b066bd4bdafd7434dee4bc6e"}, + {file = "msgpack-1.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81fc7ba725464651190b196f3cd848e8553d4d510114a954681fd0b9c479d7e1"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d5b5b962221fa2c5d3a7f8133f9abffc114fe218eb4365e40f17732ade576c8e"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:77ccd2af37f3db0ea59fb280fa2165bf1b096510ba9fe0cc2bf8fa92a22fdb43"}, + {file = "msgpack-1.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b17be2478b622939e39b816e0aa8242611cc8d3583d1cd8ec31b249f04623243"}, + {file = "msgpack-1.0.4-cp38-cp38-win32.whl", hash = "sha256:2bb8cdf50dd623392fa75525cce44a65a12a00c98e1e37bf0fb08ddce2ff60d2"}, + {file = "msgpack-1.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:26b8feaca40a90cbe031b03d82b2898bf560027160d3eae1423f4a67654ec5d6"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:462497af5fd4e0edbb1559c352ad84f6c577ffbbb708566a0abaaa84acd9f3ae"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2999623886c5c02deefe156e8f869c3b0aaeba14bfc50aa2486a0415178fce55"}, + {file = "msgpack-1.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f0029245c51fd9473dc1aede1160b0a29f4a912e6b1dd353fa6d317085b219da"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed6f7b854a823ea44cf94919ba3f727e230da29feb4a99711433f25800cf747f"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0df96d6eaf45ceca04b3f3b4b111b86b33785683d682c655063ef8057d61fd92"}, + {file = "msgpack-1.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a4192b1ab40f8dca3f2877b70e63799d95c62c068c84dc028b40a6cb03ccd0f"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e3590f9fb9f7fbc36df366267870e77269c03172d086fa76bb4eba8b2b46624"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:1576bd97527a93c44fa856770197dec00d223b0b9f36ef03f65bac60197cedf8"}, + {file = "msgpack-1.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:63e29d6e8c9ca22b21846234913c3466b7e4ee6e422f205a2988083de3b08cae"}, + {file = "msgpack-1.0.4-cp39-cp39-win32.whl", hash = "sha256:fb62ea4b62bfcb0b380d5680f9a4b3f9a2d166d9394e9bbd9666c0ee09a3645c"}, + {file = "msgpack-1.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:4d5834a2a48965a349da1c5a79760d94a1a0172fbb5ab6b5b33cbf8447e109ce"}, + {file = "msgpack-1.0.4.tar.gz", hash = "sha256:f5d869c18f030202eb412f08b28d2afeea553d6613aee89e200d7aca7ef01f5f"}, +] +multidict = [ + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"}, + {file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"}, + {file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"}, + {file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"}, + {file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"}, + {file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"}, + {file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"}, + {file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"}, + {file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"}, + {file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"}, + {file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"}, + {file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"}, + {file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"}, + {file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"}, + {file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"}, + {file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"}, + {file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"}, + {file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"}, + {file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"}, + {file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"}, + {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, + {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, +] +mypy = [] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +nanoid = [] nodeenv = [ - {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, - {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, + {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, + {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, ] packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] -pathspec = [ - {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, - {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, -] +pathspec = [] platformdirs = [ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, @@ -839,30 +1108,23 @@ pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] -pre-commit = [ - {file = "pre_commit-2.18.1-py2.py3-none-any.whl", hash = "sha256:02226e69564ebca1a070bd1f046af866aa1c318dbc430027c50ab832ed2b73f2"}, - {file = "pre_commit-2.18.1.tar.gz", hash = "sha256:5d445ee1fa8738d506881c5d84f83c62bb5be6b2838e32207433647e8e5ebe10"}, -] +pre-commit = [] 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"}, -] -pymdown-extensions = [ - {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, - {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, -] +pygments = [] +pymdown-extensions = [] pyparsing = [ - {file = "pyparsing-3.0.8-py3-none-any.whl", hash = "sha256:ef7b523f6356f763771559412c0d7134753f037822dad1b16945b7b846f7ad06"}, - {file = "pyparsing-3.0.8.tar.gz", hash = "sha256:7bf433498c016c4314268d95df76c81b842a4cb2b276fa3312cfb1e1d85f6954"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] -pytest = [ - {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, - {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +pytest = [] +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 = [] 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"}, @@ -871,10 +1133,6 @@ python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] -pytkdocs = [ - {file = "pytkdocs-0.9.0-py3-none-any.whl", hash = "sha256:12ed87d71b3518301c7b8c12c1a620e4b481a9d2fca1038aea665955000fad7f"}, - {file = "pytkdocs-0.9.0.tar.gz", hash = "sha256:c8c39acb63824f69c3f6f58b3aed6ae55250c35804b76fd0cba09d5c11be13da"}, -] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, @@ -914,14 +1172,14 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -rich = [ - {file = "rich-12.3.0-py3-none-any.whl", hash = "sha256:0eb63013630c6ee1237e0e395d51cb23513de6b5531235e33889e8842bdf3a6f"}, - {file = "rich-12.3.0.tar.gz", hash = "sha256:7e8700cda776337036a712ff0495b04052fb5f957c7dfb8df997f88350044b64"}, -] +requests = [] +rich = [] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +syrupy = [] +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"}, @@ -931,72 +1189,60 @@ tomli = [ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, -] -typing-extensions = [ - {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"}, - {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"}, -] -virtualenv = [ - {file = "virtualenv-20.14.1-py2.py3-none-any.whl", hash = "sha256:e617f16e25b42eb4f6e74096b9c9e37713cf10bf30168fb4a739f3fa8f898a3a"}, - {file = "virtualenv-20.14.1.tar.gz", hash = "sha256:ef589a79795589aada0c1c5b319486797c03b67ac3984c48c669c0e4f50df3a5"}, + {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, + {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, + {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, + {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, + {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, + {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, + {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, + {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, + {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, + {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, ] +typing-extensions = [] +urllib3 = [] +virtualenv = [] watchdog = [ - {file = "watchdog-2.1.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:177bae28ca723bc00846466016d34f8c1d6a621383b6caca86745918d55c7383"}, - {file = "watchdog-2.1.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1d1cf7dfd747dec519486a98ef16097e6c480934ef115b16f18adb341df747a4"}, - {file = "watchdog-2.1.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7f14ce6adea2af1bba495acdde0e510aecaeb13b33f7bd2f6324e551b26688ca"}, - {file = "watchdog-2.1.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4d0e98ac2e8dd803a56f4e10438b33a2d40390a72750cff4939b4b274e7906fa"}, - {file = "watchdog-2.1.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:81982c7884aac75017a6ecc72f1a4fedbae04181a8665a34afce9539fc1b3fab"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0b4a1fe6201c6e5a1926f5767b8664b45f0fcb429b62564a41f490ff1ce1dc7a"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6e6ae29b72977f2e1ee3d0b760d7ee47896cb53e831cbeede3e64485e5633cc8"}, - {file = "watchdog-2.1.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b9777664848160449e5b4260e0b7bc1ae0f6f4992a8b285db4ec1ef119ffa0e2"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:19b36d436578eb437e029c6b838e732ed08054956366f6dd11875434a62d2b99"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b61acffaf5cd5d664af555c0850f9747cc5f2baf71e54bbac164c58398d6ca7b"}, - {file = "watchdog-2.1.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e877c70245424b06c41ac258023ea4bd0c8e4ff15d7c1368f17cd0ae6e351dd"}, - {file = "watchdog-2.1.7-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d802d65262a560278cf1a65ef7cae4e2bc7ecfe19e5451349e4c67e23c9dc420"}, - {file = "watchdog-2.1.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b3750ee5399e6e9c69eae8b125092b871ee9e2fcbd657a92747aea28f9056a5c"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_aarch64.whl", hash = "sha256:ed6d9aad09a2a948572224663ab00f8975fae242aa540509737bb4507133fa2d"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_armv7l.whl", hash = "sha256:b26e13e8008dcaea6a909e91d39b629a39635d1a8a7239dd35327c74f4388601"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_i686.whl", hash = "sha256:0908bb50f6f7de54d5d31ec3da1654cb7287c6b87bce371954561e6de379d690"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_ppc64.whl", hash = "sha256:bdcbf75580bf4b960fb659bbccd00123d83119619195f42d721e002c1621602f"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:81a5861d0158a7e55fe149335fb2bbfa6f48cbcbd149b52dbe2cd9a544034bbd"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_s390x.whl", hash = "sha256:03b43d583df0f18782a0431b6e9e9965c5b3f7cf8ec36a00b930def67942c385"}, - {file = "watchdog-2.1.7-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ae934e34c11aa8296c18f70bf66ed60e9870fcdb4cc19129a04ca83ab23e7055"}, - {file = "watchdog-2.1.7-py3-none-win32.whl", hash = "sha256:49639865e3db4be032a96695c98ac09eed39bbb43fe876bb217da8f8101689a6"}, - {file = "watchdog-2.1.7-py3-none-win_amd64.whl", hash = "sha256:340b875aecf4b0e6672076a6f05cfce6686935559bb6d34cebedee04126a9566"}, - {file = "watchdog-2.1.7-py3-none-win_ia64.whl", hash = "sha256:351e09b6d9374d5bcb947e6ac47a608ec25b9d70583e9db00b2fcdb97b00b572"}, - {file = "watchdog-2.1.7.tar.gz", hash = "sha256:3fd47815353be9c44eebc94cc28fe26b2b0c5bd889dafc4a5a7cbdf924143480"}, -] -zipp = [ - {file = "zipp-3.8.0-py3-none-any.whl", hash = "sha256:c4f6e5bbf48e74f7a38e7cc5b0480ff42b0ae5178957d564d18932525d5cf099"}, - {file = "zipp-3.8.0.tar.gz", hash = "sha256:56bf8aadb83c24db6c4b577e13de374ccfb67da2078beba1d037c17980bf43ad"}, + {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, + {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"}, + {file = "watchdog-2.1.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"}, + {file = "watchdog-2.1.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591"}, + {file = "watchdog-2.1.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3"}, + {file = "watchdog-2.1.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7"}, + {file = "watchdog-2.1.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd"}, + {file = "watchdog-2.1.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3"}, + {file = "watchdog-2.1.9-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d"}, + {file = "watchdog-2.1.9-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_aarch64.whl", hash = "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_armv7l.whl", hash = "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_i686.whl", hash = "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64.whl", hash = "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_s390x.whl", hash = "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1"}, + {file = "watchdog-2.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6"}, + {file = "watchdog-2.1.9-py3-none-win32.whl", hash = "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1"}, + {file = "watchdog-2.1.9-py3-none-win_amd64.whl", hash = "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c"}, + {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"}, + {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"}, ] +yarl = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml index 6484cb0e2..962dd329d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [tool.poetry] name = "textual" -version = "0.1.18" -homepage = "https://github.com/willmcgugan/textual" -description = "Text User Interface using Rich" -readme = "README.md" -authors = ["Will McGugan "] +version = "0.2.0" +homepage = "https://github.com/Textualize/textual" +description = "Modern Text User Interface framework" +authors = ["Will McGugan "] license = "MIT" +readme = "README.md" classifiers = [ "Development Status :: 1 - Planning", "Environment :: Console", @@ -18,24 +18,53 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Typing :: Typed", ] +include = [ + "src/textual/py.typed" +] +[tool.poetry.scripts] +textual = "textual.cli.cli:run" [tool.poetry.dependencies] python = "^3.7" -rich = "^12.3.0" -#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} -typing-extensions = { version = ">=4.0.0, <5.0", python = "<3.9" } +rich = "^12.6.0" +#rich = {path="../rich", develop=true} +importlib-metadata = "^4.11.3" +typing-extensions = { version = "^4.0.0", python = "<3.10" } + +# Dependencies below are required for devtools only +aiohttp = { version = "^3.8.1", optional = true } +click = {version = "8.1.2", optional = true} +msgpack = { version = "^1.0.3", optional = true } +nanoid = "^2.0.0" + +[tool.poetry.extras] +dev = ["aiohttp", "click", "msgpack"] [tool.poetry.dev-dependencies] - -pytest = "^6.2.3" +pytest = "^7.1.3" black = "^22.3.0" -mypy = "^0.910" +mypy = "^0.982" pytest-cov = "^2.12.1" -mkdocs = "^1.2.1" -mkdocstrings = "^0.15.2" -mkdocs-material = "^7.1.10" +mkdocs = "^1.3.0" +mkdocstrings = {extras = ["python"], version = "^0.19.0"} +mkdocs-material = "^8.2.15" pre-commit = "^2.13.0" +pytest-aiohttp = "^1.0.4" +time-machine = "^2.6.0" +Jinja2 = "<3.1.0" +syrupy = "^3.0.0" + +[tool.black] +includes = "src" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "--strict-markers" +markers = [ + "integration_test: marks tests as slow integration tests (deselect with '-m \"not integration_test\"')", +] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/reference/README.md b/reference/README.md new file mode 100644 index 000000000..62f72ad39 --- /dev/null +++ b/reference/README.md @@ -0,0 +1 @@ +Contains private docs, mainly for the developers reference diff --git a/reference/_color_system.md b/reference/_color_system.md new file mode 100644 index 000000000..7915c39f9 --- /dev/null +++ b/reference/_color_system.md @@ -0,0 +1,73 @@ +_Note: This is kind of a living document, which might not form part of the user-facing documentation._ + +# Textual Color System + +Textual's color system is a palette of colors for building TUIs, and a set of guidelines for how they should be used. Based loosely on Google's Material color system, the Textual color system ensures that elements in the TUI look aesthetically pleasing while maximizing legibility + +## The colors + +There are 10 base colors specified in the Textual Color System. Although it is unlikely that all will need to be specified, since some may be derived from others, and some defaults may not need to be changed. + +A dark mode is automatically derived from the base colors. See Dark Mode below. + +### Shades + +Each color has 6 additional shades (3 darker and 3 lighter), giving a total of 7 shades per color. These are calculated automatically from the base colors. + +### Primary and Secondary + +The _primary_ and _secondary_ colors are used as a background for large areas of the interface, such as headers and sidebars. The secondary color is optional, and if not supplied will be set to be the same as primary. If supplied, the secondary color should be compliment the primary, and together can be considered the _branding colors_ as they have the greatest influence over the look of the TUI. + +### Background and Surface + +The _surface_ colors is the base color which goes behind text. The _background_ color is typically the negative space where there is no content. + +These two colors tend to be very similar, with just enough difference in lightness to tell them apart. They should be chosen for good contrast with the text. + +In light mode the default background is #efefef (a bright grey) and the surface is #f5f5f5 (off white). In dark mode the default background is 100% black, and the default surface is #121212 (very dark grey). + +Note that, although both background and surface support the full range of shades, it may not be possible to darken or lighten them further. i.e. you can't get any lighter than 100% white or darken than 100% black. + +### Panel + +The _panel_ color is typically used as a background to emphasize text on the default surface, or as a UI element that is above the regular UI, such as a menu. + +The default panel color is derived from the surface color by blending it towards either white or black text (depending on mode). + +Unlike background and surface, the panel color is automatically selected so that it can always be lightened or darkened by the full range. + +### Accent + +The _accent_ color should be a contrasting color use in UI elements that should stand out, such as selections, status bars, and underlines. + +### Warning, Error, and Success + +The _warning_, _error_, and _success_ colors have semantic meaning. While they could be any color, by convention warning should be amber / orange, error should be red, and success should be green. + +### System + +The system color is used for system controls such as scrollbars. The default is for the system color to be the same as accent, but it is recommended that a different color is chosen to differentiate app controls from those rendered by the Textual system. + +## Text + +For every color and shade there is an automatically calculated text color, which is either white or black, chosen to produce the greatest contrast. + +The default text color as a slight alpha component, so that it not pure black or pure white, but a slight tint of the background showing through. Additionally, there are two text shades with increasingly greater alpha for reduced intensity text. + +## Dark Mode + +A dark mode is automatically generated from the theme. The dark variations of the primary and secondary colors are generated by blending with the background color. This ensures that the branding remains intact, while still providing dark backgrounds. + +The dark variations of the background and surface color defaults are selected. The other colors remain the same as light mode. The overall effect is that the majority of the interface is dark, with small portions highlighted by color. + +## Naming + +The color system produces a number of constants which are exposed in the CSS via variables. + +The name of the color will return one of the standard set of colors, for example `primary` or `panel`. + +For one of the shade variations, you can append `-darken-1`, `-darken-2`, `-darken-3` for increasingly darker colors, and `-lighten-1`, `lighten-2`, `lighten-3` for increasingly light colors. + +For the contrasting text color, prefix the name with `text-`, for instance `text-primary` or `text-panel`. Note that if the text is to be on top of a darkened or lightened color, it must also be included in the name. i.e. if the background is `primary-darken-2`, then the corresponding text color should be `text-primary-darken-2`. + +The additional two levels of faded text may be requested by appending `-fade-1` or `-fade-2` for decreasing levels of text alpha. diff --git a/reference/_devtools.md b/reference/_devtools.md new file mode 100644 index 000000000..fa66208fe --- /dev/null +++ b/reference/_devtools.md @@ -0,0 +1,14 @@ +# Devtools + +## Installation + +Using the Textual Devtools requires installation of the `dev` extra dependency. + +https://python-poetry.org/docs/pyproject/#extras + +## Running + +TODO: Note how we run the devtools themselves and how we run our Textual apps +such that they can connect. Don't forget Windows instructions :) +We might also add a link to the documentation from the exception that gets +raised when the "dev" extra dependencies aren't installed. diff --git a/reference/box.monopic b/reference/box.monopic new file mode 100644 index 000000000..064eb03e4 Binary files /dev/null and b/reference/box.monopic differ diff --git a/src/textual/__init__.py b/src/textual/__init__.py index d1019f7ba..3935489be 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,16 +1,129 @@ -from typing import Any +from __future__ import annotations + +import sys +import inspect +from typing import Callable, TYPE_CHECKING + +import rich.repr +from rich.console import RenderableType __all__ = ["log", "panic"] -def log(*args: Any, verbosity: int = 0, **kwargs) -> None: - from ._context import active_app +from ._context import active_app +from ._log import LogGroup, LogVerbosity - app = active_app.get() - app.log(*args, verbosity=verbosity, **kwargs) +if TYPE_CHECKING: + from .app import App + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias -def panic(*args: Any) -> None: +LogCallable: TypeAlias = "Callable" + + +class LoggerError(Exception): + """Raised when the logger failed.""" + + +@rich.repr.auto +class Logger: + """A Textual logger.""" + + def __init__( + self, + log_callable: LogCallable | None, + group: LogGroup = LogGroup.INFO, + verbosity: LogVerbosity = LogVerbosity.NORMAL, + ) -> None: + self._log = log_callable + self._group = group + self._verbosity = verbosity + + def __rich_repr__(self) -> rich.repr.Result: + yield self._group, LogGroup.INFO + yield self._verbosity, LogVerbosity.NORMAL + + def __call__(self, *args: object, **kwargs) -> None: + try: + app = active_app.get() + except LookupError: + raise LoggerError("Unable to log without an active app.") from None + if app.devtools is None or not app.devtools.is_connected: + return + + previous_frame = inspect.currentframe().f_back + caller = inspect.getframeinfo(previous_frame) + + _log = self._log or app._log + try: + _log( + self._group, + self._verbosity, + caller, + *args, + **kwargs, + ) + except LoggerError: + # If there is not active app, try printing + print_args = (*args, *[f"{key}={value!r}" for key, value in kwargs.items()]) + print(*print_args) + + def verbosity(self, verbose: bool) -> Logger: + """Get a new logger with selective verbosity. + + Args: + verbose (bool): True to use HIGH verbosity, otherwise NORMAL. + + Returns: + Logger: New logger. + """ + verbosity = LogVerbosity.HIGH if verbose else LogVerbosity.NORMAL + return Logger(self._log, self._group, verbosity) + + @property + def verbose(self) -> Logger: + """A verbose logger.""" + return Logger(self._log, self._group, LogVerbosity.HIGH) + + @property + def event(self) -> Logger: + """Logs events.""" + return Logger(self._log, LogGroup.EVENT) + + @property + def debug(self) -> Logger: + """Logs debug messages.""" + return Logger(self._log, LogGroup.DEBUG) + + @property + def info(self) -> Logger: + """Logs information.""" + return Logger(self._log, LogGroup.INFO) + + @property + def warning(self) -> Logger: + """Logs warnings.""" + return Logger(self._log, LogGroup.WARNING) + + @property + def error(self) -> Logger: + """Logs errors.""" + return Logger(self._log, LogGroup.ERROR) + + @property + def system(self) -> Logger: + """Logs system information.""" + return Logger(self._log, LogGroup.SYSTEM) + + +log = Logger(None) + + +def panic(*args: RenderableType) -> None: from ._context import active_app app = active_app.get() diff --git a/src/textual/__main__.py b/src/textual/__main__.py new file mode 100644 index 000000000..de8c1b79f --- /dev/null +++ b/src/textual/__main__.py @@ -0,0 +1,6 @@ +from .demo import DemoApp + + +if __name__ == "__main__": + app = DemoApp() + app.run() diff --git a/src/textual/_animator.py b/src/textual/_animator.py index b3bc42b6b..0b8ebb1ab 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -2,75 +2,112 @@ from __future__ import annotations import asyncio import sys -from time import time -from tracemalloc import start -from typing import Callable, TypeVar - +from abc import ABC, abstractmethod from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, TypeVar, TYPE_CHECKING +from . import _clock +from ._callback import invoke from ._easing import DEFAULT_EASING, EASING -from ._timer import Timer -from ._types import MessageTarget +from ._types import CallbackType +from .timer import Timer if sys.version_info >= (3, 8): - from typing import Protocol -else: - from typing_extensions import Protocol + from typing import Protocol, runtime_checkable +else: # pragma: no cover + from typing_extensions import Protocol, runtime_checkable +if TYPE_CHECKING: + from textual.app import App EasingFunction = Callable[[float], float] T = TypeVar("T") +@runtime_checkable class Animatable(Protocol): - def blend(self: T, destination: T, factor: float) -> T: + def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover ... +class Animation(ABC): + on_complete: CallbackType | None = None + """Callback to run after animation completes""" + + @abstractmethod + def __call__(self, time: float) -> bool: # pragma: no cover + """Call the animation, return a boolean indicating whether animation is in-progress or complete. + + Args: + time (float): The current timestamp + + Returns: + bool: True if the animation has finished, otherwise False. + """ + raise NotImplementedError("") + + def __eq__(self, other: object) -> bool: + return False + + @dataclass -class Animation: +class SimpleAnimation(Animation): obj: object attribute: str start_time: float duration: float start_value: float | Animatable end_value: float | Animatable - easing_function: EasingFunction + final_value: object + easing: EasingFunction + on_complete: CallbackType | None = None def __call__(self, time: float) -> bool: - def blend_float(start: float, end: float, factor: float) -> float: - return start + (end - start) * factor - - AnimatableT = TypeVar("AnimatableT", bound=Animatable) - - def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT: - return start.blend(end, factor) - - blend_function = ( - blend_float if isinstance(self.start_value, (int, float)) else blend - ) - if self.duration == 0: - value = self.end_value + setattr(self.obj, self.attribute, self.final_value) + return True + + factor = min(1.0, (time - self.start_time) / self.duration) + eased_factor = self.easing(factor) + + if factor == 1.0: + value = self.final_value + elif isinstance(self.start_value, Animatable): + assert isinstance( + self.end_value, Animatable + ), "end_value must be animatable" + value = self.start_value.blend(self.end_value, eased_factor) else: - factor = min(1.0, (time - self.start_time) / self.duration) - eased_factor = self.easing_function(factor) - # value = blend_function(self.start_value, self.end_value, eased_factor) + assert isinstance( + self.start_value, (int, float) + ), f"`start_value` must be float, not {self.start_value!r}" + assert isinstance( + self.end_value, (int, float) + ), f"`end_value` must be float, not {self.end_value!r}" if self.end_value > self.start_value: - eased_factor = self.easing_function(factor) + eased_factor = self.easing(factor) value = ( self.start_value + (self.end_value - self.start_value) * eased_factor ) else: - eased_factor = 1 - self.easing_function(factor) + eased_factor = 1 - self.easing(factor) value = ( self.end_value + (self.start_value - self.end_value) * eased_factor ) setattr(self.obj, self.attribute, value) - return value == self.end_value + return factor >= 1 + + def __eq__(self, other: object) -> bool: + if isinstance(other, SimpleAnimation): + return ( + self.final_value == other.final_value + and self.duration == other.duration + ) + return False class BoundAnimator: @@ -81,96 +118,228 @@ class BoundAnimator: def __call__( self, attribute: str, - value: float, + value: float | Animatable, *, + final_value: object = ..., duration: float | None = None, speed: float | None = None, + delay: float = 0.0, easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, ) -> None: + """Animate an attribute. + + Args: + attribute (str): Name of the attribute to animate. + value (float | Animatable): The value to animate to. + final_value (object, optional): The final value of the animation. Defaults to `value` if not set. + duration (float | None, optional): The duration of the animate. Defaults to None. + speed (float | None, optional): The speed of the animation. Defaults to None. + delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0. + easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic". + on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None. + + """ easing_function = EASING[easing] if isinstance(easing, str) else easing - self._animator.animate( + return self._animator.animate( self._obj, attribute=attribute, value=value, + final_value=final_value, duration=duration, speed=speed, + delay=delay, easing=easing_function, + on_complete=on_complete, ) class Animator: - def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: + """An object to manage updates to a given attribute over a period of time.""" + + def __init__(self, app: App, frames_per_second: int = 60) -> None: self._animations: dict[tuple[object, str], Animation] = {} + self.app = app self._timer = Timer( - target, + app, 1 / frames_per_second, - target, + app, name="Animator", callback=self, pause=True, ) - self._timer_task: asyncio.Task | None = None + self._idle_event = asyncio.Event() async def start(self) -> None: - if self._timer_task is None: - self._timer_task = self._timer.start() + """Start the animator task.""" + self._idle_event.set() + self._timer.start() async def stop(self) -> None: - await self._timer.stop() - if self._timer_task: - await self._timer_task - self._timer_task = None + """Stop the animator task.""" + try: + await self._timer.stop() + except asyncio.CancelledError: + pass def bind(self, obj: object) -> BoundAnimator: + """Bind the animator to a given objects.""" return BoundAnimator(self, obj) def animate( self, obj: object, attribute: str, - value: float, + value: Any, *, + final_value: object = ..., duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + delay: float = 0.0, + on_complete: CallbackType | None = None, ) -> None: + """Animate an attribute to a new value. - start_time = time() + Args: + obj (object): The object containing the attribute. + attribute (str): The name of the attribute. + value (Any): The destination value of the attribute. + final_value (Any, optional): The final value, or ellipsis if it is the same as ``value``. Defaults to Ellipsis/ + duration (float | None, optional): The duration of the animation, or ``None`` to use speed. Defaults to ``None``. + speed (float | None, optional): The speed of the animation. Defaults to None. + easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING. + delay (float, optional): Number of seconds to delay the start of the animation by. Defaults to 0. + on_complete (CallbackType | None, optional): Callback to run after the animation completes. + """ + animate_callback = partial( + self._animate, + obj, + attribute, + value, + final_value=final_value, + duration=duration, + speed=speed, + easing=easing, + on_complete=on_complete, + ) + if delay: + self.app.set_timer(delay, animate_callback) + else: + animate_callback() - animation_key = (obj, attribute) - if animation_key in self._animations: - self._animations[animation_key](start_time) + def _animate( + self, + obj: object, + attribute: str, + value: Any, + *, + final_value: object = ..., + duration: float | None = None, + speed: float | None = None, + easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, + ): + """Animate an attribute to a new value. - start_value = getattr(obj, attribute) + Args: + obj (object): The object containing the attribute. + attribute (str): The name of the attribute. + value (Any): The destination value of the attribute. + final_value (Any, optional): The final value, or ellipsis if it is the same as ``value``. Defaults to .... + duration (float | None, optional): The duration of the animation, or ``None`` to use speed. Defaults to ``None``. + speed (float | None, optional): The speed of the animation. Defaults to None. + easing (EasingFunction | str, optional): An easing function. Defaults to DEFAULT_EASING. + on_complete (CallbackType | None, optional): Callback to run after the animation completes. + """ + if not hasattr(obj, attribute): + raise AttributeError( + f"Can't animate attribute {attribute!r} on {obj!r}; attribute does not exist" + ) + assert (duration is not None and speed is None) or ( + duration is None and speed is not None + ), "An Animation should have a duration OR a speed" - if start_value == value: - self._animations.pop(animation_key, None) + if final_value is ...: + final_value = value + + start_time = self._get_time() + + animation_key = (id(obj), attribute) + + easing_function = EASING[easing] if isinstance(easing, str) else easing + + animation: Animation | None = None + if hasattr(obj, "__textual_animation__"): + animation = getattr(obj, "__textual_animation__")( + attribute, + value, + start_time, + duration=duration, + speed=speed, + easing=easing_function, + on_complete=on_complete, + ) + if animation is None: + start_value = getattr(obj, attribute) + + if start_value == value: + self._animations.pop(animation_key, None) + return + + if duration is not None: + animation_duration = duration + else: + if hasattr(value, "get_distance_to"): + animation_duration = value.get_distance_to(start_value) / ( + speed or 50 + ) + else: + animation_duration = abs(value - start_value) / (speed or 50) + + animation = SimpleAnimation( + obj, + attribute=attribute, + start_time=start_time, + duration=animation_duration, + start_value=start_value, + end_value=value, + final_value=final_value, + easing=easing_function, + on_complete=on_complete, + ) + assert animation is not None, "animation expected to be non-None" + + current_animation = self._animations.get(animation_key) + if current_animation is not None and current_animation == animation: return - if duration is not None: - animation_duration = duration - else: - animation_duration = abs(value - start_value) / (speed or 50) - easing_function = EASING[easing] if isinstance(easing, str) else easing - animation = Animation( - obj, - attribute=attribute, - start_time=start_time, - duration=animation_duration, - start_value=start_value, - end_value=value, - easing_function=easing_function, - ) self._animations[animation_key] = animation self._timer.resume() + self._idle_event.clear() async def __call__(self) -> None: if not self._animations: self._timer.pause() + self._idle_event.set() else: - animation_time = time() + animation_time = self._get_time() animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] - if animation(animation_time): + animation_complete = animation(animation_time) + if animation_complete: + completion_callback = animation.on_complete + if completion_callback is not None: + await invoke(completion_callback) del self._animations[animation_key] + + def _get_time(self) -> float: + """Get the current wall clock time, via the internal Timer.""" + # N.B. We could remove this method and always call `self._timer.get_time()` internally, + # but it's handy to have in mocking situations + return _clock.get_time_no_wait() + + async def wait_for_idle(self) -> None: + """Wait for any animations to complete.""" + await self._idle_event.wait() diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index b1f441cdc..73d8e8082 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -1,9 +1,9 @@ -from typing import Dict, Tuple +from typing import Mapping, Tuple from .keys import Keys # Mapping of vt100 escape codes to Keys. -ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { +ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = { # Control keys. " ": (Keys.Space,), "\r": (Keys.Enter,), @@ -15,8 +15,8 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { "\x05": (Keys.ControlE,), # Control-E (end) "\x06": (Keys.ControlF,), # Control-F (cursor forward) "\x07": (Keys.ControlG,), # Control-G - "\x08": (Keys.ControlH,), # Control-H (8) (Identical to '\b') - "\x09": (Keys.ControlI,), # Control-I (9) (Identical to '\t') + "\x08": (Keys.Backspace,), # Control-H (8) (Identical to '\b') + "\x09": (Keys.Tab,), # Control-I (9) (Identical to '\t') "\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n') "\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab) "\x0c": (Keys.ControlL,), # Control-L (clear; form feed) @@ -50,8 +50,8 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { # handle backspace and control-h individually for the few terminals that # support it. (Most terminals send ControlH when backspace is pressed.) # See: http://www.ibb.net/~anne/keyboard.html - "\x7f": (Keys.ControlH,), - # -- + "\x7f": (Keys.Backspace,), + "\x1b\x7f": (Keys.ControlW,), # Various "\x1b[1~": (Keys.Home,), # tmux "\x1b[2~": (Keys.Insert,), @@ -138,7 +138,6 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { # Tmux (Win32 subsystem) sends the following scroll events. "\x1b[62~": (Keys.ScrollUp,), "\x1b[63~": (Keys.ScrollDown,), - "\x1b[200~": (Keys.BracketedPaste,), # Start of bracketed paste. # -- # Sequences generated by numpad 5. Not sure what it means. (It doesn't # appear in 'infocmp'. Just ignore. @@ -234,8 +233,8 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { "\x1bOc": (Keys.ControlRight,), # rxvt "\x1bOd": (Keys.ControlLeft,), # rxvt # Control + shift + arrows. - "\x1b[1;6A": (Keys.ControlShiftDown,), - "\x1b[1;6B": (Keys.ControlShiftUp,), + "\x1b[1;6A": (Keys.ControlShiftUp,), + "\x1b[1;6B": (Keys.ControlShiftDown,), "\x1b[1;6C": (Keys.ControlShiftRight,), "\x1b[1;6D": (Keys.ControlShiftLeft,), "\x1b[1;6F": (Keys.ControlShiftEnd,), @@ -303,3 +302,7 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8), "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9), } + +# https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 +SYNC_START = "\x1b[?2026h" +SYNC_END = "\x1b[?2026l" diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py new file mode 100644 index 000000000..2d89fad11 --- /dev/null +++ b/src/textual/_arrange.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from collections import defaultdict +from fractions import Fraction +from operator import attrgetter +from typing import Sequence, TYPE_CHECKING + +from .geometry import Region, Size, Spacing +from ._layout import DockArrangeResult, WidgetPlacement +from ._partition import partition + +if TYPE_CHECKING: + from .widget import Widget + +# TODO: This is a bit of a fudge, need to ensure it is impossible for layouts to generate this value +TOP_Z = 2**31 - 1 + + +def arrange( + widget: Widget, children: Sequence[Widget], size: Size, viewport: Size +) -> DockArrangeResult: + """Arrange widgets by applying docks and calling layouts + + Args: + widget (Widget): The parent (container) widget. + size (Size): The size of the available area. + viewport (Size): The size of the viewport (terminal). + + Returns: + tuple[list[WidgetPlacement], set[Widget], Spacing]: Widget arrangement information. + """ + + arrange_widgets: set[Widget] = set() + + dock_layers: defaultdict[str, list[Widget]] = defaultdict(list) + for child in children: + if child.display: + dock_layers[child.styles.layer or "default"].append(child) + + width, height = size + + placements: list[WidgetPlacement] = [] + add_placement = placements.append + region = size.region + + _WidgetPlacement = WidgetPlacement + top_z = TOP_Z + scroll_spacing = Spacing() + null_spacing = Spacing() + get_dock = attrgetter("styles.dock") + styles = widget.styles + + for widgets in dock_layers.values(): + + layout_widgets, dock_widgets = partition(get_dock, widgets) + + arrange_widgets.update(dock_widgets) + top = right = bottom = left = 0 + + for dock_widget in dock_widgets: + edge = dock_widget.styles.dock + + fraction_unit = Fraction( + size.height if edge in ("top", "bottom") else size.width + ) + 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 + widget_height = int(widget_height_fraction) + margin.height + + if edge == "bottom": + dock_region = Region( + 0, height - widget_height, widget_width, widget_height + ) + bottom = max(bottom, widget_height) + elif edge == "top": + dock_region = Region(0, 0, widget_width, widget_height) + top = max(top, widget_height) + elif edge == "left": + dock_region = Region(0, 0, widget_width, widget_height) + left = max(left, widget_width) + elif edge == "right": + dock_region = Region( + width - widget_width, 0, widget_width, widget_height + ) + right = max(right, widget_width) + else: + # Should not occur, mainly to keep Mypy happy + raise AssertionError("invalid value for edge") # pragma: no-cover + + align_offset = dock_widget.styles._align_size( + (widget_width, widget_height), size + ) + dock_region = dock_region.shrink(margin).translate(align_offset) + add_placement( + _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) + ) + + dock_spacing = Spacing(top, right, bottom, left) + region = region.shrink(dock_spacing) + 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 styles.align_horizontal != "left" or styles.align_vertical != "top": + placement_size = Region.from_union( + [ + placement.region.grow(placement.margin) + for placement in layout_placements + ] + ).size + placement_offset += styles._align_size( + placement_size, region.size + ).clamped + + if placement_offset: + layout_placements = [ + _WidgetPlacement( + _region + placement_offset, margin, layout_widget, order, fixed + ) + for _region, margin, layout_widget, order, fixed in layout_placements + ] + + placements.extend(layout_placements) + + return placements, arrange_widgets, scroll_spacing diff --git a/src/textual/_border.py b/src/textual/_border.py new file mode 100644 index 000000000..7294e4fc0 --- /dev/null +++ b/src/textual/_border.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import sys +from functools import lru_cache +from typing import cast, Tuple, Union + +from rich.console import Console, ConsoleOptions, RenderResult, RenderableType +import rich.repr +from rich.segment import Segment, SegmentLines +from rich.style import Style + +from .color import Color +from .css.types import EdgeStyle, EdgeType + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + +INNER = 1 +OUTER = 2 + +BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = { + # Each string of the tuple represents a sub-tuple itself: + # - 1st string represents (top1, top2, top3) + # - 2nd string represents (mid1, mid2, mid3) + # - 3rd string represents (bottom1, bottom2, bottom3) + "": (" ", " ", " "), + "ascii": ("+-+", "| |", "+-+"), + "none": (" ", " ", " "), + "hidden": (" ", " ", " "), + "blank": (" ", " ", " "), + "round": ("โ•ญโ”€โ•ฎ", "โ”‚ โ”‚", "โ•ฐโ”€โ•ฏ"), + "solid": ("โ”Œโ”€โ”", "โ”‚ โ”‚", "โ””โ”€โ”˜"), + "double": ("โ•”โ•โ•—", "โ•‘ โ•‘", "โ•šโ•โ•"), + "dashed": ("โ”โ•โ”“", "โ• โ•", "โ”—โ•โ”›"), + "heavy": ("โ”โ”โ”“", "โ”ƒ โ”ƒ", "โ”—โ”โ”›"), + "inner": ("โ–—โ–„โ––", "โ– โ–Œ", "โ–โ–€โ–˜"), + "outer": ("โ–›โ–€โ–œ", "โ–Œ โ–", "โ–™โ–„โ–Ÿ"), + "hkey": ("โ–”โ–”โ–”", " ", "โ–โ–โ–"), + "vkey": ("โ– โ–•", "โ– โ–•", "โ– โ–•"), + "tall": ("โ–Šโ–”โ–Ž", "โ–Š โ–Ž", "โ–Šโ–โ–Ž"), + "wide": ("โ–โ–โ–", "โ–Ž โ–‹", "โ–”โ–”โ–”"), +} + +# Some of the borders are on the widget background and some are on the background of the parent +# This table selects which for each character, 0 indicates the widget, 1 selects the parent +BORDER_LOCATIONS: dict[ + EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]] +] = { + "": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "ascii": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "round": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "solid": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "double": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "dashed": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "heavy": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "inner": ((1, 1, 1), (1, 1, 1), (1, 1, 1)), + "outer": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "hkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "vkey": ((0, 0, 0), (0, 0, 0), (0, 0, 0)), + "tall": ((2, 0, 1), (2, 0, 1), (2, 0, 1)), + "wide": ((1, 1, 1), (0, 1, 3), (1, 1, 1)), +} + +INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden"))) + +BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]] + +BoxSegments: TypeAlias = Tuple[ + Tuple[Segment, Segment, Segment], + Tuple[Segment, Segment, Segment], + Tuple[Segment, Segment, Segment], +] + +Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle] + + +@lru_cache(maxsize=1024) +def get_box( + name: EdgeType, + inner_style: Style, + outer_style: Style, + style: Style, +) -> BoxSegments: + """Get segments used to render a box. + + Args: + name (str): Name of the box type. + inner_style (Style): The inner style (widget background) + outer_style (Style): The outer style (parent background) + style (Style): Widget style + + Returns: + tuple: A tuple of 3 Segment triplets. + """ + _Segment = Segment + ( + (top1, top2, top3), + (mid1, mid2, mid3), + (bottom1, bottom2, bottom3), + ) = BORDER_CHARS[name] + + ( + (ltop1, ltop2, ltop3), + (lmid1, lmid2, lmid3), + (lbottom1, lbottom2, lbottom3), + ) = BORDER_LOCATIONS[name] + + inner = inner_style + style + outer = outer_style + style + styles = ( + inner, + outer, + Style.from_color(outer.bgcolor, inner.color), + Style.from_color(inner.bgcolor, outer.color), + ) + + return ( + ( + _Segment(top1, styles[ltop1]), + _Segment(top2, styles[ltop2]), + _Segment(top3, styles[ltop3]), + ), + ( + _Segment(mid1, styles[lmid1]), + _Segment(mid2, styles[lmid2]), + _Segment(mid3, styles[lmid3]), + ), + ( + _Segment(bottom1, styles[lbottom1]), + _Segment(bottom2, styles[lbottom2]), + _Segment(bottom3, styles[lbottom3]), + ), + ) + + +def render_row( + box_row: tuple[Segment, Segment, Segment], width: int, left: bool, right: bool +) -> list[Segment]: + """Render a top, or bottom border row. + + Args: + box_row (tuple[Segment, Segment, Segment]): Corners and side segments. + width (int): Total width of resulting line. + left (bool): Render left corner. + right (bool): Render right corner. + + Returns: + list[Segment]: A list of segments. + """ + box1, box2, box3 = box_row + if left and right: + return [box1, Segment(box2.text * (width - 2), box2.style), box3] + if left: + return [box1, Segment(box2.text * (width - 1), box2.style)] + if right: + return [Segment(box2.text * (width - 1), box2.style), box3] + else: + return [Segment(box2.text * width, box2.style)] + + +@rich.repr.auto +class Border: + """Renders Textual CSS borders. + + This is analogous to Rich's `Box` but more flexible. Different borders may be + applied to each of the four edges, and more advanced borders can be achieved through + various combinations of Widget and parent background colors. + + """ + + def __init__( + self, + renderable: RenderableType, + borders: Borders, + inner_color: Color, + outer_color: Color, + outline: bool = False, + ): + self.renderable = renderable + self.edge_styles = borders + self.outline = outline + + ( + (top, top_color), + (right, right_color), + (bottom, bottom_color), + (left, left_color), + ) = borders + self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] + self._sides = (top, right, bottom, left) + from_color = Style.from_color + + self._styles = ( + from_color(top_color.rich_color), + from_color(right_color.rich_color), + from_color(bottom_color.rich_color), + from_color(left_color.rich_color), + ) + self.inner_style = from_color(bgcolor=inner_color.rich_color) + self.outer_style = from_color(bgcolor=outer_color.rich_color) + + def __rich_repr__(self) -> rich.repr.Result: + yield self.renderable + yield self.edge_styles + + def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None: + """Crops a renderable in place. + + Args: + lines (list[list[Segment]]): Segment lines. + width (int): Desired width. + """ + top, right, bottom, left = self._sides + # the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string + has_left = bool(left) + has_right = bool(right) + has_top = bool(top) + has_bottom = bool(bottom) + + if has_top: + lines.pop(0) + if has_bottom and lines: + lines.pop(-1) + + # TODO: Divide is probably quite inefficient here, + # It could be much faster for the specific case of one off the start end end + divide = Segment.divide + if has_left and has_right: + for line in lines: + _, line[:] = divide(line, [1, width - 1]) + elif has_left: + for line in lines: + _, line[:] = divide(line, [1, width]) + elif has_right: + for line in lines: + line[:], _ = divide(line, [width - 1, width]) + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + top, right, bottom, left = self._sides + style = console.get_style(self.inner_style) + outer_style = console.get_style(self.outer_style) + top_style, right_style, bottom_style, left_style = self._styles + + # ditto than in `_crop_renderable` โ˜ + has_left = bool(left) + has_right = bool(right) + has_top = bool(top) + has_bottom = bool(bottom) + + width = options.max_width - has_left - has_right + + if width <= 2: + lines = console.render_lines(self.renderable, options, new_lines=True) + yield SegmentLines(lines) + return + + if self.outline: + render_options = options + else: + if options.height is None: + render_options = options.update_width(width) + else: + new_height = options.height - has_top - has_bottom + if new_height >= 1: + render_options = options.update_dimensions(width, new_height) + else: + render_options = options.update_width(width) + + lines = console.render_lines(self.renderable, render_options) + if self.outline: + self._crop_renderable(lines, options.max_width) + + _Segment = Segment + new_line = _Segment.line() + if has_top: + box1, box2, box3 = get_box(top, style, outer_style, top_style)[0] + if has_left: + yield box1 if top == left else _Segment(" ", box2.style) + yield _Segment(box2.text * width, box2.style) + if has_right: + yield box3 if top == left else _Segment(" ", box3.style) + yield new_line + + left_segment = get_box(left, style, outer_style, left_style)[1][0] + _right_segment = get_box(right, style, outer_style, right_style)[1][2] + right_segment = _Segment(_right_segment.text + "\n", _right_segment.style) + + if has_left and has_right: + for line in lines: + yield left_segment + yield from line + yield right_segment + elif has_left: + for line in lines: + yield left_segment + yield from line + yield new_line + elif has_right: + for line in lines: + yield from line + yield right_segment + else: + for line in lines: + yield from line + yield new_line + + if has_bottom: + box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2] + if has_left: + yield box1 if bottom == left else _Segment(" ", box1.style) + yield _Segment(box2.text * width, box2.style) + if has_right: + yield box3 if bottom == right else _Segment(" ", box3.style) + yield new_line + + +_edge_type_normalization_table: dict[EdgeType, EdgeType] = { + # i.e. we normalize "border: none;" to "border: ;". + # As a result our layout-related calculations that include borders are simpler (and have better performance) + "none": "", + "hidden": "", +} + + +def normalize_border_value(value: BorderValue) -> BorderValue: + return _edge_type_normalization_table.get(value[0], value[0]), value[1] + + +if __name__ == "__main__": + from rich import print + from rich.text import Text + from rich.padding import Padding + + from .color import Color + + inner = Color.parse("#303F9F") + outer = Color.parse("#212121") + + lorem = """[#C5CAE9]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.""" + text = Text.from_markup(lorem) + border = Border( + Padding(text, 1, style="on #303F9F"), + ( + ("none", Color.parse("#C5CAE9")), + ("none", Color.parse("#C5CAE9")), + ("wide", Color.parse("#C5CAE9")), + ("none", Color.parse("#C5CAE9")), + ), + inner_color=inner, + outer_color=outer, + ) + + print( + Padding(border, (1, 2), style="on #212121"), + ) + print() + + border = Border( + Padding(text, 1, style="on #303F9F"), + ( + ("hkey", Color.parse("#8BC34A")), + ("hkey", Color.parse("#8BC34A")), + ("hkey", Color.parse("#8BC34A")), + ("hkey", Color.parse("#8BC34A")), + ), + inner_color=inner, + outer_color=outer, + ) + + print( + Padding(border, (1, 2), style="on #212121"), + ) diff --git a/src/textual/_cache.py b/src/textual/_cache.py new file mode 100644 index 000000000..2f9bdd49d --- /dev/null +++ b/src/textual/_cache.py @@ -0,0 +1,156 @@ +""" + +A LRU (Least Recently Used) Cache container. + +Use when you want to cache slow operations and new keys are a good predictor +of subsequent keys. + +Note that stdlib's @lru_cache is implemented in C and faster! It's best to use +@lru_cache where you are caching things that are fairly quick and called many times. +Use LRUCache where you want increased flexibility and you are caching slow operations +where the overhead of the cache is a small fraction of the total processing time. + +""" + +from __future__ import annotations + +from threading import Lock +from typing import Dict, Generic, KeysView, TypeVar, overload + +CacheKey = TypeVar("CacheKey") +CacheValue = TypeVar("CacheValue") +DefaultValue = TypeVar("DefaultValue") + + +class LRUCache(Generic[CacheKey, CacheValue]): + """ + A dictionary-like container with a maximum size. + + If an additional item is added when the LRUCache is full, the least + recently used key is discarded to make room for the new item. + + The implementation is similar to functools.lru_cache, which uses a (doubly) + linked list to keep track of the most recently used items. + + Each entry is stored as [PREV, NEXT, KEY, VALUE] where PREV is a reference + to the previous entry, and NEXT is a reference to the next value. + + """ + + def __init__(self, maxsize: int) -> None: + self._maxsize = maxsize + self._cache: Dict[CacheKey, list[object]] = {} + self._full = False + self._head: list[object] = [] + self._lock = Lock() + super().__init__() + + @property + def maxsize(self) -> int: + return self._maxsize + + @maxsize.setter + def maxsize(self, maxsize: int) -> None: + self._maxsize = maxsize + + def __bool__(self) -> bool: + return bool(self._cache) + + def __len__(self) -> int: + return len(self._cache) + + def clear(self) -> None: + """Clear the cache.""" + with self._lock: + self._cache.clear() + self._full = False + self._head = [] + + def keys(self) -> KeysView[CacheKey]: + """Get cache keys.""" + # Mostly for tests + return self._cache.keys() + + def set(self, key: CacheKey, value: CacheValue) -> None: + """Set a value. + + Args: + key (CacheKey): Key. + value (CacheValue): Value. + """ + with self._lock: + link = self._cache.get(key) + if link is None: + head = self._head + if not head: + # First link references itself + self._head[:] = [head, head, key, value] + else: + # Add a new root to the beginning + self._head = [head[0], head, key, value] + # Updated references on previous root + head[0][1] = self._head # type: ignore[index] + head[0] = self._head + self._cache[key] = self._head + + if self._full or len(self._cache) > self._maxsize: + # Cache is full, we need to evict the oldest one + self._full = True + head = self._head + last = head[0] + last[0][1] = head # type: ignore[index] + head[0] = last[0] # type: ignore[index] + del self._cache[last[2]] # type: ignore[index] + + __setitem__ = set + + @overload + def get(self, key: CacheKey) -> CacheValue | None: + ... + + @overload + def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue: + ... + + def get( + self, key: CacheKey, default: DefaultValue | None = None + ) -> CacheValue | DefaultValue | None: + """Get a value from the cache, or return a default if the key is not present. + + Args: + key (CacheKey): Key + default (Optional[DefaultValue], optional): Default to return if key is not present. Defaults to None. + + Returns: + Union[CacheValue, Optional[DefaultValue]]: Either the value or a default. + """ + link = self._cache.get(key) + if link is None: + return default + with self._lock: + if link is not self._head: + # Remove link from list + link[0][1] = link[1] # type: ignore[index] + link[1][0] = link[0] # type: ignore[index] + head = self._head + # Move link to head of list + link[0] = head[0] + link[1] = head + self._head = head[0][1] = head[0] = link # type: ignore[index] + + return link[3] # type: ignore[return-value] + + def __getitem__(self, key: CacheKey) -> CacheValue: + link = self._cache[key] + with self._lock: + if link is not self._head: + link[0][1] = link[1] # type: ignore[index] + link[1][0] = link[0] # type: ignore[index] + head = self._head + link[0] = head[0] + link[1] = head + self._head = head[0][1] = head[0] = link # type: ignore[index] + return link[3] # type: ignore[return-value] + + def __contains__(self, key: CacheKey) -> bool: + return key in self._cache diff --git a/src/textual/_callback.py b/src/textual/_callback.py index ad3b56e44..0d52effab 100644 --- a/src/textual/_callback.py +++ b/src/textual/_callback.py @@ -23,7 +23,6 @@ async def invoke(callback: Callable, *params: object) -> Any: """ _rich_traceback_guard = True parameter_count = count_parameters(callback) - result = callback(*params[:parameter_count]) if isawaitable(result): result = await result diff --git a/src/textual/_cells.py b/src/textual/_cells.py new file mode 100644 index 000000000..2b838b93c --- /dev/null +++ b/src/textual/_cells.py @@ -0,0 +1,6 @@ +__all__ = ["cell_len"] + +try: + from rich.cells import cached_cell_len as cell_len +except ImportError: + from rich.cells import cell_len diff --git a/src/textual/_clock.py b/src/textual/_clock.py new file mode 100644 index 000000000..ca4559d1b --- /dev/null +++ b/src/textual/_clock.py @@ -0,0 +1,58 @@ +import asyncio + +from ._time import time + + +""" +A module that serves as the single source of truth for everything time-related in a Textual app. +Having this logic centralised makes it easier to simulate time in integration tests, +by mocking the few functions exposed by this module. +""" + + +# N.B. This class and its singleton instance have to be hidden APIs because we want to be able to mock time, +# even for Python modules that imported functions such as `get_time` *before* we mocked this internal _Clock. +# (so mocking public APIs such as `get_time` wouldn't affect direct references to then that were done during imports) +class _Clock: + async def get_time(self) -> float: + return time() + + def get_time_no_wait(self) -> float: + return time() + + async def sleep(self, seconds: float) -> None: + await asyncio.sleep(seconds) + + +_clock = _Clock() + + +def get_time_no_wait() -> float: + """ + Get the current wall clock time. + + Returns: + float: the value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards. + """ + return _clock.get_time_no_wait() + + +async def get_time() -> float: + """ + Asynchronous version of `get_time`. Useful in situations where we want asyncio to be + able to "do things" elsewhere right before we fetch the time. + + Returns: + float: the value (in fractional seconds) of a monotonic clock, i.e. a clock that cannot go backwards. + """ + return await _clock.get_time() + + +async def sleep(seconds: float) -> None: + """ + Coroutine that completes after a given time (in seconds). + + Args: + seconds (float): the duration we should wait for before unblocking the awaiter + """ + return await _clock.sleep(seconds) diff --git a/src/textual/_color_constants.py b/src/textual/_color_constants.py new file mode 100644 index 000000000..cedab5673 --- /dev/null +++ b/src/textual/_color_constants.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, float]] = { + # Let's start with a specific pseudo-color:: + "transparent": (0, 0, 0, 0), + # Then, the 16 common ANSI colors: + "ansi_black": (0, 0, 0), + "ansi_red": (128, 0, 0), + "ansi_green": (0, 128, 0), + "ansi_yellow": (128, 128, 0), + "ansi_blue": (0, 0, 128), + "ansi_magenta": (128, 0, 128), + "ansi_cyan": (0, 128, 128), + "ansi_white": (192, 192, 192), + "ansi_bright_black": (128, 128, 128), + "ansi_bright_red": (255, 0, 0), + "ansi_bright_green": (0, 255, 0), + "ansi_bright_yellow": (255, 255, 0), + "ansi_bright_blue": (0, 0, 255), + "ansi_bright_magenta": (255, 0, 255), + "ansi_bright_cyan": (0, 255, 255), + "ansi_bright_white": (255, 255, 255), + # And then, Web color keywords: (up to CSS Color Module Level 4) + "black": (0, 0, 0), + "silver": (192, 192, 192), + "gray": (128, 128, 128), + "white": (255, 255, 255), + "maroon": (128, 0, 0), + "red": (255, 0, 0), + "purple": (128, 0, 128), + "fuchsia": (255, 0, 255), + "green": (0, 128, 0), + "lime": (0, 255, 0), + "olive": (128, 128, 0), + "yellow": (255, 255, 0), + "navy": (0, 0, 128), + "blue": (0, 0, 255), + "teal": (0, 128, 128), + "aqua": (0, 255, 255), + "orange": (255, 165, 0), + "aliceblue": (240, 248, 255), + "antiquewhite": (250, 235, 215), + "aquamarine": (127, 255, 212), + "azure": (240, 255, 255), + "beige": (245, 245, 220), + "bisque": (255, 228, 196), + "blanchedalmond": (255, 235, 205), + "blueviolet": (138, 43, 226), + "brown": (165, 42, 42), + "burlywood": (222, 184, 135), + "cadetblue": (95, 158, 160), + "chartreuse": (127, 255, 0), + "chocolate": (210, 105, 30), + "coral": (255, 127, 80), + "cornflowerblue": (100, 149, 237), + "cornsilk": (255, 248, 220), + "crimson": (220, 20, 60), + "cyan": (0, 255, 255), + "darkblue": (0, 0, 139), + "darkcyan": (0, 139, 139), + "darkgoldenrod": (184, 134, 11), + "darkgray": (169, 169, 169), + "darkgreen": (0, 100, 0), + "darkgrey": (169, 169, 169), + "darkkhaki": (189, 183, 107), + "darkmagenta": (139, 0, 139), + "darkolivegreen": (85, 107, 47), + "darkorange": (255, 140, 0), + "darkorchid": (153, 50, 204), + "darkred": (139, 0, 0), + "darksalmon": (233, 150, 122), + "darkseagreen": (143, 188, 143), + "darkslateblue": (72, 61, 139), + "darkslategray": (47, 79, 79), + "darkslategrey": (47, 79, 79), + "darkturquoise": (0, 206, 209), + "darkviolet": (148, 0, 211), + "deeppink": (255, 20, 147), + "deepskyblue": (0, 191, 255), + "dimgray": (105, 105, 105), + "dimgrey": (105, 105, 105), + "dodgerblue": (30, 144, 255), + "firebrick": (178, 34, 34), + "floralwhite": (255, 250, 240), + "forestgreen": (34, 139, 34), + "gainsboro": (220, 220, 220), + "ghostwhite": (248, 248, 255), + "gold": (255, 215, 0), + "goldenrod": (218, 165, 32), + "greenyellow": (173, 255, 47), + "grey": (128, 128, 128), + "honeydew": (240, 255, 240), + "hotpink": (255, 105, 180), + "indianred": (205, 92, 92), + "indigo": (75, 0, 130), + "ivory": (255, 255, 240), + "khaki": (240, 230, 140), + "lavender": (230, 230, 250), + "lavenderblush": (255, 240, 245), + "lawngreen": (124, 252, 0), + "lemonchiffon": (255, 250, 205), + "lightblue": (173, 216, 230), + "lightcoral": (240, 128, 128), + "lightcyan": (224, 255, 255), + "lightgoldenrodyellow": (250, 250, 210), + "lightgray": (211, 211, 211), + "lightgreen": (144, 238, 144), + "lightgrey": (211, 211, 211), + "lightpink": (255, 182, 193), + "lightsalmon": (255, 160, 122), + "lightseagreen": (32, 178, 170), + "lightskyblue": (135, 206, 250), + "lightslategray": (119, 136, 153), + "lightslategrey": (119, 136, 153), + "lightsteelblue": (176, 196, 222), + "lightyellow": (255, 255, 224), + "limegreen": (50, 205, 50), + "linen": (250, 240, 230), + "magenta": (255, 0, 255), + "mediumaquamarine": (102, 205, 170), + "mediumblue": (0, 0, 205), + "mediumorchid": (186, 85, 211), + "mediumpurple": (147, 112, 219), + "mediumseagreen": (60, 179, 113), + "mediumslateblue": (123, 104, 238), + "mediumspringgreen": (0, 250, 154), + "mediumturquoise": (72, 209, 204), + "mediumvioletred": (199, 21, 133), + "midnightblue": (25, 25, 112), + "mintcream": (245, 255, 250), + "mistyrose": (255, 228, 225), + "moccasin": (255, 228, 181), + "navajowhite": (255, 222, 173), + "oldlace": (253, 245, 230), + "olivedrab": (107, 142, 35), + "orangered": (255, 69, 0), + "orchid": (218, 112, 214), + "palegoldenrod": (238, 232, 170), + "palegreen": (152, 251, 152), + "paleturquoise": (175, 238, 238), + "palevioletred": (219, 112, 147), + "papayawhip": (255, 239, 213), + "peachpuff": (255, 218, 185), + "peru": (205, 133, 63), + "pink": (255, 192, 203), + "plum": (221, 160, 221), + "powderblue": (176, 224, 230), + "rosybrown": (188, 143, 143), + "royalblue": (65, 105, 225), + "saddlebrown": (139, 69, 19), + "salmon": (250, 128, 114), + "sandybrown": (244, 164, 96), + "seagreen": (46, 139, 87), + "seashell": (255, 245, 238), + "sienna": (160, 82, 45), + "skyblue": (135, 206, 235), + "slateblue": (106, 90, 205), + "slategray": (112, 128, 144), + "slategrey": (112, 128, 144), + "snow": (255, 250, 250), + "springgreen": (0, 255, 127), + "steelblue": (70, 130, 180), + "tan": (210, 180, 140), + "thistle": (216, 191, 216), + "tomato": (255, 99, 71), + "turquoise": (64, 224, 208), + "violet": (238, 130, 238), + "wheat": (245, 222, 179), + "whitesmoke": (245, 245, 245), + "yellowgreen": (154, 205, 50), + "rebeccapurple": (102, 51, 153), +} diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py new file mode 100644 index 000000000..2efdb0744 --- /dev/null +++ b/src/textual/_compositor.py @@ -0,0 +1,809 @@ +""" + +The compositor handles combining widgets in to a single screen (i.e. compositing). + +It also stores the results of that process, so that Textual knows the widgets on +the screen and their locations. The compositor uses this information to answer +queries regarding the widget under an offset, or the style under an offset. + +Additionally, the compositor can render portions of the screen which may have updated, +without having to render the entire screen. + +""" + +from __future__ import annotations + +import sys +from itertools import chain +from operator import itemgetter +from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, cast + +import rich.repr +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.control import Control +from rich.segment import Segment +from rich.style import Style + +from . import errors +from ._cells import cell_len +from ._loop import loop_last +from ._profile import timer +from ._types import Lines +from .geometry import Offset, Region, Size + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +if TYPE_CHECKING: + from .widget import Widget + + +class ReflowResult(NamedTuple): + """The result of a reflow operation. Describes the chances to widgets.""" + + hidden: set[Widget] # Widgets that are hidden + shown: set[Widget] # Widgets that are shown + resized: set[Widget] # Widgets that have been resized + + +class MapGeometry(NamedTuple): + """Defines the absolute location of a Widget.""" + + region: Region # The (screen) region occupied by the widget + order: tuple[tuple[int, ...], ...] # A tuple of ints defining the painting order + clip: Region # A region to clip the widget by (if a Widget is within a container) + virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container + container_size: Size # The container size (area not occupied by scrollbars) + virtual_region: Region # The region relative to the container (but not necessarily visible) + + @property + def visible_region(self) -> Region: + """The Widget region after clipping.""" + return self.clip.intersection(self.region) + + +# Maps a widget on to its geometry (information that describes its position in the composition) +CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" + + +@rich.repr.auto(angular=True) +class LayoutUpdate: + """A renderable containing the result of a render for a given region.""" + + def __init__(self, lines: Lines, region: Region) -> None: + self.lines = lines + self.region = region + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + x = self.region.x + new_line = Segment.line() + move_to = Control.move_to + for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): + yield move_to(x, y) + yield from line + if not last: + yield new_line + + def __rich_repr__(self) -> rich.repr.Result: + yield self.region + + +@rich.repr.auto(angular=True) +class ChopsUpdate: + """A renderable that applies updated spans to the screen.""" + + def __init__( + self, + chops: list[dict[int, list[Segment] | None]], + spans: list[tuple[int, int, int]], + chop_ends: list[list[int]], + ) -> None: + """A renderable which updates chops (fragments of lines). + + Args: + chops (list[dict[int, list[Segment] | None]]): A mapping of offsets to list of segments, per line. + crop (Region): Region to restrict update to. + chop_ends (list[list[int]]): A list of the end offsets for each line + """ + self.chops = chops + self.spans = spans + self.chop_ends = chop_ends + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + move_to = Control.move_to + new_line = Segment.line() + chops = self.chops + chop_ends = self.chop_ends + last_y = self.spans[-1][0] + + _cell_len = cell_len + + for y, x1, x2 in self.spans: + line = chops[y] + ends = chop_ends[y] + for end, (x, segments) in zip(ends, line.items()): + # TODO: crop to x extents + if segments is None: + continue + + if x > x2 or end <= x1: + continue + + if x2 > x >= x1 and end <= x2: + yield move_to(x, y) + yield from segments + continue + + iter_segments = iter(segments) + if x < x1: + for segment in iter_segments: + next_x = x + _cell_len(segment.text) + if next_x > x1: + yield move_to(x, y) + yield segment + break + x = next_x + else: + yield move_to(x, y) + if end <= x2: + yield from iter_segments + else: + for segment in iter_segments: + if x >= x2: + break + yield segment + x += _cell_len(segment.text) + + if y != last_y: + yield new_line + + def __rich_repr__(self) -> rich.repr.Result: + return + yield + + +@rich.repr.auto(angular=True) +class Compositor: + """Responsible for storing information regarding the relative positions of Widgets and rendering them.""" + + def __init__(self) -> None: + # A mapping of Widget on to its "render location" (absolute position / depth) + self.map: CompositorMap = {} + self._layers: list[tuple[Widget, MapGeometry]] | None = None + + # All widgets considered in the arrangement + # Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons + self.widgets: set[Widget] = set() + + # Mapping of visible widgets on to their region, and clip region + self._visible_widgets: dict[Widget, tuple[Region, Region]] | None = None + + # The top level widget + self.root: Widget | None = None + + # Dimensions of the arrangement + self.size = Size(0, 0) + + # The points in each line where the line bisects the left and right edges of the widget + self._cuts: list[list[int]] | None = None + + # Regions that require an update + self._dirty_regions: set[Region] = set() + + # Mapping of line numbers on to lists of widget and regions + self._layers_visible: list[list[tuple[Widget, Region, Region]]] | None = None + + @classmethod + def _regions_to_spans( + cls, regions: Iterable[Region] + ) -> Iterable[tuple[int, int, int]]: + """Converts the regions to horizontal spans. Spans will be combined if they overlap + or are contiguous to produce optimal non-overlapping spans. + + Args: + regions (Iterable[Region]): An iterable of Regions. + + Returns: + Iterable[tuple[int, int, int]]: Yields tuples of (Y, X1, X2) + """ + inline_ranges: dict[int, list[tuple[int, int]]] = {} + setdefault = inline_ranges.setdefault + for region_x, region_y, width, height in regions: + span = (region_x, region_x + width) + for y in range(region_y, region_y + height): + setdefault(y, []).append(span) + + slice_remaining = slice(1, None) + for y, ranges in sorted(inline_ranges.items()): + if len(ranges) == 1: + # Special case of 1 span + yield (y, *ranges[0]) + else: + ranges.sort() + x1, x2 = ranges[0] + for next_x1, next_x2 in ranges[slice_remaining]: + if next_x1 <= x2: + if next_x2 > x2: + x2 = next_x2 + else: + yield (y, x1, x2) + x1 = next_x1 + x2 = next_x2 + yield (y, x1, x2) + + def __rich_repr__(self) -> rich.repr.Result: + yield "size", self.size + yield "widgets", self.widgets + + def reflow(self, parent: Widget, size: Size) -> ReflowResult: + """Reflow (layout) widget and its children. + + Args: + parent (Widget): The root widget. + size (Size): Size of the area to be filled. + + Returns: + ReflowResult: Hidden shown and resized widgets + """ + self._cuts = None + self._layers = None + self._layers_visible = None + self._visible_widgets = None + self.root = parent + self.size = size + + # Keep a copy of the old map because we're going to compare it with the update + old_map = self.map.copy() + old_widgets = old_map.keys() + map, widgets = self._arrange_root(parent, size) + + new_widgets = map.keys() + + # Newly visible widgets + shown_widgets = new_widgets - old_widgets + # Newly hidden widgets + hidden_widgets = old_widgets - new_widgets + + # Replace map and widgets + self.map = map + self.widgets = widgets + + screen = size.region + + # Widgets with changed size + resized_widgets = { + widget + for widget, (region, *_) in map.items() + if widget in old_widgets and old_map[widget].region.size != region.size + } + + # Gets pairs of tuples of (Widget, MapGeometry) which have changed + # i.e. if something is moved / deleted / added + + if screen not in self._dirty_regions: + crop_screen = screen.intersection + changes = map.items() ^ old_map.items() + regions = { + region + for region in ( + crop_screen(map_geometry.visible_region) + for _, map_geometry in changes + ) + if region + } + self._dirty_regions.update(regions) + + return ReflowResult( + hidden=hidden_widgets, + shown=shown_widgets, + resized=resized_widgets, + ) + + @property + def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: + """Get a mapping of widgets on to region and clip. + + Returns: + dict[Widget, tuple[Region, Region]]: visible widget mapping. + """ + if self._visible_widgets is None: + screen = self.size.region + in_screen = screen.overlaps + overlaps = Region.overlaps + + # Widgets and regions in render order + visible_widgets = [ + (order, widget, region, clip) + for widget, (region, order, clip, _, _, _) in self.map.items() + if in_screen(region) and overlaps(clip, region) + ] + visible_widgets.sort(key=itemgetter(0), reverse=True) + self._visible_widgets = { + widget: (region, clip) for _, widget, region, clip in visible_widgets + } + return self._visible_widgets + + def _arrange_root( + self, root: Widget, size: Size + ) -> tuple[CompositorMap, set[Widget]]: + """Arrange a widgets children based on its layout attribute. + + Args: + root (Widget): Top level widget. + + Returns: + tuple[CompositorMap, set[Widget]]: Compositor map and set of widgets. + """ + + ORIGIN = Offset(0, 0) + + map: CompositorMap = {} + widgets: set[Widget] = set() + layer_order: int = 0 + + def add_widget( + widget: Widget, + virtual_region: Region, + region: Region, + order: tuple[tuple[int, ...], ...], + layer_order: int, + clip: Region, + ) -> None: + """Called recursively to place a widget and its children in the map. + + Args: + widget (Widget): The widget to add. + region (Region): The region the widget will occupy. + order (tuple[int, ...]): A tuple of ints to define the order. + clip (Region): The clipping region (i.e. the viewport which contains it). + """ + widgets.add(widget) + styles_offset = widget.styles.offset + layout_offset = ( + styles_offset.resolve(region.size, clip.size) + if styles_offset + else ORIGIN + ) + + # Container region is minus border + container_region = region.shrink(widget.styles.gutter) + container_size = container_region.size + + # Widgets with scrollbars (containers or scroll view) require additional processing + if widget.is_scrollable: + # The region that contains the content (container region minus scrollbars) + child_region = widget._get_scrollable_region(container_region) + + # Adjust the clip region accordingly + sub_clip = clip.intersection(child_region) + + # The region covered by children relative to parent widget + total_region = child_region.reset_offset + + if widget.is_container: + # Arrange the layout + placements, arranged_widgets, spacing = widget._arrange( + child_region.size + ) + widgets.update(arranged_widgets) + + # An offset added to all placements + placement_offset = container_region.offset + layout_offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get + + # Add all the widgets + for sub_region, margin, sub_widget, z, fixed in reversed( + placements + ): + # Combine regions with children to calculate the "virtual size" + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union( + sub_region.grow(spacing + margin) + ) + widget_region = sub_region + placement_scroll_offset + + widget_order = order + ( + (get_layer_index(sub_widget.layer, 0), z, layer_order), + ) + + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + layer_order, + sub_clip, + ) + layer_order -= 1 + + # Add any scrollbars + for chrome_widget, chrome_region in widget._arrange_scrollbars( + container_region + ): + map[chrome_widget] = MapGeometry( + chrome_region + layout_offset, + order, + clip, + container_size, + container_size, + chrome_region, + ) + + map[widget] = MapGeometry( + region + layout_offset, + order, + clip, + total_region.size, + container_size, + virtual_region, + ) + + else: + # Add the widget to the map + map[widget] = MapGeometry( + region + layout_offset, + order, + clip, + region.size, + container_size, + virtual_region, + ) + + # Add top level (root) widget + add_widget(root, size.region, size.region, ((0,),), layer_order, size.region) + return map, widgets + + @property + def layers(self) -> list[tuple[Widget, MapGeometry]]: + """Get widgets and geometry in layer order.""" + if self._layers is None: + self._layers = sorted( + self.map.items(), key=lambda item: item[1].order, reverse=True + ) + return self._layers + + @property + def layers_visible(self) -> list[list[tuple[Widget, Region, Region]]]: + """Visible widgets and regions in layers order.""" + + if self._layers_visible is None: + layers_visible: list[list[tuple[Widget, Region, Region]]] + layers_visible = [[] for y in range(self.size.height)] + layers_visible_appends = [layer.append for layer in layers_visible] + intersection = Region.intersection + _range = range + for widget, (region, clip) in self.visible_widgets.items(): + cropped_region = intersection(region, clip) + _x, region_y, _width, region_height = cropped_region + if region_height: + widget_location = (widget, cropped_region, region) + for y in _range(region_y, region_y + region_height): + layers_visible_appends[y](widget_location) + self._layers_visible = layers_visible + return self._layers_visible + + def get_offset(self, widget: Widget) -> Offset: + """Get the offset of a widget.""" + try: + return self.map[widget].region.offset + except KeyError: + raise errors.NoWidget("Widget is not in layout") + + def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: + """Get the widget under a given coordinate. + + Args: + x (int): X Coordinate. + y (int): Y Coordinate. + + Raises: + errors.NoWidget: If there is not widget underneath (x, y). + + Returns: + tuple[Widget, Region]: A tuple of the widget and its region. + """ + + contains = Region.contains + if len(self.layers_visible) > y >= 0: + for widget, cropped_region, region in self.layers_visible[y]: + if contains(cropped_region, x, y) and widget.visible: + return widget, region + raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") + + def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: + """Get all widgets under a given coordinate. + + Args: + x (int): X coordinate. + y (int): Y coordinate. + + Returns: + Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples. + """ + contains = Region.contains + for widget, cropped_region, region in self.layers_visible[y]: + if contains(cropped_region, x, y) and widget.visible: + yield widget, region + + def get_style_at(self, x: int, y: int) -> Style: + """Get the Style at the given cell or Style.null() + + Args: + x (int): X position within the Layout + y (int): Y position within the Layout + + Returns: + Style: The Style at the cell (x, y) within the Layout + """ + try: + widget, region = self.get_widget_at(x, y) + except errors.NoWidget: + return Style.null() + if widget not in self.visible_widgets: + return Style.null() + + x -= region.x + y -= region.y + + lines = widget.render_lines(Region(0, y, region.width, 1)) + + if not lines: + return Style.null() + end = 0 + for segment in lines[0]: + end += segment.cell_length + if x < end: + return segment.style or Style.null() + return Style.null() + + def find_widget(self, widget: Widget) -> MapGeometry: + """Get information regarding the relative position of a widget in the Compositor. + + Args: + widget (Widget): The Widget in this layout you wish to know the Region of. + + Raises: + NoWidget: If the Widget is not contained in this Layout. + + Returns: + MapGeometry: Widget's composition information. + + """ + try: + region = self.map[widget] + except KeyError: + raise errors.NoWidget("Widget is not in layout") + else: + return region + + @property + def cuts(self) -> list[list[int]]: + """Get vertical cuts. + + A cut is every point on a line where a widget starts or ends. + + Returns: + list[list[int]]: A list of cuts for every line. + """ + if self._cuts is not None: + return self._cuts + + width, height = self.size + screen_region = self.size.region + cuts = [[0, width] for _ in range(height)] + + intersection = Region.intersection + extend = list.extend + + for region, clip in self.visible_widgets.values(): + region = intersection(region, clip) + if region and (region in screen_region): + x, y, region_width, region_height = region + region_cuts = (x, x + region_width) + for cut in cuts[y : y + region_height]: + extend(cut, region_cuts) + + # Sort the cuts for each line + self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts] + + return self._cuts + + def _get_renders( + self, crop: Region | None = None + ) -> Iterable[tuple[Region, Region, Lines]]: + """Get rendered widgets (lists of segments) in the composition. + + Returns: + Iterable[tuple[Region, Region, Lines]]: An iterable of , , and + """ + # If a renderable throws an error while rendering, the user likely doesn't care about the traceback + # up to this point. + _rich_traceback_guard = True + + if not self.map: + return + + def is_visible(widget: Widget) -> bool: + """Return True if the widget is (literally) visible by examining various + properties which affect whether it can be seen or not.""" + return ( + widget.visible + and not widget.is_transparent + and widget.styles.opacity > 0 + ) + + _Region = Region + + visible_widgets = self.visible_widgets + + if crop: + crop_overlaps = crop.overlaps + widget_regions = [ + (widget, region, clip) + for widget, (region, clip) in visible_widgets.items() + if crop_overlaps(clip) and is_visible(widget) + ] + else: + widget_regions = [ + (widget, region, clip) + for widget, (region, clip) in visible_widgets.items() + if is_visible(widget) + ] + + intersection = _Region.intersection + contains_region = _Region.contains_region + + for widget, region, clip in widget_regions: + if contains_region(clip, region): + yield region, clip, widget.render_lines( + _Region(0, 0, region.width, region.height) + ) + else: + clipped_region = intersection(region, clip) + if not clipped_region: + continue + new_x, new_y, new_width, new_height = clipped_region + delta_x = new_x - region.x + delta_y = new_y - region.y + yield region, clip, widget.render_lines( + _Region(delta_x, delta_y, new_width, new_height) + ) + + @classmethod + def _assemble_chops( + cls, chops: list[dict[int, list[Segment] | None]] + ) -> list[list[Segment]]: + """Combine chops in to lines.""" + from_iterable = chain.from_iterable + segment_lines: list[list[Segment]] = [ + list(from_iterable(line for line in bucket.values() if line is not None)) + for bucket in chops + ] + return segment_lines + + def render(self, full: bool = False) -> RenderableType | None: + """Render a layout. + + Returns: + SegmentLines: A renderable + """ + + width, height = self.size + screen_region = Region(0, 0, width, height) + + if full: + update_regions: set[Region] = set() + else: + update_regions = self._dirty_regions.copy() + if screen_region in update_regions: + # If one of the updates is the entire screen, then we only need one update + full = True + self._dirty_regions.clear() + + if full: + crop = screen_region + spans = [] + is_rendered_line = lambda y: True + elif update_regions: + # Create a crop regions that surrounds all updates + crop = Region.from_union(update_regions).intersection(screen_region) + spans = list(self._regions_to_spans(update_regions)) + is_rendered_line = {y for y, _, _ in spans}.__contains__ + else: + return None + + divide = Segment.divide + + # Maps each cut on to a list of segments + cuts = self.cuts + + # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None. + fromkeys = cast( + "Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys + ) + # A mapping of cut index to a list of segments for each line + chops: list[dict[int, list[Segment] | None]] + chops = [fromkeys(cut_set[:-1]) for cut_set in cuts] + + cut_segments: Iterable[list[Segment]] + + # Go through all the renders in reverse order and fill buckets with no render + renders = self._get_renders(crop) + intersection = Region.intersection + + for region, clip, lines in renders: + render_region = intersection(region, clip) + + for y, line in zip(render_region.line_range, lines): + if not is_rendered_line(y): + continue + + chops_line = chops[y] + + first_cut, last_cut = render_region.column_span + cuts_line = cuts[y] + final_cuts = [ + cut for cut in cuts_line if (last_cut >= cut >= first_cut) + ] + if len(final_cuts) <= 2: + # Two cuts, which means the entire line + cut_segments = [line] + else: + render_x = render_region.x + relative_cuts = [cut - render_x for cut in final_cuts[1:]] + cut_segments = divide(line, relative_cuts) + + # Since we are painting front to back, the first segments for a cut "wins" + for cut, segments in zip(final_cuts, cut_segments): + if chops_line[cut] is None: + chops_line[cut] = segments + + if full: + render_lines = self._assemble_chops(chops) + return LayoutUpdate(render_lines, screen_region) + else: + chop_ends = [cut_set[1:] for cut_set in cuts] + return ChopsUpdate(chops, spans, chop_ends) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + if self._dirty_regions: + yield self.render() + + def update_widgets(self, widgets: set[Widget]) -> None: + """Update a given widget in the composition. + + Args: + console (Console): Console instance. + widget (Widget): Widget to update. + + """ + regions: list[Region] = [] + add_region = regions.append + get_widget = self.visible_widgets.__getitem__ + for widget in self.visible_widgets.keys() & widgets: + region, clip = get_widget(widget) + offset = region.offset + intersection = clip.intersection + for dirty_region in widget._exchange_repaint_regions(): + update_region = intersection(dirty_region.translate(offset)) + if update_region: + add_region(update_region) + + self._dirty_regions.update(regions) diff --git a/src/textual/_context.py b/src/textual/_context.py index e16817631..04b264d33 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -5,4 +5,9 @@ from contextvars import ContextVar if TYPE_CHECKING: from .app import App + +class NoActiveAppError(RuntimeError): + pass + + active_app: ContextVar["App"] = ContextVar("active_app") diff --git a/src/textual/_doc.py b/src/textual/_doc.py new file mode 100644 index 000000000..17918e351 --- /dev/null +++ b/src/textual/_doc.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os +import shlex +from typing import Iterable + +from textual.app import App +from textual._import_app import 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) -> str: + """A superfences formatter to insert an SVG screenshot.""" + + try: + cmd: list[str] = shlex.split(attrs["path"]) + path = cmd[0] + + _press = attrs.get("press", None) + press = [*_press.split(",")] if _press else ["_"] + title = attrs.get("title") + + print(f"screenshotting {path!r}") + + cwd = os.getcwd() + try: + rows = int(attrs.get("lines", 24)) + columns = int(attrs.get("columns", 80)) + svg = take_svg_screenshot( + None, path, press, title, terminal_size=(rows, columns) + ) + finally: + os.chdir(cwd) + + assert svg is not None + return svg + + except Exception as error: + import traceback + + traceback.print_exception(error) + + +def take_svg_screenshot( + app: App | None = None, + app_path: str | None = None, + press: Iterable[str] = ("_",), + title: str | None = None, + terminal_size: tuple[int, int] = (24, 80), +) -> str: + """ + + Args: + app: An app instance. Must be supplied if app_path is not. + app_path: A path to an app. Must be supplied if app is not. + press: Key presses to run before taking screenshot. "_" is a short pause. + title: The terminal title in the output image. + terminal_size: A pair of integers (rows, columns), representing terminal size. + + Returns: + str: An SVG string, showing the content of the terminal window at the time + the screenshot was taken. + + """ + rows, columns = terminal_size + + os.environ["COLUMNS"] = str(columns) + os.environ["LINES"] = str(rows) + + if app is None: + app = import_app(app_path) + + if title is None: + title = app.title + + app.run( + quit_after=5, + press=press or ["ctrl+c"], + headless=True, + screenshot=True, + screenshot_title=title, + ) + svg = app._screenshot + return svg + + +def rich(source, language, css_class, options, md, attrs, **kwargs) -> str: + """A superfences formatter to insert an SVG screenshot.""" + + import io + + from rich.console import Console + + title = attrs.get("title", "Rich") + + console = Console( + file=io.StringIO(), + record=True, + force_terminal=True, + color_system="truecolor", + ) + error_console = Console(stderr=True) + + globals: dict = {} + try: + exec(source, globals) + except Exception: + error_console.print_exception() + # console.bell() + + if "output" in globals: + console.print(globals["output"]) + output_svg = console.export_svg(title=title) + return output_svg diff --git a/src/textual/_duration.py b/src/textual/_duration.py new file mode 100644 index 000000000..c7479167b --- /dev/null +++ b/src/textual/_duration.py @@ -0,0 +1,47 @@ +import re + +_match_duration = re.compile(r"^(-?\d+\.?\d*)(s|ms)$").match + + +class DurationError(Exception): + """ + Exception indicating a general issue with a CSS duration. + """ + + +class DurationParseError(DurationError): + """ + Indicates a malformed duration string that could not be parsed. + """ + + +def _duration_as_seconds(duration: str) -> float: + """ + Args: + duration (str): A string of the form ``"2s"`` or ``"300ms"``, representing 2 seconds and + 300 milliseconds respectively. If no unit is supplied, e.g. ``"2"``, then the duration is + assumed to be in seconds. + Raises: + DurationParseError: If the argument ``duration`` is not a valid duration string. + Returns: + float: The duration in seconds. + + """ + match = _match_duration(duration) + + if match: + value, unit_name = match.groups() + value = float(value) + if unit_name == "ms": + duration_secs = value / 1000 + else: + duration_secs = value + else: + try: + duration_secs = float(duration) + except ValueError: + raise DurationParseError( + f"{duration!r} is not a valid duration." + ) from ValueError + + return duration_secs diff --git a/src/textual/_event_broker.py b/src/textual/_event_broker.py index a5bd074ac..1b63a6cf5 100644 --- a/src/textual/_event_broker.py +++ b/src/textual/_event_broker.py @@ -24,5 +24,4 @@ def extract_handler_actions(event_name: str, meta: dict[str, Any]) -> HandlerArg if __name__ == "__main__": - print(extract_handler_actions("mouse.down", {"@mouse.down.hot": "app.bell()"})) diff --git a/src/textual/_filter.py b/src/textual/_filter.py new file mode 100644 index 000000000..3857d6812 --- /dev/null +++ b/src/textual/_filter.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from functools import lru_cache + +from rich.segment import Segment +from rich.style import Style + +from .color import Color + + +class LineFilter(ABC): + """Base class for a line filter.""" + + @abstractmethod + def filter(self, segments: list[Segment]) -> list[Segment]: + """Transform a list of segments.""" + + +class Monochrome(LineFilter): + """Convert all colors to monochrome.""" + + def filter(self, segments: list[Segment]) -> list[Segment]: + to_monochrome = self.to_monochrome + _Segment = Segment + return [ + _Segment(text, to_monochrome(style), None) for text, style, _ in segments + ] + + @lru_cache(1024) + def to_monochrome(self, style: Style) -> Style: + """Convert colors in a style to monochrome. + + Args: + style (Style): A Rich Style. + + Returns: + Style: A new Rich style. + """ + style_color = style.color + style_background = style.bgcolor + color = ( + None + if style_color is None + else Color.from_rich_color(style_color).monochrome.rich_color + ) + background = ( + None + if style_background is None + else Color.from_rich_color(style_background).monochrome.rich_color + ) + return style + Style.from_color(color, background) diff --git a/src/textual/_import_app.py b/src/textual/_import_app.py new file mode 100644 index 000000000..ee75227fb --- /dev/null +++ b/src/textual/_import_app.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import os +import runpy +import shlex +from pathlib import Path +from typing import cast, TYPE_CHECKING + + +if TYPE_CHECKING: + from textual.app import App + + +class AppFail(Exception): + pass + + +def import_app(import_name: str) -> App: + """Import an app from a path or import name. + + Args: + import_name (str): A name to import, such as `foo.bar`, or a path ending with .py. + + Raises: + AppFail: If the app could not be found for any reason. + + Returns: + App: A Textual application + """ + + import inspect + import importlib + import sys + + from textual.app import App, WINDOWS + + import_name, *argv = shlex.split(import_name, posix=not WINDOWS) + lib, _colon, name = import_name.partition(":") + + if lib.endswith(".py"): + path = os.path.abspath(lib) + sys.path.append(str(Path(path).parent)) + try: + global_vars = runpy.run_path(path, {}) + except Exception as error: + raise AppFail(str(error)) + + if "sys" in global_vars: + global_vars["sys"].argv = [path, *argv] + + if name: + # User has given a name, use that + try: + app = global_vars[name] + except KeyError: + raise AppFail(f"App {name!r} not found in {lib!r}") + else: + # User has not given a name + if "app" in global_vars: + # App exists, lets use that + try: + app = global_vars["app"] + except KeyError: + raise AppFail(f"App {name!r} not found in {lib!r}") + else: + # Find a App class or instance that is *not* the base class + apps = [ + value + for value in global_vars.values() + if ( + isinstance(value, App) + or (inspect.isclass(value) and issubclass(value, App)) + and value is not App + ) + ] + if not apps: + raise AppFail( + f'Unable to find app in {lib!r}, try specifying app with "foo.py:app"' + ) + if len(apps) > 1: + raise AppFail( + f'Multiple apps found {lib!r}, try specifying app with "foo.py:app"' + ) + app = apps[0] + app._BASE_PATH = path + + else: + # Assuming the user wants to import the file + sys.path.append("") + try: + module = importlib.import_module(lib) + except ImportError as error: + raise AppFail(str(error)) + + try: + app = getattr(module, name or "app") + except AttributeError: + raise AppFail(f"Unable to find {name!r} in {module!r}") + + if inspect.isclass(app) and issubclass(app, App): + app = app() + + return cast(App, app) diff --git a/src/textual/_layout.py b/src/textual/_layout.py new file mode 100644 index 000000000..a516ff0fe --- /dev/null +++ b/src/textual/_layout.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +import sys +from typing import ClassVar, NamedTuple, TYPE_CHECKING + + +from .geometry import Region, Size, Spacing + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +if TYPE_CHECKING: + from .widget import Widget + + +ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" +DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" + + +class WidgetPlacement(NamedTuple): + """The position, size, and relative order of a widget within its parent.""" + + region: Region + margin: Spacing + widget: Widget + order: int = 0 + fixed: bool = False + + +class Layout(ABC): + """Responsible for arranging Widgets in a view and rendering them.""" + + name: ClassVar[str] = "" + + def __repr__(self) -> str: + return f"<{self.name}>" + + @abstractmethod + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: + """Generate a layout map that defines where on the screen the widgets will be drawn. + + Args: + parent (Widget): Parent widget. + size (Size): Size of container. + + Returns: + Iterable[WidgetPlacement]: An iterable of widget location + """ + + def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: + """Get the width of the content. + + Args: + widget (Widget): The container widget. + container (Size): The container size. + viewport (Size): The viewport size. + + Returns: + int: Width of the content. + """ + width: int | None = None + gutter_width = widget.gutter.width + for child in widget.displayed_children: + if not child.is_container: + child_width = ( + child.get_content_width(container, viewport) + + gutter_width + + child.gutter.width + ) + width = child_width if width is None else max(width, child_width) + if width is None: + width = container.width + + return width + + def get_content_height( + self, widget: Widget, container: Size, viewport: Size, width: int + ) -> int: + """Get the content height. + + Args: + widget (Widget): The container widget. + container (Size): The container size. + viewport (Size): The viewport. + width (int): The content width. + + Returns: + int: Content height (in lines). + """ + if not widget.displayed_children: + height = container.height + else: + placements, *_ = widget._arrange(Size(width, container.height)) + height = max( + placement.region.bottom + placement.margin.bottom + for placement in placements + ) + return height diff --git a/src/textual/_layout_resolve.py b/src/textual/_layout_resolve.py index e8530c3ad..6a650f42b 100644 --- a/src/textual/_layout_resolve.py +++ b/src/textual/_layout_resolve.py @@ -1,8 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass import sys from fractions import Fraction -from typing import cast, List, Optional, Sequence +from typing import cast, Sequence + if sys.version_info >= (3, 8): from typing import Protocol @@ -10,15 +12,25 @@ else: from typing_extensions import Protocol # pragma: no cover -class Edge(Protocol): +class EdgeProtocol(Protocol): """Any object that defines an edge (such as Layout).""" - size: Optional[int] = None - fraction: int = 1 + # Size of edge in cells, or None for no fixed size + size: int | None + # Portion of flexible space to use if size is None + fraction: int + # Minimum size for edge, in cells + min_size: int + + +@dataclass +class Edge: + size: int | None = None + fraction: int | None = 1 min_size: int = 1 -def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]: +def layout_resolve(total: int, edges: Sequence[EdgeProtocol]) -> list[int]: """Divide total space to satisfy size, fraction, and min_size, constraints. The returned list of integers should add up to total in most cases, unless it is @@ -29,52 +41,58 @@ def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]: Args: total (int): Total number of characters. - edges (List[Edge]): Edges within total space. + edges (Sequence[Edge]): Edges within total space. Returns: - List[int]: Number of characters for each edge. + list[int]: Number of characters for each edge. """ # Size of edge or None for yet to be determined sizes = [(edge.size or None) for edge in edges] - _Fraction = Fraction + if None not in sizes: + # No flexible edges + return cast("list[int]", sizes) - # While any edges haven't been calculated - while None in sizes: - # Get flexible edges and index to map these back on to sizes list - flexible_edges = [ - (index, edge) - for index, (size, edge) in enumerate(zip(sizes, edges)) - if size is None + # Get flexible edges and index to map these back on to sizes list + flexible_edges = [ + (index, edge) + for index, (size, edge) in enumerate(zip(sizes, edges)) + if size is None + ] + # Remaining space in total + remaining = total - sum([size or 0 for size in sizes]) + if remaining <= 0: + # No room for flexible edges + return [ + ((edge.min_size or 1) if size is None else size) + for size, edge in zip(sizes, edges) ] - # Remaining space in total - remaining = total - sum(size or 0 for size in sizes) - if remaining <= 0: - # No room for flexible edges - return [ - ((edge.min_size or 1) if size is None else size) - for size, edge in zip(sizes, edges) - ] + + # Get the total fraction value for all flexible edges + total_flexible = sum([(edge.fraction or 1) for _, edge in flexible_edges]) + while flexible_edges: # Calculate number of characters in a ratio portion - portion = _Fraction( - remaining, sum((edge.fraction or 1) for _, edge in flexible_edges) - ) + portion = Fraction(remaining, total_flexible) # If any edges will be less than their minimum, replace size with the minimum - for index, edge in flexible_edges: - if portion * edge.fraction <= edge.min_size: + for flexible_index, (index, edge) in enumerate(flexible_edges): + if portion * edge.fraction < edge.min_size: + # This flexible edge will be smaller than its minimum size + # We need to fix the size and redistribute the outstanding space sizes[index] = edge.min_size + remaining -= edge.min_size + total_flexible -= edge.fraction or 1 + del flexible_edges[flexible_index] # New fixed size will invalidate calculations, so we need to repeat the process break else: # Distribute flexible space and compensate for rounding error # Since edge sizes can only be integers we need to add the remainder # to the following line - remainder = _Fraction(0) + remainder = Fraction(0) for index, edge in flexible_edges: - size, remainder = divmod(portion * edge.fraction + remainder, 1) - sizes[index] = size + sizes[index], remainder = divmod(portion * edge.fraction + remainder, 1) break # Sizes now contains integers only - return cast(List[int], sizes) + return cast("list[int]", sizes) diff --git a/src/textual/_line_cache.py b/src/textual/_line_cache.py deleted file mode 100644 index 23c9a6da2..000000000 --- a/src/textual/_line_cache.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - - -from typing import Iterable - -from rich.cells import cell_len -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult -from rich.control import Control -from rich.segment import Segment -from rich.style import Style - -from ._loop import loop_last - - -class LineCache: - def __init__(self, lines: list[list[Segment]]) -> None: - self.lines = lines - self._dirty = [True] * len(self.lines) - - @classmethod - def from_renderable( - cls, - console: Console, - renderable: RenderableType, - width: int, - height: int, - ) -> "LineCache": - options = console.options.update_dimensions(width, height) - lines = console.render_lines(renderable, options) - return cls(lines) - - @property - def dirty(self) -> bool: - return any(self._dirty) - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - - new_line = Segment.line() - for line in self.lines: - yield from line - yield new_line - - def render(self, x: int, y: int, width: int, height: int) -> Iterable[Segment]: - move_to = Control.move_to - lines = self.lines[:height] - new_line = Segment.line() - for last, (offset_y, (line, dirty)) in loop_last( - enumerate(zip(lines, self._dirty), y) - ): - if dirty: - yield move_to(x, offset_y).segment - yield from Segment.adjust_line_length(line, width) - if not last: - yield new_line - self._dirty[:] = [False] * len(self.lines) - - def get_style_at(self, x: int, y: int) -> Style: - try: - line = self.lines[y] - except IndexError: - return Style.null() - end = 0 - for segment in line: - end += cell_len(segment.text) - if x < end: - return segment.style or Style.null() - return Style.null() diff --git a/src/textual/_lines.py b/src/textual/_lines.py deleted file mode 100644 index 9dfca984f..000000000 --- a/src/textual/_lines.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from rich.segment import Segment - -from .geometry import Region -from ._types import Lines - - -def crop_lines(lines: Lines, clip: Region) -> Lines: - lines = lines[clip.y : clip.y + clip.height] - - def width_view(line: list[Segment]) -> list[Segment]: - _, line = Segment.divide(line, [clip.x, clip.x + clip.width]) - return line - - cropped_lines = [width_view(line) for line in lines] - return cropped_lines diff --git a/src/textual/_log.py b/src/textual/_log.py new file mode 100644 index 000000000..e9adcef42 --- /dev/null +++ b/src/textual/_log.py @@ -0,0 +1,21 @@ +from enum import Enum + + +class LogGroup(Enum): + """A log group is a classification of the log message (*not* a level).""" + + UNDEFINED = 0 # Mainly for testing + EVENT = 1 + DEBUG = 2 + INFO = 3 + WARNING = 4 + ERROR = 5 + PRINT = 6 + SYSTEM = 7 + + +class LogVerbosity(Enum): + """Tags log messages as being verbose and potentially excluded from output.""" + + NORMAL = 0 + HIGH = 1 diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py new file mode 100644 index 000000000..fa5570fc4 --- /dev/null +++ b/src/textual/_node_list.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator, Sequence, overload + +import rich.repr + +if TYPE_CHECKING: + from .widget import Widget + + +@rich.repr.auto(angular=True) +class NodeList(Sequence): + """ + A container for widgets that forms one level of hierarchy. + + Although named a list, widgets may appear only once, making them more like a set. + + """ + + def __init__(self) -> None: + # The nodes in the list + self._nodes: list[Widget] = [] + self._nodes_set: set[Widget] = set() + # Increments when list is updated (used for caching) + self._updates = 0 + + def __bool__(self) -> bool: + return bool(self._nodes) + + def __length_hint__(self) -> int: + return len(self._nodes) + + def __rich_repr__(self) -> rich.repr.Result: + yield self._nodes + + def __len__(self) -> int: + return len(self._nodes) + + def __contains__(self, widget: Widget) -> bool: + return widget in self._nodes + + def _append(self, widget: Widget) -> None: + """Append a Widget. + + Args: + widget (Widget): A widget. + """ + if widget not in self._nodes_set: + self._nodes.append(widget) + self._nodes_set.add(widget) + self._updates += 1 + + def _remove(self, widget: Widget) -> None: + """Remove a widget from the list. + + Removing a widget not in the list is a null-op. + + Args: + widget (Widget): A Widget in the list. + """ + if widget in self._nodes_set: + del self._nodes[self._nodes.index(widget)] + self._nodes_set.remove(widget) + self._updates += 1 + + def _clear(self) -> None: + """Clear the node list.""" + if self._nodes: + self._nodes.clear() + self._nodes_set.clear() + self._updates += 1 + + def __iter__(self) -> Iterator[Widget]: + return iter(self._nodes) + + def __reversed__(self) -> Iterator[Widget]: + return reversed(self._nodes) + + @overload + def __getitem__(self, index: int) -> Widget: + ... + + @overload + def __getitem__(self, index: slice) -> list[Widget]: + ... + + def __getitem__(self, index: int | slice) -> Widget | list[Widget]: + return self._nodes[index] diff --git a/src/textual/_opacity.py b/src/textual/_opacity.py new file mode 100644 index 000000000..026da6d84 --- /dev/null +++ b/src/textual/_opacity.py @@ -0,0 +1,45 @@ +from typing import Iterable + +from rich.segment import Segment +from rich.style import Style + +from textual.color import Color + + +def _apply_opacity( + segments: Iterable[Segment], + base_background: Color, + opacity: float, +) -> Iterable[Segment]: + """Takes an iterable of foreground Segments and blends them into the supplied + background color, yielding copies of the Segments with blended foreground and + background colors applied. + + Args: + segments (Iterable[Segment]): The segments in the foreground. + base_background (Color): The background color to blend foreground into. + opacity (float): The blending factor. A value of 1.0 means output segments will + have identical foreground and background colors to input segments. + """ + _Segment = Segment + from_rich_color = Color.from_rich_color + from_color = Style.from_color + blend = base_background.blend + for segment in segments: + text, style, _ = segment + if not style: + yield segment + continue + + blended_style = style + if style.color: + color = from_rich_color(style.color) + blended_foreground = blend(color, factor=opacity) + blended_style += from_color(color=blended_foreground.rich_color) + + if style.bgcolor: + bgcolor = from_rich_color(style.bgcolor) + blended_background = blend(bgcolor, factor=opacity) + blended_style += from_color(bgcolor=blended_background.rich_color) + + yield _Segment(text, blended_style) diff --git a/src/textual/_parser.py b/src/textual/_parser.py index 60ed2d3c7..f01ca6f56 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -9,7 +9,6 @@ from typing import ( TypeVar, Generic, Union, - Iterator, Iterable, ) @@ -48,7 +47,7 @@ class _ReadUntil(Awaitable): self.max_bytes = max_bytes -class PeekBuffer(Awaitable): +class _PeekBuffer(Awaitable): __slots__: list[str] = [] @@ -62,7 +61,7 @@ class Parser(Generic[T]): read = _Read read1 = _Read1 read_until = _ReadUntil - peek_buffer = PeekBuffer + peek_buffer = _PeekBuffer def __init__(self) -> None: self._buffer = io.StringIO() @@ -104,14 +103,14 @@ class Parser(Generic[T]): while tokens: yield popleft() - while pos < data_size or isinstance(self._awaiting, PeekBuffer): + while pos < data_size or isinstance(self._awaiting, _PeekBuffer): _awaiting = self._awaiting if isinstance(_awaiting, _Read1): self._awaiting = self._gen.send(data[pos : pos + 1]) pos += 1 - elif isinstance(_awaiting, PeekBuffer): + elif isinstance(_awaiting, _PeekBuffer): self._awaiting = self._gen.send(data[pos:]) elif isinstance(_awaiting, _Read): @@ -167,7 +166,6 @@ if __name__ == "__main__": def parse( self, on_token: Callable[[str], None] ) -> Generator[Awaitable, str, None]: - data = yield self.read1() while True: data = yield self.read1() if not data: diff --git a/src/textual/_partition.py b/src/textual/_partition.py new file mode 100644 index 000000000..734cdad61 --- /dev/null +++ b/src/textual/_partition.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Callable, Iterable, TypeVar + + +T = TypeVar("T") + + +def partition( + pred: Callable[[T], object], iterable: Iterable[T] +) -> tuple[list[T], list[T]]: + """Partition a sequence in to two list from a given predicate. The first list will contain + the values where the predicate is False, the second list will contain the remaining values. + + Args: + pred (Callable[[T], object]): A callable that returns True or False for a given value. + iterable (Iterable[T]): In Iterable of values. + + Returns: + tuple[list[T], list[T]]: A list of values where the predicate is False, and a list + where the predicate is True. + """ + + result: tuple[list[T], list[T]] = ([], []) + appends = (result[0].append, result[1].append) + + for value in iterable: + appends[1 if pred(value) else 0](value) + return result diff --git a/src/textual/_path.py b/src/textual/_path.py new file mode 100644 index 000000000..28a9ba1bc --- /dev/null +++ b/src/textual/_path.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import inspect +import sys +from pathlib import Path, PurePath + + +def _make_path_object_relative(path: str | PurePath, obj: object) -> Path: + """Convert the supplied path to a Path object that is relative to a given Python object. + If the supplied path is absolute, it will simply be converted to a Path object. + Used, for example, to return the path of a CSS file relative to a Textual App instance. + + Args: + path (str | Path): A path. + obj (object): A Python object to resolve the path relative to. + + Returns: + Path: A resolved Path object, relative to obj + """ + path = Path(path) + + # If the path supplied by the user is absolute, we can use it directly + if path.is_absolute(): + return path + + # Otherwise (relative path), resolve it relative to obj... + base_path = getattr(obj, "_BASE_PATH", None) + if base_path is not None: + subclass_path = Path(base_path) + else: + subclass_path = Path(inspect.getfile(obj.__class__)) + resolved_path = (subclass_path.parent / path).resolve() + return resolved_path diff --git a/src/textual/_profile.py b/src/textual/_profile.py index 32db80262..a64f6402f 100644 --- a/src/textual/_profile.py +++ b/src/textual/_profile.py @@ -3,10 +3,9 @@ Timer context manager, only used in debug. """ -from time import time - import contextlib from typing import Generator +from time import perf_counter from . import log @@ -14,8 +13,8 @@ from . import log @contextlib.contextmanager def timer(subject: str = "time") -> Generator[None, None, None]: """print the elapsed time. (only used in debugging)""" - start = time() + start = perf_counter() yield - elapsed = time() - start + elapsed = perf_counter() - start elapsed_ms = elapsed * 1000 log(f"{subject} elapsed {elapsed_ms:.2f}ms") diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py new file mode 100644 index 000000000..cf10dfcb5 --- /dev/null +++ b/src/textual/_resolve.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from fractions import Fraction +from itertools import accumulate +from typing import cast, Sequence + +from .css.scalar import Scalar +from .geometry import Size + + +def resolve( + dimensions: Sequence[Scalar], + total: int, + gutter: int, + size: Size, + viewport: Size, +) -> list[tuple[int, int]]: + """Resolve a list of dimensions. + + Args: + dimensions (Sequence[Scalar]): Scalars for column / row sizes. + total (int): Total space to divide. + gutter (int): Gutter between rows / columns. + size (Size): Size of container. + viewport (Size): Size of viewport. + + Returns: + list[tuple[int, int]]: List of (, ) + """ + + resolved: list[tuple[Scalar, Fraction | None]] = [ + ( + (scalar, None) + if scalar.is_fraction + else (scalar, scalar.resolve_dimension(size, viewport)) + ) + for scalar in dimensions + ] + + from_float = Fraction.from_float + total_fraction = from_float( + sum(scalar.value for scalar, fraction in resolved if fraction is None) + ) + + if total_fraction: + total_gutter = gutter * (len(dimensions) - 1) + consumed = sum(fraction for _, fraction in resolved if fraction is not None) + remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed) + fraction_unit = Fraction(remaining, total_fraction) + resolved_fractions = [ + from_float(scalar.value) * fraction_unit if fraction is None else fraction + for scalar, fraction in resolved + ] + else: + resolved_fractions = cast( + "list[Fraction]", [fraction for _, fraction in resolved] + ) + + fraction_gutter = Fraction(gutter) + offsets = [0] + [ + int(fraction) + for fraction in accumulate( + value + for fraction in resolved_fractions + for value in (fraction, fraction_gutter) + ) + ] + results = [ + (offset1, offset2 - offset1) + for offset1, offset2 in zip(offsets[::2], offsets[1::2]) + ] + + return results diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py new file mode 100644 index 000000000..b2e4a13f7 --- /dev/null +++ b/src/textual/_segment_tools.py @@ -0,0 +1,193 @@ +""" +Tools for processing Segments, or lists of Segments. +""" + +from __future__ import annotations + +from typing import Iterable + +from rich.segment import Segment +from rich.style import Style + +from ._cells import cell_len +from ._types import Lines +from .css.types import AlignHorizontal, AlignVertical +from .geometry import Size + + +def line_crop( + segments: list[Segment], start: int, end: int, total: int +) -> list[Segment]: + """Crops a list of segments between two cell offsets. + + Args: + segments (list[Segment]): A list of Segments for a line. + start (int): Start offset + end (int): End offset (exclusive) + total (int): Total cell length of segments. + Returns: + list[Segment]: A new shorter list of segments + """ + # This is essentially a specialized version of Segment.divide + # The following line has equivalent functionality (but a little slower) + # return list(Segment.divide(segments, [start, end]))[1] + + _cell_len = cell_len + pos = 0 + output_segments: list[Segment] = [] + add_segment = output_segments.append + iter_segments = iter(segments) + segment: Segment | None = None + for segment in iter_segments: + end_pos = pos + _cell_len(segment.text) + if end_pos > start: + segment = segment.split_cells(start - pos)[1] + break + pos = end_pos + else: + return [] + + if end >= total: + # The end crop is the end of the segments, so we can collect all remaining segments + if segment: + add_segment(segment) + output_segments.extend(iter_segments) + return output_segments + + pos = start + while segment is not None: + end_pos = pos + _cell_len(segment.text) + if end_pos < end: + add_segment(segment) + else: + add_segment(segment.split_cells(end - pos)[0]) + break + pos = end_pos + segment = next(iter_segments, None) + + return output_segments + + +def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]: + """Optionally remove a cell from the start and / or end of a list of segments. + + Args: + segments (list[Segment]): A line (list of Segments) + start (bool): Remove cell from start. + end (bool): Remove cell from end. + + Returns: + list[Segment]: A new list of segments. + """ + segments = segments.copy() + if segments and start: + _, first_segment = segments[0].split_cells(1) + if first_segment.text: + segments[0] = first_segment + else: + segments.pop(0) + if segments and end: + last_segment = segments[-1] + last_segment, _ = last_segment.split_cells(len(last_segment.text) - 1) + if last_segment.text: + segments[-1] = last_segment + else: + segments.pop() + return segments + + +def line_pad( + segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style +) -> list[Segment]: + """Adds padding to the left and / or right of a list of segments. + + Args: + segments (Iterable[Segment]): A line of segments. + pad_left (int): Cells to pad on the left. + pad_right (int): Cells to pad on the right. + style (Style): Style of padded cells. + + Returns: + list[Segment]: A new line with padding. + """ + if pad_left and pad_right: + return [ + Segment(" " * pad_left, style), + *segments, + Segment(" " * pad_right, style), + ] + elif pad_left: + return [ + Segment(" " * pad_left, style), + *segments, + ] + elif pad_right: + return [ + *segments, + Segment(" " * pad_right, style), + ] + return list(segments) + + +def align_lines( + lines: Lines, + style: Style, + size: Size, + horizontal: AlignHorizontal, + vertical: AlignVertical, +) -> Iterable[list[Segment]]: + """Align lines. + + Args: + lines (Lines): A list of lines. + style (Style): Background style. + size (Size): Size of container. + horizontal (AlignHorizontal): Horizontal alignment. + vertical (AlignVertical): Vertical alignment + + Returns: + Iterable[list[Segment]]: Aligned lines. + + """ + + width, height = size + shape_width, shape_height = Segment.get_shape(lines) + + def blank_lines(count: int) -> Lines: + return [[Segment(" " * width, style)]] * count + + top_blank_lines = bottom_blank_lines = 0 + vertical_excess_space = max(0, height - shape_height) + + if vertical == "top": + bottom_blank_lines = vertical_excess_space + elif vertical == "middle": + top_blank_lines = vertical_excess_space // 2 + bottom_blank_lines = height - top_blank_lines + elif vertical == "bottom": + top_blank_lines = vertical_excess_space + + yield from blank_lines(top_blank_lines) + + horizontal_excess_space = max(0, width - shape_width) + + adjust_line_length = Segment.adjust_line_length + if horizontal == "left": + for line in lines: + yield adjust_line_length(line, width, style, pad=True) + + elif horizontal == "center": + left_space = horizontal_excess_space // 2 + for line in lines: + yield [ + Segment(" " * left_space, style), + *adjust_line_length(line, width - left_space, style, pad=True), + ] + + elif horizontal == "right": + get_line_length = Segment.get_line_length + for line in lines: + left_space = width - get_line_length(line) + yield [Segment(" " * left_space, style), *line] + + yield from blank_lines(bottom_blank_lines) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py new file mode 100644 index 000000000..ae795a12e --- /dev/null +++ b/src/textual/_styles_cache.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING, Callable, Iterable, List + +from rich.segment import Segment +from rich.style import Style + +from ._border import get_box, render_row +from ._filter import LineFilter +from ._opacity import _apply_opacity +from ._segment_tools import line_crop, line_pad, line_trim +from ._types import Lines +from .color import Color +from .geometry import Region, Size, Spacing +from .renderables.text_opacity import TextOpacity +from .renderables.tint import Tint + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +if TYPE_CHECKING: + from .css.styles import StylesBase + from .widget import Widget + + +RenderLineCallback: TypeAlias = Callable[[int], List[Segment]] + + +def style_links( + segments: Iterable[Segment], link_id: str, link_style: Style +) -> list[Segment]: + """Apply a style to the given link id. + + Args: + segments (Iterable[Segment]): Segments. + link_id (str): A link id. + link_style (Style): Style to apply. + + Returns: + list[Segment]: A list of new segments. + """ + + _Segment = Segment + + segments = [ + _Segment( + text, + (style + link_style if style is not None else None) + if (style and not style._null and style._link_id == link_id) + else style, + control, + ) + for text, style, control in segments + ] + return segments + + +class StylesCache: + """Responsible for rendering CSS Styles and keeping a cached of rendered lines. + + The render method applies border, outline, and padding set in the Styles object to widget content. + + The diagram below shows content (possibly from a Rich renderable) with padding and border. The + labels A. B. and C. indicate the code path (see comments in render_line below) chosen to render + the indicated lines. + + ``` + โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“โ—€โ”€โ”€ A. border + โ”ƒ โ”ƒโ—€โ” + โ”ƒ โ”ƒ โ””โ”€ B. border + padding + + โ”ƒ Lorem ipsum dolor โ”ƒโ—€โ” border + โ”ƒ sit amet, โ”ƒ โ”‚ + โ”ƒ consectetur โ”ƒ โ””โ”€ C. border + padding + + โ”ƒ adipiscing elit, โ”ƒ content + padding + + โ”ƒ sed do eiusmod โ”ƒ border + โ”ƒ tempor incididunt โ”ƒ + โ”ƒ โ”ƒ + โ”ƒ โ”ƒ + โ”—โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”› + ``` + + """ + + def __init__(self) -> None: + self._cache: dict[int, list[Segment]] = {} + self._dirty_lines: set[int] = set() + self._width = 1 + + def set_dirty(self, *regions: Region) -> None: + """Add a dirty regions.""" + if regions: + for region in regions: + self._dirty_lines.update(region.line_range) + else: + self.clear() + + def is_dirty(self, y: int) -> bool: + """Check if a given line is dirty (needs to be rendered again). + + Args: + y (int): Y coordinate of line. + + Returns: + bool: True if line requires a render, False if can be cached. + """ + return y in self._dirty_lines + + def clear(self) -> None: + """Clear the styles cache (will cause the content to re-render).""" + self._cache.clear() + self._dirty_lines.clear() + + def render_widget(self, widget: Widget, crop: Region) -> Lines: + """Render the content for a widget. + + Args: + widget (Widget): A widget. + region (Region): A region of the widget to render. + + Returns: + Lines: Rendered lines. + """ + base_background, background = widget.background_colors + styles = widget.styles + lines = self.render( + styles, + widget.region.size, + base_background, + background, + widget.render_line, + content_size=widget.content_region.size, + padding=styles.padding, + crop=crop, + filter=widget.app._filter, + ) + if widget.auto_links: + _style_links = style_links + hover_style = widget.hover_style + link_hover_style = widget.link_hover_style + if ( + link_hover_style + and hover_style._link_id + and hover_style._meta + and "@click" in hover_style.meta + ): + if link_hover_style: + lines = [ + _style_links(line, hover_style.link_id, link_hover_style) + for line in lines + ] + + return lines + + def render( + self, + styles: StylesBase, + size: Size, + base_background: Color, + background: Color, + render_content_line: RenderLineCallback, + content_size: Size | None = None, + padding: Spacing | None = None, + crop: Region | None = None, + filter: LineFilter | None = None, + ) -> Lines: + """Render a widget content plus CSS styles. + + Args: + styles (StylesBase): CSS Styles object. + size (Size): Size of widget. + base_background (Color): Background color beneath widget. + background (Color): Background color of widget. + render_content_line (RenderLineCallback): Callback to render content line. + content_size (Size | None, optional): Size of content or None to assume full size. Defaults to None. + padding (Spacing | None, optional): Override padding from Styles, or None to use styles.padding. Defaults to None. + crop (Region | None, optional): Region to crop to. Defaults to None. + + Returns: + Lines: Rendered lines. + """ + if content_size is None: + content_size = size + if padding is None: + padding = styles.padding + if crop is None: + crop = size.region + + width, height = size + if width != self._width: + self.clear() + self._width = width + lines: Lines = [] + add_line = lines.append + simplify = Segment.simplify + + is_dirty = self._dirty_lines.__contains__ + render_line = self.render_line + for y in crop.line_range: + if is_dirty(y) or y not in self._cache: + line = render_line( + styles, + y, + size, + content_size, + padding, + base_background, + background, + render_content_line, + ) + line = list(simplify(line)) + self._cache[y] = line + else: + line = self._cache[y] + if filter: + line = filter.filter(line) + add_line(line) + self._dirty_lines.difference_update(crop.line_range) + + if crop.column_span != (0, width): + _line_crop = line_crop + x1, x2 = crop.column_span + lines = [_line_crop(line, x1, x2, width) for line in lines] + + return lines + + def render_line( + self, + styles: StylesBase, + y: int, + size: Size, + content_size: Size, + padding: Spacing, + base_background: Color, + background: Color, + render_content_line: RenderLineCallback, + ) -> list[Segment]: + """Render a styled line. + + Args: + styles (StylesBase): Styles object. + y (int): The y coordinate of the line (relative to widget screen offset). + size (Size): Size of the widget. + content_size (Size): Size of the content area. + padding (Spacing): Padding. + base_background (Color): Background color of widget beneath this line. + background (Color): Background color of widget. + render_content_line (RenderLineCallback): Callback to render a line of content. + + Returns: + list[Segment]: A line of segments. + """ + + gutter = styles.gutter + width, height = size + content_width, content_height = content_size + + pad_top, pad_right, pad_bottom, pad_left = padding + + ( + (border_top, border_top_color), + (border_right, border_right_color), + (border_bottom, border_bottom_color), + (border_left, border_left_color), + ) = styles.border + + ( + (outline_top, outline_top_color), + (outline_right, outline_right_color), + (outline_bottom, outline_bottom_color), + (outline_left, outline_left_color), + ) = styles.outline + + from_color = Style.from_color + + inner = from_color(bgcolor=(base_background + background).rich_color) + outer = from_color(bgcolor=base_background.rich_color) + + def post(segments: Iterable[Segment]) -> list[Segment]: + """Post process segments to apply opacity and tint. + + Args: + segments (Iterable[Segment]): Iterable of segments. + + Returns: + list[Segment]: New list of segments + """ + if styles.text_opacity != 1.0: + segments = TextOpacity.process_segments(segments, styles.text_opacity) + if styles.tint.a: + segments = Tint.process_segments(segments, styles.tint) + if styles.opacity != 1.0: + segments = _apply_opacity(segments, base_background, styles.opacity) + segments = list(segments) + return segments if isinstance(segments, list) else list(segments) + + line: Iterable[Segment] + # Draw top or bottom borders (A) + if (border_top and y == 0) or (border_bottom and y == height - 1): + border_color = base_background + ( + border_top_color if y == 0 else border_bottom_color + ) + box_segments = get_box( + border_top if y == 0 else border_bottom, + inner, + outer, + from_color(color=border_color.rich_color), + ) + line = render_row( + box_segments[0 if y == 0 else 2], + width, + border_left != "", + border_right != "", + ) + + # Draw padding (B) + elif (pad_top and y < gutter.top) or ( + pad_bottom and y >= height - gutter.bottom + ): + background_style = from_color(bgcolor=background.rich_color) + left_style = from_color(color=(background + border_left_color).rich_color) + left = get_box(border_left, inner, outer, left_style)[1][0] + right_style = from_color(color=(background + border_right_color).rich_color) + right = get_box(border_right, inner, outer, right_style)[1][2] + if border_left and border_right: + line = [left, Segment(" " * (width - 2), background_style), right] + elif border_left: + line = [left, Segment(" " * (width - 1), background_style)] + elif border_right: + line = [Segment(" " * (width - 1), background_style), right] + else: + line = [Segment(" " * width, background_style)] + else: + # Content with border and padding (C) + content_y = y - gutter.top + if content_y < content_height: + line = render_content_line(y - gutter.top) + else: + line = [Segment(" " * content_width, inner)] + if inner: + line = Segment.apply_style(line, inner) + line = line_pad(line, pad_left, pad_right, inner) + + if border_left or border_right: + # Add left / right border + left_style = from_color( + (base_background + border_left_color).rich_color + ) + left = get_box(border_left, inner, outer, left_style)[1][0] + right_style = from_color( + (base_background + border_right_color).rich_color + ) + right = get_box(border_right, inner, outer, right_style)[1][2] + + if border_left and border_right: + line = [left, *line, right] + elif border_left: + line = [left, *line] + else: + line = [*line, right] + + # Draw any outline + if (outline_top and y == 0) or (outline_bottom and y == height - 1): + # Top or bottom outlines + outline_color = outline_top_color if y == 0 else outline_bottom_color + box_segments = get_box( + outline_top if y == 0 else outline_bottom, + inner, + outer, + from_color(color=outline_color.rich_color), + ) + line = render_row( + box_segments[0 if y == 0 else 2], + width, + outline_left != "", + outline_right != "", + ) + + elif outline_left or outline_right: + # Lines in side outline + left_style = from_color((base_background + outline_left_color).rich_color) + left = get_box(outline_left, inner, outer, left_style)[1][0] + right_style = from_color((base_background + outline_right_color).rich_color) + right = get_box(outline_right, inner, outer, right_style)[1][2] + line = line_trim(list(line), outline_left != "", outline_right != "") + if outline_left and outline_right: + line = [left, *line, right] + elif outline_left: + line = [left, *line] + else: + line = [*line, right] + + return post(line) diff --git a/src/textual/_text_backend.py b/src/textual/_text_backend.py new file mode 100644 index 000000000..59e204671 --- /dev/null +++ b/src/textual/_text_backend.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class TextEditorBackend: + """Represents a text editor (some text and a cursor)""" + + content: str = "" + cursor_index: int = 0 + + def set_content(self, text: str) -> None: + """Set the content of the editor + + Args: + text (str): The text to set as the content + """ + self.content = text + + def delete_back(self) -> bool: + """Delete the character behind the cursor and moves cursor back. If the + cursor is at the start of the content, does nothing other than immediately + return False. + + Returns: + bool: True if the text content was modified. False otherwise. + """ + if self.cursor_index == 0: + return False + + new_text = ( + self.content[: self.cursor_index - 1] + self.content[self.cursor_index :] + ) + self.content = new_text + self.cursor_index = max(0, self.cursor_index - 1) + return True + + def delete_forward(self) -> bool: + """Delete the character in front of the cursor without moving the cursor. + + Returns: + bool: True if the text content was modified. False otherwise. + """ + if self.cursor_index == len(self.content): + return False + + new_text = ( + self.content[: self.cursor_index] + self.content[self.cursor_index + 1 :] + ) + self.content = new_text + return True + + def cursor_left(self) -> bool: + """Move the cursor 1 character left in the text. Is a noop if cursor is at start. + + Returns: + bool: True if the cursor moved. False otherwise. + """ + previous_index = self.cursor_index + new_index = max(0, previous_index - 1) + self.cursor_index = new_index + return previous_index != new_index + + def cursor_right(self) -> bool: + """Move the cursor 1 character right in the text. Is a noop if the cursor is at end. + + Returns: + bool: True if the cursor moved. False otherwise. + """ + previous_index = self.cursor_index + new_index = min(len(self.content), previous_index + 1) + self.cursor_index = new_index + return previous_index != new_index + + def query_cursor_left(self) -> bool: + """Check if the cursor can move 1 codepoint left in the text. + + Returns: + bool: True if the cursor can move left. False otherwise. + """ + previous_index = self.cursor_index + new_index = max(0, previous_index - 1) + return previous_index != new_index + + def query_cursor_right(self) -> str | None: + """Check if the cursor can move right (we can't move right if we're at the end) + and return the codepoint to the right of the cursor if it exists. If it doesn't + exist (e.g. we're at the end), then return None + + Returns: + str: The codepoint to the right of the cursor if it exists, otherwise None. + """ + previous_index = self.cursor_index + new_index = min(len(self.content), previous_index + 1) + if new_index == len(self.content): + return None + elif previous_index != new_index: + return self.content[new_index] + return None + + def cursor_text_start(self) -> bool: + """Move the cursor to the start of the text + + Returns: + bool: True if the cursor moved. False otherwise. + """ + if self.cursor_index == 0: + return False + + self.cursor_index = 0 + return True + + def cursor_text_end(self) -> bool: + """Move the cursor to the end of the text + + Returns: + bool: True if the cursor moved. False otherwise. + """ + text_length = len(self.content) + if self.cursor_index == text_length: + return False + + self.cursor_index = text_length + return True + + def insert(self, text: str) -> bool: + """Insert some text at the cursor position, and move the cursor + to the end of the newly inserted text. + + Args: + text: The text to insert + + Returns: + bool: Always returns True since text should be insertable regardless of cursor location + """ + new_text = ( + self.content[: self.cursor_index] + text + self.content[self.cursor_index :] + ) + self.content = new_text + self.cursor_index = min(len(self.content), self.cursor_index + len(text)) + return True + + def get_range(self, start: int, end: int) -> str: + """Return the text between 2 indices. Useful for previews/views into + a subset of the content e.g. scrollable single-line input fields + + Args: + start (int): The starting index to return text from (inclusive) + end (int): The index to return text up to (exclusive) + + Returns: + str: The sliced string between start and end. + """ + return self.content[start:end] + + @property + def cursor_at_end(self): + return self.cursor_index == len(self.content) diff --git a/src/textual/_time.py b/src/textual/_time.py new file mode 100644 index 000000000..f99abc138 --- /dev/null +++ b/src/textual/_time.py @@ -0,0 +1,12 @@ +import platform + +from time import monotonic, perf_counter + +PLATFORM = platform.system() +WINDOWS = PLATFORM == "Windows" + + +if WINDOWS: + time = perf_counter +else: + time = monotonic diff --git a/src/textual/_timer.py b/src/textual/_timer.py deleted file mode 100644 index cbeef27cd..000000000 --- a/src/textual/_timer.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import weakref -from asyncio import ( - get_event_loop, - CancelledError, - Event, - sleep, - Task, -) -from time import monotonic -from typing import Awaitable, Callable, Union - -from rich.repr import Result, rich_repr - -from . import events -from ._types import MessageTarget - -TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] - - -class EventTargetGone(Exception): - pass - - -@rich_repr -class Timer: - _timer_count: int = 1 - - def __init__( - self, - event_target: MessageTarget, - interval: float, - sender: MessageTarget, - *, - name: str | None = None, - callback: TimerCallback | None = None, - repeat: int = None, - skip: bool = False, - pause: bool = False, - ) -> None: - self._target_repr = repr(event_target) - self._target = weakref.ref(event_target) - self._interval = interval - self.sender = sender - self.name = f"Timer#{self._timer_count}" if name is None else name - self._timer_count += 1 - self._callback = callback - self._repeat = repeat - self._skip = skip - self._active = Event() - if not pause: - self._active.set() - - def __rich_repr__(self) -> Result: - yield self._interval - yield "name", self.name - yield "repeat", self._repeat, None - - @property - def target(self) -> MessageTarget: - target = self._target() - if target is None: - raise EventTargetGone() - return target - - def start(self) -> Task: - """Start the timer return the task. - - Returns: - Task: A Task instance for the timer. - """ - self._task = get_event_loop().create_task(self._run()) - return self._task - - async def stop(self) -> None: - """Stop the timer, and block until it exists.""" - self._task.cancel() - await self._task - - def pause(self) -> None: - """Pause the timer.""" - self._active.clear() - - def resume(self) -> None: - """Result a paused timer.""" - self._active.set() - - async def _run(self) -> None: - """Run the timer.""" - count = 0 - _repeat = self._repeat - _interval = self._interval - start = monotonic() - try: - while _repeat is None or count <= _repeat: - next_timer = start + ((count + 1) * _interval) - if self._skip and next_timer < monotonic(): - count += 1 - continue - wait_time = max(0, next_timer - monotonic()) - if wait_time: - await sleep(wait_time) - event = events.Timer( - self.sender, timer=self, count=count, callback=self._callback - ) - count += 1 - try: - await self.target.post_message(event) - except EventTargetGone: - break - await self._active.wait() - except CancelledError: - pass diff --git a/src/textual/_types.py b/src/textual/_types.py index ff5f75e18..60d943963 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,5 +1,6 @@ import sys -from typing import Awaitable, Callable, List, Optional, TYPE_CHECKING +from typing import Awaitable, Callable, List, TYPE_CHECKING, Union + from rich.segment import Segment if sys.version_info >= (3, 8): @@ -9,17 +10,16 @@ else: if TYPE_CHECKING: - from .events import Event from .message import Message -Callback = Callable[[], None] -# IntervalID = int - class MessageTarget(Protocol): async def post_message(self, message: "Message") -> bool: ... + async def _post_priority_message(self, message: "Message") -> bool: + ... + def post_message_no_wait(self, message: "Message") -> bool: ... @@ -32,6 +32,5 @@ class EventTarget(Protocol): ... -MessageHandler = Callable[["Message"], Awaitable] - Lines = List[List[Segment]] +CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 7a2194e97..40af686a4 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -1,29 +1,54 @@ from __future__ import annotations - +import unicodedata import re -from typing import Callable, Generator +from typing import Any, Callable, Generator, Iterable from . import events -from ._types import MessageTarget +from . import messages +from ._ansi_sequences import ANSI_SEQUENCES_KEYS from ._parser import Awaitable, Parser, TokenCallback -from ._ansi_sequences import ANSI_SEQUENCES +from ._types import MessageTarget +from .keys import KEY_NAME_REPLACEMENTS +# When trying to determine whether the current sequence is a supported/valid +# escape sequence, at which length should we give up and consider our search +# to be unsuccessful? +_MAX_SEQUENCE_SEARCH_THRESHOLD = 20 + _re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(\d+);(?P\d)\$y" +) +_re_bracketed_paste_start = re.compile(r"^\x1b\[200~$") +_re_bracketed_paste_end = re.compile(r"^\x1b\[201~$") class XTermParser(Parser[events.Event]): - _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])") - def __init__(self, sender: MessageTarget, more_data: Callable[[], bool]) -> None: + def __init__( + self, sender: MessageTarget, more_data: Callable[[], bool], debug: bool = False + ) -> None: self.sender = sender self.more_data = more_data self.last_x = 0 self.last_y = 0 + + self._debug_log_file = open("keys.log", "wt") if debug else None + super().__init__() + def debug_log(self, *args: Any) -> None: # pragma: no cover + if self._debug_log_file is not None: + self._debug_log_file.write(" ".join(args) + "\n") + self._debug_log_file.flush() + + def feed(self, data: str) -> Iterable[events.Event]: + self.debug_log(f"FEED {data!r}") + return super().feed(data) + def parse_mouse_code(self, code: str, sender: MessageTarget) -> events.Event | None: sgr_match = self._re_sgr_mouse.match(code) if sgr_match: @@ -39,7 +64,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 = ( @@ -66,23 +91,117 @@ class XTermParser(Parser[events.Event]): ESC = "\x1b" read1 = self.read1 - get_ansi_sequence = ANSI_SEQUENCES.get + sequence_to_key_events = self._sequence_to_key_events more_data = self.more_data + paste_buffer: list[str] = [] + bracketed_paste = False + use_prior_escape = False + + def reissue_sequence_as_keys(reissue_sequence: str) -> None: + for character in reissue_sequence: + key_events = sequence_to_key_events(character) + for event in key_events: + if event.key == "escape": + event = events.Key(event.sender, "circumflex_accent", "^") + on_token(event) while not self.is_eof: - character = yield read1() - # log.debug("character=%r", character) - if character == ESC and ((yield self.peek_buffer()) or more_data()): + if not bracketed_paste and paste_buffer: + # We're at the end of the bracketed paste. + # The paste buffer has content, but the bracketed paste has finished, + # so we flush the paste buffer. We have to remove the final character + # since if bracketed paste has come to an end, we'll have added the + # ESC from the closing bracket, since at that point we didn't know what + # the full escape code was. + pasted_text = "".join(paste_buffer[:-1]) + on_token(events.Paste(self.sender, text=pasted_text)) + paste_buffer.clear() + + character = ESC if use_prior_escape else (yield read1()) + use_prior_escape = False + + if bracketed_paste: + paste_buffer.append(character) + + self.debug_log(f"character={character!r}") + if character == ESC: + # Could be the escape key was pressed OR the start of an escape sequence sequence: str = character + if not bracketed_paste: + # TODO: There's nothing left in the buffer at the moment, + # but since we're on an escape, how can we be sure that the + # data that next gets fed to the parser isn't an escape sequence? + + # This problem arises when an ESC falls at the end of a chunk. + # We'll be at an escape, but peek_buffer will return an empty + # string because there's nothing in the buffer yet. + + # This code makes an assumption that an escape sequence will never be + # "chopped up", so buffers would never contain partial escape sequences. + peek_buffer = yield self.peek_buffer() + if not peek_buffer: + # An escape arrived without any following characters + on_token(events.Key(self.sender, "escape", "\x1b")) + continue + if peek_buffer and peek_buffer[0] == ESC: + # There is an escape in the buffer, so ESC ESC has arrived + yield read1() + on_token(events.Key(self.sender, "escape", "\x1b")) + # If there is no further data, it is not part of a sequence, + # So we don't need to go in to the loop + if len(peek_buffer) == 1 and not more_data(): + continue + + # Look ahead through the suspected escape sequence for a match while True: - sequence += yield read1() - # log.debug(f"sequence=%r", sequence) - keys = get_ansi_sequence(sequence, None) - if keys is not None: - for key in keys: - on_token(events.Key(self.sender, key=key)) + + # If we run into another ESC at this point, then we've failed + # to find a match, and should issue everything we've seen within + # the suspected sequence as Key events instead. + sequence_character = yield read1() + new_sequence = sequence + sequence_character + + threshold_exceeded = len(sequence) > _MAX_SEQUENCE_SEARCH_THRESHOLD + found_escape = sequence_character and sequence_character == ESC + + if threshold_exceeded: + # We exceeded the sequence length threshold, so reissue all the + # characters in that sequence as key-presses. + reissue_sequence_as_keys(new_sequence) break - else: + + if found_escape: + # We've hit an escape, so we need to reissue all the keys + # up to but not including it, since this escape could be + # part of an upcoming control sequence. + use_prior_escape = True + reissue_sequence_as_keys(sequence) + break + + sequence = new_sequence + + self.debug_log(f"sequence={sequence!r}") + + bracketed_paste_start_match = _re_bracketed_paste_start.match( + sequence + ) + if bracketed_paste_start_match is not None: + bracketed_paste = True + break + + bracketed_paste_end_match = _re_bracketed_paste_end.match(sequence) + if bracketed_paste_end_match is not None: + bracketed_paste = False + break + + if not bracketed_paste: + # Was it a pressed key event that we received? + key_events = list(sequence_to_key_events(sequence)) + for event in key_events: + on_token(event) + if key_events: + break + # Or a mouse event? mouse_match = _re_mouse_event.match(sequence) if mouse_match is not None: mouse_code = mouse_match.group(0) @@ -90,20 +209,56 @@ class XTermParser(Parser[events.Event]): if event: on_token(event) break + + # Or a mode report? + # (i.e. the terminal saying it supports a mode we requested) + mode_report_match = _re_terminal_mode_response.match(sequence) + if mode_report_match is not None: + if ( + mode_report_match["mode_id"] == "2026" + and int(mode_report_match["setting_parameter"]) > 0 + ): + on_token( + messages.TerminalSupportsSynchronizedOutput( + self.sender + ) + ) + break else: - keys = get_ansi_sequence(character, None) - if keys is not None: - for key in keys: - on_token(events.Key(self.sender, key=key)) + if not bracketed_paste: + for event in sequence_to_key_events(character): + on_token(event) + + def _sequence_to_key_events( + self, sequence: str, _unicode_name=unicodedata.name + ) -> Iterable[events.Key]: + """Map a sequence of code points on to a sequence of keys. + + Args: + sequence (str): Sequence of code points. + + Returns: + Iterable[events.Key]: keys + + """ + keys = ANSI_SEQUENCES_KEYS.get(sequence) + if keys is not None: + for key in keys: + yield events.Key( + self.sender, key.value, sequence if len(sequence) == 1 else None + ) + elif len(sequence) == 1: + try: + if not sequence.isalnum(): + name = ( + _unicode_name(sequence) + .lower() + .replace("-", "_") + .replace(" ", "_") + ) else: - on_token(events.Key(self.sender, key=character)) - - -if __name__ == "__main__": - parser = XTermParser() - - import os - import sys - - for token in parser.feed(sys.stdin.read(20)): - print(token) + name = sequence + name = KEY_NAME_REPLACEMENTS.get(name, name) + yield events.Key(self.sender, name, sequence) + except: + yield events.Key(self.sender, sequence, sequence) diff --git a/src/textual/actions.py b/src/textual/actions.py index 41839834b..8d3dbdaa2 100644 --- a/src/textual/actions.py +++ b/src/textual/actions.py @@ -1,7 +1,6 @@ from __future__ import annotations import ast -from typing import Any, Tuple import re @@ -12,7 +11,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() @@ -30,12 +40,3 @@ def parse(action: str) -> tuple[str, tuple[Any, ...]]: action_name, action_params if isinstance(action_params, tuple) else (action_params,), ) - - -if __name__ == "__main__": - - print(parse("foo")) - - print(parse("view.toggle('side')")) - - print(parse("view.toggle")) diff --git a/src/textual/app.py b/src/textual/app.py index a35025f0f..646b13371 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,34 +1,57 @@ from __future__ import annotations -import os import asyncio +import inspect +import io +import os import platform -from typing import Any, Callable, ClassVar, Type, TypeVar +import sys +import unicodedata import warnings +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime +from pathlib import Path, PurePath +from time import perf_counter +from typing import Any, Generic, Iterable, Type, TYPE_CHECKING, TypeVar, cast, Union +from weakref import WeakSet, WeakValueDictionary -from rich.control import Control +from ._ansi_sequences import SYNC_END, SYNC_START +from ._path import _make_path_object_relative + +import nanoid +import rich import rich.repr -from rich.screen import Screen from rich.console import Console, RenderableType -from rich.measure import Measurement +from rich.protocol import is_renderable +from rich.segment import Segment, Segments from rich.traceback import Traceback -from . import events -from . import actions -from ._animator import Animator -from .binding import Bindings, NoBinding -from .geometry import Offset, Region -from . import log +from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages +from ._animator import Animator, DEFAULT_EASING, Animatable, EasingFunction from ._callback import invoke from ._context import active_app -from ._event_broker import extract_handler_actions, NoHandler +from ._event_broker import NoHandler, extract_handler_actions +from ._filter import LineFilter, Monochrome +from .binding import Binding, Bindings +from .css.query import NoMatches +from .css.stylesheet import Stylesheet +from .design import ColorSystem +from .dom import DOMNode from .driver import Driver -from .layouts.dock import DockLayout, Dock -from .message_pump import MessagePump -from ._profile import timer -from .view import View -from .views import DockView -from .widget import Widget, Reactive +from .drivers.headless_driver import HeadlessDriver +from .features import FeatureFlag, parse_features +from .file_monitor import FileMonitor +from .geometry import Offset, Region, Size +from .keys import REPLACED_KEYS +from .messages import CallbackType +from .reactive import Reactive +from .renderables.blank import Blank +from .screen import Screen +from .widget import AwaitMount, Widget + +if TYPE_CHECKING: + from .devtools.client import DevtoolsClient + PLATFORM = platform.system() WINDOWS = PLATFORM == "Windows" @@ -36,79 +59,305 @@ WINDOWS = PLATFORM == "Windows" # asyncio will warn against resources not being cleared warnings.simplefilter("always", ResourceWarning) +# `asyncio.get_event_loop()` is deprecated since Python 3.10: +_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) LayoutDefinition = "dict[str, Any]" -ViewType = TypeVar("ViewType", bound=View) +DEFAULT_COLORS = { + "dark": ColorSystem( + primary="#004578", + secondary="#ffa62b", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + accent="#0178D4", + dark=True, + ), + "light": ColorSystem( + primary="#004578", + secondary="#ffa62b", + warning="#ffa62b", + error="#ba3c5b", + success="#4EBF71", + accent="#0178D4", + dark=False, + ), +} -try: - import uvloop -except ImportError: +ComposeResult = Iterable[Widget] +RenderResult = RenderableType + + +class AppError(Exception): pass -else: - uvloop.install() class ActionError(Exception): pass -@rich.repr.auto -class App(MessagePump): - """The base class for Textual Applications""" +class ScreenError(Exception): + pass - KEYS: ClassVar[dict[str, str]] = {} + +class ScreenStackError(ScreenError): + """Raised when attempting to pop the last screen from the stack.""" + + +ReturnType = TypeVar("ReturnType") + + +class _NullFile: + def write(self, text: str) -> None: + pass + + def flush(self) -> None: + pass + + +CSSPathType = Union[str, PurePath, None] + + +@rich.repr.auto +class App(Generic[ReturnType], DOMNode): + """The base class for Textual Applications. + + Args: + driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. + 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. + """ + + # Inline CSS for quick scripts (generally css_path should be preferred.) + CSS = "" + + # Default (lowest priority) CSS + DEFAULT_CSS = """ + App { + background: $background; + color: $text; + } + """ + + SCREENS: dict[str, Screen] = {} + _BASE_PATH: str | None = None + CSS_PATH: CSSPathType = None + TITLE: str | None = None + SUB_TITLE: str | None = None + + title: Reactive[str] = Reactive("") + sub_title: Reactive[str] = Reactive("") + dark: Reactive[bool] = Reactive(True) def __init__( self, - screen: bool = True, driver_class: Type[Driver] | None = None, - log: str = "", - log_verbosity: int = 1, - title: str = "Textual Application", + css_path: CSSPathType = None, + watch_css: bool = False, ): - """The Textual Application base class + # 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. + _init_uvloop() - Args: - console (Console, optional): A Rich Console. Defaults to None. - screen (bool, optional): Enable full-screen application mode. Defaults to True. - driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. - title (str, optional): Title of the application. Defaults to "Textual Application". - """ - self.console = Console() - self.error_console = Console(stderr=True) - self._screen = screen + super().__init__() + self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) + + self._filter: LineFilter | None = None + environ = dict(os.environ) + no_color = environ.pop("NO_COLOR", None) + if no_color is not None: + self._filter = Monochrome() + self.console = Console( + file=(_NullFile() if self.is_headless else sys.__stdout__), + markup=False, + highlight=False, + emoji=False, + legacy_windows=False, + _environ=environ, + ) + self.error_console = Console(markup=False, stderr=True) self.driver_class = driver_class or self.get_driver_class() - self._title = title - self._layout = DockLayout() - self._view_stack: list[DockView] = [] - self.children: set[MessagePump] = set() + self._screen_stack: list[Screen] = [] + self._sync_available = False - self.focused: Widget | None = None self.mouse_over: Widget | None = None self.mouse_captured: Widget | None = None self._driver: Driver | None = None self._exit_renderables: list[RenderableType] = [] - self._docks: list[Dock] = [] - self._action_targets = {"app", "view"} + self._action_targets = {"app", "screen"} self._animator = Animator(self) - self.animate = self._animator.bind(self) + self._animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) - self.bindings = Bindings() - self._title = title + self.title = ( + self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}" + ) + self.sub_title = self.SUB_TITLE if self.SUB_TITLE is not None else "" - self.log_file = open(log, "wt") if log else None - self.log_verbosity = log_verbosity + self._logger = Logger(self._log) - self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) + self._bindings.bind("ctrl+c", "quit", show=False, universal=True) self._refresh_required = False - super().__init__() + self.design = DEFAULT_COLORS - title: Reactive[str] = Reactive("Textual") - sub_title: Reactive[str] = Reactive("") - background: Reactive[str] = Reactive("black") + self.stylesheet = Stylesheet(variables=self.get_css_variables()) + self._require_stylesheet_update: set[DOMNode] = set() + + # We want the CSS path to be resolved from the location of the App subclass + css_path = css_path or self.CSS_PATH + if css_path is not None: + if isinstance(css_path, str): + css_path = Path(css_path) + css_path = _make_path_object_relative(css_path, self) if css_path else None + + self.css_path = css_path + + self._registry: WeakSet[DOMNode] = WeakSet() + + self._installed_screens: WeakValueDictionary[ + str, Screen + ] = WeakValueDictionary() + self._installed_screens.update(**self.SCREENS) + + self.devtools: DevtoolsClient | None = None + if "devtools" in self.features: + try: + from .devtools.client import DevtoolsClient + except ImportError: + # Dev dependencies not installed + pass + else: + self.devtools = DevtoolsClient() + + self._return_value: ReturnType | None = None + + self.css_monitor = ( + FileMonitor(self.css_path, self._on_css_change) + if ((watch_css or self.debug) and self.css_path) + else None + ) + self._screenshot: str | None = None + + def animate( + self, + attribute: str, + value: float | Animatable, + *, + final_value: object = ..., + duration: float | None = None, + speed: float | None = None, + delay: float = 0.0, + easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, + ) -> None: + """Animate an attribute. + + Args: + attribute (str): Name of the attribute to animate. + value (float | Animatable): The value to animate to. + final_value (object, optional): The final value of the animation. Defaults to `value` if not set. + duration (float | None, optional): The duration of the animate. Defaults to None. + speed (float | None, optional): The speed of the animation. Defaults to None. + delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0. + easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic". + on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None. + + """ + self._animate( + attribute, + value, + final_value=final_value, + duration=duration, + speed=speed, + delay=delay, + easing=easing, + on_complete=on_complete, + ) + + @property + def debug(self) -> bool: + """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. + + 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. + + Returns: + list[Screen]: List of screens. + + """ + return self._screen_stack.copy() + + def exit(self, result: ReturnType | None = None) -> None: + """Exit the app, and return the supplied result. + + Args: + result (ReturnType | None, optional): Return value. Defaults to None. + """ + self._return_value = result + self._close_messages_no_wait() + + @property + def focused(self) -> Widget | None: + """Get the widget that is focused on the currently active screen.""" + return self.screen.focused + + @property + def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]: + """Get current bindings. If no widget is focused, then the app-level bindings + are returned. If a widget is focused, then any bindings present in the active + screen and app are merged and returned.""" + + namespace_binding_map: dict[str, tuple[DOMNode, Binding]] = {} + for namespace, bindings in reversed(self._binding_chain): + for key, binding in bindings.keys.items(): + namespace_binding_map[key] = (namespace, binding) + + return namespace_binding_map + + def _set_active(self) -> None: + """Set this app to be the currently active app.""" + active_app.set(self) + + def compose(self) -> ComposeResult: + """Yield child widgets for a container.""" + return + yield + + def get_css_variables(self) -> dict[str, str]: + """Get a mapping of variables used to pre-populate CSS. + + Returns: + dict[str, str]: A mapping of variable name to value. + """ + variables = self.design["dark" if self.dark else "light"].generate() + return variables + + def watch_dark(self, dark: bool) -> None: + """Watches the dark bool.""" + self.set_class(dark, "-dark-mode") + self.set_class(not dark, "-light-mode") + self.refresh_css() def get_driver_class(self) -> Type[Driver]: """Get a driver class for this platform. @@ -118,6 +367,7 @@ class App(MessagePump): Returns: Driver: A Driver class which manages input and display. """ + driver_class: Type[Driver] if WINDOWS: from .drivers.windows_driver import WindowsDriver @@ -130,42 +380,176 @@ class App(MessagePump): def __rich_repr__(self) -> rich.repr.Result: yield "title", self.title + yield "id", self.id, None + if self.name: + yield "name", self.name + if self.classes: + yield "classes", set(self.classes) + pseudo_classes = self.pseudo_classes + if pseudo_classes: + yield "pseudo_classes", set(pseudo_classes) - def __rich__(self) -> RenderableType: - return self.view + @property + def is_transparent(self) -> bool: + return True @property def animator(self) -> Animator: return self._animator @property - def view(self) -> DockView: - return self._view_stack[-1] + def screen(self) -> Screen: + """Get the current screen. - def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None: - """Write to logs. + Raises: + ScreenStackError: If there are no screens on the stack. - Args: - *args (Any): Positional arguments are converted to string and written to logs. - verbosity (int, optional): Verbosity level 0-3. Defaults to 1. + Returns: + Screen: The currently active screen. """ try: - if self.log_file and verbosity <= self.log_verbosity: - output = f" ".join(str(arg) for arg in args) + return self._screen_stack[-1] + except IndexError: + raise ScreenStackError("No screens on stack") from None + + @property + def size(self) -> Size: + """Get the size of the terminal. + + Returns: + Size: Size of the terminal + """ + return Size(*self.console.size) + + @property + def log(self) -> Logger: + return self._logger + + def _log( + self, + group: LogGroup, + verbosity: LogVerbosity, + _textual_calling_frame: inspect.FrameInfo, + *objects: Any, + **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. + """ + + devtools = self.devtools + if devtools is None or not devtools.is_connected: + return + + if verbosity.value > LogVerbosity.NORMAL.value and not devtools.verbose: + return + + try: + from .devtools.client import DevtoolsLog + + if len(objects) == 1 and not kwargs: + devtools.log( + DevtoolsLog(objects, caller=_textual_calling_frame), + group, + verbosity, + ) + else: + output = " ".join(str(arg) for arg in objects) if kwargs: key_values = " ".join( - f"{key}={value}" for key, value in kwargs.items() + f"{key}={value!r}" for key, value in kwargs.items() ) - output = " ".join((output, key_values)) - self.log_file.write(output + "\n") - self.log_file.flush() - except Exception: - pass + output = f"{output} {key_values}" if output else key_values + devtools.log( + DevtoolsLog(output, caller=_textual_calling_frame), + group, + verbosity, + ) + except Exception as error: + self._handle_exception(error) - async def bind( + def action_toggle_dark(self) -> None: + """Action to toggle dark mode.""" + self.dark = not self.dark + + def action_screenshot(self, filename: str | None = None, path: str = "./") -> None: + """Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen. + + Args: + filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None. + path (str, optional): Path to directory. Defaults to "~/". + """ + self.save_screenshot(filename, path) + + def export_screenshot(self, *, title: str | None = None) -> str: + """Export an SVG screenshot of the current screen. + + Args: + title (str | None, optional): The title of the exported screenshot or None + to use app title. Defaults to None. + + """ + + console = Console( + width=self.console.width, + height=self.console.height, + file=io.StringIO(), + force_terminal=True, + color_system="truecolor", + record=True, + legacy_windows=False, + ) + screen_render = self.screen._compositor.render(full=True) + console.print(screen_render) + return console.export_svg(title=title or self.title) + + def save_screenshot( + self, + filename: str | None = None, + path: str = "./", + time_format: str = "%Y-%m-%d %X %f", + ) -> str: + """Save an SVG screenshot of the current screen. + + Args: + filename (str | None, optional): Filename of SVG screenshot, or None to auto-generate + a filename with the date and time. Defaults to None. + path (str, optional): Path to directory for output. Defaults to current working directory. + time_format (str, optional): Time format to use if filename is None. Defaults to "%Y-%m-%d %X %f". + + Returns: + str: Filename of screenshot. + """ + if filename is None: + svg_filename = ( + f"{self.title.lower()} {datetime.now().strftime(time_format)}.svg" + ) + svg_filename = svg_filename.replace("/", "_").replace("\\", "_") + else: + svg_filename = filename + svg_path = os.path.expanduser(os.path.join(path, svg_filename)) + screenshot_svg = self.export_screenshot() + with open(svg_path, "w") as svg_file: + svg_file.write(screenshot_svg) + return svg_path + + def bind( self, keys: str, action: str, + *, description: str = "", show: bool = True, key_display: str | None = None, @@ -179,61 +563,346 @@ class App(MessagePump): show (bool, optional): Show key in UI. Defaults to True. key_display (str, optional): Replacement text for key, or None to use default. Defaults to None. """ - self.bindings.bind( + self._bindings.bind( keys, action, description, show=show, key_display=key_display ) - @classmethod def run( - cls, - console: Console = None, - screen: bool = True, - driver: Type[Driver] = None, - **kwargs, - ): - """Run the app. + 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. Args: - console (Console, optional): Console object. Defaults to None. - screen (bool, optional): Enable application mode. Defaults to True. - driver (Type[Driver], optional): Driver class or None for default. Defaults to None. + 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. + 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: + ReturnType | None: The return value specified in `App.exit` or None if exit wasn't called. """ + if headless: + self.features = cast( + "frozenset[FeatureFlag]", self.features.union({"headless"}) + ) + async def run_app() -> None: - app = cls(screen=screen, driver_class=driver, **kwargs) - await app.process_messages() + if quit_after is not None: + self.set_timer(quit_after, self.shutdown) + if press is not None: + app = self - asyncio.run(run_app()) + async def press_keys() -> None: + """A task to send key events.""" + assert press + driver = app._driver + assert driver is not None + await asyncio.sleep(0.01) + for key in press: + if key == "_": + print("(pause 50ms)") + await asyncio.sleep(0.05) + elif key.startswith("wait:"): + _, wait_ms = key.split(":") + print(f"(pause {wait_ms}ms)") + await asyncio.sleep(float(wait_ms) / 1000) + else: + if len(key) == 1 and not key.isalnum(): + key = ( + unicodedata.name(key) + .lower() + .replace("-", "_") + .replace(" ", "_") + ) + original_key = REPLACED_KEYS.get(key, key) + try: + char = unicodedata.lookup( + original_key.upper().replace("_", " ") + ) + except KeyError: + char = key if len(key) == 1 else None + print(f"press {key!r} (char={char!r})") + key_event = events.Key(self, key, char) + driver.send_event(key_event) + await asyncio.sleep(0.01) - async def push_view(self, view: ViewType) -> ViewType: - self.register(view, self) - self._view_stack.append(view) - return view + await app._animator.wait_for_idle() - async def set_focus(self, widget: Widget | None) -> None: + if screenshot: + self._screenshot = self.export_screenshot( + title=screenshot_title + ) + await self.shutdown() + + async def press_keys_task(): + """Press some keys in the background.""" + asyncio.create_task(press_keys()) + + 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: + asyncio.run(run_app()) + else: + # However, this works with Python<3.10: + event_loop = asyncio.get_event_loop() + event_loop.run_until_complete(run_app()) + + return self._return_value + + async def _on_css_change(self) -> None: + """Called when the CSS changes (if watch_css is True).""" + if self.css_path is not None: + try: + time = perf_counter() + stylesheet = self.stylesheet.copy() + stylesheet.read(self.css_path) + stylesheet.parse() + elapsed = (perf_counter() - time) * 1000 + self.log.system( + f" loaded {self.css_path!r} in {elapsed:.0f} ms" + ) + except Exception as error: + # TODO: Catch specific exceptions + self.log.error(error) + self.bell() + else: + self.stylesheet = stylesheet + self.reset_styles() + self.stylesheet.update(self) + self.screen.refresh(layout=True) + + def render(self) -> RenderableType: + return Blank(self.styles.background) + + def get_child(self, id: str) -> DOMNode: + """Shorthand for self.screen.get_child(id: str) + Returns the first child (immediate descendent) of this DOMNode + with the given ID. + + Args: + id (str): The ID of the node to search for. + + Returns: + DOMNode: The first child of this node with the specified ID. + + Raises: + NoMatches: if no children could be found for this ID + """ + return self.screen.get_child(id) + + def update_styles(self, node: DOMNode | None = None) -> None: + """Request update of styles. + + Should be called whenever CSS classes / pseudo classes change. + + """ + self._require_stylesheet_update.add(self.screen if node is None else node) + self.check_idle() + + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> AwaitMount: + """Mount widgets. Widgets specified as positional args, or keywords args. If supplied + as keyword args they will be assigned an id of the key. + + Returns: + AwaitMount: An awaitable object that waits for widgets to be mounted. + + """ + mounted_widgets = self._register(self.screen, *anon_widgets, **widgets) + return AwaitMount(mounted_widgets) + + def mount_all(self, widgets: Iterable[Widget]) -> AwaitMount: + """Mount widgets from an iterable. + + Args: + widgets (Iterable[Widget]): An iterable of widgets. + """ + mounted_widgets = list(widgets) + for widget in mounted_widgets: + self._register(self.screen, widget) + return AwaitMount(mounted_widgets) + + def is_screen_installed(self, screen: Screen | str) -> bool: + """Check if a given screen has been installed. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + Returns: + bool: True if the screen is currently installed, + """ + if isinstance(screen, str): + return screen in self._installed_screens + else: + return screen in self._installed_screens.values() + + def get_screen(self, screen: Screen | str) -> Screen: + """Get an installed screen. + + If the screen isn't running, it will be registered before it is run. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + Raises: + KeyError: If the named screen doesn't exist. + + Returns: + Screen: A screen instance. + """ + if isinstance(screen, str): + try: + next_screen = self._installed_screens[screen] + except KeyError: + raise KeyError(f"No screen called {screen!r} installed") from None + else: + next_screen = screen + if not next_screen.is_running: + self._register(self, next_screen) + return next_screen + + def _replace_screen(self, screen: Screen) -> Screen: + """Handle the replaced screen. + + Args: + screen (Screen): A screen object. + + Returns: + Screen: The screen that was replaced. + + """ + screen.post_message_no_wait(events.ScreenSuspend(self)) + self.log.system(f"{screen} SUSPENDED") + if not self.is_screen_installed(screen) and screen not in self._screen_stack: + screen.remove() + self.log.system(f"{screen} REMOVED") + return screen + + def push_screen(self, screen: Screen | str) -> None: + """Push a new screen on the screen stack. + + Args: + screen (Screen | str): A Screen instance or the name of an installed screen. + + """ + next_screen = self.get_screen(screen) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log.system(f"{self.screen} is current (PUSHED)") + + def switch_screen(self, screen: Screen | str) -> None: + """Switch to another screen by replacing the top of the screen stack with a new screen. + + Args: + screen (Screen | str): Either a Screen object or screen name (the `name` argument when installed). + + """ + if self.screen is not screen: + self._replace_screen(self._screen_stack.pop()) + next_screen = self.get_screen(screen) + self._screen_stack.append(next_screen) + self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log.system(f"{self.screen} is current (SWITCHED)") + + def install_screen(self, screen: Screen, name: str | None = None) -> str: + """Install a screen. + + Args: + screen (Screen): Screen to install. + name (str | None, optional): Unique name of screen or None to auto-generate. + Defaults to None. + + Raises: + ScreenError: If the screen can't be installed. + + Returns: + str: The name of the screen + """ + if name is None: + name = nanoid.generate() + if name in self._installed_screens: + raise ScreenError(f"Can't install screen; {name!r} is already installed") + if screen in self._installed_screens.values(): + raise ScreenError( + "Can't install screen; {screen!r} has already been installed" + ) + self._installed_screens[name] = screen + self.get_screen(name) # Ensures screen is running + self.log.system(f"{screen} INSTALLED name={name!r}") + return name + + def uninstall_screen(self, screen: Screen | str) -> str | None: + """Uninstall a screen. If the screen was not previously installed then this + method is a null-op. + + Args: + screen (Screen | str): The screen to uninstall or the name of a installed screen. + + Returns: + str | None: The name of the screen that was uninstalled, or None if no screen was uninstalled. + """ + if isinstance(screen, str): + if screen not in self._installed_screens: + return None + uninstall_screen = self._installed_screens[screen] + if uninstall_screen in self._screen_stack: + raise ScreenStackError("Can't uninstall screen in screen stack") + del self._installed_screens[screen] + self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}") + return screen + else: + if screen in self._screen_stack: + raise ScreenStackError("Can't uninstall screen in screen stack") + for name, installed_screen in self._installed_screens.items(): + if installed_screen is screen: + self._installed_screens.pop(name) + self.log.system(f"{screen} UNINSTALLED name={name!r}") + return name + return None + + def pop_screen(self) -> Screen: + """Pop the current screen from the stack, and switch to the previous screen. + + Returns: + Screen: The screen that was replaced. + """ + screen_stack = self._screen_stack + if len(screen_stack) <= 1: + raise ScreenStackError( + "Can't pop screen; there must be at least one screen on the stack" + ) + previous_screen = self._replace_screen(screen_stack.pop()) + self.screen._screen_resized(self.size) + self.screen.post_message_no_wait(events.ScreenResume(self)) + self.log.system(f"{self.screen} is active") + return previous_screen + + def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: """Focus (or unfocus) a widget. A focused widget will receive key events first. Args: - widget (Widget): [description] + widget (Widget): Widget to focus. + scroll_visible (bool, optional): Scroll widget in to view. """ - log("set_focus", widget) - if widget == self.focused: - # Widget is already focused - return + self.screen.set_focus(widget, scroll_visible) - if widget is None: - if self.focused is not None: - focused = self.focused - self.focused = None - await focused.post_message(events.Blur(self)) - elif widget.can_focus: - if self.focused is not None: - await self.focused.post_message(events.Blur(self)) - if widget is not None and self.focused != widget: - self.focused = widget - await widget.post_message(events.Focus(self)) + async def _set_mouse_over(self, widget: Widget | None) -> None: + """Called when the mouse is over another widget. - async def set_mouse_over(self, widget: Widget | None) -> None: + Args: + widget (Widget | None): Widget under mouse, or None for no widgets. + """ if widget is None: if self.mouse_over is not None: try: @@ -241,16 +910,16 @@ class App(MessagePump): finally: self.mouse_over = None else: - if self.mouse_over != widget: + if self.mouse_over is not widget: try: if self.mouse_over is not None: - await self.mouse_over.forward_event(events.Leave(self)) + await self.mouse_over._forward_event(events.Leave(self)) if widget is not None: - await widget.forward_event(events.Enter(self)) + await widget._forward_event(events.Enter(self)) finally: self.mouse_over = widget - async def capture_mouse(self, widget: Widget | None) -> None: + def capture_mouse(self, widget: Widget | None) -> None: """Send all mouse events to the given widget, disable mouse capture. Args: @@ -259,130 +928,353 @@ class App(MessagePump): if widget == self.mouse_captured: return if self.mouse_captured is not None: - await self.mouse_captured.post_message( + self.mouse_captured.post_message_no_wait( events.MouseRelease(self, self.mouse_position) ) self.mouse_captured = widget if widget is not None: - await widget.post_message(events.MouseCapture(self, self.mouse_position)) + widget.post_message_no_wait(events.MouseCapture(self, self.mouse_position)) def panic(self, *renderables: RenderableType) -> None: - """Exits the app with a traceback. + """Exits the app then displays a message. Args: - traceback (Traceback, optional): Rich Traceback object or None to generate one - for the most recent exception. Defaults to None. + *renderables (RenderableType, optional): Rich renderables to display on exit. """ - if not renderables: - renderables = ( - Traceback(show_locals=True, width=None, locals_max_length=5), - ) - self._exit_renderables.extend(renderables) - self.close_messages_no_wait() + assert all( + is_renderable(renderable) for renderable in renderables + ), "Can only call panic with strings or Rich renderables" - async def process_messages(self) -> None: - active_app.set(self) + def render(renderable: RenderableType) -> list[Segment]: + """Render a panic renderables.""" + segments = list(self.console.render(renderable, self.console.options)) + return segments - log("---") - log(f"driver={self.driver_class}") + pre_rendered = [Segments(render(renderable)) for renderable in renderables] + self._exit_renderables.extend(pre_rendered) + self._close_messages_no_wait() - load_event = events.Load(sender=self) - await self.dispatch_message(load_event) - await self.post_message(events.Mount(self)) - await self.push_view(DockView()) + def _handle_exception(self, error: Exception) -> None: + """Called with an unhandled exception. - # Wait for the load event to be processed, so we don't go in to application mode beforehand - await load_event.wait() + Args: + error (Exception): An exception instance. + """ - driver = self._driver = self.driver_class(self.console, self) - try: - driver.start_application_mode() - except Exception: - self.console.print_exception() + if hasattr(error, "__rich__"): + # Exception has a rich method, so we can defer to that for the rendering + self.panic(error) else: - try: - self.console = Console() - self.title = self._title - self.refresh() - await self.animator.start() - await super().process_messages() - log("PROCESS END") - await self.animator.stop() - await self.close_all() + # Use default exception rendering + self.fatal_error() + + def fatal_error(self) -> None: + """Exits the app after an unhandled exception.""" + self.bell() + traceback = Traceback( + show_locals=True, width=None, locals_max_length=5, suppress=[rich] + ) + self._exit_renderables.append( + Segments(self.console.render(traceback, self.console.options)) + ) + 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( + self, ready_callback: CallbackType | None = None + ) -> None: + self._set_active() + + if self.devtools is not None: + from .devtools.client import DevtoolsConnectionError + + try: + await self.devtools.connect() + self.log.system(f"Connected to devtools ( {self.devtools.url} )") + except DevtoolsConnectionError: + self.log.system(f"Couldn't connect to devtools ( {self.devtools.url} )") + + self.log.system("---") + + self.log.system(driver=self.driver_class) + self.log.system(loop=asyncio.get_running_loop()) + self.log.system(features=self.features) + + try: + if self.css_path is not None: + self.stylesheet.read(self.css_path) + for path, css, tie_breaker in self.get_default_css(): + self.stylesheet.add_source( + css, path=path, is_default_css=True, tie_breaker=tie_breaker + ) + if self.CSS: + try: + app_css_path = ( + f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}" + ) + except TypeError: + app_css_path = f"{self.__class__.__name__}" + self.stylesheet.add_source( + self.CSS, path=app_css_path, is_default_css=False + ) + except Exception as error: + self._handle_exception(error) + self._print_error_renderables() + return + + if self.css_monitor: + self.set_interval(0.25, self.css_monitor, name="css monitor") + self.log.system("[b green]STARTED[/]", self.css_monitor) + + async def run_process_messages(): + + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + self._mounted_event.set() + + Reactive._initialize_object(self) + + self.stylesheet.update(self) + self.refresh() + + await self.animator.start() + await self._ready() + if ready_callback is not None: + await ready_callback() + + self._running = True + + try: + await self._process_messages_loop() + except asyncio.CancelledError: + pass + finally: + self._running = False + for timer in list(self._timers): + await timer.stop() + + await self.animator.stop() + await self._close_all() + + self._running = True + try: + load_event = events.Load(sender=self) + await self._dispatch_message(load_event) + + driver: Driver + driver_class = cast( + "type[Driver]", + HeadlessDriver if self.is_headless else self.driver_class, + ) + driver = self._driver = driver_class(self.console, self) + + driver.start_application_mode() + try: + if self.is_headless: + await run_process_messages() + else: + if self.devtools is not None: + devtools = self.devtools + assert devtools is not None + from .devtools.redirect_output import StdoutRedirector + + redirector = StdoutRedirector(devtools) + with redirect_stderr(redirector): + with redirect_stdout(redirector): # type: ignore + await run_process_messages() + else: + null_file = _NullFile() + with redirect_stderr(null_file): + with redirect_stdout(null_file): + await run_process_messages() - except Exception: - self.panic() finally: driver.stop_application_mode() - if self._exit_renderables: - for renderable in self._exit_renderables: - self.error_console.print(renderable) - if self.log_file is not None: - self.log_file.close() + except Exception as error: + self._handle_exception(error) + finally: + self._running = False + self._print_error_renderables() + if self.devtools is not None and self.devtools.is_connected: + await self._disconnect_devtools() - def register(self, child: MessagePump, parent: MessagePump) -> bool: - if child not in self.children: - self.children.add(child) - child.set_parent(parent) - child.start_messages() - child.post_message_no_wait(events.Mount(sender=parent)) + async def _pre_process(self) -> None: + pass + + async def _ready(self) -> None: + """Called immediately prior to processing messages. + + May be used as a hook for any operations that should run first. + + """ + try: + screenshot_timer = float(os.environ.get("TEXTUAL_SCREENSHOT", "0")) + 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(title=screenshot_title) + self._screenshot = svg # type: ignore + await self.shutdown() + + self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") + + async def _on_compose(self) -> None: + widgets = list(self.compose()) + await self.mount_all(widgets) + + def _on_idle(self) -> None: + """Perform actions when there are no messages in the queue.""" + if self._require_stylesheet_update: + nodes: set[DOMNode] = { + child + for node in self._require_stylesheet_update + for child in node.walk_children() + } + self._require_stylesheet_update.clear() + self.stylesheet.update_nodes(nodes, animate=True) + + def _register_child(self, parent: DOMNode, child: Widget) -> bool: + if child not in self._registry: + parent.children._append(child) + self._registry.add(child) + child._attach(parent) + child._post_register(self) + child._start_messages() return True return False - async def close_all(self) -> None: - while self.children: - child = self.children.pop() - await child.close_messages() - - async def remove(self, child: MessagePump) -> None: - self.children.remove(child) - - async def shutdown(self): - driver = self._driver - assert driver is not None - driver.disable_input() - await self.close_messages() - - def refresh(self, repaint: bool = True, layout: bool = False) -> None: - sync_available = ( - os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" and not WINDOWS - ) - if not self._closed: - console = self.console - try: - if sync_available: - console.file.write("\x1bP=1s\x1b\\") - console.print(Screen(Control.home(), self.view, Control.home())) - if sync_available: - console.file.write("\x1bP=2s\x1b\\") - console.file.flush() - except Exception: - self.panic() - - def display(self, renderable: RenderableType) -> None: - sync_available = os.environ.get("TERM_PROGRAM", "") != "Apple_Terminal" - if not self._closed: - console = self.console - try: - console.print(renderable) - except Exception: - self.panic() - - def measure(self, renderable: RenderableType, max_width=100_000) -> int: - """Get the optimal width for a widget or renderable. + def _register( + self, parent: DOMNode, *anon_widgets: Widget, **widgets: Widget + ) -> list[Widget]: + """Register widget(s) so they may receive events. Args: - renderable (RenderableType): A renderable (including Widget) - max_width ([type], optional): Maximum width. Defaults to 100_000. + parent (Widget): Parent Widget. Returns: - int: Number of cells required to render. + list[Widget]: List of modified widgets. + """ - measurement = Measurement.get( - self.console, self.console.options.update(max_width=max_width), renderable - ) - return measurement.maximum + if not anon_widgets and not widgets: + return [] + name_widgets: list[tuple[str | None, Widget]] + name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()] + apply_stylesheet = self.stylesheet.apply + + for widget_id, widget in name_widgets: + if not isinstance(widget, Widget): + raise AppError(f"Can't register {widget!r}; expected a Widget instance") + if widget not in self._registry: + if widget_id is not None: + widget.id = widget_id + self._register_child(parent, widget) + if widget.children: + self._register(widget, *widget.children) + apply_stylesheet(widget) + + registered_widgets = [widget for _, widget in name_widgets] + return registered_widgets + + def _unregister(self, widget: Widget) -> None: + """Unregister a widget. + + Args: + widget (Widget): A Widget to unregister + """ + widget.reset_focus() + if isinstance(widget._parent, Widget): + widget._parent.children._remove(widget) + widget._detach() + self._registry.discard(widget) + + async def _disconnect_devtools(self): + if self.devtools is not None: + await self.devtools.disconnect() + + def _start_widget(self, parent: Widget, widget: Widget) -> None: + """Start a widget (run it's task) so that it can receive messages. + + Args: + parent (Widget): The parent of the Widget. + widget (Widget): The Widget to start. + """ + widget._attach(parent) + widget._start_messages() + + def is_mounted(self, widget: Widget) -> bool: + """Check if a widget is mounted. + + Args: + widget (Widget): A widget. + + Returns: + bool: True of the widget is mounted. + """ + return widget in self._registry + + async def _close_all(self) -> None: + while self._registry: + child = self._registry.pop() + 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() + + def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: + if self._screen_stack: + self.screen.refresh(repaint=repaint, layout=layout) + self.check_idle() + + def refresh_css(self, animate: bool = True) -> None: + """Refresh CSS. + + Args: + animate (bool, optional): Also execute CSS animations. Defaults to True. + """ + stylesheet = self.app.stylesheet + stylesheet.set_variables(self.get_css_variables()) + stylesheet.reparse() + stylesheet.update(self.app, animate=animate) + self.screen._refresh_layout(self.size, full=True) + + def _display(self, screen: Screen, renderable: RenderableType | None) -> None: + """Display a renderable within a sync. + + Args: + screen (Screen): Screen instance + renderable (RenderableType): A Rich renderable. + """ + if screen is not self.screen or renderable is None: + return + if self._running and not self._closed and not self.is_headless: + console = self.console + self._begin_update() + try: + try: + console.print(renderable) + except Exception as error: + self._handle_exception(error) + finally: + self._end_update() + console.file.flush() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given coordinates. @@ -394,173 +1286,313 @@ class App(MessagePump): Returns: tuple[Widget, Region]: The widget and the widget's screen region. """ - return self.view.get_widget_at(x, y) + return self.screen.get_widget_at(x, y) - async def press(self, key: str) -> bool: + def bell(self) -> None: + """Play the console 'bell'.""" + if not self.is_headless: + self.console.bell() + + @property + def _binding_chain(self) -> list[tuple[DOMNode, Bindings]]: + """Get a chain of nodes and bindings to consider. If no widget is focused, returns the bindings from both the screen and the app level bindings. Otherwise, combines all the bindings from the currently focused node up the DOM to the root App. + + Returns: + list[tuple[DOMNode, Bindings]]: List of DOM nodes and their bindings. + """ + focused = self.focused + namespace_bindings: list[tuple[DOMNode, Bindings]] + if focused is None: + namespace_bindings = [ + (self.screen, self.screen._bindings), + (self, self._bindings), + ] + else: + namespace_bindings = [(node, node._bindings) for node in focused.ancestors] + return namespace_bindings + + async def check_bindings(self, key: str, universal: bool = False) -> bool: """Handle a key press. Args: key (str): A key + universal (bool): Check universal keys if True, otherwise non-universal keys. Returns: bool: True if the key was handled by a binding, otherwise False """ - try: - binding = self.bindings.get_key(key) - except NoBinding: - return False - else: - await self.action(binding.action) - return True + + for namespace, bindings in self._binding_chain: + binding = bindings.keys.get(key) + if binding is not None and binding.universal == universal: + await self.action(binding.action, default_namespace=namespace) + return True + return False async def on_event(self, event: events.Event) -> None: # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App - if isinstance(event, events.InputEvent) and not event.is_forwarded: + if isinstance(event, events.Compose): + screen = Screen(id="_default") + self._register(self, screen) + self._screen_stack.append(screen) + await super().on_event(event) + + elif isinstance(event, events.InputEvent) and not event.is_forwarded: if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) - if isinstance(event, events.Key) and self.focused is not None: - # Key events are sent direct to focused widget - if self.bindings.allow_forward(event.key): - await self.focused.forward_event(event) - else: - # Key has allow_forward=False which disallows forward to focused widget - await super().on_event(event) + await self.screen._forward_event(event) + elif isinstance(event, events.Key): + if not await self.check_bindings(event.key, universal=True): + forward_target = self.focused or self.screen + await forward_target._forward_event(event) else: - # Forward the event to the view - await self.view.forward_event(event) + await self.screen._forward_event(event) + + elif isinstance(event, events.Paste): + if self.focused is not None: + await self.focused._forward_event(event) else: await super().on_event(event) async def action( self, - action: str, + action: str | tuple[str, tuple[str, ...]], default_namespace: object | None = None, - modifiers: set[str] | None = None, - ) -> None: + ) -> bool: """Perform an action. Args: action (str): Action encoded in a string. + default_namespace (object | None): Namespace to use if not provided in the action, + or None to use app. Defaults to None. + + Returns: + bool: True if the event has handled. """ - target, params = actions.parse(action) + print("ACTION", action, default_namespace) + if isinstance(action, str): + target, params = actions.parse(action) + else: + target, params = action + implicit_destination = True if "." in target: destination, action_name = target.split(".", 1) if destination not in self._action_targets: raise ActionError(f"Action namespace {destination} is not known") action_target = getattr(self, destination) + implicit_destination = True else: action_target = default_namespace or self action_name = target - log("ACTION", action_target, action_name) - await self.dispatch_action(action_target, action_name, params) + handled = await self._dispatch_action(action_target, action_name, params) + if not handled and implicit_destination and not isinstance(action_target, App): + handled = await self.app._dispatch_action(self.app, action_name, params) + return handled - async def dispatch_action( + async def _dispatch_action( self, namespace: object, action_name: str, params: Any - ) -> None: + ) -> bool: + log( + "", + namespace=namespace, + action_name=action_name, + params=params, + ) _rich_traceback_guard = True - method_name = f"action_{action_name}" - method = getattr(namespace, method_name, None) - if callable(method): - await invoke(method, *params) - async def broker_event( + public_method_name = f"action_{action_name}" + private_method_name = f"_{public_method_name}" + + private_method = getattr(namespace, private_method_name, None) + public_method = getattr(namespace, public_method_name, None) + + if private_method is None and public_method is None: + log( + f" {action_name!r} has no target. Couldn't find methods {public_method_name!r} or {private_method_name!r}" + ) + + if callable(private_method): + await invoke(private_method, *params) + return True + elif callable(public_method): + await invoke(public_method, *params) + return True + + return False + + async def _broker_event( self, event_name: str, event: events.Event, default_namespace: object | None ) -> bool: - event.stop() + """Allow the app an opportunity to dispatch events to action system. + + Args: + event_name (str): _description_ + event (events.Event): An event object. + default_namespace (object | None): TODO: _description_ + + Returns: + bool: True if an action was processed. + """ try: style = getattr(event, "style") except AttributeError: return False try: - modifiers, action = extract_handler_actions(event_name, style.meta) + _modifiers, action = extract_handler_actions(event_name, style.meta) except NoHandler: return False - if isinstance(action, str): - await self.action( - action, default_namespace=default_namespace, modifiers=modifiers - ) - elif isinstance(action, Callable): + else: + event.stop() + if isinstance(action, (str, tuple)): + await self.action(action, default_namespace=default_namespace) + elif callable(action): await action() else: return False return True - async def on_key(self, event: events.Key) -> None: - await self.press(event.key) + async def _on_update(self, message: messages.Update) -> None: + message.stop() - async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: + async def _on_layout(self, message: messages.Layout) -> None: + message.stop() + + async def _on_key(self, event: events.Key) -> None: + if event.key == "tab": + self.screen.focus_next() + elif event.key == "shift+tab": + self.screen.focus_previous() + else: + if not (await self.check_bindings(event.key)): + await self.dispatch_key(event) + + 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: - await self.view.post_message(event) + async def _on_resize(self, event: events.Resize) -> None: + event.stop() + await self.screen.post_message(event) - async def action_press(self, key: str) -> None: - await self.press(key) + async def _on_remove(self, event: events.Remove) -> None: + widget = event.widget + parent = widget.parent + + remove_widgets = widget.walk_children( + Widget, with_self=True, method="depth", reverse=True + ) + + if self.screen.focused in remove_widgets: + self.screen._reset_focus( + self.screen.focused, + [to_remove for to_remove in remove_widgets if to_remove.can_focus], + ) + + for child in remove_widgets: + await child._close_messages() + self._unregister(child) + if parent is not None: + parent.refresh(layout=True) + + async def action_check_bindings(self, key: str) -> None: + await self.check_bindings(key) async def action_quit(self) -> None: + """Quit the app as soon as possible.""" await self.shutdown() async def action_bang(self) -> None: 1 / 0 async def action_bell(self) -> None: - self.console.bell() + """Play the terminal 'bell'.""" + self.bell() + + async def action_focus(self, widget_id: str) -> None: + """Focus the given widget. + + Args: + widget_id (str): ID of widget to focus. + """ + try: + node = self.query(f"#{widget_id}").first() + except NoMatches: + pass + else: + if isinstance(node, Widget): + self.set_focus(node) + + async def action_switch_screen(self, screen: str) -> None: + """Switches to another screen. + + Args: + screen (str): Name of the screen. + """ + self.switch_screen(screen) + + async def action_push_screen(self, screen: str) -> None: + """Pushes a screen on to the screen stack and makes it active. + + Args: + screen (str): Name of the screen. + """ + self.push_screen(screen) + + async def action_pop_screen(self) -> None: + """Removes the topmost screen and makes the new topmost screen active.""" + self.pop_screen() + + async def action_back(self) -> None: + try: + self.pop_screen() + except ScreenStackError: + pass + + async def action_add_class_(self, selector: str, class_name: str) -> None: + self.screen.query(selector).add_class(class_name) + + async def action_remove_class_(self, selector: str, class_name: str) -> None: + self.screen.query(selector).remove_class(class_name) + + async def action_toggle_class(self, selector: str, class_name: str) -> None: + self.screen.query(selector).toggle_class(class_name) + + def _on_terminal_supports_synchronized_output( + self, message: messages.TerminalSupportsSynchronizedOutput + ) -> None: + log.system("[b green]SynchronizedOutput mode is supported") + self._sync_available = True + + def _begin_update(self) -> None: + if self._sync_available: + self.console.file.write(SYNC_START) + + def _end_update(self) -> None: + if self._sync_available: + self.console.file.write(SYNC_END) -if __name__ == "__main__": - import asyncio - from logging import FileHandler +_uvloop_init_done: bool = False - from rich.panel import Panel - from .widgets import Header - from .widgets import Footer +def _init_uvloop() -> None: + """ + Import and install the `uvloop` asyncio policy, if available. + This is done only once, even if the function is called multiple times. + """ + global _uvloop_init_done - from .widgets import Placeholder - from .scrollbar import ScrollBar + if _uvloop_init_done: + return - from rich.markdown import Markdown + try: + import uvloop + except ImportError: + pass + else: + uvloop.install() - # from .widgets.scroll_view import ScrollView - - import os - - class MyApp(App): - """Just a test app.""" - - async def on_load(self, event: events.Load) -> None: - await self.bind("ctrl+c", "quit", show=False) - await self.bind("q", "quit", "Quit") - await self.bind("x", "bang", "Test error handling") - await self.bind("b", "toggle_sidebar", "Toggle sidebar") - - show_bar: Reactive[bool] = Reactive(False) - - async def watch_show_bar(self, show_bar: bool) -> None: - self.animator.animate(self.bar, "layout_offset_x", 0 if show_bar else -40) - - async def action_toggle_sidebar(self) -> None: - self.show_bar = not self.show_bar - - async def on_mount(self, event: events.Mount) -> None: - - view = await self.push_view(DockView()) - - header = Header() - footer = Footer() - self.bar = Placeholder(name="left") - - await view.dock(header, edge="top") - await view.dock(footer, edge="bottom") - await view.dock(self.bar, edge="left", size=40, z=1) - self.bar.layout_offset_x = -40 - - sub_view = DockView() - await sub_view.dock(Placeholder(), Placeholder(), edge="top") - await view.dock(sub_view, edge="left") - - MyApp.run(log="textual.log") + _uvloop_init_done = True diff --git a/src/textual/background.py b/src/textual/background.py deleted file mode 100644 index ad3a3fd12..000000000 --- a/src/textual/background.py +++ /dev/null @@ -1,29 +0,0 @@ -from rich.console import Console, ConsoleOptions, RenderResult -from rich.segment import Segment, SegmentLines -from rich.style import StyleType - -from .widget import Widget - - -class BackgroundRenderable: - def __init__(self, style: StyleType) -> None: - self.style = style - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - - width = options.max_width - height = options.height or console.height - style = console.get_style(self.style) - blank_segment = Segment(" " * width, style) - lines = SegmentLines([[blank_segment]] * height, new_lines=True) - yield lines - - -class Background(Widget): - def __init__(self, style: StyleType = "on blue") -> None: - self.background_style = style - - def render(self) -> BackgroundRenderable: - return BackgroundRenderable(self.background_style) diff --git a/src/textual/binding.py b/src/textual/binding.py index eeaf7e22e..3ea032589 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -1,29 +1,104 @@ from __future__ import annotations + +import sys from dataclasses import dataclass +from typing import Iterable, MutableMapping + +import rich.repr + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + +BindingType: TypeAlias = "Binding | tuple[str, str, str]" + + +class BindingError(Exception): + """A binding related error.""" class NoBinding(Exception): """A binding was not found.""" -@dataclass +@dataclass(frozen=True) class Binding: key: str + """Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" action: str + """Action to bind to.""" description: str - show: bool = False + """Description of action.""" + show: bool = True + """Show the action in Footer, or False to hide.""" key_display: str | None = None - allow_forward: bool = True + """How the key should be shown in footer.""" + universal: bool = False + """Allow forwarding from app to focused widget.""" +@rich.repr.auto class Bindings: """Manage a set of bindings.""" - def __init__(self) -> None: - self.keys: dict[str, Binding] = {} + def __init__(self, bindings: Iterable[BindingType] | None = None) -> None: + def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]: + for binding in bindings: + # If it's a tuple of length 3, convert into a Binding first + if isinstance(binding, tuple): + if len(binding) != 3: + raise BindingError( + f"BINDINGS must contain a tuple of three strings, not {binding!r}" + ) + binding = Binding(*binding) + + binding_keys = binding.key.split(",") + if len(binding_keys) > 1: + for key in binding_keys: + new_binding = Binding( + key=key, + action=binding.action, + description=binding.description, + show=binding.show, + key_display=binding.key_display, + universal=binding.universal, + ) + yield new_binding + else: + yield binding + + self.keys: MutableMapping[str, Binding] = ( + {binding.key: binding for binding in make_bindings(bindings)} + if bindings + else {} + ) + + def __rich_repr__(self) -> rich.repr.Result: + yield self.keys + + @classmethod + def merge(cls, bindings: Iterable[Bindings]) -> Bindings: + """Merge a bindings. Subsequence bound keys override initial keys. + + Args: + bindings (Iterable[Bindings]): A number of bindings. + + Returns: + Bindings: New bindings. + """ + keys: dict[str, Binding] = {} + for _bindings in bindings: + keys.update(_bindings.keys) + return Bindings(keys.values()) @property def shown_keys(self) -> list[Binding]: + """A list of bindings for shown keys. + + Returns: + list[Binding]: Shown bindings. + """ keys = [binding for binding in self.keys.values() if binding.show] return keys @@ -34,7 +109,7 @@ class Bindings: description: str = "", show: bool = True, key_display: str | None = None, - allow_forward: bool = True, + universal: bool = False, ) -> None: all_keys = [key.strip() for key in keys.split(",")] for key in all_keys: @@ -44,37 +119,22 @@ class Bindings: description, show=show, key_display=key_display, - allow_forward=allow_forward, + universal=universal, ) def get_key(self, key: str) -> Binding: + """Get a binding if it exists. + + Args: + key (str): Key to look up. + + Raises: + NoBinding: If the binding does not exist. + + Returns: + Binding: A binding object for the key, + """ try: return self.keys[key] except KeyError: raise NoBinding(f"No binding for {key}") from None - - def allow_forward(self, key: str) -> bool: - binding = self.keys.get(key, None) - if binding is None: - return True - return binding.allow_forward - - -class BindingStack: - """Manage a stack of bindings.""" - - def __init__(self, *bindings: Bindings) -> None: - self._stack: list[Bindings] = list(bindings) - - def push(self, bindings: Bindings) -> None: - self._stack.append(bindings) - - def pop(self) -> Bindings: - return self._stack.pop() - - def get_key(self, key: str) -> Binding: - for bindings in reversed(self._stack): - binding = bindings.keys.get(key, None) - if binding is not None: - return binding - raise NoBinding(f"No binding for {key}") from None diff --git a/src/textual/box_model.py b/src/textual/box_model.py new file mode 100644 index 000000000..7271bef54 --- /dev/null +++ b/src/textual/box_model.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from fractions import Fraction +from typing import Callable, NamedTuple + +from .css.styles import StylesBase +from .geometry import Size, Spacing + + +class BoxModel(NamedTuple): + """The result of `get_box_model`.""" + + # Content + padding + border + width: Fraction + height: Fraction + margin: Spacing # Additional margin + + +def get_box_model( + styles: StylesBase, + container: Size, + viewport: Size, + fraction_unit: Fraction, + get_content_width: Callable[[Size, Size], int], + get_content_height: Callable[[Size, Size, int], int], +) -> BoxModel: + """Resolve the box model for this Styles. + + Args: + styles (StylesBase): Styles object. + container (Size): The size of the widget container. + viewport (Size): The viewport size. + get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. + get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. + + Returns: + BoxModel: A tuple with the size of the content area and margin. + """ + _content_width, _content_height = container + content_width = Fraction(_content_width) + content_height = Fraction(_content_height) + is_border_box = styles.box_sizing == "border-box" + 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 + + # 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: + # No width specified, fill available space + content_width = Fraction(content_container.width - margin.width) + elif is_auto_width: + # When width is auto, we want enough space to always fit the content + content_width = Fraction( + get_content_width(content_container - styles.margin.totals, viewport) + ) + else: + # An explicit width + styles_width = styles.width + content_width = styles_width.resolve_dimension( + sizing_container - styles.margin.totals, viewport, fraction_unit + ) + if is_border_box and styles_width.excludes_border: + content_width -= gutter.width + + if styles.min_width is not None: + # Restrict to minimum width, if set + min_width = styles.min_width.resolve_dimension( + content_container, viewport, fraction_unit + ) + content_width = max(content_width, min_width) + + if styles.max_width is not None: + # Restrict to maximum width, if set + max_width = styles.max_width.resolve_dimension( + content_container, viewport, fraction_unit + ) + if is_border_box: + max_width -= gutter.width + content_width = min(content_width, max_width) + + content_width = max(Fraction(0), content_width) + + if styles.height is None: + # No height specified, fill the available space + content_height = Fraction(content_container.height - margin.height) + elif is_auto_height: + # Calculate dimensions based on content + content_height = Fraction( + 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 + ) + if is_border_box and styles_height.excludes_border: + content_height -= gutter.height + + if styles.min_height is not None: + # Restrict to minimum height, if set + min_height = styles.min_height.resolve_dimension( + content_container, viewport, fraction_unit + ) + content_height = max(content_height, min_height) + + if styles.max_height is not None: + # Restrict maximum height, if set + max_height = styles.max_height.resolve_dimension( + content_container, viewport, fraction_unit + ) + content_height = min(content_height, max_height) + + content_height = max(Fraction(1), content_height) + model = BoxModel( + content_width + gutter.width, content_height + gutter.height, margin + ) + return model diff --git a/src/textual/cli/__init__.py b/src/textual/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/cli/__main__.py b/src/textual/cli/__main__.py new file mode 100644 index 000000000..27cfa4889 --- /dev/null +++ b/src/textual/cli/__main__.py @@ -0,0 +1,3 @@ +from .cli import run + +run() diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py new file mode 100644 index 000000000..3b1df1f4b --- /dev/null +++ b/src/textual/cli/cli.py @@ -0,0 +1,119 @@ +from __future__ import annotations + + +import click +from importlib_metadata import version + +from textual._import_app import import_app, AppFail + + +@click.group() +@click.version_option(version("textual")) +def run(): + pass + + +@run.command(help="Run the Textual Devtools console.") +@click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True) +@click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True) +def console(verbose: bool, exclude: list[str]) -> None: + """Launch the textual console.""" + from rich.console import Console + from textual.devtools.server import _run_devtools + + console = Console() + console.clear() + console.show_cursor(False) + try: + _run_devtools(verbose=verbose, exclude=exclude) + finally: + console.show_cursor(True) + + +@run.command( + "run", + context_settings={ + "ignore_unknown_options": True, + }, +) +@click.argument("import_name", metavar="FILE or FILE:APP") +@click.option("--dev", "dev", help="Enable development mode", is_flag=True) +@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 + import, which will load the code and run an app called "app". You may optionally + add a colon plus the class or class instance you want to run. + + Here are some examples: + + textual run foo.py + + textual run foo.py:MyApp + + textual run module.foo + + textual run module.foo:MyApp + + If you are running a file and want to pass command line arguments, wrap the filename and arguments + in quotes: + + textual run "foo.py arg --option" + + """ + + import os + import sys + + from textual.features import parse_features + + features = set(parse_features(os.environ.get("TEXTUAL", ""))) + if dev: + features.add("debug") + features.add("devtools") + + os.environ["TEXTUAL"] = ",".join(sorted(features)) + try: + app = import_app(import_name) + except AppFail as error: + from rich.console import Console + + console = Console(stderr=True) + console.print(str(error)) + sys.exit(1) + + press_keys = press.split(",") if press else None + result = app.run(press=press_keys) + + if result is not None: + from rich.console import Console + from rich.pretty import Pretty + + console = Console() + console.print("[b]The app returned:") + console.print(Pretty(result)) + + +@run.command("borders") +def borders(): + """Explore the border styles available in Textual.""" + from textual.cli.previews import borders + + borders.app.run() + + +@run.command("easing") +def easing(): + """Explore the animation easing functions available in Textual.""" + from textual.cli.previews import easing + + easing.app.run() + + +@run.command("colors") +def colors(): + """Explore the design system.""" + from textual.cli.previews import colors + + colors.app.run() diff --git a/src/textual/cli/previews/__init__.py b/src/textual/cli/previews/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py new file mode 100644 index 000000000..613343443 --- /dev/null +++ b/src/textual/cli/previews/borders.py @@ -0,0 +1,65 @@ +from textual.app import App, ComposeResult +from textual.constants import BORDERS +from textual.widgets import Button, Static +from textual.containers import Vertical + + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class BorderButtons(Vertical): + DEFAULT_CSS = """ + BorderButtons { + dock: left; + width: 24; + overflow-y: scroll; + } + + BorderButtons > Button { + width: 100%; + } + """ + + def compose(self) -> ComposeResult: + for border in BORDERS: + if border: + yield Button(border, id=border) + + +class BorderApp(App): + """Demonstrates the border styles.""" + + CSS = """ + #text { + margin: 2 4; + padding: 2 4; + border: solid $secondary; + height: auto; + background: $panel; + color: $text; + } + """ + + def compose(self): + yield BorderButtons() + self.text = Static(TEXT, id="text") + yield self.text + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.text.styles.border = ( + event.button.id, + self.stylesheet._variables["secondary"], + ) + self.bell() + + +app = BorderApp() + +if __name__ == "__main__": + app.run() diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css new file mode 100644 index 000000000..3af8eabd7 --- /dev/null +++ b/src/textual/cli/previews/colors.css @@ -0,0 +1,329 @@ + +ColorButtons { + dock: left; + overflow-y: auto; + width: 30; +} + +ColorButtons > Button { + width: 30; +} + +ColorsView { + width: 100%; + height: 100%; + align: center middle; + overflow-x: auto; + background: $background; + scrollbar-gutter: stable; +} + +ColorItem { + layout: horizontal; + height: 3; + width: 1fr; +} + +ColorBar { + height: auto; + width: 1fr; + content-align: center middle; +} + +ColorBar.label { + width: 2fr; + +} + +ColorItem { + width: 100%; + padding: 1 2; +} + +ColorGroup { + margin: 2 0; + width: 80; + height: auto; + padding: 1 4 2 4; + background: $surface; + border: wide $surface; +} + + +ColorGroup.-active { + border: wide $secondary; +} + +.text { + color: $text; +} + +.muted { + color: $text-muted; +} + + +.disabled { + color: $text-disabled; +} + + +ColorLabel { + padding: 0 0 1 0; + content-align: center middle; + color: $text; + text-style: bold; +} + +.primary-darken-3 { + background: $primary-darken-3; +} +.primary-darken-2 { + background: $primary-darken-2; +} +.primary-darken-1 { + background: $primary-darken-1; +} +.primary { + background: $primary; +} +.primary-lighten-1 { + background: $primary-lighten-1; +} +.primary-lighten-2 { + background: $primary-lighten-2; +} +.primary-lighten-3 { + background: $primary-lighten-3; +} +.secondary-darken-3 { + background: $secondary-darken-3; +} +.secondary-darken-2 { + background: $secondary-darken-2; +} +.secondary-darken-1 { + background: $secondary-darken-1; +} +.secondary { + background: $secondary; +} +.secondary-lighten-1 { + background: $secondary-lighten-1; +} +.secondary-lighten-2 { + background: $secondary-lighten-2; +} +.secondary-lighten-3 { + background: $secondary-lighten-3; +} +.background-darken-3 { + background: $background-darken-3; +} +.background-darken-2 { + background: $background-darken-2; +} +.background-darken-1 { + background: $background-darken-1; +} +.background { + background: $background; +} +.background-lighten-1 { + background: $background-lighten-1; +} +.background-lighten-2 { + background: $background-lighten-2; +} +.background-lighten-3 { + background: $background-lighten-3; +} +.primary-background-darken-3 { + background: $primary-background-darken-3; +} +.primary-background-darken-2 { + background: $primary-background-darken-2; +} +.primary-background-darken-1 { + background: $primary-background-darken-1; +} +.primary-background { + background: $primary-background; +} +.primary-background-lighten-1 { + background: $primary-background-lighten-1; +} +.primary-background-lighten-2 { + background: $primary-background-lighten-2; +} +.primary-background-lighten-3 { + background: $primary-background-lighten-3; +} +.secondary-background-darken-3 { + background: $secondary-background-darken-3; +} +.secondary-background-darken-2 { + background: $secondary-background-darken-2; +} +.secondary-background-darken-1 { + background: $secondary-background-darken-1; +} +.secondary-background { + background: $secondary-background; +} +.secondary-background-lighten-1 { + background: $secondary-background-lighten-1; +} +.secondary-background-lighten-2 { + background: $secondary-background-lighten-2; +} +.secondary-background-lighten-3 { + background: $secondary-background-lighten-3; +} +.surface-darken-3 { + background: $surface-darken-3; +} +.surface-darken-2 { + background: $surface-darken-2; +} +.surface-darken-1 { + background: $surface-darken-1; +} +.surface { + background: $surface; +} +.surface-lighten-1 { + background: $surface-lighten-1; +} +.surface-lighten-2 { + background: $surface-lighten-2; +} +.surface-lighten-3 { + background: $surface-lighten-3; +} +.panel-darken-3 { + background: $panel-darken-3; +} +.panel-darken-2 { + background: $panel-darken-2; +} +.panel-darken-1 { + background: $panel-darken-1; +} +.panel { + background: $panel; +} +.panel-lighten-1 { + background: $panel-lighten-1; +} +.panel-lighten-2 { + background: $panel-lighten-2; +} +.panel-lighten-3 { + background: $panel-lighten-3; +} +.boost-darken-3 { + background: $boost-darken-3; +} +.boost-darken-2 { + background: $boost-darken-2; +} +.boost-darken-1 { + background: $boost-darken-1; +} +.boost { + background: $boost; +} +.boost-lighten-1 { + background: $boost-lighten-1; +} +.boost-lighten-2 { + background: $boost-lighten-2; +} +.boost-lighten-3 { + background: $boost-lighten-3; +} +.warning-darken-3 { + background: $warning-darken-3; +} +.warning-darken-2 { + background: $warning-darken-2; +} +.warning-darken-1 { + background: $warning-darken-1; +} +.warning { + background: $warning; +} +.warning-lighten-1 { + background: $warning-lighten-1; +} +.warning-lighten-2 { + background: $warning-lighten-2; +} +.warning-lighten-3 { + background: $warning-lighten-3; +} +.error-darken-3 { + background: $error-darken-3; +} +.error-darken-2 { + background: $error-darken-2; +} +.error-darken-1 { + background: $error-darken-1; +} +.error { + background: $error; +} +.error-lighten-1 { + background: $error-lighten-1; +} +.error-lighten-2 { + background: $error-lighten-2; +} +.error-lighten-3 { + background: $error-lighten-3; +} +.success-darken-3 { + background: $success-darken-3; +} +.success-darken-2 { + background: $success-darken-2; +} +.success-darken-1 { + background: $success-darken-1; +} +.success { + background: $success; +} +.success-lighten-1 { + background: $success-lighten-1; +} +.success-lighten-2 { + background: $success-lighten-2; +} +.success-lighten-3 { + background: $success-lighten-3; +} +.accent-darken-3 { + background: $accent-darken-3; +} +.accent-darken-2 { + background: $accent-darken-2; +} +.accent-darken-1 { + background: $accent-darken-1; +} +.accent { + background: $accent; +} +.accent-lighten-1 { + background: $accent-lighten-1; +} +.accent-lighten-2 { + background: $accent-lighten-2; +} +.accent-lighten-3 { + background: $accent-lighten-3; +} diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py new file mode 100644 index 000000000..5edd4050e --- /dev/null +++ b/src/textual/cli/previews/colors.py @@ -0,0 +1,91 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.design import ColorSystem +from textual.widget import Widget +from textual.widgets import Button, Footer, Static + + +class ColorButtons(Vertical): + def compose(self) -> ComposeResult: + for border in ColorSystem.COLOR_NAMES: + if border: + yield Button(border, id=border) + + +class ColorBar(Static): + pass + + +class ColorItem(Horizontal): + pass + + +class ColorGroup(Vertical): + pass + + +class Content(Vertical): + pass + + +class ColorLabel(Static): + pass + + +class ColorsView(Vertical): + def compose(self) -> ComposeResult: + + LEVELS = [ + "darken-3", + "darken-2", + "darken-1", + "", + "lighten-1", + "lighten-2", + "lighten-3", + ] + + for color_name in ColorSystem.COLOR_NAMES: + + items: list[Widget] = [ColorLabel(f'"{color_name}"')] + for level in LEVELS: + color = f"{color_name}-{level}" if level else color_name + item = ColorItem( + ColorBar(f"${color}", classes="text label"), + ColorBar(f"$text-muted", classes="muted"), + ColorBar(f"$text-disabled", classes="disabled"), + classes=color, + ) + items.append(item) + + yield ColorGroup(*items, id=f"group-{color_name}") + + +class ColorsApp(App): + CSS_PATH = "colors.css" + + BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + + def compose(self) -> ComposeResult: + yield Content(ColorButtons()) + yield Footer() + + def on_mount(self) -> None: + self.call_later(self.update_view) + + def update_view(self) -> None: + content = self.query_one("Content", Content) + content.mount(ColorsView()) + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.bell() + self.query(ColorGroup).remove_class("-active") + group = self.query_one(f"#group-{event.button.id}", ColorGroup) + group.add_class("-active") + group.scroll_visible(top=True, speed=150) + + +app = ColorsApp() + +if __name__ == "__main__": + app.run() diff --git a/src/textual/cli/previews/easing.css b/src/textual/cli/previews/easing.css new file mode 100644 index 000000000..83277d566 --- /dev/null +++ b/src/textual/cli/previews/easing.css @@ -0,0 +1,54 @@ +EasingButtons > Button { + width: 100%; +} +EasingButtons { + dock: left; + overflow-y: scroll; + width: 20; +} + +#bar-container { + content-align: center middle; +} + +#duration-input { + width: 30; + background: $boost; + padding: 0 1; + border: tall transparent; +} + +#duration-input:focus { + border: tall $accent; +} + +#inputs { + padding: 1; + height: auto; + dock: top; + background: $boost; +} + +Bar { + width: 1fr; +} + +#other { + width: 1fr; + background: $panel; + padding: 1; + height: 100%; + border-left: vkey $background; +} + +#opacity-widget { + padding: 1; + background: $warning; + color: $text; + border: wide $background; +} + +#label { + width: auto; + padding: 1; +} diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py new file mode 100644 index 000000000..6edcdb82a --- /dev/null +++ b/src/textual/cli/previews/easing.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from rich.console import RenderableType +from textual._easing import EASING +from textual.app import App, ComposeResult +from textual.cli.previews.borders import TEXT +from textual.containers import Container, Horizontal, Vertical +from textual.reactive import Reactive +from textual.scrollbar import ScrollBarRender +from textual.widget import Widget +from textual.widgets import Button, Footer, Static, Input + +VIRTUAL_SIZE = 100 +WINDOW_SIZE = 10 +START_POSITION = 0.0 +END_POSITION = float(VIRTUAL_SIZE - WINDOW_SIZE) + + +class EasingButtons(Widget): + def compose(self) -> ComposeResult: + for easing in sorted(EASING, reverse=True): + yield Button(easing, id=easing) + + +class Bar(Widget): + position = Reactive.init(START_POSITION) + animation_running = Reactive(False) + + DEFAULT_CSS = """ + + Bar { + background: $surface; + color: $error; + } + + Bar.-active { + background: $surface; + color: $success; + } + + """ + + def watch_animation_running(self, running: bool) -> None: + self.set_class(running, "-active") + + def render(self) -> RenderableType: + return ScrollBarRender( + virtual_size=VIRTUAL_SIZE, + window_size=WINDOW_SIZE, + position=self.position, + style=self.rich_style, + ) + + +class EasingApp(App): + position = Reactive.init(START_POSITION) + duration = Reactive.var(1.0) + + def on_load(self): + self.bind( + "ctrl+p", "focus('duration-input')", description="Focus: Duration Input" + ) + self.bind("ctrl+b", "toggle_dark", description="Toggle Dark") + + def compose(self) -> ComposeResult: + self.animated_bar = Bar() + self.animated_bar.position = START_POSITION + duration_input = Input("1.0", placeholder="Duration", id="duration-input") + + self.opacity_widget = Static( + f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget" + ) + + yield EasingButtons() + yield Vertical( + Horizontal( + Static("Animation Duration:", id="label"), duration_input, id="inputs" + ), + Horizontal( + self.animated_bar, + Container(self.opacity_widget, id="other"), + ), + Footer(), + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.bell() + self.animated_bar.animation_running = True + + def _animation_complete(): + self.animated_bar.animation_running = False + + target_position = ( + END_POSITION if self.position == START_POSITION else START_POSITION + ) + self.animate( + "position", + value=target_position, + final_value=target_position, + duration=self.duration, + easing=event.button.id, + on_complete=_animation_complete, + ) + + def watch_position(self, value: int): + self.animated_bar.position = value + self.opacity_widget.styles.opacity = 1 - value / END_POSITION + + def on_input_changed(self, event: Input.Changed): + if event.sender.id == "duration-input": + new_duration = _try_float(event.value) + if new_duration is not None: + self.duration = new_duration + + def action_toggle_dark(self): + self.dark = not self.dark + + +def _try_float(string: str) -> float | None: + try: + return float(string) + except ValueError: + return None + + +app = EasingApp(css_path="easing.css") +if __name__ == "__main__": + app.run() diff --git a/src/textual/color.py b/src/textual/color.py new file mode 100644 index 000000000..e836452cb --- /dev/null +++ b/src/textual/color.py @@ -0,0 +1,592 @@ +""" +This module contains a powerful Color class which Textual uses to expose colors. + +The only exception would be for Rich renderables, which require a rich.color.Color instance. +You can convert from a Textual color to a Rich color with the [rich_color][textual.color.Color.rich_color] property. + +## Named colors + +The following named colors are used by the [parse][textual.color.Color.parse] method. + +```{.rich title="colors"} +from textual._color_constants import COLOR_NAME_TO_RGB +from textual.color import Color +from rich.table import Table +from rich.text import Text +table = Table("Name", "hex", "RGB", "Color", expand=True, highlight=True) + +for name, triplet in sorted(COLOR_NAME_TO_RGB.items()): + if len(triplet) != 3: + continue + color = Color(*triplet) + r, g, b = triplet + table.add_row( + f'"{name}"', + Text(f"{color.hex}", "bold green"), + f"rgb({r}, {g}, {b})", + Text(" ", style=f"on rgb({r},{g},{b})") + ) +output = table +``` + + +""" + +from __future__ import annotations + +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 + +import rich.repr +from rich.color import Color as RichColor +from rich.color import ColorType +from rich.color_triplet import ColorTriplet +from rich.style import Style +from rich.text import Text + +from textual.css.scalar import percentage_string_to_float +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 + + +class HSL(NamedTuple): + """A color in HLS format.""" + + h: float + """Hue""" + s: float + """Saturation""" + l: float + """Lightness""" + + @property + def css(self) -> str: + """HSL in css format.""" + h, s, l = self + + def as_str(number: float) -> str: + return f"{number:.1f}".rstrip("0").rstrip(".") + + return f"hsl({as_str(h*360)},{as_str(s*100)}%,{as_str(l*100)}%)" + + +class HSV(NamedTuple): + """A color in HSV format.""" + + h: float + """Hue""" + s: float + """Saturation""" + v: float + """Value""" + + +class Lab(NamedTuple): + """A color in CIE-L*ab format.""" + + L: float + a: float + b: float + + +RE_COLOR = re.compile( + rf"""^ +\#([0-9a-fA-F]{{3}})$| +\#([0-9a-fA-F]{{4}})$| +\#([0-9a-fA-F]{{6}})$| +\#([0-9a-fA-F]{{8}})$| +rgb{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$| +rgba{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$| +hsl{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}){CLOSE_BRACE}$| +hsla{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}){CLOSE_BRACE}$ +""", + re.VERBOSE, +) + +# Fast way to split a string of 6 characters in to 3 pairs of 2 characters +_split_pairs3: Callable[[str], tuple[str, str, str]] = itemgetter( + slice(0, 2), slice(2, 4), slice(4, 6) +) +# Fast way to split a string of 8 characters in to 4 pairs of 2 characters +_split_pairs4: Callable[[str], tuple[str, str, str, str]] = itemgetter( + slice(0, 2), slice(2, 4), slice(4, 6), slice(6, 8) +) + + +class ColorParseError(Exception): + """A color failed to parse. + + Args: + message (str): the error message + suggested_color (str | None): a close color we can suggest. Defaults to None. + """ + + def __init__(self, message: str, suggested_color: str | None = None): + super().__init__(message) + self.suggested_color = suggested_color + + +@rich.repr.auto +class Color(NamedTuple): + """A class to represent a RGB color with an alpha component.""" + + 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: + """Create a new color from Rich's Color class. + + Args: + rich_color (RichColor): An instance of rich.color.Color. + + Returns: + Color: A new Color. + """ + r, g, b = rich_color.get_truecolor() + return cls(r, g, b) + + @classmethod + def from_hsl(cls, h: float, s: float, l: float) -> Color: + """Create a color from HLS components. + + Args: + h (float): Hue. + l (float): Lightness. + s (float): Saturation. + + Returns: + Color: A new color. + """ + r, g, b = hls_to_rgb(h, l, s) + return cls(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5)) + + def __rich__(self) -> Text: + """A Rich method to show the color.""" + return Text( + f" {self!r} ", + style=Style.from_color( + self.get_contrast_text().rich_color, self.rich_color + ), + ) + + @property + def inverse(self) -> Color: + """The inverse of this color.""" + r, g, b, a = self + return Color(255 - r, 255 - g, 255 - b, a) + + @property + def is_transparent(self) -> bool: + """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. + + Returns: + Color: A color object. + + """ + r, g, b, a = self + _clamp = clamp + color = Color( + _clamp(r, 0, 255), + _clamp(g, 0, 255), + _clamp(b, 0, 255), + _clamp(a, 0.0, 1.0), + ) + return color + + @property + def rich_color(self) -> RichColor: + """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) + ) + + @property + def normalized(self) -> tuple[float, float, float]: + """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. + + Returns: + tuple[int, int, int]: Color components + """ + r, g, b, _ = self + return (r, g, b) + + @property + def hsl(self) -> HSL: + """Get the color as HSL. + + Returns: + HSL: Color in HSL format. + """ + r, g, b = self.normalized + h, l, s = rgb_to_hls(r, g, b) + return HSL(h, s, l) + + @property + def brightness(self) -> float: + """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 + + @property + def hex(self) -> str: + """The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA. + + Returns: + str: A CSS hex-style color, e.g. `"#46b3de"` or `"#3342457f"` + + """ + r, g, b, a = self.clamped + return ( + f"#{r:02X}{g:02X}{b:02X}" + if a == 1 + else f"#{r:02X}{g:02X}{b:02X}{int(a*255):02X}" + ) + + @property + def hex6(self) -> str: + """The color in CSS hex form, with 6 digits for RGB. Alpha is ignored. + + Returns: + str: A CSS hex-style color, e.g. "#46b3de" + + """ + r, g, b, a = self.clamped + return f"#{r:02X}{g:02X}{b:02X}" + + @property + def css(self) -> str: + """The color in CSS rgb or rgba form. + + Returns: + str: A CSS style color, e.g. `"rgb(10,20,30)"` or `"rgb(50,70,80,0.5)"` + + """ + r, g, b, a = self + return f"rgb({r},{g},{b})" if a == 1 else f"rgba({r},{g},{b},{a})" + + @property + def monochrome(self) -> Color: + """Get a monochrome version of this color. + + Returns: + Color: A new monochrome color. + """ + r, g, b, a = self + gray = round(r * 0.2126 + g * 0.7152 + b * 0.0722) + return Color(gray, gray, gray, a) + + def __rich_repr__(self) -> rich.repr.Result: + r, g, b, a = self + yield r + yield g + yield b + yield "a", a + + def with_alpha(self, alpha: float) -> Color: + """Create a new color with the given alpha. + + Args: + alpha (float): New value for alpha. + + Returns: + Color: A new color. + """ + r, g, b, _ = self + return Color(r, g, b, alpha) + + def blend( + self, destination: Color, factor: float, alpha: float | None = None + ) -> Color: + """Generate a new color between two colors. + + Args: + destination (Color): Another color. + factor (float): A blend factor, 0 -> 1. + alpha (float | None): New alpha for result. Defaults to None. + + Returns: + Color: A new color. + """ + if factor == 0: + return self + elif factor == 1: + return destination + r1, g1, b1, a1 = self + r2, g2, b2, a2 = destination + + if alpha is None: + new_alpha = a1 + (a2 - a1) * factor + else: + new_alpha = alpha + + return Color( + int(r1 + (r2 - r1) * factor), + int(g1 + (g2 - g1) * factor), + int(b1 + (b2 - b1) * factor), + new_alpha, + ) + + def __add__(self, other: object) -> Color: + if isinstance(other, Color): + new_color = self.blend(other, other.a, alpha=1.0) + return new_color + return NotImplemented + + @classmethod + @lru_cache(maxsize=1024 * 4) + def parse(cls, color_text: str | Color) -> Color: + """Parse a string containing a named color or CSS-style color. + + Colors may be parsed from the following formats: + + Text beginning with a `#` is parsed as hex: + + R, G, and B must be hex digits (0-9A-F) + + - `#RGB` + - `#RRGGBB` + - `#RRGGBBAA` + + Text in the following formats is parsed as decimal values: + + RED, GREEN, and BLUE must be numbers between 0 and 255. + ALPHA should ba a value between 0 and 1. + + - `rgb(RED,GREEN,BLUE)` + - `rgba(RED,GREEN,BLUE,ALPHA)` + - `hsl(RED,GREEN,BLUE)` + - `hsla(RED,GREEN,BLUE,ALPHA)` + + All other text will raise a `ColorParseError`. + + Args: + color_text (str | Color): Text with a valid color format. Color objects will + be returned unmodified. + + Raises: + ColorParseError: If the color is not encoded correctly. + + Returns: + Color: New color object. + """ + if isinstance(color_text, Color): + return color_text + color_from_name = COLOR_NAME_TO_RGB.get(color_text) + if color_from_name is not None: + return cls(*color_from_name) + color_match = RE_COLOR.match(color_text) + if color_match is None: + error_message = f"failed to parse {color_text!r} as a color" + suggested_color = None + if not color_text.startswith(("#", "rgb", "hsl")): + # Seems like we tried to use a color name: let's try to find one that is close enough: + suggested_color = get_suggestion(color_text, COLOR_NAME_TO_RGB.keys()) + if suggested_color: + error_message += f"; did you mean '{suggested_color}'?" + raise ColorParseError(error_message, suggested_color) + ( + rgb_hex_triple, + rgb_hex_quad, + rgb_hex, + rgba_hex, + rgb, + rgba, + hsl, + hsla, + ) = color_match.groups() + + if rgb_hex_triple is not None: + r, g, b = rgb_hex_triple + color = cls(int(f"{r}{r}", 16), int(f"{g}{g}", 16), int(f"{b}{b}", 16)) + elif rgb_hex_quad is not None: + r, g, b, a = rgb_hex_quad + color = cls( + int(f"{r}{r}", 16), + int(f"{g}{g}", 16), + int(f"{b}{b}", 16), + int(f"{a}{a}", 16) / 255.0, + ) + elif rgb_hex is not None: + r, g, b = [int(pair, 16) for pair in _split_pairs3(rgb_hex)] + color = cls(r, g, b, 1.0) + elif rgba_hex is not None: + r, g, b, a = [int(pair, 16) for pair in _split_pairs4(rgba_hex)] + color = cls(r, g, b, a / 255.0) + elif rgb is not None: + r, g, b = [clamp(int(float(value)), 0, 255) for value in rgb.split(",")] + color = cls(r, g, b, 1.0) + elif rgba is not None: + float_r, float_g, float_b, float_a = [ + float(value) for value in rgba.split(",") + ] + color = cls( + clamp(int(float_r), 0, 255), + clamp(int(float_g), 0, 255), + clamp(int(float_b), 0, 255), + clamp(float_a, 0.0, 1.0), + ) + elif hsl is not None: + h, s, l = hsl.split(",") + h = float(h) % 360 / 360 + s = percentage_string_to_float(s) + l = percentage_string_to_float(l) + color = Color.from_hsl(h, s, l) + elif hsla is not None: + h, s, l, a = hsla.split(",") + h = float(h) % 360 / 360 + s = percentage_string_to_float(s) + l = percentage_string_to_float(l) + a = clamp(float(a), 0.0, 1.0) + color = Color.from_hsl(h, s, l).with_alpha(a) + else: + raise AssertionError("Can't get here if RE_COLOR matches") + return color + + @lru_cache(maxsize=1024) + def darken(self, amount: float, alpha: float | None = None) -> Color: + """Darken the color by a given amount. + + Args: + amount (float): Value between 0-1 to reduce luminance by. + alpha (float | None, optional): Alpha component for new color or None to copy alpha. Defaults to None. + + Returns: + Color: New color. + """ + l, a, b = rgb_to_lab(self) + l -= amount * 100 + return lab_to_rgb(Lab(l, a, b), self.a if alpha is None else alpha).clamped + + def lighten(self, amount: float, alpha: float | None = None) -> Color: + """Lighten the color by a given amount. + + Args: + amount (float): Value between 0-1 to increase luminance by. + alpha (float | None, optional): Alpha component for new color or None to copy alpha. Defaults to None. + + Returns: + Color: New color. + """ + return self.darken(-amount, alpha) + + @lru_cache(maxsize=1024) + def get_contrast_text(self, alpha=0.95) -> Color: + """Get a light or dark color that best contrasts this color, for use with text. + + Args: + alpha (float, optional): An alpha value to adjust the pure white / black by. + Defaults to 0.95. + + Returns: + Color: A new color, either an off-white or off-black + """ + brightness = self.brightness + white_contrast = abs(brightness - WHITE.brightness) + black_contrast = abs(brightness - BLACK.brightness) + return (WHITE if white_contrast > black_contrast else BLACK).with_alpha(alpha) + + +# Color constants +WHITE = Color(255, 255, 255) +BLACK = Color(0, 0, 0) + + +def rgb_to_lab(rgb: Color) -> Lab: + """Convert an RGB color to the CIE-L*ab format. + + Uses the standard RGB color space with a D65/2โฐ standard illuminant. + Conversion passes through the XYZ color space. + Cf. http://www.easyrgb.com/en/math.php. + """ + + r, g, b = rgb.r / 255, rgb.g / 255, rgb.b / 255 + + r = pow((r + 0.055) / 1.055, 2.4) if r > 0.04045 else r / 12.92 + g = pow((g + 0.055) / 1.055, 2.4) if g > 0.04045 else g / 12.92 + b = pow((b + 0.055) / 1.055, 2.4) if b > 0.04045 else b / 12.92 + + x = (r * 41.24 + g * 35.76 + b * 18.05) / 95.047 + y = (r * 21.26 + g * 71.52 + b * 7.22) / 100 + z = (r * 1.93 + g * 11.92 + b * 95.05) / 108.883 + + off = 16 / 116 + x = pow(x, 1 / 3) if x > 0.008856 else 7.787 * x + off + y = pow(y, 1 / 3) if y > 0.008856 else 7.787 * y + off + z = pow(z, 1 / 3) if z > 0.008856 else 7.787 * z + off + + return Lab(116 * y - 16, 500 * (x - y), 200 * (y - z)) + + +def lab_to_rgb(lab: Lab, alpha: float = 1.0) -> Color: + """Convert a CIE-L*ab color to RGB. + + Uses the standard RGB color space with a D65/2โฐ standard illuminant. + Conversion passes through the XYZ color space. + Cf. http://www.easyrgb.com/en/math.php. + """ + + y = (lab.L + 16) / 116 + x = lab.a / 500 + y + z = y - lab.b / 200 + + off = 16 / 116 + y = pow(y, 3) if y > 0.2068930344 else (y - off) / 7.787 + x = 0.95047 * pow(x, 3) if x > 0.2068930344 else 0.122059 * (x - off) + z = 1.08883 * pow(z, 3) if z > 0.2068930344 else 0.139827 * (z - off) + + r = x * 3.2406 + y * -1.5372 + z * -0.4986 + g = x * -0.9689 + y * 1.8758 + z * 0.0415 + b = x * 0.0557 + y * -0.2040 + z * 1.0570 + + r = 1.055 * pow(r, 1 / 2.4) - 0.055 if r > 0.0031308 else 12.92 * r + g = 1.055 * pow(g, 1 / 2.4) - 0.055 if g > 0.0031308 else 12.92 * g + b = 1.055 * pow(b, 1 / 2.4) - 0.055 if b > 0.0031308 else 12.92 * b + + return Color(int(r * 255), int(g * 255), int(b * 255), alpha) diff --git a/src/textual/constants.py b/src/textual/constants.py new file mode 100644 index 000000000..6429da790 --- /dev/null +++ b/src/textual/constants.py @@ -0,0 +1,11 @@ +""" +Constants that we might want to expose via the public API. + +""" + +from ._border import BORDER_CHARS + +__all__ = ["BORDERS"] + + +BORDERS = list(BORDER_CHARS) diff --git a/src/textual/containers.py b/src/textual/containers.py new file mode 100644 index 000000000..231e220b0 --- /dev/null +++ b/src/textual/containers.py @@ -0,0 +1,55 @@ +from .widget import Widget + + +class Container(Widget): + """Simple container widget, with vertical layout.""" + + DEFAULT_CSS = """ + Container { + layout: vertical; + overflow: auto; + } + """ + + +class Vertical(Widget): + """A container widget which aligns children vertically.""" + + DEFAULT_CSS = """ + Vertical { + layout: vertical; + overflow-y: auto; + } + """ + + +class Horizontal(Widget): + """A container widget which aligns children horizontally.""" + + DEFAULT_CSS = """ + Horizontal { + layout: horizontal; + overflow-x: hidden; + } + """ + + +class Grid(Widget): + """A container widget with grid alignment.""" + + DEFAULT_CSS = """ + Grid { + layout: grid; + } + """ + + +class Content(Widget, can_focus=True, can_focus_children=False): + """A container for content such as text.""" + + DEFAULT_CSS = """ + Vertical { + layout: vertical; + overflow-y: auto; + } + """ diff --git a/src/textual/css/__init__.py b/src/textual/css/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/css/_error_tools.py b/src/textual/css/_error_tools.py new file mode 100644 index 000000000..b22d9caa0 --- /dev/null +++ b/src/textual/css/_error_tools.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Iterable + + +def friendly_list( + words: Iterable[str], joiner: str = "or", omit_empty: bool = True +) -> str: + """Generate a list of words as readable prose. + + >>> friendly_list(["foo", "bar", "baz"]) + "'foo', 'bar', or 'baz'" + + Args: + words (Iterable[str]): A list of words. + joiner (str, optional): The last joiner word. Defaults to "or". + + Returns: + str: List as prose. + """ + words = [ + repr(word) for word in sorted(words, key=str.lower) if word or not omit_empty + ] + if len(words) == 1: + return words[0] + elif len(words) == 2: + word1, word2 = words + return f"{word1} {joiner} {word2}" + else: + return f'{", ".join(words[:-1])}, {joiner} {words[-1]}' diff --git a/src/textual/css/_help_renderables.py b/src/textual/css/_help_renderables.py new file mode 100644 index 000000000..ca2307f22 --- /dev/null +++ b/src/textual/css/_help_renderables.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Iterable + +import rich.repr +from rich.console import Console, ConsoleOptions, RenderResult +from rich.highlighter import ReprHighlighter +from rich.markup import render +from rich.text import Text +from rich.tree import Tree + +_highlighter = ReprHighlighter() + + +def _markup_and_highlight(text: str) -> Text: + """Highlight and render markup in a string of text, returning + a styled Text object. + + Args: + text (str): The text to highlight and markup. + + Returns: + Text: The Text, with highlighting and markup applied. + """ + return _highlighter(render(text)) + + +class Example: + """Renderable for an example, which can appear below bullet points in + the help text. + + Attributes: + markup (str): The markup to display for this example + """ + + def __init__(self, markup: str) -> None: + self.markup = markup + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield _markup_and_highlight(f" [dim]e.g. [/][i]{self.markup}[/]") + + +@rich.repr.auto +class Bullet: + """Renderable for a single 'bullet point' containing information and optionally some examples + pertaining to that information. + + Attributes: + markup (str): The markup to display + examples (Iterable[Example] | None): An optional list of examples + to display below this bullet. + """ + + def __init__(self, markup: str, examples: Iterable[Example] | None = None) -> None: + self.markup = markup + self.examples = [] if examples is None else examples + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield _markup_and_highlight(self.markup) + yield from self.examples + + +@rich.repr.auto +class HelpText: + """Renderable for help text - the user is shown this when they + encounter a style-related error (e.g. setting a style property to an invalid + value). + + Attributes: + summary (str): A succinct summary of the issue. + bullets (Iterable[Bullet] | None): Bullet points which provide additional + context around the issue. These are rendered below the summary. Defaults to None. + """ + + def __init__(self, summary: str, *, bullets: Iterable[Bullet] = None) -> None: + self.summary = summary + self.bullets = bullets or [] + + def __str__(self) -> str: + return self.summary + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + tree = Tree(_markup_and_highlight(f"[b blue]{self.summary}"), guide_style="dim") + for bullet in self.bullets: + tree.add(bullet) + yield tree diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py new file mode 100644 index 000000000..e148426ed --- /dev/null +++ b/src/textual/css/_help_text.py @@ -0,0 +1,765 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import Iterable + +from textual.color import ColorParseError +from textual.css._help_renderables import Example, Bullet, HelpText +from textual.css.constants import ( + VALID_BORDER, + VALID_LAYOUT, + VALID_ALIGN_HORIZONTAL, + VALID_ALIGN_VERTICAL, + VALID_STYLE_FLAGS, + VALID_TEXT_ALIGN, +) + +if sys.version_info >= (3, 8): + from typing import Literal, Iterable, Sequence +else: + from typing_extensions import Literal + +from textual.css._error_tools import friendly_list +from textual.css.scalar import SYMBOL_UNIT + +StylingContext = Literal["inline", "css"] +"""The type of styling the user was using when the error was encountered. +Used to give help text specific to the context i.e. we give CSS help if the +user hit an issue with their CSS, and Python help text when the user has an +issue with inline styles.""" + + +@dataclass +class ContextSpecificBullets: + """ + Args: + inline (Iterable[Bullet]): Information only relevant to users who are using inline styling. + css (Iterable[Bullet]): Information only relevant to users who are using CSS. + """ + + inline: Sequence[Bullet] + css: Sequence[Bullet] + + def get_by_context(self, context: StylingContext) -> list[Bullet]: + """Get the information associated with the given context + + Args: + context (StylingContext | None): The context to retrieve info for. + """ + if context == "inline": + return list(self.inline) + else: + return list(self.css) + + +def _python_name(property_name: str) -> str: + """Convert a CSS property name to the corresponding Python attribute name + + Args: + property_name (str): The CSS property name + + Returns: + str: The Python attribute name as found on the Styles object + """ + return property_name.replace("-", "_") + + +def _css_name(property_name: str) -> str: + """Convert a Python style attribute name to the corresponding CSS property name + + Args: + property_name (str): The Python property name + + Returns: + str: The CSS property name + """ + return property_name.replace("_", "-") + + +def _contextualize_property_name( + property_name: str, + context: StylingContext, +) -> str: + """Convert a property name to CSS or inline by replacing + '-' with '_' or vice-versa + + Args: + property_name (str): The name of the property + context (StylingContext): The context the property is being used in. + + Returns: + str: The property name converted to the given context. + """ + return _css_name(property_name) if context == "css" else _python_name(property_name) + + +def _spacing_examples(property_name: str) -> ContextSpecificBullets: + """Returns examples for spacing properties""" + return ContextSpecificBullets( + inline=[ + Bullet( + f"Set [i]{property_name}[/] to a tuple to assign spacing to each edge", + examples=[ + Example( + f"widget.styles.{property_name} = (1, 2) [dim]# Vertical, horizontal" + ), + Example( + f"widget.styles.{property_name} = (1, 2, 3, 4) [dim]# Top, right, bottom, left" + ), + ], + ), + Bullet( + "Or to an integer to assign a single value to all edges", + examples=[Example(f"widget.styles.{property_name} = 2")], + ), + ], + css=[ + Bullet( + "Supply 1, 2 or 4 integers separated by a space", + examples=[ + Example(f"{property_name}: 1;"), + Example(f"{property_name}: 1 2; [dim]# Vertical, horizontal"), + Example( + f"{property_name}: 1 2 3 4; [dim]# Top, right, bottom, left" + ), + ], + ), + ], + ) + + +def property_invalid_value_help_text( + property_name: str, context: StylingContext, *, suggested_property_name: str = None +) -> HelpText: + """Help text to show when the user supplies an invalid value for CSS property + property. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the spacing property is being used in. + Keyword Args: + suggested_property_name (str | None): A suggested name for the property (e.g. "width" for "wdth"). Defaults to None. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + summary = f"Invalid CSS property {property_name!r}" + if suggested_property_name: + suggested_property_name = _contextualize_property_name( + suggested_property_name, context + ) + summary += f'. Did you mean "{suggested_property_name}"?' + return HelpText(summary) + + +def spacing_wrong_number_of_values_help_text( + property_name: str, + num_values_supplied: int, + context: StylingContext, +) -> HelpText: + """Help text to show when the user supplies the wrong number of values + for a spacing property (e.g. padding or margin). + + Args: + property_name (str): The name of the property + num_values_supplied (int): The number of values the user supplied (a number other than 1, 2 or 4). + context (StylingContext | None): The context the spacing property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid number of values for the [i]{property_name}[/] property", + bullets=[ + Bullet( + f"You supplied {num_values_supplied} values for the [i]{property_name}[/] property" + ), + Bullet( + "Spacing properties like [i]margin[/] and [i]padding[/] require either 1, 2 or 4 integer values" + ), + *_spacing_examples(property_name).get_by_context(context), + ], + ) + + +def spacing_invalid_value_help_text( + property_name: str, + context: StylingContext, +) -> HelpText: + """Help text to show when the user supplies an invalid value for a spacing + property. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the spacing property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for the [i]{property_name}[/] property", + bullets=_spacing_examples(property_name).get_by_context(context), + ) + + +def scalar_help_text( + property_name: str, + context: StylingContext, +) -> HelpText: + """Help text to show when the user supplies an invalid value for + a scalar property. + + Args: + property_name (str): The name of the property + num_values_supplied (int): The number of values the user supplied (a number other than 1, 2 or 4). + context (StylingContext | None): The context the scalar property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for the [i]{property_name}[/] property", + bullets=[ + Bullet( + f"Scalar properties like [i]{property_name}[/] require numerical values and an optional unit" + ), + Bullet(f"Valid units are {friendly_list(SYMBOL_UNIT)}"), + *ContextSpecificBullets( + inline=[ + Bullet( + "Assign a string, int or Scalar object itself", + examples=[ + Example(f'widget.styles.{property_name} = "50%"'), + Example(f"widget.styles.{property_name} = 10"), + Example(f"widget.styles.{property_name} = Scalar(...)"), + ], + ), + ], + css=[ + Bullet( + "Write the number followed by the unit", + examples=[ + Example(f"{property_name}: 50%;"), + Example(f"{property_name}: 5;"), + ], + ), + ], + ).get_by_context(context), + ], + ) + + +def string_enum_help_text( + property_name: str, + valid_values: Iterable[str], + context: StylingContext, +) -> HelpText: + """Help text to show when the user supplies an invalid value for a string + enum property. + + Args: + property_name (str): The name of the property + valid_values (list[str]): A list of the values that are considered valid. + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for the [i]{property_name}[/] property", + bullets=[ + Bullet( + f"The [i]{property_name}[/] property can only be set to {friendly_list(valid_values)}" + ), + *ContextSpecificBullets( + inline=[ + Bullet( + "Assign any of the valid strings to the property", + examples=[ + Example(f'widget.styles.{property_name} = "{valid_value}"') + for valid_value in sorted(valid_values) + ], + ) + ], + css=[ + Bullet( + "Assign any of the valid strings to the property", + examples=[ + Example(f"{property_name}: {valid_value};") + for valid_value in sorted(valid_values) + ], + ) + ], + ).get_by_context(context), + ], + ) + + +def color_property_help_text( + property_name: str, + context: StylingContext, + *, + error: Exception = None, +) -> HelpText: + """Help text to show when the user supplies an invalid value for a color + property. For example, an unparseable color string. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + error (ColorParseError | None): The error that caused this help text to be displayed. Defaults to None. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + summary = f"Invalid value for the [i]{property_name}[/] property" + suggested_color = ( + error.suggested_color if error and isinstance(error, ColorParseError) else None + ) + if suggested_color: + summary += f'. Did you mean "{suggested_color}"?' + return HelpText( + summary=summary, + bullets=[ + Bullet( + f"The [i]{property_name}[/] property can only be set to a valid color" + ), + Bullet(f"Colors can be specified using hex, RGB, or ANSI color names"), + *ContextSpecificBullets( + inline=[ + Bullet( + "Assign colors using strings or Color objects", + examples=[ + Example(f'widget.styles.{property_name} = "#ff00aa"'), + Example( + f'widget.styles.{property_name} = "rgb(12,231,45)"' + ), + Example(f'widget.styles.{property_name} = "red"'), + Example( + f"widget.styles.{property_name} = Color(1, 5, 29, a=0.5)" + ), + ], + ) + ], + css=[ + Bullet( + "Colors can be set as follows", + examples=[ + Example(f"{property_name}: [#ff00aa]#ff00aa[/];"), + Example(f"{property_name}: rgb(12,231,45);"), + Example(f"{property_name}: [rgb(255,0,0)]red[/];"), + ], + ) + ], + ).get_by_context(context), + ], + ) + + +def border_property_help_text(property_name: str, context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value for a border + property (such as border, border-right, outline) + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for [i]{property_name}[/] property", + bullets=[ + *ContextSpecificBullets( + inline=[ + Bullet( + f"Set [i]{property_name}[/] using a tuple of the form (, )", + examples=[ + Example( + f'widget.styles.{property_name} = ("solid", "red")' + ), + Example( + f'widget.styles.{property_name} = ("round", "#f0f0f0")' + ), + Example( + f'widget.styles.{property_name} = [("dashed", "#f0f0f0"), ("solid", "blue")] [dim]# Vertical, horizontal' + ), + ], + ), + Bullet( + f"Valid values for are:\n{friendly_list(VALID_BORDER)}" + ), + Bullet( + f"Colors can be specified using hex, RGB, or ANSI color names" + ), + ], + css=[ + Bullet( + f"Set [i]{property_name}[/] using a value of the form [i] [/]", + examples=[ + Example(f"{property_name}: solid red;"), + Example(f"{property_name}: dashed #00ee22;"), + ], + ), + Bullet( + f"Valid values for are:\n{friendly_list(VALID_BORDER)}" + ), + Bullet( + f"Colors can be specified using hex, RGB, or ANSI color names" + ), + ], + ).get_by_context(context), + ], + ) + + +def layout_property_help_text(property_name: str, context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value + for a layout property. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for [i]{property_name}[/] property", + bullets=[ + Bullet( + f"The [i]{property_name}[/] property expects a value of {friendly_list(VALID_LAYOUT)}" + ), + ], + ) + + +def dock_property_help_text(property_name: str, context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value for dock. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for [i]{property_name}[/] property", + bullets=[ + Bullet("The value must be one of 'top', 'right', 'bottom' or 'left'"), + *ContextSpecificBullets( + inline=[ + Bullet( + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f'header.styles.dock = "top"')], + ) + ], + css=[ + Bullet( + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f"dock: top")], + ) + ], + ).get_by_context(context), + ], + ) + + +def fractional_property_help_text( + property_name: str, context: StylingContext +) -> HelpText: + """Help text to show when the user supplies an invalid value for a fractional property. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value for [i]{property_name}[/] property", + bullets=[ + *ContextSpecificBullets( + inline=[ + Bullet( + f"Set [i]{property_name}[/] to a string or float value", + examples=[ + Example(f'widget.styles.{property_name} = "50%"'), + Example(f"widget.styles.{property_name} = 0.25"), + ], + ) + ], + css=[ + Bullet( + f"Set [i]{property_name}[/] to a string or float", + examples=[ + Example(f"{property_name}: 50%;"), + Example(f"{property_name}: 0.25;"), + ], + ) + ], + ).get_by_context(context) + ], + ) + + +def offset_property_help_text(context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value for the offset property. + + Args: + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary="Invalid value for [i]offset[/] property", + bullets=[ + *ContextSpecificBullets( + inline=[ + Bullet( + markup="The [i]offset[/] property expects a tuple of 2 values [i](, )[/]", + examples=[ + Example("widget.styles.offset = (2, '50%')"), + ], + ), + ], + css=[ + Bullet( + markup="The [i]offset[/] property expects a value of the form [i] [/]", + examples=[ + Example( + "offset: 2 3; [dim]# Horizontal offset of 2, vertical offset of 3" + ), + Example( + "offset: 2 50%; [dim]# Horizontal offset of 2, vertical offset of 50%" + ), + ], + ), + ], + ).get_by_context(context), + Bullet(" and can be a number or scalar value"), + ], + ) + + +def scrollbar_size_property_help_text(context: StylingContext) -> HelpText: + """Help text to show when the user supplies an invalid value for the scrollbar-size property. + + Args: + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary="Invalid value for [i]scrollbar-size[/] property", + bullets=[ + *ContextSpecificBullets( + inline=[ + Bullet( + markup="The [i]scrollbar_size[/] property expects a tuple of 2 values [i](, )[/]", + examples=[ + Example("widget.styles.scrollbar_size = (2, 1)"), + ], + ), + ], + css=[ + Bullet( + markup="The [i]scrollbar-size[/] property expects a value of the form [i] [/]", + examples=[ + Example( + "scrollbar-size: 2 3; [dim]# Horizontal size of 2, vertical size of 3" + ), + ], + ), + ], + ).get_by_context(context), + Bullet( + " and must be positive integers, greater than zero" + ), + ], + ) + + +def scrollbar_size_single_axis_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies an invalid value for a scrollbar-size-* property. + + Args: + property_name (str): The name of the property + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet( + markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greater than zero", + examples=[ + Example(f"{property_name}: 2;"), + ], + ), + ], + ) + + +def integer_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies an invalid integer value. + + Args: + property_name (str): The name of the property + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet( + markup=f"An integer value is expected here", + examples=[ + Example(f"{property_name}: 2;"), + ], + ), + ], + ) + + +def align_help_text() -> HelpText: + """Help text to show when the user supplies an invalid value for a `align`. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary="Invalid value for [i]align[/] property", + bullets=[ + Bullet( + markup="The [i]align[/] property expects exactly 2 values", + examples=[ + Example("align: "), + Example( + "align: center middle; [dim]# Center vertically & horizontally within parent" + ), + Example( + "align: left middle; [dim]# Align on the middle left of the parent" + ), + ], + ), + Bullet( + f"Valid values for are {friendly_list(VALID_ALIGN_HORIZONTAL)}" + ), + Bullet( + f"Valid values for are {friendly_list(VALID_ALIGN_VERTICAL)}", + ), + ], + ) + + +def text_align_help_text() -> HelpText: + """Help text to show when the user supplies an invalid value for the text-align property + + Returns: + HelpText: Renderable for displaying the help text for this property. + """ + return HelpText( + summary="Invalid value for the [i]text-align[/] property.", + bullets=[ + Bullet( + f"The [i]text-align[/] property must be one of {friendly_list(VALID_TEXT_ALIGN)}", + examples=[ + Example("text-align: center;"), + Example("text-align: right;"), + ], + ) + ], + ) + + +def offset_single_axis_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies an invalid value for an offset-* property. + + Args: + property_name (str): The name of the property + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet( + markup=f"The [i]{property_name}[/] property can be set to a number or scalar value", + examples=[ + Example(f"{property_name}: 10;"), + Example(f"{property_name}: 50%;"), + ], + ), + Bullet(f"Valid scalar units are {friendly_list(SYMBOL_UNIT)}"), + ], + ) + + +def style_flags_property_help_text( + property_name: str, value: str, context: StylingContext +) -> HelpText: + """Help text to show when the user supplies an invalid value for a style flags property. + + Args: + property_name (str): The name of the property + context (StylingContext | None): The context the property is being used in. + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value '{value}' in [i]{property_name}[/] property", + bullets=[ + Bullet( + f"Style flag values such as [i]{property_name}[/] expect space-separated values" + ), + Bullet(f"Permitted values are {friendly_list(VALID_STYLE_FLAGS)}"), + *ContextSpecificBullets( + inline=[ + Bullet( + markup="Supply a string or Style object", + examples=[ + Example( + f'widget.styles.{property_name} = "bold italic underline"' + ) + ], + ), + ], + css=[ + Bullet( + markup="Supply style flags separated by spaces", + examples=[Example(f"{property_name}: bold italic underline;")], + ) + ], + ).get_by_context(context), + ], + ) + + +def table_rows_or_columns_help_text( + property_name: str, value: str, context: StylingContext +): + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value '{value}' in [i]{property_name}[/] property" + ) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py new file mode 100644 index 000000000..5770add70 --- /dev/null +++ b/src/textual/css/_style_properties.py @@ -0,0 +1,1037 @@ +""" +Style properties are descriptors which allow the ``Styles`` object to accept different types when +setting attributes. This gives the developer more freedom in how to express style information. + +Descriptors also play nicely with Mypy, which is aware that attributes can have different types +when setting and getting. + +""" + +from __future__ import annotations + +from typing import Generic, Iterable, NamedTuple, TypeVar, TYPE_CHECKING, cast + +import rich.repr +from rich.style import Style + +from ._help_text import ( + border_property_help_text, + layout_property_help_text, + fractional_property_help_text, + offset_property_help_text, + style_flags_property_help_text, +) +from ._help_text import ( + spacing_wrong_number_of_values_help_text, + scalar_help_text, + string_enum_help_text, + color_property_help_text, +) +from .._border import normalize_border_value +from ..color import Color, ColorParseError +from ._error_tools import friendly_list +from .constants import NULL_SPACING, VALID_STYLE_FLAGS +from .errors import StyleTypeError, StyleValueError +from .scalar import ( + get_symbols, + UNIT_SYMBOL, + Unit, + Scalar, + ScalarOffset, + ScalarParseError, + percentage_string_to_float, +) +from .transition import Transition +from ..geometry import Spacing, SpacingDimensions, clamp + +if TYPE_CHECKING: + from .._layout import Layout + from .styles import Styles, StylesBase + +from .types import DockEdge, EdgeType, AlignHorizontal, AlignVertical + +BorderDefinition = ( + "Sequence[tuple[EdgeType, str | Color] | None] | tuple[EdgeType, str | Color]" +) + +PropertyGetType = TypeVar("PropertyGetType") +PropertySetType = TypeVar("PropertySetType") + + +class GenericProperty(Generic[PropertyGetType, PropertySetType]): + def __init__(self, default: PropertyGetType, layout: bool = False) -> None: + self.default = default + self.layout = layout + + def validate_value(self, value: object) -> PropertyGetType: + """Validate the setter value + + Args: + value (object): The value being set. + + Returns: + PropertyType: The value to be set. + """ + # Raise StyleValueError here + return cast(PropertyGetType, value) + + def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> PropertyGetType: + return cast(PropertyGetType, obj.get_rule(self.name, self.default)) + + def __set__(self, obj: StylesBase, value: PropertySetType | None) -> None: + _rich_traceback_omit = True + if value is None: + obj.clear_rule(self.name) + obj.refresh(layout=self.layout) + return + new_value = self.validate_value(value) + if obj.set_rule(self.name, new_value): + obj.refresh(layout=self.layout) + + +class IntegerProperty(GenericProperty[int, int]): + def validate_value(self, value: object) -> int: + if isinstance(value, (int, float)): + return int(value) + else: + raise StyleValueError(f"Expected a number here, got f{value}") + + +class BooleanProperty(GenericProperty[bool, bool]): + """A property that requires a True or False value.""" + + def validate_value(self, value: object) -> bool: + return bool(value) + + +class ScalarProperty: + """Descriptor for getting and setting scalar properties. Scalars are numeric values with a unit, e.g. "50vh".""" + + def __init__( + self, + units: set[Unit] | None = None, + percent_unit: Unit = Unit.WIDTH, + allow_auto: bool = True, + ) -> None: + self.units: set[Unit] = units or {*UNIT_SYMBOL} + self.percent_unit = percent_unit + self.allow_auto = allow_auto + super().__init__() + + def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Scalar | None: + """Get the scalar property + + Args: + obj (Styles): The ``Styles`` object + objtype (type[Styles]): The ``Styles`` class + + Returns: + The Scalar object or ``None`` if it's not set. + """ + value = obj.get_rule(self.name) + return value + + def __set__( + self, obj: StylesBase, value: float | int | Scalar | str | None + ) -> None: + """Set the scalar property + + Args: + obj (Styles): The ``Styles`` object. + value (float | int | Scalar | str | None): The value to set the scalar property to. + You can directly pass a float or int value, which will be interpreted with + a default unit of Cells. You may also provide a string such as ``"50%"``, + as you might do when writing CSS. If a string with no units is supplied, + Cells will be used as the unit. Alternatively, you can directly supply + a ``Scalar`` object. + + Raises: + StyleValueError: If the value is of an invalid type, uses an invalid unit, or + cannot be parsed for any other reason. + """ + _rich_traceback_omit = True + if value is None: + obj.clear_rule(self.name) + obj.refresh(layout=True) + return + if isinstance(value, (int, float)): + new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH) + elif isinstance(value, Scalar): + new_value = value + elif isinstance(value, str): + try: + new_value = Scalar.parse(value) + except ScalarParseError: + raise StyleValueError( + "unable to parse scalar from {value!r}", + help_text=scalar_help_text( + property_name=self.name, context="inline" + ), + ) + else: + raise StyleValueError("expected float, int, Scalar, or None") + + if ( + new_value is not None + and new_value.unit == Unit.AUTO + and not self.allow_auto + ): + raise StyleValueError("'auto' not allowed here") + + if new_value is not None and new_value.unit != Unit.AUTO: + if new_value.unit not in self.units: + raise StyleValueError( + f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" + ) + if new_value.is_percent: + new_value = Scalar( + float(new_value.value), self.percent_unit, Unit.WIDTH + ) + if obj.set_rule(self.name, new_value): + obj.refresh(layout=True) + + +class ScalarListProperty: + def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> tuple[Scalar, ...] | None: + value = obj.get_rule(self.name) + return value + + def __set__( + self, obj: StylesBase, value: str | Iterable[str | float] | None + ) -> None: + if value is None: + obj.clear_rule(self.name) + obj.refresh(layout=True) + return + parse_values: Iterable[str | float] + if isinstance(value, str): + parse_values = value.split() + else: + parse_values = value + + scalars = [] + for parse_value in parse_values: + if isinstance(parse_value, (int, float)): + scalars.append(Scalar.from_number(parse_value)) + else: + scalars.append( + Scalar.parse(parse_value) + if isinstance(parse_value, str) + else parse_value + ) + if obj.set_rule(self.name, tuple(scalars)): + obj.refresh(layout=True) + + +class BoxProperty: + """Descriptor for getting and setting outlines and borders along a single edge. + For example "border-right", "outline-bottom", etc. + """ + + def __init__(self, default_color: Color) -> None: + self._default_color = default_color + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + _type, edge = name.split("_") + self._type = _type + self.edge = edge + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> tuple[EdgeType, Color]: + """Get the box property + + Args: + obj (Styles): The ``Styles`` object + objtype (type[Styles]): The ``Styles`` class + + Returns: + A ``tuple[EdgeType, Style]`` containing the string type of the box and + it's style. Example types are "rounded", "solid", and "dashed". + """ + box_type, color = obj.get_rule(self.name) or ("", self._default_color) + if box_type in {"none", "hidden"}: + box_type = "" + return (box_type, color) + + def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None): + """Set the box property + + Args: + obj (Styles): The ``Styles`` object. + value (tuple[EdgeType, str | Color | Style], optional): A 2-tuple containing the type of box to use, + e.g. "dashed", and the ``Style`` to be used. You can supply the ``Style`` directly, or pass a + ``str`` (e.g. ``"blue on #f0f0f0"`` ) or ``Color`` instead. + + Raises: + StyleSyntaxError: If the string supplied for the color has invalid syntax. + """ + _rich_traceback_omit = True + + if border is None: + if obj.clear_rule(self.name): + obj.refresh(layout=True) + else: + _type, color = border + new_value = border + if isinstance(color, str): + try: + new_value = (_type, Color.parse(color)) + except ColorParseError as error: + raise StyleValueError( + str(error), + help_text=border_property_help_text( + self.name, context="inline" + ), + ) + elif isinstance(color, Color): + new_value = (_type, color) + current_value: tuple[str, Color] = cast( + "tuple[str, Color]", obj.get_rule(self.name) + ) + has_edge = current_value and current_value[0] + new_edge = bool(_type) + if obj.set_rule(self.name, new_value): + obj.refresh(layout=has_edge != new_edge) + + +@rich.repr.auto +class Edges(NamedTuple): + """Stores edges for border / outline.""" + + top: tuple[EdgeType, Color] + right: tuple[EdgeType, Color] + bottom: tuple[EdgeType, Color] + left: tuple[EdgeType, Color] + + def __bool__(self) -> bool: + (top, _), (right, _), (bottom, _), (left, _) = self + return bool(top or right or bottom or left) + + def __rich_repr__(self) -> rich.repr.Result: + top, right, bottom, left = self + if top[0]: + yield "top", top + if right[0]: + yield "right", right + if bottom[0]: + yield "bottom", bottom + if left[0]: + yield "left", left + + @property + def spacing(self) -> Spacing: + """Get spacing created by borders. + + Returns: + tuple[int, int, int, int]: Spacing for top, right, bottom, and left. + """ + (top, _), (right, _), (bottom, _), (left, _) = self + return Spacing( + 1 if top else 0, + 1 if right else 0, + 1 if bottom else 0, + 1 if left else 0, + ) + + +class BorderProperty: + """Descriptor for getting and setting full borders and outlines. + + Args: + layout (bool): True if the layout should be refreshed after setting, False otherwise. + + """ + + def __init__(self, layout: bool) -> None: + self._layout = layout + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + self._properties = ( + f"{name}_top", + f"{name}_right", + f"{name}_bottom", + f"{name}_left", + ) + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Edges: + """Get the border + + Args: + obj (Styles): The ``Styles`` object + objtype (type[Styles]): The ``Styles`` class + + Returns: + An ``Edges`` object describing the type and style of each edge. + """ + top, right, bottom, left = self._properties + + border = Edges( + getattr(obj, top), + getattr(obj, right), + getattr(obj, bottom), + getattr(obj, left), + ) + return border + + def __set__( + self, + obj: StylesBase, + border: BorderDefinition | None, + ) -> None: + """Set the border + + Args: + obj (Styles): The ``Styles`` object. + border (Sequence[tuple[EdgeType, str | Color | Style] | None] | tuple[EdgeType, str | Color | Style] | None): + A ``tuple[EdgeType, str | Color | Style]`` representing the type of box to use and the ``Style`` to apply + to the box. + Alternatively, you can supply a sequence of these tuples and they will be applied per-edge. + If the sequence is of length 1, all edges will be decorated according to the single element. + If the sequence is length 2, the first ``tuple`` will be applied to the top and bottom edges. + If the sequence is length 4, the tuples will be applied to the edges in the order: top, right, bottom, left. + + Raises: + StyleValueError: When the supplied ``tuple`` is not of valid length (1, 2, or 4). + """ + _rich_traceback_omit = True + top, right, bottom, left = self._properties + + border_spacing = Edges( + getattr(obj, top), + getattr(obj, right), + getattr(obj, bottom), + getattr(obj, left), + ).spacing + + def check_refresh() -> None: + """Check if an update requires a layout""" + if not self._layout: + obj.refresh() + else: + layout = ( + Edges( + getattr(obj, top), + getattr(obj, right), + getattr(obj, bottom), + getattr(obj, left), + ).spacing + != border_spacing + ) + obj.refresh(layout=layout) + + if border is None: + clear_rule = obj.clear_rule + clear_rule(top) + clear_rule(right) + clear_rule(bottom) + clear_rule(left) + check_refresh() + return + if isinstance(border, tuple) and len(border) == 2: + _border = normalize_border_value(border) + setattr(obj, top, _border) + setattr(obj, right, _border) + setattr(obj, bottom, _border) + setattr(obj, left, _border) + check_refresh() + return + + count = len(border) + if count == 1: + _border = normalize_border_value(border[0]) + setattr(obj, top, _border) + setattr(obj, right, _border) + setattr(obj, bottom, _border) + setattr(obj, left, _border) + elif count == 2: + _border1, _border2 = ( + normalize_border_value(border[0]), + normalize_border_value(border[1]), + ) + setattr(obj, top, _border1) + setattr(obj, bottom, _border1) + setattr(obj, right, _border2) + setattr(obj, left, _border2) + elif count == 4: + _border1, _border2, _border3, _border4 = ( + normalize_border_value(border[0]), + normalize_border_value(border[1]), + normalize_border_value(border[2]), + normalize_border_value(border[3]), + ) + setattr(obj, top, _border1) + setattr(obj, right, _border2) + setattr(obj, bottom, _border3) + setattr(obj, left, _border4) + else: + raise StyleValueError( + "expected 1, 2, or 4 values", + help_text=border_property_help_text(self.name, context="inline"), + ) + check_refresh() + + +class SpacingProperty: + """Descriptor for getting and setting spacing properties (e.g. padding and margin).""" + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Spacing: + """Get the Spacing + + Args: + obj (Styles): The ``Styles`` object + objtype (type[Styles]): The ``Styles`` class + + Returns: + Spacing: The Spacing. If unset, returns the null spacing ``(0, 0, 0, 0)``. + """ + return obj.get_rule(self.name, NULL_SPACING) + + def __set__(self, obj: StylesBase, spacing: SpacingDimensions | None): + """Set the Spacing + + Args: + obj (Styles): The ``Styles`` object. + style (Style | str, optional): You can supply the ``Style`` directly, or a + string (e.g. ``"blue on #f0f0f0"``). + + Raises: + ValueError: When the value is malformed, e.g. a ``tuple`` with a length that is + not 1, 2, or 4. + """ + _rich_traceback_omit = True + if spacing is None: + if obj.clear_rule(self.name): + obj.refresh(layout=True) + else: + try: + unpacked_spacing = Spacing.unpack(spacing) + except ValueError as error: + raise StyleValueError( + str(error), + help_text=spacing_wrong_number_of_values_help_text( + property_name=self.name, + num_values_supplied=len(spacing), + context="inline", + ), + ) + if obj.set_rule(self.name, unpacked_spacing): + obj.refresh(layout=True) + + +class DockProperty: + """Descriptor for getting and setting the dock property. The dock property + allows you to specify which edge you want to fix a Widget to. + """ + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> DockEdge: + """Get the Dock property + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + str: The dock name as a string, or "" if the rule is not set. + """ + return cast(DockEdge, obj.get_rule("dock", "")) + + def __set__(self, obj: Styles, dock_name: str | None): + """Set the Dock property + + Args: + obj (Styles): The ``Styles`` object + dock_name (str | None): The name of the dock to attach this widget to + """ + _rich_traceback_omit = True + if obj.set_rule("dock", dock_name): + obj.refresh(layout=True) + + +class LayoutProperty: + """Descriptor for getting and setting layout.""" + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Layout | None: + """ + Args: + obj (Styles): The Styles object + objtype (type[Styles]): The Styles class + Returns: + The ``Layout`` object. + """ + return obj.get_rule(self.name) + + def __set__(self, obj: StylesBase, layout: str | Layout | None): + """ + Args: + obj (Styles): The Styles object. + layout (str | Layout): The layout to use. You can supply the name of the layout + or a ``Layout`` object. + """ + + from ..layouts.factory import ( + get_layout, + Layout, + MissingLayout, + ) # Prevents circular import + + _rich_traceback_omit = True + if layout is None: + if obj.clear_rule("layout"): + obj.refresh(layout=True) + elif isinstance(layout, Layout): + if obj.set_rule("layout", layout): + obj.refresh(layout=True) + else: + try: + layout_object = get_layout(layout) + except MissingLayout as error: + raise StyleValueError( + str(error), + help_text=layout_property_help_text(self.name, context="inline"), + ) + if obj.set_rule("layout", layout_object): + obj.refresh(layout=True) + + +class OffsetProperty: + """Descriptor for getting and setting the offset property. + Offset consists of two values, x and y, that a widget's position + will be adjusted by before it is rendered. + """ + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> ScalarOffset: + """Get the offset + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + ScalarOffset: The ``ScalarOffset`` indicating the adjustment that + will be made to widget position prior to it being rendered. + """ + return obj.get_rule(self.name, ScalarOffset.null()) + + def __set__( + self, obj: StylesBase, offset: tuple[int | str, int | str] | ScalarOffset | None + ): + """Set the offset + + Args: + obj: The ``Styles`` class + offset: A ScalarOffset object, or a 2-tuple of the form ``(x, y)`` indicating + the x and y offsets. When the ``tuple`` form is used, x and y can be specified + as either ``int`` or ``str``. The string format allows you to also specify + any valid scalar unit e.g. ``("0.5vw", "0.5vh")``. + + Raises: + ScalarParseError: If any of the string values supplied in the 2-tuple cannot + be parsed into a Scalar. For example, if you specify a non-existent unit. + """ + _rich_traceback_omit = True + if offset is None: + if obj.clear_rule(self.name): + obj.refresh(layout=True) + elif isinstance(offset, ScalarOffset): + if obj.set_rule(self.name, offset): + obj.refresh(layout=True) + else: + x, y = offset + + try: + scalar_x = ( + Scalar.parse(x, Unit.WIDTH) + if isinstance(x, str) + else Scalar(float(x), Unit.CELLS, Unit.WIDTH) + ) + scalar_y = ( + Scalar.parse(y, Unit.HEIGHT) + if isinstance(y, str) + else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) + ) + except ScalarParseError as error: + raise StyleValueError( + str(error), help_text=offset_property_help_text(context="inline") + ) + + _offset = ScalarOffset(scalar_x, scalar_y) + + if obj.set_rule(self.name, _offset): + obj.refresh(layout=True) + + +class StringEnumProperty: + """Descriptor for getting and setting string properties and ensuring that the set + value belongs in the set of valid values. + """ + + def __init__(self, valid_values: set[str], default: str, layout=False) -> None: + self._valid_values = valid_values + self._default = default + self._layout = layout + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> str: + """Get the string property, or the default value if it's not set + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + str: The string property value + """ + return obj.get_rule(self.name, self._default) + + def __set__(self, obj: StylesBase, value: str | None = None): + """Set the string property and ensure it is in the set of allowed values. + + Args: + obj (Styles): The ``Styles`` object + value (str, optional): The string value to set the property to. + + Raises: + StyleValueError: If the value is not in the set of valid values. + """ + _rich_traceback_omit = True + if value is None: + if obj.clear_rule(self.name): + obj.refresh(layout=self._layout) + else: + if value not in self._valid_values: + raise StyleValueError( + f"{self.name} must be one of {friendly_list(self._valid_values)} (received {value!r})", + help_text=string_enum_help_text( + self.name, + valid_values=list(self._valid_values), + context="inline", + ), + ) + if obj.set_rule(self.name, value): + obj.refresh(layout=self._layout) + + +class NameProperty: + """Descriptor for getting and setting name properties.""" + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None) -> str: + """Get the name property + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + str: The name + """ + return obj.get_rule(self.name, "") + + def __set__(self, obj: StylesBase, name: str | None): + """Set the name property + + Args: + obj: The ``Styles`` object + name: The name to set the property to + + Raises: + StyleTypeError: If the value is not a ``str``. + """ + _rich_traceback_omit = True + if name is None: + if obj.clear_rule(self.name): + obj.refresh(layout=True) + else: + if not isinstance(name, str): + raise StyleTypeError(f"{self.name} must be a str") + if obj.set_rule(self.name, name): + obj.refresh(layout=True) + + +class NameListProperty: + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> tuple[str, ...]: + return cast("tuple[str, ...]", obj.get_rule(self.name, ())) + + def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None): + _rich_traceback_omit = True + if names is None: + if obj.clear_rule(self.name): + obj.refresh(layout=True) + elif isinstance(names, str): + if obj.set_rule( + self.name, tuple(name.strip().lower() for name in names.split(" ")) + ): + obj.refresh(layout=True) + elif isinstance(names, tuple): + if obj.set_rule(self.name, names): + obj.refresh(layout=True) + + +class ColorProperty: + """Descriptor for getting and setting color properties.""" + + def __init__(self, default_color: Color | str, background: bool = False) -> None: + self._default_color = Color.parse(default_color) + self._is_background = background + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Color: + """Get a ``Color``. + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + Color: The Color + """ + return cast(Color, obj.get_rule(self.name, self._default_color)) + + def __set__(self, obj: StylesBase, color: Color | str | None): + """Set the Color + + Args: + obj (Styles): The ``Styles`` object + color (Color | str | None): The color to set. Pass a ``Color`` instance directly, + or pass a ``str`` which will be parsed into a color (e.g. ``"red""``, ``"rgb(20, 50, 80)"``, + ``"#f4e32d"``). + + Raises: + ColorParseError: When the color string is invalid. + """ + _rich_traceback_omit = True + if color is None: + if obj.clear_rule(self.name): + obj.refresh(children=self._is_background) + elif isinstance(color, Color): + if obj.set_rule(self.name, color): + obj.refresh(children=self._is_background) + + elif isinstance(color, str): + alpha = 1.0 + parsed_color = Color(255, 255, 255) + for token in color.split(): + if token.endswith("%"): + try: + alpha = percentage_string_to_float(token) + except ValueError: + raise StyleValueError(f"invalid percentage value '{token}'") + continue + try: + parsed_color = Color.parse(token) + except ColorParseError as error: + raise StyleValueError( + f"Invalid color value '{token}'", + help_text=color_property_help_text( + self.name, context="inline", error=error + ), + ) + parsed_color = parsed_color.with_alpha(alpha) + if obj.set_rule(self.name, parsed_color): + obj.refresh(children=self._is_background) + else: + raise StyleValueError(f"Invalid color value {color}") + + +class StyleFlagsProperty: + """Descriptor for getting and set style flag properties (e.g. ``bold italic underline``).""" + + def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> Style: + """Get the ``Style`` + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + Style: The ``Style`` object + """ + return obj.get_rule(self.name, Style.null()) + + def __set__(self, obj: StylesBase, style_flags: Style | str | None): + """Set the style using a style flag string + + Args: + obj (Styles): The ``Styles`` object. + style_flags (str, optional): The style flags to set as a string. For example, + ``"bold italic"``. + + Raises: + StyleValueError: If the value is an invalid style flag + """ + _rich_traceback_omit = True + if style_flags is None: + if obj.clear_rule(self.name): + obj.refresh() + elif isinstance(style_flags, Style): + if obj.set_rule(self.name, style_flags): + obj.refresh() + else: + words = [word.strip() for word in style_flags.split(" ")] + valid_word = VALID_STYLE_FLAGS.__contains__ + for word in words: + if not valid_word(word): + raise StyleValueError( + f"unknown word {word!r} in style flags", + help_text=style_flags_property_help_text( + self.name, word, context="inline" + ), + ) + style = Style.parse(style_flags) + if obj.set_rule(self.name, style): + obj.refresh() + + +class TransitionsProperty: + """Descriptor for getting transitions properties""" + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> dict[str, Transition]: + """Get a mapping of properties to the transitions applied to them. + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + dict[str, Transition]: A ``dict`` mapping property names to the ``Transition`` applied to them. + e.g. ``{"offset": Transition(...), ...}``. If no transitions have been set, an empty ``dict`` + is returned. + """ + return obj.get_rule("transitions", {}) + + def __set__(self, obj: Styles, transitions: dict[str, Transition] | None) -> None: + _rich_traceback_omit = True + if transitions is None: + obj.clear_rule("transitions") + else: + obj.set_rule("transitions", transitions.copy()) + + +class FractionalProperty: + """Property that can be set either as a float (e.g. 0.1) or a + string percentage (e.g. '10%'). Values will be clamped to the range (0, 1). + """ + + def __init__(self, default: float = 1.0): + self.default = default + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.name = name + + def __get__(self, obj: StylesBase, type: type[StylesBase]) -> float: + """Get the property value as a float between 0 and 1 + + Args: + obj (Styles): The ``Styles`` object. + objtype (type[Styles]): The ``Styles`` class. + + Returns: + float: The value of the property (in the range (0, 1)) + """ + return cast(float, obj.get_rule(self.name, self.default)) + + def __set__(self, obj: StylesBase, value: float | str | None) -> None: + """Set the property value, clamping it between 0 and 1. + + Args: + obj (Styles): The Styles object. + value (float|str|None): The value to set as a float between 0 and 1, or + as a percentage string such as '10%'. + """ + _rich_traceback_omit = True + name = self.name + if value is None: + if obj.clear_rule(name): + obj.refresh() + return + + if isinstance(value, float): + float_value = value + elif isinstance(value, str) and value.endswith("%"): + float_value = float(Scalar.parse(value).value) / 100 + else: + raise StyleValueError( + f"{self.name} must be a str (e.g. '10%') or a float (e.g. 0.1)", + help_text=fractional_property_help_text(name, context="inline"), + ) + if obj.set_rule(name, clamp(float_value, 0, 1)): + obj.refresh() + + +class AlignProperty: + """Combines the horizontal and vertical alignment properties in to a single property.""" + + def __set_name__(self, owner: StylesBase, name: str) -> None: + self.horizontal = f"{name}_horizontal" + self.vertical = f"{name}_vertical" + + def __get__( + self, obj: StylesBase, type: type[StylesBase] + ) -> tuple[AlignHorizontal, AlignVertical]: + horizontal = getattr(obj, self.horizontal) + vertical = getattr(obj, self.vertical) + return (horizontal, vertical) + + def __set__( + self, obj: StylesBase, value: tuple[AlignHorizontal, AlignVertical] + ) -> None: + horizontal, vertical = value + setattr(obj, self.horizontal, horizontal) + setattr(obj, self.vertical, vertical) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py new file mode 100644 index 000000000..5faa27b21 --- /dev/null +++ b/src/textual/css/_styles_builder.py @@ -0,0 +1,975 @@ +from __future__ import annotations + +from functools import lru_cache +from typing import Iterable, NoReturn, Sequence, cast + +import rich.repr + +from .._border import BorderValue, normalize_border_value +from .._duration import _duration_as_seconds +from .._easing import EASING +from ..color import Color, ColorParseError +from ..geometry import Spacing, SpacingDimensions, clamp +from ..suggestions import get_suggestion +from ._error_tools import friendly_list +from ._help_renderables import HelpText +from ._help_text import ( + align_help_text, + border_property_help_text, + color_property_help_text, + dock_property_help_text, + fractional_property_help_text, + integer_help_text, + layout_property_help_text, + offset_property_help_text, + offset_single_axis_help_text, + property_invalid_value_help_text, + scalar_help_text, + scrollbar_size_property_help_text, + scrollbar_size_single_axis_help_text, + spacing_invalid_value_help_text, + spacing_wrong_number_of_values_help_text, + string_enum_help_text, + style_flags_property_help_text, + table_rows_or_columns_help_text, + text_align_help_text, +) +from .constants import ( + VALID_ALIGN_HORIZONTAL, + VALID_ALIGN_VERTICAL, + VALID_BORDER, + VALID_BOX_SIZING, + VALID_DISPLAY, + VALID_EDGE, + VALID_OVERFLOW, + VALID_SCROLLBAR_GUTTER, + VALID_STYLE_FLAGS, + VALID_TEXT_ALIGN, + VALID_VISIBILITY, +) +from .errors import DeclarationError, StyleValueError +from .model import Declaration +from .scalar import ( + Scalar, + ScalarError, + ScalarOffset, + ScalarParseError, + Unit, + percentage_string_to_float, +) +from .styles import Styles +from .tokenize import Token +from .transition import Transition +from .types import BoxSizing, Display, EdgeType, Overflow, Visibility + + +def _join_tokens(tokens: Iterable[Token], joiner: str = "") -> str: + """Convert tokens into a string by joining their values + + Args: + tokens (Iterable[Token]): Tokens to join + joiner (str): String to join on, defaults to "" + + Returns: + str: The tokens, joined together to form a string. + """ + return joiner.join(token.value for token in tokens) + + +class StylesBuilder: + """ + The StylesBuilder object takes tokens parsed from the CSS and converts + to the appropriate internal types. + """ + + def __init__(self) -> None: + self.styles = Styles() + + def __rich_repr__(self) -> rich.repr.Result: + yield "styles", self.styles + + def __repr__(self) -> str: + return "StylesBuilder()" + + def error(self, name: str, token: Token, message: str | HelpText) -> NoReturn: + raise DeclarationError(name, token, message) + + def add_declaration(self, declaration: Declaration) -> None: + if not declaration.tokens: + return + rule_name = declaration.name.replace("-", "_") + process_method = getattr(self, f"process_{rule_name}", None) + + if process_method is None: + suggested_property_name = self._get_suggested_property_name_for_rule( + declaration.name + ) + self.error( + declaration.name, + declaration.token, + property_invalid_value_help_text( + declaration.name, + "css", + suggested_property_name=suggested_property_name, + ), + ) + return + + tokens = declaration.tokens + + important = tokens[-1].name == "important" + if important: + tokens = tokens[:-1] + self.styles.important.add(rule_name) + try: + process_method(declaration.name, tokens) + except DeclarationError: + raise + except Exception as error: + self.error(declaration.name, declaration.token, str(error)) + + @lru_cache(maxsize=None) + def _get_processable_rule_names(self) -> Sequence[str]: + """ + Returns the list of CSS properties we can manage - + i.e. the ones for which we have a `process_[property name]` method + + Returns: + Sequence[str]: All the "Python-ised" CSS property names this class can handle. + + Example: ("width", "background", "offset_x", ...) + """ + return [attr[8:] for attr in dir(self) if attr.startswith("process_")] + + def _process_enum_multiple( + self, name: str, tokens: list[Token], valid_values: set[str], count: int + ) -> tuple[str, ...]: + """Generic code to process a declaration with two enumerations, like overflow: auto auto""" + if len(tokens) > count or not tokens: + self.error(name, tokens[0], f"expected 1 to {count} tokens here") + results: list[str] = [] + append = results.append + for token in tokens: + token_name, value, _, _, location, _ = token + if token_name != "token": + self.error( + name, + token, + f"invalid token {value!r}; expected {friendly_list(valid_values)}", + ) + append(value) + + short_results = results[:] + + while len(results) < count: + results.extend(short_results) + results = results[:count] + + return tuple(results) + + def _process_enum( + self, name: str, tokens: list[Token], valid_values: set[str] + ) -> str: + """Process a declaration that expects an enum. + + Args: + name (str): Name of declaration. + tokens (list[Token]): Tokens from parser. + valid_values (list[str]): A set of valid values. + + Returns: + bool: True if the value is valid or False if it is invalid (also generates an error) + """ + + if len(tokens) != 1: + string_enum_help_text(name, valid_values=list(valid_values), context="css"), + + token = tokens[0] + token_name, value, _, _, location, _ = token + if token_name != "token": + self.error( + name, + token, + string_enum_help_text( + name, valid_values=list(valid_values), context="css" + ), + ) + if value not in valid_values: + self.error( + name, + token, + string_enum_help_text( + name, valid_values=list(valid_values), context="css" + ), + ) + return value + + def process_display(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + name, value, _, _, location, _ = token + + if name == "token": + value = value.lower() + if value in VALID_DISPLAY: + self.styles._rules["display"] = cast(Display, value) + else: + self.error( + name, + token, + string_enum_help_text( + "display", valid_values=list(VALID_DISPLAY), context="css" + ), + ) + else: + self.error( + name, + token, + string_enum_help_text( + "display", valid_values=list(VALID_DISPLAY), context="css" + ), + ) + + def _process_scalar(self, name: str, tokens: list[Token]) -> None: + def scalar_error(): + self.error( + name, tokens[0], scalar_help_text(property_name=name, context="css") + ) + + if not tokens: + return + if len(tokens) == 1: + try: + self.styles._rules[name.replace("-", "_")] = Scalar.parse( + tokens[0].value + ) + except ScalarParseError: + scalar_error() + else: + scalar_error() + + def process_box_sizing(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + name, value, _, _, location, _ = token + + if name == "token": + value = value.lower() + if value in VALID_BOX_SIZING: + self.styles._rules["box_sizing"] = cast(BoxSizing, value) + else: + self.error( + name, + token, + string_enum_help_text( + "box-sizing", + valid_values=list(VALID_BOX_SIZING), + context="css", + ), + ) + else: + self.error( + name, + token, + string_enum_help_text( + "box-sizing", valid_values=list(VALID_BOX_SIZING), context="css" + ), + ) + + def process_width(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_height(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_min_width(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_min_height(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_max_width(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_max_height(self, name: str, tokens: list[Token]) -> None: + self._process_scalar(name, tokens) + + def process_overflow(self, name: str, tokens: list[Token]) -> None: + rules = self.styles._rules + overflow_x, overflow_y = self._process_enum_multiple( + name, tokens, VALID_OVERFLOW, 2 + ) + rules["overflow_x"] = cast(Overflow, overflow_x) + rules["overflow_y"] = cast(Overflow, overflow_y) + + def process_overflow_x(self, name: str, tokens: list[Token]) -> None: + self.styles._rules["overflow_x"] = cast( + Overflow, self._process_enum(name, tokens, VALID_OVERFLOW) + ) + + def process_overflow_y(self, name: str, tokens: list[Token]) -> None: + self.styles._rules["overflow_y"] = cast( + Overflow, self._process_enum(name, tokens, VALID_OVERFLOW) + ) + + def process_visibility(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + name, value, _, _, location, _ = token + if name == "token": + value = value.lower() + if value in VALID_VISIBILITY: + self.styles._rules["visibility"] = cast(Visibility, value) + else: + self.error( + name, + token, + string_enum_help_text( + "visibility", + valid_values=list(VALID_VISIBILITY), + context="css", + ), + ) + else: + string_enum_help_text( + "visibility", valid_values=list(VALID_VISIBILITY), context="css" + ) + + def _process_fractional(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + token = tokens[0] + error = False + if len(tokens) != 1: + error = True + else: + token_name = token.name + value = token.value + rule_name = name.replace("-", "_") + if token_name == "scalar" and value.endswith("%"): + try: + text_opacity = percentage_string_to_float(value) + self.styles.set_rule(rule_name, text_opacity) + except ValueError: + error = True + elif token_name == "number": + try: + text_opacity = clamp(float(value), 0, 1) + self.styles.set_rule(rule_name, text_opacity) + except ValueError: + error = True + else: + error = True + + if error: + self.error(name, token, fractional_property_help_text(name, context="css")) + + process_opacity = _process_fractional + process_text_opacity = _process_fractional + + def _process_space(self, name: str, tokens: list[Token]) -> None: + space: list[int] = [] + append = space.append + for token in tokens: + token_name, value, _, _, _, _ = token + if token_name == "number": + try: + append(int(value)) + except ValueError: + self.error( + name, + token, + spacing_invalid_value_help_text(name, context="css"), + ) + else: + self.error( + name, token, spacing_invalid_value_help_text(name, context="css") + ) + if len(space) not in (1, 2, 4): + self.error( + name, + tokens[0], + spacing_wrong_number_of_values_help_text( + name, num_values_supplied=len(space), context="css" + ), + ) + self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space))) + + def _process_space_partial(self, name: str, tokens: list[Token]) -> None: + """Process granular margin / padding declarations.""" + if len(tokens) != 1: + self.error( + name, tokens[0], spacing_invalid_value_help_text(name, context="css") + ) + + _EDGE_SPACING_MAP = {"top": 0, "right": 1, "bottom": 2, "left": 3} + token = tokens[0] + token_name, value, _, _, _, _ = token + if token_name == "number": + space = int(value) + else: + self.error( + name, token, spacing_invalid_value_help_text(name, context="css") + ) + style_name, _, edge = name.replace("-", "_").partition("_") + + current_spacing = cast( + "tuple[int, int, int, int]", + self.styles._rules.get(style_name, (0, 0, 0, 0)), + ) + + spacing_list = list(current_spacing) + spacing_list[_EDGE_SPACING_MAP[edge]] = space + + self.styles._rules[style_name] = Spacing(*spacing_list) + + process_padding = _process_space + process_margin = _process_space + + process_margin_top = _process_space_partial + process_margin_right = _process_space_partial + process_margin_bottom = _process_space_partial + process_margin_left = _process_space_partial + + process_padding_top = _process_space_partial + process_padding_right = _process_space_partial + process_padding_bottom = _process_space_partial + process_padding_left = _process_space_partial + + def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue: + + border_type: EdgeType = "solid" + border_color = Color(0, 255, 0) + + def border_value_error(): + self.error(name, token, border_property_help_text(name, context="css")) + + for token in tokens: + token_name, value, _, _, _, _ = token + if token_name == "token": + if value in VALID_BORDER: + border_type = value + else: + try: + border_color = Color.parse(value) + except ColorParseError: + border_value_error() + + elif token_name == "color": + try: + border_color = Color.parse(value) + except ColorParseError: + border_value_error() + + else: + border_value_error() + + return normalize_border_value((border_type, border_color)) + + def _process_border_edge(self, edge: str, name: str, tokens: list[Token]) -> None: + border = self._parse_border(name, tokens) + self.styles._rules[f"border_{edge}"] = border + + def process_border(self, name: str, tokens: list[Token]) -> None: + border = self._parse_border(name, tokens) + rules = self.styles._rules + rules["border_top"] = rules["border_right"] = border + rules["border_bottom"] = rules["border_left"] = border + + def process_border_top(self, name: str, tokens: list[Token]) -> None: + self._process_border_edge("top", name, tokens) + + def process_border_right(self, name: str, tokens: list[Token]) -> None: + self._process_border_edge("right", name, tokens) + + def process_border_bottom(self, name: str, tokens: list[Token]) -> None: + self._process_border_edge("bottom", name, tokens) + + def process_border_left(self, name: str, tokens: list[Token]) -> None: + self._process_border_edge("left", name, tokens) + + def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None: + border = self._parse_border(name, tokens) + self.styles._rules[f"outline_{edge}"] = border + + def process_outline(self, name: str, tokens: list[Token]) -> None: + border = self._parse_border(name, tokens) + rules = self.styles._rules + rules["outline_top"] = rules["outline_right"] = border + rules["outline_bottom"] = rules["outline_left"] = border + + def process_outline_top(self, name: str, tokens: list[Token]) -> None: + self._process_outline("top", name, tokens) + + def process_parse_border_right(self, name: str, tokens: list[Token]) -> None: + self._process_outline("right", name, tokens) + + def process_outline_bottom(self, name: str, tokens: list[Token]) -> None: + self._process_outline("bottom", name, tokens) + + def process_outline_left(self, name: str, tokens: list[Token]) -> None: + self._process_outline("left", name, tokens) + + def process_offset(self, name: str, tokens: list[Token]) -> None: + def offset_error(name: str, token: Token) -> None: + self.error(name, token, offset_property_help_text(context="css")) + + if not tokens: + return + if len(tokens) != 2: + offset_error(name, tokens[0]) + else: + token1, token2 = tokens + + if token1.name not in ("scalar", "number"): + offset_error(name, token1) + if token2.name not in ("scalar", "number"): + offset_error(name, token2) + + scalar_x = Scalar.parse(token1.value, Unit.WIDTH) + scalar_y = Scalar.parse(token2.value, Unit.HEIGHT) + self.styles._rules["offset"] = ScalarOffset(scalar_x, scalar_y) + + def process_offset_x(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], offset_single_axis_help_text(name)) + else: + token = tokens[0] + if token.name not in ("scalar", "number"): + self.error(name, token, offset_single_axis_help_text(name)) + x = Scalar.parse(token.value, Unit.WIDTH) + y = self.styles.offset.y + self.styles._rules["offset"] = ScalarOffset(x, y) + + def process_offset_y(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], offset_single_axis_help_text(name)) + else: + token = tokens[0] + if token.name not in ("scalar", "number"): + self.error(name, token, offset_single_axis_help_text(name)) + y = Scalar.parse(token.value, Unit.HEIGHT) + x = self.styles.offset.x + self.styles._rules["offset"] = ScalarOffset(x, y) + + def process_layout(self, name: str, tokens: list[Token]) -> None: + from ..layouts.factory import MissingLayout, get_layout + + if tokens: + if len(tokens) != 1: + self.error( + name, tokens[0], layout_property_help_text(name, context="css") + ) + else: + value = tokens[0].value + layout_name = value + try: + self.styles._rules["layout"] = get_layout(layout_name) + except MissingLayout: + self.error( + name, + tokens[0], + layout_property_help_text(name, context="css"), + ) + + def process_color(self, name: str, tokens: list[Token]) -> None: + """Processes a simple color declaration.""" + name = name.replace("-", "_") + + color: Color | None = None + alpha: float | None = None + + self.styles._rules[f"auto_{name}"] = False + for token in tokens: + if ( + "background" not in name + and token.name == "token" + and token.value == "auto" + ): + self.styles._rules[f"auto_{name}"] = True + elif token.name == "scalar": + alpha_scalar = Scalar.parse(token.value) + if alpha_scalar.unit != Unit.PERCENT: + self.error(name, token, "alpha must be given as a percentage.") + alpha = alpha_scalar.value / 100.0 + + elif token.name in ("color", "token"): + try: + color = Color.parse(token.value) + except Exception as error: + self.error( + name, + token, + color_property_help_text(name, context="css", error=error), + ) + else: + self.error(name, token, color_property_help_text(name, context="css")) + + if color is not None or alpha is not None: + if alpha is not None: + color = (color or Color(255, 255, 255)).with_alpha(alpha) + self.styles._rules[name] = color + + process_tint = process_color + process_background = process_color + process_scrollbar_color = process_color + process_scrollbar_color_hover = process_color + process_scrollbar_color_active = process_color + process_scrollbar_corner_color = process_color + process_scrollbar_background = process_color + process_scrollbar_background_hover = process_color + process_scrollbar_background_active = process_color + + process_link_color = process_color + process_link_background = process_color + process_link_hover_color = process_color + process_link_hover_background = process_color + + def process_text_style(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + value = token.value + if value not in VALID_STYLE_FLAGS: + self.error( + name, + token, + style_flags_property_help_text(name, value, context="css"), + ) + + style_definition = " ".join(token.value for token in tokens) + self.styles._rules[name.replace("-", "_")] = style_definition + + process_link_style = process_text_style + process_link_hover_style = process_text_style + + def process_text_align(self, name: str, tokens: list[Token]) -> None: + """Process a text-align declaration""" + if not tokens: + return + + if len(tokens) > 1 or tokens[0].value not in VALID_TEXT_ALIGN: + self.error( + name, + tokens[0], + text_align_help_text(), + ) + + self.styles._rules["text_align"] = tokens[0].value + + def process_dock(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + + if len(tokens) > 1 or tokens[0].value not in VALID_EDGE: + self.error( + name, + tokens[0], + dock_property_help_text(name, context="css"), + ) + + dock = tokens[0].value + self.styles._rules["dock"] = dock + + def process_layer(self, name: str, tokens: list[Token]) -> None: + if len(tokens) > 1: + self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration") + self.styles._rules["layer"] = tokens[0].value + + def process_layers(self, name: str, tokens: list[Token]) -> None: + layers: list[str] = [] + for token in tokens: + if token.name != "token": + self.error(name, token, "{token.name} not expected here") + layers.append(token.value) + self.styles._rules["layers"] = tuple(layers) + + def process_transition(self, name: str, tokens: list[Token]) -> None: + transitions: dict[str, Transition] = {} + + def make_groups() -> Iterable[list[Token]]: + """Batch tokens into comma-separated groups.""" + group: list[Token] = [] + for token in tokens: + if token.name == "comma": + if group: + yield group + group = [] + else: + group.append(token) + if group: + yield group + + valid_duration_token_names = ("duration", "number") + for tokens in make_groups(): + css_property = "" + duration = 1.0 + easing = "linear" + delay = 0.0 + + try: + iter_tokens = iter(tokens) + token = next(iter_tokens) + if token.name != "token": + self.error(name, token, "expected property") + + css_property = token.value + token = next(iter_tokens) + if token.name not in valid_duration_token_names: + self.error(name, token, "expected duration or number") + try: + duration = _duration_as_seconds(token.value) + except ScalarError as error: + self.error(name, token, str(error)) + + token = next(iter_tokens) + if token.name != "token": + self.error(name, token, "easing function expected") + + if token.value not in EASING: + self.error( + name, + token, + f"expected easing function; found {token.value!r}", + ) + easing = token.value + + token = next(iter_tokens) + if token.name not in valid_duration_token_names: + self.error(name, token, "expected duration or number") + try: + delay = _duration_as_seconds(token.value) + except ScalarError as error: + self.error(name, token, str(error)) + except StopIteration: + pass + transitions[css_property] = Transition(duration, easing, delay) + + self.styles._rules["transitions"] = transitions + + def process_align(self, name: str, tokens: list[Token]) -> None: + def align_error(name, token): + self.error(name, token, align_help_text()) + + if len(tokens) != 2: + self.error(name, tokens[0], align_help_text()) + + token_horizontal = tokens[0] + token_vertical = tokens[1] + + if token_horizontal.name != "token": + align_error(name, token_horizontal) + elif token_horizontal.value not in VALID_ALIGN_HORIZONTAL: + align_error(name, token_horizontal) + + if token_vertical.name != "token": + align_error(name, token_vertical) + elif token_vertical.value not in VALID_ALIGN_VERTICAL: + align_error(name, token_horizontal) + + name = name.replace("-", "_") + self.styles._rules[f"{name}_horizontal"] = token_horizontal.value + self.styles._rules[f"{name}_vertical"] = token_vertical.value + + def process_align_horizontal(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_ALIGN_HORIZONTAL, context="css"), + ) + else: + self.styles._rules[name.replace("-", "_")] = value + + def process_align_vertical(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_ALIGN_VERTICAL, context="css"), + ) + else: + self.styles._rules[name.replace("-", "_")] = value + + process_content_align = process_align + process_content_align_horizontal = process_align_horizontal + process_content_align_vertical = process_align_vertical + + def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_SCROLLBAR_GUTTER, context="css"), + ) + else: + self.styles._rules[name.replace("-", "_")] = value + + def process_scrollbar_size(self, name: str, tokens: list[Token]) -> None: + def scrollbar_size_error(name: str, token: Token) -> None: + self.error(name, token, scrollbar_size_property_help_text(context="css")) + + if not tokens: + return + if len(tokens) != 2: + scrollbar_size_error(name, tokens[0]) + else: + token1, token2 = tokens + + if token1.name != "number" or not token1.value.isdigit(): + scrollbar_size_error(name, token1) + if token2.name != "number" or not token2.value.isdigit(): + scrollbar_size_error(name, token2) + + horizontal = int(token1.value) + if horizontal == 0: + scrollbar_size_error(name, token1) + vertical = int(token2.value) + if vertical == 0: + scrollbar_size_error(name, token2) + self.styles._rules["scrollbar_size_horizontal"] = horizontal + self.styles._rules["scrollbar_size_vertical"] = vertical + + def process_scrollbar_size_vertical(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], scrollbar_size_single_axis_help_text(name)) + else: + token = tokens[0] + if token.name != "number" or not token.value.isdigit(): + self.error(name, token, scrollbar_size_single_axis_help_text(name)) + value = int(token.value) + if value == 0: + self.error(name, token, scrollbar_size_single_axis_help_text(name)) + self.styles._rules["scrollbar_size_vertical"] = value + + def process_scrollbar_size_horizontal(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], scrollbar_size_single_axis_help_text(name)) + else: + token = tokens[0] + if token.name != "number" or not token.value.isdigit(): + self.error(name, token, scrollbar_size_single_axis_help_text(name)) + value = int(token.value) + if value == 0: + self.error(name, token, scrollbar_size_single_axis_help_text(name)) + self.styles._rules["scrollbar_size_horizontal"] = value + + def _process_grid_rows_or_columns(self, name: str, tokens: list[Token]) -> None: + scalars: list[Scalar] = [] + for token in tokens: + if token.name == "number": + scalars.append(Scalar.from_number(float(token.value))) + elif token.name == "scalar": + scalars.append( + Scalar.parse( + token.value, + percent_unit=Unit.WIDTH if name == "rows" else Unit.HEIGHT, + ) + ) + else: + self.error( + name, + token, + table_rows_or_columns_help_text(name, token.value, context="css"), + ) + self.styles._rules[name.replace("-", "_")] = scalars + + process_grid_rows = _process_grid_rows_or_columns + process_grid_columns = _process_grid_rows_or_columns + + def _process_integer(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], integer_help_text(name)) + else: + token = tokens[0] + if token.name != "number" or not token.value.isdigit(): + self.error(name, token, integer_help_text(name)) + value = int(token.value) + if value == 0: + self.error(name, token, integer_help_text(name)) + self.styles._rules[name.replace("-", "_")] = value + + process_grid_gutter_horizontal = _process_integer + process_grid_gutter_vertical = _process_integer + process_column_span = _process_integer + process_row_span = _process_integer + process_grid_size_columns = _process_integer + process_grid_size_rows = _process_integer + + def process_grid_gutter(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) == 1: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_gutter_horizontal"] = value + self.styles._rules["grid_gutter_vertical"] = value + + elif len(tokens) == 2: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_gutter_horizontal"] = value + token = tokens[1] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_gutter_vertical"] = value + + else: + self.error(name, tokens[0], "expected two integers here") + + def process_grid_size(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) == 1: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_size_columns"] = value + self.styles._rules["grid_size_rows"] = 0 + + elif len(tokens) == 2: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_size_columns"] = value + token = tokens[1] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["grid_size_rows"] = value + + else: + self.error(name, tokens[0], "expected two integers here") + + def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None: + """ + Returns a valid CSS property "Python" name, or None if no close matches could be found. + + Args: + rule_name (str): An invalid "Python-ised" CSS property (i.e. "offst_x" rather than "offst-x") + + Returns: + str | None: The closest valid "Python-ised" CSS property. + Returns `None` if no close matches could be found. + + Example: returns "background" for rule_name "bkgrund", "offset_x" for "ofset_x" + """ + return get_suggestion(rule_name, self._get_processable_rule_names()) diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py new file mode 100644 index 000000000..a59671753 --- /dev/null +++ b/src/textual/css/constants.py @@ -0,0 +1,60 @@ +from __future__ import annotations +import sys +import typing + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final # pragma: no cover + +from ..geometry import Spacing + +if typing.TYPE_CHECKING: + from .types import EdgeType + +VALID_VISIBILITY: Final = {"visible", "hidden"} +VALID_DISPLAY: Final = {"block", "none"} +VALID_BORDER: Final[set[EdgeType]] = { + "none", + "hidden", + "ascii", + "round", + "blank", + "solid", + "double", + "dashed", + "heavy", + "inner", + "outer", + "hkey", + "vkey", + "tall", + "wide", +} +VALID_EDGE: Final = {"top", "right", "bottom", "left"} +VALID_LAYOUT: Final = {"vertical", "horizontal", "grid"} + +VALID_BOX_SIZING: Final = {"border-box", "content-box"} +VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} +VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"} +VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"} +VALID_TEXT_ALIGN: Final = {"start", "end", "left", "right", "center", "justify"} +VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"} +VALID_STYLE_FLAGS: Final = { + "none", + "not", + "bold", + "blink", + "italic", + "underline", + "overline", + "strike", + "b", + "i", + "u", + "uu", + "o", + "reverse", +} + +NULL_SPACING: Final = Spacing.all(0) diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py new file mode 100644 index 000000000..c0a5ca95d --- /dev/null +++ b/src/textual/css/errors.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console, RenderResult +from rich.traceback import Traceback + +from ._help_renderables import HelpText +from .tokenize import Token +from .tokenizer import TokenError + + +class DeclarationError(Exception): + def __init__(self, name: str, token: Token, message: str | HelpText) -> None: + self.name = name + self.token = token + self.message = message + super().__init__(str(message)) + + +class StyleTypeError(TypeError): + pass + + +class UnresolvedVariableError(TokenError): + pass + + +class StyleValueError(ValueError): + """Raised when the value of a style property is not valid + + Attributes: + help_text (HelpText | None): Optional HelpText to be rendered when this + error is raised. + """ + + def __init__(self, *args, help_text: HelpText | None = None): + super().__init__(*args) + self.help_text = help_text + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + yield Traceback.from_exception(type(self), self, self.__traceback__) + if self.help_text is not None: + yield "" + yield self.help_text + yield "" + + +class StylesheetError(Exception): + pass diff --git a/src/textual/css/match.py b/src/textual/css/match.py new file mode 100644 index 000000000..4b647c461 --- /dev/null +++ b/src/textual/css/match.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import Iterable, TYPE_CHECKING +from .model import CombinatorType, Selector, SelectorSet + + +if TYPE_CHECKING: + from ..dom import DOMNode + + +def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool: + """Check if a given selector matches any of the given selector sets. + + Args: + selector_sets (Iterable[SelectorSet]): Iterable of selector sets. + node (DOMNode): DOM node. + + Returns: + bool: True if the node matches the selector, otherwise False. + """ + return any( + _check_selectors(selector_set.selectors, node.css_path_nodes) + for selector_set in selector_sets + ) + + +def _check_selectors(selectors: list[Selector], css_path_nodes: list[DOMNode]) -> bool: + """Match a list of selectors against a node. + + Args: + selectors (list[Selector]): A list of selectors. + node (DOMNode): A DOM node. + + Returns: + bool: True if the node matches the selector. + """ + + DESCENDENT = CombinatorType.DESCENDENT + + node = css_path_nodes[-1] + path_count = len(css_path_nodes) + selector_count = len(selectors) + + stack: list[tuple[int, int]] = [(0, 0)] + + push = stack.append + pop = stack.pop + selector_index = 0 + + while stack: + selector_index, node_index = stack[-1] + if selector_index == selector_count or node_index == path_count: + pop() + else: + path_node = css_path_nodes[node_index] + selector = selectors[selector_index] + if selector.combinator == DESCENDENT: + # Find a matching descendent + if selector.check(path_node): + if path_node is node and selector_index == selector_count - 1: + return True + stack[-1] = (selector_index + 1, node_index + selector.advance) + push((selector_index, node_index + 1)) + else: + stack[-1] = (selector_index, node_index + 1) + else: + # Match the next node + if selector.check(path_node): + if path_node is node and selector_index == selector_count - 1: + return True + stack[-1] = (selector_index + 1, node_index + selector.advance) + else: + pop() + return False diff --git a/src/textual/css/model.py b/src/textual/css/model.py new file mode 100644 index 000000000..c5efebc15 --- /dev/null +++ b/src/textual/css/model.py @@ -0,0 +1,235 @@ +from __future__ import annotations + + +import rich.repr + +from dataclasses import dataclass, field +from enum import Enum +from typing import Iterable, TYPE_CHECKING + +from .styles import Styles +from .tokenize import Token +from .types import Specificity3 + +if TYPE_CHECKING: + from ..dom import DOMNode + + +class SelectorType(Enum): + UNIVERSAL = 1 + TYPE = 2 + CLASS = 3 + ID = 4 + + +class CombinatorType(Enum): + SAME = 1 + DESCENDENT = 2 + CHILD = 3 + + +@dataclass +class Selector: + """Represents a CSS selector. + + Some examples of selectors: + + * + Header.title + App > Content + """ + + name: str + combinator: CombinatorType = CombinatorType.DESCENDENT + type: SelectorType = SelectorType.TYPE + pseudo_classes: list[str] = field(default_factory=list) + specificity: Specificity3 = field(default_factory=lambda: (0, 0, 0)) + _name_lower: str = field(default="", repr=False) + advance: int = 1 + + @property + def css(self) -> str: + """Rebuilds the selector as it would appear in CSS.""" + pseudo_suffix = "".join(f":{name}" for name in self.pseudo_classes) + if self.type == SelectorType.UNIVERSAL: + return "*" + elif self.type == SelectorType.TYPE: + return f"{self.name}{pseudo_suffix}" + elif self.type == SelectorType.CLASS: + return f".{self.name}{pseudo_suffix}" + else: + return f"#{self.name}{pseudo_suffix}" + + def __post_init__(self) -> None: + self._name_lower = self.name.lower() + self._checks = { + SelectorType.UNIVERSAL: self._check_universal, + SelectorType.TYPE: self._check_type, + SelectorType.CLASS: self._check_class, + SelectorType.ID: self._check_id, + } + + def _add_pseudo_class(self, pseudo_class: str) -> None: + """Adds a pseudo class and updates specificity. + + Args: + pseudo_class (str): Name of pseudo class. + """ + self.pseudo_classes.append(pseudo_class) + specificity1, specificity2, specificity3 = self.specificity + self.specificity = (specificity1, specificity2 + 1, specificity3) + + def check(self, node: DOMNode) -> bool: + """Check if a given node matches the selector. + + Args: + node (DOMNode): A DOM node. + + Returns: + bool: True if the selector matches, otherwise False. + """ + return self._checks[self.type](node) + + def _check_universal(self, node: DOMNode) -> bool: + return node.has_pseudo_class(*self.pseudo_classes) + + def _check_type(self, node: DOMNode) -> bool: + if self._name_lower not in node._css_type_names: + return False + if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): + return False + return True + + def _check_class(self, node: DOMNode) -> bool: + if not node.has_class(self._name_lower): + return False + if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): + return False + return True + + def _check_id(self, node: DOMNode) -> bool: + if not node.id == self._name_lower: + return False + if self.pseudo_classes and not node.has_pseudo_class(*self.pseudo_classes): + return False + return True + + +@dataclass +class Declaration: + token: Token + name: str + tokens: list[Token] = field(default_factory=list) + + +@rich.repr.auto(angular=True) +@dataclass +class SelectorSet: + selectors: list[Selector] = field(default_factory=list) + specificity: Specificity3 = (0, 0, 0) + + def __post_init__(self) -> None: + SAME = CombinatorType.SAME + for selector, next_selector in zip(self.selectors, self.selectors[1:]): + selector.advance = int(next_selector.combinator != SAME) + + @property + def css(self) -> str: + return RuleSet._selector_to_css(self.selectors) + + def __rich_repr__(self) -> rich.repr.Result: + selectors = RuleSet._selector_to_css(self.selectors) + yield selectors + yield None, self.specificity + + @classmethod + def from_selectors(cls, selectors: list[list[Selector]]) -> Iterable[SelectorSet]: + for selector_list in selectors: + id_total = class_total = type_total = 0 + for selector in selector_list: + _id, _class, _type = selector.specificity + id_total += _id + class_total += _class + type_total += _type + yield SelectorSet(selector_list, (id_total, class_total, type_total)) + + +@dataclass +class RuleSet: + selector_set: list[SelectorSet] = field(default_factory=list) + styles: Styles = field(default_factory=Styles) + errors: list[tuple[Token, str]] = field(default_factory=list) + + is_default_rules: bool = False + tie_breaker: int = 0 + selector_names: set[str] = field(default_factory=set) + + def __hash__(self): + return id(self) + + @classmethod + def _selector_to_css(cls, selectors: list[Selector]) -> str: + tokens: list[str] = [] + for selector in selectors: + if selector.combinator == CombinatorType.DESCENDENT: + tokens.append(" ") + elif selector.combinator == CombinatorType.CHILD: + tokens.append(" > ") + tokens.append(selector.css) + return "".join(tokens).strip() + + @property + def selectors(self): + return ", ".join( + self._selector_to_css(selector_set.selectors) + for selector_set in self.selector_set + ) + + @property + def css(self) -> str: + """Generate the CSS this RuleSet + + Returns: + str: A string containing CSS code. + """ + declarations = "\n".join(f" {line}" for line in self.styles.css_lines) + css = f"{self.selectors} {{\n{declarations}\n}}" + return css + + def _post_parse(self) -> None: + """Called after the RuleSet is parsed.""" + # Build a set of the class names that have been updated + + class_type = SelectorType.CLASS + id_type = SelectorType.ID + type_type = SelectorType.TYPE + universal_type = SelectorType.UNIVERSAL + + update_selectors = self.selector_names.update + + for selector_set in self.selector_set: + update_selectors( + "*" + for selector in selector_set.selectors + if selector.type == universal_type + ) + update_selectors( + selector.name + for selector in selector_set.selectors + if selector.type == type_type + ) + update_selectors( + f".{selector.name}" + for selector in selector_set.selectors + if selector.type == class_type + ) + update_selectors( + f"#{selector.name}" + for selector in selector_set.selectors + if selector.type == id_type + ) + update_selectors( + f":{pseudo_class}" + for selector in selector_set.selectors + for pseudo_class in selector.pseudo_classes + ) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py new file mode 100644 index 000000000..fdfca677a --- /dev/null +++ b/src/textual/css/parse.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import PurePath +from typing import Iterator, Iterable, NoReturn + +from rich import print + +from .errors import UnresolvedVariableError +from .types import Specificity3 +from ._styles_builder import StylesBuilder, DeclarationError +from .model import ( + Declaration, + RuleSet, + Selector, + CombinatorType, + SelectorSet, + SelectorType, +) +from .styles import Styles +from ..suggestions import get_suggestion +from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values +from .tokenizer import EOFError, ReferencedBy + +SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = { + "selector": (SelectorType.TYPE, (0, 0, 1)), + "selector_start": (SelectorType.TYPE, (0, 0, 1)), + "selector_class": (SelectorType.CLASS, (0, 1, 0)), + "selector_start_class": (SelectorType.CLASS, (0, 1, 0)), + "selector_id": (SelectorType.ID, (1, 0, 0)), + "selector_start_id": (SelectorType.ID, (1, 0, 0)), + "selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)), + "selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)), +} + + +@lru_cache(maxsize=1024) +def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: + + if not css_selectors.strip(): + return () + + tokens = iter(tokenize(css_selectors, "")) + + get_selector = SELECTOR_MAP.get + combinator: CombinatorType | None = CombinatorType.DESCENDENT + selectors: list[Selector] = [] + rule_selectors: list[list[Selector]] = [] + + while True: + try: + token = next(tokens) + except EOFError: + break + token_name = token.name + if token_name == "pseudo_class": + selectors[-1]._add_pseudo_class(token.value.lstrip(":")) + elif token_name == "whitespace": + if combinator is None or combinator == CombinatorType.SAME: + combinator = CombinatorType.DESCENDENT + elif token_name == "new_selector": + rule_selectors.append(selectors[:]) + selectors.clear() + combinator = None + elif token_name == "declaration_set_start": + break + elif token_name == "combinator_child": + combinator = CombinatorType.CHILD + else: + _selector, specificity = get_selector( + token_name, (SelectorType.TYPE, (0, 0, 0)) + ) + selectors.append( + Selector( + name=token.value.lstrip(".#"), + combinator=combinator or CombinatorType.DESCENDENT, + type=_selector, + specificity=specificity, + ) + ) + combinator = CombinatorType.SAME + if selectors: + rule_selectors.append(selectors[:]) + + selector_set = tuple(SelectorSet.from_selectors(rule_selectors)) + return selector_set + + +def parse_rule_set( + tokens: Iterator[Token], + token: Token, + is_default_rules: bool = False, + tie_breaker: int = 0, +) -> Iterable[RuleSet]: + get_selector = SELECTOR_MAP.get + combinator: CombinatorType | None = CombinatorType.DESCENDENT + selectors: list[Selector] = [] + rule_selectors: list[list[Selector]] = [] + styles_builder = StylesBuilder() + + while True: + if token.name == "pseudo_class": + selectors[-1]._add_pseudo_class(token.value.lstrip(":")) + elif token.name == "whitespace": + if combinator is None or combinator == CombinatorType.SAME: + combinator = CombinatorType.DESCENDENT + elif token.name == "new_selector": + rule_selectors.append(selectors[:]) + selectors.clear() + combinator = None + elif token.name == "declaration_set_start": + break + elif token.name == "combinator_child": + combinator = CombinatorType.CHILD + else: + _selector, specificity = get_selector( + token.name, (SelectorType.TYPE, (0, 0, 0)) + ) + selectors.append( + Selector( + name=token.value.lstrip(".#"), + combinator=combinator or CombinatorType.DESCENDENT, + type=_selector, + specificity=specificity, + ) + ) + combinator = CombinatorType.SAME + + token = next(tokens) + + if selectors: + rule_selectors.append(selectors[:]) + + declaration = Declaration(token, "") + + errors: list[tuple[Token, str]] = [] + + while True: + token = next(tokens) + token_name = token.name + if token_name in ("whitespace", "declaration_end"): + continue + if token_name == "declaration_name": + if declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + declaration = Declaration(token, "") + declaration.name = token.value.rstrip(":") + elif token_name == "declaration_set_end": + break + else: + declaration.tokens.append(token) + + if declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + + rule_set = RuleSet( + list(SelectorSet.from_selectors(rule_selectors)), + styles_builder.styles, + errors, + is_default_rules=is_default_rules, + tie_breaker=tie_breaker, + ) + rule_set._post_parse() + yield rule_set + + +def parse_declarations(css: str, path: str) -> Styles: + """Parse declarations and return a Styles object. + + Args: + css (str): String containing CSS. + path (str): Path to the CSS, or something else to identify the location. + + Returns: + Styles: A styles object. + """ + + tokens = iter(tokenize_declarations(css, path)) + styles_builder = StylesBuilder() + + declaration: Declaration | None = None + errors: list[tuple[Token, str]] = [] + + while True: + token = next(tokens, None) + if token is None: + break + token_name = token.name + if token_name in ("whitespace", "declaration_end", "eof"): + continue + if token_name == "declaration_name": + if declaration and declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + raise + declaration = Declaration(token, "") + declaration.name = token.value.rstrip(":") + elif token_name == "declaration_set_end": + break + else: + if declaration: + declaration.tokens.append(token) + + if declaration and declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + raise + + return styles_builder.styles + + +def _unresolved(variable_name: str, variables: Iterable[str], token: Token) -> NoReturn: + """Raise a TokenError regarding an unresolved variable. + + Args: + variable_name (str): A variable name. + variables (Iterable[str]): Possible choices used to generate suggestion. + token (Token): The Token. + + Raises: + UnresolvedVariableError: Always raises a TokenError. + + """ + message = f"reference to undefined variable '${variable_name}'" + suggested_variable = get_suggestion(variable_name, list(variables)) + if suggested_variable: + message += f"; did you mean '${suggested_variable}'?" + + raise UnresolvedVariableError( + token.path, + token.code, + token.start, + message, + end=token.end, + ) + + +def substitute_references( + tokens: Iterable[Token], css_variables: dict[str, list[Token]] | None = None +) -> Iterable[Token]: + """Replace variable references with values by substituting variable reference + tokens with the tokens representing their values. + + Args: + tokens (Iterable[Token]): Iterator of Tokens which may contain tokens + with the name "variable_ref". + + Returns: + Iterable[Token]: Yields Tokens such that any variable references (tokens where + token.name == "variable_ref") have been replaced with the tokens representing + the value. In other words, an Iterable of Tokens similar to the original input, + but with variables resolved. Substituted tokens will have their referenced_by + attribute populated with information about where the tokens are being substituted to. + """ + variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {} + + iter_tokens = iter(tokens) + + while tokens: + token = next(iter_tokens, None) + if token is None: + break + if token.name == "variable_name": + variable_name = token.value[1:-1] # Trim the $ and the :, i.e. "$x:" -> "x" + yield token + + while True: + token = next(iter_tokens, None) + # TODO: Mypy error looks legit + if token.name == "whitespace": + yield token + else: + break + + # Store the tokens for any variable definitions, and substitute + # any variable references we encounter with them. + while True: + if not token: + break + elif token.name == "whitespace": + variables.setdefault(variable_name, []).append(token) + yield token + elif token.name == "variable_value_end": + yield token + break + # For variables referring to other variables + elif token.name == "variable_ref": + ref_name = token.value[1:] + if ref_name in variables: + variable_tokens = variables.setdefault(variable_name, []) + reference_tokens = variables[ref_name] + variable_tokens.extend(reference_tokens) + ref_location = token.location + ref_length = len(token.value) + for _token in reference_tokens: + yield _token.with_reference( + ReferencedBy( + ref_name, ref_location, ref_length, token.code + ) + ) + else: + _unresolved(ref_name, variables.keys(), token) + else: + variables.setdefault(variable_name, []).append(token) + yield token + token = next(iter_tokens, None) + elif token.name == "variable_ref": + variable_name = token.value[1:] # Trim the $, so $x -> x + if variable_name in variables: + variable_tokens = variables[variable_name] + ref_location = token.location + ref_length = len(token.value) + ref_code = token.code + for _token in variable_tokens: + yield _token.with_reference( + ReferencedBy(variable_name, ref_location, ref_length, ref_code) + ) + else: + _unresolved(variable_name, variables.keys(), token) + else: + yield token + + +def parse( + css: str, + path: str | PurePath, + variables: dict[str, str] | None = None, + variable_tokens: dict[str, list[Token]] | None = None, + is_default_rules: bool = False, + tie_breaker: int = 0, +) -> Iterable[RuleSet]: + """Parse CSS by tokenizing it, performing variable substitution, + and generating rule sets from it. + + Args: + css (str): The input CSS + path (str): Path to the CSS + variables (dict[str, str]): Substitution variables to substitute tokens for. + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. + """ + + reference_tokens = tokenize_values(variables) if variables is not None else {} + if variable_tokens: + reference_tokens.update(variable_tokens) + + tokens = iter(substitute_references(tokenize(css, path), variable_tokens)) + while True: + token = next(tokens, None) + if token is None: + break + if token.name.startswith("selector_start"): + yield from parse_rule_set( + tokens, + token, + is_default_rules=is_default_rules, + tie_breaker=tie_breaker, + ) + + +if __name__ == "__main__": + print(parse_selectors("Foo > Bar.baz { foo: bar")) + + css = """#something { + text: on red; + transition: offset 5.51s in_out_cubic; + offset-x: 100%; +} +""" + + from textual.css.stylesheet import Stylesheet, StylesheetParseError + from rich.console import Console + + console = Console() + stylesheet = Stylesheet() + try: + stylesheet.add_source(css) + except StylesheetParseError as e: + console.print(e.errors) + print(stylesheet) + print(stylesheet.css) diff --git a/src/textual/css/query.py b/src/textual/css/query.py new file mode 100644 index 000000000..93addb6fb --- /dev/null +++ b/src/textual/css/query.py @@ -0,0 +1,334 @@ +""" +A DOMQuery is a set of DOM nodes associated with a given CSS selector. + +This set of nodes may be further filtered with the filter method. Additional methods apply +actions to the nodes in the query. + +If this sounds like JQuery, a (once) popular JS library, it is no coincidence. + +DOMQuery objects are typically created by Widget.query method. + +Queries are *lazy*. Results will be calculated at the point you iterate over the query, or call +a method which evaluates the query, such as first() and last(). + +""" + + +from __future__ import annotations + +from typing import cast, Generic, TYPE_CHECKING, Iterator, TypeVar, overload + +import rich.repr + +from .errors import DeclarationError +from .match import match +from .model import SelectorSet +from .parse import parse_declarations, parse_selectors + +if TYPE_CHECKING: + from ..dom import DOMNode + from ..widget import Widget + + +class QueryError(Exception): + """Base class for a query related error.""" + + +class NoMatches(QueryError): + """No nodes matched the query.""" + + +class WrongType(QueryError): + """Query result was not of the correct type.""" + + +QueryType = TypeVar("QueryType", bound="Widget") + + +@rich.repr.auto(angular=True) +class DOMQuery(Generic[QueryType]): + __slots__ = [ + "_node", + "_nodes", + "_filters", + "_excludes", + ] + + def __init__( + self, + node: DOMNode, + *, + filter: str | None = None, + exclude: str | None = None, + parent: DOMQuery | None = None, + ) -> None: + + self._node = node + self._nodes: list[QueryType] | None = None + self._filters: list[tuple[SelectorSet, ...]] = ( + parent._filters.copy() if parent else [] + ) + self._excludes: list[tuple[SelectorSet, ...]] = ( + parent._excludes.copy() if parent else [] + ) + if filter is not None: + self._filters.append(parse_selectors(filter)) + if exclude is not None: + self._excludes.append(parse_selectors(exclude)) + + @property + def node(self) -> DOMNode: + return self._node + + @property + def nodes(self) -> list[QueryType]: + """Lazily evaluate nodes.""" + from ..widget import Widget + + if self._nodes is None: + nodes = [ + node + for node in self._node.walk_children(Widget) + if all(match(selector_set, node) for selector_set in self._filters) + ] + nodes = [ + node + for node in nodes + if not any(match(selector_set, node) for selector_set in self._excludes) + ] + self._nodes = cast("list[QueryType]", nodes) + return self._nodes + + def __len__(self) -> int: + return len(self.nodes) + + def __bool__(self) -> bool: + """True if non-empty, otherwise False.""" + return bool(self.nodes) + + def __iter__(self) -> Iterator[QueryType]: + return iter(self.nodes) + + def __reversed__(self) -> Iterator[QueryType]: + return reversed(self.nodes) + + @overload + def __getitem__(self, index: int) -> QueryType: + ... + + @overload + def __getitem__(self, index: slice) -> list[QueryType]: + ... + + def __getitem__(self, index: int | slice) -> QueryType | list[QueryType]: + return self.nodes[index] + + def __rich_repr__(self) -> rich.repr.Result: + yield self.node + if self._filters: + yield "filter", " AND ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._filters + ) + if self._excludes: + yield "exclude", " OR ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._excludes + ) + + def filter(self, selector: str) -> DOMQuery[QueryType]: + """Filter this set by the given CSS selector. + + Args: + selector (str): A CSS selector. + + Returns: + DOMQuery: New DOM Query. + """ + + return DOMQuery(self.node, filter=selector, parent=self) + + def exclude(self, selector: str) -> DOMQuery[QueryType]: + """Exclude nodes that match a given selector. + + Args: + selector (str): A CSS selector. + + Returns: + DOMQuery: New DOM query. + """ + return DOMQuery(self.node, exclude=selector, parent=self) + + ExpectType = TypeVar("ExpectType") + + @overload + def first(self) -> Widget: + ... + + @overload + def first(self, expect_type: type[ExpectType]) -> ExpectType: + ... + + def first( + self, expect_type: type[ExpectType] | None = None + ) -> QueryType | ExpectType: + """Get the *first* matching node. + + Args: + expect_type (type[ExpectType] | None, optional): Require matched node is of this type, + or None for any type. Defaults to None. + + Raises: + WrongType: If the wrong type was found. + NoMatches: If there are no matching nodes in the query. + + Returns: + Widget | ExpectType: The matching Widget. + """ + if self.nodes: + first = self.nodes[0] + if expect_type is not None: + if not isinstance(first, expect_type): + raise WrongType( + f"Query value is wrong type; expected {expect_type}, got {type(first)}" + ) + return first + else: + raise NoMatches(f"No nodes match {self!r}") + + @overload + def last(self) -> Widget: + ... + + @overload + def last(self, expect_type: type[ExpectType]) -> ExpectType: + ... + + def last( + self, expect_type: type[ExpectType] | None = None + ) -> QueryType | ExpectType: + """Get the *last* matching node. + + Args: + expect_type (type[ExpectType] | None, optional): Require matched node is of this type, + or None for any type. Defaults to None. + + Raises: + WrongType: If the wrong type was found. + NoMatches: If there are no matching nodes in the query. + + Returns: + Widget | ExpectType: The matching Widget. + """ + if self.nodes: + last = self.nodes[-1] + if expect_type is not None: + if not isinstance(last, expect_type): + raise WrongType( + f"Query value is wrong type; expected {expect_type}, got {type(last)}" + ) + return last + else: + raise NoMatches(f"No nodes match {self!r}") + + @overload + def results(self) -> Iterator[Widget]: + ... + + @overload + def results(self, filter_type: type[ExpectType]) -> Iterator[ExpectType]: + ... + + def results( + self, filter_type: type[ExpectType] | None = None + ) -> Iterator[Widget | ExpectType]: + """Get query results, optionally filtered by a given type. + + Args: + filter_type (type[ExpectType] | None): A Widget class to filter results, + or None for no filter. Defaults to None. + + Yields: + Iterator[Widget | ExpectType]: An iterator of Widget instances. + """ + if filter_type is None: + yield from self + else: + for node in self: + if isinstance(node, filter_type): + yield node + + def set_class(self, add: bool, *class_names: str) -> DOMQuery[QueryType]: + """Set the given class name(s) according to a condition. + + Args: + add (bool): Add the classes if True, otherwise remove them. + + Returns: + DOMQuery: Self. + """ + for node in self: + node.set_class(add, *class_names) + return self + + def add_class(self, *class_names: str) -> DOMQuery[QueryType]: + """Add the given class name(s) to nodes.""" + for node in self: + node.add_class(*class_names) + return self + + def remove_class(self, *class_names: str) -> DOMQuery[QueryType]: + """Remove the given class names from the nodes.""" + for node in self: + node.remove_class(*class_names) + return self + + def toggle_class(self, *class_names: str) -> DOMQuery[QueryType]: + """Toggle the given class names from matched nodes.""" + for node in self: + node.toggle_class(*class_names) + return self + + def remove(self) -> DOMQuery[QueryType]: + """Remove matched nodes from the DOM""" + for node in self: + node.remove() + return self + + def set_styles( + self, css: str | None = None, **update_styles + ) -> DOMQuery[QueryType]: + """Set styles on matched nodes. + + Args: + css (str, optional): CSS declarations to parser, or None. Defaults to None. + """ + _rich_traceback_omit = True + + for node in self: + node.set_styles(**update_styles) + if css is not None: + try: + new_styles = parse_declarations(css, path="set_styles") + except DeclarationError as error: + raise DeclarationError(error.name, error.token, error.message) from None + for node in self: + node._inline_styles.merge(new_styles) + node.refresh(layout=True) + return self + + def refresh( + self, *, repaint: bool = True, layout: bool = False + ) -> DOMQuery[QueryType]: + """Refresh matched nodes. + + Args: + repaint (bool): Repaint node(s). defaults to True. + layout (bool): Layout node(s). Defaults to False. + + Returns: + DOMQuery: Query for chaining. + """ + for node in self: + node.refresh(repaint=repaint, layout=layout) + return self diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py new file mode 100644 index 000000000..bb4368514 --- /dev/null +++ b/src/textual/css/scalar.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +from enum import Enum, unique +from fractions import Fraction +from functools import lru_cache +import re +from typing import Iterable, NamedTuple + +import rich.repr + +from ..geometry import Offset, Size, clamp + + +class ScalarError(Exception): + pass + + +class ScalarResolveError(ScalarError): + pass + + +class ScalarParseError(ScalarError): + pass + + +@unique +class Unit(Enum): + """Enumeration of the various units inherited from CSS.""" + + CELLS = 1 + FRACTION = 2 + PERCENT = 3 + WIDTH = 4 + HEIGHT = 5 + VIEW_WIDTH = 6 + VIEW_HEIGHT = 7 + AUTO = 8 + + +UNIT_EXCLUDES_BORDER = {Unit.CELLS, Unit.FRACTION, Unit.VIEW_WIDTH, Unit.VIEW_HEIGHT} + +UNIT_SYMBOL = { + Unit.CELLS: "", + Unit.FRACTION: "fr", + Unit.PERCENT: "%", + Unit.WIDTH: "w", + Unit.HEIGHT: "h", + Unit.VIEW_WIDTH: "vw", + Unit.VIEW_HEIGHT: "vh", +} + +SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()} + +_MATCH_SCALAR = re.compile(r"^(-?\d+\.?\d*)(fr|%|w|h|vw|vh)?$").match + + +def _resolve_cells( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves explicit cell size, i.e. width: 10 + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return Fraction(value) + + +def _resolve_fraction( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves a fraction unit i.e. width: 2fr + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return fraction_unit * Fraction(value) + + +def _resolve_width( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves width unit i.e. width: 50w. + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return Fraction(value) * Fraction(size.width, 100) + + +def _resolve_height( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves height unit, i.e. height: 12h. + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return Fraction(value) * Fraction(size.height, 100) + + +def _resolve_view_width( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves view width unit, i.e. width: 25vw. + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return Fraction(value) * Fraction(viewport.width, 100) + + +def _resolve_view_height( + value: float, size: Size, viewport: Size, fraction_unit: Fraction +) -> Fraction: + """Resolves view height unit, i.e. height: 25vh. + + Args: + value (float): Scalar value. + size (Size): Size of widget. + viewport (Size): Size of viewport. + fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction. + + Returns: + Fraction: Resolved unit. + """ + return Fraction(value) * Fraction(viewport.height, 100) + + +RESOLVE_MAP = { + Unit.CELLS: _resolve_cells, + Unit.FRACTION: _resolve_fraction, + Unit.WIDTH: _resolve_width, + Unit.HEIGHT: _resolve_height, + Unit.VIEW_WIDTH: _resolve_view_width, + Unit.VIEW_HEIGHT: _resolve_view_height, +} + + +def get_symbols(units: Iterable[Unit]) -> list[str]: + """Get symbols for an iterable of units. + + Args: + units (Iterable[Unit]): A number of units. + + Returns: + list[str]: List of symbols. + """ + return [UNIT_SYMBOL[unit] for unit in units] + + +class Scalar(NamedTuple): + """A numeric value and a unit.""" + + value: float + unit: Unit + percent_unit: Unit + + def __str__(self) -> str: + value, unit, _ = self + if unit == Unit.AUTO: + 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.""" + return self.unit == Unit.PERCENT + + @property + def is_fraction(self) -> bool: + """Check if the unit is a fraction.""" + return self.unit == Unit.FRACTION + + @property + def excludes_border(self) -> bool: + return self.unit in UNIT_EXCLUDES_BORDER + + @property + def cells(self) -> int | None: + """Check if the unit is explicit cells.""" + value, unit, _ = self + return int(value) if unit == Unit.CELLS else None + + @property + def fraction(self) -> int | None: + """Get the fraction value, or None if not a value.""" + value, unit, _ = self + return int(value) if unit == Unit.FRACTION else None + + @property + def symbol(self) -> str: + """Get the symbol of this unit.""" + return UNIT_SYMBOL[self.unit] + + @property + def is_auto(self) -> bool: + """Check if this is an auto unit.""" + return self.unit == Unit.AUTO + + @classmethod + def from_number(cls, value: float) -> Scalar: + """Create a scalar with cells unit. + + Args: + value (float): A number of cells. + + Returns: + Scalar: New Scalar. + """ + return cls(float(value), Unit.CELLS, Unit.WIDTH) + + @classmethod + def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar: + """Parse a string in to a Scalar + + Args: + token (str): A string containing a scalar, e.g. "3.14fr" + + Raises: + ScalarParseError: If the value is not a valid scalar + + Returns: + Scalar: New scalar + """ + if token.lower() == "auto": + scalar = cls(1.0, Unit.AUTO, Unit.AUTO) + else: + match = _MATCH_SCALAR(token) + if match is None: + raise ScalarParseError(f"{token!r} is not a valid scalar") + value, unit_name = match.groups() + scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) + return scalar + + @lru_cache(maxsize=4096) + def resolve_dimension( + self, size: Size, viewport: Size, fraction_unit: Fraction | None = None + ) -> Fraction: + """Resolve scalar with units in to a dimensions. + + Args: + size (tuple[int, int]): Size of the container. + viewport (tuple[int, int]): Size of the viewport (typically terminal size) + + Raises: + ScalarResolveError: If the unit is unknown. + + Returns: + int: A size (in cells) + """ + value, unit, percent_unit = self + + if unit == Unit.PERCENT: + unit = percent_unit + try: + dimension = RESOLVE_MAP[unit]( + value, size, viewport, fraction_unit or Fraction(1) + ) + except KeyError: + raise ScalarResolveError(f"expected dimensions; found {str(self)!r}") + return dimension + + def copy_with( + self, + value: float | None = None, + unit: Unit | None = None, + percent_unit: Unit | None = None, + ) -> Scalar: + """Get a copy of this Scalar, with values optionally modified + + Args: + value (float | None): The new value, or None to keep the same value + unit (Unit | None): The new unit, or None to keep the same unit + percent_unit (Unit | None): The new percent_unit, or None to keep the same percent_unit + """ + return Scalar( + value if value is not None else self.value, + unit if unit is not None else self.unit, + percent_unit if percent_unit is not None else self.percent_unit, + ) + + +@rich.repr.auto(angular=True) +class ScalarOffset(NamedTuple): + """An Offset with two scalars, used to animate between to Scalars.""" + + x: Scalar + y: Scalar + + @classmethod + def null(cls) -> ScalarOffset: + """Get a null scalar offset (0, 0).""" + return NULL_SCALAR + + @classmethod + def from_offset(cls, offset: tuple[int, int]) -> ScalarOffset: + """Create a Scalar offset from a tuple of integers. + + Args: + offset (tuple[int, int]): Offset in cells. + + Returns: + ScalarOffset: New offset. + """ + x, y = offset + return cls( + Scalar(x, Unit.CELLS, Unit.WIDTH), + Scalar(y, Unit.CELLS, Unit.HEIGHT), + ) + + def __bool__(self) -> bool: + x, y = self + return bool(x.value or y.value) + + def __rich_repr__(self) -> rich.repr.Result: + yield None, str(self.x) + yield None, str(self.y) + + def resolve(self, size: Size, viewport: Size) -> Offset: + """Resolve the offset in to cells. + + Args: + size (Size): Size of container. + viewport (Size): Size of viewport. + + Returns: + Offset: Offset in cells. + """ + x, y = self + return Offset( + round(x.resolve_dimension(size, viewport)), + round(y.resolve_dimension(size, viewport)), + ) + + +NULL_SCALAR = ScalarOffset(Scalar.from_number(0), Scalar.from_number(0)) + + +def percentage_string_to_float(string: str) -> float: + """Convert a string percentage e.g. '20%' to a float e.g. 20.0. + + Args: + string (str): The percentage string to convert. + """ + string = string.strip() + if string.endswith("%"): + float_percentage = clamp(float(string[:-1]) / 100.0, 0.0, 1.0) + else: + float_percentage = float(string) + return float_percentage + + +if __name__ == "__main__": + print(Scalar.parse("3.14fr")) + s = Scalar.parse("23") + print(repr(s)) + print(repr(s.cells)) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py new file mode 100644 index 000000000..697f94405 --- /dev/null +++ b/src/textual/css/scalar_animation.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .scalar import ScalarOffset +from .._animator import Animation +from .._animator import EasingFunction +from .._types import CallbackType +from ..geometry import Offset + +if TYPE_CHECKING: + from ..widget import Widget + from .styles import Styles + + +class ScalarAnimation(Animation): + def __init__( + self, + widget: Widget, + styles: Styles, + start_time: float, + attribute: str, + value: ScalarOffset, + duration: float | None, + speed: float | None, + easing: EasingFunction, + on_complete: CallbackType | None = None, + ): + assert ( + speed is not None or duration is not None + ), "One of speed or duration required" + self.widget = widget + self.styles = styles + self.start_time = start_time + self.attribute = attribute + self.final_value = value + self.easing = easing + self.on_complete = on_complete + + size = widget.outer_size + viewport = widget.app.size + + self.start: Offset = getattr(styles, attribute).resolve(size, viewport) + self.destination: Offset = value.resolve(size, viewport) + + if speed is not None: + distance = self.start.get_distance_to(self.destination) + self.duration = distance / speed + else: + assert duration is not None, "Duration expected to be non-None" + self.duration = duration + + def __call__(self, time: float) -> bool: + factor = min(1.0, (time - self.start_time) / self.duration) + eased_factor = self.easing(factor) + + if eased_factor >= 1: + setattr(self.styles, self.attribute, self.final_value) + return True + + if hasattr(self.start, "blend"): + value = self.start.blend(self.destination, eased_factor) + else: + value = self.start + (self.destination - self.start) * eased_factor + current = self.styles._rules.get(self.attribute) + if current != value: + setattr(self.styles, f"{self.attribute}", value) + + return False + + def __eq__(self, other: object) -> bool: + if isinstance(other, ScalarAnimation): + return ( + self.final_value == other.final_value + and self.duration == other.duration + ) + return False diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py new file mode 100644 index 000000000..d830b6774 --- /dev/null +++ b/src/textual/css/styles.py @@ -0,0 +1,1053 @@ +from __future__ import annotations + +import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from functools import lru_cache +from operator import attrgetter +from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast + +import rich.repr +from rich.style import Style + +from .._types import CallbackType +from .._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction +from ..color import Color +from ..geometry import Offset, Spacing +from ._style_properties import ( + AlignProperty, + BooleanProperty, + BorderProperty, + BoxProperty, + ColorProperty, + DockProperty, + FractionalProperty, + IntegerProperty, + LayoutProperty, + NameListProperty, + NameProperty, + OffsetProperty, + ScalarListProperty, + ScalarProperty, + SpacingProperty, + StringEnumProperty, + StyleFlagsProperty, + TransitionsProperty, +) +from .constants import ( + VALID_ALIGN_HORIZONTAL, + VALID_ALIGN_VERTICAL, + VALID_BOX_SIZING, + VALID_DISPLAY, + VALID_OVERFLOW, + VALID_SCROLLBAR_GUTTER, + VALID_VISIBILITY, + VALID_TEXT_ALIGN, +) +from .scalar import Scalar, ScalarOffset, Unit +from .scalar_animation import ScalarAnimation +from .transition import Transition +from .types import ( + AlignHorizontal, + AlignVertical, + BoxSizing, + Display, + Edge, + Overflow, + ScrollbarGutter, + Specificity3, + Specificity6, + Visibility, + TextAlign, +) + +if sys.version_info >= (3, 8): + from typing import TypedDict +else: + from typing_extensions import TypedDict + +if TYPE_CHECKING: + from .._layout import Layout + from ..dom import DOMNode + + +class RulesMap(TypedDict, total=False): + """A typed dict for CSS rules. + + Any key may be absent, indicating that rule has not been set. + + Does not define composite rules, that is a rule that is made of a combination of other rules. + """ + + display: Display + visibility: Visibility + layout: "Layout" + + auto_color: bool + color: Color + background: Color + text_style: Style + + opacity: float + text_opacity: float + + padding: Spacing + margin: Spacing + offset: ScalarOffset + + border_top: tuple[str, Color] + border_right: tuple[str, Color] + border_bottom: tuple[str, Color] + border_left: tuple[str, Color] + + outline_top: tuple[str, Color] + outline_right: tuple[str, Color] + outline_bottom: tuple[str, Color] + outline_left: tuple[str, Color] + + box_sizing: BoxSizing + width: Scalar + height: Scalar + min_width: Scalar + min_height: Scalar + max_width: Scalar + max_height: Scalar + + dock: str + + overflow_x: Overflow + overflow_y: Overflow + + layers: tuple[str, ...] + layer: str + + transitions: dict[str, Transition] + + tint: Color + + scrollbar_color: Color + scrollbar_color_hover: Color + scrollbar_color_active: Color + + scrollbar_corner_color: Color + + scrollbar_background: Color + scrollbar_background_hover: Color + scrollbar_background_active: Color + + scrollbar_gutter: ScrollbarGutter + + scrollbar_size_vertical: int + scrollbar_size_horizontal: int + + align_horizontal: AlignHorizontal + align_vertical: AlignVertical + + content_align_horizontal: AlignHorizontal + content_align_vertical: AlignVertical + + grid_size_rows: int + grid_size_columns: int + grid_gutter_horizontal: int + grid_gutter_vertical: int + grid_rows: tuple[Scalar, ...] + grid_columns: tuple[Scalar, ...] + + row_span: int + column_span: int + + text_align: TextAlign + + link_color: Color + auto_link_color: bool + link_background: Color + link_style: Style + + link_hover_color: Color + auto_link_hover_color: bool + link_hover_background: Color + link_hover_style: Style + + +RULE_NAMES = list(RulesMap.__annotations__.keys()) +RULE_NAMES_SET = frozenset(RULE_NAMES) +_rule_getter = attrgetter(*RULE_NAMES) + + +class DockGroup(NamedTuple): + name: str + edge: Edge + z: int + + +class StylesBase(ABC): + """A common base class for Styles and RenderStyles""" + + ANIMATABLE = { + "offset", + "padding", + "margin", + "width", + "height", + "min_width", + "min_height", + "max_width", + "max_height", + "auto_color", + "color", + "background", + "opacity", + "text_opacity", + "tint", + "scrollbar_color", + "scrollbar_color_hover", + "scrollbar_color_active", + "scrollbar_background", + "scrollbar_background_hover", + "scrollbar_background_active", + "link_color", + "link_background", + "link_hover_color", + "link_hover_background", + } + + node: DOMNode | None = None + + display = StringEnumProperty(VALID_DISPLAY, "block", layout=True) + visibility = StringEnumProperty(VALID_VISIBILITY, "visible") + layout = LayoutProperty() + + auto_color = BooleanProperty(default=False) + color = ColorProperty(Color(255, 255, 255)) + background = ColorProperty(Color(0, 0, 0, 0), background=True) + text_style = StyleFlagsProperty() + + opacity = FractionalProperty() + text_opacity = FractionalProperty() + + padding = SpacingProperty() + margin = SpacingProperty() + offset = OffsetProperty() + + border = BorderProperty(layout=True) + border_top = BoxProperty(Color(0, 255, 0)) + border_right = BoxProperty(Color(0, 255, 0)) + border_bottom = BoxProperty(Color(0, 255, 0)) + border_left = BoxProperty(Color(0, 255, 0)) + + outline = BorderProperty(layout=False) + outline_top = BoxProperty(Color(0, 255, 0)) + outline_right = BoxProperty(Color(0, 255, 0)) + outline_bottom = BoxProperty(Color(0, 255, 0)) + outline_left = BoxProperty(Color(0, 255, 0)) + + box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box", layout=True) + width = ScalarProperty(percent_unit=Unit.WIDTH) + height = ScalarProperty(percent_unit=Unit.HEIGHT) + min_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) + min_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) + max_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) + max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) + + dock = DockProperty() + + overflow_x = StringEnumProperty(VALID_OVERFLOW, "hidden") + overflow_y = StringEnumProperty(VALID_OVERFLOW, "hidden") + + layer = NameProperty() + layers = NameListProperty() + transitions = TransitionsProperty() + + tint = ColorProperty("transparent") + scrollbar_color = ColorProperty("ansi_bright_magenta") + scrollbar_color_hover = ColorProperty("ansi_yellow") + scrollbar_color_active = ColorProperty("ansi_bright_yellow") + + scrollbar_corner_color = ColorProperty("#666666") + + scrollbar_background = ColorProperty("#555555") + scrollbar_background_hover = ColorProperty("#444444") + scrollbar_background_active = ColorProperty("black") + + scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto") + + scrollbar_size_vertical = IntegerProperty(default=1, layout=True) + scrollbar_size_horizontal = IntegerProperty(default=1, layout=True) + + align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") + align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + align = AlignProperty() + + content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") + content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") + content_align = AlignProperty() + + grid_rows = ScalarListProperty() + grid_columns = ScalarListProperty() + + grid_size_columns = IntegerProperty(default=1, layout=True) + grid_size_rows = IntegerProperty(default=0, layout=True) + grid_gutter_horizontal = IntegerProperty(default=0, layout=True) + grid_gutter_vertical = IntegerProperty(default=0, layout=True) + + row_span = IntegerProperty(default=1, layout=True) + column_span = IntegerProperty(default=1, layout=True) + + text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start") + + link_color = ColorProperty("transparent") + auto_link_color = BooleanProperty(False) + link_background = ColorProperty("transparent") + link_style = StyleFlagsProperty() + + link_hover_color = ColorProperty("transparent") + auto_link_hover_color = BooleanProperty(False) + link_hover_background = ColorProperty("transparent") + link_hover_style = StyleFlagsProperty() + + def __eq__(self, styles: object) -> bool: + """Check that Styles contains the same rules.""" + if not isinstance(styles, StylesBase): + return NotImplemented + return self.get_rules() == styles.get_rules() + + @property + def gutter(self) -> Spacing: + """Get space around widget. + + Returns: + Spacing: Space around widget content. + """ + spacing = self.padding + self.border.spacing + return spacing + + @property + def auto_dimensions(self) -> bool: + """Check if width or height are set to 'auto'.""" + has_rule = self.has_rule + return (has_rule("width") and self.width.is_auto) or ( + has_rule("height") and self.height.is_auto + ) + + @abstractmethod + def has_rule(self, rule: str) -> bool: + """Check if a rule is set on this Styles object. + + Args: + rule (str): Rule name. + + Returns: + bool: ``True`` if the rules is present, otherwise ``False``. + """ + + @abstractmethod + def clear_rule(self, rule: str) -> bool: + """Removes the rule from the Styles object, as if it had never been set. + + Args: + rule (str): Rule name. + + Returns: + bool: ``True`` if a rule was cleared, or ``False`` if the rule is already not set. + """ + + @abstractmethod + def get_rules(self) -> RulesMap: + """Get the rules in a mapping. + + Returns: + RulesMap: A TypedDict of the rules. + """ + + @abstractmethod + def set_rule(self, rule: str, value: object | None) -> bool: + """Set a rule. + + Args: + rule (str): Rule name. + value (object | None): New rule value. + + Returns: + bool: ``True`` if the rule changed, otherwise ``False``. + """ + + @abstractmethod + def get_rule(self, rule: str, default: object = None) -> object: + """Get an individual rule. + + Args: + rule (str): Name of rule. + default (object, optional): Default if rule does not exists. Defaults to None. + + Returns: + object: Rule value or default. + """ + + @abstractmethod + def refresh(self, *, layout: bool = False, children: bool = False) -> None: + """Mark the styles as requiring a refresh. + + Args: + layout (bool, optional): Also require a layout. Defaults to False. + children (bool, opional): Also refresh children. Defaults to False. + """ + + @abstractmethod + def reset(self) -> None: + """Reset the rules to initial state.""" + + @abstractmethod + def merge(self, other: StylesBase) -> None: + """Merge values from another Styles. + + Args: + other (Styles): A Styles object. + """ + + @abstractmethod + def merge_rules(self, rules: RulesMap) -> None: + """Merge rules in to Styles. + + Args: + rules (RulesMap): A mapping of rules. + """ + + def get_render_rules(self) -> RulesMap: + """Get rules map with defaults.""" + # Get a dictionary of rules, going through the properties + rules = dict(zip(RULE_NAMES, _rule_getter(self))) + return cast(RulesMap, rules) + + @classmethod + def is_animatable(cls, rule: str) -> bool: + """Check if a given rule may be animated. + + Args: + rule (str): Name of the rule. + + Returns: + bool: ``True`` if the rule may be animated, otherwise ``False``. + """ + return rule in cls.ANIMATABLE + + @classmethod + @lru_cache(maxsize=1024) + def parse(cls, css: str, path: str, *, node: DOMNode = None) -> Styles: + """Parse CSS and return a Styles object. + + Args: + css (str): Textual CSS. + path (str): Path or string indicating source of CSS. + node (DOMNode, optional): Node to associate with the Styles. Defaults to None. + + Returns: + Styles: A Styles instance containing result of parsing CSS. + """ + from .parse import parse_declarations + + styles = parse_declarations(css, path) + styles.node = node + return styles + + def _get_transition(self, key: str) -> Transition | None: + """Get a transition. + + Args: + key (str): Transition key. + + Returns: + Transition | None: Transition object or None it no transition exists. + """ + if key in self.ANIMATABLE: + return self.transitions.get(key, None) + else: + return None + + def _align_width(self, width: int, parent_width: int) -> int: + """Align the width dimension. + + Args: + width (int): Width of the content. + parent_width (int): Width of the parent container. + + Returns: + int: An offset to add to the X coordinate. + """ + offset_x = 0 + align_horizontal = self.align_horizontal + if align_horizontal != "left": + if align_horizontal == "center": + offset_x = (parent_width - width) // 2 + else: + offset_x = parent_width - width + return offset_x + + def _align_height(self, height: int, parent_height: int) -> int: + """Align the height dimensions + + Args: + height (int): Height of the content. + parent_height (int): Height of the parent container. + + Returns: + int: An offset to add to the Y coordinate. + """ + offset_y = 0 + align_vertical = self.align_vertical + if align_vertical != "top": + if align_vertical == "middle": + offset_y = (parent_height - height) // 2 + else: + offset_y = parent_height - height + return offset_y + + def _align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: + """Align a size according to alignment rules. + + Args: + child (tuple[int, int]): The size of the child (width, height) + parent (tuple[int, int]): The size of the parent (width, height) + + Returns: + Offset: Offset required to align the child. + """ + width, height = child + parent_width, parent_height = parent + return Offset( + self._align_width(width, parent_width), + self._align_height(height, parent_height), + ) + + +@rich.repr.auto +@dataclass +class Styles(StylesBase): + node: DOMNode | None = None + _rules: RulesMap = field(default_factory=dict) + + important: set[str] = field(default_factory=set) + + def copy(self) -> Styles: + """Get a copy of this Styles object.""" + return Styles(node=self.node, _rules=self.get_rules(), important=self.important) + + def has_rule(self, rule: str) -> bool: + assert rule in RULE_NAMES_SET, f"no such rule {rule!r}" + return rule in self._rules + + def clear_rule(self, rule: str) -> bool: + """Removes the rule from the Styles object, as if it had never been set. + + Args: + rule (str): Rule name. + + Returns: + bool: ``True`` if a rule was cleared, or ``False`` if it was already not set. + """ + return self._rules.pop(rule, None) is not None + + def get_rules(self) -> RulesMap: + return self._rules.copy() + + def set_rule(self, rule: str, value: object | None) -> bool: + """Set a rule. + + Args: + rule (str): Rule name. + value (object | None): New rule value. + + Returns: + bool: ``True`` if the rule changed, otherwise ``False``. + """ + if value is None: + return self._rules.pop(rule, None) is not None + else: + current = self._rules.get(rule) + self._rules[rule] = value + return current != value + + def get_rule(self, rule: str, default: object = None) -> object: + return self._rules.get(rule, default) + + def refresh(self, *, layout: bool = False, children: bool = False) -> None: + if self.node is not None: + self.node.refresh(layout=layout) + if children: + for child in self.node.walk_children(with_self=False, reverse=True): + child.refresh(layout=layout) + + def reset(self) -> None: + """Reset the rules to initial state.""" + self._rules.clear() + + def merge(self, other: Styles) -> None: + """Merge values from another Styles. + + Args: + other (Styles): A Styles object. + """ + + self._rules.update(other._rules) + + def merge_rules(self, rules: RulesMap) -> None: + self._rules.update(rules) + + def extract_rules( + self, + specificity: Specificity3, + is_default_rules: bool = False, + tie_breaker: int = 0, + ) -> list[tuple[str, Specificity6, Any]]: + """Extract rules from Styles object, and apply !important css specificity as + well as higher specificity of user CSS vs widget CSS. + + Args: + specificity (Specificity3): A node specificity. + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. + + Returns: + list[tuple[str, Specificity6, Any]]]: A list containing a tuple of , . + """ + is_important = self.important.__contains__ + rules = [ + ( + rule_name, + ( + 0 if is_default_rules else 1, + 1 if is_important(rule_name) else 0, + *specificity, + tie_breaker, + ), + rule_value, + ) + for rule_name, rule_value in self._rules.items() + ] + return rules + + def __rich_repr__(self) -> rich.repr.Result: + has_rule = self.has_rule + for name in RULE_NAMES: + if has_rule(name): + yield name, getattr(self, name) + if self.important: + yield "important", self.important + + def __textual_animation__( + self, + attribute: str, + value: Any, + start_time: float, + duration: float | None, + speed: float | None, + easing: EasingFunction, + on_complete: CallbackType | None = None, + ) -> ScalarAnimation | None: + if isinstance(value, ScalarOffset): + return ScalarAnimation( + self.node, + self, + start_time, + attribute, + value, + duration=duration, + speed=speed, + easing=easing, + on_complete=on_complete, + ) + return None + + def _get_border_css_lines( + self, rules: RulesMap, name: str + ) -> Iterable[tuple[str, str]]: + """Get pairs of strings containing , for border css declarations. + + Args: + rules (RulesMap): A rules map. + name (str): Name of rules (border or outline) + + Returns: + Iterable[tuple[str, str]]: An iterable of CSS declarations. + + """ + + has_rule = rules.__contains__ + get_rule = rules.__getitem__ + + has_top = has_rule(f"{name}_top") + has_right = has_rule(f"{name}_right") + has_bottom = has_rule(f"{name}_bottom") + has_left = has_rule(f"{name}_left") + if not any((has_top, has_right, has_bottom, has_left)): + # No border related rules + return + + if all((has_top, has_right, has_bottom, has_left)): + # All rules are set + # See if we can set them with a single border: declaration + top = get_rule(f"{name}_top") + right = get_rule(f"{name}_right") + bottom = get_rule(f"{name}_bottom") + left = get_rule(f"{name}_left") + + if top == right and right == bottom and bottom == left: + border_type, border_color = rules[f"{name}_top"] + yield name, f"{border_type} {border_color.hex}" + return + + # Check for edges + if has_top: + border_type, border_color = rules[f"{name}_top"] + yield f"{name}-top", f"{border_type} {border_color.hex}" + + if has_right: + border_type, border_color = rules[f"{name}_right"] + yield f"{name}-right", f"{border_type} {border_color.hex}" + + if has_bottom: + border_type, border_color = rules[f"{name}_bottom"] + yield f"{name}-bottom", f"{border_type} {border_color.hex}" + + if has_left: + border_type, border_color = rules[f"{name}_left"] + yield f"{name}-left", f"{border_type} {border_color.hex}" + + @property + def css_lines(self) -> list[str]: + lines: list[str] = [] + append = lines.append + + def append_declaration(name: str, value: str) -> None: + if name in self.important: + append(f"{name}: {value} !important;") + else: + append(f"{name}: {value};") + + rules = self.get_rules() + get_rule = rules.get + has_rule = rules.__contains__ + + if has_rule("display"): + append_declaration("display", rules["display"]) + if has_rule("visibility"): + append_declaration("visibility", rules["visibility"]) + if has_rule("padding"): + append_declaration("padding", rules["padding"].css) + if has_rule("margin"): + append_declaration("margin", rules["margin"].css) + + for name, rule in self._get_border_css_lines(rules, "border"): + append_declaration(name, rule) + + for name, rule in self._get_border_css_lines(rules, "outline"): + append_declaration(name, rule) + + if has_rule("offset"): + x, y = self.offset + append_declaration("offset", f"{x} {y}") + if has_rule("dock"): + append_declaration("dock", rules["dock"]) + if has_rule("layers"): + append_declaration("layers", " ".join(self.layers)) + if has_rule("layer"): + append_declaration("layer", self.layer) + if has_rule("layout"): + assert self.layout is not None + append_declaration("layout", self.layout.name) + + if has_rule("color"): + append_declaration("color", self.color.hex) + if has_rule("background"): + append_declaration("background", self.background.hex) + if has_rule("text_style"): + append_declaration("text-style", str(get_rule("text_style"))) + if has_rule("tint"): + append_declaration("tint", self.tint.css) + + if has_rule("overflow_x"): + append_declaration("overflow-x", self.overflow_x) + if has_rule("overflow_y"): + append_declaration("overflow-y", self.overflow_y) + + if has_rule("scrollbar_color"): + append_declaration("scrollbar-color", self.scrollbar_color.css) + if has_rule("scrollbar_color_hover"): + append_declaration("scrollbar-color-hover", self.scrollbar_color_hover.css) + if has_rule("scrollbar_color_active"): + append_declaration( + "scrollbar-color-active", self.scrollbar_color_active.css + ) + + if has_rule("scrollbar_corner_color"): + append_declaration( + "scrollbar-corner-color", self.scrollbar_corner_color.css + ) + + if has_rule("scrollbar_background"): + append_declaration("scrollbar-background", self.scrollbar_background.css) + if has_rule("scrollbar_background_hover"): + append_declaration( + "scrollbar-background-hover", self.scrollbar_background_hover.css + ) + if has_rule("scrollbar_background_active"): + append_declaration( + "scrollbar-background-active", self.scrollbar_background_active.css + ) + + if has_rule("scrollbar_gutter"): + append_declaration("scrollbar-gutter", self.scrollbar_gutter) + if has_rule("scrollbar_size"): + append_declaration( + "scrollbar-size", + f"{self.scrollbar_size_horizontal} {self.scrollbar_size_vertical}", + ) + else: + if has_rule("scrollbar_size_horizontal"): + append_declaration( + "scrollbar-size-horizontal", str(self.scrollbar_size_horizontal) + ) + if has_rule("scrollbar_size_vertical"): + append_declaration( + "scrollbar-size-vertical", str(self.scrollbar_size_vertical) + ) + + if has_rule("box_sizing"): + append_declaration("box-sizing", self.box_sizing) + if has_rule("width"): + append_declaration("width", str(self.width)) + if has_rule("height"): + append_declaration("height", str(self.height)) + if has_rule("min_width"): + append_declaration("min-width", str(self.min_width)) + if has_rule("min_height"): + append_declaration("min-height", str(self.min_height)) + if has_rule("max_width"): + append_declaration("max-width", str(self.min_width)) + if has_rule("max_height"): + append_declaration("max-height", str(self.min_height)) + if has_rule("transitions"): + append_declaration( + "transition", + ", ".join( + f"{name} {transition}" + for name, transition in self.transitions.items() + ), + ) + + if has_rule("align_horizontal") and has_rule("align_vertical"): + append_declaration( + "align", f"{self.align_horizontal} {self.align_vertical}" + ) + elif has_rule("align_horizontal"): + append_declaration("align-horizontal", self.align_horizontal) + elif has_rule("align_vertical"): + append_declaration("align-vertical", self.align_vertical) + + if has_rule("content_align_horizontal") and has_rule("content_align_vertical"): + append_declaration( + "content-align", + f"{self.content_align_horizontal} {self.content_align_vertical}", + ) + elif has_rule("content_align_horizontal"): + append_declaration( + "content-align-horizontal", self.content_align_horizontal + ) + elif has_rule("content_align_vertical"): + append_declaration("content-align-vertical", self.content_align_vertical) + + if has_rule("text_align"): + append_declaration("text-align", self.text_align) + + if has_rule("opacity"): + append_declaration("opacity", str(self.opacity)) + if has_rule("text_opacity"): + append_declaration("text-opacity", str(self.text_opacity)) + + if has_rule("grid_columns"): + append_declaration( + "grid-columns", + " ".join(str(scalar) for scalar in self.grid_columns or ()), + ) + if has_rule("grid_rows"): + append_declaration( + "grid-rows", + " ".join(str(scalar) for scalar in self.grid_rows or ()), + ) + if has_rule("grid_size_columns"): + append_declaration("grid-size-columns", str(self.grid_size_columns)) + if has_rule("grid_size_rows"): + append_declaration("grid-size-rows", str(self.grid_size_rows)) + + if has_rule("grid_gutter_horizontal"): + append_declaration( + "grid-gutter-horizontal", str(self.grid_gutter_horizontal) + ) + if has_rule("grid_gutter_vertical"): + append_declaration("grid-gutter-vertical", str(self.grid_gutter_vertical)) + + if has_rule("row_span"): + append_declaration("row-span", str(self.row_span)) + if has_rule("column_span"): + append_declaration("column-span", str(self.column_span)) + + if has_rule("link_color"): + append_declaration("link-color", self.link_color.css) + if has_rule("link_background"): + append_declaration("link-background", self.link_background.css) + if has_rule("link_style"): + append_declaration("link-style", str(self.link_style)) + + if has_rule("link_hover_color"): + append_declaration("link-hover-color", self.link_hover_color.css) + if has_rule("link_hover_background"): + append_declaration("link-hover-background", self.link_hover_background.css) + if has_rule("link_hover_style"): + append_declaration("link-hover-style", str(self.link_hover_style)) + + lines.sort() + return lines + + @property + def css(self) -> str: + return "\n".join(self.css_lines) + + +@rich.repr.auto +class RenderStyles(StylesBase): + """Presents a combined view of two Styles object: a base Styles and inline Styles.""" + + def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None: + self.node = node + self._base_styles = base + self._inline_styles = inline_styles + self._animate: BoundAnimator | None = None + + @property + def base(self) -> Styles: + """Quick access to base (css) style.""" + return self._base_styles + + @property + def inline(self) -> Styles: + """Quick access to the inline styles.""" + return self._inline_styles + + @property + def rich_style(self) -> Style: + """Get a Rich style for this Styles object.""" + assert self.node is not None + return self.node.rich_style + + def animate( + self, + attribute: str, + value: float | Animatable, + *, + final_value: object = ..., + duration: float | None = None, + speed: float | None = None, + delay: float = 0.0, + easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, + ) -> None: + """Animate an attribute. + + Args: + attribute (str): Name of the attribute to animate. + value (float | Animatable): The value to animate to. + final_value (object, optional): The final value of the animation. Defaults to `value` if not set. + duration (float | None, optional): The duration of the animate. Defaults to None. + speed (float | None, optional): The speed of the animation. Defaults to None. + delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0. + easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic". + on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None. + + """ + if self._animate is None: + self._animate = self.node.app.animator.bind(self) + assert self._animate is not None + self._animate( + attribute, + value, + final_value=final_value, + duration=duration, + speed=speed, + delay=delay, + easing=easing, + on_complete=on_complete, + ) + + def __rich_repr__(self) -> rich.repr.Result: + for rule_name in RULE_NAMES: + if self.has_rule(rule_name): + yield rule_name, getattr(self, rule_name) + + def refresh(self, *, layout: bool = False, children: bool = False) -> None: + self._inline_styles.refresh(layout=layout, children=children) + + def merge(self, other: Styles) -> None: + """Merge values from another Styles. + + Args: + other (Styles): A Styles object. + """ + self._inline_styles.merge(other) + + def merge_rules(self, rules: RulesMap) -> None: + self._inline_styles.merge_rules(rules) + + def reset(self) -> None: + """Reset the rules to initial state.""" + self._inline_styles.reset() + + def has_rule(self, rule: str) -> bool: + """Check if a rule has been set.""" + return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule) + + def set_rule(self, rule: str, value: object | None) -> bool: + return self._inline_styles.set_rule(rule, value) + + def get_rule(self, rule: str, default: object = None) -> object: + if self._inline_styles.has_rule(rule): + return self._inline_styles.get_rule(rule, default) + return self._base_styles.get_rule(rule, default) + + def clear_rule(self, rule_name: str) -> bool: + """Clear a rule (from inline).""" + return self._inline_styles.clear_rule(rule_name) + + def get_rules(self) -> RulesMap: + """Get rules as a dictionary""" + rules = {**self._base_styles._rules, **self._inline_styles._rules} + return cast(RulesMap, rules) + + @property + def css(self) -> str: + """Get the CSS for the combined styles.""" + styles = Styles() + styles.merge(self._base_styles) + styles.merge(self._inline_styles) + combined_css = styles.css + return combined_css + + +if __name__ == "__main__": + styles = Styles() + + styles.display = "none" + styles.visibility = "hidden" + styles.border = ("solid", "rgb(10,20,30)") + styles.outline_right = ("solid", "red") + styles.text_style = "italic" + styles.dock = "bar" + styles.layers = "foo bar" + + from rich import print + + print(styles.text_style) + print(styles.text) + + print(styles) + print(styles.css) + + print(styles.extract_rules((0, 1, 0))) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py new file mode 100644 index 000000000..9d09835c6 --- /dev/null +++ b/src/textual/css/stylesheet.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import os +from collections import defaultdict +from operator import itemgetter +from pathlib import Path, PurePath +from typing import Iterable, NamedTuple, cast + +import rich.repr +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.markup import render +from rich.padding import Padding +from rich.panel import Panel +from rich.style import Style +from rich.syntax import Syntax +from rich.text import Text + +from .. import messages +from ..dom import DOMNode +from ..widget import Widget +from .errors import StylesheetError +from .match import _check_selectors +from .model import RuleSet +from .parse import parse +from .styles import RulesMap, Styles +from .tokenize import Token, tokenize_values +from .tokenizer import TokenError +from .types import Specificity3, Specificity6 + + +class StylesheetParseError(StylesheetError): + def __init__(self, errors: StylesheetErrors) -> None: + self.errors = errors + + def __rich__(self) -> RenderableType: + return self.errors + + +class StylesheetErrors: + def __init__(self, rules: list[RuleSet]) -> None: + self.rules = rules + self.variables: dict[str, str] = {} + + @classmethod + def _get_snippet(cls, code: str, line_no: int) -> RenderableType: + syntax = Syntax( + code, + lexer="scss", + theme="ansi_light", + line_numbers=True, + indent_guides=True, + line_range=(max(0, line_no - 2), line_no + 2), + highlight_lines={line_no}, + ) + return syntax + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + error_count = 0 + for rule in self.rules: + for token, message in rule.errors: + error_count += 1 + + if token.path: + path = Path(token.path) + filename = path.name + else: + path = None + filename = "" + + if token.referenced_by: + line_idx, col_idx = token.referenced_by.location + line_no, col_no = line_idx + 1, col_idx + 1 + path_string = ( + f"{path.absolute() if path else filename}:{line_no}:{col_no}" + ) + else: + line_idx, col_idx = token.location + line_no, col_no = line_idx + 1, col_idx + 1 + path_string = ( + f"{path.absolute() if path else filename}:{line_no}:{col_no}" + ) + + link_style = Style( + link=f"file://{path.absolute()}", + color="red", + bold=True, + italic=True, + ) + + path_text = Text(path_string, style=link_style) + title = Text.assemble(Text("Error at ", style="bold red"), path_text) + yield "" + yield Panel( + self._get_snippet( + token.referenced_by.code if token.referenced_by else token.code, + line_no, + ), + title=title, + title_align="left", + border_style="red", + ) + yield Padding(message, pad=(0, 0, 1, 3)) + + yield "" + yield render( + f" [b][red]CSS parsing failed:[/] {error_count} error{'s' if error_count != 1 else ''}[/] found in stylesheet" + ) + + +class CssSource(NamedTuple): + """Contains the CSS content and whether or not the CSS comes from user defined stylesheets + vs widget-level stylesheets. + + Args: + content (str): The CSS as a string. + is_defaults (bool): True if the CSS is default (i.e. that defined at the widget level). + False if it's user CSS (which will override the defaults). + """ + + content: str + is_defaults: bool + tie_breaker: int = 0 + + +@rich.repr.auto(angular=True) +class Stylesheet: + def __init__(self, *, variables: dict[str, str] | None = None) -> None: + self._rules: list[RuleSet] = [] + self._rules_map: dict[str, list[RuleSet]] | None = None + self._variables = variables or {} + self.__variable_tokens: dict[str, list[Token]] | None = None + self.source: dict[str, CssSource] = {} + self._require_parse = False + + def __rich_repr__(self) -> rich.repr.Result: + yield list(self.source.keys()) + + @property + def _variable_tokens(self) -> dict[str, list[Token]]: + if self.__variable_tokens is None: + self.__variable_tokens = tokenize_values(self._variables) + return self.__variable_tokens + + @property + def rules(self) -> list[RuleSet]: + """List of rule sets. + + Returns: + list[RuleSet]: List of rules sets for this stylesheet. + """ + if self._require_parse: + self.parse() + self._require_parse = False + assert self._rules is not None + return self._rules + + @property + def rules_map(self) -> dict[str, list[RuleSet]]: + """Structure that maps a selector on to a list of rules. + + Returns: + dict[str, list[RuleSet]]: Mapping of selector to rule sets. + """ + if self._rules_map is None: + rules_map: dict[str, list[RuleSet]] = defaultdict(list) + for rule in self.rules: + for name in rule.selector_names: + rules_map[name].append(rule) + self._rules_map = dict(rules_map) + return self._rules_map + + @property + def css(self) -> str: + return "\n\n".join(rule_set.css for rule_set in self.rules) + + def copy(self) -> Stylesheet: + """Create a copy of this stylesheet. + + Returns: + Stylesheet: New stylesheet. + """ + stylesheet = Stylesheet(variables=self._variables.copy()) + stylesheet.source = self.source.copy() + return stylesheet + + def set_variables(self, variables: dict[str, str]) -> None: + """Set CSS variables. + + Args: + variables (dict[str, str]): A mapping of name to variable. + """ + self._variables = variables + self.__variable_tokens = None + + def _parse_rules( + self, + css: str, + path: str | PurePath, + is_default_rules: bool = False, + tie_breaker: int = 0, + ) -> list[RuleSet]: + """Parse CSS and return rules. + + Args: + is_default_rules: + css (str): String containing Textual CSS. + path (str | PurePath): Path to CSS or unique identifier + is_default_rules (bool): True if the rules we're extracting are + default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS. + + Raises: + StylesheetError: If the CSS is invalid. + + Returns: + list[RuleSet]: List of RuleSets. + """ + try: + rules = list( + parse( + css, + path, + variable_tokens=self._variable_tokens, + is_default_rules=is_default_rules, + tie_breaker=tie_breaker, + ) + ) + except TokenError: + raise + except Exception as error: + raise StylesheetError(f"failed to parse css; {error}") + + return rules + + def read(self, filename: str | PurePath) -> None: + """Read Textual CSS file. + + Args: + filename (str | PurePath): filename of CSS. + + Raises: + StylesheetError: If the CSS could not be read. + StylesheetParseError: If the CSS is invalid. + """ + filename = os.path.expanduser(filename) + try: + with open(filename, "rt") as css_file: + css = css_file.read() + path = os.path.abspath(filename) + except Exception as error: + raise StylesheetError(f"unable to read CSS file {filename!r}") from None + self.source[str(path)] = CssSource(css, False, 0) + self._require_parse = True + + def add_source( + self, + css: str, + path: str | PurePath | None = None, + is_default_css: bool = False, + tie_breaker: int = 0, + ) -> None: + """Parse CSS from a string. + + Args: + css (str): String with CSS source. + path (str | PurePath, optional): The path of the source if a file, or some other identifier. + Defaults to None. + is_default_css (bool): True if the CSS is defined in the Widget, False if the CSS is defined + in a user stylesheet. + + Raises: + StylesheetError: If the CSS could not be read. + StylesheetParseError: If the CSS is invalid. + """ + + if path is None: + path = str(hash(css)) + elif isinstance(path, PurePath): + path = str(css) + if path in self.source and self.source[path].content == css: + # Path already in source, and CSS is identical + content, is_defaults, source_tie_breaker = self.source[path] + if source_tie_breaker > tie_breaker: + self.source[path] = CssSource(content, is_defaults, tie_breaker) + return + self.source[path] = CssSource(css, is_default_css, tie_breaker) + self._require_parse = True + + def parse(self) -> None: + """Parse the source in the stylesheet. + + Raises: + StylesheetParseError: If there are any CSS related errors. + """ + rules: list[RuleSet] = [] + add_rules = rules.extend + for path, (css, is_default_rules, tie_breaker) in self.source.items(): + css_rules = self._parse_rules( + css, path, is_default_rules=is_default_rules, tie_breaker=tie_breaker + ) + if any(rule.errors for rule in css_rules): + error_renderable = StylesheetErrors(css_rules) + raise StylesheetParseError(error_renderable) + add_rules(css_rules) + self._rules = rules + self._require_parse = False + self._rules_map = None + + def reparse(self) -> None: + """Re-parse source, applying new variables. + + Raises: + StylesheetError: If the CSS could not be read. + StylesheetParseError: If the CSS is invalid. + + """ + # Do this in a fresh Stylesheet so if there are errors we don't break self. + stylesheet = Stylesheet(variables=self._variables) + for path, (css, is_defaults, tie_breaker) in self.source.items(): + stylesheet.add_source( + css, path, is_default_css=is_defaults, tie_breaker=tie_breaker + ) + stylesheet.parse() + self._rules = stylesheet.rules + self._rules_map = None + self.source = stylesheet.source + + @classmethod + def _check_rule( + cls, rule: RuleSet, css_path_nodes: list[DOMNode] + ) -> Iterable[Specificity3]: + for selector_set in rule.selector_set: + if _check_selectors(selector_set.selectors, css_path_nodes): + yield selector_set.specificity + + def apply( + self, + node: DOMNode, + *, + limit_rules: set[RuleSet] | None = None, + animate: bool = False, + ) -> None: + """Apply the stylesheet to a DOM node. + + Args: + node (DOMNode): The ``DOMNode`` to apply the stylesheet to. + Applies the styles defined in this ``Stylesheet`` to the node. + If the same rule is defined multiple times for the node (e.g. multiple + classes modifying the same CSS property), then only the most specific + rule will be applied. + animate (bool, optional): Animate changed rules. Defaults to ``False``. + """ + # 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: defaultdict[str, list[tuple[Specificity6, object]]] + rule_attributes = defaultdict(list) + + _check_rule = self._check_rule + css_path_nodes = node.css_path_nodes + + rules: Iterable[RuleSet] + if limit_rules: + rules = [rule for rule in reversed(self.rules) if rule in limit_rules] + else: + rules = reversed(self.rules) + + # Collect the rules defined in the stylesheet + node._has_hover_style = False + node._has_focus_within = False + for rule in rules: + is_default_rules = rule.is_default_rules + tie_breaker = rule.tie_breaker + if ":hover" in rule.selector_names: + node._has_hover_style = True + if ":focus-within" in rule.selector_names: + node._has_focus_within = True + for base_specificity in _check_rule(rule, css_path_nodes): + for key, rule_specificity, value in rule.styles.extract_rules( + base_specificity, is_default_rules, tie_breaker + ): + rule_attributes[key].append((rule_specificity, value)) + + if not rule_attributes: + return + # For each rule declared for this node, keep only the most specific one + get_first_item = itemgetter(0) + node_rules: RulesMap = cast( + RulesMap, + { + name: max(specificity_rules, key=get_first_item)[1] + for name, specificity_rules in rule_attributes.items() + }, + ) + self.replace_rules(node, node_rules, animate=animate) + + node._component_styles.clear() + for component in node.COMPONENT_CLASSES: + virtual_node = DOMNode(classes=component) + virtual_node._attach(node) + self.apply(virtual_node, animate=False) + node._component_styles[component] = virtual_node.styles + + @classmethod + def replace_rules( + cls, node: DOMNode, rules: RulesMap, animate: bool = False + ) -> None: + """Replace style rules on a node, animating as required. + + Args: + node (DOMNode): A DOM node. + rules (RulesMap): Mapping of rules. + animate (bool, optional): Enable animation. Defaults to False. + """ + + # Alias styles and base styles + styles = node.styles + base_styles = styles.base + + # Styles currently used on new rules + modified_rule_keys = base_styles.get_rules().keys() | rules.keys() + # Current render rules (missing rules are filled with default) + current_render_rules = styles.get_render_rules() + + # Calculate replacement rules (defaults + new rules) + new_styles = Styles(node, rules) + if new_styles == base_styles: + # Nothing to change, return early + return + + # New render rules + new_render_rules = new_styles.get_render_rules() + + # Some aliases + is_animatable = styles.is_animatable + get_current_render_rule = current_render_rules.get + get_new_render_rule = new_render_rules.get + + if animate: + for key in modified_rule_keys: + # Get old and new render rules + old_render_value = get_current_render_rule(key) + new_render_value = get_new_render_rule(key) + # Get new rule value (may be None) + new_value = rules.get(key) + + # Check if this can / should be animated + if is_animatable(key) and new_render_value != old_render_value: + transition = new_styles._get_transition(key) + if transition is not None: + duration, easing, delay = transition + node.app.animator.animate( + node.styles.base, + key, + new_render_value, + final_value=new_value, + duration=duration, + delay=delay, + easing=easing, + ) + continue + # Default is to set value (if new_value is None, rule will be removed) + setattr(base_styles, key, new_value) + else: + # Not animated, so we apply the rules directly + get_rule = rules.get + + for key in modified_rule_keys: + setattr(base_styles, key, get_rule(key)) + + node.post_message_no_wait(messages.StylesUpdated(sender=node)) + + def update(self, root: DOMNode, animate: bool = False) -> None: + """Update styles on node and its children. + + Args: + root (DOMNode): Root note to update. + animate (bool, optional): Enable CSS animation. Defaults to False. + """ + + self.update_nodes(root.walk_children(), animate=animate) + + def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: + """Update styles for nodes. + + Args: + nodes (DOMNode): Nodes to update. + animate (bool, optional): Enable CSS animation. Defaults to False. + """ + + rules_map = self.rules_map + apply = self.apply + + for node in nodes: + rules = { + rule + for name in node._selector_names + if name in rules_map + for rule in rules_map[name] + } + apply(node, limit_rules=rules, animate=animate) + 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) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py new file mode 100644 index 000000000..dbec369df --- /dev/null +++ b/src/textual/css/tokenize.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import re +from pathlib import PurePath +from typing import Iterable + +from textual.css.tokenizer import Expect, Tokenizer, Token + +PERCENT = r"-?\d+\.?\d*%" +DECIMAL = r"-?\d+\.?\d*" +COMMA = r"\s*,\s*" +OPEN_BRACE = r"\(\s*" +CLOSE_BRACE = r"\s*\)" + +HEX_COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}" +RGB_COLOR = rf"rgb{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}|rgba{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}" +HSL_COLOR = rf"hsl{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{CLOSE_BRACE}|hsla{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}{CLOSE_BRACE}" + +COMMENT_START = r"\/\*" +SCALAR = rf"{DECIMAL}(?:fr|%|w|h|vw|vh)" +DURATION = r"\d+\.?\d*(?:ms|s)" +NUMBER = r"\-?\d+\.?\d*" +COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}" +KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+" +TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*" +STRING = r"\".*?\"" +VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+" + +IDENTIFIER = r"[a-zA-Z_\-][a-zA-Z0-9_\-]*" + +# Values permitted in variable and rule declarations. +DECLARATION_VALUES = { + "scalar": SCALAR, + "duration": DURATION, + "number": NUMBER, + "color": COLOR, + "key_value": KEY_VALUE, + "token": TOKEN, + "string": STRING, + "variable_ref": VARIABLE_REF, +} + +# The tokenizers "expectation" while at the root/highest level of scope +# in the CSS file. At this level we might expect to see selectors, comments, +# variable definitions etc. +expect_root_scope = Expect( + whitespace=r"\s+", + comment_start=COMMENT_START, + selector_start_id=r"\#" + IDENTIFIER, + selector_start_class=r"\." + IDENTIFIER, + selector_start_universal=r"\*", + selector_start=r"[a-zA-Z_\-]+", + variable_name=rf"{VARIABLE_REF}:", +).expect_eof(True) + +# After a variable declaration e.g. "$warning-text: TOKENS;" +# for tokenizing variable value ------^~~~~~~^ +expect_variable_name_continue = Expect( + variable_value_end=r"\n|;", + whitespace=r"\s+", + comment_start=COMMENT_START, + **DECLARATION_VALUES, +).expect_eof(True) + +expect_comment_end = Expect( + comment_end=re.escape("*/"), +) + +# After we come across a selector in CSS e.g. ".my-class", we may +# find other selectors, pseudo-classes... e.g. ".my-class :hover" +expect_selector_continue = Expect( + whitespace=r"\s+", + comment_start=COMMENT_START, + pseudo_class=r"\:[a-zA-Z_-]+", + selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*", + selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", + selector_universal=r"\*", + selector=r"[a-zA-Z_\-]+", + combinator_child=">", + new_selector=r",", + declaration_set_start=r"\{", +) + +# A rule declaration e.g. "text: red;" +# ^---^ +expect_declaration = Expect( + whitespace=r"\s+", + comment_start=COMMENT_START, + declaration_name=r"[a-zA-Z_\-]+\:", + declaration_set_end=r"\}", +) + +expect_declaration_solo = Expect( + whitespace=r"\s+", + comment_start=COMMENT_START, + declaration_name=r"[a-zA-Z_\-]+\:", + declaration_set_end=r"\}", +).expect_eof(True) + +# The value(s)/content from a rule declaration e.g. "text: red;" +# ^---^ +expect_declaration_content = Expect( + declaration_end=r";", + whitespace=r"\s+", + comment_start=COMMENT_START, + **DECLARATION_VALUES, + important=r"\!important", + comma=",", + declaration_set_end=r"\}", +) + +expect_declaration_content_solo = Expect( + declaration_end=r";", + whitespace=r"\s+", + comment_start=COMMENT_START, + **DECLARATION_VALUES, + important=r"\!important", + comma=",", + declaration_set_end=r"\}", +).expect_eof(True) + + +class TokenizerState: + """State machine for the tokenizer. + + Attributes: + EXPECT: The initial expectation of the tokenizer. Since we start tokenizing + at the root scope, we might expect to see either a variable or selector, for example. + STATE_MAP: Maps token names to Expects, defines the sets of valid tokens + that we'd expect to see next, given the current token. For example, if + we've just processed a variable declaration name, we next expect to see + the value of that variable. + """ + + EXPECT = expect_root_scope + STATE_MAP = { + "variable_name": expect_variable_name_continue, + "variable_value_end": expect_root_scope, + "selector_start": expect_selector_continue, + "selector_start_id": expect_selector_continue, + "selector_start_class": expect_selector_continue, + "selector_start_universal": expect_selector_continue, + "selector_id": expect_selector_continue, + "selector_class": expect_selector_continue, + "selector_universal": expect_selector_continue, + "declaration_set_start": expect_declaration, + "declaration_name": expect_declaration_content, + "declaration_end": expect_declaration, + "declaration_set_end": expect_root_scope, + } + + def __call__(self, code: str, path: str | PurePath) -> Iterable[Token]: + tokenizer = Tokenizer(code, path=path) + expect = self.EXPECT + get_token = tokenizer.get_token + get_state = self.STATE_MAP.get + while True: + token = get_token(expect) + name = token.name + if name == "comment_start": + tokenizer.skip_to(expect_comment_end) + continue + elif name == "eof": + break + expect = get_state(name, expect) + yield token + + +class DeclarationTokenizerState(TokenizerState): + EXPECT = expect_declaration_solo + STATE_MAP = { + "declaration_name": expect_declaration_content, + "declaration_end": expect_declaration_solo, + } + + +class ValueTokenizerState(TokenizerState): + EXPECT = expect_declaration_content_solo + + +tokenize = TokenizerState() +tokenize_declarations = DeclarationTokenizerState() +tokenize_value = ValueTokenizerState() + + +def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: + """Tokens the values in a dict of strings. + + Args: + values (dict[str, str]): A mapping of CSS variable name on to a value, to be + added to the CSS context. + + Returns: + dict[str, list[Token]]: A mapping of name on to a list of tokens, + """ + value_tokens = { + name: list(tokenize_value(value, "__name__")) for name, value in values.items() + } + return value_tokens + + +if __name__ == "__main__": + from rich import print + + css = """#something { + + color: rgb(10,12,23) + } + """ + # transition: offset 500 in_out_cubic; + tokens = tokenize(css, __name__) + print(list(tokens)) + + print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"})) diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py new file mode 100644 index 000000000..51c8bf622 --- /dev/null +++ b/src/textual/css/tokenizer.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import re +from pathlib import PurePath +from typing import NamedTuple + +from rich.console import Group, RenderableType +from rich.highlighter import ReprHighlighter +from rich.padding import Padding +from rich.panel import Panel +import rich.repr +from rich.syntax import Syntax +from rich.text import Text + +from ._error_tools import friendly_list + + +class TokenError(Exception): + """Error raised when the CSS cannot be tokenized (syntax error).""" + + def __init__( + self, + path: str, + code: str, + start: tuple[int, int], + message: str, + end: tuple[int, int] | None = None, + ) -> None: + """ + Args: + path (str): Path to source or "" if source is parsed from a literal. + code (str): The code being parsed. + start (tuple[int, int]): Line number of the error. + message (str): A message associated with the error. + end (tuple[int, int] | None): End location of token, or None if not known. Defaults to None. + """ + + self.path = path + self.code = code + self.start = start + self.end = end or start + super().__init__(message) + + def _get_snippet(self) -> Panel: + """Get a short snippet of code around a given line number. + + Returns: + Panel: A renderable. + """ + line_no = self.start[0] + # TODO: Highlight column number + syntax = Syntax( + self.code, + lexer="scss", + theme="ansi_light", + line_numbers=True, + indent_guides=True, + line_range=(max(0, line_no - 2), line_no + 2), + highlight_lines={line_no}, + ) + syntax.stylize_range("reverse bold", self.start, self.end) + return Panel(syntax, border_style="red") + + def __rich__(self) -> RenderableType: + highlighter = ReprHighlighter() + errors: list[RenderableType] = [] + + message = str(self) + errors.append(Text(" Error in stylesheet:", style="bold red")) + + line_no, col_no = self.start + + errors.append(highlighter(f" {self.path or ''}:{line_no}:{col_no}")) + errors.append(self._get_snippet()) + + final_message = "\n".join( + f"โ€ข {message_part.strip()}" for message_part in message.split(";") + ) + errors.append( + Padding( + highlighter( + Text(final_message, "red"), + ), + pad=(0, 1), + ) + ) + + return Group(*errors) + + +class EOFError(TokenError): + pass + + +class Expect: + def __init__(self, **tokens: str) -> None: + self.names = list(tokens.keys()) + self.regexes = list(tokens.values()) + self._regex = re.compile( + "(" + + "|".join(f"(?P<{name}>{regex})" for name, regex in tokens.items()) + + ")" + ) + self.match = self._regex.match + self.search = self._regex.search + self._expect_eof = False + + def expect_eof(self, eof: bool) -> Expect: + self._expect_eof = eof + return self + + def __rich_repr__(self) -> rich.repr.Result: + yield from zip(self.names, self.regexes) + + +class ReferencedBy(NamedTuple): + name: str + location: tuple[int, int] + length: int + code: str + + +@rich.repr.auto +class Token(NamedTuple): + name: str + value: str + path: str + code: str + location: tuple[int, int] + referenced_by: ReferencedBy | None = None + + @property + def start(self) -> tuple[int, int]: + """Start line and column (1 indexed).""" + line, offset = self.location + return (line + 1, offset) + + @property + def end(self) -> tuple[int, int]: + """End line and column (1 indexed).""" + line, offset = self.location + return (line + 1, offset + len(self.value)) + + def with_reference(self, by: ReferencedBy | None) -> "Token": + """Return a copy of the Token, with reference information attached. + This is used for variable substitution, where a variable reference + can refer to tokens which were defined elsewhere. With the additional + ReferencedBy data attached, we can track where the token we are referring + to is used. + """ + return Token( + name=self.name, + value=self.value, + path=self.path, + code=self.code, + location=self.location, + referenced_by=by, + ) + + def __str__(self) -> str: + return self.value + + def __rich_repr__(self) -> rich.repr.Result: + yield "name", self.name + yield "value", self.value + yield "path", self.path + yield "code", self.code if len(self.code) < 40 else self.code[:40] + "..." + yield "location", self.location + yield "referenced_by", self.referenced_by, None + + +class Tokenizer: + def __init__(self, text: str, path: str | PurePath = "") -> None: + self.path = str(path) + self.code = text + self.lines = text.splitlines(keepends=True) + self.line_no = 0 + self.col_no = 0 + + def get_token(self, expect: Expect) -> Token: + line_no = self.line_no + col_no = self.col_no + if line_no >= len(self.lines): + if expect._expect_eof: + return Token( + "eof", + "", + self.path, + self.code, + (line_no + 1, col_no + 1), + None, + ) + else: + raise EOFError( + self.path, + self.code, + (line_no + 1, col_no + 1), + "Unexpected end of file", + ) + line = self.lines[line_no] + match = expect.match(line, col_no) + if match is None: + expected = friendly_list(" ".join(name.split("_")) for name in expect.names) + message = f"Expected one of {expected}.; Did you forget a semicolon at the end of a line?" + raise TokenError( + self.path, + self.code, + (line_no, col_no), + message, + ) + iter_groups = iter(match.groups()) + + next(iter_groups) + + for name, value in zip(expect.names, iter_groups): + if value is not None: + break + else: + # For MyPy's benefit + raise AssertionError("can't reach here") + + token = Token( + name, + value, + self.path, + self.code, + (line_no, col_no), + referenced_by=None, + ) + col_no += len(value) + if col_no >= len(line): + line_no += 1 + col_no = 0 + self.line_no = line_no + self.col_no = col_no + return token + + def skip_to(self, expect: Expect) -> Token: + line_no = self.line_no + col_no = self.col_no + + while True: + if line_no >= len(self.lines): + raise EOFError( + self.path, self.code, line_no, col_no, "Unexpected end of file" + ) + line = self.lines[line_no] + match = expect.search(line, col_no) + + if match is None: + line_no += 1 + col_no = 0 + else: + self.line_no = line_no + self.col_no = match.span(0)[0] + return self.get_token(expect) diff --git a/src/textual/css/transition.py b/src/textual/css/transition.py new file mode 100644 index 000000000..9d9facf10 --- /dev/null +++ b/src/textual/css/transition.py @@ -0,0 +1,16 @@ +from typing import NamedTuple + + +class Transition(NamedTuple): + duration: float = 1.0 + easing: str = "linear" + delay: float = 0.0 + + def __str__(self) -> str: + duration, easing, delay = self + if delay: + return f"{duration:.1f}s {easing} {delay:.1f}" + elif easing != "linear": + return f"{duration:.1f}s {easing}" + else: + return f"{duration:.1f}s" diff --git a/src/textual/css/types.py b/src/textual/css/types.py new file mode 100644 index 000000000..24958900c --- /dev/null +++ b/src/textual/css/types.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +from typing import Tuple + +from ..color import Color + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + +Edge = Literal["top", "right", "bottom", "left"] +DockEdge = Literal["top", "right", "bottom", "left", ""] +EdgeType = Literal[ + "", + "ascii", + "none", + "hidden", + "blank", + "round", + "solid", + "double", + "dashed", + "heavy", + "inner", + "outer", + "hkey", + "vkey", + "tall", + "wide", +] +Visibility = Literal["visible", "hidden", "initial", "inherit"] +Display = Literal["block", "none"] +AlignHorizontal = Literal["left", "center", "right"] +AlignVertical = Literal["top", "middle", "bottom"] +ScrollbarGutter = Literal["auto", "stable"] +BoxSizing = Literal["border-box", "content-box"] +Overflow = Literal["scroll", "hidden", "auto"] +EdgeStyle = Tuple[EdgeType, Color] +TextAlign = Literal["left", "start", "center", "right", "end", "justify"] + +Specificity3 = Tuple[int, int, int] +Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/src/textual/demo.css b/src/textual/demo.css new file mode 100644 index 000000000..08a2e4e9b --- /dev/null +++ b/src/textual/demo.css @@ -0,0 +1,262 @@ + * { + transition: background 250ms linear, color 250ms linear; +} + +Screen { + layers: base overlay notes notifications; + overflow: hidden; +} + + +Notification { + dock: bottom; + layer: notification; + width: auto; + margin: 2 4; + padding: 1 2; + background: $background; + color: $text; + height: auto; + +} + +Sidebar { + width: 40; + background: $panel; + transition: offset 500ms in_out_cubic; + layer: overlay; + +} + +Sidebar:focus-within { + offset: 0 0 !important; +} + +Sidebar.-hidden { + offset-x: -100%; +} + +Sidebar Title { + background: $boost; + color: $secondary; + padding: 2 4; + border-right: vkey $background; + dock: top; + text-align: center; + text-style: bold; +} + + +OptionGroup { + background: $boost; + color: $text; + height: 1fr; + border-right: vkey $background; +} + +Option { + margin: 1 0 0 1; + height: 3; + padding: 1 2; + background: $boost; + border: tall $panel; + text-align: center; +} + +Option:hover { + background: $primary 20%; + color: $text; +} + +Body { + height: 100%; + overflow-y: scroll; + width: 100%; + background: $surface; + +} + +AboveFold { + width: 100%; + height: 100%; + align: center middle; +} + +Welcome { + background: $boost; + height: auto; + max-width: 100; + min-width: 40; + border: wide $primary; + padding: 1 2; + margin: 1 2; + box-sizing: border-box; +} + +Welcome Button { + width: 100%; + margin-top: 1; +} + +Column { + height: auto; + min-height: 100vh; + align: center top; +} + + +DarkSwitch { + background: $panel; + padding: 1; + dock: bottom; + height: auto; + border-right: vkey $background; +} + +DarkSwitch .label { + + padding: 1 2; + color: $text-muted; +} + +DarkSwitch Checkbox { + background: $boost; +} + + +Screen > Container { + height: 100%; + overflow: hidden; +} + +TextLog { + background: $surface; + color: $text; + height: 50vh; + dock: bottom; + layer: notes; + border-top: hkey $primary; + offset-y: 0; + transition: offset 400ms in_out_cubic; + padding: 0 1 1 1; +} + + +TextLog:focus { + offset: 0 0 !important; +} + +TextLog.-hidden { + offset-y: 100%; +} + + + +Section { + height: auto; + min-width: 40; + margin: 1 2 4 2; + +} + +SectionTitle { + padding: 1 2; + background: $boost; + text-align: center; + text-style: bold; +} + +SubTitle { + padding-top: 1; + border-bottom: heavy $panel; + color: $text; + text-style: bold; +} + +TextContent { + margin: 1 0; +} + +QuickAccess { + width: 30; + dock: left; + +} + +LocationLink { + margin: 1 0 0 1; + height: 1; + padding: 1 2; + background: $boost; + color: $text; + + content-align: center middle; +} + +LocationLink:hover { + background: $accent; + color: $text; + text-style: bold; +} + + +.pad { + margin: 1 0; +} + +DataTable { + height: 16; +} + + +LoginForm { + height: auto; + margin: 1 0; + padding: 1 2; + layout: grid; + grid-size: 2; + grid-rows: 4; + grid-columns: 12 1fr; + background: $boost; + border: wide $background; +} + +LoginForm Button{ + margin: 0 1; + width: 100%; +} + +LoginForm .label { + padding: 1 2; + text-align: right; +} + +Message { + margin: 0 1; + +} + + +TreeControl { + margin: 1 0; +} + + +Window { + background: $boost; + overflow: auto; + height: auto; + max-height: 16; +} + +Window > Static { + width: auto; +} + + +Version { + color: $text-disabled; + dock: bottom; + text-align: center; + padding: 1; +} diff --git a/src/textual/demo.py b/src/textual/demo.py new file mode 100644 index 000000000..adc24dcb3 --- /dev/null +++ b/src/textual/demo.py @@ -0,0 +1,425 @@ +from __future__ import annotations + +from importlib_metadata import version +from pathlib import Path + +from rich import box +from rich.console import RenderableType +from rich.json import JSON +from rich.markdown import Markdown +from rich.pretty import Pretty +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal +from textual.reactive import reactive, watch +from textual.widgets import ( + Button, + Checkbox, + DataTable, + Footer, + Header, + Input, + Static, + TextLog, +) + +from_markup = Text.from_markup + +example_table = Table( + show_edge=False, + show_header=True, + expand=True, + row_styles=["none", "dim"], + box=box.SIMPLE, +) +example_table.add_column(from_markup("[green]Date"), style="green", no_wrap=True) +example_table.add_column(from_markup("[blue]Title"), style="blue") + +example_table.add_column( + from_markup("[magenta]Box Office"), + style="magenta", + justify="right", + no_wrap=True, +) +example_table.add_row( + "Dec 20, 2019", + "Star Wars: The Rise of Skywalker", + "$375,126,118", +) +example_table.add_row( + "May 25, 2018", + from_markup("[b]Solo[/]: A Star Wars Story"), + "$393,151,347", +) +example_table.add_row( + "Dec 15, 2017", + "Star Wars Ep. VIII: The Last Jedi", + from_markup("[bold]$1,332,539,889[/bold]"), +) +example_table.add_row( + "May 19, 1999", + from_markup("Star Wars Ep. [b]I[/b]: [i]The phantom Menace"), + "$1,027,044,677", +) + + +WELCOME_MD = """ + +## Textual Demo + +**Welcome**! Textual is a framework for creating sophisticated applications with the terminal. + +""" + + +RICH_MD = """ + +Textual is built on **Rich**, the popular Python library for advanced terminal output. + +Add content to your Textual App with Rich *renderables* (this text is written in Markdown and formatted with Rich's Markdown class). + +Here are some examples: + + +""" + +CSS_MD = """ + +Textual uses Cascading Stylesheets (CSS) to create Rich interactive User Interfaces. + +- **Easy to learn** - much simpler than browser CSS +- **Live editing** - see your changes without restarting the app! + +Here's an example of some CSS used in this app: + +""" + + +EXAMPLE_CSS = """\ +Screen { + layers: base overlay notes; + overflow: hidden; +} + +Sidebar { + width: 40; + background: $panel; + transition: offset 500ms in_out_cubic; + layer: overlay; + +} + +Sidebar.-hidden { + offset-x: -100%; +}""" + +DATA = { + "foo": [ + 3.1427, + ( + "Paul Atreides", + "Vladimir Harkonnen", + "Thufir Hawat", + "Gurney Halleck", + "Duncan Idaho", + ), + ], +} + +WIDGETS_MD = """ + +Textual widgets are powerful interactive components. + +Build your own or use the builtin widgets. + +- **Input** Text / Password input. +- **Button** Clickable button with a number of styles. +- **Checkbox** A checkbox to toggle between states. +- **DataTable** A spreadsheet-like widget for navigating data. Cells may contain text or Rich renderables. +- **TreeControl** An generic tree with expandable nodes. +- **DirectoryTree** A tree of file and folders. +- *... many more planned ...* + +""" + + +MESSAGE = """ +We hope you enjoy using Textual. + +Here are some links. You can click these! + +[@click="app.open_link('https://textual.textualize.io')"]Textual Docs[/] + +[@click="app.open_link('https://github.com/Textualize/textual')"]Textual GitHub Repository[/] + +[@click="app.open_link('https://github.com/Textualize/rich')"]Rich GitHub Repository[/] + + +Built with โ™ฅ by [@click="app.open_link(https://www.textualize.io)"]Textualize.io[/] + +""" + + +JSON_EXAMPLE = """{ + "glossary": { + "title": "example glossary", + "GlossDiv": { + "title": "S", + "GlossList": { + "GlossEntry": { + "ID": "SGML", + "SortAs": "SGML", + "GlossTerm": "Standard Generalized Markup Language", + "Acronym": "SGML", + "Abbrev": "ISO 8879:1986", + "GlossDef": { + "para": "A meta-markup language, used to create markup languages such as DocBook.", + "GlossSeeAlso": ["GML", "XML"] + }, + "GlossSee": "markup" + } + } + } + } +} +""" + + +class Body(Container): + pass + + +class Title(Static): + pass + + +class DarkSwitch(Horizontal): + def compose(self) -> ComposeResult: + yield Checkbox(value=self.app.dark) + yield Static("Dark mode toggle", classes="label") + + def on_mount(self) -> None: + watch(self.app, "dark", self.on_dark_change) + + def on_dark_change(self, dark: bool) -> None: + self.query_one(Checkbox).value = self.app.dark + + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + self.app.dark = event.value + + +class Welcome(Container): + def compose(self) -> ComposeResult: + yield Static(Markdown(WELCOME_MD)) + yield Button("Start", variant="success") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.app.add_note("[b magenta]Start!") + self.app.query_one(".location-first").scroll_visible(speed=50, top=True) + + +class OptionGroup(Container): + pass + + +class SectionTitle(Static): + pass + + +class Message(Static): + pass + + +class Version(Static): + def render(self) -> RenderableType: + return f"[b]v{version('textual')}" + + +class Sidebar(Container): + def compose(self) -> ComposeResult: + yield Title("Textual Demo") + yield OptionGroup(Message(MESSAGE), Version()) + yield DarkSwitch() + + +class AboveFold(Container): + pass + + +class Section(Container): + pass + + +class Column(Container): + pass + + +class TextContent(Static): + pass + + +class QuickAccess(Container): + pass + + +class LocationLink(Static): + def __init__(self, label: str, reveal: str) -> None: + super().__init__(label) + self.reveal = reveal + + def on_click(self) -> None: + self.app.query_one(self.reveal).scroll_visible(top=True) + self.app.add_note(f"Scrolling to [b]{self.reveal}[/b]") + + +class LoginForm(Container): + def compose(self) -> ComposeResult: + yield Static("Username", classes="label") + yield Input(placeholder="Username") + yield Static("Password", classes="label") + yield Input(placeholder="Password", password=True) + yield Static() + yield Button("Login", variant="primary") + + +class Window(Container): + pass + + +class SubTitle(Static): + pass + + +class Notification(Static): + def on_mount(self) -> None: + self.set_timer(3, self.remove) + + def on_click(self) -> None: + self.remove() + + +class DemoApp(App): + CSS_PATH = "demo.css" + TITLE = "Textual Demo" + BINDINGS = [ + ("ctrl+b", "toggle_sidebar", "Sidebar"), + ("ctrl+t", "app.toggle_dark", "Toggle Dark mode"), + ("ctrl+s", "app.screenshot()", "Screenshot"), + ("f1", "app.toggle_class('TextLog', '-hidden')", "Notes"), + Binding("ctrl+c,ctrl+q", "app.quit", "Quit", show=True), + ] + + show_sidebar = reactive(False) + + def add_note(self, renderable: RenderableType) -> None: + self.query_one(TextLog).write(renderable) + + def compose(self) -> ComposeResult: + example_css = "\n".join(Path(self.css_path).read_text().splitlines()[:50]) + yield Container( + Sidebar(classes="-hidden"), + Header(show_clock=True), + TextLog(classes="-hidden", wrap=False, highlight=True, markup=True), + Body( + QuickAccess( + LocationLink("TOP", ".location-top"), + LocationLink("Widgets", ".location-widgets"), + LocationLink("Rich content", ".location-rich"), + LocationLink("CSS", ".location-css"), + ), + AboveFold(Welcome(), classes="location-top"), + Column( + Section( + SectionTitle("Widgets"), + TextContent(Markdown(WIDGETS_MD)), + LoginForm(), + DataTable(), + ), + classes="location-widgets location-first", + ), + Column( + Section( + SectionTitle("Rich"), + TextContent(Markdown(RICH_MD)), + SubTitle("Pretty Printed data (try resizing the terminal)"), + Static(Pretty(DATA, indent_guides=True), classes="pretty pad"), + SubTitle("JSON"), + Window(Static(JSON(JSON_EXAMPLE), expand=True), classes="pad"), + SubTitle("Tables"), + Static(example_table, classes="table pad"), + ), + classes="location-rich", + ), + Column( + Section( + SectionTitle("CSS"), + TextContent(Markdown(CSS_MD)), + Window( + Static( + Syntax( + example_css, + "css", + theme="material", + line_numbers=True, + ), + expand=True, + ) + ), + ), + classes="location-css", + ), + ), + ) + yield Footer() + + def action_open_link(self, link: str) -> None: + self.app.bell() + import webbrowser + + webbrowser.open(link) + + def action_toggle_sidebar(self) -> None: + sidebar = self.query_one(Sidebar) + self.set_focus(None) + if sidebar.has_class("-hidden"): + sidebar.remove_class("-hidden") + else: + if sidebar.query("*:focus"): + self.screen.set_focus(None) + sidebar.add_class("-hidden") + + def on_mount(self) -> None: + self.add_note("Textual Demo app is running") + table = self.query_one(DataTable) + 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(20): + table.add_row(*[f"Cell ([b]{n}[/b], {col})" for col in range(6)]) + self.query_one("Welcome Button", Button).focus() + + def action_screenshot(self, filename: str | None = None, path: str = "./") -> None: + """Save an SVG "screenshot". This action will save an SVG file containing the current contents of the screen. + + Args: + filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None. + path (str, optional): Path to directory. Defaults to "./". + """ + self.bell() + path = self.save_screenshot(filename, path) + message = Text.assemble("Screenshot saved to ", (f"'{path}'", "bold green")) + self.add_note(message) + self.screen.mount(Notification(message)) + + +app = DemoApp() +if __name__ == "__main__": + app.run() diff --git a/src/textual/design.py b/src/textual/design.py new file mode 100644 index 000000000..35650e9bf --- /dev/null +++ b/src/textual/design.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from typing import Iterable + +from rich.console import group +from rich.padding import Padding +from rich.table import Table +from rich.text import Text + +from .color import Color, WHITE + + +NUMBER_OF_SHADES = 3 + +# Where no content exists +DEFAULT_DARK_BACKGROUND = "#121212" +# What text usually goes on top off +DEFAULT_DARK_SURFACE = "#1e1e1e" + +DEFAULT_LIGHT_SURFACE = "#f5f5f5" +DEFAULT_LIGHT_BACKGROUND = "#efefef" + + +class ColorSystem: + """Defines a standard set of colors and variations for building a UI. + + Primary is the main theme color + Secondary is a second theme color + + + """ + + COLOR_NAMES = [ + "primary", + "secondary", + "background", + "primary-background", + "secondary-background", + "surface", + "panel", + "boost", + "warning", + "error", + "success", + "accent", + ] + + def __init__( + self, + primary: str, + secondary: str | None = None, + warning: str | None = None, + error: str | None = None, + success: str | None = None, + accent: str | None = None, + background: str | None = None, + surface: str | None = None, + panel: str | None = None, + boost: str | None = None, + dark: bool = False, + luminosity_spread: float = 0.15, + text_alpha: float = 0.95, + ): + def parse(color: str | None) -> Color | None: + if color is None: + return None + return Color.parse(color) + + self.primary = Color.parse(primary) + self.secondary = parse(secondary) + self.warning = parse(warning) + self.error = parse(error) + self.success = parse(success) + self.accent = parse(accent) + self.background = parse(background) + self.surface = parse(surface) + self.panel = parse(panel) + self.boost = parse(boost) + self._dark = dark + self._luminosity_spread = luminosity_spread + self._text_alpha = text_alpha + + @property + def shades(self) -> Iterable[str]: + """The names of the colors and derived shades.""" + for color in self.COLOR_NAMES: + for shade_number in range(-NUMBER_OF_SHADES, NUMBER_OF_SHADES + 1): + if shade_number < 0: + yield f"{color}-darken-{abs(shade_number)}" + elif shade_number > 0: + yield f"{color}-lighten-{shade_number}" + else: + yield color + + def generate(self) -> dict[str, str]: + """Generate a mapping of color name on to a CSS color. + + Args: + dark (bool, optional): Enable dark mode. Defaults to False. + luminosity_spread (float, optional): Amount of luminosity to subtract and add to generate + shades. Defaults to 0.2. + text_alpha (float, optional): Alpha value for text. Defaults to 0.9. + + Returns: + dict[str, str]: A mapping of color name on to a CSS-style encoded color + + """ + + primary = self.primary + secondary = self.secondary or primary + warning = self.warning or primary + error = self.error or secondary + success = self.success or secondary + accent = self.accent or primary + + dark = self._dark + luminosity_spread = self._luminosity_spread + + if dark: + background = self.background or Color.parse(DEFAULT_DARK_BACKGROUND) + surface = self.surface or Color.parse(DEFAULT_DARK_SURFACE) + else: + background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND) + surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE) + + foreground = background.inverse + + boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.04) + + if self.panel is None: + panel = surface.blend(primary, 0.1, alpha=1) + if dark: + panel += boost + else: + panel = self.panel + + colors: dict[str, str] = {} + + def luminosity_range(spread) -> Iterable[tuple[str, float]]: + """Get the range of shades from darken2 to lighten2. + + Returns: + Iterable of tuples () + + """ + luminosity_step = spread / 2 + for n in range(-NUMBER_OF_SHADES, +NUMBER_OF_SHADES + 1): + if n < 0: + label = "-darken" + elif n > 0: + label = "-lighten" + else: + label = "" + yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step + + # Color names and color + COLORS: list[tuple[str, Color]] = [ + ("primary", primary), + ("secondary", secondary), + ("primary-background", primary), + ("secondary-background", secondary), + ("background", background), + ("foreground", foreground), + ("panel", panel), + ("boost", boost), + ("surface", surface), + ("warning", warning), + ("error", error), + ("success", success), + ("accent", accent), + ] + + # Colors names that have a dark variant + DARK_SHADES = {"primary-background", "secondary-background"} + + for name, color in COLORS: + is_dark_shade = dark and name in DARK_SHADES + spread = luminosity_spread + for shade_name, luminosity_delta in luminosity_range(spread): + if is_dark_shade: + dark_background = background.blend(color, 0.15, alpha=1.0) + shade_color = dark_background.blend( + WHITE, spread + luminosity_delta, alpha=1.0 + ).clamped + colors[f"{name}{shade_name}"] = shade_color.hex + else: + shade_color = color.lighten(luminosity_delta) + colors[f"{name}{shade_name}"] = shade_color.hex + + colors["text"] = "auto 87%" + colors["text-muted"] = "auto 60%" + colors["text-disabled"] = "auto 38%" + + return colors + + +def show_design(light: ColorSystem, dark: ColorSystem) -> Table: + """Generate a renderable to show color systems. + + Args: + light (ColorSystem): Light ColorSystem. + dark (ColorSystem): Dark ColorSystem + + Returns: + Table: Table showing all colors. + + """ + + @group() + def make_shades(system: ColorSystem): + colors = system.generate() + for name in system.shades: + background = Color.parse(colors[name]).with_alpha(1.0) + foreground = background + background.get_contrast_text(0.9) + + text = Text(name) + + yield Padding(text, 1, style=f"{foreground.hex6} on {background.hex6}") + + table = Table(box=None, expand=True) + table.add_column("Light", justify="center") + table.add_column("Dark", justify="center") + table.add_row(make_shades(light), make_shades(dark)) + return table + + +if __name__ == "__main__": + from .app import DEFAULT_COLORS + + from rich import print + + print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])) diff --git a/src/textual/devtools/__init__.py b/src/textual/devtools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py new file mode 100644 index 000000000..6a3e77bc2 --- /dev/null +++ b/src/textual/devtools/client.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import pickle +from asyncio import Queue, QueueFull, Task +from io import StringIO +from time import time +from typing import Any, NamedTuple, Type + +from rich.console import Console +from rich.segment import Segment + +from .._log import LogGroup, LogVerbosity + + +import aiohttp +import msgpack +from aiohttp import ( + ClientConnectorError, + ClientResponseError, + ClientWebSocketResponse, +) + + +DEVTOOLS_PORT = 8081 +WEBSOCKET_CONNECT_TIMEOUT = 3 +LOG_QUEUE_MAXSIZE = 512 + + +class DevtoolsLog(NamedTuple): + """A devtools log message. + + Attributes: + objects_or_string (tuple[Any, ...]): Corresponds to the data that will + ultimately be passed to Console.print in order to generate the log + Segments. + caller (inspect.FrameInfo): Information about where this log message was + created. In other words, where did the user call `print` or `App.log` + from. Used to display line number and file name in the devtools window. + """ + + objects_or_string: tuple[Any, ...] | str + caller: inspect.FrameInfo + + +class DevtoolsConsole(Console): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.record = True + + def export_segments(self) -> list[Segment]: + """Return the list of Segments that have be printed using this console + + Returns: + list[Segment]: The list of Segments that have been printed using this console + """ + with self._record_buffer_lock: + segments = self._record_buffer[:] + self._record_buffer.clear() + return segments + + +class DevtoolsConnectionError(Exception): + """Raise when the devtools client is unable to connect to the server""" + + +class ClientShutdown: + """Sentinel type sent to client queue(s) to indicate shutdown""" + + +class DevtoolsClient: + """Client responsible for websocket communication with the devtools server. + Communicates using a simple JSON protocol. + + Messages have the format `{"type": , "payload": }`. + + Valid values for `"type"` (that can be sent from client -> server) are + `"client_log"` (for log messages) and `"client_spillover"` (for reporting + to the server that messages were discarded due to rate limiting). + + A `"client_log"` message has a `"payload"` format as follows: + ``` + {"timestamp": , + "path": , + "line_number": , + "encoded_segments": } + ``` + + A `"client_spillover"` message has a `"payload"` format as follows: + ``` + {"spillover": } + ``` + + Args: + host (str): The host the devtools server is running on, defaults to "127.0.0.1" + port (int): The port the devtools server is accessed via, defaults to 8081 + """ + + def __init__(self, host: str = "127.0.0.1", port: int = DEVTOOLS_PORT) -> None: + self.url: str = f"ws://{host}:{port}" + self.session: aiohttp.ClientSession | None = None + self.log_queue_task: Task | None = None + self.update_console_task: Task | None = None + self.console: DevtoolsConsole = DevtoolsConsole(file=StringIO()) + self.websocket: ClientWebSocketResponse | None = None + self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None + self.spillover: int = 0 + self.verbose: bool = False + + async def connect(self) -> None: + """Connect to the devtools server. + + Raises: + DevtoolsConnectionError: If we're unable to establish + a connection to the server for any reason. + """ + self.session = aiohttp.ClientSession() + self.log_queue = Queue(maxsize=LOG_QUEUE_MAXSIZE) + try: + self.websocket = await self.session.ws_connect( + f"{self.url}/textual-devtools-websocket", + timeout=WEBSOCKET_CONNECT_TIMEOUT, + ) + except (ClientConnectorError, ClientResponseError): + raise DevtoolsConnectionError() + + log_queue = self.log_queue + websocket = self.websocket + + async def update_console() -> None: + """Coroutine function scheduled as a Task, which listens on + the websocket for updates from the server regarding any changes + in the server Console dimensions. When the client learns of this + change, it will update its own Console to ensure it renders at + the correct width for server-side display. + """ + async for message in self.websocket: + if message.type == aiohttp.WSMsgType.TEXT: + message_json = json.loads(message.data) + if message_json["type"] == "server_info": + payload = message_json["payload"] + self.console.width = payload["width"] + self.console.height = payload["height"] + self.verbose = payload.get("verbose", False) + + async def send_queued_logs(): + """Coroutine function which is scheduled as a Task, which consumes + messages from the log queue and sends them to the server via websocket. + """ + while True: + log = await log_queue.get() + if log is ClientShutdown: + log_queue.task_done() + break + if isinstance(log, str): + await websocket.send_str(log) + else: + assert isinstance(log, bytes) + await websocket.send_bytes(log) + log_queue.task_done() + + self.log_queue_task = asyncio.create_task(send_queued_logs()) + self.update_console_task = asyncio.create_task(update_console()) + + async def _stop_log_queue_processing(self) -> None: + """Schedule end of processing of the log queue, meaning that any messages a + user logs will be added to the queue, but not consumed and sent to + the server. + """ + if self.log_queue is not None: + await self.log_queue.put(ClientShutdown) + if self.log_queue_task: + await self.log_queue_task + + async def _stop_incoming_message_processing(self) -> None: + """Schedule stop of the task which listens for incoming messages from the + server around changes in the server console size. + """ + if self.websocket: + await self.websocket.close() + if self.update_console_task: + await self.update_console_task + if self.session: + await self.session.close() + + async def disconnect(self) -> None: + """Disconnect from the devtools server by stopping tasks and + closing connections. + """ + await self._stop_log_queue_processing() + await self._stop_incoming_message_processing() + + @property + def is_connected(self) -> bool: + """Checks connection to devtools server. + + Returns: + bool: True if this host is connected to the server. False otherwise. + """ + if not self.session or not self.websocket: + return False + return not (self.session.closed or self.websocket.closed) + + def log( + self, + log: DevtoolsLog, + group: LogGroup = LogGroup.UNDEFINED, + verbosity: LogVerbosity = LogVerbosity.NORMAL, + ) -> None: + """Queue a log to be sent to the devtools server for display. + + Args: + log (DevtoolsLog): The log to write to devtools + """ + if isinstance(log.objects_or_string, str): + self.console.print(log.objects_or_string) + else: + self.console.print(*log.objects_or_string) + + segments = self.console.export_segments() + + encoded_segments = self._encode_segments(segments) + message: bytes | None = msgpack.packb( + { + "type": "client_log", + "payload": { + "group": group.value, + "verbosity": verbosity.value, + "timestamp": int(time()), + "path": getattr(log.caller, "filename", ""), + "line_number": getattr(log.caller, "lineno", 0), + "segments": encoded_segments, + }, + } + ) + assert message is not None + try: + if self.log_queue: + self.log_queue.put_nowait(message) + if self.spillover > 0 and self.log_queue.qsize() < LOG_QUEUE_MAXSIZE: + # Tell the server how many messages we had to discard due + # to the log queue filling to capacity on the client. + spillover_message = json.dumps( + { + "type": "client_spillover", + "payload": { + "spillover": self.spillover, + }, + } + ) + self.log_queue.put_nowait(spillover_message) + self.spillover = 0 + except QueueFull: + self.spillover += 1 + + @classmethod + def _encode_segments(cls, segments: list[Segment]) -> bytes: + """Pickle a list of Segments + + Args: + segments (list[Segment]): A list of Segments to encode + + Returns: + bytes: The Segment list pickled with the latest protocol. + """ + pickled = pickle.dumps(segments, protocol=4) + return pickled diff --git a/src/textual/devtools/redirect_output.py b/src/textual/devtools/redirect_output.py new file mode 100644 index 000000000..bed976274 --- /dev/null +++ b/src/textual/devtools/redirect_output.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import inspect + +from typing import TYPE_CHECKING, cast +from .client import DevtoolsLog +from .._log import LogGroup, LogVerbosity + +if TYPE_CHECKING: + from .client import DevtoolsClient + + +class StdoutRedirector: + """ + A write-only file-like object which redirects anything written to it to the devtools + instance associated with the given Textual application. Used within Textual to redirect + data written using `print` (or any other stdout writes) to the devtools and/or to the + log file. + """ + + def __init__(self, devtools: DevtoolsClient) -> None: + """ + Args: + devtools (DevtoolsClient): The running Textual app instance. + log_file (TextIOWrapper): The log file for the Textual App. + """ + self.devtools = devtools + self._buffer: list[DevtoolsLog] = [] + + def write(self, string: str) -> None: + """Write the log string to the internal buffer. If the string contains + a newline character `\n`, the whole string will be buffered and then the + buffer will be flushed immediately after. + + Args: + string (str): The string to write to the buffer. + """ + + if not self.devtools.is_connected: + return + + previous_frame = inspect.currentframe().f_back + caller = inspect.getframeinfo(previous_frame) + + self._buffer.append(DevtoolsLog(string, caller=caller)) + + # By default, `print` adds a "\n" suffix which results in a buffer + # flush. You can choose a different suffix with the `end` parameter. + # If you modify the `end` parameter to something other than "\n", + # then `print` will no longer flush automatically. However, if a + # string you are printing contains a "\n", that will trigger + # a flush after that string has been buffered, regardless of the value + # of `end`. + if "\n" in string: + self.flush() + + def flush(self) -> None: + """Flush the buffer. This will send all buffered log messages to + the devtools server and the log file. In the case of the devtools, + where possible, log messages will be batched and sent as one. + """ + self._write_to_devtools() + self._buffer.clear() + + def _write_to_devtools(self) -> None: + """Send the contents of the buffer to the devtools.""" + if not self.devtools.is_connected: + return + + log_batch: list[DevtoolsLog] = [] + for log in self._buffer: + end_of_batch = log_batch and ( + log_batch[-1].caller.filename != log.caller.filename + or log_batch[-1].caller.lineno != log.caller.lineno + ) + if end_of_batch: + self._log_devtools_batched(log_batch) + log_batch.clear() + log_batch.append(log) + if log_batch: + self._log_devtools_batched(log_batch) + + def _log_devtools_batched(self, log_batch: list[DevtoolsLog]) -> None: + """Write a single batch of logs to devtools. A batch means contiguous logs + which have been written from the same line number and file path. + A single `print` call may correspond to multiple writes. + e.g. `print("a", "b", "c")` is 3 calls to `write`, so we batch + up these 3 write calls since they come from the same location, so that + they appear inside the same log message in the devtools window + rather than a single `print` statement resulting in 3 separate + logs being displayed. + + Args: + log_batch (list[DevtoolsLog]): A batch of logs to send to the + devtools server as one. Log content will be joined together. + """ + + # This code is only called via stdout.write, and so by this point we know + # that the log message content is a string. The cast below tells mypy this. + batched_log = "".join(cast(str, log.objects_or_string) for log in log_batch) + batched_log = batched_log.rstrip() + self.devtools.log( + DevtoolsLog(batched_log, caller=log_batch[-1].caller), + LogGroup.PRINT, + LogVerbosity.NORMAL, + ) diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py new file mode 100644 index 000000000..4f6f40d7b --- /dev/null +++ b/src/textual/devtools/renderables.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from typing import Iterable + +from importlib_metadata import version + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +from rich.align import Align +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.styled import Styled +from rich.table import Table +from rich.text import Text +from textual._log import LogGroup + +DevConsoleMessageLevel = Literal["info", "warning", "error"] + + +class DevConsoleHeader: + def __init__(self, verbose: bool = False) -> None: + self.verbose = verbose + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + 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." + ) + if self.verbose: + preamble.append(Text.from_markup("\n[cyan]Verbose logs enabled")) + render_options = options.update(width=options.max_width - 4) + 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 + yield new_line + + +class DevConsoleLog: + """Renderable representing a single log message + + Args: + segments (Iterable[Segment]): The segments to display + path (str): The path of the file on the client that the log call was made from + line_number (int): The line number of the file on the client the log call was made from + unix_timestamp (int): Seconds since January 1st 1970 + """ + + def __init__( + self, + segments: Iterable[Segment], + path: str, + line_number: int, + unix_timestamp: int, + group: int, + verbosity: int, + severity: int, + ) -> None: + self.segments = segments + self.path = path + self.line_number = line_number + self.unix_timestamp = unix_timestamp + self.group = group + self.verbosity = verbosity + self.severity = severity + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + local_time = datetime.fromtimestamp(self.unix_timestamp) + table = Table.grid(expand=True) + + file_link = escape(f"file://{Path(self.path).absolute()}") + file_and_line = escape(f"{Path(self.path).name}:{self.line_number}") + group = LogGroup(self.group).name + time = local_time.time() + + group_text = Text(group) + if group == "WARNING": + group_text.stylize("bold yellow reverse") + elif group == "ERROR": + group_text.stylize("bold red reverse") + else: + group_text.stylize("dim") + + log_message = Text.assemble((f"[{time}]", "dim"), " ", group_text) + + table.add_row( + log_message, + Align.right( + Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) + ), + ) + yield table + + if group == "PRINT": + yield Styled(Segments(self.segments), "bold") + else: + yield from self.segments + + +class DevConsoleNotice: + """Renderable for messages written by the devtools console itself + + Args: + message (str): The message to display + level (DevtoolsMessageLevel): The message level ("info", "warning", or "error"). + Determines colors used to render the message and the perceived importance. + """ + + def __init__(self, message: str, *, level: DevConsoleMessageLevel = "info") -> None: + self.message = message + self.level = level + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + level_to_style = { + "info": "dim", + "warning": "yellow", + "error": "red", + } + yield Rule(self.message, style=level_to_style.get(self.level, "dim")) diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py new file mode 100644 index 000000000..ab13ca8ee --- /dev/null +++ b/src/textual/devtools/server.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import asyncio +from aiohttp.web import run_app +from aiohttp.web_app import Application +from aiohttp.web_request import Request +from aiohttp.web_routedef import get +from aiohttp.web_ws import WebSocketResponse + +from textual.devtools.client import DEVTOOLS_PORT +from textual.devtools.service import DevtoolsService + +DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS = 2 + + +async def websocket_handler(request: Request) -> WebSocketResponse: + """aiohttp websocket handler for sending data between devtools client and server + + Args: + request (Request): The request to the websocket endpoint + + Returns: + WebSocketResponse: The websocket response + """ + service: DevtoolsService = request.app["service"] + return await service.handle(request) + + +async def _on_shutdown(app: Application) -> None: + """aiohttp shutdown handler, called when the aiohttp server is stopped""" + service: DevtoolsService = app["service"] + await service.shutdown() + + +async def _on_startup(app: Application) -> None: + service: DevtoolsService = app["service"] + await service.start() + + +def _run_devtools(verbose: bool, exclude: list[str] | None = None) -> None: + app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude) + + def noop_print(_: str): + return None + + try: + run_app( + app, port=DEVTOOLS_PORT, print=noop_print, loop=asyncio.get_event_loop() + ) + except OSError: + from rich import print + + print() + print("[bold red]Couldn't start server") + print("Is there another instance of [reverse]textual console[/] running?") + + +def _make_devtools_aiohttp_app( + size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS, + verbose: bool = False, + exclude: list[str] | None = None, +) -> Application: + app = Application() + + app.on_shutdown.append(_on_shutdown) + app.on_startup.append(_on_startup) + + app["verbose"] = verbose + app["service"] = DevtoolsService( + update_frequency=size_change_poll_delay_secs, verbose=verbose, exclude=exclude + ) + + app.add_routes( + [ + get("/textual-devtools-websocket", websocket_handler), + ] + ) + + return app + + +if __name__ == "__main__": + _run_devtools() diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py new file mode 100644 index 000000000..c3deabfe3 --- /dev/null +++ b/src/textual/devtools/service.py @@ -0,0 +1,291 @@ +"""Manages a running devtools instance""" +from __future__ import annotations + +import asyncio +import base64 +import json +import pickle +from json import JSONDecodeError +from typing import cast + +from aiohttp import WSMessage, WSMsgType +from aiohttp.abc import Request +from aiohttp.web_ws import WebSocketResponse +from rich.console import Console +from rich.markup import escape +import msgpack + +from textual._log import LogGroup +from textual._time import time +from textual.devtools.renderables import ( + DevConsoleLog, + DevConsoleNotice, + DevConsoleHeader, +) + +QUEUEABLE_TYPES = {"client_log", "client_spillover"} + + +class DevtoolsService: + """A running instance of devtools has a single DevtoolsService which is + responsible for tracking connected client applications. + """ + + def __init__( + self, + update_frequency: float, + verbose: bool = False, + exclude: list[str] | None = None, + ) -> None: + """ + Args: + update_frequency (float): The number of seconds to wait between + sending updates of the console size to connected clients. + verbose (bool): Enable verbose logging on client. + exclude (list[str]): List of log groups to exclude from output. + """ + self.update_frequency = update_frequency + self.verbose = verbose + self.exclude = set(name.upper() for name in exclude) if exclude else set() + self.console = Console() + self.shutdown_event = asyncio.Event() + self.clients: list[ClientHandler] = [] + + async def start(self): + """Starts devtools tasks""" + self.size_poll_task = asyncio.create_task(self._console_size_poller()) + self.console.print(DevConsoleHeader(verbose=self.verbose)) + + @property + def clients_connected(self) -> bool: + """Returns True if there are connected clients, False otherwise.""" + return len(self.clients) > 0 + + async def _console_size_poller(self) -> None: + """Poll console dimensions, and add a `server_info` message to the Queue + any time a change occurs. We only poll if there are clients connected, + and if we're not shutting down the server. + """ + current_width = self.console.width + current_height = self.console.height + await self._send_server_info_to_all() + while not self.shutdown_event.is_set(): + width = self.console.width + height = self.console.height + dimensions_changed = width != current_width or height != current_height + if dimensions_changed: + await self._send_server_info_to_all() + current_width = width + current_height = height + try: + await asyncio.wait_for( + self.shutdown_event.wait(), timeout=self.update_frequency + ) + except asyncio.TimeoutError: + pass + + async def _send_server_info_to_all(self) -> None: + """Add `server_info` message to the queues of every client""" + for client_handler in self.clients: + await self.send_server_info(client_handler) + + async def send_server_info(self, client_handler: ClientHandler) -> None: + """Send information about the server e.g. width and height of Console to + a connected client. + + Args: + client_handler (ClientHandler): The client to send information to + """ + await client_handler.send_message( + { + "type": "server_info", + "payload": { + "width": self.console.width, + "height": self.console.height, + "verbose": self.verbose, + }, + } + ) + + async def handle(self, request: Request) -> WebSocketResponse: + """Handles a single client connection""" + client = ClientHandler(request, service=self) + self.clients.append(client) + websocket = await client.run() + self.clients.remove(client) + return websocket + + async def shutdown(self) -> None: + """Stop server async tasks and clean up all client handlers""" + + # Stop polling/writing Console dimensions to clients + self.shutdown_event.set() + await self.size_poll_task + + # We're shutting down the server, so inform all connected clients + for client in self.clients: + await client.close() + self.clients.clear() + + +class ClientHandler: + """Handles a single client connection to the devtools. + A single DevtoolsService managers many ClientHandlers. A single ClientHandler + corresponds to a single running Textual application instance, and is responsible + for communication with that Textual app. + """ + + def __init__(self, request: Request, service: DevtoolsService) -> None: + """ + Args: + request (Request): The aiohttp.Request associated with this client + service (DevtoolsService): The parent DevtoolsService which is responsible + for the handling of this client. + """ + self.request = request + self.service = service + self.websocket = WebSocketResponse() + + async def send_message(self, message: dict[str, object]) -> None: + """Send a message to a client + + Args: + message (dict[str, object]): The dict which will be sent + to the client. + """ + await self.outgoing_queue.put(message) + + async def _consume_outgoing(self) -> None: + """Consume messages from the outgoing (server -> client) Queue.""" + while True: + message_json = await self.outgoing_queue.get() + if message_json is None: + self.outgoing_queue.task_done() + break + type = message_json["type"] + if type == "server_info": + await self.websocket.send_json(message_json) + self.outgoing_queue.task_done() + + async def _consume_incoming(self) -> None: + """Consume messages from the incoming (client -> server) Queue, and print + the corresponding renderables to the console for each message. + """ + last_message_time: float | None = None + while True: + message = await self.incoming_queue.get() + if message is None: + self.incoming_queue.task_done() + break + + type = message["type"] + if type == "client_log": + payload = message["payload"] + if LogGroup(payload.get("group", 0)).name in self.service.exclude: + continue + encoded_segments = payload["segments"] + segments = pickle.loads(encoded_segments) + message_time = time() + if ( + last_message_time is not None + and message_time - last_message_time > 0.5 + ): + # Print a rule if it has been longer than half a second since the last message + self.service.console.rule() + self.service.console.print( + DevConsoleLog( + segments=segments, + path=payload["path"], + line_number=payload["line_number"], + unix_timestamp=payload["timestamp"], + group=payload.get("group", 0), + verbosity=payload.get("verbosity", 0), + severity=payload.get("severity", 0), + ) + ) + last_message_time = message_time + elif type == "client_spillover": + spillover = int(message["payload"]["spillover"]) + info_renderable = DevConsoleNotice( + f"Discarded {spillover} messages", level="warning" + ) + self.service.console.print(info_renderable) + self.incoming_queue.task_done() + + async def run(self) -> WebSocketResponse: + """Prepare the websocket and communication queues, and continuously + read messages from the queues. + + Returns: + WebSocketResponse: The WebSocketResponse associated with this client. + """ + + await self.websocket.prepare(self.request) + self.incoming_queue: asyncio.Queue[dict | None] = asyncio.Queue() + self.outgoing_queue: asyncio.Queue[dict | None] = asyncio.Queue() + self.outgoing_messages_task = asyncio.create_task(self._consume_outgoing()) + self.incoming_messages_task = asyncio.create_task(self._consume_incoming()) + + if self.request.remote: + self.service.console.print( + DevConsoleNotice(f"Client '{escape(self.request.remote)}' connected") + ) + try: + await self.service.send_server_info(client_handler=self) + async for message in self.websocket: + message = cast(WSMessage, message) + + if message.type in (WSMsgType.TEXT, WSMsgType.BINARY): + + try: + if isinstance(message.data, bytes): + message = msgpack.unpackb(message.data) + else: + message = json.loads(message.data) + except JSONDecodeError: + self.service.console.print(escape(str(message.data))) + continue + + type = message.get("type") + if not type: + continue + if ( + type in QUEUEABLE_TYPES + and not self.service.shutdown_event.is_set() + ): + await self.incoming_queue.put(message) + elif message.type == WSMsgType.ERROR: + self.service.console.print( + DevConsoleNotice("Websocket error occurred", level="error") + ) + break + except Exception as error: + self.service.console.print(DevConsoleNotice(str(error), level="error")) + finally: + if self.request.remote: + self.service.console.print( + "\n", + DevConsoleNotice( + f"Client '{escape(self.request.remote)}' disconnected" + ), + ) + await self.close() + + return self.websocket + + async def close(self) -> None: + """Stop all incoming/outgoing message processing, + and shutdown the websocket connection associated with this + client. + """ + + # Stop any writes to the websocket first + await self.outgoing_queue.put(None) + await self.outgoing_messages_task + + # Now we can shut the socket down + await self.websocket.close() + + # This task is independent of the websocket + await self.incoming_queue.put(None) + await self.incoming_messages_task diff --git a/src/textual/dom.py b/src/textual/dom.py new file mode 100644 index 000000000..1a35b0717 --- /dev/null +++ b/src/textual/dom.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import re +import sys +from collections import deque +from inspect import getfile +from typing import ( + TYPE_CHECKING, + ClassVar, + Iterable, + Iterator, + Type, + TypeVar, + cast, + overload, +) + +import rich.repr +from rich.highlighter import ReprHighlighter +from rich.pretty import Pretty +from rich.style import Style +from rich.text import Text +from rich.tree import Tree + +from ._context import NoActiveAppError +from ._node_list import NodeList +from .binding import Bindings, BindingType +from .color import BLACK, WHITE, Color +from .css._error_tools import friendly_list +from .css.constants import VALID_DISPLAY, VALID_VISIBILITY +from .css.errors import DeclarationError, StyleValueError +from .css.parse import parse_declarations +from .css.query import NoMatches +from .css.styles import RenderStyles, Styles +from .css.tokenize import IDENTIFIER +from .message_pump import MessagePump +from .timer import Timer + +if TYPE_CHECKING: + from .app import App + from .css.query import DOMQuery + from .screen import Screen + from .widget import Widget + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias + + +_re_identifier = re.compile(IDENTIFIER) + + +WalkMethod: TypeAlias = Literal["depth", "breadth"] + + +class BadIdentifier(Exception): + """raised by check_identifiers.""" + + +def check_identifiers(description: str, *names: str) -> None: + """Validate identifier and raise an error if it fails. + + Args: + description (str): Description of where identifier is used for error message. + names (list[str]): Identifiers to check. + + Returns: + bool: True if the name is valid. + """ + match = _re_identifier.match + for name in names: + if match(name) is None: + raise BadIdentifier( + f"{name!r} is an invalid {description}; " + "identifiers must contain only letters, numbers, underscores, or hyphens, and must not begin with a number." + ) + + +class DOMError(Exception): + pass + + +class NoScreen(DOMError): + pass + + +@rich.repr.auto +class DOMNode(MessagePump): + """The base class for object that can be in the Textual DOM (App and Widget)""" + + # CSS defaults + DEFAULT_CSS: ClassVar[str] = "" + + # Default classes argument if not supplied + DEFAULT_CLASSES: str = "" + + # Virtual DOM nodes + COMPONENT_CLASSES: ClassVar[set[str]] = set() + + # Mapping of key bindings + BINDINGS: ClassVar[list[BindingType]] = [] + + # True if this node inherits the CSS from the base class. + _inherit_css: ClassVar[bool] = True + # List of names of base class (lower cased) that inherit CSS + _css_type_names: ClassVar[frozenset[str]] = frozenset() + + def __init__( + self, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + self._classes = set() + self._name = name + self._id = None + if id is not None: + self.id = id + + _classes = classes.split() if classes else [] + check_identifiers("class name", *_classes) + self._classes.update(_classes) + + self.children = NodeList() + self._css_styles: Styles = Styles(self) + self._inline_styles: Styles = Styles(self) + self.styles = RenderStyles(self, self._css_styles, self._inline_styles) + # A mapping of class names to Styles set in COMPONENT_CLASSES + self._component_styles: dict[str, RenderStyles] = {} + + self._auto_refresh: float | None = None + self._auto_refresh_timer: Timer | None = None + self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)} + self._bindings = Bindings(self.BINDINGS) + self._has_hover_style: bool = False + self._has_focus_within: bool = False + + super().__init__() + + @property + def auto_refresh(self) -> float | None: + return self._auto_refresh + + @auto_refresh.setter + def auto_refresh(self, interval: float | None) -> None: + if self._auto_refresh_timer is not None: + self._auto_refresh_timer.stop_no_wait() + self._auto_refresh_timer = None + if interval is not None: + self._auto_refresh_timer = self.set_interval( + interval, self._automatic_refresh, name=f"auto refresh {self!r}" + ) + self._auto_refresh = interval + + def _automatic_refresh(self) -> None: + """Perform an automatic refresh (set with auto_refresh property).""" + self.refresh() + + def __init_subclass__(cls, inherit_css: bool = True) -> None: + super().__init_subclass__() + cls._inherit_css = inherit_css + css_type_names: set[str] = set() + for base in cls._css_bases(cls): + css_type_names.add(base.__name__.lower()) + cls._css_type_names = frozenset(css_type_names) + + def get_component_styles(self, name: str) -> RenderStyles: + """Get a "component" styles object (must be defined in COMPONENT_CLASSES classvar). + + Args: + name (str): Name of the component. + + Raises: + KeyError: If the component class doesn't exist. + + Returns: + RenderStyles: A Styles object. + """ + if name not in self._component_styles: + raise KeyError(f"No {name!r} key in COMPONENT_CLASSES") + styles = self._component_styles[name] + return styles + + @property + def _node_bases(self) -> Iterator[Type[DOMNode]]: + """Get the DOMNode bases classes (including self.__class__) + + Returns: + Iterator[Type[DOMNode]]: An iterable of DOMNode classes. + """ + # Node bases are in reversed order so that the base class is lower priority + return self._css_bases(self.__class__) + + @classmethod + def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]: + """Get the DOMNode base classes, which inherit CSS. + + Args: + base (Type[DOMNode]): A DOMNode class + + Returns: + Iterator[Type[DOMNode]]: An iterable of DOMNode classes. + """ + _class = base + while True: + yield _class + if not _class._inherit_css: + break + for _base in _class.__bases__: + if issubclass(_base, DOMNode): + _class = _base + break + else: + break + + def _post_register(self, app: App) -> None: + """Called when the widget is registered + + Args: + app (App): Parent application. + """ + + def __rich_repr__(self) -> rich.repr.Result: + yield "name", self._name, None + yield "id", self._id, None + if self._classes: + yield "classes", " ".join(self._classes) + + def get_default_css(self) -> list[tuple[str, str, int]]: + """Gets the CSS for this class and inherited from bases. + + Returns: + list[tuple[str, str]]: a list of tuples containing (PATH, SOURCE) for this + and inherited from base classes. + """ + + css_stack: list[tuple[str, str, int]] = [] + + def get_path(base: Type[DOMNode]) -> str: + """Get a path to the DOM Node""" + try: + return f"{getfile(base)}:{base.__name__}" + except TypeError: + return f"{base.__name__}" + + for tie_breaker, base in enumerate(self._node_bases): + css = base.DEFAULT_CSS.strip() + if css: + css_stack.append((get_path(base), css, -tie_breaker)) + + return css_stack + + @property + def parent(self) -> DOMNode | None: + """Get the parent node. + + Returns: + DOMNode | None: The node which is the direct parent of this node. + """ + + return cast("DOMNode | None", self._parent) + + @property + def screen(self) -> "Screen": + """Get the screen that this node is contained within. Note that this may not be the currently active screen within the app.""" + # Get the node by looking up a chain of parents + # Note that self.screen may not be the same as self.app.screen + from .screen import Screen + + node = self + while node and not isinstance(node, Screen): + node = node._parent + if not isinstance(node, Screen): + raise NoScreen("node has no screen") + return node + + @property + def id(self) -> str | None: + """The ID of this node, or None if the node has no ID. + + Returns: + (str | None): A Node ID or None. + """ + return self._id + + @id.setter + def id(self, new_id: str) -> str: + """Sets the ID (may only be done once). + + Args: + new_id (str): ID for this node. + + Raises: + ValueError: If the ID has already been set. + + """ + check_identifiers("id", new_id) + + if self._id is not None: + raise ValueError( + f"Node 'id' attribute may not be changed once set (current id={self._id!r})" + ) + self._id = new_id + return new_id + + @property + def name(self) -> str | None: + return self._name + + @property + def css_identifier(self) -> str: + """A CSS selector that identifies this DOM node.""" + tokens = [self.__class__.__name__] + if self.id is not None: + tokens.append(f"#{self.id}") + return "".join(tokens) + + @property + def css_identifier_styled(self) -> Text: + """A stylized CSS identifier.""" + tokens = Text.styled(self.__class__.__name__) + if self.id is not None: + tokens.append(f"#{self.id}", style="bold") + if self.classes: + tokens.append(".") + tokens.append(".".join(class_name for class_name in self.classes), "italic") + if self.name: + tokens.append(f"[name={self.name}]", style="underline") + return tokens + + @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 + def pseudo_classes(self) -> frozenset[str]: + """Get a set of all pseudo classes""" + pseudo_classes = frozenset({*self.get_pseudo_classes()}) + return pseudo_classes + + @property + def css_path_nodes(self) -> list[DOMNode]: + """A list of nodes from the root to this node, forming a "path". + + Returns: + list[DOMNode]: List of Nodes, starting with the root and ending with this node. + """ + result: list[DOMNode] = [self] + append = result.append + + node: DOMNode = self + while isinstance(node._parent, DOMNode): + node = node._parent + append(node) + return result[::-1] + + @property + def _selector_names(self) -> list[str]: + """Get a set of selectors applicable to this widget. + + Returns: + set[str]: Set of selector names. + """ + selectors: list[str] = [ + "*", + *(f".{class_name}" for class_name in self._classes), + *(f":{class_name}" for class_name in self.get_pseudo_classes()), + *self._css_types, + ] + if self._id is not None: + selectors.append(f"#{self._id}") + return selectors + + @property + def display(self) -> bool: + """ + Check if this widget should display or not. + + Returns: + bool: ``True`` if this DOMNode is displayed (``display != "none"``) otherwise ``False`` . + """ + return self.styles.display != "none" and not (self._closing or self._closed) + + @display.setter + def display(self, new_val: bool | str) -> None: + """ + Args: + new_val (bool | str): Shortcut to set the ``display`` CSS property. + ``False`` will set ``display: none``. ``True`` will set ``display: block``. + A ``False`` value will prevent the DOMNode from consuming space in the layout. + """ + # TODO: This will forget what the original "display" value was, so if a user + # toggles to False then True, we'll reset to the default "block", rather than + # what the user initially specified. + if isinstance(new_val, bool): + self.styles.display = "block" if new_val else "none" + elif new_val in VALID_DISPLAY: + self.styles.display = new_val + else: + raise StyleValueError( + f"invalid value for display (received {new_val!r}, " + f"expected {friendly_list(VALID_DISPLAY)})", + ) + + @property + def visible(self) -> bool: + """Check if the node is visible or None. + + Returns: + bool: True if the node is visible. + """ + return self.styles.visibility != "hidden" + + @visible.setter + def visible(self, new_value: bool) -> None: + if isinstance(new_value, bool): + self.styles.visibility = "visible" if new_value else "hidden" + elif new_value in VALID_VISIBILITY: + self.styles.visibility = new_value + else: + raise StyleValueError( + f"invalid value for visibility (received {new_value!r}, " + f"expected {friendly_list(VALID_VISIBILITY)})" + ) + + @property + def tree(self) -> Tree: + """Get a Rich tree object which will recursively render the structure of the node tree. + + Returns: + Tree: A Rich object which may be printed. + """ + from rich.columns import Columns + from rich.console import Group + from rich.panel import Panel + + from .widget import Widget + + def render_info(node: DOMNode) -> Columns: + if isinstance(node, Widget): + info = Columns( + [ + Pretty(node), + highlighter(f"region={node.region!r}"), + highlighter( + f"virtual_size={node.virtual_size!r}", + ), + ] + ) + else: + info = Columns([Pretty(node)]) + return info + + highlighter = ReprHighlighter() + tree = Tree(render_info(self)) + + def add_children(tree, node): + for child in node.children: + info = render_info(child) + css = child.styles.css + if css: + info = Group( + info, + Panel.fit( + Text(child.styles.css), + border_style="dim", + title="css", + title_align="left", + ), + ) + branch = tree.add(info) + if tree.children: + add_children(branch, child) + + add_children(tree, self) + return tree + + @property + def text_style(self) -> Style: + """Get the text style object. + + A widget's style is influenced by its parent. for instance if a parent is bold, then + the child will also be bold. + + Returns: + Style: Rich Style object. + """ + return Style.combine( + node.styles.text_style for node in reversed(self.ancestors) + ) + + @property + def rich_style(self) -> Style: + """Get a Rich Style object for this DOMNode.""" + background = WHITE + color = BLACK + style = Style() + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + background += styles.background + if styles.has_rule("color"): + color = styles.color + style += styles.text_style + if styles.has_rule("auto_color") and styles.auto_color: + color = background.get_contrast_text(color.a) + style += Style.from_color( + (background + color).rich_color, background.rich_color + ) + return style + + @property + def background_colors(self) -> tuple[Color, Color]: + """Get the background color and the color of the parent's background. + + Returns: + tuple[Color, Color]: Tuple of (base background, background) + + """ + base_background = background = BLACK + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + base_background = background + background += styles.background + return (base_background, background) + + @property + def colors(self) -> tuple[Color, Color, Color, Color]: + """Gets the Widgets foreground and background colors, and its parent's (base) colors. + + Returns: + tuple[Color, Color, Color, Color]: Tuple of (base background, base color, background, color) + """ + base_background = background = WHITE + base_color = color = BLACK + for node in reversed(self.ancestors): + styles = node.styles + if styles.has_rule("background"): + base_background = background + background += styles.background + if styles.has_rule("color"): + base_color = color + if styles.auto_color: + color = background.get_contrast_text(color.a) + else: + color = styles.color + + return (base_background, base_color, background, color) + + @property + def ancestors(self) -> list[DOMNode]: + """Get a list of Nodes by tracing ancestors all the way back to App.""" + nodes: list[MessagePump | None] = [] + add_node = nodes.append + node: MessagePump | None = self + while node is not None: + add_node(node) + node = node._parent + return cast("list[DOMNode]", nodes) + + @property + def displayed_children(self) -> list[Widget]: + """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]: + """Get any pseudo classes applicable to this Node, e.g. hover, focus. + + Returns: + Iterable[str]: Iterable of strings, such as a generator. + """ + return () + + def reset_styles(self) -> None: + """Reset styles back to their initial state""" + from .widget import Widget + + for node in self.walk_children(): + node._css_styles.reset() + if isinstance(node, Widget): + node._set_dirty() + node._layout_required = True + + def _add_child(self, node: Widget) -> None: + """Add a new child node. + + Args: + node (DOMNode): A DOM node. + """ + self.children._append(node) + node._attach(self) + + def _add_children(self, *nodes: Widget, **named_nodes: Widget) -> None: + """Add multiple children to this node. + + Args: + *nodes (DOMNode): Positional args should be new DOM nodes. + **named_nodes (DOMNode): Keyword args will be assigned the argument name as an ID. + """ + _append = self.children._append + for node in nodes: + node._attach(self) + _append(node) + for node_id, node in named_nodes.items(): + node._attach(self) + _append(node) + node.id = node_id + + WalkType = TypeVar("WalkType") + + @overload + def walk_children( + self, + filter_type: type[WalkType], + *, + with_self: bool = True, + method: WalkMethod = "depth", + reverse: bool = False, + ) -> list[WalkType]: + ... + + @overload + def walk_children( + self, + *, + with_self: bool = True, + method: WalkMethod = "depth", + reverse: bool = False, + ) -> list[DOMNode]: + ... + + def walk_children( + self, + filter_type: type[WalkType] | None = None, + *, + with_self: bool = True, + method: WalkMethod = "depth", + reverse: bool = False, + ) -> list[DOMNode] | list[WalkType]: + """Generate descendant nodes. + + Args: + filter_type (type[WalkType] | None, optional): Filter only this type, or None for no filter. + Defaults to None. + with_self (bool, optional): Also yield self in addition to descendants. Defaults to True. + method (Literal["breadth", "depth"], optional): One of "depth" or "breadth". Defaults to "depth". + reverse (bool, optional): Reverse the order (bottom up). Defaults to False. + + Returns: + list[DOMNode] | list[WalkType]: A list of nodes. + + """ + + def walk_depth_first() -> Iterable[DOMNode]: + """Walk the tree depth first (parents first).""" + stack: list[Iterator[DOMNode]] = [iter(self.children)] + pop = stack.pop + push = stack.append + check_type = filter_type or DOMNode + + if with_self and isinstance(self, check_type): + yield self + while stack: + node = next(stack[-1], None) + if node is None: + pop() + else: + if isinstance(node, check_type): + yield node + if node.children: + push(iter(node.children)) + + def walk_breadth_first() -> Iterable[DOMNode]: + """Walk the tree breadth first (children first).""" + queue: deque[DOMNode] = deque() + popleft = queue.popleft + extend = queue.extend + check_type = filter_type or DOMNode + + if with_self and isinstance(self, check_type): + yield self + extend(self.children) + while queue: + node = popleft() + if isinstance(node, check_type): + yield node + extend(node.children) + + node_generator = ( + walk_depth_first() if method == "depth" else walk_breadth_first() + ) + + # We want a snapshot of the DOM at this point So that it doesn't + # change mid-walk + nodes = list(node_generator) + if reverse: + nodes.reverse() + return nodes + + def get_child(self, id: str) -> DOMNode: + """Return the first child (immediate descendent) of this node with the given ID. + + Args: + id (str): The ID of the child. + + Returns: + DOMNode: The first child of this node with the ID. + + Raises: + NoMatches: if no children could be found for this ID + """ + for child in self.children: + if child.id == id: + return child + raise NoMatches(f"No child found with id={id!r}") + + ExpectType = TypeVar("ExpectType", bound="Widget") + + @overload + def query(self, selector: str | None) -> DOMQuery[Widget]: + ... + + @overload + def query(self, selector: type[ExpectType]) -> DOMQuery[ExpectType]: + ... + + def query( + self, selector: str | type[ExpectType] | None = None + ) -> DOMQuery[Widget] | DOMQuery[ExpectType]: + """Get a DOM query matching a selector. + + Args: + selector (str | type | None, optional): A CSS selector or `None` for all nodes. Defaults to None. + + Returns: + DOMQuery: A query object. + """ + from .css.query import DOMQuery + + query: str | None + if isinstance(selector, str) or selector is None: + query = selector + else: + query = selector.__name__ + + return DOMQuery(self, filter=query) + + @overload + def query_one(self, selector: str) -> Widget: + ... + + @overload + def query_one(self, selector: type[ExpectType]) -> ExpectType: + ... + + @overload + def query_one(self, selector: str, expect_type: type[ExpectType]) -> ExpectType: + ... + + def query_one( + self, + selector: str | type[ExpectType], + expect_type: type[ExpectType] | None = None, + ) -> ExpectType | Widget: + """Get the first Widget matching the given selector or selector type. + + Args: + selector (str | type): A selector. + expect_type (type | None, optional): Require the object be of the supplied type, or None for any type. + Defaults to None. + + Returns: + Widget | ExpectType: A widget matching the selector. + """ + from .css.query import DOMQuery + + if isinstance(selector, str): + query_selector = selector + else: + query_selector = selector.__name__ + query: DOMQuery[Widget] = DOMQuery(self, filter=query_selector) + + if expect_type is None: + return query.first() + else: + return query.first(expect_type) + + def set_styles(self, css: str | None = None, **update_styles) -> None: + """Set custom styles on this object.""" + + if css is not None: + try: + new_styles = parse_declarations(css, path="set_styles") + except DeclarationError as error: + raise DeclarationError(error.name, error.token, error.message) from None + self._inline_styles.merge(new_styles) + self.refresh(layout=True) + + styles = self.styles + for key, value in update_styles.items(): + setattr(styles, key, value) + + def has_class(self, *class_names: str) -> bool: + """Check if the Node has all the given class names. + + Args: + *class_names (str): CSS class names to check. + + Returns: + bool: ``True`` if the node has all the given class names, otherwise ``False``. + """ + return self._classes.issuperset(class_names) + + def set_class(self, add: bool, *class_names: str) -> None: + """Add or remove class(es) based on a condition. + + Args: + add (bool): Add the classes if True, otherwise remove them. + """ + if add: + self.add_class(*class_names) + else: + self.remove_class(*class_names) + + def add_class(self, *class_names: str) -> None: + """Add class names to this Node. + + Args: + *class_names (str): CSS class names to add. + + """ + check_identifiers("class name", *class_names) + old_classes = self._classes.copy() + self._classes.update(class_names) + if old_classes == self._classes: + return + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + + def remove_class(self, *class_names: str) -> None: + """Remove class names from this Node. + + Args: + *class_names (str): CSS class names to remove. + + """ + check_identifiers("class name", *class_names) + old_classes = self._classes.copy() + self._classes.difference_update(class_names) + if old_classes == self._classes: + return + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + + def toggle_class(self, *class_names: str) -> None: + """Toggle class names on this Node. + + Args: + *class_names (str): CSS class names to toggle. + + """ + check_identifiers("class name", *class_names) + old_classes = self._classes.copy() + self._classes.symmetric_difference_update(class_names) + if old_classes == self._classes: + return + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + + def has_pseudo_class(self, *class_names: str) -> bool: + """Check for pseudo class (such as hover, focus etc)""" + has_pseudo_classes = self.pseudo_classes.issuperset(class_names) + return has_pseudo_classes + + def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: + pass diff --git a/src/textual/driver.py b/src/textual/driver.py index d6b1fd5ba..559c5e014 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -1,13 +1,10 @@ from __future__ import annotations import asyncio -from time import time -import platform from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from . import events -from . import log +from . import _clock, events from ._types import MessageTarget if TYPE_CHECKING: @@ -15,11 +12,14 @@ if TYPE_CHECKING: class Driver(ABC): - def __init__(self, console: "Console", target: "MessageTarget") -> None: + def __init__( + self, console: "Console", target: "MessageTarget", debug: bool = False + ) -> None: self.console = console self._target = target - self._loop = asyncio.get_event_loop() - self._mouse_down_time = time() + self._debug = debug + self._loop = asyncio.get_running_loop() + self._mouse_down_time = _clock.get_time_no_wait() def send_event(self, event: events.Event) -> None: asyncio.run_coroutine_threadsafe( diff --git a/src/textual/drivers/headless_driver.py b/src/textual/drivers/headless_driver.py new file mode 100644 index 000000000..cdde957b9 --- /dev/null +++ b/src/textual/drivers/headless_driver.py @@ -0,0 +1,47 @@ +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: + 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 + + def stop_application_mode(self) -> None: + pass diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 45db1925a..e8c7bd00a 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -14,34 +14,43 @@ from threading import Event, Thread if TYPE_CHECKING: from rich.console import Console -from .. import log +import rich.repr -from .. import events +from .. import log from ..driver import Driver from ..geometry import Size from .._types import MessageTarget from .._xterm_parser import XTermParser from .._profile import timer +from .. import events +@rich.repr.auto class LinuxDriver(Driver): """Powers display and input for Linux / MacOS""" - def __init__(self, console: "Console", target: "MessageTarget") -> None: - super().__init__(console, target) + def __init__( + self, console: "Console", target: "MessageTarget", debug: bool = False + ) -> None: + super().__init__(console, target, debug) self.fileno = sys.stdin.fileno() self.attrs_before: list[Any] | None = None self.exit_event = Event() self._key_thread: Thread | None = None + def __rich_repr__(self) -> rich.repr.Result: + yield "debug", self._debug + def _get_terminal_size(self) -> tuple[int, int]: width: int | None = 80 height: int | None = 25 + import shutil + try: - width, height = os.get_terminal_size(sys.__stdin__.fileno()) + width, height = shutil.get_terminal_size() except (AttributeError, ValueError, OSError): try: - width, height = os.get_terminal_size(sys.__stdout__.fileno()) + width, height = shutil.get_terminal_size() except (AttributeError, ValueError, OSError): pass width = width or 80 @@ -61,6 +70,14 @@ class LinuxDriver(Driver): # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr # extensions. + def _enable_bracketed_paste(self) -> None: + """Enable bracketed paste mode.""" + self.console.file.write("\x1b[?2004h") + + def _disable_bracketed_paste(self) -> None: + """Disable bracketed paste mode.""" + self.console.file.write("\x1b[?2004l") + def _disable_mouse_support(self) -> None: write = self.console.file.write write("\x1b[?1000l") # @@ -71,18 +88,21 @@ class LinuxDriver(Driver): def start_application_mode(self): - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() - def on_terminal_resize(signum, stack) -> None: + def send_size_event(): terminal_size = self._get_terminal_size() width, height = terminal_size - event = events.Resize(self._target, Size(width, height)) - self.console.size = 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, ) + def on_terminal_resize(signum, stack) -> None: + send_size_event() + signal.signal(signal.SIGWINCH, on_terminal_resize) self.console.set_alt_screen(True) @@ -114,15 +134,15 @@ class LinuxDriver(Driver): self.console.show_cursor(False) self.console.file.write("\033[?1003h\n") self.console.file.flush() - self._key_thread = Thread( - target=self.run_input_thread, args=(asyncio.get_event_loop(),) - ) - width, height = self.console.size = self._get_terminal_size() - asyncio.run_coroutine_threadsafe( - self._target.post_message(events.Resize(self._target, Size(width, height))), - loop=loop, - ) + self._key_thread = Thread(target=self.run_input_thread, args=(loop,)) + send_size_event() self._key_thread.start() + self._request_terminal_sync_mode_support() + self._enable_bracketed_paste() + + def _request_terminal_sync_mode_support(self): + self.console.file.write("\033[?2026$p") + self.console.file.flush() @classmethod def _patch_lflag(cls, attrs: int) -> int: @@ -148,15 +168,16 @@ class LinuxDriver(Driver): if not self.exit_event.is_set(): signal.signal(signal.SIGWINCH, signal.SIG_DFL) self._disable_mouse_support() - termios.tcflush(self.fileno, termios.TCIFLUSH) self.exit_event.set() if self._key_thread is not None: self._key_thread.join() + termios.tcflush(self.fileno, termios.TCIFLUSH) except Exception as error: # TODO: log this pass def stop_application_mode(self) -> None: + self._disable_bracketed_paste() self.disable_input() if self.attrs_before is not None: @@ -189,19 +210,21 @@ class LinuxDriver(Driver): return True return False - parser = XTermParser(self._target, more_data) + parser = XTermParser(self._target, more_data, self._debug) + feed = parser.feed utf8_decoder = getincrementaldecoder("utf-8")().decode decode = utf8_decoder read = os.read + EVENT_READ = selectors.EVENT_READ try: while not self.exit_event.is_set(): selector_events = selector.select(0.1) for _selector_key, mask in selector_events: - if mask | selectors.EVENT_READ: + if mask | EVENT_READ: unicode_data = decode(read(fileno, 1024)) - for event in parser.feed(unicode_data): + for event in feed(unicode_data): self.process_event(event) except Exception as error: log(error) @@ -211,9 +234,7 @@ class LinuxDriver(Driver): if __name__ == "__main__": - from time import sleep from rich.console import Console - from .. import events console = Console() @@ -221,6 +242,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/drivers/win32.py b/src/textual/drivers/win32.py index 66c31540f..e3da7b9ea 100644 --- a/src/textual/drivers/win32.py +++ b/src/textual/drivers/win32.py @@ -220,11 +220,9 @@ class EventMonitor(threading.Thread): self.target = target self.exit_event = exit_event self.process_event = process_event - self.app.log("event monitor constructed") super().__init__() def run(self) -> None: - self.app.log("event monitor thread started") exit_requested = self.exit_event.is_set parser = XTermParser(self.target, lambda: False) @@ -265,6 +263,11 @@ class EventMonitor(threading.Thread): key_event = input_record.Event.KeyEvent key = key_event.uChar.UnicodeChar if key_event.bKeyDown or key == "\x1b": + if ( + key_event.dwControlKeyState + and key_event.wVirtualKeyCode == 0 + ): + continue append_key(key) elif event_type == WINDOW_BUFFER_SIZE_EVENT: # Window size changed, store size @@ -280,10 +283,10 @@ class EventMonitor(threading.Thread): self.on_size_change(*new_size) except Exception as error: - self.app.log("EVENT MONITOR ERROR", error) - self.app.log("event monitor thread finished") + self.app.log.error("EVENT MONITOR ERROR", error) def on_size_change(self, width: int, height: int) -> None: """Called when terminal size changes.""" - event = Resize(self.target, Size(width, height)) + size = Size(width, height) + event = Resize(self.target, size, size) run_coroutine_threadsafe(self.target.post_message(event), loop=self.loop) diff --git a/src/textual/drivers/windows_driver.py b/src/textual/drivers/windows_driver.py index bc7a73c57..fb51973ea 100644 --- a/src/textual/drivers/windows_driver.py +++ b/src/textual/drivers/windows_driver.py @@ -17,8 +17,10 @@ if TYPE_CHECKING: class WindowsDriver(Driver): """Powers display and input for Windows.""" - def __init__(self, console: "Console", target: "MessageTarget") -> None: - super().__init__(console, target) + def __init__( + self, console: "Console", target: "MessageTarget", debug: bool = False + ) -> None: + super().__init__(console, target, debug) self.in_fileno = sys.stdin.fileno() self.out_fileno = sys.stdout.fileno() @@ -42,9 +44,17 @@ class WindowsDriver(Driver): write("\x1b[?1006l") self.console.file.flush() + def _enable_bracketed_paste(self) -> None: + """Enable bracketed paste mode.""" + self.console.file.write("\x1b[?2004h") + + def _disable_bracketed_paste(self) -> None: + """Disable bracketed paste mode.""" + self.console.file.write("\x1b[?2004l") + def start_application_mode(self) -> None: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() self._restore_console = win32.enable_application_mode() @@ -52,6 +62,7 @@ class WindowsDriver(Driver): self._enable_mouse_support() self.console.show_cursor(False) self.console.file.write("\033[?1003h\n") + self._enable_bracketed_paste() app = active_app.get() @@ -73,6 +84,7 @@ class WindowsDriver(Driver): pass def stop_application_mode(self) -> None: + self._disable_bracketed_paste() self.disable_input() if self._restore_console: self._restore_console() diff --git a/src/textual/errors.py b/src/textual/errors.py new file mode 100644 index 000000000..032c3ed3b --- /dev/null +++ b/src/textual/errors.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class TextualError(Exception): + """Base class for Textual errors.""" + + +class NoWidget(TextualError): + """Specified widget was not found.""" + + +class RenderError(TextualError): + """An object could not be rendered.""" + + +class DuplicateKeyHandlers(TextualError): + """More than one handler for a single key press. E.g. key_ctrl_i and key_tab handlers both found on one object.""" diff --git a/src/textual/events.py b/src/textual/events.py index ec136a7f0..ee84b929f 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -1,39 +1,34 @@ 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 .geometry import Offset, Size -from .message import Message from ._types import MessageTarget -from .keys import Keys +from .geometry import Offset, Size +from .keys import _get_key_aliases +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 @rich.repr.auto class Event(Message): + """The base class for all events.""" + def __rich_repr__(self) -> rich.repr.Result: return yield - def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None: - super().__init_subclass__(bubble=bubble, verbosity=verbosity) - - -class Null(Event, verbosity=3): - def can_replace(self, message: Message) -> bool: - return isinstance(message, Null) - @rich.repr.auto -class Callback(Event, bubble=False, verbosity=3): +class Callback(Event, bubble=False, verbose=True): def __init__( self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] ) -> None: @@ -44,6 +39,10 @@ class Callback(Event, bubble=False, verbosity=3): yield "callback", self.callback +class InvokeCallbacks(Event, bubble=False, verbose=True): + """Sent after the Screen is updated""" + + class ShutdownRequest(Event): pass @@ -83,44 +82,53 @@ class Action(Event): yield "action", self.action -class Resize(Event, verbosity=2, bubble=False): - """Sent when the app or widget has been resized.""" +class Resize(Event, bubble=False): + """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"] - size: Size + """ - def __init__(self, sender: MessageTarget, size: Size) -> None: - """ - Args: - sender (MessageTarget): Event sender. - width (int): New width in terminal cells. - height (int): New height in terminal cells. - """ + __slots__ = ["size", "virtual_size", "container_size"] + + def __init__( + self, + sender: MessageTarget, + size: Size, + virtual_size: Size, + container_size: Size | None = None, + ) -> None: self.size = size + self.virtual_size = virtual_size + self.container_size = size if container_size is None else container_size super().__init__(sender) def can_replace(self, message: "Message") -> bool: return isinstance(message, Resize) - @property - def width(self) -> int: - return self.size.width - - @property - def height(self) -> int: - return self.size.height - def __rich_repr__(self) -> rich.repr.Result: - yield self.width - yield self.height + yield "size", self.size + yield "virtual_size", self.virtual_size + yield "container_size", self.container_size, self.size -class Mount(Event, bubble=False): +class Compose(Event, bubble=False, verbose=True): + """Sent to a widget to request it to compose and mount children.""" + + +class Mount(Event, bubble=False, verbose=True): """Sent when a widget is *mounted* and may receive messages.""" -class Unmount(Event, bubble=False): - """Sent when a widget is unmounted, and may no longer receive messages.""" +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): @@ -140,17 +148,16 @@ class Hide(Event, bubble=False): class MouseCapture(Event, bubble=False): """Sent when the mouse has been captured. - When a mouse has been captures, all further mouse events will be sent to the capturing widget. + When a mouse has been captured, 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 @@ -160,14 +167,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 @@ -181,27 +188,69 @@ 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. - __slots__ = ["key"] + Args: + sender (MessageTarget): The sender of the event (the App). + key (str): A key name (textual.keys.Keys). + char (str | None, optional): A printable character or None if it is not printable. - def __init__(self, sender: MessageTarget, key: str) -> None: - """ + Attributes: + key_aliases (list[str]): The aliases for the key, including the key itself + """ - 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", "char"] + + def __init__(self, sender: MessageTarget, key: str, char: str | None) -> None: super().__init__(sender) - self.key = key.value if isinstance(key, Keys) else key + self.key = key + self.char = (key if len(key) == 1 else None) if char is None else char + self.key_aliases = [_normalize_key(alias) for alias in _get_key_aliases(key)] def __rich_repr__(self) -> rich.repr.Result: yield "key", self.key + yield "char", self.char, None + + @property + def key_name(self) -> str | None: + """Name of a key suitable for use as a Python identifier.""" + return _normalize_key(self.key) + + @property + def is_printable(self) -> bool: + """Return True if the key is printable. Currently, we assume any key event that + isn't defined in key bindings is printable. + + Returns: + bool: True if the key is printable. + """ + return False if self.char is None else self.char.isprintable() + + +def _normalize_key(key: str) -> str: + """Convert the key string to a name suitable for use as a Python identifier.""" + return key.replace("+", "_") @rich.repr.auto class MouseEvent(InputEvent, bubble=True): - """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", @@ -232,22 +281,6 @@ class MouseEvent(InputEvent, bubble=True): 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 @@ -293,15 +326,58 @@ class MouseEvent(InputEvent, bubble=True): yield "meta", self.meta, False yield "ctrl", self.ctrl, False + @property + def offset(self) -> Offset: + """The mouse coordinate as an offset. + + Returns: + Offset: Mouse coordinate. + + """ + return Offset(self.x, self.y) + + @property + def screen_offset(self) -> Offset: + """Mouse coordinate relative to the screen. + + Returns: + Offset: Mouse coordinate. + """ + return Offset(self.screen_x, self.screen_y) + + @property + def delta(self) -> Offset: + """Mouse coordinate delta (change since last event). + + Returns: + Offset: Mouse coordinate. + + """ + return Offset(self.delta_x, self.delta_y) + @property def style(self) -> Style: + """The (Rich) Style under the cursor.""" return self._style or Style() @style.setter def style(self, style: Style) -> None: self._style = style - def offset(self, x: int, y: int) -> MouseEvent: + def get_content_offset(self, widget: Widget) -> Offset | None: + """Get offset within a widget's content area, or None if offset is not in content (i.e. padding or border). + + Args: + widget (Widget): Widget receiving the event. + + Returns: + Offset | None: An offset where the origin is at the top left of the content area. + """ + if self.screen_offset not in widget.content_region: + return None + return self.offset - widget.gutter.top_left + + def _apply_offset(self, x: int, y: int) -> MouseEvent: return self.__class__( self.sender, x=self.x + x, @@ -319,21 +395,21 @@ class MouseEvent(InputEvent, bubble=True): @rich.repr.auto -class MouseMove(MouseEvent, verbosity=3, bubble=True): +class MouseMove(MouseEvent, bubble=False, verbose=True): """Sent when the mouse cursor moves.""" @rich.repr.auto -class MouseDown(MouseEvent, bubble=True): +class MouseDown(MouseEvent, bubble=True, verbose=True): pass @rich.repr.auto -class MouseUp(MouseEvent, bubble=True): +class MouseUp(MouseEvent, bubble=True, verbose=True): pass -class MouseScrollDown(InputEvent, verbosity=3, bubble=True): +class MouseScrollDown(InputEvent, bubble=True, verbose=True): __slots__ = ["x", "y"] def __init__(self, sender: MessageTarget, x: int, y: int) -> None: @@ -342,31 +418,34 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True): self.y = y -class MouseScrollUp(MouseScrollDown, bubble=True): - pass +class MouseScrollUp(InputEvent, bubble=True, verbose=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): pass -class DoubleClick(MouseEvent, bubble=True): - pass - - @rich.repr.auto -class Timer(Event, verbosity=3, bubble=False): +class Timer(Event, bubble=False, verbose=True): __slots__ = ["time", "count", "callback"] def __init__( self, sender: MessageTarget, timer: "TimerClass", + time: float, count: int = 0, callback: TimerCallback | None = None, ) -> None: super().__init__(sender) self.timer = timer + self.time = time self.count = count self.callback = callback @@ -375,11 +454,11 @@ class Timer(Event, verbosity=3, bubble=False): yield "count", self.count -class Enter(Event, bubble=False): +class Enter(Event, bubble=False, verbose=True): pass -class Leave(Event, bubble=False): +class Leave(Event, bubble=False, verbose=True): pass @@ -391,6 +470,37 @@ class Blur(Event, bubble=False): pass -# class Update(Event, bubble=False): -# def can_replace(self, event: Message) -> bool: -# return isinstance(event, Update) and event.sender == self.sender +class DescendantFocus(Event, bubble=True, verbose=True): + pass + + +class DescendantBlur(Event, bubble=True, verbose=True): + pass + + +@rich.repr.auto +class Paste(Event, bubble=False): + """Event containing text that was pasted into the Textual application. + This event will only appear when running in a terminal emulator that supports + bracketed paste mode. Textual will enable bracketed pastes when an app starts, + and disable it when the app shuts down. + + 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: + super().__init__(sender) + self.text = text + + def __rich_repr__(self) -> rich.repr.Result: + yield "text", self.text + + +class ScreenResume(Event, bubble=False): + pass + + +class ScreenSuspend(Event, bubble=False): + pass diff --git a/src/textual/features.py b/src/textual/features.py new file mode 100644 index 000000000..3d7aa6c0a --- /dev/null +++ b/src/textual/features.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import sys + +from typing import cast + +if sys.version_info >= (3, 8): + from typing import Final, Literal +else: + from typing_extensions import Final, Literal + +FEATURES: Final = {"devtools", "debug", "headless"} + +FeatureFlag = Literal["devtools", "debug", "headless"] + + +def parse_features(features: str) -> frozenset[FeatureFlag]: + """Parse features env var + + Args: + features (str): Comma separated feature flags + + Returns: + frozenset[FeatureFlag]: A frozen set of known features. + """ + + features_set = frozenset( + feature.strip().lower() for feature in features.split(",") if feature.strip() + ).intersection(FEATURES) + + return cast("frozenset[FeatureFlag]", features_set) diff --git a/src/textual/file_monitor.py b/src/textual/file_monitor.py new file mode 100644 index 000000000..562682db8 --- /dev/null +++ b/src/textual/file_monitor.py @@ -0,0 +1,36 @@ +import os +from pathlib import PurePath +from typing import Callable + +import rich.repr + +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: 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 + return changed + + async def __call__(self) -> None: + if self.check(): + await self.on_change() + + async def on_change(self) -> None: + """Called when file changes.""" + await invoke(self.callback) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 861e11fc0..55ad94ef7 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -1,21 +1,36 @@ +""" + +Functions and classes to manage terminal geometry (anything involving coordinates or dimensions). + +""" + from __future__ import annotations -from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar +import sys +from functools import lru_cache +from operator import attrgetter, itemgetter +from typing import Any, Collection, NamedTuple, Tuple, TypeVar, Union, cast +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: # pragma: no cover + from typing_extensions import TypeAlias -SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] - +SpacingDimensions: TypeAlias = Union[ + int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int] +] 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. @@ -31,16 +46,41 @@ def clamp(value: T, minimum: T, maximum: T) -> T: class Offset(NamedTuple): - """A point defined by x and y coordinates.""" + """A cell offset defined by x and y coordinates. Offsets are typically relative to the + top left of the terminal or other container. + + Textual prefers the names `x` and `y`, but you could consider `x` to be the _column_ and `y` to be the _row_. + + """ x: int = 0 + """Offset in the x-axis (horizontal)""" y: int = 0 + """Offset in the y-axis (vertical)""" @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) + @property + def clamped(self) -> Offset: + """Ensure x and y are above zero. + + Returns: + Offset: New offset. + """ + x, y = self + return Offset(0 if x < 0 else x, 0 if y < 0 else y) + + def __bool__(self) -> bool: + return self != (0, 0) + def __add__(self, other: object) -> Offset: if isinstance(other, tuple): _x, _y = self @@ -55,29 +95,59 @@ class Offset(NamedTuple): return Offset(_x - x, _y - y) return NotImplemented + def __mul__(self, other: object) -> Offset: + if isinstance(other, (float, int)): + x, y = self + return Offset(int(x * other), int(y * other)) + return NotImplemented + + def __neg__(self) -> Offset: + x, y = self + return Offset(-x, -y) + def blend(self, destination: Offset, factor: float) -> Offset: """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 - return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor))) + return Offset( + int(x1 + (x2 - x1) * factor), + int(y1 + (y2 - y1) * factor), + ) + + def get_distance_to(self, other: Offset) -> float: + """Get the distance to another offset. + + Args: + other (Offset): An offset. + + Returns: + float: Distance to other offset. + """ + x1, y1 = self + x2, y2 = other + distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 + return distance class Size(NamedTuple): - """An area defined by its width and height.""" + """The dimensions of a rectangular region.""" width: int = 0 + """The width in cells.""" + height: int = 0 + """The height in cells.""" def __bool__(self) -> bool: - """A Size is Falsey if it has area 0.""" + """A Size is Falsy if it has area 0.""" return self.width * self.height != 0 @property @@ -91,26 +161,44 @@ 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) - def __add__(self, other: tuple[int, int]) -> Size: - width, height = self - width2, height2 = other - return Size(width + width2, height + height2) + @property + def line_range(self) -> range: + """Get a range covering lines. - def __sub__(self, other: tuple[int, int]) -> Size: - width, height = self - width2, height2 = other - return Size(width - width2, height - height2) + Returns: + range: A builtin range object. + """ + return range(self.height) + + def __add__(self, other: object) -> Size: + if isinstance(other, tuple): + width, height = self + width2, height2 = other + return Size(max(0, width + width2), max(0, height + height2)) + return NotImplemented + + def __sub__(self, other: object) -> Size: + if isinstance(other, tuple): + width, height = self + width2, height2 = other + return Size(max(0, width - width2), max(0, height - height2)) + 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. @@ -119,7 +207,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. @@ -143,22 +231,60 @@ class Size(NamedTuple): class Region(NamedTuple): - """Defines a rectangular region.""" + """Defines a rectangular region. + + A Region consists of a coordinate (x and y) and dimensions (width and height). + + ``` + (x, y) + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ–ฒ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ height + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ–ผ + โ—€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ width โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ + ``` + + """ x: int = 0 + """Offset in the x-axis (horizontal).""" y: int = 0 + """Offset in the y-axis (vertical).""" width: int = 0 + """The width of the region.""" height: int = 0 + """The height of the region.""" + + @classmethod + def from_union(cls, regions: Collection[Region]) -> Region: + """Create a Region from the union of other regions. + + Args: + regions (Collection[Region]): One or more regions. + + Returns: + Region: A Region that encloses all other regions. + """ + if not regions: + raise ValueError("At least one region expected") + min_x = min(regions, key=itemgetter(0)).x + max_x = max(regions, key=attrgetter("right")).right + min_y = min(regions, key=itemgetter(1)).y + max_y = max(regions, key=attrgetter("bottom")).bottom + return cls(min_x, min_y, max_x - min_x, max_y - min_y) @classmethod def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region: """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. @@ -166,104 +292,257 @@ class Region(NamedTuple): return cls(x1, y1, x2 - x1, y2 - y1) @classmethod - def from_origin(cls, origin: tuple[int, int], size: tuple[int, int]) -> Region: - """Create a region from origin and size. + def from_offset(cls, offset: tuple[int, int], size: tuple[int, int]) -> Region: + """Create a region from offset and size. Args: - origin (Point): Origin (top left point) + offset (Point): Offset (top left point). size (tuple[int, int]): Dimensions of region. Returns: Region: A region instance. """ - x, y = origin + x, y = offset width, height = size return cls(x, y, width, height) - def __bool__(self) -> bool: - return bool(self.width and self.height) + @classmethod + def get_scroll_to_visible( + cls, window_region: Region, region: Region, *, top: bool = False + ) -> Offset: + """Calculate the smallest offset required to translate a window so that it contains + another region. - @property - def x_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + This method is used to calculate the required offset to scroll something in to view. - The end value is non inclusive. + Args: + window_region (Region): The window region. + region (Region): The region to move inside the window. + top (bool, optional): Get offset to top of window. Defaults to False Returns: - tuple[int, int]: [description] + Offset: An offset required to add to region to move it inside window_region. + """ + + if region in window_region: + # Region is already inside the window, so no need to move it. + return Offset(0, 0) + + window_left, window_top, window_right, window_bottom = window_region.corners + region = region.crop_size(window_region.size) + left, top_, right, bottom = region.corners + delta_x = delta_y = 0 + + if not ( + (window_right > left >= window_left) + and (window_right > right >= window_left) + ): + # The region does not fit + # The window needs to scroll on the X axis to bring region in to view + delta_x = min( + left - window_left, + left - (window_right - region.width), + key=abs, + ) + + if not ( + (window_bottom > top_ >= window_top) + and (window_bottom > bottom >= window_top) + ): + # The window needs to scroll on the Y axis to bring region in to view + if top: + delta_y = top_ - window_top + else: + delta_y = min( + top_ - window_top, + top_ - (window_bottom - region.height), + key=abs, + ) + return Offset(delta_x, delta_y) + + def __bool__(self) -> bool: + """A Region is considered False when it has no area.""" + _, _, width, height = self + return width * height > 0 + + @property + def column_span(self) -> tuple[int, int]: + """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) @property - def y_extents(self) -> tuple[int, int]: - """Get the starting and ending x coord. + def line_span(self) -> tuple[int, int]: + """Get the start and end line number (y coord). - The end value is non inclusive. + The end value is exclusive. Returns: - tuple[int, int]: [description] + tuple[int, int]: Pair of y coordinates (line numbers). + """ return (self.y, self.y + self.height) @property - def x_max(self) -> int: - """Maximum X value (non inclusive)""" + def right(self) -> int: + """Maximum X value (non inclusive). + + Returns: + int: x coordinate. + + """ return self.x + self.width @property - def y_max(self) -> int: - """Maximum Y value (non inclusive)""" + def bottom(self) -> int: + """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 covered by this region. + + """ return self.width * self.height @property - def origin(self) -> Offset: - """Get the start point of the region.""" + def offset(self) -> Offset: + """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. + + 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. + + 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. + + 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 def corners(self) -> tuple[int, int, int, int]: - """Get the maxima and minima of region. + """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 @property - def x_range(self) -> range: - """A range object for X coordinates""" + def column_range(self) -> range: + """A range object for X coordinates.""" return range(self.x, self.x + self.width) @property - def y_range(self) -> range: - """A range object for Y coordinates""" + def line_range(self) -> range: + """A range object for Y coordinates.""" return range(self.y, self.y + self.height) - def __add__(self, other: Any) -> Region: + @property + def reset_offset(self) -> Region: + """An region of the same size at (0, 0). + + Returns: + Region: reset region. + + """ + _, _, width, height = self + return Region(0, 0, width, height) + + def __add__(self, other: object) -> Region: if isinstance(other, tuple): ox, oy = other x, y, width, height = self return Region(x + ox, y + oy, width, height) return NotImplemented - def __sub__(self, other: Any) -> Region: + def __sub__(self, other: object) -> Region: if isinstance(other, tuple): ox, oy = other x, y, width, height = self return Region(x - ox, y - oy, width, height) return NotImplemented + def at_offset(self, offset: tuple[int, int]) -> Region: + """Get a new Region with the same size at a given offset. + + Args: + offset (tuple[int, int]): An offset. + + Returns: + Region: New Region with adjusted offset. + """ + x, y = offset + _x, _y, width, height = self + return Region(x, y, width, height) + + def crop_size(self, size: tuple[int, int]) -> Region: + """Get a region with the same offset, with a size no larger than `size`. + + Args: + size (tuple[int, int]): Maximum width and height (WIDTH, HEIGHT). + + Returns: + Region: New region that could fit within `size`. + """ + x, y, width1, height1 = self + width2, height2 = size + return Region(x, y, min(width1, width2), min(height1, height2)) + def expand(self, size: tuple[int, int]) -> Region: """Increase the size of the region by adding a border. @@ -282,6 +561,20 @@ class Region(NamedTuple): height + expand_height * 2, ) + def clip_size(self, size: tuple[int, int]) -> Region: + """Clip the size to fit within minimum values. + + Args: + size (tuple[int, int]): Maximum width and height. + + Returns: + Region: No region, not bigger than size. + """ + x, y, width, height = self + max_width, max_height = size + return Region(x, y, min(width, max_width), min(height, max_height)) + + @lru_cache(maxsize=1024) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. @@ -302,8 +595,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. @@ -327,6 +620,7 @@ class Region(NamedTuple): raise TypeError(f"a tuple of two integers is required, not {point!r}") return (x2 > ox >= x1) and (y2 > oy >= y1) + @lru_cache(maxsize=1024) def contains_region(self, other: Region) -> bool: """Check if a region is entirely contained within this region. @@ -338,24 +632,28 @@ class Region(NamedTuple): """ x1, y1, x2, y2 = self.corners ox, oy, ox2, oy2 = other.corners - return (x2 >= ox >= x1 and y2 >= oy >= y1) and ( - x2 >= ox2 >= x1 and y2 >= oy2 >= y1 + return ( + (x2 >= ox >= x1) + and (y2 >= oy >= y1) + and (x2 >= ox2 >= x1) + and (y2 >= oy2 >= y1) ) - def translate(self, x: int = 0, y: int = 0) -> Region: - """Move the origin of the Region. + def translate(self, offset: tuple[int, int]) -> Region: + """Move the offset of the Region. Args: - translate_x (int): Value to add to x coordinate. - translate_y (int): Value to add to y coordinate. + offset (tuple[int, int]): Offset to add to region. Returns: - Region: A new region shifted by x, y + Region: A new region shifted by (x, y) """ self_x, self_y, width, height = self - return Region(self_x + x, self_y + y, width, height) + offset_x, offset_y = offset + return Region(self_x + offset_x, self_y + offset_y, width, height) + @lru_cache(maxsize=4096) def __contains__(self, other: Any) -> bool: """Check if a point is in this region.""" if isinstance(other, Region): @@ -387,14 +685,53 @@ class Region(NamedTuple): ) return new_region + def grow(self, margin: tuple[int, int, int, int]) -> Region: + """Grow a region by adding spacing. + + Args: + margin (tuple[int, int, in, int]): Grow space by `(, , , )`. + + Returns: + Region: New region. + """ + + top, right, bottom, left = margin + x, y, width, height = self + return Region( + x=x - left, + y=y - top, + width=max(0, width + left + right), + height=max(0, height + top + bottom), + ) + + def shrink(self, margin: tuple[int, int, int, int]) -> Region: + """Shrink a region by subtracting spacing. + + Args: + margin (tuple[int, int, int, int]): Shrink space by `(, , , )`. + + Returns: + Region: The new, smaller region. + """ + + top, right, bottom, left = margin + x, y, width, height = self + return Region( + x=x + left, + y=y + top, + width=max(0, width - (left + right)), + height=max(0, height - (top + bottom)), + ) + + @lru_cache(maxsize=4096) def intersection(self, region: Region) -> Region: - """Get that covers both regions. + """Get the overlapping portion of the two regions. Args: region (Region): A region that overlaps this region. Returns: - Region: A new region that fits within ``region``. + Region: A new region that covers when the two regions overlap. """ # Unrolled because this method is used a lot x1, y1, w1, h1 = self @@ -411,64 +748,309 @@ class Region(NamedTuple): return Region(rx1, ry1, rx2 - rx1, ry2 - ry1) + @lru_cache(maxsize=4096) def union(self, region: Region) -> Region: - """Get a new region that contains both regions. + """Get the smallest region that contains both regions. Args: - region (Region): [description] + region (Region): Another region. Returns: - Region: [description] + Region: An optimally sized region to cover both regions. """ x1, y1, x2, y2 = self.corners ox1, oy1, ox2, oy2 = region.corners - union_region = Region.from_corners( + union_region = self.from_corners( min(x1, ox1), min(y1, oy1), max(x2, ox2), max(y2, oy2) ) return union_region + @lru_cache(maxsize=1024) + def split(self, cut_x: int, cut_y: int) -> tuple[Region, Region, Region, Region]: + """Split a region in to 4 from given x and y offsets (cuts). + + ``` + cut_x โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ” + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ 0 โ”‚ โ”‚ 1 โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + cut_y โ†’ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ” + โ”‚ 2 โ”‚ โ”‚ 3 โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”˜ + ``` + + Args: + cut_x (int): Offset from self.x where the cut should be made. If negative, the cut + is taken from the right edge. + cut_y (int): Offset from self.y where the cut should be made. If negative, the cut + is taken from the lower edge. + + Returns: + tuple[Region, Region, Region, Region]: Four new regions which add up to the original (self). + """ + + x, y, width, height = self + if cut_x < 0: + cut_x = width + cut_x + if cut_y < 0: + cut_y = height + cut_y + + _Region = Region + return ( + _Region(x, y, cut_x, cut_y), + _Region(x + cut_x, y, width - cut_x, cut_y), + _Region(x, y + cut_y, cut_x, height - cut_y), + _Region(x + cut_x, y + cut_y, width - cut_x, height - cut_y), + ) + + @lru_cache(maxsize=1024) + def split_vertical(self, cut: int) -> tuple[Region, Region]: + """Split a region in to two, from a given x offset. + + ``` + cut โ†“ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ” + โ”‚ 0 โ”‚โ”‚ 1 โ”‚ + โ”‚ โ”‚โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”˜ + ``` + + Args: + cut (int): An offset from self.x where the cut should be made. If cut is negative, + it is taken from the right edge. + + Returns: + tuple[Region, Region]: Two regions, which add up to the original (self). + """ + + x, y, width, height = self + if cut < 0: + cut = width + cut + + return ( + Region(x, y, cut, height), + Region(x + cut, y, width - cut, height), + ) + + @lru_cache(maxsize=1024) + def split_horizontal(self, cut: int) -> tuple[Region, Region]: + """Split a region in to two, from a given x offset. + + ``` + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ 0 โ”‚ + โ”‚ โ”‚ + cut โ†’ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ 1 โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + ``` + + Args: + cut (int): An offset from self.x where the cut should be made. May be negative, + for the offset to start from the right edge. + + Returns: + tuple[Region, Region]: Two regions, which add up to the original (self). + """ + x, y, width, height = self + if cut < 0: + cut = height + cut + + return ( + Region(x, y, width, cut), + Region(x, y + cut, width, height - cut), + ) + class Spacing(NamedTuple): """The spacing around a renderable.""" top: int = 0 + """Space from the top of a region.""" right: int = 0 + """Space from the left of a region.""" bottom: int = 0 + """Space from the bottom of a region.""" left: int = 0 + """Space from the left of a region.""" + + def __bool__(self) -> bool: + return self != (0, 0, 0, 0) @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]: + """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. + + Returns: + str: Spacing in CSS format. + + """ + top, right, bottom, left = self + if top == right == bottom == left: + return f"{top}" + if (top, right) == (bottom, left): + return f"{top} {right}" + else: + return f"{top} {right} {bottom} {left}" + @classmethod def unpack(cls, pad: SpacingDimensions) -> Spacing: - """Unpack padding specified in CSS style.""" + """Unpack padding specified in CSS style. + + Args: + pad (SpacingDimensions): An integer, or tuple of 1, 2, or 4 integers. + + Raises: + ValueError: If `pad` is an invalid value. + + Returns: + Spacing: New Spacing object. + """ if isinstance(pad, int): return cls(pad, pad, pad, pad) - if len(pad) == 1: + pad_len = len(pad) + if pad_len == 1: _pad = pad[0] return cls(_pad, _pad, _pad, _pad) - if len(pad) == 2: + if pad_len == 2: pad_top, pad_right = cast(Tuple[int, int], pad) return cls(pad_top, pad_right, pad_top, pad_right) - if len(pad) == 4: + if pad_len == 4: top, right, bottom, left = cast(Tuple[int, int, int, int], pad) return cls(top, right, bottom, left) - raise ValueError(f"1, 2 or 4 integers required for spacing; {len(pad)} given") + raise ValueError( + f"1, 2 or 4 integers required for spacing properties; {pad_len} given" + ) + + @classmethod + def vertical(cls, amount: int) -> Spacing: + """Construct a Spacing with a given amount of spacing on vertical edges, + and no horizontal spacing. + + Args: + amount (int): The magnitude of spacing to apply to vertical edges + + Returns: + Spacing: `Spacing(amount, 0, amount, 0)` + """ + return Spacing(amount, 0, amount, 0) + + @classmethod + def horizontal(cls, amount: int) -> Spacing: + """Construct a Spacing with a given amount of spacing on horizontal edges, + and no vertical spacing. + + Args: + amount (int): The magnitude of spacing to apply to horizontal edges + + Returns: + Spacing: `Spacing(0, amount, 0, amount)` + """ + return Spacing(0, amount, 0, amount) + + @classmethod + def all(cls, amount: int) -> Spacing: + """Construct a Spacing with a given amount of spacing on all edges. + + Args: + amount (int): The magnitude of spacing to apply to all edges + + Returns: + Spacing: `Spacing(amount, amount, amount, amount)` + """ + return Spacing(amount, amount, amount, amount) + + def __add__(self, other: object) -> Spacing: + if isinstance(other, tuple): + top1, right1, bottom1, left1 = self + top2, right2, bottom2, left2 = other + return Spacing( + top1 + top2, right1 + right2, bottom1 + bottom2, left1 + left2 + ) + return NotImplemented + + def __sub__(self, other: object) -> Spacing: + if isinstance(other, tuple): + top1, right1, bottom1, left1 = self + top2, right2, bottom2, left2 = other + return Spacing( + top1 - top2, right1 - right2, bottom1 - bottom2, left1 - left2 + ) + return NotImplemented + + def grow_maximum(self, other: Spacing) -> Spacing: + """Grow spacing with a maximum. + + Args: + other (Spacing): Spacing object. + + Returns: + Spacing: New spacing were the values are maximum of the two values. + """ + top, right, bottom, left = self + other_top, other_right, other_bottom, other_left = other + return Spacing( + max(top, other_top), + max(right, other_right), + max(bottom, other_bottom), + max(left, other_left), + ) + + +NULL_OFFSET = Offset(0, 0) diff --git a/src/textual/keys.py b/src/textual/keys.py index 2abb0df13..e6d386c68 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -1,9 +1,9 @@ -from dataclasses import dataclass +from __future__ import annotations + from enum import Enum + # Adapted from prompt toolkit https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/prompt_toolkit/keys.py - - class Keys(str, Enum): """ List of keys for use in key bindings. @@ -177,11 +177,6 @@ class Keys(str, Enum): ScrollUp = "" ScrollDown = "" - CPRResponse = "" - Vt100MouseEvent = "" - WindowsMouseEvent = "" - BracketedPaste = "" - # For internal use: key which is ignored. # (The key binding for this key should not do anything.) Ignore = "" @@ -201,8 +196,30 @@ class Keys(str, Enum): ShiftControlEnd = ControlShiftEnd -@dataclass -class Binding: - action: str - description: str - show: bool = False +# Unicode db contains some obscure names +# This mapping replaces them with more common terms +KEY_NAME_REPLACEMENTS = { + "solidus": "slash", + "reverse_solidus": "backslash", + "commercial_at": "at", + "hyphen_minus": "minus", + "plus_sign": "plus", + "low_line": "underscore", +} +REPLACED_KEYS = {value: key for key, value in KEY_NAME_REPLACEMENTS.items()} + +# Some keys have aliases. For example, if you press `ctrl+m` on your keyboard, +# it's treated the same way as if you press `enter`. Key handlers `key_ctrl_m` and +# `key_enter` are both valid in this case. +KEY_ALIASES = { + "tab": ["ctrl+i"], + "enter": ["ctrl+m"], + "escape": ["ctrl+left_square_brace"], + "ctrl+at": ["ctrl+space"], + "ctrl+j": ["newline"], +} + + +def _get_key_aliases(key: str) -> list[str]: + """Return all aliases for the given key, including the key itself""" + return [key] + KEY_ALIASES.get(key, []) diff --git a/src/textual/layout.py b/src/textual/layout.py deleted file mode 100644 index af061859d..000000000 --- a/src/textual/layout.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod, abstractmethod -from dataclasses import dataclass -from itertools import chain -from operator import itemgetter -import sys - -from typing import Iterable, Iterator, NamedTuple, TYPE_CHECKING -from rich import segment - -import rich.repr -from rich.control import Control -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.segment import Segment, SegmentLines -from rich.style import Style - -from . import log, panic -from ._loop import loop_last -from .layout_map import LayoutMap -from ._profile import timer -from ._lines import crop_lines -from ._types import Lines - -from .geometry import clamp, Region, Offset, Size - - -PY38 = sys.version_info >= (3, 8) - - -if TYPE_CHECKING: - from .widget import Widget - from .view import View - - -class NoWidget(Exception): - pass - - -class OrderedRegion(NamedTuple): - region: Region - order: tuple[int, int] - - -class ReflowResult(NamedTuple): - """The result of a reflow operation. Describes the chances to widgets.""" - - hidden: set[Widget] - shown: set[Widget] - resized: set[Widget] - - -class WidgetPlacement(NamedTuple): - - region: Region - widget: Widget | None = None - order: tuple[int, ...] = () - - -@rich.repr.auto -class LayoutUpdate: - def __init__(self, lines: Lines, region: Region) -> None: - self.lines = lines - self.region = region - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - yield Control.home().segment - x = self.region.x - new_line = Segment.line() - move_to = Control.move_to - for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): - yield move_to(x, y).segment - yield from line - if not last: - yield new_line - - def __rich_repr__(self) -> rich.repr.Result: - x, y, width, height = self.region - yield "x", x - yield "y", y - yield "width", width - yield "height", height - - -class Layout(ABC): - """Responsible for arranging Widgets in a view and rendering them.""" - - def __init__(self) -> None: - self._layout_map: LayoutMap | None = None - self.width = 0 - self.height = 0 - self.regions: dict[Widget, tuple[Region, Region]] = {} - self._cuts: list[list[int]] | None = None - self._require_update: bool = True - self.background = "" - - def check_update(self) -> bool: - return self._require_update - - def require_update(self) -> None: - self._require_update = True - self.reset() - self._layout_map = None - - def reset_update(self) -> None: - self._require_update = False - - def reset(self) -> None: - self._cuts = None - - def reflow(self, view: View, size: Size) -> ReflowResult: - self.reset() - - self.width = size.width - self.height = size.height - - map = LayoutMap(size) - map.add_widget(view, size.region, (), size.region) - - self._require_update = False - - old_widgets = set() if self.map is None else set(self.map.keys()) - new_widgets = set(map.keys()) - # Newly visible widgets - shown_widgets = new_widgets - old_widgets - # Newly hidden widgets - hidden_widgets = old_widgets - new_widgets - - self._layout_map = map - - # Copy renders if the size hasn't changed - new_renders = { - widget: (region, clip) for widget, (region, _order, clip) in map.items() - } - self.regions = new_renders - - # Widgets with changed size - resized_widgets = { - widget - for widget, (region, *_) in map.items() - if widget in old_widgets and widget.size != region.size - } - - return ReflowResult( - hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets - ) - - @abstractmethod - def get_widgets(self) -> Iterable[Widget]: - ... - - @abstractmethod - def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: - """Generate a layout map that defines where on the screen the widgets will be drawn. - - Args: - console (Console): Console instance. - size (Dimensions): Size of container. - viewport (Region): Screen relative viewport. - - Returns: - Iterable[WidgetPlacement]: An iterable of widget location - """ - - async def mount_all(self, view: "View") -> None: - await view.mount(*self.get_widgets()) - - @property - def map(self) -> LayoutMap | None: - return self._layout_map - - def __iter__(self) -> Iterator[tuple[Widget, Region, Region]]: - if self.map is not None: - layers = sorted( - self.map.widgets.items(), key=lambda item: item[1].order, reverse=True - ) - for widget, (region, order, clip) in layers: - yield widget, region.intersection(clip), region - - def get_offset(self, widget: Widget) -> Offset: - """Get the offset of a widget.""" - try: - return self.map[widget].region.origin - except KeyError: - raise NoWidget("Widget is not in layout") - - def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: - """Get the widget under the given point or None.""" - for widget, cropped_region, region in self: - if widget.is_visual and cropped_region.contains(x, y): - return widget, region - raise NoWidget(f"No widget under screen coordinate ({x}, {y})") - - def get_style_at(self, x: int, y: int) -> Style: - try: - widget, region = self.get_widget_at(x, y) - except NoWidget: - return Style.null() - if widget not in self.regions: - return Style.null() - lines = widget._get_lines() - x -= region.x - y -= region.y - line = lines[y] - end = 0 - for segment in line: - end += segment.cell_length - if x < end: - return segment.style or Style.null() - return Style.null() - - def get_widget_region(self, widget: Widget) -> Region: - try: - region, *_ = self.map[widget] - except KeyError: - raise NoWidget("Widget is not in layout") - else: - return region - - @property - def cuts(self) -> list[list[int]]: - """Get vertical cuts. - - A cut is every point on a line where a widget starts or ends. - - Returns: - list[list[int]]: A list of cuts for every line. - """ - if self._cuts is not None: - return self._cuts - width = self.width - height = self.height - screen_region = Region(0, 0, width, height) - cuts_sets = [{0, width} for _ in range(height)] - - if self.map is not None: - for region, order, clip in self.map.values(): - region = region.intersection(clip) - if region and (region in screen_region): - region_cuts = region.x_extents - for y in region.y_range: - cuts_sets[y].update(region_cuts) - - # Sort the cuts for each line - self._cuts = [sorted(cut_set) for cut_set in cuts_sets] - return self._cuts - - def _get_renders(self, console: Console) -> Iterable[tuple[Region, Region, Lines]]: - _rich_traceback_guard = True - layout_map = self.map - - if layout_map: - widget_regions = sorted( - ( - (widget, region, order, clip) - for widget, (region, order, clip) in layout_map.items() - ), - key=itemgetter(2), - reverse=True, - ) - else: - widget_regions = [] - - for widget, region, _order, clip in widget_regions: - - if not widget.is_visual: - continue - - lines = widget._get_lines() - - if region in clip: - yield region, clip, lines - elif clip.overlaps(region): - new_region = region.intersection(clip) - delta_x = new_region.x - region.x - delta_y = new_region.y - region.y - splits = [delta_x, delta_x + new_region.width] - lines = lines[delta_y : delta_y + new_region.height] - divide = Segment.divide - lines = [list(divide(line, splits))[1] for line in lines] - yield region, clip, lines - - @classmethod - def _assemble_chops( - cls, chops: list[dict[int, list[Segment] | None]] - ) -> Iterable[list[Segment]]: - - from_iterable = chain.from_iterable - for bucket in chops: - yield from_iterable( - line for _, line in sorted(bucket.items()) if line is not None - ) - - def render( - self, - console: Console, - *, - crop: Region = None, - ) -> SegmentLines: - """Render a layout. - - Args: - console (Console): Console instance. - clip (Optional[Region]): Region to clip to. - - Returns: - SegmentLines: A renderable - """ - width = self.width - height = self.height - screen = Region(0, 0, width, height) - - crop_region = crop.intersection(screen) if crop else screen - - _Segment = Segment - divide = _Segment.divide - - # Maps each cut on to a list of segments - cuts = self.cuts - chops: list[dict[int, list[Segment] | None]] = [ - {cut: None for cut in cut_set} for cut_set in cuts - ] - - # TODO: Provide an option to update the background - background_style = console.get_style(self.background) - background_render = [ - [_Segment(" " * width, background_style)] for _ in range(height) - ] - # Go through all the renders in reverse order and fill buckets with no render - renders = list(self._get_renders(console)) - - for region, clip, lines in chain( - renders, [(screen, screen, background_render)] - ): - render_region = region.intersection(clip) - for y, line in zip(render_region.y_range, lines): - - first_cut, last_cut = render_region.x_extents - final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - - if len(final_cuts) == 2: - cut_segments = [line] - else: - render_x = render_region.x - relative_cuts = [cut - render_x for cut in final_cuts] - _, *cut_segments = divide(line, relative_cuts) - for cut, segments in zip(final_cuts, cut_segments): - if chops[y][cut] is None: - chops[y][cut] = segments - - # Assemble the cut renders in to lists of segments - crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners - output_lines = self._assemble_chops(chops[crop_y:crop_y2]) - - def width_view(line: list[Segment]) -> list[Segment]: - if line: - div_lines = list(divide(line, [crop_x, crop_x2])) - line = div_lines[1] if len(div_lines) > 1 else div_lines[0] - return line - - if crop is not None and (crop_x, crop_x2) != (0, self.width): - render_lines = [width_view(line) for line in output_lines] - else: - render_lines = list(output_lines) - - return SegmentLines(render_lines, new_lines=True) - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - yield self.render(console) - - def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None: - if widget not in self.regions: - return None - - region, clip = self.regions[widget] - - if not region.size: - return None - - widget.clear_render_cache() - - update_region = region.intersection(clip) - update_lines = self.render(console, crop=update_region).lines - update = LayoutUpdate(update_lines, update_region) - return update diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py deleted file mode 100644 index 916c6a116..000000000 --- a/src/textual/layout_map.py +++ /dev/null @@ -1,69 +0,0 @@ -from __future__ import annotations - -from rich.console import Console - -from typing import ItemsView, KeysView, ValuesView, NamedTuple - -from . import log -from .geometry import Region, Size - -from .widget import Widget - - -class RenderRegion(NamedTuple): - region: Region - order: tuple[int, ...] - clip: Region - - -class LayoutMap: - def __init__(self, size: Size) -> None: - self.size = size - self.widgets: dict[Widget, RenderRegion] = {} - - def __getitem__(self, widget: Widget) -> RenderRegion: - return self.widgets[widget] - - def items(self) -> ItemsView[Widget, RenderRegion]: - return self.widgets.items() - - def keys(self) -> KeysView[Widget]: - return self.widgets.keys() - - def values(self) -> ValuesView[RenderRegion]: - return self.widgets.values() - - def clear(self) -> None: - self.widgets.clear() - - def add_widget( - self, - widget: Widget, - region: Region, - order: tuple[int, ...], - clip: Region, - ) -> None: - from .view import View - - if widget in self.widgets: - return - - self.widgets[widget] = RenderRegion(region + widget.layout_offset, order, clip) - - if isinstance(widget, View): - view: View = widget - scroll = view.scroll - total_region = region.size.region - sub_clip = clip.intersection(region) - - arrangement = view.get_arrangement(region.size, scroll) - for sub_region, sub_widget, sub_order in arrangement: - total_region = total_region.union(sub_region) - if sub_widget is not None: - self.add_widget( - sub_widget, - sub_region + region.origin - scroll, - sub_order, - sub_clip, - ) - view.virtual_size = total_region.size diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py deleted file mode 100644 index d2b50cba3..000000000 --- a/src/textual/layouts/dock.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import sys -from collections import defaultdict -from dataclasses import dataclass -from typing import Iterable, TYPE_CHECKING, Sequence - -from rich.console import Console - -from .._layout_resolve import layout_resolve -from ..geometry import Offset, Region, Size -from ..layout import Layout, WidgetPlacement -from ..layout_map import LayoutMap - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - - -if TYPE_CHECKING: - from ..widget import Widget - - -DockEdge = Literal["top", "right", "bottom", "left"] - - -@dataclass -class DockOptions: - size: int | None = None - fraction: int = 1 - min_size: int = 1 - - -@dataclass -class Dock: - edge: DockEdge - widgets: Sequence[Widget] - z: int = 0 - - -class DockLayout(Layout): - def __init__(self, docks: list[Dock] = None) -> None: - self.docks: list[Dock] = docks or [] - super().__init__() - - def get_widgets(self) -> Iterable[Widget]: - for dock in self.docks: - yield from dock.widgets - - def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: - - map: LayoutMap = LayoutMap(size) - width, height = size - layout_region = Region(0, 0, width, height) - layers: dict[int, Region] = defaultdict(lambda: layout_region) - - for index, dock in enumerate(self.docks): - dock_options = [ - DockOptions( - widget.layout_size, widget.layout_fraction, widget.layout_min_size - ) - for widget in dock.widgets - ] - region = layers[dock.z] - if not region: - # No space left - continue - - order = (dock.z, index) - x, y, width, height = region - - if dock.edge == "top": - sizes = layout_resolve(height, dock_options) - render_y = y - remaining = region.height - total = 0 - for widget, layout_size in zip(dock.widgets, sizes): - if not widget.visible: - continue - layout_size = min(remaining, layout_size) - if not layout_size: - break - total += layout_size - yield WidgetPlacement( - Region(x, render_y, width, layout_size), widget, order - ) - render_y += layout_size - remaining = max(0, remaining - layout_size) - region = Region(x, y + total, width, height - total) - - elif dock.edge == "bottom": - sizes = layout_resolve(height, dock_options) - render_y = y + height - remaining = region.height - total = 0 - for widget, layout_size in zip(dock.widgets, sizes): - if not widget.visible: - continue - layout_size = min(remaining, layout_size) - if not layout_size: - break - total += layout_size - yield WidgetPlacement( - Region(x, render_y - layout_size, width, layout_size), - widget, - order, - ) - render_y -= layout_size - remaining = max(0, remaining - layout_size) - region = Region(x, y, width, height - total) - - elif dock.edge == "left": - sizes = layout_resolve(width, dock_options) - render_x = x - remaining = region.width - total = 0 - for widget, layout_size in zip(dock.widgets, sizes): - if not widget.visible: - continue - layout_size = min(remaining, layout_size) - if not layout_size: - break - total += layout_size - yield WidgetPlacement( - Region(render_x, y, layout_size, height), - widget, - order, - ) - render_x += layout_size - remaining = max(0, remaining - layout_size) - region = Region(x + total, y, width - total, height) - - elif dock.edge == "right": - sizes = layout_resolve(width, dock_options) - render_x = x + width - remaining = region.width - total = 0 - for widget, layout_size in zip(dock.widgets, sizes): - if not widget.visible: - continue - layout_size = min(remaining, layout_size) - if not layout_size: - break - total += layout_size - yield WidgetPlacement( - Region(render_x - layout_size, y, layout_size, height), - widget, - order, - ) - render_x -= layout_size - remaining = max(0, remaining - layout_size) - region = Region(x, y, width - total, height) - - layers[dock.z] = region - - return map diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py new file mode 100644 index 000000000..a5f89d843 --- /dev/null +++ b/src/textual/layouts/factory.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from .._layout import Layout +from .horizontal import HorizontalLayout +from .grid import GridLayout +from .vertical import VerticalLayout + +LAYOUT_MAP: dict[str, type[Layout]] = { + "horizontal": HorizontalLayout, + "grid": GridLayout, + "vertical": VerticalLayout, +} + + +class MissingLayout(Exception): + pass + + +def get_layout(name: str) -> Layout: + """Get a named layout object. + + Args: + name (str): Name of the layout. + + Raises: + MissingLayout: If the named layout doesn't exist. + + Returns: + Layout: A layout object. + """ + + layout_class = LAYOUT_MAP.get(name) + if layout_class is None: + raise MissingLayout(f"no layout called {name!r}, valid layouts") + return layout_class() diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index ec76e43c6..ea9466461 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -1,436 +1,160 @@ from __future__ import annotations -from collections import defaultdict -from dataclasses import dataclass -from operator import itemgetter -from logging import getLogger -from itertools import cycle, product -import sys -from typing import Iterable, NamedTuple +from fractions import Fraction +from typing import TYPE_CHECKING, Iterable -from rich.console import Console +from .._layout import ArrangeResult, Layout, WidgetPlacement +from .._resolve import resolve +from ..css.scalar import Scalar +from ..geometry import Region, Size, Spacing -from .._layout_resolve import layout_resolve -from ..geometry import Size, Offset, Region -from ..layout import Layout, WidgetPlacement -from ..layout_map import LayoutMap -from ..widget import Widget - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - -log = getLogger("rich") - -GridAlign = Literal["start", "end", "center", "stretch"] - - -@dataclass -class GridOptions: - name: str - size: int | None = None - fraction: int = 1 - min_size: int = 1 - max_size: int | None = None - - -class GridArea(NamedTuple): - col_start: str - col_end: str - row_start: str - row_end: str +if TYPE_CHECKING: + from ..widget import Widget class GridLayout(Layout): - def __init__( - self, - gap: tuple[int, int] | int | None = None, - gutter: tuple[int, int] | int | None = None, - align: tuple[GridAlign, GridAlign] | None = None, - ) -> None: - self.columns: list[GridOptions] = [] - self.rows: list[GridOptions] = [] - self.areas: dict[str, GridArea] = {} - self.widgets: dict[Widget, str | None] = {} - self.column_gap = 0 - self.row_gap = 0 - self.column_repeat = False - self.row_repeat = False - self.column_align: GridAlign = "start" - self.row_align: GridAlign = "start" - self.column_gutter: int = 0 - self.row_gutter: int = 0 - self.hidden_columns: set[str] = set() - self.hidden_rows: set[str] = set() + """Used to layout Widgets in to a grid.""" - if gap is not None: - if isinstance(gap, tuple): - self.set_gap(*gap) - else: - self.set_gap(gap) + name = "grid" - if gutter is not None: - if isinstance(gutter, tuple): - self.set_gutter(*gutter) - else: - self.set_gutter(gutter) + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: + styles = parent.styles + row_scalars = styles.grid_rows or [Scalar.parse("1fr")] + column_scalars = styles.grid_columns or [Scalar.parse("1fr")] + gutter_horizontal = styles.grid_gutter_horizontal + gutter_vertical = styles.grid_gutter_vertical + table_size_columns = max(1, styles.grid_size_columns) + table_size_rows = styles.grid_size_rows + viewport = parent.screen.size - if align is not None: - self.set_align(*align) + def cell_coords(column_count: int) -> Iterable[tuple[int, int]]: + """Iterate over table coordinates ad infinitum. - super().__init__() + Args: + column_count (int): Number of columns - def is_row_visible(self, row_name: str) -> bool: - return row_name not in self.hidden_rows + """ + row = 0 + while True: + for column in range(column_count): + yield (column, row) + row += 1 - def is_column_visible(self, column_name: str) -> bool: - return column_name not in self.hidden_columns + def widget_coords( + column_start: int, row_start: int, columns: int, rows: int + ) -> set[tuple[int, int]]: + """Get coords occupied by a cell. - def show_row(self, row_name: str, visible: bool = True) -> bool: - changed = (row_name in self.hidden_rows) == visible - if visible: - self.hidden_rows.discard(row_name) - else: - self.hidden_rows.add(row_name) - if changed: - self.require_update() - return True - return False + Args: + column_start (int): Start column. + row_start (int): Start_row. + columns (int): Number of columns. + rows (int): Number of rows. - def show_column(self, column_name: str, visible: bool = True) -> bool: - changed = (column_name in self.hidden_columns) == visible - if visible: - self.hidden_columns.discard(column_name) - else: - self.hidden_columns.add(column_name) - if changed: - self.require_update() - return True - return False + Returns: + set[tuple[int, int]]: Set of coords. + """ + return { + (column, row) + for column in range(column_start, column_start + columns) + for row in range(row_start, row_start + rows) + } - def add_column( - self, - name: str, - *, - size: int | None = None, - fraction: int = 1, - min_size: int = 1, - max_size: int | None = None, - repeat: int = 1, - ) -> None: - names = ( - [name] - if repeat == 1 - else [f"{name}{count}" for count in range(1, repeat + 1)] - ) - append = self.columns.append - for name in names: - append( - GridOptions( - name, - size=size, - fraction=fraction, - min_size=min_size, - max_size=max_size, - ) - ) - self.require_update() + def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]: + """Repeat an iterable of scalars as many times as required to return + a list of `count` values. - def add_row( - self, - name: str, - *, - size: int | None = None, - fraction: int = 1, - min_size: int = 1, - max_size: int | None = None, - repeat: int = 1, - ) -> None: - names = ( - [name] - if repeat == 1 - else [f"{name}{count}" for count in range(1, repeat + 1)] - ) - append = self.rows.append - for name in names: - append( - GridOptions( - name, - size=size, - fraction=fraction, - min_size=min_size, - max_size=max_size, - ) - ) - self.require_update() + Args: + scalars (Iterable[T]): Iterable of values. + count (int): Number of values to return. - def _add_area( - self, name: str, columns: str | tuple[str, str], rows: str | tuple[str, str] - ) -> None: - if isinstance(columns, str): - column_start = f"{columns}-start" - column_end = f"{columns}-end" - else: - column_start, column_end = columns + Returns: + list[T]: A list of values. + """ + limited_values = list(scalars)[:] + while len(limited_values) < count: + limited_values.extend(scalars) + return limited_values[:count] - if isinstance(rows, str): - row_start = f"{rows}-start" - row_end = f"{rows}-end" - else: - row_start, row_end = rows + cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {} + cell_size_map: dict[Widget, tuple[int, int, int, int]] = {} - self.areas[name] = GridArea(column_start, column_end, row_start, row_end) + column_count = table_size_columns + next_coord = iter(cell_coords(column_count)).__next__ + cell_coord = (0, 0) + column = row = 0 - def add_areas(self, **areas: str) -> None: - for name, area in areas.items(): - area = area.replace(" ", "") - column, _, row = area.partition(",") - - column_start, column_sep, column_end = column.partition("|") - row_start, row_sep, row_end = row.partition("|") - - self._add_area( - name, - (column_start, column_end) if column_sep else column, - (row_start, row_end) if row_sep else row, - ) - self.require_update() - - def set_gap(self, column: int, row: int | None = None) -> None: - self.column_gap = column - self.row_gap = column if row is None else row - self.require_update() - - def set_gutter(self, column: int, row: int | None = None) -> None: - self.column_gutter = column - self.row_gutter = column if row is None else row - self.require_update() - - def add_widget(self, widget: Widget, area: str | None = None) -> Widget: - self.widgets[widget] = area - self.require_update() - return widget - - def place(self, *auto_widgets: Widget, **area_widgets: Widget) -> None: - widgets = self.widgets - for area, widget in area_widgets.items(): - widgets[widget] = area - for widget in auto_widgets: - widgets[widget] = None - self.require_update() - - def set_repeat(self, column: bool | None = None, row: bool | None = None) -> None: - if column is not None: - self.column_repeat = column - if row is not None: - self.row_repeat = row - self.require_update() - - def set_align(self, column: GridAlign | None = None, row: GridAlign | None = None): - if column is not None: - self.column_align = column - if row is not None: - self.row_align = row - self.require_update() - - @classmethod - def _align( - cls, - region: Region, - grid_size: Size, - container: Size, - col_align: GridAlign, - row_align: GridAlign, - ) -> Region: - offset_x = 0 - offset_y = 0 - - def align(size: int, container: int, align: GridAlign) -> int: - offset = 0 - if align == "end": - offset = container - size - elif align == "center": - offset = (container - size) // 2 - return offset - - offset_x = align(grid_size.width, container.width, col_align) - offset_y = align(grid_size.height, container.height, row_align) - - region = region.translate(offset_x, offset_y) - return region - - def get_widgets(self) -> Iterable[Widget]: - return self.widgets.keys() - - def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: - """Generate a map that associates widgets with their location on screen. - - Args: - width (int): [description] - height (int): [description] - offset (Point, optional): [description]. Defaults to Point(0, 0). - - Returns: - dict[Widget, OrderedRegion]: [description] - """ - width, height = size - - def resolve( - size: int, edges: list[GridOptions], gap: int, repeat: bool - ) -> Iterable[tuple[int, int]]: - total_gap = gap * (len(edges) - 1) - tracks: Iterable[int] - tracks = [ - track if edge.max_size is None else min(edge.max_size, track) - for track, edge in zip(layout_resolve(size - total_gap, edges), edges) - ] - if repeat: - tracks = cycle(tracks) - total = 0 - edge_count = len(edges) - for index, track in enumerate(tracks): - if total + track >= size and index >= edge_count: + for child in children: + child_styles = child.styles + column_span = child_styles.column_span or 1 + row_span = child_styles.row_span or 1 + # Find a slot where this cell fits + # A cell on a previous row may have a row span + while True: + column, row = cell_coord + coords = widget_coords(column, row, column_span, row_span) + if cell_map.keys().isdisjoint(coords): + for coord in coords: + cell_map[coord] = (child, coord == cell_coord) + cell_size_map[child] = ( + column, + row, + column_span - 1, + row_span - 1, + ) break - yield total, total + track - total += track + gap + else: + cell_coord = next_coord() + continue + cell_coord = next_coord() - def resolve_tracks( - grid: list[GridOptions], size: int, gap: int, repeat: bool - ) -> tuple[list[str], dict[str, tuple[int, int]], int, int]: - spans = [ - (options.name, span) - for options, span in zip(cycle(grid), resolve(size, grid, gap, repeat)) - ] - - max_size = 0 - tracks: dict[str, tuple[int, int]] = {} - counts: dict[str, int] = defaultdict(int) - if repeat: - names = [] - for index, (name, (start, end)) in enumerate(spans): - max_size = max(max_size, end) - counts[name] += 1 - count = counts[name] - names.append(f"{name}-{count}") - tracks[f"{name}-{count}-start"] = (index, start) - tracks[f"{name}-{count}-end"] = (index, end) - else: - names = [name for name, _span in spans] - for index, (name, (start, end)) in enumerate(spans): - max_size = max(max_size, end) - tracks[f"{name}-start"] = (index, start) - tracks[f"{name}-end"] = (index, end) - - return names, tracks, len(spans), max_size - - container = Size(width - self.column_gutter * 2, height - self.row_gutter * 2) - column_names, column_tracks, column_count, column_size = resolve_tracks( - [ - options - for options in self.columns - if options.name not in self.hidden_columns - ], - container.width, - self.column_gap, - self.column_repeat, + # Resolve columns / rows + columns = resolve( + repeat_scalars(column_scalars, table_size_columns), + size.width, + gutter_vertical, + size, + viewport, ) - row_names, row_tracks, row_count, row_size = resolve_tracks( - [options for options in self.rows if options.name not in self.hidden_rows], - container.height, - self.row_gap, - self.row_repeat, - ) - grid_size = Size(column_size, row_size) - - widget_areas = ( - (widget, area) - for widget, area in self.widgets.items() - if area and widget.visible - ) - - free_slots = set(product(range(column_count), range(row_count))) - order = 1 - from_corners = Region.from_corners - gutter = Offset(self.column_gutter, self.row_gutter) - for widget, area in widget_areas: - column_start, column_end, row_start, row_end = self.areas[area] - try: - col1, x1 = column_tracks[column_start] - col2, x2 = column_tracks[column_end] - row1, y1 = row_tracks[row_start] - row2, y2 = row_tracks[row_end] - except (KeyError, IndexError): - continue - - free_slots.difference_update( - product(range(col1, col2 + 1), range(row1, row2 + 1)) - ) - - region = self._align( - from_corners(x1, y1, x2, y2), - grid_size, - container, - self.column_align, - self.row_align, - ) - yield WidgetPlacement(region + gutter, widget, (0, order)) - order += 1 - - # Widgets with no area assigned. - auto_widgets = (widget for widget, area in self.widgets.items() if area is None) - - grid_slots = sorted( - ( - slot - for slot in product(range(column_count), range(row_count)) - if slot in free_slots + rows = resolve( + repeat_scalars( + row_scalars, table_size_rows if table_size_rows else row + 1 ), - key=itemgetter(1, 0), # TODO: other orders + size.height, + gutter_horizontal, + size, + viewport, ) - for widget, (col, row) in zip(auto_widgets, grid_slots): - - col_name = column_names[col] - row_name = row_names[row] - _col1, x1 = column_tracks[f"{col_name}-start"] - _col2, x2 = column_tracks[f"{col_name}-end"] - - _row1, y1 = row_tracks[f"{row_name}-start"] - _row2, y2 = row_tracks[f"{row_name}-end"] - - region = self._align( - from_corners(x1, y1, x2, y2), - grid_size, - container, - self.column_align, - self.row_align, + placements: list[WidgetPlacement] = [] + add_placement = placements.append + fraction_unit = Fraction(1) + widgets: list[Widget] = [] + add_widget = widgets.append + max_column = len(columns) - 1 + max_row = len(rows) - 1 + margin = Spacing() + for widget, (column, row, column_span, row_span) in cell_size_map.items(): + x = columns[column][0] + if row > max_row: + break + y = rows[row][0] + x2, cell_width = columns[min(max_column, column + column_span)] + y2, cell_height = rows[min(max_row, row + row_span)] + cell_size = Size(cell_width + x2 - x, cell_height + y2 - y) + width, height, margin = widget._get_box_model( + cell_size, + viewport, + fraction_unit, ) - yield WidgetPlacement(region + gutter, widget, (0, order)) - order += 1 + region = ( + Region(x, y, int(width + margin.width), int(height + margin.height)) + .shrink(margin) + .clip_size(cell_size) + ) + add_placement(WidgetPlacement(region, margin, widget)) + add_widget(widget) - return map - - -if __name__ == "__main__": - layout = GridLayout() - - layout.add_column(size=20, name="a") - layout.add_column(size=10, name="b") - - layout.add_row(fraction=1, name="top") - layout.add_row(fraction=2, name="bottom") - - layout.add_areas(center="a-start|b-end,top") - # layout.set_repeat(True) - - from ..widgets import Placeholder - - layout.place(center=Placeholder()) - - from rich import print - - print(layout.widgets) - - map = layout.generate_map(100, 80) - print(map) + return (placements, set(widgets)) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py new file mode 100644 index 000000000..1216beb4e --- /dev/null +++ b/src/textual/layouts/horizontal.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from fractions import Fraction +from typing import cast + +from textual.geometry import Size, Region +from textual._layout import ArrangeResult, Layout, WidgetPlacement + +from textual.widget import Widget + + +class HorizontalLayout(Layout): + """Used to layout Widgets horizontally on screen, from left to right. Since Widgets naturally + fill the space of their parent container, all widgets used in a horizontal layout should have a specified. + """ + + name = "horizontal" + + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: + + placements: list[WidgetPlacement] = [] + add_placement = placements.append + + x = max_height = Fraction(0) + parent_size = parent.outer_size + + styles = [child.styles for child in children if child.styles.width is not None] + total_fraction = sum( + [int(style.width.value) for style in styles if style.width.is_fraction] + ) + fraction_unit = Fraction(size.width, total_fraction or 1) + + box_models = [ + widget._get_box_model(size, parent_size, fraction_unit) + for widget in cast("list[Widget]", children) + ] + + margins = [ + max((box1.margin.right, box2.margin.left)) + for box1, box2 in zip(box_models, box_models[1:]) + ] + if box_models: + margins.append(box_models[-1].margin.right) + + x = Fraction(box_models[0].margin.left if box_models else 0) + + displayed_children = [child for child in children if child.display] + + _Region = Region + _WidgetPlacement = WidgetPlacement + for widget, box_model, margin in zip(children, box_models, margins): + content_width, content_height, box_margin = box_model + offset_y = box_margin.top + next_x = x + content_width + region = _Region( + int(x), offset_y, int(next_x - int(x)), int(content_height) + ) + max_height = max( + max_height, content_height + offset_y + box_model.margin.bottom + ) + add_placement(_WidgetPlacement(region, box_model.margin, widget, 0)) + x = next_x + margin + + return placements, set(displayed_children) + + def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: + """Get the width of the content. In Horizontal layout, the content width of + a widget is the sum of the widths of its children. + + Args: + widget (Widget): The container widget. + container (Size): The container size. + viewport (Size): The viewport size. + + Returns: + int: Width of the content. + """ + width: int | None = None + gutter_width = widget.gutter.width + for child in widget.displayed_children: + if not child.is_container: + child_width = ( + child.get_content_width(container, viewport) + + gutter_width + + child.gutter.width + ) + if width is None: + width = child_width + else: + width += child_width + if width is None: + width = container.width + + return width diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 5cb6908f1..9460bf0db 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -1,64 +1,58 @@ from __future__ import annotations -from typing import Iterable +from fractions import Fraction +from typing import TYPE_CHECKING -from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions -from ..layout import Layout, WidgetPlacement -from ..widget import Widget -from .._loop import loop_last +from ..geometry import Region, Size +from .._layout import ArrangeResult, Layout, WidgetPlacement + +if TYPE_CHECKING: + from ..widget import Widget class VerticalLayout(Layout): - def __init__( - self, - *, - auto_width: bool = False, - z: int = 0, - gutter: SpacingDimensions = (0, 0, 0, 0), - ): - self.auto_width = auto_width - self.z = z - self.gutter = Spacing.unpack(gutter) - self._widgets: list[Widget] = [] - self._max_widget_width = 0 - super().__init__() + """Used to layout Widgets vertically on screen, from top to bottom.""" - def add(self, widget: Widget) -> None: - self._widgets.append(widget) - self._max_widget_width = max(widget.app.measure(widget), self._max_widget_width) + name = "vertical" - def clear(self) -> None: - del self._widgets[:] - self._max_widget_width = 0 + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: - def get_widgets(self) -> Iterable[Widget]: - return self._widgets + placements: list[WidgetPlacement] = [] + add_placement = placements.append - def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: - index = 0 - width, _height = size - gutter = self.gutter - x, y = self.gutter.top_left - render_width = ( - max(width, self._max_widget_width) - if self.auto_width - else width - gutter.width + parent_size = parent.outer_size + + styles = [child.styles for child in children if child.styles.height is not None] + total_fraction = sum( + [int(style.height.value) for style in styles if style.height.is_fraction] ) + fraction_unit = Fraction(size.height, total_fraction or 1) - total_width = render_width + box_models = [ + widget._get_box_model(size, parent_size, fraction_unit) + for widget in children + ] - gutter_height = max(gutter.top, gutter.bottom) + margins = [ + max((box1.margin.bottom, box2.margin.top)) + for box1, box2 in zip(box_models, box_models[1:]) + ] + if box_models: + margins.append(box_models[-1].margin.bottom) - for last, widget in loop_last(self._widgets): - if ( - not widget.render_cache - or widget.render_cache.size.width != render_width - ): - widget.render_lines_free(render_width) - assert widget.render_cache is not None - render_height = widget.render_cache.size.height - region = Region(x, y, render_width, render_height) - yield WidgetPlacement(region, widget, (self.z, index)) - y += render_height + (gutter.bottom if last else gutter_height) + y = Fraction(box_models[0].margin.top if box_models else 0) - yield WidgetPlacement(Region(0, 0, total_width + gutter.width, y)) + _Region = Region + _WidgetPlacement = WidgetPlacement + for widget, box_model, margin in zip(children, box_models, margins): + content_width, content_height, box_margin = box_model + next_y = y + content_height + region = _Region( + box_margin.left, int(y), int(content_width), int(next_y) - int(y) + ) + add_placement(_WidgetPlacement(region, box_model.margin, widget, 0)) + y = next_y + margin + + return placements, set(children) diff --git a/src/textual/message.py b/src/textual/message.py index 7a5af02a9..aafe13dea 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -1,18 +1,26 @@ from __future__ import annotations -from asyncio import Event -from time import monotonic -from typing import ClassVar +from typing import ClassVar, TYPE_CHECKING import rich.repr +from . import _clock from .case import camel_to_snake -from ._types import MessageTarget +from ._types import MessageTarget as MessageTarget + +if TYPE_CHECKING: + from .widget import Widget + from .message_pump import MessagePump @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", @@ -21,48 +29,57 @@ class Message: "_forwarded", "_no_default_action", "_stop_propagation", - "__done_event", + "_handler_name", ] sender: MessageTarget - bubble: ClassVar[bool] = True - verbosity: ClassVar[int] = 1 + bubble: ClassVar[bool] = True # Message will bubble to parent + verbose: ClassVar[bool] = False # Message is verbose + no_dispatch: ClassVar[bool] = False # Message may not be handled by client code + 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 = monotonic() + self.name = camel_to_snake(self.__class__.__name__) + self.time = _clock.get_time_no_wait() self._forwarded = False self._no_default_action = False self._stop_propagation = False - self.__done_event: Event | None = None + self._handler_name = ( + f"on_{self.namespace}_{self.name}" if self.namespace else f"on_{self.name}" + ) super().__init__() def __rich_repr__(self) -> rich.repr.Result: yield self.sender - def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None: + def __init_subclass__( + cls, + bubble: bool | None = True, + verbose: bool = False, + no_dispatch: bool | None = False, + namespace: str | None = None, + ) -> None: super().__init_subclass__() - cls.bubble = bubble - cls.verbosity = verbosity - - @property - def _done_event(self) -> Event: - if self.__done_event is None: - self.__done_event = Event() - return self.__done_event + if bubble is not None: + cls.bubble = bubble + cls.verbose = verbose + if no_dispatch is not None: + cls.no_dispatch = no_dispatch + if namespace is not None: + cls.namespace = namespace @property def is_forwarded(self) -> bool: return self._forwarded - def set_forwarded(self) -> None: + @property + def handler_name(self) -> str: + """The name of the handler associated with this message.""" + # Property to make it read only + return self._handler_name + + def _set_forwarded(self) -> None: """Mark this event as being forwarded.""" self._forwarded = True @@ -78,7 +95,8 @@ class Message: return False def prevent_default(self, prevent: bool = True) -> Message: - """Suppress the default action. + """Suppress the default action(s). This will prevent handlers in any base classes + from being called. Args: prevent (bool, optional): True if the default action should be suppressed, @@ -96,6 +114,10 @@ class Message: self._stop_propagation = stop return self - async def wait(self) -> None: - """Wait for the message to be processed.""" - await self._done_event.wait() + async def _bubble_to(self, widget: MessagePump) -> None: + """Bubble to a widget (typically the parent). + + Args: + widget (Widget): Target of bubble. + """ + await widget.post_message(self) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 50a37e424..7d7712a81 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -1,27 +1,34 @@ +""" + +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 -from asyncio import CancelledError -from asyncio import Queue, QueueEmpty, Task +import inspect +from asyncio import CancelledError, Queue, QueueEmpty, Task from functools import partial -from typing import TYPE_CHECKING, Awaitable, Iterable, Callable +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable from weakref import WeakSet -from . import events -from . import log -from ._timer import Timer, TimerCallback +from . import Logger, events, log, messages from ._callback import invoke -from ._context import active_app +from ._context import NoActiveAppError, active_app +from ._time import time +from .case import camel_to_snake +from .errors import DuplicateKeyHandlers +from .events import Event from .message import Message +from .reactive import Reactive +from .timer import Timer, TimerCallback if TYPE_CHECKING: from .app import App -class NoParent(Exception): - pass - - class CallbackError(Exception): pass @@ -30,7 +37,30 @@ class MessagePumpClosed(Exception): pass -class MessagePump: +class MessagePumpMeta(type): + """Metaclass for message pump. This exists to populate a Message inner class of a Widget with the + parent classes' name. + + """ + + def __new__( + cls, + name: str, + bases: tuple[type, ...], + class_dict: dict[str, Any], + **kwargs, + ): + namespace = camel_to_snake(name) + isclass = inspect.isclass + for value in class_dict.values(): + if isclass(value) and issubclass(value, Message): + if not value.namespace: + value.namespace = namespace + class_obj = super().__new__(cls, name, bases, class_dict, **kwargs) + return class_obj + + +class MessagePump(metaclass=MessagePumpMeta): def __init__(self, parent: MessagePump | None = None) -> None: self._message_queue: Queue[Message | None] = Queue() self._parent = parent @@ -40,7 +70,10 @@ class MessagePump: 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() + self._last_idle: float = time() + self._max_idle: float | None = None + self._mounted_event = asyncio.Event() @property def task(self) -> Task: @@ -48,30 +81,56 @@ class MessagePump: return self._task @property - def parent(self) -> MessagePump: - if self._parent is None: - raise NoParent(f"{self._parent} has no parent") - return self._parent + def has_parent(self) -> bool: + return self._parent is not None @property def app(self) -> "App": - """Get the current app.""" - return active_app.get() + """ + Get the current app. + + Returns: + App: The current app. + + Raises: + NoActiveAppError: if no active app could be found for the current asyncio context + """ + try: + return active_app.get() + except LookupError: + 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: - return self.app.log(*args, **kwargs) + @property + def log(self) -> Logger: + """Get a logger for this object. - def set_parent(self, parent: MessagePump) -> None: + Returns: + Logger: A logger. + """ + return self.app._logger + + def _attach(self, parent: MessagePump) -> None: + """Set the parent, and therefore attach this node to the tree. + + Args: + parent (MessagePump): Parent node. + """ 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 @@ -83,7 +142,7 @@ class MessagePump: """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: @@ -102,7 +161,7 @@ class MessagePump: 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. @@ -111,9 +170,14 @@ class MessagePump: """ if self._pending_message is None: try: - self._pending_message = self._message_queue.get_nowait() + message = self._message_queue.get_nowait() except QueueEmpty: pass + else: + if message is None: + self._closed = True + raise MessagePumpClosed("The message pump is now closed") + self._pending_message = message if self._pending_message is not None: return self._pending_message @@ -122,74 +186,140 @@ class MessagePump: def set_timer( self, delay: float, - callback: TimerCallback = None, + callback: TimerCallback | None = None, *, name: str | None = None, + pause: bool = False, ) -> Timer: - timer = Timer(self, delay, self, name=name, callback=callback, repeat=0) - self._child_tasks.add(timer.start()) + """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, + self, + name=name or f"set_timer#{Timer._timer_count}", + callback=callback, + repeat=0, + pause=pause, + ) + timer.start() + self._timers.add(timer) return timer def set_interval( self, interval: float, - callback: TimerCallback = None, + callback: TimerCallback | None = None, *, name: str | None = None, repeat: int = 0, - ): + pause: bool = False, + ) -> Timer: + """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, self, name=name, callback=callback, repeat=repeat or None + self, + interval, + self, + name=name or f"set_interval#{Timer._timer_count}", + callback=callback, + repeat=repeat or None, + pause=pause, ) - self._child_tasks.add(timer.start()) + timer.start() + self._timers.add(timer) return timer - async def call_later(self, callback: Callable, *args, **kwargs) -> None: - """Run a callback after processing all messages and refreshing the screen. + 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. Positional and keyword arguments are passed to the callable. Args: callback (Callable): A callable. """ - await self.post_message( - events.Callback(self, partial(callback, *args, **kwargs)) - ) + # We send the InvokeLater message to ourselves first, to ensure we've cleared + # out anything already pending in our own queue. + message = messages.InvokeLater(self, partial(callback, *args, **kwargs)) + self.post_message_no_wait(message) - def close_messages_no_wait(self) -> 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: + """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: + if self._closed or self._closing: return - self._closing = True - + stop_timers = list(self._timers) + for timer in stop_timers: + await timer.stop() + self._timers.clear() await self._message_queue.put(None) + if self._task is not None and asyncio.current_task() != self._task: + # Ensure everything is closed before returning + await self._task - for task in self._child_tasks: - task.cancel() - await task - self._child_tasks.clear() + def _start_messages(self) -> None: + """Start messages task.""" + self._task = asyncio.create_task(self._process_messages()) - def start_messages(self) -> None: - self._task = asyncio.create_task(self.process_messages()) - - async def process_messages(self) -> None: + async def _process_messages(self) -> None: self._running = True + + await self._pre_process() + try: - return await self._process_messages() + await self._process_messages_loop() except CancelledError: pass finally: self._running = False + for timer in list(self._timers): + await timer.stop() - async def _process_messages(self) -> None: + async def _pre_process(self) -> None: + """Procedure to run before processing messages.""" + # Dispatch compose and mount messages without going through loop + # These events must occur in this order, and at the start. + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + # This is critical, mount may be waiting + self._mounted_event.set() + Reactive._initialize_object(self) + + async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True while not self._closed: try: - message = await self.get_message() + message = await self._get_message() except MessagePumpClosed: break except CancelledError: @@ -199,92 +329,134 @@ class MessagePump: # Combine any pending messages that may supersede this one while not (self._closed or self._closing): - pending = self.peek_message() + try: + pending = self._peek_message() + except MessagePumpClosed: + break if pending is None or not message.can_replace(pending): break - # self.log(message, "replaced with", pending) 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: - self.app.panic() + self._mounted_event.set() + self.app._handle_exception(error) break finally: - if isinstance(message, events.Event) and self._message_queue.empty(): + + self._message_queue.task_done() + current_time = time() + + # Insert idle events + if self._message_queue.empty() or ( + self._max_idle is not None + and current_time - self._last_idle > self._max_idle + ): + self._last_idle = current_time if not self._closed: event = events.Idle(self) - for method in self._get_dispatch_methods("on_idle", event): - await method(event) + for _cls, method in self._get_dispatch_methods( + "on_idle", event + ): + try: + await invoke(method, event) + except Exception as error: + self.app._handle_exception(error) + break log("CLOSED", self) - async def dispatch_message(self, message: Message) -> bool | None: + async def _dispatch_message(self, message: Message) -> None: + """Dispatch a message received from the message queue. + + Args: + message (Message): A message object + """ _rich_traceback_guard = True - try: - if isinstance(message, events.Event): - if not isinstance(message, events.Null): - await self.on_event(message) - else: - return await self.on_message(message) - finally: - message._done_event.set() - return False + if message.no_dispatch: + return + + # Allow apps to treat events and messages separately + if isinstance(message, Event): + await self.on_event(message) + else: + await self._on_message(message) def _get_dispatch_methods( self, method_name: str, message: Message - ) -> Iterable[Callable[[Message], Awaitable]]: + ) -> Iterable[tuple[type, Callable[[Message], Awaitable]]]: + """Gets handlers from the MRO + + Args: + method_name (str): Handler method name. + message (Message): Message object. + + """ + private_method = f"_{method_name}" for cls in self.__class__.__mro__: if message._no_default_action: break - method = cls.__dict__.get(method_name, None) + method = cls.__dict__.get(private_method) or cls.__dict__.get(method_name) if method is not None: - yield method.__get__(self, cls) + yield cls, method.__get__(self, cls) async def on_event(self, event: events.Event) -> None: + """Called to process an event. + + Args: + event (events.Event): An Event object. + """ + await self._on_message(event) + + async def _on_message(self, message: Message) -> None: + """Called to process a message. + + Args: + message (Message): A Message object. + """ _rich_traceback_guard = True + handler_name = message._handler_name - for method in self._get_dispatch_methods(f"on_{event.name}", event): - log(event, ">>>", self, verbosity=event.verbosity) - await invoke(method, event) - - if event.bubble and self._parent and not event._stop_propagation: - if event.sender == self._parent: - # parent is sender, so we stop propagation after parent - event.stop() - if self.is_parent_active: - await self._parent.post_message(event) - - async def on_message(self, message: Message) -> None: - _rich_traceback_guard = True - method_name = f"handle_{message.name}" - - method = getattr(self, method_name, None) - if method is not None: - log(message, ">>>", self, verbosity=message.verbosity) + # Look through the MRO to find a handler + for cls, method in self._get_dispatch_methods(handler_name, message): + log.event.verbosity(message.verbose)( + message, + ">>>", + self, + f"method=<{cls.__name__}.{handler_name}>", + ) await invoke(method, message) + # Bubble messages up the DOM (if enabled on the message) if message.bubble and self._parent and not message._stop_propagation: if message.sender == self._parent: # parent is sender, so we stop propagation after parent message.stop() - if not self._parent._closed and not self._parent._closing: - await self._parent.post_message(message) + if self.is_parent_active and not self._parent._closing: + await message._bubble_to(self._parent) - def post_message_no_wait(self, message: Message) -> bool: - if self._closing or self._closed: - return False - if not self.check_message_enabled(message): - return True - self._message_queue.put_nowait(message) - return True + def check_idle(self) -> None: + """Prompt the message pump to call idle if the queue is empty.""" + if self._message_queue.empty(): + self.post_message_no_wait(messages.Prompt(sender=self)) async def post_message(self, message: Message) -> bool: + """Post a message or an event to this message pump. + + Args: + message (Message): A message object. + + Returns: + bool: True if the messages was posted successfully, False if the message was not posted + (because the message pump was in the process of closing). + """ + if self._closing or self._closed: return False if not self.check_message_enabled(message): @@ -292,31 +464,133 @@ class MessagePump: await self._message_queue.put(message) return True - def post_message_from_child_no_wait(self, message: Message) -> bool: + # TODO: This may not be needed, or may only be needed by the timer + # Consider removing or making private + async def _post_priority_message(self, message: Message) -> bool: + """Post a "priority" messages which will be processes prior to regular messages. + + Note that you should rarely need this in a regular app. It exists primarily to allow + timer messages to skip the queue, so that they can be more regular. + + Args: + message (Message): A message. + + 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 - return self.post_message_no_wait(message) + if not self.check_message_enabled(message): + return False + await self._message_queue.put(message) + return True - async def post_message_from_child(self, message: Message) -> bool: + def post_message_no_wait(self, message: Message) -> bool: + """Posts a message on the queue. + + Args: + message (Message): A message (or Event). + + Returns: + bool: True if the messages was processed, False if it wasn't. + """ + if self._closing or self._closed: + return False + if not self.check_message_enabled(message): + return False + self._message_queue.put_nowait(message) + return True + + async def _post_message_from_child(self, message: Message) -> bool: if self._closing or self._closed: return False return await self.post_message(message) + def _post_message_from_child_no_wait(self, message: Message) -> bool: + if self._closing or self._closed: + return False + return self.post_message_no_wait(message) + async def on_callback(self, event: events.Callback) -> None: - await event.callback() + await invoke(event.callback) def emit_no_wait(self, message: Message) -> bool: + """Send a message to the _parent_, non async version. + + Args: + message (Message): A message object. + + Returns: + bool: True if the message was posted successfully. + """ if self._parent: - return self._parent.post_message_from_child_no_wait(message) + return self._parent._post_message_from_child_no_wait(message) else: 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) + return await self._parent._post_message_from_child(message) else: return False + # TODO: Does dispatch_key belong on message pump? + async def dispatch_key(self, event: events.Key) -> bool: + """Dispatch a key event to method. + + This method will call the method named 'key_' if it exists. + Some keys have aliases. The first alias found will be invoked if it exists. + If multiple handlers exist that match the key, an exception is raised. + + Args: + event (events.Key): A key event. + + Returns: + bool: True if key was handled, otherwise False. + + Raises: + DuplicateKeyHandlers: When there's more than 1 handler that could handle this key. + """ + + def get_key_handler(pump: MessagePump, key: str) -> Callable | None: + """Look for the public and private handler methods by name on self.""" + public_handler_name = f"key_{key}" + public_handler = getattr(pump, public_handler_name, None) + + private_handler_name = f"_key_{key}" + private_handler = getattr(pump, private_handler_name, None) + + return public_handler or private_handler + + handled = False + invoked_method = None + key_name = event.key_name + if not key_name: + return False + + for key_alias in event.key_aliases: + key_method = get_key_handler(self, key_alias) + if key_method is not None: + if invoked_method: + _raise_duplicate_key_handlers_error( + key_name, invoked_method.__name__, key_method.__name__ + ) + # If key handlers return False, then they are not considered handled + # This allows key handlers to do some conditional logic + handled = (await invoke(key_method, event)) != False + invoked_method = key_method + + return handled + async def on_timer(self, event: events.Timer) -> None: event.prevent_default() event.stop() @@ -327,3 +601,15 @@ class MessagePump: raise CallbackError( f"unable to run callback {event.callback!r}; {error}" ) + + +def _raise_duplicate_key_handlers_error( + key_name: str, first_handler: str, second_handler: str +) -> None: + """Raise exception for case where user presses a key and there are multiple candidate key handler methods for it.""" + raise DuplicateKeyHandlers( + f"Multiple handlers for key press {key_name!r}.\n" + f"We found both {first_handler!r} and {second_handler!r}, " + f"and didn't know which to call.\n" + f"Consider combining them into a single handler.", + ) diff --git a/src/textual/messages.py b/src/textual/messages.py index 87331e981..2b1ff4792 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING import rich.repr +from .geometry import Region +from ._types import CallbackType from .message import Message @@ -12,7 +14,7 @@ if TYPE_CHECKING: @rich.repr.auto -class Update(Message, verbosity=3): +class Update(Message, verbose=True): def __init__(self, sender: MessagePump, widget: Widget): super().__init__(sender) self.widget = widget @@ -27,17 +29,53 @@ class Update(Message, verbosity=3): return NotImplemented def can_replace(self, message: Message) -> bool: - return isinstance(message, Update) and self == message + # Update messages can replace update for the same widget + return isinstance(message, Update) and self.widget == message.widget @rich.repr.auto -class Layout(Message, verbosity=3): +class Layout(Message, verbose=True): def can_replace(self, message: Message) -> bool: - return isinstance(message, (Layout, Update)) + return isinstance(message, Layout) @rich.repr.auto -class CursorMove(Message): - def __init__(self, sender: MessagePump, line: int) -> None: - self.line = line +class InvokeLater(Message, verbose=True, bubble=False): + def __init__(self, sender: MessagePump, callback: CallbackType) -> None: + self.callback = callback super().__init__(sender) + + def __rich_repr__(self) -> rich.repr.Result: + yield "callback", self.callback + + +@rich.repr.auto +class ScrollToRegion(Message, bubble=False): + """Ask the parent to scroll a given region in to view.""" + + def __init__(self, sender: MessagePump, region: Region) -> None: + self.region = region + super().__init__(sender) + + +@rich.repr.auto +class StylesUpdated(Message, verbose=True): + def __init__(self, sender: MessagePump) -> None: + super().__init__(sender) + + def can_replace(self, message: Message) -> bool: + return isinstance(message, StylesUpdated) + + +class Prompt(Message, no_dispatch=True): + """Used to 'wake up' an event loop.""" + + def can_replace(self, message: Message) -> bool: + return isinstance(message, Prompt) + + +class TerminalSupportsSynchronizedOutput(Message): + """ + Used to make the App aware that the terminal emulator supports synchronised output. + @link https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 + """ diff --git a/src/textual/page.py b/src/textual/page.py deleted file mode 100644 index b45eb7ec1..000000000 --- a/src/textual/page.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -from logging import getLogger - -from rich.console import Console, ConsoleOptions, RenderableType, RenderResult -from rich.padding import Padding, PaddingDimensions -from rich.segment import Segment -from rich.style import StyleType - -from .geometry import Size, Offset -from .message import Message -from .widget import Widget, Reactive - -log = getLogger("rich") - - -class PageUpdate(Message): - def can_replace(self, message: "Message") -> bool: - return isinstance(message, PageUpdate) - - -class PageRender: - def __init__( - self, - page: Page, - renderable: RenderableType, - width: int | None = None, - height: int | None = None, - style: StyleType = "", - padding: PaddingDimensions = (0, 0), - ) -> None: - self.page = page - self.renderable = renderable - self.width = width - self.height = height - self.style = style - self.padding = padding - self.offset = Offset(0, 0) - self._render_width: int | None = None - self._render_height: int | None = None - self.size = Size(0, 0) - self._lines: list[list[Segment]] = [] - - def move_to(self, x: int = 0, y: int = 0) -> None: - self.offset = Offset(x, y) - - def clear(self) -> None: - self._render_width = None - self._render_height = None - del self._lines[:] - - def update(self, renderable: RenderableType) -> None: - self.renderable = renderable - self.clear() - - def render(self, console: Console, options: ConsoleOptions) -> None: - width = self.width or options.max_width or console.width - options = options.update_dimensions(width, None) - style = console.get_style(self.style) - renderable = self.renderable - if self.padding: - renderable = Padding(renderable, self.padding) - self._lines[:] = console.render_lines(renderable, options, style=style) - self.size = Size(width, len(self._lines)) - self.page.emit_no_wait(PageUpdate(self.page)) - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - if not self._lines: - self.render(console, options) - style = console.get_style(self.style) - width = self._render_width or console.width - height = options.height or console.height - x, y = self.offset - window_lines = self._lines[y : y + height] - - if x: - - def width_view(line: list[Segment]) -> list[Segment]: - _, line = Segment.divide(line, [x, x + width]) - return line - - window_lines = [width_view(line) for line in window_lines] - - missing_lines = len(window_lines) - height - if missing_lines: - blank_line = [Segment(" " * width, style), Segment.line()] - window_lines.extend(blank_line for _ in range(missing_lines)) - - new_line = Segment.line() - for line in window_lines: - yield from line - yield new_line - - -class Page(Widget): - def __init__( - self, renderable: RenderableType, name: str = None, style: StyleType = "" - ): - self._page = PageRender(self, renderable, style=style) - super().__init__(name=name) - - scroll_x: Reactive[int] = Reactive(0) - scroll_y: Reactive[int] = Reactive(0) - - def validate_scroll_x(self, value: int) -> int: - return max(0, value) - - def validate_scroll_y(self, value: int) -> int: - return max(0, value) - - async def watch_scroll_x(self, new: int) -> None: - x, y = self._page.offset - self._page.offset = Offset(new, y) - - async def watch_scroll_y(self, new: int) -> None: - x, y = self._page.offset - self._page.offset = Offset(x, new) - - def update(self, renderable: RenderableType | None = None) -> None: - if renderable: - self._page.update(renderable) - else: - self._page.clear() - self.require_repaint() - - @property - def virtual_size(self) -> Size: - return self._page.size - - def render(self) -> RenderableType: - return self._page diff --git a/src/textual/reactive.py b/src/textual/reactive.py index df0470751..c237955b4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -1,21 +1,12 @@ from __future__ import annotations -from inspect import isawaitable from functools import partial -from typing import ( - Any, - Awaitable, - Callable, - Generic, - Type, - Union, - TypeVar, - TYPE_CHECKING, -) +from inspect import isawaitable +from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, Union +from weakref import WeakSet + -from . import log from . import events - from ._callback import count_parameters, invoke from ._types import MessageTarget @@ -29,24 +20,100 @@ if TYPE_CHECKING: ReactiveType = TypeVar("ReactiveType") +class _NotSet: + pass + + +_NOT_SET = _NotSet() + +T = TypeVar("T") + + class Reactive(Generic[ReactiveType]): - """Reactive descriptor.""" + """Reactive descriptor. + + 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. + + """ def __init__( self, - default: ReactiveType, + default: ReactiveType | Callable[[], ReactiveType], *, layout: bool = False, repaint: bool = True, + init: bool = False, ) -> None: self._default = default - self.layout = layout - self.repaint = repaint - self._first = True + 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: A Reactive instance which calls watchers or initialize. + """ + return cls(default, layout=layout, repaint=repaint, init=True) + + @classmethod + def var( + cls, + default: ReactiveType | Callable[[], ReactiveType], + ) -> Reactive: + """A reactive variable that doesn't update or layout. + + Args: + default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + + Returns: + Reactive: A Reactive descriptor. + """ + return cls(default, layout=False, repaint=False, init=True) + + @classmethod + def _initialize_object(cls, obj: object) -> None: + """Set defaults and call any watchers / computes for the first time. + + Args: + obj (Reactable): An object with Reactive descriptors + """ + if not hasattr(obj, "__reactive_initialized"): + startswith = str.startswith + for key in obj.__class__.__dict__: + if startswith(key, "_default_"): + name = key[9:] + # Check defaults + if not hasattr(obj, name): + # Attribute has no value yet + default = getattr(obj, key) + default_value = default() if callable(default) else default + # Set the default vale (calls `__set__`) + setattr(obj, name, default_value) + setattr(obj, "__reactive_initialized", True) def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: + # Check for compute method if hasattr(owner, f"compute_{name}"): + # Compute methods are stored in a list called `__computes` try: computes = getattr(owner, "__computes") except AttributeError: @@ -54,73 +121,124 @@ class Reactive(Generic[ReactiveType]): setattr(owner, "__computes", computes) computes.append(name) + # The name of the attribute self.name = name - self.internal_name = f"__{name}" - setattr(owner, self.internal_name, self._default) + # The internal name where the attribute's value is stored + self.internal_name = f"_reactive_{name}" + default = self._default + setattr(owner, f"_default_{name}", default) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: - return getattr(obj, self.internal_name) + value: _NotSet | ReactiveType = getattr(obj, self.internal_name, _NOT_SET) + if isinstance(value, _NotSet): + # No value present, we need to set the default + init_name = f"_default_{self.name}" + default = getattr(obj, init_name) + default_value = default() if callable(default) else default + # Set and return the value + setattr(obj, self.internal_name, default_value) + if self._init: + self._check_watchers(obj, self.name, default_value, first_set=True) + return default_value + return value def __set__(self, obj: Reactable, value: ReactiveType) -> None: - name = self.name - current_value = getattr(obj, self.internal_name, None) + current_value = getattr(obj, name) + # Check for validate function validate_function = getattr(obj, f"validate_{name}", None) - if callable(validate_function): + # Check if this is the first time setting the value + first_set = getattr(obj, f"__first_set_{self.internal_name}", True) + # Call validate, but not on first set. + if callable(validate_function) and not first_set: value = validate_function(value) - - if current_value != value or self._first: - - self._first = False + # If the value has changed, or this is the first time setting the value + if current_value != value or first_set: + # Set the first set flag to False + setattr(obj, f"__first_set_{self.internal_name}", False) + # Store the internal value setattr(obj, self.internal_name, value) - self.check_watchers(obj, name, current_value) - - if self.layout: - obj.refresh(layout=True) - elif self.repaint: - obj.refresh() + # Check all watchers + self._check_watchers(obj, name, current_value, first_set=first_set) + # Refresh according to descriptor flags + 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: + def _check_watchers( + cls, obj: Reactable, name: str, old_value: Any, first_set: bool = False + ) -> None: + """Check watchers, and call watch methods / computes - internal_name = f"__{name}" + Args: + obj (Reactable): The reactable object. + name (str): Attribute name. + old_value (Any): The old (previous) value of the attribute. + first_set (bool, optional): True if this is the first time setting the value. Defaults to False. + """ + # Get the current value. + internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) async def update_watcher( obj: Reactable, watch_function: Callable, old_value: Any, value: Any ) -> None: + """Call watch function, and run compute. + + Args: + obj (Reactable): Reactable object. + watch_function (Callable): Watch method. + old_value (Any): Old value. + value (Any): new value. + """ _rich_traceback_guard = True + # Call watch with one or two parameters if count_parameters(watch_function) == 2: watch_result = watch_function(old_value, value) else: watch_result = watch_function(value) + # Optionally await result if isawaitable(watch_result): await watch_result - await Reactive.compute(obj) + # Run computes + await Reactive._compute(obj) + # Check for watch method watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): + # Post a callback message, so we can call the watch method in an orderly async manner obj.post_message_no_wait( events.Callback( - obj, + sender=obj, callback=partial( update_watcher, obj, watch_function, old_value, value ), ) ) + # Check for watchers set via `watch` watcher_name = f"__{name}_watchers" watchers = getattr(obj, watcher_name, ()) for watcher in watchers: obj.post_message_no_wait( events.Callback( - obj, + sender=obj, callback=partial(update_watcher, obj, watcher, old_value, value), ) ) + # Run computes + obj.post_message_no_wait( + events.Callback(sender=obj, callback=partial(Reactive._compute, obj)) + ) + @classmethod - async def compute(cls, obj: Reactable) -> None: + async def _compute(cls, obj: Reactable) -> None: + """Invoke all computes. + + Args: + obj (Reactable): Reactable object. + """ _rich_traceback_guard = True computes = getattr(obj, "__computes", []) for compute in computes: @@ -128,17 +246,58 @@ class Reactive(Generic[ReactiveType]): compute_method = getattr(obj, f"compute_{compute}") except AttributeError: continue + value = await invoke(compute_method) setattr(obj, compute, value) +class reactive(Reactive[ReactiveType]): + """Create a reactive 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 True. + + """ + + def __init__( + self, + default: ReactiveType | Callable[[], ReactiveType], + *, + layout: bool = False, + repaint: bool = True, + init: bool = True, + ) -> None: + super().__init__(default, layout=layout, repaint=repaint, init=init) + + +class var(Reactive[ReactiveType]): + """Create a reactive attribute (with no auto-refresh). + + Args: + default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + """ + + def __init__(self, default: ReactiveType | Callable[[], ReactiveType]) -> None: + super().__init__(default, layout=False, repaint=False, init=True) + + def watch( - obj: Reactable, attribute_name: str, callback: Callable[[Any], Awaitable[None]] + obj: Reactable, attribute_name: str, callback: Callable[[Any], object] ) -> None: + """Watch a reactive variable on an object. + + Args: + obj (Reactable): The parent object. + attribute_name (str): The attribute to watch. + callback (Callable[[Any], object]): A callable to call when the attribute changes. + """ watcher_name = f"__{attribute_name}_watchers" current_value = getattr(obj, attribute_name, None) if not hasattr(obj, watcher_name): - setattr(obj, watcher_name, set()) + setattr(obj, watcher_name, WeakSet()) watchers = getattr(obj, watcher_name) watchers.add(callback) - Reactive.check_watchers(obj, attribute_name, current_value) + Reactive._check_watchers(obj, attribute_name, current_value) diff --git a/src/textual/render.py b/src/textual/render.py new file mode 100644 index 000000000..ca8fe72bd --- /dev/null +++ b/src/textual/render.py @@ -0,0 +1,22 @@ +from rich.console import Console, RenderableType +from rich.protocol import rich_cast + + +def measure(console: Console, renderable: RenderableType, default: int) -> int: + """Measure a rich renderable. + + Args: + console (Console): A console object. + renderable (RenderableType): Rich renderable. + default (int): Default width to use if renderable does not expose dimensions. + + Returns: + int: Width in cells + """ + width = default + renderable = rich_cast(renderable) + get_console_width = getattr(renderable, "__rich_measure__", None) + if get_console_width is not None: + render_width = get_console_width(console, console.options).maximum + width = max(0, render_width) + return width diff --git a/src/textual/renderables/__init__.py b/src/textual/renderables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/textual/renderables/_blend_colors.py b/src/textual/renderables/_blend_colors.py new file mode 100644 index 000000000..8bfcd753a --- /dev/null +++ b/src/textual/renderables/_blend_colors.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from rich.color import Color + + +def blend_colors(color1: Color, color2: Color, ratio: float) -> Color: + """Given two RGB colors, return a color that sits some distance between + them in RGB color space. + + Args: + color1 (Color): The first color. + color2 (Color): The second color. + ratio (float): The ratio of color1 to color2. + + Returns: + Color: A Color representing the blending of the two supplied colors. + """ + r1, g1, b1 = color1.triplet + r2, g2, b2 = color2.triplet + + return Color.from_rgb( + r1 + (r2 - r1) * ratio, + g1 + (g2 - g1) * ratio, + b1 + (b2 - b1) * ratio, + ) + + +def blend_colors_rgb( + color1: tuple[int, int, int], color2: tuple[int, int, int], ratio: float +) -> Color: + """Blend two colors given as a tuple of 3 values for red, green, and blue. + + Args: + color1 (tuple[int, int, int]): The first color. + color2 (tuple[int, int, int]): The second color. + ratio (float): The ratio of color1 to color2. + + Returns: + Color: A Color representing the blending of the two supplied colors. + """ + r1, g1, b1 = color1 + r2, g2, b2 = color2 + return Color.from_rgb( + r1 + (r2 - r1) * ratio, + g1 + (g2 - g1) * ratio, + b1 + (b2 - b1) * ratio, + ) diff --git a/src/textual/renderables/align.py b/src/textual/renderables/align.py new file mode 100644 index 000000000..3aa2442ab --- /dev/null +++ b/src/textual/renderables/align.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.measure import Measurement +from rich.segment import Segment +from rich.style import Style + +from .._segment_tools import align_lines +from ..css.types import AlignHorizontal, AlignVertical +from ..geometry import Size + + +class Align: + def __init__( + self, + renderable: RenderableType, + size: Size, + style: Style, + horizontal: AlignHorizontal, + vertical: AlignVertical, + ) -> None: + """Align a child renderable + + Args: + renderable (RenderableType): Renderable to align. + size (Size): Size of container. + style (Style): Style of any padding. + horizontal (AlignHorizontal): Horizontal alignment. + vertical (AlignVertical): Vertical alignment. + """ + self.renderable = renderable + self.size = size + self.style = style + self.horizontal = horizontal + self.vertical = vertical + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + lines = console.render_lines(self.renderable, options, pad=False) + new_line = Segment.line() + for line in align_lines( + lines, + self.style, + self.size, + self.horizontal, + self.vertical, + ): + yield from line + yield new_line + + def __rich_measure__( + self, console: "Console", options: "ConsoleOptions" + ) -> Measurement: + width, _ = self.size + return Measurement(width, width) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py new file mode 100644 index 000000000..b83dbbc34 --- /dev/null +++ b/src/textual/renderables/blank.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console, RenderResult +from rich.segment import Segment +from rich.style import Style + +from ..color import Color + + +class Blank: + """Draw solid background color.""" + + def __init__(self, color: Color | str = "transparent") -> None: + background = Color.parse(color) + self._style = Style.from_color(bgcolor=background.rich_color) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = options.max_width + height = options.height or options.max_height + + segment = Segment(" " * width, self._style) + line = Segment.line() + for _ in range(height): + yield segment + yield line + + +if __name__ == "__main__": + from rich import print + + print(Blank("red")) diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py new file mode 100644 index 000000000..7d5fb3243 --- /dev/null +++ b/src/textual/renderables/gradient.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console, RenderResult + +from rich.segment import Segment +from rich.style import Style + +from ..color import Color + + +class VerticalGradient: + """Draw a vertical gradient.""" + + def __init__(self, color1: str, color2: str) -> None: + self._color1 = Color.parse(color1) + self._color2 = Color.parse(color2) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = options.max_width + height = options.height or options.max_height + color1 = self._color1 + color2 = self._color2 + default_color = Color(0, 0, 0).rich_color + from_color = Style.from_color + blend = color1.blend + rich_color1 = color1.rich_color + for y in range(height): + line_color = from_color( + default_color, + ( + blend(color2, y / (height - 1)).rich_color + if height > 1 + else rich_color1 + ), + ) + yield Segment(f"{width * ' '}\n", line_color) + + +if __name__ == "__main__": + from rich import print + + print(VerticalGradient("red", "blue")) diff --git a/src/textual/renderables/sparkline.py b/src/textual/renderables/sparkline.py new file mode 100644 index 000000000..5630c6bb6 --- /dev/null +++ b/src/textual/renderables/sparkline.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import statistics +from typing import Sequence, Iterable, Callable, TypeVar + +from rich.color import Color +from rich.console import ConsoleOptions, Console, RenderResult +from rich.segment import Segment +from rich.style import Style + +from textual.renderables._blend_colors import blend_colors + +T = TypeVar("T", int, float) + + +class Sparkline: + """A sparkline representing a series of data. + + Args: + data (Sequence[T]): The sequence of data to render. + width (int, optional): The width of the sparkline/the number of buckets to partition the data into. + min_color (Color, optional): The color of values equal to the min value in data. + max_color (Color, optional): The color of values equal to the max value in data. + summary_function (Callable[list[T]]): Function that will be applied to each bucket. + """ + + BARS = "โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ" + + def __init__( + self, + data: Sequence[T], + *, + width: int | None, + min_color: Color = Color.from_rgb(0, 255, 0), + max_color: Color = Color.from_rgb(255, 0, 0), + summary_function: Callable[[list[T]], float] = max, + ) -> None: + self.data = data + self.width = width + self.min_color = Style.from_color(min_color) + self.max_color = Style.from_color(max_color) + self.summary_function = summary_function + + @classmethod + def _buckets(cls, data: Sequence[T], num_buckets: int) -> Iterable[list[T]]: + """Partition ``data`` into ``num_buckets`` buckets. For example, the data + [1, 2, 3, 4] partitioned into 2 buckets is [[1, 2], [3, 4]]. + + Args: + data (Sequence[T]): The data to partition. + num_buckets (int): The number of buckets to partition the data into. + """ + num_steps, remainder = divmod(len(data), num_buckets) + for i in range(num_buckets): + start = i * num_steps + min(i, remainder) + end = (i + 1) * num_steps + min(i + 1, remainder) + partition = data[start:end] + if partition: + yield partition + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = self.width or options.max_width + len_data = len(self.data) + if len_data == 0: + yield Segment("โ–" * width, self.min_color) + return + if len_data == 1: + yield Segment("โ–ˆ" * width, self.max_color) + return + + minimum, maximum = min(self.data), max(self.data) + extent = maximum - minimum or 1 + + buckets = list(self._buckets(self.data, num_buckets=self.width)) + + bucket_index = 0 + bars_rendered = 0 + step = len(buckets) / width + summary_function = self.summary_function + min_color, max_color = self.min_color.color, self.max_color.color + while bars_rendered < width: + partition = buckets[int(bucket_index)] + partition_summary = summary_function(partition) + height_ratio = (partition_summary - minimum) / extent + bar_index = int(height_ratio * (len(self.BARS) - 1)) + bar_color = blend_colors(min_color, max_color, height_ratio) + bars_rendered += 1 + bucket_index += step + yield Segment(self.BARS[bar_index], Style.from_color(bar_color)) + + +if __name__ == "__main__": + console = Console() + + def last(l): + return l[-1] + + funcs = min, max, last, statistics.median, statistics.mean + nums = [10, 2, 30, 60, 45, 20, 7, 8, 9, 10, 50, 13, 10, 6, 5, 4, 3, 7, 20] + console.print(f"data = {nums}\n") + for f in funcs: + console.print( + f"{f.__name__}:\t", Sparkline(nums, width=12, summary_function=f), end="" + ) + console.print("\n") diff --git a/src/textual/renderables/text_opacity.py b/src/textual/renderables/text_opacity.py new file mode 100644 index 000000000..dffb5667b --- /dev/null +++ b/src/textual/renderables/text_opacity.py @@ -0,0 +1,124 @@ +import functools +from typing import Iterable + +from rich.cells import cell_len +from rich.color import Color +from rich.console import ConsoleOptions, Console, RenderResult, RenderableType +from rich.segment import Segment +from rich.style import Style + +from textual.renderables._blend_colors import blend_colors + + +@functools.lru_cache(maxsize=1024) +def _get_blended_style_cached( + bg_color: Color, fg_color: Color, opacity: float +) -> Style: + """Blend from one color to another. + + Cached because when a UI is static the opacity will be constant. + + Args: + bg_color (Color): Background color. + fg_color (Color): Foreground color. + opacity (float): Opacity. + + Returns: + Style: Resulting style. + """ + return Style.from_color( + color=blend_colors(bg_color, fg_color, ratio=opacity), + bgcolor=bg_color, + ) + + +class TextOpacity: + """Blend foreground in to background.""" + + def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None: + """Wrap a renderable to blend foreground color into the background color. + + Args: + renderable (RenderableType): The RenderableType to manipulate. + opacity (float): The opacity as a float. A value of 1.0 means text is fully visible. + """ + self.renderable = renderable + self.opacity = opacity + + @classmethod + def process_segments( + cls, segments: Iterable[Segment], opacity: float + ) -> Iterable[Segment]: + """Apply opacity to segments. + + Args: + segments (Iterable[Segment]): Incoming segments. + opacity (float): Opacity to apply. + + Returns: + Iterable[Segment]: Segments with applied opacity. + + """ + _Segment = Segment + _from_color = Style.from_color + if opacity == 0: + for text, style, control in segments: + invisible_style = _from_color(bgcolor=style.bgcolor) + yield _Segment(cell_len(text) * " ", invisible_style) + else: + for segment in segments: + text, style, control = segment + if not style: + yield segment + continue + + color = style.color + bgcolor = style.bgcolor + if color and color.triplet and bgcolor and bgcolor.triplet: + color_style = _get_blended_style_cached(bgcolor, color, opacity) + yield _Segment(text, style + color_style) + else: + yield segment + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + return self.process_segments(segments, self.opacity) + + +if __name__ == "__main__": + from rich.live import Live + from rich.panel import Panel + from rich.text import Text + + from time import sleep + + console = Console() + + panel = Panel.fit( + Text("Steak: ยฃ30", style="#fcffde on #03761e"), + title="Menu", + style="#ffffff on #000000", + ) + console.print(panel) + + opacity_panel = TextOpacity(panel, opacity=0.5) + console.print(opacity_panel) + + def frange(start, end, step): + current = start + while current < end: + yield current + current += step + + while current >= 0: + yield current + current -= step + + import itertools + + with Live(opacity_panel, refresh_per_second=60) as live: + for value in itertools.cycle(frange(0, 1, 0.05)): + opacity_panel.value = value + sleep(0.05) diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py new file mode 100644 index 000000000..022862b3a --- /dev/null +++ b/src/textual/renderables/tint.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import Iterable + +from rich.console import ConsoleOptions, Console, RenderResult, RenderableType +from rich.segment import Segment +from rich.style import Style + +from ..color import Color + + +class Tint: + """Applies a color on top of an existing renderable.""" + + def __init__(self, renderable: RenderableType, color: Color) -> None: + """Wrap a renderable to apply a tint color. + + Args: + renderable (RenderableType): A renderable. + color (Color): A color (presumably with alpha). + """ + self.renderable = renderable + self.color = color + + @classmethod + def process_segments( + cls, segments: Iterable[Segment], color: Color + ) -> Iterable[Segment]: + """Apply tint to segments. + + Args: + segments (Iterable[Segment]): Incoming segments. + color (Color): Color of tint. + + Returns: + Iterable[Segment]: Segments with applied tint. + + """ + from_rich_color = Color.from_rich_color + style_from_color = Style.from_color + _Segment = Segment + + NULL_STYLE = Style() + for segment in segments: + text, style, control = segment + if control: + yield segment + else: + style = style or NULL_STYLE + yield _Segment( + text, + ( + style + + style_from_color( + ( + (from_rich_color(style.color) + color).rich_color + if style.color is not None + else None + ), + ( + (from_rich_color(style.bgcolor) + color).rich_color + if style.bgcolor is not None + else None + ), + ) + ), + control, + ) + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + segments = console.render(self.renderable, options) + color = self.color + return self.process_segments(segments, color) diff --git a/src/textual/renderables/underline_bar.py b/src/textual/renderables/underline_bar.py new file mode 100644 index 000000000..c0d508df6 --- /dev/null +++ b/src/textual/renderables/underline_bar.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from rich.console import ConsoleOptions, Console, RenderResult +from rich.style import StyleType +from rich.text import Text + + +class UnderlineBar: + """Thin horizontal bar with a portion highlighted. + + Args: + highlight_range (tuple[float, float]): The range to highlight. Defaults to ``(0, 0)`` (no highlight) + highlight_style (StyleType): The style of the highlighted range of the bar. + background_style (StyleType): The style of the non-highlighted range(s) of the bar. + width (int, optional): The width of the bar, or ``None`` to fill available width. + """ + + def __init__( + self, + highlight_range: tuple[float, float] = (0, 0), + highlight_style: StyleType = "magenta", + background_style: StyleType = "grey37", + clickable_ranges: dict[str, tuple[int, int]] | None = None, + width: int | None = None, + ) -> None: + self.highlight_range = highlight_range + self.highlight_style = highlight_style + self.background_style = background_style + self.clickable_ranges = clickable_ranges or {} + self.width = width + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + highlight_style = console.get_style(self.highlight_style) + background_style = console.get_style(self.background_style) + + half_bar_right = "โ•ธ" + half_bar_left = "โ•บ" + bar = "โ”" + + width = self.width or options.max_width + start, end = self.highlight_range + + start = max(start, 0) + end = min(end, width) + + output_bar = Text("", end="") + + if start == end == 0 or end < 0 or start > end: + output_bar.append(Text(bar * width, style=background_style, end="")) + yield output_bar + return + + # Round start and end to nearest half + start = round(start * 2) / 2 + end = round(end * 2) / 2 + + # Check if we start/end on a number that rounds to a .5 + half_start = start - int(start) > 0 + half_end = end - int(end) > 0 + + # Initial non-highlighted portion of bar + output_bar.append( + Text(bar * (int(start - 0.5)), style=background_style, end="") + ) + if not half_start and start > 0: + output_bar.append(Text(half_bar_right, style=background_style, end="")) + + # The highlighted portion + bar_width = int(end) - int(start) + if half_start: + output_bar.append( + Text( + half_bar_left + bar * (bar_width - 1), style=highlight_style, end="" + ) + ) + else: + output_bar.append(Text(bar * bar_width, style=highlight_style, end="")) + if half_end: + output_bar.append(Text(half_bar_right, style=highlight_style, end="")) + + # The non-highlighted tail + if not half_end and end - width != 0: + output_bar.append(Text(half_bar_left, style=background_style, end="")) + output_bar.append( + Text(bar * (int(width) - int(end) - 1), style=background_style, end="") + ) + + # Fire actions when certain ranges are clicked (e.g. for tabs) + for range_name, (start, end) in self.clickable_ranges.items(): + output_bar.apply_meta( + {"@click": f"range_clicked('{range_name}')"}, start, end + ) + + yield output_bar + + +if __name__ == "__main__": + import random + from time import sleep + from rich.color import ANSI_COLOR_NAMES + + console = Console() + + def frange(start, end, step): + current = start + while current < end: + yield current + current += step + + while current >= 0: + yield current + current -= step + + step = 0.1 + start_range = frange(0.5, 10.5, step) + end_range = frange(10, 20, step) + ranges = zip(start_range, end_range) + + console.print(UnderlineBar(width=20), f" (.0, .0)") + + for range in ranges: + color = random.choice(list(ANSI_COLOR_NAMES.keys())) + console.print( + UnderlineBar(range, highlight_style=color, width=20), + f" {range}", + ) + + from rich.live import Live + + bar = UnderlineBar(highlight_range=(0, 4.5), width=80) + with Live(bar, refresh_per_second=60) as live: + while True: + bar.highlight_range = ( + bar.highlight_range[0] + 0.1, + bar.highlight_range[1] + 0.1, + ) + sleep(0.005) diff --git a/src/textual/richreadme.md b/src/textual/richreadme.md index 880d2544d..090795a07 100644 --- a/src/textual/richreadme.md +++ b/src/textual/richreadme.md @@ -315,7 +315,7 @@ See the [tree.py](https://github.com/willmcgugan/rich/blob/master/examples/tree.
Columns -Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (MacOS / Linux) `ls` command which displays a directory listing in columns: +Rich can render content in neat [columns](https://rich.readthedocs.io/en/latest/columns.html) with equal or optimal width. Here's a very basic clone of the (macOS / Linux) `ls` command which displays a directory listing in columns: ```python import os diff --git a/src/textual/screen.py b/src/textual/screen.py new file mode 100644 index 000000000..70b971125 --- /dev/null +++ b/src/textual/screen.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import sys +from typing import Iterable, Iterator + +import rich.repr +from rich.console import RenderableType +from rich.style import Style + +from . import errors, events, messages +from ._callback import invoke +from ._compositor import Compositor, MapGeometry +from .timer import Timer +from ._types import CallbackType +from .dom import DOMNode +from .geometry import Offset, Region, Size +from .reactive import Reactive +from .renderables.blank import Blank +from .widget import Widget + +if sys.version_info >= (3, 8): + from typing import Final +else: + from typing_extensions import Final + +# Screen updates will be batched so that they don't happen more often than 60 times per second: +UPDATE_PERIOD: Final = 1 / 60 + + +@rich.repr.auto +class Screen(Widget): + """A widget for the root of the app.""" + + DEFAULT_CSS = """ + Screen { + layout: vertical; + overflow-y: auto; + background: $surface; + } + """ + + focused: Reactive[Widget | None] = Reactive(None) + + def __init__( + self, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self._compositor = Compositor() + self._dirty_widgets: set[Widget] = set() + self._update_timer: Timer | None = None + self._callbacks: list[CallbackType] = [] + self._max_idle = UPDATE_PERIOD + + @property + def is_transparent(self) -> bool: + return False + + @property + def is_current(self) -> bool: + """Check if this screen is current (i.e. visible to user).""" + return self.app.screen is self + + @property + def update_timer(self) -> Timer: + """Timer used to perform updates.""" + if self._update_timer is None: + self._update_timer = self.set_interval( + UPDATE_PERIOD, self._on_timer_update, name="screen_update", pause=True + ) + return self._update_timer + + @property + def widgets(self) -> list[Widget]: + """Get all widgets.""" + return list(self._compositor.map.keys()) + + @property + def visible_widgets(self) -> list[Widget]: + """Get a list of visible widgets.""" + return list(self._compositor.visible_widgets) + + def render(self) -> RenderableType: + 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. + + Args: + widget (Widget): A widget + + Returns: + Offset: The widget's offset relative to the top left of the terminal. + """ + return self._compositor.get_offset(widget) + + def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: + """Get the widget at a given coordinate. + + Args: + x (int): X Coordinate. + y (int): Y Coordinate. + + Returns: + tuple[Widget, Region]: Widget and screen region. + """ + return self._compositor.get_widget_at(x, y) + + def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: + """Get all widgets under a given coordinate. + + Args: + x (int): X coordinate. + y (int): Y coordinate. + + Returns: + Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples. + """ + return self._compositor.get_widgets_at(x, y) + + def get_style_at(self, x: int, y: int) -> Style: + """Get the style under a given coordinate. + + Args: + x (int): X Coordinate. + y (int): Y Coordinate. + + Returns: + Style: Rich Style object + """ + return self._compositor.get_style_at(x, y) + + def find_widget(self, widget: Widget) -> MapGeometry: + """Get the screen region of a Widget. + + Args: + widget (Widget): A Widget within the composition. + + Returns: + Region: Region relative to screen. + + Raises: + NoWidget: If the widget could not be found in this screen. + """ + return self._compositor.find_widget(widget) + + @property + def focus_chain(self) -> list[Widget]: + """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 + stack: list[Iterator[Widget]] = [iter(self.focusable_children)] + pop = stack.pop + push = stack.append + + while stack: + node = next(stack[-1], None) + if node is None: + pop() + else: + if node.is_container and node.can_focus_children: + push(iter(node.focusable_children)) + else: + if node.can_focus: + add_widget(node) + + return widgets + + def _move_focus(self, direction: int = 0) -> Widget | None: + """Move the focus in the given direction. + + Args: + direction (int, optional): 1 to move forward, -1 to move backward, or + 0 to keep the current focus. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + focusable_widgets = self.focus_chain + + if not focusable_widgets: + # Nothing focusable, so nothing to do + return self.focused + if self.focused is None: + # Nothing currently focused, so focus the first one + self.set_focus(focusable_widgets[0]) + else: + try: + # Find the index of the currently focused widget + current_index = focusable_widgets.index(self.focused) + except ValueError: + # Focused widget was removed in the interim, start again + self.set_focus(focusable_widgets[0]) + else: + # Only move the focus if we are currently showing the focus + if direction: + current_index = (current_index + direction) % len(focusable_widgets) + self.set_focus(focusable_widgets[current_index]) + + return self.focused + + def focus_next(self) -> Widget | None: + """Focus the next widget. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + return self._move_focus(1) + + def focus_previous(self) -> Widget | None: + """Focus the previous widget. + + Returns: + Widget | None: Newly focused widget, or None for no focus. + """ + return self._move_focus(-1) + + def _reset_focus( + self, widget: Widget, avoiding: list[Widget] | None = None + ) -> None: + """Reset the focus when a widget is removed + + Args: + widget (Widget): A widget that is removed. + avoiding (list[DOMNode] | None, optional): Optional list of nodes to avoid. + """ + + avoiding = avoiding or [] + + # Make this a NOP if we're being asked to deal with a widget that + # isn't actually the currently-focused widget. + if self.focused is not widget: + return + + # Grab the list of widgets that we can set focus to. + focusable_widgets = self.focus_chain + if not focusable_widgets: + # If there's nothing to focus... give up now. + return + + try: + # Find the location of the widget we're taking focus from, in + # the focus chain. + widget_index = focusable_widgets.index(widget) + except ValueError: + # widget is not in focusable widgets + # It may have been made invisible + # Move to a sibling if possible + for sibling in widget.visible_siblings: + if sibling not in avoiding and sibling.can_focus: + self.set_focus(sibling) + break + else: + self.set_focus(None) + return + + # Now go looking for something before it, that isn't about to be + # removed, and which can receive focus, and go focus that. + chosen: Widget | None = None + for candidate in reversed( + focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index] + ): + if candidate not in avoiding: + chosen = candidate + break + + # Go with the what was found. + self.set_focus(chosen) + + def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: + """Focus (or un-focus) a widget. A focused widget will receive key events first. + + Args: + widget (Widget | None): Widget to focus, or None to un-focus. + scroll_visible (bool, optional): Scroll widget in to view. + """ + if widget is self.focused: + # Widget is already focused + return + + if widget is None: + # No focus, so blur currently focused widget if it exists + if self.focused is not None: + self.focused.post_message_no_wait(events.Blur(self)) + self.focused.emit_no_wait(events.DescendantBlur(self)) + self.focused = None + elif widget.can_focus: + if self.focused != widget: + if self.focused is not None: + # Blur currently focused widget + self.focused.post_message_no_wait(events.Blur(self)) + self.focused.emit_no_wait(events.DescendantBlur(self)) + # Change focus + self.focused = widget + # Send focus event + if scroll_visible: + self.screen.scroll_to_widget(widget) + widget.post_message_no_wait(events.Focus(self)) + widget.emit_no_wait(events.DescendantFocus(self)) + + async def _on_idle(self, event: events.Idle) -> None: + # Check for any widgets marked as 'dirty' (needs a repaint) + event.prevent_default() + + if self.is_current: + if self._layout_required: + self._refresh_layout() + self._layout_required = False + self._dirty_widgets.clear() + if self._repaint_required: + self._dirty_widgets.clear() + self._dirty_widgets.add(self) + self._repaint_required = False + + if self._dirty_widgets: + self.update_timer.resume() + + # The Screen is idle - a good opportunity to invoke the scheduled callbacks + await self._invoke_and_clear_callbacks() + + def _on_timer_update(self) -> None: + """Called by the _update_timer.""" + # Render widgets together + if self._dirty_widgets: + self._compositor.update_widgets(self._dirty_widgets) + self.app._display(self, self._compositor.render()) + self._dirty_widgets.clear() + + self.update_timer.pause() + if self._callbacks: + self.post_message_no_wait(events.InvokeCallbacks(self)) + + async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None: + """Handle PostScreenUpdate events, which are sent after the screen is updated""" + await self._invoke_and_clear_callbacks() + + async def _invoke_and_clear_callbacks(self) -> None: + """If there are scheduled callbacks to run, call them and clear + the callback queue.""" + if self._callbacks: + callbacks = self._callbacks[:] + self._callbacks.clear() + for callback in callbacks: + await invoke(callback) + + def _invoke_later(self, callback: CallbackType) -> None: + """Enqueue a callback to be invoked after the screen is repainted. + + Args: + callback (CallbackType): A callback. + """ + + self._callbacks.append(callback) + self.check_idle() + + def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: + """Refresh the layout (can change size and positions of widgets).""" + size = self.outer_size if size is None else size + if not size: + return + + self._compositor.update_widgets(self._dirty_widgets) + self.update_timer.pause() + try: + hidden, shown, resized = self._compositor.reflow(self, size) + Hide = events.Hide + Show = events.Show + + for widget in hidden: + widget.post_message_no_wait(Hide(self)) + for widget in shown: + widget.post_message_no_wait(Show(self)) + + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized + ResizeEvent = events.Resize + + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + if widget in send_resize: + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) + + except Exception as error: + self.app._handle_exception(error) + return + display_update = self._compositor.render(full=full) + if display_update is not None: + self.app._display(self, display_update) + + async def _on_update(self, message: messages.Update) -> None: + message.stop() + message.prevent_default() + widget = message.widget + assert isinstance(widget, Widget) + self._dirty_widgets.add(widget) + self.check_idle() + + async def _on_layout(self, message: messages.Layout) -> None: + message.stop() + message.prevent_default() + self._layout_required = True + self.check_idle() + + def _screen_resized(self, size: Size): + """Called by App when the screen is resized.""" + self._refresh_layout(size, full=True) + + def _on_screen_resume(self) -> None: + """Called by the App""" + size = self.app.size + self._refresh_layout(size, full=True) + + async def _on_resize(self, event: events.Resize) -> None: + event.stop() + self._screen_resized(event.size) + + async def _handle_mouse_move(self, event: events.MouseMove) -> None: + try: + if self.app.mouse_captured: + widget = self.app.mouse_captured + region = self.find_widget(widget).region + else: + widget, region = self.get_widget_at(event.x, event.y) + except errors.NoWidget: + await self.app._set_mouse_over(None) + else: + await self.app._set_mouse_over(widget) + mouse_event = events.MouseMove( + self, + event.x - region.x, + event.y - region.y, + event.delta_x, + event.delta_y, + event.button, + event.shift, + event.meta, + event.ctrl, + screen_x=event.screen_x, + screen_y=event.screen_y, + style=event.style, + ) + widget.hover_style = event.style + mouse_event._set_forwarded() + await widget._forward_event(mouse_event) + + async def _forward_event(self, event: events.Event) -> None: + if event.is_forwarded: + return + event._set_forwarded() + if isinstance(event, (events.Enter, events.Leave)): + await self.post_message(event) + + elif isinstance(event, events.MouseMove): + event.style = self.get_style_at(event.screen_x, event.screen_y) + await self._handle_mouse_move(event) + + elif isinstance(event, events.MouseEvent): + try: + if self.app.mouse_captured: + widget = self.app.mouse_captured + region = self.find_widget(widget).region + else: + widget, region = self.get_widget_at(event.x, event.y) + except errors.NoWidget: + self.set_focus(None) + else: + if isinstance(event, events.MouseUp) and widget.can_focus: + if self.focused is not widget: + self.set_focus(widget) + event.stop() + return + event.style = self.get_style_at(event.screen_x, event.screen_y) + if widget is self: + event._set_forwarded() + await self.post_message(event) + else: + await widget._forward_event( + event._apply_offset(-region.x, -region.y) + ) + + elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): + try: + widget, _region = self.get_widget_at(event.x, event.y) + except errors.NoWidget: + return + scroll_widget = widget + if scroll_widget is not None: + if scroll_widget is self: + await self.post_message(event) + else: + await scroll_widget._forward_event(event) + else: + await self.post_message(event) diff --git a/src/textual/screen_update.py b/src/textual/screen_update.py deleted file mode 100644 index 487d7094a..000000000 --- a/src/textual/screen_update.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -from rich.console import Console, RenderableType -from rich.control import Control -from rich.segment import Segment, Segments - -from .geometry import Offset -from ._loop import loop_last - - -class ScreenUpdate: - def __init__( - self, console: Console, renderable: RenderableType, width: int, height: int - ) -> None: - - self.lines = console.render_lines( - renderable, console.options.update_dimensions(width, height) - ) - self.offset = Offset(0, 0) - - def render(self, x: int, y: int) -> Iterable[Segment]: - move_to = Control.move_to - new_line = Segment.line() - for last, (offset_y, line) in loop_last(enumerate(self.lines, y)): - yield move_to(x, offset_y).segment - yield from line - if not last: - yield new_line - - def __rich__(self) -> RenderableType: - x, y = self.offset - update = self.render(x, y) - return Segments(update) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py new file mode 100644 index 000000000..0fc2a4cc9 --- /dev/null +++ b/src/textual/scroll_view.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from rich.console import RenderableType + +from .geometry import 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). + + """ + + DEFAULT_CSS = """ + ScrollView { + overflow-y: auto; + overflow-x: auto; + } + """ + + @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. + """ + if ( + self._size != size + or virtual_size != self.virtual_size + or container_size != self.container_size + ): + self._size = size + virtual_size = self.virtual_size + self._scroll_update(virtual_size) + self._container_size = size - self.styles.gutter.totals + self.scroll_to(self.scroll_x, self.scroll_y, animate=False) + self.refresh() + + 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}") diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 89c6ca8cc..707cd67d2 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -1,52 +1,65 @@ from __future__ import annotations +from math import ceil import rich.repr from rich.color import Color -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType +from rich.console import ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment, Segments from rich.style import Style, StyleType from . import events -from .geometry import Offset from ._types import MessageTarget +from .geometry import Offset from .message import Message -from .widget import Reactive, Widget +from .reactive import Reactive +from .renderables.blank import Blank +from .widget import Widget + + +class ScrollMessage(Message, bubble=False): + pass @rich.repr.auto -class ScrollUp(Message): +class ScrollUp(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto -class ScrollDown(Message): +class ScrollDown(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" @rich.repr.auto -class ScrollLeft(Message): +class ScrollLeft(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto -class ScrollRight(Message): +class ScrollRight(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" -class ScrollTo(Message): +class ScrollTo(ScrollMessage, verbose=True): """Message sent when click and dragging handle.""" def __init__( - self, sender: MessageTarget, x: float | None = None, y: float | None = None + self, + sender: MessageTarget, + x: float | None = None, + y: float | None = None, + animate: bool = True, ) -> None: self.x = x self.y = y + self.animate = animate super().__init__(sender) def __rich_repr__(self) -> rich.repr.Result: yield "x", self.x, None yield "y", self.y, None + yield "animate", self.animate, True class ScrollBarRender: @@ -73,7 +86,6 @@ class ScrollBarRender: virtual_size: float = 50, window_size: float = 20, position: float = 0, - ascii_only: bool = False, thickness: int = 1, vertical: bool = True, back_color: Color = Color.parse("#555555"), @@ -81,19 +93,15 @@ class ScrollBarRender: ) -> Segments: if vertical: - if ascii_only: - bars = ["|", "|", "|", "|", "|", "|", "|", "|"] - else: - bars = ["โ–", "โ–‚", "โ–ƒ", "โ–„", "โ–…", "โ–†", "โ–‡", "โ–ˆ"] + bars = ["โ–", "โ–‚", "โ–ƒ", "โ–„", "โ–…", "โ–†", "โ–‡", " "] else: - if ascii_only: - bars = ["-", "-", "-", "-", "-", "-", "-", "-"] - else: - bars = ["โ–ˆ", "โ–‰", "โ–Š", "โ–‹", "โ–Œ", "โ–", "โ–Ž", "โ–"] + bars = ["โ–‰", "โ–Š", "โ–‹", "โ–Œ", "โ–", "โ–Ž", "โ–", " "] back = back_color bar = bar_color + len_bars = len(bars) + width_thickness = thickness if vertical else 1 _Segment = Segment @@ -101,17 +109,17 @@ class ScrollBarRender: blank = " " * width_thickness foreground_meta = {"@mouse.up": "release", "@mouse.down": "grab"} - if window_size and size and virtual_size: + if window_size and size and virtual_size and size != virtual_size: step_size = virtual_size / size - start = int(position / step_size * 8) - end = start + max(8, int(window_size / step_size * 8)) + start = int(position / step_size * len_bars) + end = start + max(len_bars, int(ceil(window_size / step_size * len_bars))) - start_index, start_bar = divmod(start, 8) - end_index, end_bar = divmod(end, 8) + start_index, start_bar = divmod(max(0, start), len_bars) + end_index, end_bar = divmod(max(0, end), len_bars) - upper = {"@click": "scroll_up"} - lower = {"@click": "scroll_down"} + upper = {"@mouse.up": "scroll_up"} + lower = {"@mouse.up": "scroll_down"} upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper)) lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower)) @@ -123,22 +131,28 @@ class ScrollBarRender: _Segment(blank, _Style(bgcolor=bar, meta=foreground_meta)) ] * (end_index - start_index) + # Apply the smaller bar characters to head and tail of scrollbar for more "granularity" if start_index < len(segments): - segments[start_index] = _Segment( - bars[7 - start_bar] * width_thickness, - _Style(bgcolor=back, color=bar, meta=foreground_meta) - if vertical - else _Style(bgcolor=bar, color=back, meta=foreground_meta), - ) + bar_character = bars[len_bars - 1 - start_bar] + if bar_character != " ": + segments[start_index] = _Segment( + bar_character * width_thickness, + _Style(bgcolor=back, color=bar, meta=foreground_meta) + if vertical + else _Style(bgcolor=bar, color=back, meta=foreground_meta), + ) if end_index < len(segments): - segments[end_index] = _Segment( - bars[7 - end_bar] * width_thickness, - _Style(bgcolor=bar, color=back, meta=foreground_meta) - if vertical - else _Style(bgcolor=back, color=bar, meta=foreground_meta), - ) + bar_character = bars[len_bars - 1 - end_bar] + if bar_character != " ": + segments[end_index] = _Segment( + bar_character * width_thickness, + _Style(bgcolor=bar, color=back, meta=foreground_meta) + if vertical + else _Style(bgcolor=back, color=bar, meta=foreground_meta), + ) else: - segments = [_Segment(blank)] * int(size) + style = _Style(bgcolor=back) + segments = [_Segment(blank, style=style)] * int(size) if vertical: return Segments(segments, new_lines=True) else: @@ -175,39 +189,71 @@ class ScrollBarRender: @rich.repr.auto class ScrollBar(Widget): - def __init__(self, vertical: bool = True, name: str | None = None) -> None: + + DEFAULT_CSS = """ + ScrollBar { + link-hover-color: ; + link-hover-background:; + link-hover-style: ; + link-color: transparent; + link-background: transparent; + } + """ + + def __init__( + self, vertical: bool = True, name: str | None = None, *, thickness: int = 1 + ) -> None: self.vertical = vertical + self.thickness = thickness self.grabbed_position: float = 0 super().__init__(name=name) + self.auto_links = False - virtual_size: Reactive[int] = Reactive(100) + window_virtual_size: Reactive[int] = Reactive(100) window_size: Reactive[int] = Reactive(0) position: Reactive[int] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) grabbed: Reactive[Offset | None] = Reactive(None) def __rich_repr__(self) -> rich.repr.Result: - yield "virtual_size", self.virtual_size + yield from super().__rich_repr__() + yield "window_virtual_size", self.window_virtual_size yield "window_size", self.window_size yield "position", self.position + if self.thickness > 1: + yield "thickness", self.thickness def render(self) -> RenderableType: - style = Style( - bgcolor=(Color.parse("#555555" if self.mouse_over else "#444444")), - color=Color.parse("bright_yellow" if self.grabbed else "bright_magenta"), + styles = self.parent.styles + background = ( + styles.scrollbar_background_hover + if self.mouse_over + else styles.scrollbar_background ) + color = ( + styles.scrollbar_color_active if self.grabbed else styles.scrollbar_color + ) + color = background + color + scrollbar_style = Style.from_color(color.rich_color, background.rich_color) return ScrollBarRender( - virtual_size=self.virtual_size, - window_size=self.window_size, + virtual_size=self.window_virtual_size, + window_size=( + self.window_size if self.window_size < self.window_virtual_size else 0 + ), position=self.position, + thickness=self.thickness, vertical=self.vertical, - style=style, + style=scrollbar_style, ) - async def on_enter(self, event: events.Enter) -> None: + def _on_hide(self, event: events.Hide) -> None: + if self.grabbed: + self.release_mouse() + + def _on_enter(self, event: events.Enter) -> None: self.mouse_over = True - async 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: @@ -216,25 +262,27 @@ class ScrollBar(Widget): async def action_scroll_up(self) -> None: await self.emit(ScrollUp(self) if self.vertical else ScrollLeft(self)) - async def action_grab(self) -> None: - await self.capture_mouse() + def action_grab(self) -> None: + self.capture_mouse() - async def action_released(self) -> None: - await self.capture_mouse(False) + 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: - await self.release_mouse() + self.release_mouse() + event.stop() - async 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 - async def on_mouse_release(self, event: events.MouseRelease) -> None: + def _on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None + event.stop() - async def on_mouse_move(self, event: events.MouseMove) -> None: - if self.grabbed: + 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 if self.vertical: @@ -242,7 +290,7 @@ class ScrollBar(Widget): self.grabbed_position + ( (event.screen_y - self.grabbed.y) - * (self.virtual_size / self.window_size) + * (self.window_virtual_size / self.window_size) ) ) else: @@ -250,19 +298,40 @@ class ScrollBar(Widget): self.grabbed_position + ( (event.screen_x - self.grabbed.x) - * (self.virtual_size / self.window_size) + * (self.window_virtual_size / self.window_size) ) ) await self.emit(ScrollTo(self, x=x, y=y)) + event.stop() + + async def _on_click(self, event: events.Click) -> None: + event.stop() + + +class ScrollBarCorner(Widget): + """Widget which fills the gap between horizontal and vertical scrollbars, + should they both be present.""" + + def __init__(self, name: str | None = None): + super().__init__(name=name) + + def render(self) -> RenderableType: + assert self.parent is not None + styles = self.parent.styles + color = styles.scrollbar_corner_color + return Blank(color) if __name__ == "__main__": from rich.console import Console - from rich.segment import Segments console = Console() - bar = ScrollBarRender() - console.print( - ScrollBarRender(position=15.3, window_size=100, thickness=5, vertical=True) - ) + thickness = 2 + console.print(f"Bars thickness: {thickness}") + + console.print("Vertical bar:") + console.print(ScrollBarRender.render_bar(thickness=thickness)) + + console.print("Horizontal bar:") + console.print(ScrollBarRender.render_bar(vertical=False, thickness=thickness)) diff --git a/src/textual/suggestions.py b/src/textual/suggestions.py new file mode 100644 index 000000000..e3fb20db2 --- /dev/null +++ b/src/textual/suggestions.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from difflib import get_close_matches +from typing import Sequence + + +def get_suggestion(word: str, possible_words: Sequence[str]) -> str | None: + """ + Returns a close match of `word` amongst `possible_words`. + + Args: + word (str): The word we want to find a close match for + possible_words (Sequence[str]): The words amongst which we want to find a close match + + Returns: + str | None: The closest match amongst the `possible_words`. Returns `None` if no close matches could be found. + + Example: returns "red" for word "redu" and possible words ("yellow", "red") + """ + possible_matches = get_close_matches(word, possible_words, n=1) + return None if not possible_matches else possible_matches[0] + + +def get_suggestions(word: str, possible_words: Sequence[str], count: int) -> list[str]: + """ + Returns a list of up to `count` matches of `word` amongst `possible_words`. + + Args: + word (str): The word we want to find a close match for + possible_words (Sequence[str]): The words amongst which we want to find close matches + + Returns: + list[str]: The closest matches amongst the `possible_words`, from the closest to the least close. + Returns an empty list if no close matches could be found. + + Example: returns ["yellow", "ellow"] for word "yllow" and possible words ("yellow", "red", "ellow") + """ + return get_close_matches(word, possible_words, n=count) diff --git a/src/textual/timer.py b/src/textual/timer.py new file mode 100644 index 000000000..eb98a9533 --- /dev/null +++ b/src/textual/timer.py @@ -0,0 +1,182 @@ +""" + +Timer objects are created by [set_interval][textual.message_pump.MessagePump.set_interval] or + [set_timer][textual.message_pump.MessagePump.set_timer]. + +""" + +from __future__ import annotations + +import asyncio +import weakref +from asyncio import ( + CancelledError, + Event, + Task, +) +from typing import Awaitable, Callable, Union + +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 + +TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] + + +class EventTargetGone(Exception): + pass + + +@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 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. + """ + + _timer_count: int = 1 + + def __init__( + self, + event_target: MessageTarget, + interval: float, + sender: MessageTarget, + *, + name: str | None = None, + callback: TimerCallback | None = None, + repeat: int | None = None, + skip: bool = True, + pause: bool = False, + ) -> None: + self._target_repr = repr(event_target) + self._target = weakref.ref(event_target) + self._interval = interval + self.sender = sender + self.name = f"Timer#{self._timer_count}" if name is None else name + self._timer_count += 1 + self._callback = callback + self._repeat = repeat + self._skip = skip + self._active = Event() + self._task: Task | None = None + self._reset: bool = False + if not pause: + self._active.set() + + def __rich_repr__(self) -> Result: + yield self._interval + yield "name", self.name + yield "repeat", self._repeat, None + + @property + def target(self) -> MessageTarget: + target = self._target() + if target is None: + raise EventTargetGone() + return target + + def start(self) -> Task: + """Start the timer return the task. + + Returns: + Task: A Task instance for the timer. + """ + self._task = asyncio.create_task(self._run_timer()) + return self._task + + def stop_no_wait(self) -> None: + """Stop the timer.""" + 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.""" + if self._task is not None: + self._active.set() + self._task.cancel() + self._task = None + + def pause(self) -> None: + """Pause the timer. + + A paused timer will not send events until it is resumed. + + """ + self._active.clear() + + def reset(self) -> None: + """Reset the timer, so it starts from the beginning.""" + self._active.set() + self._reset = True + + def resume(self) -> None: + """Resume a paused timer.""" + self._active.set() + + async def _run_timer(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 + await self._active.wait() + start = _clock.get_time_no_wait() + 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) + + count += 1 + await self._active.wait() + if self._reset: + start = _clock.get_time_no_wait() + count = 0 + self._reset = False + continue + try: + await self._tick(next_timer=next_timer, count=count) + except EventTargetGone: + break + + 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: + try: + await invoke(self._callback) + except Exception as error: + app = active_app.get() + app._handle_exception(error) + else: + event = events.Timer( + self.sender, + timer=self, + time=next_timer, + count=count, + callback=self._callback, + ) + await self.target._post_priority_message(event) diff --git a/src/textual/view.py b/src/textual/view.py deleted file mode 100644 index 0c924c61b..000000000 --- a/src/textual/view.py +++ /dev/null @@ -1,260 +0,0 @@ -from __future__ import annotations - -from itertools import chain -from typing import Callable, Iterable, ClassVar, TYPE_CHECKING - -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -import rich.repr -from rich.style import Style - -from . import events -from . import log -from . import messages -from .layout import Layout, NoWidget, WidgetPlacement -from .geometry import Size, Offset, Region -from .reactive import Reactive, watch - -from .widget import Widget - - -if TYPE_CHECKING: - from .app import App - - -@rich.repr.auto -class View(Widget): - - layout_factory: ClassVar[Callable[[], Layout]] - - def __init__(self, layout: Layout = None, name: str | None = None) -> None: - self.layout: Layout = layout or self.layout_factory() - self.mouse_over: Widget | None = None - self.widgets: set[Widget] = set() - self.named_widgets: dict[str, Widget] = {} - self._mouse_style: Style = Style() - self._mouse_widget: Widget | None = None - - self._cached_arrangement: tuple[Size, Offset, list[WidgetPlacement]] = ( - Size(), - Offset(), - [], - ) - - super().__init__(name=name) - - def __init_subclass__( - cls, layout: Callable[[], Layout] | None = None, **kwargs - ) -> None: - if layout is not None: - cls.layout_factory = layout - super().__init_subclass__(**kwargs) - - background: Reactive[str] = Reactive("") - scroll_x: Reactive[int] = Reactive(0) - scroll_y: Reactive[int] = Reactive(0) - virtual_size = Reactive(Size(0, 0)) - - async def watch_background(self, value: str) -> None: - self.layout.background = value - self.app.refresh() - - @property - def scroll(self) -> Offset: - return Offset(self.scroll_x, self.scroll_y) - - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - return - yield - - def __rich_repr__(self) -> rich.repr.Result: - yield "name", self.name - - def __getitem__(self, widget_name: str) -> Widget: - return self.named_widgets[widget_name] - - @property - def is_visual(self) -> bool: - return False - - @property - def is_root_view(self) -> bool: - return bool(self._parent and self.parent is self.app) - - def is_mounted(self, widget: Widget) -> bool: - return widget in self.widgets - - def render(self) -> RenderableType: - return self.layout - - def get_offset(self, widget: Widget) -> Offset: - return self.layout.get_offset(widget) - - def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: - cached_size, cached_scroll, arrangement = self._cached_arrangement - if cached_size == size and cached_scroll == scroll: - return arrangement - arrangement = list(self.layout.arrange(size, scroll)) - self._cached_arrangement = (size, scroll, arrangement) - return arrangement - - async def handle_update(self, message: messages.Update) -> None: - if self.is_root_view: - message.stop() - widget = message.widget - assert isinstance(widget, Widget) - - display_update = self.layout.update_widget(self.console, widget) - # self.log("UPDATE", widget, display_update) - if display_update is not None: - self.app.display(display_update) - - async def handle_layout(self, message: messages.Layout) -> None: - await self.refresh_layout() - if self.is_root_view: - message.stop() - self.app.refresh() - - async def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: - - name_widgets: Iterable[tuple[str | None, Widget]] - name_widgets = chain( - ((None, widget) for widget in anon_widgets), widgets.items() - ) - for name, widget in name_widgets: - name = name or widget.name - if self.app.register(widget, self): - if name: - self.named_widgets[name] = widget - self.widgets.add(widget) - - self.refresh() - - async def refresh_layout(self) -> None: - self._cached_arrangement = (Size(), Offset(), []) - try: - await self.layout.mount_all(self) - if not self.is_root_view: - await self.app.view.refresh_layout() - return - - if not self.size: - return - - hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size)) - assert self.layout.map is not None - - for widget in hidden: - widget.post_message_no_wait(events.Hide(self)) - for widget in shown: - widget.post_message_no_wait(events.Show(self)) - - send_resize = shown - send_resize.update(resized) - - for widget, region, unclipped_region in self.layout: - widget._update_size(unclipped_region.size) - if widget in send_resize: - widget.post_message_no_wait( - events.Resize(self, unclipped_region.size) - ) - except: - self.app.panic() - - async def on_resize(self, event: events.Resize) -> None: - self._update_size(event.size) - if self.is_root_view: - await self.refresh_layout() - self.app.refresh() - event.stop() - - def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: - return self.layout.get_widget_at(x, y) - - def get_style_at(self, x: int, y: int) -> Style: - return self.layout.get_style_at(x, y) - - def get_widget_region(self, widget: Widget) -> Region: - return self.layout.get_widget_region(widget) - - async def on_mount(self, event: events.Mount) -> None: - async def watch_background(value: str) -> None: - self.background = value - - watch(self.app, "background", watch_background) - - async def on_idle(self, event: events.Idle) -> None: - if self.layout.check_update(): - self.layout.reset_update() - await self.refresh_layout() - - async def _on_mouse_move(self, event: events.MouseMove) -> None: - - try: - if self.app.mouse_captured: - widget = self.app.mouse_captured - region = self.get_widget_region(widget) - else: - widget, region = self.get_widget_at(event.x, event.y) - except NoWidget: - await self.app.set_mouse_over(None) - else: - await self.app.set_mouse_over(widget) - await widget.forward_event( - events.MouseMove( - self, - event.x - region.x, - event.y - region.y, - event.delta_x, - event.delta_y, - event.button, - event.shift, - event.meta, - event.ctrl, - screen_x=event.screen_x, - screen_y=event.screen_y, - style=event.style, - ) - ) - - async def forward_event(self, event: events.Event) -> None: - event.set_forwarded() - if isinstance(event, (events.Enter, events.Leave)): - await self.post_message(event) - - elif isinstance(event, events.MouseMove): - event.style = self.get_style_at(event.screen_x, event.screen_y) - await self._on_mouse_move(event) - - elif isinstance(event, events.MouseEvent): - try: - if self.app.mouse_captured: - widget = self.app.mouse_captured - region = self.get_widget_region(widget) - else: - widget, region = self.get_widget_at(event.x, event.y) - except NoWidget: - pass - else: - if isinstance(event, events.MouseDown) and widget.can_focus: - await self.app.set_focus(widget) - event.style = self.get_style_at(event.screen_x, event.screen_y) - await widget.forward_event(event.offset(-region.x, -region.y)) - - elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): - try: - widget, _region = self.get_widget_at(event.x, event.y) - except NoWidget: - return - scroll_widget = widget - if scroll_widget is not None: - await scroll_widget.forward_event(event) - else: - self.log("view.forwarded", event) - await self.post_message(event) - - async def action_toggle(self, name: str) -> None: - widget = self.named_widgets[name] - widget.visible = not widget.visible - await self.post_message(messages.Layout(self)) diff --git a/src/textual/views/__init__.py b/src/textual/views/__init__.py deleted file mode 100644 index d0f5515b6..000000000 --- a/src/textual/views/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._dock_view import DockView, Dock, DockEdge -from ._grid_view import GridView -from ._window_view import WindowView diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py deleted file mode 100644 index e468eab50..000000000 --- a/src/textual/views/_dock_view.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations -from typing import cast, Optional - -from ..layouts.dock import DockLayout, Dock, DockEdge -from ..layouts.grid import GridLayout, GridAlign -from ..view import View -from ..widget import Widget - - -class DoNotSet: - pass - - -do_not_set = DoNotSet() - - -class DockView(View): - def __init__(self, name: str | None = None) -> None: - super().__init__(layout=DockLayout(), name=name) - - async def dock( - self, - *widgets: Widget, - edge: DockEdge = "top", - z: int = 0, - size: int | None | DoNotSet = do_not_set, - name: str | None = None, - ) -> None: - - dock = Dock(edge, widgets, z) - assert isinstance(self.layout, DockLayout) - self.layout.docks.append(dock) - for widget in widgets: - if size is not do_not_set: - widget.layout_size = cast(Optional[int], size) - if name is None: - await self.mount(widget) - else: - await self.mount(**{name: widget}) - await self.refresh_layout() - - async def dock_grid( - self, - *, - edge: DockEdge = "top", - z: int = 0, - size: int | None | DoNotSet = do_not_set, - name: str | None = None, - gap: tuple[int, int] | int | None = None, - gutter: tuple[int, int] | int | None = None, - align: tuple[GridAlign, GridAlign] | None = None, - ) -> GridLayout: - - grid = GridLayout(gap=gap, gutter=gutter, align=align) - view = View(layout=grid, name=name) - dock = Dock(edge, (view,), z) - assert isinstance(self.layout, DockLayout) - self.layout.docks.append(dock) - if size is not do_not_set: - view.layout_size = cast(Optional[int], size) - if name is None: - await self.mount(view) - else: - await self.mount(**{name: view}) - await self.refresh_layout() - return grid diff --git a/src/textual/views/_document_view.py b/src/textual/views/_document_view.py deleted file mode 100644 index 9fe5c6445..000000000 --- a/src/textual/views/_document_view.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -from ..view import View diff --git a/src/textual/views/_grid_view.py b/src/textual/views/_grid_view.py deleted file mode 100644 index af04b6d3f..000000000 --- a/src/textual/views/_grid_view.py +++ /dev/null @@ -1,9 +0,0 @@ -from ..view import View -from ..layouts.grid import GridLayout - - -class GridView(View, layout=GridLayout): - @property - def grid(self) -> GridLayout: - assert isinstance(self.layout, GridLayout) - return self.layout diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py deleted file mode 100644 index b4cb1f061..000000000 --- a/src/textual/views/_window_view.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from rich.console import RenderableType - -from .. import events -from ..geometry import Size, SpacingDimensions -from ..layouts.vertical import VerticalLayout -from ..view import View -from ..message import Message -from .. import messages -from ..widget import Widget -from ..widgets import Static - - -class WindowChange(Message): - def can_replace(self, message: Message) -> bool: - return isinstance(message, WindowChange) - - -class WindowView(View, layout=VerticalLayout): - def __init__( - self, - widget: RenderableType | Widget, - *, - auto_width: bool = False, - gutter: SpacingDimensions = (0, 0), - name: str | None = None, - ) -> None: - layout = VerticalLayout(gutter=gutter, auto_width=auto_width) - self.widget = widget if isinstance(widget, Widget) else Static(widget) - layout.add(self.widget) - super().__init__(name=name, layout=layout) - - async def update(self, widget: Widget | RenderableType) -> None: - layout = self.layout - assert isinstance(layout, VerticalLayout) - layout.clear() - self.widget = widget if isinstance(widget, Widget) else Static(widget) - layout.add(self.widget) - self.layout.require_update() - self.refresh(layout=True) - await self.emit(WindowChange(self)) - - async def handle_update(self, message: messages.Update) -> None: - message.prevent_default() - await self.emit(WindowChange(self)) - - async def handle_layout(self, message: messages.Layout) -> None: - self.log("TRANSLATING layout") - self.layout.require_update() - message.stop() - self.refresh() - - async def watch_virtual_size(self, size: Size) -> None: - await self.emit(WindowChange(self)) - - async def watch_scroll_x(self, value: int) -> None: - self.layout.require_update() - self.refresh() - - async def watch_scroll_y(self, value: int) -> None: - self.layout.require_update() - self.refresh() - - async def on_resize(self, event: events.Resize) -> None: - await self.emit(WindowChange(self)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 68c75f8b9..363c1f518 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,252 +1,1810 @@ from __future__ import annotations -from logging import getLogger +from asyncio import Lock, wait, create_task +from fractions import Fraction +from itertools import islice +from operator import attrgetter from typing import ( - Any, Awaitable, + Generator, TYPE_CHECKING, - Callable, ClassVar, + Collection, + Iterable, NamedTuple, - NewType, + Sequence, cast, ) -import rich.repr -from rich import box -from rich.align import Align -from rich.console import Console, RenderableType -from rich.panel import Panel -from rich.padding import Padding -from rich.pretty import Pretty -from rich.style import Style -from rich.styled import Styled -from rich.text import TextType -from . import events -from ._animator import BoundAnimator -from ._callback import invoke +import rich.repr +from rich.console import ( + Console, + ConsoleOptions, + ConsoleRenderable, + JustifyMethod, + RenderableType, + RenderResult, + RichCast, +) +from rich.measure import Measurement +from rich.segment import Segment +from rich.style import Style +from rich.text import Text + +from . import errors, events, messages +from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction +from ._arrange import DockArrangeResult, arrange from ._context import active_app -from .geometry import Size, Spacing, SpacingDimensions -from .message import Message -from .message_pump import MessagePump -from .messages import Layout, Update -from .reactive import Reactive, watch +from ._layout import Layout +from ._segment_tools import align_lines +from ._styles_cache import StylesCache from ._types import Lines +from .binding import NoBinding +from .box_model import BoxModel, get_box_model +from .css.scalar import ScalarOffset +from .dom import DOMNode, NoScreen +from .geometry import Offset, Region, Size, Spacing, clamp +from .layouts.vertical import VerticalLayout +from .message import Message +from .messages import CallbackType +from .reactive import Reactive +from .render import measure if TYPE_CHECKING: - from .app import App - from .view import View + from .app import App, ComposeResult + from .scrollbar import ( + ScrollBar, + ScrollBarCorner, + ScrollDown, + ScrollLeft, + ScrollRight, + ScrollTo, + ScrollUp, + ) -log = getLogger("rich") +_JUSTIFY_MAP: dict[str, JustifyMethod] = { + "start": "left", + "end": "right", + "justify": "full", +} + + +class AwaitMount: + """An awaitable returned by mount() and mount_all(). + + Example: + await self.mount(Static("foo")) + + """ + + def __init__(self, widgets: Sequence[Widget]) -> None: + self._widgets = widgets + + def __await__(self) -> Generator[None, None, None]: + async def await_mount() -> None: + aws = [ + create_task(widget._mounted_event.wait()) for widget in self._widgets + ] + if aws: + await wait(aws) + + return await_mount().__await__() + + +class _Styled: + """Apply a style to a renderable. + + Args: + renderable (RenderableType): Any renderable. + style (StyleType): A style to apply across the entire renderable. + """ + + def __init__( + self, renderable: "RenderableType", style: Style, link_style: Style | None + ) -> None: + self.renderable = renderable + self.style = style + self.link_style = link_style + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + style = console.get_style(self.style) + result_segments = console.render(self.renderable, options) + + _Segment = Segment + if style: + apply = style.__add__ + result_segments = ( + _Segment(text, apply(_style), control) + for text, _style, control in result_segments + ) + link_style = self.link_style + if link_style: + result_segments = ( + _Segment( + text, + style + if style._meta is None + else (style + link_style if "@click" in style.meta else style), + control, + ) + for text, style, control in result_segments + ) + return result_segments + + def __rich_measure__( + self, console: "Console", options: "ConsoleOptions" + ) -> Measurement: + return self.renderable.__rich_measure__(console, options) class RenderCache(NamedTuple): + """Stores results of a previous render.""" + size: Size lines: Lines - @property - def cursor_line(self) -> int | None: - for index, line in enumerate(self.lines): - for text, style, control in line: - if style and style._meta and style.meta.get("cursor", False): - return index - return None - @rich.repr.auto -class Widget(MessagePump): - _id: ClassVar[int] = 0 - _counts: ClassVar[dict[str, int]] = {} +class Widget(DOMNode): + """ + A Widget is the base class for Textual widgets. + + See also [static][textual.widgets._static.Static] for starting point for your own widgets. + + """ + + DEFAULT_CSS = """ + Widget{ + scrollbar-background: $panel-darken-1; + scrollbar-background-hover: $panel-darken-2; + scrollbar-color: $primary-lighten-1; + scrollbar-color-active: $warning-darken-1; + scrollbar-corner-color: $panel-darken-1; + scrollbar-size-vertical: 2; + scrollbar-size-horizontal: 1; + link-background:; + link-color: $text; + link-style: underline; + link-hover-background: $accent; + link-hover-color: $text; + link-hover-style: bold not underline; + } + """ + COMPONENT_CLASSES: ClassVar[set[str]] = set() + can_focus: bool = False + """Widget may receive focus.""" + can_focus_children: bool = True + """Widget's children may receive focus.""" + expand = Reactive(False) + """Rich renderable may expand.""" + shrink = Reactive(True) + """Rich renderable may shrink.""" + auto_links = Reactive(True) + """Widget will highlight links automatically.""" - def __init__(self, name: str | None = None) -> None: - class_name = self.__class__.__name__ - Widget._counts.setdefault(class_name, 0) - Widget._counts[class_name] += 1 - _count = self._counts[class_name] + hover_style: Reactive[Style] = Reactive(Style, repaint=False) + highlight_link_id: Reactive[str] = Reactive("") - self.name = name or f"{class_name}#{_count}" + def __init__( + self, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: self._size = Size(0, 0) - self._repaint_required = False + self._container_size = Size(0, 0) self._layout_required = False + self._repaint_required = False + self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None - self._reactive_watches: dict[str, Callable] = {} - self.render_cache: RenderCache | None = None self.highlight_style: Style | None = None - super().__init__() + self._vertical_scrollbar: ScrollBar | None = None + self._horizontal_scrollbar: ScrollBar | None = None + self._scrollbar_corner: ScrollBarCorner | None = None - visible: Reactive[bool] = Reactive(True, layout=True) - layout_size: Reactive[int | None] = Reactive(None, layout=True) - layout_fraction: Reactive[int] = Reactive(1, layout=True) - layout_min_size: Reactive[int] = Reactive(1, layout=True) - layout_offset_x: Reactive[float] = Reactive(0.0, layout=True) - layout_offset_y: Reactive[float] = Reactive(0.0, layout=True) + self._render_cache = RenderCache(Size(0, 0), []) + # Regions which need to be updated (in Widget) + self._dirty_regions: set[Region] = set() + # Regions which need to be transferred from cache to screen + self._repaint_regions: set[Region] = set() - style: Reactive[str | None] = Reactive(None) - padding: Reactive[Spacing | None] = Reactive(None, layout=True) - margin: Reactive[Spacing | None] = Reactive(None, layout=True) - border: Reactive[str] = Reactive("none", layout=True) - border_style: Reactive[str] = Reactive("") - border_title: Reactive[TextType] = Reactive("") + # Cache the auto content dimensions + # TODO: add mechanism to explicitly clear this + self._content_width_cache: tuple[object, int] = (None, 0) + self._content_height_cache: tuple[object, int] = (None, 0) - BOX_MAP = {"normal": box.SQUARE, "round": box.ROUNDED, "bold": box.HEAVY} + self._arrangement: DockArrangeResult | None = None + self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) - def validate_padding(self, padding: SpacingDimensions) -> Spacing: - return Spacing.unpack(padding) + self._styles_cache = StylesCache() + self._rich_style_cache: dict[str, Style] = {} + self._stabilized_scrollbar_size: Size | None = None + self._lock = Lock() - def validate_margin(self, margin: SpacingDimensions) -> Spacing: - return Spacing.unpack(margin) + super().__init__( + name=name, + id=id, + classes=self.DEFAULT_CLASSES if classes is None else classes, + ) + self._add_children(*children) - def validate_layout_offset_x(self, value) -> int: - return int(value) + virtual_size = Reactive(Size(0, 0), layout=True) + auto_width = Reactive(True) + auto_height = Reactive(True) + has_focus = Reactive(False) + mouse_over = Reactive(False) + scroll_x = Reactive(0.0, repaint=False, layout=False) + scroll_y = Reactive(0.0, repaint=False, layout=False) + scroll_target_x = Reactive(0.0, repaint=False) + scroll_target_y = Reactive(0.0, repaint=False) + show_vertical_scrollbar = Reactive(False, layout=True) + show_horizontal_scrollbar = Reactive(False, layout=True) - def validate_layout_offset_y(self, value) -> int: - return int(value) + @property + def siblings(self) -> list[Widget]: + """Get the widget's siblings (self is removed from the return list). - def __init_subclass__(cls, can_focus: bool = True) -> None: - super().__init_subclass__() - cls.can_focus = can_focus + Returns: + list[Widget]: A list of siblings. + """ + parent = self.parent + if parent is not None: + siblings = list(parent.children) + siblings.remove(self) + return siblings + else: + return [] + + @property + def visible_siblings(self) -> list[Widget]: + """A list of siblings which will be shown. + + Returns: + list[Widget]: List of siblings. + """ + siblings = [ + widget for widget in self.siblings if widget.visible and widget.display + ] + return siblings + + @property + def allow_vertical_scroll(self) -> bool: + """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. + + 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.is_scrollable and ( + self.allow_horizontal_scroll or self.allow_vertical_scroll + ) + + @property + def offset(self) -> Offset: + """Widget offset from origin. + + Returns: + Offset: Relative offset. + """ + return self.styles.offset.resolve(self.size, self.app.size) + + @offset.setter + def offset(self, offset: Offset) -> None: + self.styles.offset = ScalarOffset.from_offset(offset) + + def get_component_rich_style(self, name: str) -> Style: + """Get a *Rich* style for a component. + + Args: + name (str): Name of component. + + Returns: + Style: A Rich style object. + """ + style = self._rich_style_cache.get(name) + if style is None: + style = self.get_component_styles(name).rich_style + self._rich_style_cache[name] = style + return style + + def _arrange(self, size: Size) -> DockArrangeResult: + """Arrange children. + + Args: + size (Size): Size of container. + + Returns: + ArrangeResult: Widget locations. + """ + + arrange_cache_key = (self.children._updates, size) + if ( + self._arrangement is not None + and arrange_cache_key == self._arrangement_cache_key + ): + return self._arrangement + + self._arrangement_cache_key = arrange_cache_key + self._arrangement = arrange(self, self.children, size, self.screen.size) + return self._arrangement + + def _clear_arrangement_cache(self) -> None: + """Clear arrangement cache, forcing a new arrange operation.""" + self._arrangement = None + + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> AwaitMount: + """Mount child widgets (making this widget a container). + + Widgets may be passed as positional arguments or keyword arguments. If keyword arguments, + the keys will be set as the Widget's id. + + Example: + ```python + self.mount(Static("hello"), header=Header()) + ``` + + Returns: + AwaitMount: An awaitable object that waits for widgets to be mounted. + + """ + mounted_widgets = self.app._register(self, *anon_widgets, **widgets) + return AwaitMount(mounted_widgets) + + def compose(self) -> ComposeResult: + """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 + + def _post_register(self, app: App) -> None: + """Called when the instance is registered. + + Args: + app (App): App instance. + """ + # Parse the Widget's CSS + for path, css, tie_breaker in self.get_default_css(): + self.app.stylesheet.add_source( + css, path=path, is_default_css=True, tie_breaker=tie_breaker + ) + + def _get_box_model( + self, container: Size, viewport: Size, fraction_unit: Fraction + ) -> BoxModel: + """Process the box model for this widget. + + 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. + """ + box_model = get_box_model( + self.styles, + container, + viewport, + fraction_unit, + self.get_content_width, + self.get_content_height, + ) + return box_model + + def get_content_width(self, container: Size, viewport: Size) -> int: + """Called by textual to get the width of the content area. May be overridden in a subclass. + + Args: + container (Size): Size of the container (immediate parent) widget. + viewport (Size): Size of the viewport. + + Returns: + int: The optimal width of the content. + """ + if self.is_container: + assert self._layout is not None + return self._layout.get_content_width(self, container, viewport) + + cache_key = container.width + if self._content_width_cache[0] == cache_key: + return self._content_width_cache[1] + + console = self.app.console + renderable = self._render() + + width = measure(console, renderable, container.width) + if self.expand: + width = max(container.width, width) + if self.shrink: + width = min(width, container.width) + + self._content_width_cache = (cache_key, width) + return width + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + """Called by Textual to get the height of the content area. May be overridden in a subclass. + + 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. + """ + + if self.is_container: + assert self._layout is not None + height = ( + self._layout.get_content_height( + self, + container, + viewport, + width, + ) + + self.scrollbar_size_horizontal + ) + else: + cache_key = width + + if self._content_height_cache[0] == cache_key: + 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) + # 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) + + return height + + def watch_hover_style( + self, previous_hover_style: Style, hover_style: Style + ) -> None: + if self.auto_links: + self.highlight_link_id = hover_style.link_id + + def watch_scroll_x(self, new_value: float) -> None: + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.position = int(new_value) + self.horizontal_scrollbar.refresh() + self.refresh(layout=True) + + def watch_scroll_y(self, new_value: float) -> None: + if self.show_vertical_scrollbar: + self.vertical_scrollbar.position = int(new_value) + self.vertical_scrollbar.refresh() + self.refresh(layout=True) + + def validate_scroll_x(self, value: float) -> float: + return clamp(value, 0, self.max_scroll_x) + + def validate_scroll_target_x(self, value: float) -> float: + return clamp(value, 0, self.max_scroll_x) + + def validate_scroll_y(self, value: float) -> float: + return clamp(value, 0, self.max_scroll_y) + + def validate_scroll_target_y(self, value: float) -> float: + return clamp(value, 0, self.max_scroll_y) + + @property + def max_scroll_x(self) -> int: + """The maximum value of `scroll_x`.""" + return max( + 0, + self.virtual_size.width + - self.container_size.width + + self.scrollbar_size_vertical, + ) + + @property + def max_scroll_y(self) -> int: + """The maximum value of `scroll_y`.""" + return max( + 0, + self.virtual_size.height + - self.container_size.height + + self.scrollbar_size_horizontal, + ) + + @property + def scrollbar_corner(self) -> ScrollBarCorner: + """Return the ScrollBarCorner - the cells that appear between the + horizontal and vertical scrollbars (only when both are visible). + """ + from .scrollbar import ScrollBarCorner + + if self._scrollbar_corner is not None: + return self._scrollbar_corner + self._scrollbar_corner = ScrollBarCorner() + self.app._start_widget(self, self._scrollbar_corner) + return self._scrollbar_corner + + @property + def vertical_scrollbar(self) -> ScrollBar: + """Get a vertical scrollbar (create if necessary). + + Returns: + ScrollBar: ScrollBar Widget. + """ + from .scrollbar import ScrollBar + + if self._vertical_scrollbar is not None: + return self._vertical_scrollbar + self._vertical_scrollbar = scroll_bar = ScrollBar( + vertical=True, name="vertical", thickness=self.scrollbar_size_vertical + ) + self._vertical_scrollbar.display = False + self.app._start_widget(self, scroll_bar) + return scroll_bar + + @property + def horizontal_scrollbar(self) -> ScrollBar: + """Get a vertical scrollbar (create if necessary). + + Returns: + ScrollBar: ScrollBar Widget. + """ + from .scrollbar import ScrollBar + + if self._horizontal_scrollbar is not None: + return self._horizontal_scrollbar + self._horizontal_scrollbar = scroll_bar = ScrollBar( + vertical=False, name="horizontal", thickness=self.scrollbar_size_horizontal + ) + self._horizontal_scrollbar.display = False + + self.app._start_widget(self, scroll_bar) + return scroll_bar + + def _refresh_scrollbars(self) -> None: + """Refresh scrollbar visibility.""" + if not self.is_scrollable: + return + + styles = self.styles + overflow_x = styles.overflow_x + overflow_y = styles.overflow_y + width, height = self.container_size + + show_horizontal = self.show_horizontal_scrollbar + if overflow_x == "hidden": + show_horizontal = False + if overflow_x == "scroll": + show_horizontal = True + elif overflow_x == "auto": + show_horizontal = self.virtual_size.width > width + + show_vertical = self.show_vertical_scrollbar + if overflow_y == "hidden": + show_vertical = False + elif overflow_y == "scroll": + show_vertical = True + elif overflow_y == "auto": + show_vertical = self.virtual_size.height > height + + if ( + overflow_x == "auto" + and show_vertical + and not show_horizontal + and self._stabilized_scrollbar_size != self.container_size + ): + show_horizontal = ( + self.virtual_size.width + styles.scrollbar_size_vertical > width + ) + self._stabilized_scrollbar_size = self.container_size + + self.show_horizontal_scrollbar = show_horizontal + self.show_vertical_scrollbar = show_vertical + self.horizontal_scrollbar.display = show_horizontal + self.vertical_scrollbar.display = show_vertical + + @property + def scrollbars_enabled(self) -> tuple[bool, bool]: + """A tuple of booleans that indicate if scrollbars are enabled. + + Returns: + tuple[bool, bool]: A tuple of (, ) + + """ + if not self.is_scrollable: + return False, False + + enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar + return enabled + + @property + def scrollbar_size_vertical(self) -> int: + """Get the width used by the *vertical* scrollbar. + + Returns: + int: Number of columns in the vertical scrollbar. + """ + styles = self.styles + if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto": + return styles.scrollbar_size_vertical + return styles.scrollbar_size_vertical if self.show_vertical_scrollbar else 0 + + @property + def scrollbar_size_horizontal(self) -> int: + """Get the height used by the *horizontal* scrollbar. + + Returns: + int: Number of rows in the horizontal scrollbar. + """ + styles = self.styles + if styles.scrollbar_gutter == "stable" and styles.overflow_x == "auto": + return styles.scrollbar_size_horizontal + return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0 + + @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 + ) + return gutter + + @property + def gutter(self) -> Spacing: + """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. + + 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). + + Returns: + Size: Outer size. + """ + return self._size + + @property + def container_size(self) -> Size: + """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). + + Returns: + Region: Screen region that contains a widget's content. + """ + content_region = self.region.shrink(self.styles.gutter) + return content_region + + @property + def content_offset(self) -> Offset: + """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 content_size(self) -> Size: + """Get the size of the content area.""" + return self.region.shrink(self.styles.gutter).size + + @property + def region(self) -> Region: + """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: + return Region() + except errors.NoWidget: + return Region() + + @property + def container_viewport(self) -> Region: + """The viewport region (parent window). + + Returns: + Region: The region that contains this widget. + """ + if self.parent is None: + return self.size.region + assert isinstance(self.parent, Widget) + return self.parent.region + + @property + def virtual_region(self) -> Region: + """The widget region relative to it's container. Which may not be visible, + depending on scroll offset. + """ + try: + return self.screen.find_widget(self).virtual_region + except NoScreen: + return Region() + except errors.NoWidget: + return Region() + + @property + def window_region(self) -> Region: + """The region within the scrollable area that is currently visible. + + Returns: + Region: New region. + """ + window_region = self.region.at_offset(self.scroll_offset) + return window_region + + @property + def virtual_region_with_margin(self) -> Region: + """The widget region relative to its container (*including margin*), which may not be visible, + depending on the scroll offset. + + Returns: + Region: The virtual region of the Widget, inclusive of its margin. + """ + return self.virtual_region.grow(self.styles.margin) + + @property + def focusable_children(self) -> list[Widget]: + """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 + ] + return sorted(focusable, key=attrgetter("_focus_sort_key")) + + @property + def _focus_sort_key(self) -> tuple[int, int]: + """Key function to sort widgets in to focus 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 + def is_transparent(self) -> bool: + """Check if the background styles is not set. + + Returns: + bool: ``True`` if there is background color, otherwise ``False``. + """ + return self.is_scrollable and self.styles.background.is_transparent + + @property + def _console(self) -> Console: + """Get the current console. + + Returns: + Console: A Rich console object. + + """ + return active_app.get().console + + def animate( + self, + attribute: str, + value: float | Animatable, + *, + final_value: object = ..., + duration: float | None = None, + speed: float | None = None, + delay: float = 0.0, + easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, + ) -> None: + """Animate an attribute. + + Args: + attribute (str): Name of the attribute to animate. + value (float | Animatable): The value to animate to. + final_value (object, optional): The final value of the animation. Defaults to `value` if not set. + duration (float | None, optional): The duration of the animate. Defaults to None. + speed (float | None, optional): The speed of the animation. Defaults to None. + delay (float, optional): A delay (in seconds) before the animation starts. Defaults to 0.0. + easing (EasingFunction | str, optional): An easing method. Defaults to "in_out_cubic". + on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None. + + """ + if self._animate is None: + self._animate = self.app.animator.bind(self) + assert self._animate is not None + self._animate( + attribute, + value, + final_value=final_value, + duration=duration, + speed=speed, + delay=delay, + easing=easing, + on_complete=on_complete, + ) + + @property + 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 + def is_container(self) -> bool: + """Check if this widget is a container (contains other widgets). + + Returns: + bool: True if this widget is a container. + """ + return self.styles.layout is not None or bool(self.children) + + @property + def is_scrollable(self) -> bool: + """Check if this Widget may be scrolled. + + Returns: + bool: True if this widget may be scrolled. + """ + return self.styles.layout is not None or bool(self.children) + + @property + def layer(self) -> str: + """Get the name of this widgets layer. + + Returns: + str: Name of layer. + + """ + return self.styles.layer or "default" + + @property + def layers(self) -> tuple[str, ...]: + """Layers of from parent. + + Returns: + tuple[str, ...]: Tuple of layer names. + """ + for node in self.ancestors: + if not isinstance(node, Widget): + break + if node.styles.has_rule("layers"): + return node.styles.layers + return ("default",) + + @property + def link_style(self) -> Style: + """Style of links.""" + styles = self.styles + _, background = self.background_colors + link_background = background + styles.link_background + link_color = link_background + ( + link_background.get_contrast_text(styles.link_color.a) + if styles.auto_link_color + else styles.link_color + ) + style = styles.link_style + Style.from_color( + link_color.rich_color, + link_background.rich_color, + ) + return style + + @property + def link_hover_style(self) -> Style: + """Style of links with mouse hover.""" + styles = self.styles + _, background = self.background_colors + hover_background = background + styles.link_hover_background + hover_color = hover_background + ( + hover_background.get_contrast_text(styles.link_hover_color.a) + if styles.auto_link_hover_color + else styles.link_hover_color + ) + style = styles.link_hover_style + Style.from_color( + hover_color.rich_color, + hover_background.rich_color, + ) + return style + + def _set_dirty(self, *regions: Region) -> None: + """Set the Widget as 'dirty' (requiring re-paint). + + Regions should be specified as positional args. If no regions are added, then + the entire widget will be considered dirty. + + Args: + *regions (Region): Regions which require a repaint. + + """ + if regions: + content_offset = self.content_offset + widget_regions = [region.translate(content_offset) for region in regions] + self._dirty_regions.update(widget_regions) + self._repaint_regions.update(widget_regions) + self._styles_cache.set_dirty(*widget_regions) + else: + self._dirty_regions.clear() + self._repaint_regions.clear() + self._styles_cache.clear() + self._dirty_regions.add(self.outer_size.region) + self._repaint_regions.add(self.outer_size.region) + + def _exchange_repaint_regions(self) -> Collection[Region]: + """Get a copy of the regions which need a repaint, and clear internal cache. + + Returns: + Collection[Region]: Regions to repaint. + """ + regions = self._repaint_regions.copy() + self._repaint_regions.clear() + return regions + + def scroll_to( + self, + x: float | None = None, + y: float | None = None, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """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 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 None. + + Returns: + bool: True if the scroll position changed, otherwise False. + """ + scrolled_x = scrolled_y = False + if animate: + # TODO: configure animation speed + if duration is None and speed is None: + speed = 50 + if x is not None: + self.scroll_target_x = x + if x != self.scroll_x: + self.animate( + "scroll_x", + self.scroll_target_x, + speed=speed, + duration=duration, + easing="out_cubic", + ) + scrolled_x = True + if y is not None: + self.scroll_target_y = y + if y != self.scroll_y: + self.animate( + "scroll_y", + self.scroll_target_y, + speed=speed, + duration=duration, + easing="out_cubic", + ) + scrolled_y = True + + else: + if x is not None: + scroll_x = self.scroll_x + self.scroll_target_x = self.scroll_x = x + scrolled_x = scroll_x != self.scroll_x + if y is not None: + scroll_y = self.scroll_y + self.scroll_target_y = self.scroll_y = y + scrolled_y = scroll_y != self.scroll_y + + return scrolled_x or scrolled_y + + def scroll_relative( + self, + x: float | None = None, + y: float | None = None, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll relative to current position. + + Args: + 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 None. + + Returns: + bool: True if the scroll position changed, otherwise False. + """ + return self.scroll_to( + None if x is None else (self.scroll_x + x), + None if y is None else (self.scroll_y + y), + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_home( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll to home position. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + """ + if speed is None and duration is None: + duration = 1.0 + return self.scroll_to(0, 0, animate=animate, speed=speed, duration=duration) + + def scroll_end( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll to the end of the container. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + if speed is None and duration is None: + duration = 1.0 + return self.scroll_to( + 0, self.max_scroll_y, animate=animate, speed=speed, duration=duration + ) + + def scroll_left( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one cell left. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + x=self.scroll_target_x - 1, animate=animate, speed=speed, duration=duration + ) + + def scroll_right( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll on cell right. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + x=self.scroll_target_x + 1, animate=animate, speed=speed, duration=duration + ) + + def scroll_down( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one line down. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + y=self.scroll_target_y + 1, animate=animate, speed=speed, duration=duration + ) + + def scroll_up( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one line up. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + y=self.scroll_target_y - 1, animate=animate, speed=speed, duration=duration + ) + + def scroll_page_up( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one page up. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + y=self.scroll_target_y - self.container_size.height, + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_page_down( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one page down. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + return self.scroll_to( + y=self.scroll_target_y + self.container_size.height, + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_page_left( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one page left. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + if speed is None and duration is None: + duration = 0.3 + return self.scroll_to( + x=self.scroll_target_x - self.container_size.width, + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_page_right( + self, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: + """Scroll one page right. + + Args: + animate (bool, optional): Animate scroll. 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 None. + + Returns: + bool: True if any scrolling was done. + + """ + if speed is None and duration is None: + duration = 0.3 + return self.scroll_to( + x=self.scroll_target_x + self.container_size.width, + animate=animate, + speed=speed, + duration=duration, + ) + + def scroll_to_widget( + self, + widget: Widget, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + top: bool = False, + ) -> bool: + """Scroll scrolling to bring a widget in to view. + + Args: + widget (Widget): A descendant widget. + animate (bool, optional): True to animate, or False to jump. 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 None. + + Returns: + bool: True if any scrolling has occurred in any descendant, otherwise False. + """ + + # Grow the region by the margin so to keep the margin in view. + region = widget.virtual_region_with_margin + scrolled = False + + while isinstance(widget.parent, Widget) and widget is not self: + container = widget.parent + scroll_offset = container.scroll_to_region( + region, + spacing=widget.parent.gutter, + animate=animate, + speed=speed, + duration=duration, + top=top, + ) + if scroll_offset: + scrolled = True + + # 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) + ) + .grow(container.styles.margin) + .intersection(container.virtual_region) + ) + widget = container + return scrolled + + def scroll_to_region( + self, + region: Region, + *, + spacing: Spacing | None = None, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + top: bool = False, + ) -> Offset: + """Scrolls a given region in to view, if required. + + This method will scroll the least distance required to move `region` fully within + the scrollable area. + + Args: + region (Region): A region that should be visible. + spacing (Spacing | None, optional): Optional spacing around the region. Defaults to None. + animate (bool, optional): True to animate, or False to jump. 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 None. + top (bool, optional): Scroll region to top of container. Defaults to False. + + 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) + + if window in region: + return Offset() + + delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top) + scroll_x, scroll_y = self.scroll_offset + delta = Offset( + clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x, + clamp(scroll_y + delta_y, 0, self.max_scroll_y) - scroll_y, + ) + if delta: + if speed is None and duration is None: + duration = 0.2 + self.scroll_relative( + delta.x or None, + delta.y or None, + animate=animate if (abs(delta_y) > 1 or delta_x) else False, + speed=speed, + duration=duration, + ) + return delta + + def scroll_visible( + self, + animate: bool = True, + *, + speed: float | None = None, + duration: float | None = None, + top: bool = False, + ) -> None: + """Scroll the container to make this widget visible. + + Args: + animate (bool, optional): _description_. Defaults to True. + speed (float | None, optional): _description_. Defaults to None. + duration (float | None, optional): _description_. Defaults to None. + top (bool, optional): Scroll to top of container. Defaults to False. + """ + parent = self.parent + if isinstance(parent, Widget): + self.call_later( + parent.scroll_to_widget, + self, + animate=animate, + speed=speed, + duration=duration, + top=top, + ) + + def __init_subclass__( + cls, + can_focus: bool | None = None, + can_focus_children: bool | None = None, + inherit_css: bool = True, + ) -> None: + base = cls.__mro__[0] + super().__init_subclass__(inherit_css=inherit_css) + if issubclass(base, Widget): + cls.can_focus = base.can_focus if can_focus is None else can_focus + cls.can_focus_children = ( + base.can_focus_children + if can_focus_children is None + else can_focus_children + ) def __rich_repr__(self) -> rich.repr.Result: - yield "name", self.name + yield "id", self.id, None + if self.name: + yield "name", self.name + if self.classes: + yield "classes", set(self.classes) + pseudo_classes = self.pseudo_classes + if pseudo_classes: + yield "pseudo_classes", set(pseudo_classes) - def __rich__(self) -> RenderableType: - renderable = self.render_styled() - return renderable + def _get_scrollable_region(self, region: Region) -> Region: + """Adjusts the Widget region to accommodate scrollbars. - def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: - watch(self, attribute_name, callback) + Args: + region (Region): A region for the widget. - def render_styled(self) -> RenderableType: + Returns: + Region: The widget region minus scrollbars. + """ + show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled + + scrollbar_size_horizontal = self.styles.scrollbar_size_horizontal + scrollbar_size_vertical = self.styles.scrollbar_size_vertical + + if self.styles.scrollbar_gutter == "stable": + # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not: + show_vertical_scrollbar = True + scrollbar_size_vertical = self.styles.scrollbar_size_vertical + + if show_horizontal_scrollbar and show_vertical_scrollbar: + (region, _, _, _) = region.split( + -scrollbar_size_vertical, + -scrollbar_size_horizontal, + ) + elif show_vertical_scrollbar: + region, _ = region.split_vertical(-scrollbar_size_vertical) + elif show_horizontal_scrollbar: + region, _ = region.split_horizontal(-scrollbar_size_horizontal) + return region + + def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]]: + """Arrange the 'chrome' widgets (typically scrollbars) for a layout element. + + Args: + region (Region): The containing region. + + Returns: + Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region. + + """ + + show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled + + scrollbar_size_horizontal = self.scrollbar_size_horizontal + scrollbar_size_vertical = self.scrollbar_size_vertical + + if show_horizontal_scrollbar and show_vertical_scrollbar: + ( + _, + vertical_scrollbar_region, + horizontal_scrollbar_region, + scrollbar_corner_gap, + ) = region.split( + -scrollbar_size_vertical, + -scrollbar_size_horizontal, + ) + if scrollbar_corner_gap: + yield self.scrollbar_corner, scrollbar_corner_gap + if vertical_scrollbar_region: + yield self.vertical_scrollbar, vertical_scrollbar_region + if horizontal_scrollbar_region: + yield self.horizontal_scrollbar, horizontal_scrollbar_region + + elif show_vertical_scrollbar: + _, scrollbar_region = region.split_vertical(-scrollbar_size_vertical) + if scrollbar_region: + yield self.vertical_scrollbar, scrollbar_region + elif show_horizontal_scrollbar: + _, scrollbar_region = region.split_horizontal(-scrollbar_size_horizontal) + if scrollbar_region: + yield self.horizontal_scrollbar, scrollbar_region + + def get_pseudo_classes(self) -> Iterable[str]: + """Pseudo classes for a widget. + + Returns: + Iterable[str]: Names of the pseudo classes. + + """ + if self.mouse_over: + yield "hover" + if self.has_focus: + yield "focus" + try: + focused = self.screen.focused + except NoScreen: + pass + else: + if focused: + node = focused + while node is not None: + if node is self: + yield "focus-within" + break + node = node._parent + + def post_render(self, renderable: RenderableType) -> ConsoleRenderable: """Applies style attributes to the default renderable. Returns: RenderableType: A new renderable. """ - renderable = self.render() - if self.padding is not None: - renderable = Padding(renderable, self.padding) - if self.border in self.BOX_MAP: - renderable = Panel( - renderable, - box=self.BOX_MAP.get(self.border) or box.SQUARE, - style=self.border_style, - ) - if self.margin is not None: - renderable = Padding(renderable, self.margin) - if self.style: - renderable = Styled(renderable, self.style) + text_justify: JustifyMethod | None = None + if self.styles.has_rule("text_align"): + text_align: JustifyMethod = cast(JustifyMethod, self.styles.text_align) + text_justify = _JUSTIFY_MAP.get(text_align, text_align) + + if isinstance(renderable, str): + renderable = Text.from_markup(renderable, justify=text_justify) + + if ( + isinstance(renderable, Text) + and text_justify is not None + and renderable.justify is None + ): + renderable.justify = text_justify + + renderable = _Styled( + renderable, self.rich_style, self.link_style if self.auto_links else None + ) + return renderable - @property - def size(self) -> Size: - return self._size + def watch_mouse_over(self, value: bool) -> None: + """Update from CSS if mouse over state changes.""" + if self._has_hover_style: + self.app.update_styles(self) - @property - def is_visual(self) -> bool: - return True + def watch_has_focus(self, value: bool) -> None: + """Update from CSS if has focus state changes.""" + self.app.update_styles(self) - @property - def console(self) -> Console: - """Get the current console.""" - return active_app.get().console + def _size_updated( + self, size: Size, virtual_size: Size, container_size: Size + ) -> None: + """Called when the widget's size is updated. - @property - def root_view(self) -> "View": - """Return the top-most view.""" - return active_app.get().view + Args: + size (Size): Screen size. + virtual_size (Size): Virtual (scrollable) size. + container_size (Size): Container size (size of parent). + """ + if ( + self._size != size + or self.virtual_size != virtual_size + or self._container_size != container_size + ): + self._size = size + self.virtual_size = virtual_size + self._container_size = container_size + if self.is_scrollable: + self._scroll_update(virtual_size) + self.refresh() - @property - def animate(self) -> BoundAnimator: - if self._animate is None: - self._animate = self.app.animator.bind(self) - assert self._animate is not None - return self._animate + def _scroll_update(self, virtual_size: Size) -> None: + """Update scrollbars visibility and dimensions. - @property - def layout_offset(self) -> tuple[int, int]: - """Get the layout offset as a tuple.""" - return (round(self.layout_offset_x), round(self.layout_offset_y)) + Args: + virtual_size (Size): Virtual size. + """ + self._refresh_scrollbars() + width, height = self.container_size - @property - def gutter(self) -> Spacing: - mt, mr, mb, bl = self.margin or (0, 0, 0, 0) - pt, pr, pb, pl = self.padding or (0, 0, 0, 0) - border = 1 if self.border else 0 - gutter = Spacing( - mt + pt + border, mr + pr + border, mb + pb + border, bl + pl + border - ) - return gutter + if self.show_vertical_scrollbar: + self.vertical_scrollbar.window_virtual_size = virtual_size.height + self.vertical_scrollbar.window_size = ( + height - self.scrollbar_size_horizontal + ) + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.window_virtual_size = virtual_size.width + self.horizontal_scrollbar.window_size = width - self.scrollbar_size_vertical - def _update_size(self, size: Size) -> None: - self._size = size + self.scroll_x = self.validate_scroll_x(self.scroll_x) + self.scroll_y = self.validate_scroll_y(self.scroll_y) - def render_lines(self) -> None: + def _render_content(self) -> None: + """Render all lines.""" width, height = self.size - renderable = self.render_styled() - options = self.console.options.update_dimensions(width, height) - lines = self.console.render_lines(renderable, options) - self.render_cache = RenderCache(self.size, lines) + renderable = self.render() + renderable = self.post_render(renderable) + options = self._console.options.update_dimensions(width, height).update( + highlight=False + ) - def render_lines_free(self, width: int) -> None: - renderable = self.render_styled() - options = self.console.options.update(width=width, height=None) - lines = self.console.render_lines(renderable, options) - self.render_cache = RenderCache(Size(width, len(lines)), lines) + segments = self._console.render(renderable, options) + lines = list( + islice( + Segment.split_and_crop_lines( + segments, width, include_new_lines=False, pad=False + ), + None, + height, + ) + ) - def _get_lines(self) -> Lines: - """Get segment lines to render the widget.""" - if self.render_cache is None: - self.render_lines() - assert self.render_cache is not None - lines = self.render_cache.lines + styles = self.styles + align_horizontal, align_vertical = styles.content_align + lines = list( + align_lines( + lines, + Style(), + self.size, + align_horizontal, + align_vertical, + ) + ) + + self._render_cache = RenderCache(self.size, lines) + self._dirty_regions.clear() + + def render_line(self, y: int) -> list[Segment]: + """Render a line of content. + + Args: + y (int): Y Coordinate of line. + + Returns: + list[Segment]: A rendered line. + """ + if self._dirty_regions: + self._render_content() + try: + line = self._render_cache.lines[y] + except IndexError: + line = [Segment(" " * self.size.width, self.rich_style)] + return line + + def render_lines(self, crop: Region) -> Lines: + """Render the widget in to lines. + + Args: + crop (Region): Region within visible area to render. + + Returns: + Lines: A list of list of segments. + """ + lines = self._styles_cache.render_widget(self, crop) return lines - def clear_render_cache(self) -> None: - self.render_cache = None - - def check_repaint(self) -> bool: - return self._repaint_required - - def check_layout(self) -> bool: - return self._layout_required - - def reset_check_repaint(self) -> None: - self._repaint_required = False - - def reset_check_layout(self) -> None: - self._layout_required = False - def get_style_at(self, x: int, y: int) -> Style: - offset_x, offset_y = self.root_view.get_offset(self) - return self.root_view.get_style_at(x + offset_x, y + offset_y) + """Get the Rich style in a widget at a given relative offset. - async def call_later(self, callback: Callable, *args, **kwargs) -> None: - await self.app.call_later(callback, *args, **kwargs) + Args: + x (int): X coordinate relative to the widget. + y (int): Y coordinate relative to the widget. - async def forward_event(self, event: events.Event) -> None: - event.set_forwarded() + Returns: + Style: A rich Style object. + """ + offset = Offset(x, y) + screen_offset = offset + self.region.offset + + widget, _ = self.screen.get_widget_at(*screen_offset) + if widget is not self: + return Style() + return self.screen.get_style_at(*screen_offset) + + async def _forward_event(self, event: events.Event) -> None: + event._set_forwarded() await self.post_message(event) - def refresh(self, repaint: bool = True, layout: bool = False) -> None: + def refresh( + self, *regions: Region, repaint: bool = True, layout: bool = False + ) -> None: """Initiate a refresh of the widget. This method sets an internal flag to perform a refresh, which will be done on the next idle event. Only one refresh will be done even if this method is called multiple times. + By default this method will cause the content of the widget to refresh, but not change its size. You can also + set `layout=True` to perform a layout. + + !!! warning + + It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will + do this automatically. + 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. """ + if layout: - self.clear_render_cache() self._layout_required = True - elif repaint: - self.clear_render_cache() + if isinstance(self._parent, Widget): + self._parent._clear_arrangement_cache() + + if repaint: + self._set_dirty(*regions) + self._content_width_cache = (None, 0) + self._content_height_cache = (None, 0) + self._rich_style_cache.clear() self._repaint_required = True - self.post_message_no_wait(events.Null(self)) + + self.check_idle() + + def remove(self) -> None: + """Remove the Widget from the DOM (effectively deleting it)""" + self.app.post_message_no_wait(events.Remove(self, widget=self)) def render(self) -> RenderableType: """Get renderable for widget. @@ -254,64 +1812,240 @@ class Widget(MessagePump): Returns: RenderableType: Any renderable """ - return Panel( - Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__ - ) + render = "" if self.is_container else self.css_identifier_styled + return render - async def action(self, action: str, *params) -> None: + def _render(self) -> ConsoleRenderable | RichCast: + """Get renderable, promoting str to text as required. + + Returns: + ConsoleRenderable | RichCast: A renderable + """ + renderable = self.render() + if isinstance(renderable, str): + return Text(renderable) + return renderable + + async def action(self, action: str) -> None: + """Perform a given action, with this widget as the default namespace. + + Args: + action (str): Action encoded as a string. + """ await self.app.action(action, self) async def post_message(self, message: Message) -> bool: + """Post a message to this widget. + + Args: + message (Message): Message to post. + + Returns: + bool: True if the message was posted, False if this widget was closed / closing. + """ if not self.check_message_enabled(message): return True if not self.is_running: - self.log(self, "IS NOT RUNNING") + self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") return await super().post_message(message) - async def on_resize(self, event: events.Resize) -> None: - self.refresh() - - async def on_idle(self, event: events.Idle) -> None: - if self.check_layout(): - self.render_cache = None - self.reset_check_repaint() - self.reset_check_layout() - await self.emit(Layout(self)) - elif self.check_repaint(): - self.render_cache = None - self.reset_check_repaint() - await self.emit(Update(self, self)) - - async def focus(self) -> None: - await self.app.set_focus(self) - - async def capture_mouse(self, capture: bool = True) -> None: - await self.app.capture_mouse(self if capture else None) - - async def release_mouse(self) -> None: - await 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) - - async def dispatch_key(self, event: events.Key) -> None: - """Dispatch a key event to method. - - This method will call the method named 'key_' if it exists. + async def _on_idle(self, event: events.Idle) -> None: + """Called when there are no more events on the queue. Args: - event (events.Key): A key event. + event (events.Idle): Idle event. + """ + 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, scroll_visible: bool = True) -> None: + """Give focus to this widget. + + Args: + scroll_visible (bool, optional): Scroll parent to make this widget + visible. Defaults to True. """ - key_method = getattr(self, f"key_{event.key}", None) - if key_method is not None: - await invoke(key_method, event) + def set_focus(widget: Widget): + """Callback to set the focus.""" + try: + widget.screen.set_focus(self, scroll_visible=scroll_visible) + except NoScreen: + pass - async def on_mouse_down(self, event: events.MouseDown) -> None: + self.app.call_later(set_focus, self) + + def reset_focus(self) -> None: + """Reset the focus (move it to the next available widget).""" + try: + self.screen._reset_focus(self) + except NoScreen: + pass + + def capture_mouse(self, capture: bool = True) -> None: + """Capture (or release) the mouse. + + When captured, mouse events will go to this widget even when the pointer is not directly over the widget. + + Args: + capture (bool, optional): True to capture or False to release. Defaults to True. + """ + self.app.capture_mouse(self if capture else None) + + def release_mouse(self) -> None: + """Release the mouse. + + Mouse events will only be sent when the mouse is over the widget. + """ + 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) + + def _on_styles_updated(self) -> None: + self._rich_style_cache.clear() + + 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: + await self.handle_key(event) + + async def handle_key(self, event: events.Key) -> bool: + return await self.dispatch_key(event) + + async def _on_compose(self, event: events.Compose) -> None: + widgets = list(self.compose()) + await self.mount(*widgets) + + def _on_mount(self, event: events.Mount) -> None: + if self.styles.overflow_y == "scroll": + self.show_vertical_scrollbar = True + if self.styles.overflow_x == "scroll": + self.show_horizontal_scrollbar = True + + def _on_leave(self, event: events.Leave) -> None: + self.mouse_over = False + self.hover_style = Style() + + def _on_enter(self, event: events.Enter) -> None: + self.mouse_over = True + + def _on_focus(self, event: events.Focus) -> None: + for node in self.ancestors: + if node._has_focus_within: + self.app.update_styles(node) + self.has_focus = True + self.refresh() + + def _on_blur(self, event: events.Blur) -> None: + if any(node._has_focus_within for node in self.ancestors): + self.app.update_styles(self) + self.has_focus = False + self.refresh() + + 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: + if self.allow_vertical_scroll: + if self.scroll_up(animate=False): + event.stop() + + def _on_scroll_to(self, message: ScrollTo) -> None: + 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.allow_vertical_scroll: + self.scroll_page_up() + event.stop() + + def _on_scroll_down(self, event: ScrollDown) -> None: + if self.allow_vertical_scroll: + self.scroll_page_down() + event.stop() + + def _on_scroll_left(self, event: ScrollLeft) -> None: + if self.allow_horizontal_scroll: + self.scroll_page_left() + event.stop() + + def _on_scroll_right(self, event: ScrollRight) -> None: + if self.allow_horizontal_scroll: + self.scroll_page_right() + event.stop() + + def _on_hide(self, event: events.Hide) -> None: + if self.has_focus: + self.reset_focus() + + def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: + self.scroll_to_region(message.region, animate=True) + + def _key_home(self) -> bool: + if self._allow_scroll: + self.scroll_home() + return True + return False + + def _key_end(self) -> bool: + if self._allow_scroll: + self.scroll_end() + return True + return False + + def _key_left(self) -> bool: + if self.allow_horizontal_scroll: + self.scroll_left() + return True + return False + + def _key_right(self) -> bool: + if self.allow_horizontal_scroll: + self.scroll_right() + return True + return False + + def _key_down(self) -> bool: + 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.allow_vertical_scroll: + self.scroll_page_down() + return True + return False + + def _key_pageup(self) -> bool: + if self.allow_vertical_scroll: + self.scroll_page_up() + return True + return False diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 3488b26e3..42db99a70 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -1,24 +1,46 @@ -from ._footer import Footer -from ._header import Header -from ._button import Button, ButtonPressed -from ._placeholder import Placeholder -from ._scroll_view import ScrollView -from ._static import Static -from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID -from ._directory_tree import DirectoryTree, FileClick +from __future__ import annotations +from importlib import import_module +import typing +from ..case import camel_to_snake + +if typing.TYPE_CHECKING: + from ..widget import Widget + +# โš ๏ธFor any new built-in Widget we create, not only we have to add them to the following list, but also to the +# `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't be able to "see" them. __all__ = [ "Button", - "ButtonPressed", + "Checkbox", + "DataTable", "DirectoryTree", - "FileClick", "Footer", "Header", "Placeholder", - "ScrollView", + "Pretty", "Static", - "TreeClick", + "Input", + "TextLog", "TreeControl", - "TreeNode", - "NodeID", + "Welcome", ] + +_WIDGETS_LAZY_LOADING_CACHE: dict[str, type[Widget]] = {} + + +# Let's decrease startup time by lazy loading our Widgets: +def __getattr__(widget_class: str) -> type[Widget]: + try: + return _WIDGETS_LAZY_LOADING_CACHE[widget_class] + except KeyError: + pass + + if widget_class not in __all__: + raise ImportError(f"Package 'textual.widgets' has no class '{widget_class}'") + + widget_module_path = f"._{camel_to_snake(widget_class)}" + module = import_module(widget_module_path, package="textual.widgets") + class_ = getattr(module, widget_class) + + _WIDGETS_LAZY_LOADING_CACHE[widget_class] = class_ + return class_ diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi new file mode 100644 index 000000000..5ceb01835 --- /dev/null +++ b/src/textual/widgets/__init__.pyi @@ -0,0 +1,14 @@ +# This stub file must re-export every classes exposed in the __init__.py's `__all__` list: +from ._button import Button as Button +from ._data_table import DataTable as DataTable +from ._checkbox import Checkbox as Checkbox +from ._directory_tree import DirectoryTree as DirectoryTree +from ._footer import Footer as Footer +from ._header import Header as Header +from ._placeholder import Placeholder as Placeholder +from ._pretty import Pretty as Pretty +from ._static import Static as Static +from ._input import Input as Input +from ._text_log import TextLog as TextLog +from ._tree_control import TreeControl as TreeControl +from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 770edb197..7bc4d3a8c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -1,67 +1,353 @@ from __future__ import annotations -from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.style import StyleType +import sys +from functools import partial +from typing import cast + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal # pragma: no cover + +import rich.repr +from rich.console import RenderableType +from rich.text import Text, TextType from .. import events +from ..css._error_tools import friendly_list from ..message import Message from ..reactive import Reactive -from ..widget import Widget +from ..widgets import Static + +ButtonVariant = Literal["default", "primary", "success", "warning", "error"] +_VALID_BUTTON_VARIANTS = {"default", "primary", "success", "warning", "error"} -class ButtonPressed(Message, bubble=True): +class InvalidButtonVariant(Exception): pass -class Expand: - def __init__(self, renderable: RenderableType) -> None: - self.renderable = renderable +class Button(Static, can_focus=True): + """A simple clickable button.""" - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - width = options.max_width - height = options.height or 1 - yield from console.render( - self.renderable, options.update_dimensions(width, height) - ) + DEFAULT_CSS = """ + Button { + width: auto; + min-width: 16; + height: 3; + background: $panel; + color: $text; + border: none; + border-top: tall $panel-lighten-2; + border-bottom: tall $panel-darken-3; + content-align: center middle; + text-style: bold; + } + + Button.-disabled { + opacity: 0.4; + text-opacity: 0.7; + } + + Button:focus { + text-style: bold reverse; + } + + Button:hover { + border-top: tall $panel-lighten-1; + background: $panel-darken-2; + color: $text; + } + + Button.-active { + background: $panel; + border-bottom: tall $panel-lighten-2; + border-top: tall $panel-darken-2; + tint: $background 30%; + } + + /* Primary variant */ + Button.-primary { + background: $primary; + color: $text; + border-top: tall $primary-lighten-3; + border-bottom: tall $primary-darken-3; + + } + + Button.-primary:hover { + background: $primary-darken-2; + color: $text; + border-top: tall $primary-lighten-2; + } + + Button.-primary.-active { + background: $primary; + border-bottom: tall $primary-lighten-3; + border-top: tall $primary-darken-3; + } -class ButtonRenderable: - def __init__(self, label: RenderableType, style: StyleType = "") -> None: - self.label = label - self.style = style + /* Success variant */ + Button.-success { + background: $success; + color: $text; + border-top: tall $success-lighten-2; + border-bottom: tall $success-darken-3; + } - def __rich_console__( - self, console: Console, options: ConsoleOptions - ) -> RenderResult: - width = options.max_width - height = options.height or 1 + Button.-success:hover { + background: $success-darken-2; + color: $text; + } - yield Align.center( - self.label, vertical="middle", style=self.style, width=width, height=height - ) + Button.-success.-active { + background: $success; + border-bottom: tall $success-lighten-2; + border-top: tall $success-darken-2; + } + + /* Warning variant */ + Button.-warning { + background: $warning; + color: $text; + border-top: tall $warning-lighten-2; + border-bottom: tall $warning-darken-3; + } + + Button.-warning:hover { + background: $warning-darken-2; + color: $text; + + } + + Button.-warning.-active { + background: $warning; + border-bottom: tall $warning-lighten-2; + border-top: tall $warning-darken-2; + } + + + /* Error variant */ + Button.-error { + background: $error; + color: $text; + border-top: tall $error-lighten-2; + border-bottom: tall $error-darken-3; + + } + + Button.-error:hover { + background: $error-darken-1; + color: $text; + + } + + Button.-error.-active { + background: $error; + border-bottom: tall $error-lighten-2; + border-top: tall $error-darken-2; + } + + """ + + ACTIVE_EFFECT_DURATION = 0.3 + """When buttons are clicked they get the `-active` class for this duration (in seconds)""" + + class Pressed(Message, bubble=True): + @property + def button(self) -> Button: + return cast(Button, self.sender) -class Button(Widget): def __init__( self, - label: RenderableType, + label: TextType | None = None, + disabled: bool = False, + variant: ButtonVariant = "default", + *, name: str | None = None, - style: StyleType = "white on dark_blue", + id: str | None = None, + classes: str | None = None, ): - super().__init__(name=name) - self.name = name or str(label) - self.button_style = style + """Create a Button widget. - self.label = label + Args: + label (str): The text that appears within the button. + disabled (bool): Whether the button is disabled or not. + variant (ButtonVariant): The variant of the button. + name: The name of the button. + id: The ID of the button in the DOM. + classes: The CSS classes of the button. + """ + super().__init__(name=name, id=id, classes=classes) + + if label is None: + label = self.css_identifier_styled + + self.label = self.validate_label(label) + + self.disabled = disabled + if disabled: + self.add_class("-disabled") + + self.variant = variant label: Reactive[RenderableType] = Reactive("") + variant = Reactive.init("default") + disabled = Reactive(False) + + def __rich_repr__(self) -> rich.repr.Result: + yield from super().__rich_repr__() + yield "variant", self.variant, "default" + yield "disabled", self.disabled, False + + def watch_mouse_over(self, value: bool) -> None: + """Update from CSS if mouse over state changes.""" + if self._has_hover_style and not self.disabled: + self.app.update_styles(self) + + def validate_variant(self, variant: str) -> str: + if variant not in _VALID_BUTTON_VARIANTS: + raise InvalidButtonVariant( + f"Valid button variants are {friendly_list(_VALID_BUTTON_VARIANTS)}" + ) + return variant + + def watch_variant(self, old_variant: str, variant: str): + self.remove_class(f"_{old_variant}") + self.add_class(f"-{variant}") + + def watch_disabled(self, disabled: bool) -> None: + self.set_class(disabled, "-disabled") + self.can_focus = not disabled + + def validate_label(self, label: RenderableType) -> RenderableType: + """Parse markup for self.label""" + if isinstance(label, str): + return Text.from_markup(label) + return label def render(self) -> RenderableType: - return ButtonRenderable(self.label, style=self.button_style) + label = self.label.copy() + label = Text.assemble(" ", label, " ") + label.stylize(self.text_style) + return label - async def on_click(self, event: events.Click) -> None: - event.prevent_default().stop() - await self.emit(ButtonPressed(self)) + async def _on_click(self, event: events.Click) -> None: + event.stop() + self.press() + + def press(self) -> None: + """Respond to a button press.""" + if self.disabled or not self.display: + return + # Manage the "active" effect: + self._start_active_affect() + # ...and let other components know that we've just been clicked: + self.emit_no_wait(Button.Pressed(self)) + + def _start_active_affect(self) -> None: + """Start a small animation to show the button was clicked.""" + self.add_class("-active") + self.set_timer( + self.ACTIVE_EFFECT_DURATION, partial(self.remove_class, "-active") + ) + + async def _on_key(self, event: events.Key) -> None: + if event.key == "enter" and not self.disabled: + self._start_active_affect() + await self.emit(Button.Pressed(self)) + + @classmethod + def success( + cls, + label: TextType | None = None, + disabled: bool = False, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> Button: + """Utility constructor for creating a success Button variant. + + Args: + label (str): The text that appears within the button. + disabled (bool): Whether the button is disabled or not. + name: The name of the button. + id: The ID of the button in the DOM. + classes: The CSS classes of the button. + + Returns: + Button: A Button widget of the 'success' variant. + """ + return Button( + label=label, + disabled=disabled, + variant="success", + name=name, + id=id, + classes=classes, + ) + + @classmethod + def warning( + cls, + label: TextType | None = None, + disabled: bool = False, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> Button: + """Utility constructor for creating a warning Button variant. + + Args: + label (str): The text that appears within the button. + disabled (bool): Whether the button is disabled or not. + name: The name of the button. + id: The ID of the button in the DOM. + classes: The CSS classes of the button. + + Returns: + Button: A Button widget of the 'warning' variant. + """ + return Button( + label=label, + disabled=disabled, + variant="warning", + name=name, + id=id, + classes=classes, + ) + + @classmethod + def error( + cls, + label: TextType | None = None, + disabled: bool = False, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> Button: + """Utility constructor for creating an error Button variant. + + Args: + label (str): The text that appears within the button. + disabled (bool): Whether the button is disabled or not. + name: The name of the button. + id: The ID of the button in the DOM. + classes: The CSS classes of the button. + + Returns: + Button: A Button widget of the 'error' variant. + """ + return Button( + label=label, + disabled=disabled, + variant="error", + name=name, + id=id, + classes=classes, + ) diff --git a/src/textual/widgets/_checkbox.py b/src/textual/widgets/_checkbox.py new file mode 100644 index 000000000..41336f9d2 --- /dev/null +++ b/src/textual/widgets/_checkbox.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import ClassVar + +from rich.console import RenderableType + +from ..binding import Binding +from ..geometry import Size +from ..message import Message +from ..reactive import reactive +from ..widget import Widget +from ..scrollbar import ScrollBarRender + + +class Checkbox(Widget, can_focus=True): + """A checkbox widget. Represents a boolean value. Can be toggled by clicking + on it or by pressing the enter key or space bar while it has focus. + + Args: + value (bool, optional): The initial value of the checkbox. Defaults to False. + animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True. + """ + + DEFAULT_CSS = """ + + Checkbox { + border: tall transparent; + background: $panel; + height: auto; + width: auto; + padding: 0 2; + } + + Checkbox > .checkbox--switch { + background: $panel-darken-2; + color: $panel-lighten-2; + } + + Checkbox:hover { + border: tall $background; + } + + Checkbox:focus { + border: tall $accent; + } + + Checkbox.-on { + + } + + Checkbox.-on > .checkbox--switch { + color: $success; + } + """ + + BINDINGS = [ + Binding("enter,space", "toggle", "toggle", show=False), + ] + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "checkbox--switch", + } + + value = reactive(False, init=False) + slider_pos = reactive(0.0) + + def __init__( + self, + value: bool = None, + *, + animate: bool = True, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ): + super().__init__(name=name, id=id, classes=classes) + if value: + self.slider_pos = 1.0 + self._reactive_value = value + self._should_animate = animate + + def watch_value(self, value: bool) -> None: + target_slider_pos = 1.0 if value else 0.0 + if self._should_animate: + self.animate("slider_pos", target_slider_pos, duration=0.3) + else: + self.slider_pos = target_slider_pos + self.emit_no_wait(self.Changed(self, self.value)) + + def watch_slider_pos(self, slider_pos: float) -> None: + self.set_class(slider_pos == 1, "-on") + + def render(self) -> RenderableType: + style = self.get_component_rich_style("checkbox--switch") + return ScrollBarRender( + virtual_size=100, + window_size=50, + position=self.slider_pos * 50, + style=style, + vertical=False, + ) + + def get_content_width(self, container: Size, viewport: Size) -> int: + return 4 + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + return 1 + + def on_click(self) -> None: + self.toggle() + + def action_toggle(self) -> None: + self.toggle() + + def toggle(self) -> None: + """Toggle the checkbox value. As a result of the value changing, + a Checkbox.Changed message will be emitted.""" + self.value = not self.value + + class Changed(Message, bubble=True): + """Checkbox was toggled.""" + + def __init__(self, sender: Checkbox, value: bool) -> None: + super().__init__(sender) + self.value = value + self.input = sender diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py new file mode 100644 index 000000000..8cc67cc21 --- /dev/null +++ b/src/textual/widgets/_data_table.py @@ -0,0 +1,647 @@ +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from itertools import chain, zip_longest +from typing import ClassVar, Generic, Iterable, NamedTuple, TypeVar, cast + +from rich.console import RenderableType +from rich.padding import Padding +from rich.protocol import is_renderable +from rich.segment import Segment +from rich.style import Style +from rich.text import Text, TextType + +from .. import events, messages +from .._cache import LRUCache +from .._profile import timer +from .._segment_tools import line_crop +from .._types import Lines +from ..geometry import Region, Size, Spacing, clamp +from ..reactive import Reactive +from ..render import measure +from ..scroll_view import ScrollView + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + +CursorType = Literal["cell", "row", "column"] +CELL: CursorType = "cell" +CellType = TypeVar("CellType") + + +def default_cell_formatter(obj: object) -> RenderableType | None: + """Format a cell in to a renderable. + + Args: + obj (object): Data for a cell. + + Returns: + RenderableType | None: A renderable or None if the object could not be rendered. + """ + if isinstance(obj, str): + return Text.from_markup(obj) + if not is_renderable(obj): + return None + return cast(RenderableType, obj) + + +@dataclass +class Column: + """Table column.""" + + label: Text + width: int = 0 + visible: bool = False + index: int = 0 + + content_width: int = 0 + auto_width: bool = False + + @property + def render_width(self) -> int: + """Width in cells, required to render a column.""" + # +2 is to account for space padding either side of the cell + if self.auto_width: + return self.content_width + 2 + else: + return self.width + 2 + + +@dataclass +class Row: + """Table row.""" + + index: int + height: int + y: int + cell_renderables: list[RenderableType] = field(default_factory=list) + + +@dataclass +class Cell: + """Table cell.""" + + value: object + + +class Coord(NamedTuple): + """An object to represent the coordinate of a cell within the data table.""" + + row: int + column: int + + def left(self) -> Coord: + """Get coordinate to the left.""" + row, column = self + return Coord(row, column - 1) + + def right(self) -> Coord: + """Get coordinate to the right.""" + row, column = self + return Coord(row, column + 1) + + def up(self) -> Coord: + """Get coordinate above.""" + row, column = self + return Coord(row - 1, column) + + def down(self) -> Coord: + """Get coordinate below.""" + row, column = self + return Coord(row + 1, column) + + +class DataTable(ScrollView, Generic[CellType], can_focus=True): + + DEFAULT_CSS = """ + App.-dark DataTable { + background:; + } + DataTable { + background: $surface ; + color: $text; + } + DataTable > .datatable--header { + text-style: bold; + background: $primary; + color: $text; + } + DataTable > .datatable--fixed { + text-style: bold; + background: $primary; + color: $text; + } + + DataTable > .datatable--odd-row { + + } + + DataTable > .datatable--even-row { + background: $primary 10%; + } + + DataTable > .datatable--cursor { + background: $secondary; + color: $text; + } + + .-dark-mode DataTable > .datatable--even-row { + background: $primary 15%; + } + + DataTable > .datatable--highlight { + background: $secondary 20%; + } + """ + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "datatable--header", + "datatable--fixed", + "datatable--odd-row", + "datatable--even-row", + "datatable--highlight", + "datatable--cursor", + } + + def __init__( + self, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self.columns: list[Column] = [] + self.rows: dict[int, Row] = {} + self.data: dict[int, list[CellType]] = {} + self.row_count = 0 + self._y_offsets: list[tuple[int, int]] = [] + self._row_render_cache: LRUCache[ + tuple[int, int, Style, int, int], tuple[Lines, Lines] + ] + self._row_render_cache = LRUCache(1000) + self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines] + self._cell_render_cache = LRUCache(10000) + self._line_cache: LRUCache[ + tuple[int, int, int, int, int, int, Style], list[Segment] + ] + self._line_cache = LRUCache(1000) + + self._line_no = 0 + self._require_update_dimensions: bool = False + self._new_rows: set[int] = set() + + show_header = Reactive(True) + fixed_rows = Reactive(0) + fixed_columns = Reactive(0) + zebra_stripes = Reactive(False) + header_height = Reactive(1) + show_cursor = Reactive(True) + cursor_type = Reactive(CELL) + + cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + + @property + def hover_row(self) -> int: + return self.hover_cell.row + + @property + def hover_column(self) -> int: + return self.hover_cell.column + + @property + def cursor_row(self) -> int: + return self.cursor_cell.row + + @property + def cursor_column(self) -> int: + return self.cursor_cell.column + + def _clear_caches(self) -> None: + self._row_render_cache.clear() + self._cell_render_cache.clear() + self._line_cache.clear() + self._styles_cache.clear() + + def get_row_height(self, row_index: int) -> int: + if row_index == -1: + return self.header_height + return self.rows[row_index].height + + async def on_styles_updated(self, message: messages.StylesUpdated) -> None: + self._clear_caches() + self.refresh() + + def watch_show_header(self, show_header: bool) -> None: + self._clear_caches() + + def watch_fixed_rows(self, fixed_rows: int) -> None: + self._clear_caches() + + def watch_zebra_stripes(self, zebra_stripes: bool) -> None: + self._clear_caches() + + def watch_hover_cell(self, old: Coord, value: Coord) -> None: + self.refresh_cell(*old) + self.refresh_cell(*value) + + def watch_cursor_cell(self, old: Coord, value: Coord) -> None: + self.refresh_cell(*old) + self.refresh_cell(*value) + + def validate_cursor_cell(self, value: Coord) -> Coord: + row, column = value + row = clamp(row, 0, self.row_count - 1) + column = clamp(column, self.fixed_columns, len(self.columns) - 1) + return Coord(row, column) + + def _update_dimensions(self, new_rows: Iterable[int]) -> None: + """Called to recalculate the virtual (scrollable) size.""" + for row_index in new_rows: + for column, renderable in zip( + self.columns, self._get_row_renderables(row_index) + ): + content_width = measure(self.app.console, renderable, 1) + column.content_width = max(column.content_width, content_width) + + total_width = sum(column.render_width for column in self.columns) + header_height = self.header_height if self.show_header else 0 + self.virtual_size = Size( + total_width, + len(self._y_offsets) + header_height, + ) + + def _get_cell_region(self, row_index: int, column_index: int) -> Region: + if row_index not in self.rows: + return Region(0, 0, 0, 0) + row = self.rows[row_index] + x = sum(column.render_width for column in self.columns[:column_index]) + width = self.columns[column_index].render_width + height = row.height + y = row.y + if self.show_header: + y += self.header_height + cell_region = Region(x, y, width, height) + return cell_region + + def add_columns(self, *labels: TextType) -> None: + """Add a number of columns. + + Args: + *labels: Column headers. + + """ + for label in labels: + self.add_column(label, width=None) + + def add_column(self, label: TextType, *, width: int | None = None) -> None: + """Add a column to the table. + + Args: + label (TextType): A str or Text object containing the label (shown top of column). + width (int, optional): Width of the column in cells or None to fit content. Defaults to None. + """ + text_label = Text.from_markup(label) if isinstance(label, str) else label + + content_width = measure(self.app.console, text_label, 1) + if width is None: + column = Column( + text_label, + content_width, + index=len(self.columns), + content_width=content_width, + auto_width=True, + ) + else: + column = Column( + text_label, width, content_width=content_width, index=len(self.columns) + ) + + self.columns.append(column) + self._require_update_dimensions = True + self.check_idle() + + def add_row(self, *cells: CellType, height: int = 1) -> None: + """Add a row. + + Args: + *cells: Positional arguments should contain cell data. + height (int, optional): The height of a row (in lines). Defaults to 1. + """ + row_index = self.row_count + + self.data[row_index] = list(cells) + self.rows[row_index] = Row(row_index, height, self._line_no) + + for line_no in range(height): + self._y_offsets.append((row_index, line_no)) + + self.row_count += 1 + self._line_no += height + + self._new_rows.add(row_index) + self._require_update_dimensions = True + self.check_idle() + + def add_rows(self, rows: Iterable[Iterable[CellType]]) -> None: + """Add a number of rows. + + Args: + rows (Iterable[Iterable[CellType]]): Iterable of rows. A row is an iterable of cells. + """ + for row in rows: + self.add_row(*row) + + def on_idle(self) -> None: + if self._require_update_dimensions: + self._require_update_dimensions = False + new_rows = self._new_rows.copy() + self._new_rows.clear() + self._update_dimensions(new_rows) + + def refresh_cell(self, row_index: int, column_index: int) -> None: + """Refresh a cell. + + Args: + row_index (int): Row index. + column_index (int): Column index. + """ + if row_index < 0 or column_index < 0: + return + region = self._get_cell_region(row_index, column_index) + if not self.window_region.overlaps(region): + return + region = region.translate(-self.scroll_offset) + self.refresh(region) + + def _get_row_renderables(self, row_index: int) -> list[RenderableType]: + """Get renderables for the given row. + + Args: + row_index (int): Index of the row. + + Returns: + list[RenderableType]: List of renderables + """ + + if row_index == -1: + row = [column.label for column in self.columns] + return row + + data = self.data.get(row_index) + empty = Text() + if data is None: + return [empty for _ in self.columns] + else: + return [ + Text() if datum is None else default_cell_formatter(datum) or empty + for datum, _ in zip_longest(data, range(len(self.columns))) + ] + + def _render_cell( + self, + row_index: int, + column_index: int, + style: Style, + width: int, + cursor: bool = False, + hover: bool = False, + ) -> Lines: + """Render the given cell. + + Args: + row_index (int): Index of the row. + column_index (int): Index of the column. + style (Style): Style to apply. + width (int): Width of the cell. + + Returns: + Lines: A list of segments per line. + """ + if hover: + style += self.get_component_styles("datatable--highlight").rich_style + if cursor: + style += self.get_component_styles("datatable--cursor").rich_style + cell_key = (row_index, column_index, style, cursor, hover) + if cell_key not in self._cell_render_cache: + style += Style.from_meta({"row": row_index, "column": column_index}) + height = ( + self.header_height if row_index == -1 else self.rows[row_index].height + ) + cell = self._get_row_renderables(row_index)[column_index] + lines = self.app.console.render_lines( + Padding(cell, (0, 1)), + self.app.console.options.update_dimensions(width, height), + style=style, + ) + self._cell_render_cache[cell_key] = lines + return self._cell_render_cache[cell_key] + + def _render_row( + self, + row_index: int, + line_no: int, + base_style: Style, + cursor_column: int = -1, + hover_column: int = -1, + ) -> tuple[Lines, Lines]: + """Render a row in to lines for each cell. + + Args: + row_index (int): Index of the row. + line_no (int): Line number (on screen, 0 is top) + base_style (Style): Base style of row. + + Returns: + tuple[Lines, Lines]: Lines for fixed cells, and Lines for scrollable cells. + """ + + cache_key = (row_index, line_no, base_style, cursor_column, hover_column) + + if cache_key in self._row_render_cache: + return self._row_render_cache[cache_key] + + render_cell = self._render_cell + + if self.fixed_columns: + fixed_style = self.get_component_styles("datatable--fixed").rich_style + fixed_style += Style.from_meta({"fixed": True}) + fixed_row = [ + render_cell(row_index, column.index, fixed_style, column.render_width)[ + line_no + ] + for column in self.columns[: self.fixed_columns] + ] + else: + fixed_row = [] + + if row_index == -1: + row_style = self.get_component_styles("datatable--header").rich_style + else: + if self.zebra_stripes: + component_row_style = ( + "datatable--odd-row" if row_index % 2 else "datatable--even-row" + ) + row_style = self.get_component_styles(component_row_style).rich_style + else: + row_style = base_style + + scrollable_row = [ + render_cell( + row_index, + column.index, + row_style, + column.render_width, + cursor=cursor_column == column.index, + hover=hover_column == column.index, + )[line_no] + for column in self.columns + ] + + row_pair = (fixed_row, scrollable_row) + self._row_render_cache[cache_key] = row_pair + return row_pair + + def _get_offsets(self, y: int) -> tuple[int, int]: + """Get row number and line offset for a given line. + + Args: + y (int): Y coordinate relative to screen top. + + Returns: + tuple[int, int]: Line number and line offset within cell. + """ + if self.show_header: + if y < self.header_height: + return (-1, y) + y -= self.header_height + if y > len(self._y_offsets): + raise LookupError("Y coord {y!r} is greater than total height") + return self._y_offsets[y] + + def _render_line( + self, y: int, x1: int, x2: int, base_style: Style + ) -> list[Segment]: + """Render a line in to a list of segments. + + Args: + y (int): Y coordinate of line + x1 (int): X start crop. + x2 (int): X end crop (exclusive). + base_style (Style): Style to apply to line. + + Returns: + list[Segment]: List of segments for rendering. + """ + + width = self.size.width + + try: + row_index, line_no = self._get_offsets(y) + except LookupError: + return [Segment(" " * width, base_style)] + cursor_column = ( + self.cursor_column + if (self.show_cursor and self.cursor_row == row_index) + else -1 + ) + hover_column = self.hover_column if (self.hover_row == row_index) else -1 + + cache_key = (y, x1, x2, width, cursor_column, hover_column, base_style) + if cache_key in self._line_cache: + return self._line_cache[cache_key] + + fixed, scrollable = self._render_row( + row_index, + line_no, + base_style, + cursor_column=cursor_column, + hover_column=hover_column, + ) + fixed_width = sum( + column.render_width for column in self.columns[: self.fixed_columns] + ) + + fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else [] + scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) + + segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width) + segments = Segment.adjust_line_length(segments, width, style=base_style) + simplified_segments = list(Segment.simplify(segments)) + + self._line_cache[cache_key] = simplified_segments + return segments + + def render_line(self, y: int) -> list[Segment]: + width, height = self.size + scroll_x, scroll_y = self.scroll_offset + fixed_top_row_count = sum( + self.get_row_height(row_index) for row_index in range(self.fixed_rows) + ) + if self.show_header: + fixed_top_row_count += self.get_row_height(-1) + + style = self.rich_style + + if y >= fixed_top_row_count: + y += scroll_y + + return self._render_line(y, scroll_x, scroll_x + width, style) + + def on_mouse_move(self, event: events.MouseMove): + meta = event.style.meta + if meta: + try: + self.hover_cell = Coord(meta["row"], meta["column"]) + except KeyError: + pass + + def _get_cell_border(self) -> Spacing: + top = self.header_height if self.show_header else 0 + top += sum( + self.rows[row_index].height + for row_index in range(self.fixed_rows) + if row_index in self.rows + ) + left = sum(column.render_width for column in self.columns[: self.fixed_columns]) + return Spacing(top, 0, 0, left) + + def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: + region = self._get_cell_region(self.cursor_row, self.cursor_column) + spacing = self._get_cell_border() + self.scrollbar_gutter + self.scroll_to_region(region, animate=animate, spacing=spacing) + + def on_click(self, event: events.Click) -> None: + meta = self.get_style_at(event.x, event.y).meta + if meta: + self.cursor_cell = Coord(meta["row"], meta["column"]) + self._scroll_cursor_in_to_view() + event.stop() + + def key_down(self, event: events.Key): + self.cursor_cell = self.cursor_cell.down() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() + + def key_up(self, event: events.Key): + self.cursor_cell = self.cursor_cell.up() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view() + + def key_right(self, event: events.Key): + self.cursor_cell = self.cursor_cell.right() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view(animate=True) + + def key_left(self, event: events.Key): + self.cursor_cell = self.cursor_cell.left() + event.stop() + event.prevent_default() + self._scroll_cursor_in_to_view(animate=True) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0808d0fe3..ffae6442f 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -7,13 +7,13 @@ import os.path from rich.console import RenderableType import rich.repr + from rich.text import Text from .. import events from ..message import Message -from ..reactive import Reactive from .._types import MessageTarget -from . import TreeControl, TreeClick, TreeNode, NodeID +from ._tree_control import TreeControl, TreeNode @dataclass @@ -22,36 +22,27 @@ class DirEntry: is_dir: bool -@rich.repr.auto -class FileClick(Message, bubble=True): - def __init__(self, sender: MessageTarget, path: str) -> None: - self.path = path - super().__init__(sender) - - class DirectoryTree(TreeControl[DirEntry]): - def __init__(self, path: str, name: str = None) -> None: - self.path = path.rstrip("/") + @rich.repr.auto + class FileClick(Message, bubble=True): + def __init__(self, sender: MessageTarget, path: str) -> None: + self.path = path + super().__init__(sender) + + def __init__( + self, + path: str, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + self.path = os.path.expanduser(path.rstrip("/")) label = os.path.basename(self.path) - data = DirEntry(path, True) - super().__init__(label, name=name, data=data) + data = DirEntry(self.path, True) + super().__init__(label, data, name=name, id=id, classes=classes) self.root.tree.guide_style = "black" - has_focus: Reactive[bool] = Reactive(False) - - def on_focus(self) -> None: - self.has_focus = True - - def on_blur(self) -> None: - self.has_focus = False - - async def watch_hover_node(self, hover_node: NodeID) -> None: - for node in self.nodes.values(): - node.tree.guide_style = ( - "bold not dim red" if node.id == hover_node else "black" - ) - self.refresh(layout=True) - def render_node(self, node: TreeNode[DirEntry]) -> RenderableType: return self.render_tree_label( node, @@ -81,25 +72,28 @@ class DirectoryTree(TreeControl[DirEntry]): if is_hover: label.stylize("underline") if is_dir: - label.stylize("bold magenta") + label.stylize("bold") icon = "๐Ÿ“‚" if expanded else "๐Ÿ“" else: - label.stylize("bright_green") icon = "๐Ÿ“„" - label.highlight_regex(r"\..*$", "green") + label.highlight_regex(r"\..*$", "italic") if label.plain.startswith("."): label.stylize("dim") if is_cursor and has_focus: - label.stylize("reverse") + cursor_style = self.get_component_styles("tree--cursor").rich_style + label.stylize(cursor_style) icon_label = Text(f"{icon} ", no_wrap=True, overflow="ellipsis") + label icon_label.apply_meta(meta) return icon_label - async def on_mount(self, event: events.Mount) -> None: - await self.load_directory(self.root) + def on_styles_updated(self) -> None: + self.render_tree_label.cache_clear() + + def on_mount(self) -> None: + self.call_later(self.load_directory, self.root) async def load_directory(self, node: TreeNode[DirEntry]): path = node.data.path @@ -107,21 +101,23 @@ class DirectoryTree(TreeControl[DirEntry]): list(scandir(path)), key=lambda entry: (not entry.is_dir(), entry.name) ) for entry in directory: - await node.add(entry.name, DirEntry(entry.path, entry.is_dir())) + node.add(entry.name, DirEntry(entry.path, entry.is_dir())) node.loaded = True - await node.expand() + node.expand() self.refresh(layout=True) - async def handle_tree_click(self, message: TreeClick[DirEntry]) -> None: + async def on_tree_control_node_selected( + self, message: TreeControl.NodeSelected[DirEntry] + ) -> None: dir_entry = message.node.data if not dir_entry.is_dir: - await self.emit(FileClick(self, dir_entry.path)) + await self.emit(self.FileClick(self, dir_entry.path)) else: if not message.node.loaded: await self.load_directory(message.node) - await message.node.expand() + message.node.expand() else: - await message.node.toggle() + message.node.toggle() if __name__ == "__main__": @@ -130,6 +126,6 @@ if __name__ == "__main__": class TreeApp(App): async def on_mount(self, event: events.Mount) -> None: - await self.view.dock(DirectoryTree("/Users/willmcgugan/projects")) + await self.screen.dock(DirectoryTree("/Users/willmcgugan/projects")) - TreeApp.run(log="textual.log") + TreeApp(log_path="textual.log").run() diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 88eafac44..63b2265aa 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,22 +1,53 @@ from __future__ import annotations -from rich.console import RenderableType -from rich.style import Style -from rich.text import Text +from collections import defaultdict + import rich.repr +from rich.console import RenderableType +from rich.text import Text from .. import events -from ..reactive import Reactive +from ..reactive import Reactive, watch from ..widget import Widget @rich.repr.auto class Footer(Widget): + """A simple header widget which docks itself to the top of the parent container.""" + + DEFAULT_CSS = """ + Footer { + background: $accent; + color: $text; + dock: bottom; + height: 1; + } + Footer > .footer--highlight { + background: $accent-darken-1; + } + + Footer > .footer--highlight-key { + background: $secondary; + text-style: bold; + } + + Footer > .footer--key { + text-style: bold; + background: $accent-darken-2; + } + """ + + COMPONENT_CLASSES = { + "footer--description", + "footer--key", + "footer--highlight", + "footer--highlight-key", + } + def __init__(self) -> None: - self.keys: list[tuple[str, str]] = [] super().__init__() - self.layout_size = 1 self._key_text: Text | None = None + self.auto_links = False highlight_key: Reactive[str | None] = Reactive(None) @@ -24,27 +55,50 @@ class Footer(Widget): """If highlight key changes we need to regenerate the text.""" self._key_text = None + def on_mount(self) -> None: + watch(self.screen, "focused", self._focus_changed) + + def _focus_changed(self, focused: Widget | None) -> None: + self._key_text = None + self.refresh() + async def on_mouse_move(self, event: events.MouseMove) -> None: """Store any key we are moving over.""" self.highlight_key = event.style.meta.get("key") async def on_leave(self, event: events.Leave) -> None: - """Clear any highlight when the mouse leave the widget""" + """Clear any highlight when the mouse leaves the widget""" self.highlight_key = None def __rich_repr__(self) -> rich.repr.Result: - yield "keys", self.keys + yield from super().__rich_repr__() def make_key_text(self) -> Text: """Create text containing all the keys.""" + base_style = self.rich_style text = Text( - style="white on dark_green", + style=self.rich_style, no_wrap=True, overflow="ellipsis", justify="left", end="", ) - for binding in self.app.bindings.shown_keys: + highlight_style = self.get_component_rich_style("footer--highlight") + highlight_key_style = self.get_component_rich_style("footer--highlight-key") + key_style = self.get_component_rich_style("footer--key") + + bindings = [ + binding + for (_namespace, binding) in self.app.namespace_bindings.values() + if binding.show + ] + + action_to_bindings = defaultdict(list) + for binding in bindings: + action_to_bindings[binding.action].append(binding) + + for action, bindings in action_to_bindings.items(): + binding = bindings[0] key_display = ( binding.key.upper() if binding.key_display is None @@ -52,13 +106,22 @@ class Footer(Widget): ) hovered = self.highlight_key == binding.key key_text = Text.assemble( - (f" {key_display} ", "reverse" if hovered else "default on default"), - f" {binding.description} ", - meta={"@click": f"app.press('{binding.key}')", "key": binding.key}, + (f" {key_display} ", highlight_key_style if hovered else key_style), + ( + f" {binding.description} ", + highlight_style if hovered else base_style, + ), + meta={ + "@click": f"app.check_bindings('{binding.key}')", + "key": binding.key, + }, ) text.append_text(key_text) return text + def post_render(self, renderable): + return renderable + def render(self) -> RenderableType: if self._key_text is None: self._key_text = self.make_key_text() diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 15a027d34..c4382906d 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -1,76 +1,127 @@ +from __future__ import annotations + from datetime import datetime -from logging import getLogger -from rich.console import Console, ConsoleOptions, RenderableType -from rich.panel import Panel -from rich.repr import rich_repr, Result -from rich.style import StyleType -from rich.table import Table -from rich.text import TextType +from rich.text import Text -from .. import events from ..widget import Widget -from ..reactive import watch, Reactive +from ..reactive import Reactive, watch -log = getLogger("rich") + +class HeaderIcon(Widget): + """Display an 'icon' on the left of the header.""" + + DEFAULT_CSS = """ + HeaderIcon { + dock: left; + padding: 0 1; + width: 8; + content-align: left middle; + } + """ + icon = Reactive("โญ˜") + + def render(self): + return self.icon + + +class HeaderClock(Widget): + """Display a clock on the right of the header.""" + + DEFAULT_CSS = """ + HeaderClock { + dock: right; + width: 10; + padding: 0 1; + background: $secondary-background-lighten-1; + color: $text; + text-opacity: 85%; + content-align: center middle; + } + """ + + def on_mount(self) -> None: + self.set_interval(1, callback=self.refresh, name=f"update header clock") + + def render(self): + return Text(datetime.now().time().strftime("%X")) + + +class HeaderTitle(Widget): + """Display the title / subtitle in the header.""" + + DEFAULT_CSS = """ + HeaderTitle { + content-align: center middle; + width: 100%; + margin-right: 10; + } + """ + + text: Reactive[str] = Reactive("") + sub_text = Reactive("") + + def render(self) -> Text: + text = Text(self.text, no_wrap=True, overflow="ellipsis") + if self.sub_text: + text.append(" โ€” ") + text.append(self.sub_text, "dim") + return text class Header(Widget): + """A header widget with icon and clock. + + Args: + show_clock (bool, optional): True if the clock should be shown on the right of the header. + """ + + DEFAULT_CSS = """ + Header { + dock: top; + width: 100%; + background: $secondary-background; + color: $text; + height: 1; + } + Header.-tall { + height: 3; + } + """ + + tall = Reactive(False) + + DEFAULT_CLASSES = "" + def __init__( self, + show_clock: bool = False, *, - tall: bool = True, - style: StyleType = "white on dark_green", - clock: bool = True, - ) -> None: - super().__init__() - self.tall = tall - self.style = style - self.clock = clock + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ): + super().__init__(name=name, id=id, classes=classes) + self.show_clock = show_clock - tall: Reactive[bool] = Reactive(True, layout=True) - style: Reactive[StyleType] = Reactive("white on blue") - clock: Reactive[bool] = Reactive(True) - title: Reactive[str] = Reactive("") - sub_title: Reactive[str] = Reactive("") + def compose(self): + yield HeaderIcon() + yield HeaderTitle() + if self.show_clock: + yield HeaderClock() - @property - def full_title(self) -> str: - return f"{self.title} - {self.sub_title}" if self.sub_title else self.title + def watch_tall(self, tall: bool) -> None: + self.set_class(tall, "-tall") - def __rich_repr__(self) -> Result: - yield self.title + def on_click(self): + self.toggle_class("-tall") - async def watch_tall(self, tall: bool) -> None: - self.layout_size = 3 if tall else 1 + def on_mount(self) -> None: + def set_title(title: str) -> None: + self.query_one(HeaderTitle).text = title - def get_clock(self) -> str: - return datetime.now().time().strftime("%X") - - def render(self) -> RenderableType: - header_table = Table.grid(padding=(0, 1), expand=True) - header_table.style = self.style - header_table.add_column(justify="left", ratio=0, width=8) - header_table.add_column("title", justify="center", ratio=1) - header_table.add_column("clock", justify="right", width=8) - header_table.add_row( - "๐Ÿž", self.full_title, self.get_clock() if self.clock else "" - ) - header: RenderableType - header = Panel(header_table, style=self.style) if self.tall else header_table - return header - - async def on_mount(self, event: events.Mount) -> None: - self.set_interval(1.0, callback=self.refresh) - - async def set_title(title: str) -> None: - self.title = title - - async def set_sub_title(sub_title: str) -> None: - self.sub_title = sub_title + def set_sub_title(sub_title: str) -> None: + self.query_one(HeaderTitle).sub_text = sub_title watch(self.app, "title", set_title) watch(self.app, "sub_title", set_sub_title) - - async def on_click(self, event: events.Click) -> None: - self.tall = not self.tall diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py new file mode 100644 index 000000000..936d59d68 --- /dev/null +++ b/src/textual/widgets/_input.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +from rich.cells import cell_len, get_character_cell_size +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.highlighter import Highlighter +from rich.segment import Segment +from rich.text import Text + +from .. import events +from .._segment_tools import line_crop +from ..binding import Binding +from ..geometry import Size +from ..message import Message +from ..reactive import reactive +from ..widget import Widget + + +class _InputRenderable: + """Render the input content.""" + + def __init__(self, input: Input, cursor_visible: bool) -> None: + self.input = input + self.cursor_visible = cursor_visible + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + + input = self.input + result = input._value + if input._cursor_at_end: + result.pad_right(1) + cursor_style = input.get_component_rich_style("input--cursor") + if self.cursor_visible and input.has_focus: + cursor = input.cursor_position + result.stylize(cursor_style, cursor, cursor + 1) + width = input.content_size.width + segments = list(result.render(console)) + line_length = Segment.get_line_length(segments) + if line_length < width: + segments = Segment.adjust_line_length(segments, width) + line_length = width + + line = line_crop( + list(segments), + input.view_position, + input.view_position + width, + line_length, + ) + yield from line + + +class Input(Widget, can_focus=True): + """A text input widget.""" + + DEFAULT_CSS = """ + Input { + background: $boost; + color: $text; + padding: 0 2; + border: tall $background; + width: 100%; + height: 1; + } + Input.-disabled { + opacity: 0.6; + } + Input:focus { + border: tall $accent; + } + Input>.input--cursor { + background: $surface; + color: $text; + text-style: reverse; + } + Input>.input--placeholder { + color: $text-disabled; + } + """ + + BINDINGS = [ + Binding("left", "cursor_left", "cursor left", show=False), + Binding("right", "cursor_right", "cursor right", show=False), + Binding("backspace", "delete_left", "delete left", show=False), + Binding("home", "home", "home", show=False), + Binding("end", "end", "end", show=False), + Binding("ctrl+d", "delete_right", "delete right", show=False), + Binding("enter", "submit", "submit", show=False), + ] + + COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} + + cursor_blink = reactive(True) + value = reactive("", layout=True, init=False) + input_scroll_offset = reactive(0) + cursor_position = reactive(0) + view_position = reactive(0) + placeholder = reactive("") + complete = reactive("") + width = reactive(1) + _cursor_visible = reactive(True) + password = reactive(False) + max_size: reactive[int | None] = reactive(None) + + def __init__( + self, + value: str | None = None, + placeholder: str = "", + highlighter: Highlighter | None = None, + password: bool = False, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + if value is not None: + self.value = value + self.placeholder = placeholder + self.highlighter = highlighter + self.password = password + + def _position_to_cell(self, position: int) -> int: + """Convert an index within the value to cell position.""" + cell_offset = cell_len(self.value[:position]) + return cell_offset + + @property + def _cursor_offset(self) -> int: + """Get the cell offset of the cursor.""" + offset = self._position_to_cell(self.cursor_position) + if self._cursor_at_end: + offset += 1 + return offset + + @property + def _cursor_at_end(self) -> bool: + """Check if the cursor is at the end""" + return self.cursor_position >= len(self.value) + + def validate_cursor_position(self, cursor_position: int) -> int: + return min(max(0, cursor_position), len(self.value)) + + def validate_view_position(self, view_position: int) -> int: + width = self.content_size.width + new_view_position = max(0, min(view_position, self.cursor_width - width)) + return new_view_position + + def watch_cursor_position(self, cursor_position: int) -> None: + width = self.content_size.width + view_start = self.view_position + view_end = view_start + width + cursor_offset = self._cursor_offset + + if cursor_offset >= view_end or cursor_offset < view_start: + view_position = cursor_offset - width // 2 + self.view_position = view_position + else: + self.view_position = self.view_position + + async def watch_value(self, value: str) -> None: + if self.styles.auto_dimensions: + self.refresh(layout=True) + await self.emit(self.Changed(self, value)) + + @property + def cursor_width(self) -> int: + """Get the width of the input (with extra space for cursor at the end).""" + if self.placeholder and not self.value: + return cell_len(self.placeholder) + return self._position_to_cell(len(self.value)) + 1 + + def render(self) -> RenderableType: + if not self.value: + placeholder = Text(self.placeholder) + placeholder.stylize(self.get_component_rich_style("input--placeholder")) + if self.has_focus: + cursor_style = self.get_component_rich_style("input--cursor") + if self._cursor_visible: + placeholder.stylize(cursor_style, 0, 1) + return placeholder + return _InputRenderable(self, self._cursor_visible) + + @property + def _value(self) -> Text: + """Value rendered as text.""" + if self.password: + return Text("โ€ข" * len(self.value), no_wrap=True, overflow="ignore") + else: + text = Text(self.value, no_wrap=True, overflow="ignore") + if self.highlighter is not None: + text = self.highlighter(text) + return text + + def get_content_width(self, container: Size, viewport: Size) -> int: + return self.cursor_width + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + return 1 + + def _toggle_cursor(self) -> None: + """Toggle visibility of cursor.""" + self._cursor_visible = not self._cursor_visible + + def on_mount(self) -> None: + self.blink_timer = self.set_interval( + 0.5, + self._toggle_cursor, + pause=not (self.cursor_blink and self.has_focus), + ) + + def on_blur(self) -> None: + self.blink_timer.pause() + + def on_focus(self) -> None: + self.cursor_position = len(self.value) + if self.cursor_blink: + self.blink_timer.resume() + + async def on_key(self, event: events.Key) -> None: + self._cursor_visible = True + if self.cursor_blink: + self.blink_timer.reset() + + # Do key bindings first + if await self.handle_key(event): + event.prevent_default() + event.stop() + return + elif event.is_printable: + event.stop() + assert event.char is not None + self.insert_text_at_cursor(event.char) + event.prevent_default() + + def on_paste(self, event: events.Paste) -> None: + line = event.text.splitlines()[0] + self.insert_text_at_cursor(line) + + def on_click(self, event: events.Click) -> None: + offset = event.get_content_offset(self) + if offset is None: + return + event.stop() + click_x = offset.x + self.view_position + cell_offset = 0 + _cell_size = get_character_cell_size + for index, char in enumerate(self.value): + if cell_offset >= click_x: + self.cursor_position = index + break + cell_offset += _cell_size(char) + else: + self.cursor_position = len(self.value) + + def insert_text_at_cursor(self, text: str) -> None: + """Insert new text at the cursor, move the cursor to the end of the new text. + + Args: + text (str): new text to insert. + """ + if self.cursor_position > len(self.value): + self.value += text + self.cursor_position = len(self.value) + else: + value = self.value + before = value[: self.cursor_position] + after = value[self.cursor_position :] + self.value = f"{before}{text}{after}" + self.cursor_position += len(text) + + def action_cursor_left(self) -> None: + self.cursor_position -= 1 + + def action_cursor_right(self) -> None: + self.cursor_position += 1 + + def action_home(self) -> None: + self.cursor_position = 0 + + def action_end(self) -> None: + self.cursor_position = len(self.value) + + def action_delete_right(self) -> None: + value = self.value + delete_position = self.cursor_position + before = value[:delete_position] + after = value[delete_position + 1 :] + self.value = f"{before}{after}" + self.cursor_position = delete_position + + def action_delete_left(self) -> None: + if self.cursor_position <= 0: + # Cursor at the start, so nothing to delete + return + if self.cursor_position == len(self.value): + # Delete from end + self.value = self.value[:-1] + self.cursor_position = len(self.value) + else: + # Cursor in the middle + value = self.value + delete_position = self.cursor_position - 1 + before = value[:delete_position] + after = value[delete_position + 1 :] + self.value = f"{before}{after}" + self.cursor_position = delete_position + + async def action_submit(self) -> None: + await self.emit(self.Submitted(self, self.value)) + + class Changed(Message, bubble=True): + """Value was changed.""" + + def __init__(self, sender: Input, value: str) -> None: + super().__init__(sender) + self.value = value + self.input = sender + + class Submitted(Message, bubble=True): + """Value was updated via enter key or blur.""" + + def __init__(self, sender: Input, value: str) -> None: + super().__init__(sender) + self.value = value + self.input = sender diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 8cb0cc405..7ce714a24 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -6,14 +6,11 @@ from rich.console import RenderableType from rich.panel import Panel from rich.pretty import Pretty import rich.repr - -from logging import getLogger +from rich.style import Style from .. import events -from ..geometry import Offset -from ..widget import Reactive, Widget - -log = getLogger("rich") +from ..reactive import Reactive +from ..widget import Widget @rich.repr.auto(angular=False) @@ -21,28 +18,36 @@ class Placeholder(Widget, can_focus=True): has_focus: Reactive[bool] = Reactive(False) mouse_over: Reactive[bool] = Reactive(False) - style: Reactive[str] = Reactive("") - height: Reactive[int | None] = Reactive(None) - def __init__(self, *, name: str | None = None, height: int | None = None) -> None: - super().__init__(name=name) - self.height = height + def __init__( + # parent class constructor signature: + self, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + # ...and now for our own class specific params: + title: str | None = None, + ) -> None: + super().__init__(*children, name=name, id=id, classes=classes) + self.title = title def __rich_repr__(self) -> rich.repr.Result: - yield "name", self.name + yield from super().__rich_repr__() yield "has_focus", self.has_focus, False yield "mouse_over", self.mouse_over, False def render(self) -> RenderableType: + # Apply colours only inside render_styled + # Pass the full RICH style object into `render` - not the `Styles` return Panel( Align.center( - Pretty(self, no_wrap=True, overflow="ellipsis"), vertical="middle" + Pretty(self, no_wrap=True, overflow="ellipsis"), + vertical="middle", ), - title=self.__class__.__name__, + title=self.title or self.__class__.__name__, border_style="green" if self.mouse_over else "blue", box=box.HEAVY if self.has_focus else box.ROUNDED, - style=self.style, - height=self.height, ) async def on_focus(self, event: events.Focus) -> None: diff --git a/src/textual/widgets/_pretty.py b/src/textual/widgets/_pretty.py new file mode 100644 index 000000000..ff43350f5 --- /dev/null +++ b/src/textual/widgets/_pretty.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any +from rich.pretty import Pretty as PrettyRenderable + +from ..widget import Widget + + +class Pretty(Widget): + DEFAULT_CSS = """ + Static { + height: auto; + } + """ + + def __init__( + self, + object: Any, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__( + name=name, + id=id, + classes=classes, + ) + self._renderable = PrettyRenderable(object) + + def render(self) -> PrettyRenderable: + return self._renderable + + def update(self, object: Any) -> None: + self._renderable = PrettyRenderable(object) + self.refresh(layout=True) diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py deleted file mode 100644 index 1cf5ccc25..000000000 --- a/src/textual/widgets/_scroll_view.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -from rich.console import RenderableType -from rich.style import StyleType - - -from .. import events -from ..geometry import SpacingDimensions -from ..layouts.grid import GridLayout -from ..message import Message -from ..messages import CursorMove -from ..scrollbar import ScrollTo, ScrollBar -from ..geometry import clamp -from ..view import View - -from ..widget import Widget - -from ..reactive import Reactive - - -class ScrollView(View): - def __init__( - self, - contents: RenderableType | Widget | None = None, - *, - auto_width: bool = False, - name: str | None = None, - style: StyleType = "", - fluid: bool = True, - gutter: SpacingDimensions = (0, 0), - ) -> None: - from ..views import WindowView - - self.fluid = fluid - self.vscroll = ScrollBar(vertical=True) - self.hscroll = ScrollBar(vertical=False) - self.window = WindowView( - "" if contents is None else contents, auto_width=auto_width, gutter=gutter - ) - layout = GridLayout() - layout.add_column("main") - layout.add_column("vscroll", size=1) - layout.add_row("main") - layout.add_row("hscroll", size=1) - layout.add_areas( - content="main,main", vscroll="vscroll,main", hscroll="main,hscroll" - ) - layout.show_row("hscroll", False) - layout.show_column("vscroll", False) - super().__init__(name=name, layout=layout) - - x: Reactive[float] = Reactive(0, repaint=False) - y: Reactive[float] = Reactive(0, repaint=False) - - target_x: Reactive[float] = Reactive(0, repaint=False) - target_y: Reactive[float] = Reactive(0, repaint=False) - - def validate_x(self, value: float) -> float: - return clamp(value, 0, self.max_scroll_x) - - def validate_target_x(self, value: float) -> float: - return clamp(value, 0, self.max_scroll_x) - - def validate_y(self, value: float) -> float: - return clamp(value, 0, self.max_scroll_y) - - def validate_target_y(self, value: float) -> float: - return clamp(value, 0, self.max_scroll_y) - - @property - def max_scroll_y(self) -> float: - return max(0, self.window.virtual_size.height - self.window.size.height) - - @property - def max_scroll_x(self) -> float: - return max(0, self.window.virtual_size.width - self.window.size.width) - - async def watch_x(self, new_value: float) -> None: - self.window.scroll_x = round(new_value) - self.hscroll.position = round(new_value) - - async def watch_y(self, new_value: float) -> None: - self.window.scroll_y = round(new_value) - self.vscroll.position = round(new_value) - - async def update(self, renderable: RenderableType, home: bool = True) -> None: - if home: - self.home() - await self.window.update(renderable) - - async def on_mount(self, event: events.Mount) -> None: - assert isinstance(self.layout, GridLayout) - self.layout.place( - content=self.window, - vscroll=self.vscroll, - hscroll=self.hscroll, - ) - await self.layout.mount_all(self) - - def home(self) -> None: - self.x = self.y = 0 - - def scroll_up(self) -> None: - self.target_y += 1.5 - self.animate("y", self.target_y, easing="out_cubic", speed=80) - - def scroll_down(self) -> None: - self.target_y -= 1.5 - self.animate("y", self.target_y, easing="out_cubic", speed=80) - - def page_up(self) -> None: - self.target_y -= self.size.height - self.animate("y", self.target_y, easing="out_cubic") - - def page_down(self) -> None: - self.target_y += self.size.height - self.animate("y", self.target_y, easing="out_cubic") - - def page_left(self) -> None: - self.target_x -= self.size.width - self.animate("x", self.target_x, speed=120, easing="out_cubic") - - def page_right(self) -> None: - self.target_x += self.size.width - self.animate("x", self.target_x, speed=120, easing="out_cubic") - - def scroll_in_to_view(self, line: int) -> None: - if line < self.y: - self.y = line - elif line >= self.y + self.size.height: - self.y = line - self.size.height + 1 - - def scroll_to_center(self, line: int) -> None: - self.target_y = line - self.size.height // 2 - if abs(self.target_y - self.y) > 1: - # Animate if its more than 1 - self.animate("y", self.target_y, easing="out_cubic") - else: - # Jump if its just one step - self.y = self.target_y - - async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: - self.scroll_up() - - async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None: - self.scroll_down() - - async def on_key(self, event: events.Key) -> None: - await self.dispatch_key(event) - - async def key_down(self) -> None: - self.target_y += 2 - self.animate("y", self.target_y, easing="linear", speed=100) - - async def key_up(self) -> None: - self.target_y -= 2 - self.animate("y", self.target_y, easing="linear", speed=100) - - async def key_pagedown(self) -> None: - self.target_y += self.size.height - self.animate("y", self.target_y, easing="out_cubic") - - async def key_pageup(self) -> None: - self.target_y -= self.size.height - self.animate("y", self.target_y, easing="out_cubic") - - async def key_end(self) -> None: - self.target_x = 0 - self.target_y = self.window.virtual_size.height - self.size.height - self.animate("x", self.target_x, duration=1, easing="out_cubic") - self.animate("y", self.target_y, duration=1, easing="out_cubic") - - async def key_home(self) -> None: - self.target_x = 0 - self.target_y = 0 - self.animate("x", self.target_x, duration=1, easing="out_cubic") - self.animate("y", self.target_y, duration=1, easing="out_cubic") - - async def handle_scroll_up(self) -> None: - self.page_up() - - async def handle_scroll_down(self) -> None: - self.page_down() - - async def handle_scroll_left(self) -> None: - self.page_left() - - async def handle_scroll_right(self) -> None: - self.page_right() - - async def handle_scroll_to(self, message: ScrollTo) -> None: - if message.x is not None: - self.target_x = message.x - if message.y is not None: - self.target_y = message.y - self.animate("x", self.target_x, speed=150, easing="out_cubic") - self.animate("y", self.target_y, speed=150, easing="out_cubic") - - async def handle_window_change(self, message: Message) -> None: - - message.stop() - - virtual_width, virtual_height = self.window.virtual_size - width, height = self.size - - self.x = self.validate_x(self.x) - self.y = self.validate_y(self.y) - - self.hscroll.virtual_size = virtual_width - self.hscroll.window_size = width - self.vscroll.virtual_size = virtual_height - self.vscroll.window_size = height - - assert isinstance(self.layout, GridLayout) - - vscroll_change = self.layout.show_column("vscroll", virtual_height > height) - hscroll_change = self.layout.show_row("hscroll", virtual_width > width) - if hscroll_change or vscroll_change: - self.refresh(layout=True) - - def handle_cursor_move(self, message: CursorMove) -> None: - self.scroll_to_center(message.line) - message.stop() diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 148a60074..0d5004bae 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -1,31 +1,98 @@ from __future__ import annotations from rich.console import RenderableType -from rich.padding import Padding, PaddingDimensions -from rich.style import StyleType -from rich.styled import Styled +from rich.protocol import is_renderable +from rich.text import Text + +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): + """A widget to display simple static content, or use as a base class for more complex widgets. + + Args: + renderable (RenderableType, optional): A Rich renderable, or string containing console markup. + Defaults to "". + expand (bool, optional): Expand content if required to fill container. Defaults to False. + shrink (bool, optional): Shrink content if required to fill container. Defaults to False. + markup (bool, optional): True if markup should be parsed and rendered. Defaults to True. + name (str | None, optional): Name of widget. Defaults to None. + id (str | None, optional): ID of Widget. Defaults to None. + classes (str | None, optional): Space separated list of class names. Defaults to None. + """ + + DEFAULT_CSS = """ + Static { + height: auto; + } + """ + + _renderable: RenderableType + def __init__( self, - renderable: RenderableType, + renderable: RenderableType = "", + *, + expand: bool = False, + shrink: bool = False, + markup: bool = True, name: str | None = None, - style: StyleType = "", - padding: PaddingDimensions = 0, + id: str | None = None, + classes: str | None = None, ) -> None: - super().__init__(name) + + super().__init__(name=name, id=id, classes=classes) + self.expand = expand + self.shrink = shrink + self.markup = markup self.renderable = renderable - self.style = style - self.padding = padding + _check_renderable(renderable) + + @property + def renderable(self) -> RenderableType: + return self._renderable or "" + + @renderable.setter + def renderable(self, renderable: RenderableType) -> None: + if isinstance(renderable, str): + if self.markup: + self._renderable = Text.from_markup(renderable) + else: + self._renderable = Text(renderable) + else: + self._renderable = renderable def render(self) -> RenderableType: - renderable = self.renderable - if self.padding: - renderable = Padding(renderable, self.padding) - return Styled(renderable, self.style) + """Get a rich renderable for the widget's content. - async def update(self, renderable: RenderableType) -> None: + Returns: + RenderableType: A rich renderable. + """ + return self._renderable + + def update(self, renderable: RenderableType = "") -> None: + """Update the widget's content area with new text or Rich renderable. + + Args: + renderable (RenderableType, optional): A new rich renderable. Defaults to empty renderable; + """ + _check_renderable(renderable) self.renderable = renderable - self.refresh() + self.refresh(layout=True) diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py new file mode 100644 index 000000000..05f1f0d5e --- /dev/null +++ b/src/textual/widgets/_text_log.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from typing import cast + +from rich.console import RenderableType +from rich.highlighter import ReprHighlighter +from rich.pretty import Pretty +from rich.protocol import is_renderable +from rich.segment import Segment +from rich.text import Text + +from ..reactive import var +from ..geometry import Size, Region +from ..scroll_view import ScrollView +from .._cache import LRUCache +from .._segment_tools import line_crop +from .._types import Lines + + +class TextLog(ScrollView, can_focus=True): + DEFAULT_CSS = """ + TextLog{ + background: $surface; + color: $text; + overflow-y: scroll; + } + """ + + max_lines: var[int | None] = var(None) + min_width: var[int] = var(78) + wrap: var[bool] = var(False) + highlight: var[bool] = var(False) + markup: var[bool] = var(False) + + def __init__( + self, + *, + max_lines: int | None = None, + min_width: int = 78, + wrap: bool = False, + highlight: bool = False, + markup: bool = False, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + super().__init__(name=name, id=id, classes=classes) + self.max_lines = max_lines + self.lines: list[list[Segment]] = [] + self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] + self._line_cache = LRUCache(1024) + self.max_width: int = 0 + self.min_width = min_width + self.wrap = wrap + self.highlight = highlight + self.markup = markup + self.highlighter = ReprHighlighter() + + def _on_styles_updated(self) -> None: + self._line_cache.clear() + + def write(self, content: RenderableType | object) -> None: + """Write text or a rich renderable. + + Args: + content (RenderableType): Rich renderable (or text). + """ + + renderable: RenderableType + if not is_renderable(content): + renderable = Pretty(content) + else: + if isinstance(content, str): + if self.markup: + content = Text.from_markup(content) + if self.highlight: + renderable = self.highlighter(content) + else: + renderable = Text(content) + else: + renderable = cast(RenderableType, content) + + console = self.app.console + width = max(self.min_width, self.size.width or self.min_width) + + render_options = console.options.update_width(width) + if not self.wrap: + render_options = render_options.update(overflow="ignore", no_wrap=True) + segments = self.app.console.render(renderable, render_options.update_width(80)) + lines = list(Segment.split_lines(segments)) + + self.max_width = max( + self.max_width, + max(sum(segment.cell_length for segment in _line) for _line in lines), + ) + self.lines.extend(lines) + + if self.max_lines is not None: + self.lines = self.lines[-self.max_lines :] + self.virtual_size = Size(self.max_width, len(self.lines)) + self.scroll_end(animate=False, speed=100) + + def clear(self) -> None: + """Clear the text log.""" + del self.lines[:] + self.max_width = 0 + self.virtual_size = Size(self.max_width, len(self.lines)) + + def render_line(self, y: int) -> list[Segment]: + scroll_x, scroll_y = self.scroll_offset + line = self._render_line(scroll_y + y, scroll_x, self.size.width) + line = list(Segment.apply_style(line, self.rich_style)) + return line + + def render_lines(self, crop: Region) -> Lines: + """Render the widget in to lines. + + Args: + crop (Region): Region within visible area to. + + Returns: + Lines: A list of list of segments + """ + lines = self._styles_cache.render_widget(self, crop) + return lines + + def _render_line(self, y: int, scroll_x: int, width: int) -> list[Segment]: + + if y >= len(self.lines): + return [Segment(" " * width, self.rich_style)] + + key = (y, scroll_x, width, self.max_width) + if key in self._line_cache: + return self._line_cache[key] + + line = self.lines[y] + line = Segment.adjust_line_length( + line, max(self.max_width, width), self.rich_style + ) + line = line_crop(line, scroll_x, scroll_x + width, self.max_width) + self._line_cache[key] = line + return line diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index 0a0367684..c471e0686 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -1,27 +1,28 @@ from __future__ import annotations -from typing import Generic, Iterator, NewType, TypeVar +from typing import ClassVar, Generic, Iterator, NewType, TypeVar import rich.repr from rich.console import RenderableType +from rich.style import Style, NULL_STYLE from rich.text import Text, TextType from rich.tree import Tree -from rich.padding import PaddingDimensions -from .. import log +from ..geometry import Region, Size from .. import events from ..reactive import Reactive from .._types import MessageTarget -from ..widget import Widget +from ..widgets import Static from ..message import Message -from ..messages import CursorMove +from .. import messages NodeID = NewType("NodeID", int) NodeDataType = TypeVar("NodeDataType") +EventNodeDataType = TypeVar("EventNodeDataType") @rich.repr.auto @@ -143,16 +144,16 @@ class TreeNode(Generic[NodeDataType]): sibling = node return None - async def expand(self, expanded: bool = True) -> None: + def expand(self, expanded: bool = True) -> None: self._expanded = expanded self._tree.expanded = expanded self._control.refresh(layout=True) - async def toggle(self) -> None: - await self.expand(not self._expanded) + def toggle(self) -> None: + self.expand(not self._expanded) - async def add(self, label: TextType, data: NodeDataType) -> None: - await self._control.add(self.id, label, data=data) + def add(self, label: TextType, data: NodeDataType) -> None: + self._control.add(self.id, label, data=data) self._control.refresh(layout=True) self._empty = False @@ -160,66 +161,115 @@ class TreeNode(Generic[NodeDataType]): return self._control.render_node(self) -@rich.repr.auto -class TreeClick(Generic[NodeDataType], Message, bubble=True): - def __init__(self, sender: MessageTarget, node: TreeNode[NodeDataType]) -> None: - self.node = node - super().__init__(sender) +class TreeControl(Generic[NodeDataType], Static, can_focus=True): + DEFAULT_CSS = """ + TreeControl { + color: $text; + height: auto; + width: 100%; + link-style: not underline; + } - def __rich_repr__(self) -> rich.repr.Result: - yield "node", self.node + TreeControl > .tree--guides { + color: $success; + } + TreeControl > .tree--guides-highlight { + color: $success; + text-style: uu; + } + + TreeControl > .tree--guides-cursor { + color: $secondary; + text-style: bold; + } + + TreeControl > .tree--labels { + color: $text; + } + + TreeControl > .tree--cursor { + background: $secondary; + color: $text; + } + + """ + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "tree--guides", + "tree--guides-highlight", + "tree--guides-cursor", + "tree--labels", + "tree--cursor", + } + + class NodeSelected(Generic[EventNodeDataType], Message, bubble=False): + def __init__( + self, sender: MessageTarget, node: TreeNode[EventNodeDataType] + ) -> None: + self.node = node + super().__init__(sender) -class TreeControl(Generic[NodeDataType], Widget): def __init__( self, label: TextType, data: NodeDataType, *, name: str | None = None, - padding: PaddingDimensions = (1, 1), + id: str | None = None, + classes: str | None = None, ) -> None: + super().__init__(name=name, id=id, classes=classes) self.data = data - self.id = NodeID(0) + self.node_id = NodeID(0) self.nodes: dict[NodeID, TreeNode[NodeDataType]] = {} self._tree = Tree(label) + self.root: TreeNode[NodeDataType] = TreeNode( - None, self.id, self, self._tree, label, data + None, self.node_id, self, self._tree, label, data ) self._tree.label = self.root - self.nodes[NodeID(self.id)] = self.root - super().__init__(name=name) - self.padding = padding + self.nodes[NodeID(self.node_id)] = self.root + + self.auto_links = False hover_node: Reactive[NodeID | None] = Reactive(None) - cursor: Reactive[NodeID] = Reactive(NodeID(0), layout=True) - cursor_line: Reactive[int] = Reactive(0, repaint=False) - show_cursor: Reactive[bool] = Reactive(False, layout=True) - - def watch_show_cursor(self, value: bool) -> None: - self.emit_no_wait(CursorMove(self, self.cursor_line)) + cursor: Reactive[NodeID] = Reactive(NodeID(0)) + cursor_line: Reactive[int] = Reactive(0) + show_cursor: Reactive[bool] = Reactive(False) def watch_cursor_line(self, value: int) -> None: - if self.show_cursor: - self.emit_no_wait(CursorMove(self, value + self.gutter.top)) + line_region = Region(0, value, self.size.width, 1) + self.emit_no_wait(messages.ScrollToRegion(self, line_region)) - async def add( + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + def get_size(tree: Tree) -> int: + return 1 + sum( + get_size(child) if child.expanded else 1 for child in tree.children + ) + + size = get_size(self._tree) + return size + + def add( self, node_id: NodeID, label: TextType, data: NodeDataType, ) -> None: + parent = self.nodes[node_id] - self.id = NodeID(self.id + 1) + self.node_id = NodeID(self.node_id + 1) child_tree = parent._tree.add(label) + child_tree.guide_style = self._guide_style child_node: TreeNode[NodeDataType] = TreeNode( - parent, self.id, self, child_tree, label, data + parent, self.node_id, self, child_tree, label, data ) parent.children.append(child_node) child_tree.label = child_node - self.nodes[self.id] = child_node + self.nodes[self.node_id] = child_node self.refresh(layout=True) @@ -250,11 +300,29 @@ class TreeControl(Generic[NodeDataType], Widget): return None def render(self) -> RenderableType: + guide_style = self._guide_style + + def update_guide_style(tree: Tree) -> None: + tree.guide_style = guide_style + for child in tree.children: + if child.expanded: + update_guide_style(child) + + update_guide_style(self._tree) + if self.hover_node is not None: + hover = self.nodes.get(self.hover_node) + if hover is not None: + hover._tree.guide_style = self._highlight_guide_style + if self.cursor is not None and self.show_cursor: + cursor = self.nodes.get(self.cursor) + if cursor is not None: + cursor._tree.guide_style = self._cursor_guide_style return self._tree def render_node(self, node: TreeNode[NodeDataType]) -> RenderableType: + label_style = self.get_component_styles("tree--labels").rich_style label = ( - Text(node.label, no_wrap=True, overflow="ellipsis") + Text(node.label, no_wrap=True, style=label_style, overflow="ellipsis") if isinstance(node.label, str) else node.label ) @@ -263,33 +331,82 @@ class TreeControl(Generic[NodeDataType], Widget): label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id}) return label - async def action_click_label(self, node_id: NodeID) -> None: + def action_click_label(self, node_id: NodeID) -> None: node = self.nodes[node_id] self.cursor = node.id self.cursor_line = self.find_cursor() or 0 - self.show_cursor = False - await self.post_message(TreeClick(self, node)) + self.show_cursor = True + self.post_message_no_wait(self.NodeSelected(self, node)) - async def on_mouse_move(self, event: events.MouseMove) -> None: + def on_mount(self) -> None: + self._tree.guide_style = self._guide_style + + @property + def _guide_style(self) -> Style: + return self.get_component_rich_style("tree--guides") + + @property + def _highlight_guide_style(self) -> Style: + return self.get_component_rich_style("tree--guides-highlight") + + @property + def _cursor_guide_style(self) -> Style: + return self.get_component_rich_style("tree--guides-cursor") + + def on_mouse_move(self, event: events.MouseMove) -> None: self.hover_node = event.style.meta.get("tree_node") - async def on_key(self, event: events.Key) -> None: - await self.dispatch_key(event) - - async def key_down(self, event: events.Key) -> None: + def key_down(self, event: events.Key) -> None: event.stop() - await self.cursor_down() + self.cursor_down() - async def key_up(self, event: events.Key) -> None: + def key_up(self, event: events.Key) -> None: event.stop() - await self.cursor_up() + self.cursor_up() - async def key_enter(self, event: events.Key) -> None: + def key_pagedown(self) -> None: + assert self.parent is not None + height = self.container_viewport.height + + cursor = self.cursor + cursor_line = self.cursor_line + for _ in range(height): + cursor_node = self.nodes[cursor] + next_node = cursor_node.next_node + if next_node is not None: + cursor_line += 1 + cursor = next_node.id + self.cursor = cursor + self.cursor_line = cursor_line + + def key_pageup(self) -> None: + assert self.parent is not None + height = self.container_viewport.height + cursor = self.cursor + cursor_line = self.cursor_line + for _ in range(height): + cursor_node = self.nodes[cursor] + previous_node = cursor_node.previous_node + if previous_node is not None: + cursor_line -= 1 + cursor = previous_node.id + self.cursor = cursor + self.cursor_line = cursor_line + + def key_home(self) -> None: + self.cursor_line = 0 + self.cursor = NodeID(0) + + def key_end(self) -> None: + self.cursor = self.nodes[NodeID(0)].children[-1].id + self.cursor_line = self.find_cursor() or 0 + + def key_enter(self, event: events.Key) -> None: cursor_node = self.nodes[self.cursor] event.stop() - await self.post_message(TreeClick(self, cursor_node)) + self.post_message_no_wait(self.NodeSelected(self, cursor_node)) - async def cursor_down(self) -> None: + def cursor_down(self) -> None: if not self.show_cursor: self.show_cursor = True return @@ -299,7 +416,7 @@ class TreeControl(Generic[NodeDataType], Widget): self.cursor_line += 1 self.cursor = next_node.id - async def cursor_up(self) -> None: + def cursor_up(self) -> None: if not self.show_cursor: self.show_cursor = True return @@ -308,24 +425,3 @@ class TreeControl(Generic[NodeDataType], Widget): if previous_node is not None: self.cursor_line -= 1 self.cursor = previous_node.id - - -if __name__ == "__main__": - - from textual import events - from textual.app import App - - class TreeApp(App): - async def on_mount(self, event: events.Mount) -> None: - await self.view.dock(TreeControl("Tree Root", data="foo")) - - async def handle_tree_click(self, message: TreeClick) -> None: - if message.node.empty: - await message.node.add("foo") - await message.node.add("bar") - await message.node.add("baz") - await message.node.expand() - else: - await message.node.toggle() - - TreeApp.run(log="textual.log") diff --git a/src/textual/widgets/_welcome.py b/src/textual/widgets/_welcome.py new file mode 100644 index 000000000..3c8a6d1be --- /dev/null +++ b/src/textual/widgets/_welcome.py @@ -0,0 +1,53 @@ +from ..app import ComposeResult +from ._static import Static +from ._button import Button +from ..containers import Container + +from rich.markdown import Markdown + +WELCOME_MD = """\ +# Welcome! + +Textual is a TUI, or *Text User Interface*, framework for Python inspired by modern web development. **We hope you enjoy using Textual!** + +## Dune quote + +> "I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain." + +""" + + +class Welcome(Static): + + DEFAULT_CSS = """ + Welcome { + width: 100%; + height: 100%; + background: $surface; + } + + Welcome Container { + padding: 1; + background: $panel; + color: $text; + } + + Welcome #text { + margin: 0 1; + } + + Welcome #close { + dock: bottom; + width: 100%; + } + """ + + def compose(self) -> ComposeResult: + yield Container(Static(Markdown(WELCOME_MD), id="text"), id="md") + yield Button("OK", id="close", variant="success") diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py new file mode 100644 index 000000000..2b814d6a8 --- /dev/null +++ b/src/textual/widgets/tabs.py @@ -0,0 +1,342 @@ +from __future__ import annotations + +import string +from dataclasses import dataclass +from typing import Iterable + +from rich.cells import cell_len +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult +from rich.segment import Segment +from rich.style import StyleType, Style +from rich.text import Text + +from textual import events +from textual._layout_resolve import layout_resolve, Edge +from textual.keys import Keys +from textual.reactive import Reactive +from textual.renderables.text_opacity import TextOpacity +from textual.renderables.underline_bar import UnderlineBar +from textual.widget import Widget + +__all__ = ["Tab", "Tabs"] + + +@dataclass +class Tab: + """Data container representing a single tab. + + Attributes: + label (str): The user-facing label that will appear inside the tab. + name (str, optional): A unique string key that will identify the tab. If None, it will default to the label. + If the name is not unique within a single list of tabs, only the final Tab will be displayed. + """ + + label: str + name: str | None = None + + def __post_init__(self): + if self.name is None: + self.name = self.label + + def __str__(self): + return self.label + + +class TabsRenderable: + """Renderable for the Tabs widget.""" + + def __init__( + self, + tabs: Iterable[Tab], + *, + active_tab_name: str, + active_tab_style: StyleType, + active_bar_style: StyleType, + inactive_tab_style: StyleType, + inactive_bar_style: StyleType, + inactive_text_opacity: float, + tab_padding: int | None, + bar_offset: float, + width: int | None = None, + ): + self.tabs = {tab.name: tab for tab in tabs} + + try: + self.active_tab_name = active_tab_name or next(iter(self.tabs)) + except StopIteration: + self.active_tab_name = None + + self.active_tab_style = active_tab_style + self.active_bar_style = active_bar_style + + self.inactive_tab_style = inactive_tab_style + self.inactive_bar_style = inactive_bar_style + + self.bar_offset = bar_offset + self.width = width + self.tab_padding = tab_padding + self.inactive_text_opacity = inactive_text_opacity + + self._label_range_cache: dict[str, tuple[int, int]] = {} + self._selection_range_cache: dict[str, tuple[int, int]] = {} + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + if self.tabs: + yield from self.get_tab_labels(console, options) + yield Segment.line() + yield from self.get_underline_bar(console) + + def get_tab_labels(self, console: Console, options: ConsoleOptions) -> RenderResult: + """Yields the spaced-out labels that appear above the line for the Tabs widget""" + width = self.width or options.max_width + tab_values = self.tabs.values() + + space = Edge(size=self.tab_padding or None, min_size=1, fraction=1) + edges = [] + for tab in tab_values: + tab = Edge(size=cell_len(tab.label), min_size=1, fraction=None) + edges.extend([space, tab, space]) + + spacing = layout_resolve(width, edges=edges) + + active_tab_style = console.get_style(self.active_tab_style) + inactive_tab_style = console.get_style(self.inactive_tab_style) + + label_cell_cursor = 0 + for tab_index, tab in enumerate(tab_values): + tab_edge_index = tab_index * 3 + 1 + + len_label_text = spacing[tab_edge_index] + lpad = spacing[tab_edge_index - 1] + rpad = spacing[tab_edge_index + 1] + + label_left_padding = Text(" " * lpad, end="") + label_right_padding = Text(" " * rpad, end="") + + padded_label = f"{label_left_padding}{tab.label}{label_right_padding}" + if tab.name == self.active_tab_name: + yield Text(padded_label, end="", style=active_tab_style) + else: + tab_content = Text( + padded_label, + end="", + style=inactive_tab_style + + Style.from_meta({"@click": f"range_clicked('{tab.name}')"}), + ) + dimmed_tab_content = TextOpacity( + tab_content, opacity=self.inactive_text_opacity + ) + segments = console.render(dimmed_tab_content) + yield from segments + + # Cache the position of the label text within this tab + label_cell_cursor += lpad + self._label_range_cache[tab.name] = ( + label_cell_cursor, + label_cell_cursor + len_label_text, + ) + label_cell_cursor += len_label_text + rpad + + # Cache the position of the whole tab, i.e. the range that can be clicked + self._selection_range_cache[tab.name] = ( + label_cell_cursor - lpad, + label_cell_cursor + len_label_text + rpad, + ) + + def get_underline_bar(self, console: Console) -> RenderResult: + """Yields the bar that appears below the tab labels in the Tabs widget""" + if self.tabs: + ranges = self._label_range_cache + tab_index = int(self.bar_offset) + next_tab_index = (tab_index + 1) % len(ranges) + range_values = list(ranges.values()) + tab1_start, tab1_end = range_values[tab_index] + tab2_start, tab2_end = range_values[next_tab_index] + + bar_start = tab1_start + (tab2_start - tab1_start) * ( + self.bar_offset - tab_index + ) + bar_end = tab1_end + (tab2_end - tab1_end) * (self.bar_offset - tab_index) + else: + bar_start = 0 + bar_end = 0 + underline = UnderlineBar( + highlight_range=(bar_start, bar_end), + highlight_style=self.active_bar_style, + background_style=self.inactive_bar_style, + clickable_ranges=self._selection_range_cache, + ) + yield from console.render(underline) + + +class Tabs(Widget): + """Widget which displays a set of horizontal tabs. + + Args: + tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered. + active_tab (str, optional): The name of the tab that should be active on first render. + active_tab_style (StyleType): Style to apply to the label of the active tab. + active_bar_style (StyleType): Style to apply to the underline of the active tab. + inactive_tab_style (StyleType): Style to apply to the label of inactive tabs. + inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs. + inactive_text_opacity (float): Opacity of the text labels of inactive tabs. + animation_duration (float): The duration of the tab change animation, in seconds. + animation_function (str): The easing function to use for the tab change animation. + tab_padding (int, optional): The padding at the side of each tab. If None, tabs will + automatically be padded such that they fit the available horizontal space. + search_by_first_character (bool): If True, entering a character on your keyboard + will activate the next tab (in left-to-right order) with a label starting with + that character. + """ + + _active_tab_name: Reactive[str | None] = Reactive("") + _bar_offset: Reactive[float] = Reactive(0.0) + + def __init__( + self, + tabs: list[Tab], + active_tab: str | None = None, + active_tab_style: StyleType = "#f0f0f0 on #021720", + active_bar_style: StyleType = "#1BB152", + inactive_tab_style: StyleType = "#f0f0f0 on #021720", + inactive_bar_style: StyleType = "#455058", + inactive_text_opacity: float = 0.5, + animation_duration: float = 0.3, + animation_function: str = "out_cubic", + tab_padding: int | None = None, + search_by_first_character: bool = True, + ) -> None: + super().__init__() + self.tabs = tabs + + self._bar_offset = float(self.find_tab_by_name(active_tab) or 0) + self._active_tab_name = active_tab or next(iter(self.tabs), None) + + self.active_tab_style = active_tab_style + self.active_bar_style = active_bar_style + + self.inactive_bar_style = inactive_bar_style + self.inactive_tab_style = inactive_tab_style + self.inactive_text_opacity = inactive_text_opacity + + self.animation_function = animation_function + self.animation_duration = animation_duration + + self.tab_padding = tab_padding + + self.search_by_first_character = search_by_first_character + + def on_key(self, event: events.Key) -> None: + """Handles key press events when this widget is in focus. + Pressing "escape" removes focus from this widget. Use the left and + right arrow keys to cycle through tabs. Use number keys to jump to tabs + based in their number ("1" jumps to the leftmost tab). Type a character + to cycle through tabs with labels beginning with that character. + + Args: + event (events.Key): The Key event being handled + """ + if not self.tabs: + event.prevent_default() + return + + if event.key == Keys.Escape: + self.screen.set_focus(None) + elif event.key == Keys.Right: + self.activate_next_tab() + elif event.key == Keys.Left: + self.activate_previous_tab() + elif event.key in string.digits: + self.activate_tab_by_number(int(event.key)) + elif self.search_by_first_character: + self.activate_tab_by_first_char(event.key) + + event.prevent_default() + + def activate_next_tab(self) -> None: + """Activate the tab to the right of the currently active tab""" + current_tab_index = self.find_tab_by_name(self._active_tab_name) + next_tab_index = (current_tab_index + 1) % len(self.tabs) + next_tab_name = self.tabs[next_tab_index].name + self._active_tab_name = next_tab_name + + def activate_previous_tab(self) -> None: + """Activate the tab to the left of the currently active tab""" + current_tab_index = self.find_tab_by_name(self._active_tab_name) + previous_tab_index = current_tab_index - 1 + previous_tab_name = self.tabs[previous_tab_index].name + self._active_tab_name = previous_tab_name + + def activate_tab_by_first_char(self, char: str) -> None: + """Activate the next tab that begins with the character + + Args: + char (str): The character to search for + """ + + def find_next_matching_tab( + char: str, start: int | None, end: int | None + ) -> Tab | None: + for tab in self.tabs[start:end]: + if tab.label.lower().startswith(char.lower()): + return tab + + current_tab_index = self.find_tab_by_name(self._active_tab_name) + next_tab_index = (current_tab_index + 1) % len(self.tabs) + + next_matching_tab = find_next_matching_tab(char, next_tab_index, None) + if not next_matching_tab: + next_matching_tab = find_next_matching_tab(char, None, current_tab_index) + + if next_matching_tab: + self._active_tab_name = next_matching_tab.name + + def activate_tab_by_number(self, tab_number: int) -> None: + """Activate a tab using the tab number. + + Args: + tab_number (int): The number of the tab. + The leftmost tab is number 1, the next is 2, and so on. 0 represents the 10th tab. + """ + if tab_number > len(self.tabs): + return + if tab_number == 0 and len(self.tabs) >= 10: + tab_number = 10 + self._active_tab_name = self.tabs[tab_number - 1].name + + def action_range_clicked(self, target_tab_name: str) -> None: + """Handles 'range_clicked' actions which are fired when tabs are clicked""" + self._active_tab_name = target_tab_name + + def watch__active_tab_name(self, tab_name: str) -> None: + """Animates the underline bar position when the active tab changes""" + target_tab_index = self.find_tab_by_name(tab_name) + self.animate( + "_bar_offset", + float(target_tab_index), + easing=self.animation_function, + duration=self.animation_duration, + ) + + def find_tab_by_name(self, tab_name: str) -> int: + """Return the index of the first tab with a certain name + + Args: + tab_name (str): The name to search for. + """ + return next((i for i, tab in enumerate(self.tabs) if tab.name == tab_name), 0) + + def render(self) -> RenderableType: + return TabsRenderable( + self.tabs, + tab_padding=self.tab_padding, + active_tab_name=self._active_tab_name, + active_tab_style=self.active_tab_style, + active_bar_style=self.active_bar_style, + inactive_tab_style=self.inactive_tab_style, + inactive_bar_style=self.inactive_bar_style, + bar_offset=self._bar_offset, + inactive_text_opacity=self.inactive_text_opacity, + ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 000000000..3909ba67c --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,10 @@ +from click.testing import CliRunner +from importlib_metadata import version + +from textual.cli.cli import run + + +def test_cli_version(): + runner = CliRunner() + result = runner.invoke(run, ["--version"]) + assert version("textual") in result.output diff --git a/tests/css/__init__.py b/tests/css/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/css/test_help_text.py b/tests/css/test_help_text.py new file mode 100644 index 000000000..928ca2d62 --- /dev/null +++ b/tests/css/test_help_text.py @@ -0,0 +1,127 @@ +import pytest + +from tests.utilities.render import render +from textual.css._help_text import ( + spacing_wrong_number_of_values_help_text, + spacing_invalid_value_help_text, + scalar_help_text, + string_enum_help_text, + color_property_help_text, + border_property_help_text, + layout_property_help_text, + fractional_property_help_text, + offset_property_help_text, + align_help_text, + offset_single_axis_help_text, + style_flags_property_help_text, +) + + +@pytest.fixture(params=["css", "inline"]) +def styling_context(request): + return request.param + + +def test_help_text_examples_are_contextualized(): + """Ensure that if the user is using CSS, they see CSS-specific examples + and if they're using inline styles they see inline-specific examples.""" + rendered_inline = render(spacing_invalid_value_help_text("padding", "inline")) + assert "widget.styles.padding" in rendered_inline + + rendered_css = render(spacing_invalid_value_help_text("padding", "css")) + assert "padding:" in rendered_css + + +def test_spacing_wrong_number_of_values(styling_context): + rendered = render( + spacing_wrong_number_of_values_help_text("margin", 3, styling_context) + ) + assert "Invalid number of values" in rendered + assert "margin" in rendered + assert "3" in rendered + + +def test_spacing_invalid_value(styling_context): + rendered = render(spacing_invalid_value_help_text("padding", styling_context)) + assert "Invalid value for" in rendered + assert "padding" in rendered + + +def test_scalar_help_text(styling_context): + rendered = render(scalar_help_text("max-width", styling_context)) + assert "Invalid value for" in rendered + + # Ensure property name is contextualised to inline/css styling + if styling_context == "css": + assert "max-width" in rendered + elif styling_context == "inline": + assert "max_width" in rendered + + +def test_string_enum_help_text(styling_context): + rendered = render( + string_enum_help_text("display", ["none", "hidden"], styling_context) + ) + assert "Invalid value for" in rendered + + # Ensure property name is mentioned + assert "display" in rendered + + # Ensure each valid value is mentioned + assert "hidden" in rendered + assert "none" in rendered + + +def test_color_property_help_text(styling_context): + rendered = render(color_property_help_text("background", styling_context)) + assert "Invalid value for" in rendered + assert "background" in rendered + + +def test_border_property_help_text(styling_context): + rendered = render(border_property_help_text("border", styling_context)) + assert "Invalid value for" in rendered + assert "border" in rendered + + +def test_layout_property_help_text(styling_context): + rendered = render(layout_property_help_text("layout", styling_context)) + assert "Invalid value for" in rendered + assert "layout" in rendered + + +def test_fractional_property_help_text(styling_context): + rendered = render(fractional_property_help_text("opacity", styling_context)) + assert "Invalid value for" in rendered + assert "opacity" in rendered + + +def test_offset_property_help_text(styling_context): + rendered = render(offset_property_help_text(styling_context)) + assert "Invalid value for" in rendered + assert "offset" in rendered + + +def test_align_help_text(): + rendered = render(align_help_text()) + assert "Invalid value for" in rendered + assert "align" in rendered + + +def test_offset_single_axis_help_text(): + rendered = render(offset_single_axis_help_text("offset-x")) + assert "Invalid value for" in rendered + assert "offset-x" in rendered + + +def test_style_flags_property_help_text(styling_context): + rendered = render( + style_flags_property_help_text("text-style", "notavalue b", styling_context) + ) + assert "Invalid value" in rendered + assert "notavalue" in rendered + + if styling_context == "css": + assert "text-style" in rendered + else: + assert "text_style" in rendered diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py new file mode 100644 index 000000000..fed5db430 --- /dev/null +++ b/tests/css/test_parse.py @@ -0,0 +1,1191 @@ +from __future__ import annotations + +import pytest + +from textual.color import Color +from textual.css.errors import UnresolvedVariableError +from textual.css.parse import substitute_references +from textual.css.scalar import Scalar, Unit +from textual.css.stylesheet import Stylesheet, StylesheetParseError +from textual.css.tokenize import tokenize +from textual.css.tokenizer import Token, ReferencedBy +from textual.css.transition import Transition +from textual.geometry import Spacing +from textual.layouts.vertical import VerticalLayout + + +class TestVariableReferenceSubstitution: + def test_simple_reference(self): + css = "$x: 1; #some-widget{border: $x;}" + variables = substitute_references(tokenize(css, "")) + assert list(variables) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="selector_start_id", + value="#some-widget", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 19), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(0, 20), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 27), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="x", location=(0, 28), length=2, code=css + ), + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(0, 30), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 31), + referenced_by=None, + ), + ] + + def test_simple_reference_no_whitespace(self): + css = "$x:1; #some-widget{border: $x;}" + variables = substitute_references(tokenize(css, "")) + assert list(variables) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="selector_start_id", + value="#some-widget", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 18), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(0, 19), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 26), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 3), + referenced_by=ReferencedBy( + name="x", location=(0, 27), length=2, code=css + ), + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(0, 29), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 30), + referenced_by=None, + ), + ] + + def test_undefined_variable(self): + css = ".thing { border: $not-defined; }" + with pytest.raises(UnresolvedVariableError): + list(substitute_references(tokenize(css, ""))) + + def test_transitive_reference(self): + css = "$x: 1\n$y: $x\n.thing { border: $y }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="variable_name", + value="$y:", + path="", + code=css, + location=(1, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 3), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="x", location=(1, 4), length=2, code=css + ), + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(1, 6), + referenced_by=None, + ), + Token( + name="selector_start_class", + value=".thing", + path="", + code=css, + location=(2, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 6), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(2, 7), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 8), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(2, 9), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 16), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 19), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(2, 20), + referenced_by=None, + ), + ] + + def test_multi_value_variable(self): + css = "$x: 2 4\n$y: 6 $x 2\n.thing { border: $y }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="number", + value="4", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="variable_name", + value="$y:", + path="", + code=css, + location=(1, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 3), + referenced_by=None, + ), + Token( + name="number", + value="6", + path="", + code=css, + location=(1, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 5), + referenced_by=None, + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="x", location=(1, 6), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 5), + referenced_by=ReferencedBy( + name="x", location=(1, 6), length=2, code=css + ), + ), + Token( + name="number", + value="4", + path="", + code=css, + location=(0, 6), + referenced_by=ReferencedBy( + name="x", location=(1, 6), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 8), + referenced_by=None, + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(1, 9), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(1, 10), + referenced_by=None, + ), + Token( + name="selector_start_class", + value=".thing", + path="", + code=css, + location=(2, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 6), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(2, 7), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 8), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(2, 9), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 16), + referenced_by=None, + ), + Token( + name="number", + value="6", + path="", + code=css, + location=(1, 4), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 5), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 5), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="number", + value="4", + path="", + code=css, + location=(0, 6), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 8), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(1, 9), + referenced_by=ReferencedBy( + name="y", location=(2, 17), length=2, code=css + ), + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(2, 19), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(2, 20), + referenced_by=None, + ), + ] + + def test_variable_used_inside_property_value(self): + css = "$x: red\n.thing { border: on $x; }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="selector_start_class", + value=".thing", + path="", + code=css, + location=(1, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 6), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(1, 7), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 8), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(1, 9), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 16), + referenced_by=None, + ), + Token( + name="token", + value="on", + path="", + code=css, + location=(1, 17), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 19), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 4), + referenced_by=ReferencedBy( + name="x", location=(1, 20), length=2, code=css + ), + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(1, 22), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 23), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(1, 24), + referenced_by=None, + ), + ] + + def test_variable_definition_eof(self): + css = "$x: 1" + assert list(substitute_references(tokenize(css, ""))) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + ] + + def test_variable_reference_whitespace_trimming(self): + css = "$x: 123;.thing{border: $x}" + assert list(substitute_references(tokenize(css, ""))) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="123", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 10), + referenced_by=None, + ), + Token( + name="selector_start_class", + value=".thing", + path="", + code=css, + location=(0, 11), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 17), + referenced_by=None, + ), + Token( + name="declaration_name", + value="border:", + path="", + code=css, + location=(0, 18), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 25), + referenced_by=None, + ), + Token( + name="number", + value="123", + path="", + code=css, + location=(0, 7), + referenced_by=ReferencedBy( + name="x", location=(0, 26), length=2, code=css + ), + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 28), + referenced_by=None, + ), + ] + + +class TestParseLayout: + def test_valid_layout_name(self): + css = "#some-widget { layout: vertical; }" + + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + assert isinstance(styles.layout, VerticalLayout) + + def test_invalid_layout_name(self): + css = "#some-widget { layout: invalidlayout; }" + + stylesheet = Stylesheet() + with pytest.raises(StylesheetParseError) as ex: + stylesheet.add_source(css) + stylesheet.parse() + + assert ex.value.errors is not None + + +class TestParseText: + def test_foreground(self): + css = """#some-widget { + color: green; + } + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + assert styles.color == Color.parse("green") + + def test_background(self): + css = """#some-widget { + background: red; + } + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + assert styles.background == Color.parse("red") + + +class TestParseColor: + """More in-depth tests around parsing of CSS colors""" + + @pytest.mark.parametrize( + "value,result", + [ + ("rgb(1,255,50)", Color(1, 255, 50)), + ("rgb( 1, 255,50 )", Color(1, 255, 50)), + ("rgba( 1, 255,50,0.3 )", Color(1, 255, 50, 0.3)), + ("rgba( 1, 255,50, 1.3 )", Color(1, 255, 50, 1.0)), + ("hsl( 180, 50%, 50% )", Color(64, 191, 191)), + ("hsl(180,50%,50%)", Color(64, 191, 191)), + ("hsla(180,50%,50%,0.25)", Color(64, 191, 191, 0.25)), + ("hsla( 180, 50% ,50%,0.25 )", Color(64, 191, 191, 0.25)), + ("hsla( 180, 50% , 50% , 1.5 )", Color(64, 191, 191)), + ], + ) + def test_rgb_and_hsl(self, value, result): + css = f""".box {{ + color: {value}; + }} + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + assert styles.color == result + + +class TestParseOffset: + @pytest.mark.parametrize( + "offset_x, parsed_x, offset_y, parsed_y", + [ + [ + "-5.5%", + Scalar(-5.5, Unit.PERCENT, Unit.WIDTH), + "-30%", + Scalar(-30, Unit.PERCENT, Unit.HEIGHT), + ], + [ + "5%", + Scalar(5, Unit.PERCENT, Unit.WIDTH), + "40%", + Scalar(40, Unit.PERCENT, Unit.HEIGHT), + ], + [ + "10", + Scalar(10, Unit.CELLS, Unit.WIDTH), + "40", + Scalar(40, Unit.CELLS, Unit.HEIGHT), + ], + ], + ) + def test_composite_rule(self, offset_x, parsed_x, offset_y, parsed_y): + css = f"""#some-widget {{ + offset: {offset_x} {offset_y}; + }} + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + + assert len(stylesheet.rules) == 1 + assert stylesheet.rules[0].errors == [] + assert styles.offset.x == parsed_x + assert styles.offset.y == parsed_y + + @pytest.mark.parametrize( + "offset_x, parsed_x, offset_y, parsed_y", + [ + [ + "-5.5%", + Scalar(-5.5, Unit.PERCENT, Unit.WIDTH), + "-30%", + Scalar(-30, Unit.PERCENT, Unit.HEIGHT), + ], + [ + "5%", + Scalar(5, Unit.PERCENT, Unit.WIDTH), + "40%", + Scalar(40, Unit.PERCENT, Unit.HEIGHT), + ], + [ + "-10", + Scalar(-10, Unit.CELLS, Unit.WIDTH), + "40", + Scalar(40, Unit.CELLS, Unit.HEIGHT), + ], + ], + ) + def test_separate_rules(self, offset_x, parsed_x, offset_y, parsed_y): + css = f"""#some-widget {{ + offset-x: {offset_x}; + offset-y: {offset_y}; + }} + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + + assert len(stylesheet.rules) == 1 + assert stylesheet.rules[0].errors == [] + assert styles.offset.x == parsed_x + assert styles.offset.y == parsed_y + + +class TestParseOverflow: + def test_multiple_enum(self): + css = "#some-widget { overflow: hidden auto; }" + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + + assert len(stylesheet.rules) == 1 + assert styles.overflow_x == "hidden" + assert styles.overflow_y == "auto" + + +class TestParseTransition: + @pytest.mark.parametrize( + "duration, parsed_duration", + [ + ["5.57s", 5.57], + ["0.5s", 0.5], + ["1200ms", 1.2], + ["0.5ms", 0.0005], + ["20", 20.0], + ["0.1", 0.1], + ], + ) + def test_various_duration_formats(self, duration, parsed_duration): + easing = "in_out_cubic" + transition_property = "offset" + delay = duration + css = f"""#some-widget {{ + transition: {transition_property} {duration} {easing} {delay}; + }} + """ + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + + assert len(stylesheet.rules) == 1 + assert stylesheet.rules[0].errors == [] + assert styles.transitions == { + "offset": Transition( + duration=parsed_duration, easing=easing, delay=parsed_duration + ) + } + + def test_no_delay_specified(self): + css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}" + stylesheet = Stylesheet() + stylesheet.add_source(css) + + styles = stylesheet.rules[0].styles + + assert stylesheet.rules[0].errors == [] + assert styles.transitions == { + "offset-x": Transition(duration=1, easing="in_out_cubic", delay=0) + } + + def test_unknown_easing_function(self): + invalid_func_name = "invalid_easing_function" + css = f"#some-widget {{ transition: offset 1 {invalid_func_name} 1; }}" + + stylesheet = Stylesheet() + with pytest.raises(StylesheetParseError) as ex: + stylesheet.add_source(css) + stylesheet.parse() + + rules = stylesheet._parse_rules(css, "foo") + stylesheet_errors = rules[0].errors + + assert len(stylesheet_errors) == 1 + assert stylesheet_errors[0][0].value == invalid_func_name + assert ex.value.errors is not None + + +class TestParseOpacity: + @pytest.mark.parametrize( + "css_value, styles_value", + [ + ["-0.2", 0.0], + ["0.4", 0.4], + ["1.3", 1.0], + ["-20%", 0.0], + ["25%", 0.25], + ["128%", 1.0], + ], + ) + def test_opacity_to_styles(self, css_value, styles_value): + css = f"#some-widget {{ text-opacity: {css_value} }}" + stylesheet = Stylesheet() + stylesheet.add_source(css) + + assert stylesheet.rules[0].styles.text_opacity == styles_value + assert not stylesheet.rules[0].errors + + def test_opacity_invalid_value(self): + css = "#some-widget { text-opacity: 123x }" + stylesheet = Stylesheet() + + with pytest.raises(StylesheetParseError): + stylesheet.add_source(css) + stylesheet.parse() + rules = stylesheet._parse_rules(css, "foo") + assert rules[0].errors + + +class TestParseMargin: + def test_margin_partial(self): + css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}" + stylesheet = Stylesheet() + stylesheet.add_source(css) + assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1) + + +class TestParsePadding: + def test_padding_partial(self): + css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}" + stylesheet = Stylesheet() + stylesheet.add_source(css) + assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1) + + +class TestParseTextAlign: + @pytest.mark.parametrize( + "valid_align", ["left", "start", "center", "right", "end", "justify"] + ) + def test_text_align(self, valid_align): + css = f"#foo {{ text-align: {valid_align} }}" + stylesheet = Stylesheet() + stylesheet.add_source(css) + assert stylesheet.rules[0].styles.text_align == valid_align + + def test_text_align_invalid(self): + css = "#foo { text-align: invalid-value; }" + stylesheet = Stylesheet() + with pytest.raises(StylesheetParseError): + stylesheet.add_source(css) + stylesheet.parse() + rules = stylesheet._parse_rules(css, "foo") + assert rules[0].errors + + def test_text_align_empty_uses_default(self): + css = "#foo { text-align: ; }" + stylesheet = Stylesheet() + stylesheet.add_source(css) + assert stylesheet.rules[0].styles.text_align == "start" diff --git a/tests/css/test_scalar.py b/tests/css/test_scalar.py new file mode 100644 index 000000000..c7add3d82 --- /dev/null +++ b/tests/css/test_scalar.py @@ -0,0 +1,19 @@ +from textual.css.scalar import Scalar, Unit + + +def test_copy_with_value(): + old = Scalar(1, Unit.HEIGHT, Unit.CELLS) + new = old.copy_with(value=2) + assert new == Scalar(2, Unit.HEIGHT, Unit.CELLS) + + +def test_copy_with_unit(): + old = Scalar(1, Unit.HEIGHT, Unit.CELLS) + new = old.copy_with(unit=Unit.WIDTH) + assert new == Scalar(1, Unit.WIDTH, Unit.CELLS) + + +def test_copy_with_percent_unit(): + old = Scalar(1, Unit.HEIGHT, Unit.CELLS) + new = old.copy_with(percent_unit=Unit.FRACTION) + assert new == Scalar(1, Unit.HEIGHT, Unit.FRACTION) diff --git a/tests/css/test_styles.py b/tests/css/test_styles.py new file mode 100644 index 000000000..a7c574ecc --- /dev/null +++ b/tests/css/test_styles.py @@ -0,0 +1,293 @@ +import sys +from decimal import Decimal + +if sys.version_info >= (3, 10): + from typing import Literal +else: # pragma: no cover + from typing_extensions import Literal + +import pytest + +from rich.style import Style + +from textual.app import ComposeResult +from textual.color import Color +from textual.css.errors import StyleValueError +from textual.css.scalar import Scalar, Unit +from textual.css.styles import Styles, RenderStyles +from textual.dom import DOMNode +from textual.widget import Widget + +from tests.utilities.test_app import AppTest + + +def test_styles_reset(): + styles = Styles() + styles.text_style = "not bold" + assert styles.text_style == Style(bold=False) + styles.reset() + assert styles.text_style is Style.null() + + +def test_has_rule(): + styles = Styles() + assert not styles.has_rule("text_style") + styles.text_style = "bold" + assert styles.has_rule("text_style") + styles.text_style = None + assert not styles.has_rule("text_style") + + +def test_clear_rule(): + styles = Styles() + styles.text_style = "bold" + assert styles.has_rule("text_style") + styles.clear_rule("text_style") + assert not styles.has_rule("text_style") + + +def test_get_rules(): + styles = Styles() + # Empty rules at start + assert styles.get_rules() == {} + styles.text_style = "bold" + assert styles.get_rules() == {"text_style": Style.parse("bold")} + styles.display = "none" + assert styles.get_rules() == { + "text_style": Style.parse("bold"), + "display": "none", + } + + +def test_set_rule(): + styles = Styles() + assert styles.get_rules() == {} + styles.set_rule("text_style", Style.parse("bold")) + assert styles.get_rules() == {"text_style": Style.parse("bold")} + + +def test_reset(): + styles = Styles() + assert styles.get_rules() == {} + styles.set_rule("text_style", Style.parse("bold")) + assert styles.get_rules() == {"text_style": Style.parse("bold")} + styles.reset() + assert styles.get_rules() == {} + + +def test_merge(): + styles = Styles() + styles.set_rule("text_style", Style.parse("bold")) + styles2 = Styles() + styles2.set_rule("display", "none") + styles.merge(styles2) + assert styles.get_rules() == { + "text_style": Style.parse("bold"), + "display": "none", + } + + +def test_merge_rules(): + styles = Styles() + styles.set_rule("text_style", Style.parse("bold")) + styles.merge_rules({"display": "none"}) + assert styles.get_rules() == { + "text_style": Style.parse("bold"), + "display": "none", + } + + +def test_render_styles_border(): + base = Styles() + inline = Styles() + styles_view = RenderStyles(None, base, inline) + + base.border_top = ("heavy", "red") + # Base has border-top: heavy red + assert styles_view.border_top == ("heavy", Color.parse("red")) + + inline.border_left = ("rounded", "green") + # Base has border-top heavy red, inline has border-left: rounded green + assert styles_view.border_top == ("heavy", Color.parse("red")) + assert styles_view.border_left == ("rounded", Color.parse("green")) + assert styles_view.border == ( + ("heavy", Color.parse("red")), + ("", Color(0, 255, 0)), + ("", Color(0, 255, 0)), + ("rounded", Color.parse("green")), + ) + + +def test_get_opacity_default(): + styles = RenderStyles(DOMNode(), Styles(), Styles()) + assert styles.text_opacity == 1.0 + + +def test_styles_css_property(): + css = "opacity: 50%; text-opacity: 20%; background: green; color: red; tint: dodgerblue 20%;" + styles = Styles().parse(css, path="") + assert styles.css == ( + "background: #008000;\n" + "color: #FF0000;\n" + "opacity: 0.5;\n" + "text-opacity: 0.2;\n" + "tint: rgba(30,144,255,0.2);" + ) + + +@pytest.mark.parametrize( + "set_value, expected", + [ + [0.2, 0.2], + [-0.4, 0.0], + [5.8, 1.0], + ["25%", 0.25], + ["-10%", 0.0], + ["120%", 1.0], + ], +) +def test_opacity_set_then_get(set_value, expected): + styles = RenderStyles(DOMNode(), Styles(), Styles()) + styles.text_opacity = set_value + assert styles.text_opacity == expected + + +def test_opacity_set_invalid_type_error(): + styles = RenderStyles(DOMNode(), Styles(), Styles()) + with pytest.raises(StyleValueError): + styles.text_opacity = "invalid value" + + +@pytest.mark.parametrize( + "size_dimension_input,size_dimension_expected_output", + [ + # fmt: off + [None, None], + [1, Scalar(1, Unit.CELLS, Unit.WIDTH)], + [1.0, Scalar(1.0, Unit.CELLS, Unit.WIDTH)], + [1.2, Scalar(1.2, Unit.CELLS, Unit.WIDTH)], + [1.2e3, Scalar(1200.0, Unit.CELLS, Unit.WIDTH)], + ["20", Scalar(20, Unit.CELLS, Unit.WIDTH)], + ["1.4", Scalar(1.4, Unit.CELLS, Unit.WIDTH)], + [Scalar(100, Unit.CELLS, Unit.WIDTH), Scalar(100, Unit.CELLS, Unit.WIDTH)], + [Scalar(10.3, Unit.CELLS, Unit.WIDTH), Scalar(10.3, Unit.CELLS, Unit.WIDTH)], + [Scalar(10.4, Unit.CELLS, Unit.HEIGHT), Scalar(10.4, Unit.CELLS, Unit.HEIGHT)], + [Scalar(10.5, Unit.PERCENT, Unit.WIDTH), Scalar(10.5, Unit.WIDTH, Unit.WIDTH)], + [Scalar(10.6, Unit.PERCENT, Unit.PERCENT), Scalar(10.6, Unit.WIDTH, Unit.WIDTH)], + [Scalar(10.7, Unit.HEIGHT, Unit.PERCENT), Scalar(10.7, Unit.HEIGHT, Unit.PERCENT)], + # percentage values are normalised to floats and get the WIDTH "percent_unit": + [Scalar(11, Unit.PERCENT, Unit.HEIGHT), Scalar(11.0, Unit.WIDTH, Unit.WIDTH)], + # fmt: on + ], +) +def test_widget_style_size_can_accept_various_data_types_and_normalize_them( + size_dimension_input, size_dimension_expected_output +): + widget = Widget() + + widget.styles.width = size_dimension_input + assert widget.styles.width == size_dimension_expected_output + + +@pytest.mark.parametrize( + "size_dimension_input", + [ + "a", + "1.4e3", + 3.14j, + Decimal("3.14"), + list(), + tuple(), + dict(), + ], +) +def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_input): + widget = Widget() + + with pytest.raises(StyleValueError): + widget.styles.width = size_dimension_input + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "overflow_y,scrollbar_gutter,scrollbar_size,text_length,expected_text_widget_width,expects_vertical_scrollbar", + ( + # ------------------------------------------------ + # ----- Let's start with `overflow-y: auto`: + # short text: full width, no scrollbar + ["auto", "auto", 1, "short_text", 80, False], + # long text: reduced width, scrollbar + ["auto", "auto", 1, "long_text", 78, True], + # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar + ["auto", "stable", 1, "short_text", 78, False], + # long text, `scrollbar-gutter: stable`: reduced width, scrollbar + ["auto", "stable", 1, "long_text", 78, True], + # ------------------------------------------------ + # ----- And now let's see the behaviour with `overflow-y: scroll`: + # short text: reduced width, scrollbar + ["scroll", "auto", 1, "short_text", 78, True], + # long text: reduced width, scrollbar + ["scroll", "auto", 1, "long_text", 78, True], + # short text, `scrollbar-gutter: stable`: reduced width, scrollbar + ["scroll", "stable", 1, "short_text", 78, True], + # long text, `scrollbar-gutter: stable`: reduced width, scrollbar + ["scroll", "stable", 1, "long_text", 78, True], + # ------------------------------------------------ + # ----- Finally, let's check the behaviour with `overflow-y: hidden`: + # short text: full width, no scrollbar + ["hidden", "auto", 1, "short_text", 80, False], + # long text: full width, no scrollbar + ["hidden", "auto", 1, "long_text", 80, False], + # short text, `scrollbar-gutter: stable`: reduced width, no scrollbar + ["hidden", "stable", 1, "short_text", 78, False], + # long text, `scrollbar-gutter: stable`: reduced width, no scrollbar + ["hidden", "stable", 1, "long_text", 78, False], + # ------------------------------------------------ + # ----- Bonus round with a custom scrollbar size, now that we can set this: + ["auto", "auto", 3, "short_text", 80, False], + ["auto", "auto", 3, "long_text", 77, True], + ["scroll", "auto", 3, "short_text", 77, True], + ["scroll", "stable", 3, "short_text", 77, True], + ["hidden", "auto", 3, "long_text", 80, False], + ["hidden", "stable", 3, "short_text", 77, False], + ), +) +async def test_scrollbar_gutter( + overflow_y: str, + scrollbar_gutter: str, + scrollbar_size: int, + text_length: Literal["short_text", "long_text"], + expected_text_widget_width: int, + expects_vertical_scrollbar: bool, +): + from rich.text import Text + from textual.geometry import Size + + class TextWidget(Widget): + def render(self) -> Text: + text_multiplier = 10 if text_length == "long_text" else 2 + return Text( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a." + * text_multiplier + ) + + container = Widget() + container.styles.height = 3 + container.styles.overflow_y = overflow_y + container.styles.scrollbar_gutter = scrollbar_gutter + if scrollbar_size > 1: + container.styles.scrollbar_size_vertical = scrollbar_size + + text_widget = TextWidget() + text_widget.styles.height = "auto" + container._add_child(text_widget) + + class MyTestApp(AppTest): + def compose(self) -> ComposeResult: + yield container + + app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10)) + await app.boot_and_shutdown() + + assert text_widget.outer_size.width == expected_text_widget_width + assert container.scrollbars_enabled[0] is expects_vertical_scrollbar diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py new file mode 100644 index 000000000..be54fe936 --- /dev/null +++ b/tests/css/test_stylesheet.py @@ -0,0 +1,250 @@ +from contextlib import nullcontext as does_not_raise +from typing import Any + +import pytest + +from textual.color import Color +from textual.css._help_renderables import HelpText +from textual.css.stylesheet import Stylesheet, StylesheetParseError, CssSource +from textual.css.tokenizer import TokenError +from textual.dom import DOMNode +from textual.geometry import Spacing +from textual.widget import Widget + + +def _make_user_stylesheet(css: str) -> Stylesheet: + stylesheet = Stylesheet() + stylesheet.source["test.css"] = CssSource(css, is_defaults=False) + stylesheet.parse() + return stylesheet + + +def test_stylesheet_apply_highest_specificity_wins(): + """#ids have higher specificity than .classes""" + css = "#id {color: red;} .class {color: blue;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(classes="class", id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(255, 0, 0) + + +def test_stylesheet_apply_doesnt_override_defaults(): + css = "#id {color: red;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.margin == Spacing.all(0) + assert node.styles.box_sizing == "border-box" + + +def test_stylesheet_apply_highest_specificity_wins_multiple_classes(): + """When we use two selectors containing only classes, then the selector + `.b.c` has greater specificity than the selector `.a`""" + css = ".b.c {background: blue;} .a {background: red; color: lime;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(classes="a b c") + stylesheet.apply(node) + + assert node.styles.background == Color(0, 0, 255) + assert node.styles.color == Color(0, 255, 0) + + +def test_stylesheet_many_classes_dont_overrule_id(): + """#id is further to the left in the specificity tuple than class, and + a selector containing multiple classes cannot take priority over even a + single class.""" + css = "#id {color: red;} .a.b.c.d {color: blue;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(classes="a b c d", id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(255, 0, 0) + + +def test_stylesheet_last_rule_wins_when_same_rule_twice_in_one_ruleset(): + css = "#id {color: red; color: blue;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 0, 255) + + +def test_stylesheet_rulesets_merged_for_duplicate_selectors(): + css = "#id {color: red; background: lime;} #id {color:blue;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(id="id") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 0, 255) + assert node.styles.background == Color(0, 255, 0) + + +def test_stylesheet_apply_takes_final_rule_in_specificity_clash(): + """.a and .b both contain background and have same specificity, so .b wins + since it was declared last - the background should be blue.""" + css = ".a {background: red; color: lime;} .b {background: blue;}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(classes="a b", id="c") + stylesheet.apply(node) + + assert node.styles.color == Color(0, 255, 0) # color: lime + assert node.styles.background == Color(0, 0, 255) # background: blue + + +def test_stylesheet_apply_empty_rulesets(): + """Ensure that we don't crash when working with empty rulesets""" + css = ".a {} .b {}" + stylesheet = _make_user_stylesheet(css) + node = DOMNode(classes="a b") + stylesheet.apply(node) + + +def test_stylesheet_apply_user_css_over_widget_css(): + user_css = ".a {color: red; tint: yellow;}" + + class MyWidget(Widget): + DEFAULT_CSS = ".a {color: blue !important; background: lime;}" + + node = MyWidget() + node.add_class("a") + + stylesheet = _make_user_stylesheet(user_css) + stylesheet.add_source( + MyWidget.DEFAULT_CSS, "widget.py:MyWidget", is_default_css=True + ) + stylesheet.apply(node) + + # The node is red because user CSS overrides Widget.DEFAULT_CSS + assert node.styles.color == Color(255, 0, 0) + # The background colour defined in the Widget still applies, since user CSS doesn't override it + assert node.styles.background == Color(0, 255, 0) + # As expected, the tint colour is yellow, since there's no competition between user or widget CSS + assert node.styles.tint == Color(255, 255, 0) + + +@pytest.mark.parametrize( + "css_value,expectation,expected_color", + [ + # Valid values: + ["transparent", does_not_raise(), Color(0, 0, 0, 0)], + ["ansi_red", does_not_raise(), Color(128, 0, 0)], + ["ansi_bright_magenta", does_not_raise(), Color(255, 0, 255)], + ["red", does_not_raise(), Color(255, 0, 0)], + ["lime", does_not_raise(), Color(0, 255, 0)], + ["coral", does_not_raise(), Color(255, 127, 80)], + ["aqua", does_not_raise(), Color(0, 255, 255)], + ["deepskyblue", does_not_raise(), Color(0, 191, 255)], + ["rebeccapurple", does_not_raise(), Color(102, 51, 153)], + ["#ffcc00", does_not_raise(), Color(255, 204, 0)], + ["#ffcc0033", does_not_raise(), Color(255, 204, 0, 0.2)], + ["rgb(200,90,30)", does_not_raise(), Color(200, 90, 30)], + ["rgba(200,90,30,0.3)", does_not_raise(), Color(200, 90, 30, 0.3)], + # Some invalid ones: + ["coffee", pytest.raises(StylesheetParseError), None], # invalid color name + ["ansi_dark_cyan", pytest.raises(StylesheetParseError), None], + ["red 4", pytest.raises(StylesheetParseError), None], # space in it + ["1", pytest.raises(StylesheetParseError), None], # invalid value + ["()", pytest.raises(TokenError), None], # invalid tokens + ], +) +def test_color_property_parsing(css_value, expectation, expected_color): + stylesheet = Stylesheet() + css = """ + * { + background: ${COLOR}; + } + """.replace( + "${COLOR}", css_value + ) + + with expectation: + stylesheet.add_source(css) + stylesheet.parse() + + if expected_color: + css_rule = stylesheet.rules[0] + assert css_rule.styles.background == expected_color + + +@pytest.mark.parametrize( + "css_property_name,expected_property_name_suggestion", + [ + ["backgroundu", "background"], + ["bckgroundu", "background"], + ["ofset-x", "offset-x"], + ["ofst_y", "offset-y"], + ["colr", "color"], + ["colour", "color"], + ["wdth", "width"], + ["wth", "width"], + ["wh", None], + ["xkcd", None], + ], +) +def test_did_you_mean_for_css_property_names( + css_property_name: str, expected_property_name_suggestion +): + stylesheet = Stylesheet() + css = """ + * { + border: blue; + ${PROPERTY}: red; + } + """.replace( + "${PROPERTY}", css_property_name + ) + + stylesheet.add_source(css) + with pytest.raises(StylesheetParseError) as err: + stylesheet.parse() + + _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText + displayed_css_property_name = css_property_name.replace("_", "-") + expected_summary = f"Invalid CSS property {displayed_css_property_name!r}" + if expected_property_name_suggestion: + expected_summary += f'. Did you mean "{expected_property_name_suggestion}"?' + assert help_text.summary == expected_summary + + +@pytest.mark.parametrize( + "css_property_name,css_property_value,expected_color_suggestion", + [ + ["color", "blu", "blue"], + ["background", "chartruse", "chartreuse"], + ["tint", "ansi_whi", "ansi_white"], + ["scrollbar-color", "transprnt", "transparent"], + ["color", "xkcd", None], + ], +) +def test_did_you_mean_for_color_names( + css_property_name: str, css_property_value: str, expected_color_suggestion +): + stylesheet = Stylesheet() + css = """ + * { + border: blue; + ${PROPERTY}: ${VALUE}; + } + """.replace( + "${PROPERTY}", css_property_name + ).replace( + "${VALUE}", css_property_value + ) + + stylesheet.add_source(css) + with pytest.raises(StylesheetParseError) as err: + stylesheet.parse() + + _, help_text = err.value.errors.rules[0].errors[0] # type: Any, HelpText + displayed_css_property_name = css_property_name.replace("_", "-") + expected_error_summary = ( + f"Invalid value for the [i]{displayed_css_property_name}[/] property" + ) + + if expected_color_suggestion is not None: + expected_error_summary += f'. Did you mean "{expected_color_suggestion}"?' + + assert help_text.summary == expected_error_summary diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py new file mode 100644 index 000000000..1e8593c2c --- /dev/null +++ b/tests/css/test_tokenize.py @@ -0,0 +1,779 @@ +from __future__ import annotations + +import pytest + +from textual.css.tokenize import tokenize +from textual.css.tokenizer import Token, TokenError + +VALID_VARIABLE_NAMES = [ + "warning-text", + "warning_text", + "warningtext1", + "1warningtext", + "WarningText1", + "warningtext_", + "warningtext-", + "_warningtext", + "-warningtext", +] + + +@pytest.mark.parametrize("name", VALID_VARIABLE_NAMES) +def test_variable_declaration_valid_names(name): + css = f"${name}: black on red;" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value=f"${name}:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 14), + referenced_by=None, + ), + Token( + name="token", + value="black", + path="", + code=css, + location=(0, 15), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 20), + referenced_by=None, + ), + Token( + name="token", + value="on", + path="", + code=css, + location=(0, 21), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 23), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 24), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 27), + referenced_by=None, + ), + ] + + +def test_variable_declaration_multiple_values(): + css = "$x: 2vw\t4% 6s red;" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="scalar", + value="2vw", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value="\t", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="scalar", + value="4%", + path="", + code=css, + location=(0, 8), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 10), + referenced_by=None, + ), + Token( + name="duration", + value="6s", + path="", + code=css, + location=(0, 11), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 13), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 15), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 18), + referenced_by=None, + ), + ] + + +def test_variable_declaration_comment_ignored(): + css = "$x: red; /* comment */" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 7), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 8), + referenced_by=None, + ), + ] + + +def test_variable_declaration_comment_interspersed_ignored(): + css = "$x: re/* comment */d;" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="token", + value="re", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="token", + value="d", + path="", + code=css, + location=(0, 19), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 20), + referenced_by=None, + ), + ] + + +def test_variable_declaration_no_semicolon(): + css = "$x: 1\n$y: 2" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="variable_name", + value="$y:", + path="", + code=css, + location=(1, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(1, 3), + referenced_by=None, + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(1, 4), + referenced_by=None, + ), + ] + + +def test_variable_declaration_invalid_value(): + css = "$x:(@$12x)" + with pytest.raises(TokenError): + list(tokenize(css, "")) + + +def test_variables_declarations_amongst_rulesets(): + css = "$x:1; .thing{text:red;} $y:2;" + tokens = list(tokenize(css, "")) + assert tokens == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="number", + value="1", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="selector_start_class", + value=".thing", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 12), + referenced_by=None, + ), + Token( + name="declaration_name", + value="text:", + path="", + code=css, + location=(0, 13), + referenced_by=None, + ), + Token( + name="token", + value="red", + path="", + code=css, + location=(0, 18), + referenced_by=None, + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(0, 21), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 22), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 23), + referenced_by=None, + ), + Token( + name="variable_name", + value="$y:", + path="", + code=css, + location=(0, 24), + referenced_by=None, + ), + Token( + name="number", + value="2", + path="", + code=css, + location=(0, 27), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 28), + referenced_by=None, + ), + ] + + +def test_variables_reference_in_rule_declaration_value(): + css = ".warn{text: $warning;}" + assert list(tokenize(css, "")) == [ + Token( + name="selector_start_class", + value=".warn", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="declaration_name", + value="text:", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 11), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$warning", + path="", + code=css, + location=(0, 12), + referenced_by=None, + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(0, 20), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 21), + referenced_by=None, + ), + ] + + +def test_variables_reference_in_rule_declaration_value_multiple(): + css = ".card{padding: $pad-y $pad-x;}" + assert list(tokenize(css, "")) == [ + Token( + name="selector_start_class", + value=".card", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=css, + location=(0, 5), + referenced_by=None, + ), + Token( + name="declaration_name", + value="padding:", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 14), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$pad-y", + path="", + code=css, + location=(0, 15), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 21), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$pad-x", + path="", + code=css, + location=(0, 22), + referenced_by=None, + ), + Token( + name="declaration_end", + value=";", + path="", + code=css, + location=(0, 28), + referenced_by=None, + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=css, + location=(0, 29), + referenced_by=None, + ), + ] + + +def test_variables_reference_in_variable_declaration(): + css = "$x: $y;" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$y", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="variable_value_end", + value=";", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + ] + + +def test_variable_references_in_variable_declaration_multiple(): + css = "$x: $y $z\n" + assert list(tokenize(css, "")) == [ + Token( + name="variable_name", + value="$x:", + path="", + code=css, + location=(0, 0), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 3), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$y", + path="", + code=css, + location=(0, 4), + referenced_by=None, + ), + Token( + name="whitespace", + value=" ", + path="", + code=css, + location=(0, 6), + referenced_by=None, + ), + Token( + name="variable_ref", + value="$z", + path="", + code=css, + location=(0, 8), + referenced_by=None, + ), + Token( + name="variable_value_end", + value="\n", + path="", + code=css, + location=(0, 10), + referenced_by=None, + ), + ] + + +def test_allow_new_lines(): + css = ".foo{margin: 1\n1 0 0}" + tokens = list(tokenize(css, "")) + print(repr(tokens)) + expected = [ + Token( + name="selector_start_class", + value=".foo", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 0), + ), + Token( + name="declaration_set_start", + value="{", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 4), + ), + Token( + name="declaration_name", + value="margin:", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 5), + ), + Token( + name="whitespace", + value=" ", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 12), + ), + Token( + name="number", + value="1", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 13), + ), + Token( + name="whitespace", + value="\n", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(0, 14), + ), + Token( + name="number", + value="1", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 0), + ), + Token( + name="whitespace", + value=" ", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 1), + ), + Token( + name="number", + value="0", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 2), + ), + Token( + name="whitespace", + value=" ", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 3), + ), + Token( + name="number", + value="0", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 4), + ), + Token( + name="declaration_set_end", + value="}", + path="", + code=".foo{margin: 1\n1 0 0}", + location=(1, 5), + ), + ] + assert list(tokenize(css, "")) == expected diff --git a/tests/devtools/__init__.py b/tests/devtools/__init__.py new file mode 100644 index 000000000..c668d5fd7 --- /dev/null +++ b/tests/devtools/__init__.py @@ -0,0 +1,16 @@ +import os +import sys + +import pytest + +_MACOS_CI = sys.platform == "darwin" and os.getenv("CI", "0") != "0" +_WINDOWS = sys.platform == "win32" + +# TODO - this needs to be revisited - perhaps when aiohttp 4.0 is released? +# We get occasional test failures relating to devtools. These *appear* to be limited to MacOS, +# and the error messages suggest the event loop is being shutdown before async fixture +# teardown code has finished running. These are very rare, but are much more of an issue on +# CI since they can delay builds that have passed locally. +pytestmark = pytest.mark.skipif( + _MACOS_CI or _WINDOWS, reason="Issue #411" +) diff --git a/tests/devtools/conftest.py b/tests/devtools/conftest.py new file mode 100644 index 000000000..5b24a0655 --- /dev/null +++ b/tests/devtools/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from textual.devtools.client import DevtoolsClient +from textual.devtools.server import _make_devtools_aiohttp_app +from textual.devtools.service import DevtoolsService + + +@pytest.fixture +async def server(aiohttp_server, unused_tcp_port): + app = _make_devtools_aiohttp_app( + size_change_poll_delay_secs=0.001, + ) + server = await aiohttp_server(app, port=unused_tcp_port) + service: DevtoolsService = app["service"] + yield server + await service.shutdown() + await server.close() + + +@pytest.fixture +async def devtools(aiohttp_client, server): + client = await aiohttp_client(server) + devtools = DevtoolsClient(host=client.host, port=client.port) + await devtools.connect() + yield devtools + await devtools.disconnect() + await client.close() diff --git a/tests/devtools/test_devtools.py b/tests/devtools/test_devtools.py new file mode 100644 index 000000000..944de3fca --- /dev/null +++ b/tests/devtools/test_devtools.py @@ -0,0 +1,96 @@ +from datetime import datetime + +import msgpack +import pytest +import time_machine +from rich.align import Align +from rich.console import Console +from rich.segment import Segment + +from tests.utilities.render import wait_for_predicate +from textual.devtools.renderables import DevConsoleLog, DevConsoleNotice + +TIMESTAMP = 1649166819 +WIDTH = 40 +# The string "Hello, world!" is encoded in the payload below +_EXAMPLE_LOG = { + "type": "client_log", + "payload": { + "segments": b"\x80\x04\x955\x00\x00\x00\x00\x00\x00\x00]\x94\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94a.", + "line_number": 123, + "path": "abc/hello.py", + "timestamp": TIMESTAMP, + }, +} +EXAMPLE_LOG = msgpack.packb(_EXAMPLE_LOG) + + +@pytest.fixture(scope="module") +def console(): + return Console(width=WIDTH) + + +@time_machine.travel(TIMESTAMP) +def test_log_message_render(console): + message = DevConsoleLog( + [Segment("content")], + path="abc/hello.py", + line_number=123, + unix_timestamp=TIMESTAMP, + group=0, + verbosity=0, + severity=0, + ) + table = next(iter(message.__rich_console__(console, console.options))) + + assert len(table.rows) == 1 + + columns = list(table.columns) + left_cells = list(columns[0].cells) + left = left_cells[0] + right_cells = list(columns[1].cells) + right: Align = right_cells[0] + + # Since we can't guarantee the timezone the tests will run in... + local_time = datetime.fromtimestamp(TIMESTAMP) + string_timestamp = local_time.time() + + assert left.plain == f"[{string_timestamp}] UNDEFINED" + assert right.align == "right" + assert "hello.py:123" in right.renderable + + +def test_internal_message_render(console): + message = DevConsoleNotice("hello") + rule = next(iter(message.__rich_console__(console, console.options))) + assert rule.title == "hello" + assert rule.characters == "โ”€" + + +async def test_devtools_valid_client_log(devtools): + await devtools.websocket.send_bytes(EXAMPLE_LOG) + assert devtools.is_connected + + +async def test_devtools_string_not_json_message(devtools): + await devtools.websocket.send_str("ABCDEFG") + assert devtools.is_connected + + +async def test_devtools_invalid_json_message(devtools): + await devtools.websocket.send_json({"invalid": "json"}) + assert devtools.is_connected + + +async def test_devtools_spillover_message(devtools): + await devtools.websocket.send_json( + {"type": "client_spillover", "payload": {"spillover": 123}} + ) + assert devtools.is_connected + + +async def test_devtools_console_size_change(server, devtools): + # Update the width of the console on the server-side + server.app["service"].console.width = 124 + # Wait for the client side to update the console on their end + await wait_for_predicate(lambda: devtools.console.width == 124) diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py new file mode 100644 index 000000000..1d7e8e8f7 --- /dev/null +++ b/tests/devtools/test_devtools_client.py @@ -0,0 +1,107 @@ +import json +import types +from asyncio import Queue +from datetime import datetime + +import msgpack +import time_machine +from aiohttp.web_ws import WebSocketResponse +from rich.console import ConsoleDimensions +from rich.panel import Panel + +from tests.utilities.render import wait_for_predicate +from textual.devtools.client import DevtoolsClient +from textual.devtools.redirect_output import DevtoolsLog + +CALLER_LINENO = 123 +CALLER_PATH = "a/b/c.py" +CALLER = types.SimpleNamespace(filename=CALLER_PATH, lineno=CALLER_LINENO) +TIMESTAMP = 1649166819 + + +def test_devtools_client_initialize_defaults(): + devtools = DevtoolsClient() + assert devtools.url == "ws://127.0.0.1:8081" + + +async def test_devtools_client_is_connected(devtools): + assert devtools.is_connected + + +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) +async def test_devtools_log_places_encodes_and_queues_message(devtools): + + await devtools._stop_log_queue_processing() + devtools.log(DevtoolsLog("Hello, world!", CALLER)) + queued_log = await devtools.log_queue.get() + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + + +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) +async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtools): + await devtools._stop_log_queue_processing() + devtools.log(DevtoolsLog(("hello", "world"), CALLER)) + queued_log = await devtools.log_queue.get() + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + assert queued_log_data == { + "type": "client_log", + "payload": { + "group": 0, + "verbosity": 0, + "timestamp": 1649166819, + "path": "a/b/c.py", + "line_number": 123, + "segments": b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", + }, + } + + +async def test_devtools_log_spillover(devtools): + # Give the devtools an intentionally small max queue size + await devtools._stop_log_queue_processing() + devtools.log_queue = Queue(maxsize=2) + + # Force spillover of 2 + devtools.log(DevtoolsLog((Panel("hello, world"),), CALLER)) + devtools.log(DevtoolsLog("second message", CALLER)) + devtools.log(DevtoolsLog("third message", CALLER)) # Discarded by rate-limiting + devtools.log(DevtoolsLog("fourth message", CALLER)) # Discarded by rate-limiting + + assert devtools.spillover == 2 + + # Consume log queue + while not devtools.log_queue.empty(): + await devtools.log_queue.get() + + # Add another message now that we're under spillover threshold + devtools.log(DevtoolsLog("another message", CALLER)) + await devtools.log_queue.get() + + # Ensure we're informing the server of spillover rate-limiting + spillover_message = await devtools.log_queue.get() + assert json.loads(spillover_message) == { + "type": "client_spillover", + "payload": {"spillover": 2}, + } + + +async def test_devtools_client_update_console_dimensions(devtools, server): + """Sending new server info through websocket from server to client should (eventually) + result in the dimensions of the devtools client console being updated to match. + """ + server_to_client: WebSocketResponse = next( + iter(server.app["service"].clients) + ).websocket + server_info = { + "type": "server_info", + "payload": { + "width": 123, + "height": 456, + }, + } + await server_to_client.send_json(server_info) + await wait_for_predicate( + lambda: devtools.console.size == ConsoleDimensions(123, 456) + ) diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py new file mode 100644 index 000000000..f3e253701 --- /dev/null +++ b/tests/devtools/test_redirect_output.py @@ -0,0 +1,108 @@ +from contextlib import redirect_stdout +from datetime import datetime + +import msgpack +import time_machine + +from textual.devtools.redirect_output import StdoutRedirector + +TIMESTAMP = 1649166819 + + +@time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) +async def test_print_redirect_to_devtools_only(devtools): + await devtools._stop_log_queue_processing() + + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + print("Hello, world!") + + assert devtools.log_queue.qsize() == 1 + + queued_log = await devtools.log_queue.get() + queued_log_data = msgpack.unpackb(queued_log) + print(repr(queued_log_data)) + payload = queued_log_data["payload"] + + assert queued_log_data["type"] == "client_log" + assert payload["timestamp"] == TIMESTAMP + assert ( + payload["segments"] + == b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e." + ) + + +async def test_print_redirect_to_logfile_only(devtools): + await devtools.disconnect() + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + print("Hello, world!") + + +async def test_print_redirect_to_devtools_and_logfile(devtools): + await devtools._stop_log_queue_processing() + + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + print("Hello, world!") + + assert devtools.log_queue.qsize() == 1 + + +@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +async def test_print_without_flush_not_sent_to_devtools(devtools): + await devtools._stop_log_queue_processing() + + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + # End is no longer newline character, so print will no longer + # flush the output buffer by default. + print("Hello, world!", end="") + + assert devtools.log_queue.qsize() == 0 + + +@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +async def test_print_forced_flush_sent_to_devtools(devtools): + await devtools._stop_log_queue_processing() + + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + print("Hello, world!", end="", flush=True) + + assert devtools.log_queue.qsize() == 1 + + +@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +async def test_print_multiple_args_batched_as_one_log(devtools): + await devtools._stop_log_queue_processing() + redirector = StdoutRedirector(devtools) + with redirect_stdout(redirector): # type: ignore + # This print adds 3 messages to the buffer that can be batched + print("The first", "batch", "of logs", end="") + # This message cannot be batched with the previous message, + # and so it will be the 2nd item added to the log queue. + print("I'm in the second batch") + + assert devtools.log_queue.qsize() == 2 + + +@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +async def test_print_strings_containing_newline_flushed(devtools): + await devtools._stop_log_queue_processing() + + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore + # Flushing is disabled since end="", but the first + # string will be flushed since it contains a newline + print("Hel\nlo", end="") + print("world", end="") + + assert devtools.log_queue.qsize() == 1 + + +@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) +async def test_flush_flushes_buffered_logs(devtools): + await devtools._stop_log_queue_processing() + + redirector = StdoutRedirector(devtools) + with redirect_stdout(redirector): # type: ignore + print("x", end="") + + assert devtools.log_queue.qsize() == 0 + redirector.flush() + assert devtools.log_queue.qsize() == 1 diff --git a/tests/layouts/test_common_layout_features.py b/tests/layouts/test_common_layout_features.py new file mode 100644 index 000000000..a660c15e8 --- /dev/null +++ b/tests/layouts/test_common_layout_features.py @@ -0,0 +1,28 @@ +import pytest + +from textual.screen import Screen +from textual.widget import Widget + + +@pytest.mark.parametrize( + "layout,display,expected_in_displayed_children", + [ + ("horizontal", "block", True), + ("vertical", "block", True), + ("horizontal", "none", False), + ("vertical", "none", False), + ], +) +def test_nodes_take_display_property_into_account_when_they_display_their_children( + layout: str, display: str, expected_in_displayed_children: bool +): + widget = Widget(name="widget that might not be visible ๐Ÿฅท ") + widget.styles.display = display + + screen = Screen() + screen.styles.layout = layout + screen._add_child(widget) + + displayed_children = screen.displayed_children + assert isinstance(displayed_children, list) + assert (widget in screen.displayed_children) is expected_in_displayed_children diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py new file mode 100644 index 000000000..bece5ab6a --- /dev/null +++ b/tests/layouts/test_factory.py @@ -0,0 +1,14 @@ +import pytest + +from textual.layouts.factory import get_layout, MissingLayout +from textual.layouts.vertical import VerticalLayout + + +def test_get_layout_valid_layout(): + layout = get_layout("vertical") + assert type(layout) is VerticalLayout + + +def test_get_layout_invalid_layout(): + with pytest.raises(MissingLayout): + get_layout("invalid") diff --git a/tests/layouts/test_horizontal.py b/tests/layouts/test_horizontal.py new file mode 100644 index 000000000..5a7aad388 --- /dev/null +++ b/tests/layouts/test_horizontal.py @@ -0,0 +1,46 @@ +from textual.geometry import Size +from textual.layouts.horizontal import HorizontalLayout +from textual.widget import Widget + + +class SizedWidget(Widget): + """Simple Widget wrapped allowing you to modify the return values for + get_content_width and get_content_height via the constructor.""" + + def __init__( + self, + *children: Widget, + content_width: int = 10, + content_height: int = 5, + ): + super().__init__(*children) + self.content_width = content_width + self.content_height = content_height + + def get_content_width(self, container: Size, viewport: Size) -> int: + return self.content_width + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + return self.content_height + + +CHILDREN = [ + SizedWidget(content_width=10, content_height=5), + SizedWidget(content_width=4, content_height=2), + SizedWidget(content_width=12, content_height=3), +] + + +def test_horizontal_get_content_width(): + parent = Widget(*CHILDREN) + layout = HorizontalLayout() + width = layout.get_content_width(widget=parent, container=Size(), viewport=Size()) + assert width == sum(child.content_width for child in CHILDREN) + + +def test_horizontal_get_content_width_no_children(): + parent = Widget() + layout = HorizontalLayout() + container_size = Size(24, 24) + width = layout.get_content_width(widget=parent, container=container_size, viewport=Size()) + assert width == container_size.width diff --git a/tests/renderables/__init__.py b/tests/renderables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/renderables/test_sparkline.py b/tests/renderables/test_sparkline.py new file mode 100644 index 000000000..74f1f2f6b --- /dev/null +++ b/tests/renderables/test_sparkline.py @@ -0,0 +1,41 @@ +from tests.utilities.render import render +from textual.renderables.sparkline import Sparkline + +GREEN = "\x1b[38;2;0;255;0m" +RED = "\x1b[38;2;255;0;0m" +BLENDED = "\x1b[38;2;127;127;0m" # Color between red and green +STOP = "\x1b[0m" + + +def test_sparkline_no_data(): + assert render(Sparkline([], width=4)) == f"{GREEN}โ–โ–โ–โ–{STOP}" + + +def test_sparkline_single_datapoint(): + assert render(Sparkline([2.5], width=4)) == f"{RED}โ–ˆโ–ˆโ–ˆโ–ˆ{STOP}" + + +def test_sparkline_two_values_min_max(): + assert render(Sparkline([2, 4], width=2)) == f"{GREEN}โ–{STOP}{RED}โ–ˆ{STOP}" + + +def test_sparkline_expand_data_to_width(): + assert render(Sparkline([2, 4], + width=4)) == f"{GREEN}โ–{STOP}{GREEN}โ–{STOP}{RED}โ–ˆ{STOP}{RED}โ–ˆ{STOP}" + + +def test_sparkline_expand_data_to_width_non_divisible(): + assert render(Sparkline([2, 4], width=3)) == f"{GREEN}โ–{STOP}{GREEN}โ–{STOP}{RED}โ–ˆ{STOP}" + + +def test_sparkline_shrink_data_to_width(): + assert render(Sparkline([2, 2, 4, 4, 6, 6], width=3)) == f"{GREEN}โ–{STOP}{BLENDED}โ–„{STOP}{RED}โ–ˆ{STOP}" + + +def test_sparkline_shrink_data_to_width_non_divisible(): + assert render( + Sparkline([1, 2, 3, 4, 5], width=3, summary_function=min)) == f"{GREEN}โ–{STOP}{BLENDED}โ–„{STOP}{RED}โ–ˆ{STOP}" + + +def test_sparkline_color_blend(): + assert render(Sparkline([1, 2, 3], width=3)) == f"{GREEN}โ–{STOP}{BLENDED}โ–„{STOP}{RED}โ–ˆ{STOP}" diff --git a/tests/renderables/test_text_opacity.py b/tests/renderables/test_text_opacity.py new file mode 100644 index 000000000..b543667ba --- /dev/null +++ b/tests/renderables/test_text_opacity.py @@ -0,0 +1,49 @@ +import pytest +from rich.text import Text + +from tests.utilities.render import render +from textual.renderables.text_opacity import TextOpacity + +STOP = "\x1b[0m" + + +@pytest.fixture +def text(): + return Text("Hello, world!", style="#ff0000 on #00ff00", end="") + + +def test_simple_text_opacity(text): + blended_red_on_green = "\x1b[38;2;127;127;0;48;2;0;255;0m" + assert render(TextOpacity(text, opacity=.5)) == ( + f"{blended_red_on_green}Hello, world!{STOP}" + ) + + +def test_value_zero_doesnt_render_the_text(text): + assert render(TextOpacity(text, opacity=0)) == ( + f"\x1b[48;2;0;255;0m {STOP}" + ) + + +def test_text_opacity_value_of_one_noop(text): + assert render(TextOpacity(text, opacity=1)) == render(text) + + +def test_ansi_colors_noop(): + ansi_colored_text = Text("Hello, world!", style="red on green", end="") + assert render(TextOpacity(ansi_colored_text, opacity=.5)) == render(ansi_colored_text) + + +def test_text_opacity_no_style_noop(): + text_no_style = Text("Hello, world!", end="") + assert render(TextOpacity(text_no_style, opacity=.2)) == render(text_no_style) + + +def test_text_opacity_only_fg_noop(): + text_only_fg = Text("Hello, world!", style="#ff0000", end="") + assert render(TextOpacity(text_only_fg, opacity=.5)) == render(text_only_fg) + + +def test_text_opacity_only_bg_noop(): + text_only_bg = Text("Hello, world!", style="on #ff0000", end="") + assert render(TextOpacity(text_only_bg, opacity=.5)) == render(text_only_bg) diff --git a/tests/renderables/test_tint.py b/tests/renderables/test_tint.py new file mode 100644 index 000000000..52e54aa60 --- /dev/null +++ b/tests/renderables/test_tint.py @@ -0,0 +1,17 @@ +import io + +from rich.console import Console +from rich.text import Text + +from textual.color import Color +from textual.renderables.tint import Tint + + +def test_tint(): + console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor") + renderable = Text.from_markup("[#aabbcc on #112233]foo") + console.print(Tint(renderable, Color(0, 100, 0, 0.5))) + output = console.file.getvalue() + print(repr(output)) + expected = "\x1b[38;2;85;143;102;48;2;8;67;25mfoo\x1b[0m\n" + assert output == expected diff --git a/tests/renderables/test_underline_bar.py b/tests/renderables/test_underline_bar.py new file mode 100644 index 000000000..549b331e3 --- /dev/null +++ b/tests/renderables/test_underline_bar.py @@ -0,0 +1,143 @@ +from unittest.mock import create_autospec + +from rich.console import Console +from rich.console import ConsoleOptions +from rich.text import Text + +from tests.utilities.render import render +from textual.renderables.underline_bar import UnderlineBar + +MAGENTA = "\x1b[35m" +GREY = "\x1b[38;5;59m" +STOP = "\x1b[0m" +GREEN = "\x1b[32m" +RED = "\x1b[31m" + + +def test_no_highlight(): + bar = UnderlineBar(width=6) + assert render(bar) == f"{GREY}โ”โ”โ”โ”โ”โ”{STOP}" + + +def test_highlight_from_zero(): + bar = UnderlineBar(highlight_range=(0, 2.5), width=6) + assert render(bar) == ( + f"{MAGENTA}โ”โ”{STOP}{MAGENTA}โ•ธ{STOP}{GREY}โ”โ”โ”{STOP}" + ) + + +def test_highlight_from_zero_point_five(): + bar = UnderlineBar(highlight_range=(0.5, 2), width=6) + assert render(bar) == ( + f"{MAGENTA}โ•บโ”{STOP}{GREY}โ•บ{STOP}{GREY}โ”โ”โ”{STOP}" + ) + + +def test_highlight_middle(): + bar = UnderlineBar(highlight_range=(2, 4), width=6) + assert render(bar) == ( + f"{GREY}โ”{STOP}" + f"{GREY}โ•ธ{STOP}" + f"{MAGENTA}โ”โ”{STOP}" + f"{GREY}โ•บ{STOP}" + f"{GREY}โ”{STOP}" + ) + + +def test_highlight_half_start(): + bar = UnderlineBar(highlight_range=(2.5, 4), width=6) + assert render(bar) == ( + f"{GREY}โ”โ”{STOP}" + f"{MAGENTA}โ•บโ”{STOP}" + f"{GREY}โ•บ{STOP}" + f"{GREY}โ”{STOP}" + ) + + +def test_highlight_half_end(): + bar = UnderlineBar(highlight_range=(2, 4.5), width=6) + assert render(bar) == ( + f"{GREY}โ”{STOP}" + f"{GREY}โ•ธ{STOP}" + f"{MAGENTA}โ”โ”{STOP}" + f"{MAGENTA}โ•ธ{STOP}" + f"{GREY}โ”{STOP}" + ) + + +def test_highlight_half_start_and_half_end(): + bar = UnderlineBar(highlight_range=(2.5, 4.5), width=6) + assert render(bar) == ( + f"{GREY}โ”โ”{STOP}" + f"{MAGENTA}โ•บโ”{STOP}" + f"{MAGENTA}โ•ธ{STOP}" + f"{GREY}โ”{STOP}" + ) + + +def test_highlight_to_near_end(): + bar = UnderlineBar(highlight_range=(3, 5.5), width=6) + assert render(bar) == ( + f"{GREY}โ”โ”{STOP}" + f"{GREY}โ•ธ{STOP}" + f"{MAGENTA}โ”โ”{STOP}" + f"{MAGENTA}โ•ธ{STOP}" + ) + + +def test_highlight_to_end(): + bar = UnderlineBar(highlight_range=(3, 6), width=6) + assert render(bar) == ( + f"{GREY}โ”โ”{STOP}{GREY}โ•ธ{STOP}{MAGENTA}โ”โ”โ”{STOP}" + ) + + +def test_highlight_out_of_bounds_start(): + bar = UnderlineBar(highlight_range=(-2, 3), width=6) + assert render(bar) == ( + f"{MAGENTA}โ”โ”โ”{STOP}{GREY}โ•บ{STOP}{GREY}โ”โ”{STOP}" + ) + + +def test_highlight_out_of_bounds_end(): + bar = UnderlineBar(highlight_range=(3, 9), width=6) + assert render(bar) == ( + f"{GREY}โ”โ”{STOP}{GREY}โ•ธ{STOP}{MAGENTA}โ”โ”โ”{STOP}" + ) + + +def test_highlight_full_range_out_of_bounds_end(): + bar = UnderlineBar(highlight_range=(9, 10), width=6) + assert render(bar) == f"{GREY}โ”โ”โ”โ”โ”โ”{STOP}" + + +def test_highlight_full_range_out_of_bounds_start(): + bar = UnderlineBar(highlight_range=(-5, -2), width=6) + assert render(bar) == f"{GREY}โ”โ”โ”โ”โ”โ”{STOP}" + + +def test_custom_styles(): + bar = UnderlineBar(highlight_range=(2, 4), highlight_style="red", background_style="green", width=6) + assert render(bar) == ( + f"{GREEN}โ”{STOP}" + f"{GREEN}โ•ธ{STOP}" + f"{RED}โ”โ”{STOP}" + f"{GREEN}โ•บ{STOP}" + f"{GREEN}โ”{STOP}" + ) + + +def test_clickable_ranges(): + bar = UnderlineBar(highlight_range=(0, 1), width=6, clickable_ranges={"foo": (0, 2), "bar": (4, 5)}) + + console = create_autospec(Console) + options = create_autospec(ConsoleOptions) + text: Text = list(bar.__rich_console__(console, options))[0] + + start, end, style = text.spans[-2] + assert (start, end) == (0, 2) + assert style.meta == {'@click': "range_clicked('foo')"} + + start, end, style = text.spans[-1] + assert (start, end) == (4, 5) + assert style.meta == {'@click': "range_clicked('bar')"} diff --git a/tests/snapshot_tests/__init__.py b/tests/snapshot_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr new file mode 100644 index 000000000..e5ffd0b4e --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -0,0 +1,1254 @@ +# name: test_checkboxes + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CheckboxApp + + + + + + + + + + + + + + Example checkboxes + + + โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Ž + off:     โ–Šโ–Ž + โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–Ž + โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Ž + on:      โ–Šโ–Ž + โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–Ž + โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Ž + focused: โ–Šโ–Ž + โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–Ž + โ–Šโ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–”โ–Ž + custom:  โ–Šโ–Ž + โ–Šโ–โ–โ–โ–โ–โ–โ–โ–โ–Ž + + + + + + + + + + ''' +# --- +# name: test_dock_layout_sidebar + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DockLayoutExample + + + + + + + + + + Sidebar1Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + โ–‡โ–‡ + Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + + Docking a widget removes it from the layout and  + fixes its position, aligned to either the top,  + right, bottom, or left edges of a container. + + Docked widgets will not scroll out of view,  + making them ideal for sticky headers, footers,  + and sidebars. + + + + + + ''' +# --- +# name: test_grid_layout_basic + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GridLayoutExample + + + + + + + + + + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Oneโ”‚โ”‚Twoโ”‚โ”‚Threeโ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Fourโ”‚โ”‚Fiveโ”‚โ”‚Sixโ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + ''' +# --- +# name: test_grid_layout_basic_overflow + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GridLayoutExample + + + + + + + + + + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Oneโ”‚โ”‚Twoโ”‚โ”‚Threeโ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Fourโ”‚โ”‚Fiveโ”‚โ”‚Sixโ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Sevenโ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + ''' +# --- +# name: test_grid_layout_gutter + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GridLayoutExample + + + + + + + + + + OneTwoThree + + + + + + + + + + + + FourFiveSix + + + + + + + + + + + + + + + + ''' +# --- +# name: test_horizontal_layout + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HorizontalLayoutExample + + + + + + + + + + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Oneโ”‚โ”‚Twoโ”‚โ”‚Threeโ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + ''' +# --- +# name: test_layers + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LayersExample + + + + + + + + + + + + + + + + + + + + + box1 (layer = above) + + + + + + box2 (layer = below) + + + + + + + + + + + ''' +# --- +# name: test_vertical_layout + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VerticalLayoutExample + + + + + + + + + + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Oneโ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Twoโ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚Threeโ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ”‚โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + + + + ''' +# --- diff --git a/tests/snapshot_tests/conftest.py b/tests/snapshot_tests/conftest.py new file mode 100644 index 000000000..79b5584b1 --- /dev/null +++ b/tests/snapshot_tests/conftest.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import difflib +import os +from dataclasses import dataclass +from datetime import datetime +from operator import attrgetter +from os import PathLike +from pathlib import Path +from typing import Union, List, Optional, Callable, Iterable + +import pytest +from _pytest.config import ExitCode +from _pytest.fixtures import FixtureRequest +from _pytest.main import Session +from _pytest.terminal import TerminalReporter +from jinja2 import Template +from rich.console import Console +from syrupy import SnapshotAssertion + +from textual._doc import take_svg_screenshot +from textual._import_app import import_app +from textual.app import App + +TEXTUAL_SNAPSHOT_SVG_KEY = pytest.StashKey[str]() +TEXTUAL_ACTUAL_SVG_KEY = pytest.StashKey[str]() +TEXTUAL_SNAPSHOT_PASS = pytest.StashKey[bool]() +TEXTUAL_APP_KEY = pytest.StashKey[App]() + + +@pytest.fixture +def snap_compare( + snapshot: SnapshotAssertion, request: FixtureRequest +) -> Callable[[str], bool]: + """ + This fixture returns a function which can be used to compare the output of a Textual + app with the output of the same app in the past. This is snapshot testing, and it + used to catch regressions in output. + """ + + def compare( + app_path: str, + press: Iterable[str] = ("_",), + terminal_size: tuple[int, int] = (24, 80), + ) -> bool: + """ + Compare a current screenshot of the app running at app_path, with + a previously accepted (validated by human) snapshot stored on disk. + When the `--snapshot-update` flag is supplied (provided by syrupy), + the snapshot on disk will be updated to match the current screenshot. + + Args: + app_path (str): The path of the app. + press (Iterable[str]): Key presses to run before taking screenshot. "_" is a short pause. + terminal_size (tuple[int, int]): A pair of integers (rows, columns), representing terminal size. + + Returns: + bool: True if the screenshot matches the snapshot. + """ + node = request.node + app = import_app(app_path) + actual_screenshot = take_svg_screenshot( + app=app, + press=press, + terminal_size=terminal_size, + ) + result = snapshot == actual_screenshot + + if result is False: + # The split and join below is a mad hack, sorry... + node.stash[TEXTUAL_SNAPSHOT_SVG_KEY] = "\n".join(str(snapshot).splitlines()[1:-1]) + node.stash[TEXTUAL_ACTUAL_SVG_KEY] = actual_screenshot + node.stash[TEXTUAL_APP_KEY] = app + else: + node.stash[TEXTUAL_SNAPSHOT_PASS] = True + + return result + + return compare + + +@dataclass +class SvgSnapshotDiff: + """Model representing a diff between current screenshot of an app, + and the snapshot on disk. This is ultimately intended to be used in + a Jinja2 template.""" + snapshot: Optional[str] + actual: Optional[str] + test_name: str + file_similarity: float + path: PathLike + line_number: int + app: App + environment: dict + + +def pytest_sessionfinish( + session: Session, + exitstatus: Union[int, ExitCode], +) -> None: + """Called after whole test run finished, right before returning the exit status to the system. + Generates the snapshot report and writes it to disk. + """ + diffs: List[SvgSnapshotDiff] = [] + num_snapshots_passing = 0 + for item in session.items: + + # Grab the data our fixture attached to the pytest node + num_snapshots_passing += int(item.stash.get(TEXTUAL_SNAPSHOT_PASS, False)) + snapshot_svg = item.stash.get(TEXTUAL_SNAPSHOT_SVG_KEY, None) + actual_svg = item.stash.get(TEXTUAL_ACTUAL_SVG_KEY, None) + app = item.stash.get(TEXTUAL_APP_KEY, None) + + if snapshot_svg and actual_svg and app: + path, line_index, name = item.reportinfo() + diffs.append( + SvgSnapshotDiff( + snapshot=str(snapshot_svg), + actual=str(actual_svg), + file_similarity=100 + * difflib.SequenceMatcher( + a=str(snapshot_svg), b=str(actual_svg) + ).ratio(), + test_name=name, + path=path, + line_number=line_index + 1, + app=app, + environment=dict(os.environ), + ) + ) + + if diffs: + diff_sort_key = attrgetter("file_similarity") + diffs = sorted(diffs, key=diff_sort_key) + + conftest_path = Path(__file__) + snapshot_template_path = ( + conftest_path.parent / "snapshot_report_template.jinja2" + ) + snapshot_report_path_dir = conftest_path.parent / "output" + snapshot_report_path_dir.mkdir(parents=True, exist_ok=True) + snapshot_report_path = snapshot_report_path_dir / "snapshot_report.html" + + template = Template(snapshot_template_path.read_text()) + + num_fails = len(diffs) + num_snapshot_tests = len(diffs) + num_snapshots_passing + + rendered_report = template.render( + diffs=diffs, + passes=num_snapshots_passing, + fails=num_fails, + pass_percentage=100 * (num_snapshots_passing / max(num_snapshot_tests, 1)), + fail_percentage=100 * (num_fails / max(num_snapshot_tests, 1)), + num_snapshot_tests=num_snapshot_tests, + now=datetime.utcnow(), + ) + with open(snapshot_report_path, "w+", encoding="utf-8") as snapshot_file: + snapshot_file.write(rendered_report) + + session.config._textual_snapshots = diffs + session.config._textual_snapshot_html_report = snapshot_report_path + + +def pytest_terminal_summary( + terminalreporter: TerminalReporter, + exitstatus: ExitCode, + config: pytest.Config, +) -> None: + """Add a section to terminal summary reporting. + Displays the link to the snapshot report that was generated in a prior hook. + """ + diffs = getattr(config, "_textual_snapshots", None) + console = Console() + if diffs: + snapshot_report_location = config._textual_snapshot_html_report + console.rule("[b red]Textual Snapshot Report", style="red") + console.print(f"\n[black on red]{len(diffs)} mismatched snapshots[/]\n" + f"\n[b]View the [link=file://{snapshot_report_location}]failure report[/].\n") + console.print(f"[dim]{snapshot_report_location}\n") + console.rule(style="red") diff --git a/tests/snapshot_tests/snapshot_report_template.jinja2 b/tests/snapshot_tests/snapshot_report_template.jinja2 new file mode 100644 index 000000000..d1f7b2530 --- /dev/null +++ b/tests/snapshot_tests/snapshot_report_template.jinja2 @@ -0,0 +1,197 @@ + + + + + + Textual Snapshot Test Report + + + + + +
+
+
+

+ Textual Snapshot Tests +

+ Showing diffs for {{ fails }} mismatched snapshot(s) +
+
+
+ + {{ diffs | length }} snapshots changed + + ยท + + {{ passes }} snapshots matched + +
+
+
+
+
+
+
+ + {% for diff in diffs %} +
+
+
+
+ + {{ diff.test_name }} + + {{ diff.path }}:{{ diff.line_number }} + + +
+ + +
+
+
+
+
+ {{ diff.actual }} +
+ Output from test (More info) +
+
+
+
+ +
+
+ {{ diff.snapshot }} +
+
+ Historical snapshot +
+
+
+
+
+ + + {# Modal with debug info: #} + +
+
+ {% endfor %} + +
+
+
+
+

If you're happy with the test output, run pytest with the --snapshot-update flag to update the snapshot. +

+
+
+
+
+ +
+
+
+

Report generated at UTC {{ now }}.

+
+
+
+ +
+ + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py new file mode 100644 index 000000000..72249c3ed --- /dev/null +++ b/tests/snapshot_tests/test_snapshots.py @@ -0,0 +1,32 @@ +def test_grid_layout_basic(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout1.py") + + +def test_grid_layout_basic_overflow(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout2.py") + + +def test_grid_layout_gutter(snap_compare): + assert snap_compare("docs/examples/guide/layout/grid_layout7_gutter.py") + + +def test_layers(snap_compare): + assert snap_compare("docs/examples/guide/layout/layers.py") + + +def test_horizontal_layout(snap_compare): + assert snap_compare("docs/examples/guide/layout/horizontal_layout.py") + + +def test_vertical_layout(snap_compare): + assert snap_compare("docs/examples/guide/layout/vertical_layout.py") + + +def test_dock_layout_sidebar(snap_compare): + assert snap_compare("docs/examples/guide/layout/dock_layout2_sidebar.py") + + +def test_checkboxes(snap_compare): + """Tests checkboxes but also acts a regression test for using + width: auto in a Horizontal layout context.""" + assert snap_compare("docs/examples/widgets/checkbox.py") diff --git a/tests/test_animator.py b/tests/test_animator.py new file mode 100644 index 000000000..a92f38c96 --- /dev/null +++ b/tests/test_animator.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import Mock + +import pytest + +from textual._animator import Animator, SimpleAnimation +from textual._easing import EASING, DEFAULT_EASING + + +class Animatable: + """An animatable object.""" + + def __init__(self, value): + self.value = value + + def blend(self, destination: Animatable, factor: float) -> Animatable: + return Animatable(self.value + (destination.value - self.value) * factor) + + +@dataclass +class AnimateTest: + """An object with animatable properties.""" + + foo: float | None = 0.0 # Plain float that may be set to None on final_value + bar: Animatable = Animatable(0) # A mock object supporting the animatable protocol + + +def test_simple_animation(): + """Test an animation from one float to another.""" + + # Thing that may be animated + animate_test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_test, + "foo", + time, + 3.0, + start_value=20.0, + end_value=50.0, + final_value=None, + easing=lambda x: x, + ) + + assert animate_test.foo == 0.0 + + assert animation(time) is False + assert animate_test.foo == 20.0 + + assert animation(time + 1.0) is False + assert animate_test.foo == 30.0 + + assert animation(time + 2.0) is False + assert animate_test.foo == 40.0 + + assert animation(time + 2.9) is False # Not quite final value + assert animate_test.foo == pytest.approx(49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animate_test.foo is None # This is final_value + + assert animation(time + 3.0) is True + assert animate_test.foo is None + + +def test_simple_animation_duration_zero(): + """Test animation handles duration of 0.""" + + # Thing that may be animated + animatable = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animatable, + "foo", + time, + 0.0, + start_value=20.0, + end_value=50.0, + final_value=50.0, + easing=lambda x: x, + ) + + assert animation(time) is True # Duration is 0, so this is last value + assert animatable.foo == 50.0 + + assert animation(time + 1.0) is True + assert animatable.foo == 50.0 + + +def test_simple_animation_reverse(): + """Test an animation from one float to another, where the end value is less than the start.""" + + # Thing that may be animated + animate_Test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_Test, + "foo", + time, + 3.0, + start_value=50.0, + end_value=20.0, + final_value=20.0, + easing=lambda x: x, + ) + + assert animation(time) is False + assert animate_Test.foo == 50.0 + + assert animation(time + 1.0) is False + assert animate_Test.foo == 40.0 + + assert animation(time + 2.0) is False + assert animate_Test.foo == 30.0 + + assert animation(time + 3.0) is True + assert animate_Test.foo == 20.0 + + +def test_animatable(): + """Test SimpleAnimation works with the Animatable protocol""" + + animate_test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_test, + "bar", + time, + 3.0, + start_value=Animatable(20.0), + end_value=Animatable(50.0), + final_value=Animatable(50.0), + easing=lambda x: x, + ) + + assert animation(time) is False + assert animate_test.bar.value == 20.0 + + assert animation(time + 1.0) is False + assert animate_test.bar.value == 30.0 + + assert animation(time + 2.0) is False + assert animate_test.bar.value == 40.0 + + assert animation(time + 2.9) is False + assert animate_test.bar.value == pytest.approx(49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animate_test.bar.value == 50.0 + + +class MockAnimator(Animator): + """A mock animator.""" + + def __init__(self, *args) -> None: + super().__init__(*args) + self._time = 0.0 + self._on_animation_frame_called = False + + def on_animation_frame(self): + self._on_animation_frame_called = True + + def _get_time(self): + return self._time + + +async def test_animator(): + target = Mock() + animator = MockAnimator(target) + animate_test = AnimateTest() + + # Animate attribute "foo" on animate_test to 100.0 in 10 seconds + animator.animate(animate_test, "foo", 100.0, duration=10.0) + + expected = SimpleAnimation( + animate_test, + "foo", + 0.0, + duration=10.0, + start_value=0.0, + end_value=100.0, + final_value=100.0, + easing=EASING[DEFAULT_EASING], + ) + assert animator._animations[(id(animate_test), "foo")] == expected + assert not animator._on_animation_frame_called + + await animator() + assert animate_test.foo == 0 + + animator._time = 5 + await animator() + assert animate_test.foo == 50 + + # New animation in the middle of an existing one + animator.animate(animate_test, "foo", 200, duration=1) + assert animate_test.foo == 50 + + animator._time = 6 + await animator() + assert animate_test.foo == 200 + + +def test_bound_animator(): + target = Mock() + animator = MockAnimator(target) + animate_test = AnimateTest() + + # Bind an animator so it animates attributes on the given object + bound_animator = animator.bind(animate_test) + + # Animate attribute "foo" on animate_test to 100.0 in 10 seconds + bound_animator("foo", 100.0, duration=10) + + expected = SimpleAnimation( + animate_test, + "foo", + 0, + duration=10, + start_value=0, + end_value=100, + final_value=100, + easing=EASING[DEFAULT_EASING], + ) + assert animator._animations[(id(animate_test), "foo")] == expected + + +async def test_animator_on_complete_callback_not_fired_before_duration_ends(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 9 + await animator() + + assert not callback.called + + +async def test_animator_on_complete_callback_fired_at_duration(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 10 + await animator() + + callback.assert_called_once_with() diff --git a/tests/test_arrange.py b/tests/test_arrange.py new file mode 100644 index 000000000..580d68e02 --- /dev/null +++ b/tests/test_arrange.py @@ -0,0 +1,93 @@ +from textual._arrange import arrange, TOP_Z +from textual._layout import WidgetPlacement +from textual.geometry import Region, Size, Spacing +from textual.widget import Widget + + +def test_arrange_empty(): + container = Widget(id="container") + + placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) + assert placements == [] + assert widgets == set() + assert spacing == Spacing(0, 0, 0, 0) + + +def test_arrange_dock_top(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "top" + header.styles.height = "1" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement( + Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), + ] + assert widgets == {child, header} + assert spacing == Spacing(1, 0, 0, 0) + + +def test_arrange_dock_left(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "left" + header.styles.width = "10" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement( + Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 0, 0, 10) + + +def test_arrange_dock_right(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "right" + header.styles.width = "10" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement( + Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 10, 0, 0) + + +def test_arrange_dock_bottom(): + container = Widget(id="container") + child = Widget(id="child") + header = Widget(id="header") + header.styles.dock = "bottom" + header.styles.height = "1" + + placements, widgets, spacing = arrange( + container, [child, header], Size(80, 24), Size(80, 24) + ) + assert placements == [ + WidgetPlacement( + Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), + ] + assert widgets == {child, header} + assert spacing == Spacing(0, 0, 1, 0) diff --git a/tests/test_auto_refresh.py b/tests/test_auto_refresh.py new file mode 100644 index 000000000..d5122a7a4 --- /dev/null +++ b/tests/test_auto_refresh.py @@ -0,0 +1,28 @@ +from time import time + +from textual.app import App + + +class RefreshApp(App[float]): + def __init__(self): + self.count = 0 + super().__init__() + + def on_mount(self): + self.start = time() + self.auto_refresh = 0.1 + + def _automatic_refresh(self): + self.count += 1 + if self.count == 3: + self.exit(time() - self.start) + super()._automatic_refresh() + + +def test_auto_refresh(): + app = RefreshApp() + + elapsed = app.run(quit_after=1, headless=True) + assert elapsed is not None + # CI can run slower, so we need to give this a bit of margin + assert 0.2 <= elapsed < 0.8 diff --git a/tests/test_binding.py b/tests/test_binding.py new file mode 100644 index 000000000..d2d61ac18 --- /dev/null +++ b/tests/test_binding.py @@ -0,0 +1,31 @@ +import pytest + +from textual.binding import Bindings, Binding + +BINDING1 = Binding("a,b", action="action1", description="description1") +BINDING2 = Binding("c", action="action2", description="description2") + + +@pytest.fixture +def bindings(): + yield Bindings([BINDING1, BINDING2]) + + +def test_bindings_get_key(bindings): + assert bindings.get_key("b") == Binding("b", action="action1", description="description1") + assert bindings.get_key("c") == BINDING2 + + +def test_bindings_merge_simple(bindings): + left = Bindings([BINDING1]) + right = Bindings([BINDING2]) + assert Bindings.merge([left, right]).keys == bindings.keys + + +def test_bindings_merge_overlap(): + left = Bindings([BINDING1]) + another_binding = Binding("a", action="another_action", description="another_description") + assert Bindings.merge([left, Bindings([another_binding])]).keys == { + "a": another_binding, + "b": Binding("b", action="action1", description="description1"), + } diff --git a/tests/test_border.py b/tests/test_border.py new file mode 100644 index 000000000..852e713ba --- /dev/null +++ b/tests/test_border.py @@ -0,0 +1,25 @@ +from rich.segment import Segment +from rich.style import Style + +from textual._border import render_row + + +def test_border_render_row(): + + style = Style.parse("red") + row = (Segment("โ”", style), Segment("โ”", style), Segment("โ”“", style)) + + assert render_row(row, 5, False, False) == [Segment(row[1].text * 5, row[1].style)] + assert render_row(row, 5, True, False) == [ + row[0], + Segment(row[1].text * 4, row[1].style), + ] + assert render_row(row, 5, False, True) == [ + Segment(row[1].text * 4, row[1].style), + row[2], + ] + assert render_row(row, 5, True, True) == [ + row[0], + Segment(row[1].text * 3, row[1].style), + row[2], + ] diff --git a/tests/test_box_model.py b/tests/test_box_model.py new file mode 100644 index 000000000..1c82620ff --- /dev/null +++ b/tests/test_box_model.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from fractions import Fraction + +from textual.box_model import BoxModel, get_box_model +from textual.css.styles import Styles +from textual.geometry import Size, Spacing + + +def test_content_box(): + styles = Styles() + styles.width = 10 + styles.height = 8 + styles.padding = 1 + styles.border = ("solid", "red") + + one = Fraction(1) + + # border-box is default + assert styles.box_sizing == "border-box" + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + # Size should be inclusive of padding / border + assert box_model == BoxModel(Fraction(10), Fraction(8), Spacing(0, 0, 0, 0)) + + # Switch to content-box + styles.box_sizing = "content-box" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + # width and height have added padding / border to accommodate content + assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0)) + + +def test_width(): + """Test width settings.""" + styles = Styles() + one = Fraction(1) + + def get_auto_width(container: Size, parent: Size) -> int: + return 10 + + def get_auto_height(container: Size, parent: Size, width: int) -> int: + return 10 + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) + + # Add a margin and check that it is reported + styles.margin = (1, 2, 3, 4) + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) + + # Set width to auto-detect + styles.width = "auto" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + # Setting width to auto should call get_auto_width + assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4)) + + # Set width to 100 vw which should make it the width of the parent + styles.width = "100vw" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(80), Fraction(16), Spacing(1, 2, 3, 4)) + + # Set the width to 100% should make it fill the container size + styles.width = "100%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) + + styles.width = "100vw" + styles.max_width = "50%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4)) + + +def test_height(): + """Test width settings.""" + styles = Styles() + one = Fraction(1) + + def get_auto_width(container: Size, parent: Size) -> int: + return 10 + + def get_auto_height(container: Size, parent: Size, width: int) -> int: + return 10 + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) + + # Add a margin and check that it is reported + styles.margin = (1, 2, 3, 4) + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) + + # Set width to 100 vw which should make it the width of the parent + styles.height = "100vh" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(24), Spacing(1, 2, 3, 4)) + + # Set the width to 100% should make it fill the container size + styles.height = "100%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) + + styles.height = "auto" + styles.margin = 2 + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2)) + + styles.margin = 1, 2, 3, 4 + styles.height = "100vh" + styles.max_height = "50%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(54), Fraction(10), Spacing(1, 2, 3, 4)) + + +def test_max(): + """Check that max_width and max_height are respected.""" + styles = Styles() + styles.width = 100 + styles.height = 80 + styles.max_width = 40 + styles.max_height = 30 + one = Fraction(1) + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) + + +def test_min(): + """Check that min_width and min_height are respected.""" + styles = Styles() + styles.width = 10 + styles.height = 5 + styles.min_width = 40 + styles.min_height = 30 + one = Fraction(1) + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 000000000..e81b7e06a --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,129 @@ +from __future__ import annotations +from __future__ import unicode_literals + +import pytest + +from textual._cache import LRUCache + + +def test_lru_cache(): + cache = LRUCache(3) + + # insert some values + cache["foo"] = 1 + cache["bar"] = 2 + cache["baz"] = 3 + assert "foo" in cache + assert "bar" in cache + assert "baz" in cache + + # Cache size is 3, so the following should kick oldest one out + cache["egg"] = 4 + assert "foo" not in cache + assert "egg" in cache + + # cache is now full + # look up two keys + cache["bar"] + cache["baz"] + + # Insert a new value + cache["eggegg"] = 5 + assert len(cache) == 3 + # Check it kicked out the 'oldest' key + assert "egg" not in cache + assert "eggegg" in cache + + +def test_lru_cache_get(): + cache = LRUCache(3) + + # insert some values + cache["foo"] = 1 + cache["bar"] = 2 + cache["baz"] = 3 + assert "foo" in cache + + # Cache size is 3, so the following should kick oldest one out + cache["egg"] = 4 + # assert len(cache) == 3 + assert cache.get("foo") is None + assert "egg" in cache + + # cache is now full + # look up two keys + cache.get("bar") + cache.get("baz") + + # Insert a new value + cache["eggegg"] = 5 + # Check it kicked out the 'oldest' key + assert "egg" not in cache + assert "eggegg" in cache + + +def test_lru_cache_mapping(): + """Test cache values can be set and read back.""" + cache = LRUCache(3) + cache["foo"] = 1 + cache.set("bar", 2) + cache.set("baz", 3) + assert cache["foo"] == 1 + assert cache["bar"] == 2 + assert cache.get("baz") == 3 + + +def test_lru_cache_clear(): + cache = LRUCache(3) + assert len(cache) == 0 + cache["foo"] = 1 + assert "foo" in cache + assert len(cache) == 1 + cache.clear() + assert "foo" not in cache + assert len(cache) == 0 + + +def test_lru_cache_bool(): + cache = LRUCache(3) + assert not cache + cache["foo"] = "bar" + assert cache + + +@pytest.mark.parametrize( + "keys,expected", + [ + ((), ()), + (("foo",), ("foo",)), + (("foo", "bar"), ("foo", "bar")), + (("foo", "bar", "baz"), ("foo", "bar", "baz")), + (("foo", "bar", "baz", "egg"), ("bar", "baz", "egg")), + (("foo", "bar", "baz", "egg", "bob"), ("baz", "egg", "bob")), + ], +) +def test_lru_cache_evicts(keys: list[str], expected: list[str]): + """Test adding adding additional values evicts oldest key""" + cache = LRUCache(3) + for value, key in enumerate(keys): + cache[key] = value + assert tuple(cache.keys()) == expected + + +@pytest.mark.parametrize( + "keys,expected_len", + [ + ((), 0), + (("foo",), 1), + (("foo", "bar"), 2), + (("foo", "bar", "baz"), 3), + (("foo", "bar", "baz", "egg"), 3), + (("foo", "bar", "baz", "egg", "bob"), 3), + ], +) +def test_lru_cache_len(keys: list[str], expected_len: int): + """Test adding adding additional values evicts oldest key""" + cache = LRUCache(3) + for value, key in enumerate(keys): + cache[key] = value + assert len(cache) == expected_len diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 000000000..10340d3dc --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,218 @@ +import pytest +from rich.color import Color as RichColor +from rich.text import Text + +from textual.color import Color, Lab, lab_to_rgb, rgb_to_lab + + +def test_rich_color(): + """Check conversion to Rich color.""" + assert Color(10, 20, 30, 1.0).rich_color == RichColor.from_rgb(10, 20, 30) + assert Color.from_rich_color(RichColor.from_rgb(10, 20, 30)) == Color( + 10, 20, 30, 1.0 + ) + + +def test_rich_color_rich_output(): + assert isinstance(Color(10, 20, 30).__rich__(), Text) + + +def test_normalized(): + assert Color(255, 128, 64).normalized == pytest.approx((1.0, 128 / 255, 64 / 255)) + + +def test_clamped(): + assert Color(300, 100, -20, 1.5).clamped == Color(255, 100, 0, 1.0) + + +def test_css(): + """Check conversion to CSS style""" + assert Color(10, 20, 30, 1.0).css == "rgb(10,20,30)" + assert Color(10, 20, 30, 0.5).css == "rgba(10,20,30,0.5)" + + +def test_monochrome(): + assert Color(10, 20, 30).monochrome == Color(19, 19, 19) + assert Color(10, 20, 30, 0.5).monochrome == Color(19, 19, 19, 0.5) + assert Color(255, 255, 255).monochrome == Color(255, 255, 255) + assert Color(0, 0, 0).monochrome == Color(0, 0, 0) + + +def test_rgb(): + assert Color(10, 20, 30, 0.55).rgb == (10, 20, 30) + + +def test_hls(): + + red = Color(200, 20, 32) + print(red.hsl) + assert red.hsl == pytest.approx( + (0.9888888888888889, 0.818181818181818, 0.43137254901960786) + ) + assert Color.from_hsl( + 0.9888888888888889, 0.818181818181818, 0.43137254901960786 + ).normalized == pytest.approx(red.normalized, rel=1e-5) + + +def test_color_brightness(): + assert Color(255, 255, 255).brightness == 1 + assert Color(0, 0, 0).brightness == 0 + assert Color(127, 127, 127).brightness == pytest.approx(0.49803921568627446) + assert Color(255, 127, 64).brightness == pytest.approx(0.6199607843137255) + + +def test_color_hex(): + assert Color(255, 0, 127).hex == "#FF007F" + assert Color(255, 0, 127, 0.5).hex == "#FF007F7F" + + +def test_color_css(): + assert Color(255, 0, 127).css == "rgb(255,0,127)" + assert Color(255, 0, 127, 0.5).css == "rgba(255,0,127,0.5)" + + +def test_color_with_alpha(): + assert Color(255, 50, 100).with_alpha(0.25) == Color(255, 50, 100, 0.25) + + +def test_color_blend(): + assert Color(0, 0, 0).blend(Color(255, 255, 255), 0) == Color(0, 0, 0) + assert Color(0, 0, 0).blend(Color(255, 255, 255), 1.0) == Color(255, 255, 255) + assert Color(0, 0, 0).blend(Color(255, 255, 255), 0.5) == Color(127, 127, 127) + + +@pytest.mark.parametrize( + "text,expected", + [ + ("#000000", Color(0, 0, 0, 1.0)), + ("#ffffff", Color(255, 255, 255, 1.0)), + ("#FFFFFF", Color(255, 255, 255, 1.0)), + ("#fab", Color(255, 170, 187, 1.0)), # #ffaabb + ("#fab0", Color(255, 170, 187, 0.0)), # #ffaabb00 + ("#020304ff", Color(2, 3, 4, 1.0)), + ("#02030400", Color(2, 3, 4, 0.0)), + ("#0203040f", Color(2, 3, 4, 0.058823529411764705)), + ("rgb(0,0,0)", Color(0, 0, 0, 1.0)), + ("rgb(255,255,255)", Color(255, 255, 255, 1.0)), + ("rgba(255,255,255,1)", Color(255, 255, 255, 1.0)), + ("rgb(2,3,4)", Color(2, 3, 4, 1.0)), + ("rgba(2,3,4,1.0)", Color(2, 3, 4, 1.0)), + ("rgba(2,3,4,0.058823529411764705)", Color(2, 3, 4, 0.058823529411764705)), + ("hsl(45,25%,25%)", Color(80, 72, 48)), + ("hsla(45,25%,25%,0.35)", Color(80, 72, 48, 0.35)), + ], +) +def test_color_parse(text, expected): + assert Color.parse(text) == expected + + +@pytest.mark.parametrize( + "input,output", + [ + ("rgb( 300, 300 , 300 )", Color(255, 255, 255)), + ("rgba( 2 , 3 , 4, 1.0 )", Color(2, 3, 4, 1.0)), + ("hsl( 45, 25% , 25% )", Color(80, 72, 48)), + ("hsla( 45, 25% , 25%, 0.35 )", Color(80, 72, 48, 0.35)), + ], +) +def test_color_parse_input_has_spaces(input, output): + assert Color.parse(input) == output + + +@pytest.mark.parametrize( + "input,output", + [ + ("rgb(300, 300, 300)", Color(255, 255, 255)), + ("rgba(300, 300, 300, 300)", Color(255, 255, 255, 1.0)), + ("hsl(400, 200%, 250%)", Color(255, 255, 255, 1.0)), + ("hsla(400, 200%, 250%, 1.9)", Color(255, 255, 255, 1.0)), + ], +) +def test_color_parse_clamp(input, output): + assert Color.parse(input) == output + + +def test_color_parse_hsl_negative_degrees(): + assert Color.parse("hsl(-90, 50%, 50%)") == Color.parse("hsl(270, 50%, 50%)") + + +def test_color_parse_hsla_negative_degrees(): + assert Color.parse("hsla(-45, 50%, 50%, 0.2)") == Color.parse( + "hsla(315, 50%, 50%, 0.2)" + ) + + +def test_color_parse_color(): + # as a convenience, if Color.parse is passed a color object, it will return it + color = Color(20, 30, 40, 0.5) + assert Color.parse(color) is color + + +def test_color_add(): + assert Color(50, 100, 200) + Color(10, 20, 30, 0.9) == Color(14, 28, 47) + + +# Computed with http://www.easyrgb.com/en/convert.php, +# (r, g, b) values in sRGB to (L*, a*, b*) values in CIE-L*ab. +RGB_LAB_DATA = [ + (10, 23, 73, 10.245, 15.913, -32.672), + (200, 34, 123, 45.438, 67.750, -8.008), + (0, 0, 0, 0, 0, 0), + (255, 255, 255, 100, 0, 0), +] + + +def test_color_darken(): + assert Color(200, 210, 220).darken(1) == Color(0, 0, 0) + assert Color(200, 210, 220).darken(-1) == Color(255, 255, 255) + assert Color(200, 210, 220).darken(0.1) == Color(172, 182, 192) + assert Color(200, 210, 220).darken(0.5) == Color(71, 80, 88) + + +def test_color_lighten(): + assert Color(200, 210, 220).lighten(1) == Color(255, 255, 255) + assert Color(200, 210, 220).lighten(-1) == Color(0, 0, 0) + assert Color(200, 210, 220).lighten(0.1) == Color(228, 238, 248) + + +@pytest.mark.parametrize( + "r, g, b, L_, a_, b_", + RGB_LAB_DATA, +) +def test_rgb_to_lab(r, g, b, L_, a_, b_): + """Test conversion from the RGB color space to CIE-L*ab.""" + rgb = Color(r, g, b) + lab = rgb_to_lab(rgb) + assert lab.L == pytest.approx(L_, abs=0.1) + assert lab.a == pytest.approx(a_, abs=0.1) + assert lab.b == pytest.approx(b_, abs=0.1) + + +@pytest.mark.parametrize( + "r, g, b, L_, a_, b_", + RGB_LAB_DATA, +) +def test_lab_to_rgb(r, g, b, L_, a_, b_): + """Test conversion from the CIE-L*ab color space to RGB.""" + + lab = Lab(L_, a_, b_) + rgb = lab_to_rgb(lab) + assert rgb.r == pytest.approx(r, abs=1) + assert rgb.g == pytest.approx(g, abs=1) + assert rgb.b == pytest.approx(b, abs=1) + + +def test_rgb_lab_rgb_roundtrip(): + """Test RGB -> CIE-L*ab -> RGB color conversion roundtripping.""" + + for r in range(0, 256, 32): + for g in range(0, 256, 32): + for b in range(0, 256, 32): + c_ = lab_to_rgb(rgb_to_lab(Color(r, g, b))) + assert c_.r == pytest.approx(r, abs=1) + assert c_.g == pytest.approx(g, abs=1) + assert c_.b == pytest.approx(b, abs=1) + + +def test_inverse(): + assert Color(55, 0, 255, 0.1).inverse == Color(200, 255, 0, 0.1) diff --git a/tests/test_compositor_regions_to_spans.py b/tests/test_compositor_regions_to_spans.py new file mode 100644 index 000000000..31de88ad5 --- /dev/null +++ b/tests/test_compositor_regions_to_spans.py @@ -0,0 +1,54 @@ +from textual._compositor import Compositor +from textual.geometry import Region + + +def test_regions_to_ranges_no_regions(): + assert list(Compositor._regions_to_spans([])) == [] + + +def test_regions_to_ranges_single_region(): + regions = [Region(0, 0, 3, 2)] + assert list(Compositor._regions_to_spans(regions)) == [ + (0, 0, 3), + (1, 0, 3), + ] + + +def test_regions_to_ranges_partially_overlapping_regions(): + regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)] + assert list(Compositor._regions_to_spans(regions)) == [ + (0, 0, 2), + (1, 0, 3), + (2, 1, 3), + ] + + +def test_regions_to_ranges_fully_overlapping_regions(): + regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)] + assert list(Compositor._regions_to_spans(regions)) == [ + (1, 1, 4), + (2, 0, 4), + (3, 1, 4), + ] + + +def test_regions_to_ranges_disjoint_regions_different_lines(): + regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)] + assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 2), (2, 2, 4)] + + +def test_regions_to_ranges_disjoint_regions_same_line(): + regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)] + assert list(Compositor._regions_to_spans(regions)) == [ + (0, 0, 1), + (0, 2, 3), + (1, 0, 1), + ] + + +def test_regions_to_ranges_directly_adjacent_ranges_merged(): + regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)] + assert list(Compositor._regions_to_spans(regions)) == [ + (0, 0, 2), + (1, 0, 2), + ] diff --git a/tests/test_dom.py b/tests/test_dom.py new file mode 100644 index 000000000..5a713193a --- /dev/null +++ b/tests/test_dom.py @@ -0,0 +1,139 @@ +import pytest + +from textual.css.errors import StyleValueError +from textual.css.query import NoMatches +from textual.dom import DOMNode, BadIdentifier + + +def test_display_default(): + node = DOMNode() + assert node.display is True + + +@pytest.mark.parametrize( + "setter_value,style_value", + [[True, "block"], [False, "none"], ["block", "block"], ["none", "none"]], +) +def test_display_set_bool(setter_value, style_value): + node = DOMNode() + node.display = setter_value + assert node.styles.display == style_value + + +def test_display_set_invalid_value(): + node = DOMNode() + with pytest.raises(StyleValueError): + node.display = "blah" + + +@pytest.fixture +def parent(): + parent = DOMNode(id="parent") + child1 = DOMNode(id="child1") + child2 = DOMNode(id="child2") + grandchild1 = DOMNode(id="grandchild1") + child1._add_child(grandchild1) + + parent._add_child(child1) + parent._add_child(child2) + + yield parent + + +def test_get_child_gets_first_child(parent): + child = parent.get_child(id="child1") + assert child.id == "child1" + assert child.get_child(id="grandchild1").id == "grandchild1" + assert parent.get_child(id="child2").id == "child2" + + +def test_get_child_no_matching_child(parent): + with pytest.raises(NoMatches): + parent.get_child(id="doesnt-exist") + + +def test_get_child_only_immediate_descendents(parent): + with pytest.raises(NoMatches): + parent.get_child(id="grandchild1") + + +def test_validate(): + with pytest.raises(BadIdentifier): + DOMNode(id="23") + with pytest.raises(BadIdentifier): + DOMNode(id=".3") + with pytest.raises(BadIdentifier): + DOMNode(classes="+2323") + with pytest.raises(BadIdentifier): + DOMNode(classes="foo 22") + + node = DOMNode() + node.add_class("foo") + with pytest.raises(BadIdentifier): + node.add_class("1") + with pytest.raises(BadIdentifier): + node.remove_class("1") + with pytest.raises(BadIdentifier): + node.toggle_class("1") + + +@pytest.fixture +def search(): + """ + a + / \ + b c + / / \ + d e f + """ + a = DOMNode(id="a") + b = DOMNode(id="b") + c = DOMNode(id="c") + d = DOMNode(id="d") + e = DOMNode(id="e") + f = DOMNode(id="f") + + a._add_child(b) + a._add_child(c) + b._add_child(d) + c._add_child(e) + c._add_child(f) + + yield a + + +def test_walk_children_depth(search): + children = [ + node.id for node in search.walk_children(method="depth", with_self=False) + ] + assert children == ["b", "d", "c", "e", "f"] + + +def test_walk_children_with_self_depth(search): + children = [ + node.id for node in search.walk_children(method="depth", with_self=True) + ] + assert children == ["a", "b", "d", "c", "e", "f"] + + +def test_walk_children_breadth(search): + children = [ + node.id for node in search.walk_children(with_self=False, method="breadth") + ] + print(children) + assert children == ["b", "c", "d", "e", "f"] + + +def test_walk_children_with_self_breadth(search): + children = [ + node.id for node in search.walk_children(with_self=True, method="breadth") + ] + print(children) + assert children == ["a", "b", "c", "d", "e", "f"] + + children = [ + node.id + for node in search.walk_children(with_self=True, method="breadth", reverse=True) + ] + + assert children == ["f", "e", "d", "c", "b", "a"] diff --git a/tests/test_easing.py b/tests/test_easing.py index abdfe0a70..d73d24c7f 100644 --- a/tests/test_easing.py +++ b/tests/test_easing.py @@ -8,7 +8,6 @@ import pytest from textual._easing import EASING - POINTS = [ 0.0, 0.05, diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 000000000..36b6955ac --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from textual.app import App + + +def test_textual_env_var(monkeypatch): + monkeypatch.setenv("TEXTUAL", "") + app = App() + assert app.features == set() + assert app.devtools is None + assert app.debug is False + + monkeypatch.setenv("TEXTUAL", "devtools") + app = App() + assert app.features == {"devtools"} + assert app.devtools is not None + assert app.debug is False + + monkeypatch.setenv("TEXTUAL", "devtools,debug") + app = App() + assert app.features == {"devtools", "debug"} + assert app.devtools is not None + assert app.debug is True + + monkeypatch.setenv("TEXTUAL", "devtools, debug") + app = App() + assert app.features == {"devtools", "debug"} + assert app.devtools is not None + assert app.debug is True diff --git a/tests/test_focus.py b/tests/test_focus.py new file mode 100644 index 000000000..811817b92 --- /dev/null +++ b/tests/test_focus.py @@ -0,0 +1,56 @@ +from textual.app import App +from textual.screen import Screen +from textual.widget import Widget + + +class Focusable(Widget, can_focus=True): + pass + + +class NonFocusable(Widget, can_focus=False, can_focus_children=False): + pass + + +async def test_focus_chain(): + app = App() + app._set_active() + app.push_screen(Screen()) + + screen = app.screen + + # Check empty focus chain + assert not screen.focus_chain + + app.screen._add_children( + Focusable(id="foo"), + NonFocusable(id="bar"), + Focusable(Focusable(id="Paul"), id="container1"), + NonFocusable(Focusable(id="Jessica"), id="container2"), + Focusable(id="baz"), + ) + + focused = [widget.id for widget in screen.focus_chain] + assert focused == ["foo", "Paul", "baz"] + + +async def test_focus_next_and_previous(): + app = App() + app._set_active() + app.push_screen(Screen()) + + screen = app.screen + + screen._add_children( + Focusable(id="foo"), + NonFocusable(id="bar"), + Focusable(Focusable(id="Paul"), id="container1"), + NonFocusable(Focusable(id="Jessica"), id="container2"), + Focusable(id="baz"), + ) + + assert screen.focus_next().id == "foo" + assert screen.focus_next().id == "Paul" + assert screen.focus_next().id == "baz" + + assert screen.focus_previous().id == "Paul" + assert screen.focus_previous().id == "foo" diff --git a/tests/test_geometry.py b/tests/test_geometry.py index ffaded07b..02cac2fa0 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,6 +1,6 @@ import pytest -from textual.geometry import clamp, Offset, Size, Region +from textual.geometry import clamp, Offset, Size, Region, Spacing def test_dimensions_region(): @@ -63,38 +63,95 @@ def test_clamp(): assert clamp(5, 10, 0) == 5 -def test_point_is_origin(): +def test_offset_bool(): + assert Offset(1, 0) + assert Offset(0, 1) + assert Offset(0, -1) + assert not Offset(0, 0) + + +def test_offset_is_origin(): assert Offset(0, 0).is_origin assert not Offset(1, 0).is_origin -def test_point_add(): +def test_clamped(): + assert Offset(-10, 0).clamped == Offset(0, 0) + assert Offset(-10, -5).clamped == Offset(0, 0) + assert Offset(5, -5).clamped == Offset(5, 0) + assert Offset(5, 10).clamped == Offset(5, 10) + + +def test_offset_add(): assert Offset(1, 1) + Offset(2, 2) == Offset(3, 3) assert Offset(1, 2) + Offset(3, 4) == Offset(4, 6) with pytest.raises(TypeError): Offset(1, 1) + "foo" -def test_point_sub(): +def test_offset_sub(): assert Offset(1, 1) - Offset(2, 2) == Offset(-1, -1) assert Offset(3, 4) - Offset(2, 1) == Offset(1, 3) with pytest.raises(TypeError): Offset(1, 1) - "foo" -def test_point_blend(): +def test_offset_neg(): + assert Offset(0, 0) == Offset(0, 0) + assert -Offset(2, -3) == Offset(-2, 3) + + +def test_offset_mul(): + assert Offset(2, 1) * 2 == Offset(4, 2) + assert Offset(2, 1) * -2 == Offset(-4, -2) + assert Offset(2, 1) * 0 == Offset(0, 0) + with pytest.raises(TypeError): + Offset(10, 20) * "foo" + + +def test_offset_blend(): assert Offset(1, 2).blend(Offset(3, 4), 0) == Offset(1, 2) assert Offset(1, 2).blend(Offset(3, 4), 1) == Offset(3, 4) assert Offset(1, 2).blend(Offset(3, 4), 0.5) == Offset(2, 3) +def test_offset_get_distance_to(): + assert Offset(20, 30).get_distance_to(Offset(20, 30)) == 0 + assert Offset(0, 0).get_distance_to(Offset(1, 0)) == 1.0 + assert Offset(2, 1).get_distance_to(Offset(5, 5)) == 5.0 + + def test_region_null(): assert Region() == Region(0, 0, 0, 0) assert not Region() -def test_region_from_origin(): - assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) +def test_region_from_union(): + with pytest.raises(ValueError): + Region.from_union([]) + regions = [ + Region(10, 20, 30, 40), + Region(15, 25, 5, 5), + Region(30, 25, 20, 10), + ] + assert Region.from_union(regions) == Region(10, 20, 40, 40) + + +def test_region_from_offset(): + assert Region.from_offset(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) + + +@pytest.mark.parametrize( + "window,region,scroll", + [ + (Region(0, 0, 200, 100), Region(0, 0, 200, 100), Offset(0, 0)), + (Region(0, 0, 200, 100), Region(0, -100, 10, 10), Offset(0, -100)), + (Region(10, 15, 20, 10), Region(0, 0, 50, 50), Offset(-10, -15)), + ], +) +def test_get_scroll_to_visible(window, region, scroll): + assert Region.get_scroll_to_visible(window, region) == scroll + assert region.overlaps(window + scroll) def test_region_area(): @@ -108,7 +165,19 @@ def test_region_size(): def test_region_origin(): - assert Region(1, 2, 3, 4).origin == Offset(1, 2) + assert Region(1, 2, 3, 4).offset == Offset(1, 2) + + +def test_region_bottom_left(): + assert Region(1, 2, 3, 4).bottom_left == Offset(1, 6) + + +def test_region_top_right(): + assert Region(1, 2, 3, 4).top_right == Offset(4, 2) + + +def test_region_bottom_right(): + assert Region(1, 2, 3, 4).bottom_right == Offset(4, 6) def test_region_add(): @@ -123,6 +192,20 @@ def test_region_sub(): Region(1, 2, 3, 4) - "foo" +def test_region_at_offset(): + assert Region(10, 10, 30, 40).at_offset((0, 0)) == Region(0, 0, 30, 40) + assert Region(10, 10, 30, 40).at_offset((-15, 30)) == Region(-15, 30, 30, 40) + + +def test_crop_size(): + assert Region(10, 20, 100, 200).crop_size((50, 40)) == Region(10, 20, 50, 40) + assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40) + + +def test_clip_size(): + assert Region(10, 10, 100, 80).clip_size((50, 100)) == Region(10, 10, 50, 80) + + def test_region_overlaps(): assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20)) assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20)) @@ -157,8 +240,8 @@ def test_region_contains_region(): def test_region_translate(): - assert Region(1, 2, 3, 4).translate(10, 20) == Region(11, 22, 3, 4) - assert Region(1, 2, 3, 4).translate(y=20) == Region(1, 22, 3, 4) + assert Region(1, 2, 3, 4).translate((10, 20)) == Region(11, 22, 3, 4) + assert Region(1, 2, 3, 4).translate((0, 20)) == Region(1, 22, 3, 4) def test_region_contains_special(): @@ -173,6 +256,18 @@ def test_clip(): assert Region(10, 10, 20, 30).clip(20, 25) == Region(10, 10, 10, 15) +def test_region_shrink(): + margin = Spacing(top=1, right=2, bottom=3, left=4) + region = Region(x=10, y=10, width=50, height=50) + assert region.shrink(margin) == Region(x=14, y=11, width=44, height=46) + + +def test_region_grow(): + margin = Spacing(top=1, right=2, bottom=3, left=4) + region = Region(x=10, y=10, width=50, height=50) + assert region.grow(margin) == Region(x=6, y=9, width=56, height=54) + + def test_region_intersection(): assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( 10, 10, 10, 10 @@ -190,31 +285,160 @@ def test_region_union(): def test_size_add(): assert Size(5, 10) + Size(2, 3) == Size(7, 13) + with pytest.raises(TypeError): + Size(1, 2) + "foo" def test_size_sub(): assert Size(5, 10) - Size(2, 3) == Size(3, 7) + with pytest.raises(TypeError): + Size(1, 2) - "foo" def test_region_x_extents(): - assert Region(5, 10, 20, 30).x_extents == (5, 25) + assert Region(5, 10, 20, 30).column_span == (5, 25) def test_region_y_extents(): - assert Region(5, 10, 20, 30).y_extents == (10, 40) + assert Region(5, 10, 20, 30).line_span == (10, 40) def test_region_x_max(): - assert Region(5, 10, 20, 30).x_max == 25 + assert Region(5, 10, 20, 30).right == 25 def test_region_y_max(): - assert Region(5, 10, 20, 30).y_max == 40 + assert Region(5, 10, 20, 30).bottom == 40 -def test_region_x_range(): - assert Region(5, 10, 20, 30).x_range == range(5, 25) +def test_region_columns_range(): + assert Region(5, 10, 20, 30).column_range == range(5, 25) -def test_region_y_range(): - assert Region(5, 10, 20, 30).y_range == range(10, 40) +def test_region_lines_range(): + assert Region(5, 10, 20, 30).line_range == range(10, 40) + + +def test_region_reset_offset(): + assert Region(5, 10, 20, 30).reset_offset == Region(0, 0, 20, 30) + + +def test_region_expand(): + assert Region(50, 10, 10, 5).expand((2, 3)) == Region(48, 7, 14, 11) + + +def test_spacing_bool(): + assert Spacing(1, 0, 0, 0) + assert Spacing(0, 1, 0, 0) + assert Spacing(0, 1, 0, 0) + assert Spacing(0, 0, 1, 0) + assert Spacing(0, 0, 0, 1) + assert not Spacing(0, 0, 0, 0) + + +def test_spacing_width(): + assert Spacing(2, 3, 4, 5).width == 8 + + +def test_spacing_height(): + assert Spacing(2, 3, 4, 5).height == 6 + + +def test_spacing_top_left(): + assert Spacing(2, 3, 4, 5).top_left == (5, 2) + + +def test_spacing_bottom_right(): + assert Spacing(2, 3, 4, 5).bottom_right == (3, 4) + + +def test_spacing_totals(): + assert Spacing(2, 3, 4, 5).totals == (8, 6) + + +def test_spacing_css(): + assert Spacing(1, 1, 1, 1).css == "1" + assert Spacing(1, 2, 1, 2).css == "1 2" + assert Spacing(1, 2, 3, 4).css == "1 2 3 4" + + +def test_spacing_unpack(): + assert Spacing.unpack(1) == Spacing(1, 1, 1, 1) + assert Spacing.unpack((1,)) == Spacing(1, 1, 1, 1) + assert Spacing.unpack((1, 2)) == Spacing(1, 2, 1, 2) + assert Spacing.unpack((1, 2, 3, 4)) == Spacing(1, 2, 3, 4) + + with pytest.raises(ValueError): + assert Spacing.unpack(()) == Spacing(1, 2, 1, 2) + + with pytest.raises(ValueError): + assert Spacing.unpack((1, 2, 3)) == Spacing(1, 2, 1, 2) + + with pytest.raises(ValueError): + assert Spacing.unpack((1, 2, 3, 4, 5)) == Spacing(1, 2, 1, 2) + + +def test_spacing_add(): + assert Spacing(1, 2, 3, 4) + Spacing(5, 6, 7, 8) == Spacing(6, 8, 10, 12) + + with pytest.raises(TypeError): + Spacing(1, 2, 3, 4) + "foo" + + +def test_spacing_sub(): + assert Spacing(1, 2, 3, 4) - Spacing(5, 6, 7, 8) == Spacing(-4, -4, -4, -4) + + with pytest.raises(TypeError): + Spacing(1, 2, 3, 4) - "foo" + + +def test_spacing_convenience_constructors(): + assert Spacing.vertical(2) == Spacing(2, 0, 2, 0) + assert Spacing.horizontal(2) == Spacing(0, 2, 0, 2) + assert Spacing.all(2) == Spacing(2, 2, 2, 2) + + +def test_split(): + assert Region(10, 5, 22, 15).split(10, 5) == ( + Region(10, 5, 10, 5), + Region(20, 5, 12, 5), + Region(10, 10, 10, 10), + Region(20, 10, 12, 10), + ) + + +def test_split_negative(): + assert Region(10, 5, 22, 15).split(-1, -1) == ( + Region(10, 5, 21, 14), + Region(31, 5, 1, 14), + Region(10, 19, 21, 1), + Region(31, 19, 1, 1), + ) + + +def test_split_vertical(): + assert Region(10, 5, 22, 15).split_vertical(10) == ( + Region(10, 5, 10, 15), + Region(20, 5, 12, 15), + ) + + +def test_split_vertical_negative(): + assert Region(10, 5, 22, 15).split_vertical(-1) == ( + Region(10, 5, 21, 15), + Region(31, 5, 1, 15), + ) + + +def test_split_horizontal(): + assert Region(10, 5, 22, 15).split_horizontal(5) == ( + Region(10, 5, 22, 5), + Region(10, 10, 22, 10), + ) + + +def test_split_horizontal_negative(): + assert Region(10, 5, 22, 15).split_horizontal(-1) == ( + Region(10, 5, 22, 14), + Region(10, 19, 22, 1), + ) diff --git a/tests/test_integration_scrolling.py b/tests/test_integration_scrolling.py new file mode 100644 index 000000000..0cd862977 --- /dev/null +++ b/tests/test_integration_scrolling.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import Sequence, cast + +import pytest + +from tests.utilities.test_app import AppTest +from textual.app import ComposeResult +from textual.geometry import Size +from textual.widget import Widget +from textual.widgets import Placeholder + +pytestmark = pytest.mark.integration_test + +SCREEN_SIZE = Size(100, 30) + + +@pytest.mark.skip("Needs a rethink") +@pytest.mark.asyncio +@pytest.mark.parametrize( + ( + "screen_size", + "placeholders_count", + "scroll_to_placeholder_id", + "scroll_to_animate", + "waiting_duration", + "last_screen_expected_placeholder_ids", + ), + ( + [SCREEN_SIZE, 10, None, None, 0.01, (0, 1, 2, 3, 4)], + [SCREEN_SIZE, 10, "placeholder_3", False, 0.01, (0, 1, 2, 3, 4)], + [SCREEN_SIZE, 10, "placeholder_5", False, 0.01, (1, 2, 3, 4, 5)], + [SCREEN_SIZE, 10, "placeholder_7", False, 0.01, (3, 4, 5, 6, 7)], + [SCREEN_SIZE, 10, "placeholder_9", False, 0.01, (5, 6, 7, 8, 9)], + # N.B. Scroll duration is hard-coded to 0.2 in the `scroll_to_widget` method atm + # Waiting for this duration should allow us to see the scroll finished: + [SCREEN_SIZE, 10, "placeholder_9", True, 0.21, (5, 6, 7, 8, 9)], + # After having waited for approximately half of the scrolling duration, we should + # see the middle Placeholders as we're scrolling towards the last of them. + [SCREEN_SIZE, 10, "placeholder_9", True, 0.1, (4, 5, 6, 7, 8)], + ), +) +async def test_scroll_to_widget( + screen_size: Size, + placeholders_count: int, + scroll_to_animate: bool | None, + scroll_to_placeholder_id: str | None, + waiting_duration: float | None, + last_screen_expected_placeholder_ids: Sequence[int], +): + class VerticalContainer(Widget): + DEFAULT_CSS = """ + VerticalContainer { + layout: vertical; + overflow: hidden auto; + } + VerticalContainer Placeholder { + margin: 1 0; + height: 5; + } + """ + + class MyTestApp(AppTest): + DEFAULT_CSS = """ + Placeholder { + height: 5; /* minimal height to see the name of a Placeholder */ + } + """ + + def compose(self) -> ComposeResult: + placeholders = [ + Placeholder(id=f"placeholder_{i}", name=f"Placeholder #{i}") + for i in range(placeholders_count) + ] + + yield VerticalContainer(*placeholders, id="root") + + app = MyTestApp(size=screen_size, test_name="scroll_to_widget") + + async with app.in_running_state(waiting_duration_after_yield=waiting_duration or 0): + if scroll_to_placeholder_id: + target_widget_container = cast(Widget, app.query("#root").first()) + target_widget = cast( + Widget, app.query(f"#{scroll_to_placeholder_id}").first() + ) + target_widget_container.scroll_to_widget( + target_widget, animate=scroll_to_animate + ) + + last_display_capture = app.last_display_capture + + placeholders_visibility_by_id = { + id_: f"placeholder_{id_}" in last_display_capture + for id_ in range(placeholders_count) + } + print(placeholders_visibility_by_id) + # Let's start by checking placeholders that should be visible: + for placeholder_id in last_screen_expected_placeholder_ids: + assert placeholders_visibility_by_id[placeholder_id] is True, ( + f"Placeholder '{placeholder_id}' should be visible but isn't" + f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" + ) + + # Ok, now for placeholders that should *not* be visible: + # We're simply going to check that all the placeholders that are not in + # `last_screen_expected_placeholder_ids` are not on the screen: + last_screen_expected_out_of_viewport_placeholder_ids = sorted( + tuple( + set(range(placeholders_count)) - set(last_screen_expected_placeholder_ids) + ) + ) + for placeholder_id in last_screen_expected_out_of_viewport_placeholder_ids: + assert placeholders_visibility_by_id[placeholder_id] is False, ( + f"Placeholder '{placeholder_id}' should not be visible but is" + f" :: placeholders_visibility_by_id={placeholders_visibility_by_id}" + ) diff --git a/tests/test_layout_resolve.py b/tests/test_layout_resolve.py new file mode 100644 index 000000000..e5ce35424 --- /dev/null +++ b/tests/test_layout_resolve.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import NamedTuple + +import pytest + +from textual._layout_resolve import layout_resolve + + +class Edge(NamedTuple): + size: int | None = None + fraction: int = 1 + min_size: int = 1 + + +def test_empty(): + assert layout_resolve(10, []) == [] + + +def test_total_zero(): + assert layout_resolve(0, [Edge(10)]) == [10] + + +def test_single(): + # One edge fixed size + assert layout_resolve(100, [Edge(10)]) == [10] + # One edge fraction of 1 + assert layout_resolve(100, [Edge(None, 1)]) == [100] + # One edge fraction 3 + assert layout_resolve(100, [Edge(None, 2)]) == [100] + # One edge, fraction1, min size 20 + assert layout_resolve(100, [Edge(None, 1, 20)]) == [100] + # One edge fraction 1, min size 120 + assert layout_resolve(100, [Edge(None, 1, 120)]) == [120] + + +def test_two(): + # Two edges fixed size + assert layout_resolve(100, [Edge(10), Edge(20)]) == [10, 20] + # Two edges, fixed size of one exceeds total + assert layout_resolve(100, [Edge(120), Edge(None, 1)]) == [120, 1] + # Two edges, fraction 1 each + assert layout_resolve(100, [Edge(None, 1), Edge(None, 1)]) == [50, 50] + # Two edges, one with fraction 2, one with fraction 1 + # Note first value is rounded down, second is rounded up + assert layout_resolve(100, [Edge(None, 2), Edge(None, 1)]) == [66, 34] + # Two edges, both with fraction 2 + assert layout_resolve(100, [Edge(None, 2), Edge(None, 2)]) == [50, 50] + # Two edges, one with fraction 3, one with fraction 1 + assert layout_resolve(100, [Edge(None, 3), Edge(None, 1)]) == [75, 25] + # Two edges, one with fraction 3, one with fraction 1, second with min size of 30 + assert layout_resolve(100, [Edge(None, 3), Edge(None, 1, 30)]) == [70, 30] + # Two edges, one with fraction 1 and min size 30, one with fraction 3 + assert layout_resolve(100, [Edge(None, 1, 30), Edge(None, 3)]) == [30, 70] + + +@pytest.mark.parametrize( + "size, edges, result", + [ + (10, [Edge(8), Edge(None, 0, 2), Edge(4)], [8, 2, 4]), + (10, [Edge(None, 1), Edge(None, 1), Edge(None, 1)], [3, 3, 4]), + (10, [Edge(5), Edge(None, 1), Edge(None, 1)], [5, 2, 3]), + (10, [Edge(None, 2), Edge(None, 1), Edge(None, 1)], [5, 2, 3]), + (10, [Edge(None, 2), Edge(3), Edge(None, 1)], [4, 3, 3]), + ( + 10, + [Edge(None, 2), Edge(None, 1), Edge(None, 1), Edge(None, 1)], + [4, 2, 2, 2], + ), + ( + 10, + [Edge(None, 4), Edge(None, 1), Edge(None, 1), Edge(None, 1)], + [5, 2, 1, 2], + ), + (2, [Edge(None, 1), Edge(None, 1), Edge(None, 1)], [1, 1, 1]), + ( + 2, + [ + Edge(None, 1, min_size=5), + Edge(None, 1, min_size=4), + Edge(None, 1, min_size=3), + ], + [5, 4, 3], + ), + ( + 18, + [ + Edge(None, 1, min_size=1), + Edge(3), + Edge(None, 1, min_size=1), + Edge(4), + Edge(None, 1, min_size=1), + Edge(5), + Edge(None, 1, min_size=1), + ], + [1, 3, 2, 4, 1, 5, 2], + ), + ], +) +def test_multiple(size, edges, result): + assert layout_resolve(size, edges) == result diff --git a/tests/test_message_pump.py b/tests/test_message_pump.py new file mode 100644 index 000000000..749afa1d8 --- /dev/null +++ b/tests/test_message_pump.py @@ -0,0 +1,56 @@ +import pytest + +from textual.errors import DuplicateKeyHandlers +from textual.events import Key +from textual.widget import Widget + + +class ValidWidget(Widget): + called_by = None + + def key_x(self): + self.called_by = self.key_x + + def key_ctrl_i(self): + self.called_by = self.key_ctrl_i + + +async def test_dispatch_key_valid_key(): + widget = ValidWidget() + result = await widget.dispatch_key(Key(widget, key="x", char="x")) + assert result is True + assert widget.called_by == widget.key_x + + +async def test_dispatch_key_valid_key_alias(): + """When you press tab or ctrl+i, it comes through as a tab key event, but handlers for + tab and ctrl+i are both considered valid.""" + widget = ValidWidget() + result = await widget.dispatch_key(Key(widget, key="tab", char="\t")) + assert result is True + assert widget.called_by == widget.key_ctrl_i + + +class DuplicateHandlersWidget(Widget): + called_by = None + + def key_x(self): + self.called_by = self.key_x + + def _key_x(self): + self.called_by = self._key_x + + def key_tab(self): + self.called_by = self.key_tab + + def key_ctrl_i(self): + self.called_by = self.key_ctrl_i + + +async def test_dispatch_key_raises_when_conflicting_handler_aliases(): + """If you've got a handler for e.g. ctrl+i and a handler for tab, that's probably a mistake. + In the terminal, they're the same thing, so we fail fast via exception here.""" + widget = DuplicateHandlersWidget() + with pytest.raises(DuplicateKeyHandlers): + await widget.dispatch_key(Key(widget, key="tab", char="\t")) + assert widget.called_by == widget.key_tab diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 000000000..f48da58b1 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,53 @@ +from textual._parser import Parser + + +def test_read1(): + class TestParser(Parser[str]): + """A simple parser that reads a byte at a time from a stream.""" + + def parse(self, on_token): + while True: + data = yield self.read1() + if not data: + break + on_token(data) + + test_parser = TestParser() + test_data = "Where there is a Will there is a way!" + + for size in range(1, len(test_data) + 1): + # Feed the parser in pieces, first 1 character at a time, then 2, etc + data = [] + for offset in range(0, len(test_data), size): + for chunk in test_parser.feed(test_data[offset : offset + size]): + data.append(chunk) + # Check we have received all the data in characters, no matter the fee dsize + assert len(data) == len(test_data) + assert "".join(data) == test_data + + +def test_read(): + class TestParser(Parser[str]): + """A parser that reads chunks of a given size from the stream.""" + + def __init__(self, size): + self.size = size + super().__init__() + + def parse(self, on_token): + while True: + data = yield self.read1() + if not data: + break + on_token(data) + + test_data = "Where there is a Will there is a way!" + + for read_size in range(1, len(test_data) + 1): + for size in range(1, len(test_data) + 1): + test_parser = TestParser(read_size) + data = [] + for offset in range(0, len(test_data), size): + for chunk in test_parser.feed(test_data[offset : offset + size]): + data.append(chunk) + assert "".join(data) == test_data diff --git a/tests/test_partition.py b/tests/test_partition.py new file mode 100644 index 000000000..de5147058 --- /dev/null +++ b/tests/test_partition.py @@ -0,0 +1,27 @@ +from textual._partition import partition + + +def test_partition(): + def is_odd(value: int) -> bool: + return bool(value % 2) + + def is_greater_than_five(value: int) -> bool: + return value > 5 + + assert partition(is_odd, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ( + [2, 4, 6, 8, 10], + [1, 3, 5, 7, 9], + ) + + assert partition(is_odd, [1, 2]) == ([2], [1]) + assert partition(is_odd, [1]) == ([], [1]) + assert partition(is_odd, []) == ([], []) + + assert partition(is_greater_than_five, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) == ( + [1, 2, 3, 4, 5], + [6, 7, 8, 9, 10], + ) + + assert partition(is_greater_than_five, [6, 7, 8, 9, 10]) == ([], [6, 7, 8, 9, 10]) + + assert partition(is_greater_than_five, [1, 2, 3]) == ([1, 2, 3], []) diff --git a/tests/test_query.py b/tests/test_query.py new file mode 100644 index 000000000..be6108046 --- /dev/null +++ b/tests/test_query.py @@ -0,0 +1,80 @@ +from textual.widget import Widget + + +def test_query(): + class View(Widget): + pass + + class App(Widget): + pass + + app = App() + main_view = View(id="main") + help_view = View(id="help") + app._add_child(main_view) + app._add_child(help_view) + + widget1 = Widget(id="widget1") + widget2 = Widget(id="widget2") + sidebar = Widget(id="sidebar") + sidebar.add_class("float") + + helpbar = Widget(id="helpbar") + helpbar.add_class("float") + + 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) + + tooltip = Widget(id="tooltip") + tooltip.add_class("float", "transient") + sub_view._add_child(tooltip) + + help = Widget(id="markdown") + help_view._add_child(help) + help_view._add_child(helpbar) + + # repeat tests to account for caching + for repeat in range(3): + assert list(app.query("Frob")) == [] + assert list(app.query(".frob")) == [] + assert list(app.query("#frob")) == [] + + assert list(app.query("App")) == [app] + assert list(app.query("#main")) == [main_view] + assert list(app.query("View#main")) == [main_view] + assert list(app.query("#widget1")) == [widget1] + assert list(app.query("#widget2")) == [widget2] + + assert list(app.query("Widget.float")) == [sidebar, tooltip, helpbar] + assert list(app.query("Widget.float").results(Widget)) == [ + sidebar, + tooltip, + helpbar, + ] + assert list(app.query("Widget.float").results(View)) == [] + + assert list(app.query("Widget.float.transient")) == [tooltip] + + assert list(app.query("App > View")) == [main_view, help_view] + assert list(app.query("App > View#help")) == [help_view] + assert list(app.query("App > View#main .float ")) == [sidebar, tooltip] + assert list(app.query("View > View")) == [sub_view] + + assert list(app.query("#help *")) == [help, helpbar] + assert list(app.query("#main *")) == [ + widget1, + widget2, + sidebar, + sub_view, + tooltip, + ] + + assert list(app.query("App,View")) == [app, main_view, sub_view, help_view] + assert list(app.query("#widget1, #widget2")) == [widget1, widget2] + assert list(app.query("#widget1 , #widget2")) == [widget1, widget2] + assert list(app.query("#widget1, #widget2, App")) == [app, widget1, widget2] diff --git a/tests/test_resolve.py b/tests/test_resolve.py new file mode 100644 index 000000000..50b4a44b0 --- /dev/null +++ b/tests/test_resolve.py @@ -0,0 +1,58 @@ +import pytest + +from textual._resolve import resolve +from textual.css.scalar import Scalar +from textual.geometry import Size + + +def test_resolve_empty(): + assert resolve([], 10, 1, Size(20, 10), Size(80, 24)) == [] + + +@pytest.mark.parametrize( + "scalars,total,gutter,result", + [ + (["10"], 100, 0, [(0, 10)]), + ( + ["10", "20"], + 100, + 0, + [(0, 10), (10, 20)], + ), + ( + ["10", "20"], + 100, + 1, + [(0, 10), (11, 20)], + ), + ( + ["10", "1fr"], + 100, + 1, + [(0, 10), (11, 89)], + ), + ( + ["1fr", "1fr"], + 100, + 0, + [(0, 50), (50, 50)], + ), + ( + ["3", "1fr", "1fr", "1"], + 100, + 1, + [(0, 3), (4, 46), (51, 47), (99, 1)], + ), + ], +) +def test_resolve(scalars, total, gutter, result): + assert ( + resolve( + [Scalar.parse(scalar) for scalar in scalars], + total, + gutter, + Size(40, 20), + Size(80, 24), + ) + == result + ) diff --git a/tests/test_screens.py b/tests/test_screens.py new file mode 100644 index 000000000..cea3179e3 --- /dev/null +++ b/tests/test_screens.py @@ -0,0 +1,92 @@ +import sys + +import pytest + +from textual.app import App, ScreenStackError +from textual.screen import Screen + +skip_py310 = pytest.mark.skipif( + sys.version_info.minor == 10 and sys.version_info.major == 3, + reason="segfault on py3.10", +) + + +@skip_py310 +@pytest.mark.asyncio +async def test_screens(): + + app = App() + app._set_active() + + with pytest.raises(ScreenStackError): + app.screen + + assert not app._installed_screens + + screen1 = Screen(name="screen1") + screen2 = Screen(name="screen2") + screen3 = Screen(name="screen3") + + # installs screens + app.install_screen(screen1, "screen1") + app.install_screen(screen2, "screen2") + + # Check they are installed + assert app.is_screen_installed("screen1") + assert app.is_screen_installed("screen2") + + assert app.get_screen("screen1") is screen1 + with pytest.raises(KeyError): + app.get_screen("foo") + + # Check screen3 is not installed + assert not app.is_screen_installed("screen3") + + # Installs screen3 + app.install_screen(screen3, "screen3") + # Confirm installed + assert app.is_screen_installed("screen3") + + # Check screen stack is empty + assert app.screen_stack == [] + # Push a screen + app.push_screen("screen1") + # Check it is on the stack + assert app.screen_stack == [screen1] + # Check it is current + assert app.screen is screen1 + + # Switch to another screen + app.switch_screen("screen2") + # Check it has changed the stack and that it is current + assert app.screen_stack == [screen2] + assert app.screen is screen2 + + # Push another screen + app.push_screen("screen3") + assert app.screen_stack == [screen2, screen3] + assert app.screen is screen3 + + # Pop a screen + assert app.pop_screen() is screen3 + assert app.screen is screen2 + assert app.screen_stack == [screen2] + + # Uninstall screens + app.uninstall_screen(screen1) + assert not app.is_screen_installed(screen1) + app.uninstall_screen("screen3") + assert not app.is_screen_installed(screen1) + + # Check we can't uninstall a screen on the stack + with pytest.raises(ScreenStackError): + app.uninstall_screen(screen2) + + # Check we can't pop last screen + with pytest.raises(ScreenStackError): + app.pop_screen() + + screen1.remove() + screen2.remove() + screen3.remove() + await app.shutdown() diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py new file mode 100644 index 000000000..630114ed9 --- /dev/null +++ b/tests/test_segment_tools.py @@ -0,0 +1,112 @@ +from rich.segment import Segment +from rich.style import Style + +from textual._segment_tools import line_crop, line_trim, line_pad + + +def test_line_crop(): + bold = Style(bold=True) + italic = Style(italic=True) + segments = [ + Segment("Hello", bold), + Segment(" World!", italic), + ] + total = sum(segment.cell_length for segment in segments) + + assert line_crop(segments, 1, 2, total) == [Segment("e", bold)] + assert line_crop(segments, 4, 20, total) == [ + Segment("o", bold), + Segment(" World!", italic), + ] + + +def test_line_crop_emoji(): + bold = Style(bold=True) + italic = Style(italic=True) + segments = [ + Segment("Hello", bold), + Segment("๐Ÿ’ฉ๐Ÿ’ฉ๐Ÿ’ฉ", italic), + ] + total = sum(segment.cell_length for segment in segments) + assert line_crop(segments, 8, 11, total) == [Segment(" ๐Ÿ’ฉ", italic)] + assert line_crop(segments, 9, 11, total) == [Segment("๐Ÿ’ฉ", italic)] + + +def test_line_crop_edge(): + segments = [Segment("foo"), Segment("bar"), Segment("baz")] + total = sum(segment.cell_length for segment in segments) + + assert line_crop(segments, 2, 9, total) == [ + Segment("o"), + Segment("bar"), + Segment("baz"), + ] + assert line_crop(segments, 3, 9, total) == [Segment("bar"), Segment("baz")] + assert line_crop(segments, 4, 9, total) == [Segment("ar"), Segment("baz")] + assert line_crop(segments, 4, 8, total) == [Segment("ar"), Segment("ba")] + + +def test_line_crop_edge_2(): + segments = [ + Segment("โ•ญโ”€"), + Segment( + "โ”€โ”€โ”€โ”€โ”€โ”€ Placeholder โ”€โ”€โ”€โ”€โ”€โ”€โ”€", + ), + Segment( + "โ”€โ•ฎ", + ), + ] + total = sum(segment.cell_length for segment in segments) + result = line_crop(segments, 30, 60, total) + expected = [] + print(repr(result)) + assert result == expected + + +def test_line_trim(): + segments = [Segment("foo")] + + assert line_trim(segments, False, False) == segments + assert line_trim(segments, True, False) == [Segment("oo")] + assert line_trim(segments, False, True) == [Segment("fo")] + assert line_trim(segments, True, True) == [Segment("o")] + + fob_segments = [Segment("f"), Segment("o"), Segment("b")] + + assert line_trim(fob_segments, True, False) == [ + Segment("o"), + Segment("b"), + ] + + assert line_trim(fob_segments, False, True) == [ + Segment("f"), + Segment("o"), + ] + + assert line_trim(fob_segments, True, True) == [ + Segment("o"), + ] + + assert line_trim([], True, True) == [] + + +def test_line_pad(): + segments = [Segment("foo"), Segment("bar")] + style = Style.parse("red") + assert line_pad(segments, 2, 3, style) == [ + Segment(" ", style), + *segments, + Segment(" ", style), + ] + + assert line_pad(segments, 0, 3, style) == [ + *segments, + Segment(" ", style), + ] + + assert line_pad(segments, 2, 0, style) == [ + Segment(" ", style), + *segments, + ] + + assert line_pad(segments, 0, 0, style) == segments diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py new file mode 100644 index 000000000..32795dca0 --- /dev/null +++ b/tests/test_styles_cache.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from rich.segment import Segment +from rich.style import Style + +from textual._styles_cache import StylesCache +from textual._types import Lines +from textual.color import Color +from textual.css.styles import Styles +from textual.geometry import Region, Size + + +def _extract_content(lines: Lines): + """Extract the text content from lines.""" + content = ["".join(segment.text for segment in line) for line in lines] + return content + + +def test_set_dirty(): + cache = StylesCache() + cache.set_dirty(Region(3, 4, 10, 2)) + assert not cache.is_dirty(3) + assert cache.is_dirty(4) + assert cache.is_dirty(5) + assert not cache.is_dirty(6) + + +def test_no_styles(): + """Test that empty style returns the content un-altered""" + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + cache = StylesCache() + lines = cache.render( + styles, + Size(3, 3), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + ) + style = Style.from_color(bgcolor=Color.parse("green").rich_color) + expected = [ + [Segment("foo", style)], + [Segment("bar", style)], + [Segment("baz", style)], + ] + assert lines == expected + + +def test_border(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(5, 5), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + ) + + text_content = _extract_content(lines) + + expected_text = [ + "โ”โ”โ”โ”โ”“", + "โ”ƒfooโ”ƒ", + "โ”ƒbarโ”ƒ", + "โ”ƒbazโ”ƒ", + "โ”—โ”โ”โ”โ”›", + ] + + assert text_content == expected_text + + +def test_padding(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + cache = StylesCache() + lines = cache.render( + styles, + Size(5, 5), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + ) + + text_content = _extract_content(lines) + + expected_text = [ + " ", + " foo ", + " bar ", + " baz ", + " ", + ] + + assert text_content == expected_text + + +def test_padding_border(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + ) + + text_content = _extract_content(lines) + + expected_text = [ + "โ”โ”โ”โ”โ”โ”โ”“", + "โ”ƒ โ”ƒ", + "โ”ƒ foo โ”ƒ", + "โ”ƒ bar โ”ƒ", + "โ”ƒ baz โ”ƒ", + "โ”ƒ โ”ƒ", + "โ”—โ”โ”โ”โ”โ”โ”›", + ] + + assert text_content == expected_text + + +def test_outline(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.outline = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(3, 3), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + ) + + text_content = _extract_content(lines) + expected_text = [ + "โ”โ”โ”“", + "โ”ƒaโ”ƒ", + "โ”—โ”โ”›", + ] + assert text_content == expected_text + + +def test_crop(): + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + content.__getitem__, + content_size=Size(3, 3), + crop=Region(2, 2, 3, 3), + ) + text_content = _extract_content(lines) + expected_text = [ + "foo", + "bar", + "baz", + ] + assert text_content == expected_text + + +def test_dirty_cache(): + """Check that we only render content once or if it has been marked as dirty.""" + + content = [ + [Segment("foo")], + [Segment("bar")], + [Segment("baz")], + ] + rendered_lines: list[int] = [] + + def get_content_line(y: int) -> list[Segment]: + rendered_lines.append(y) + return content[y] + + styles = Styles() + styles.padding = 1 + styles.border = ("heavy", "white") + cache = StylesCache() + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + ) + assert rendered_lines == [0, 1, 2] + del rendered_lines[:] + + text_content = _extract_content(lines) + expected_text = [ + "โ”โ”โ”โ”โ”โ”โ”“", + "โ”ƒ โ”ƒ", + "โ”ƒ foo โ”ƒ", + "โ”ƒ bar โ”ƒ", + "โ”ƒ baz โ”ƒ", + "โ”ƒ โ”ƒ", + "โ”—โ”โ”โ”โ”โ”โ”›", + ] + assert text_content == expected_text + + # Re-render styles, check that content was not requested + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + content_size=Size(3, 3), + ) + assert rendered_lines == [] + del rendered_lines[:] + text_content = _extract_content(lines) + assert text_content == expected_text + + # Mark 2 lines as dirty + cache.set_dirty(Region(0, 2, 7, 2)) + + lines = cache.render( + styles, + Size(7, 7), + Color.parse("blue"), + Color.parse("green"), + get_content_line, + content_size=Size(3, 3), + ) + assert rendered_lines == [0, 1] + text_content = _extract_content(lines) + assert text_content == expected_text diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py new file mode 100644 index 000000000..8faedcbaf --- /dev/null +++ b/tests/test_suggestions.py @@ -0,0 +1,35 @@ +import pytest + +from textual.suggestions import get_suggestion, get_suggestions + + +@pytest.mark.parametrize( + "word, possible_words, expected_result", + ( + ["background", ("background",), "background"], + ["backgroundu", ("background",), "background"], + ["bkgrund", ("background",), "background"], + ["llow", ("background",), None], + ["llow", ("background", "yellow"), "yellow"], + ["yllow", ("background", "yellow", "ellow"), "yellow"], + ), +) +def test_get_suggestion(word, possible_words, expected_result): + assert get_suggestion(word, possible_words) == expected_result + + +@pytest.mark.parametrize( + "word, possible_words, count, expected_result", + ( + ["background", ("background",), 1, ["background"]], + ["backgroundu", ("background",), 1, ["background"]], + ["bkgrund", ("background",), 1, ["background"]], + ["llow", ("background",), 1, []], + ["llow", ("background", "yellow"), 1, ["yellow"]], + ["yllow", ("background", "yellow", "ellow"), 1, ["yellow"]], + ["yllow", ("background", "yellow", "ellow"), 2, ["yellow", "ellow"]], + ["yllow", ("background", "yellow", "red"), 2, ["yellow"]], + ), +) +def test_get_suggestions(word, possible_words, count, expected_result): + assert get_suggestions(word, possible_words, count) == expected_result diff --git a/tests/test_text_backend.py b/tests/test_text_backend.py new file mode 100644 index 000000000..bf9296720 --- /dev/null +++ b/tests/test_text_backend.py @@ -0,0 +1,159 @@ +from textual._text_backend import TextEditorBackend + +CONTENT = "Hello, world!" + + +def test_set_content(): + editor = TextEditorBackend() + editor.set_content(CONTENT) + assert editor.content == CONTENT + + +def test_delete_back_cursor_at_start_is_noop(): + editor = TextEditorBackend(CONTENT) + assert not editor.delete_back() + assert editor == TextEditorBackend(CONTENT, 0) + + +def test_delete_back_cursor_at_end(): + editor = TextEditorBackend(CONTENT) + assert editor.cursor_text_end() + assert editor.delete_back() + assert editor == TextEditorBackend("Hello, world", 12) + + +def test_delete_back_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, 5) + assert editor.delete_back() + assert editor == TextEditorBackend("Hell, world!", 4) + + +def test_delete_forward_cursor_at_start(): + editor = TextEditorBackend(CONTENT) + assert editor.delete_forward() + assert editor.content == "ello, world!" + + +def test_delete_forward_cursor_at_end_is_noop(): + editor = TextEditorBackend(CONTENT) + assert editor.cursor_text_end() + assert not editor.delete_forward() + assert editor == TextEditorBackend(CONTENT, len(CONTENT)) + + +def test_delete_forward_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, 5) + editor.cursor_index = 5 + assert editor.delete_forward() + assert editor == TextEditorBackend("Hello world!", 5) + + +def test_cursor_left_cursor_at_start_is_noop(): + editor = TextEditorBackend(CONTENT) + assert not editor.cursor_left() + assert editor == TextEditorBackend(CONTENT) + + +def test_cursor_left_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, 6) + assert editor.cursor_left() + assert editor == TextEditorBackend(CONTENT, 5) + + +def test_cursor_left_cursor_at_end(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert editor.cursor_left() + assert editor == TextEditorBackend(CONTENT, len(CONTENT) - 1) + + +def test_cursor_right_cursor_at_start(): + editor = TextEditorBackend(CONTENT) + assert editor.cursor_right() + assert editor == TextEditorBackend(CONTENT, 1) + + +def test_cursor_right_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, 5) + assert editor.cursor_right() + assert editor == TextEditorBackend(CONTENT, 6) + + +def test_cursor_right_cursor_at_end_is_noop(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + editor.cursor_right() + assert editor == TextEditorBackend(CONTENT, len(CONTENT)) + + +def test_query_cursor_left_cursor_at_start_returns_false(): + editor = TextEditorBackend(CONTENT) + assert not editor.query_cursor_left() + + +def test_query_cursor_left_cursor_at_end_returns_true(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert editor.query_cursor_left() + + +def test_query_cursor_left_cursor_in_middle_returns_true(): + editor = TextEditorBackend(CONTENT, 6) + assert editor.query_cursor_left() + + +def test_query_cursor_right_cursor_at_start_returns_true(): + editor = TextEditorBackend(CONTENT) + assert editor.query_cursor_right() + + +def test_query_cursor_right_cursor_in_middle_returns_true(): + editor = TextEditorBackend(CONTENT, 6) + assert editor.query_cursor_right() + + +def test_query_cursor_right_cursor_at_end_returns_false(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert not editor.query_cursor_right() + +def test_cursor_text_start_cursor_already_at_start(): + editor = TextEditorBackend(CONTENT) + assert not editor.cursor_text_start() + assert editor.cursor_index == 0 + +def test_cursor_text_start_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, 6) + assert editor.cursor_text_start() + assert editor.cursor_index == 0 + +def test_cursor_text_end_cursor_already_at_end(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert not editor.cursor_text_end() + assert editor.cursor_index == len(CONTENT) + +def test_cursor_text_end_cursor_in_middle(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert not editor.cursor_text_end() + assert editor.cursor_index == len(CONTENT) + + +def test_insert_at_cursor_cursor_at_start(): + editor = TextEditorBackend(CONTENT) + assert editor.insert("ABC") + assert editor.content == "ABC" + CONTENT + assert editor.cursor_index == len("ABC") + +def test_insert_at_cursor_cursor_in_middle(): + start_cursor_index = 6 + editor = TextEditorBackend(CONTENT, start_cursor_index) + assert editor.insert("ABC") + assert editor.content == "Hello,ABC world!" + assert editor.cursor_index == start_cursor_index + len("ABC") + + +def test_insert_at_cursor_cursor_at_end(): + editor = TextEditorBackend(CONTENT, len(CONTENT)) + assert editor.insert("ABC") + assert editor.content == CONTENT + "ABC" + assert editor.cursor_index == len(editor.content) + +def test_get_range(): + editor = TextEditorBackend(CONTENT) + assert editor.get_range(0, 5) == "Hello" diff --git a/tests/test_widget.py b/tests/test_widget.py new file mode 100644 index 000000000..9c81c3fe4 --- /dev/null +++ b/tests/test_widget.py @@ -0,0 +1,66 @@ +import pytest + +from textual.app import App +from textual.css.errors import StyleValueError +from textual.geometry import Size +from textual.widget import Widget + + +@pytest.mark.parametrize( + "set_val, get_val, style_str", + [ + [True, True, "visible"], + [False, False, "hidden"], + ["hidden", False, "hidden"], + ["visible", True, "visible"], + ], +) +def test_widget_set_visible_true(set_val, get_val, style_str): + widget = Widget() + widget.visible = set_val + + assert widget.visible is get_val + assert widget.styles.visibility == style_str + + +def test_widget_set_visible_invalid_string(): + widget = Widget() + + with pytest.raises(StyleValueError): + widget.visible = "nope! no widget for me!" + + assert widget.visible + + +def test_widget_content_width(): + class TextWidget(Widget): + def __init__(self, text: str, id: str) -> None: + self.text = text + super().__init__(id=id) + self.expand = False + self.shrink = True + + def render(self) -> str: + return self.text + + widget1 = TextWidget("foo", id="widget1") + widget2 = TextWidget("foo\nbar", id="widget2") + widget3 = TextWidget("foo\nbar\nbaz", id="widget3") + + app = App() + app._set_active() + + width = widget1.get_content_width(Size(20, 20), Size(80, 24)) + height = widget1.get_content_height(Size(20, 20), Size(80, 24), width) + assert width == 3 + assert height == 1 + + width = widget2.get_content_width(Size(20, 20), Size(80, 24)) + height = widget2.get_content_height(Size(20, 20), Size(80, 24), width) + assert width == 3 + assert height == 2 + + width = widget3.get_content_width(Size(20, 20), Size(80, 24)) + height = widget3.get_content_height(Size(20, 20), Size(80, 24), width) + assert width == 3 + assert height == 3 diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py new file mode 100644 index 000000000..993903a05 --- /dev/null +++ b/tests/test_xterm_parser.py @@ -0,0 +1,307 @@ +import itertools +from unittest import mock + +import pytest + +from textual._xterm_parser import XTermParser +from textual.events import ( + Paste, + Key, + MouseDown, + MouseUp, + MouseMove, + MouseScrollDown, + MouseScrollUp, +) +from textual.messages import TerminalSupportsSynchronizedOutput + + +def chunks(data, size): + if size == 0: + yield data + return + + chunk_start = 0 + chunk_end = size + while True: + yield data[chunk_start:chunk_end] + chunk_start = chunk_end + chunk_end += size + if chunk_end >= len(data): + yield data[chunk_start:chunk_end] + break + + +@pytest.fixture +def parser(): + return XTermParser(sender=mock.sentinel, more_data=lambda: False) + + +@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" + + data = end + text + events = [] + for chunk in chunks(data, chunk_size): + events.append(parser.feed(chunk)) + + events = list(itertools.chain.from_iterable(list(event) for event in events)) + + assert events[0].key == "end" + assert [event.key for event in events[1:]] == list(text) + + +def test_bracketed_paste(parser): + """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. + """ + pasted_text = "PASTED" + events = list(parser.feed(f"\x1b[200~{pasted_text}\x1b[201~")) + + assert len(events) == 1 + assert isinstance(events[0], Paste) + assert events[0].text == pasted_text + assert events[0].sender == mock.sentinel + + +def test_bracketed_paste_content_contains_escape_codes(parser): + """When performing a bracketed paste, if the pasted content contains + supported ANSI escape sequences, it should not interfere with the paste, + and no escape sequences within the bracketed paste should be converted + into Textual events. + """ + pasted_text = "PAS\x0fTED" + events = list(parser.feed(f"\x1b[200~{pasted_text}\x1b[201~")) + assert len(events) == 1 + assert events[0].text == pasted_text + + +def test_bracketed_paste_amongst_other_codes(parser): + pasted_text = "PASTED" + events = list(parser.feed(f"\x1b[8~\x1b[200~{pasted_text}\x1b[201~\x1b[8~")) + assert len(events) == 3 # Key.End -> Paste -> Key.End + assert events[0].key == "end" + assert events[1].text == pasted_text + assert events[2].key == "end" + + +def test_cant_match_escape_sequence_too_long(parser): + """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" + events = list(parser.feed(sequence)) + + # Every character in the sequence is converted to a key press + assert len(events) == len(sequence) + assert all(isinstance(event, Key) for event in events) + + # When we backtrack '\x1b' is translated to '^' + assert events[0].key == "circumflex_accent" + + # The rest of the characters correspond to the expected key presses + events = events[1:] + for index, character in enumerate(sequence[1:]): + assert events[index].char == 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, + ], +) +def test_unknown_sequence_followed_by_known_sequence(parser, chunk_size): + """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. + """ + unknown_sequence = "\x1b[?" + known_sequence = "\x1b[8~" # key = 'end' + + sequence = unknown_sequence + known_sequence + + events = [] + parser.more_data = lambda: True + for chunk in chunks(sequence, chunk_size): + events.append(parser.feed(chunk)) + + events = list(itertools.chain.from_iterable(list(event) for event in events)) + + assert [event.key for event in events] == [ + "circumflex_accent", + "left_square_bracket", + "question_mark", + "end", + ] + + +def test_simple_key_presses_all_delivered_correct_order(parser): + sequence = "123abc" + events = parser.feed(sequence) + assert "".join(event.key for event in events) == sequence + + +def test_simple_keypress_non_character_key(parser): + sequence = "\x09" + events = list(parser.feed(sequence)) + assert len(events) == 1 + assert events[0].key == "tab" + + +def test_key_presses_and_escape_sequence_mixed(parser): + sequence = "abc\x1b[13~123" + events = list(parser.feed(sequence)) + + assert len(events) == 7 + assert "".join(event.key for event in events) == "abcf3123" + + +def test_single_escape(parser): + """A single \x1b should be interpreted as a single press of the Escape key""" + events = parser.feed("\x1b") + assert [event.key for event in events] == ["escape"] + + +def test_double_escape(parser): + """Windows Terminal writes double ESC when the user presses the Escape key once.""" + events = parser.feed("\x1b\x1b") + 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), + ], +) +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)) + + assert len(events) == 1 + + event = events[0] + + assert isinstance(event, event_type) + assert event.x == 49 + assert event.y == 24 + assert event.screen_x == 49 + assert event.screen_y == 24 + assert event.meta is 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 + ], +) +def test_mouse_move(parser, sequence, shift, meta, button): + events = list(parser.feed(sequence)) + + assert len(events) == 1 + + event = events[0] + + assert isinstance(event, MouseMove) + assert event.x == 14 + assert event.y == 37 + assert event.shift is shift + assert event.meta is meta + assert event.button == button + + +@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. + """ + events = list(parser.feed(sequence)) + + assert len(events) == 1 + + event = events[0] + + assert isinstance(event, MouseScrollUp) + assert event.x == 17 + assert event.y == 24 + + +@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, MouseScrollDown) + assert event.x == 17 + assert event.y == 24 + + +def test_mouse_event_detected_but_info_not_parsed(parser): + # I don't know if this can actually happen in reality, but + # there's a branch in the code that allows for the possibility. + events = list(parser.feed("\x1b[<65;18;20;25M")) + assert len(events) == 0 + + +def test_escape_sequence_resulting_in_multiple_keypresses(parser): + """Some sequences are interpreted as more than 1 keypress""" + events = list(parser.feed("\x1b[2;4~")) + assert len(events) == 2 + assert events[0].key == "escape" + assert events[1].key == "shift+insert" + + +def test_terminal_mode_reporting_synchronized_output_supported(parser): + sequence = "\x1b[?2026;1$y" + events = list(parser.feed(sequence)) + assert len(events) == 1 + assert isinstance(events[0], TerminalSupportsSynchronizedOutput) + assert events[0].sender == mock.sentinel + + +def test_terminal_mode_reporting_synchronized_output_not_supported(parser): + sequence = "\x1b[?2026;0$y" + events = list(parser.feed(sequence)) + assert events == [] diff --git a/tests/utilities/render.py b/tests/utilities/render.py new file mode 100644 index 000000000..f1511e53c --- /dev/null +++ b/tests/utilities/render.py @@ -0,0 +1,56 @@ +import asyncio +import io +import re +from typing import Callable + +import pytest +from rich.console import Console, RenderableType + +re_link_ids = re.compile(r"id=[\d\.\-]*?;.*?\x1b") + + +def replace_link_ids(render: str) -> str: + """Link IDs have a random ID and system path which is a problem for + reproducible tests. + + """ + return re_link_ids.sub("id=0;foo\x1b", render) + + +def render(renderable: RenderableType, no_wrap: bool = False) -> str: + console = Console( + width=100, file=io.StringIO(), color_system="truecolor", legacy_windows=False + ) + with console.capture() as capture: + console.print(renderable, no_wrap=no_wrap, end="") + output = replace_link_ids(capture.get()) + return output + + +async def wait_for_predicate( + predicate: Callable[[], bool], + timeout_secs: float = 2, + poll_delay_secs: float = 0.001, +) -> None: + """Wait for the given predicate to become True by evaluating it every `poll_delay_secs` + seconds. Fail the pytest test if the predicate does not become True after `timeout_secs` + seconds. + + Args: + predicate (Callable[[], bool]): The predicate function which will be called repeatedly. + timeout_secs (float): If the predicate doesn't evaluate to True after this number of + seconds, the test will fail. + poll_delay_secs (float): The number of seconds to wait between each call to the + predicate function. + """ + time_taken = 0 + while True: + result = predicate() + if result: + return + await asyncio.sleep(poll_delay_secs) + time_taken += poll_delay_secs + if time_taken > timeout_secs: + pytest.fail( + f"Predicate {predicate} did not return True after {timeout_secs} seconds." + ) diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py new file mode 100644 index 000000000..25678f28d --- /dev/null +++ b/tests/utilities/test_app.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import asyncio +import contextlib +import io +from math import ceil +from pathlib import Path +from time import monotonic +from typing import AsyncContextManager, cast, ContextManager +from unittest import mock + +from rich.console import Console + +from textual import events, errors +from textual._ansi_sequences import SYNC_START +from textual._clock import _Clock +from textual._context import active_app +from textual.app import App, ComposeResult +from textual.app import WINDOWS +from textual.driver import Driver +from textual.geometry import Size, Region + + +# N.B. These classes would better be named TestApp/TestConsole/TestDriver/etc, +# but it makes pytest emit warning as it will try to collect them as classes containing test cases :-/ + + +class AppTest(App): + def __init__(self, *, test_name: str, size: Size): + # Tests will log in "/tests/test.[test name].log": + log_path = Path(__file__).parent.parent / f"test.{test_name}.log" + super().__init__( + driver_class=DriverTest, + ) + + # Let's disable all features by default + self.features = frozenset() + + # We need this so the "start buffeting"` is always sent for a screen refresh, + # whatever the environment: + # (we use it to slice the output into distinct full screens displays) + self._sync_available = True + + self._size = size + self._console = ConsoleTest(width=size.width, height=size.height) + self._error_console = ConsoleTest(width=size.width, height=size.height) + + def log_tree(self) -> None: + """Handy shortcut when testing stuff""" + self.log(self.tree) + + def compose(self) -> ComposeResult: + raise NotImplementedError( + "Create a subclass of TestApp and override its `compose()` method, rather than using TestApp directly" + ) + + def in_running_state( + self, + *, + time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks + waiting_duration_after_initialisation: float = 1, + waiting_duration_after_yield: float = 0, + ) -> AsyncContextManager[ClockMock]: + async def run_app() -> None: + await self._process_messages() + + @contextlib.asynccontextmanager + async def get_running_state_context_manager(): + with mock_textual_timers( + ticks_granularity_fps=time_mocking_ticks_granularity_fps + ) as clock_mock: + run_task = asyncio.create_task(run_app()) + + # We have to do this because `run_app()` is running in its own async task, and our test is going to + # run in this one - so the app must also be the active App in our current context: + self._set_active() + + await clock_mock.advance_clock(waiting_duration_after_initialisation) + # make sure the App has entered its main loop at this stage: + assert self._driver is not None + + await self.force_full_screen_update() + + # And now it's time to pass the torch on to the test function! + # We provide the `move_clock_forward` function to it, + # so it can also do some time-based Textual stuff if it needs to: + yield clock_mock + + await clock_mock.advance_clock(waiting_duration_after_yield) + + # Make sure our screen is up-to-date before exiting the context manager, + # so tests using our `last_display_capture` for example can assert things on a fully refreshed screen: + await self.force_full_screen_update() + + # 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() + + async def boot_and_shutdown( + self, + *, + waiting_duration_after_initialisation: float = 0.001, + waiting_duration_before_shutdown: float = 0, + ): + """Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases""" + async with self.in_running_state( + waiting_duration_after_initialisation=waiting_duration_after_initialisation, + waiting_duration_after_yield=waiting_duration_before_shutdown, + ): + pass + + def get_char_at(self, x: int, y: int) -> str: + """Get the character at the given cell or empty string + + Args: + x (int): X position within the Layout + y (int): Y position within the Layout + + Returns: + str: The character at the cell (x, y) within the Layout + """ + # N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()` + try: + widget, region = self.get_widget_at(x, y) + except errors.NoWidget: + return "" + if widget not in self.screen._compositor.visible_widgets: + return "" + + x -= region.x + y -= region.y + lines = widget.render_lines(Region(0, y, region.width, 1)) + if not lines: + return "" + end = 0 + for segment in lines[0]: + end += segment.cell_length + if x < end: + return segment.text[0] + return "" + + async def force_full_screen_update( + self, *, repaint: bool = True, layout: bool = True + ) -> None: + try: + screen = self.screen + except IndexError: + return # the app may not have a screen yet + + # We artificially tell the Compositor that the whole area should be refreshed + screen._compositor._dirty_regions = { + Region(0, 0, screen.outer_size.width, screen.outer_size.height), + } + screen.refresh(repaint=repaint, layout=layout) + # We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return: + screen._dirty_widgets.add(screen) + screen._on_timer_update() + + await let_asyncio_process_some_events() + + def _handle_exception(self, error: Exception) -> None: + # In tests we want the errors to be raised, rather than printed to a Console + raise error + + def run(self): + raise NotImplementedError( + "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`" + ) + + @property + def active_app(self) -> App | None: + return active_app.get() + + @property + def total_capture(self) -> str | None: + return self.console.file.getvalue() + + @property + def last_display_capture(self) -> str | None: + total_capture = self.total_capture + if not total_capture: + return None + screen_captures = total_capture.split(SYNC_START) + for single_screen_capture in reversed(screen_captures): + if len(single_screen_capture) > 30: + # let's return the last occurrence of a screen that seem to be properly "fully-paint" + return single_screen_capture + return None + + @property + def console(self) -> ConsoleTest: + return self._console + + @console.setter + def console(self, console: Console) -> None: + """This is a no-op, the console is always a TestConsole""" + return + + @property + def error_console(self) -> ConsoleTest: + return self._error_console + + @error_console.setter + def error_console(self, console: Console) -> None: + """This is a no-op, the error console is always a TestConsole""" + return + + +class ConsoleTest(Console): + def __init__(self, *, width: int, height: int): + file = io.StringIO() + super().__init__( + color_system="256", + file=file, + width=width, + height=height, + force_terminal=False, + legacy_windows=False, + ) + + @property + def file(self) -> io.StringIO: + return cast(io.StringIO, self._file) + + @property + def is_dumb_terminal(self) -> bool: + return False + + +class DriverTest(Driver): + def start_application_mode(self) -> None: + size = Size(self.console.size.width, self.console.size.height) + event = events.Resize(self._target, size, size) + asyncio.run_coroutine_threadsafe( + self._target.post_message(event), + loop=asyncio.get_running_loop(), + ) + + def disable_input(self) -> None: + pass + + def stop_application_mode(self) -> None: + pass + + +# It seems that we have to give _way more_ time to `asyncio` on Windows in order to see our different awaiters +# properly triggered when we pause our own "move clock forward" loop. +# It could be caused by the fact that the time resolution for `asyncio` on this platform seems rather low: +# > The resolution of the monotonic clock on Windows is usually around 15.6 msec. +# > The best resolution is 0.5 msec. +# @link https://docs.python.org/3/library/asyncio-platforms.html: +ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.005 + + +async def let_asyncio_process_some_events() -> None: + await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD) + + +class ClockMock(_Clock): + # To avoid issues with floats we will store the current time as an integer internally. + # Tenths of microseconds should be a good enough granularity: + TIME_RESOLUTION = 10_000_000 + + def __init__( + self, + *, + ticks_granularity_fps: int = 60, + ): + self._ticks_granularity_fps = ticks_granularity_fps + self._single_tick_duration = int(self.TIME_RESOLUTION / ticks_granularity_fps) + self._start_time: int = -1 + self._current_time: int = -1 + # For each call to our `sleep` method we will store an asyncio.Event + # and the time at which we should trigger it: + self._pending_sleep_events: dict[int, list[asyncio.Event]] = {} + + def get_time_no_wait(self) -> float: + if self._current_time == -1: + self._start_clock() + + return self._current_time / self.TIME_RESOLUTION + + async def sleep(self, seconds: float) -> None: + event = asyncio.Event() + internal_waiting_duration = int(seconds * self.TIME_RESOLUTION) + target_event_monotonic_time = self._current_time + internal_waiting_duration + self._pending_sleep_events.setdefault(target_event_monotonic_time, []).append( + event + ) + # Ok, let's wait for this Event + # (which can only be "unlocked" by calls to `advance_clock()`) + await event.wait() + + async def advance_clock(self, seconds: float) -> None: + """ + Artificially advance the Textual clock forward. + + Args: + seconds: for each second we will artificially tick `ticks_granularity_fps` times + """ + if self._current_time == -1: + self._start_clock() + + ticks_count = ceil(seconds * self._ticks_granularity_fps) + activated_timers_count_total = 0 # useful when debugging this code :-) + for tick_counter in range(ticks_count): + self._current_time += self._single_tick_duration + activated_timers_count = self._check_sleep_timers_to_activate() + activated_timers_count_total += activated_timers_count + # Now that we likely unlocked some occurrences of `await sleep(duration)`, + # let's give an opportunity to asyncio-related stuff to happen: + if activated_timers_count: + await let_asyncio_process_some_events() + + await let_asyncio_process_some_events() + + def _start_clock(self) -> None: + # N.B. `start_time` is not actually used, but it is useful to have when we set breakpoints there :-) + self._start_time = self._current_time = int(monotonic() * self.TIME_RESOLUTION) + + def _check_sleep_timers_to_activate(self) -> int: + activated_timers_count = 0 + activated_events_times_to_clear: list[int] = [] + for (monotonic_time, target_events) in self._pending_sleep_events.items(): + if self._current_time < monotonic_time: + continue # not time for you yet, dear awaiter... + # Right, let's release these waiting events! + for event in target_events: + event.set() + activated_timers_count += len(target_events) + # ...and let's mark it for removal: + activated_events_times_to_clear.append(monotonic_time) + + for event_time_to_clear in activated_events_times_to_clear: + del self._pending_sleep_events[event_time_to_clear] + + return activated_timers_count + + +def mock_textual_timers( + *, + ticks_granularity_fps: int = 60, +) -> ContextManager[ClockMock]: + @contextlib.contextmanager + def mock_textual_timers_context_manager(): + clock_mock = ClockMock(ticks_granularity_fps=ticks_granularity_fps) + with mock.patch("textual._clock._clock", new=clock_mock): + yield clock_mock + + return mock_textual_timers_context_manager()