diff --git a/CHANGELOG.md b/CHANGELOG.md index 1854c24bc6..0a6ca85f48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### 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 +- Added support for A-F to Digits widget https://github.com/Textualize/textual/pull/5094 + ### Changed - `Screen.ALLOW_IN_MAXIMIZED_VIEW` will now default to `App.ALLOW_IN_MAXIMIZED_VIEW` https://github.com/Textualize/textual/pull/5088 - Widgets matching `.-textual-system` will now be included in the maximize view by default https://github.com/Textualize/textual/pull/5088 - 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 - `Pilot.click` and friends will now accept a widget, in addition to a selector https://github.com/Textualize/textual/pull/5095 -### Added - -- Added support for A-F to Digits widget https://github.com/Textualize/textual/pull/5094 - ## [0.82.0] - 2024-10-03 ### Fixed 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 d7c32ac0a1..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,38 +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 = 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]]: @@ -688,10 +655,17 @@ def add_widget( widget_order = order + ((layer_index, z, layer_order),) - if overlay and sub_widget.styles.constrain != "none": - widget_region = self._constrain( - sub_widget.styles, widget_region, no_clip - ) + if overlay: + styles = sub_widget.styles + has_rule = styles.has_rule + if has_rule("constrain_x") or has_rule("constrain_y"): + widget_region = widget_region.constrain( + styles.constrain_x, + styles.constrain_y, + styles.margin, + no_clip, + ) + if widget._cover_widget is None: add_widget( sub_widget, @@ -739,13 +713,22 @@ def add_widget( 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 widget.absolute_offset is not None: + margin = styles.margin + widget_region = widget_region.at_offset( + widget.absolute_offset + margin.top_left + ) + widget_region = widget_region.translate( + styles.offset.resolve(widget_region.grow(margin).size, size) + ) + has_rule = styles.has_rule + if has_rule("constrain_x") or has_rule("constrain_y"): + widget_region = widget_region.constrain( + styles.constrain_x, + styles.constrain_y, + styles.margin, + size.region, ) - - if styles.constrain != "none": - widget_region = self._constrain(styles, widget_region, no_clip) 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_builder.py b/src/textual/css/_styles_builder.py index 530388761c..6d1baf45e8 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 expected 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..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 = {"x", "y", "both", "inflect", "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/styles.py b/src/textual/css/styles.py index 026a0bbb0b..4e00d74d23 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,12 @@ class StylesBase: overlay = StringEnumProperty( VALID_OVERLAY, "none", layout=True, refresh_parent=True ) - constrain = StringEnumProperty(VALID_CONSTRAIN, "none") + constrain_x: StringEnumProperty[Constrain] = StringEnumProperty( + VALID_CONSTRAIN, "none" + ) + constrain_y: StringEnumProperty[Constrain] = StringEnumProperty( + VALID_CONSTRAIN, "none" + ) def __textual_animation__( self, @@ -1172,8 +1178,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..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", "x", "y", "both"] +Constrain = Literal["none", "inflect", "inside"] Overlay = Literal["none", "screen"] Specificity3 = Tuple[int, int, int] 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/screen.py b/src/textual/screen.py index 55999c7ccd..1d1123491f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1305,7 +1305,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/_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 86221969a0..2b5b0440c0 100644 --- a/src/textual/widgets/_tooltip.py +++ b/src/textual/widgets/_tooltip.py @@ -7,14 +7,15 @@ class Tooltip(Static, inherit_css=False): DEFAULT_CSS = """ Tooltip { layer: _tooltips; - margin: 1 2; + margin: 1 0; padding: 1 2; background: $background; width: auto; height: auto; - constrain: inflect; + constrain: inside inflect; max-width: 40; display: none; + offset-x: -50%; } """ DEFAULT_CLASSES = "-textual-system" 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/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) 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