Implement tree rendering with cursor
This commit is contained in:
parent
2407377cf1
commit
dec73f183f
1 changed files with 342 additions and 0 deletions
342
cheuph/cursor_rendering.py
Normal file
342
cheuph/cursor_rendering.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue