Start working on rendering part
This commit is contained in:
parent
573e538869
commit
ef5320058c
5 changed files with 552 additions and 0 deletions
8
cheuph/render/__init__.py
Normal file
8
cheuph/render/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from .element import *
|
||||||
|
from .markup import *
|
||||||
|
from .tree_display import *
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
|
__all__ += element.__all__
|
||||||
|
__all__ += markup.__all__
|
||||||
|
__all__ += tree_display.__all__
|
||||||
29
cheuph/render/element.py
Normal file
29
cheuph/render/element.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from typing import Hashable, List
|
||||||
|
|
||||||
|
from .markup import AttributedText
|
||||||
|
|
||||||
|
__all__ = ["Id", "Element", "ElementSupply", "RenderedElement"]
|
||||||
|
|
||||||
|
Id = Hashable
|
||||||
|
|
||||||
|
class Element:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ElementSupply:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RenderedElement:
|
||||||
|
def __init__(self,
|
||||||
|
element: Element,
|
||||||
|
rendered: List[AttributedText],
|
||||||
|
) -> None:
|
||||||
|
self._element = element
|
||||||
|
self._lines = rendered
|
||||||
|
|
||||||
|
@property
|
||||||
|
def element(self) -> Element:
|
||||||
|
return self._element
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self) -> int:
|
||||||
|
return len(self._lines)
|
||||||
286
cheuph/render/markup.py
Normal file
286
cheuph/render/markup.py
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||||
|
|
||||||
|
__all__ = ["Attributes", "Chunk", "AttributedText"]
|
||||||
|
|
||||||
|
Attributes = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# TODO remove empty Chunks in join_chunks
|
||||||
|
class Chunk:
|
||||||
|
@staticmethod
|
||||||
|
def join_chunks(chunks: List["Chunk"]) -> List["Chunk"]:
|
||||||
|
if not chunks:
|
||||||
|
return []
|
||||||
|
|
||||||
|
new_chunks: List[Chunk] = []
|
||||||
|
|
||||||
|
current_chunk = chunks[0]
|
||||||
|
for chunk in chunks[1:]:
|
||||||
|
joined_chunk = current_chunk._join(chunk)
|
||||||
|
|
||||||
|
if joined_chunk is None:
|
||||||
|
new_chunks.append(current_chunk)
|
||||||
|
current_chunk = chunk
|
||||||
|
else:
|
||||||
|
current_chunk = joined_chunk
|
||||||
|
|
||||||
|
new_chunks.append(current_chunk)
|
||||||
|
|
||||||
|
return new_chunks
|
||||||
|
|
||||||
|
# Common special methods
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
text: str,
|
||||||
|
attributes: Attributes = {},
|
||||||
|
) -> None:
|
||||||
|
self._text = text
|
||||||
|
self._attributes = dict(attributes)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Chunk({self.text!r}, {self._attributes!r})"
|
||||||
|
|
||||||
|
# Uncommon special methods
|
||||||
|
|
||||||
|
def __getitem__(self, key: Union[int, slice]) -> "Chunk":
|
||||||
|
return Chunk(self.text[key], self._attributes)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.text)
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attributes(self) -> Attributes:
|
||||||
|
return dict(self._attributes)
|
||||||
|
|
||||||
|
# Private methods
|
||||||
|
|
||||||
|
def _join(self, chunk: "Chunk") -> Optional["Chunk"]:
|
||||||
|
if self._attributes == chunk._attributes:
|
||||||
|
return Chunk(self.text + chunk.text, self._attributes)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Public methods
|
||||||
|
|
||||||
|
def get(self, name: str, default: Any = None) -> Any:
|
||||||
|
return self.attributes.get(name, default)
|
||||||
|
|
||||||
|
def set(self, name: str, value: Any) -> "Chunk":
|
||||||
|
new_attributes = dict(self._attributes)
|
||||||
|
new_attributes[name] = value
|
||||||
|
return Chunk(self.text, new_attributes)
|
||||||
|
|
||||||
|
def remove(self, name: str) -> "Chunk":
|
||||||
|
new_attributes = dict(self._attributes)
|
||||||
|
|
||||||
|
# This removes the value with that key, if it exists, and does nothing
|
||||||
|
# if it doesn't exist. (Since we give a default value, no KeyError is
|
||||||
|
# raised if the key isn't found.)
|
||||||
|
new_attributes.pop(name, None)
|
||||||
|
|
||||||
|
return Chunk(self.text, new_attributes)
|
||||||
|
|
||||||
|
|
||||||
|
class AttributedText:
|
||||||
|
"""
|
||||||
|
Objects of this class are immutable and behave str-like. Supported
|
||||||
|
operations are len, + and splicing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_chunks(cls, chunks: Iterable[Chunk]) -> "AttributedText":
|
||||||
|
new = cls()
|
||||||
|
new._chunks = Chunk.join_chunks(list(chunks))
|
||||||
|
return new
|
||||||
|
|
||||||
|
# Common special methods
|
||||||
|
|
||||||
|
def __init__(self, text: Optional[str] = None) -> None:
|
||||||
|
self._chunks: List[Chunk] = []
|
||||||
|
if text is not None:
|
||||||
|
self._chunks.append(Chunk(text))
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"AttributedText.from_chunks({self._chunks!r})"
|
||||||
|
|
||||||
|
# Uncommon special methods
|
||||||
|
|
||||||
|
def __add__(self, other: "AttributedText") -> "AttributedText":
|
||||||
|
return AttributedText.from_chunks(self._chunks + other._chunks)
|
||||||
|
|
||||||
|
def __getitem__(self, key: Union[int, slice]) -> "AttributedText":
|
||||||
|
chunks: List[Chunk]
|
||||||
|
|
||||||
|
if isinstance(key, slice):
|
||||||
|
chunks = Chunk.join_chunks(self._slice(key))
|
||||||
|
else:
|
||||||
|
chunks = [self._at(key)]
|
||||||
|
|
||||||
|
return AttributedText.from_chunks(chunks)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return sum(map(len, self._chunks))
|
||||||
|
|
||||||
|
# Properties
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return "".join(chunk.text for chunk in self._chunks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chunks(self) -> List[Chunk]:
|
||||||
|
return list(self._chunks)
|
||||||
|
|
||||||
|
# Private methods
|
||||||
|
|
||||||
|
def _at(self, key: int) -> Chunk:
|
||||||
|
if key < 0:
|
||||||
|
key = len(self) + key
|
||||||
|
|
||||||
|
pos = 0
|
||||||
|
for chunk in self._chunks:
|
||||||
|
chunk_key = key - pos
|
||||||
|
|
||||||
|
if 0 <= chunk_key < len(chunk):
|
||||||
|
return chunk[chunk_key]
|
||||||
|
|
||||||
|
pos += len(chunk)
|
||||||
|
|
||||||
|
# We haven't found the chunk
|
||||||
|
raise KeyError
|
||||||
|
|
||||||
|
def _slice(self, key: slice) -> List[Chunk]:
|
||||||
|
start, stop, step = key.start, key.stop, key.step
|
||||||
|
|
||||||
|
if start is None:
|
||||||
|
start = 0
|
||||||
|
elif start < 0:
|
||||||
|
start = len(self) + start
|
||||||
|
|
||||||
|
if stop is None:
|
||||||
|
stop = len(self)
|
||||||
|
elif stop < 0:
|
||||||
|
stop = len(self) + stop
|
||||||
|
|
||||||
|
pos = 0 # cursor position
|
||||||
|
resulting_chunks = []
|
||||||
|
|
||||||
|
for chunk in self._chunks:
|
||||||
|
chunk_start = start - pos
|
||||||
|
chunk_stop = stop - pos
|
||||||
|
|
||||||
|
offset: Optional[int] = None
|
||||||
|
if step is not None:
|
||||||
|
offset = (start - pos) % step
|
||||||
|
|
||||||
|
if chunk_stop <= 0 or chunk_start >= len(chunk):
|
||||||
|
pass
|
||||||
|
elif chunk_start < 0 and chunk_stop > len(chunk):
|
||||||
|
resulting_chunks.append(chunk[offset::step])
|
||||||
|
elif chunk_start < 0:
|
||||||
|
resulting_chunks.append(chunk[offset:chunk_stop:step])
|
||||||
|
elif chunk_stop > len(chunk):
|
||||||
|
resulting_chunks.append(chunk[chunk_start::step])
|
||||||
|
else:
|
||||||
|
resulting_chunks.append(chunk[chunk_start:chunk_stop:step])
|
||||||
|
|
||||||
|
pos += len(chunk)
|
||||||
|
|
||||||
|
return resulting_chunks
|
||||||
|
|
||||||
|
# Public methods
|
||||||
|
|
||||||
|
def at(self, pos: int) -> Attributes:
|
||||||
|
return self._at(pos).attributes
|
||||||
|
|
||||||
|
def get(self,
|
||||||
|
pos: int,
|
||||||
|
name: str,
|
||||||
|
default: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
return self._at(pos).get(name, default)
|
||||||
|
|
||||||
|
# "find all separate blocks with this property"
|
||||||
|
def find_all(self, name: str) -> List[Tuple[Any, int, int]]:
|
||||||
|
blocks = []
|
||||||
|
pos = 0
|
||||||
|
block = None
|
||||||
|
|
||||||
|
for chunk in self._chunks:
|
||||||
|
if name in chunk.attributes:
|
||||||
|
attribute = chunk.attributes[name]
|
||||||
|
start = pos
|
||||||
|
stop = pos + len(chunk)
|
||||||
|
|
||||||
|
if block is None:
|
||||||
|
block = (attribute, start, stop)
|
||||||
|
continue
|
||||||
|
|
||||||
|
block_attr, block_start, _ = block
|
||||||
|
|
||||||
|
if block_attr == attribute:
|
||||||
|
block = (attribute, block_start, stop)
|
||||||
|
else:
|
||||||
|
blocks.append(block)
|
||||||
|
block = (attribute, start, stop)
|
||||||
|
|
||||||
|
pos += len(chunk)
|
||||||
|
|
||||||
|
if block is not None:
|
||||||
|
blocks.append(block)
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
def set(self,
|
||||||
|
name: str,
|
||||||
|
value: Any,
|
||||||
|
start: Optional[int] = None,
|
||||||
|
stop: Optional[int] = None,
|
||||||
|
) -> "AttributedText":
|
||||||
|
if start is None and stop is None:
|
||||||
|
chunks = (chunk.set(name, value) for chunk in self._chunks)
|
||||||
|
return AttributedText.from_chunks(chunks)
|
||||||
|
elif start is None:
|
||||||
|
return self[:stop].set(name, value) + self[stop:]
|
||||||
|
elif stop is None:
|
||||||
|
return self[:start] + self[start:].set(name, value)
|
||||||
|
elif start > stop:
|
||||||
|
# set value everywhere BUT the specified interval
|
||||||
|
return self.set(name, value, stop=stop).set(name, value, start=start)
|
||||||
|
else:
|
||||||
|
middle = self[start:stop].set(name, value)
|
||||||
|
return self[:start] + middle + self[stop:]
|
||||||
|
|
||||||
|
def set_at(self, name: str, value: Any, pos: int) -> "AttributedText":
|
||||||
|
return self.set(name, value, pos, pos)
|
||||||
|
|
||||||
|
def remove(self,
|
||||||
|
name: str,
|
||||||
|
start: Optional[int] = None,
|
||||||
|
stop: Optional[int] = None,
|
||||||
|
) -> "AttributedText":
|
||||||
|
if start is None and stop is None:
|
||||||
|
chunks = (chunk.remove(name) for chunk in self._chunks)
|
||||||
|
return AttributedText.from_chunks(chunks)
|
||||||
|
elif start is None:
|
||||||
|
return self[:stop].remove(name) + self[stop:]
|
||||||
|
elif stop is None:
|
||||||
|
return self[:start] + self[start:].remove(name)
|
||||||
|
elif start > stop:
|
||||||
|
# remove value everywhere BUT the specified interval
|
||||||
|
return self.remove(name, stop=stop).remove(name, start=start)
|
||||||
|
else:
|
||||||
|
middle = self[start:stop].remove(name)
|
||||||
|
return self[:start] + middle + self[stop:]
|
||||||
126
cheuph/render/tree_display.py
Normal file
126
cheuph/render/tree_display.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import collections
|
||||||
|
from typing import Any, Deque, Optional, Set
|
||||||
|
|
||||||
|
from .element import ElementSupply, Id, RenderedElement
|
||||||
|
|
||||||
|
__all__ = ["TreeDisplay"]
|
||||||
|
|
||||||
|
class TreeDisplay:
|
||||||
|
def __init__(self,
|
||||||
|
supply: ElementSupply,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
) -> None:
|
||||||
|
self._width = width
|
||||||
|
self._height = height
|
||||||
|
|
||||||
|
self._root_id: Optional[Id] = None
|
||||||
|
self._anchor_id: Optional[Id] = None
|
||||||
|
self._cursor_id: Optional[Id] = None
|
||||||
|
|
||||||
|
self._anchor_offset: int = 0
|
||||||
|
self._horizontal_offset: int = 0
|
||||||
|
|
||||||
|
# Object references
|
||||||
|
self._supply = supply
|
||||||
|
self._rendered: Optional[TreeList]
|
||||||
|
self._folded: Set[Id] = set()
|
||||||
|
|
||||||
|
def resize(self, width: int, height: int):
|
||||||
|
# TODO maybe empty _rendered/invalidate caches etc.?
|
||||||
|
self._width = width
|
||||||
|
self._height = height
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
# Steps:
|
||||||
|
#
|
||||||
|
# 1. Find and render anchor's branch to TreeList
|
||||||
|
# 2. Render above and below the branch until the screen is full (with
|
||||||
|
# the specified anchor offset)
|
||||||
|
# 2.1. Keep the TreeList for later things like scrolling
|
||||||
|
# 3. Cut out the visible lines and messages
|
||||||
|
# 4. Cut out the visible parts horizontally (self._horizontal_offset)
|
||||||
|
# 4.1. Keep the result for later reference (mouse clicks)
|
||||||
|
# 5. Convert the result to plain text and draw it in the curses window
|
||||||
|
#
|
||||||
|
# Not happy with these steps yet. Scrolling, checking if the cursor is
|
||||||
|
# in view, switching anchors etc. still feel weird.
|
||||||
|
#
|
||||||
|
# TODO Add the above into the TreeDisplay model.
|
||||||
|
|
||||||
|
if self._anchor_id is None:
|
||||||
|
return # TODO draw empty screen
|
||||||
|
|
||||||
|
|
||||||
|
if self._root_id is None:
|
||||||
|
ancestor_id = self._supply.get_furthest_ancestor_id(
|
||||||
|
self._anchor_id)
|
||||||
|
else:
|
||||||
|
ancestor_id = self._root_id
|
||||||
|
|
||||||
|
ancestor_tree = self._render_tree(self._supply.get_tree(ancestor_id))
|
||||||
|
|
||||||
|
self._rendered = TreeList(ancestor_tree, self._anchor_id)
|
||||||
|
self._rendered.offset_by(self._anchor_offset)
|
||||||
|
|
||||||
|
if self._root_id is None:
|
||||||
|
self._fill_screen_upwards()
|
||||||
|
self._fill_screen_downwards()
|
||||||
|
|
||||||
|
def _render_tree(self, tree: Element, depth=0):
|
||||||
|
elements: List[RenderedElement] = []
|
||||||
|
|
||||||
|
highlighted = tree.id == self._cursor_id
|
||||||
|
folded = tree.id in self._folded
|
||||||
|
|
||||||
|
elements.append(tree.render(depth=depth, highlighted=highlighted,
|
||||||
|
folded=folded))
|
||||||
|
|
||||||
|
if not folded:
|
||||||
|
for child in tree.children:
|
||||||
|
elements.extend(self._render_tree(child, depth=depth+1))
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
def _fill_screen_upwards(self):
|
||||||
|
while True:
|
||||||
|
if self._rendered.upper_offset <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
above_tree_id = self._supply.get_previous_id(
|
||||||
|
self._rendered.upper_tree_id)
|
||||||
|
|
||||||
|
if above_tree_id is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
above_tree = self._supply.get_tree(above_tree_id)
|
||||||
|
self._rendered.add_above(self._render_tree(above_tree))
|
||||||
|
|
||||||
|
def _fill_screen_downwards(self):
|
||||||
|
"""
|
||||||
|
Eerily similar to _fill_screen_upwards()...
|
||||||
|
"""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._rendered.lower_offset >= self._height - 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
below_tree_id = self._supply.get_next_id(
|
||||||
|
self._rendered.lower_tree_id)
|
||||||
|
|
||||||
|
if below_tree_id is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
below_tree = self._supply.get_tree(below_tree_id)
|
||||||
|
self._rendered.add_below(self._render_tree(below_tree))
|
||||||
|
|
||||||
|
def draw_to(self, window: Any):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Terminology:
|
||||||
|
#
|
||||||
|
# root
|
||||||
|
# ancestor
|
||||||
|
# parent
|
||||||
|
# sibling
|
||||||
|
# child
|
||||||
103
cheuph/render/tree_list.py
Normal file
103
cheuph/render/tree_list.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
from .element import Id, RenderedElement
|
||||||
|
|
||||||
|
__all__ = ["TreeList"]
|
||||||
|
|
||||||
|
class TreeList:
|
||||||
|
def __init__(self,
|
||||||
|
tree: List[RenderedElement],
|
||||||
|
anchor_id: Id,
|
||||||
|
) -> None:
|
||||||
|
self._deque = collections.deque()
|
||||||
|
|
||||||
|
self._anchor_id = anchor_id
|
||||||
|
|
||||||
|
# The offsets can be thought of as the index of a line relative to the
|
||||||
|
# anchor's first line.
|
||||||
|
#
|
||||||
|
# The upper offset is the index of the uppermost message's first line.
|
||||||
|
# upper_offset <= 0.
|
||||||
|
#
|
||||||
|
# The lower offset is the index of the lowermost message's LAST line.
|
||||||
|
# lower_offset >= 0.
|
||||||
|
self._upper_offset: Int
|
||||||
|
self._lower_offset: Int
|
||||||
|
|
||||||
|
# The upper and lower tree ids are the ids of the uppermost or
|
||||||
|
# lowermost tree added to the TreeList. They can be used to request the
|
||||||
|
# previous or next tree from an ElementSupply.
|
||||||
|
self._upper_tree_id: Id
|
||||||
|
self._lower_tree_id: Id
|
||||||
|
|
||||||
|
self._add_first_tree(tree)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upper_offset(self) -> Int:
|
||||||
|
return self._upper_offset
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lower_offset(self) -> Int:
|
||||||
|
return self._lower_offset
|
||||||
|
|
||||||
|
@property
|
||||||
|
def upper_tree_id(self) -> Id:
|
||||||
|
return self._upper_tree_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lower_tree_id(self) -> Id:
|
||||||
|
return self._lower_tree_id
|
||||||
|
|
||||||
|
def _add_first_tree(self, tree: List[RenderedElement]) -> None:
|
||||||
|
if len(tree) == 0:
|
||||||
|
raise ValueError("The tree must contain at least one element")
|
||||||
|
|
||||||
|
tree_id = tree[0].element.id
|
||||||
|
self._upper_tree_id = tree_id
|
||||||
|
self._lower_tree_id = tree_id
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
found_anchor = False
|
||||||
|
|
||||||
|
for rendered in elements:
|
||||||
|
if rendered.element.id == anchor_id:
|
||||||
|
found_anchor = True
|
||||||
|
self._upper_offset = -offset
|
||||||
|
|
||||||
|
offset += rendered.height
|
||||||
|
|
||||||
|
if not found_anchor:
|
||||||
|
raise ValueError("The initial tree must contain the anchor")
|
||||||
|
|
||||||
|
# Subtracting 1 because the lower offset is the index of the lowermost
|
||||||
|
# message's last line, not the first line of a hypothetical message
|
||||||
|
# below that.
|
||||||
|
self._lower_offset = offset - 1
|
||||||
|
|
||||||
|
def add_above(self, tree: List[RenderedElement]) -> None:
|
||||||
|
if len(tree) == 0:
|
||||||
|
raise ValueError("The tree must contain at least one element")
|
||||||
|
|
||||||
|
self._upper_tree_id = tree[0].element.id
|
||||||
|
|
||||||
|
for rendered in reversed(tree):
|
||||||
|
self._deque.appendleft(rendered)
|
||||||
|
self._upper_offset -= rendered.height
|
||||||
|
|
||||||
|
# Alternative to the above for loop
|
||||||
|
#delta = sum(map(lambda r: r.height, tree))
|
||||||
|
#self._upper_offset -= delta
|
||||||
|
#self._deque.extendLeft(reversed(tree))
|
||||||
|
|
||||||
|
def add_below(self, tree: List[RenderedElement]) -> None:
|
||||||
|
if len(tree) == 0:
|
||||||
|
raise ValueError("The tree must contain at least one element")
|
||||||
|
|
||||||
|
self._lower_tree_id = tree[0].element.id
|
||||||
|
|
||||||
|
for rendered in tree:
|
||||||
|
self._deque.append(rendered)
|
||||||
|
self._lower_offset += rendered.height
|
||||||
|
|
||||||
|
# Alternative to the above for loop
|
||||||
|
#delta = sum(map(lambda r: r.height, tree))
|
||||||
|
#self._lower_offset += delta
|
||||||
|
#self._deque.extend(tree)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue