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
### Added
- CSS support
- Too numerous to mention
## [0.1.15] - 2022-01-31
### Added

View File

@@ -8,7 +8,7 @@ The `Click` event is sent to a widget when the user clicks a mouse button.
## 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 |

View File

@@ -8,7 +8,7 @@ The `MouseMove` event is sent to a widget when the mouse pointer is moved over a
## 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 |

View File

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

View File

@@ -8,7 +8,7 @@ The `MouseScrollDown` event is sent to a widget when the scroll wheel (or trackp
## Attributes
| attribute | type | purpose |
| --------- | ---- | -------------------------------------- |
|-----------|------|----------------------------------------|
| `x` | int | Mouse x 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
| attribute | type | purpose |
| --------- | ---- | -------------------------------------- |
|-----------|------|----------------------------------------|
| `x` | int | Mouse x 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
| 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 |

View File

@@ -8,7 +8,7 @@ The `Paste` event is sent to a widget when the user pastes text.
## Attributes
| attribute | type | purpose |
| --------- | ---- | ------------------------ |
|-----------|------|--------------------------|
| `text` | str | The text that was pasted |
## 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
| attribute | type | purpose |
| ---------------- | ---- | ------------------------------------------------ |
|------------------|------|--------------------------------------------------|
| `size` | Size | The new size of the Widget |
| `virtual_size` | Size | The virtual size (scrollable area) of the Widget |
| `container_size` | Size | The size of the container (parent widget) |

View File

@@ -0,0 +1,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.
```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:
```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
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
textual --help

View File

@@ -131,10 +131,26 @@ Textual supports the following builtin actions which are defined on the app.
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

View File

@@ -1,12 +1,151 @@
# 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
- Screens API
- Install screen
- Uninstall screen
- Push screen
- Pop screen
- Switch Screen
- Screens example
## 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.
<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"
```bash
git clone https://github.com/Textualize/textual.git
git clone -b css https://github.com/Textualize/textual.git
```
=== "SSH"
```bash
git clone git@github.com:Textualize/textual.git
git clone -b css git@github.com:Textualize/textual.git
```
=== "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
```
With the repository cloned, navigate to `docs/examples/tutorial` and run `stopwatch.py`.
```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.
```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"
```
@@ -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.
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
@@ -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.
```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"
```
@@ -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:
- 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.

View File

@@ -1,6 +1,5 @@
# Button
## Description
A simple button widget which can be pressed using a mouse click or by pressing ++return++
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
| 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. |
## Events
## Messages
### 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

View File

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

View File

@@ -1 +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 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
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
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
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_url: https://textual.textualize.io/
repo_url: https://github.com/textualize/textual/
edit_uri: edit/css/docs/
nav:
- Introduction:
@@ -101,7 +102,9 @@ nav:
- "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"

34
poetry.lock generated
View File

@@ -230,7 +230,7 @@ async = ["aiofiles (>=0.7,<1.0)"]
[[package]]
name = "identify"
version = "2.5.5"
version = "2.5.6"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -429,11 +429,11 @@ python-versions = ">=3.7"
[[package]]
name = "mypy"
version = "0.950"
version = "0.982"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
[package.dependencies]
mypy-extensions = ">=0.4.3"
@@ -842,7 +842,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "d976cf5f1d28001eecafb074b02e644f16c971828be5a115d1c3b9b2db49fc70"
content-hash = "84203bb5193474eb9204f4f808739cb25e61f02a38d0062ea2ea71d3703573c1"
[metadata.files]
aiohttp = []
@@ -1086,31 +1086,7 @@ multidict = [
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
]
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 = []
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"},

View File

@@ -1,10 +1,11 @@
[tool.poetry]
name = "textual"
version = "0.1.15"
version = "0.2.0b5"
homepage = "https://github.com/Textualize/textual"
description = "Text User Interface using Rich"
authors = ["Will McGugan <willmcgugan@gmail.com>"]
description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"]
license = "MIT"
readme = "README.md"
classifiers = [
"Development Status :: 1 - Planning",
"Environment :: Console",
@@ -15,6 +16,10 @@ classifiers = [
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Typing :: Typed",
]
include = [
"src/textual/py.typed"
]
[tool.poetry.scripts]
@@ -25,7 +30,7 @@ python = "^3.7"
rich = "^12.6.0"
#rich = {path="../rich", develop=true}
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
aiohttp = { version = "^3.8.1", optional = true }
@@ -39,7 +44,7 @@ dev = ["aiohttp", "click", "msgpack"]
[tool.poetry.dev-dependencies]
pytest = "^7.1.3"
black = "^22.3.0"
mypy = "^0.950"
mypy = "^0.982"
pytest-cov = "^2.12.1"
mkdocs = "^1.3.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.reactive import Reactive
from textual.widget import Widget
from textual.widgets.text_input import TextInput, TextWidgetBase
from textual.widgets._input import Input
def get_files() -> list[Path]:
@@ -53,12 +53,12 @@ class FileSearchApp(App):
def on_mount(self) -> None:
self.file_table = FileTable(id="file_table", files=list(Path.cwd().iterdir()))
self.search_bar = TextInput(placeholder="Search for files...")
self.search_bar.focus()
self.mount(file_table_wrapper=Widget(self.file_table))
self.search_bar = Input(placeholder="Search for files...")
# self.search_bar.focus()
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

View File

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

View File

@@ -8,7 +8,7 @@ from textual.widgets import Static, Footer
class JustABox(App):
BINDINGS = [
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:

View File

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

View File

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

View File

@@ -1,17 +1,16 @@
from __future__ import annotations
import sys
import rich.repr
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]"
@@ -23,7 +22,7 @@ class NoBinding(Exception):
"""A binding was not found."""
@dataclass
@dataclass(frozen=True)
class Binding:
key: str
"""Key to bind."""
@@ -47,7 +46,20 @@ class Bindings:
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
for binding in bindings:
if isinstance(binding, Binding):
yield 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
else:
if len(binding) != 3:
raise BindingError(

View File

@@ -223,7 +223,7 @@ class Size(NamedTuple):
class Region(NamedTuple):
"""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)

View File

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

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass, field
from itertools import chain
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.padding import Padding
@@ -12,18 +12,16 @@ from rich.segment import Segment
from rich.style import Style
from rich.text import Text, TextType
from .. import events
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 clamp, Region, Size, Spacing
from ..geometry import Region, Size, Spacing, clamp
from ..reactive import Reactive
from .._profile import timer
from ..render import measure
from ..scroll_view import ScrollView
from .. import messages
if sys.version_info >= (3, 8):
from typing import Literal
else:
@@ -55,10 +53,22 @@ class Column:
"""Table column."""
label: Text
width: int
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:
@@ -168,23 +178,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
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)
@@ -251,9 +259,16 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
column = clamp(column, self.fixed_columns, len(self.columns) - 1)
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."""
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(
total_width,
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:
return Region(0, 0, 0, 0)
row = self.rows[row_index]
x = sum(column.width for column in self.columns[:column_index])
width = self.columns[column_index].width
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:
@@ -272,25 +287,52 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
cell_region = Region(x, y, width, height)
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.
Args:
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.
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
self.columns.append(Column(text_label, width, index=len(self.columns)))
self._update_dimensions()
self.refresh()
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)
@@ -299,10 +341,34 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.row_count += 1
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:
"""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)
@@ -330,7 +396,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
if data is None:
return [empty for _ in self.columns]
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(
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 += Style.from_meta({"fixed": True})
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]
]
else:
@@ -423,7 +494,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
row_index,
column.index,
row_style,
column.width,
column.render_width,
cursor=cursor_column == column.index,
hover=hover_column == column.index,
)[line_no]
@@ -490,7 +561,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
cursor_column=cursor_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 []
scrollable_line: list[Segment] = list(chain.from_iterable(scrollable))
@@ -503,14 +576,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
return segments
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
scroll_x, scroll_y = self.scroll_offset
fixed_top_row_count = sum(
@@ -534,9 +599,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
except KeyError:
pass
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def _get_cell_border(self) -> Spacing:
top = self.header_height if self.show_header else 0
top += sum(
@@ -544,7 +606,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
for row_index in range(self.fixed_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)
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:

View File

@@ -12,6 +12,7 @@ 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 {
@@ -65,7 +66,7 @@ class Footer(Widget):
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:

View File

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

View File

@@ -25,13 +25,14 @@ def _check_renderable(renderable: object):
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:
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.

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