import collections from typing import Any, List, Optional, Set from .element import Element, ElementSupply, Id, RenderedElement from .exceptions import TreeException from .markup import AttributedText 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. They are always as wide as the current width. Any missing characters are filled with spaces. 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 # TODO add root stuff self.anchor_id: Optional[Id] = None self.anchor_offset: int = 0 self.cursor_id: Optional[Id] = None self.horizontal_offset: int = 0 # Object references self._supply = supply self._folded: Set[Id] = set() self._rendered: Optional[TreeList] = None self._display_lines: Optional[List[AttributedText]] = None # RENDERING @property def display_lines(self) -> List[AttributedText]: if self._display_lines is None: raise TreeException(( "No display lines available (have you called" " render_display_lines() yet?)" )) return self._display_lines def rerender(self) -> None: """ This function updates the internal TreeList (step 1). It should be called when the ElementSupply changes or when the anchor, anchor offset, cursor, width or height are manually changed. """ if self.anchor_id is None: # As described in the class docstring, we have no starting point # for rendering, so we don't even attempt it. self._rendered = None return anchor_tree = self._render_tree(self.anchor_id) self._rendered = TreeList(anchor_tree, self.anchor_id) self._rendered.offset_by(self.anchor_offset) self._fill_screen_upwards() self._fill_screen_downwards() def _render_tree(self, tree_id: Id, depth: int = 0 ) -> List[RenderedElement]: elements: List[RenderedElement] = [] highlighted = tree_id == self.cursor_id folded = tree_id in self._folded tree = self._supply.get(tree_id) rendered = tree.render(width=self.width, depth=depth, highlighted=highlighted, folded=folded) elements.append(rendered) if not folded: for child_id in self._supply.get_children_ids(tree_id): subelements = self._render_tree(child_id, depth=depth+1) elements.extend(subelements) return elements def _fill_screen_upwards(self) -> None: if self._rendered is None: raise TreeException(( "Can't fill screen upwards without a TreeList. This exception" " should never occur." )) 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 # We've hit the top of the supply self._rendered.add_above(self._render_tree(above_tree_id)) def _fill_screen_downwards(self) -> None: """ Eerily similar to _fill_screen_upwards()... """ if self._rendered is None: raise TreeException(( "Can't fill screen downwards without a TreeList. This exception" " should never occur." )) while True: if self._rendered.lower_offset <= 0: break below_tree_id = self._supply.get_previous_id( self._rendered.upper_tree_id) if below_tree_id is None: break # We've hit the bottom of the supply self._rendered.add_below(self._render_tree(below_tree_id)) def render_display_lines(self) -> None: """ This function updates the display lines (step 2). It should be called just before drawing the display lines to the curses window. """ filler_line = AttributedText(" " * self.width) if self._rendered is None: self._display_lines = [filler_line] * self.height return lines = [] if self._rendered.upper_offset > 0: # Fill the screen with empty lines until we hit the actual messages lines.extend([filler_line] * self._rendered.upper_offset) rendered_lines = self._rendered.to_lines(start=0, stop=self.height-1) lines.extend(line for line, rendered in rendered_lines) if self._rendered.lower_offset < self.height - 1: # Fill the rest of the screen with empty lines lines_left = self.height - 1 - self._rendered.lower_offset lines.extend([filler_line] * lines_left) self._display_lines = lines # SCROLLING def center_anchor(self) -> None: """ Center the anchor vertically on the screen. This does not render anything. """ pass # TODO def ensure_anchor_is_visible(self) -> None: """ Scroll up or down far enough that the anchor is completely visible. If the anchor is higher than the screen, scroll such that the first line of the anchor is at the top of the screen. This does not render anything. """ pass # TODO def anchor_center_element(self) -> None: """ Select the element closest to the center of the screen (vertically) as anchor. Set the anchor offset such that no scrolling happens. This function updates the internal TreeList (step 1). """ pass # TODO # FOLDING def is_folded(self, element_id: Id) -> bool: """ Check whether an element is folded. This does not render anything. """ return element_id in self._folded def fold(self, element_id: Id) -> None: """ Fold an element. This does not render anything. """ self._folded.add(element_id) def unfold(self, element_id: Id) -> None: """ Unfold an element. This does not render anything. """ if element_id in self._folded: self._folded.remove(element_id) def toggle_fold(self, element_id: Id) -> bool: """ Toggle whether an element is folded. Returns whether the element is folded now. This does not render anything. """ if self.is_folded(element_id): self.unfold(element_id) return False else: self.fold(element_id) return True # Terminology: # # root # ancestor # parent # sibling # child