mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' of github.com:Textualize/textual into docs-animator
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
16
docs/examples/guide/screens/modal01.css
Normal file
16
docs/examples/guide/screens/modal01.css
Normal 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%;
|
||||
}
|
||||
37
docs/examples/guide/screens/modal01.py
Normal file
37
docs/examples/guide/screens/modal01.py
Normal 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()
|
||||
18
docs/examples/guide/screens/screen01.css
Normal file
18
docs/examples/guide/screens/screen01.css
Normal 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;
|
||||
}
|
||||
34
docs/examples/guide/screens/screen01.py
Normal file
34
docs/examples/guide/screens/screen01.py
Normal 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()
|
||||
18
docs/examples/guide/screens/screen02.css
Normal file
18
docs/examples/guide/screens/screen02.css
Normal 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;
|
||||
}
|
||||
36
docs/examples/guide/screens/screen02.py
Normal file
36
docs/examples/guide/screens/screen02.py
Normal 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()
|
||||
15
docs/examples/widgets/footer.py
Normal file
15
docs/examples/widgets/footer.py
Normal 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()
|
||||
12
docs/examples/widgets/header.py
Normal file
12
docs/examples/widgets/header.py
Normal 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()
|
||||
13
docs/examples/widgets/input.py
Normal file
13
docs/examples/widgets/input.py
Normal 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()
|
||||
12
docs/examples/widgets/static.py
Normal file
12
docs/examples/widgets/static.py
Normal 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()
|
||||
29
docs/examples/widgets/table.py
Normal file
29
docs/examples/widgets/table.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
docs/images/screens/pop_screen.excalidraw.svg
Normal file
16
docs/images/screens/pop_screen.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
16
docs/images/screens/push_screen.excalidraw.svg
Normal file
16
docs/images/screens/push_screen.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 21 KiB |
16
docs/images/screens/switch_screen.excalidraw.svg
Normal file
16
docs/images/screens/switch_screen.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +1 @@
|
||||
::: textual.app.App
|
||||
::: textual.app
|
||||
|
||||
1
docs/reference/data_table.md
Normal file
1
docs/reference/data_table.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.DataTable
|
||||
1
docs/reference/footer.md
Normal file
1
docs/reference/footer.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Footer
|
||||
1
docs/reference/header.md
Normal file
1
docs/reference/header.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Header
|
||||
1
docs/reference/input.md
Normal file
1
docs/reference/input.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.widgets.Input
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
34
poetry.lock
generated
@@ -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"},
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,6 +46,19 @@ class Bindings:
|
||||
def make_bindings(bindings: Iterable[BindingType]) -> Iterable[Binding]:
|
||||
for binding in bindings:
|
||||
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
|
||||
else:
|
||||
if len(binding) != 3:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
0
src/textual/py.typed
Normal 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
31
tests/test_binding.py
Normal 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"),
|
||||
}
|
||||
Reference in New Issue
Block a user