From 149c39c86c083772d3249c8bbd5c1fa7923a8d55 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 May 2023 16:14:31 +0100 Subject: [PATCH] Tooltips (#2670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 2 + docs/examples/guide/widgets/tooltip01.py | 26 ++++ docs/examples/guide/widgets/tooltip02.py | 31 +++++ docs/guide/widgets.md | 52 ++++++++ src/textual/_compositor.py | 68 +++++++--- src/textual/_doc.py | 12 +- src/textual/_resolve.py | 30 ++++- src/textual/app.py | 4 + src/textual/css/constants.py | 2 +- src/textual/css/tokenize.py | 2 +- src/textual/geometry.py | 41 ++++++ src/textual/layouts/horizontal.py | 13 +- src/textual/layouts/vertical.py | 22 ++-- src/textual/screen.py | 83 ++++++++++++ src/textual/widget.py | 31 ++++- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_tooltip.py | 19 +++ .../__snapshots__/test_snapshots.ambr | 122 +++++++++--------- tests/test_geometry.py | 17 +++ 20 files changed, 478 insertions(+), 102 deletions(-) create mode 100644 docs/examples/guide/widgets/tooltip01.py create mode 100644 docs/examples/guide/widgets/tooltip02.py create mode 100644 src/textual/widgets/_tooltip.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a312372d4..e0da63d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/examples/guide/widgets/tooltip01.py b/docs/examples/guide/widgets/tooltip01.py new file mode 100644 index 000000000..45cae9f72 --- /dev/null +++ b/docs/examples/guide/widgets/tooltip01.py @@ -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() diff --git a/docs/examples/guide/widgets/tooltip02.py b/docs/examples/guide/widgets/tooltip02.py new file mode 100644 index 000000000..4de1125b2 --- /dev/null +++ b/docs/examples/guide/widgets/tooltip02.py @@ -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() diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index 9547eae60..6cc397512 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -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 diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bd2c75395..467b4c2c2 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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,15 +640,10 @@ 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( sub_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, diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 7a41b3288..8639f1d25 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -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() diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index ff8c792f6..eecd99dcf 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -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, diff --git a/src/textual/app.py b/src/textual/app.py index e9300f8c8..64836e6f5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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: diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 4cfa9580b..40df114f8 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -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) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index dea674404..12e7ed6e2 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -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_\-]+" diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 41ca459f6..9ad26a56a 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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. diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index a1c4eea4a..51d00884f 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -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) ) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index c74332535..539b373dc 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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 diff --git a/src/textual/screen.py b/src/textual/screen.py index bed2590c0..6414ccc12 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index b77f3dd7c..19393d38f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 3ed23b4c2..eb191f746 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -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", ] diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 86a3985a5..926a7be78 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -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 diff --git a/src/textual/widgets/_tooltip.py b/src/textual/widgets/_tooltip.py new file mode 100644 index 000000000..c00b57be6 --- /dev/null +++ b/src/textual/widgets/_tooltip.py @@ -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; + } + """ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index e1b1456d8..7d180becf 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -22750,137 +22750,137 @@ font-weight: 700; } - .terminal-2224040135-matrix { + .terminal-1736386763-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2224040135-title { + .terminal-1736386763-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2224040135-r1 { fill: #e1e1e1 } - .terminal-2224040135-r2 { fill: #c5c8c6 } - .terminal-2224040135-r3 { fill: #004578 } - .terminal-2224040135-r4 { fill: #23568b } - .terminal-2224040135-r5 { fill: #fea62b } - .terminal-2224040135-r6 { fill: #cc555a } - .terminal-2224040135-r7 { fill: #14191f } + .terminal-1736386763-r1 { fill: #e1e1e1 } + .terminal-1736386763-r2 { fill: #c5c8c6 } + .terminal-1736386763-r3 { fill: #004578 } + .terminal-1736386763-r4 { fill: #23568b } + .terminal-1736386763-r5 { fill: #fea62b } + .terminal-1736386763-r6 { fill: #cc555a } + .terminal-1736386763-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - - - - SPAM - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM▁▁ - SPAM▁▁ - ──────────────────────────────────────────────────────────────────────── - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - ▅▅ - ▄▄ - - - - - - - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM + + + + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────────── + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM▂▂ + SPAM + SPAM▁▁ + ──────────────────────────────────────────────────────────────────────── + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▅▅▅▅ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── + SPAM diff --git a/tests/test_geometry.py b/tests/test_geometry.py index b36845c39..fd437d7f9 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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)