From dec73f183f7d9ba10b3cf31c53f7bfc645f1596e Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 6 Jun 2019 14:15:12 +0000 Subject: [PATCH] Implement tree rendering with cursor --- cheuph/cursor_rendering.py | 342 +++++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 cheuph/cursor_rendering.py diff --git a/cheuph/cursor_rendering.py b/cheuph/cursor_rendering.py new file mode 100644 index 0000000..ac8c331 --- /dev/null +++ b/cheuph/cursor_rendering.py @@ -0,0 +1,342 @@ +from abc import ABC, abstractmethod +from typing import Generic, Optional, Tuple, TypeVar + +from .attributed_lines import AttributedLines +from .element import Element, Id, RenderedElement, RenderedMessage +from .element_supply import ElementSupply +from .markup import AT, AttributedText, Attributes +from .rendered_element_cache import RenderedElementCache + +__all__ = ["CursorRenderer", "CursorTreeRenderer"] + +E = TypeVar("E", bound=Element) +R = TypeVar("R", bound=RenderedElement) +M = TypeVar("M", bound=RenderedMessage) # because it has a meta field + +class CursorRenderer(ABC, Generic[E, R]): + + @abstractmethod + @property + def meta_width(self) -> int: + pass + + @abstractmethod + def render_element(self, element: E, width: int) -> R: + pass + + @abstractmethod + def render_cursor(self, width: int) -> AttributedText: + pass + +class CursorTreeRenderer(Generic[E]): + """ + This class renders a tree of Element-s from an ElementSupply to + AttributedLines, including user interface elements like a cursor (and + possibly subtree folding?). + + It does the following: + 1. render the tree + 2. handle scrolling + 3. handle the cursor + """ + + def __init__(self, + supply: ElementSupply[E], + renderer: CursorRenderer[E, M], + indent_width: int = 2, + indent: str = "│", + indent_fill: str = " ", + indent_attrs: Attributes = {}, + cursor_indent: str = "┃", + cursor_corner: str = "┗", + cursor_fill: str = "━", + cursor_indent_attrs: Attributes = {}, + ) -> None: + + self._supply = supply + self._renderer = renderer + self._cache = RenderedElementCache[M]() + + # Rendering result + self._lines = AttributedLines() + + # Cursor and scrolling + self._cursor_id: Optional[Id] = None + self._anchor_id: Optional[Id] = None + self._anchor_offset = 0.5 + + # Last known dimensions + self._width = 80 + self._height = 40 + + # Configurable variables + if indent_width < 0: raise ValueError("indent width must be 0 or greater") + self._indent_width = indent_width + self._indent = indent + self._indent_fill = indent_fill + self._indent_attrs = indent_attrs + self._cursor_indent = cursor_indent + self._cursor_corner = cursor_corner + self._cursor_fill = cursor_fill + self._cursor_indent_attrs = cursor_indent_attrs + + @property + def lines(self) -> AttributedLines: + # Not sure if the between() is necessary + return self._lines.between(0, self._height - 1) + + # Offsets + + @staticmethod + def get_absolute_offset(offset: float, height: int) -> int: + return round(offset * (height - 1)) + + @staticmethod + def get_relative_offset(line: int, height: int) -> float: + if height <= 1: + return 0.5 + + return line / (height - 1) + + @property + def _absolute_anchor_offset(self) -> int: + return self.get_absolute_offset(self._anchor_offset, self._height) + + @_absolute_anchor_offset.setter + def _absolute_anchor_offset(self, offset: int) -> None: + self._anchor_offset = self.get_relative_offset(offset, self._height) + + # Message cache operations + + def invalidate(self, message_id: Id) -> None: + self._cache.invalidate(message_id) + + def invalidate_all(self) -> None: + self._cache.invalidate_all() + + # Rendering a single message + + def _get_rendered_message(self, message_id: Id, width: int) -> M: + cached = self._cache.get(message_id) + if cached is not None: + return cached + + message = self._supply.get(message_id) + rendered = self._renderer.render_element(message, width) + self._cache.add(rendered) + return rendered + + def _render_message(self, + message_id: Id, + indent: AttributedText, + ) -> AttributedLines: + + width = self._width - len(indent) - self._renderer.meta_width + rendered: RenderedMessage = self._get_rendered_message(message_id, + width) + + meta = rendered.meta + meta_spaces = AT(" " * len(meta)) + + lines = AttributedLines() + for offset, line in enumerate(rendered.lines): + text = (meta if offset == 0 else meta_spaces) + indent + line + attrs = {"mid": message_id, "offset": offset} + lines.append_below(attrs, text) + + return lines + + def _render_cursor(self, + indent: AttributedText = AT(), + ) -> AttributedLines: + lines = AttributedLines() + width = self._width - len(indent) - self._renderer.meta_width + attrs = {"cursor": True, "offset": 0} + lines.append_below(attrs, self._renderer.render_cursor(width)) + return lines + + def _render_indent(self, + cursor: bool = False, + cursor_line: bool = False, + ) -> AttributedText: + + if self._indent_width < 1: + return AT() + + if cursor_line: + attrs = self._cursor_indent_attrs + start = AT(self._cursor_corner, attributes=attrs) + fill = AT(self._cursor_fill, attributes=attrs) + elif cursor: + start_attrs = self._cursor_indent_attrs + start = AT(self._cursor_indent, attributes=start_attrs) + fill_attrs = self._indent_attrs + fill = AT(self._indent_fill, attributes=fill_attrs) + else: + attrs = self._indent_attrs + start = AT(self._indent, attributes=attrs) + fill = AT(self._indent_fill, attributes=attrs) + + return start + fill * (self._indent_width - len(start)) + + # Rendering the tree + + def _render_subtree(self, + lines: AttributedLines, + root_id: Id, + indent: AttributedText = AT(), + ) -> None: + + if self._anchor_id == root_id: + lines.lower_offset = -1 + + # Do we have to draw a cursor? + cursor = self._cursor_id == root_id + + # Render main message (root) + rendered_lines = self._render_message(root_id, indent) + lines.extend_below(rendered_lines) + + # Determine new indent + extra_indent = self._render_indent(cursor=cursor) + new_indent = indent + extra_indent + + # Render children + for child_id in self._supply.child_ids(root_id): + self._render_subtree(lines, child_id, new_indent) + + # Render cursor if necessary + if cursor: + # The cursor also acts as anchor if anchor is not specified + if self._anchor_id is None: + lines.lower_offset = -1 + + cursor_indent = indent + self._render_indent(cursor_line=True) + lines.extend_below(self._render_cursor(indent)) + + def _render_tree(self, root_id: Id) -> AttributedLines: + lines = AttributedLines() + self._render_subtree(lines, root_id) + return lines + + def _render_tree_containing(self, message_id: Id) -> AttributedLines: + root_id = self._supply.root_id(message_id) + return self._render_tree(root_id) + + def _expand_upwards_until(self, + lines: AttributedLines, + ancestor_id: Id, + target_upper_offset: int, + ) -> Tuple[Id, bool]: + + last_rendered_id = ancestor_id + + while True: + next_id = self._supply.previous_id(last_rendered_id) + + if next_id is None: + # We've hit the top of the supply + return last_rendered_id, True + + if lines.upper_offset <= target_upper_offset: + # We haven't hit the top, but the target has been satisfied + return last_rendered_id, False + + lines.extend_above(self._render_tree(next_id)) + last_rendered_id = next_id + + def _expand_downwards_until(self, + lines: AttributedLines, + ancestor_id: Id, + target_lower_offset: int, + ) -> None: + + last_rendered_id = ancestor_id + + while True: + next_id = self._supply.next_id(last_rendered_id) + + if next_id is None: + # We've hit the bottom of the supply, but we might still have a + # cursor to render + break + + if lines.lower_offset >= target_lower_offset: + return + + lines.extend_below(self._render_tree(next_id)) + last_rendered_id = next_id + + if self._cursor_id is None: + lines.extend_below(self._render_cursor()) + + # Rendering the lines (finally) + + def _render_lines_from_cursor(self) -> Tuple[AttributedLines, int, bool]: + """ + Uses the following strategy: + 1. Render the cursor + 2. Render the lowest tree, if there is one + 3. Extend upwards until the top of the screen, if necessary + """ + + # Step 1 + lines = self._render_cursor() + # No need to use the anchor offset since we know we're always at the + # bottom of the screen + lines.lower_offset = self._height - 1 + delta = self._height - 1 - self._absolute_anchor_offset + + # Step 2 + lowest_root_id = self._supply.lowest_root_id() + if lowest_root_id is None: + hit_top = True + else: + lines.extend_above(self._render_tree(lowest_root_id)) + + # Step 3 + _, hit_top = self._expand_upwards_until(lines, lowest_root_id, 0) + + return lines, delta, hit_top + + def _render_lines_from_anchor(self, + anchor_id: Id, + ) -> Tuple[AttributedLines, int, bool]: + """ + Uses the following strategy: + 1. Render the anchor's tree + 2. Extend upwards until the top of the screen + 3. Adjust the offset to match rule 2 + 4. Extend downwards until the bottom of the screen + 5. Adjust the offset to match rule 1 + 6. Extend upwards again until the top of the screen + """ + + delta = 0 + + # Step 1 + ancestor_id = self._supply.root_id(anchor_id) + lines = self._render_tree(ancestor_id) + lines.upper_offset += self._absolute_anchor_offset + + # Step 2 + upper_id, hit_top = self._expand_upwards_until(lines, ancestor_id, 0) + + # Step 3 + if lines.upper_offset > 0: + delta -= lines.upper_offset + lines.upper_offset = 0 # = upper_offset - delta + + # Step 4 + self._expand_downwards_until(lines, ancestor_id, self._height - 1) + + # Step 5 + if lines.lower_offset < self._height - 1: + delta += (self._height - 1) - lines.lower_offset + lines.lower_offset = self._height - 1 + + # Step 6 + if not hit_top and lines.upper_offset > 0: + _, hit_top = self._expand_upwards_until(lines, upper_id, 0) + + return lines, delta, hit_top