* inflect

* diagram

* tooltip render

* tooltip property

* add guard

* tooltip docs

* docs

* tidy, fix horizontal

* words, removed comment

* fix screenshot render

* simplify

* simfplify

* changelog

* simplify optimize

* inflect tests

* Apply suggestions from code review

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* docstring

* disable auto focus

* should be fraction

* optimization

* snapshot update

* Update tests/snapshot_tests/snapshot_apps/scroll_to_center.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-05-30 16:14:31 +01:00
committed by GitHub
parent 83c83de78b
commit 149c39c86c
20 changed files with 478 additions and 102 deletions

View File

@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 - `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 - 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 - `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 - `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 - `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 - `Input` has a new component class `input--suggestion` https://github.com/Textualize/textual/pull/2604

View File

@@ -0,0 +1,26 @@
from textual.app import App, ComposeResult
from textual.widgets import Button
TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear."""
class TooltipApp(App):
CSS = """
Screen {
align: center middle;
}
"""
def compose(self) -> ComposeResult:
yield Button("Click me", variant="success")
def on_mount(self) -> None:
self.query_one(Button).tooltip = TEXT
if __name__ == "__main__":
app = TooltipApp()
app.run()

View File

@@ -0,0 +1,31 @@
from textual.app import App, ComposeResult
from textual.widgets import Button
TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear."""
class TooltipApp(App):
CSS = """
Screen {
align: center middle;
}
Tooltip {
padding: 2 4;
background: $primary;
color: auto 90%;
}
"""
def compose(self) -> ComposeResult:
yield Button("Click me", variant="success")
def on_mount(self) -> None:
self.query_one(Button).tooltip = TEXT
if __name__ == "__main__":
app = TooltipApp()
app.run()

View File

@@ -193,6 +193,58 @@ Let's modify the default width for the fizzbuzz example. By default, the table w
Note that we've added `expand=True` to tell the `Table` to expand beyond the optimal width, so that it fills the 50 characters returned by `get_content_width`. 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 ## Line API

View File

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

View File

@@ -33,11 +33,13 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
try: try:
rows = int(attrs.get("lines", 24)) rows = int(attrs.get("lines", 24))
columns = int(attrs.get("columns", 80)) columns = int(attrs.get("columns", 80))
hover = attrs.get("hover", "")
svg = take_svg_screenshot( svg = take_svg_screenshot(
None, None,
path, path,
press, press,
title, hover=hover,
title=title,
terminal_size=(columns, rows), terminal_size=(columns, rows),
wait_for_animation=False, wait_for_animation=False,
) )
@@ -58,6 +60,7 @@ def take_svg_screenshot(
app: App | None = None, app: App | None = None,
app_path: str | None = None, app_path: str | None = None,
press: Iterable[str] = (), press: Iterable[str] = (),
hover: str = "",
title: str | None = None, title: str | None = None,
terminal_size: tuple[int, int] = (80, 24), terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None, 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: 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. 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. 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. title: The terminal title in the output image.
terminal_size: A pair of integers (rows, columns), representing terminal size. terminal_size: A pair of integers (rows, columns), representing terminal size.
run_before: An arbitrary callable that runs arbitrary code before taking the 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 assert path is not None
with open(path, "rb") as source_file: with open(path, "rb") as source_file:
hash.update(source_file.read()) 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" cache_key = f"{hash.hexdigest()}.svg"
return cache_key return cache_key
@@ -115,7 +119,11 @@ def take_svg_screenshot(
result = run_before(pilot) result = run_before(pilot)
if inspect.isawaitable(result): if inspect.isawaitable(result):
await result await result
await pilot.pause()
await pilot.press(*press) await pilot.press(*press)
if hover:
await pilot.hover(hover)
await pilot.pause(0.5)
if wait_for_animation: if wait_for_animation:
await pilot.wait_for_scheduled_animations() await pilot.wait_for_scheduled_animations()
await pilot.pause() await pilot.pause()

View File

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

View File

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

View File

@@ -70,6 +70,6 @@ VALID_PSEUDO_CLASSES: Final = {
"hover", "hover",
} }
VALID_OVERLAY: Final = {"none", "screen"} 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) NULL_SPACING: Final = Spacing.all(0)

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,9 @@ class VerticalLayout(Layout):
add_placement = placements.append add_placement = placements.append
child_styles = [child.styles for child in children] 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: if box_margins:
resolve_margin = Size( resolve_margin = Size(
max( max(
@@ -60,7 +62,14 @@ class VerticalLayout(Layout):
if box_models: if box_models:
margins.append(box_models[-1].margin.bottom) 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 _Region = Region
_WidgetPlacement = WidgetPlacement _WidgetPlacement = WidgetPlacement
@@ -74,14 +83,7 @@ class VerticalLayout(Layout):
box_margin.left, int(y), int(content_width), int(next_y) - int(y) box_margin.left, int(y), int(content_width), int(next_y) - int(y)
) )
add_placement( add_placement(
_WidgetPlacement( _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
region,
box_model.margin,
widget,
0,
False,
overlay,
)
) )
if not overlay: if not overlay:
y = next_y + margin y = next_y + margin

View File

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

View File

@@ -4,7 +4,7 @@ The base class for widgets.
from __future__ import annotations from __future__ import annotations
from asyncio import Lock, wait from asyncio import wait
from collections import Counter from collections import Counter
from fractions import Fraction from fractions import Fraction
from itertools import islice from itertools import islice
@@ -325,7 +325,10 @@ class Widget(DOMNode):
self._stabilize_scrollbar: tuple[Size, str, str] | None = None self._stabilize_scrollbar: tuple[Size, str, str] | None = None
"""Used to prevent scrollbar logic getting stuck in an infinite loop.""" """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__( super().__init__(
name=name, name=name,
@@ -367,7 +370,7 @@ class Widget(DOMNode):
"""Show a horizontal scrollbar?""" """Show a horizontal scrollbar?"""
show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True) 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 border_title: str | Text | None = _BorderTitle() # type: ignore
"""A title to show in the top border (if there is one).""" """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: def offset(self, offset: tuple[int, int]) -> None:
self.styles.offset = ScalarOffset.from_offset(offset) 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: def __enter__(self) -> Self:
"""Use as context manager when composing.""" """Use as context manager when composing."""
self.app._compose_stacks[-1].append(self) self.app._compose_stacks[-1].append(self)
@@ -1585,6 +1601,7 @@ class Widget(DOMNode):
break break
if node.styles.has_rule("layers"): if node.styles.has_rule("layers"):
layers = node.styles.layers layers = node.styles.layers
return layers return layers
@property @property
@@ -3112,8 +3129,16 @@ class Widget(DOMNode):
except Exception: except Exception:
self.app.panic(Traceback()) self.app.panic(Traceback())
else: else:
self._extend_compose(widgets)
await self.mount(*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: def _on_mount(self, event: events.Mount) -> None:
if self.styles.overflow_y == "scroll": if self.styles.overflow_y == "scroll":
self.show_vertical_scrollbar = True self.show_vertical_scrollbar = True

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from textual.widgets import Static
class Tooltip(Static):
DEFAULT_CSS = """
Tooltip {
layer: _tooltips;
margin: 1 2;
padding: 1 2;
background: $panel;
width: auto;
height: auto;
constrain: inflect;
max-width: 40;
display: none;
}
"""

File diff suppressed because one or more lines are too long

View File

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