diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4369ce2e..b65350ed6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,11 +2,12 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.3.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml + args: ['--unsafe'] - repo: https://github.com/psf/black rev: 22.3.0 hooks: diff --git a/docs/examples/styles/content_align.css b/docs/examples/styles/content_align.css new file mode 100644 index 000000000..7024809b0 --- /dev/null +++ b/docs/examples/styles/content_align.css @@ -0,0 +1,20 @@ +#box1 { + content-align: left top; + background: red; +} + +#box2 { + content-align: center middle; + background: green; +} + +#box3 { + content-align: right bottom; + background: blue; +} + +Static { + height: 1fr; + padding: 1; + color: white; +} diff --git a/docs/examples/styles/content_align.py b/docs/examples/styles/content_align.py new file mode 100644 index 000000000..930553f40 --- /dev/null +++ b/docs/examples/styles/content_align.py @@ -0,0 +1,13 @@ +from textual.app import App +from textual.widgets import Static + + +class ContentAlignApp(App): + def compose(self): + yield Static("With [i]content-align[/] you can...", id="box1") + yield Static("...[b]Easily align content[/]...", id="box2") + yield Static("...Horizontally [i]and[/] vertically!", id="box3") + + +app = ContentAlignApp(css_path="content_align.css") +app.run() diff --git a/docs/examples/styles/scrollbar_gutter.css b/docs/examples/styles/scrollbar_gutter.css new file mode 100644 index 000000000..ed62eb852 --- /dev/null +++ b/docs/examples/styles/scrollbar_gutter.css @@ -0,0 +1,8 @@ +Screen { + scrollbar-gutter: stable; +} + +#text-box { + color: floralwhite; + background: darkmagenta; +} diff --git a/docs/examples/styles/scrollbar_gutter.py b/docs/examples/styles/scrollbar_gutter.py new file mode 100644 index 000000000..b847b3434 --- /dev/null +++ b/docs/examples/styles/scrollbar_gutter.py @@ -0,0 +1,18 @@ +from textual.app import App +from textual.widgets import Static + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class ScrollbarGutterApp(App): + def compose(self): + yield Static(TEXT, id="text-box") + + +app = ScrollbarGutterApp(css_path="scrollbar_gutter.css") diff --git a/docs/styles/border.md b/docs/styles/border.md index 3ac9a119a..c060bfb25 100644 --- a/docs/styles/border.md +++ b/docs/styles/border.md @@ -3,7 +3,7 @@ The `border` rule enables the drawing of a box around a widget. A border is set with a border value (see below) followed by a color. | Border value | Explanation | -| ------------ | ------------------------------------------------------- | +| ------------ |---------------------------------------------------------| | `"ascii"` | A border with plus, hyphen, and vertical bar | | `"blank"` | A blank border (reserves space for a border) | | `"dashed"` | Dashed line border | diff --git a/docs/styles/content_align.md b/docs/styles/content_align.md new file mode 100644 index 000000000..72ec6d132 --- /dev/null +++ b/docs/styles/content_align.md @@ -0,0 +1,65 @@ +# Content-align + +The `content-align` property allows you to align content _inside_ a widget. + +You can specify the alignment of content on both the horizontal and vertical axes. + +## Syntax + +``` +content-align: ; +``` + +### Values + +#### `HORIZONTAL` + +| Value | Description | +|------------------|----------------------------------------------------| +| `left` (default) | Align content on the left of the horizontal axis | +| `center` | Align content in the center of the horizontal axis | +| `right` | Align content on the right of the horizontal axis | + +#### `VERTICAL` + +| Value | Description | +|-----------------|--------------------------------------------------| +| `top` (default) | Align content at the top of the vertical axis | +| `middle` | Align content in the middle of the vertical axis | +| `bottom` | Align content at the bottom of the vertical axis | + +## Example + +=== "content_align.py" + + ```python + --8<-- "docs/examples/styles/content_align.py" + ``` + +=== "content_align.css" + + ```scss + --8<-- "docs/examples/styles/content_align.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/content_align.py"} + ``` + +## CSS + +```sass +/* Align content in the very center of a widget */ +content-align: center middle; +/* Align content at the top right of a widget */ +content-align: right top; +``` + +## Python +```python +# Align content in the very center of a widget +widget.styles.content_align = ("center", "middle") +# Align content at the top right of a widget +widget.styles.content_align = ("right", "top") +``` diff --git a/docs/styles/scrollbar_gutter.md b/docs/styles/scrollbar_gutter.md new file mode 100644 index 000000000..7c2ed5e44 --- /dev/null +++ b/docs/styles/scrollbar_gutter.md @@ -0,0 +1,53 @@ +# Scrollbar-gutter + +The `scrollbar-gutter` rule allows authors to reserve space for the vertical scrollbar. + +Setting the value to `stable` prevents unwanted layout changes when the scrollbar becomes visible. + +## Syntax + +``` +scrollbar-gutter: [auto|stable]; +``` + +### Values + +| Value | Description | +|------------------|--------------------------------------------------| +| `auto` (default) | No space is reserved for the vertical scrollbar. | +| `stable` | Space is reserved for the vertical scrollbar. | + +## Example + +In the example below, notice the gap reserved for the scrollbar on the right side of the +terminal window. + +=== "scrollbar_gutter.py" + + ```python + --8<-- "docs/examples/styles/scrollbar_gutter.py" + ``` + +=== "scrollbar_gutter.css" + + ```scss + --8<-- "docs/examples/styles/scrollbar_gutter.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/styles/scrollbar_gutter.py"} + ``` + +## CSS + +```sass +/* Reserve space for vertical scrollbar */ +scrollbar-gutter: stable; +``` + +## Python + +```python +self.styles.scrollbar_gutter = "stable" +``` diff --git a/mkdocs.yml b/mkdocs.yml index fa0fdc5d8..f6c520f31 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,12 +36,13 @@ nav: - "events/resize.md" - "events/screen_resume.md" - "events/screen_suspend.md" - - "events/show.md" - - Styles: + - "events/show.md" + - Styles: - "styles/background.md" - "styles/border.md" - "styles/box_sizing.md" - "styles/color.md" + - "styles/content_align.md" - "styles/display.md" - "styles/min_height.md" - "styles/max_height.md" @@ -55,6 +56,7 @@ nav: - "styles/padding.md" - "styles/scrollbar.md" - "styles/scrollbar_size.md" + - "styles/scrollbar_gutter.md" - "styles/text_style.md" - "styles/tint.md" - "styles/visibility.md" @@ -87,7 +89,7 @@ markdown_extensions: - pymdownx.superfences - pymdownx.snippets - pymdownx.tabbed: - alternate_style: true + alternate_style: true - pymdownx.snippets - markdown.extensions.attr_list diff --git a/sandbox/darren/just_a_box.py b/sandbox/darren/just_a_box.py index 781c66f67..ee8be631e 100644 --- a/sandbox/darren/just_a_box.py +++ b/sandbox/darren/just_a_box.py @@ -1,11 +1,11 @@ from __future__ import annotations +import asyncio + from rich.console import RenderableType -from rich.panel import Panel from textual import events from textual.app import App, ComposeResult -from textual.layout import Horizontal, Vertical from textual.widget import Widget @@ -18,37 +18,22 @@ class Box(Widget, can_focus=True): super().__init__(*children, id=id, classes=classes) def render(self) -> RenderableType: - return Panel("Box") + return "Box" class JustABox(App): def compose(self) -> ComposeResult: - yield Horizontal( - Vertical( - Box(id="box1", classes="box"), - Box(id="box2", classes="box"), - # Box(id="box3", classes="box"), - # Box(id="box4", classes="box"), - # Box(id="box5", classes="box"), - # Box(id="box6", classes="box"), - # Box(id="box7", classes="box"), - # Box(id="box8", classes="box"), - # Box(id="box9", classes="box"), - # Box(id="box10", classes="box"), - id="left_pane", - ), - Box(id="middle_pane"), - Vertical( - Box(id="boxa", classes="box"), - Box(id="boxb", classes="box"), - Box(id="boxc", classes="box"), - id="right_pane", - ), - id="horizontal", - ) + self.box = Box() + yield self.box - def key_p(self): - print(self.query("#horizontal").first().styles.layout) + def key_a(self): + self.animator.animate( + self.box.styles, + "opacity", + value=0.0, + duration=2.0, + on_complete=self.box.remove, + ) async def on_key(self, event: events.Key) -> None: await self.dispatch_key(event) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index dbc5d9670..198c0e266 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -8,9 +8,10 @@ from typing import Any, Callable, TypeVar from dataclasses import dataclass from . import _clock +from ._callback import invoke from ._easing import DEFAULT_EASING, EASING from ._timer import Timer -from ._types import MessageTarget +from ._types import MessageTarget, CallbackType if sys.version_info >= (3, 8): from typing import Protocol, runtime_checkable @@ -30,8 +31,20 @@ class Animatable(Protocol): class Animation(ABC): + + on_complete: CallbackType | None = None + """Callback to run after animation completes""" + @abstractmethod def __call__(self, time: float) -> bool: # pragma: no cover + """Call the animation, return a boolean indicating whether animation is in-progress or complete. + + Args: + time (float): The current timestamp + + Returns: + bool: True if the animation has finished, otherwise False. + """ raise NotImplementedError("") def __eq__(self, other: object) -> bool: @@ -48,6 +61,7 @@ class SimpleAnimation(Animation): end_value: float | Animatable final_value: object easing: EasingFunction + on_complete: CallbackType | None = None def __call__(self, time: float) -> bool: @@ -109,6 +123,7 @@ class BoundAnimator: duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, ) -> None: easing_function = EASING[easing] if isinstance(easing, str) else easing return self._animator.animate( @@ -119,6 +134,7 @@ class BoundAnimator: duration=duration, speed=speed, easing=easing_function, + on_complete=on_complete, ) @@ -163,6 +179,7 @@ class Animator: duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, + on_complete: CallbackType | None = None, ) -> None: """Animate an attribute to a new value. @@ -201,6 +218,7 @@ class Animator: duration=duration, speed=speed, easing=easing_function, + on_complete=on_complete, ) if animation is None: start_value = getattr(obj, attribute) @@ -223,6 +241,7 @@ class Animator: end_value=value, final_value=final_value, easing=easing_function, + on_complete=on_complete, ) assert animation is not None, "animation expected to be non-None" @@ -233,7 +252,7 @@ class Animator: self._animations[animation_key] = animation self._timer.resume() - def __call__(self) -> None: + async def __call__(self) -> None: if not self._animations: self._timer.pause() else: @@ -241,7 +260,11 @@ class Animator: animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] - if animation(animation_time): + animation_complete = animation(animation_time) + if animation_complete: + completion_callback = animation.on_complete + if completion_callback is not None: + await invoke(completion_callback) del self._animations[animation_key] def _get_time(self) -> float: diff --git a/src/textual/_types.py b/src/textual/_types.py index 180d02be4..7158e31a4 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -1,5 +1,6 @@ import sys -from typing import Awaitable, Callable, List, Optional, TYPE_CHECKING +from typing import Awaitable, Callable, List, TYPE_CHECKING, Union + from rich.segment import Segment if sys.version_info >= (3, 8): @@ -11,8 +12,6 @@ else: if TYPE_CHECKING: from .message import Message -Callback = Callable[[], None] - class MessageTarget(Protocol): async def post_message(self, message: "Message") -> bool: @@ -34,5 +33,5 @@ class EventTarget(Protocol): MessageHandler = Callable[["Message"], Awaitable] - Lines = List[List[Segment]] +CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index ae37249f9..aa81ec805 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable -from .. import events, log +from .._types import CallbackType from ..geometry import Offset from .._animator import Animation from .scalar import ScalarOffset @@ -25,6 +25,7 @@ class ScalarAnimation(Animation): duration: float | None, speed: float | None, easing: EasingFunction, + on_complete: CallbackType | None = None, ): assert ( speed is not None or duration is not None @@ -35,6 +36,7 @@ class ScalarAnimation(Animation): self.attribute = attribute self.final_value = value self.easing = easing + self.on_complete = on_complete size = widget.outer_size viewport = widget.app.size @@ -55,7 +57,6 @@ class ScalarAnimation(Animation): eased_factor = self.easing(factor) if eased_factor >= 1: - offset = self.final_value setattr(self.styles, self.attribute, self.final_value) return True diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index dd0b53e6f..c4e952ed3 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast import rich.repr from rich.style import Style +from textual._types import CallbackType from .._animator import Animation, EasingFunction from ..color import Color from ..geometry import Offset, Spacing @@ -579,9 +580,9 @@ class Styles(StylesBase): duration: float | None, speed: float | None, easing: EasingFunction, + on_complete: CallbackType | None = None, ) -> Animation | None: - from ..widget import Widget - + # from ..widget import Widget # node = self.node # assert isinstance(self.node, Widget) if isinstance(value, ScalarOffset): @@ -594,6 +595,7 @@ class Styles(StylesBase): duration=duration, speed=speed, easing=easing, + on_complete=on_complete, ) return None @@ -760,7 +762,7 @@ class Styles(StylesBase): ) elif has_rule("align_horizontal"): append_declaration("align-horizontal", self.align_horizontal) - elif has_rule("align_horizontal"): + elif has_rule("align_vertical"): append_declaration("align-vertical", self.align_vertical) if has_rule("content_align_horizontal") and has_rule("content_align_vertical"): @@ -772,7 +774,7 @@ class Styles(StylesBase): append_declaration( "content-align-horizontal", self.content_align_horizontal ) - elif has_rule("content_align_horizontal"): + elif has_rule("content_align_vertical"): append_declaration("content-align-vertical", self.content_align_vertical) lines.sort() diff --git a/src/textual/messages.py b/src/textual/messages.py index 84bc8d14a..58e4cd2a6 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Awaitable, Union +from typing import TYPE_CHECKING import rich.repr +from ._types import CallbackType from .message import Message @@ -11,9 +12,6 @@ if TYPE_CHECKING: from .widget import Widget -CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] - - @rich.repr.auto class Update(Message, verbosity=3): def __init__(self, sender: MessagePump, widget: Widget): diff --git a/src/textual/screen.py b/src/textual/screen.py index f0b84b173..28f986a2d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -12,8 +12,7 @@ from ._callback import invoke from .geometry import Offset, Region, Size from ._compositor import Compositor, MapGeometry -from .messages import CallbackType -from ._profile import timer +from ._types import CallbackType from .reactive import Reactive from .renderables.blank import Blank from ._timer import Timer diff --git a/tests/test_animator.py b/tests/test_animator.py index fd3f7c038..926949b27 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -1,12 +1,10 @@ from __future__ import annotations - from dataclasses import dataclass from unittest.mock import Mock import pytest - from textual._animator import Animator, SimpleAnimation from textual._easing import EASING, DEFAULT_EASING @@ -184,8 +182,7 @@ class MockAnimator(Animator): return self._time -def test_animator(): - +async def test_animator(): target = Mock() animator = MockAnimator(target) animate_test = AnimateTest() @@ -206,11 +203,11 @@ def test_animator(): assert animator._animations[(id(animate_test), "foo")] == expected assert not animator._on_animation_frame_called - animator() + await animator() assert animate_test.foo == 0 animator._time = 5 - animator() + await animator() assert animate_test.foo == 50 # New animation in the middle of an existing one @@ -218,12 +215,11 @@ def test_animator(): assert animate_test.foo == 50 animator._time = 6 - animator() + await animator() assert animate_test.foo == 200 def test_bound_animator(): - target = Mock() animator = MockAnimator(target) animate_test = AnimateTest() @@ -245,3 +241,29 @@ def test_bound_animator(): easing=EASING[DEFAULT_EASING], ) assert animator._animations[(id(animate_test), "foo")] == expected + + +def test_animator_on_complete_callback_not_fired_before_duration_ends(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 9 + animator() + + assert not callback.called + + +async def test_animator_on_complete_callback_fired_at_duration(): + callback = Mock() + animate_test = AnimateTest() + animator = MockAnimator(Mock()) + + animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback) + + animator._time = 10 + await animator() + + callback.assert_called_once_with()