mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into themes
This commit is contained in:
@@ -5,11 +5,13 @@ 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/).
|
||||
|
||||
## Unreleased
|
||||
## [0.82.0] - 2024-10-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issue with screen not updating when auto_refresh was enabled https://github.com/Textualize/textual/pull/5063
|
||||
- Fixed issues regarding loading indicator https://github.com/Textualize/textual/pull/5079
|
||||
- Fixed issues with inspecting the lazy loaded widgets module https://github.com/Textualize/textual/pull/5080
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,6 +19,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Added support for keymaps (user configurable key bindings) https://github.com/Textualize/textual/pull/5038
|
||||
- Added descriptions to bindings for all internal widgets, and updated casing to be consistent https://github.com/Textualize/textual/pull/5062
|
||||
|
||||
### Changed
|
||||
|
||||
- Breaking change: `Widget.set_loading` no longer return an awaitable https://github.com/Textualize/textual/pull/5079
|
||||
|
||||
## [0.81.0] - 2024-09-25
|
||||
|
||||
### Added
|
||||
@@ -2419,6 +2425,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
- New handler system for messages that doesn't require inheritance
|
||||
- Improved traceback handling
|
||||
|
||||
[0.82.0]: https://github.com/Textualize/textual/compare/v0.81.0...v0.82.0
|
||||
[0.81.0]: https://github.com/Textualize/textual/compare/v0.80.1...v0.81.0
|
||||
[0.80.1]: https://github.com/Textualize/textual/compare/v0.80.0...v0.80.1
|
||||
[0.80.0]: https://github.com/Textualize/textual/compare/v0.79.0...v0.80.0
|
||||
|
||||
12
docs/examples/guide/widgets/counter.tcss
Normal file
12
docs/examples/guide/widgets/counter.tcss
Normal file
@@ -0,0 +1,12 @@
|
||||
Counter {
|
||||
background: $panel-darken-1;
|
||||
padding: 1 2;
|
||||
color: $text-muted;
|
||||
|
||||
&:focus { /* (1)! */
|
||||
background: $primary;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
outline-left: thick $accent;
|
||||
}
|
||||
}
|
||||
27
docs/examples/guide/widgets/counter01.py
Normal file
27
docs/examples/guide/widgets/counter01.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from textual.app import App, ComposeResult, RenderResult
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
|
||||
class Counter(Static, can_focus=True): # (1)!
|
||||
"""A counter that can be incremented and decremented by pressing keys."""
|
||||
|
||||
count = reactive(0)
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
return f"Count: {self.count}"
|
||||
|
||||
|
||||
class CounterApp(App[None]):
|
||||
CSS_PATH = "counter.tcss"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Counter()
|
||||
yield Counter()
|
||||
yield Counter()
|
||||
yield Footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = CounterApp()
|
||||
app.run()
|
||||
35
docs/examples/guide/widgets/counter02.py
Normal file
35
docs/examples/guide/widgets/counter02.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from textual.app import App, ComposeResult, RenderResult
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Footer, Static
|
||||
|
||||
|
||||
class Counter(Static, can_focus=True):
|
||||
"""A counter that can be incremented and decremented by pressing keys."""
|
||||
|
||||
BINDINGS = [
|
||||
("up,k", "change_count(1)", "Increment"), # (1)!
|
||||
("down,j", "change_count(-1)", "Decrement"),
|
||||
]
|
||||
|
||||
count = reactive(0)
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
return f"Count: {self.count}"
|
||||
|
||||
def action_change_count(self, amount: int) -> None: # (2)!
|
||||
self.count += amount
|
||||
|
||||
|
||||
class CounterApp(App[None]):
|
||||
CSS_PATH = "counter.tcss"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Counter()
|
||||
yield Counter()
|
||||
yield Counter()
|
||||
yield Footer()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = CounterApp()
|
||||
app.run()
|
||||
@@ -113,9 +113,16 @@ The app splits the screen in to quarters, with a `RichLog` widget in each quarte
|
||||
|
||||
You can move focus by pressing the ++tab++ key to focus the next widget. Pressing ++shift+tab++ moves the focus in the opposite direction.
|
||||
|
||||
### Focusable widgets
|
||||
|
||||
Each widget has a boolean `can_focus` attribute which determines if it is capable of receiving focus.
|
||||
Note that `can_focus=True` does not mean the widget will _always_ be focusable.
|
||||
For example, a disabled widget cannot receive focus even if `can_focus` is `True`.
|
||||
|
||||
### Controlling focus
|
||||
|
||||
Textual will handle keyboard focus automatically, but you can tell Textual to focus a widget by calling the widget's [focus()][textual.widget.Widget.focus] method.
|
||||
By default, Textual will focus the first focusable widget when the app starts.
|
||||
|
||||
### Focus events
|
||||
|
||||
@@ -154,6 +161,9 @@ Note how the footer displays bindings and makes them clickable.
|
||||
Multiple keys can be bound to a single action by comma-separating them.
|
||||
For example, `("r,t", "add_bar('red')", "Add Red")` means both ++r++ and ++t++ are bound to `add_bar('red')`.
|
||||
|
||||
When you press a key, Textual will first check for a matching binding in the `BINDINGS` list of the currently focused widget.
|
||||
If no match is found, it will search upwards through the DOM all the way up to the `App` looking for a match.
|
||||
|
||||
### Binding class
|
||||
|
||||
The tuple of three strings may be enough for simple bindings, but you can also replace the tuple with a [Binding][textual.binding.Binding] instance which exposes a few more options.
|
||||
|
||||
@@ -190,6 +190,69 @@ If the supplied text is too long to fit within the widget, it will be cropped (a
|
||||
There are a number of styles that influence how titles are displayed (color and alignment).
|
||||
See the [style reference](../styles/index.md) for details.
|
||||
|
||||
## Focus & keybindings
|
||||
|
||||
Widgets can have a list of associated key [bindings](../guide/input.md#bindings),
|
||||
which let them call [actions](../guide/actions.md) in response to key presses.
|
||||
|
||||
A widget is able to handle key presses if it or one of its descendants has [focus](../guide/input.md#input-focus).
|
||||
|
||||
Widgets aren't focusable by default.
|
||||
To allow a widget to be focused, we need to set `can_focus=True` when defining a widget subclass.
|
||||
Here's an example of a simple focusable widget:
|
||||
|
||||
=== "counter01.py"
|
||||
|
||||
```python title="counter01.py" hl_lines="6"
|
||||
--8<-- "docs/examples/guide/widgets/counter01.py"
|
||||
```
|
||||
|
||||
1. Allow the widget to receive input focus.
|
||||
|
||||
=== "counter.tcss"
|
||||
|
||||
```css title="counter.tcss" hl_lines="6-11"
|
||||
--8<-- "docs/examples/guide/widgets/counter.tcss"
|
||||
```
|
||||
|
||||
1. These styles are applied only when the widget has focus.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/widgets/counter01.py"}
|
||||
```
|
||||
|
||||
|
||||
The app above contains three `Counter` widgets, which we can focus by clicking or using ++tab++ and ++shift+tab++.
|
||||
|
||||
Now that our counter is focusable, let's add some keybindings to it to allow us to change the count using the keyboard.
|
||||
To do this, we add a `BINDINGS` class variable to `Counter`, with bindings for ++up++ and ++down++.
|
||||
These new bindings are linked to the `change_count` action, which updates the `count` reactive attribute.
|
||||
|
||||
With our bindings in place, we can now change the count of the _currently focused_ counter using ++up++ and ++down++.
|
||||
|
||||
=== "counter02.py"
|
||||
|
||||
```python title="counter02.py" hl_lines="9-12 19-20"
|
||||
--8<-- "docs/examples/guide/widgets/counter02.py"
|
||||
```
|
||||
|
||||
1. Associates presses of ++up++ or ++k++ with the `change_count` action, passing `1` as the argument to increment the count. The final argument ("Increment") is a user-facing label displayed in the footer when this binding is active.
|
||||
2. Called when the binding is triggered. Take care to add the `action_` prefix to the method name.
|
||||
|
||||
=== "counter.tcss"
|
||||
|
||||
```css title="counter.tcss"
|
||||
--8<-- "docs/examples/guide/widgets/counter.tcss"
|
||||
```
|
||||
|
||||
1. These styles are applied only when the widget has focus.
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/guide/widgets/counter02.py" press="up,tab,down,down"}
|
||||
```
|
||||
|
||||
## Rich renderables
|
||||
|
||||
In previous examples we've set strings as content for Widgets. You can also use special objects called [renderables](https://rich.readthedocs.io/en/latest/protocol.html) for advanced visuals. You can use any renderable defined in [Rich](https://github.com/Textualize/rich) or third party libraries.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.81.0"
|
||||
version = "0.82.0"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
repository = "https://github.com/Textualize/textual"
|
||||
documentation = "https://textual.textualize.io/"
|
||||
|
||||
@@ -664,6 +664,17 @@ class Compositor:
|
||||
|
||||
get_layer_index = layers_to_index.get
|
||||
|
||||
if widget._cover_widget is not None:
|
||||
map[widget._cover_widget] = _MapGeometry(
|
||||
region.shrink(widget.styles.gutter),
|
||||
order,
|
||||
clip,
|
||||
region.size,
|
||||
container_size,
|
||||
virtual_region,
|
||||
dock_gutter,
|
||||
)
|
||||
|
||||
# Add all the widgets
|
||||
for sub_region, _, sub_widget, z, fixed, overlay in reversed(
|
||||
placements
|
||||
@@ -681,18 +692,17 @@ class Compositor:
|
||||
widget_region = self._constrain(
|
||||
sub_widget.styles, widget_region, no_clip
|
||||
)
|
||||
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region,
|
||||
widget_region,
|
||||
((1, 0, 0),) if overlay else widget_order,
|
||||
layer_order,
|
||||
no_clip if overlay else sub_clip,
|
||||
visible,
|
||||
arrange_result.scroll_spacing,
|
||||
)
|
||||
|
||||
if widget._cover_widget is None:
|
||||
add_widget(
|
||||
sub_widget,
|
||||
sub_region,
|
||||
widget_region,
|
||||
((1, 0, 0),) if overlay else widget_order,
|
||||
layer_order,
|
||||
no_clip if overlay else sub_clip,
|
||||
visible,
|
||||
arrange_result.scroll_spacing,
|
||||
)
|
||||
layer_order -= 1
|
||||
|
||||
if visible:
|
||||
@@ -737,7 +747,7 @@ class Compositor:
|
||||
if styles.constrain != "none":
|
||||
widget_region = self._constrain(styles, widget_region, no_clip)
|
||||
|
||||
map[widget] = _MapGeometry(
|
||||
map[widget._render_widget] = _MapGeometry(
|
||||
widget_region,
|
||||
order,
|
||||
clip,
|
||||
|
||||
@@ -209,7 +209,7 @@ class Color(NamedTuple):
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
"""Is the color transparent (i.e. has 0 alpha)?"""
|
||||
return self.a == 0 and self.ansi is not None
|
||||
return self.a == 0 and self.ansi is None
|
||||
|
||||
@property
|
||||
def clamped(self) -> Color:
|
||||
|
||||
@@ -14,7 +14,6 @@ from types import TracebackType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
AsyncGenerator,
|
||||
Awaitable,
|
||||
ClassVar,
|
||||
Collection,
|
||||
Generator,
|
||||
@@ -58,7 +57,6 @@ from textual._segment_tools import align_lines
|
||||
from textual._styles_cache import StylesCache
|
||||
from textual._types import AnimationLevel
|
||||
from textual.actions import SkipAction
|
||||
from textual.await_complete import AwaitComplete
|
||||
from textual.await_remove import AwaitRemove
|
||||
from textual.box_model import BoxModel
|
||||
from textual.cache import FIFOCache
|
||||
@@ -333,6 +331,38 @@ class Widget(DOMNode):
|
||||
loading: Reactive[bool] = Reactive(False)
|
||||
"""If set to `True` this widget will temporarily be replaced with a loading indicator."""
|
||||
|
||||
virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
|
||||
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""
|
||||
|
||||
has_focus: Reactive[bool] = Reactive(False, repaint=False)
|
||||
"""Does this widget have focus? Read only."""
|
||||
|
||||
mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
|
||||
"""Is the mouse over this widget? Read only."""
|
||||
|
||||
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
|
||||
"""The scroll position on the X axis."""
|
||||
|
||||
scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
|
||||
"""The scroll position on the Y axis."""
|
||||
|
||||
scroll_target_x = Reactive(0.0, repaint=False)
|
||||
"""Scroll target destination, X coord."""
|
||||
|
||||
scroll_target_y = Reactive(0.0, repaint=False)
|
||||
"""Scroll target destination, Y coord."""
|
||||
|
||||
show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
||||
"""Show a vertical scrollbar?"""
|
||||
|
||||
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
||||
"""Show a horizontal scrollbar?"""
|
||||
|
||||
border_title: str | Text | None = _BorderTitle() # type: ignore
|
||||
"""A title to show in the top border (if there is one)."""
|
||||
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
|
||||
"""A title to show in the bottom border (if there is one)."""
|
||||
|
||||
# Default sort order, incremented by constructor
|
||||
_sort_order: ClassVar[int] = 0
|
||||
|
||||
@@ -430,38 +460,8 @@ class Widget(DOMNode):
|
||||
"""An anchored child widget, or `None` if no child is anchored."""
|
||||
self._anchor_animate: bool = False
|
||||
"""Flag to enable animation when scrolling anchored widgets."""
|
||||
|
||||
virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True)
|
||||
"""The virtual (scrollable) [size][textual.geometry.Size] of the widget."""
|
||||
|
||||
has_focus: Reactive[bool] = Reactive(False, repaint=False)
|
||||
"""Does this widget have focus? Read only."""
|
||||
|
||||
mouse_hover: Reactive[bool] = Reactive(False, repaint=False)
|
||||
"""Is the mouse over this widget? Read only."""
|
||||
|
||||
scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
|
||||
"""The scroll position on the X axis."""
|
||||
|
||||
scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False)
|
||||
"""The scroll position on the Y axis."""
|
||||
|
||||
scroll_target_x = Reactive(0.0, repaint=False)
|
||||
"""Scroll target destination, X coord."""
|
||||
|
||||
scroll_target_y = Reactive(0.0, repaint=False)
|
||||
"""Scroll target destination, Y coord."""
|
||||
|
||||
show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
||||
"""Show a vertical scrollbar?"""
|
||||
|
||||
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
||||
"""Show a horizontal scrollbar?"""
|
||||
|
||||
border_title: str | Text | None = _BorderTitle() # type: ignore
|
||||
"""A title to show in the top border (if there is one)."""
|
||||
border_subtitle: str | Text | None = _BorderTitle() # type: ignore
|
||||
"""A title to show in the bottom border (if there is one)."""
|
||||
self._cover_widget: Widget | None = None
|
||||
"""Widget to render over this widget (used by loading indicator)."""
|
||||
|
||||
@property
|
||||
def is_mounted(self) -> bool:
|
||||
@@ -587,6 +587,33 @@ class Widget(DOMNode):
|
||||
except NoScreen:
|
||||
return False
|
||||
|
||||
@property
|
||||
def _render_widget(self) -> Widget:
|
||||
"""The widget the compositor should render."""
|
||||
# Will return the "cover widget" if one is set, otherwise self.
|
||||
return self._cover_widget if self._cover_widget is not None else self
|
||||
|
||||
def _cover(self, widget: Widget) -> None:
|
||||
"""Set a widget used to replace the visuals of this widget (used for loading indicator).
|
||||
|
||||
Args:
|
||||
widget: A newly constructed, but unmounted widget.
|
||||
"""
|
||||
self._uncover()
|
||||
self._cover_widget = widget
|
||||
widget._parent = self
|
||||
widget._start_messages()
|
||||
widget._post_register(self.app)
|
||||
self.app.stylesheet.apply(widget)
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _uncover(self) -> None:
|
||||
"""Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover]."""
|
||||
if self._cover_widget is not None:
|
||||
self._cover_widget.remove()
|
||||
self._cover_widget = None
|
||||
self.refresh(layout=True)
|
||||
|
||||
def anchor(self, *, animate: bool = False) -> None:
|
||||
"""Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]),
|
||||
but also keeps it in view if the widget's size changes, or the size of its container changes.
|
||||
@@ -716,7 +743,7 @@ class Widget(DOMNode):
|
||||
loading_widget = self.app.get_loading_widget()
|
||||
return loading_widget
|
||||
|
||||
def set_loading(self, loading: bool) -> Awaitable:
|
||||
def set_loading(self, loading: bool) -> None:
|
||||
"""Set or reset the loading state of this widget.
|
||||
|
||||
A widget in a loading state will display a LoadingIndicator that obscures the widget.
|
||||
@@ -728,19 +755,16 @@ class Widget(DOMNode):
|
||||
An optional awaitable.
|
||||
"""
|
||||
LOADING_INDICATOR_CLASS = "-textual-loading-indicator"
|
||||
LOADING_INDICATOR_QUERY = f".{LOADING_INDICATOR_CLASS}"
|
||||
remove_indicator = self.query_children(LOADING_INDICATOR_QUERY).remove()
|
||||
if loading:
|
||||
loading_indicator = self.get_loading_widget()
|
||||
loading_indicator.add_class(LOADING_INDICATOR_CLASS)
|
||||
await_mount = self.mount(loading_indicator)
|
||||
return AwaitComplete(remove_indicator, await_mount).call_next(self)
|
||||
self._cover(loading_indicator)
|
||||
else:
|
||||
return remove_indicator
|
||||
self._uncover()
|
||||
|
||||
async def _watch_loading(self, loading: bool) -> None:
|
||||
def _watch_loading(self, loading: bool) -> None:
|
||||
"""Called when the 'loading' reactive is changed."""
|
||||
await self.set_loading(loading)
|
||||
self.set_loading(loading)
|
||||
|
||||
ExpectType = TypeVar("ExpectType", bound="Widget")
|
||||
|
||||
@@ -3994,6 +4018,7 @@ class Widget(DOMNode):
|
||||
self.scroll_to_region(message.region, animate=True)
|
||||
|
||||
def _on_unmount(self) -> None:
|
||||
self._uncover()
|
||||
self.workers.cancel_node(self)
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
|
||||
@@ -104,7 +104,7 @@ def __getattr__(widget_class: str) -> type[Widget]:
|
||||
pass
|
||||
|
||||
if widget_class not in __all__:
|
||||
raise ImportError(f"Package 'textual.widgets' has no class '{widget_class}'")
|
||||
raise AttributeError(f"Package 'textual.widgets' has no class '{widget_class}'")
|
||||
|
||||
widget_module_path = f"._{camel_to_snake(widget_class)}"
|
||||
module = import_module(widget_module_path, package="textual.widgets")
|
||||
|
||||
@@ -23,6 +23,7 @@ class LoadingIndicator(Widget):
|
||||
min-height: 1;
|
||||
content-align: center middle;
|
||||
color: $accent;
|
||||
text-style: not reverse;
|
||||
}
|
||||
LoadingIndicator.-textual-loading-indicator {
|
||||
layer: _loading;
|
||||
|
||||
@@ -4,7 +4,6 @@ Tests for the loading indicator animation, which is considered a basic animation
|
||||
"""
|
||||
|
||||
from textual.app import App
|
||||
from textual.widgets import LoadingIndicator
|
||||
|
||||
|
||||
async def test_loading_indicator_is_not_static_on_full() -> None:
|
||||
@@ -15,7 +14,7 @@ async def test_loading_indicator_is_not_static_on_full() -> None:
|
||||
async with app.run_test() as pilot:
|
||||
app.screen.loading = True
|
||||
await pilot.pause()
|
||||
indicator = app.query_one(LoadingIndicator)
|
||||
indicator = app.screen._cover_widget
|
||||
assert str(indicator.render()) != "Loading..."
|
||||
|
||||
|
||||
@@ -27,7 +26,7 @@ async def test_loading_indicator_is_not_static_on_basic() -> None:
|
||||
async with app.run_test() as pilot:
|
||||
app.screen.loading = True
|
||||
await pilot.pause()
|
||||
indicator = app.query_one(LoadingIndicator)
|
||||
indicator = app.screen._cover_widget
|
||||
assert str(indicator.render()) != "Loading..."
|
||||
|
||||
|
||||
@@ -39,5 +38,5 @@ async def test_loading_indicator_is_static_on_none() -> None:
|
||||
async with app.run_test() as pilot:
|
||||
app.screen.loading = True
|
||||
await pilot.pause()
|
||||
indicator = app.query_one(LoadingIndicator)
|
||||
indicator = app.screen._cover_widget
|
||||
assert str(indicator.render()) == "Loading..."
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 461 KiB |
@@ -12,6 +12,7 @@ from textual.containers import Center, Grid, Middle, Vertical
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
from textual.pilot import Pilot
|
||||
from textual.renderables.gradient import LinearGradient
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
@@ -2228,3 +2229,40 @@ def test_push_screen_on_mount(snap_compare):
|
||||
app = MyApp()
|
||||
|
||||
assert snap_compare(app)
|
||||
|
||||
|
||||
def test_transparent_background(snap_compare):
|
||||
"""Check that a transparent background defers to render().
|
||||
|
||||
This should display a colorful gradient, filling the screen.
|
||||
"""
|
||||
|
||||
COLORS = [
|
||||
"#881177",
|
||||
"#aa3355",
|
||||
"#cc6666",
|
||||
"#ee9944",
|
||||
"#eedd00",
|
||||
"#99dd55",
|
||||
"#44dd88",
|
||||
"#22ccbb",
|
||||
"#00bbcc",
|
||||
"#0099cc",
|
||||
"#3366bb",
|
||||
"#663399",
|
||||
]
|
||||
|
||||
class TransparentApp(App):
|
||||
CSS = """
|
||||
Screen {
|
||||
background: transparent;
|
||||
}
|
||||
"""
|
||||
|
||||
def render(self) -> LinearGradient:
|
||||
"""Renders a gradient, when the background is transparent."""
|
||||
stops = [(i / (len(COLORS) - 1), c) for i, c in enumerate(COLORS)]
|
||||
return LinearGradient(30.0, stops)
|
||||
|
||||
app = TransparentApp()
|
||||
snap_compare(app)
|
||||
|
||||
@@ -256,3 +256,12 @@ def test_gradient():
|
||||
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)
|
||||
|
||||
|
||||
def test_is_transparent():
|
||||
"""Check is_transparent is reporting correctly."""
|
||||
assert Color(0, 0, 0, 0).is_transparent
|
||||
assert Color(20, 20, 30, 0).is_transparent
|
||||
assert not Color(20, 20, 30, a=0.01).is_transparent
|
||||
assert not Color(20, 20, 30, a=1).is_transparent
|
||||
assert not Color(20, 20, 30, 0, ansi=1).is_transparent
|
||||
|
||||
@@ -425,23 +425,23 @@ async def test_loading():
|
||||
app = pilot.app
|
||||
label = app.query_one(Label)
|
||||
assert label.loading == False
|
||||
assert len(label.query(LoadingIndicator)) == 0
|
||||
assert label._cover_widget is None
|
||||
|
||||
label.loading = True
|
||||
await pilot.pause()
|
||||
assert len(label.query(LoadingIndicator)) == 1
|
||||
assert label._cover_widget is not None
|
||||
|
||||
label.loading = True # Setting to same value is a null-op
|
||||
await pilot.pause()
|
||||
assert len(label.query(LoadingIndicator)) == 1
|
||||
assert label._cover_widget is not None
|
||||
|
||||
label.loading = False
|
||||
await pilot.pause()
|
||||
assert len(label.query(LoadingIndicator)) == 0
|
||||
assert label._cover_widget is None
|
||||
|
||||
label.loading = False # Setting to same value is a null-op
|
||||
await pilot.pause()
|
||||
assert len(label.query(LoadingIndicator)) == 0
|
||||
assert label._cover_widget is None
|
||||
|
||||
|
||||
async def test_is_mounted_property():
|
||||
@@ -576,3 +576,21 @@ def test_bad_widget_name_raised() -> None:
|
||||
|
||||
class lowercaseWidget(Widget):
|
||||
pass
|
||||
|
||||
|
||||
def test_lazy_loading() -> None:
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/5077
|
||||
|
||||
Check that the lazy loading magic doesn't break attribute access.
|
||||
|
||||
"""
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
from textual.widgets import Foo # nopycln: import
|
||||
|
||||
from textual import widgets
|
||||
from textual.widgets import Label
|
||||
|
||||
assert not hasattr(widgets, "foo")
|
||||
assert not hasattr(widgets, "bar")
|
||||
assert hasattr(widgets, "Label")
|
||||
|
||||
Reference in New Issue
Block a user