mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Tooltips (#2670)
* inflect * diagram * tooltip render * tooltip property * add guard * tooltip docs * docs * tidy, fix horizontal * words, removed comment * fix screenshot render * simplify * simfplify * changelog * simplify optimize * inflect tests * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * docstring * disable auto focus * should be fraction * optimization * snapshot update * Update tests/snapshot_tests/snapshot_apps/scroll_to_center.py Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
|
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
|
||||||
- Added `SelectionList` widget https://github.com/Textualize/textual/pull/2652
|
- Added `SelectionList` widget https://github.com/Textualize/textual/pull/2652
|
||||||
- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594
|
- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594
|
||||||
|
- Added `Widget.tooltip` property https://github.com/Textualize/textual/pull/2670
|
||||||
|
- Added `Region.inflect` https://github.com/Textualize/textual/pull/2670
|
||||||
- `Suggester` API to compose with widgets for automatic suggestions https://github.com/Textualize/textual/issues/2330
|
- `Suggester` API to compose with widgets for automatic suggestions https://github.com/Textualize/textual/issues/2330
|
||||||
- `SuggestFromList` class to let widgets get completions from a fixed set of options https://github.com/Textualize/textual/pull/2604
|
- `SuggestFromList` class to let widgets get completions from a fixed set of options https://github.com/Textualize/textual/pull/2604
|
||||||
- `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604
|
- `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604
|
||||||
|
|||||||
26
docs/examples/guide/widgets/tooltip01.py
Normal file
26
docs/examples/guide/widgets/tooltip01.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Button
|
||||||
|
|
||||||
|
TEXT = """I must not fear.
|
||||||
|
Fear is the mind-killer.
|
||||||
|
Fear is the little-death that brings total obliteration.
|
||||||
|
I will face my fear."""
|
||||||
|
|
||||||
|
|
||||||
|
class TooltipApp(App):
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Button("Click me", variant="success")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one(Button).tooltip = TEXT
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TooltipApp()
|
||||||
|
app.run()
|
||||||
31
docs/examples/guide/widgets/tooltip02.py
Normal file
31
docs/examples/guide/widgets/tooltip02.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Button
|
||||||
|
|
||||||
|
TEXT = """I must not fear.
|
||||||
|
Fear is the mind-killer.
|
||||||
|
Fear is the little-death that brings total obliteration.
|
||||||
|
I will face my fear."""
|
||||||
|
|
||||||
|
|
||||||
|
class TooltipApp(App):
|
||||||
|
CSS = """
|
||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
Tooltip {
|
||||||
|
padding: 2 4;
|
||||||
|
background: $primary;
|
||||||
|
color: auto 90%;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Button("Click me", variant="success")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one(Button).tooltip = TEXT
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = TooltipApp()
|
||||||
|
app.run()
|
||||||
@@ -193,6 +193,58 @@ Let's modify the default width for the fizzbuzz example. By default, the table w
|
|||||||
|
|
||||||
Note that we've added `expand=True` to tell the `Table` to expand beyond the optimal width, so that it fills the 50 characters returned by `get_content_width`.
|
Note that we've added `expand=True` to tell the `Table` to expand beyond the optimal width, so that it fills the 50 characters returned by `get_content_width`.
|
||||||
|
|
||||||
|
## Tooltips
|
||||||
|
|
||||||
|
Widgets can have *tooltips* which is content displayed when the user hovers the mouse over the widget.
|
||||||
|
You can use tooltips to add supplementary information or help messages.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
|
||||||
|
It is best not to rely on tooltips for essential information.
|
||||||
|
Some users prefer to use the keyboard exclusively and may never see tooltips.
|
||||||
|
|
||||||
|
|
||||||
|
To add a tooltip, assign to the widget's [`tooltip`][textual.widgets.Widget.tooltip] property.
|
||||||
|
You can set text or any other [Rich](https://github.com/Textualize/rich) renderable.
|
||||||
|
|
||||||
|
The following example adds a tooltip to a button:
|
||||||
|
|
||||||
|
=== "tooltip01.py"
|
||||||
|
|
||||||
|
```python title="tooltip01.py"
|
||||||
|
--8<-- "docs/examples/guide/widgets/tooltip01.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output (before hover)"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/guide/widgets/tooltip01.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output (after hover)"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/guide/widgets/tooltip01.py" hover="Button"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customizing the tooltip
|
||||||
|
|
||||||
|
If you don't like the default look of the tooltips, you can customize them to your liking with CSS.
|
||||||
|
Add a rule to your CSS that targets `Tooltip`. Here's an example:
|
||||||
|
|
||||||
|
=== "tooltip02.py"
|
||||||
|
|
||||||
|
```python title="tooltip02.py" hl_lines="15-19"
|
||||||
|
--8<-- "docs/examples/guide/widgets/tooltip02.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output (before hover)"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/guide/widgets/tooltip02.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Output (after hover)"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/guide/widgets/tooltip02.py" hover="Button"}
|
||||||
|
```
|
||||||
|
|
||||||
## Line API
|
## Line API
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .strip import Strip, StripRenderable
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import TypeAlias
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
|
from .css.styles import RenderStyles
|
||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
@@ -496,6 +497,38 @@ class Compositor:
|
|||||||
}
|
}
|
||||||
return self._visible_widgets
|
return self._visible_widgets
|
||||||
|
|
||||||
|
def _constrain(
|
||||||
|
self, styles: RenderStyles, region: Region, constrain_region: Region
|
||||||
|
) -> Region:
|
||||||
|
"""Applies constrain logic to a Region.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
styles: The widget's styles.
|
||||||
|
region: The region to constrain.
|
||||||
|
constrain_region: The outer region.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New region.
|
||||||
|
"""
|
||||||
|
constrain = styles.constrain
|
||||||
|
if constrain == "inflect":
|
||||||
|
inflect_margin = styles.margin
|
||||||
|
margin_region = region.grow(inflect_margin)
|
||||||
|
region = region.inflect(
|
||||||
|
(-1 if margin_region.right > constrain_region.right else 0),
|
||||||
|
(-1 if margin_region.bottom > constrain_region.bottom else 0),
|
||||||
|
inflect_margin,
|
||||||
|
)
|
||||||
|
region = region.translate_inside(constrain_region, True, True)
|
||||||
|
elif constrain != "none":
|
||||||
|
# Constrain to avoid clipping
|
||||||
|
region = region.translate_inside(
|
||||||
|
constrain_region,
|
||||||
|
constrain in ("x", "both"),
|
||||||
|
constrain in ("y", "both"),
|
||||||
|
)
|
||||||
|
return region
|
||||||
|
|
||||||
def _arrange_root(
|
def _arrange_root(
|
||||||
self, root: Widget, size: Size, visible_only: bool = True
|
self, root: Widget, size: Size, visible_only: bool = True
|
||||||
) -> tuple[CompositorMap, set[Widget]]:
|
) -> tuple[CompositorMap, set[Widget]]:
|
||||||
@@ -540,13 +573,14 @@ class Compositor:
|
|||||||
visible: Whether the widget should be visible by default.
|
visible: Whether the widget should be visible by default.
|
||||||
This may be overridden by the CSS rule `visibility`.
|
This may be overridden by the CSS rule `visibility`.
|
||||||
"""
|
"""
|
||||||
visibility = widget.styles.get_rule("visibility")
|
styles = widget.styles
|
||||||
|
visibility = styles.get_rule("visibility")
|
||||||
if visibility is not None:
|
if visibility is not None:
|
||||||
visible = visibility == "visible"
|
visible = visibility == "visible"
|
||||||
|
|
||||||
if visible:
|
if visible:
|
||||||
add_new_widget(widget)
|
add_new_widget(widget)
|
||||||
styles_offset = widget.styles.offset
|
styles_offset = styles.offset
|
||||||
layout_offset = (
|
layout_offset = (
|
||||||
styles_offset.resolve(region.size, clip.size)
|
styles_offset.resolve(region.size, clip.size)
|
||||||
if styles_offset
|
if styles_offset
|
||||||
@@ -554,9 +588,7 @@ class Compositor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Container region is minus border
|
# Container region is minus border
|
||||||
container_region = region.shrink(widget.styles.gutter).translate(
|
container_region = region.shrink(styles.gutter).translate(layout_offset)
|
||||||
layout_offset
|
|
||||||
)
|
|
||||||
container_size = container_region.size
|
container_size = container_region.size
|
||||||
|
|
||||||
# Widgets with scrollbars (containers or scroll view) require additional processing
|
# Widgets with scrollbars (containers or scroll view) require additional processing
|
||||||
@@ -608,15 +640,10 @@ class Compositor:
|
|||||||
|
|
||||||
widget_order = order + ((layer_index, z, layer_order),)
|
widget_order = order + ((layer_index, z, layer_order),)
|
||||||
|
|
||||||
if overlay:
|
if overlay and sub_widget.styles.constrain != "none":
|
||||||
constrain = sub_widget.styles.constrain
|
widget_region = self._constrain(
|
||||||
if constrain != "none":
|
sub_widget.styles, widget_region, no_clip
|
||||||
# Constrain to avoid clipping
|
)
|
||||||
widget_region = widget_region.translate_inside(
|
|
||||||
no_clip,
|
|
||||||
constrain in ("x", "both"),
|
|
||||||
constrain in ("y", "both"),
|
|
||||||
)
|
|
||||||
|
|
||||||
add_widget(
|
add_widget(
|
||||||
sub_widget,
|
sub_widget,
|
||||||
@@ -659,8 +686,19 @@ class Compositor:
|
|||||||
|
|
||||||
elif visible:
|
elif visible:
|
||||||
# Add the widget to the map
|
# Add the widget to the map
|
||||||
|
|
||||||
|
widget_region = region + layout_offset
|
||||||
|
|
||||||
|
if widget._absolute_offset is not None:
|
||||||
|
widget_region = widget_region.reset_offset.translate(
|
||||||
|
widget._absolute_offset + widget.styles.margin.top_left
|
||||||
|
)
|
||||||
|
|
||||||
|
if styles.constrain != "none":
|
||||||
|
widget_region = self._constrain(styles, widget_region, no_clip)
|
||||||
|
|
||||||
map[widget] = _MapGeometry(
|
map[widget] = _MapGeometry(
|
||||||
region + layout_offset,
|
widget_region,
|
||||||
order,
|
order,
|
||||||
clip,
|
clip,
|
||||||
region.size,
|
region.size,
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
|
|||||||
try:
|
try:
|
||||||
rows = int(attrs.get("lines", 24))
|
rows = int(attrs.get("lines", 24))
|
||||||
columns = int(attrs.get("columns", 80))
|
columns = int(attrs.get("columns", 80))
|
||||||
|
hover = attrs.get("hover", "")
|
||||||
svg = take_svg_screenshot(
|
svg = take_svg_screenshot(
|
||||||
None,
|
None,
|
||||||
path,
|
path,
|
||||||
press,
|
press,
|
||||||
title,
|
hover=hover,
|
||||||
|
title=title,
|
||||||
terminal_size=(columns, rows),
|
terminal_size=(columns, rows),
|
||||||
wait_for_animation=False,
|
wait_for_animation=False,
|
||||||
)
|
)
|
||||||
@@ -58,6 +60,7 @@ def take_svg_screenshot(
|
|||||||
app: App | None = None,
|
app: App | None = None,
|
||||||
app_path: str | None = None,
|
app_path: str | None = None,
|
||||||
press: Iterable[str] = (),
|
press: Iterable[str] = (),
|
||||||
|
hover: str = "",
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
terminal_size: tuple[int, int] = (80, 24),
|
terminal_size: tuple[int, int] = (80, 24),
|
||||||
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
|
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
|
||||||
@@ -69,6 +72,7 @@ def take_svg_screenshot(
|
|||||||
app: An app instance. Must be supplied if app_path is not.
|
app: An app instance. Must be supplied if app_path is not.
|
||||||
app_path: A path to an app. Must be supplied if app is not.
|
app_path: A path to an app. Must be supplied if app is not.
|
||||||
press: Key presses to run before taking screenshot. "_" is a short pause.
|
press: Key presses to run before taking screenshot. "_" is a short pause.
|
||||||
|
hover: Hover over the given widget.
|
||||||
title: The terminal title in the output image.
|
title: The terminal title in the output image.
|
||||||
terminal_size: A pair of integers (rows, columns), representing terminal size.
|
terminal_size: A pair of integers (rows, columns), representing terminal size.
|
||||||
run_before: An arbitrary callable that runs arbitrary code before taking the
|
run_before: An arbitrary callable that runs arbitrary code before taking the
|
||||||
@@ -97,7 +101,7 @@ def take_svg_screenshot(
|
|||||||
assert path is not None
|
assert path is not None
|
||||||
with open(path, "rb") as source_file:
|
with open(path, "rb") as source_file:
|
||||||
hash.update(source_file.read())
|
hash.update(source_file.read())
|
||||||
hash.update(f"{press}-{title}-{terminal_size}".encode("utf-8"))
|
hash.update(f"{press}-{hover}-{title}-{terminal_size}".encode("utf-8"))
|
||||||
cache_key = f"{hash.hexdigest()}.svg"
|
cache_key = f"{hash.hexdigest()}.svg"
|
||||||
return cache_key
|
return cache_key
|
||||||
|
|
||||||
@@ -115,7 +119,11 @@ def take_svg_screenshot(
|
|||||||
result = run_before(pilot)
|
result = run_before(pilot)
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
|
await pilot.pause()
|
||||||
await pilot.press(*press)
|
await pilot.press(*press)
|
||||||
|
if hover:
|
||||||
|
await pilot.hover(hover)
|
||||||
|
await pilot.pause(0.5)
|
||||||
if wait_for_animation:
|
if wait_for_animation:
|
||||||
await pilot.wait_for_scheduled_animations()
|
await pilot.wait_for_scheduled_animations()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ def resolve_fraction_unit(
|
|||||||
resolve_scalar(styles.max_width),
|
resolve_scalar(styles.max_width),
|
||||||
)
|
)
|
||||||
for styles in widget_styles
|
for styles in widget_styles
|
||||||
|
if styles.overlay != "screen"
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
resolve = [
|
resolve = [
|
||||||
@@ -140,6 +141,7 @@ def resolve_fraction_unit(
|
|||||||
resolve_scalar(styles.max_height),
|
resolve_scalar(styles.max_height),
|
||||||
)
|
)
|
||||||
for styles in widget_styles
|
for styles in widget_styles
|
||||||
|
if styles.overlay != "screen"
|
||||||
]
|
]
|
||||||
|
|
||||||
resolved: list[Fraction | None] = [None] * len(resolve)
|
resolved: list[Fraction | None] = [None] * len(resolve)
|
||||||
@@ -220,13 +222,24 @@ def resolve_box_models(
|
|||||||
# If all box models have been calculated
|
# If all box models have been calculated
|
||||||
widget_styles = [widget.styles for widget in widgets]
|
widget_styles = [widget.styles for widget in widgets]
|
||||||
if resolve_dimension == "width":
|
if resolve_dimension == "width":
|
||||||
total_remaining = int(sum([width for width, _, _ in filter(None, box_models)]))
|
total_remaining = int(
|
||||||
|
sum(
|
||||||
|
[
|
||||||
|
box_model.width
|
||||||
|
for widget, box_model in zip(widgets, box_models)
|
||||||
|
if (box_model is not None and widget.styles.overlay != "screen")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
remaining_space = int(max(0, size.width - total_remaining - margin_width))
|
remaining_space = int(max(0, size.width - total_remaining - margin_width))
|
||||||
fraction_unit = resolve_fraction_unit(
|
fraction_unit = resolve_fraction_unit(
|
||||||
[
|
[
|
||||||
styles
|
styles
|
||||||
for styles in widget_styles
|
for styles in widget_styles
|
||||||
if styles.width is not None and styles.width.is_fraction
|
if styles.width is not None
|
||||||
|
and styles.width.is_fraction
|
||||||
|
and styles.overlay != "screen"
|
||||||
],
|
],
|
||||||
size,
|
size,
|
||||||
viewport_size,
|
viewport_size,
|
||||||
@@ -237,14 +250,23 @@ def resolve_box_models(
|
|||||||
height_fraction = Fraction(margin_size.height)
|
height_fraction = Fraction(margin_size.height)
|
||||||
else:
|
else:
|
||||||
total_remaining = int(
|
total_remaining = int(
|
||||||
sum([height for _, height, _ in filter(None, box_models)])
|
sum(
|
||||||
|
[
|
||||||
|
box_model.height
|
||||||
|
for widget, box_model in zip(widgets, box_models)
|
||||||
|
if (box_model is not None and widget.styles.overlay != "screen")
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
remaining_space = int(max(0, size.height - total_remaining - margin_height))
|
remaining_space = int(max(0, size.height - total_remaining - margin_height))
|
||||||
fraction_unit = resolve_fraction_unit(
|
fraction_unit = resolve_fraction_unit(
|
||||||
[
|
[
|
||||||
styles
|
styles
|
||||||
for styles in widget_styles
|
for styles in widget_styles
|
||||||
if styles.height is not None and styles.height.is_fraction
|
if styles.height is not None
|
||||||
|
and styles.height.is_fraction
|
||||||
|
and styles.overlay != "screen"
|
||||||
],
|
],
|
||||||
size,
|
size,
|
||||||
viewport_size,
|
viewport_size,
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self._loop: asyncio.AbstractEventLoop | None = None
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
self._return_value: ReturnType | None = None
|
self._return_value: ReturnType | None = None
|
||||||
self._exit = False
|
self._exit = False
|
||||||
|
self._disable_tooltips = False
|
||||||
|
|
||||||
self.css_monitor = (
|
self.css_monitor = (
|
||||||
FileMonitor(self.css_path, self._on_css_change)
|
FileMonitor(self.css_path, self._on_css_change)
|
||||||
@@ -1058,6 +1059,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
*,
|
*,
|
||||||
headless: bool = True,
|
headless: bool = True,
|
||||||
size: tuple[int, int] | None = (80, 24),
|
size: tuple[int, int] | None = (80, 24),
|
||||||
|
tooltips: bool = False,
|
||||||
) -> AsyncGenerator[Pilot, None]:
|
) -> AsyncGenerator[Pilot, None]:
|
||||||
"""An asynchronous context manager for testing app.
|
"""An asynchronous context manager for testing app.
|
||||||
|
|
||||||
@@ -1075,10 +1077,12 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
headless: Run in headless mode (no output or input).
|
headless: Run in headless mode (no output or input).
|
||||||
size: Force terminal size to `(WIDTH, HEIGHT)`,
|
size: Force terminal size to `(WIDTH, HEIGHT)`,
|
||||||
or None to auto-detect.
|
or None to auto-detect.
|
||||||
|
tooltips: Enable tooltips when testing.
|
||||||
"""
|
"""
|
||||||
from .pilot import Pilot
|
from .pilot import Pilot
|
||||||
|
|
||||||
app = self
|
app = self
|
||||||
|
app._disable_tooltips = not tooltips
|
||||||
app_ready_event = asyncio.Event()
|
app_ready_event = asyncio.Event()
|
||||||
|
|
||||||
def on_app_ready() -> None:
|
def on_app_ready() -> None:
|
||||||
|
|||||||
@@ -70,6 +70,6 @@ VALID_PSEUDO_CLASSES: Final = {
|
|||||||
"hover",
|
"hover",
|
||||||
}
|
}
|
||||||
VALID_OVERLAY: Final = {"none", "screen"}
|
VALID_OVERLAY: Final = {"none", "screen"}
|
||||||
VALID_CONSTRAIN: Final = {"x", "y", "both", "none"}
|
VALID_CONSTRAIN: Final = {"x", "y", "both", "inflect", "none"}
|
||||||
|
|
||||||
NULL_SPACING: Final = Spacing.all(0)
|
NULL_SPACING: Final = Spacing.all(0)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ DURATION = r"\d+\.?\d*(?:ms|s)"
|
|||||||
NUMBER = r"\-?\d+\.?\d*"
|
NUMBER = r"\-?\d+\.?\d*"
|
||||||
COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}"
|
COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}"
|
||||||
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
|
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
|
||||||
TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
|
TOKEN = "[a-zA-Z_][a-zA-Z0-9_-]*"
|
||||||
STRING = r"\".*?\""
|
STRING = r"\".*?\""
|
||||||
VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"
|
VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"
|
||||||
|
|
||||||
|
|||||||
@@ -905,6 +905,47 @@ class Region(NamedTuple):
|
|||||||
height2,
|
height2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def inflect(
|
||||||
|
self, x_axis: int = +1, y_axis: int = +1, margin: Spacing | None = None
|
||||||
|
) -> Region:
|
||||||
|
"""Inflect a region around one or both axis.
|
||||||
|
|
||||||
|
The `x_axis` and `y_axis` parameters define which direction to move the region.
|
||||||
|
A positive value will move the region right or down, a negative value will move
|
||||||
|
the region left or up. A value of `0` will leave that axis unmodified.
|
||||||
|
|
||||||
|
```
|
||||||
|
╔══════════╗ │
|
||||||
|
║ ║
|
||||||
|
║ Self ║ │
|
||||||
|
║ ║
|
||||||
|
╚══════════╝ │
|
||||||
|
|
||||||
|
─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─
|
||||||
|
|
||||||
|
│ ┌──────────┐
|
||||||
|
│ │
|
||||||
|
│ │ Result │
|
||||||
|
│ │
|
||||||
|
│ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x_axis: +1 to inflect in the positive direction, -1 to inflect in the negative direction.
|
||||||
|
y_axis: +1 to inflect in the positive direction, -1 to inflect in the negative direction.
|
||||||
|
margin: Additional margin.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A new region.
|
||||||
|
"""
|
||||||
|
inflect_margin = NULL_SPACING if margin is None else margin
|
||||||
|
x, y, width, height = self
|
||||||
|
if x_axis:
|
||||||
|
x += (width + inflect_margin.width) * x_axis
|
||||||
|
if y_axis:
|
||||||
|
y += (height + inflect_margin.height) * y_axis
|
||||||
|
return Region(x, y, width, height)
|
||||||
|
|
||||||
|
|
||||||
class Spacing(NamedTuple):
|
class Spacing(NamedTuple):
|
||||||
"""The spacing around a renderable, such as padding and border.
|
"""The spacing around a renderable, such as padding and border.
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class HorizontalLayout(Layout):
|
|||||||
) -> ArrangeResult:
|
) -> ArrangeResult:
|
||||||
placements: list[WidgetPlacement] = []
|
placements: list[WidgetPlacement] = []
|
||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
x = max_height = Fraction(0)
|
|
||||||
|
|
||||||
child_styles = [child.styles for child in children]
|
child_styles = [child.styles for child in children]
|
||||||
box_margins: list[Spacing] = [styles.margin for styles in child_styles]
|
box_margins: list[Spacing] = [styles.margin for styles in child_styles]
|
||||||
@@ -63,7 +62,14 @@ class HorizontalLayout(Layout):
|
|||||||
if box_models:
|
if box_models:
|
||||||
margins.append(box_models[-1].margin.right)
|
margins.append(box_models[-1].margin.right)
|
||||||
|
|
||||||
x = Fraction(box_models[0].margin.left if box_models else 0)
|
x = next(
|
||||||
|
(
|
||||||
|
Fraction(box_model.margin.left)
|
||||||
|
for box_model, child in zip(box_models, children)
|
||||||
|
if child.styles.overlay != "screen"
|
||||||
|
),
|
||||||
|
Fraction(0),
|
||||||
|
)
|
||||||
|
|
||||||
_Region = Region
|
_Region = Region
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
@@ -75,9 +81,6 @@ class HorizontalLayout(Layout):
|
|||||||
region = _Region(
|
region = _Region(
|
||||||
int(x), offset_y, int(next_x - int(x)), int(content_height)
|
int(x), offset_y, int(next_x - int(x)), int(content_height)
|
||||||
)
|
)
|
||||||
max_height = max(
|
|
||||||
max_height, content_height + offset_y + box_model.margin.bottom
|
|
||||||
)
|
|
||||||
add_placement(
|
add_placement(
|
||||||
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
|
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ class VerticalLayout(Layout):
|
|||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
|
|
||||||
child_styles = [child.styles for child in children]
|
child_styles = [child.styles for child in children]
|
||||||
box_margins: list[Spacing] = [styles.margin for styles in child_styles]
|
box_margins: list[Spacing] = [
|
||||||
|
styles.margin for styles in child_styles if styles.overlay != "screen"
|
||||||
|
]
|
||||||
if box_margins:
|
if box_margins:
|
||||||
resolve_margin = Size(
|
resolve_margin = Size(
|
||||||
max(
|
max(
|
||||||
@@ -60,7 +62,14 @@ class VerticalLayout(Layout):
|
|||||||
if box_models:
|
if box_models:
|
||||||
margins.append(box_models[-1].margin.bottom)
|
margins.append(box_models[-1].margin.bottom)
|
||||||
|
|
||||||
y = Fraction(box_models[0].margin.top if box_models else 0)
|
y = next(
|
||||||
|
(
|
||||||
|
Fraction(box_model.margin.top)
|
||||||
|
for box_model, child in zip(box_models, children)
|
||||||
|
if child.styles.overlay != "screen"
|
||||||
|
),
|
||||||
|
Fraction(0),
|
||||||
|
)
|
||||||
|
|
||||||
_Region = Region
|
_Region = Region
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
@@ -74,14 +83,7 @@ class VerticalLayout(Layout):
|
|||||||
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
|
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
|
||||||
)
|
)
|
||||||
add_placement(
|
add_placement(
|
||||||
_WidgetPlacement(
|
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
|
||||||
region,
|
|
||||||
box_model.margin,
|
|
||||||
widget,
|
|
||||||
0,
|
|
||||||
False,
|
|
||||||
overlay,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if not overlay:
|
if not overlay:
|
||||||
y = next_y + margin
|
y = next_y + margin
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ The `Screen` class is a special widget which represents the content in the termi
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import partial
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
@@ -22,9 +23,11 @@ from typing import (
|
|||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import RenderableType
|
from rich.console import RenderableType
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
from rich.traceback import Traceback
|
||||||
|
|
||||||
from . import errors, events, messages
|
from . import errors, events, messages
|
||||||
from ._callback import invoke
|
from ._callback import invoke
|
||||||
|
from ._compose import compose
|
||||||
from ._compositor import Compositor, MapGeometry
|
from ._compositor import Compositor, MapGeometry
|
||||||
from ._context import visible_screen_stack
|
from ._context import visible_screen_stack
|
||||||
from ._types import CallbackType
|
from ._types import CallbackType
|
||||||
@@ -39,6 +42,7 @@ from .renderables.background_screen import BackgroundScreen
|
|||||||
from .renderables.blank import Blank
|
from .renderables.blank import Blank
|
||||||
from .timer import Timer
|
from .timer import Timer
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
from .widgets import Tooltip
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import Final
|
from typing_extensions import Final
|
||||||
@@ -141,6 +145,9 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
self._callbacks: list[CallbackType] = []
|
self._callbacks: list[CallbackType] = []
|
||||||
self._result_callbacks: list[ResultCallback[ScreenResultType]] = []
|
self._result_callbacks: list[ResultCallback[ScreenResultType]] = []
|
||||||
|
|
||||||
|
self._tooltip_widget: Widget | None = None
|
||||||
|
self._tooltip_timer: Timer | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_modal(self) -> bool:
|
def is_modal(self) -> bool:
|
||||||
"""Is the screen modal?"""
|
"""Is the screen modal?"""
|
||||||
@@ -169,6 +176,18 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
)
|
)
|
||||||
return self.__update_timer
|
return self.__update_timer
|
||||||
|
|
||||||
|
@property
|
||||||
|
def layers(self) -> tuple[str, ...]:
|
||||||
|
"""Layers from parent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of layer names.
|
||||||
|
"""
|
||||||
|
if self.app._disable_tooltips:
|
||||||
|
return super().layers
|
||||||
|
else:
|
||||||
|
return (*super().layers, "_tooltips")
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
background = self.styles.background
|
background = self.styles.background
|
||||||
try:
|
try:
|
||||||
@@ -490,6 +509,11 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
|
|
||||||
self._update_focus_styles(focused, blurred)
|
self._update_focus_styles(focused, blurred)
|
||||||
|
|
||||||
|
def _extend_compose(self, widgets: list[Widget]) -> None:
|
||||||
|
"""Insert the tooltip widget, if required."""
|
||||||
|
if not self.app._disable_tooltips:
|
||||||
|
widgets.insert(0, Tooltip(id="textual-tooltip"))
|
||||||
|
|
||||||
async def _on_idle(self, event: events.Idle) -> None:
|
async def _on_idle(self, event: events.Idle) -> None:
|
||||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
@@ -700,6 +724,36 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
for screen in self.app._background_screens:
|
for screen in self.app._background_screens:
|
||||||
screen._screen_resized(event.size)
|
screen._screen_resized(event.size)
|
||||||
|
|
||||||
|
def _update_tooltip(self, widget: Widget) -> None:
|
||||||
|
"""Update the content of the tooltip."""
|
||||||
|
try:
|
||||||
|
tooltip = self.get_child_by_type(Tooltip)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if tooltip.display and self._tooltip_widget is widget:
|
||||||
|
self._handle_tooltip_timer(widget)
|
||||||
|
|
||||||
|
def _handle_tooltip_timer(self, widget: Widget) -> None:
|
||||||
|
"""Called by a timer from _handle_mouse_move to update the tooltip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widget: The widget under the mouse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
tooltip = self.get_child_by_type(Tooltip)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
tooltip_content = widget.tooltip
|
||||||
|
if tooltip_content is None:
|
||||||
|
tooltip.display = False
|
||||||
|
else:
|
||||||
|
tooltip.display = True
|
||||||
|
tooltip._absolute_offset = self.app.mouse_position
|
||||||
|
tooltip.update(tooltip_content)
|
||||||
|
|
||||||
def _handle_mouse_move(self, event: events.MouseMove) -> None:
|
def _handle_mouse_move(self, event: events.MouseMove) -> None:
|
||||||
try:
|
try:
|
||||||
if self.app.mouse_captured:
|
if self.app.mouse_captured:
|
||||||
@@ -709,6 +763,14 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
widget, region = self.get_widget_at(event.x, event.y)
|
widget, region = self.get_widget_at(event.x, event.y)
|
||||||
except errors.NoWidget:
|
except errors.NoWidget:
|
||||||
self.app._set_mouse_over(None)
|
self.app._set_mouse_over(None)
|
||||||
|
if self._tooltip_timer is not None:
|
||||||
|
self._tooltip_timer.stop()
|
||||||
|
if not self.app._disable_tooltips:
|
||||||
|
try:
|
||||||
|
self.get_child_by_type(Tooltip).display = False
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.app._set_mouse_over(widget)
|
self.app._set_mouse_over(widget)
|
||||||
mouse_event = events.MouseMove(
|
mouse_event = events.MouseMove(
|
||||||
@@ -728,6 +790,27 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
mouse_event._set_forwarded()
|
mouse_event._set_forwarded()
|
||||||
widget._forward_event(mouse_event)
|
widget._forward_event(mouse_event)
|
||||||
|
|
||||||
|
if not self.app._disable_tooltips:
|
||||||
|
try:
|
||||||
|
tooltip = self.get_child_by_type(Tooltip)
|
||||||
|
except NoMatches:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
tooltip.styles.offset = event.screen_offset
|
||||||
|
|
||||||
|
if self._tooltip_widget != widget or not tooltip.display:
|
||||||
|
self._tooltip_widget = widget
|
||||||
|
if self._tooltip_timer is not None:
|
||||||
|
self._tooltip_timer.stop()
|
||||||
|
|
||||||
|
self._tooltip_timer = self.set_timer(
|
||||||
|
0.3,
|
||||||
|
partial(self._handle_tooltip_timer, widget),
|
||||||
|
name="tooltip-timer",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tooltip.display = False
|
||||||
|
|
||||||
def _forward_event(self, event: events.Event) -> None:
|
def _forward_event(self, event: events.Event) -> None:
|
||||||
if event.is_forwarded:
|
if event.is_forwarded:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ The base class for widgets.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from asyncio import Lock, wait
|
from asyncio import wait
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
@@ -325,7 +325,10 @@ class Widget(DOMNode):
|
|||||||
self._stabilize_scrollbar: tuple[Size, str, str] | None = None
|
self._stabilize_scrollbar: tuple[Size, str, str] | None = None
|
||||||
"""Used to prevent scrollbar logic getting stuck in an infinite loop."""
|
"""Used to prevent scrollbar logic getting stuck in an infinite loop."""
|
||||||
|
|
||||||
self._lock = Lock()
|
self._tooltip: RenderableType | None = None
|
||||||
|
"""The tooltip content."""
|
||||||
|
self._absolute_offset: Offset | None = None
|
||||||
|
"""Force an absolute offset for the widget (used by tooltips)."""
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name=name,
|
name=name,
|
||||||
@@ -367,7 +370,7 @@ class Widget(DOMNode):
|
|||||||
"""Show a horizontal scrollbar?"""
|
"""Show a horizontal scrollbar?"""
|
||||||
|
|
||||||
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True)
|
||||||
"""SHow a vertical scrollbar?"""
|
"""Show a horizontal scrollbar?"""
|
||||||
|
|
||||||
border_title: str | Text | None = _BorderTitle() # type: ignore
|
border_title: str | Text | None = _BorderTitle() # type: ignore
|
||||||
"""A title to show in the top border (if there is one)."""
|
"""A title to show in the top border (if there is one)."""
|
||||||
@@ -441,6 +444,19 @@ class Widget(DOMNode):
|
|||||||
def offset(self, offset: tuple[int, int]) -> None:
|
def offset(self, offset: tuple[int, int]) -> None:
|
||||||
self.styles.offset = ScalarOffset.from_offset(offset)
|
self.styles.offset = ScalarOffset.from_offset(offset)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tooltip(self) -> RenderableType | None:
|
||||||
|
"""Tooltip for the widget, or `None` for no tooltip."""
|
||||||
|
return self._tooltip
|
||||||
|
|
||||||
|
@tooltip.setter
|
||||||
|
def tooltip(self, tooltip: RenderableType | None):
|
||||||
|
self._tooltip = tooltip
|
||||||
|
try:
|
||||||
|
self.screen._update_tooltip(self)
|
||||||
|
except NoScreen:
|
||||||
|
pass
|
||||||
|
|
||||||
def __enter__(self) -> Self:
|
def __enter__(self) -> Self:
|
||||||
"""Use as context manager when composing."""
|
"""Use as context manager when composing."""
|
||||||
self.app._compose_stacks[-1].append(self)
|
self.app._compose_stacks[-1].append(self)
|
||||||
@@ -1585,6 +1601,7 @@ class Widget(DOMNode):
|
|||||||
break
|
break
|
||||||
if node.styles.has_rule("layers"):
|
if node.styles.has_rule("layers"):
|
||||||
layers = node.styles.layers
|
layers = node.styles.layers
|
||||||
|
|
||||||
return layers
|
return layers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3112,8 +3129,16 @@ class Widget(DOMNode):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.app.panic(Traceback())
|
self.app.panic(Traceback())
|
||||||
else:
|
else:
|
||||||
|
self._extend_compose(widgets)
|
||||||
await self.mount(*widgets)
|
await self.mount(*widgets)
|
||||||
|
|
||||||
|
def _extend_compose(self, widgets: list[Widget]) -> None:
|
||||||
|
"""Hook to extend composed widgets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
widgets: Widgets to be mounted.
|
||||||
|
"""
|
||||||
|
|
||||||
def _on_mount(self, event: events.Mount) -> None:
|
def _on_mount(self, event: events.Mount) -> None:
|
||||||
if self.styles.overflow_y == "scroll":
|
if self.styles.overflow_y == "scroll":
|
||||||
self.show_vertical_scrollbar = True
|
self.show_vertical_scrollbar = True
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from ._tabbed_content import TabbedContent, TabPane
|
from ._tabbed_content import TabbedContent, TabPane
|
||||||
from ._tabs import Tab, Tabs
|
from ._tabs import Tab, Tabs
|
||||||
from ._text_log import TextLog
|
from ._text_log import TextLog
|
||||||
|
from ._tooltip import Tooltip
|
||||||
from ._tree import Tree
|
from ._tree import Tree
|
||||||
from ._welcome import Welcome
|
from ._welcome import Welcome
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ __all__ = [
|
|||||||
"TabPane",
|
"TabPane",
|
||||||
"Tabs",
|
"Tabs",
|
||||||
"TextLog",
|
"TextLog",
|
||||||
|
"Tooltip",
|
||||||
"Tree",
|
"Tree",
|
||||||
"Welcome",
|
"Welcome",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ from ._tabbed_content import TabPane as TabPane
|
|||||||
from ._tabs import Tab as Tab
|
from ._tabs import Tab as Tab
|
||||||
from ._tabs import Tabs as Tabs
|
from ._tabs import Tabs as Tabs
|
||||||
from ._text_log import TextLog as TextLog
|
from ._text_log import TextLog as TextLog
|
||||||
|
from ._tooltip import Tooltip as Tooltip
|
||||||
from ._tree import Tree as Tree
|
from ._tree import Tree as Tree
|
||||||
from ._welcome import Welcome as Welcome
|
from ._welcome import Welcome as Welcome
|
||||||
|
|||||||
19
src/textual/widgets/_tooltip.py
Normal file
19
src/textual/widgets/_tooltip.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
class Tooltip(Static):
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Tooltip {
|
||||||
|
layer: _tooltips;
|
||||||
|
margin: 1 2;
|
||||||
|
padding: 1 2;
|
||||||
|
background: $panel;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
constrain: inflect;
|
||||||
|
max-width: 40;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
"""
|
||||||
File diff suppressed because one or more lines are too long
@@ -459,3 +459,20 @@ def test_translate_inside():
|
|||||||
assert Region(10, 10, 20, 5).translate_inside(Region(0, 0, 100, 100)) == Region(
|
assert Region(10, 10, 20, 5).translate_inside(Region(0, 0, 100, 100)) == Region(
|
||||||
10, 10, 20, 5
|
10, 10, 20, 5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflect():
|
||||||
|
# Default inflect positive
|
||||||
|
assert Region(10, 10, 30, 20).inflect(margin=Spacing(2, 2, 2, 2)) == Region(
|
||||||
|
44, 34, 30, 20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inflect y axis negative
|
||||||
|
assert Region(10, 10, 30, 20).inflect(
|
||||||
|
y_axis=-1, margin=Spacing(2, 2, 2, 2)
|
||||||
|
) == Region(44, -14, 30, 20)
|
||||||
|
|
||||||
|
# Inflect y axis negative
|
||||||
|
assert Region(10, 10, 30, 20).inflect(
|
||||||
|
x_axis=-1, margin=Spacing(2, 2, 2, 2)
|
||||||
|
) == Region(-24, 34, 30, 20)
|
||||||
|
|||||||
Reference in New Issue
Block a user