Merge branch 'main' of github.com:Textualize/textual into themes

This commit is contained in:
Darren Burns
2024-10-03 16:58:28 +01:00
17 changed files with 1187 additions and 68 deletions

View File

@@ -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

View 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;
}
}

View 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()

View 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()

View File

@@ -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.

View File

@@ -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.

View File

@@ -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/"

View File

@@ -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,

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View File

@@ -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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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")