import collections from typing import Any, List, Optional, Set from .element import Element, ElementSupply, Id, RenderedElement from .tree_list import TreeList __all__ = ["TreeDisplay"] class TreeDisplay: """ This class renders elements from an ElementSupply to a list of lines, which can then be drawn onto a curses window. It maintains two "pointers" to specific messages: An anchor, which is used for scrolling, and a cursor, which highlights elements and can be used for interaction. ANCHOR The anchor is a message at a fixed vertical screen position. This position is called the anchor offset and it specifies the position of the first line of the anchor message relative to the top of the screen. A position of 0 would mean that the anchor is displayed at the top of the screen, where a position of (height - 1) would mean that only the first line of the anchor is displayed at the bottom of the screen (the rest is offscreen). If no anchor is set, any attempt to render will result in a blank screen (as if the room was empty) since the anchor is the point from which the TreeDisplay starts to render the tree(s). CURSOR The cursor is a highlighted message that is meant to be used for user interaction. The element that the cursor points to is passed highlighed=True in its render() method, whereas all other elements are passed highlighted=False. The cursor can also point to None (i. e. no message), in which case no element is highlighted. At the moment, the TreeDisplay contains no cursor movement code, so the user of the TreeDisplay has to implement their own by setting the cursor to the respective element id themselves. You might also want to set the anchor to the cursor and maybe center it on screen when the cursor is moved. RENDERING Rendering consists of these steps: 0. Initialize the TreeDisplay with the correct screen width and height and keep the width and height up to date when the window resolution changes. 1. Update the internal TreeList through one of various functions 2. Cut out the display lines (the text that is later visible on screen) 3. Draw the display lines to the curses window Step 2 uses the contents of the TreeList from step 1, but DOESN'T happen automatically when the TreeList changes. It is also guaranteed to not modify the display lines if it fails in some way. The display lines are supposed to contain the same text that is currently visible on the screen, and can be used to look up mouse clicks (see the "id" attribute in the "RENDERING - technical details" section below). They're a simple format for translating between elements and onscreen text. RENDERING - technical details The process of rendering results in a TreeList and a list of lines (called display lines) representing the text that should be displayed on the screen. The TreeList's (vertical) offset of messages corresponds to the line on the screen where the message will be drawn. This means that the anchor message's offset in the TreeList will be the anchor offset referred to in the ANCHOR section above. Like the Elements and RenderedElements, the TreeDisplay and TreeList also use AttributedStrings for rendered content, especially in the display lines. In addition to the attributes added by the Element during rendering, the TreeDisplay also adds the following attributes to the display lines: 1. "id" - the id of the element visible on this line 2. "parent" - the parent id (or None) of the element visible on this line 3. "cursor" - True, only added if the cursor points to the element visible on this line When an Element is rendered (producing a RenderedElement), its render() function is called with: 1. the element's depth/level (top-level elements have a depth of 0) 1. the current screen width 2. whether the element is highlighted (by the cursor) 3. whether the element is folded The RenderedElement contains one or more lines (zero lines may break the TreeDisplay, not sure yet) of AttributedText, which may contain formatting information (such as text color or style). This means that an Element can decide its own height. These lines should generally stay within the width passed to render(), but may exceed it in certain exceptional situations (e. g. too close to the right side of the screen). Because of this, the TreeDisplay supports horizontal scrolling. SCROLLING The most basic form of scrolling is to just increase or decrease the anchor offset. Depending on the application, this approach is enough. In some cases, more complex behaviour is required. For example, if you're displaying a section from a live tree structure like a chat room (see https://euphoria.io), there is no static top-most or bottom-most message to anchor to (unless you want to download the whole room's log). Also, new messages may appear at any time in (almost) any place. With the above, basic scrolling model, offscreen conversations would often slide around the visible messages, leading to frustration of the user. In cases like this, an alternative scrolling model can be employed: First, change the anchor offset as required and render the messages. Then, select the middle-most message as the new anchor and adapt the anchor offset such that the message stays in the same position. FOLDING Finally, the TreeDisplay supports folding and unfolding elements, making their subtrees invisible. Folding happens on elements. When an element is folded, it is still displayed but its (direct and indirect) child messages are no longer displayed. """ def __init__(self, supply: ElementSupply, width: int, height: int, ) -> None: """ supply - the ElementSupply that this TreeDisplay should use width - the width of the target window (in characters) height - the width of the target window (in lines) To use the TreeDisplay, you might also want to: - set the anchor - make sure the anchor is visible """ 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] = None self._folded: Set[Id] = set() def resize(self, width: int, height: int) -> None: # TODO maybe empty _rendered/invalidate caches etc.? self._width = width self._height = height def render(self) -> None: # 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: self._rendered = None return 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() self._pad.set_lines(self._rendered.to_lines()) # The vertical offset (anchor offset) is already being dealt with in # the TreeView, since it is more useful to apply it there (each line's # offset/index is also the anchor offset it would have if it was the # anchor. self._pad.stamp(self._horizontal_offset, 0, self._width, self._height) def _render_tree(self, tree: Element, depth: int = 0 ) -> List[RenderedElement]: 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) -> None: if self._rendered is None: return # TODO think of sensible thing to do here 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) -> None: """ Eerily similar to _fill_screen_upwards()... """ if self._rendered is None: return # TODO think of sensible thing to do here 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) -> None: # TODO color styles of the text itself pass # Terminology: # # root # ancestor # parent # sibling # child