class TokenListControl(UIControl): """ Control that displays a list of (Token, text) tuples. (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) Mouse support: The list of tokens can also contain tuples of three items, looking like: (Token, text, handler). When mouse support is enabled and the user clicks on this token, then the given handler is called. That handler should accept two inputs: (CommandLineInterface, MouseEvent) and it should either handle the event or return `NotImplemented` in case we want the containing Window to handle this event. :param get_tokens: Callable that takes a `CommandLineInterface` instance and returns the list of (Token, text) tuples to be displayed right now. :param default_char: default :class:`.Char` (character and Token) to use for the background when there is more space available than `get_tokens` returns. :param get_default_char: Like `default_char`, but this is a callable that takes a :class:`prompt_toolkit.interface.CommandLineInterface` and returns a :class:`.Char` instance. :param has_focus: `bool` or `CLIFilter`, when this evaluates to `True`, this UI control will take the focus. The cursor will be shown in the upper left corner of this control, unless `get_token` returns a ``Token.SetCursorPosition`` token somewhere in the token list, then the cursor will be shown there. :param wrap_lines: `bool` or `CLIFilter`: Wrap long lines. """ def __init__(self, get_tokens, default_char=None, get_default_char=None, align_right=False, align_center=False, has_focus=False, wrap_lines=True): assert default_char is None or isinstance(default_char, Char) assert get_default_char is None or callable(get_default_char) assert not (default_char and get_default_char) self.align_right = to_cli_filter(align_right) self.align_center = to_cli_filter(align_center) self._has_focus_filter = to_cli_filter(has_focus) self.wrap_lines = to_cli_filter(wrap_lines) self.get_tokens = get_tokens # Construct `get_default_char` callable. if default_char: get_default_char = lambda _: default_char elif not get_default_char: get_default_char = lambda _: Char(' ', Token) self.get_default_char = get_default_char #: Cache for rendered screens. self._screen_lru_cache = SimpleLRUCache(maxsize=18) self._token_lru_cache = SimpleLRUCache(maxsize=1) # Only cache one token list. We don't need the previous item. # Render info for the mouse support. self._tokens = None # The last rendered tokens. self._pos_to_indexes = None # Mapping from mouse positions (x,y) to # positions in the token list. def __repr__(self): return '%s(%r)' % (self.__class__.__name__, self.get_tokens) def _get_tokens_cached(self, cli): """ Get tokens, but only retrieve tokens 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._token_lru_cache.get(cli.render_counter, lambda: self.get_tokens(cli)) def has_focus(self, cli): return self._has_focus_filter(cli) def preferred_width(self, cli, max_available_width): """ Return the preferred width for this control. That is the width of the longest line. """ text = ''.join(t[1] for t in self._get_tokens_cached(cli)) line_lengths = [get_cwidth(l) for l in text.split('\n')] return max(line_lengths) def preferred_height(self, cli, width): screen = self.create_screen(cli, width, None) return screen.height def create_screen(self, cli, width, height): # Get tokens tokens_with_mouse_handlers = self._get_tokens_cached(cli) default_char = self.get_default_char(cli) # Strip mouse handlers from tokens. tokens = [tuple(item[:2]) for item in tokens_with_mouse_handlers] # Wrap/align right/center parameters. wrap_lines = self.wrap_lines(cli) right = self.align_right(cli) center = self.align_center(cli) # Create screen, or take it from the cache. key = (default_char, tokens_with_mouse_handlers, width, wrap_lines, right, center) params = (default_char, tokens, width, wrap_lines, right, center) screen, self._pos_to_indexes = self._screen_lru_cache.get( key, lambda: self._get_screen(*params)) self._tokens = tokens_with_mouse_handlers return screen @classmethod def _get_screen(cls, default_char, tokens, width, wrap_lines, right, center): screen = Screen(default_char, initial_width=width) # Only call write_data when we actually have tokens. # (Otherwise the screen height will go up from 0 to 1 while we don't # want that. -- An empty control should not take up any space.) if tokens: def process_line(line): " Center or right align a single line. " used_width = token_list_width(line) padding = width - used_width if center: padding = int(padding / 2) return [(default_char.token, default_char.char * padding) ] + line + [(Token, '\n')] if right or center: tokens2 = [] for line in split_lines(tokens): tokens2.extend(process_line(line)) tokens = tokens2 indexes_to_pos = screen.write_data( tokens, width=(width if wrap_lines else None)) pos_to_indexes = dict((v, k) for k, v in indexes_to_pos.items()) else: pos_to_indexes = {} return screen, pos_to_indexes @classmethod def static(cls, tokens): def get_static_tokens(cli): return tokens return cls(get_static_tokens) def mouse_handler(self, cli, mouse_event): """ Handle mouse events. (When the token 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 `Window` to handle this particular event.) """ if self._pos_to_indexes: # Find position in the token list. position = mouse_event.position index = self._pos_to_indexes.get((position.x, position.y)) if index is not None: # Find mouse handler for this character. count = 0 for item in self._tokens: count += len(item[1]) if count >= index: if len(item) >= 3: # Handler found. Call it. handler = item[2] handler(cli, mouse_event) return else: break # Otherwise, don't handle here. return NotImplemented
class BufferControl(UIControl): """ Control for visualising the content of a `Buffer`. :param input_processors: list of :class:`~prompt_toolkit.layout.processors.Processor`. :param lexer: :class:`~prompt_toolkit.layout.lexers.Lexer` instance for syntax highlighting. :param preview_search: `bool` or `CLIFilter`: Show search while typing. :param wrap_lines: `bool` or `CLIFilter`: Wrap long lines. :param buffer_name: String representing the name of the buffer to display. :param default_char: :class:`.Char` instance to use to fill the background. This is transparent by default. """ def __init__(self, buffer_name=DEFAULT_BUFFER, input_processors=None, lexer=None, preview_search=False, wrap_lines=True, menu_position=None, default_char=None): assert input_processors is None or all( isinstance(i, Processor) for i in input_processors) assert menu_position is None or callable(menu_position) assert lexer is None or isinstance(lexer, Lexer) self.preview_search = to_cli_filter(preview_search) self.wrap_lines = to_cli_filter(wrap_lines) self.input_processors = input_processors or [] self.buffer_name = buffer_name self.menu_position = menu_position self.lexer = lexer or SimpleLexer() self.default_char = default_char or Char(token=Token.Transparent) #: LRU 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 faily easy way to cache such an expensive operation. self._token_lru_cache = SimpleLRUCache(maxsize=8) #: Keep a similar cache for rendered screens. (when we scroll up/down #: through the screen, or when we change another buffer, we don't want #: to recreate the same screen again.) self._screen_lru_cache = SimpleLRUCache(maxsize=8) self._xy_to_cursor_position = None self._last_click_timestamp = None def _buffer(self, cli): """ The buffer object that contains the 'main' content. """ return cli.buffers[self.buffer_name] def has_focus(self, cli): # This control gets the focussed if the actual `Buffer` instance has the # focus or when any of the `InputProcessor` classes tells us that it # wants the focus. (E.g. in case of a reverse-search, where the actual # search buffer may not be displayed, but the "reverse-i-search" text # should get the focus.) return cli.focus_stack.current == self.buffer_name or \ any(i.has_focus(cli) for i in self.input_processors) def preferred_width(self, cli, max_available_width): # Return the length of the longest line. return max(map(len, self._buffer(cli).document.lines)) def preferred_height(self, cli, width): # Draw content on a screen using this width. Measure the height of the # result. screen = self.create_screen(cli, width, None) return screen.height def _get_input_tokens(self, cli, document): """ Tokenize input text for highlighting. Return (tokens, source_to_display, display_to_source) tuple. :param document: The document to be shown. This can be `buffer.document` but could as well be a different one, in case we are searching through the history. (Buffer.document_for_search) """ def get(): # Call lexer. tokens = list(self.lexer.get_tokens(cli, document.text)) # 'Explode' tokens in characters. # (Some input processors -- like search/selection highlighter -- # rely on that each item in the tokens array only contains one # character.) tokens = [(token, c) for token, text in tokens for c in text] # Run all processors over the input. # (They can transform both the tokens and the cursor position.) source_to_display_functions = [] display_to_source_functions = [] d_ = document # Each processor receives the document of the previous one. for p in self.input_processors: transformation = p.apply_transformation(cli, d_, tokens) d_ = transformation.document assert isinstance(transformation, Transformation) tokens = transformation.tokens source_to_display_functions.append( transformation.source_to_display) display_to_source_functions.append( transformation.display_to_source) # Chain cursor transformation (movement) functions. def source_to_display(cursor_position): " Chained source_to_display. " for f in source_to_display_functions: cursor_position = f(cursor_position) return cursor_position def display_to_source(cursor_position): " Chained display_to_source. " for f in reversed(display_to_source_functions): cursor_position = f(cursor_position) return cursor_position return tokens, source_to_display, display_to_source key = ( document.text, # Include invalidation_hashes from all processors. tuple( p.invalidation_hash(cli, document) for p in self.input_processors), ) return self._token_lru_cache.get(key, get) def create_screen(self, cli, width, height): buffer = self._buffer(cli) # 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.) def preview_now(): """ True when we should preview a search. """ return bool( self.preview_search(cli) and cli.is_searching and cli.current_buffer.text) if preview_now(): document = buffer.document_for_search( SearchState(text=cli.current_buffer.text, direction=cli.search_state.direction, ignore_case=cli.search_state.ignore_case)) else: document = buffer.document # Wrap. wrap_width = width if self.wrap_lines(cli) else None def _create_screen(): screen = Screen(self.default_char, initial_width=width) # Get tokens # Note: we add the space character at the end, because that's where # the cursor can also be. input_tokens, source_to_display, display_to_source = self._get_input_tokens( cli, document) input_tokens += [(self.default_char.token, ' ')] indexes_to_pos = screen.write_data(input_tokens, width=wrap_width) pos_to_indexes = dict((v, k) for k, v in indexes_to_pos.items()) def cursor_position_to_xy(cursor_position): """ Turn a cursor position in the buffer into x/y coordinates on the screen. """ # First get the real token position by applying all transformations. cursor_position = source_to_display(cursor_position) # Then look up into the table. try: return indexes_to_pos[cursor_position] except KeyError: # This can fail with KeyError, but only if one of the # processors is returning invalid key locations. raise # return 0, 0 def xy_to_cursor_position(x, y): """ Turn x/y screen coordinates back to the original cursor position in the buffer. """ # Look up reverse in table. while x > 0 or y > 0: try: index = pos_to_indexes[x, y] break except KeyError: # No match found -> mouse click outside of region # containing text. Look to the left or up. if x: x -= 1 elif y: y -= 1 else: # Nobreak. index = 0 # Transform. return display_to_source(index) return screen, cursor_position_to_xy, xy_to_cursor_position # Build a key for the caching. If any of these parameters changes, we # have to recreate a new screen. key = ( # When the text changes, we obviously have to recreate a new screen. document.text, # When the width changes, line wrapping will be different. # (None when disabled.) wrap_width, # Include invalidation_hashes from all processors. tuple( p.invalidation_hash(cli, document) for p in self.input_processors), ) # Get from cache, or create if this doesn't exist yet. screen, cursor_position_to_xy, self._xy_to_cursor_position = self._screen_lru_cache.get( key, _create_screen) x, y = cursor_position_to_xy(document.cursor_position) screen.cursor_position = Point(y=y, x=x) # 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 focussed buffer.) if cli.current_buffer_name == self.buffer_name: menu_position = self.menu_position( cli) if self.menu_position else None if menu_position is not None: assert isinstance(menu_position, int) x, y = cursor_position_to_xy(menu_position) screen.menu_position = Point(y=y, x=x) 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.) x, y = cursor_position_to_xy( min( buffer.cursor_position, buffer.complete_state. original_document.cursor_position)) screen.menu_position = Point(y=y, x=x) else: screen.menu_position = None return screen def mouse_handler(self, cli, mouse_event): """ Mouse handler for this control. """ buffer = self._buffer(cli) position = mouse_event.position if self._xy_to_cursor_position: # Translate coordinates back to the cursor position of the # original input. pos = self._xy_to_cursor_position(position.x, position.y) # Set the cursor position. if pos <= len(buffer.text): if mouse_event.event_type == MouseEventTypes.MOUSE_DOWN: buffer.exit_selection() buffer.cursor_position = pos elif mouse_event.event_type == MouseEventTypes.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 - pos) > 1: buffer.start_selection( selection_type=SelectionType.CHARACTERS) buffer.cursor_position = pos # 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 < .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 def move_cursor_down(self, cli): b = self._buffer(cli) b.cursor_position += b.document.get_cursor_down_position() def move_cursor_up(self, cli): b = self._buffer(cli) b.cursor_position += b.document.get_cursor_up_position()
class Window(Container): """ Container that holds a control. :param content: :class:`~prompt_toolkit.layout.controls.UIControl` instance. :param width: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. :param height: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. :param get_width: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. :param get_height: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. :param dont_extend_width: When `True`, don't take up more width then the preferred width reported by the control. :param dont_extend_height: When `True`, don't take up more width then the preferred height reported by the control. :param left_margins: A list of :class:`~prompt_toolkit.layout.margins.Margin` instance to be displayed on the left. For instance: :class:`~prompt_toolkit.layout.margins.NumberredMargin` can be one of them in order to show line numbers. :param right_margins: Like `left_margins`, but on the other side. :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the preferred amount of lines/columns to be always visible before/after the cursor. When both top and bottom are a very high number, the cursor will be centered vertically most of the time. :param allow_scroll_beyond_bottom: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, allow scrolling so far, that the top part of the content is not visible anymore, while there is still empty space available at the bottom of the window. In the Vi editor for instance, this is possible. You will see tildes while the top part of the body is hidden. :param get_vertical_scroll: Callable that takes this window instance as input and returns a preferred vertical scroll. (When this is `None`, the scroll is only determined by the last and current cursor position.) :param get_horizontal_scroll: Callable that takes this window instance as input and returns a preferred vertical scroll. :param always_hide_cursor: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, never display the cursor, even when the user control specifies a cursor position. """ def __init__(self, content, width=None, height=None, get_width=None, get_height=None, dont_extend_width=False, dont_extend_height=False, left_margins=None, right_margins=None, scroll_offsets=None, allow_scroll_beyond_bottom=False, get_vertical_scroll=None, get_horizontal_scroll=None, always_hide_cursor=False): assert isinstance(content, UIControl) assert width is None or isinstance(width, LayoutDimension) assert height is None or isinstance(height, LayoutDimension) assert get_width is None or callable(get_width) assert get_height is None or callable(get_height) assert width is None or get_width is None assert height is None or get_height is None assert scroll_offsets is None or isinstance(scroll_offsets, ScrollOffsets) assert left_margins is None or all(isinstance(m, Margin) for m in left_margins) assert right_margins is None or all(isinstance(m, Margin) for m in right_margins) assert get_vertical_scroll is None or callable(get_vertical_scroll) assert get_horizontal_scroll is None or callable(get_horizontal_scroll) self.allow_scroll_beyond_bottom = to_cli_filter(allow_scroll_beyond_bottom) self.always_hide_cursor = to_cli_filter(always_hide_cursor) self.content = content self.dont_extend_width = dont_extend_width self.dont_extend_height = dont_extend_height self.left_margins = left_margins or [] self.right_margins = right_margins or [] self.scroll_offsets = scroll_offsets or ScrollOffsets() self.get_vertical_scroll = get_vertical_scroll self.get_horizontal_scroll = get_horizontal_scroll self._width = get_width or (lambda cli: width) self._height = get_height or (lambda cli: height) # Cache for the screens generated by the margin. self._margin_cache = SimpleLRUCache(maxsize=8) self.reset() def __repr__(self): return 'Window(content=%r)' % self.content def reset(self): self.content.reset() #: Scrolling position of the main content. self.vertical_scroll = 0 self.horizontal_scroll = 0 #: Keep render information (mappings between buffer input and render #: output.) self.render_info = None def preferred_width(self, cli, max_available_width): # Width of the margins. total_margin_width = sum(m.get_width(cli) for m in self.left_margins + self.right_margins) # Window of the content. preferred_width = self.content.preferred_width( cli, max_available_width - total_margin_width) if preferred_width is not None: preferred_width += total_margin_width # Merge. return self._merge_dimensions( dimension=self._width(cli), preferred=preferred_width, dont_extend=self.dont_extend_width) def preferred_height(self, cli, width): return self._merge_dimensions( dimension=self._height(cli), preferred=self.content.preferred_height(cli, width), dont_extend=self.dont_extend_height) @staticmethod def _merge_dimensions(dimension, preferred=None, dont_extend=False): """ Take the LayoutDimension from this `Window` class and the received preferred size from the `UIControl` and return a `LayoutDimension` to report to the parent container. """ dimension = dimension or LayoutDimension() # When a preferred dimension was explicitely given to the Window, # ignore the UIControl. if dimension.preferred_specified: preferred = dimension.preferred # When a 'preferred' dimension is given by the UIControl, make sure # that it stays within the bounds of the Window. if preferred is not None: if dimension.max: preferred = min(preferred, dimension.max) if dimension.min: preferred = max(preferred, dimension.min) # When a `dont_extend` flag has been given, use the preferred dimension # also as the max demension. if dont_extend and preferred is not None: max_ = min(dimension.max, preferred) else: max_ = dimension.max return LayoutDimension(min=dimension.min, max=max_, preferred=preferred) def write_to_screen(self, cli, screen, mouse_handlers, write_position): """ Write window to screen. This renders the user control, the margins and copies everything over to the absolute position at the given screen. """ # Render margins. left_margin_widths = [m.get_width(cli) for m in self.left_margins] right_margin_widths = [m.get_width(cli) for m in self.right_margins] total_margin_width = sum(left_margin_widths + right_margin_widths) # Render UserControl. temp_screen = self.content.create_screen( cli, write_position.width - total_margin_width, write_position.height) # Scroll content. applied_scroll_offsets = self._scroll( temp_screen, write_position.width - total_margin_width, write_position.height, cli) # Write body to screen. self._copy_body(cli, temp_screen, screen, write_position, sum(left_margin_widths), write_position.width - total_margin_width, applied_scroll_offsets) # Remember render info. (Set before generating the margins. They need this.) self.render_info = WindowRenderInfo( original_screen=temp_screen, horizontal_scroll=self.horizontal_scroll, vertical_scroll=self.vertical_scroll, window_width=write_position.width, window_height=write_position.height, cursor_position=Point(y=temp_screen.cursor_position.y - self.vertical_scroll, x=temp_screen.cursor_position.x - self.horizontal_scroll), configured_scroll_offsets=self.scroll_offsets, applied_scroll_offsets=applied_scroll_offsets) # Set mouse handlers. def mouse_handler(cli, mouse_event): """ Wrapper around the mouse_handler of the `UIControl` that turns absolute coordinates into relative coordinates. """ position = mouse_event.position # Call the mouse handler of the UIControl first. result = self.content.mouse_handler( cli, MouseEvent( position=Point(x=position.x - write_position.xpos - sum(left_margin_widths), y=position.y - write_position.ypos + self.vertical_scroll), event_type=mouse_event.event_type)) # If it returns NotImplemented, handle it here. if result == NotImplemented: return self._mouse_handler(cli, mouse_event) return result mouse_handlers.set_mouse_handler_for_range( x_min=write_position.xpos + sum(left_margin_widths), x_max=write_position.xpos + write_position.width - total_margin_width, y_min=write_position.ypos, y_max=write_position.ypos + write_position.height, handler=mouse_handler) # Render and copy margins. move_x = 0 def render_margin(m, width): " Render margin. return `Screen`. " # Retrieve margin tokens. tokens = m.create_margin(cli, self.render_info, width, write_position.height) # Turn it into a screen. (Take a screen from the cache if we # already rendered those tokens using this size.) def create_screen(): return TokenListControl.static(tokens).create_screen( cli, width + 1, write_position.height) key = (tokens, width, write_position.height) return self._margin_cache.get(key, create_screen) for m, width in zip(self.left_margins, left_margin_widths): # Create screen for margin. margin_screen = render_margin(m, width) # Copy and shift X. self._copy_margin(margin_screen, screen, 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_screen = render_margin(m, width) # Copy and shift X. self._copy_margin(margin_screen, screen, write_position, move_x, width) move_x += width def _copy_body(self, cli, temp_screen, new_screen, write_position, move_x, width, applied_scroll_offsets): """ Copy characters from the temp screen that we got from the `UIControl` to the real screen. """ xpos = write_position.xpos + move_x ypos = write_position.ypos height = write_position.height temp_buffer = temp_screen.data_buffer new_buffer = new_screen.data_buffer temp_screen_height = temp_screen.height y = 0 # Now copy the region we need to the real screen. for y in range(0, height): # We keep local row variables. (Don't look up the row in the dict # for each iteration of the nested loop.) new_row = new_buffer[y + ypos] if y >= temp_screen_height and y >= write_position.height: # Break out of for loop when we pass after the last row of the # temp screen. (We use the 'y' position for calculation of new # screen's height.) break else: temp_row = temp_buffer[y + self.vertical_scroll] # Copy row content, except for transparent tokens. # (This is useful in case of floats.) for x in range(0, width): cell = temp_row[x + self.horizontal_scroll] if cell.token != Transparent: new_row[x + xpos] = cell if self.content.has_focus(cli): new_screen.cursor_position = Point(y=temp_screen.cursor_position.y + ypos - self.vertical_scroll, x=temp_screen.cursor_position.x + xpos - self.horizontal_scroll) if not self.always_hide_cursor(cli): new_screen.show_cursor = temp_screen.show_cursor if not new_screen.menu_position and temp_screen.menu_position: new_screen.menu_position = Point(y=temp_screen.menu_position.y + ypos - self.vertical_scroll, x=temp_screen.menu_position.x + xpos - self.horizontal_scroll) # Update height of the output screen. (new_screen.write_data is not # called, so the screen is not aware of its height.) new_screen.height = max(new_screen.height, ypos + y + 1) def _copy_margin(self, temp_screen, new_screen, write_position, move_x, width): """ Copy characters from the margin screen to the real screen. """ xpos = write_position.xpos + move_x ypos = write_position.ypos temp_buffer = temp_screen.data_buffer new_buffer = new_screen.data_buffer # Now copy the region we need to the real screen. for y in range(0, write_position.height): new_row = new_buffer[y + ypos] temp_row = temp_buffer[y] # Copy row content, except for transparent tokens. # (This is useful in case of floats.) for x in range(0, width): cell = temp_row[x] if cell.token != Transparent: new_row[x + xpos] = cell def _scroll(self, temp_screen, width, height, cli): """ Scroll to make sure the cursor position is visible and that we maintain the requested scroll offset. Return the applied scroll offsets. """ def do_scroll(current_scroll, scroll_offset_start, scroll_offset_end, cursor_pos, window_size, content_size): " Scrolling algorithm. Used for both horizontal and vertical scrolling. " # Calculate the scroll offset to apply. # This can obviously never be more than have the screen size. Also, when the # cursor appears at the top or bottom, we don't apply the offset. scroll_offset_start = int(min(scroll_offset_start, window_size / 2, cursor_pos)) scroll_offset_end = int(min(scroll_offset_end, window_size / 2, content_size - 1 - cursor_pos)) # Prevent negative scroll offsets. if current_scroll < 0: current_scroll = 0 # Scroll back if we scrolled to much and there's still space to show more of the document. if (not self.allow_scroll_beyond_bottom(cli) and current_scroll > content_size - window_size): current_scroll = max(0, content_size - window_size) # Scroll up if cursor is before visible part. if current_scroll > cursor_pos - scroll_offset_start: current_scroll = max(0, cursor_pos - scroll_offset_start) # Scroll down if cursor is after visible part. if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end: current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end # Calculate the applied scroll offset. This value can be lower than what we had. scroll_offset_start = max(0, min(current_scroll, scroll_offset_start)) scroll_offset_end = max(0, min(content_size - current_scroll - window_size, scroll_offset_end)) return current_scroll, scroll_offset_start, scroll_offset_end # When a preferred scroll is given, take that first into account. if self.get_vertical_scroll: self.vertical_scroll = self.get_vertical_scroll(self) assert isinstance(self.vertical_scroll, int) if self.get_horizontal_scroll: self.horizontal_scroll = self.get_horizontal_scroll(self) assert isinstance(self.horizontal_scroll, int) # Update horizontal/vertical scroll to make sure that the cursor # remains visible. offsets = self.scroll_offsets self.vertical_scroll, scroll_offset_top, scroll_offset_bottom = do_scroll( current_scroll=self.vertical_scroll, scroll_offset_start=offsets.top, scroll_offset_end=offsets.bottom, cursor_pos=temp_screen.cursor_position.y, window_size=height, content_size=temp_screen.height) self.horizontal_scroll, scroll_offset_left, scroll_offset_right = do_scroll( current_scroll=self.horizontal_scroll, scroll_offset_start=offsets.left, scroll_offset_end=offsets.right, cursor_pos=temp_screen.cursor_position.x, window_size=width, content_size=temp_screen.width) applied_scroll_offsets = ScrollOffsets( top=scroll_offset_top, bottom=scroll_offset_bottom, left=scroll_offset_left, right=scroll_offset_right) return applied_scroll_offsets def _mouse_handler(self, cli, mouse_event): """ Mouse handler. Called when the UI control doesn't handle this particular event. """ if mouse_event.event_type == MouseEventTypes.SCROLL_DOWN: self._scroll_down(cli) elif mouse_event.event_type == MouseEventTypes.SCROLL_UP: self._scroll_up(cli) def _scroll_down(self, cli): " Scroll window down. " info = self.render_info if self.vertical_scroll < info.content_height - info.window_height: if info.cursor_position.y <= info.configured_scroll_offsets.top: self.content.move_cursor_down(cli) self.vertical_scroll += 1 def _scroll_up(self, cli): " Scroll window up. " info = self.render_info if info.vertical_scroll > 0: if info.cursor_position.y >= info.window_height - 1 - info.configured_scroll_offsets.bottom: self.content.move_cursor_up(cli) self.vertical_scroll -= 1 def walk(self): # Only yield self. A window doesn't have children. yield self
class TokenListControl(UIControl): """ Control that displays a list of (Token, text) tuples. (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) Mouse support: The list of tokens can also contain tuples of three items, looking like: (Token, text, handler). When mouse support is enabled and the user clicks on this token, then the given handler is called. That handler should accept two inputs: (CommandLineInterface, MouseEvent) and it should either handle the event or return `NotImplemented` in case we want the containing Window to handle this event. :param get_tokens: Callable that takes a `CommandLineInterface` instance and returns the list of (Token, text) tuples to be displayed right now. :param default_char: default :class:`.Char` (character and Token) to use for the background when there is more space available than `get_tokens` returns. :param get_default_char: Like `default_char`, but this is a callable that takes a :class:`prompt_toolkit.interface.CommandLineInterface` and returns a :class:`.Char` instance. :param has_focus: `bool` or `CLIFilter`, when this evaluates to `True`, this UI control will take the focus. The cursor will be shown in the upper left corner of this control, unless `get_token` returns a ``Token.SetCursorPosition`` token somewhere in the token list, then the cursor will be shown there. :param wrap_lines: `bool` or `CLIFilter`: Wrap long lines. """ def __init__(self, get_tokens, default_char=None, get_default_char=None, align_right=False, align_center=False, has_focus=False, wrap_lines=True): assert default_char is None or isinstance(default_char, Char) assert get_default_char is None or callable(get_default_char) assert not (default_char and get_default_char) self.align_right = to_cli_filter(align_right) self.align_center = to_cli_filter(align_center) self._has_focus_filter = to_cli_filter(has_focus) self.wrap_lines = to_cli_filter(wrap_lines) self.get_tokens = get_tokens # Construct `get_default_char` callable. if default_char: get_default_char = lambda _: default_char elif not get_default_char: get_default_char = lambda _: Char(' ', Token) self.get_default_char = get_default_char #: Cache for rendered screens. self._screen_lru_cache = SimpleLRUCache(maxsize=18) self._token_lru_cache = SimpleLRUCache(maxsize=1) # Only cache one token list. We don't need the previous item. # Render info for the mouse support. self._tokens = None # The last rendered tokens. self._pos_to_indexes = None # Mapping from mouse positions (x,y) to # positions in the token list. def __repr__(self): return '%s(%r)' % (self.__class__.__name__, self.get_tokens) def _get_tokens_cached(self, cli): """ Get tokens, but only retrieve tokens 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._token_lru_cache.get( cli.render_counter, lambda: self.get_tokens(cli)) def has_focus(self, cli): return self._has_focus_filter(cli) def preferred_width(self, cli, max_available_width): """ Return the preferred width for this control. That is the width of the longest line. """ text = ''.join(t[1] for t in self._get_tokens_cached(cli)) line_lengths = [get_cwidth(l) for l in text.split('\n')] return max(line_lengths) def preferred_height(self, cli, width): screen = self.create_screen(cli, width, None) return screen.height def create_screen(self, cli, width, height): # Get tokens tokens_with_mouse_handlers = self._get_tokens_cached(cli) default_char = self.get_default_char(cli) # Wrap/align right/center parameters. wrap_lines = self.wrap_lines(cli) right = self.align_right(cli) center = self.align_center(cli) def process_line(line): " Center or right align a single line. " used_width = token_list_width(line) padding = width - used_width if center: padding = int(padding / 2) return [(default_char.token, default_char.char * padding)] + line + [(Token, '\n')] if right or center: tokens2 = [] for line in split_lines(tokens_with_mouse_handlers): tokens2.extend(process_line(line)) tokens_with_mouse_handlers = tokens2 # Strip mouse handlers from tokens. tokens = [tuple(item[:2]) for item in tokens_with_mouse_handlers] # Create screen, or take it from the cache. key = (default_char, tokens_with_mouse_handlers, width, wrap_lines, right, center) params = (default_char, tokens, width, wrap_lines, right, center) screen, self._pos_to_indexes = self._screen_lru_cache.get(key, lambda: self._get_screen(*params)) self._tokens = tokens_with_mouse_handlers return screen @classmethod def _get_screen(cls, default_char, tokens, width, wrap_lines, right, center): screen = Screen(default_char, initial_width=width) # Only call write_data when we actually have tokens. # (Otherwise the screen height will go up from 0 to 1 while we don't # want that. -- An empty control should not take up any space.) if tokens: write_data_result = screen.write_data(tokens, width=(width if wrap_lines else None)) indexes_to_pos = write_data_result.indexes_to_pos pos_to_indexes = dict((v, k) for k, v in indexes_to_pos.items()) else: pos_to_indexes = {} return screen, pos_to_indexes @classmethod def static(cls, tokens): def get_static_tokens(cli): return tokens return cls(get_static_tokens) def mouse_handler(self, cli, mouse_event): """ Handle mouse events. (When the token 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 `Window` to handle this particular event.) """ if self._pos_to_indexes: # Find position in the token list. position = mouse_event.position index = self._pos_to_indexes.get((position.x, position.y)) if index is not None: # Find mouse handler for this character. count = 0 for item in self._tokens: count += len(item[1]) if count >= index: if len(item) >= 3: # Handler found. Call it. handler = item[2] handler(cli, mouse_event) return else: break # Otherwise, don't handle here. return NotImplemented
class BufferControl(UIControl): """ Control for visualising the content of a `Buffer`. :param input_processors: list of :class:`~prompt_toolkit.layout.processors.Processor`. :param lexer: :class:`~prompt_toolkit.layout.lexers.Lexer` instance for syntax highlighting. :param preview_search: `bool` or `CLIFilter`: Show search while typing. :param get_search_state: Callable that takes a CommandLineInterface and returns the SearchState to be used. (If not CommandLineInterface.search_state.) :param wrap_lines: `bool` or `CLIFilter`: Wrap long lines. :param buffer_name: String representing the name of the buffer to display. :param default_char: :class:`.Char` instance to use to fill the background. This is transparent by default. :param focus_on_click: Focus this buffer when it's click, but not yet focussed. """ def __init__(self, buffer_name=DEFAULT_BUFFER, input_processors=None, highlighters=None, lexer=None, preview_search=False, search_buffer_name=SEARCH_BUFFER, get_search_state=None, wrap_lines=True, menu_position=None, default_char=None, focus_on_click=False): assert input_processors is None or all(isinstance(i, Processor) for i in input_processors) assert highlighters is None or all(isinstance(i, Highlighter) for i in highlighters) assert menu_position is None or callable(menu_position) assert lexer is None or isinstance(lexer, Lexer) assert get_search_state is None or callable(get_search_state) self.preview_search = to_cli_filter(preview_search) self.get_search_state = get_search_state self.wrap_lines = to_cli_filter(wrap_lines) self.focus_on_click = to_cli_filter(focus_on_click) self.input_processors = input_processors or [] self.highlighters = highlighters or [] self.buffer_name = buffer_name self.menu_position = menu_position self.lexer = lexer or SimpleLexer() self.default_char = default_char or Char(token=Token.Transparent) self.search_buffer_name = search_buffer_name #: LRU 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 faily easy way to cache such an expensive operation. self._token_lru_cache = SimpleLRUCache(maxsize=8) #: Keep a similar cache for rendered screens. (when we scroll up/down #: through the screen, or when we change another buffer, we don't want #: to recreate the same screen again.) self._screen_lru_cache = SimpleLRUCache(maxsize=8) #: Highlight Cache. #: When nothing of the buffer content or processors has changed, but #: the highlighting of the selection/search changes, self._highlight_lru_cache = SimpleLRUCache(maxsize=8) self._xy_to_cursor_position = None self._last_click_timestamp = None def _buffer(self, cli): """ The buffer object that contains the 'main' content. """ return cli.buffers[self.buffer_name] def has_focus(self, cli): # This control gets the focussed if the actual `Buffer` instance has the # focus or when any of the `InputProcessor` classes tells us that it # wants the focus. (E.g. in case of a reverse-search, where the actual # search buffer may not be displayed, but the "reverse-i-search" text # should get the focus.) return cli.focus_stack.current == self.buffer_name or \ any(i.has_focus(cli) for i in self.input_processors) def preferred_width(self, cli, max_available_width): # Return the length of the longest line. return max(map(len, self._buffer(cli).document.lines)) def preferred_height(self, cli, width): # Draw content on a screen using this width. Measure the height of the # result. screen, highlighters = self.create_screen(cli, width, None) return screen.height def _get_input_tokens(self, cli, document): """ Tokenize input text for highlighting. Return (tokens, source_to_display, display_to_source) tuple. :param document: The document to be shown. This can be `buffer.document` but could as well be a different one, in case we are searching through the history. (Buffer.document_for_search) """ def get(): # Call lexer. tokens = list(self.lexer.get_tokens(cli, document.text)) # 'Explode' tokens in characters. # (Some input processors -- like search/selection highlighter -- # rely on that each item in the tokens array only contains one # character.) tokens = [(token, c) for token, text in tokens for c in text] # Run all processors over the input. # (They can transform both the tokens and the cursor position.) source_to_display_functions = [] display_to_source_functions = [] d_ = document # Each processor receives the document of the previous one. for p in self.input_processors: transformation = p.apply_transformation(cli, d_, tokens) d_ = transformation.document assert isinstance(transformation, Transformation) tokens = transformation.tokens source_to_display_functions.append(transformation.source_to_display) display_to_source_functions.append(transformation.display_to_source) # Chain cursor transformation (movement) functions. def source_to_display(cursor_position): " Chained source_to_display. " for f in source_to_display_functions: cursor_position = f(cursor_position) return cursor_position def display_to_source(cursor_position): " Chained display_to_source. " for f in reversed(display_to_source_functions): cursor_position = f(cursor_position) return cursor_position return tokens, source_to_display, display_to_source key = ( document.text, # Include invalidation_hashes from all processors. tuple(p.invalidation_hash(cli, document) for p in self.input_processors), ) return self._token_lru_cache.get(key, get) def create_screen(self, cli, width, height): buffer = self._buffer(cli) # 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.) def preview_now(): """ True when we should preview a search. """ return bool(self.preview_search(cli) and cli.buffers[self.search_buffer_name].text) if preview_now(): if self.get_search_state: ss = self.get_search_state(cli) else: ss = cli.search_state document = buffer.document_for_search(SearchState( text=cli.current_buffer.text, direction=ss.direction, ignore_case=ss.ignore_case)) else: document = buffer.document # Wrap. wrap_width = width if self.wrap_lines(cli) else None def _create_screen(): screen = Screen(self.default_char, initial_width=width) # Get tokens # Note: we add the space character at the end, because that's where # the cursor can also be. input_tokens, source_to_display, display_to_source = self._get_input_tokens(cli, document) input_tokens += [(self.default_char.token, ' ')] write_data_result = screen.write_data(input_tokens, width=wrap_width) indexes_to_pos = write_data_result.indexes_to_pos line_lengths = write_data_result.line_lengths pos_to_indexes = dict((v, k) for k, v in indexes_to_pos.items()) def cursor_position_to_xy(cursor_position): """ Turn a cursor position in the buffer into x/y coordinates on the screen. """ cursor_position = min(len(document.text), cursor_position) # First get the real token position by applying all transformations. cursor_position = source_to_display(cursor_position) # Then look up into the table. try: return indexes_to_pos[cursor_position] except KeyError: # This can fail with KeyError, but only if one of the # processors is returning invalid key locations. raise # return 0, 0 def xy_to_cursor_position(x, y): """ Turn x/y screen coordinates back to the original cursor position in the buffer. """ # Look up reverse in table. while x > 0 or y > 0: try: index = pos_to_indexes[x, y] break except KeyError: # No match found -> mouse click outside of region # containing text. Look to the left or up. if x: x -= 1 elif y: y -=1 else: # Nobreak. index = 0 # Transform. return display_to_source(index) return screen, cursor_position_to_xy, xy_to_cursor_position, line_lengths # Build a key for the caching. If any of these parameters changes, we # have to recreate a new screen. key = ( # When the text changes, we obviously have to recreate a new screen. document.text, # When the width changes, line wrapping will be different. # (None when disabled.) wrap_width, # Include invalidation_hashes from all processors. tuple(p.invalidation_hash(cli, document) for p in self.input_processors), ) # Get from cache, or create if this doesn't exist yet. screen, cursor_position_to_xy, self._xy_to_cursor_position, line_lengths = \ self._screen_lru_cache.get(key, _create_screen) x, y = cursor_position_to_xy(document.cursor_position) screen.cursor_position = Point(y=y, x=x) # 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 focussed buffer.) if cli.current_buffer_name == self.buffer_name: menu_position = self.menu_position(cli) if self.menu_position else None if menu_position is not None: assert isinstance(menu_position, int) x, y = cursor_position_to_xy(menu_position) screen.menu_position = Point(y=y, x=x) 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.) x, y = cursor_position_to_xy( min(buffer.cursor_position, buffer.complete_state.original_document.cursor_position)) screen.menu_position = Point(y=y, x=x) else: screen.menu_position = None # Add highlighting. highlight_key = ( key, # Includes everything from the 'key' above. (E.g. when the # document changes, we have to recalculate highlighting.) # Include invalidation_hashes from all highlighters. tuple(h.invalidation_hash(cli, document) for h in self.highlighters) ) highlighting = self._highlight_lru_cache.get(highlight_key, lambda: self._get_highlighting(cli, document, cursor_position_to_xy, line_lengths)) return screen, highlighting def _get_highlighting(self, cli, document, cursor_position_to_xy, line_lengths): """ Return a _HighlightDict for the highlighting. (This is a lazy dict of dicts.) The Window class will apply this for the visible regions. - That way, we don't have to recalculate the screen again for each selection/search change. :param line_lengths: Maps line numbers to the length of these lines. """ def get_row_size(y): " Return the max 'x' value for a given row in the screen. " return max(1, line_lengths.get(y, 0)) # Get list of fragments. row_to_fragments = defaultdict(list) for h in self.highlighters: for fragment in h.get_fragments(cli, document): # Expand fragments. start_column, start_row = cursor_position_to_xy(fragment.start) end_column, end_row = cursor_position_to_xy(fragment.end) token = fragment.token if start_row == end_row: # Single line highlighting. row_to_fragments[start_row].append( _HighlightFragment(start_column, end_column, token)) else: # Multi line highlighting. # (First line.) row_to_fragments[start_row].append( _HighlightFragment(start_column, get_row_size(start_row), token)) # (Middle lines.) for y in range(start_row + 1, end_row): row_to_fragments[y].append(_HighlightFragment(0, get_row_size(y), token)) # (Last line.) row_to_fragments[end_row].append(_HighlightFragment(0, end_column, token)) # Create dict to return. return _HighlightDict(row_to_fragments) def mouse_handler(self, cli, mouse_event): """ Mouse handler for this control. """ buffer = self._buffer(cli) position = mouse_event.position # Focus buffer when clicked. if self.has_focus(cli): if self._xy_to_cursor_position: # Translate coordinates back to the cursor position of the # original input. pos = self._xy_to_cursor_position(position.x, position.y) # Set the cursor position. if pos <= len(buffer.text): if mouse_event.event_type == MouseEventTypes.MOUSE_DOWN: buffer.exit_selection() buffer.cursor_position = pos elif mouse_event.event_type == MouseEventTypes.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 - pos) > 1: buffer.start_selection(selection_type=SelectionType.CHARACTERS) buffer.cursor_position = pos # 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 < .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 focussed, but focussing on click events. else: if self.focus_on_click(cli) and mouse_event.event_type == MouseEventTypes.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 # focussed and be handled anyway.) cli.focus_stack.replace(self.buffer_name) else: return NotImplemented def move_cursor_down(self, cli): b = self._buffer(cli) b.cursor_position += b.document.get_cursor_down_position() def move_cursor_up(self, cli): b = self._buffer(cli) b.cursor_position += b.document.get_cursor_up_position()
class Window(Container): """ Container that holds a control. :param content: :class:`~prompt_toolkit.layout.controls.UIControl` instance. :param width: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. :param height: :class:`~prompt_toolkit.layout.dimension.LayoutDimension` instance. :param get_width: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. :param get_height: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. :param dont_extend_width: When `True`, don't take up more width then the preferred width reported by the control. :param dont_extend_height: When `True`, don't take up more width then the preferred height reported by the control. :param left_margins: A list of :class:`~prompt_toolkit.layout.margins.Margin` instance to be displayed on the left. For instance: :class:`~prompt_toolkit.layout.margins.NumberredMargin` can be one of them in order to show line numbers. :param right_margins: Like `left_margins`, but on the other side. :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the preferred amount of lines/columns to be always visible before/after the cursor. When both top and bottom are a very high number, the cursor will be centered vertically most of the time. :param allow_scroll_beyond_bottom: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, allow scrolling so far, that the top part of the content is not visible anymore, while there is still empty space available at the bottom of the window. In the Vi editor for instance, this is possible. You will see tildes while the top part of the body is hidden. :param get_vertical_scroll: Callable that takes this window instance as input and returns a preferred vertical scroll. (When this is `None`, the scroll is only determined by the last and current cursor position.) :param get_horizontal_scroll: Callable that takes this window instance as input and returns a preferred vertical scroll. :param always_hide_cursor: A `bool` or :class:`~prompt_toolkit.filters.CLIFilter` instance. When True, never display the cursor, even when the user control specifies a cursor position. """ def __init__(self, content, width=None, height=None, get_width=None, get_height=None, dont_extend_width=False, dont_extend_height=False, left_margins=None, right_margins=None, scroll_offsets=None, allow_scroll_beyond_bottom=False, get_vertical_scroll=None, get_horizontal_scroll=None, always_hide_cursor=False): assert isinstance(content, UIControl) assert width is None or isinstance(width, LayoutDimension) assert height is None or isinstance(height, LayoutDimension) assert get_width is None or callable(get_width) assert get_height is None or callable(get_height) assert width is None or get_width is None assert height is None or get_height is None assert scroll_offsets is None or isinstance(scroll_offsets, ScrollOffsets) assert left_margins is None or all( isinstance(m, Margin) for m in left_margins) assert right_margins is None or all( isinstance(m, Margin) for m in right_margins) assert get_vertical_scroll is None or callable(get_vertical_scroll) assert get_horizontal_scroll is None or callable(get_horizontal_scroll) self.allow_scroll_beyond_bottom = to_cli_filter( allow_scroll_beyond_bottom) self.always_hide_cursor = to_cli_filter(always_hide_cursor) self.content = content self.dont_extend_width = dont_extend_width self.dont_extend_height = dont_extend_height self.left_margins = left_margins or [] self.right_margins = right_margins or [] self.scroll_offsets = scroll_offsets or ScrollOffsets() self.get_vertical_scroll = get_vertical_scroll self.get_horizontal_scroll = get_horizontal_scroll self._width = get_width or (lambda cli: width) self._height = get_height or (lambda cli: height) # Cache for the screens generated by the margin. self._margin_cache = SimpleLRUCache(maxsize=8) self.reset() def __repr__(self): return 'Window(content=%r)' % self.content def reset(self): self.content.reset() #: Scrolling position of the main content. self.vertical_scroll = 0 self.horizontal_scroll = 0 #: Keep render information (mappings between buffer input and render #: output.) self.render_info = None def preferred_width(self, cli, max_available_width): # Width of the margins. total_margin_width = sum( m.get_width(cli) for m in self.left_margins + self.right_margins) # Window of the content. preferred_width = self.content.preferred_width( cli, max_available_width - total_margin_width) if preferred_width is not None: preferred_width += total_margin_width # Merge. return self._merge_dimensions(dimension=self._width(cli), preferred=preferred_width, dont_extend=self.dont_extend_width) def preferred_height(self, cli, width): return self._merge_dimensions(dimension=self._height(cli), preferred=self.content.preferred_height( cli, width), dont_extend=self.dont_extend_height) @staticmethod def _merge_dimensions(dimension, preferred=None, dont_extend=False): """ Take the LayoutDimension from this `Window` class and the received preferred size from the `UIControl` and return a `LayoutDimension` to report to the parent container. """ dimension = dimension or LayoutDimension() # When a preferred dimension was explicitly given to the Window, # ignore the UIControl. if dimension.preferred_specified: preferred = dimension.preferred # When a 'preferred' dimension is given by the UIControl, make sure # that it stays within the bounds of the Window. if preferred is not None: if dimension.max: preferred = min(preferred, dimension.max) if dimension.min: preferred = max(preferred, dimension.min) # When a `dont_extend` flag has been given, use the preferred dimension # also as the max dimension. if dont_extend and preferred is not None: max_ = min(dimension.max, preferred) else: max_ = dimension.max return LayoutDimension(min=dimension.min, max=max_, preferred=preferred) def write_to_screen(self, cli, screen, mouse_handlers, write_position): """ Write window to screen. This renders the user control, the margins and copies everything over to the absolute position at the given screen. """ # Calculate margin sizes. left_margin_widths = [m.get_width(cli) for m in self.left_margins] right_margin_widths = [m.get_width(cli) for m in self.right_margins] total_margin_width = sum(left_margin_widths + right_margin_widths) # Render UserControl. tpl = self.content.create_screen( cli, write_position.width - total_margin_width, write_position.height) if isinstance(tpl, tuple): temp_screen, highlighting = tpl else: # For backwards, compatibility. temp_screen, highlighting = tpl, defaultdict( lambda: defaultdict(lambda: None)) # Scroll content. applied_scroll_offsets = self._scroll( temp_screen, write_position.width - total_margin_width, write_position.height, cli) # Write body to screen. self._copy_body(cli, temp_screen, highlighting, screen, write_position, sum(left_margin_widths), write_position.width - total_margin_width, applied_scroll_offsets) # Remember render info. (Set before generating the margins. They need this.) self.render_info = WindowRenderInfo( original_screen=temp_screen, horizontal_scroll=self.horizontal_scroll, vertical_scroll=self.vertical_scroll, window_width=write_position.width, window_height=write_position.height, cursor_position=Point( y=temp_screen.cursor_position.y - self.vertical_scroll, x=temp_screen.cursor_position.x - self.horizontal_scroll), configured_scroll_offsets=self.scroll_offsets, applied_scroll_offsets=applied_scroll_offsets) # Set mouse handlers. def mouse_handler(cli, mouse_event): """ Wrapper around the mouse_handler of the `UIControl` that turns absolute coordinates into relative coordinates. """ position = mouse_event.position # Call the mouse handler of the UIControl first. result = self.content.mouse_handler( cli, MouseEvent(position=Point(x=position.x - write_position.xpos - sum(left_margin_widths), y=position.y - write_position.ypos + self.vertical_scroll), event_type=mouse_event.event_type)) # If it returns NotImplemented, handle it here. if result == NotImplemented: return self._mouse_handler(cli, mouse_event) return result mouse_handlers.set_mouse_handler_for_range( x_min=write_position.xpos + sum(left_margin_widths), x_max=write_position.xpos + write_position.width - total_margin_width, y_min=write_position.ypos, y_max=write_position.ypos + write_position.height, handler=mouse_handler) # Render and copy margins. move_x = 0 def render_margin(m, width): " Render margin. Return `Screen`. " # Retrieve margin tokens. tokens = m.create_margin(cli, self.render_info, width, write_position.height) # Turn it into a screen. (Take a screen from the cache if we # already rendered those tokens using this size.) def create_screen(): return TokenListControl.static(tokens).create_screen( cli, width + 1, write_position.height) key = (tokens, width, write_position.height) return self._margin_cache.get(key, create_screen) for m, width in zip(self.left_margins, left_margin_widths): # Create screen for margin. margin_screen = render_margin(m, width) # Copy and shift X. self._copy_margin(margin_screen, screen, 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_screen = render_margin(m, width) # Copy and shift X. self._copy_margin(margin_screen, screen, write_position, move_x, width) move_x += width def _copy_body(self, cli, temp_screen, highlighting, new_screen, write_position, move_x, width, applied_scroll_offsets): """ Copy characters from the temp screen that we got from the `UIControl` to the real screen. """ xpos = write_position.xpos + move_x ypos = write_position.ypos height = write_position.height temp_buffer = temp_screen.data_buffer new_buffer = new_screen.data_buffer temp_screen_height = temp_screen.height vertical_scroll = self.vertical_scroll horizontal_scroll = self.horizontal_scroll y = 0 # Now copy the region we need to the real screen. for y in range(0, height): # We keep local row variables. (Don't look up the row in the dict # for each iteration of the nested loop.) new_row = new_buffer[y + ypos] if y >= temp_screen_height and y >= write_position.height: # Break out of for loop when we pass after the last row of the # temp screen. (We use the 'y' position for calculation of new # screen's height.) break else: temp_row = temp_buffer[y + vertical_scroll] highlighting_row = highlighting[y + vertical_scroll] # Copy row content, except for transparent tokens. # (This is useful in case of floats.) # Also apply highlighting. for x in range(0, width): cell = temp_row[x + horizontal_scroll] highlighting_token = highlighting_row[x] if highlighting_token: new_row[x + xpos] = Char(cell.char, highlighting_token) elif cell.token != Transparent: new_row[x + xpos] = cell if self.content.has_focus(cli): new_screen.cursor_position = Point( y=temp_screen.cursor_position.y + ypos - vertical_scroll, x=temp_screen.cursor_position.x + xpos - horizontal_scroll) if not self.always_hide_cursor(cli): new_screen.show_cursor = temp_screen.show_cursor if not new_screen.menu_position and temp_screen.menu_position: new_screen.menu_position = Point( y=temp_screen.menu_position.y + ypos - vertical_scroll, x=temp_screen.menu_position.x + xpos - horizontal_scroll) # Update height of the output screen. (new_screen.write_data is not # called, so the screen is not aware of its height.) new_screen.height = max(new_screen.height, ypos + y + 1) def _copy_margin(self, temp_screen, new_screen, write_position, move_x, width): """ Copy characters from the margin screen to the real screen. """ xpos = write_position.xpos + move_x ypos = write_position.ypos temp_buffer = temp_screen.data_buffer new_buffer = new_screen.data_buffer # Now copy the region we need to the real screen. for y in range(0, write_position.height): new_row = new_buffer[y + ypos] temp_row = temp_buffer[y] # Copy row content, except for transparent tokens. # (This is useful in case of floats.) for x in range(0, width): cell = temp_row[x] if cell.token != Transparent: new_row[x + xpos] = cell def _scroll(self, temp_screen, width, height, cli): """ Scroll to make sure the cursor position is visible and that we maintain the requested scroll offset. Return the applied scroll offsets. """ def do_scroll(current_scroll, scroll_offset_start, scroll_offset_end, cursor_pos, window_size, content_size): " Scrolling algorithm. Used for both horizontal and vertical scrolling. " # Calculate the scroll offset to apply. # This can obviously never be more than have the screen size. Also, when the # cursor appears at the top or bottom, we don't apply the offset. scroll_offset_start = int( min(scroll_offset_start, window_size / 2, cursor_pos)) scroll_offset_end = int( min(scroll_offset_end, window_size / 2, content_size - 1 - cursor_pos)) # Prevent negative scroll offsets. if current_scroll < 0: current_scroll = 0 # Scroll back if we scrolled to much and there's still space to show more of the document. if (not self.allow_scroll_beyond_bottom(cli) and current_scroll > content_size - window_size): current_scroll = max(0, content_size - window_size) # Scroll up if cursor is before visible part. if current_scroll > cursor_pos - scroll_offset_start: current_scroll = max(0, cursor_pos - scroll_offset_start) # Scroll down if cursor is after visible part. if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end: current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end # Calculate the applied scroll offset. This value can be lower than what we had. scroll_offset_start = max(0, min(current_scroll, scroll_offset_start)) scroll_offset_end = max( 0, min(content_size - current_scroll - window_size, scroll_offset_end)) return current_scroll, scroll_offset_start, scroll_offset_end # When a preferred scroll is given, take that first into account. if self.get_vertical_scroll: self.vertical_scroll = self.get_vertical_scroll(self) assert isinstance(self.vertical_scroll, int) if self.get_horizontal_scroll: self.horizontal_scroll = self.get_horizontal_scroll(self) assert isinstance(self.horizontal_scroll, int) # Update horizontal/vertical scroll to make sure that the cursor # remains visible. offsets = self.scroll_offsets self.vertical_scroll, scroll_offset_top, scroll_offset_bottom = do_scroll( current_scroll=self.vertical_scroll, scroll_offset_start=offsets.top, scroll_offset_end=offsets.bottom, cursor_pos=temp_screen.cursor_position.y, window_size=height, content_size=temp_screen.height) self.horizontal_scroll, scroll_offset_left, scroll_offset_right = do_scroll( current_scroll=self.horizontal_scroll, scroll_offset_start=offsets.left, scroll_offset_end=offsets.right, cursor_pos=temp_screen.cursor_position.x, window_size=width, content_size=temp_screen.width) applied_scroll_offsets = ScrollOffsets(top=scroll_offset_top, bottom=scroll_offset_bottom, left=scroll_offset_left, right=scroll_offset_right) return applied_scroll_offsets def _mouse_handler(self, cli, mouse_event): """ Mouse handler. Called when the UI control doesn't handle this particular event. """ if mouse_event.event_type == MouseEventTypes.SCROLL_DOWN: self._scroll_down(cli) elif mouse_event.event_type == MouseEventTypes.SCROLL_UP: self._scroll_up(cli) def _scroll_down(self, cli): " Scroll window down. " info = self.render_info if self.vertical_scroll < info.content_height - info.window_height: if info.cursor_position.y <= info.configured_scroll_offsets.top: self.content.move_cursor_down(cli) self.vertical_scroll += 1 def _scroll_up(self, cli): " Scroll window up. " info = self.render_info if info.vertical_scroll > 0: if info.cursor_position.y >= info.window_height - 1 - info.configured_scroll_offsets.bottom: self.content.move_cursor_up(cli) self.vertical_scroll -= 1 def walk(self, cli): # Only yield self. A window doesn't have children. yield self