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