diff --git a/cheuph/render/__init__.py b/cheuph/render/__init__.py new file mode 100644 index 0000000..440d10c --- /dev/null +++ b/cheuph/render/__init__.py @@ -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__ diff --git a/cheuph/render/element.py b/cheuph/render/element.py new file mode 100644 index 0000000..6d71c99 --- /dev/null +++ b/cheuph/render/element.py @@ -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) diff --git a/cheuph/render/markup.py b/cheuph/render/markup.py new file mode 100644 index 0000000..68e19cb --- /dev/null +++ b/cheuph/render/markup.py @@ -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:] diff --git a/cheuph/render/tree_display.py b/cheuph/render/tree_display.py new file mode 100644 index 0000000..3aa1da8 --- /dev/null +++ b/cheuph/render/tree_display.py @@ -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 diff --git a/cheuph/render/tree_list.py b/cheuph/render/tree_list.py new file mode 100644 index 0000000..367fdf1 --- /dev/null +++ b/cheuph/render/tree_list.py @@ -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)