""" Contains the widgets that manage Textual scrollbars. !!! note You will not typically need this for most apps. """ from __future__ import annotations from math import ceil from typing import ClassVar, Type import rich.repr from rich.color import Color from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment, Segments from rich.style import Style, StyleType from textual import events from textual.geometry import Offset from textual.message import Message from textual.reactive import Reactive from textual.renderables.blank import Blank from textual.widget import Widget class ScrollMessage(Message, bubble=False): """Base class for all scrollbar messages.""" @rich.repr.auto class ScrollUp(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto class ScrollDown(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" @rich.repr.auto class ScrollLeft(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto class ScrollRight(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" class ScrollTo(ScrollMessage, verbose=True): """Message sent when click and dragging handle.""" __slots__ = ["x", "y", "animate"] def __init__( self, x: float | None = None, y: float | None = None, animate: bool = True, ) -> None: self.x = x self.y = y self.animate = animate super().__init__() def __rich_repr__(self) -> rich.repr.Result: yield "x", self.x, None yield "y", self.y, None yield "animate", self.animate, True class ScrollBarRender: VERTICAL_BARS: ClassVar[list[str]] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", " "] """Glyphs used for vertical scrollbar ends, for smoother display.""" HORIZONTAL_BARS: ClassVar[list[str]] = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "] """Glyphs used for horizontal scrollbar ends, for smoother display.""" BLANK_GLYPH: ClassVar[str] = " " """Glyph used for the main body of the scrollbar""" def __init__( self, virtual_size: int = 100, window_size: int = 0, position: float = 0, thickness: int = 1, vertical: bool = True, style: StyleType = "bright_magenta on #555555", ) -> None: self.virtual_size = virtual_size self.window_size = window_size self.position = position self.thickness = thickness self.vertical = vertical self.style = style @classmethod def render_bar( cls, size: int = 25, virtual_size: float = 50, window_size: float = 20, position: float = 0, thickness: int = 1, vertical: bool = True, back_color: Color = Color.parse("#555555"), bar_color: Color = Color.parse("bright_magenta"), ) -> Segments: if vertical: bars = cls.VERTICAL_BARS else: bars = cls.HORIZONTAL_BARS back = back_color bar = bar_color len_bars = len(bars) width_thickness = thickness if vertical else 1 _Segment = Segment _Style = Style blank = cls.BLANK_GLYPH * width_thickness foreground_meta = {"@mouse.down": "grab"} if window_size and size and virtual_size and size != virtual_size: bar_ratio = virtual_size / size thumb_size = max(1, window_size / bar_ratio) position_ratio = position / (virtual_size - window_size) position = (size - thumb_size) * position_ratio start = int(position * len_bars) end = start + ceil(thumb_size * len_bars) start_index, start_bar = divmod(max(0, start), len_bars) end_index, end_bar = divmod(max(0, end), len_bars) upper = {"@mouse.down": "scroll_up"} lower = {"@mouse.down": "scroll_down"} upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper)) lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower)) segments = [upper_back_segment] * int(size) segments[end_index:] = [lower_back_segment] * (size - end_index) segments[start_index:end_index] = [ _Segment(blank, _Style(color=bar, reverse=True, meta=foreground_meta)) ] * (end_index - start_index) # Apply the smaller bar characters to head and tail of scrollbar for more "granularity" if start_index < len(segments): bar_character = bars[len_bars - 1 - start_bar] if bar_character != " ": segments[start_index] = _Segment( bar_character * width_thickness, ( _Style(bgcolor=back, color=bar, meta=foreground_meta) if vertical else _Style( bgcolor=back, color=bar, meta=foreground_meta, reverse=True, ) ), ) if end_index < len(segments): bar_character = bars[len_bars - 1 - end_bar] if bar_character != " ": segments[end_index] = _Segment( bar_character * width_thickness, ( _Style( bgcolor=back, color=bar, meta=foreground_meta, reverse=True, ) if vertical else _Style(bgcolor=back, color=bar, meta=foreground_meta) ), ) else: style = _Style(bgcolor=back) segments = [_Segment(blank, style=style)] * int(size) if vertical: return Segments(segments, new_lines=True) else: return Segments((segments + [_Segment.line()]) * thickness, new_lines=False) def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: size = ( (options.height or console.height) if self.vertical else (options.max_width or console.width) ) thickness = ( (options.max_width or console.width) if self.vertical else (options.height or console.height) ) _style = console.get_style(self.style) bar = self.render_bar( size=size, window_size=self.window_size, virtual_size=self.virtual_size, position=self.position, vertical=self.vertical, thickness=thickness, back_color=_style.bgcolor or Color.parse("#555555"), bar_color=_style.color or Color.parse("bright_magenta"), ) yield bar @rich.repr.auto class ScrollBar(Widget): renderer: ClassVar[Type[ScrollBarRender]] = ScrollBarRender """The class used for rendering scrollbars. This can be overridden and set to a ScrollBarRender-derived class in order to delegate all scrollbar rendering to that class. E.g.: ``` class MyScrollBarRender(ScrollBarRender): ... app = MyApp() ScrollBar.renderer = MyScrollBarRender app.run() ``` Because this variable is accessed through specific instances (rather than through the class ScrollBar itself) it is also possible to set this on specific scrollbar instance to change only that instance: ``` my_widget.horizontal_scrollbar.renderer = MyScrollBarRender ``` """ DEFAULT_CLASSES = "-textual-system" # Nothing to select in scrollbars ALLOW_SELECT = False def __init__( self, vertical: bool = True, name: str | None = None, *, thickness: int = 1 ) -> None: self.vertical = vertical self.thickness = thickness self.grabbed_position: float = 0 super().__init__(name=name) self.auto_links = False window_virtual_size: Reactive[int] = Reactive(100) window_size: Reactive[int] = Reactive(0) position: Reactive[float] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) grabbed: Reactive[Offset | None] = Reactive(None) def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() yield "window_virtual_size", self.window_virtual_size yield "window_size", self.window_size yield "position", self.position if self.thickness > 1: yield "thickness", self.thickness def render(self) -> RenderableType: assert self.parent is not None styles = self.parent.styles if self.grabbed: background = styles.scrollbar_background_active color = styles.scrollbar_color_active elif self.mouse_over: background = styles.scrollbar_background_hover color = styles.scrollbar_color_hover else: background = styles.scrollbar_background color = styles.scrollbar_color if background.a < 1: base_background, _ = self.parent.background_colors background = base_background + background color = background + color scrollbar_style = Style.from_color(color.rich_color, background.rich_color) if self.screen.styles.scrollbar_color.a == 0: return self.renderer(vertical=self.vertical, style=scrollbar_style) return self._render_bar(scrollbar_style) def _render_bar(self, scrollbar_style: Style) -> RenderableType: """Get a renderable for the scrollbar with given style. Args: scrollbar_style: Scrollbar style. Returns: Scrollbar renderable. """ window_size = ( self.window_size if self.window_size < self.window_virtual_size else 0 ) virtual_size = self.window_virtual_size return self.renderer( virtual_size=ceil(virtual_size), window_size=ceil(window_size), position=self.position, thickness=self.thickness, vertical=self.vertical, style=scrollbar_style, ) def _on_hide(self, event: events.Hide) -> None: if self.grabbed: self.release_mouse() self.grabbed = None def _on_enter(self, event: events.Enter) -> None: if event.node is self: self.mouse_over = True def _on_leave(self, event: events.Leave) -> None: if event.node is self: self.mouse_over = False def action_scroll_down(self) -> None: """Scroll vertical scrollbars down, horizontal scrollbars right.""" if not self.grabbed: self.post_message(ScrollDown() if self.vertical else ScrollRight()) def action_scroll_up(self) -> None: """Scroll vertical scrollbars up, horizontal scrollbars left.""" if not self.grabbed: self.post_message(ScrollUp() if self.vertical else ScrollLeft()) def action_grab(self) -> None: """Begin capturing the mouse cursor.""" self.capture_mouse() async def _on_mouse_down(self, event: events.MouseDown) -> None: # We don't want mouse events on the scrollbar bubbling event.stop() async def _on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: self.release_mouse() self.grabbed = None event.stop() def _on_mouse_capture(self, event: events.MouseCapture) -> None: if isinstance(self._parent, Widget): self._parent.release_anchor() self.grabbed = event.mouse_position self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None if self.vertical and isinstance(self.parent, Widget): self.parent._check_anchor() event.stop() async def _on_mouse_move(self, event: events.MouseMove) -> None: if self.grabbed and self.window_size: x: float | None = None y: float | None = None if self.vertical: virtual_size = self.window_virtual_size y = self.grabbed_position + ( (event._screen_y - self.grabbed.y) * (virtual_size / self.window_size) ) else: virtual_size = self.window_virtual_size x = self.grabbed_position + ( (event._screen_x - self.grabbed.x) * (virtual_size / self.window_size) ) self.post_message( ScrollTo(x=x, y=y, animate=not self.app.supports_smooth_scrolling) ) event.stop() async def _on_click(self, event: events.Click) -> None: event.stop() class ScrollBarCorner(Widget): """Widget which fills the gap between horizontal and vertical scrollbars, should they both be present.""" def render(self) -> RenderableType: assert self.parent is not None styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color)