mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1710 from Textualize/line-api-docs
Documented the Line API
This commit is contained in:
1
docs/api/scroll_view.md
Normal file
1
docs/api/scroll_view.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.scroll_view.ScrollView
|
||||
1
docs/api/strip.md
Normal file
1
docs/api/strip.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.strip.Strip
|
||||
43
docs/examples/guide/widgets/checker01.py
Normal file
43
docs/examples/guide/widgets/checker01.py
Normal 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()
|
||||
55
docs/examples/guide/widgets/checker02.py
Normal file
55
docs/examples/guide/widgets/checker02.py
Normal 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()
|
||||
64
docs/examples/guide/widgets/checker03.py
Normal file
64
docs/examples/guide/widgets/checker03.py
Normal 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()
|
||||
106
docs/examples/guide/widgets/checker04.py
Normal file
106
docs/examples/guide/widgets/checker04.py
Normal 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()
|
||||
@@ -200,4 +200,191 @@ TODO: Explanation of compound widgets
|
||||
|
||||
## 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)
|
||||
|
||||
16
docs/images/render_line.excalidraw.svg
Normal file
16
docs/images/render_line.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
16
docs/images/scroll_view.excalidraw.svg
Normal file
16
docs/images/scroll_view.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 155 KiB |
16
docs/images/segment.excalidraw.svg
Normal file
16
docs/images/segment.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -167,7 +167,9 @@ nav:
|
||||
- "api/query.md"
|
||||
- "api/reactive.md"
|
||||
- "api/screen.md"
|
||||
- "api/scroll_view.md"
|
||||
- "api/static.md"
|
||||
- "api/strip.md"
|
||||
- "api/text_log.md"
|
||||
- "api/timer.md"
|
||||
- "api/tree.md"
|
||||
|
||||
2307
poetry.lock
generated
2307
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,7 @@ dev = ["aiohttp", "click", "msgpack"]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.1.3"
|
||||
black = "^22.3.0,<22.10.0" # macos wheel issue on 22.10
|
||||
black = "^23.1.0"
|
||||
mypy = "^0.990"
|
||||
pytest-cov = "^2.12.1"
|
||||
mkdocs = "^1.3.0"
|
||||
|
||||
@@ -322,7 +322,6 @@ class Animator:
|
||||
)
|
||||
|
||||
if animation is None:
|
||||
|
||||
if not isinstance(value, (int, float)) and not isinstance(
|
||||
value, Animatable
|
||||
):
|
||||
|
||||
@@ -79,7 +79,6 @@ class Parser(Generic[T]):
|
||||
self._awaiting = next(self._gen)
|
||||
|
||||
def feed(self, data: str) -> Iterable[T]:
|
||||
|
||||
if self._eof:
|
||||
raise ParseError("end of file reached") from None
|
||||
if not data:
|
||||
@@ -104,7 +103,6 @@ class Parser(Generic[T]):
|
||||
yield popleft()
|
||||
|
||||
while pos < data_size or isinstance(self._awaiting, _PeekBuffer):
|
||||
|
||||
_awaiting = self._awaiting
|
||||
if isinstance(_awaiting, _Read1):
|
||||
self._awaiting = self._gen.send(data[pos : pos + 1])
|
||||
|
||||
@@ -38,7 +38,6 @@ class Sleeper(Thread):
|
||||
|
||||
|
||||
async def check_sleeps() -> None:
|
||||
|
||||
sleeper = Sleeper()
|
||||
sleeper.start()
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from rich.style import Style
|
||||
from ._border import get_box, render_row
|
||||
from ._filter import LineFilter
|
||||
from ._opacity import _apply_opacity
|
||||
from ._segment_tools import line_crop, line_pad, line_trim
|
||||
from ._segment_tools import line_pad, line_trim
|
||||
from ._typing import TypeAlias
|
||||
from .color import Color
|
||||
from .geometry import Region, Size, Spacing
|
||||
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
from .css.styles import StylesBase
|
||||
from .widget import Widget
|
||||
|
||||
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]
|
||||
RenderLineCallback: TypeAlias = Callable[[int], Strip]
|
||||
|
||||
|
||||
@lru_cache(1024 * 8)
|
||||
@@ -212,7 +212,7 @@ class StylesCache:
|
||||
padding: Spacing,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
render_content_line: Callable[[int], Strip],
|
||||
) -> Strip:
|
||||
"""Render a styled line.
|
||||
|
||||
@@ -313,6 +313,7 @@ class StylesCache:
|
||||
content_y = y - gutter.top
|
||||
if content_y < content_height:
|
||||
line = render_content_line(y - gutter.top)
|
||||
line = line.adjust_cell_length(content_width)
|
||||
else:
|
||||
line = [make_blank(content_width, inner)]
|
||||
if inner:
|
||||
|
||||
@@ -92,7 +92,6 @@ class XTermParser(Parser[events.Event]):
|
||||
return None
|
||||
|
||||
def parse(self, on_token: TokenCallback) -> Generator[Awaitable, str, None]:
|
||||
|
||||
ESC = "\x1b"
|
||||
read1 = self.read1
|
||||
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
|
||||
while True:
|
||||
|
||||
# If we run into another ESC at this point, then we've failed
|
||||
# to find a match, and should issue everything we've seen within
|
||||
# the suspected sequence as Key events instead.
|
||||
|
||||
@@ -30,7 +30,6 @@ class Content(Vertical):
|
||||
|
||||
class ColorsView(Vertical):
|
||||
def compose(self) -> ComposeResult:
|
||||
|
||||
LEVELS = [
|
||||
"darken-3",
|
||||
"darken-2",
|
||||
@@ -42,7 +41,6 @@ class ColorsView(Vertical):
|
||||
]
|
||||
|
||||
for color_name in ColorSystem.COLOR_NAMES:
|
||||
|
||||
items: list[Widget] = [Label(f'"{color_name}"')]
|
||||
for level in LEVELS:
|
||||
color = f"{color_name}-{level}" if level else color_name
|
||||
|
||||
@@ -434,7 +434,6 @@ class StylesBuilder:
|
||||
process_padding_left = _process_space_partial
|
||||
|
||||
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
|
||||
|
||||
border_type: EdgeType = "solid"
|
||||
border_color = Color(0, 255, 0)
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
|
||||
|
||||
if not css_selectors.strip():
|
||||
return ()
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ class DOMQuery(Generic[QueryType]):
|
||||
exclude: str | None = None,
|
||||
parent: DOMQuery | None = None,
|
||||
) -> None:
|
||||
|
||||
self._node = node
|
||||
self._nodes: list[QueryType] | None = None
|
||||
self._filters: list[tuple[SelectorSet, ...]] = (
|
||||
|
||||
@@ -327,7 +327,6 @@ class StylesBase(ABC):
|
||||
|
||||
# Check we are animating a Scalar or Scalar offset
|
||||
if isinstance(start_value, (Scalar, ScalarOffset)):
|
||||
|
||||
# If destination is a number, we can convert that to a scalar
|
||||
if isinstance(value, (int, float)):
|
||||
value = Scalar(value, Unit.CELLS, Unit.CELLS)
|
||||
|
||||
@@ -236,7 +236,6 @@ class ClientHandler:
|
||||
message = cast(WSMessage, message)
|
||||
|
||||
if message.type in (WSMsgType.TEXT, WSMsgType.BINARY):
|
||||
|
||||
try:
|
||||
if isinstance(message.data, bytes):
|
||||
message = msgpack.unpackb(message.data)
|
||||
|
||||
@@ -92,7 +92,6 @@ class LinuxDriver(Driver):
|
||||
self.console.file.flush()
|
||||
|
||||
def start_application_mode(self):
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def send_size_event():
|
||||
@@ -123,7 +122,6 @@ class LinuxDriver(Driver):
|
||||
except termios.error:
|
||||
pass
|
||||
else:
|
||||
|
||||
newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG])
|
||||
newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG])
|
||||
|
||||
@@ -208,7 +206,6 @@ class LinuxDriver(Driver):
|
||||
pass # TODO: log
|
||||
|
||||
def _run_input_thread(self, loop) -> None:
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(self.fileno, selectors.EVENT_READ)
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ class WindowsDriver(Driver):
|
||||
self.console.file.write("\x1b[?2004l")
|
||||
|
||||
def start_application_mode(self) -> None:
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
self._restore_console = win32.enable_application_mode()
|
||||
|
||||
@@ -18,7 +18,6 @@ class HorizontalLayout(Layout):
|
||||
def arrange(
|
||||
self, parent: Widget, children: list[Widget], size: Size
|
||||
) -> ArrangeResult:
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
x = max_height = Fraction(0)
|
||||
|
||||
@@ -19,7 +19,6 @@ class VerticalLayout(Layout):
|
||||
def arrange(
|
||||
self, parent: Widget, children: list[Widget], size: Size
|
||||
) -> ArrangeResult:
|
||||
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
parent_size = parent.outer_size
|
||||
|
||||
@@ -398,7 +398,6 @@ class MessagePump(metaclass=MessagePumpMeta):
|
||||
self.app._handle_exception(error)
|
||||
break
|
||||
finally:
|
||||
|
||||
self._message_queue.task_done()
|
||||
|
||||
current_time = time()
|
||||
|
||||
@@ -170,7 +170,6 @@ class Reactive(Generic[ReactiveType]):
|
||||
getattr(obj, "__computes", []).clear()
|
||||
|
||||
def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
|
||||
|
||||
# Check for compute method
|
||||
if hasattr(owner, f"compute_{name}"):
|
||||
# Compute methods are stored in a list called `__computes`
|
||||
|
||||
@@ -92,7 +92,6 @@ class ScrollBarRender:
|
||||
back_color: Color = Color.parse("#555555"),
|
||||
bar_color: Color = Color.parse("bright_magenta"),
|
||||
) -> Segments:
|
||||
|
||||
if vertical:
|
||||
bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", " "]
|
||||
else:
|
||||
@@ -190,7 +189,6 @@ class ScrollBarRender:
|
||||
|
||||
@rich.repr.auto
|
||||
class ScrollBar(Widget):
|
||||
|
||||
renderer: ClassVar[Type[ScrollBarRender]] = ScrollBarRender
|
||||
"""The class used for rendering scrollbars.
|
||||
This can be overriden and set to a ScrollBarRender-derived class
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Iterable, Iterator
|
||||
import rich.repr
|
||||
from rich.cells import cell_len, set_cell_size
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from rich.style import Style, StyleType
|
||||
|
||||
from ._cache import FIFOCache
|
||||
from ._filter import LineFilter
|
||||
@@ -49,7 +49,7 @@ class Strip:
|
||||
return "".join(segment.text for segment in self._segments)
|
||||
|
||||
@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.
|
||||
|
||||
Args:
|
||||
@@ -59,7 +59,8 @@ class Strip:
|
||||
Returns:
|
||||
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
|
||||
def from_lines(
|
||||
@@ -135,6 +136,23 @@ class Strip:
|
||||
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:
|
||||
"""Adjust the cell length, possibly truncating or extending.
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
self.path = path
|
||||
super().__init__(
|
||||
path,
|
||||
|
||||
@@ -28,7 +28,6 @@ class _InputRenderable:
|
||||
def __rich_console__(
|
||||
self, console: "Console", options: "ConsoleOptions"
|
||||
) -> "RenderResult":
|
||||
|
||||
input = self.input
|
||||
result = input._value
|
||||
if input._cursor_at_end:
|
||||
|
||||
@@ -57,7 +57,6 @@ class Static(Widget, inherit_bindings=False):
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.expand = expand
|
||||
self.shrink = shrink
|
||||
|
||||
@@ -14,7 +14,6 @@ from ..reactive import var
|
||||
from ..geometry import Size, Region
|
||||
from ..scroll_view import ScrollView
|
||||
from .._cache import LRUCache
|
||||
from .._segment_tools import line_crop
|
||||
from ..strip import Strip
|
||||
|
||||
|
||||
@@ -160,7 +159,6 @@ class TextLog(ScrollView, can_focus=True):
|
||||
return lines
|
||||
|
||||
def _render_line(self, y: int, scroll_x: int, width: int) -> Strip:
|
||||
|
||||
if y >= len(self.lines):
|
||||
return Strip.blank(width, self.rich_style)
|
||||
|
||||
|
||||
@@ -281,7 +281,6 @@ class TreeNode(Generic[TreeDataType]):
|
||||
|
||||
|
||||
class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("enter", "select_cursor", "Select", show=False),
|
||||
Binding("space", "toggle_node", "Toggle", show=False),
|
||||
|
||||
@@ -24,7 +24,6 @@ Where the fear has gone there will be nothing. Only I will remain."
|
||||
|
||||
|
||||
class Welcome(Static):
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Welcome {
|
||||
width: 100%;
|
||||
|
||||
@@ -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():
|
||||
assert Strip([Segment("foo"), Segment("bar")]).simplify() == Strip(
|
||||
[Segment("foobar")]
|
||||
|
||||
@@ -10,7 +10,7 @@ from textual.geometry import Region, Size
|
||||
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."""
|
||||
content = ["".join(segment.text for segment in line) for line in lines]
|
||||
return content
|
||||
@@ -28,9 +28,9 @@ def test_set_dirty():
|
||||
def test_no_styles():
|
||||
"""Test that empty style returns the content un-altered"""
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
cache = StylesCache()
|
||||
@@ -54,9 +54,9 @@ def test_no_styles():
|
||||
|
||||
def test_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.border = ("heavy", "white")
|
||||
@@ -85,9 +85,9 @@ def test_border():
|
||||
|
||||
def test_padding():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -116,9 +116,9 @@ def test_padding():
|
||||
|
||||
def test_padding_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -150,9 +150,9 @@ def test_padding_border():
|
||||
|
||||
def test_outline():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.outline = ("heavy", "white")
|
||||
@@ -177,9 +177,9 @@ def test_outline():
|
||||
|
||||
def test_crop():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
@@ -203,17 +203,17 @@ def test_crop():
|
||||
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."""
|
||||
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
Strip([Segment("foo")]),
|
||||
Strip([Segment("bar")]),
|
||||
Strip([Segment("baz")]),
|
||||
]
|
||||
rendered_lines: list[int] = []
|
||||
|
||||
def get_content_line(y: int) -> list[Segment]:
|
||||
def get_content_line(y: int) -> Strip:
|
||||
rendered_lines.append(y)
|
||||
return content[y]
|
||||
|
||||
@@ -227,11 +227,13 @@ def test_dirty_cache():
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
get_content_line,
|
||||
Size(3, 3),
|
||||
)
|
||||
assert rendered_lines == [0, 1, 2]
|
||||
del rendered_lines[:]
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
|
||||
expected_text = [
|
||||
"┏━━━━━┓",
|
||||
"┃ ┃",
|
||||
|
||||
Reference in New Issue
Block a user