* 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:
Will McGugan
2023-05-30 16:14:31 +01:00
committed by GitHub
parent 83c83de78b
commit 149c39c86c
20 changed files with 478 additions and 102 deletions

View File

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

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

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

View File

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

View File

@@ -39,6 +39,7 @@ from .strip import Strip, StripRenderable
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from .css.styles import RenderStyles
from .screen import Screen
from .widget import Widget
@@ -496,6 +497,38 @@ class Compositor:
}
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(
self, root: Widget, size: Size, visible_only: bool = True
) -> tuple[CompositorMap, set[Widget]]:
@@ -540,13 +573,14 @@ class Compositor:
visible: Whether the widget should be visible by default.
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:
visible = visibility == "visible"
if visible:
add_new_widget(widget)
styles_offset = widget.styles.offset
styles_offset = styles.offset
layout_offset = (
styles_offset.resolve(region.size, clip.size)
if styles_offset
@@ -554,9 +588,7 @@ class Compositor:
)
# Container region is minus border
container_region = region.shrink(widget.styles.gutter).translate(
layout_offset
)
container_region = region.shrink(styles.gutter).translate(layout_offset)
container_size = container_region.size
# Widgets with scrollbars (containers or scroll view) require additional processing
@@ -608,14 +640,9 @@ class Compositor:
widget_order = order + ((layer_index, z, layer_order),)
if overlay:
constrain = sub_widget.styles.constrain
if constrain != "none":
# Constrain to avoid clipping
widget_region = widget_region.translate_inside(
no_clip,
constrain in ("x", "both"),
constrain in ("y", "both"),
if overlay and sub_widget.styles.constrain != "none":
widget_region = self._constrain(
sub_widget.styles, widget_region, no_clip
)
add_widget(
@@ -659,8 +686,19 @@ class Compositor:
elif visible:
# 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(
region + layout_offset,
widget_region,
order,
clip,
region.size,

View File

@@ -33,11 +33,13 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
try:
rows = int(attrs.get("lines", 24))
columns = int(attrs.get("columns", 80))
hover = attrs.get("hover", "")
svg = take_svg_screenshot(
None,
path,
press,
title,
hover=hover,
title=title,
terminal_size=(columns, rows),
wait_for_animation=False,
)
@@ -58,6 +60,7 @@ def take_svg_screenshot(
app: App | None = None,
app_path: str | None = None,
press: Iterable[str] = (),
hover: str = "",
title: str | None = None,
terminal_size: tuple[int, int] = (80, 24),
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_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.
hover: Hover over the given widget.
title: The terminal title in the output image.
terminal_size: A pair of integers (rows, columns), representing terminal size.
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
with open(path, "rb") as source_file:
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"
return cache_key
@@ -115,7 +119,11 @@ def take_svg_screenshot(
result = run_before(pilot)
if inspect.isawaitable(result):
await result
await pilot.pause()
await pilot.press(*press)
if hover:
await pilot.hover(hover)
await pilot.pause(0.5)
if wait_for_animation:
await pilot.wait_for_scheduled_animations()
await pilot.pause()

View File

@@ -131,6 +131,7 @@ def resolve_fraction_unit(
resolve_scalar(styles.max_width),
)
for styles in widget_styles
if styles.overlay != "screen"
]
else:
resolve = [
@@ -140,6 +141,7 @@ def resolve_fraction_unit(
resolve_scalar(styles.max_height),
)
for styles in widget_styles
if styles.overlay != "screen"
]
resolved: list[Fraction | None] = [None] * len(resolve)
@@ -220,13 +222,24 @@ def resolve_box_models(
# If all box models have been calculated
widget_styles = [widget.styles for widget in widgets]
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))
fraction_unit = resolve_fraction_unit(
[
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,
viewport_size,
@@ -237,14 +250,23 @@ def resolve_box_models(
height_fraction = Fraction(margin_size.height)
else:
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))
fraction_unit = resolve_fraction_unit(
[
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,
viewport_size,

View File

@@ -469,6 +469,7 @@ class App(Generic[ReturnType], DOMNode):
self._loop: asyncio.AbstractEventLoop | None = None
self._return_value: ReturnType | None = None
self._exit = False
self._disable_tooltips = False
self.css_monitor = (
FileMonitor(self.css_path, self._on_css_change)
@@ -1058,6 +1059,7 @@ class App(Generic[ReturnType], DOMNode):
*,
headless: bool = True,
size: tuple[int, int] | None = (80, 24),
tooltips: bool = False,
) -> AsyncGenerator[Pilot, None]:
"""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).
size: Force terminal size to `(WIDTH, HEIGHT)`,
or None to auto-detect.
tooltips: Enable tooltips when testing.
"""
from .pilot import Pilot
app = self
app._disable_tooltips = not tooltips
app_ready_event = asyncio.Event()
def on_app_ready() -> None:

View File

@@ -70,6 +70,6 @@ VALID_PSEUDO_CLASSES: Final = {
"hover",
}
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)

View File

@@ -23,7 +23,7 @@ DURATION = r"\d+\.?\d*(?:ms|s)"
NUMBER = r"\-?\d+\.?\d*"
COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}"
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"\".*?\""
VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"

View File

@@ -905,6 +905,47 @@ class Region(NamedTuple):
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):
"""The spacing around a renderable, such as padding and border.

View File

@@ -24,7 +24,6 @@ class HorizontalLayout(Layout):
) -> ArrangeResult:
placements: list[WidgetPlacement] = []
add_placement = placements.append
x = max_height = Fraction(0)
child_styles = [child.styles for child in children]
box_margins: list[Spacing] = [styles.margin for styles in child_styles]
@@ -63,7 +62,14 @@ class HorizontalLayout(Layout):
if box_models:
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
_WidgetPlacement = WidgetPlacement
@@ -75,9 +81,6 @@ class HorizontalLayout(Layout):
region = _Region(
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(
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
)

View File

@@ -24,7 +24,9 @@ class VerticalLayout(Layout):
add_placement = placements.append
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:
resolve_margin = Size(
max(
@@ -60,7 +62,14 @@ class VerticalLayout(Layout):
if box_models:
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
_WidgetPlacement = WidgetPlacement
@@ -74,14 +83,7 @@ class VerticalLayout(Layout):
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
)
add_placement(
_WidgetPlacement(
region,
box_model.margin,
widget,
0,
False,
overlay,
)
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
)
if not overlay:
y = next_y + margin

View File

@@ -5,6 +5,7 @@ The `Screen` class is a special widget which represents the content in the termi
from __future__ import annotations
from functools import partial
from typing import (
TYPE_CHECKING,
Awaitable,
@@ -22,9 +23,11 @@ from typing import (
import rich.repr
from rich.console import RenderableType
from rich.style import Style
from rich.traceback import Traceback
from . import errors, events, messages
from ._callback import invoke
from ._compose import compose
from ._compositor import Compositor, MapGeometry
from ._context import visible_screen_stack
from ._types import CallbackType
@@ -39,6 +42,7 @@ from .renderables.background_screen import BackgroundScreen
from .renderables.blank import Blank
from .timer import Timer
from .widget import Widget
from .widgets import Tooltip
if TYPE_CHECKING:
from typing_extensions import Final
@@ -141,6 +145,9 @@ class Screen(Generic[ScreenResultType], Widget):
self._callbacks: list[CallbackType] = []
self._result_callbacks: list[ResultCallback[ScreenResultType]] = []
self._tooltip_widget: Widget | None = None
self._tooltip_timer: Timer | None = None
@property
def is_modal(self) -> bool:
"""Is the screen modal?"""
@@ -169,6 +176,18 @@ class Screen(Generic[ScreenResultType], Widget):
)
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:
background = self.styles.background
try:
@@ -490,6 +509,11 @@ class Screen(Generic[ScreenResultType], Widget):
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:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()
@@ -700,6 +724,36 @@ class Screen(Generic[ScreenResultType], Widget):
for screen in self.app._background_screens:
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:
try:
if self.app.mouse_captured:
@@ -709,6 +763,14 @@ class Screen(Generic[ScreenResultType], Widget):
widget, region = self.get_widget_at(event.x, event.y)
except errors.NoWidget:
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:
self.app._set_mouse_over(widget)
mouse_event = events.MouseMove(
@@ -728,6 +790,27 @@ class Screen(Generic[ScreenResultType], Widget):
mouse_event._set_forwarded()
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:
if event.is_forwarded:
return

View File

@@ -4,7 +4,7 @@ The base class for widgets.
from __future__ import annotations
from asyncio import Lock, wait
from asyncio import wait
from collections import Counter
from fractions import Fraction
from itertools import islice
@@ -325,7 +325,10 @@ class Widget(DOMNode):
self._stabilize_scrollbar: tuple[Size, str, str] | None = None
"""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__(
name=name,
@@ -367,7 +370,7 @@ class Widget(DOMNode):
"""Show a horizontal scrollbar?"""
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
"""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:
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:
"""Use as context manager when composing."""
self.app._compose_stacks[-1].append(self)
@@ -1585,6 +1601,7 @@ class Widget(DOMNode):
break
if node.styles.has_rule("layers"):
layers = node.styles.layers
return layers
@property
@@ -3112,8 +3129,16 @@ class Widget(DOMNode):
except Exception:
self.app.panic(Traceback())
else:
self._extend_compose(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:
if self.styles.overflow_y == "scroll":
self.show_vertical_scrollbar = True

View File

@@ -36,6 +36,7 @@ if typing.TYPE_CHECKING:
from ._tabbed_content import TabbedContent, TabPane
from ._tabs import Tab, Tabs
from ._text_log import TextLog
from ._tooltip import Tooltip
from ._tree import Tree
from ._welcome import Welcome
@@ -70,6 +71,7 @@ __all__ = [
"TabPane",
"Tabs",
"TextLog",
"Tooltip",
"Tree",
"Welcome",
]

View File

@@ -28,5 +28,6 @@ from ._tabbed_content import TabPane as TabPane
from ._tabs import Tab as Tab
from ._tabs import Tabs as Tabs
from ._text_log import TextLog as TextLog
from ._tooltip import Tooltip as Tooltip
from ._tree import Tree as Tree
from ._welcome import Welcome as Welcome

View 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

View File

@@ -459,3 +459,20 @@ def test_translate_inside():
assert Region(10, 10, 20, 5).translate_inside(Region(0, 0, 100, 100)) == Region(
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)