Spaces:
Sleeping
Sleeping
| """ | |
| User interface Controls for the layout. | |
| """ | |
| from __future__ import annotations | |
| import time | |
| from abc import ABCMeta, abstractmethod | |
| from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple | |
| from prompt_toolkit.application.current import get_app | |
| from prompt_toolkit.buffer import Buffer | |
| from prompt_toolkit.cache import SimpleCache | |
| from prompt_toolkit.data_structures import Point | |
| from prompt_toolkit.document import Document | |
| from prompt_toolkit.filters import FilterOrBool, to_filter | |
| from prompt_toolkit.formatted_text import ( | |
| AnyFormattedText, | |
| StyleAndTextTuples, | |
| to_formatted_text, | |
| ) | |
| from prompt_toolkit.formatted_text.utils import ( | |
| fragment_list_to_text, | |
| fragment_list_width, | |
| split_lines, | |
| ) | |
| from prompt_toolkit.lexers import Lexer, SimpleLexer | |
| from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType | |
| from prompt_toolkit.search import SearchState | |
| from prompt_toolkit.selection import SelectionType | |
| from prompt_toolkit.utils import get_cwidth | |
| from .processors import ( | |
| DisplayMultipleCursors, | |
| HighlightIncrementalSearchProcessor, | |
| HighlightSearchProcessor, | |
| HighlightSelectionProcessor, | |
| Processor, | |
| TransformationInput, | |
| merge_processors, | |
| ) | |
| if TYPE_CHECKING: | |
| from prompt_toolkit.key_binding.key_bindings import ( | |
| KeyBindingsBase, | |
| NotImplementedOrNone, | |
| ) | |
| from prompt_toolkit.utils import Event | |
| __all__ = [ | |
| "BufferControl", | |
| "SearchBufferControl", | |
| "DummyControl", | |
| "FormattedTextControl", | |
| "UIControl", | |
| "UIContent", | |
| ] | |
| GetLinePrefixCallable = Callable[[int, int], AnyFormattedText] | |
| class UIControl(metaclass=ABCMeta): | |
| """ | |
| Base class for all user interface controls. | |
| """ | |
| def reset(self) -> None: | |
| # Default reset. (Doesn't have to be implemented.) | |
| pass | |
| def preferred_width(self, max_available_width: int) -> int | None: | |
| return None | |
| def preferred_height( | |
| self, | |
| width: int, | |
| max_available_height: int, | |
| wrap_lines: bool, | |
| get_line_prefix: GetLinePrefixCallable | None, | |
| ) -> int | None: | |
| return None | |
| def is_focusable(self) -> bool: | |
| """ | |
| Tell whether this user control is focusable. | |
| """ | |
| return False | |
| def create_content(self, width: int, height: int) -> UIContent: | |
| """ | |
| Generate the content for this user control. | |
| Returns a :class:`.UIContent` instance. | |
| """ | |
| def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: | |
| """ | |
| Handle mouse events. | |
| When `NotImplemented` is returned, it means that the given event is not | |
| handled by the `UIControl` itself. The `Window` or key bindings can | |
| decide to handle this event as scrolling or changing focus. | |
| :param mouse_event: `MouseEvent` instance. | |
| """ | |
| return NotImplemented | |
| def move_cursor_down(self) -> None: | |
| """ | |
| Request to move the cursor down. | |
| This happens when scrolling down and the cursor is completely at the | |
| top. | |
| """ | |
| def move_cursor_up(self) -> None: | |
| """ | |
| Request to move the cursor up. | |
| """ | |
| def get_key_bindings(self) -> KeyBindingsBase | None: | |
| """ | |
| The key bindings that are specific for this user control. | |
| Return a :class:`.KeyBindings` object if some key bindings are | |
| specified, or `None` otherwise. | |
| """ | |
| def get_invalidate_events(self) -> Iterable[Event[object]]: | |
| """ | |
| Return a list of `Event` objects. This can be a generator. | |
| (The application collects all these events, in order to bind redraw | |
| handlers to these events.) | |
| """ | |
| return [] | |
| class UIContent: | |
| """ | |
| Content generated by a user control. This content consists of a list of | |
| lines. | |
| :param get_line: Callable that takes a line number and returns the current | |
| line. This is a list of (style_str, text) tuples. | |
| :param line_count: The number of lines. | |
| :param cursor_position: a :class:`.Point` for the cursor position. | |
| :param menu_position: a :class:`.Point` for the menu position. | |
| :param show_cursor: Make the cursor visible. | |
| """ | |
| def __init__( | |
| self, | |
| get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []), | |
| line_count: int = 0, | |
| cursor_position: Point | None = None, | |
| menu_position: Point | None = None, | |
| show_cursor: bool = True, | |
| ): | |
| self.get_line = get_line | |
| self.line_count = line_count | |
| self.cursor_position = cursor_position or Point(x=0, y=0) | |
| self.menu_position = menu_position | |
| self.show_cursor = show_cursor | |
| # Cache for line heights. Maps cache key -> height | |
| self._line_heights_cache: dict[Hashable, int] = {} | |
| def __getitem__(self, lineno: int) -> StyleAndTextTuples: | |
| "Make it iterable (iterate line by line)." | |
| if lineno < self.line_count: | |
| return self.get_line(lineno) | |
| else: | |
| raise IndexError | |
| def get_height_for_line( | |
| self, | |
| lineno: int, | |
| width: int, | |
| get_line_prefix: GetLinePrefixCallable | None, | |
| slice_stop: int | None = None, | |
| ) -> int: | |
| """ | |
| Return the height that a given line would need if it is rendered in a | |
| space with the given width (using line wrapping). | |
| :param get_line_prefix: None or a `Window.get_line_prefix` callable | |
| that returns the prefix to be inserted before this line. | |
| :param slice_stop: Wrap only "line[:slice_stop]" and return that | |
| partial result. This is needed for scrolling the window correctly | |
| when line wrapping. | |
| :returns: The computed height. | |
| """ | |
| # Instead of using `get_line_prefix` as key, we use render_counter | |
| # instead. This is more reliable, because this function could still be | |
| # the same, while the content would change over time. | |
| key = get_app().render_counter, lineno, width, slice_stop | |
| try: | |
| return self._line_heights_cache[key] | |
| except KeyError: | |
| if width == 0: | |
| height = 10**8 | |
| else: | |
| # Calculate line width first. | |
| line = fragment_list_to_text(self.get_line(lineno))[:slice_stop] | |
| text_width = get_cwidth(line) | |
| if get_line_prefix: | |
| # Add prefix width. | |
| text_width += fragment_list_width( | |
| to_formatted_text(get_line_prefix(lineno, 0)) | |
| ) | |
| # Slower path: compute path when there's a line prefix. | |
| height = 1 | |
| # Keep wrapping as long as the line doesn't fit. | |
| # Keep adding new prefixes for every wrapped line. | |
| while text_width > width: | |
| height += 1 | |
| text_width -= width | |
| fragments2 = to_formatted_text( | |
| get_line_prefix(lineno, height - 1) | |
| ) | |
| prefix_width = get_cwidth(fragment_list_to_text(fragments2)) | |
| if prefix_width >= width: # Prefix doesn't fit. | |
| height = 10**8 | |
| break | |
| text_width += prefix_width | |
| else: | |
| # Fast path: compute height when there's no line prefix. | |
| try: | |
| quotient, remainder = divmod(text_width, width) | |
| except ZeroDivisionError: | |
| height = 10**8 | |
| else: | |
| if remainder: | |
| quotient += 1 # Like math.ceil. | |
| height = max(1, quotient) | |
| # Cache and return | |
| self._line_heights_cache[key] = height | |
| return height | |
| class FormattedTextControl(UIControl): | |
| """ | |
| Control that displays formatted text. This can be either plain text, an | |
| :class:`~prompt_toolkit.formatted_text.HTML` object an | |
| :class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str, | |
| text)`` tuples or a callable that takes no argument and returns one of | |
| those, depending on how you prefer to do the formatting. See | |
| ``prompt_toolkit.layout.formatted_text`` for more information. | |
| (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) | |
| When this UI control has the focus, the cursor will be shown in the upper | |
| left corner of this control by default. There are two ways for specifying | |
| the cursor position: | |
| - Pass a `get_cursor_position` function which returns a `Point` instance | |
| with the current cursor position. | |
| - If the (formatted) text is passed as a list of ``(style, text)`` tuples | |
| and there is one that looks like ``('[SetCursorPosition]', '')``, then | |
| this will specify the cursor position. | |
| Mouse support: | |
| The list of fragments can also contain tuples of three items, looking like: | |
| (style_str, text, handler). When mouse support is enabled and the user | |
| clicks on this fragment, then the given handler is called. That handler | |
| should accept two inputs: (Application, MouseEvent) and it should | |
| either handle the event or return `NotImplemented` in case we want the | |
| containing Window to handle this event. | |
| :param focusable: `bool` or :class:`.Filter`: Tell whether this control is | |
| focusable. | |
| :param text: Text or formatted text to be displayed. | |
| :param style: Style string applied to the content. (If you want to style | |
| the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the | |
| :class:`~prompt_toolkit.layout.Window` instead.) | |
| :param key_bindings: a :class:`.KeyBindings` object. | |
| :param get_cursor_position: A callable that returns the cursor position as | |
| a `Point` instance. | |
| """ | |
| def __init__( | |
| self, | |
| text: AnyFormattedText = "", | |
| style: str = "", | |
| focusable: FilterOrBool = False, | |
| key_bindings: KeyBindingsBase | None = None, | |
| show_cursor: bool = True, | |
| modal: bool = False, | |
| get_cursor_position: Callable[[], Point | None] | None = None, | |
| ) -> None: | |
| self.text = text # No type check on 'text'. This is done dynamically. | |
| self.style = style | |
| self.focusable = to_filter(focusable) | |
| # Key bindings. | |
| self.key_bindings = key_bindings | |
| self.show_cursor = show_cursor | |
| self.modal = modal | |
| self.get_cursor_position = get_cursor_position | |
| #: Cache for the content. | |
| self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18) | |
| self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache( | |
| maxsize=1 | |
| ) | |
| # Only cache one fragment list. We don't need the previous item. | |
| # Render info for the mouse support. | |
| self._fragments: StyleAndTextTuples | None = None | |
| def reset(self) -> None: | |
| self._fragments = None | |
| def is_focusable(self) -> bool: | |
| return self.focusable() | |
| def __repr__(self) -> str: | |
| return f"{self.__class__.__name__}({self.text!r})" | |
| def _get_formatted_text_cached(self) -> StyleAndTextTuples: | |
| """ | |
| Get fragments, but only retrieve fragments once during one render run. | |
| (This function is called several times during one rendering, because | |
| we also need those for calculating the dimensions.) | |
| """ | |
| return self._fragment_cache.get( | |
| get_app().render_counter, lambda: to_formatted_text(self.text, self.style) | |
| ) | |
| def preferred_width(self, max_available_width: int) -> int: | |
| """ | |
| Return the preferred width for this control. | |
| That is the width of the longest line. | |
| """ | |
| text = fragment_list_to_text(self._get_formatted_text_cached()) | |
| line_lengths = [get_cwidth(l) for l in text.split("\n")] | |
| return max(line_lengths) | |
| def preferred_height( | |
| self, | |
| width: int, | |
| max_available_height: int, | |
| wrap_lines: bool, | |
| get_line_prefix: GetLinePrefixCallable | None, | |
| ) -> int | None: | |
| """ | |
| Return the preferred height for this control. | |
| """ | |
| content = self.create_content(width, None) | |
| if wrap_lines: | |
| height = 0 | |
| for i in range(content.line_count): | |
| height += content.get_height_for_line(i, width, get_line_prefix) | |
| if height >= max_available_height: | |
| return max_available_height | |
| return height | |
| else: | |
| return content.line_count | |
| def create_content(self, width: int, height: int | None) -> UIContent: | |
| # Get fragments | |
| fragments_with_mouse_handlers = self._get_formatted_text_cached() | |
| fragment_lines_with_mouse_handlers = list( | |
| split_lines(fragments_with_mouse_handlers) | |
| ) | |
| # Strip mouse handlers from fragments. | |
| fragment_lines: list[StyleAndTextTuples] = [ | |
| [(item[0], item[1]) for item in line] | |
| for line in fragment_lines_with_mouse_handlers | |
| ] | |
| # Keep track of the fragments with mouse handler, for later use in | |
| # `mouse_handler`. | |
| self._fragments = fragments_with_mouse_handlers | |
| # If there is a `[SetCursorPosition]` in the fragment list, set the | |
| # cursor position here. | |
| def get_cursor_position( | |
| fragment: str = "[SetCursorPosition]", | |
| ) -> Point | None: | |
| for y, line in enumerate(fragment_lines): | |
| x = 0 | |
| for style_str, text, *_ in line: | |
| if fragment in style_str: | |
| return Point(x=x, y=y) | |
| x += len(text) | |
| return None | |
| # If there is a `[SetMenuPosition]`, set the menu over here. | |
| def get_menu_position() -> Point | None: | |
| return get_cursor_position("[SetMenuPosition]") | |
| cursor_position = (self.get_cursor_position or get_cursor_position)() | |
| # Create content, or take it from the cache. | |
| key = (tuple(fragments_with_mouse_handlers), width, cursor_position) | |
| def get_content() -> UIContent: | |
| return UIContent( | |
| get_line=lambda i: fragment_lines[i], | |
| line_count=len(fragment_lines), | |
| show_cursor=self.show_cursor, | |
| cursor_position=cursor_position, | |
| menu_position=get_menu_position(), | |
| ) | |
| return self._content_cache.get(key, get_content) | |
| def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: | |
| """ | |
| Handle mouse events. | |
| (When the fragment list contained mouse handlers and the user clicked on | |
| on any of these, the matching handler is called. This handler can still | |
| return `NotImplemented` in case we want the | |
| :class:`~prompt_toolkit.layout.Window` to handle this particular | |
| event.) | |
| """ | |
| if self._fragments: | |
| # Read the generator. | |
| fragments_for_line = list(split_lines(self._fragments)) | |
| try: | |
| fragments = fragments_for_line[mouse_event.position.y] | |
| except IndexError: | |
| return NotImplemented | |
| else: | |
| # Find position in the fragment list. | |
| xpos = mouse_event.position.x | |
| # Find mouse handler for this character. | |
| count = 0 | |
| for item in fragments: | |
| count += len(item[1]) | |
| if count > xpos: | |
| if len(item) >= 3: | |
| # Handler found. Call it. | |
| # (Handler can return NotImplemented, so return | |
| # that result.) | |
| handler = item[2] | |
| return handler(mouse_event) | |
| else: | |
| break | |
| # Otherwise, don't handle here. | |
| return NotImplemented | |
| def is_modal(self) -> bool: | |
| return self.modal | |
| def get_key_bindings(self) -> KeyBindingsBase | None: | |
| return self.key_bindings | |
| class DummyControl(UIControl): | |
| """ | |
| A dummy control object that doesn't paint any content. | |
| Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The | |
| `fragment` and `char` attributes of the `Window` class can be used to | |
| define the filling.) | |
| """ | |
| def create_content(self, width: int, height: int) -> UIContent: | |
| def get_line(i: int) -> StyleAndTextTuples: | |
| return [] | |
| return UIContent(get_line=get_line, line_count=100**100) # Something very big. | |
| def is_focusable(self) -> bool: | |
| return False | |
| class _ProcessedLine(NamedTuple): | |
| fragments: StyleAndTextTuples | |
| source_to_display: Callable[[int], int] | |
| display_to_source: Callable[[int], int] | |
| class BufferControl(UIControl): | |
| """ | |
| Control for visualizing the content of a :class:`.Buffer`. | |
| :param buffer: The :class:`.Buffer` object to be displayed. | |
| :param input_processors: A list of | |
| :class:`~prompt_toolkit.layout.processors.Processor` objects. | |
| :param include_default_input_processors: When True, include the default | |
| processors for highlighting of selection, search and displaying of | |
| multiple cursors. | |
| :param lexer: :class:`.Lexer` instance for syntax highlighting. | |
| :param preview_search: `bool` or :class:`.Filter`: Show search while | |
| typing. When this is `True`, probably you want to add a | |
| ``HighlightIncrementalSearchProcessor`` as well. Otherwise only the | |
| cursor position will move, but the text won't be highlighted. | |
| :param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable. | |
| :param focus_on_click: Focus this buffer when it's click, but not yet focused. | |
| :param key_bindings: a :class:`.KeyBindings` object. | |
| """ | |
| def __init__( | |
| self, | |
| buffer: Buffer | None = None, | |
| input_processors: list[Processor] | None = None, | |
| include_default_input_processors: bool = True, | |
| lexer: Lexer | None = None, | |
| preview_search: FilterOrBool = False, | |
| focusable: FilterOrBool = True, | |
| search_buffer_control: ( | |
| None | SearchBufferControl | Callable[[], SearchBufferControl] | |
| ) = None, | |
| menu_position: Callable[[], int | None] | None = None, | |
| focus_on_click: FilterOrBool = False, | |
| key_bindings: KeyBindingsBase | None = None, | |
| ): | |
| self.input_processors = input_processors | |
| self.include_default_input_processors = include_default_input_processors | |
| self.default_input_processors = [ | |
| HighlightSearchProcessor(), | |
| HighlightIncrementalSearchProcessor(), | |
| HighlightSelectionProcessor(), | |
| DisplayMultipleCursors(), | |
| ] | |
| self.preview_search = to_filter(preview_search) | |
| self.focusable = to_filter(focusable) | |
| self.focus_on_click = to_filter(focus_on_click) | |
| self.buffer = buffer or Buffer() | |
| self.menu_position = menu_position | |
| self.lexer = lexer or SimpleLexer() | |
| self.key_bindings = key_bindings | |
| self._search_buffer_control = search_buffer_control | |
| #: Cache for the lexer. | |
| #: Often, due to cursor movement, undo/redo and window resizing | |
| #: operations, it happens that a short time, the same document has to be | |
| #: lexed. This is a fairly easy way to cache such an expensive operation. | |
| self._fragment_cache: SimpleCache[ | |
| Hashable, Callable[[int], StyleAndTextTuples] | |
| ] = SimpleCache(maxsize=8) | |
| self._last_click_timestamp: float | None = None | |
| self._last_get_processed_line: Callable[[int], _ProcessedLine] | None = None | |
| def __repr__(self) -> str: | |
| return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>" | |
| def search_buffer_control(self) -> SearchBufferControl | None: | |
| result: SearchBufferControl | None | |
| if callable(self._search_buffer_control): | |
| result = self._search_buffer_control() | |
| else: | |
| result = self._search_buffer_control | |
| assert result is None or isinstance(result, SearchBufferControl) | |
| return result | |
| def search_buffer(self) -> Buffer | None: | |
| control = self.search_buffer_control | |
| if control is not None: | |
| return control.buffer | |
| return None | |
| def search_state(self) -> SearchState: | |
| """ | |
| Return the `SearchState` for searching this `BufferControl`. This is | |
| always associated with the search control. If one search bar is used | |
| for searching multiple `BufferControls`, then they share the same | |
| `SearchState`. | |
| """ | |
| search_buffer_control = self.search_buffer_control | |
| if search_buffer_control: | |
| return search_buffer_control.searcher_search_state | |
| else: | |
| return SearchState() | |
| def is_focusable(self) -> bool: | |
| return self.focusable() | |
| def preferred_width(self, max_available_width: int) -> int | None: | |
| """ | |
| This should return the preferred width. | |
| Note: We don't specify a preferred width according to the content, | |
| because it would be too expensive. Calculating the preferred | |
| width can be done by calculating the longest line, but this would | |
| require applying all the processors to each line. This is | |
| unfeasible for a larger document, and doing it for small | |
| documents only would result in inconsistent behavior. | |
| """ | |
| return None | |
| def preferred_height( | |
| self, | |
| width: int, | |
| max_available_height: int, | |
| wrap_lines: bool, | |
| get_line_prefix: GetLinePrefixCallable | None, | |
| ) -> int | None: | |
| # Calculate the content height, if it was drawn on a screen with the | |
| # given width. | |
| height = 0 | |
| content = self.create_content(width, height=1) # Pass a dummy '1' as height. | |
| # When line wrapping is off, the height should be equal to the amount | |
| # of lines. | |
| if not wrap_lines: | |
| return content.line_count | |
| # When the number of lines exceeds the max_available_height, just | |
| # return max_available_height. No need to calculate anything. | |
| if content.line_count >= max_available_height: | |
| return max_available_height | |
| for i in range(content.line_count): | |
| height += content.get_height_for_line(i, width, get_line_prefix) | |
| if height >= max_available_height: | |
| return max_available_height | |
| return height | |
| def _get_formatted_text_for_line_func( | |
| self, document: Document | |
| ) -> Callable[[int], StyleAndTextTuples]: | |
| """ | |
| Create a function that returns the fragments for a given line. | |
| """ | |
| # Cache using `document.text`. | |
| def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]: | |
| return self.lexer.lex_document(document) | |
| key = (document.text, self.lexer.invalidation_hash()) | |
| return self._fragment_cache.get(key, get_formatted_text_for_line) | |
| def _create_get_processed_line_func( | |
| self, document: Document, width: int, height: int | |
| ) -> Callable[[int], _ProcessedLine]: | |
| """ | |
| Create a function that takes a line number of the current document and | |
| returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source) | |
| tuple. | |
| """ | |
| # Merge all input processors together. | |
| input_processors = self.input_processors or [] | |
| if self.include_default_input_processors: | |
| input_processors = self.default_input_processors + input_processors | |
| merged_processor = merge_processors(input_processors) | |
| def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: | |
| "Transform the fragments for a given line number." | |
| # Get cursor position at this line. | |
| def source_to_display(i: int) -> int: | |
| """X position from the buffer to the x position in the | |
| processed fragment list. By default, we start from the 'identity' | |
| operation.""" | |
| return i | |
| transformation = merged_processor.apply_transformation( | |
| TransformationInput( | |
| self, document, lineno, source_to_display, fragments, width, height | |
| ) | |
| ) | |
| return _ProcessedLine( | |
| transformation.fragments, | |
| transformation.source_to_display, | |
| transformation.display_to_source, | |
| ) | |
| def create_func() -> Callable[[int], _ProcessedLine]: | |
| get_line = self._get_formatted_text_for_line_func(document) | |
| cache: dict[int, _ProcessedLine] = {} | |
| def get_processed_line(i: int) -> _ProcessedLine: | |
| try: | |
| return cache[i] | |
| except KeyError: | |
| processed_line = transform(i, get_line(i)) | |
| cache[i] = processed_line | |
| return processed_line | |
| return get_processed_line | |
| return create_func() | |
| def create_content( | |
| self, width: int, height: int, preview_search: bool = False | |
| ) -> UIContent: | |
| """ | |
| Create a UIContent. | |
| """ | |
| buffer = self.buffer | |
| # Trigger history loading of the buffer. We do this during the | |
| # rendering of the UI here, because it needs to happen when an | |
| # `Application` with its event loop is running. During the rendering of | |
| # the buffer control is the earliest place we can achieve this, where | |
| # we're sure the right event loop is active, and don't require user | |
| # interaction (like in a key binding). | |
| buffer.load_history_if_not_yet_loaded() | |
| # Get the document to be shown. If we are currently searching (the | |
| # search buffer has focus, and the preview_search filter is enabled), | |
| # then use the search document, which has possibly a different | |
| # text/cursor position.) | |
| search_control = self.search_buffer_control | |
| preview_now = preview_search or bool( | |
| # Only if this feature is enabled. | |
| self.preview_search() | |
| and | |
| # And something was typed in the associated search field. | |
| search_control | |
| and search_control.buffer.text | |
| and | |
| # And we are searching in this control. (Many controls can point to | |
| # the same search field, like in Pyvim.) | |
| get_app().layout.search_target_buffer_control == self | |
| ) | |
| if preview_now and search_control is not None: | |
| ss = self.search_state | |
| document = buffer.document_for_search( | |
| SearchState( | |
| text=search_control.buffer.text, | |
| direction=ss.direction, | |
| ignore_case=ss.ignore_case, | |
| ) | |
| ) | |
| else: | |
| document = buffer.document | |
| get_processed_line = self._create_get_processed_line_func( | |
| document, width, height | |
| ) | |
| self._last_get_processed_line = get_processed_line | |
| def translate_rowcol(row: int, col: int) -> Point: | |
| "Return the content column for this coordinate." | |
| return Point(x=get_processed_line(row).source_to_display(col), y=row) | |
| def get_line(i: int) -> StyleAndTextTuples: | |
| "Return the fragments for a given line number." | |
| fragments = get_processed_line(i).fragments | |
| # Add a space at the end, because that is a possible cursor | |
| # position. (When inserting after the input.) We should do this on | |
| # all the lines, not just the line containing the cursor. (Because | |
| # otherwise, line wrapping/scrolling could change when moving the | |
| # cursor around.) | |
| fragments = fragments + [("", " ")] | |
| return fragments | |
| content = UIContent( | |
| get_line=get_line, | |
| line_count=document.line_count, | |
| cursor_position=translate_rowcol( | |
| document.cursor_position_row, document.cursor_position_col | |
| ), | |
| ) | |
| # If there is an auto completion going on, use that start point for a | |
| # pop-up menu position. (But only when this buffer has the focus -- | |
| # there is only one place for a menu, determined by the focused buffer.) | |
| if get_app().layout.current_control == self: | |
| menu_position = self.menu_position() if self.menu_position else None | |
| if menu_position is not None: | |
| assert isinstance(menu_position, int) | |
| menu_row, menu_col = buffer.document.translate_index_to_position( | |
| menu_position | |
| ) | |
| content.menu_position = translate_rowcol(menu_row, menu_col) | |
| elif buffer.complete_state: | |
| # Position for completion menu. | |
| # Note: We use 'min', because the original cursor position could be | |
| # behind the input string when the actual completion is for | |
| # some reason shorter than the text we had before. (A completion | |
| # can change and shorten the input.) | |
| menu_row, menu_col = buffer.document.translate_index_to_position( | |
| min( | |
| buffer.cursor_position, | |
| buffer.complete_state.original_document.cursor_position, | |
| ) | |
| ) | |
| content.menu_position = translate_rowcol(menu_row, menu_col) | |
| else: | |
| content.menu_position = None | |
| return content | |
| def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: | |
| """ | |
| Mouse handler for this control. | |
| """ | |
| buffer = self.buffer | |
| position = mouse_event.position | |
| # Focus buffer when clicked. | |
| if get_app().layout.current_control == self: | |
| if self._last_get_processed_line: | |
| processed_line = self._last_get_processed_line(position.y) | |
| # Translate coordinates back to the cursor position of the | |
| # original input. | |
| xpos = processed_line.display_to_source(position.x) | |
| index = buffer.document.translate_row_col_to_index(position.y, xpos) | |
| # Set the cursor position. | |
| if mouse_event.event_type == MouseEventType.MOUSE_DOWN: | |
| buffer.exit_selection() | |
| buffer.cursor_position = index | |
| elif ( | |
| mouse_event.event_type == MouseEventType.MOUSE_MOVE | |
| and mouse_event.button != MouseButton.NONE | |
| ): | |
| # Click and drag to highlight a selection | |
| if ( | |
| buffer.selection_state is None | |
| and abs(buffer.cursor_position - index) > 0 | |
| ): | |
| buffer.start_selection(selection_type=SelectionType.CHARACTERS) | |
| buffer.cursor_position = index | |
| elif mouse_event.event_type == MouseEventType.MOUSE_UP: | |
| # When the cursor was moved to another place, select the text. | |
| # (The >1 is actually a small but acceptable workaround for | |
| # selecting text in Vi navigation mode. In navigation mode, | |
| # the cursor can never be after the text, so the cursor | |
| # will be repositioned automatically.) | |
| if abs(buffer.cursor_position - index) > 1: | |
| if buffer.selection_state is None: | |
| buffer.start_selection( | |
| selection_type=SelectionType.CHARACTERS | |
| ) | |
| buffer.cursor_position = index | |
| # Select word around cursor on double click. | |
| # Two MOUSE_UP events in a short timespan are considered a double click. | |
| double_click = ( | |
| self._last_click_timestamp | |
| and time.time() - self._last_click_timestamp < 0.3 | |
| ) | |
| self._last_click_timestamp = time.time() | |
| if double_click: | |
| start, end = buffer.document.find_boundaries_of_current_word() | |
| buffer.cursor_position += start | |
| buffer.start_selection(selection_type=SelectionType.CHARACTERS) | |
| buffer.cursor_position += end - start | |
| else: | |
| # Don't handle scroll events here. | |
| return NotImplemented | |
| # Not focused, but focusing on click events. | |
| else: | |
| if ( | |
| self.focus_on_click() | |
| and mouse_event.event_type == MouseEventType.MOUSE_UP | |
| ): | |
| # Focus happens on mouseup. (If we did this on mousedown, the | |
| # up event will be received at the point where this widget is | |
| # focused and be handled anyway.) | |
| get_app().layout.current_control = self | |
| else: | |
| return NotImplemented | |
| return None | |
| def move_cursor_down(self) -> None: | |
| b = self.buffer | |
| b.cursor_position += b.document.get_cursor_down_position() | |
| def move_cursor_up(self) -> None: | |
| b = self.buffer | |
| b.cursor_position += b.document.get_cursor_up_position() | |
| def get_key_bindings(self) -> KeyBindingsBase | None: | |
| """ | |
| When additional key bindings are given. Return these. | |
| """ | |
| return self.key_bindings | |
| def get_invalidate_events(self) -> Iterable[Event[object]]: | |
| """ | |
| Return the Window invalidate events. | |
| """ | |
| # Whenever the buffer changes, the UI has to be updated. | |
| yield self.buffer.on_text_changed | |
| yield self.buffer.on_cursor_position_changed | |
| yield self.buffer.on_completions_changed | |
| yield self.buffer.on_suggestion_set | |
| class SearchBufferControl(BufferControl): | |
| """ | |
| :class:`.BufferControl` which is used for searching another | |
| :class:`.BufferControl`. | |
| :param ignore_case: Search case insensitive. | |
| """ | |
| def __init__( | |
| self, | |
| buffer: Buffer | None = None, | |
| input_processors: list[Processor] | None = None, | |
| lexer: Lexer | None = None, | |
| focus_on_click: FilterOrBool = False, | |
| key_bindings: KeyBindingsBase | None = None, | |
| ignore_case: FilterOrBool = False, | |
| ): | |
| super().__init__( | |
| buffer=buffer, | |
| input_processors=input_processors, | |
| lexer=lexer, | |
| focus_on_click=focus_on_click, | |
| key_bindings=key_bindings, | |
| ) | |
| # If this BufferControl is used as a search field for one or more other | |
| # BufferControls, then represents the search state. | |
| self.searcher_search_state = SearchState(ignore_case=ignore_case) | |