Merge branch 'css' of github.com:Textualize/textual into docs-animator

This commit is contained in:
Darren Burns
2022-10-06 14:11:19 +01:00
57 changed files with 919 additions and 168 deletions

View File

@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.2.0] - Unreleased ## [0.2.0] - Unreleased
### Added
- CSS support
- Too numerous to mention
## [0.1.15] - 2022-01-31 ## [0.1.15] - 2022-01-31
### Added ### Added

View File

@@ -8,7 +8,7 @@ The `Click` event is sent to a widget when the user clicks a mouse button.
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- | |------------|------|-------------------------------------------|
| `x` | int | Mouse x coordinate, relative to Widget | | `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget |
| `delta_x` | int | Change in x since last mouse event | | `delta_x` | int | Change in x since last mouse event |

View File

@@ -8,7 +8,7 @@ The `MouseMove` event is sent to a widget when the mouse pointer is moved over a
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- | |------------|------|-------------------------------------------|
| `x` | int | Mouse x coordinate, relative to Widget | | `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget |
| `delta_x` | int | Change in x since last mouse event | | `delta_x` | int | Change in x since last mouse event |

View File

@@ -8,7 +8,7 @@ The `MouseRelease` event is sent to a widget when it is no longer receiving mous
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| ---------------- | ------ | --------------------------------------------- | |------------------|--------|-----------------------------------------------|
| `mouse_position` | Offset | Mouse coordinates when the mouse was released | | `mouse_position` | Offset | Mouse coordinates when the mouse was released |
## Code ## Code

View File

@@ -8,7 +8,7 @@ The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackp
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ---- | -------------------------------------- | |-----------|------|----------------------------------------|
| `x` | int | Mouse x coordinate, relative to Widget | | `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget |

View File

@@ -8,7 +8,7 @@ The `MouseScrollUp` event is sent to a widget when the scroll wheel (or trackpad
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ---- | -------------------------------------- | |-----------|------|----------------------------------------|
| `x` | int | Mouse x coordinate, relative to Widget | | `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget |

View File

@@ -8,7 +8,7 @@ The `MouseUp` event is sent to a widget when the user releases a mouse button.
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| ---------- | ---- | ----------------------------------------- | |------------|------|-------------------------------------------|
| `x` | int | Mouse x coordinate, relative to Widget | | `x` | int | Mouse x coordinate, relative to Widget |
| `y` | int | Mouse y coordinate, relative to Widget | | `y` | int | Mouse y coordinate, relative to Widget |
| `delta_x` | int | Change in x since last mouse event | | `delta_x` | int | Change in x since last mouse event |

View File

@@ -8,7 +8,7 @@ The `Paste` event is sent to a widget when the user pastes text.
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| --------- | ---- | ------------------------ | |-----------|------|--------------------------|
| `text` | str | The text that was pasted | | `text` | str | The text that was pasted |
## Code ## Code

View File

@@ -8,7 +8,7 @@ The `Resize` event is sent to a widget when its size changes and when it is firs
## Attributes ## Attributes
| attribute | type | purpose | | attribute | type | purpose |
| ---------------- | ---- | ------------------------------------------------ | |------------------|------|--------------------------------------------------|
| `size` | Size | The new size of the Widget | | `size` | Size | The new size of the Widget |
| `virtual_size` | Size | The virtual size (scrollable area) of the Widget | | `virtual_size` | Size | The virtual size (scrollable area) of the Widget |
| `container_size` | Size | The size of the container (parent widget) | | `container_size` | Size | The size of the container (parent widget) |

View File

@@ -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%;
}

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -0,0 +1,34 @@
from textual.app import App, Screen, ComposeResult
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()

View File

@@ -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;
}

View File

@@ -0,0 +1,36 @@
from textual.app import App, Screen, ComposeResult
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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -24,19 +24,23 @@ 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. If you plan on developing Textual apps, then you should install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development.
```bash ```
pip install "textual[dev]" pip install "textual[dev]==0.2.0b5"
``` ```
If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: If you only plan on _running_ Textual apps, then you can drop the `[dev]` part:
```bash
pip install textual
``` ```
pip install textual==0.2.0b5
```
!!! important
There may be a more recent beta version since the time of writing. Check the [release history](https://pypi.org/project/textual/#history) for a more recent version.
## Textual CLI ## Textual CLI
If you installed the dev dependencies you have have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps. 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 ```bash
textual --help textual --help

View File

@@ -131,10 +131,26 @@ Textual supports the following builtin actions which are defined on the app.
options: options:
show_root_heading: false show_root_heading: false
### Push screen
::: textual.app.App.action_push_screen
### Pop screen
::: textual.app.App.action_pop_screen
### Screenshot ### Screenshot
::: textual.app.App.action_screenshot ::: textual.app.App.action_screenshot
### Switch screen
::: textual.app.App.action_switch_screen
### Toggle_dark ### Toggle_dark
::: textual.app.App.action_toggle_dark ::: textual.app.App.action_toggle_dark

View File

@@ -1,12 +1,151 @@
# Screens # Screens
TODO: Screens docs This chapter covers Textual's screen API. We will discuss how to create screens and switch between them.
- Explanation of screens ## What is a screen?
- Screens API
- Install 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.
- Uninstall screen
- Push screen 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.
- Pop screen
- Switch Screen !!! tip
- Screens example
Try printing `widget.parent` to see what object your widget is connected to.
<div class="excalidraw">
--8<-- "docs/images/dom1.excalidraw.svg"
</div>
## 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="17-23 28"
--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++ screen 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 associated 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 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="30-31"
--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 the `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.
<div class="excalidraw">
--8<-- "docs/images/screens/push_screen.excalidraw.svg"
</div>
#### 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.
<div class="excalidraw">
--8<-- "docs/images/screens/pop_screen.excalidraw.svg"
</div>
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.
<div class="excalidraw">
--8<-- "docs/images/screens/switch_screen.excalidraw.svg"
</div>
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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -1 +1 @@
::: textual.app.App ::: textual.app

View File

@@ -0,0 +1 @@
::: textual.widgets.DataTable

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

@@ -0,0 +1 @@
::: textual.widgets.Footer

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

@@ -0,0 +1 @@
::: textual.widgets.Header

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

@@ -0,0 +1 @@
::: textual.widgets.Input

View File

@@ -35,13 +35,13 @@ If you want to try the finished Stopwatch app and follow along with the code, fi
=== "HTTPS" === "HTTPS"
```bash ```bash
git clone https://github.com/Textualize/textual.git git clone -b css https://github.com/Textualize/textual.git
``` ```
=== "SSH" === "SSH"
```bash ```bash
git clone git@github.com:Textualize/textual.git git clone -b css git@github.com:Textualize/textual.git
``` ```
=== "GitHub CLI" === "GitHub CLI"
@@ -50,6 +50,7 @@ If you want to try the finished Stopwatch app and follow along with the code, fi
gh repo clone Textualize/textual gh repo clone Textualize/textual
``` ```
With the repository cloned, navigate to `docs/examples/tutorial` and run `stopwatch.py`. With the repository cloned, navigate to `docs/examples/tutorial` and run `stopwatch.py`.
```bash ```bash
@@ -152,7 +153,7 @@ Textual has a builtin `Button` widget which takes care of the first three compon
Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go. Let's add those to the app. Just a skeleton for now, we will add the rest of the features as we go.
```python title="stopwatch02.py" hl_lines="3 6-7 10-18 30" ```python title="stopwatch02.py" hl_lines="2-3 6-7 10-18 30"
--8<-- "docs/examples/tutorial/stopwatch02.py" --8<-- "docs/examples/tutorial/stopwatch02.py"
``` ```
@@ -160,7 +161,7 @@ We've imported two new widgets in this code: `Button`, which creates a clickable
We've defined an empty `TimeDisplay` widget by extending `Static`. We will flesh this out later. We've defined an empty `TimeDisplay` widget by extending `Static`. We will flesh this out later.
The Stopwatch widget also class extends `Static`. This class has a `compose()` method which yields child widgets, consisting of three `Button` objects and a single `TimeDisplay`. These widgets will form the stopwatch in our sketch. 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 buttons
@@ -377,7 +378,7 @@ We've seen how we can update widgets with a timer, but we still need to wire up
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. 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 30-44 50-61" ```python title="stopwatch06.py" hl_lines="14 18 30-44 50-61"
--8<-- "docs/examples/tutorial/stopwatch06.py" --8<-- "docs/examples/tutorial/stopwatch06.py"
``` ```
@@ -431,6 +432,7 @@ Let's use these methods to implement adding and removing stopwatches to our app.
Here's a summary of the changes: 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_add_stopwatch` to add a new stopwatch.
- Added `action_remove_stopwatch` to remove a stopwatch. - Added `action_remove_stopwatch` to remove a stopwatch.
- Added keybindings for the actions. - Added keybindings for the actions.

View File

@@ -1,6 +1,5 @@
# Button # Button
## Description
A simple button widget which can be pressed using a mouse click or by pressing ++return++ A simple button widget which can be pressed using a mouse click or by pressing ++return++
when it has focus. when it has focus.
@@ -33,16 +32,16 @@ Clicking any of the non-disabled buttons in the example app below will result in
## Reactive Attributes ## Reactive Attributes
| Name | Type | Default | Description | | Name | Type | Default | Description |
|------------|--------|-------------|-----------------------------------------------------------------------------------------------------------------------------------| | ---------- | ------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `label` | `str` | `""` | The text that appears inside the button. | | `label` | `str` | `""` | The text that appears inside the button. |
| `variant` | `str` | `"default"` | Semantic styling variant. One of `default`, `primary`, `success`, `warning`, `error`. | | `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. | | `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. |
## Events ## Messages
### Pressed ### Pressed
The `Button.Pressed` event is sent when the button is pressed. The `Button.Pressed` message is sent when the button is pressed.
- [x] Bubbles - [x] Bubbles

View File

@@ -1 +1,38 @@
# DataTable # 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
* [Table][textual.widgets.DataTable] code reference

View File

@@ -1 +1,42 @@
# Footer # 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 keybind.
=== "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

View File

@@ -1 +1,35 @@
# Header # 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

View File

@@ -1 +1,67 @@
# Input # 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

View File

@@ -1 +1,34 @@
# Static # 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

View File

@@ -1,6 +1,7 @@
site_name: Textual site_name: Textual
site_url: https://textual.textualize.io/ site_url: https://textual.textualize.io/
repo_url: https://github.com/textualize/textual/ repo_url: https://github.com/textualize/textual/
edit_uri: edit/css/docs/
nav: nav:
- Introduction: - Introduction:
@@ -101,7 +102,9 @@ nav:
- "reference/containers.md" - "reference/containers.md"
- "reference/dom_node.md" - "reference/dom_node.md"
- "reference/events.md" - "reference/events.md"
- "reference/footer.md"
- "reference/geometry.md" - "reference/geometry.md"
- "reference/header.md"
- "reference/index.md" - "reference/index.md"
- "reference/message_pump.md" - "reference/message_pump.md"
- "reference/message.md" - "reference/message.md"

34
poetry.lock generated
View File

@@ -230,7 +230,7 @@ async = ["aiofiles (>=0.7,<1.0)"]
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.5.5" version = "2.5.6"
description = "File identification library for Python" description = "File identification library for Python"
category = "dev" category = "dev"
optional = false optional = false
@@ -429,11 +429,11 @@ python-versions = ">=3.7"
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "0.950" version = "0.982"
description = "Optional static typing for Python" description = "Optional static typing for Python"
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
mypy-extensions = ">=0.4.3" mypy-extensions = ">=0.4.3"
@@ -842,7 +842,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "d976cf5f1d28001eecafb074b02e644f16c971828be5a115d1c3b9b2db49fc70" content-hash = "84203bb5193474eb9204f4f808739cb25e61f02a38d0062ea2ea71d3703573c1"
[metadata.files] [metadata.files]
aiohttp = [] aiohttp = []
@@ -1086,31 +1086,7 @@ multidict = [
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"}, {file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
] ]
mypy = [ mypy = []
{file = "mypy-0.950-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf9c261958a769a3bd38c3e133801ebcd284ffb734ea12d01457cb09eacf7d7b"},
{file = "mypy-0.950-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5b5bd0ffb11b4aba2bb6d31b8643902c48f990cc92fda4e21afac658044f0c0"},
{file = "mypy-0.950-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e7647df0f8fc947388e6251d728189cfadb3b1e558407f93254e35abc026e22"},
{file = "mypy-0.950-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:eaff8156016487c1af5ffa5304c3e3fd183edcb412f3e9c72db349faf3f6e0eb"},
{file = "mypy-0.950-cp310-cp310-win_amd64.whl", hash = "sha256:563514c7dc504698fb66bb1cf897657a173a496406f1866afae73ab5b3cdb334"},
{file = "mypy-0.950-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dd4d670eee9610bf61c25c940e9ade2d0ed05eb44227275cce88701fee014b1f"},
{file = "mypy-0.950-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca75ecf2783395ca3016a5e455cb322ba26b6d33b4b413fcdedfc632e67941dc"},
{file = "mypy-0.950-cp36-cp36m-win_amd64.whl", hash = "sha256:6003de687c13196e8a1243a5e4bcce617d79b88f83ee6625437e335d89dfebe2"},
{file = "mypy-0.950-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4c653e4846f287051599ed8f4b3c044b80e540e88feec76b11044ddc5612ffed"},
{file = "mypy-0.950-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e19736af56947addedce4674c0971e5dceef1b5ec7d667fe86bcd2b07f8f9075"},
{file = "mypy-0.950-cp37-cp37m-win_amd64.whl", hash = "sha256:ef7beb2a3582eb7a9f37beaf38a28acfd801988cde688760aea9e6cc4832b10b"},
{file = "mypy-0.950-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0112752a6ff07230f9ec2f71b0d3d4e088a910fdce454fdb6553e83ed0eced7d"},
{file = "mypy-0.950-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ee0a36edd332ed2c5208565ae6e3a7afc0eabb53f5327e281f2ef03a6bc7687a"},
{file = "mypy-0.950-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77423570c04aca807508a492037abbd72b12a1fb25a385847d191cd50b2c9605"},
{file = "mypy-0.950-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5ce6a09042b6da16d773d2110e44f169683d8cc8687e79ec6d1181a72cb028d2"},
{file = "mypy-0.950-cp38-cp38-win_amd64.whl", hash = "sha256:5b231afd6a6e951381b9ef09a1223b1feabe13625388db48a8690f8daa9b71ff"},
{file = "mypy-0.950-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0384d9f3af49837baa92f559d3fa673e6d2652a16550a9ee07fc08c736f5e6f8"},
{file = "mypy-0.950-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1fdeb0a0f64f2a874a4c1f5271f06e40e1e9779bf55f9567f149466fc7a55038"},
{file = "mypy-0.950-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:61504b9a5ae166ba5ecfed9e93357fd51aa693d3d434b582a925338a2ff57fd2"},
{file = "mypy-0.950-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a952b8bc0ae278fc6316e6384f67bb9a396eb30aced6ad034d3a76120ebcc519"},
{file = "mypy-0.950-cp39-cp39-win_amd64.whl", hash = "sha256:eaea21d150fb26d7b4856766e7addcf929119dd19fc832b22e71d942835201ef"},
{file = "mypy-0.950-py3-none-any.whl", hash = "sha256:a4d9898f46446bfb6405383b57b96737dcfd0a7f25b748e78ef3e8c576bba3cb"},
{file = "mypy-0.950.tar.gz", hash = "sha256:1b333cfbca1762ff15808a0ef4f71b5d3eed8528b23ea1c3fb50543c867d68de"},
]
mypy-extensions = [ mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {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"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},

View File

@@ -1,10 +1,11 @@
[tool.poetry] [tool.poetry]
name = "textual" name = "textual"
version = "0.1.15" version = "0.2.0b5"
homepage = "https://github.com/Textualize/textual" homepage = "https://github.com/Textualize/textual"
description = "Text User Interface using Rich" description = "Modern Text User Interface framework"
authors = ["Will McGugan <willmcgugan@gmail.com>"] authors = ["Will McGugan <will@textualize.io>"]
license = "MIT" license = "MIT"
readme = "README.md"
classifiers = [ classifiers = [
"Development Status :: 1 - Planning", "Development Status :: 1 - Planning",
"Environment :: Console", "Environment :: Console",
@@ -15,6 +16,10 @@ classifiers = [
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Typing :: Typed",
]
include = [
"src/textual/py.typed"
] ]
[tool.poetry.scripts] [tool.poetry.scripts]
@@ -25,7 +30,7 @@ python = "^3.7"
rich = "^12.6.0" rich = "^12.6.0"
#rich = {path="../rich", develop=true} #rich = {path="../rich", develop=true}
importlib-metadata = "^4.11.3" importlib-metadata = "^4.11.3"
typing-extensions = { version = "^4.0.0", python = "<3.8" } typing-extensions = { version = "^4.0.0", python = "<3.10" }
# Dependencies below are required for devtools only # Dependencies below are required for devtools only
aiohttp = { version = "^3.8.1", optional = true } aiohttp = { version = "^3.8.1", optional = true }
@@ -39,7 +44,7 @@ dev = ["aiohttp", "click", "msgpack"]
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.3" pytest = "^7.1.3"
black = "^22.3.0" black = "^22.3.0"
mypy = "^0.950" mypy = "^0.982"
pytest-cov = "^2.12.1" pytest-cov = "^2.12.1"
mkdocs = "^1.3.0" mkdocs = "^1.3.0"
mkdocstrings = {extras = ["python"], version = "^0.19.0"} mkdocstrings = {extras = ["python"], version = "^0.19.0"}

View File

@@ -11,7 +11,7 @@ from textual.app import App
from textual.geometry import Size from textual.geometry import Size
from textual.reactive import Reactive from textual.reactive import Reactive
from textual.widget import Widget from textual.widget import Widget
from textual.widgets.text_input import TextInput, TextWidgetBase from textual.widgets._input import Input
def get_files() -> list[Path]: def get_files() -> list[Path]:
@@ -53,12 +53,12 @@ class FileSearchApp(App):
def on_mount(self) -> None: def on_mount(self) -> None:
self.file_table = FileTable(id="file_table", files=list(Path.cwd().iterdir())) self.file_table = FileTable(id="file_table", files=list(Path.cwd().iterdir()))
self.search_bar = TextInput(placeholder="Search for files...") self.search_bar = Input(placeholder="Search for files...")
self.search_bar.focus() # self.search_bar.focus()
self.mount(file_table_wrapper=Widget(self.file_table))
self.mount(search_bar=self.search_bar) self.mount(search_bar=self.search_bar)
self.mount(file_table_wrapper=Widget(self.file_table))
def on_text_input_changed(self, event: TextInput.Changed) -> None: def on_input_changed(self, event: Input.Changed) -> None:
self.file_table.filter = event.value self.file_table.filter = event.value

View File

@@ -1,12 +1,8 @@
Screen { Screen {
} }
#file_table_wrapper { #file_table_wrapper {
dock: bottom;
height: auto;
overflow: auto auto;
scrollbar-color: $accent-darken-1; scrollbar-color: $accent-darken-1;
} }
@@ -15,7 +11,5 @@ Screen {
} }
#search_bar { #search_bar {
dock: bottom;
background: $accent;
height: 1; height: 1;
} }

View File

@@ -8,7 +8,7 @@ from textual.widgets import Static, Footer
class JustABox(App): class JustABox(App):
BINDINGS = [ BINDINGS = [
Binding(key="t", action="text_fade_out", description="text-opacity fade out"), Binding(key="t", action="text_fade_out", description="text-opacity fade out"),
Binding(key="o", action="widget_fade_out", description="opacity fade out"), Binding(key="o,f,w", action="widget_fade_out", description="opacity fade out"),
] ]
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:

View File

@@ -38,12 +38,12 @@ class TableApp(App):
table = self.table = DataTable(id="data") table = self.table = DataTable(id="data")
yield table yield table
table.add_column("Foo", width=20) table.add_column("Foo")
table.add_column("Bar", width=60) table.add_column("Bar")
table.add_column("Baz", width=20) table.add_column("Baz")
table.add_column("Foo", width=16) table.add_column("Foo")
table.add_column("Bar", width=16) table.add_column("Bar")
table.add_column("Baz", width=16) table.add_column("Baz")
for n in range(200): for n in range(200):
height = 1 height = 1

View File

@@ -26,20 +26,11 @@ import nanoid
import rich import rich
import rich.repr import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.protocol import is_renderable from rich.protocol import is_renderable
from rich.segment import Segment, Segments from rich.segment import Segment, Segments
from rich.traceback import Traceback from rich.traceback import Traceback
from . import ( from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
Logger,
LogGroup,
LogVerbosity,
actions,
events,
log,
messages,
)
from ._animator import Animator from ._animator import Animator
from ._callback import invoke from ._callback import invoke
from ._context import active_app from ._context import active_app
@@ -113,7 +104,7 @@ class ScreenError(Exception):
class ScreenStackError(ScreenError): class ScreenStackError(ScreenError):
pass """Raised when attempting to pop the last screen from the stack."""
ReturnType = TypeVar("ReturnType") ReturnType = TypeVar("ReturnType")
@@ -223,6 +214,7 @@ class App(Generic[ReturnType], DOMNode):
self._installed_screens: WeakValueDictionary[ self._installed_screens: WeakValueDictionary[
str, Screen str, Screen
] = WeakValueDictionary() ] = WeakValueDictionary()
self._installed_screens.update(**self.SCREENS)
self.devtools = DevtoolsClient() self.devtools = DevtoolsClient()
self._return_value: ReturnType | None = None self._return_value: ReturnType | None = None
@@ -805,7 +797,7 @@ class App(Generic[ReturnType], DOMNode):
try: try:
next_screen = self._installed_screens[screen] next_screen = self._installed_screens[screen]
except KeyError: except KeyError:
raise KeyError("No screen called {screen!r} installed") from None raise KeyError(f"No screen called {screen!r} installed") from None
else: else:
next_screen = screen next_screen = screen
if not next_screen.is_running: if not next_screen.is_running:
@@ -833,7 +825,7 @@ class App(Generic[ReturnType], DOMNode):
"""Push a new screen on the screen stack. """Push a new screen on the screen stack.
Args: Args:
screen (Screen | str): A Screen instance or an id. screen (Screen | str): A Screen instance or the name of an installed screen.
""" """
next_screen = self.get_screen(screen) next_screen = self.get_screen(screen)
@@ -935,7 +927,7 @@ class App(Generic[ReturnType], DOMNode):
widget (Widget): Widget to focus. widget (Widget): Widget to focus.
scroll_visible (bool, optional): Scroll widget in to view. scroll_visible (bool, optional): Scroll widget in to view.
""" """
if widget == self.focused: if widget is self.focused:
# Widget is already focused # Widget is already focused
return return
@@ -1112,6 +1104,7 @@ class App(Generic[ReturnType], DOMNode):
mount_event = events.Mount(sender=self) mount_event = events.Mount(sender=self)
await self._dispatch_message(mount_event) await self._dispatch_message(mount_event)
Reactive.initialize_object(self)
self.title = self._title self.title = self._title
self.stylesheet.update(self) self.stylesheet.update(self)
self.refresh() self.refresh()

View File

@@ -1,17 +1,16 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
import rich.repr
from dataclasses import dataclass from dataclasses import dataclass
from typing import Iterable, MutableMapping from typing import Iterable, MutableMapping
import rich.repr
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
from typing import TypeAlias from typing import TypeAlias
else: # pragma: no cover else: # pragma: no cover
from typing_extensions import TypeAlias from typing_extensions import TypeAlias
BindingType: TypeAlias = "Binding | tuple[str, str, str]" BindingType: TypeAlias = "Binding | tuple[str, str, str]"
@@ -23,7 +22,7 @@ class NoBinding(Exception):
"""A binding was not found.""" """A binding was not found."""
@dataclass @dataclass(frozen=True)
class Binding: class Binding:
key: str key: str
"""Key to bind.""" """Key to bind."""
@@ -47,6 +46,19 @@ class Bindings:
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]: def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
for binding in bindings: for binding in bindings:
if isinstance(binding, Binding): if isinstance(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,
allow_forward=binding.allow_forward,
)
yield new_binding
else:
yield binding yield binding
else: else:
if len(binding) != 3: if len(binding) != 3:

View File

@@ -223,7 +223,7 @@ class Size(NamedTuple):
class Region(NamedTuple): class Region(NamedTuple):
"""Defines a rectangular region. """Defines a rectangular region.
A Region consists a coordinate (x and y) and dimensions (width and height). A Region consists of a coordinate (x and y) and dimensions (width and height).
``` ```
(x, y) (x, y)

View File

@@ -286,6 +286,7 @@ class MessagePump(metaclass=MessagePumpMeta):
def _start_messages(self) -> None: def _start_messages(self) -> None:
"""Start messages task.""" """Start messages task."""
Reactive.initialize_object(self)
self._task = asyncio.create_task(self._process_messages()) self._task = asyncio.create_task(self._process_messages())
async def _process_messages(self) -> None: async def _process_messages(self) -> None:
@@ -303,7 +304,6 @@ class MessagePump(metaclass=MessagePumpMeta):
"""Process messages until the queue is closed.""" """Process messages until the queue is closed."""
_rich_traceback_guard = True _rich_traceback_guard = True
await Reactive.initialize_object(self)
while not self._closed: while not self._closed:
try: try:
message = await self._get_message() message = await self._get_message()

0
src/textual/py.typed Normal file
View File

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
Reactable = Union[Widget, App] Reactable = Union[Widget, App]
ReactiveType = TypeVar("ReactiveType", covariant=True) ReactiveType = TypeVar("ReactiveType")
T = TypeVar("T") T = TypeVar("T")
@@ -83,7 +83,7 @@ class Reactive(Generic[ReactiveType]):
return cls(default, layout=False, repaint=False, init=True) return cls(default, layout=False, repaint=False, init=True)
@classmethod @classmethod
async def initialize_object(cls, obj: object) -> None: def initialize_object(cls, obj: object) -> None:
"""Call any watchers / computes for the first time. """Call any watchers / computes for the first time.
Args: Args:

View File

@@ -40,8 +40,13 @@ class Screen(Widget):
dark: Reactive[bool] = Reactive(False) dark: Reactive[bool] = Reactive(False)
def __init__(self, name: str | None = None, id: str | None = None) -> None: def __init__(
super().__init__(name=name, id=id) 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._compositor = Compositor()
self._dirty_widgets: set[Widget] = set() self._dirty_widgets: set[Widget] = set()
self._update_timer: Timer | None = None self._update_timer: Timer | None = None

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from itertools import chain
import sys import sys
from typing import ClassVar, Generic, NamedTuple, TypeVar, cast 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.console import RenderableType
from rich.padding import Padding from rich.padding import Padding
@@ -12,18 +12,16 @@ from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.text import Text, TextType from rich.text import Text, TextType
from .. import events from .. import events, messages
from .._cache import LRUCache from .._cache import LRUCache
from .._profile import timer
from .._segment_tools import line_crop from .._segment_tools import line_crop
from .._types import Lines from .._types import Lines
from ..geometry import clamp, Region, Size, Spacing from ..geometry import Region, Size, Spacing, clamp
from ..reactive import Reactive from ..reactive import Reactive
from .._profile import timer from ..render import measure
from ..scroll_view import ScrollView from ..scroll_view import ScrollView
from .. import messages
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
from typing import Literal from typing import Literal
else: else:
@@ -55,10 +53,22 @@ class Column:
"""Table column.""" """Table column."""
label: Text label: Text
width: int width: int = 0
visible: bool = False visible: bool = False
index: int = 0 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 @dataclass
class Row: class Row:
@@ -168,23 +178,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.rows: dict[int, Row] = {} self.rows: dict[int, Row] = {}
self.data: dict[int, list[CellType]] = {} self.data: dict[int, list[CellType]] = {}
self.row_count = 0 self.row_count = 0
self._y_offsets: list[tuple[int, int]] = [] self._y_offsets: list[tuple[int, int]] = []
self._row_render_cache: LRUCache[ self._row_render_cache: LRUCache[
tuple[int, int, Style, int, int], tuple[Lines, Lines] tuple[int, int, Style, int, int], tuple[Lines, Lines]
] ]
self._row_render_cache = LRUCache(1000) self._row_render_cache = LRUCache(1000)
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines] self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines]
self._cell_render_cache = LRUCache(10000) self._cell_render_cache = LRUCache(10000)
self._line_cache: LRUCache[ self._line_cache: LRUCache[
tuple[int, int, int, int, int, int, Style], list[Segment] tuple[int, int, int, int, int, int, Style], list[Segment]
] ]
self._line_cache = LRUCache(1000) self._line_cache = LRUCache(1000)
self._line_no = 0 self._line_no = 0
self._require_update_dimensions: bool = False
self._new_rows: set[int] = set()
show_header = Reactive(True) show_header = Reactive(True)
fixed_rows = Reactive(0) fixed_rows = Reactive(0)
@@ -251,9 +259,16 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
column = clamp(column, self.fixed_columns, len(self.columns) - 1) column = clamp(column, self.fixed_columns, len(self.columns) - 1)
return Coord(row, column) return Coord(row, column)
def _update_dimensions(self) -> None: def _update_dimensions(self, new_rows: Iterable[int]) -> None:
"""Called to recalculate the virtual (scrollable) size.""" """Called to recalculate the virtual (scrollable) size."""
total_width = sum(column.width for column in self.columns) 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)
self.virtual_size = Size( self.virtual_size = Size(
total_width, total_width,
max(len(self._y_offsets), (self.header_height if self.show_header else 0)), max(len(self._y_offsets), (self.header_height if self.show_header else 0)),
@@ -263,8 +278,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
if row_index not in self.rows: if row_index not in self.rows:
return Region(0, 0, 0, 0) return Region(0, 0, 0, 0)
row = self.rows[row_index] row = self.rows[row_index]
x = sum(column.width for column in self.columns[:column_index]) x = sum(column.render_width for column in self.columns[:column_index])
width = self.columns[column_index].width width = self.columns[column_index].render_width
height = row.height height = row.height
y = row.y y = row.y
if self.show_header: if self.show_header:
@@ -272,25 +287,52 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
cell_region = Region(x, y, width, height) cell_region = Region(x, y, width, height)
return cell_region return cell_region
def add_column(self, label: TextType, *, width: int = 10) -> None: 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. """Add a column to the table.
Args: Args:
label (TextType): A str or Text object containing the label (shown top of column) label (TextType): A str or Text object containing the label (shown top of column).
width (int, optional): Width of the column in cells. Defaults to 10. 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 text_label = Text.from_markup(label) if isinstance(label, str) else label
self.columns.append(Column(text_label, width, index=len(self.columns)))
self._update_dimensions() content_width = measure(self.app.console, text_label, 1)
self.refresh() 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: def add_row(self, *cells: CellType, height: int = 1) -> None:
"""Add a row. """Add a row.
Args: Args:
*cells: Positional arguments should contain cell data.
height (int, optional): The height of a row (in lines). Defaults to 1. height (int, optional): The height of a row (in lines). Defaults to 1.
""" """
row_index = self.row_count row_index = self.row_count
self.data[row_index] = list(cells) self.data[row_index] = list(cells)
self.rows[row_index] = Row(row_index, height, self._line_no) self.rows[row_index] = Row(row_index, height, self._line_no)
@@ -299,10 +341,34 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.row_count += 1 self.row_count += 1
self._line_no += height self._line_no += height
self._update_dimensions()
self.refresh() 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: 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: if row_index < 0 or column_index < 0:
return return
region = self._get_cell_region(row_index, column_index) region = self._get_cell_region(row_index, column_index)
@@ -330,7 +396,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
if data is None: if data is None:
return [empty for _ in self.columns] return [empty for _ in self.columns]
else: else:
return [default_cell_formatter(datum) or empty for datum in data] 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( def _render_cell(
self, self,
@@ -401,7 +470,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
fixed_style = self.get_component_styles("datatable--fixed").rich_style fixed_style = self.get_component_styles("datatable--fixed").rich_style
fixed_style += Style.from_meta({"fixed": True}) fixed_style += Style.from_meta({"fixed": True})
fixed_row = [ fixed_row = [
render_cell(row_index, column.index, fixed_style, column.width)[line_no] render_cell(row_index, column.index, fixed_style, column.render_width)[
line_no
]
for column in self.columns[: self.fixed_columns] for column in self.columns[: self.fixed_columns]
] ]
else: else:
@@ -423,7 +494,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
row_index, row_index,
column.index, column.index,
row_style, row_style,
column.width, column.render_width,
cursor=cursor_column == column.index, cursor=cursor_column == column.index,
hover=hover_column == column.index, hover=hover_column == column.index,
)[line_no] )[line_no]
@@ -490,7 +561,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
cursor_column=cursor_column, cursor_column=cursor_column,
hover_column=hover_column, hover_column=hover_column,
) )
fixed_width = sum(column.width for column in self.columns[: self.fixed_columns]) 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 [] fixed_line: list[Segment] = list(chain.from_iterable(fixed)) if fixed else []
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
@@ -503,14 +576,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
return segments return segments
def render_line(self, y: int) -> list[Segment]: 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.
"""
width, height = self.size width, height = self.size
scroll_x, scroll_y = self.scroll_offset scroll_x, scroll_y = self.scroll_offset
fixed_top_row_count = sum( fixed_top_row_count = sum(
@@ -534,9 +599,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
except KeyError: except KeyError:
pass pass
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def _get_cell_border(self) -> Spacing: def _get_cell_border(self) -> Spacing:
top = self.header_height if self.show_header else 0 top = self.header_height if self.show_header else 0
top += sum( top += sum(
@@ -544,7 +606,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
for row_index in range(self.fixed_rows) for row_index in range(self.fixed_rows)
if row_index in self.rows if row_index in self.rows
) )
left = sum(column.width for column in self.columns[: self.fixed_columns]) left = sum(column.render_width for column in self.columns[: self.fixed_columns])
return Spacing(top, 0, 0, left) return Spacing(top, 0, 0, left)
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None: def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:

View File

@@ -12,6 +12,7 @@ from ..widget import Widget
@rich.repr.auto @rich.repr.auto
class Footer(Widget): class Footer(Widget):
"""A simple header widget which docks itself to the top of the parent container."""
DEFAULT_CSS = """ DEFAULT_CSS = """
Footer { Footer {
@@ -65,7 +66,7 @@ class Footer(Widget):
self.highlight_key = event.style.meta.get("key") self.highlight_key = event.style.meta.get("key")
async def on_leave(self, event: events.Leave) -> None: 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 self.highlight_key = None
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:

View File

@@ -10,7 +10,7 @@ from .. import events
from .._segment_tools import line_crop from .._segment_tools import line_crop
from ..binding import Binding from ..binding import Binding
from ..geometry import Size from ..geometry import Size
from ..message import Message, MessageTarget from ..message import Message
from ..reactive import reactive from ..reactive import reactive
from ..widget import Widget from ..widget import Widget
@@ -82,9 +82,9 @@ class Input(Widget, can_focus=True):
Binding("left", "cursor_left", "cursor left"), Binding("left", "cursor_left", "cursor left"),
Binding("right", "cursor_right", "cursor right"), Binding("right", "cursor_right", "cursor right"),
Binding("backspace", "delete_left", "delete left"), Binding("backspace", "delete_left", "delete left"),
Binding("home", "home", "Home"), Binding("home", "home", "home"),
Binding("end", "end", "Home"), Binding("end", "end", "end"),
Binding("ctrl+d", "delete_right", "Delete"), Binding("ctrl+d", "delete_right", "delete right"),
Binding("enter", "submit", "Submit"), Binding("enter", "submit", "Submit"),
] ]

View File

@@ -25,13 +25,14 @@ def _check_renderable(renderable: object):
class Static(Widget): class Static(Widget):
"""A widget to display simple static content, or use as a base- lass for more complex widgets. """A widget to display simple static content, or use as a base class for more complex widgets.
Args: Args:
renderable (RenderableType, optional): A Rich renderable, or string containing console markup. renderable (RenderableType, optional): A Rich renderable, or string containing console markup.
Defaults to "". Defaults to "".
expand (bool, optional): Expand content if required to fill container. Defaults to False. 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. 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. name (str | None, optional): Name of widget. Defaults to None.
id (str | None, optional): ID 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. classes (str | None, optional): Space separated list of class names. Defaults to None.

31
tests/test_binding.py Normal file
View File

@@ -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"),
}