diff --git a/cheuph/__init__.py b/cheuph/__init__.py index 0f329b5..9404128 100644 --- a/cheuph/__init__.py +++ b/cheuph/__init__.py @@ -1,20 +1,29 @@ from typing import List +from .attributed_lines import * +from .attributed_lines_widget import * +from .attributed_text_widget import * from .config import * -from .element import * -from .element_supply import * from .exceptions import * from .markup import * -from .tree_display import * -from .tree_list import * -from .widgets import * +from .message import * +from .message_cache import * +from .message_editor_widget import * +from .message_supply import * +from .message_tree_widget import * +from .user_list_widget import * __all__: List[str] = [] + +__all__ += attributed_lines.__all__ +__all__ += attributed_lines_widget.__all__ +__all__ += attributed_text_widget.__all__ __all__ += config.__all__ -__all__ += element.__all__ -__all__ += element_supply.__all__ __all__ += exceptions.__all__ __all__ += markup.__all__ -__all__ += tree_display.__all__ -__all__ += tree_list.__all__ -__all__ += widgets.__all__ +__all__ += message.__all__ +__all__ += message_cache.__all__ +__all__ += message_editor_widget.__all__ +__all__ += message_supply.__all__ +__all__ += message_tree_widget.__all__ +__all__ += user_list_widget.__all__ diff --git a/cheuph/attributed_lines.py b/cheuph/attributed_lines.py new file mode 100644 index 0000000..950eb6f --- /dev/null +++ b/cheuph/attributed_lines.py @@ -0,0 +1,16 @@ +__all__ = ["AttributedLines"] + +class AttributedLines: + """ + AttributedLines is a list of lines of AttributedText that maintains a + vertical offset. + + When rendering a tree of messages, the RenderedMessage-s are drawn line by + line to an AttributedLines. AttributedLines. The AttributedLines is then + displayed in an AttributedLinesWidget. + + Multiple AttributedLines can be concatenated, keeping either the first or + the second AttributedLines's offset. + """ + + pass diff --git a/cheuph/attributed_lines_widget.py b/cheuph/attributed_lines_widget.py new file mode 100644 index 0000000..9335d68 --- /dev/null +++ b/cheuph/attributed_lines_widget.py @@ -0,0 +1,15 @@ +__all__ = ["AttributedLinesWidget", "ALWidget"] + +class AttributedLinesWidget: + """ + This widget draws lines of AttributedText with a horizontal and a vertical + offset. It can retrieve the attributes of any character by its (x, y) + coordinates. Line-wide attributes may be specified. + + When clicked, it sends an event containing the attributes of the character + that was just clicked. + """ + + pass + +ALWidget = AttributedLinesWidget diff --git a/cheuph/widgets/attributed_text_widget.py b/cheuph/attributed_text_widget.py similarity index 85% rename from cheuph/widgets/attributed_text_widget.py rename to cheuph/attributed_text_widget.py index 921a49f..80e0fbf 100644 --- a/cheuph/widgets/attributed_text_widget.py +++ b/cheuph/attributed_text_widget.py @@ -33,7 +33,7 @@ class AttributedTextWidget(urwid.Text): def _convert_to_markup(text: AttributedText ) -> List[Union[str, Tuple[str, str]]]: - # Wonder why it can't figure out the type signature of markup on its + # Wonder why mypy can't figure out the type signature of markup on its # own... :P markup: List[Union[str, Tuple[str, str]]] markup = [ @@ -51,6 +51,14 @@ class AttributedTextWidget(urwid.Text): self._attributed_text = text super().set_text(self._convert_to_markup(text)) + def set_text(self, *args, **kwargs): + """ + This function should not be used directly. Instead, use + set_attributed_text(). + """ + + raise NotImplementedError("use set_attributed_text() instead") + def get_attributed_text(self) -> AttributedText: """ Returns the currently used AttributedText. diff --git a/cheuph/config.py b/cheuph/config.py index 0826c93..9b5946e 100644 --- a/cheuph/config.py +++ b/cheuph/config.py @@ -1,3 +1,11 @@ +# TODO define a config structure including config element descriptions and +# default values +# +# TODO improve interface for accessing config values +# +# TODO load from and save to yaml file (only the values which differ from the +# defaults or which were explicitly set) + from typing import Any, Dict __all__ = ["Fields", "Config", "ConfigView"] diff --git a/cheuph/element.py b/cheuph/element.py deleted file mode 100644 index b7268f3..0000000 --- a/cheuph/element.py +++ /dev/null @@ -1,55 +0,0 @@ -import abc -from typing import Hashable, List, Optional - -from .exceptions import ElementException, TreeException -from .markup import AttributedText - -__all__ = ["Id", "Element", "RenderedElement"] - -Id = Hashable - -class Element(abc.ABC): - def __init__(self, - id: Id, - parent_id: Optional[Id], - ) -> None: - self._id = id - self._parent_id = parent_id - - - @property - def id(self) -> Id: - return self._id - - @property - def parent_id(self) -> Optional[Id]: - return self._parent_id - - @abc.abstractmethod - def render(self, - width: int, - depth: int, - highlighted: bool = False, - folded: bool = False, - ) -> "RenderedElement": - 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 lines(self) -> List[AttributedText]: - return self._lines - - @property - def height(self) -> int: - return len(self._lines) diff --git a/cheuph/element_supply.py b/cheuph/element_supply.py deleted file mode 100644 index 9f5ca4e..0000000 --- a/cheuph/element_supply.py +++ /dev/null @@ -1,207 +0,0 @@ -import abc -from typing import Dict, List, Optional, Set - -from .element import Element, Id -from .exceptions import TreeException - -__all__ = ["ElementSupply", "MemoryElementSupply"] - -class ElementSupply(abc.ABC): - """ - An ElementSupply is an interface to query some resource containing - Elements. The elements could for example be kept in memory, in a database - or somewhere else. - - The element ids must be unique, and the elements and their parents must - form one or more trees (i. e. must not contain any cycles). - """ - - @abc.abstractmethod - def get(self, element_id: Id) -> Element: - """ - Get a single element by its id. - """ - - pass - - def get_parent_id(self, element_id: Id) -> Optional[Id]: - """ - Get the id of the parent's element. - - This function is redundant, since you can just use element.parent_id. - """ - - return self.get(element_id).parent_id - - def get_parent(self, element_id: Id) -> Optional[Element]: - """ - Like get_parent_id, but returns the Element instead. - """ - - parent_id = self.get_parent_id(element_id) - - if parent_id is not None: - return self.get(parent_id) - else: - return None - - @abc.abstractmethod - def get_children_ids(self, element_id: Optional[Id]) -> List[Id]: - """ - Get a list of the ids of all the element's children. - """ - - pass - - def get_children(self, element_id: Optional[Id]) -> List[Element]: - """ - Get a list of all children of an element. - - If the id passed is None, return a list of all top-level elements - instead. - """ - - children_ids = self.get_children_ids(element_id) - - children: List[Element] = [] - for child_id in children_ids: - children.append(self.get(child_id)) - - return children - - def get_previous_id(self, element_id: Id) -> Optional[Id]: - """ - Get the id of an element's previous sibling (i. e. the sibling just - above it). - - Returns None if there is no previous sibling. - - Depending on the amount of elements in your ElementSupply, the default - implementation might get very slow and/or use a lot of memory. - """ - - siblings = self.get_children_ids(self.get_parent_id(element_id)) - index = siblings.index(element_id) - - if index <= 0: - return None - else: - return siblings[index - 1] - - def get_previous(self, element_id: Id) -> Optional[Element]: - """ - Like get_previous_id(), but returns the Element instead. - """ - - previous_id = self.get_previous_id(element_id) - - if previous_id is not None: - return self.get(previous_id) - else: - return None - - def get_next_id(self, element_id: Id) -> Optional[Id]: - """ - Get the id of an element's next sibling (i. e. the sibling just below - it). - - Returns None if there is no next sibling. - - Depending on the amount of elements in your ElementSupply, the default - implementation might get very slow and/or use a lot of memory. - """ - - siblings = self.get_children_ids(self.get_parent_id(element_id)) - index = siblings.index(element_id) - - if index >= len(siblings) - 1: - return None - else: - return siblings[index + 1] - - def get_next(self, element_id: Id) -> Optional[Element]: - """ - Like get_next_id(), but returns the Element instead. - """ - - next_id = self.get_next_id(element_id) - - if next_id is not None: - return self.get(next_id) - else: - return None - - def get_furthest_ancestor_id(self, - element_id: Id, - root_id: Optional[Id] = None, - ) -> Id: - current_id = element_id - - while True: - parent_id = self.get_parent_id(current_id) - - if parent_id == root_id: - return current_id - elif parent_id is None: - raise TreeException( - "Reached implicit root before hitting specified root") - - current_id = parent_id - - def get_furthest_ancestor(self, - element_id: Id, - root_id: Optional[Id] = None, - ) -> Element: - return self.get(self.get_furthest_ancestor_id(element_id, - root_id=root_id)) - -class MemoryElementSupply(ElementSupply): - """ - An in-memory implementation of an ElementSupply that works with any type of - Element. - """ - - def __init__(self) -> None: - self._elements: Dict[Id, Element] = {} - self._children: Dict[Optional[Id], Set[Id]] = {None: set()} - - def add(self, element: Element) -> None: - """ - Add a new element or overwrite an existing element with the same id. - """ - - if element.id in self._elements: - self.remove(element.id) - - self._elements[element.id] = element - self._children[element.id] = set() - self._children[element.parent_id].add(element.id) - - def remove(self, element_id: Id) -> None: - """ - Remove an element. This function does nothing if the element doesn't - exist in this ElementSupply. - """ - - if element_id in self._elements: - element = self.get(element_id) - - self._elements.pop(element_id) - self._children.pop(element_id) - self._children[element.parent_id].remove(element.id) - - def get(self, element_id: Id) -> Element: - result = self._elements.get(element_id) - - if result is None: - raise TreeException(f"Element with id {element_id!r} could not be found") - - return result - - def get_children_ids(self, element_id: Optional[Id]) -> List[Id]: - result = self._children.get(element_id) - - if result is None: - raise TreeException(f"Element with id {element_id!r} could not be found") - - return list(sorted(result)) diff --git a/cheuph/euphoria/__init__.py b/cheuph/euphoria/__init__.py deleted file mode 100644 index 46b26d5..0000000 --- a/cheuph/euphoria/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import List - -from .single_room_application import * -from .util import * - -__all__: List[str] = [] -__all__ += single_room_application.__all__ -__all__ += util.__all__ diff --git a/cheuph/euphoria/room_widget.py b/cheuph/euphoria/room_widget.py deleted file mode 100644 index 39d1b8d..0000000 --- a/cheuph/euphoria/room_widget.py +++ /dev/null @@ -1,82 +0,0 @@ -import asyncio -from typing import Any, List, Optional - -import urwid -import yaboli - -from ..config import Config -from ..markup import AT -from ..widgets import ATWidget - - -class CenteredTextWidget(urwid.WidgetWrap): - def __init__(self, lines: List[AT]): - max_width = max(map(len, lines)) - text = AT("\n").join(lines) - filler = urwid.Filler(ATWidget(text, align="center")) - super().__init__(filler) - -class RoomWidget(urwid.WidgetWrap): - """ - The RoomWidget connects to and displays a single yaboli room. - - Its life cycle looks like this: - 1. Create widget - 2. Call connect() (while the event loop is running) - 3. Keep widget around and occasionally display it - 4. Call disconnect() (while the event loop is runnning) - 5. When the room should be destroyed/forgotten about, it sends a "close" - event - """ - - def __init__(self, config: Config, roomname: str) -> None: - self.c = config - self._room = yaboli.Room(roomname) - - super().__init__(self._connecting_widget()) - self._room_view = self._connected_widget() - - def _connecting_widget(self) -> Any: - lines = [AT("Connecting to ") - + AT("&" + self.room.name, style=self.c.v.element.room) - + AT("...")] - return CenteredTextWidget(lines) - - def _connected_widget(self) -> Any: - lines = [AT("Connected to ") - + AT("&" + self.room.name, style=self.c.v.element.room) - + AT(".")] - return CenteredTextWidget(lines) - - def _connection_failed_widget(self) -> Any: - lines = [AT("Could not connect to ") - + AT("&" + self.room.name, style=self.c.v.element.room) - + AT(".")] - return CenteredTextWidget(lines) - - @property - def room(self) -> yaboli.Room: - return self._room - -# Start up the connection and room - - async def _connect(self) -> None: - success = await self._room.connect() - if success: - self._w = self._room_view - else: - self._w = self._connection_failed_widget() - urwid.emit_signal(self, "close") - - def connect(self) -> None: - asyncio.create_task(self._connect()) - -# Handle input - - #def selectable(self) -> bool: - # return True - - #def keypress(self, size: Any, key: str) -> Optional[str]: - # pass - -urwid.register_signal(RoomWidget, ["close"]) diff --git a/cheuph/euphoria/single_room_application.py b/cheuph/euphoria/single_room_application.py deleted file mode 100644 index 49a244f..0000000 --- a/cheuph/euphoria/single_room_application.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Any, Optional - -import urwid - -from ..config import Config -from .room_widget import RoomWidget - -__all__ = ["SingleRoomApplication"] - -class ChooseRoomWidget(urwid.WidgetWrap): - def __init__(self, config: Config) -> None: - self.c = config - - self.error = None - self.text = urwid.Text("Choose a room:", align=urwid.CENTER) - self.edit = urwid.Edit("&", align=urwid.CENTER) - self.pile = urwid.Pile([ - self.text, - urwid.AttrMap(self.edit, self.c.v.element.room), - ]) - self.filler = urwid.Filler(self.pile) - super().__init__(self.filler) - - def render(self, size: Any, focus: Any) -> Any: - if self.error: - width, _ = size - rows = self.error.rows((width,), focus) - self.filler.bottom = rows - - return super().render(size, focus) - - def set_error(self, text: Any) -> None: - self.error = urwid.Text(text, align=urwid.CENTER) - self.pile = urwid.Pile([ - self.error, - self.text, - urwid.AttrMap(self.edit, self.c.v.element.room), - ]) - self.filler = urwid.Filler(self.pile) - self._w = self.filler - - def unset_error(self) -> None: - self.error = None - self.pile = urwid.Pile([ - self.text, - urwid.AttrMap(self.edit, self.c.v.element.room), - ]) - self.filler = urwid.Filler(self.pile) - self._w = self.filler - - def could_not_connect(self, roomname: str) -> None: - text = [ - "Could not connect to ", - (self.c.v.element.room, "&" + roomname), - ".\n", - ] - self.set_error(text) - - def invalid_room_name(self, reason: str) -> None: - text = [f"Invalid room name: {reason}\n"] - self.set_error(text) - -class SingleRoomApplication(urwid.WidgetWrap): - # The characters in the ALPHABET make up the characters that are allowed in - # room names. - ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" - - # These are other characters or character combinations necessary for the - # editor to function well. - ALLOWED_EDITOR_KEYS = { - "backspace", "delete", - "left", "right", - "home", "end", - } - - def __init__(self, config: Config) -> None: - self.c = config - - self.choose_room = ChooseRoomWidget(self.c) - super().__init__(self.choose_room) - - def selectable(self) -> bool: - return True - - def switch_to_choose(self) -> None: - self.choose_room.could_not_connect(self.choose_room.edit.edit_text) - self._w = self.choose_room - - def keypress(self, size: Any, key: str) -> Optional[str]: - if self._w == self.choose_room: - if key == "esc": - raise urwid.ExitMainLoop() - - self.choose_room.unset_error() - - if key == "enter": - roomname = self.choose_room.edit.edit_text - - if roomname: - room = RoomWidget(self.c, roomname) - urwid.connect_signal(room, "close", self.switch_to_choose) - room.connect() - self._w = room - else: - self.choose_room.invalid_room_name("too short") - - elif not super().selectable(): - return key - # Make sure we only enter valid room names - elif key.lower() in self.ALPHABET: - return super().keypress(size, key.lower()) - elif key in self.ALLOWED_EDITOR_KEYS: - return super().keypress(size, key) - - return None - - elif super().selectable(): - return super().keypress(size, key) - - return key diff --git a/cheuph/euphoria/util.py b/cheuph/euphoria/util.py deleted file mode 100644 index 480a16e..0000000 --- a/cheuph/euphoria/util.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import List, Tuple, Union - -from ..config import Config - -__all__ = ["UtilException","Palette", "palette_from_config", "DEFAULT_CONFIG"] - -class UtilException(Exception): - pass - -Palette = List[Union[Tuple[str, str], Tuple[str, str, str], - Tuple[str, str, str, str]]] - -def palette_from_config(conf: Config) -> Palette: - palette: Palette = [] - - styles = conf.tree["style"] - for style, info in styles.items(): - # First, do the alias stuff - alias = info.get("alias") - if isinstance(alias, str): - if alias in styles: - palette.append((style, alias)) - continue - else: - raise UtilException((f"style.{style}.alias must be the name of" - " another style")) - elif alias is not None: - raise UtilException(f"style.{style}.alias must be a string") - - # Foreground/background - fg = info.get("fg") - bg = info.get("bg") - - if not isinstance(fg, str) and fg is not None: - raise TypeError(f"style.{style}.fg must be a string") - - if not isinstance(bg, str) and bg is not None: - raise TypeError(f"style.{style}.bg must be a string") - - fg = fg or "" - bg = bg or "" - - palette.append((style, fg, bg)) - - return palette - -DEFAULT_CONFIG = { - "element": { - "room": "room", - }, - "style": { - "room": { - "fg": "light blue, bold", - }, - }, -} diff --git a/cheuph/message.py b/cheuph/message.py new file mode 100644 index 0000000..6a576d0 --- /dev/null +++ b/cheuph/message.py @@ -0,0 +1,36 @@ +__all__ = ["Message", "RenderedMessage"] + +class Message: + """ + A Message represents a single euphoria message. It contains the information + and functionality necessary to render itself to lines of AttributedText. + + It does not contain information that usually changes, like a list of its + child messages, or if it is currently folded. + + A message's content is assumed to never change. Truncated messages are + never untruncated and displayed in full. Thus, the Message can ignore + truncation status. + """ + + pass + +class RenderedMessage: + """ + A RenderedMessage is the result of rendering a Message. It contains lines + of AttributedText, the target width to which the Message was rendered, its + final dimensions and possibly some other useful information. + + It only contains the rendered sender nick and message body, NOT the + message's indentation. + + A RenderedMessage is immutable. It can be used in a cache to prevent + re-rendering each message every time it is needed (preventing word wrapping + and other fancy calculations to be repeated on every re-render). + + It is also useful for scrolling and cursor movement, since the height of + the message displayed on screen is not inherent to the Message object, but + rather the result of rendering a Message. + """ + + pass diff --git a/cheuph/message_cache.py b/cheuph/message_cache.py new file mode 100644 index 0000000..bf68a55 --- /dev/null +++ b/cheuph/message_cache.py @@ -0,0 +1,8 @@ +__all__ = ["RenderedMessageCache"] +class RenderedMessageCache: + """ + This is a cache for RenderedMessage-s. Message-s should not need to be + redrawn every frame (and every in-between calculation). + """ + + pass diff --git a/cheuph/message_editor_widget.py b/cheuph/message_editor_widget.py new file mode 100644 index 0000000..72cffb2 --- /dev/null +++ b/cheuph/message_editor_widget.py @@ -0,0 +1,12 @@ +__all__ = ["MessageEditorWidget"] + +class MessageEditorWidget: + """ + This widget allows the user to compose a new message. It is based on + urwid's Edit widget. + + One day, it will (hopefully) support syntax highlighting and tab + completion. + """ + + pass diff --git a/cheuph/message_supply.py b/cheuph/message_supply.py new file mode 100644 index 0000000..6298704 --- /dev/null +++ b/cheuph/message_supply.py @@ -0,0 +1,14 @@ +__all__ = ["MessageSupply"] + +class MessageSupply: + """ + A MessageSupply holds all of a room's known messages. It can be queried in + different ways. Messages can also be added to or removed from the MessageSupply + as they are received by the client. + + The MessageSupply may use a database or keep messages in memory. A + MessageSupply may also wrap around another MessageSupply, to provide caching or + similar. + """ + + pass diff --git a/cheuph/message_tree_widget.py b/cheuph/message_tree_widget.py new file mode 100644 index 0000000..1f530db --- /dev/null +++ b/cheuph/message_tree_widget.py @@ -0,0 +1,26 @@ +__all__ = ["MessageTreeWidget"] + +class MessageTreeWidget: + """ + This widget displays an ElementSupply, including user interface like a + cursor or folding markers. It usually is part of a RoomWidget. It also + keeps a RenderedMessageCache (and maybe even other caches). + + It receives key presses and mouse clicks from its parent widget. It + receives redraw requests and cache invalidation notices from the + RoomWidget. + + It doesn't directly receive new messages. Rather, the RoomWidget adds them + to the ElementSupply and then submits a cache invalidation notice and a + redraw request. + + It emits a "room top hit" event (unnamed as of yet). When the RoomWidget + receives this event, it should retrieve more messages from the server. + + It emits a "edit" event (unnamed as of yet) when the user attempts to edit + a message. When the RoomWidget receives this event, it should open a text + editor to compose a new message, and send that message once the user + finishes editing it. + """ + + pass diff --git a/cheuph/tree_display.py b/cheuph/tree_display.py deleted file mode 100644 index d75f923..0000000 --- a/cheuph/tree_display.py +++ /dev/null @@ -1,386 +0,0 @@ -import collections -from typing import Any, List, Optional, Set - -from .element import Element, Id, RenderedElement -from .element_supply import ElementSupply -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 - - ancestor_id = self._supply.get_furthest_ancestor_id(self.anchor_id) - ancestor_tree = self._render_tree(ancestor_id) - - self._rendered = TreeList(ancestor_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 >= self.height - 1: - break - - below_tree_id = self._supply.get_next_id( - self._rendered.lower_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 diff --git a/cheuph/tree_list.py b/cheuph/tree_list.py deleted file mode 100644 index e7fcee3..0000000 --- a/cheuph/tree_list.py +++ /dev/null @@ -1,166 +0,0 @@ -import collections -from typing import Deque, List, Optional, Tuple - -from .element import Id, RenderedElement -from .markup import AttributedText - -__all__ = ["TreeList"] - -class TreeList: - """ - This class is the stage between tree-like Element structures and lines of - text like the TreeDisplay's DisplayLines. - - It keeps track of the results of rendering Element trees, and also the top - and bottom tree's ids, so the TreeList can be expanded easily by appending - trees to the top and bottom. - - Despite its name, the "trees" it stores are just flat lists, and they're - stored in a flat deque one message at a time. Its name comes from how i is - used with rendered Element trees. - """ - - def __init__(self, - tree: List[RenderedElement], - anchor_id: Id, - ) -> None: - self._deque: Deque[RenderedElement] = collections.deque() - - # 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. - # The lower offset is the index of the lowermost message's LAST line. - 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, anchor_id) - - @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 offset_by(self, delta: int) -> None: - """ - Change all the TreeList's offsets by a delta (which is added to each - offset). - """ - - self._upper_offset += delta - self._lower_offset += delta - - def _add_first_tree(self, - tree: List[RenderedElement], - anchor_id: Id - ) -> 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 tree: - 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 - - self._deque.extend(tree) - - def add_above(self, tree: List[RenderedElement]) -> None: - """ - Add a rendered tree above all current trees. - """ - - 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: - """ - Add a rendered tree below all current trees. - """ - - 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) - - def to_lines(self, - start: Optional[int] = None, - stop: Optional[int] = None, - ) -> List[Tuple[AttributedText, RenderedElement]]: - - offset = self.upper_offset - lines: List[Tuple[AttributedText, RenderedElement]] = [] - - # I'm creating this generator instead of using two nested for loops - # below, because I want to be able to break out of the for loop without - # the code getting too ugly, and because it's fun :) - all_lines = ((line, rendered) - for rendered in self._deque - for line in rendered.lines) - - for line, rendered in all_lines: - after_start = start is not None and offset >= start - before_stop = stop is not None and offset <= stop - - if after_start and before_stop: - lines.append((line, rendered)) - - if not before_stop: - break - - offset += 1 - - return lines diff --git a/cheuph/user_list_widget.py b/cheuph/user_list_widget.py new file mode 100644 index 0000000..28b908e --- /dev/null +++ b/cheuph/user_list_widget.py @@ -0,0 +1,11 @@ +__all__ = ["UserListWidget"] + +class UserListWidget: + """ + This widget displays the users currently connected to a Room. + + It must be notified of changes in the user list by the RoomWidget it is a + part of. + """ + + pass diff --git a/cheuph/widgets/__init__.py b/cheuph/widgets/__init__.py deleted file mode 100644 index 5e49838..0000000 --- a/cheuph/widgets/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import List - -from .attributed_text_widget import * -from .tree_display_widget import * - -__all__: List[str] = [] -__all__ += attributed_text_widget.__all__ -__all__ += tree_display_widget.__all__ diff --git a/cheuph/widgets/tree_display_widget.py b/cheuph/widgets/tree_display_widget.py deleted file mode 100644 index 57808cf..0000000 --- a/cheuph/widgets/tree_display_widget.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any, FrozenSet - -import urwid - -from ..element_supply import ElementSupply -from ..markup import AT -from ..tree_display import TreeDisplay -from .attributed_text_widget import AttributedTextWidget - -__all__ = ["TreeDisplayWidget"] - -class TreeDisplayWidget(urwid.WidgetWrap): - def __init__(self, supply: ElementSupply) -> None: - self._display = TreeDisplay(supply, 80, 50) - - self._sizing = frozenset({"box"}) - self._selectable = False - - # I could set wrap="clip", but the TreeDisplay should already cut its - # display_lines to the correct width, based on its size. Leaving the - # wrap on might help with users spotting things going wrong. - self._text_widget = AttributedTextWidget(AT()) - - super().__init__(urwid.Filler(self._text_widget)) - - @property - def display(self) -> TreeDisplay: - return self._display - - def render(self, size: Any, focus: Any) -> Any: - self._display.width, self._display.height = size - self._display.rerender() - self._display.render_display_lines() - - text = AT("\n").join(self._display.display_lines) - self._text_widget.set_attributed_text(text) - - return self._w.render(size, focus)