mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
955 lines
31 KiB
Python
955 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import (
|
|
Any,
|
|
Awaitable,
|
|
TYPE_CHECKING,
|
|
Callable,
|
|
Iterable,
|
|
NamedTuple,
|
|
)
|
|
|
|
import rich.repr
|
|
from rich.align import Align
|
|
from rich.console import Console, RenderableType
|
|
from rich.measure import Measurement
|
|
from rich.padding import Padding
|
|
from rich.style import Style
|
|
from rich.styled import Styled
|
|
|
|
|
|
from . import errors
|
|
from . import events
|
|
from ._animator import BoundAnimator
|
|
from ._border import Border
|
|
from .box_model import BoxModel, get_box_model
|
|
from .color import Color
|
|
from ._context import active_app
|
|
from ._types import Lines
|
|
from .dom import DOMNode
|
|
from .geometry import clamp, Offset, Region, Size
|
|
from .layouts.vertical import VerticalLayout
|
|
from .message import Message
|
|
from . import messages
|
|
from ._layout import Layout
|
|
from .reactive import Reactive, watch
|
|
from .renderables.opacity import Opacity
|
|
from .renderables.tint import Tint
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from .app import App, ComposeResult
|
|
from .scrollbar import (
|
|
ScrollBar,
|
|
ScrollTo,
|
|
ScrollUp,
|
|
ScrollDown,
|
|
ScrollLeft,
|
|
ScrollRight,
|
|
)
|
|
|
|
|
|
class RenderCache(NamedTuple):
|
|
size: Size
|
|
lines: Lines
|
|
|
|
@property
|
|
def cursor_line(self) -> int | None:
|
|
for index, line in enumerate(self.lines):
|
|
for _text, style, _control in line:
|
|
if style and style._meta and style.meta.get("cursor", False):
|
|
return index
|
|
return None
|
|
|
|
|
|
@rich.repr.auto
|
|
class Widget(DOMNode):
|
|
|
|
can_focus: bool = False
|
|
can_focus_children: bool = True
|
|
|
|
CSS = """
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*children: Widget,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
) -> None:
|
|
|
|
self._size = Size(0, 0)
|
|
self._virtual_size = Size(0, 0)
|
|
self._container_size = Size(0, 0)
|
|
self._layout_required = False
|
|
self._repaint_required = False
|
|
self._default_layout = VerticalLayout()
|
|
self._animate: BoundAnimator | None = None
|
|
self._reactive_watches: dict[str, Callable] = {}
|
|
self.highlight_style: Style | None = None
|
|
|
|
self._vertical_scrollbar: ScrollBar | None = None
|
|
self._horizontal_scrollbar: ScrollBar | None = None
|
|
|
|
self._render_cache = RenderCache(Size(0, 0), [])
|
|
self._dirty_regions: list[Region] = []
|
|
|
|
super().__init__(name=name, id=id, classes=classes)
|
|
self.add_children(*children)
|
|
|
|
auto_width = Reactive(True)
|
|
auto_height = Reactive(True)
|
|
has_focus = Reactive(False)
|
|
descendant_has_focus = Reactive(False)
|
|
mouse_over = Reactive(False)
|
|
scroll_x = Reactive(0.0, repaint=False)
|
|
scroll_y = Reactive(0.0, repaint=False)
|
|
scroll_target_x = Reactive(0.0, repaint=False)
|
|
scroll_target_y = Reactive(0.0, repaint=False)
|
|
show_vertical_scrollbar = Reactive(False, layout=True)
|
|
show_horizontal_scrollbar = Reactive(False, layout=True)
|
|
|
|
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
|
self.app.register(self, *anon_widgets, **widgets)
|
|
self.screen.refresh()
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Yield child widgets for a container."""
|
|
return
|
|
yield
|
|
|
|
def on_register(self, app: App) -> None:
|
|
"""Called when the instance is registered.
|
|
|
|
Args:
|
|
app (App): App instance.
|
|
"""
|
|
# Parser the Widget's CSS
|
|
self.app.stylesheet.add_source(
|
|
self.CSS, f"{__file__}:<{self.__class__.__name__}>"
|
|
)
|
|
|
|
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
|
"""Process the box model for this widget.
|
|
|
|
Args:
|
|
container (Size): The size of the container widget (with a layout)
|
|
viewport (Size): The viewport size.
|
|
|
|
Returns:
|
|
BoxModel: The size and margin for this widget.
|
|
"""
|
|
box_model = get_box_model(
|
|
self.styles,
|
|
container,
|
|
viewport,
|
|
self.get_content_width,
|
|
self.get_content_height,
|
|
)
|
|
return box_model
|
|
|
|
def get_content_width(self, container_size: Size, viewport_size: Size) -> int:
|
|
"""Gets the width of the content area.
|
|
|
|
Args:
|
|
container_size (Size): Size of the container (immediate parent) widget.
|
|
viewport_size (Size): Size of the viewport.
|
|
|
|
Returns:
|
|
int: The optimal width of the content.
|
|
"""
|
|
console = self.app.console
|
|
renderable = self.render()
|
|
measurement = Measurement.get(console, console.options, renderable)
|
|
return measurement.maximum
|
|
|
|
def get_content_height(
|
|
self, container_size: Size, viewport_size: Size, width: int
|
|
) -> int:
|
|
"""Gets the height (number of lines) in the content area.
|
|
|
|
Args:
|
|
container_size (Size): Size of the container (immediate parent) widget.
|
|
viewport_size (Size): Size of the viewport.
|
|
width (int): Width of renderable.
|
|
|
|
Returns:
|
|
int: The height of the content.
|
|
"""
|
|
renderable = self.render()
|
|
options = self.console.options.update_width(width)
|
|
segments = self.console.render(renderable, options)
|
|
# Cheaper than counting the lines returned from render_lines!
|
|
height = sum(text.count("\n") for text, _, _ in segments)
|
|
return height
|
|
|
|
async def watch_scroll_x(self, new_value: float) -> None:
|
|
self.horizontal_scrollbar.position = int(new_value)
|
|
|
|
async def watch_scroll_y(self, new_value: float) -> None:
|
|
self.vertical_scrollbar.position = int(new_value)
|
|
|
|
def validate_scroll_x(self, value: float) -> float:
|
|
return clamp(value, 0, self.max_scroll_x)
|
|
|
|
def validate_scroll_target_x(self, value: float) -> float:
|
|
return clamp(value, 0, self.max_scroll_x)
|
|
|
|
def validate_scroll_y(self, value: float) -> float:
|
|
return clamp(value, 0, self.max_scroll_y)
|
|
|
|
def validate_scroll_target_y(self, value: float) -> float:
|
|
return clamp(value, 0, self.max_scroll_y)
|
|
|
|
@property
|
|
def max_scroll_x(self) -> float:
|
|
return max(0, self.virtual_size.width - self.container_size.width)
|
|
|
|
@property
|
|
def max_scroll_y(self) -> float:
|
|
return max(0, self.virtual_size.height - self.container_size.height)
|
|
|
|
@property
|
|
def vertical_scrollbar(self) -> ScrollBar:
|
|
"""Get a vertical scrollbar (create if necessary)
|
|
|
|
Returns:
|
|
ScrollBar: ScrollBar Widget.
|
|
"""
|
|
from .scrollbar import ScrollBar
|
|
|
|
if self._vertical_scrollbar is not None:
|
|
return self._vertical_scrollbar
|
|
self._vertical_scrollbar = scroll_bar = ScrollBar(
|
|
vertical=True, name="vertical"
|
|
)
|
|
self.app.start_widget(self, scroll_bar)
|
|
return scroll_bar
|
|
|
|
@property
|
|
def horizontal_scrollbar(self) -> ScrollBar:
|
|
"""Get a vertical scrollbar (create if necessary)
|
|
|
|
Returns:
|
|
ScrollBar: ScrollBar Widget.
|
|
"""
|
|
from .scrollbar import ScrollBar
|
|
|
|
if self._horizontal_scrollbar is not None:
|
|
return self._horizontal_scrollbar
|
|
self._horizontal_scrollbar = scroll_bar = ScrollBar(
|
|
vertical=False, name="horizontal"
|
|
)
|
|
|
|
self.app.start_widget(self, scroll_bar)
|
|
return scroll_bar
|
|
|
|
def _refresh_scrollbars(self) -> None:
|
|
"""Refresh scrollbar visibility."""
|
|
if not self.is_container:
|
|
return
|
|
|
|
styles = self.styles
|
|
overflow_x = styles.overflow_x
|
|
overflow_y = styles.overflow_y
|
|
width, height = self.container_size
|
|
|
|
show_horizontal = self.show_horizontal_scrollbar
|
|
if overflow_x == "hidden":
|
|
show_horizontal = False
|
|
if overflow_x == "scroll":
|
|
show_horizontal = True
|
|
elif overflow_x == "auto":
|
|
show_horizontal = self.virtual_size.width > width
|
|
|
|
show_vertical = self.show_vertical_scrollbar
|
|
if overflow_y == "hidden":
|
|
show_vertical = False
|
|
elif overflow_y == "scroll":
|
|
show_vertical = True
|
|
elif overflow_y == "auto":
|
|
show_vertical = self.virtual_size.height > height
|
|
|
|
self.show_horizontal_scrollbar = show_horizontal
|
|
self.show_vertical_scrollbar = show_vertical
|
|
self.horizontal_scrollbar.display = show_horizontal
|
|
self.vertical_scrollbar.display = show_vertical
|
|
|
|
@property
|
|
def scrollbars_enabled(self) -> tuple[bool, bool]:
|
|
"""A tuple of booleans that indicate if scrollbars are enabled.
|
|
|
|
Returns:
|
|
tuple[bool, bool]: A tuple of (<vertical scrollbar enabled>, <horizontal scrollbar enabled>)
|
|
|
|
"""
|
|
if self.layout is None:
|
|
return False, False
|
|
|
|
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
|
return enabled
|
|
|
|
def set_dirty(self) -> None:
|
|
"""Set the Widget as 'dirty' (requiring re-render)."""
|
|
self._dirty_regions.clear()
|
|
self._dirty_regions.append(self.size.region)
|
|
|
|
def set_clean(self) -> None:
|
|
"""Set the widget as clean."""
|
|
self._dirty_regions.clear()
|
|
|
|
def scroll_to(
|
|
self,
|
|
x: float | None = None,
|
|
y: float | None = None,
|
|
*,
|
|
animate: bool = True,
|
|
speed: float | None = None,
|
|
duration: float | None = None,
|
|
) -> bool:
|
|
"""Scroll to a given (absolute) coordinate, optionally animating.
|
|
|
|
Args:
|
|
x (int | None, optional): X coordinate (column) to scroll to, or ``None`` for no change. Defaults to None.
|
|
y (int | None, optional): Y coordinate (row) to scroll to, or ``None`` for no change. Defaults to None.
|
|
animate (bool, optional): Animate to new scroll position. Defaults to False.
|
|
|
|
Returns:
|
|
bool: True if the scroll position changed, otherwise False.
|
|
"""
|
|
scrolled_x = scrolled_y = False
|
|
|
|
if animate:
|
|
# TODO: configure animation speed
|
|
if x is not None:
|
|
self.scroll_target_x = x
|
|
if x != self.scroll_x:
|
|
self.animate(
|
|
"scroll_x",
|
|
self.scroll_target_x,
|
|
speed=speed,
|
|
duration=duration,
|
|
easing="out_cubic",
|
|
)
|
|
scrolled_x = True
|
|
if y is not None:
|
|
self.scroll_target_y = y
|
|
if y != self.scroll_y:
|
|
self.animate(
|
|
"scroll_y",
|
|
self.scroll_target_y,
|
|
speed=speed,
|
|
duration=duration,
|
|
easing="out_cubic",
|
|
)
|
|
scrolled_y = True
|
|
|
|
else:
|
|
if x is not None:
|
|
scroll_x = self.scroll_x
|
|
self.scroll_target_x = self.scroll_x = x
|
|
scrolled_x = scroll_x != self.scroll_x
|
|
if y is not None:
|
|
scroll_y = self.scroll_y
|
|
self.scroll_target_y = self.scroll_y = y
|
|
scrolled_y = scroll_y != self.scroll_y
|
|
if scrolled_x or scrolled_y:
|
|
self.refresh(repaint=False, layout=True)
|
|
|
|
return scrolled_x or scrolled_y
|
|
|
|
def scroll_relative(
|
|
self,
|
|
x: float | None = None,
|
|
y: float | None = None,
|
|
*,
|
|
animate: bool = True,
|
|
speed: float | None = None,
|
|
duration: float | None = None,
|
|
) -> bool:
|
|
"""Scroll relative to current position.
|
|
|
|
Args:
|
|
x (int | None, optional): X distance (columns) to scroll, or ``None`` for no change. Defaults to None.
|
|
y (int | None, optional): Y distance (rows) to scroll, or ``None`` for no change. Defaults to None.
|
|
animate (bool, optional): Animate to new scroll position. Defaults to False.
|
|
|
|
Returns:
|
|
bool: True if the scroll position changed, otherwise False.
|
|
"""
|
|
return self.scroll_to(
|
|
None if x is None else (self.scroll_x + x),
|
|
None if y is None else (self.scroll_y + y),
|
|
animate=animate,
|
|
speed=speed,
|
|
duration=duration,
|
|
)
|
|
|
|
def scroll_home(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(0, 0, animate=animate)
|
|
|
|
def scroll_end(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(0, self.max_scroll_y, animate=animate)
|
|
|
|
def scroll_left(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(x=self.scroll_target_x - 1, animate=animate)
|
|
|
|
def scroll_right(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(x=self.scroll_target_x + 1, animate=animate)
|
|
|
|
def scroll_up(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(y=self.scroll_target_y + 1, animate=animate)
|
|
|
|
def scroll_down(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(y=self.scroll_target_y - 1, animate=animate)
|
|
|
|
def scroll_page_up(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(
|
|
y=self.scroll_target_y - self.container_size.height, animate=animate
|
|
)
|
|
|
|
def scroll_page_down(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(
|
|
y=self.scroll_target_y + self.container_size.height, animate=animate
|
|
)
|
|
|
|
def scroll_page_left(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(
|
|
x=self.scroll_target_x - self.container_size.width, animate=animate
|
|
)
|
|
|
|
def scroll_page_right(self, *, animate: bool = True) -> bool:
|
|
return self.scroll_to(
|
|
x=self.scroll_target_x + self.container_size.width, animate=animate
|
|
)
|
|
|
|
def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool:
|
|
"""Scroll so that a child widget is in the visible area.
|
|
|
|
Args:
|
|
widget (Widget): A Widget in the children.
|
|
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
|
|
|
Returns:
|
|
bool: True if the scroll position changed, otherwise False.
|
|
"""
|
|
screen = self.screen
|
|
try:
|
|
widget_geometry = screen.find_widget(widget)
|
|
container_geometry = screen.find_widget(self)
|
|
except errors.NoWidget:
|
|
return False
|
|
|
|
widget_region = widget.content_region + widget_geometry.region.origin
|
|
container_region = self.content_region + container_geometry.region.origin
|
|
|
|
if widget_region in container_region:
|
|
# Widget is visible, nothing to do
|
|
return False
|
|
|
|
# We can either scroll so the widget is at the top of the container, or so that
|
|
# it is at the bottom. We want to pick which has the shortest distance
|
|
top_delta = widget_region.origin - container_region.origin
|
|
bottom_delta = widget_region.origin - (
|
|
container_region.origin
|
|
+ Offset(0, container_region.height - widget_region.height)
|
|
)
|
|
|
|
delta_x = min(top_delta.x, bottom_delta.x, key=abs)
|
|
delta_y = min(top_delta.y, bottom_delta.y, key=abs)
|
|
return self.scroll_relative(
|
|
delta_x or None, delta_y or None, animate=animate, duration=0.2
|
|
)
|
|
|
|
def __init_subclass__(
|
|
cls, can_focus: bool = True, can_focus_children: bool = True
|
|
) -> None:
|
|
super().__init_subclass__()
|
|
cls.can_focus = can_focus
|
|
cls.can_focus_children = can_focus_children
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
yield "id", self.id, None
|
|
if self.name:
|
|
yield "name", self.name
|
|
if self.classes:
|
|
yield "classes", set(self.classes)
|
|
pseudo_classes = self.pseudo_classes
|
|
if pseudo_classes:
|
|
yield "pseudo_classes", set(pseudo_classes)
|
|
|
|
def _arrange_container(self, region: Region) -> Region:
|
|
"""Adjusts the Widget region to accommodate scrollbars.
|
|
|
|
Args:
|
|
region (Region): A region for the widget.
|
|
|
|
Returns:
|
|
Region: The widget region minus scrollbars.
|
|
"""
|
|
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
|
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
|
(region, _, _, _) = region.split(-1, -1)
|
|
elif show_vertical_scrollbar:
|
|
region, _ = region.split_vertical(-1)
|
|
elif show_horizontal_scrollbar:
|
|
region, _ = region.split_horizontal(-1)
|
|
return region
|
|
|
|
def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, Region]]:
|
|
"""Arrange the 'chrome' widgets (typically scrollbars) for a layout element.
|
|
|
|
Args:
|
|
size (Size): _description_
|
|
|
|
Returns:
|
|
Iterable[tuple[Widget, Region]]: _description_
|
|
|
|
Yields:
|
|
Iterator[Iterable[tuple[Widget, Region]]]: _description_
|
|
"""
|
|
region = size.region
|
|
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
|
|
|
if show_horizontal_scrollbar and show_vertical_scrollbar:
|
|
(
|
|
region,
|
|
vertical_scrollbar_region,
|
|
horizontal_scrollbar_region,
|
|
_,
|
|
) = region.split(-1, -1)
|
|
if vertical_scrollbar_region:
|
|
yield self.vertical_scrollbar, vertical_scrollbar_region
|
|
if horizontal_scrollbar_region:
|
|
yield self.horizontal_scrollbar, horizontal_scrollbar_region
|
|
elif show_vertical_scrollbar:
|
|
region, scrollbar_region = region.split_vertical(-1)
|
|
if scrollbar_region:
|
|
yield self.vertical_scrollbar, scrollbar_region
|
|
elif show_horizontal_scrollbar:
|
|
region, scrollbar_region = region.split_horizontal(-1)
|
|
if scrollbar_region:
|
|
yield self.horizontal_scrollbar, scrollbar_region
|
|
|
|
def get_pseudo_classes(self) -> Iterable[str]:
|
|
"""Pseudo classes for a widget"""
|
|
if self.mouse_over:
|
|
yield "hover"
|
|
if self.has_focus:
|
|
yield "focus"
|
|
if self.descendant_has_focus:
|
|
yield "focus-within"
|
|
|
|
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
|
|
watch(self, attribute_name, callback)
|
|
|
|
def render_styled(self) -> RenderableType:
|
|
"""Applies style attributes to the default renderable.
|
|
|
|
Returns:
|
|
RenderableType: A new renderable.
|
|
"""
|
|
|
|
renderable = self.render()
|
|
|
|
styles = self.styles
|
|
parent_styles = self.parent.styles
|
|
|
|
parent_text_style = self.parent.rich_text_style
|
|
text_style = styles.rich_style
|
|
|
|
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
|
|
if content_align != ("left", "top"):
|
|
horizontal, vertical = content_align
|
|
renderable = Align(renderable, horizontal, vertical=vertical)
|
|
|
|
renderable_text_style = parent_text_style + text_style
|
|
if renderable_text_style:
|
|
renderable = Styled(renderable, renderable_text_style)
|
|
|
|
renderable = Padding(renderable, styles.padding, style=renderable_text_style)
|
|
|
|
if styles.border:
|
|
renderable = Border(
|
|
renderable,
|
|
styles.border,
|
|
inner_color=styles.background,
|
|
outer_color=Color.from_rich_color(parent_text_style.bgcolor),
|
|
)
|
|
|
|
if styles.outline:
|
|
renderable = Border(
|
|
renderable,
|
|
styles.outline,
|
|
inner_color=styles.background,
|
|
outer_color=parent_styles.background,
|
|
outline=True,
|
|
)
|
|
|
|
if styles.tint.a != 0:
|
|
renderable = Tint(renderable, styles.tint)
|
|
if styles.opacity != 1.0:
|
|
renderable = Opacity(renderable, opacity=styles.opacity)
|
|
|
|
return renderable
|
|
|
|
@property
|
|
def size(self) -> Size:
|
|
return self._size
|
|
|
|
@property
|
|
def container_size(self) -> Size:
|
|
return self._container_size
|
|
|
|
@property
|
|
def content_region(self) -> Region:
|
|
"""A region relative to the Widget origin that contains the content."""
|
|
x, y = self.styles.content_gutter.top_left
|
|
width, height = self._container_size
|
|
return Region(x, y, width, height)
|
|
|
|
@property
|
|
def content_offset(self) -> Offset:
|
|
"""An offset from the Widget origin where the content begins."""
|
|
x, y = self.styles.content_gutter.top_left
|
|
return Offset(x, y)
|
|
|
|
@property
|
|
def virtual_size(self) -> Size:
|
|
return self._virtual_size
|
|
|
|
@property
|
|
def region(self) -> Region:
|
|
"""The region occupied by this widget, relative to the Screen."""
|
|
try:
|
|
return self.screen.find_widget(self).region
|
|
except errors.NoWidget:
|
|
return Region()
|
|
|
|
@property
|
|
def scroll_offset(self) -> Offset:
|
|
return Offset(int(self.scroll_x), int(self.scroll_y))
|
|
|
|
@property
|
|
def is_transparent(self) -> bool:
|
|
"""Check if the background styles is not set.
|
|
|
|
Returns:
|
|
bool: ``True`` if there is background color, otherwise ``False``.
|
|
"""
|
|
return self.is_container and self.styles.background.is_transparent
|
|
|
|
@property
|
|
def console(self) -> Console:
|
|
"""Get the current console."""
|
|
return active_app.get().console
|
|
|
|
@property
|
|
def animate(self) -> BoundAnimator:
|
|
if self._animate is None:
|
|
self._animate = self.app.animator.bind(self)
|
|
assert self._animate is not None
|
|
return self._animate
|
|
|
|
@property
|
|
def layout(self) -> Layout | None:
|
|
return self.styles.layout or (
|
|
# If we have children we _should_ return a layout, otherwise they won't be displayed:
|
|
self._default_layout
|
|
if self.children
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def is_container(self) -> bool:
|
|
"""Check if this widget is a container (contains other widgets)
|
|
|
|
Returns:
|
|
bool: True if this widget is a container.
|
|
"""
|
|
return self.styles.layout is not None or bool(self.children)
|
|
|
|
def watch_mouse_over(self, value: bool) -> None:
|
|
"""Update from CSS if mouse over state changes."""
|
|
self.app.update_styles()
|
|
|
|
def watch_has_focus(self, value: bool) -> None:
|
|
"""Update from CSS if has focus state changes."""
|
|
self.app.update_styles()
|
|
|
|
def on_style_change(self) -> None:
|
|
self.set_dirty()
|
|
self.check_idle()
|
|
|
|
def size_updated(
|
|
self, size: Size, virtual_size: Size, container_size: Size
|
|
) -> None:
|
|
if self._size != size or self._virtual_size != virtual_size:
|
|
self._size = size
|
|
self._virtual_size = virtual_size
|
|
self._container_size = container_size
|
|
|
|
if self.is_container:
|
|
self._refresh_scrollbars()
|
|
width, height = self.container_size
|
|
if self.show_vertical_scrollbar:
|
|
self.vertical_scrollbar.window_virtual_size = virtual_size.height
|
|
self.vertical_scrollbar.window_size = height
|
|
if self.show_horizontal_scrollbar:
|
|
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
|
|
self.horizontal_scrollbar.window_size = width
|
|
|
|
self.refresh(layout=True)
|
|
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
|
|
else:
|
|
print("size updated refresh")
|
|
self.refresh()
|
|
|
|
def _render_lines(self) -> None:
|
|
"""Render all lines."""
|
|
width, height = self.size
|
|
renderable = self.render_styled()
|
|
options = self.console.options.update_dimensions(width, height).update(
|
|
highlight=False
|
|
)
|
|
lines = self.console.render_lines(renderable, options)
|
|
self._render_cache = RenderCache(self.size, lines)
|
|
self._dirty_regions.clear()
|
|
|
|
def get_render_lines(
|
|
self, start: int | None = None, end: int | None = None
|
|
) -> Lines:
|
|
"""Get segment lines to render the widget.
|
|
|
|
Args:
|
|
start (int | None, optional): line start index, or None for first line. Defaults to None.
|
|
end (int | None, optional): line end index, or None for last line. Defaults to None.
|
|
|
|
Returns:
|
|
Lines: A list of lists of segments.
|
|
"""
|
|
if self._dirty_regions:
|
|
self._render_lines()
|
|
if self.is_container:
|
|
if self.show_horizontal_scrollbar:
|
|
self.horizontal_scrollbar.refresh()
|
|
if self.show_vertical_scrollbar:
|
|
self.vertical_scrollbar.refresh()
|
|
lines = self._render_cache.lines[start:end]
|
|
return lines
|
|
|
|
def check_layout(self) -> bool:
|
|
"""Check if a layout has been requested."""
|
|
return self._layout_required
|
|
|
|
def _reset_check_layout(self) -> None:
|
|
self._layout_required = False
|
|
|
|
def get_style_at(self, x: int, y: int) -> Style:
|
|
offset_x, offset_y = self.screen.get_offset(self)
|
|
return self.screen.get_style_at(x + offset_x, y + offset_y)
|
|
|
|
def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
|
self.app.call_later(callback, *args, **kwargs)
|
|
|
|
async def forward_event(self, event: events.Event) -> None:
|
|
event.set_forwarded()
|
|
await self.post_message(event)
|
|
|
|
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
|
|
"""Initiate a refresh of the widget.
|
|
|
|
This method sets an internal flag to perform a refresh, which will be done on the
|
|
next idle event. Only one refresh will be done even if this method is called multiple times.
|
|
|
|
Args:
|
|
repaint (bool, optional): Repaint the widget (will call render() again). Defaults to True.
|
|
layout (bool, optional): Also layout widgets in the view. Defaults to False.
|
|
"""
|
|
if layout:
|
|
self._layout_required = True
|
|
if repaint:
|
|
self.set_dirty()
|
|
self._repaint_required = True
|
|
self.check_idle()
|
|
|
|
def render(self) -> RenderableType:
|
|
"""Get renderable for widget.
|
|
|
|
Returns:
|
|
RenderableType: Any renderable
|
|
"""
|
|
return "" if self.is_container else self.css_identifier_styled
|
|
|
|
async def action(self, action: str, *params) -> None:
|
|
await self.app.action(action, self)
|
|
|
|
async def post_message(self, message: Message) -> bool:
|
|
if not self.check_message_enabled(message):
|
|
return True
|
|
if not self.is_running:
|
|
self.log(self, f"IS NOT RUNNING, {message!r} not sent")
|
|
return await super().post_message(message)
|
|
|
|
def on_idle(self, event: events.Idle) -> None:
|
|
"""Called when there are no more events on the queue.
|
|
|
|
Args:
|
|
event (events.Idle): Idle event.
|
|
"""
|
|
|
|
if self.check_layout():
|
|
self._reset_check_layout()
|
|
self.screen.post_message_no_wait(messages.Layout(self))
|
|
elif self._repaint_required:
|
|
self.emit_no_wait(messages.Update(self, self))
|
|
self._repaint_required = False
|
|
|
|
def focus(self) -> None:
|
|
"""Give input focus to this widget."""
|
|
self.app.set_focus(self)
|
|
|
|
async def capture_mouse(self, capture: bool = True) -> None:
|
|
"""Capture (or release) the mouse.
|
|
|
|
When captured, all mouse coordinates will go to this widget even when the pointer is not directly over the widget.
|
|
|
|
Args:
|
|
capture (bool, optional): True to capture or False to release. Defaults to True.
|
|
"""
|
|
await self.app.capture_mouse(self if capture else None)
|
|
|
|
async def release_mouse(self) -> None:
|
|
"""Release the mouse.
|
|
|
|
Mouse events will only be sent when the mouse is over the widget.
|
|
"""
|
|
await self.app.capture_mouse(None)
|
|
|
|
async def broker_event(self, event_name: str, event: events.Event) -> bool:
|
|
return await self.app.broker_event(event_name, event, default_namespace=self)
|
|
|
|
async def on_mouse_down(self, event: events.MouseUp) -> None:
|
|
await self.broker_event("mouse.down", event)
|
|
|
|
async def on_mouse_up(self, event: events.MouseUp) -> None:
|
|
await self.broker_event("mouse.up", event)
|
|
|
|
async def on_click(self, event: events.Click) -> None:
|
|
await self.broker_event("click", event)
|
|
|
|
async def on_key(self, event: events.Key) -> None:
|
|
await self.dispatch_key(event)
|
|
|
|
def on_mount(self, event: events.Mount) -> None:
|
|
widgets = list(self.compose())
|
|
if widgets:
|
|
self.mount(*widgets)
|
|
self.screen.refresh()
|
|
|
|
def on_leave(self) -> None:
|
|
self.mouse_over = False
|
|
|
|
def on_enter(self) -> None:
|
|
self.mouse_over = True
|
|
|
|
def on_focus(self, event: events.Focus) -> None:
|
|
self.emit_no_wait(events.DescendantFocus(self))
|
|
self.has_focus = True
|
|
|
|
def on_blur(self, event: events.Blur) -> None:
|
|
self.emit_no_wait(events.DescendantBlur(self))
|
|
self.has_focus = False
|
|
|
|
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
|
|
self.descendant_has_focus = True
|
|
if self.is_container and isinstance(event.sender, Widget):
|
|
self.scroll_to_widget(event.sender, animate=True)
|
|
|
|
def on_descendant_blur(self, event: events.DescendantBlur) -> None:
|
|
self.descendant_has_focus = False
|
|
|
|
def on_mouse_scroll_down(self, event) -> None:
|
|
if self.is_container:
|
|
self.scroll_down(animate=False)
|
|
event.stop()
|
|
|
|
def on_mouse_scroll_up(self, event) -> None:
|
|
if self.is_container:
|
|
self.scroll_up(animate=False)
|
|
event.stop()
|
|
|
|
def handle_scroll_to(self, message: ScrollTo) -> None:
|
|
if self.is_container:
|
|
self.scroll_to(message.x, message.y, animate=message.animate)
|
|
message.stop()
|
|
|
|
def handle_scroll_up(self, event: ScrollUp) -> None:
|
|
if self.is_container:
|
|
self.scroll_page_up()
|
|
event.stop()
|
|
|
|
def handle_scroll_down(self, event: ScrollDown) -> None:
|
|
if self.is_container:
|
|
self.scroll_page_down()
|
|
event.stop()
|
|
|
|
def handle_scroll_left(self, event: ScrollLeft) -> None:
|
|
if self.is_container:
|
|
self.scroll_page_left()
|
|
event.stop()
|
|
|
|
def handle_scroll_right(self, event: ScrollRight) -> None:
|
|
if self.is_container:
|
|
self.scroll_page_right()
|
|
event.stop()
|
|
|
|
def key_home(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_home()
|
|
return True
|
|
return False
|
|
|
|
def key_end(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_end()
|
|
return True
|
|
return False
|
|
|
|
def key_left(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_left()
|
|
return True
|
|
return False
|
|
|
|
def key_right(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_right()
|
|
return True
|
|
return False
|
|
|
|
def key_down(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_up()
|
|
return True
|
|
return False
|
|
|
|
def key_up(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_down()
|
|
return True
|
|
return False
|
|
|
|
def key_pagedown(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_page_down()
|
|
return True
|
|
return False
|
|
|
|
def key_pageup(self) -> bool:
|
|
if self.is_container:
|
|
self.scroll_page_up()
|
|
return True
|
|
return False
|