404 lines
13 KiB
Python
404 lines
13 KiB
Python
|
|
"""
|
||
|
|
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)
|