From 9b2fe2419a8764653b1b2574ba921e34194824a6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 6 Oct 2024 21:15:26 +0100 Subject: [PATCH 1/4] constrain both axis --- src/textual/_compositor.py | 56 +++++++++++++++++++----------- src/textual/css/_styles_builder.py | 34 ++++++++++++++++++ src/textual/css/constants.py | 2 +- src/textual/css/styles.py | 20 ++++++++--- src/textual/css/types.py | 2 +- src/textual/widgets/_tooltip.py | 4 ++- 6 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d7c32ac0a1..4745eb771a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -535,23 +535,32 @@ def _constrain( 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"), - ) + constrain_x = styles.constrain_x + constrain_y = styles.constrain_y + + inflect_margin = styles.margin + margin_region = region.grow(inflect_margin) + + region = region.inflect( + ( + (-1 if margin_region.right > constrain_region.right else 0) + if constrain_x == "inflect" + else 0 + ), + ( + (-1 if margin_region.bottom > constrain_region.bottom else 0) + if constrain_y == "inflect" + else 0 + ), + inflect_margin, + ) + + region = region.translate_inside( + constrain_region.shrink(styles.margin), + constrain_x == "limit", + constrain_y == "limit", + ) + return region def _arrange_root( @@ -688,7 +697,10 @@ def add_widget( widget_order = order + ((layer_index, z, layer_order),) - if overlay and sub_widget.styles.constrain != "none": + if overlay and ( + sub_widget.styles.constrain_x != "none" + or sub_widget.styles.constrain_y != "none" + ): widget_region = self._constrain( sub_widget.styles, widget_region, no_clip ) @@ -740,11 +752,15 @@ def add_widget( widget_region = region + layout_offset if widget._absolute_offset is not None: + margin = styles.margin widget_region = widget_region.reset_offset.translate( - widget._absolute_offset + widget.styles.margin.top_left + widget._absolute_offset + margin.top_left + ) + widget_region = widget_region.translate( + styles.offset.resolve(widget_region.grow(margin).size, size) ) - if styles.constrain != "none": + if styles.constrain_x != "none" or styles.constrain_y != "none": widget_region = self._constrain(styles, widget_region, no_clip) map[widget._render_widget] = _MapGeometry( diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 530388761c..d7d52e05c9 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -1047,6 +1047,40 @@ def process_overlay(self, name: str, tokens: list[Token]) -> None: self.styles._rules[name] = value # type: ignore def process_constrain(self, name: str, tokens: list[Token]) -> None: + if len(tokens) == 1: + try: + value = self._process_enum(name, tokens, VALID_CONSTRAIN) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_CONSTRAIN, context="css"), + ) + else: + self.styles._rules["constrain_x"] = value # type: ignore + self.styles._rules["constrain_y"] = value # type: ignore + elif len(tokens) == 2: + constrain_x, constrain_y = self._process_enum_multiple( + name, tokens, VALID_CONSTRAIN, 2 + ) + self.styles._rules["constrain_x"] = constrain_x # type: ignore + self.styles._rules["constrain_y"] = constrain_y # type: ignore + else: + self.error(name, tokens[0], "one or two values here") + + def process_constrain_x(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_CONSTRAIN) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_CONSTRAIN, context="css"), + ) + else: + self.styles._rules[name] = value # type: ignore + + def process_constrain_y(self, name: str, tokens: list[Token]) -> None: try: value = self._process_enum(name, tokens, VALID_CONSTRAIN) except StyleValueError: diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index a5e68ae0b4..d182ab94b5 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -74,7 +74,7 @@ "nocolor", } VALID_OVERLAY: Final = {"none", "screen"} -VALID_CONSTRAIN: Final = {"x", "y", "both", "inflect", "none"} +VALID_CONSTRAIN: Final = {"inflect", "limit", "none"} VALID_KEYLINE: Final = {"none", "thin", "heavy", "double"} VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"} HATCHES: Final = { diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 026a0bbb0b..dc2da4ac82 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -190,7 +190,8 @@ class RulesMap(TypedDict, total=False): hatch: tuple[str, Color] | Literal["none"] overlay: Overlay - constrain: Constrain + constrain_x: Constrain + constrain_y: Constrain RULE_NAMES = list(RulesMap.__annotations__.keys()) @@ -450,7 +451,8 @@ class StylesBase: overlay = StringEnumProperty( VALID_OVERLAY, "none", layout=True, refresh_parent=True ) - constrain = StringEnumProperty(VALID_CONSTRAIN, "none") + constrain_x = StringEnumProperty(VALID_CONSTRAIN, "none") + constrain_y = StringEnumProperty(VALID_CONSTRAIN, "none") def __textual_animation__( self, @@ -1172,8 +1174,18 @@ def append_declaration(name: str, value: str) -> None: append_declaration("subtitle-text-style", str(self.border_subtitle_style)) if "overlay" in rules: append_declaration("overlay", str(self.overlay)) - if "constrain" in rules: - append_declaration("constrain", str(self.constrain)) + if "constrain_x" in rules and "constrain_y" in rules: + if self.constrain_x == self.constrain_y: + append_declaration("constrain", self.constrain_x) + else: + append_declaration( + "constrain", f"{self.constrain_x} {self.constrain_y}" + ) + elif "constrain_x" in rules: + append_declaration("constrain-x", self.constrain_x) + elif "constrain_y" in rules: + append_declaration("constrain-y", self.constrain_y) + if "keyline" in rules: keyline_type, keyline_color = self.keyline if keyline_type != "none": diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 2ba92d3264..0017007373 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -36,7 +36,7 @@ Overflow = Literal["scroll", "hidden", "auto"] EdgeStyle = Tuple[EdgeType, Color] TextAlign = Literal["left", "start", "center", "right", "end", "justify"] -Constrain = Literal["none", "x", "y", "both"] +Constrain = Literal["none", "inflect", "limit"] Overlay = Literal["none", "screen"] Specificity3 = Tuple[int, int, int] diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py index 86221969a0..f71dfcf1e6 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -8,13 +8,15 @@ class Tooltip(Static, inherit_css=False): Tooltip { layer: _tooltips; margin: 1 2; + padding: 1 2; background: $background; width: auto; height: auto; - constrain: inflect; + constrain: limit inflect; max-width: 40; display: none; + offset-x: -50%; } """ DEFAULT_CLASSES = "-textual-system" From 55b03a67dd8c40ec47776f1f7d348c78d7218079 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 6 Oct 2024 21:28:19 +0100 Subject: [PATCH 2/4] fix constrain --- src/textual/_compositor.py | 5 +++-- src/textual/css/constants.py | 2 +- src/textual/css/types.py | 2 +- src/textual/widgets/_select.py | 2 +- src/textual/widgets/_tooltip.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4745eb771a..bd9402d4ac 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -557,8 +557,8 @@ def _constrain( region = region.translate_inside( constrain_region.shrink(styles.margin), - constrain_x == "limit", - constrain_y == "limit", + constrain_x == "inside", + constrain_y == "inside", ) return region @@ -704,6 +704,7 @@ def add_widget( widget_region = self._constrain( sub_widget.styles, widget_region, no_clip ) + if widget._cover_widget is None: add_widget( sub_widget, diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index d182ab94b5..026e78be45 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -74,7 +74,7 @@ "nocolor", } VALID_OVERLAY: Final = {"none", "screen"} -VALID_CONSTRAIN: Final = {"inflect", "limit", "none"} +VALID_CONSTRAIN: Final = {"inflect", "inside", "none"} VALID_KEYLINE: Final = {"none", "thin", "heavy", "double"} VALID_HATCH: Final = {"left", "right", "cross", "vertical", "horizontal"} HATCHES: Final = { diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 0017007373..f02ff80ed0 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -36,7 +36,7 @@ Overflow = Literal["scroll", "hidden", "auto"] EdgeStyle = Tuple[EdgeType, Color] TextAlign = Literal["left", "start", "center", "right", "end", "justify"] -Constrain = Literal["none", "inflect", "limit"] +Constrain = Literal["none", "inflect", "inside"] Overlay = Literal["none", "screen"] Specificity3 = Tuple[int, int, int] diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index aa78f31b31..48cc392598 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -218,7 +218,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): height: auto; max-height: 12; overlay: screen; - constrain: y; + constrain: none inside; } &:focus > SelectCurrent { diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py index f71dfcf1e6..f64c9b5e23 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -13,7 +13,7 @@ class Tooltip(Static, inherit_css=False): background: $background; width: auto; height: auto; - constrain: limit inflect; + constrain: inside inflect; max-width: 40; display: none; offset-x: -50%; From 5f53538bf909f55fd80a888f166ef92d97c0bc88 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Oct 2024 12:54:26 +0100 Subject: [PATCH 3/4] absolute offset public --- CHANGELOG.md | 1 + src/textual/_compositor.py | 25 +++++++++---------- src/textual/css/_styles_builder.py | 2 +- src/textual/screen.py | 2 +- src/textual/widget.py | 2 +- src/textual/widgets/_tooltip.py | 1 - .../snapshot_tests/snapshot_apps/tooltips.py | 1 + 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b27bc934b..5526f1a45b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Digits are now thin by default, style with text-style: bold to get bold digits https://github.com/Textualize/textual/pull/5094 +- Made `Widget.absolute_offset` public https://github.com/Textualize/textual/pull/5097 ## [0.82.0] - 2024-10-03 diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bd9402d4ac..1e3a7ad328 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -557,8 +557,8 @@ def _constrain( region = region.translate_inside( constrain_region.shrink(styles.margin), - constrain_x == "inside", - constrain_y == "inside", + constrain_x != "none", + constrain_y != "none", ) return region @@ -697,13 +697,12 @@ def add_widget( widget_order = order + ((layer_index, z, layer_order),) - if overlay and ( - sub_widget.styles.constrain_x != "none" - or sub_widget.styles.constrain_y != "none" - ): - widget_region = self._constrain( - sub_widget.styles, widget_region, no_clip - ) + if overlay: + has_rule = sub_widget.styles.has_rule + if has_rule("constrain_x") or has_rule("constrain_y"): + widget_region = self._constrain( + sub_widget.styles, widget_region, no_clip + ) if widget._cover_widget is None: add_widget( @@ -752,16 +751,16 @@ def add_widget( widget_region = region + layout_offset - if widget._absolute_offset is not None: + if widget.absolute_offset is not None: margin = styles.margin widget_region = widget_region.reset_offset.translate( - widget._absolute_offset + margin.top_left + widget.absolute_offset + margin.top_left ) widget_region = widget_region.translate( styles.offset.resolve(widget_region.grow(margin).size, size) ) - - if styles.constrain_x != "none" or styles.constrain_y != "none": + has_rule = styles.has_rule + if has_rule("constrain_x") or has_rule("constrain_y"): widget_region = self._constrain(styles, widget_region, no_clip) map[widget._render_widget] = _MapGeometry( diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index d7d52e05c9..6d1baf45e8 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -1066,7 +1066,7 @@ def process_constrain(self, name: str, tokens: list[Token]) -> None: self.styles._rules["constrain_x"] = constrain_x # type: ignore self.styles._rules["constrain_y"] = constrain_y # type: ignore else: - self.error(name, tokens[0], "one or two values here") + self.error(name, tokens[0], "one or two values expected here") def process_constrain_x(self, name: str, tokens: list[Token]) -> None: try: diff --git a/src/textual/screen.py b/src/textual/screen.py index 36c4de658b..ebf4283a01 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1278,7 +1278,7 @@ def _handle_tooltip_timer(self, widget: Widget) -> None: tooltip.display = False else: tooltip.display = True - tooltip._absolute_offset = self.app.mouse_position + tooltip.absolute_offset = self.app.mouse_position tooltip.update(tooltip_content) def _handle_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 4177ea3302..989d9762ce 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -422,7 +422,7 @@ def __init__( self._tooltip: RenderableType | None = None """The tooltip content.""" - self._absolute_offset: Offset | None = None + self.absolute_offset: Offset | None = None """Force an absolute offset for the widget (used by tooltips).""" self._scrollbar_changes: set[tuple[bool, bool]] = set() diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py index f64c9b5e23..93e2f64dac 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -8,7 +8,6 @@ class Tooltip(Static, inherit_css=False): Tooltip { layer: _tooltips; margin: 1 2; - padding: 1 2; background: $background; width: auto; diff --git a/tests/snapshot_tests/snapshot_apps/tooltips.py b/tests/snapshot_tests/snapshot_apps/tooltips.py index 9309998afc..3bc584744a 100644 --- a/tests/snapshot_tests/snapshot_apps/tooltips.py +++ b/tests/snapshot_tests/snapshot_apps/tooltips.py @@ -4,6 +4,7 @@ class TooltipApp(App[None]): TOOLTIP_DELAY = 0.4 + def compose(self) -> ComposeResult: progress_bar = ProgressBar(100, show_eta=False) progress_bar.advance(10) From 3794d9544ee98eeb0c9fd0603294aa2835526b44 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 7 Oct 2024 18:50:54 +0100 Subject: [PATCH 4/4] change to inflect --- CHANGELOG.md | 3 + pyproject.toml | 2 +- src/textual/_compositor.py | 61 +++------ src/textual/css/_style_properties.py | 11 +- src/textual/css/styles.py | 8 +- src/textual/geometry.py | 112 +++++++++++++++-- src/textual/widgets/_tooltip.py | 2 +- .../test_tooltips_in_compound_widgets.svg | 116 +++++++++--------- tests/test_geometry.py | 63 +++++++++- 9 files changed, 253 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5526f1a45b..d367a3b4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added support for A-F to Digits widget https://github.com/Textualize/textual/pull/5094 +- Added `Region.constrain` https://github.com/Textualize/textual/pull/5097 ### Changed - Digits are now thin by default, style with text-style: bold to get bold digits https://github.com/Textualize/textual/pull/5094 - Made `Widget.absolute_offset` public https://github.com/Textualize/textual/pull/5097 +- Tooltips are now displayed directly below the mouse cursor https://github.com/Textualize/textual/pull/5097 +- `Region.inflect` will now assume that margins overlap https://github.com/Textualize/textual/pull/5097 ## [0.82.0] - 2024-10-03 diff --git a/pyproject.toml b/pyproject.toml index 400ad36099..27a330e829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ pytest-textual-snapshot = "^1.0.0" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] -addopts = "--strict-markers" +addopts = "--strict-markers -vv" markers = [ "syntax: marks tests that require syntax highlighting (deselect with '-m \"not syntax\"')", ] diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1e3a7ad328..13869c838a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -40,7 +40,6 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias - from textual.css.styles import RenderStyles from textual.screen import Screen from textual.widget import Widget @@ -522,47 +521,6 @@ def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: } 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_x = styles.constrain_x - constrain_y = styles.constrain_y - - inflect_margin = styles.margin - margin_region = region.grow(inflect_margin) - - region = region.inflect( - ( - (-1 if margin_region.right > constrain_region.right else 0) - if constrain_x == "inflect" - else 0 - ), - ( - (-1 if margin_region.bottom > constrain_region.bottom else 0) - if constrain_y == "inflect" - else 0 - ), - inflect_margin, - ) - - region = region.translate_inside( - constrain_region.shrink(styles.margin), - constrain_x != "none", - constrain_y != "none", - ) - - return region - def _arrange_root( self, root: Widget, size: Size, visible_only: bool = True ) -> tuple[CompositorMap, set[Widget]]: @@ -698,10 +656,14 @@ def add_widget( widget_order = order + ((layer_index, z, layer_order),) if overlay: - has_rule = sub_widget.styles.has_rule + styles = sub_widget.styles + has_rule = styles.has_rule if has_rule("constrain_x") or has_rule("constrain_y"): - widget_region = self._constrain( - sub_widget.styles, widget_region, no_clip + widget_region = widget_region.constrain( + styles.constrain_x, + styles.constrain_y, + styles.margin, + no_clip, ) if widget._cover_widget is None: @@ -753,7 +715,7 @@ def add_widget( if widget.absolute_offset is not None: margin = styles.margin - widget_region = widget_region.reset_offset.translate( + widget_region = widget_region.at_offset( widget.absolute_offset + margin.top_left ) widget_region = widget_region.translate( @@ -761,7 +723,12 @@ def add_widget( ) has_rule = styles.has_rule if has_rule("constrain_x") or has_rule("constrain_y"): - widget_region = self._constrain(styles, widget_region, no_clip) + widget_region = widget_region.constrain( + styles.constrain_x, + styles.constrain_y, + styles.margin, + size.region, + ) map[widget._render_widget] = _MapGeometry( widget_region, diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index f83c9d60f2..e8989847eb 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -68,6 +68,7 @@ PropertyGetType = TypeVar("PropertyGetType") PropertySetType = TypeVar("PropertySetType") +EnumType = TypeVar("EnumType", covariant=True) class GenericProperty(Generic[PropertyGetType, PropertySetType]): @@ -773,7 +774,7 @@ def __set__( obj.refresh(layout=True) -class StringEnumProperty: +class StringEnumProperty(Generic[EnumType]): """Descriptor for getting and setting string properties and ensuring that the set value belongs in the set of valid values. @@ -787,7 +788,7 @@ class StringEnumProperty: def __init__( self, valid_values: set[str], - default: str, + default: EnumType, layout: bool = False, refresh_children: bool = False, refresh_parent: bool = False, @@ -801,7 +802,9 @@ def __init__( def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name - def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> str: + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> EnumType: """Get the string property, or the default value if it's not set. Args: @@ -816,7 +819,7 @@ def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> s def _before_refresh(self, obj: StylesBase, value: str | None) -> None: """Do any housekeeping before asking for a layout refresh after a value change.""" - def __set__(self, obj: StylesBase, value: str | None = None): + def __set__(self, obj: StylesBase, value: EnumType | None = None): """Set the string property and ensure it is in the set of allowed values. Args: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index dc2da4ac82..4e00d74d23 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -451,8 +451,12 @@ class StylesBase: overlay = StringEnumProperty( VALID_OVERLAY, "none", layout=True, refresh_parent=True ) - constrain_x = StringEnumProperty(VALID_CONSTRAIN, "none") - constrain_y = StringEnumProperty(VALID_CONSTRAIN, "none") + constrain_x: StringEnumProperty[Constrain] = StringEnumProperty( + VALID_CONSTRAIN, "none" + ) + constrain_y: StringEnumProperty[Constrain] = StringEnumProperty( + VALID_CONSTRAIN, "none" + ) def __textual_animation__( self, diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 96ac2f5be6..b91fc8bf06 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -11,6 +11,7 @@ TYPE_CHECKING, Any, Collection, + Literal, NamedTuple, Tuple, TypeVar, @@ -986,6 +987,11 @@ def inflect( 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. + If a margin is provided, it will add space between the resulting region. + + Note that if margin is specified it *overlaps*, so the space will be the maximum + of two edges, and not the total. + ``` ╔══════════╗ │ ║ ║ @@ -993,13 +999,11 @@ def inflect( ║ ║ ╚══════════╝ │ - ─ ─ ─ ─ ─ ─ ─ ─ ┼ ─ ─ ─ ─ ─ ─ ─ ─ - - │ ┌──────────┐ - │ │ - │ │ Result │ - │ │ - │ └──────────┘ + ─ ─ ─ ─ ─ ─ ─ ─ ┌──────────┐ + │ │ + │ Result │ + │ │ + └──────────┘ ``` Args: @@ -1013,11 +1017,89 @@ def inflect( 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 + x += (width + inflect_margin.max_width) * x_axis if y_axis: - y += (height + inflect_margin.height) * y_axis + y += (height + inflect_margin.max_height) * y_axis return Region(x, y, width, height) + def constrain( + self, + constrain_x: Literal["none", "inside", "inflect"], + constrain_y: Literal["none", "inside", "inflect"], + margin: Spacing, + container: Region, + ) -> Region: + """Constrain a region to fit within a container, using different methods per axis. + + Args: + constrain_x: Constrain method for the X-axis. + constrain_y: Constrain method for the Y-axis. + margin: Margin to maintain around region. + container: Container to constrain to. + + Returns: + New widget, that fits inside the container (if possible). + """ + margin_region = self.grow(margin) + region = self + + def compare_span( + span_start: int, span_end: int, container_start: int, container_end: int + ) -> int: + """Compare a span with a container + + Args: + span_start: Start of the span. + span_end: end of the span. + container_start: Start of the container. + container_end: End of the container. + + Returns: + 0 if the span fits, -1 if it is less that the container, otherwise +1 + """ + if span_start >= container_start and span_end <= container_end: + return 0 + if span_start < container_start: + return -1 + return +1 + + # Apply any inflected constraints + if constrain_x == "inflect" or constrain_y == "inflect": + region = region.inflect( + ( + -compare_span( + margin_region.x, + margin_region.right, + container.x, + container.right, + ) + if constrain_x == "inflect" + else 0 + ), + ( + -compare_span( + margin_region.y, + margin_region.bottom, + container.y, + container.bottom, + ) + if constrain_y == "inflect" + else 0 + ), + margin, + ) + + # Apply translate inside constrains + # Note this is also applied, if a previous inflect constrained has been applied + # This is so that the origin is always inside the container + region = region.translate_inside( + container.shrink(margin), + constrain_x != "none", + constrain_y != "none", + ) + + return region + class Spacing(NamedTuple): """Stores spacing around a widget, such as padding and border. @@ -1072,6 +1154,18 @@ def height(self) -> int: """Total space in the y axis.""" return self.top + self.bottom + @property + def max_width(self) -> int: + """The space between regions in the X direction if margins overlap, i.e. `max(self.left, self.right)`.""" + _top, right, _bottom, left = self + return left if left > right else right + + @property + def max_height(self) -> int: + """The space between regions in the Y direction if margins overlap, i.e. `max(self.top, self.bottom)`.""" + top, _right, bottom, _left = self + return top if top > bottom else bottom + @property def top_left(self) -> tuple[int, int]: """A pair of integers for the left, and top space.""" diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py index 93e2f64dac..2b5b0440c0 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -7,7 +7,7 @@ class Tooltip(Static, inherit_css=False): DEFAULT_CSS = """ Tooltip { layer: _tooltips; - margin: 1 2; + margin: 1 0; padding: 1 2; background: $background; width: auto; diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tooltips_in_compound_widgets.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tooltips_in_compound_widgets.svg index ecb07311c1..350f730f3f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tooltips_in_compound_widgets.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tooltips_in_compound_widgets.svg @@ -19,134 +19,134 @@ font-weight: 700; } - .terminal-3603379720-matrix { + .terminal-3978259216-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3603379720-title { + .terminal-3978259216-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3603379720-r1 { fill: #fea62b } -.terminal-3603379720-r2 { fill: #323232 } -.terminal-3603379720-r3 { fill: #c5c8c6 } -.terminal-3603379720-r4 { fill: #e1e1e1 } -.terminal-3603379720-r5 { fill: #e0e0e0 } + .terminal-3978259216-r1 { fill: #fea62b } +.terminal-3978259216-r2 { fill: #323232 } +.terminal-3978259216-r3 { fill: #c5c8c6 } +.terminal-3978259216-r4 { fill: #e1e1e1 } +.terminal-3978259216-r5 { fill: #e0e0e0 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TooltipApp + TooltipApp - - - - ━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%                                            - -Hello, Tooltip! - - - - - - - - - - - - - - - - - - - - + + + + ━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━10%                                            + +Hello, Tooltip! + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_geometry.py b/tests/test_geometry.py index da3a35e026..f13b9e9b30 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,3 +1,5 @@ +from typing import Literal + import pytest from textual.geometry import Offset, Region, Size, Spacing, clamp @@ -466,20 +468,23 @@ def test_translate_inside(): def test_inflect(): + assert Region(0, 0, 1, 1).inflect() == Region(1, 1, 1, 1) + assert Region(0, 0, 1, 1).inflect(margin=Spacing.unpack(1)) == Region(2, 2, 1, 1) + # Default inflect positive assert Region(10, 10, 30, 20).inflect(margin=Spacing(2, 2, 2, 2)) == Region( - 44, 34, 30, 20 + 42, 32, 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) + ) == Region(42, -12, 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) + ) == Region(-22, 32, 30, 20) def test_size_with_height(): @@ -517,3 +522,55 @@ def test_get_spacing_between(region1: Region, region2: Region, expected: Spacing spacing = region1.get_spacing_between(region2) assert spacing == expected assert region1.shrink(spacing) == region2 + + +@pytest.mark.parametrize( + "constrain_x,constrain_y,margin,region,container,expected", + [ + # A null-op + ( + "none", + "none", + Spacing.unpack(0), + Region(0, 0, 10, 10), + Region(0, 0, 100, 100), + Region(0, 0, 10, 10), + ), + # Negative offset gets moved to 0, 0 + margin + ( + "inside", + "inside", + Spacing.unpack(1), + Region(-5, -5, 10, 10), + Region(0, 0, 100, 100), + Region(1, 1, 10, 10), + ), + # Overlapping region gets moved in, with offset + ( + "inside", + "inside", + Spacing.unpack(1), + Region(95, 95, 10, 10), + Region(0, 0, 100, 100), + Region(89, 89, 10, 10), + ), + # X coordinate moved inside, region reflected around it's Y axis + ( + "inside", + "inflect", + Spacing.unpack(1), + Region(-5, -5, 10, 10), + Region(0, 0, 100, 100), + Region(1, 6, 10, 10), + ), + ], +) +def test_constrain( + constrain_x: Literal["none", "inside", "inflect"], + constrain_y: Literal["none", "inside", "inflect"], + margin: Spacing, + region: Region, + container: Region, + expected: Region, +) -> None: + assert region.constrain(constrain_x, constrain_y, margin, container) == expected