diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py index 03f9e7d24..cc43fe5cf 100644 --- a/src/prompt_toolkit/layout/containers.py +++ b/src/prompt_toolkit/layout/containers.py @@ -1901,34 +1901,55 @@ def mouse_handler(mouse_event: MouseEvent) -> "NotImplementedOrNone": # Render and copy margins. move_x = 0 - def render_margin(m: Margin, width: int) -> UIContent: + def render_margin( + m: Margin, width: int + ) -> Tuple[UIContent, Callable[[MouseEvent], object]]: "Render margin. Return `Screen`." # Retrieve margin fragments. fragments = m.create_margin(render_info, width, write_position.height) # Turn it into a UIContent object. + margin_control = FormattedTextControl(fragments) + # already rendered those fragments using this size.) - return FormattedTextControl(fragments).create_content( - width + 1, write_position.height + return ( + margin_control.create_content(width + 1, write_position.height), + margin_control.mouse_handler, ) for m, width in zip(self.left_margins, left_margin_widths): if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.) # Create screen for margin. - margin_content = render_margin(m, width) + margin_content, margin_mouse_handler = render_margin(m, width) # Copy and shift X. - self._copy_margin(margin_content, screen, write_position, move_x, width) + self._copy_margin( + margin_content, + margin_mouse_handler, + screen, + mouse_handlers, + write_position, + move_x, + width, + ) move_x += width move_x = write_position.width - sum(right_margin_widths) for m, width in zip(self.right_margins, right_margin_widths): # Create screen for margin. - margin_content = render_margin(m, width) + margin_content, margin_mouse_handler = render_margin(m, width) # Copy and shift X. - self._copy_margin(margin_content, screen, write_position, move_x, width) + self._copy_margin( + margin_content, + margin_mouse_handler, + screen, + mouse_handlers, + write_position, + move_x, + width, + ) move_x += width # Apply 'self.style' @@ -2307,7 +2328,9 @@ def _highlight_cursorlines( def _copy_margin( self, margin_content: UIContent, + margin_mouse_handler: Callable[[MouseEvent], "NotImplementedOrNone"], new_screen: Screen, + mouse_handlers: MouseHandlers, write_position: WritePosition, move_x: int, width: int, @@ -2321,6 +2344,21 @@ def _copy_margin( margin_write_position = WritePosition(xpos, ypos, width, write_position.height) self._copy_body(margin_content, new_screen, margin_write_position, 0, width) + def _wrap_handler(mouse_event: MouseEvent) -> "None": + mouse_event.position = Point( + mouse_event.position.x - xpos, + mouse_event.position.y - ypos, + ) + margin_mouse_handler(mouse_event) + + mouse_handlers.set_mouse_handler_for_range( + x_min=margin_write_position.xpos, + x_max=margin_write_position.xpos + margin_write_position.width, + y_min=margin_write_position.ypos, + y_max=margin_write_position.ypos + margin_write_position.height, + handler=_wrap_handler, + ) + def _scroll(self, ui_content: UIContent, width: int, height: int) -> None: """ Scroll body. Ensure that the cursor is visible. diff --git a/src/prompt_toolkit/layout/margins.py b/src/prompt_toolkit/layout/margins.py index 7c46819c2..b300b544b 100644 --- a/src/prompt_toolkit/layout/margins.py +++ b/src/prompt_toolkit/layout/margins.py @@ -1,15 +1,18 @@ """ Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`. """ +import asyncio from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Callable, Optional +from prompt_toolkit.application.current import get_app from prompt_toolkit.filters import FilterOrBool, to_filter from prompt_toolkit.formatted_text import ( StyleAndTextTuples, fragment_list_to_text, to_formatted_text, ) +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType from prompt_toolkit.utils import get_cwidth from .controls import UIContent @@ -161,86 +164,266 @@ class ScrollbarMargin(Margin): Margin displaying a scrollbar. :param display_arrows: Display scroll up/down arrows. + :param up_arrow: Character to use for the scrollbar's up arrow + :param down_arrow: Character to use for the scrollbar's down arrow + :param smooth: Use block character to move scrollbar more smoothly """ + window_render_info: "WindowRenderInfo" + + eighths = "█▇▆▅▄▃▂▁ " + def __init__( self, - display_arrows: FilterOrBool = False, - up_arrow_symbol: str = "^", - down_arrow_symbol: str = "v", + display_arrows: "FilterOrBool" = True, + up_arrow_symbol: "str" = "▲", + down_arrow_symbol: "str" = "▼", + smooth: "bool" = True, ) -> None: self.display_arrows = to_filter(display_arrows) self.up_arrow_symbol = up_arrow_symbol self.down_arrow_symbol = down_arrow_symbol + self.smooth = smooth + + self.repeat_task: Optional[asyncio.Task[None]] = None + self.dragging = False + self.button_drag_offset = 0 + + self.thumb_top = 0.0 + self.thumb_size = 0.0 def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + """Return the scrollbar width: always 1.""" return 1 def create_margin( self, window_render_info: "WindowRenderInfo", width: int, height: int ) -> StyleAndTextTuples: - content_height = window_render_info.content_height - window_height = window_render_info.window_height + + result: StyleAndTextTuples = [] + + self.window_render_info = window_render_info + + # Show we render the arrow buttons? display_arrows = self.display_arrows() + # The height of the scrollbar, excluding the optional buttons + self.track_height = window_render_info.window_height if display_arrows: - window_height -= 2 + self.track_height -= 2 - try: - fraction_visible = len(window_render_info.displayed_lines) / float( + # Height of all text in the output: If there is none, we cannot divide by zero + # so we do not display a thumb + content_height = window_render_info.content_height + if content_height == 0 or content_height <= len( + window_render_info.displayed_lines + ): + self.thumb_size = 0.0 + else: + # The thumb is the part which moves, floating on the track: calculate its size + fraction_visible = len(window_render_info.displayed_lines) / ( content_height ) - fraction_above = window_render_info.vertical_scroll / float(content_height) - - scrollbar_height = int( - min(window_height, max(1, window_height * fraction_visible)) + self.thumb_size = ( + int( + min(self.track_height, max(1, self.track_height * fraction_visible)) + * 8 + ) + / 8 ) - scrollbar_top = int(window_height * fraction_above) - except ZeroDivisionError: - return [] + if not self.smooth: + self.thumb_size = int(self.thumb_size) + + # Calculate the position of the thumb + if content_height <= len(window_render_info.displayed_lines): + fraction_above = 0.0 else: + fraction_above = window_render_info.vertical_scroll / ( + content_height - len(window_render_info.displayed_lines) + ) + # Do not allow the thumb to move beyond the ends of the track + self.thumb_top = max( + 0, + min( + self.track_height - self.thumb_size, + (int((self.track_height - self.thumb_size) * fraction_above * 8) / 8), + ), + ) + if not self.smooth: + self.thumb_top = int(self.thumb_top) + + # Determine which characters to use for the ends of the thumb + thumb_top_char = self.eighths[int(self.thumb_top % 1 * 8)] + thumb_bottom_char = self.eighths[ + int((self.thumb_top + self.thumb_size) % 1 * 8) + ] + + # Calculate thumb dimensions + show_thumb_top = (self.thumb_top % 1) != 0 + thumb_top_size = 1 - self.thumb_top % 1 + show_thumb_bottom = (self.thumb_top + self.thumb_size) % 1 != 0 + thumb_bottom_size = (self.thumb_top + self.thumb_size) % 1 + thumb_middle_size = int( + self.thumb_size + - show_thumb_top * thumb_top_size + - show_thumb_bottom * thumb_bottom_size + ) + rows_after_thumb = ( + self.track_height + - int(self.thumb_top) + - show_thumb_top + - thumb_middle_size + - show_thumb_bottom + ) + + # Construct the scrollbar + + # Up button + if display_arrows: + result += [ + ("class:scrollbar.arrow", self.up_arrow_symbol, self.mouse_handler), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Track above the thumb + for _ in range(int(self.thumb_top)): + result += [ + ("class:scrollbar.background", " ", self.mouse_handler), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Top of thumb + if show_thumb_top: + result += [ + ( + "class:scrollbar.background,scrollbar.start", + thumb_top_char, + self.mouse_handler, + ), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Middle of thumb + for _ in range(thumb_middle_size): + result += [ + ("class:scrollbar.button", " ", self.mouse_handler), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Bottom of thumb + if show_thumb_bottom: + result += [ + ( + "class:scrollbar.background,scrollbar.end", + thumb_bottom_char, + self.mouse_handler, + ), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Track below the thumb + for _ in range(rows_after_thumb): + result += [ + ("class:scrollbar.background", " ", self.mouse_handler), + ("class:scrollbar", "\n", self.mouse_handler), + ] + # Down button + if display_arrows: + result += [ + ("class:scrollbar.arrow", self.down_arrow_symbol, self.mouse_handler), + ] - def is_scroll_button(row: int) -> bool: - "True if we should display a button on this row." - return scrollbar_top <= row <= scrollbar_top + scrollbar_height + return result - # Up arrow. - result: StyleAndTextTuples = [] - if display_arrows: - result.extend( - [ - ("class:scrollbar.arrow", self.up_arrow_symbol), - ("class:scrollbar", "\n"), - ] - ) + def mouse_handler( + self, mouse_event: MouseEvent, repeated: "bool" = False + ) -> "None": + """Handle scrollbar mouse events. - # Scrollbar body. - scrollbar_background = "class:scrollbar.background" - scrollbar_background_start = "class:scrollbar.background,scrollbar.start" - scrollbar_button = "class:scrollbar.button" - scrollbar_button_end = "class:scrollbar.button,scrollbar.end" - - for i in range(window_height): - if is_scroll_button(i): - if not is_scroll_button(i + 1): - # Give the last cell a different style, because we - # want to underline this. - result.append((scrollbar_button_end, " ")) - else: - result.append((scrollbar_button, " ")) - else: - if is_scroll_button(i + 1): - result.append((scrollbar_background_start, " ")) - else: - result.append((scrollbar_background, " ")) - result.append(("", "\n")) + Scrolls up or down if the arrows are clicked, repeating while the mouse button + is held down. Scolls up or down one page if the background is clicked, + repeating while the left mouse button is held down. Scrolls if the + scrollbar-button is dragged. Scrolls if the scroll-wheel is used on the + scrollbar. - # Down arrow - if display_arrows: - result.append(("class:scrollbar.arrow", self.down_arrow_symbol)) + Args: + mouse_event: The triggering mouse event + repeated: Set to True if the method is running as a repeated event - return result + """ + row = mouse_event.position.y + + content_height = self.window_render_info.content_height + + # Handle scroll events on the scrollbar + if mouse_event.event_type == MouseEventType.SCROLL_UP: + self.window_render_info.window._scroll_up() + elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: + self.window_render_info.window._scroll_down() + + # Mouse drag events + elif self.dragging and mouse_event.event_type == MouseEventType.MOUSE_MOVE: + # Scroll so the button gets moved to where the mouse is + offset = int( + (row - self.thumb_top - self.button_drag_offset) + / self.track_height + * content_height + ) + if offset < 0: + func = self.window_render_info.window._scroll_up + else: + func = self.window_render_info.window._scroll_down + if func: + # Scroll the window multiple times to scroll by the offset + for _ in range(abs(offset)): + func() + + # Mouse down events + elif mouse_event.event_type == MouseEventType.MOUSE_DOWN: + # Scroll up/down one line if clicking on the arrows + arrows = self.display_arrows() + if arrows and row == 0: + offset = -1 + elif arrows and row == self.window_render_info.window_height - 1: + offset = 1 + # Scroll up or down one page if clicking on the background + elif row < self.thumb_top or self.thumb_top + self.thumb_size < row: + direction = (row < (self.thumb_top + self.thumb_size // 2)) * -2 + 1 + offset = direction * self.window_render_info.window_height + # We are on the scroll button - start a drag event if this is not a + # repeated mouse event + elif not repeated: + self.dragging = True + self.button_drag_offset = mouse_event.position.y - int(self.thumb_top) + return + # Otherwise this is a click on the centre scroll button - do nothing + else: + offset = 0 + + if mouse_event.button == MouseButton.LEFT: + func = None + if offset < 0: + func = self.window_render_info.window._scroll_up + elif offset > 0: + func = self.window_render_info.window._scroll_down + if func: + # Scroll the window multiple times to scroll by the offset + for _ in range(abs(offset)): + func() + # Trigger this mouse event to be repeated + self.repeat_task = get_app().create_background_task( + self.repeat(mouse_event) + ) + + # Handle all other mouse events + else: + # Stop any repeated tasks + if self.repeat_task is not None: + self.repeat_task.cancel() + # Cancel drags + self.dragging = False + + async def repeat(self, mouse_event: MouseEvent, timeout: float = 0.1) -> "None": + """Repeat a mouse event after a timeout.""" + await asyncio.sleep(timeout) + self.mouse_handler(mouse_event, repeated=True) + get_app().invalidate() class PromptMargin(Margin): diff --git a/src/prompt_toolkit/styles/defaults.py b/src/prompt_toolkit/styles/defaults.py index 4ac554562..1f282aa4d 100644 --- a/src/prompt_toolkit/styles/defaults.py +++ b/src/prompt_toolkit/styles/defaults.py @@ -81,6 +81,8 @@ ("scrollbar.background", "bg:#aaaaaa"), ("scrollbar.button", "bg:#444444"), ("scrollbar.arrow", "noinherit bold"), + ("scrollbar.start", "fg:#444444"), + ("scrollbar.end", "fg:#444444 reverse"), # Start/end of scrollbars. Adding 'underline' here provides a nice little # detail to the progress bar, but it doesn't look good on all terminals. # ('scrollbar.start', 'underline #ffffff'),