Merge branch 'main' of github.com:willmcgugan/textual into datatable-cell-keys

This commit is contained in:
Darren Burns
2023-02-06 12:46:07 +00:00
86 changed files with 2564 additions and 676 deletions

View File

@@ -17,21 +17,27 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added more keyboard actions and related bindings to `Input` https://github.com/Textualize/textual/pull/1676 - Added more keyboard actions and related bindings to `Input` https://github.com/Textualize/textual/pull/1676
- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928 - Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928
- Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally - Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally
- Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to <kbd>Space</kbd> https://github.com/Textualize/textual/issues/1433
- Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437
### Changed ### Changed
- Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637 - Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637
- `Tree` now shows a (subdued) cursor for a highlighted node when focus has moved elsewhere https://github.com/Textualize/textual/issues/1471
### Fixed ### Fixed
- Fixed stuck screen https://github.com/Textualize/textual/issues/1632 - Fixed stuck screen https://github.com/Textualize/textual/issues/1632
- Fixed programmatic style changes not refreshing children layouts when parent widget did not change size https://github.com/Textualize/textual/issues/1607
- Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406 - Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406
- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372 - Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372
- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372 - Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372
- Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616 - Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616
- Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657 - Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657
- Added a workaround for an apparent Windows Terminal paste issue https://github.com/Textualize/textual/issues/1661 - Added a workaround for an apparent Windows Terminal paste issue https://github.com/Textualize/textual/issues/1661
- Fixes issue with renderable width calculation https://github.com/Textualize/textual/issues/1685 - Fixed issue with renderable width calculation https://github.com/Textualize/textual/issues/1685
- Fixed issue with app not processing Paste event https://github.com/Textualize/textual/issues/1666
- Fixed glitch with view position with auto width inputs https://github.com/Textualize/textual/issues/1693
## [0.10.1] - 2023-01-20 ## [0.10.1] - 2023-01-20

View File

@@ -0,0 +1,67 @@
{{ log.debug("Rendering " + attribute.path) }}
<div class="doc doc-object doc-attribute">
{% with html_id = attribute.path %}
{% if root %}
{% set show_full_path = config.show_root_full_path %}
{% set root_members = True %}
{% elif root_members %}
{% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %}
{% set root_members = False %}
{% else %}
{% set show_full_path = config.show_object_full_path %}
{% endif %}
{% if not root or config.show_root_heading %}
{% filter heading(heading_level,
role="data" if attribute.parent.kind.value == "module" else "attr",
id=html_id,
class="doc doc-heading",
toc_label=attribute.name) %}
{% if config.separate_signature %}
<span class="doc doc-object-name doc-attribute-name">{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}</span>
{% else %}
{% filter highlight(language="python", inline=True) %}
{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}
{% if attribute.annotation %}: {{ attribute.annotation }}{% endif %}
{% endfilter %}
{% endif %}
{% with labels = attribute.labels %}
{% include "labels.html" with context %}
{% endwith %}
{% endfilter %}
{% if config.separate_signature %}
{% filter highlight(language="python", inline=False) %}
{% filter format_code(config.line_length) %}
{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}
{% if attribute.annotation %}: {{ attribute.annotation|safe }}{% endif %}
{% endfilter %}
{% endfilter %}
{% endif %}
{% else %}
{% if config.show_root_toc_entry %}
{% filter heading(heading_level,
role="data" if attribute.parent.kind.value == "module" else "attr",
id=html_id,
toc_label=attribute.path if config.show_root_full_path else attribute.name,
hidden=True) %}
{% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
<div class="doc doc-contents {% if root %}first{% endif %}">
{% with docstring_sections = attribute.docstring.parsed %}
{% include "docstring.html" with context %}
{% endwith %}
</div>
{% endwith %}
</div>

1
docs/api/scroll_view.md Normal file
View File

@@ -0,0 +1 @@
::: textual.scroll_view.ScrollView

1
docs/api/strip.md Normal file
View File

@@ -0,0 +1 @@
::: textual.strip.Strip

View File

@@ -1,10 +1,10 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Static, Button from textual.widgets import Label, Button
class QuestionApp(App[str]): class QuestionApp(App[str]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static("Do you love Textual?") yield Label("Do you love Textual?")
yield Button("Yes", id="yes", variant="primary") yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no", variant="error") yield Button("No", id="no", variant="error")

View File

@@ -1,12 +1,12 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Static, Button from textual.widgets import Label, Button
class QuestionApp(App[str]): class QuestionApp(App[str]):
CSS_PATH = "question02.css" CSS_PATH = "question02.css"
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static("Do you love Textual?", id="question") yield Label("Do you love Textual?", id="question")
yield Button("Yes", id="yes", variant="primary") yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no", variant="error") yield Button("No", id="no", variant="error")

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Static, Button from textual.widgets import Label, Button
class QuestionApp(App[str]): class QuestionApp(App[str]):
@@ -7,8 +7,8 @@ class QuestionApp(App[str]):
Screen { Screen {
layout: grid; layout: grid;
grid-size: 2; grid-size: 2;
grid-gutter: 2; grid-gutter: 2;
padding: 2; padding: 2;
} }
#question { #question {
width: 100%; width: 100%;
@@ -16,7 +16,7 @@ class QuestionApp(App[str]):
column-span: 2; column-span: 2;
content-align: center bottom; content-align: center bottom;
text-style: bold; text-style: bold;
} }
Button { Button {
width: 100%; width: 100%;
@@ -24,7 +24,7 @@ class QuestionApp(App[str]):
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Static("Do you love Textual?", id="question") yield Label("Do you love Textual?", id="question")
yield Button("Yes", id="yes", variant="primary") yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no", variant="error") yield Button("No", id="no", variant="error")

View File

@@ -0,0 +1,23 @@
from textual.app import App, ComposeResult
from textual.widgets import Button, Header, Label
class MyApp(App[str]):
CSS_PATH = "question02.css"
TITLE = "A Question App"
SUB_TITLE = "The most important question"
def compose(self) -> ComposeResult:
yield Header()
yield Label("Do you love Textual?", id="question")
yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id)
if __name__ == "__main__":
app = MyApp()
reply = app.run()
print(reply)

View File

@@ -0,0 +1,28 @@
from textual.app import App, ComposeResult
from textual.events import Key
from textual.widgets import Button, Header, Label
class MyApp(App[str]):
CSS_PATH = "question02.css"
TITLE = "A Question App"
SUB_TITLE = "The most important question"
def compose(self) -> ComposeResult:
yield Header()
yield Label("Do you love Textual?", id="question")
yield Button("Yes", id="yes", variant="primary")
yield Button("No", id="no", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.exit(event.button.id)
def on_key(self, event: Key):
self.title = event.key
self.sub_title = f"You just pressed {event.key}!"
if __name__ == "__main__":
app = MyApp()
reply = app.run()
print(reply)

View File

@@ -0,0 +1,43 @@
from rich.segment import Segment
from rich.style import Style
from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget
class CheckerBoard(Widget):
"""Render an 8x8 checkerboard."""
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
row_index = y // 4 # A checkerboard square consists of 4 rows
if row_index >= 8: # Generate blank lines when we reach the end
return Strip.blank(self.size.width)
is_odd = row_index % 2 # Used to alternate the starting square on each row
white = Style.parse("on white") # Get a style object for a white background
black = Style.parse("on black") # Get a style object for a black background
# Generate a list of segments with alternating black and white space characters
segments = [
Segment(" " * 8, black if (column + is_odd) % 2 else white)
for column in range(8)
]
strip = Strip(segments, 8 * 8)
return strip
class BoardApp(App):
"""A simple app to show our widget."""
def compose(self) -> ComposeResult:
yield CheckerBoard()
if __name__ == "__main__":
app = BoardApp()
app.run()

View File

@@ -0,0 +1,55 @@
from rich.segment import Segment
from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget
class CheckerBoard(Widget):
"""Render an 8x8 checkerboard."""
COMPONENT_CLASSES = {
"checkerboard--white-square",
"checkerboard--black-square",
}
DEFAULT_CSS = """
CheckerBoard .checkerboard--white-square {
background: #A5BAC9;
}
CheckerBoard .checkerboard--black-square {
background: #004578;
}
"""
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
row_index = y // 4 # four lines per row
if row_index >= 8:
return Strip.blank(self.size.width)
is_odd = row_index % 2
white = self.get_component_rich_style("checkerboard--white-square")
black = self.get_component_rich_style("checkerboard--black-square")
segments = [
Segment(" " * 8, black if (column + is_odd) % 2 else white)
for column in range(8)
]
strip = Strip(segments, 8 * 8)
return strip
class BoardApp(App):
"""A simple app to show our widget."""
def compose(self) -> ComposeResult:
yield CheckerBoard()
if __name__ == "__main__":
app = BoardApp()
app.run()

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.geometry import Size
from textual.strip import Strip
from textual.scroll_view import ScrollView
from rich.segment import Segment
class CheckerBoard(ScrollView):
COMPONENT_CLASSES = {
"checkerboard--white-square",
"checkerboard--black-square",
}
DEFAULT_CSS = """
CheckerBoard .checkerboard--white-square {
background: #A5BAC9;
}
CheckerBoard .checkerboard--black-square {
background: #004578;
}
"""
def __init__(self, board_size: int) -> None:
super().__init__()
self.board_size = board_size
# Each square is 4 rows and 8 columns
self.virtual_size = Size(board_size * 8, board_size * 4)
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
scroll_x, scroll_y = self.scroll_offset # The current scroll position
y += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!
row_index = y // 4 # four lines per row
white = self.get_component_rich_style("checkerboard--white-square")
black = self.get_component_rich_style("checkerboard--black-square")
if row_index >= self.board_size:
return Strip.blank(self.size.width)
is_odd = row_index % 2
segments = [
Segment(" " * 8, black if (column + is_odd) % 2 else white)
for column in range(self.board_size)
]
strip = Strip(segments, self.board_size * 8)
# Crop the strip so that is covers the visible area
strip = strip.crop(scroll_x, scroll_x + self.size.width)
return strip
class BoardApp(App):
def compose(self) -> ComposeResult:
yield CheckerBoard(100)
if __name__ == "__main__":
app = BoardApp()
app.run()

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from textual import events
from textual.app import App, ComposeResult
from textual.geometry import Offset, Region, Size
from textual.reactive import var
from textual.strip import Strip
from textual.scroll_view import ScrollView
from rich.segment import Segment
from rich.style import Style
class CheckerBoard(ScrollView):
COMPONENT_CLASSES = {
"checkerboard--white-square",
"checkerboard--black-square",
"checkerboard--cursor-square",
}
DEFAULT_CSS = """
CheckerBoard > .checkerboard--white-square {
background: #A5BAC9;
}
CheckerBoard > .checkerboard--black-square {
background: #004578;
}
CheckerBoard > .checkerboard--cursor-square {
background: darkred;
}
"""
cursor_square = var(Offset(0, 0))
def __init__(self, board_size: int) -> None:
super().__init__()
self.board_size = board_size
# Each square is 4 rows and 8 columns
self.virtual_size = Size(board_size * 8, board_size * 4)
def on_mouse_move(self, event: events.MouseMove) -> None:
"""Called when the user moves the mouse over the widget."""
mouse_position = event.offset + self.scroll_offset
self.cursor_square = Offset(mouse_position.x // 8, mouse_position.y // 4)
def watch_cursor_square(
self, previous_square: Offset, cursor_square: Offset
) -> None:
"""Called when the cursor square changes."""
def get_square_region(square_offset: Offset) -> Region:
"""Get region relative to widget from square coordinate."""
x, y = square_offset
region = Region(x * 8, y * 4, 8, 4)
# Move the region in to the widgets frame of reference
region = region.translate(-self.scroll_offset)
return region
# Refresh the previous cursor square
self.refresh(get_square_region(previous_square))
# Refresh the new cursor square
self.refresh(get_square_region(cursor_square))
def render_line(self, y: int) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""
scroll_x, scroll_y = self.scroll_offset # The current scroll position
y += scroll_y # The line at the top of the widget is now `scroll_y`, not zero!
row_index = y // 4 # four lines per row
white = self.get_component_rich_style("checkerboard--white-square")
black = self.get_component_rich_style("checkerboard--black-square")
cursor = self.get_component_rich_style("checkerboard--cursor-square")
if row_index >= self.board_size:
return Strip.blank(self.size.width)
is_odd = row_index % 2
def get_square_style(column: int, row: int) -> Style:
"""Get the cursor style at the given position on the checkerboard."""
if self.cursor_square == Offset(column, row):
square_style = cursor
else:
square_style = black if (column + is_odd) % 2 else white
return square_style
segments = [
Segment(" " * 8, get_square_style(column, row_index))
for column in range(self.board_size)
]
strip = Strip(segments, self.board_size * 8)
# Crop the strip so that is covers the visible area
strip = strip.crop(scroll_x, scroll_x + self.size.width)
return strip
class BoardApp(App):
def compose(self) -> ComposeResult:
yield CheckerBoard(100)
if __name__ == "__main__":
app = BoardApp()
app.run()

View File

@@ -38,6 +38,7 @@ If you hit ++ctrl+c++ Textual will exit application mode and return you to the c
A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the ++option++ key. See the documentation for your terminal software for how to select text in application mode. A side effect of application mode is that you may no longer be able to select and copy text in the usual way. Terminals typically offer a way to bypass this limit with a key modifier. On iTerm you can select text if you hold the ++option++ key. See the documentation for your terminal software for how to select text in application mode.
## Events ## Events
Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with `on_` followed by the name of the event. Textual has an event system you can use to respond to key presses, mouse actions, and internal state changes. Event handlers are methods prefixed with `on_` followed by the name of the event.
@@ -116,7 +117,7 @@ When you first run this you will get a blank screen. Press any key to add the we
```{.textual path="docs/examples/app/widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"} ```{.textual path="docs/examples/app/widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"}
``` ```
### Exiting ## Exiting
An app will run until you call [App.exit()][textual.app.App.exit] which will exit application mode and the [run][textual.app.App.run] method will return. If this is the last line in your code you will return to the command prompt. An app will run until you call [App.exit()][textual.app.App.exit] which will exit application mode and the [run][textual.app.App.run] method will return. If this is the last line in your code you will return to the command prompt.
@@ -133,7 +134,7 @@ Running this app will give you the following:
Clicking either of those buttons will exit the app, and the `run()` method will return either `"yes"` or `"no"` depending on button clicked. Clicking either of those buttons will exit the app, and the `run()` method will return either `"yes"` or `"no"` depending on button clicked.
#### Return type ### Return type
You may have noticed that we subclassed `App[str]` rather than the usual `App`. You may have noticed that we subclassed `App[str]` rather than the usual `App`.
@@ -147,6 +148,7 @@ The addition of `[str]` tells mypy that `run()` is expected to return a string.
Type annotations are entirely optional (but recommended) with Textual. Type annotations are entirely optional (but recommended) with Textual.
## CSS ## CSS
Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy). Textual apps can reference [CSS](CSS.md) files which define how your app and widgets will look, while keeping your Python code free of display related code (which tends to be messy).
@@ -170,6 +172,7 @@ When `"question02.py"` runs it will load `"question02.css"` and update the app a
```{.textual path="docs/examples/app/question02.py"} ```{.textual path="docs/examples/app/question02.py"}
``` ```
### Classvar CSS ### Classvar CSS
While external CSS files are recommended for most applications, and enable some cool features like *live editing*, you can also specify the CSS directly within the Python code. While external CSS files are recommended for most applications, and enable some cool features like *live editing*, you can also specify the CSS directly within the Python code.
@@ -182,6 +185,37 @@ Here's the question app with classvar CSS:
--8<-- "docs/examples/app/question03.py" --8<-- "docs/examples/app/question03.py"
``` ```
## Title and subtitle
Textual apps have a `title` attribute which is typically the name of your application, and an optional `sub_title` attribute which adds additional context (such as the file your are working on).
By default, `title` will be set to the name of your App class, and `sub_title` is empty.
You can change these defaults by defining `TITLE` and `SUB_TITLE` class variables. Here's an example of that:
```py title="question_title01.py" hl_lines="7-8 11"
--8<-- "docs/examples/app/question_title01.py"
```
Note that the title and subtitle are displayed by the builtin [Header](./../widgets/header.md) widget at the top of the screen:
```{.textual path="docs/examples/app/question_title01.py"}
```
You can also set the title attributes dynamically within a method of your app. The following example sets the title and subtitle in response to a key press:
```py title="question_title02.py" hl_lines="20-22"
--8<-- "docs/examples/app/question_title02.py"
```
If you run this app and press the ++t++ key, you should see the header update accordingly:
```{.textual path="docs/examples/app/question_title02.py" press="t"}
```
!!! info
Note that there is no need to explicitly refresh the screen when setting the title attributes. This is an example of [reactivity](./reactivity.md), which we will cover later in the guide.
## What's next ## What's next
In the following chapter we will learn more about how to apply styles to your widgets and app. In the following chapter we will learn more about how to apply styles to your widgets and app.

View File

@@ -200,4 +200,191 @@ TODO: Explanation of compound widgets
## Line API ## Line API
TODO: Explanation of line API A downside of widgets that return Rich renderables is that Textual will redraw the entire widget when its state is updated or it changes size.
If a widget is large enough to require scrolling, or updates frequently, then this redrawing can make your app feel less responsive.
Textual offers an alternative API which reduces the amount of work required to refresh a widget, and makes it possible to update portions of a widget (as small as a single character) without a full redraw. This is known as the *line API*.
!!! note
The Line API requires a little more work that typical Rich renderables, but can produce powerful widgets such as the builtin [DataTable](./../widgets/data_table.md) which can handle thousands or even millions of rows.
### Render Line method
To build a widget with the line API, implement a `render_line` method rather than a `render` method. The `render_line` method takes a single integer argument `y` which is an offset from the top of the widget, and should return a [Strip][textual.strip.Strip] object containing that line's content.
Textual will call this method as required to get content for every row of characters in the widget.
<div class="excalidraw">
--8<-- "docs/images/render_line.excalidraw.svg"
</div>
Let's look at an example before we go in to the details. The following Textual app implements a widget with the line API that renders a checkerboard pattern. This might form the basis of a chess / checkers game. Here's the code:
=== "checker01.py"
```python title="checker01.py" hl_lines="12-31"
--8<-- "docs/examples/guide/widgets/checker01.py"
```
=== "Output"
```{.textual path="docs/examples/guide/widgets/checker01.py"}
```
The `render_line` method above calculates a `Strip` for every row of characters in the widget. Each strip contains alternating black and white space characters which form the squares in the checkerboard.
You may have noticed that the checkerboard widget makes use of some objects we haven't covered before. Let's explore those.
#### Segment and Style
A [Segment](https://rich.readthedocs.io/en/latest/protocol.html#low-level-render) is a class borrowed from the [Rich](https://github.com/Textualize/rich) project. It is small object (actually a named tuple) which bundles a string to be displayed and a [Style](https://rich.readthedocs.io/en/latest/style.html) which tells Textual how the text should look (color, bold, italic etc).
Let's look at a simple segment which would produce the text "Hello, World!" in bold.
```python
greeting = Segment("Hello, World!", Style(bold=True))
```
This would create the following object:
<div class="excalidraw">
--8<-- "docs/images/segment.excalidraw.svg"
</div>
Both Rich and Textual work with segments to generate content. A Textual app is the result of combining hundreds, or perhaps thousands, of segments,
#### Strips
A [Strip][textual.strip.Strip] is a container for a number of segments covering a single *line* (or row) in the Widget. A Strip will contain at least one segment, but often many more.
A `Strip` is constructed from a list of `Segment` objects. Here's now you might construct a strip that displays the text "Hello, World!", but with the second word in bold:
```python
segments = [
Segment("Hello, "),
Segment("World", Style(bold=True)),
Segment("!")
]
strip = Strip(segments)
```
The first and third `Segment` omit a style, which results in the widget's default style being used. The second segment has a style object which applies bold to the text "World". If this were part of a widget it would produce the text: <code>Hello, **World**!</code>
The `Strip` constructor has an optional second parameter, which should be the *cell length* of the strip. The strip above has a length of 13, so we could have constructed it like this:
```python
strip = Strip(segments, 13)
```
Note that the cell length parameter is _not_ the total number of characters in the string. It is the number of terminal "cells". Some characters (such as Asian language characters and certain emoji) take up the space of two Western alphabet characters. If you don't know in advance the number of cells your segments will occupy, it is best to omit the length parameter so that Textual calculates it automatically.
### Component classes
When applying styles to widgets we can use CSS to select the child widgets. Widgets rendered with the line API don't have children per-se, but we can still use CSS to apply styles to parts of our widget by defining *component classes*. Component classes are associated with a widget by defining a `COMPONENT_CLASSES` class variable which should be a `set` of strings containing CSS class names.
In the checkerboard example above we hard-coded the color of the squares to "white" and "black". But what if we want to create a checkerboard with different colors? We can do this by defining two component classes, one for the "white" squares and one for the "dark" squares. This will allow us to change the colors with CSS.
The following example replaces our hard-coded colors with component classes.
=== "checker02.py"
```python title="checker02.py" hl_lines="11-13 16-23 35-36"
--8<-- "docs/examples/guide/widgets/checker02.py"
```
=== "Output"
```{.textual path="docs/examples/guide/widgets/checker02.py"}
```
The `COMPONENT_CLASSES` class variable above adds two class names: `checkerboard--white-square` and `checkerboard--black-square`. These are set in the `DEFAULT_CSS` but can modified in the app's `CSS` class variable or external CSS.
!!! tip
Component classes typically begin with the name of the widget followed by *two* hyphens. This is a convention to avoid potential name clashes.
The `render_line` method calls [get_component_rich_style][textual.widget.Widget.get_component_rich_style] to get `Style` objects from the CSS, which we apply to the segments to create a more colorful looking checkerboard.
### Scrolling
A Line API widget can be made to scroll by extending the [ScrollView][textual.scroll_view.ScrollView] class (rather than `Widget`).
The `ScrollView` class will do most of the work, but we will need to manage the following details:
1. The `ScrollView` class requires a *virtual size*, which is the size of the scrollable content and should be set via the `virtual_size` property. If this is larger than the widget then Textual will add scrollbars.
2. We need to update the `render_line` method to generate strips for the visible area of the widget, taking into account the current position of the scrollbars.
Let's add scrolling to our checkerboard example. A standard 8 x 8 board isn't sufficient to demonstrate scrolling so we will make the size of the board configurable and set it to 100 x 100, for a total of 10,000 squares.
=== "checker03.py"
```python title="checker03.py" hl_lines="4 26-30 35-36 52-53"
--8<-- "docs/examples/guide/widgets/checker03.py"
```
=== "Output"
```{.textual path="docs/examples/guide/widgets/checker03.py"}
```
The virtual size is set in the constructor to match the total size of the board, which will enable scrollbars (unless you have your terminal zoomed out very far). You can update the `virtual_size` attribute dynamically as required, but our checkerboard isn't going to change size so we only need to set it once.
The `render_line` method gets the *scroll offset* which is an [Offset][textual.geometry.Offset] containing the current position of the scrollbars. We add `scroll_offset.y` to the `y` argument because `y` is relative to the top of the widget, and we need a Y coordinate relative to the scrollable content.
We also need to compensate for the position of the horizontal scrollbar. This is done in the call to `strip.crop` which *crops* the strip to the visible area between `scroll_x` and `scroll_x + self.size.width`.
!!! tip
[Strip][textual.strip.Strip] objects are immutable, so methods will return a new Strip rather than modifying the original.
<div class="excalidraw">
--8<-- "docs/images/scroll_view.excalidraw.svg"
</div>
### Region updates
The Line API makes it possible to refresh parts of a widget, as small as a single character.
Refreshing smaller regions makes updates more efficient, and keeps your widget feeling responsive.
To demonstrate this we will update the checkerboard to highlight the square under the mouse pointer.
Here's the code:
=== "checker04.py"
```python title="checker04.py" hl_lines="18 28-30 33 41-44 46-63 74 81-92"
--8<-- "docs/examples/guide/widgets/checker04.py"
```
=== "Output"
```{.textual path="docs/examples/guide/widgets/checker04.py"}
```
We've added a style to the checkerboard which is the color of the highlighted square, with a default of "darkred".
We will need this when we come to render the highlighted square.
We've also added a [reactive variable](./reactivity.md) called `cursor_square` which will hold the coordinate of the square underneath the mouse. Note that we have used [var][textual.reactive.var] which gives us reactive superpowers but won't automatically refresh the whole widget, because we want to update only the squares under the cursor.
The `on_mouse_move` handler takes the mouse coordinates from the [MouseMove][textual.events.MouseMove] object and calculates the coordinate of the square underneath the mouse. There's a little math here, so let's break it down.
- The event contains the coordinates of the mouse relative to the top left of the widget, but we need the coordinate relative to the top left of board which depends on the position of the scrollbars.
We can perform this conversion by adding `self.scroll_offset` to `event.offset`.
- Once we have the board coordinate underneath the mouse we divide the x coordinate by 8 and divide the y coordinate by 4 to give us the coordinate of a square.
If the cursor square coordinate calculated in `on_mouse_move` changes, Textual will call `watch_cursor_square` with the previous coordinate and new coordinate of the square. This method works out the regions of the widget to update and essentially does the reverse of the steps we took to go from mouse coordinates to square coordinates.
The `get_square_region` function calculates a [Region][textual.geometry.Region] object for each square and uses them as a positional argument in a call to [refresh][textual.widget.Widget.refresh]. Passing Regions to `refresh` tells Textual to update only the cells underneath those regions, and not the entire region.
!!! note
Textual is smart about performing updates. If you refresh multiple regions (even if they overlap), Textual will combine them in to as few non-overlapping regions as possible.
The final step is to update the `render_line` method to use the cursor style when rendering the square underneath the mouse.
You should find that if you move the mouse over the widget now, it will highlight the square underneath the mouse pointer in red.
### Line API examples
The following builtin widgets use the Line API. If you are building advanced widgets, it may be worth looking through the code for inspiration!
- [DataTable](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_data_table.py)
- [TextLog](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_text_log.py)
- [Tree](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_tree.py)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 155 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

64
docs/widgets/_template.md Normal file
View File

@@ -0,0 +1,64 @@
# Widget
Widget description.
- [ ] Focusable
- [ ] Container
## Example
Example app showing the widget:
=== "Output"
```{.textual path="docs/examples/widgets/checkbox.py"}
```
=== "checkbox.py"
```python
--8<-- "docs/examples/widgets/checkbox.py"
```
=== "checkbox.css"
```sass
--8<-- "docs/examples/widgets/checkbox.css"
```
## Reactive attributes
## Bindings
The WIDGET widget defines directly the following bindings:
::: textual.widgets.WIDGET.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
## Component classes
The WIDGET widget provides the following component classes:
::: textual.widget.WIDGET.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## Additional notes
- Did you know this?
- Another pro tip.
## See also
- [WIDGET](../api/WIDGET.md) code reference.
- Another related API.
- Something else useful.

View File

@@ -39,15 +39,7 @@ Clicking any of the non-disabled buttons in the example app below will result th
## Messages ## Messages
### Pressed ### ::: textual.widgets.Button.Pressed
The `Button.Pressed` message is sent when the button is pressed.
- [x] Bubbles
#### Attributes
_No other attributes_
## Additional Notes ## Additional Notes

View File

@@ -32,25 +32,31 @@ The example below shows checkboxes in various states.
| ------- | ------ | ------- | ---------------------------------- | | ------- | ------ | ------- | ---------------------------------- |
| `value` | `bool` | `False` | The default value of the checkbox. | | `value` | `bool` | `False` | The default value of the checkbox. |
## Bindings
The checkbox widget defines directly the following bindings:
::: textual.widgets.Checkbox.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
## Component Classes
The checkbox widget provides the following component classes:
::: textual.widgets.Checkbox.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## Messages ## Messages
### Pressed ### ::: textual.widgets.Checkbox.Changed
The `Checkbox.Changed` message is sent when the checkbox is toggled.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
| --------- | ------ | ------------------------------ |
| `value` | `bool` | The new value of the checkbox. |
## Additional Notes ## Additional Notes
- To remove the spacing around a checkbox, set `border: none;` and `padding: 0;`. - To remove the spacing around a checkbox, set `border: none;` and `padding: 0;`.
- The `.checkbox--switch` component class can be used to change the color and background of the switch.
- When focused, the ++enter++ or ++space++ keys can be used to toggle the checkbox.
## See Also ## See Also

View File

@@ -48,6 +48,24 @@ The example below populates a table with CSV data.
### ::: textual.widgets.DataTable.ColumnSelected ### ::: textual.widgets.DataTable.ColumnSelected
## Bindings
The data table widget defines directly the following bindings:
::: textual.widgets.DataTable.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
## Component Classes
The data table widget provides the following component classes:
::: textual.widgets.DataTable.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## See Also ## See Also
* [DataTable][textual.widgets.DataTable] code reference * [DataTable][textual.widgets.DataTable] code reference

View File

@@ -16,17 +16,7 @@ The example below creates a simple tree to navigate the current working director
## Messages ## Messages
### FileSelected ### ::: textual.widgets.DirectoryTree.FileSelected
The `DirectoryTree.FileSelected` message is sent when the user selects a file in the tree
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
| --------- | ----- | ----------------- |
| `path` | `str` | Path of the file. |
## Reactive Attributes ## Reactive Attributes
@@ -36,6 +26,14 @@ The `DirectoryTree.FileSelected` message is sent when the user selects a file in
| `show_guides` | `bool` | `True` | Show guide lines between levels. | | `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | | `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## Component Classes
The directory tree widget provides the following component classes:
::: textual.widgets.DirectoryTree.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## See Also ## See Also

View File

@@ -32,6 +32,15 @@ widget. Notice how the `Footer` automatically displays the keybinding.
This widget sends no messages. This widget sends no messages.
## Component Classes
The footer widget provides the following component classes:
::: textual.widgets.Footer.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## Additional Notes ## Additional Notes
* You can prevent keybindings from appearing in the footer by setting the `show` argument of the `Binding` to `False`. * You can prevent keybindings from appearing in the footer by setting the `show` argument of the `Binding` to `False`.

View File

@@ -32,31 +32,27 @@ The example below shows how you might create a simple form using two `Input` wid
## Messages ## Messages
### Changed ### ::: textual.widgets.Input.Changed
The `Input.Changed` message is sent when the value in the text input changes. ### ::: textual.widgets.Input.Submitted
- [x] Bubbles ## Bindings
#### Attributes The input widget defines directly the following bindings:
| attribute | type | purpose | ::: textual.widgets.Input.BINDINGS
| --------- | ----- | -------------------------------- | options:
| `value` | `str` | The new value in the text input. | show_root_heading: false
show_root_toc_entry: false
## Component Classes
### Submitted The input widget provides the following component classes:
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. |
::: textual.widgets.Input.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## Additional Notes ## Additional Notes

View File

@@ -27,13 +27,6 @@ of multiple `ListItem`s. The arrow keys can be used to navigate the list.
|---------------|--------|---------|--------------------------------------| |---------------|--------|---------|--------------------------------------|
| `highlighted` | `bool` | `False` | True if this ListItem is highlighted | | `highlighted` | `bool` | `False` | True if this ListItem is highlighted |
## Messages
### Selected
The `ListItem.Selected` message is sent when the item is selected.
- [x] Bubbles
#### Attributes #### Attributes

View File

@@ -35,34 +35,18 @@ The example below shows an app with a simple `ListView`.
## Messages ## Messages
### Highlighted ### ::: textual.widgets.ListView.Highlighted
The `ListView.Highlighted` message is emitted when the highlight changes. ### ::: textual.widgets.ListView.Selected
This happens when you use the arrow keys on your keyboard and when you
click on a list item.
- [x] Bubbles ## Bindings
#### Attributes The list view widget defines directly the following bindings:
| attribute | type | purpose |
| --------- | ---------- | ------------------------------ |
| `item` | `ListItem` | The item that was highlighted. |
### Selected
The `ListView.Selected` message is emitted when a list item is selected.
You can select a list item by pressing ++enter++ while it is highlighted,
or by clicking on it.
- [x] Bubbles
#### Attributes
| attribute | type | purpose |
| --------- | ---------- | --------------------------- |
| `item` | `ListItem` | The item that was selected. |
::: textual.widgets.ListView.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
## See Also ## See Also

View File

@@ -1,7 +1,7 @@
# Static # Static
A widget which displays static content. A widget which displays static content.
Can be used for Rich renderables and can also for the base for other types of widgets. Can be used for Rich renderables and can also be the base for other types of widgets.
- [ ] Focusable - [ ] Focusable
- [ ] Container - [ ] Container

View File

@@ -32,47 +32,33 @@ Tree widgets have a "root" attribute which is an instance of a [TreeNode][textua
| `show_guides` | `bool` | `True` | Show guide lines between levels. | | `show_guides` | `bool` | `True` | Show guide lines between levels. |
| `guide_depth` | `int` | `4` | Amount of indentation between parent and child. | | `guide_depth` | `int` | `4` | Amount of indentation between parent and child. |
## Messages ## Messages
### NodeSelected ### ::: textual.widgets.Tree.NodeCollapsed
The `Tree.NodeSelected` message is sent when the user selects a tree node. ### ::: textual.widgets.Tree.NodeExpanded
### ::: textual.widgets.Tree.NodeHighlighted
#### Attributes ### ::: textual.widgets.Tree.NodeSelected
| attribute | type | purpose | ## Bindings
| --------- | ----------------------------------------- | -------------- |
| `node` | [TreeNode][textual.widgets.tree.TreeNode] | Selected node. |
The tree widget defines directly the following bindings:
### NodeExpanded ::: textual.widgets.Tree.BINDINGS
options:
The `Tree.NodeExpanded` message is sent when the user expands a node in the tree. show_root_heading: false
show_root_toc_entry: false
#### Attributes
| attribute | type | purpose |
| --------- | ----------------------------------------- | -------------- |
| `node` | [TreeNode][textual.widgets.tree.TreeNode] | Expanded node. |
### NodeCollapsed
The `Tree.NodeCollapsed` message is sent when the user expands a node in the tree.
#### Attributes
| attribute | type | purpose |
| --------- | ----------------------------------------- | --------------- |
| `node` | [TreeNode][textual.widgets.tree.TreeNode] | Collapsed node. |
## Component Classes
The tree widget provides the following component classes:
::: textual.widgets.Tree.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## See Also ## See Also

View File

@@ -128,17 +128,17 @@ nav:
- "widgets/button.md" - "widgets/button.md"
- "widgets/checkbox.md" - "widgets/checkbox.md"
- "widgets/data_table.md" - "widgets/data_table.md"
- "widgets/text_log.md"
- "widgets/directory_tree.md" - "widgets/directory_tree.md"
- "widgets/footer.md" - "widgets/footer.md"
- "widgets/header.md" - "widgets/header.md"
- "widgets/index.md" - "widgets/index.md"
- "widgets/input.md" - "widgets/input.md"
- "widgets/label.md" - "widgets/label.md"
- "widgets/list_view.md"
- "widgets/list_item.md" - "widgets/list_item.md"
- "widgets/list_view.md"
- "widgets/placeholder.md" - "widgets/placeholder.md"
- "widgets/static.md" - "widgets/static.md"
- "widgets/text_log.md"
- "widgets/tree.md" - "widgets/tree.md"
- API: - API:
- "api/index.md" - "api/index.md"
@@ -167,7 +167,9 @@ nav:
- "api/query.md" - "api/query.md"
- "api/reactive.md" - "api/reactive.md"
- "api/screen.md" - "api/screen.md"
- "api/scroll_view.md"
- "api/static.md" - "api/static.md"
- "api/strip.md"
- "api/text_log.md" - "api/text_log.md"
- "api/timer.md" - "api/timer.md"
- "api/tree.md" - "api/tree.md"
@@ -252,6 +254,7 @@ plugins:
- search: - search:
- autorefs: - autorefs:
- mkdocstrings: - mkdocstrings:
custom_templates: docs/_templates
default_handler: python default_handler: python
handlers: handlers:
python: python:
@@ -263,9 +266,11 @@ plugins:
- "!^_" - "!^_"
- "^__init__$" - "^__init__$"
- "!^can_replace$" - "!^can_replace$"
watch: watch:
- src/textual - src/textual
- exclude:
glob:
- "**/_template.md"
extra_css: extra_css:

125
poetry.lock generated
View File

@@ -85,18 +85,19 @@ tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy
[[package]] [[package]]
name = "black" name = "black"
version = "22.8.0" version = "23.1.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.6.2" python-versions = ">=3.7"
[package.dependencies] [package.dependencies]
click = ">=8.0.0" click = ">=8.0.0"
mypy-extensions = ">=0.4.3" mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0" pathspec = ">=0.9.0"
platformdirs = ">=2" platformdirs = ">=2"
tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""}
typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}
@@ -223,7 +224,7 @@ python-versions = ">=3.7"
name = "ghp-import" name = "ghp-import"
version = "2.1.0" version = "2.1.0"
description = "Copy your docs directly to the gh-pages branch." description = "Copy your docs directly to the gh-pages branch."
category = "dev" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
@@ -322,7 +323,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.5.15" version = "2.5.17"
description = "File identification library for Python" description = "File identification library for Python"
category = "dev" category = "dev"
optional = false optional = false
@@ -368,7 +369,7 @@ python-versions = ">=3.7"
name = "jinja2" name = "jinja2"
version = "3.0.3" version = "3.0.3"
description = "A very fast and expressive template engine." description = "A very fast and expressive template engine."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -382,7 +383,7 @@ i18n = ["Babel (>=2.7)"]
name = "markdown" name = "markdown"
version = "3.3.7" version = "3.3.7"
description = "Python implementation of Markdown." description = "Python implementation of Markdown."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -418,7 +419,7 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
name = "markupsafe" name = "markupsafe"
version = "2.1.2" version = "2.1.2"
description = "Safely add untrusted strings to HTML/XML markup." description = "Safely add untrusted strings to HTML/XML markup."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
@@ -434,7 +435,7 @@ python-versions = ">=3.7"
name = "mergedeep" name = "mergedeep"
version = "1.3.4" version = "1.3.4"
description = "A deep merge function for 🐍." description = "A deep merge function for 🐍."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -442,7 +443,7 @@ python-versions = ">=3.6"
name = "mkdocs" name = "mkdocs"
version = "1.4.2" version = "1.4.2"
description = "Project documentation with Markdown." description = "Project documentation with Markdown."
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
@@ -476,6 +477,17 @@ python-versions = ">=3.7"
Markdown = ">=3.3" Markdown = ">=3.3"
mkdocs = ">=1.1" mkdocs = ">=1.1"
[[package]]
name = "mkdocs-exclude"
version = "1.0.2"
description = "A mkdocs plugin that lets you exclude files or trees."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
mkdocs = "*"
[[package]] [[package]]
name = "mkdocs-material" name = "mkdocs-material"
version = "8.5.11" version = "8.5.11"
@@ -620,7 +632,7 @@ setuptools = "*"
name = "packaging" name = "packaging"
version = "23.0" version = "23.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
@@ -773,7 +785,7 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale
name = "python-dateutil" name = "python-dateutil"
version = "2.8.2" version = "2.8.2"
description = "Extensions to the standard Python datetime module" description = "Extensions to the standard Python datetime module"
category = "dev" category = "main"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
@@ -792,7 +804,7 @@ python-versions = "*"
name = "pyyaml" name = "pyyaml"
version = "6.0" version = "6.0"
description = "YAML parser and emitter for Python" description = "YAML parser and emitter for Python"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -800,7 +812,7 @@ python-versions = ">=3.6"
name = "pyyaml-env-tag" name = "pyyaml-env-tag"
version = "0.1" version = "0.1"
description = "A custom YAML tag for referencing environment variables in YAML files. " description = "A custom YAML tag for referencing environment variables in YAML files. "
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -841,7 +853,7 @@ idna2008 = ["idna"]
[[package]] [[package]]
name = "rich" name = "rich"
version = "13.2.0" version = "13.3.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main" category = "main"
optional = false optional = false
@@ -849,15 +861,15 @@ python-versions = ">=3.7.0"
[package.dependencies] [package.dependencies]
markdown-it-py = ">=2.1.0,<3.0.0" markdown-it-py = ">=2.1.0,<3.0.0"
pygments = ">=2.6.0,<3.0.0" pygments = ">=2.14.0,<3.0.0"
typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
[package.extras] [package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "66.1.1" version = "67.1.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "dev" category = "dev"
optional = false optional = false
@@ -872,7 +884,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
@@ -990,7 +1002,7 @@ testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7
name = "watchdog" name = "watchdog"
version = "2.2.1" version = "2.2.1"
description = "Filesystem events monitoring" description = "Filesystem events monitoring"
category = "dev" category = "main"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
@@ -1012,14 +1024,14 @@ typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
[[package]] [[package]]
name = "zipp" name = "zipp"
version = "3.11.0" version = "3.12.0"
description = "Backport of pathlib-compatible object wrapper for zip files" description = "Backport of pathlib-compatible object wrapper for zip files"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
[extras] [extras]
@@ -1028,7 +1040,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "d76445ef1521cd4068907433b09d59fc1ed56f03e61063c5ad7376bb9823a8e7" content-hash = "8b9c57d32f9db7d59bacc1e254e46bc5ae523e9e831494c205caf1b5fe7982e4"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [
@@ -1141,29 +1153,31 @@ attrs = [
{file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"}, {file = "attrs-22.2.0.tar.gz", hash = "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99"},
] ]
black = [ black = [
{file = "black-22.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce957f1d6b78a8a231b18e0dd2d94a33d2ba738cd88a7fe64f53f659eea49fdd"}, {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"},
{file = "black-22.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5107ea36b2b61917956d018bd25129baf9ad1125e39324a9b18248d362156a27"}, {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"},
{file = "black-22.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e8166b7bfe5dcb56d325385bd1d1e0f635f24aae14b3ae437102dedc0c186747"}, {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"},
{file = "black-22.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd82842bb272297503cbec1a2600b6bfb338dae017186f8f215c8958f8acf869"}, {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"},
{file = "black-22.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d839150f61d09e7217f52917259831fe2b689f5c8e5e32611736351b89bb2a90"}, {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"},
{file = "black-22.8.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a05da0430bd5ced89176db098567973be52ce175a55677436a271102d7eaa3fe"}, {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"},
{file = "black-22.8.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a098a69a02596e1f2a58a2a1c8d5a05d5a74461af552b371e82f9fa4ada8342"}, {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"},
{file = "black-22.8.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5594efbdc35426e35a7defa1ea1a1cb97c7dbd34c0e49af7fb593a36bd45edab"}, {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"},
{file = "black-22.8.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a983526af1bea1e4cf6768e649990f28ee4f4137266921c2c3cee8116ae42ec3"}, {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"},
{file = "black-22.8.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b2c25f8dea5e8444bdc6788a2f543e1fb01494e144480bc17f806178378005e"}, {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"},
{file = "black-22.8.0-cp37-cp37m-win_amd64.whl", hash = "sha256:78dd85caaab7c3153054756b9fe8c611efa63d9e7aecfa33e533060cb14b6d16"}, {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:cea1b2542d4e2c02c332e83150e41e3ca80dc0fb8de20df3c5e98e242156222c"}, {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"},
{file = "black-22.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b879eb439094751185d1cfdca43023bc6786bd3c60372462b6f051efa6281a5"}, {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"},
{file = "black-22.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a12e4e1353819af41df998b02c6742643cfef58282915f781d0e4dd7a200411"}, {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"},
{file = "black-22.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3a73f66b6d5ba7288cd5d6dad9b4c9b43f4e8a4b789a94bf5abfb878c663eb3"}, {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"},
{file = "black-22.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:e981e20ec152dfb3e77418fb616077937378b322d7b26aa1ff87717fb18b4875"}, {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8ce13ffed7e66dda0da3e0b2eb1bdfc83f5812f66e09aca2b0978593ed636b6c"}, {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"},
{file = "black-22.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:32a4b17f644fc288c6ee2bafdf5e3b045f4eff84693ac069d87b1a347d861497"}, {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"},
{file = "black-22.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ad827325a3a634bae88ae7747db1a395d5ee02cf05d9aa7a9bd77dfb10e940c"}, {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"},
{file = "black-22.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53198e28a1fb865e9fe97f88220da2e44df6da82b18833b588b1883b16bb5d41"}, {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"},
{file = "black-22.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:bc4d4123830a2d190e9cc42a2e43570f82ace35c3aeb26a512a2102bce5af7ec"}, {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"},
{file = "black-22.8.0-py3-none-any.whl", hash = "sha256:d2c21d439b2baf7aa80d6dd4e3659259be64c6f49dfd0f32091063db0e006db4"}, {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"},
{file = "black-22.8.0.tar.gz", hash = "sha256:792f7eb540ba9a17e8656538701d3eb1afcb134e3b45b71f20b25c77a8db7e6e"}, {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"},
{file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"},
{file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"},
] ]
cached-property = [ cached-property = [
{file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"},
@@ -1362,8 +1376,8 @@ httpx = [
{file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"},
] ]
identify = [ identify = [
{file = "identify-2.5.15-py2.py3-none-any.whl", hash = "sha256:1f4b36c5f50f3f950864b2a047308743f064eaa6f6645da5e5c780d1c7125487"}, {file = "identify-2.5.17-py2.py3-none-any.whl", hash = "sha256:7d526dd1283555aafcc91539acc061d8f6f59adb0a7bba462735b0a318bff7ed"},
{file = "identify-2.5.15.tar.gz", hash = "sha256:c22aa206f47cc40486ecf585d27ad5f40adbfc494a3fa41dc3ed0499a23b123f"}, {file = "identify-2.5.17.tar.gz", hash = "sha256:93cc61a861052de9d4c541a7acb7e3dcc9c11b398a2144f6e52ae5285f5f4f06"},
] ]
idna = [ idna = [
{file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
@@ -1457,6 +1471,9 @@ mkdocs-autorefs = [
{file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"}, {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"},
{file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
] ]
mkdocs-exclude = [
{file = "mkdocs-exclude-1.0.2.tar.gz", hash = "sha256:ba6fab3c80ddbe3fd31d3e579861fd3124513708271180a5f81846da8c7e2a51"},
]
mkdocs-material = [ mkdocs-material = [
{file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"}, {file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"},
{file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"}, {file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"},
@@ -1758,12 +1775,12 @@ rfc3986 = [
{file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"},
] ]
rich = [ rich = [
{file = "rich-13.2.0-py3-none-any.whl", hash = "sha256:7c963f0d03819221e9ac561e1bc866e3f95a02248c1234daa48954e6d381c003"}, {file = "rich-13.3.1-py3-none-any.whl", hash = "sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9"},
{file = "rich-13.2.0.tar.gz", hash = "sha256:f1a00cdd3eebf999a15d85ec498bfe0b1a77efe9b34f645768a54132ef444ac5"}, {file = "rich-13.3.1.tar.gz", hash = "sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f"},
] ]
setuptools = [ setuptools = [
{file = "setuptools-66.1.1-py3-none-any.whl", hash = "sha256:6f590d76b713d5de4e49fe4fbca24474469f53c83632d5d0fd056f7ff7e8112b"}, {file = "setuptools-67.1.0-py3-none-any.whl", hash = "sha256:a7687c12b444eaac951ea87a9627c4f904ac757e7abdc5aac32833234af90378"},
{file = "setuptools-66.1.1.tar.gz", hash = "sha256:ac4008d396bc9cd983ea483cb7139c0240a07bbc74ffb6232fceffedc6cf03a8"}, {file = "setuptools-67.1.0.tar.gz", hash = "sha256:e261cdf010c11a41cb5cb5f1bf3338a7433832029f559a6a7614bd42a967c300"},
] ]
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@@ -1993,6 +2010,6 @@ yarl = [
{file = "yarl-1.8.2.tar.gz", hash = "sha256:49d43402c6e3013ad0978602bf6bf5328535c48d192304b91b97a3c6790b1562"}, {file = "yarl-1.8.2.tar.gz", hash = "sha256:49d43402c6e3013ad0978602bf6bf5328535c48d192304b91b97a3c6790b1562"},
] ]
zipp = [ zipp = [
{file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, {file = "zipp-3.12.0-py3-none-any.whl", hash = "sha256:9eb0a4c5feab9b08871db0d672745b53450d7f26992fd1e4653aa43345e97b86"},
{file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, {file = "zipp-3.12.0.tar.gz", hash = "sha256:73efd63936398aac78fd92b6f4865190119d6c91b531532e798977ea8dd402eb"},
] ]

View File

@@ -22,7 +22,9 @@ classifiers = [
"Typing :: Typed", "Typing :: Typed",
] ]
include = [ include = [
"src/textual/py.typed" "src/textual/py.typed",
{ path = "docs/examples", format = "sdist" },
{ path = "tests", format = "sdist" }
] ]
[tool.poetry.scripts] [tool.poetry.scripts]
@@ -40,13 +42,14 @@ aiohttp = { version = ">=3.8.1", optional = true }
click = {version = ">=8.1.2", optional = true} click = {version = ">=8.1.2", optional = true}
msgpack = { version = ">=1.0.3", optional = true } msgpack = { version = ">=1.0.3", optional = true }
nanoid = ">=2.0.0" nanoid = ">=2.0.0"
mkdocs-exclude = "^1.0.2"
[tool.poetry.extras] [tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"] dev = ["aiohttp", "click", "msgpack"]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.1.3" pytest = "^7.1.3"
black = "^22.3.0,<22.10.0" # macos wheel issue on 22.10 black = "^23.1.0"
mypy = "^0.990" mypy = "^0.990"
pytest-cov = "^2.12.1" pytest-cov = "^2.12.1"
mkdocs = "^1.3.0" mkdocs = "^1.3.0"

View File

@@ -322,7 +322,6 @@ class Animator:
) )
if animation is None: if animation is None:
if not isinstance(value, (int, float)) and not isinstance( if not isinstance(value, (int, float)) and not isinstance(
value, Animatable value, Animatable
): ):

View File

@@ -79,7 +79,6 @@ class Parser(Generic[T]):
self._awaiting = next(self._gen) self._awaiting = next(self._gen)
def feed(self, data: str) -> Iterable[T]: def feed(self, data: str) -> Iterable[T]:
if self._eof: if self._eof:
raise ParseError("end of file reached") from None raise ParseError("end of file reached") from None
if not data: if not data:
@@ -104,7 +103,6 @@ class Parser(Generic[T]):
yield popleft() yield popleft()
while pos < data_size or isinstance(self._awaiting, _PeekBuffer): while pos < data_size or isinstance(self._awaiting, _PeekBuffer):
_awaiting = self._awaiting _awaiting = self._awaiting
if isinstance(_awaiting, _Read1): if isinstance(_awaiting, _Read1):
self._awaiting = self._gen.send(data[pos : pos + 1]) self._awaiting = self._gen.send(data[pos : pos + 1])

View File

@@ -38,7 +38,6 @@ class Sleeper(Thread):
async def check_sleeps() -> None: async def check_sleeps() -> None:
sleeper = Sleeper() sleeper = Sleeper()
sleeper.start() sleeper.start()

View File

@@ -10,7 +10,7 @@ from rich.style import Style
from ._border import get_box, render_row from ._border import get_box, render_row
from ._filter import LineFilter from ._filter import LineFilter
from ._opacity import _apply_opacity from ._opacity import _apply_opacity
from ._segment_tools import line_crop, line_pad, line_trim from ._segment_tools import line_pad, line_trim
from ._typing import TypeAlias from ._typing import TypeAlias
from .color import Color from .color import Color
from .geometry import Region, Size, Spacing from .geometry import Region, Size, Spacing
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from .css.styles import StylesBase from .css.styles import StylesBase
from .widget import Widget from .widget import Widget
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]] RenderLineCallback: TypeAlias = Callable[[int], Strip]
@lru_cache(1024 * 8) @lru_cache(1024 * 8)
@@ -212,7 +212,7 @@ class StylesCache:
padding: Spacing, padding: Spacing,
base_background: Color, base_background: Color,
background: Color, background: Color,
render_content_line: RenderLineCallback, render_content_line: Callable[[int], Strip],
) -> Strip: ) -> Strip:
"""Render a styled line. """Render a styled line.
@@ -313,6 +313,7 @@ class StylesCache:
content_y = y - gutter.top content_y = y - gutter.top
if content_y < content_height: if content_y < content_height:
line = render_content_line(y - gutter.top) line = render_content_line(y - gutter.top)
line = line.adjust_cell_length(content_width)
else: else:
line = [make_blank(content_width, inner)] line = [make_blank(content_width, inner)]
if inner: if inner:

View File

@@ -10,11 +10,11 @@ async def wait_for_idle(
) -> None: ) -> None:
"""Wait until the process isn't working very hard. """Wait until the process isn't working very hard.
This will compare wall clock time with process time, if the process time This will compare wall clock time with process time. If the process time
is not advancing the same as wall clock time it means the process is in a is not advancing at the same rate as wall clock time it means the process is
sleep state or waiting for input. idle (i.e. sleeping or waiting for input).
When the process is idle it suggests that input has been processes and the state When the process is idle it suggests that input has been processed and the state
is predictable enough to test. is predictable enough to test.
Args: Args:

View File

@@ -92,7 +92,6 @@ class XTermParser(Parser[events.Event]):
return None return None
def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]: def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]:
ESC = "\x1b" ESC = "\x1b"
read1 = self.read1 read1 = self.read1
sequence_to_key_events = self._sequence_to_key_events sequence_to_key_events = self._sequence_to_key_events
@@ -161,7 +160,6 @@ class XTermParser(Parser[events.Event]):
# Look ahead through the suspected escape sequence for a match # Look ahead through the suspected escape sequence for a match
while True: while True:
# If we run into another ESC at this point, then we've failed # If we run into another ESC at this point, then we've failed
# to find a match, and should issue everything we've seen within # to find a match, and should issue everything we've seen within
# the suspected sequence as Key events instead. # the suspected sequence as Key events instead.

View File

@@ -1901,9 +1901,11 @@ class App(Generic[ReturnType], DOMNode):
else: else:
await self.screen._forward_event(event) await self.screen._forward_event(event)
elif isinstance(event, events.Paste): elif isinstance(event, events.Paste) and not event.is_forwarded:
if self.focused is not None: if self.focused is not None:
await self.focused._forward_event(event) await self.focused._forward_event(event)
else:
await self.screen._forward_event(event)
else: else:
await super().on_event(event) await super().on_event(event)

View File

@@ -30,7 +30,6 @@ class Content(Vertical):
class ColorsView(Vertical): class ColorsView(Vertical):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
LEVELS = [ LEVELS = [
"darken-3", "darken-3",
"darken-2", "darken-2",
@@ -42,7 +41,6 @@ class ColorsView(Vertical):
] ]
for color_name in ColorSystem.COLOR_NAMES: for color_name in ColorSystem.COLOR_NAMES:
items: list[Widget] = [Label(f'"{color_name}"')] items: list[Widget] = [Label(f'"{color_name}"')]
for level in LEVELS: for level in LEVELS:
color = f"{color_name}-{level}" if level else color_name color = f"{color_name}-{level}" if level else color_name

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text

View File

@@ -60,9 +60,23 @@ PropertySetType = TypeVar("PropertySetType")
class GenericProperty(Generic[PropertyGetType, PropertySetType]): class GenericProperty(Generic[PropertyGetType, PropertySetType]):
def __init__(self, default: PropertyGetType, layout: bool = False) -> None: """Descriptor that abstracts away common machinery for other style descriptors.
Args:
default: The default value (or a factory thereof) of the property.
layout: Whether to refresh the node layout on value change.
refresh_children: Whether to refresh the node children on value change.
"""
def __init__(
self,
default: PropertyGetType,
layout: bool = False,
refresh_children: bool = False,
) -> None:
self.default = default self.default = default
self.layout = layout self.layout = layout
self.refresh_children = refresh_children
def validate_value(self, value: object) -> PropertyGetType: def validate_value(self, value: object) -> PropertyGetType:
"""Validate the setter value. """Validate the setter value.
@@ -88,11 +102,11 @@ class GenericProperty(Generic[PropertyGetType, PropertySetType]):
_rich_traceback_omit = True _rich_traceback_omit = True
if value is None: if value is None:
obj.clear_rule(self.name) obj.clear_rule(self.name)
obj.refresh(layout=self.layout) obj.refresh(layout=self.layout, children=self.refresh_children)
return return
new_value = self.validate_value(value) new_value = self.validate_value(value)
if obj.set_rule(self.name, new_value): if obj.set_rule(self.name, new_value):
obj.refresh(layout=self.layout) obj.refresh(layout=self.layout, children=self.refresh_children)
class IntegerProperty(GenericProperty[int, int]): class IntegerProperty(GenericProperty[int, int]):
@@ -202,8 +216,16 @@ class ScalarProperty:
class ScalarListProperty: class ScalarListProperty:
def __init__(self, percent_unit: Unit) -> None: """Descriptor for lists of scalars.
Args:
percent_unit: The dimension to which percentage scalars will be relative to.
refresh_children: Whether to refresh the node children on value change.
"""
def __init__(self, percent_unit: Unit, refresh_children: bool = False) -> None:
self.percent_unit = percent_unit self.percent_unit = percent_unit
self.refresh_children = refresh_children
def __set_name__(self, owner: Styles, name: str) -> None: def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name self.name = name
@@ -218,7 +240,7 @@ class ScalarListProperty:
) -> None: ) -> None:
if value is None: if value is None:
obj.clear_rule(self.name) obj.clear_rule(self.name)
obj.refresh(layout=True) obj.refresh(layout=True, children=self.refresh_children)
return return
parse_values: Iterable[str | float] parse_values: Iterable[str | float]
if isinstance(value, str): if isinstance(value, str):
@@ -237,7 +259,7 @@ class ScalarListProperty:
else parse_value else parse_value
) )
if obj.set_rule(self.name, tuple(scalars)): if obj.set_rule(self.name, tuple(scalars)):
obj.refresh(layout=True) obj.refresh(layout=True, children=self.refresh_children)
class BoxProperty: class BoxProperty:
@@ -682,12 +704,25 @@ class OffsetProperty:
class StringEnumProperty: class StringEnumProperty:
"""Descriptor for getting and setting string properties and ensuring that the set """Descriptor for getting and setting string properties and ensuring that the set
value belongs in the set of valid values. value belongs in the set of valid values.
Args:
valid_values: The set of valid values that the descriptor can take.
default: The default value (or a factory thereof) of the property.
layout: Whether to refresh the node layout on value change.
refresh_children: Whether to refresh the node children on value change.
""" """
def __init__(self, valid_values: set[str], default: str, layout=False) -> None: def __init__(
self,
valid_values: set[str],
default: str,
layout: bool = False,
refresh_children: bool = False,
) -> None:
self._valid_values = valid_values self._valid_values = valid_values
self._default = default self._default = default
self._layout = layout self._layout = layout
self._refresh_children = refresh_children
def __set_name__(self, owner: StylesBase, name: str) -> None: def __set_name__(self, owner: StylesBase, name: str) -> None:
self.name = name self.name = name
@@ -721,7 +756,7 @@ class StringEnumProperty:
if value is None: if value is None:
if obj.clear_rule(self.name): if obj.clear_rule(self.name):
self._before_refresh(obj, value) self._before_refresh(obj, value)
obj.refresh(layout=self._layout) obj.refresh(layout=self._layout, children=self._refresh_children)
else: else:
if value not in self._valid_values: if value not in self._valid_values:
raise StyleValueError( raise StyleValueError(
@@ -734,7 +769,7 @@ class StringEnumProperty:
) )
if obj.set_rule(self.name, value): if obj.set_rule(self.name, value):
self._before_refresh(obj, value) self._before_refresh(obj, value)
obj.refresh(layout=self._layout) obj.refresh(layout=self._layout, children=self._refresh_children)
class OverflowProperty(StringEnumProperty): class OverflowProperty(StringEnumProperty):

View File

@@ -434,7 +434,6 @@ class StylesBuilder:
process_padding_left = _process_space_partial process_padding_left = _process_space_partial
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue: def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
border_type: EdgeType = "solid" border_type: EdgeType = "solid"
border_color = Color(0, 255, 0) border_color = Color(0, 255, 0)

View File

@@ -36,7 +36,6 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
@lru_cache(maxsize=1024) @lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
if not css_selectors.strip(): if not css_selectors.strip():
return () return ()

View File

@@ -72,7 +72,6 @@ class DOMQuery(Generic[QueryType]):
exclude: str | None = None, exclude: str | None = None,
parent: DOMQuery | None = None, parent: DOMQuery | None = None,
) -> None: ) -> None:
self._node = node self._node = node
self._nodes: list[QueryType] | None = None self._nodes: list[QueryType] | None = None
self._filters: list[tuple[SelectorSet, ...]] = ( self._filters: list[tuple[SelectorSet, ...]] = (

View File

@@ -265,26 +265,36 @@ class StylesBase(ABC):
scrollbar_background_hover = ColorProperty("#444444") scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black") scrollbar_background_active = ColorProperty("black")
scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto") scrollbar_gutter = StringEnumProperty(
VALID_SCROLLBAR_GUTTER, "auto", layout=True, refresh_children=True
)
scrollbar_size_vertical = IntegerProperty(default=1, layout=True) scrollbar_size_vertical = IntegerProperty(default=1, layout=True)
scrollbar_size_horizontal = IntegerProperty(default=1, layout=True) scrollbar_size_horizontal = IntegerProperty(default=1, layout=True)
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") align_horizontal = StringEnumProperty(
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") VALID_ALIGN_HORIZONTAL, "left", layout=True, refresh_children=True
)
align_vertical = StringEnumProperty(
VALID_ALIGN_VERTICAL, "top", layout=True, refresh_children=True
)
align = AlignProperty() align = AlignProperty()
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left") content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
content_align = AlignProperty() content_align = AlignProperty()
grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT) grid_rows = ScalarListProperty(percent_unit=Unit.HEIGHT, refresh_children=True)
grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH) grid_columns = ScalarListProperty(percent_unit=Unit.WIDTH, refresh_children=True)
grid_size_columns = IntegerProperty(default=1, layout=True) grid_size_columns = IntegerProperty(default=1, layout=True, refresh_children=True)
grid_size_rows = IntegerProperty(default=0, layout=True) grid_size_rows = IntegerProperty(default=0, layout=True, refresh_children=True)
grid_gutter_horizontal = IntegerProperty(default=0, layout=True) grid_gutter_horizontal = IntegerProperty(
grid_gutter_vertical = IntegerProperty(default=0, layout=True) default=0, layout=True, refresh_children=True
)
grid_gutter_vertical = IntegerProperty(
default=0, layout=True, refresh_children=True
)
row_span = IntegerProperty(default=1, layout=True) row_span = IntegerProperty(default=1, layout=True)
column_span = IntegerProperty(default=1, layout=True) column_span = IntegerProperty(default=1, layout=True)
@@ -317,7 +327,6 @@ class StylesBase(ABC):
# Check we are animating a Scalar or Scalar offset # Check we are animating a Scalar or Scalar offset
if isinstance(start_value, (Scalar, ScalarOffset)): if isinstance(start_value, (Scalar, ScalarOffset)):
# If destination is a number, we can convert that to a scalar # If destination is a number, we can convert that to a scalar
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
value = Scalar(value, Unit.CELLS, Unit.CELLS) value = Scalar(value, Unit.CELLS, Unit.CELLS)

View File

@@ -236,7 +236,6 @@ class ClientHandler:
message = cast(WSMessage, message) message = cast(WSMessage, message)
if message.type in (WSMsgType.TEXT, WSMsgType.BINARY): if message.type in (WSMsgType.TEXT, WSMsgType.BINARY):
try: try:
if isinstance(message.data, bytes): if isinstance(message.data, bytes):
message = msgpack.unpackb(message.data) message = msgpack.unpackb(message.data)

View File

@@ -92,7 +92,6 @@ class LinuxDriver(Driver):
self.console.file.flush() self.console.file.flush()
def start_application_mode(self): def start_application_mode(self):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def send_size_event(): def send_size_event():
@@ -123,7 +122,6 @@ class LinuxDriver(Driver):
except termios.error: except termios.error:
pass pass
else: else:
newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG]) newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG]) newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
@@ -208,7 +206,6 @@ class LinuxDriver(Driver):
pass # TODO: log pass # TODO: log
def _run_input_thread(self, loop) -> None: def _run_input_thread(self, loop) -> None:
selector = selectors.DefaultSelector() selector = selectors.DefaultSelector()
selector.register(self.fileno, selectors.EVENT_READ) selector.register(self.fileno, selectors.EVENT_READ)

View File

@@ -58,7 +58,6 @@ class WindowsDriver(Driver):
self.console.file.write("\x1b[?2004l") self.console.file.write("\x1b[?2004l")
def start_application_mode(self) -> None: def start_application_mode(self) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
self._restore_console = win32.enable_application_mode() self._restore_console = win32.enable_application_mode()

View File

@@ -7,7 +7,7 @@ from rich.style import Style
from ._types import MessageTarget from ._types import MessageTarget
from .geometry import Offset, Size from .geometry import Offset, Size
from .keys import _get_key_aliases, _get_key_display from .keys import _get_key_aliases
from .message import Message from .message import Message
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")

View File

@@ -18,7 +18,6 @@ class HorizontalLayout(Layout):
def arrange( def arrange(
self, parent: Widget, children: list[Widget], size: Size self, parent: Widget, children: list[Widget], size: Size
) -> ArrangeResult: ) -> ArrangeResult:
placements: list[WidgetPlacement] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
x = max_height = Fraction(0) x = max_height = Fraction(0)

View File

@@ -19,7 +19,6 @@ class VerticalLayout(Layout):
def arrange( def arrange(
self, parent: Widget, children: list[Widget], size: Size self, parent: Widget, children: list[Widget], size: Size
) -> ArrangeResult: ) -> ArrangeResult:
placements: list[WidgetPlacement] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
parent_size = parent.outer_size parent_size = parent.outer_size

View File

@@ -398,7 +398,6 @@ class MessagePump(metaclass=MessagePumpMeta):
self.app._handle_exception(error) self.app._handle_exception(error)
break break
finally: finally:
self._message_queue.task_done() self._message_queue.task_done()
current_time = time() current_time = time()

View File

@@ -170,7 +170,6 @@ class Reactive(Generic[ReactiveType]):
getattr(obj, "__computes", []).clear() getattr(obj, "__computes", []).clear()
def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
# Check for compute method # Check for compute method
if hasattr(owner, f"compute_{name}"): if hasattr(owner, f"compute_{name}"):
# Compute methods are stored in a list called `__computes` # Compute methods are stored in a list called `__computes`

View File

@@ -92,7 +92,6 @@ class ScrollBarRender:
back_color: Color = Color.parse("#555555"), back_color: Color = Color.parse("#555555"),
bar_color: Color = Color.parse("bright_magenta"), bar_color: Color = Color.parse("bright_magenta"),
) -> Segments: ) -> Segments:
if vertical: if vertical:
bars = ["", "", "", "", "", "", "", " "] bars = ["", "", "", "", "", "", "", " "]
else: else:
@@ -190,7 +189,6 @@ class ScrollBarRender:
@rich.repr.auto @rich.repr.auto
class ScrollBar(Widget): class ScrollBar(Widget):
renderer: ClassVar[Type[ScrollBarRender]] = ScrollBarRender renderer: ClassVar[Type[ScrollBarRender]] = ScrollBarRender
"""The class used for rendering scrollbars. """The class used for rendering scrollbars.
This can be overriden and set to a ScrollBarRender-derived class This can be overriden and set to a ScrollBarRender-derived class

View File

@@ -6,7 +6,7 @@ from typing import Iterable, Iterator
import rich.repr import rich.repr
from rich.cells import cell_len, set_cell_size from rich.cells import cell_len, set_cell_size
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style, StyleType
from ._cache import FIFOCache from ._cache import FIFOCache
from ._filter import LineFilter from ._filter import LineFilter
@@ -49,7 +49,7 @@ class Strip:
return "".join(segment.text for segment in self._segments) return "".join(segment.text for segment in self._segments)
@classmethod @classmethod
def blank(cls, cell_length: int, style: Style | None) -> Strip: def blank(cls, cell_length: int, style: StyleType | None = None) -> Strip:
"""Create a blank strip. """Create a blank strip.
Args: Args:
@@ -59,7 +59,8 @@ class Strip:
Returns: Returns:
New strip. New strip.
""" """
return cls([Segment(" " * cell_length, style)], cell_length) segment_style = Style.parse(style) if isinstance(style, str) else style
return cls([Segment(" " * cell_length, segment_style)], cell_length)
@classmethod @classmethod
def from_lines( def from_lines(
@@ -135,6 +136,23 @@ class Strip:
self._segments == strip._segments and self.cell_length == strip.cell_length self._segments == strip._segments and self.cell_length == strip.cell_length
) )
def extend_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
"""Extend the cell length if it is less than the given value.
Args:
cell_length: Required minimum cell length.
style: Style for padding if the cell length is extended.
Returns:
A new Strip.
"""
if self.cell_length < cell_length:
missing_space = cell_length - self.cell_length
segments = self._segments + [Segment(" " * missing_space, style)]
return Strip(segments, cell_length)
else:
return self
def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip: def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip:
"""Adjust the cell length, possibly truncating or extending. """Adjust the cell length, possibly truncating or extending.

View File

@@ -4,7 +4,7 @@ import typing
from ..case import camel_to_snake from ..case import camel_to_snake
# ⚠️For any new built-in Widget we create, not only do we have to import them here and add them to `__all__`, # For any new built-in Widget we create, not only do we have to import them here and add them to `__all__`,
# but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't # but also to the `__init__.pyi` file in this same folder - otherwise text editors and type checkers won't
# be able to "see" them. # be able to "see" them.
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:

View File

@@ -150,9 +150,21 @@ class Button(Static, can_focus=True):
ACTIVE_EFFECT_DURATION = 0.3 ACTIVE_EFFECT_DURATION = 0.3
"""When buttons are clicked they get the `-active` class for this duration (in seconds)""" """When buttons are clicked they get the `-active` class for this duration (in seconds)"""
label: Reactive[RenderableType] = Reactive("")
"""The text label that appears within the button."""
variant = Reactive.init("default")
"""The variant name for the button."""
disabled = Reactive(False)
"""The disabled state of the button; `True` if disabled, `False` if not."""
class Pressed(Message, bubble=True): class Pressed(Message, bubble=True):
"""Event sent when a `Button` is pressed. """Event sent when a `Button` is pressed.
Can be handled using `on_button_pressed` in a subclass of `Button` or
in a parent widget in the DOM.
Attributes: Attributes:
button: The button that was pressed. button: The button that was pressed.
""" """
@@ -194,15 +206,6 @@ class Button(Static, can_focus=True):
self.variant = self.validate_variant(variant) self.variant = self.validate_variant(variant)
label: Reactive[RenderableType] = Reactive("")
"""The text label that appears within the button."""
variant = Reactive.init("default")
"""The variant name for the button."""
disabled = Reactive(False)
"""The disabled state of the button; `True` if disabled, `False` if not."""
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__() yield from super().__rich_repr__()
yield "variant", self.variant, "default" yield "variant", self.variant, "default"

View File

@@ -4,7 +4,7 @@ from typing import ClassVar
from rich.console import RenderableType from rich.console import RenderableType
from ..binding import Binding from ..binding import Binding, BindingType
from ..geometry import Size from ..geometry import Size
from ..message import Message from ..message import Message
from ..reactive import reactive from ..reactive import reactive
@@ -13,12 +13,33 @@ from ..scrollbar import ScrollBarRender
class Checkbox(Widget, can_focus=True): class Checkbox(Widget, can_focus=True):
"""A checkbox widget. Represents a boolean value. Can be toggled by clicking """A checkbox widget that represents a boolean value.
on it or by pressing the enter key or space bar while it has focus.
Can be toggled by clicking on it or through its [bindings][textual.widgets.Checkbox.BINDINGS].
The checkbox widget also contains [component classes][textual.widgets.Checkbox.COMPONENT_CLASSES]
that enable more customization.
"""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter,space", "toggle", "Toggle", show=False),
]
"""
| Key(s) | Description |
| :- | :- |
| enter,space | Toggle the checkbox status. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"checkbox--switch",
}
"""
| Class | Description |
| :- | :- |
| `checkbox--switch` | Targets the switch of the checkbox. |
""" """
DEFAULT_CSS = """ DEFAULT_CSS = """
Checkbox { Checkbox {
border: tall transparent; border: tall transparent;
background: $panel; background: $panel;
@@ -49,13 +70,27 @@ class Checkbox(Widget, can_focus=True):
} }
""" """
BINDINGS = [ value = reactive(False, init=False)
Binding("enter,space", "toggle", "toggle", show=False), """The value of the checkbox; `True` for on and `False` for off."""
]
COMPONENT_CLASSES: ClassVar[set[str]] = { slider_pos = reactive(0.0)
"checkbox--switch", """The position of the slider."""
}
class Changed(Message, bubble=True):
"""Emitted when the status of the checkbox changes.
Can be handled using `on_checkbox_changed` in a subclass of `Checkbox`
or in a parent widget in the DOM.
Attributes:
value: The value that the checkbox was changed to.
input: The `Checkbox` widget that was changed.
"""
def __init__(self, sender: Checkbox, value: bool) -> None:
super().__init__(sender)
self.value: bool = value
self.input: Checkbox = sender
def __init__( def __init__(
self, self,
@@ -81,12 +116,6 @@ class Checkbox(Widget, can_focus=True):
self._reactive_value = value self._reactive_value = value
self._should_animate = animate self._should_animate = animate
value = reactive(False, init=False)
"""The value of the checkbox; `True` for on and `False` for off."""
slider_pos = reactive(0.0)
"""The position of the slider."""
def watch_value(self, value: bool) -> None: def watch_value(self, value: bool) -> None:
target_slider_pos = 1.0 if value else 0.0 target_slider_pos = 1.0 if value else 0.0
if self._should_animate: if self._should_animate:
@@ -124,16 +153,3 @@ class Checkbox(Widget, can_focus=True):
"""Toggle the checkbox value. As a result of the value changing, """Toggle the checkbox value. As a result of the value changing,
a Checkbox.Changed message will be emitted.""" a Checkbox.Changed message will be emitted."""
self.value = not self.value self.value = not self.value
class Changed(Message, bubble=True):
"""Checkbox was toggled.
Attributes:
value: The value that the checkbox was changed to.
input: The `Checkbox` widget that was changed.
"""
def __init__(self, sender: Checkbox, value: bool) -> None:
super().__init__(sender)
self.value: bool = value
self.input: Checkbox = sender

View File

@@ -31,7 +31,7 @@ from .._segment_tools import line_crop
from .._two_way_dict import TwoWayDict from .._two_way_dict import TwoWayDict
from .._types import SegmentLines from .._types import SegmentLines
from .._typing import Literal, TypeAlias from .._typing import Literal, TypeAlias
from ..binding import Binding from ..binding import Binding, BindingType
from ..coordinate import Coordinate from ..coordinate import Coordinate
from ..geometry import Region, Size, Spacing, clamp from ..geometry import Region, Size, Spacing, clamp
from ..message import Message from ..message import Message
@@ -140,6 +140,48 @@ class Row:
class DataTable(ScrollView, Generic[CellType], can_focus=True): class DataTable(ScrollView, Generic[CellType], can_focus=True):
"""A tabular widget that contains data."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter", "select_cursor", "Select", show=False),
Binding("up", "cursor_up", "Cursor Up", show=False),
Binding("down", "cursor_down", "Cursor Down", show=False),
Binding("right", "cursor_right", "Cursor Right", show=False),
Binding("left", "cursor_left", "Cursor Left", show=False),
]
"""
| Key(s) | Description |
| :- | :- |
| enter | Select cells under the cursor. |
| up | Move the cursor up. |
| down | Move the cursor down. |
| right | Move the cursor right. |
| left | Move the cursor left. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"datatable--header",
"datatable--cursor-fixed",
"datatable--highlight-fixed",
"datatable--fixed",
"datatable--odd-row",
"datatable--even-row",
"datatable--highlight",
"datatable--cursor",
}
"""
| Class | Description |
| :- | :- |
| `datatable--cursor` | Target the cursor. |
| `datatable--cursor-fixed` | Target fixed columns or header under the cursor. |
| `datatable--even-row` | Target even rows (row indices start at 0). |
| `datatable--fixed` | Target fixed columns or header. |
| `datatable--header` | Target the header of the data table. |
| `datatable--highlight` | Target the highlighted cell(s). |
| `datatable--highlight-fixed` | Target highlighted and fixed columns or header. |
| `datatable--odd-row` | Target odd rows (row indices start at 0). |
"""
DEFAULT_CSS = """ DEFAULT_CSS = """
App.-dark DataTable { App.-dark DataTable {
background:; background:;
@@ -190,25 +232,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
} }
""" """
COMPONENT_CLASSES: ClassVar[set[str]] = {
"datatable--header",
"datatable--cursor-fixed",
"datatable--highlight-fixed",
"datatable--fixed",
"datatable--odd-row",
"datatable--even-row",
"datatable--highlight",
"datatable--cursor",
}
BINDINGS = [
Binding("enter", "select_cursor", "Select", show=False),
Binding("up", "cursor_up", "Cursor Up", show=False),
Binding("down", "cursor_down", "Cursor Down", show=False),
Binding("right", "cursor_right", "Cursor Right", show=False),
Binding("left", "cursor_left", "Cursor Left", show=False),
]
show_header = Reactive(True) show_header = Reactive(True)
fixed_rows = Reactive(0) fixed_rows = Reactive(0)
fixed_columns = Reactive(0) fixed_columns = Reactive(0)
@@ -222,6 +245,125 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
) )
hover_cell: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False) hover_cell: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False)
class CellHighlighted(Message, bubble=True):
"""Emitted when the cursor moves to highlight a new cell.
It's only relevant when the `cursor_type` is `"cell"`.
It's also emitted when the cell cursor is re-enabled (by setting `show_cursor=True`),
and when the cursor type is changed to `"cell"`. Can be handled using
`on_data_table_cell_highlighted` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
value: The value in the highlighted cell.
coordinate: The coordinate of the highlighted cell.
"""
def __init__(
self, sender: DataTable, value: CellType, coordinate: Coordinate
) -> None:
self.value: CellType = value
self.coordinate: Coordinate = coordinate
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "value", self.value
yield "coordinate", self.coordinate
class CellSelected(Message, bubble=True):
"""Emitted by the `DataTable` widget when a cell is selected.
It's only relevant when the `cursor_type` is `"cell"`. Can be handled using
`on_data_table_cell_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
value: The value in the cell that was selected.
coordinate: The coordinate of the cell that was selected.
"""
def __init__(
self, sender: DataTable, value: CellType, coordinate: Coordinate
) -> None:
self.value: CellType = value
self.coordinate: Coordinate = coordinate
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "value", self.value
yield "coordinate", self.coordinate
class RowHighlighted(Message, bubble=True):
"""Emitted when a row is highlighted. This message is only emitted when the
`cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_highlighted`
in a subclass of `DataTable` or in a parent widget in the DOM.
Attributes:
cursor_row: The y-coordinate of the cursor that highlighted the row.
"""
def __init__(self, sender: DataTable, cursor_row: int) -> None:
self.cursor_row: int = cursor_row
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_row", self.cursor_row
class RowSelected(Message, bubble=True):
"""Emitted when a row is selected. This message is only emitted when the
`cursor_type` is set to `"row"`. Can be handled using
`on_data_table_row_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_row: The y-coordinate of the cursor that made the selection.
"""
def __init__(self, sender: DataTable, cursor_row: int) -> None:
self.cursor_row: int = cursor_row
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_row", self.cursor_row
class ColumnHighlighted(Message, bubble=True):
"""Emitted when a column is highlighted. This message is only emitted when the
`cursor_type` is set to `"column"`. Can be handled using
`on_data_table_column_highlighted` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_column: The x-coordinate of the column that was highlighted.
"""
def __init__(self, sender: DataTable, cursor_column: int) -> None:
self.cursor_column: int = cursor_column
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_column", self.cursor_column
class ColumnSelected(Message, bubble=True):
"""Emitted when a column is selected. This message is only emitted when the
`cursor_type` is set to `"column"`. Can be handled using
`on_data_table_column_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_column: The x-coordinate of the column that was selected.
"""
def __init__(self, sender: DataTable, cursor_column: int) -> None:
self.cursor_column: int = cursor_column
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_column", self.cursor_column
def __init__( def __init__(
self, self,
*, *,
@@ -1259,122 +1401,3 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
elif cursor_type == "column": elif cursor_type == "column":
_, column = cursor_cell _, column = cursor_cell
self.emit_no_wait(DataTable.ColumnSelected(self, column)) self.emit_no_wait(DataTable.ColumnSelected(self, column))
class CellHighlighted(Message, bubble=True):
"""Emitted when the cursor moves to highlight a new cell.
It's only relevant when the `cursor_type` is `"cell"`.
It's also emitted when the cell cursor is re-enabled (by setting `show_cursor=True`),
and when the cursor type is changed to `"cell"`. Can be handled using
`on_data_table_cell_highlighted` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
value: The value in the highlighted cell.
coordinate: The coordinate of the highlighted cell.
"""
def __init__(
self, sender: DataTable, value: CellType, coordinate: Coordinate
) -> None:
self.value: CellType = value
self.coordinate: Coordinate = coordinate
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "value", self.value
yield "coordinate", self.coordinate
class CellSelected(Message, bubble=True):
"""Emitted by the `DataTable` widget when a cell is selected.
It's only relevant when the `cursor_type` is `"cell"`. Can be handled using
`on_data_table_cell_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
value: The value in the cell that was selected.
coordinate: The coordinate of the cell that was selected.
"""
def __init__(
self, sender: DataTable, value: CellType, coordinate: Coordinate
) -> None:
self.value: CellType = value
self.coordinate: Coordinate = coordinate
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "value", self.value
yield "coordinate", self.coordinate
class RowHighlighted(Message, bubble=True):
"""Emitted when a row is highlighted. This message is only emitted when the
`cursor_type` is set to `"row"`. Can be handled using `on_data_table_row_highlighted`
in a subclass of `DataTable` or in a parent widget in the DOM.
Attributes:
cursor_row: The y-coordinate of the cursor that highlighted the row.
"""
def __init__(self, sender: DataTable, cursor_row: int) -> None:
self.cursor_row: int = cursor_row
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_row", self.cursor_row
class RowSelected(Message, bubble=True):
"""Emitted when a row is selected. This message is only emitted when the
`cursor_type` is set to `"row"`. Can be handled using
`on_data_table_row_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_row: The y-coordinate of the cursor that made the selection.
"""
def __init__(self, sender: DataTable, cursor_row: int) -> None:
self.cursor_row: int = cursor_row
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_row", self.cursor_row
class ColumnHighlighted(Message, bubble=True):
"""Emitted when a column is highlighted. This message is only emitted when the
`cursor_type` is set to `"column"`. Can be handled using
`on_data_table_column_highlighted` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_column: The x-coordinate of the column that was highlighted.
"""
def __init__(self, sender: DataTable, cursor_column: int) -> None:
self.cursor_column: int = cursor_column
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_column", self.cursor_column
class ColumnSelected(Message, bubble=True):
"""Emitted when a column is selected. This message is only emitted when the
`cursor_type` is set to `"column"`. Can be handled using
`on_data_table_column_selected` in a subclass of `DataTable` or in a parent
widget in the DOM.
Attributes:
cursor_column: The x-coordinate of the column that was selected.
"""
def __init__(self, sender: DataTable, cursor_column: int) -> None:
self.cursor_column: int = cursor_column
super().__init__(sender)
def __rich_repr__(self) -> rich.repr.Result:
yield "sender", self.sender
yield "cursor_column", self.cursor_column

View File

@@ -32,18 +32,21 @@ class DirectoryTree(Tree[DirEntry]):
""" """
COMPONENT_CLASSES: ClassVar[set[str]] = { COMPONENT_CLASSES: ClassVar[set[str]] = {
"tree--label",
"tree--guides",
"tree--guides-hover",
"tree--guides-selected",
"tree--cursor",
"tree--highlight",
"tree--highlight-line",
"directory-tree--folder", "directory-tree--folder",
"directory-tree--file", "directory-tree--file",
"directory-tree--extension", "directory-tree--extension",
"directory-tree--hidden", "directory-tree--hidden",
} }
"""
| Class | Description |
| :- | :- |
| `directory-tree--extension` | Target the extension of a file name. |
| `directory-tree--file` | Target files in the directory structure. |
| `directory-tree--folder` | Target folders in the directory structure. |
| `directory-tree--hidden` | Target hidden items in the directory structure. |
See also the [component classes for `Tree`][textual.widgets.Tree.COMPONENT_CLASSES].
"""
DEFAULT_CSS = """ DEFAULT_CSS = """
DirectoryTree > .directory-tree--folder { DirectoryTree > .directory-tree--folder {
@@ -64,8 +67,17 @@ class DirectoryTree(Tree[DirEntry]):
""" """
class FileSelected(Message, bubble=True): class FileSelected(Message, bubble=True):
"""Emitted when a file is selected.
Can be handled using `on_directory_tree_file_selected` in a subclass of
`DirectoryTree` or in a parent widget in the DOM.
Attributes:
path: The path of the file that was selected.
"""
def __init__(self, sender: MessageTarget, path: str) -> None: def __init__(self, sender: MessageTarget, path: str) -> None:
self.path = path self.path: str = path
super().__init__(sender) super().__init__(sender)
def __init__( def __init__(
@@ -76,7 +88,6 @@ class DirectoryTree(Tree[DirEntry]):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
) -> None: ) -> None:
self.path = path self.path = path
super().__init__( super().__init__(
path, path,

View File

@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ClassVar
import rich.repr import rich.repr
from rich.console import RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
from .. import events from .. import events
from ..keys import _get_key_display
from ..reactive import Reactive, watch from ..reactive import Reactive, watch
from ..widget import Widget from ..widget import Widget
@@ -16,6 +16,21 @@ from ..widget import Widget
class Footer(Widget): class Footer(Widget):
"""A simple footer widget which docks itself to the bottom of the parent container.""" """A simple footer widget which docks itself to the bottom of the parent container."""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"footer--description",
"footer--key",
"footer--highlight",
"footer--highlight-key",
}
"""
| Class | Description |
| :- | :- |
| `footer--description` | Targets the descriptions of the key bindings. |
| `footer--highlight` | Targets the highlighted key binding. |
| `footer--highlight-key` | Targets the key portion of the highlighted key binding. |
| `footer--key` | Targets the key portions of the key bindings. |
"""
DEFAULT_CSS = """ DEFAULT_CSS = """
Footer { Footer {
background: $accent; background: $accent;
@@ -38,20 +53,13 @@ class Footer(Widget):
} }
""" """
COMPONENT_CLASSES = { highlight_key: Reactive[str | None] = Reactive(None)
"footer--description",
"footer--key",
"footer--highlight",
"footer--highlight-key",
}
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self._key_text: Text | None = None self._key_text: Text | None = None
self.auto_links = False self.auto_links = False
highlight_key: Reactive[str | None] = Reactive(None)
async def watch_highlight_key(self, value) -> None: async def watch_highlight_key(self, value) -> None:
"""If highlight key changes we need to regenerate the text.""" """If highlight key changes we need to regenerate the text."""
self._key_text = None self._key_text = None

View File

@@ -100,10 +100,10 @@ class Header(Widget):
} }
""" """
tall = Reactive(False)
DEFAULT_CLASSES = "" DEFAULT_CLASSES = ""
tall = Reactive(False)
def __init__( def __init__(
self, self,
show_clock: bool = False, show_clock: bool = False,

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar
import re import re
@@ -10,7 +11,7 @@ from rich.text import Text
from .. import events from .. import events
from .._segment_tools import line_crop from .._segment_tools import line_crop
from ..binding import Binding from ..binding import Binding, BindingType
from ..geometry import Size from ..geometry import Size
from ..message import Message from ..message import Message
from ..reactive import reactive from ..reactive import reactive
@@ -27,7 +28,6 @@ class _InputRenderable:
def __rich_console__( def __rich_console__(
self, console: "Console", options: "ConsoleOptions" self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult": ) -> "RenderResult":
input = self.input input = self.input
result = input._value result = input._value
if input._cursor_at_end: if input._cursor_at_end:
@@ -55,6 +55,51 @@ class _InputRenderable:
class Input(Widget, can_focus=True): class Input(Widget, can_focus=True):
"""A text input widget.""" """A text input widget."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("left", "cursor_left", "cursor left", show=False),
Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False),
Binding("right", "cursor_right", "cursor right", show=False),
Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding("home,ctrl+a", "home", "home", show=False),
Binding("end,ctrl+e", "end", "end", show=False),
Binding("delete,ctrl+d", "delete_right", "delete right", show=False),
Binding("enter", "submit", "submit", show=False),
Binding(
"ctrl+w", "delete_left_word", "delete left to start of word", show=False
),
Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False),
Binding(
"ctrl+f", "delete_right_word", "delete right to start of word", show=False
),
Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False),
]
"""
| Key(s) | Description |
| :- | :- |
| left | Move the cursor left. |
| ctrl+left | Move the cursor one word to the left. |
| right | Move the cursor right. |
| ctrl+right | Move the cursor one word to the right. |
| backspace | Delete the character to the left of the cursor. |
| home,ctrl+a | Go to the beginning of the input. |
| end,ctrl+e | Go to the end of the input. |
| delete,ctrl+d | Delete the character to the right of the cursor. |
| enter | Submit the current value of the input. |
| ctrl+w | Delete the word to the left of the cursor. |
| ctrl+u | Delete everything to the left of the cursor. |
| ctrl+f | Delete the word to the right of the cursor. |
| ctrl+k | Delete everything to the right of the cursor. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {"input--cursor", "input--placeholder"}
"""
| Class | Description |
| :- | :- |
| `input--cursor` | Target the cursor. |
| `input--placeholder` | Target the placeholder text (when it exists). |
"""
DEFAULT_CSS = """ DEFAULT_CSS = """
Input { Input {
background: $boost; background: $boost;
@@ -81,28 +126,6 @@ class Input(Widget, can_focus=True):
} }
""" """
BINDINGS = [
Binding("left", "cursor_left", "cursor left", show=False),
Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False),
Binding("right", "cursor_right", "cursor right", show=False),
Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False),
Binding("home,ctrl+a", "home", "home", show=False),
Binding("end,ctrl+e", "end", "end", show=False),
Binding("enter", "submit", "submit", show=False),
Binding("backspace", "delete_left", "delete left", show=False),
Binding(
"ctrl+w", "delete_left_word", "delete left to start of word", show=False
),
Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False),
Binding("delete,ctrl+d", "delete_right", "delete right", show=False),
Binding(
"ctrl+f", "delete_right_word", "delete right to start of word", show=False
),
Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False),
]
COMPONENT_CLASSES = {"input--cursor", "input--placeholder"}
cursor_blink = reactive(True) cursor_blink = reactive(True)
value = reactive("", layout=True, init=False) value = reactive("", layout=True, init=False)
input_scroll_offset = reactive(0) input_scroll_offset = reactive(0)
@@ -115,6 +138,38 @@ class Input(Widget, can_focus=True):
password = reactive(False) password = reactive(False)
max_size: reactive[int | None] = reactive(None) max_size: reactive[int | None] = reactive(None)
class Changed(Message, bubble=True):
"""Emitted when the value changes.
Can be handled using `on_input_changed` in a subclass of `Input` or in a parent
widget in the DOM.
Attributes:
value: The value that the input was changed to.
input: The `Input` widget that was changed.
"""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value: str = value
self.input: Input = sender
class Submitted(Message, bubble=True):
"""Emitted when the enter key is pressed within an `Input`.
Can be handled using `on_input_submitted` in a subclass of `Input` or in a
parent widget in the DOM.
Attributes:
value: The value of the `Input` being submitted.
input: The `Input` widget that is being submitted.
"""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value: str = value
self.input: Input = sender
def __init__( def __init__(
self, self,
value: str | None = None, value: str | None = None,
@@ -199,6 +254,7 @@ class Input(Widget, can_focus=True):
return self._position_to_cell(len(self.value)) + 1 return self._position_to_cell(len(self.value)) + 1
def render(self) -> RenderableType: def render(self) -> RenderableType:
self.view_position = self.view_position
if not self.value: if not self.value:
placeholder = Text(self.placeholder, justify="left") placeholder = Text(self.placeholder, justify="left")
placeholder.stylize(self.get_component_rich_style("input--placeholder")) placeholder.stylize(self.get_component_rich_style("input--placeholder"))
@@ -322,20 +378,32 @@ class Input(Widget, can_focus=True):
def action_cursor_left_word(self) -> None: def action_cursor_left_word(self) -> None:
"""Move the cursor left to the start of a word.""" """Move the cursor left to the start of a word."""
try: if self.password:
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) # This is a password field so don't give any hints about word
except ValueError: # boundaries, even during movement.
self.cursor_position = 0 self.action_home()
else: else:
self.cursor_position = hit.start() try:
*_, hit = re.finditer(
self._WORD_START, self.value[: self.cursor_position]
)
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
def action_cursor_right_word(self) -> None: def action_cursor_right_word(self) -> None:
"""Move the cursor right to the start of a word.""" """Move the cursor right to the start of a word."""
hit = re.search(self._WORD_START, self.value[self.cursor_position :]) if self.password:
if hit is None: # This is a password field so don't give any hints about word
self.cursor_position = len(self.value) # boundaries, even during movement.
self.action_end()
else: else:
self.cursor_position += hit.start() hit = re.search(self._WORD_START, self.value[self.cursor_position :])
if hit is None:
self.cursor_position = len(self.value)
else:
self.cursor_position += hit.start()
def action_delete_right(self) -> None: def action_delete_right(self) -> None:
"""Delete one character at the current cursor position.""" """Delete one character at the current cursor position."""
@@ -348,12 +416,19 @@ class Input(Widget, can_focus=True):
def action_delete_right_word(self) -> None: def action_delete_right_word(self) -> None:
"""Delete the current character and all rightward to the start of the next word.""" """Delete the current character and all rightward to the start of the next word."""
after = self.value[self.cursor_position :] if self.password:
hit = re.search(self._WORD_START, after) # This is a password field so don't give any hints about word
if hit is None: # boundaries, even during deletion.
self.value = self.value[: self.cursor_position] self.action_delete_right_all()
else: else:
self.value = f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}" after = self.value[self.cursor_position :]
hit = re.search(self._WORD_START, after)
if hit is None:
self.value = self.value[: self.cursor_position]
else:
self.value = (
f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}"
)
def action_delete_right_all(self) -> None: def action_delete_right_all(self) -> None:
"""Delete the current character and all characters to the right of the cursor position.""" """Delete the current character and all characters to the right of the cursor position."""
@@ -381,14 +456,21 @@ class Input(Widget, can_focus=True):
"""Delete leftward of the cursor position to the start of a word.""" """Delete leftward of the cursor position to the start of a word."""
if self.cursor_position <= 0: if self.cursor_position <= 0:
return return
after = self.value[self.cursor_position :] if self.password:
try: # This is a password field so don't give any hints about word
*_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) # boundaries, even during deletion.
except ValueError: self.action_delete_left_all()
self.cursor_position = 0
else: else:
self.cursor_position = hit.start() after = self.value[self.cursor_position :]
self.value = f"{self.value[: self.cursor_position]}{after}" try:
*_, hit = re.finditer(
self._WORD_START, self.value[: self.cursor_position]
)
except ValueError:
self.cursor_position = 0
else:
self.cursor_position = hit.start()
self.value = f"{self.value[: self.cursor_position]}{after}"
def action_delete_left_all(self) -> None: def action_delete_left_all(self) -> None:
"""Delete all characters to the left of the cursor position.""" """Delete all characters to the left of the cursor position."""
@@ -398,29 +480,3 @@ class Input(Widget, can_focus=True):
async def action_submit(self) -> None: async def action_submit(self) -> None:
await self.emit(self.Submitted(self, self.value)) await self.emit(self.Submitted(self, self.value))
class Changed(Message, bubble=True):
"""Value was changed.
Attributes:
value: The value that the input was changed to.
input: The `Input` widget that was changed.
"""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value: str = value
self.input: Input = sender
class Submitted(Message, bubble=True):
"""Sent when the enter key is pressed within an `Input`.
Attributes:
value: The value of the `Input` being submitted..
input: The `Input` widget that is being submitted.
"""
def __init__(self, sender: Input, value: str) -> None:
super().__init__(sender)
self.value: str = value
self.input: Input = sender

View File

@@ -12,4 +12,3 @@ class Label(Static):
height: auto; height: auto;
} }
""" """
"""str: The default styling of a `Label`."""

View File

@@ -25,15 +25,16 @@ class ListItem(Widget, can_focus=False):
height: auto; height: auto;
} }
""" """
highlighted = reactive(False) highlighted = reactive(False)
class _ChildClicked(Message):
"""For informing with the parent ListView that we were clicked"""
pass
def on_click(self, event: events.Click) -> None: def on_click(self, event: events.Click) -> None:
self.emit_no_wait(self._ChildClicked(self)) self.emit_no_wait(self._ChildClicked(self))
def watch_highlighted(self, value: bool) -> None: def watch_highlighted(self, value: bool) -> None:
self.set_class(value, "--highlight") self.set_class(value, "--highlight")
class _ChildClicked(Message):
"""For informing with the parent ListView that we were clicked"""
pass

View File

@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
from typing import ClassVar
from textual import events from textual import events
from textual.await_remove import AwaitRemove from textual.await_remove import AwaitRemove
from textual.binding import Binding from textual.binding import Binding, BindingType
from textual.containers import Vertical from textual.containers import Vertical
from textual.geometry import clamp from textual.geometry import clamp
from textual.message import Message from textual.message import Message
@@ -19,14 +20,50 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
index: The index in the list that's currently highlighted. index: The index in the list that's currently highlighted.
""" """
BINDINGS = [ BINDINGS: ClassVar[list[BindingType]] = [
Binding("enter", "select_cursor", "Select", show=False), Binding("enter", "select_cursor", "Select", show=False),
Binding("up", "cursor_up", "Cursor Up", show=False), Binding("up", "cursor_up", "Cursor Up", show=False),
Binding("down", "cursor_down", "Cursor Down", show=False), Binding("down", "cursor_down", "Cursor Down", show=False),
] ]
"""
| Key(s) | Description |
| :- | :- |
| enter | Select the current item. |
| up | Move the cursor up. |
| down | Move the cursor down. |
"""
index = reactive(0, always_update=True) index = reactive(0, always_update=True)
class Highlighted(Message, bubble=True):
"""Emitted when the highlighted item changes.
Highlighted item is controlled using up/down keys.
Can be handled using `on_list_view_highlighted` in a subclass of `ListView`
or in a parent widget in the DOM.
Attributes:
item: The highlighted item, if there is one highlighted.
"""
def __init__(self, sender: ListView, item: ListItem | None) -> None:
super().__init__(sender)
self.item: ListItem | None = item
class Selected(Message, bubble=True):
"""Emitted when a list item is selected, e.g. when you press the enter key on it.
Can be handled using `on_list_view_selected` in a subclass of `ListView` or in
a parent widget in the DOM.
Attributes:
item: The selected item.
"""
def __init__(self, sender: ListView, item: ListItem) -> None:
super().__init__(sender)
self.item: ListItem = item
def __init__( def __init__(
self, self,
*children: ListItem, *children: ListItem,
@@ -139,25 +176,3 @@ class ListView(Vertical, can_focus=True, can_focus_children=False):
def __len__(self): def __len__(self):
return len(self.children) return len(self.children)
class Highlighted(Message, bubble=True):
"""Emitted when the highlighted item changes. Highlighted item is controlled using up/down keys.
Attributes:
item: The highlighted item, if there is one highlighted.
"""
def __init__(self, sender: ListView, item: ListItem | None) -> None:
super().__init__(sender)
self.item: ListItem | None = item
class Selected(Message, bubble=True):
"""Emitted when a list item is selected, e.g. when you press the enter key on it
Attributes:
item: The selected item.
"""
def __init__(self, sender: ListView, item: ListItem) -> None:
super().__init__(sender)
self.item: ListItem = item

View File

@@ -57,7 +57,6 @@ class Static(Widget, inherit_bindings=False):
id: str | None = None, id: str | None = None,
classes: str | None = None, classes: str | None = None,
) -> None: ) -> None:
super().__init__(name=name, id=id, classes=classes) super().__init__(name=name, id=id, classes=classes)
self.expand = expand self.expand = expand
self.shrink = shrink self.shrink = shrink

View File

@@ -14,7 +14,6 @@ from ..reactive import var
from ..geometry import Size, Region from ..geometry import Size, Region
from ..scroll_view import ScrollView from ..scroll_view import ScrollView
from .._cache import LRUCache from .._cache import LRUCache
from .._segment_tools import line_crop
from ..strip import Strip from ..strip import Strip
@@ -160,7 +159,6 @@ class TextLog(ScrollView, can_focus=True):
return lines return lines
def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: def _render_line(self, y: int, scroll_x: int, width: int) -> Strip:
if y >= len(self.lines): if y >= len(self.lines):
return Strip.blank(width, self.rich_style) return Strip.blank(width, self.rich_style)

View File

@@ -14,7 +14,7 @@ from .._segment_tools import line_pad
from .._types import MessageTarget from .._types import MessageTarget
from .._typing import TypeAlias from .._typing import TypeAlias
from .._immutable_sequence_view import ImmutableSequenceView from .._immutable_sequence_view import ImmutableSequenceView
from ..binding import Binding from ..binding import Binding, BindingType
from ..geometry import Region, Size, clamp from ..geometry import Region, Size, clamp
from ..message import Message from ..message import Message
from ..reactive import reactive, var from ..reactive import reactive, var
@@ -281,12 +281,41 @@ class TreeNode(Generic[TreeDataType]):
class Tree(Generic[TreeDataType], ScrollView, can_focus=True): class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
BINDINGS: ClassVar[list[BindingType]] = [
BINDINGS = [
Binding("enter", "select_cursor", "Select", show=False), Binding("enter", "select_cursor", "Select", show=False),
Binding("space", "toggle_node", "Toggle", show=False),
Binding("up", "cursor_up", "Cursor Up", show=False), Binding("up", "cursor_up", "Cursor Up", show=False),
Binding("down", "cursor_down", "Cursor Down", show=False), Binding("down", "cursor_down", "Cursor Down", show=False),
] ]
"""
| Key(s) | Description |
| :- | :- |
| enter | Select the current item. |
| space | Toggle the expand/collapsed space of the current item. |
| up | Move the cursor up. |
| down | Move the cursor down. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"tree--label",
"tree--guides",
"tree--guides-hover",
"tree--guides-selected",
"tree--cursor",
"tree--highlight",
"tree--highlight-line",
}
"""
| Class | Description |
| :- | :- |
| `tree--cursor` | Targets the cursor. |
| `tree--guides` | Targets the indentation guides. |
| `tree--guides-hover` | Targets the indentation guides under the cursor. |
| `tree--guides-selected` | Targets the indentation guides that are selected. |
| `tree--highlight` | Targets the highlighted items. |
| `tree--highlight-line` | Targets the lines under the cursor. |
| `tree--label` | Targets the (text) labels of the items. |
"""
DEFAULT_CSS = """ DEFAULT_CSS = """
Tree { Tree {
@@ -311,11 +340,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
} }
Tree > .tree--cursor { Tree > .tree--cursor {
background: $secondary; background: $secondary-darken-2;
color: $text; color: $text;
text-style: bold; text-style: bold;
} }
Tree:focus > .tree--cursor {
background: $secondary;
}
Tree > .tree--highlight { Tree > .tree--highlight {
text-style: underline; text-style: underline;
} }
@@ -326,16 +359,6 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
""" """
COMPONENT_CLASSES: ClassVar[set[str]] = {
"tree--label",
"tree--guides",
"tree--guides-hover",
"tree--guides-selected",
"tree--cursor",
"tree--highlight",
"tree--highlight-line",
}
show_root = reactive(True) show_root = reactive(True)
"""bool: Show the root of the tree.""" """bool: Show the root of the tree."""
hover_line = var(-1) hover_line = var(-1)
@@ -370,35 +393,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
), ),
} }
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected.
Attributes:
node: The node that was selected.
"""
def __init__(
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
) -> None:
self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender)
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is expanded.
Attributes:
node: The node that was expanded.
"""
def __init__(
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
) -> None:
self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender)
class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True): class NodeCollapsed(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is collapsed. """Event sent when a node is collapsed.
Can be handled using `on_tree_node_collapsed` in a subclass of `Tree` or in a
parent node in the DOM.
Attributes: Attributes:
node: The node that was collapsed. node: The node that was collapsed.
""" """
@@ -409,9 +409,28 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender) super().__init__(sender)
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is expanded.
Can be handled using `on_tree_node_expanded` in a subclass of `Tree` or in a
parent node in the DOM.
Attributes:
node: The node that was expanded.
"""
def __init__(
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
) -> None:
self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender)
class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True): class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is highlighted. """Event sent when a node is highlighted.
Can be handled using `on_tree_node_highlighted` in a subclass of `Tree` or in a
parent node in the DOM.
Attributes: Attributes:
node: The node that was highlighted. node: The node that was highlighted.
""" """
@@ -422,6 +441,22 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.node: TreeNode[EventTreeDataType] = node self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender) super().__init__(sender)
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected.
Can be handled using `on_tree_node_selected` in a subclass of `Tree` or in a
parent node in the DOM.
Attributes:
node: The node that was selected.
"""
def __init__(
self, sender: MessageTarget, node: TreeNode[EventTreeDataType]
) -> None:
self.node: TreeNode[EventTreeDataType] = node
super().__init__(sender)
def __init__( def __init__(
self, self,
label: TextType, label: TextType,
@@ -542,6 +577,17 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self._updates += 1 self._updates += 1
self.refresh() self.refresh()
def reset(self, label: TextType, data: TreeDataType | None = None) -> None:
"""Clear the tree and reset the root node.
Args:
label: The label for the root node.
data: Optional data for the root node.
"""
self.clear()
self.root.label = label
self.root.data = data
def select_node(self, node: TreeNode[TreeDataType] | None) -> None: def select_node(self, node: TreeNode[TreeDataType] | None) -> None:
"""Move the cursor to the given node, or reset cursor. """Move the cursor to the given node, or reset cursor.
@@ -893,7 +939,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
label_style += self.get_component_rich_style( label_style += self.get_component_rich_style(
"tree--highlight", partial=True "tree--highlight", partial=True
) )
if self.cursor_line == y and self.has_focus: if self.cursor_line == y:
label_style += self.get_component_rich_style( label_style += self.get_component_rich_style(
"tree--cursor", partial=False "tree--cursor", partial=False
) )
@@ -941,6 +987,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self._invalidate() self._invalidate()
def action_cursor_up(self) -> None: def action_cursor_up(self) -> None:
"""Move the cursor up one node."""
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = self.last_line self.cursor_line = self.last_line
else: else:
@@ -948,6 +995,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_cursor_down(self) -> None: def action_cursor_down(self) -> None:
"""Move the cursor down one node."""
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = 0 self.cursor_line = 0
else: else:
@@ -955,26 +1003,50 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_page_down(self) -> None: def action_page_down(self) -> None:
"""Move the cursor down a page's-worth of nodes."""
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = 0 self.cursor_line = 0
self.cursor_line += self.scrollable_content_region.height - 1 self.cursor_line += self.scrollable_content_region.height - 1
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_page_up(self) -> None: def action_page_up(self) -> None:
"""Move the cursor up a page's-worth of nodes."""
if self.cursor_line == -1: if self.cursor_line == -1:
self.cursor_line = self.last_line self.cursor_line = self.last_line
self.cursor_line -= self.scrollable_content_region.height - 1 self.cursor_line -= self.scrollable_content_region.height - 1
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_scroll_home(self) -> None: def action_scroll_home(self) -> None:
"""Move the cursor to the top of the tree."""
self.cursor_line = 0 self.cursor_line = 0
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_scroll_end(self) -> None: def action_scroll_end(self) -> None:
"""Move the cursor to the bottom of the tree.
Note:
Here bottom means vertically, not branch depth.
"""
self.cursor_line = self.last_line self.cursor_line = self.last_line
self.scroll_to_line(self.cursor_line) self.scroll_to_line(self.cursor_line)
def action_toggle_node(self) -> None:
"""Toggle the expanded state of the target node."""
try:
line = self._tree_lines[self.cursor_line]
except IndexError:
pass
else:
self._toggle_node(line.path[-1])
def action_select_cursor(self) -> None: def action_select_cursor(self) -> None:
"""Cause a select event for the target node.
Note:
If `auto_expand` is `True` use of this action on a non-leaf node
will cause both an expand/collapse event to occour, as well as a
selected event.
"""
try: try:
line = self._tree_lines[self.cursor_line] line = self._tree_lines[self.cursor_line]
except IndexError: except IndexError:

View File

@@ -24,11 +24,10 @@ Where the fear has gone there will be nothing. Only I will remain."
class Welcome(Static): class Welcome(Static):
DEFAULT_CSS = """ DEFAULT_CSS = """
Welcome { Welcome {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: $surface; background: $surface;
} }
@@ -44,7 +43,7 @@ class Welcome(Static):
Welcome #close { Welcome #close {
dock: bottom; dock: bottom;
width: 100%; width: 100%;
} }
""" """

View File

@@ -0,0 +1,95 @@
import pytest
from textual.app import App
from textual.containers import Grid
from textual.screen import Screen
from textual.widgets import Label
@pytest.mark.parametrize(
"style, value",
[
("grid_size_rows", 3),
("grid_size_columns", 3),
("grid_gutter_vertical", 4),
("grid_gutter_horizontal", 4),
("grid_rows", "1fr 3fr"),
("grid_columns", "1fr 3fr"),
],
)
async def test_programmatic_style_change_updates_children(style: str, value: object):
"""Regression test for #1607 https://github.com/Textualize/textual/issues/1607
Some programmatic style changes to a widget were not updating the layout of the
children widgets, which seemed to be happening when the style change did not affect
the size of the widget but did affect the layout of the children.
This test, in particular, checks the attributes that _should_ affect the size of the
children widgets.
"""
class MyApp(App[None]):
CSS = """
Grid { grid-size: 2 2; }
Label { width: 100%; height: 100%; }
"""
def compose(self):
yield Grid(
Label("one"),
Label("two"),
Label("three"),
Label("four"),
)
app = MyApp()
async with app.run_test() as pilot:
sizes = [(lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)]
setattr(app.query_one(Grid).styles, style, value)
await pilot.pause()
assert sizes != [
(lbl.size.width, lbl.size.height) for lbl in app.screen.query(Label)
]
@pytest.mark.parametrize(
"style, value",
[
("align_horizontal", "right"),
("align_vertical", "bottom"),
("align", ("right", "bottom")),
],
)
async def test_programmatic_align_change_updates_children_position(
style: str, value: str
):
"""Regression test for #1607 for the align(_xxx) styles.
See https://github.com/Textualize/textual/issues/1607.
"""
class MyApp(App[None]):
CSS = "Grid { grid-size: 2 2; }"
def compose(self):
yield Grid(
Label("one"),
Label("two"),
Label("three"),
Label("four"),
)
app = MyApp()
async with app.run_test() as pilot:
offsets = [(lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)]
setattr(app.query_one(Grid).styles, style, value)
await pilot.pause()
assert offsets != [
(lbl.region.x, lbl.region.y) for lbl in app.screen.query(Label)
]

View File

@@ -66,6 +66,17 @@ async def test_delete_left_word_from_end() -> None:
assert input.value == expected[input.id] assert input.value == expected[input.id]
async def test_password_delete_left_word_from_end() -> None:
"""Deleting word left from end of a password input should delete everything."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.password = True
input.action_delete_left_word()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_left_all_from_home() -> None: async def test_delete_left_all_from_home() -> None:
"""Deleting all left from home should do nothing.""" """Deleting all left from home should do nothing."""
async with InputTester().run_test() as pilot: async with InputTester().run_test() as pilot:
@@ -119,6 +130,16 @@ async def test_delete_right_word_from_home() -> None:
assert input.value == expected[input.id] assert input.value == expected[input.id]
async def test_password_delete_right_word_from_home() -> None:
"""Deleting word right from home of a password input should delete everything."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.password = True
input.action_delete_right_word()
assert input.cursor_position == 0
assert input.value == ""
async def test_delete_right_word_from_end() -> None: async def test_delete_right_word_from_end() -> None:
"""Deleting word right from end should not change the input's value.""" """Deleting word right from end should not change the input's value."""
async with InputTester().run_test() as pilot: async with InputTester().run_test() as pilot:

View File

@@ -97,6 +97,16 @@ async def test_input_left_word_from_end() -> None:
assert input.cursor_position == expected_at[input.id] assert input.cursor_position == expected_at[input.id]
async def test_password_input_left_word_from_end() -> None:
"""Going left one word from the end in a password field should land at home."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.action_end()
input.password = True
input.action_cursor_left_word()
assert input.cursor_position == 0
async def test_input_right_word_from_home() -> None: async def test_input_right_word_from_home() -> None:
"""Going right one word from the start should land correctly..""" """Going right one word from the start should land correctly.."""
async with InputTester().run_test() as pilot: async with InputTester().run_test() as pilot:
@@ -112,6 +122,15 @@ async def test_input_right_word_from_home() -> None:
assert input.cursor_position == expected_at[input.id] assert input.cursor_position == expected_at[input.id]
async def test_password_input_right_word_from_home() -> None:
"""Going right one word from the start of a password input should go to the end."""
async with InputTester().run_test() as pilot:
for input in pilot.app.query(Input):
input.password = True
input.action_cursor_right_word()
assert input.cursor_position == len(input.value)
async def test_input_right_word_from_end() -> None: async def test_input_right_word_from_end() -> None:
"""Going right one word from the end should do nothing.""" """Going right one word from the end should do nothing."""
async with InputTester().run_test() as pilot: async with InputTester().run_test() as pilot:

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Label, Input
class InputWidthAutoApp(App[None]):
CSS = """
Input.auto {
width: auto;
max-width: 100%;
}
"""
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="This has auto width", classes="auto")
yield Footer()
if __name__ == "__main__":
InputWidthAutoApp().run()

View File

@@ -0,0 +1,45 @@
from textual.app import App
from textual.widgets import Label, Static
from rich.panel import Panel
class LabelWrap(App):
CSS = """Screen {
align: center middle;
}
#l_data {
border: blank;
background: lightgray;
}
#s_data {
border: blank;
background: lightgreen;
}
#p_data {
border: blank;
background: lightgray;
}"""
def __init__(self):
super().__init__()
self.data = (
"Apple Banana Cherry Mango Fig Guava Pineapple:"
"Dragon Unicorn Centaur Phoenix Chimera Castle"
)
def compose(self):
yield Label(self.data, id="l_data")
yield Static(self.data, id="s_data")
yield Label(Panel(self.data), id="p_data")
def on_mount(self):
self.dark = False
if __name__ == "__main__":
app = LabelWrap()
app.run()

View File

@@ -0,0 +1,29 @@
from textual.app import App
from textual.containers import Grid
from textual.widgets import Label
class ProgrammaticScrollbarGutterChange(App[None]):
CSS = """
Grid { grid-size: 2 2; scrollbar-size: 5 5; }
Label { width: 100%; height: 100%; background: red; }
"""
def compose(self):
yield Grid(
Label("one"),
Label("two"),
Label("three"),
Label("four"),
)
def on_key(self, event):
if event.key == "s":
self.query_one(Grid).styles.scrollbar_gutter = "stable"
app = ProgrammaticScrollbarGutterChange()
if __name__ == "__main__":
app().run()

View File

@@ -179,6 +179,16 @@ def test_nested_auto_heights(snap_compare):
assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"]) assert snap_compare("snapshot_apps/nested_auto_heights.py", press=["1", "2", "_"])
def test_programmatic_scrollbar_gutter_change(snap_compare):
"""Regression test for #1607 https://github.com/Textualize/textual/issues/1607
See also tests/css/test_programmatic_style_changes.py for other related regression tests.
"""
assert snap_compare(
"snapshot_apps/programmatic_scrollbar_gutter_change.py", press=["s"]
)
# --- Other --- # --- Other ---
@@ -193,3 +203,14 @@ def test_demo(snap_compare):
press=["down", "down", "down"], press=["down", "down", "down"],
terminal_size=(100, 30), terminal_size=(100, 30),
) )
def test_label_widths(snap_compare):
"""Test renderable widths are calculate correctly."""
assert snap_compare(SNAPSHOT_APPS_DIR / "label_widths.py")
def test_auto_width_input(snap_compare):
assert snap_compare(
SNAPSHOT_APPS_DIR / "auto_width_input.py", press=["tab", *"Hello"]
)

View File

@@ -153,8 +153,8 @@ async def test_schedule_reverse_animations() -> None:
assert styles.background.rgb == (0, 0, 0) assert styles.background.rgb == (0, 0, 0)
# Now, the actual test is to make sure we go back to black if scheduling both at once. # Now, the actual test is to make sure we go back to black if scheduling both at once.
styles.animate("background", "white", delay=0.01, duration=0.01) styles.animate("background", "white", delay=0.05, duration=0.01)
await pilot.pause(0.005) await pilot.pause()
styles.animate("background", "black", delay=0.01, duration=0.01) styles.animate("background", "black", delay=0.05, duration=0.01)
await pilot.wait_for_scheduled_animations() await pilot.wait_for_scheduled_animations()
assert styles.background.rgb == (0, 0, 0) assert styles.background.rgb == (0, 0, 0)

18
tests/test_paste.py Normal file
View File

@@ -0,0 +1,18 @@
from textual.app import App
from textual import events
async def test_paste_app():
paste_events = []
class PasteApp(App):
def on_paste(self, event):
paste_events.append(event)
app = PasteApp()
async with app.run_test() as pilot:
await app.post_message(events.Paste(sender=app, text="Hello"))
await pilot.pause(0)
assert len(paste_events) == 1
assert paste_events[0].text == "Hello"

View File

@@ -83,6 +83,14 @@ def test_adjust_cell_length():
) )
def test_extend_cell_length():
strip = Strip([Segment("foo"), Segment("bar")])
assert strip.extend_cell_length(3).text == "foobar"
assert strip.extend_cell_length(6).text == "foobar"
assert strip.extend_cell_length(7).text == "foobar "
assert strip.extend_cell_length(9).text == "foobar "
def test_simplify(): def test_simplify():
assert Strip([Segment("foo"), Segment("bar")]).simplify() == Strip( assert Strip([Segment("foo"), Segment("bar")]).simplify() == Strip(
[Segment("foobar")] [Segment("foobar")]

View File

@@ -10,7 +10,7 @@ from textual.geometry import Region, Size
from textual.strip import Strip from textual.strip import Strip
def _extract_content(lines: list[list[Segment]]): def _extract_content(lines: list[Strip]) -> list[str]:
"""Extract the text content from lines.""" """Extract the text content from lines."""
content = ["".join(segment.text for segment in line) for line in lines] content = ["".join(segment.text for segment in line) for line in lines]
return content return content
@@ -28,9 +28,9 @@ def test_set_dirty():
def test_no_styles(): def test_no_styles():
"""Test that empty style returns the content un-altered""" """Test that empty style returns the content un-altered"""
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
cache = StylesCache() cache = StylesCache()
@@ -54,9 +54,9 @@ def test_no_styles():
def test_border(): def test_border():
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
styles.border = ("heavy", "white") styles.border = ("heavy", "white")
@@ -85,9 +85,9 @@ def test_border():
def test_padding(): def test_padding():
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
styles.padding = 1 styles.padding = 1
@@ -116,9 +116,9 @@ def test_padding():
def test_padding_border(): def test_padding_border():
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
styles.padding = 1 styles.padding = 1
@@ -150,9 +150,9 @@ def test_padding_border():
def test_outline(): def test_outline():
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
styles.outline = ("heavy", "white") styles.outline = ("heavy", "white")
@@ -177,9 +177,9 @@ def test_outline():
def test_crop(): def test_crop():
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
styles = Styles() styles = Styles()
styles.padding = 1 styles.padding = 1
@@ -203,17 +203,17 @@ def test_crop():
assert text_content == expected_text assert text_content == expected_text
def test_dirty_cache(): def test_dirty_cache() -> None:
"""Check that we only render content once or if it has been marked as dirty.""" """Check that we only render content once or if it has been marked as dirty."""
content = [ content = [
[Segment("foo")], Strip([Segment("foo")]),
[Segment("bar")], Strip([Segment("bar")]),
[Segment("baz")], Strip([Segment("baz")]),
] ]
rendered_lines: list[int] = [] rendered_lines: list[int] = []
def get_content_line(y: int) -> list[Segment]: def get_content_line(y: int) -> Strip:
rendered_lines.append(y) rendered_lines.append(y)
return content[y] return content[y]
@@ -227,11 +227,13 @@ def test_dirty_cache():
Color.parse("blue"), Color.parse("blue"),
Color.parse("green"), Color.parse("green"),
get_content_line, get_content_line,
Size(3, 3),
) )
assert rendered_lines == [0, 1, 2] assert rendered_lines == [0, 1, 2]
del rendered_lines[:] del rendered_lines[:]
text_content = _extract_content(lines) text_content = _extract_content(lines)
expected_text = [ expected_text = [
"┏━━━━━┓", "┏━━━━━┓",
"┃ ┃", "┃ ┃",

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Tree
class VerseBody:
pass
class VerseStar(VerseBody):
pass
class VersePlanet(VerseBody):
pass
class VerseMoon(VerseBody):
pass
class VerseTree(Tree[VerseBody]):
pass
class TreeClearApp(App[None]):
"""Tree clearing test app."""
def compose(self) -> ComposeResult:
yield VerseTree("White Sun", data=VerseStar())
def on_mount(self) -> None:
tree = self.query_one(VerseTree)
node = tree.root.add("Londinium", VersePlanet())
node.add_leaf("Balkerne", VerseMoon())
node.add_leaf("Colchester", VerseMoon())
node = tree.root.add("Sihnon", VersePlanet())
node.add_leaf("Airen", VerseMoon())
node.add_leaf("Xiaojie", VerseMoon())
async def test_tree_simple_clear() -> None:
"""Clearing a tree should keep the old root label and data."""
async with TreeClearApp().run_test() as pilot:
tree = pilot.app.query_one(VerseTree)
assert len(tree.root.children) > 1
pilot.app.query_one(VerseTree).clear()
assert len(tree.root.children) == 0
assert str(tree.root.label) == "White Sun"
assert isinstance(tree.root.data, VerseStar)
async def test_tree_reset_with_label() -> None:
"""Resetting a tree with a new label should use the new label and set the data to None."""
async with TreeClearApp().run_test() as pilot:
tree = pilot.app.query_one(VerseTree)
assert len(tree.root.children) > 1
pilot.app.query_one(VerseTree).reset(label="Jiangyin")
assert len(tree.root.children) == 0
assert str(tree.root.label) == "Jiangyin"
assert tree.root.data is None
async def test_tree_reset_with_label_and_data() -> None:
"""Resetting a tree with a label and data have that label and data used."""
async with TreeClearApp().run_test() as pilot:
tree = pilot.app.query_one(VerseTree)
assert len(tree.root.children) > 1
pilot.app.query_one(VerseTree).reset(label="Jiangyin", data=VersePlanet())
assert len(tree.root.children) == 0
assert str(tree.root.label) == "Jiangyin"
assert isinstance(tree.root.data, VersePlanet)

View File

@@ -49,23 +49,26 @@ async def test_tree_node_selected_message() -> None:
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] assert pilot.app.messages == ["NodeExpanded", "NodeSelected"]
async def test_tree_node_selected_message_no_auto() -> None:
"""Selecting a node should result in only a selected message being emitted."""
async with TreeApp().run_test() as pilot:
pilot.app.query_one(MyTree).auto_expand = False
await pilot.press("enter")
assert pilot.app.messages == ["NodeSelected"]
async def test_tree_node_expanded_message() -> None: async def test_tree_node_expanded_message() -> None:
"""Expanding a node should result in an expanded message being emitted.""" """Expanding a node should result in an expanded message being emitted."""
async with TreeApp().run_test() as pilot: async with TreeApp().run_test() as pilot:
await pilot.press("enter") await pilot.press("space")
assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] assert pilot.app.messages == ["NodeExpanded"]
async def test_tree_node_collapsed_message() -> None: async def test_tree_node_collapsed_message() -> None:
"""Collapsing a node should result in a collapsed message being emitted.""" """Collapsing a node should result in a collapsed message being emitted."""
async with TreeApp().run_test() as pilot: async with TreeApp().run_test() as pilot:
await pilot.press("enter", "enter") await pilot.press("space", "space")
assert pilot.app.messages == [ assert pilot.app.messages == ["NodeExpanded", "NodeCollapsed"]
"NodeExpanded",
"NodeSelected",
"NodeCollapsed",
"NodeSelected",
]
async def test_tree_node_highlighted_message() -> None: async def test_tree_node_highlighted_message() -> None:

View File

@@ -0,0 +1,79 @@
"""
Helper script to help document all widgets.
This goes through the widgets listed in textual.widgets and prints the scaffolding
for the tables that are used to document the classvars BINDINGS and COMPONENT_CLASSES.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import textual.widgets
if TYPE_CHECKING:
from textual.binding import Binding
def print_bindings(widget: str, bindings: list[Binding]) -> None:
"""Print a table summarising the bindings.
The table contains columns for the key(s) that trigger the binding,
the method that it calls (and tries to link it to the widget itself),
and the description of the binding.
"""
if bindings:
print("BINDINGS")
print('"""')
print("| Key(s) | Description |")
print("| :- | :- |")
for binding in bindings:
print(f"| {binding.key} | {binding.description} |")
if bindings:
print('"""')
def print_component_classes(classes: set[str]) -> None:
"""Print a table to document these component classes.
The table contains two columns, one with the component class name and another
for the description of what the component class is for.
The second column is always empty.
"""
if classes:
print("COMPONENT_CLASSES")
print('"""')
print("| Class | Description |")
print("| :- | :- |")
for cls in sorted(classes):
print(f"| `{cls}` | XXX |")
if classes:
print('"""')
def main() -> None:
"""Main entrypoint.
Iterates over all widgets and prints docs tables.
"""
widgets: list[str] = textual.widgets.__all__
for widget in widgets:
w = getattr(textual.widgets, widget)
bindings: list[Binding] = w.__dict__.get("BINDINGS", [])
component_classes: set[str] = getattr(w, "COMPONENT_CLASSES", set())
if bindings or component_classes:
print(widget)
print()
print_bindings(widget, bindings)
print_component_classes(component_classes)
print()
if __name__ == "__main__":
main()