Merge branch 'main' into add-containers

This commit is contained in:
Rodrigo Girão Serrão
2023-03-13 12:00:08 +00:00
36 changed files with 947 additions and 127 deletions

View File

@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.15.0] - Unreleased
## Unreleased
### Changed
@@ -14,11 +14,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Added a LoadingIndicator widget https://github.com/Textualize/textual/pull/2018
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
### Fixed
- Fixed container not resizing when a widget is removed https://github.com/Textualize/textual/issues/2007
- Fixes issue where the horizontal scrollbar would be incorrectly enabled https://github.com/Textualize/textual/pull/2024
## [0.14.0] - 2023-03-09

View File

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

View File

@@ -1,5 +1,5 @@
from textual.app import App
from textual.containers import VerticalScroll
from textual.containers import Container
from textual.widgets import Label
TEXT = """I must not fear.
@@ -14,7 +14,7 @@ Where the fear has gone there will be nothing. Only I will remain.
class ScrollbarApp(App):
def compose(self):
yield VerticalScroll(Label(TEXT * 5), classes="panel")
yield Container(Label(TEXT * 5), classes="panel")
app = ScrollbarApp(css_path="scrollbar_size.css")

View File

@@ -0,0 +1,12 @@
from textual.app import App, ComposeResult
from textual.widgets import LoadingIndicator
class LoadingApp(App):
def compose(self) -> ComposeResult:
yield LoadingIndicator()
if __name__ == "__main__":
app = LoadingApp()
app.run()

View File

@@ -44,7 +44,7 @@ python -m textual
If Textual is installed you should see the following:
```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,_,_,_,_,_,_,tab,_,w,i,l,l"}
```{.textual path="src/textual/demo.py" columns="127" lines="53" press="enter,tab,w,i,l,l"}
```
## Examples

View File

@@ -68,7 +68,7 @@ Let's look at a trivial Textual app.
=== "Output"
```{.textual path="docs/examples/guide/dom1.py" press="_"}
```{.textual path="docs/examples/guide/dom1.py"}
```
This example creates an instance of `ExampleApp`, which will implicitly create a `Screen` object. In DOM terms, the `Screen` is a _child_ of `ExampleApp`.

View File

@@ -27,7 +27,7 @@ Apps don't get much simpler than this—don't expect it to do much.
If we run this app with `python simple02.py` you will see a blank terminal, something like the following:
```{.textual path="docs/examples/app/simple02.py" press="_"}
```{.textual path="docs/examples/app/simple02.py"}
```
When you call [App.run()][textual.app.App.run] Textual puts the terminal in to a special state called *application mode*. When in application mode the terminal will no longer echo what you type. Textual will take over responding to user input (keyboard and mouse) and will update the visible portion of the terminal (i.e. the *screen*).
@@ -57,7 +57,7 @@ Another such event is the *key* event which is sent when the user presses a key.
The `on_mount` handler sets the `self.screen.styles.background` attribute to `"darkblue"` which (as you can probably guess) turns the background blue. Since the mount event is sent immediately after entering application mode, you will see a blue screen when you run this code.
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25" press="_"}
```{.textual path="docs/examples/app/event01.py" hl_lines="23-25"}
```
The key event handler (`on_key`) has an `event` parameter which will receive a [Key][textual.events.Key] instance. Every event has an associated event object which will be passed to the handler method if it is present in the method's parameter list.
@@ -114,7 +114,7 @@ Here's an app which adds a welcome widget in response to any key press:
When you first run this you will get a blank screen. Press any key to add the welcome widget. You can even press a key multiple times to add several widgets.
```{.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

View File

@@ -60,7 +60,7 @@ textual console
You should see the Textual devtools welcome message:
```{.textual title="textual console" path="docs/examples/getting_started/console.py", press="_,_"}
```{.textual title="textual console" path="docs/examples/getting_started/console.py"}
```
In the other console, run your application with `textual run` and the `--dev` switch:

View File

@@ -20,7 +20,7 @@ The most fundamental way to receive input is via [Key][textual.events.Key] event
=== "Output"
```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!,_"}
```{.textual path="docs/examples/guide/input/key01.py", press="T,e,x,t,u,a,l,!"}
```
When you press a key, the app will receive the event and write it to a [TextLog](../widgets/text_log.md) widget. Try pressing a few keys to see what happens.
@@ -102,7 +102,7 @@ The following example shows how focus works in practice.
=== "Output"
```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!,_"}
```{.textual path="docs/examples/guide/input/key03.py", press="tab,H,e,l,l,o,tab,W,o,r,l,d,!"}
```
The app splits the screen in to quarters, with a `TextLog` widget in each quarter. If you click any of the text logs, you should see that it is highlighted to show that the widget has focus. Key events will be sent to the focused widget only.

View File

@@ -444,7 +444,7 @@ The code below shows a simple sidebar implementation.
=== "Output"
```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout1_sidebar.py"
@@ -468,7 +468,7 @@ This new sidebar is double the width of the one previous one, and has a `deeppin
=== "Output"
```{.textual path="docs/examples/guide/layout/dock_layout2_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
```{.textual path="docs/examples/guide/layout/dock_layout2_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout2_sidebar.py"

View File

@@ -36,7 +36,7 @@ Let's look at a simple example of writing a screen class to simulate Window's [b
=== "Output"
```{.textual path="docs/examples/guide/screens/screen01.py" press="b,_"}
```{.textual path="docs/examples/guide/screens/screen01.py" press="b"}
```
If you run this you will see an empty screen. Hit the ++b++ key to show a blue screen of death. Hit ++escape++ to return to the default screen.
@@ -65,7 +65,7 @@ You can also _install_ new named screens dynamically with the [install_screen][t
=== "Output"
```{.textual path="docs/examples/guide/screens/screen02.py" press="b,_"}
```{.textual path="docs/examples/guide/screens/screen02.py" press="b"}
```
Although both do the same thing, we recommend `SCREENS` for screens that exist for the lifetime of your app.
@@ -145,7 +145,7 @@ Screens can be used to implement modal dialogs. The following example pushes a s
=== "Output"
```{.textual path="docs/examples/guide/screens/modal01.py" press="q,_"}
```{.textual path="docs/examples/guide/screens/modal01.py" press="q"}
```
Note the `request_quit` action in the app which pushes a new instance of `QuitScreen`. This makes the quit screen active. if you click cancel, the quit screen calls `pop_screen` to return the default screen. This also removes and deletes the `QuitScreen` object.

View File

@@ -20,7 +20,7 @@ The first line sets the [background](../styles/background.md) style to `"darkblu
The second line sets [border](../styles/border.md) to a tuple of `("heavy", "white")` which tells Textual to draw a white border with a style of `"heavy"`. Running this code will show the following:
```{.textual path="docs/examples/guide/styles/screen.py" press="_"}
```{.textual path="docs/examples/guide/styles/screen.py"}
```
## Styling widgets

View File

@@ -136,7 +136,7 @@ Let's use markup links in the hello example so that the greeting becomes a link
=== "Output"
```{.textual path="docs/examples/guide/widgets/hello05.py" press="_"}
```{.textual path="docs/examples/guide/widgets/hello05.py"}
```
If you run this example you will see that the greeting has been underlined, which indicates it is clickable. If you click on the greeting it will run the `next_word` action which updates the next word.

View File

@@ -77,7 +77,7 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t
```{.textual path="examples/pride.py"}
```
```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter,_,_"}
```{.textual path="docs/examples/tutorial/stopwatch.py" columns="100" lines="30" press="d,tab,enter"}
```

View File

@@ -19,7 +19,7 @@ Notice that even though the content is scrolled, the sidebar remains fixed.
=== "Output"
```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down,_,_,_,_,_"}
```{.textual path="docs/examples/guide/layout/dock_layout1_sidebar.py" press="pagedown,down,down"}
```
=== "dock_layout1_sidebar.py"

View File

@@ -25,7 +25,7 @@ This will be a simple yet **fully featured** app — you could distribute th
Here's what the finished app will look like:
```{.textual path="docs/examples/tutorial/stopwatch.py" press="tab,enter,_,tab,enter,_,tab,_,enter,_,tab,enter,_,_"}
```{.textual path="docs/examples/tutorial/stopwatch.py" press="tab,enter,tab,enter,tab,enter,tab,enter"}
```
### Get the code
@@ -339,7 +339,7 @@ The `on_button_pressed` method is an *event handler*. Event handlers are methods
If you run `stopwatch04.py` now you will be able to toggle between the two states by clicking the first button:
```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,_,enter,_,_,_"}
```{.textual path="docs/examples/tutorial/stopwatch04.py" title="stopwatch04.py" press="tab,tab,tab,enter"}
```
## Reactive attributes
@@ -421,7 +421,7 @@ This code supplies missing features and makes our app useful. We've made the fol
If you run `stopwatch06.py` you will be able to use the stopwatches independently.
```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,_,_,tab,enter,_,tab"}
```{.textual path="docs/examples/tutorial/stopwatch06.py" title="stopwatch06.py" press="tab,enter,tab,enter,tab"}
```
The only remaining feature of the Stopwatch app left to implement is the ability to add and remove stopwatches.
@@ -449,7 +449,7 @@ The `action_remove_stopwatch` function calls [query()][textual.dom.DOMNode.query
If you run `stopwatch.py` now you can add a new stopwatch with the ++a++ key and remove a stopwatch with ++r++.
```{.textual path="docs/examples/tutorial/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,_,_,_,_,tab,_"}
```{.textual path="docs/examples/tutorial/stopwatch.py" press="d,a,a,a,a,a,a,a,tab,enter,tab"}
```
## What next?

View File

@@ -109,6 +109,15 @@ Display a list of items (items may be other widgets).
```{.textual path="docs/examples/widgets/list_view.py"}
```
## LoadingIndicator
Display an animation while data is loading.
[LoadingIndicator reference](./widgets/loading_indicator.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/loading_indicator.py"}
```
## MarkdownViewer
Display and interact with a Markdown document (adds a table of contents and browser-like navigation to Markdown).
@@ -182,7 +191,7 @@ Display and update text in a scrolling panel.
[TextLog reference](./widgets/text_log.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/text_log.py" press="_,H,i"}
```{.textual path="docs/examples/widgets/text_log.py" press="H,i"}
```
## Tree

View File

@@ -0,0 +1,24 @@
# LoadingIndicator
Displays pulsating dots to indicate when data is being loaded.
- [ ] Focusable
- [ ] Container
=== "Output"
```{.textual path="docs/examples/widgets/loading_indicator.py"}
```
=== "loading_indicator.py"
```python
--8<-- "docs/examples/widgets/loading_indicator.py"
```
## See Also
* [LoadingIndicator](../api/loading_indicator.md) code reference

View File

@@ -13,7 +13,7 @@ The example below shows an application showing a `TextLog` with different kinds
=== "Output"
```{.textual path="docs/examples/widgets/text_log.py" press="_,H,i"}
```{.textual path="docs/examples/widgets/text_log.py" press="H,i"}
```
=== "text_log.py"

View File

@@ -132,6 +132,7 @@ nav:
- "widgets/label.md"
- "widgets/list_item.md"
- "widgets/list_view.md"
- "widgets/loading_indicator.md"
- "widgets/markdown_viewer.md"
- "widgets/markdown.md"
- "widgets/placeholder.md"
@@ -163,6 +164,7 @@ nav:
- "api/label.md"
- "api/list_item.md"
- "api/list_view.md"
- "api/loading_indicator.md"
- "api/markdown_viewer.md"
- "api/markdown.md"
- "api/message_pump.md"

View File

@@ -48,7 +48,7 @@ typing-extensions = "^4.4.0"
aiohttp = { version = ">=3.8.1", optional = true }
click = {version = ">=8.1.2", optional = true}
msgpack = { version = ">=1.0.3", optional = true }
mkdocs-exclude = "^1.0.2"
mkdocs-exclude = { version = "^1.0.2", optional = true }
[tool.poetry.extras]
dev = ["aiohttp", "click", "msgpack"]

View File

@@ -2175,7 +2175,7 @@ class App(Generic[ReturnType], DOMNode):
for child in widget._nodes:
push(child)
def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove:
def _remove_nodes(self, widgets: list[Widget], parent: DOMNode) -> AwaitRemove:
"""Remove nodes from DOM, and return an awaitable that awaits cleanup.
Args:
@@ -2198,7 +2198,8 @@ class App(Generic[ReturnType], DOMNode):
await self._prune_nodes(widgets)
finally:
finished_event.set()
self.refresh(layout=True)
if parent.styles.auto_dimensions:
parent.refresh(layout=True)
removed_widgets = self._detach_from_dom(widgets)

View File

@@ -551,6 +551,52 @@ class Color(NamedTuple):
return (WHITE if white_contrast > black_contrast else BLACK).with_alpha(alpha)
class Gradient:
"""Defines a color gradient."""
def __init__(self, *stops: tuple[float, Color]) -> None:
"""Create a color gradient that blends colors to form a spectrum.
A gradient is defined by a sequence of "stops" consisting of a float and a color.
The stop indicate the color at that point on a spectrum between 0 and 1.
Args:
stops: A colors stop.
Raises:
ValueError: If any stops are missing (must be at least a stop for 0 and 1).
"""
self.stops = sorted(stops)
if len(stops) < 2:
raise ValueError("At least 2 stops required.")
if self.stops[0][0] != 0.0:
raise ValueError("First stop must be 0.")
if self.stops[-1][0] != 1.0:
raise ValueError("Last stop must be 1.")
def get_color(self, position: float) -> Color:
"""Get a color from the gradient at a position between 0 and 1.
Positions that are between stops will return a blended color.
Args:
factor: A number between 0 and 1, where 0 is the first stop, and 1 is the last.
Returns:
A color.
"""
# TODO: consider caching
position = clamp(position, 0.0, 1.0)
for (stop1, color1), (stop2, color2) in zip(self.stops, self.stops[1:]):
if stop2 >= position >= stop1:
return color1.blend(
color2,
(position - stop1) / (stop2 - stop1),
)
return self.stops[-1][1]
# Color constants
WHITE = Color(255, 255, 255)
BLACK = Color(0, 0, 0)

View File

@@ -355,7 +355,7 @@ class DOMQuery(Generic[QueryType]):
An awaitable object that waits for the widgets to be removed.
"""
app = active_app.get()
await_remove = app._remove_nodes(list(self))
await_remove = app._remove_nodes(list(self), self._node)
return await_remove
def set_styles(

View File

@@ -158,6 +158,7 @@ class DOMNode(MessagePump):
@property
def auto_refresh(self) -> float | None:
"""Interval to refresh widget, or `None` for no automatic refresh."""
return self._auto_refresh
@auto_refresh.setter

View File

@@ -55,6 +55,7 @@ class Driver(ABC):
and not event.button
and self._last_move_event is not None
):
# Deduplicate self._down_buttons while preserving order.
buttons = list(dict.fromkeys(self._down_buttons).keys())
self._down_buttons.clear()
move_event = self._last_move_event

View File

@@ -526,7 +526,8 @@ class MessagePump(metaclass=MessagePumpMeta):
await self.on_event(message)
else:
await self._on_message(message)
await self._flush_next_callbacks()
if self._next_callbacks:
await self._flush_next_callbacks()
def _get_dispatch_methods(
self, method_name: str, message: Message

View File

@@ -1010,11 +1010,11 @@ class Widget(DOMNode):
show_vertical = self.virtual_size.height > height
# When a single scrollbar is shown, the other dimension changes, so we need to recalculate.
if show_vertical and not show_horizontal:
if overflow_x == "auto" and show_vertical and not show_horizontal:
show_horizontal = self.virtual_size.width > (
width - styles.scrollbar_size_vertical
)
if show_horizontal and not show_vertical:
if overflow_y == "auto" and show_horizontal and not show_vertical:
show_vertical = self.virtual_size.height > (
height - styles.scrollbar_size_horizontal
)
@@ -2548,7 +2548,7 @@ class Widget(DOMNode):
An awaitable object that waits for the widget to be removed.
"""
await_remove = self.app._remove_nodes([self])
await_remove = self.app._remove_nodes([self], self.parent)
return await_remove
def render(self) -> RenderableType:

View File

@@ -21,6 +21,7 @@ if typing.TYPE_CHECKING:
from ._label import Label
from ._list_item import ListItem
from ._list_view import ListView
from ._loading_indicator import LoadingIndicator
from ._markdown import Markdown, MarkdownViewer
from ._placeholder import Placeholder
from ._pretty import Pretty
@@ -45,6 +46,7 @@ __all__ = [
"Label",
"ListItem",
"ListView",
"LoadingIndicator",
"Markdown",
"MarkdownViewer",
"Placeholder",

View File

@@ -10,6 +10,7 @@ from ._input import Input as Input
from ._label import Label as Label
from ._list_item import ListItem as ListItem
from ._list_view import ListView as ListView
from ._loading_indicator import LoadingIndicator as LoadingIndicator
from ._markdown import Markdown as Markdown
from ._markdown import MarkdownViewer as MarkdownViewer
from ._placeholder import Placeholder as Placeholder

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
from time import time
from rich.console import RenderableType
from rich.style import Style
from rich.text import Text
from ..color import Gradient
from ..widget import Widget
class LoadingIndicator(Widget):
"""Display an animated loading indicator."""
COMPONENT_CLASSES = {"loading-indicator--dot"}
DEFAULT_CSS = """
LoadingIndicator {
width: 100%;
height: 100%;
content-align: center middle;
}
LoadingIndicator > .loading-indicator--dot {
color: $accent;
}
"""
def on_mount(self) -> None:
self._start_time = time()
self.auto_refresh = 1 / 16
def render(self) -> RenderableType:
elapsed = time() - self._start_time
speed = 0.8
dot = "\u25CF"
dot_styles = self.get_component_styles("loading-indicator--dot")
base_style = self.rich_style
background = self.background_colors[-1]
color = dot_styles.color
gradient = Gradient(
(0.0, background.blend(color, 0.1)),
(0.7, color),
(1.0, color.lighten(0.1)),
)
blends = [(elapsed * speed - dot_number / 8) % 1 for dot_number in range(5)]
dots = [
(
f"{dot} ",
base_style
+ Style.from_color(gradient.get_color((1 - blend) ** 2).rich_color),
)
for blend in blends
]
indicator = Text.assemble(*dots)
indicator.rstrip()
return indicator

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,189 @@
from os import urandom
from random import randrange
from textual.app import App
from textual.containers import Container, Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import DataTable, Header, Label
class LabeledBox(Container):
DEFAULT_CSS = """
LabeledBox {
layers: base_ top_;
width: 100%;
height: 100%;
}
LabeledBox > Container {
layer: base_;
border: round $primary;
width: 100%;
height: 100%;
layout: vertical;
}
LabeledBox > Label {
layer: top_;
offset-x: 2;
}
"""
def __init__(self, title, *args, **kwargs):
self.__label = Label(title)
super().__init__(self.__label, Container(*args, **kwargs))
@property
def label(self):
return self.__label
class StatusTable(DataTable):
def __init__(self):
super().__init__()
self.cursor_type = "row"
self.show_cursor = False
self.add_column("Foo")
self.add_column("Bar")
self.add_column("Baz")
for _ in range(50):
self.add_row(
"ABCDEFGH",
"0123456789",
"IJKLMNOPQRSTUVWXYZ",
)
class Status(LabeledBox):
DEFAULT_CSS = """
Status {
width: auto;
}
Status Container {
width: auto;
}
Status StatusTable {
width: auto;
margin-top: 1;
scrollbar-gutter: stable;
overflow-x: hidden;
}
"""
def __init__(self, name: str):
self.__name = name
self.__table = StatusTable()
super().__init__(f" {self.__name} ", self.__table)
@property
def name(self) -> str:
return self.__name
@property
def table(self) -> StatusTable:
return self.__table
class Rendering(LabeledBox):
DEFAULT_CSS = """
#issue-info {
height: auto;
border-bottom: dashed #632CA6;
}
#statuses-box {
height: 1fr;
width: auto;
}
"""
def __init__(self):
self.__info = Label("test")
super().__init__(
"",
Container(
Horizontal(self.__info, id="issue-info"),
Horizontal(*[Status(str(i)) for i in range(4)], id="statuses-box"),
id="issues-box",
),
)
@property
def info(self) -> Label:
return self.__info
class Sidebar(LabeledBox):
DEFAULT_CSS = """
#sidebar-status {
height: auto;
border-bottom: dashed #632CA6;
}
#sidebar-options {
height: 1fr;
}
"""
def __init__(self):
self.__status = Label("ok")
self.__options = Vertical()
super().__init__(
"",
Container(self.__status, id="sidebar-status"),
Container(self.__options, id="sidebar-options"),
)
@property
def status(self) -> Label:
return self.__status
@property
def options(self) -> Vertical:
return self.__options
class MyScreen(Screen):
DEFAULT_CSS = """
#main-content {
layout: grid;
grid-size: 2;
grid-columns: 1fr 5fr;
grid-rows: 1fr;
}
#main-content-sidebar {
height: 100%;
}
#main-content-rendering {
height: 100%;
}
"""
def compose(self):
yield Header()
yield Container(
Container(Sidebar(), id="main-content-sidebar"),
Container(Rendering(), id="main-content-rendering"),
id="main-content",
)
class MyApp(App):
async def on_mount(self):
self.install_screen(MyScreen(), "myscreen")
await self.push_screen("myscreen")
if __name__ == "__main__":
app = MyApp()
app.run()

View File

@@ -0,0 +1,38 @@
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Label
class VerticalRemoveApp(App[None]):
CSS = """
Vertical {
border: round green;
height: auto;
}
Label {
border: round yellow;
background: red;
color: yellow;
}
"""
BINDINGS = [
("a", "add", "Add"),
("d", "del", "Delete"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Vertical()
yield Footer()
def action_add(self) -> None:
self.query_one(Vertical).mount(Label("This is a test label"))
def action_del(self) -> None:
if self.query_one(Vertical).children:
self.query_one(Vertical).children[-1].remove()
if __name__ == "__main__":
VerticalRemoveApp().run()

View File

@@ -133,12 +133,12 @@ def test_header_render(snap_compare):
def test_list_view(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up", "_"]
WIDGET_EXAMPLES_DIR / "list_view.py", press=["tab", "down", "down", "up"]
)
def test_textlog_max_lines(snap_compare):
assert snap_compare("snapshot_apps/textlog_max_lines.py", press=[*"abcde", "_"])
assert snap_compare("snapshot_apps/textlog_max_lines.py", press=[*"abcde"])
def test_fr_units(snap_compare):
@@ -213,7 +213,7 @@ def test_order_independence(snap_compare):
def test_order_independence_toggle(snap_compare):
assert snap_compare("snapshot_apps/layer_order_independence.py", press="t,_")
assert snap_compare("snapshot_apps/layer_order_independence.py", press="t")
def test_columns_height(snap_compare):
@@ -228,7 +228,7 @@ def test_offsets(snap_compare):
def test_nested_auto_heights(snap_compare):
"""Test refreshing widget within a auto sized container"""
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):
@@ -306,4 +306,14 @@ def test_focus_component_class(snap_compare):
def test_line_api_scrollbars(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "line_api_scrollbars.py", press=["_"])
assert snap_compare(SNAPSHOT_APPS_DIR / "line_api_scrollbars.py")
def test_remove_with_auto_height(snap_compare):
assert snap_compare(
SNAPSHOT_APPS_DIR / "remove_auto.py", press=["a", "a", "a", "d", "d"]
)
def test_auto_table(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40))

View File

@@ -2,7 +2,7 @@ import pytest
from rich.color import Color as RichColor
from rich.text import Text
from textual.color import Color, Lab, lab_to_rgb, rgb_to_lab
from textual.color import Color, Gradient, Lab, lab_to_rgb, rgb_to_lab
def test_rich_color():
@@ -215,3 +215,31 @@ def test_rgb_lab_rgb_roundtrip():
def test_inverse():
assert Color(55, 0, 255, 0.1).inverse == Color(200, 255, 0, 0.1)
def test_gradient_errors():
with pytest.raises(ValueError):
Gradient()
with pytest.raises(ValueError):
Gradient((0, Color.parse("red")))
with pytest.raises(ValueError):
Gradient(
(0, Color.parse("red")),
(0.8, Color.parse("blue")),
)
def test_gradient():
gradient = Gradient(
(0, Color(255, 0, 0)),
(0.5, Color(0, 0, 255)),
(1, Color(0, 255, 0)),
)
assert gradient.get_color(-1) == Color(255, 0, 0)
assert gradient.get_color(0) == Color(255, 0, 0)
assert gradient.get_color(1) == Color(0, 255, 0)
assert gradient.get_color(1.2) == Color(0, 255, 0)
assert gradient.get_color(0.5) == Color(0, 0, 255)
assert gradient.get_color(0.7) == Color(0, 101, 153)