diff --git a/CHANGELOG.md b/CHANGELOG.md index a00a968..d10949e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,21 +2,8 @@ ## Next version -- Add demo gif to readme -- Fix indentation of multi-line messages -- Stop using dataclass (for backwards compatibility with Python 3.6) +Nothing yet. -## 1.0.0 (2019-06-21) +## 0.1.0 (2019-04-12) -- Add a readme -- Add ability to connect as human -- Add cookie support -- Add nick list -- Clean up code -- Fix crash on "Choose a room" screen -- Fix fetching old logs -- Rename project from "cheuph" to "bowl" - -## 0.1.0 (2019-06-21) - -- Use setuptools +- use setuptools diff --git a/README.md b/README.md deleted file mode 100644 index e9b3676..0000000 --- a/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# bowl - -A TUI client for [euphoria.io](https://euphoria.io) - -![bowl in action](demo.gif) - -## Installation - -Ensure that you have at least Python 3.7 installed. - -To install **bowl** or update your installation to the latest version, run the -following command wherever you want to install or have installed **bowl**: -``` -$ pip install git+https://github.com/Garmelon/bowl@v1.0.0 -``` - -The use of [venv](https://docs.python.org/3/library/venv.html) is recommended. - -## Example setup - -In this example, `python` refers to at least Python 3.7, as mentioned above. - -This example uses `venv`, so that `pip install` does not install any packages -globally. - -First, create a folder and a venv environment inside that folder. -``` -$ mkdir bowl -$ cd bowl -$ python -m venv . -$ . bin/activate -``` - -Then, install **bowl**. -``` -$ pip install git+https://github.com/Garmelon/bowl@v1.0.0 -``` - -Create a config file containing all default values in the default config file -location. -``` -$ mkdir -p ~/.config/bowl/ -$ bowl --export-defaults ~/.config/bowl/bowl.yaml -$ vim ~/.config/bowl/bowl.yaml -``` - -Run **bowl** (have fun!). -``` -$ bowl -``` - -Exit the venv environment again. -``` -$ deactivate -``` - -Subsequent runs of the program might look like this: -``` -$ cd bowl -$ . bin/activate -$ bowl -$ deactivate -``` diff --git a/bowl/euphoria/nick_list_widget.py b/bowl/euphoria/nick_list_widget.py deleted file mode 100644 index 99b3b17..0000000 --- a/bowl/euphoria/nick_list_widget.py +++ /dev/null @@ -1,148 +0,0 @@ -from typing import List, Optional, Tuple - -import urwid -import yaboli - -from ..attributed_lines import AttributedLines -from ..attributed_lines_widget import AttributedLinesWidget -from ..markup import AT, Attributes - -__all__ = ["NickListWidget"] - -class NickListWidget(urwid.WidgetWrap): - """ - This widget displays the nicks of 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. - """ - - def __init__(self, - session: Optional[yaboli.Session] = None, - users: Optional[yaboli.LiveSessionListing] = None, - heading_attrs: Attributes = {}, - counter_attrs: Attributes = {}, - nick_attrs: Attributes = {}, - own_nick_attrs: Attributes = {}, - ) -> None: - - self._session = session - self._users = users - - self._heading_attrs = heading_attrs - self._counter_attrs = counter_attrs - self._nick_attrs = nick_attrs - self._own_nick_attrs = own_nick_attrs - - self._offset = 0 - self._lines = AttributedLinesWidget() - - super().__init__(self._lines) - - self.rerender() - - @property - def session(self) -> Optional[yaboli.Session]: - return self._session - - @session.setter - def session(self, session: Optional[yaboli.Session]) -> None: - self._session = session - self.rerender() - - @property - def users(self) -> Optional[yaboli.LiveSessionListing]: - return self._users - - @users.setter - def users(self, users: Optional[yaboli.LiveSessionListing]) -> None: - self._users = users - self.rerender() - - def _sort_users(self) -> Tuple[List[yaboli.Session], List[yaboli.Session], List[yaboli.Session], List[yaboli.Session]]: - people = [] - bots = [] - lurkers = [] - nurkers = [] - - users = [] if self._users is None else self._users.all - - if self.session is not None: - users.append(self.session) - - for user in users: - if user.nick: - if user.is_bot: - bots.append(user) - else: - people.append(user) - else: - if user.is_bot: - nurkers.append(user) - else: - lurkers.append(user) - - return people, bots, lurkers, nurkers - - def _render_section(self, - name: str, - sessions: List[yaboli.Session], - ) -> AttributedLines: - - lines = AttributedLines() - - sessions.sort(key=lambda sess: sess.nick) - count = len(sessions) - - title = AT(name, attributes=self._heading_attrs) - title += AT(f" ({count})", attributes=self._counter_attrs) - lines.append_below({}, title) - - for sess in sessions: - if not sess.nick: - continue - - if sess is self._session: - attributes = self._own_nick_attrs - else: - attributes = self._nick_attrs - - lines.append_below({}, AT(sess.nick, attributes=attributes)) - - return lines - - def rerender(self) -> None: - lines = AttributedLines() - people, bots, lurkers, nurkers = self._sort_users() - - sections = [] - - if people: - sections.append(self._render_section("People", people)) - if bots: - sections.append(self._render_section("Bots", bots)) - if lurkers: - sections.append(self._render_section("Lurkers", lurkers)) - if nurkers: - sections.append(self._render_section("Nurkers", nurkers)) - - lines = AttributedLines() - lines.upper_offset = self._lines.upper_offset - - if len(sections) < 1: - lines.extend_below(self._render_section("Nobody", [])) - else: - lines.extend_below(sections[0]) - for section in sections[1:]: - lines.append_below({}, AT()) - lines.extend_below(section) - - self._lines.set_lines(lines) - - def scroll(self, delta: int) -> None: - self._lines.upper_offset += delta - - self._lines.lower_offset = max(1, self._lines.lower_offset) - self._lines.upper_offset = min(0, self._lines.upper_offset) - - self._invalidate() diff --git a/bowl/__init__.py b/cheuph/__init__.py similarity index 99% rename from bowl/__init__.py rename to cheuph/__init__.py index 5eff0a7..8fbf27a 100644 --- a/bowl/__init__.py +++ b/cheuph/__init__.py @@ -15,6 +15,7 @@ from .utils import * __all__: List[str] = [] + __all__ += attributed_lines.__all__ __all__ += attributed_lines_widget.__all__ __all__ += attributed_text_widget.__all__ diff --git a/bowl/attributed_lines.py b/cheuph/attributed_lines.py similarity index 95% rename from bowl/attributed_lines.py rename to cheuph/attributed_lines.py index 925c26e..970ecab 100644 --- a/bowl/attributed_lines.py +++ b/cheuph/attributed_lines.py @@ -5,11 +5,11 @@ import collections from typing import Any, Deque, Iterator, List, Optional, Set, Tuple -from .markup import AT, Attributes +from .markup import AT, AttributedText, Attributes __all__ = ["Line", "AttributedLines"] -Line = Tuple[Attributes, AT] +Line = Tuple[Attributes, AttributedText] class AttributedLines: """ @@ -49,8 +49,7 @@ class AttributedLines: def append_above(self, attributes: Attributes, - text: AT, - ) -> None: + text: AttributedText) -> None: """ Append a line above all already existing lines. The existing lines' offsets do not change. @@ -61,8 +60,7 @@ class AttributedLines: def append_below(self, attributes: Attributes, - text: AT, - ) -> None: + text: AttributedText) -> None: """ Append a line below all already existing lines. The existing lines' offsets do not change. @@ -135,7 +133,7 @@ class AttributedLines: horizontal_offset: int, offset_char: str = " ", overlap_char: str = "…", - ) -> AT: + ) -> AttributedText: """ Renders a single line to a specified width with a specified horizontal offset, applying all line-wide attributes to the result. The length of @@ -154,7 +152,7 @@ class AttributedLines: start_offset = horizontal_offset end_offset = start_offset + text_width - result: AT = AT() + result: AttributedText = AT() if start_offset < 0: pad_length = min(text_width, -start_offset) @@ -191,7 +189,7 @@ class AttributedLines: width: int, height: int, horizontal_offset: int, - ) -> List[AT]: + ) -> List[AttributedText]: """ Renders all lines individually. """ @@ -207,7 +205,7 @@ class AttributedLines: width: int, height: int, horizontal_offset: int, - ) -> AT: + ) -> AttributedText: """ Renders all lines and combines them into a single AttributedText by joining them with a newline. diff --git a/bowl/attributed_lines_widget.py b/cheuph/attributed_lines_widget.py similarity index 94% rename from bowl/attributed_lines_widget.py rename to cheuph/attributed_lines_widget.py index 5ce2011..0c3e2fb 100644 --- a/bowl/attributed_lines_widget.py +++ b/cheuph/attributed_lines_widget.py @@ -5,7 +5,7 @@ from typing import Optional, Tuple import urwid from .attributed_lines import AttributedLines -from .attributed_text_widget import ATWidget +from .attributed_text_widget import AttributedTextWidget from .markup import AT __all__ = ["AttributedLinesWidget"] @@ -17,7 +17,7 @@ class AttributedLinesWidget(urwid.WidgetWrap): """ def __init__(self, lines: Optional[AttributedLines] = None) -> None: - self._text = ATWidget(AT()) + self._text = AttributedTextWidget(AT()) self._filler = urwid.Filler(self._text, valign=urwid.TOP) super().__init__(self._filler) diff --git a/bowl/attributed_text_widget.py b/cheuph/attributed_text_widget.py similarity index 81% rename from bowl/attributed_text_widget.py rename to cheuph/attributed_text_widget.py index f070486..9261209 100644 --- a/bowl/attributed_text_widget.py +++ b/cheuph/attributed_text_widget.py @@ -2,7 +2,7 @@ from typing import Any, List, Tuple, Union import urwid -from .markup import AT +from .markup import AttributedText __all__ = ["AttributedTextWidget", "ATWidget"] @@ -15,9 +15,9 @@ class AttributedTextWidget(urwid.Text): """ def __init__(self, - text: AT, + text: AttributedText, *args: Any, - **kwargs: Any, + **kwargs: Any ) -> None: """ text - an AttributedText object @@ -30,7 +30,9 @@ class AttributedTextWidget(urwid.Text): super().__init__(self._convert_to_markup(text), *args, **kwargs) @staticmethod - def _convert_to_markup(text: AT) -> List[Union[str, Tuple[str, str]]]: + def _convert_to_markup(text: AttributedText + ) -> List[Union[str, Tuple[str, str]]]: + # Wonder why mypy can't figure out the type signature of markup on its # own... :P markup: List[Union[str, Tuple[str, str]]] @@ -41,7 +43,7 @@ class AttributedTextWidget(urwid.Text): return markup or [""] - def set_attributed_text(self, text: AT) -> None: + def set_attributed_text(self, text: AttributedText) -> None: """ Set the content of the AttributedTextWidget. """ @@ -49,7 +51,7 @@ class AttributedTextWidget(urwid.Text): self._attributed_text = text super().set_text(self._convert_to_markup(text)) - def get_attributed_text(self) -> AT: + def get_attributed_text(self) -> AttributedText: """ Returns the currently used AttributedText. @@ -61,7 +63,7 @@ class AttributedTextWidget(urwid.Text): return self._attributed_text @property - def attributed_text(self) -> AT: + def attributed_text(self) -> AttributedText: return self.get_attributed_text() ATWidget = AttributedTextWidget diff --git a/bowl/config.py b/cheuph/config.py similarity index 90% rename from bowl/config.py rename to cheuph/config.py index 19fb1ec..f545a4a 100644 --- a/bowl/config.py +++ b/cheuph/config.py @@ -1,5 +1,7 @@ +from dataclasses import dataclass, field from enum import Enum, auto -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple +from typing import (Any, Callable, Dict, Iterable, List, Optional, Tuple, + TypeVar) __all__ = ["ConfigException", "ConfigValueException", "TransparentConfig", "Kind", "Condition", "Option", "TreeLoader"] @@ -41,7 +43,6 @@ class TransparentConfig: # Special config reading and writing classes class Kind(Enum): - BOOL = auto() DICT = auto() FLOAT = auto() @@ -61,22 +62,15 @@ class Kind(Enum): Condition = Callable[[Any], bool] +@dataclass class Option: - - def __init__(self, - kind: Kind, - default: Any, - conditions: Iterable[Tuple[Condition, str]] = frozenset(), - ) -> None: - - self.kind = kind - self.default = default - self.conditions = conditions + kind: Kind + default: Any + conditions: Iterable[Tuple[Condition, str]] = field(default_factory=list) def check_valid(self, value: Any) -> None: if not self.kind.matches(value): - raise ConfigValueException( - f"value {value!r} does not match {self.kind}") + raise ConfigValueException(f"value {value!r} does not match {self.kind}") self.apply_conditions(value) diff --git a/bowl/cursor_rendering.py b/cheuph/cursor_rendering.py similarity index 98% rename from bowl/cursor_rendering.py rename to cheuph/cursor_rendering.py index f4f2dbd..dc9f088 100644 --- a/bowl/cursor_rendering.py +++ b/cheuph/cursor_rendering.py @@ -7,7 +7,7 @@ from .attributed_lines import AttributedLines from .element import Element, Id, Message, RenderedElement, RenderedMessage from .element_supply import ElementSupply from .exceptions import ShouldNeverHappen -from .markup import AT, Attributes +from .markup import AT, AttributedText, Attributes from .rendered_element_cache import RenderedElementCache __all__ = ["CursorRenderer", "CursorTreeRenderer", "BasicCursorRenderer"] @@ -28,7 +28,7 @@ class CursorRenderer(ABC, Generic[E, R]): pass @abstractmethod - def render_cursor(self, width: int) -> AT: + def render_cursor(self, width: int) -> AttributedText: pass class CursorTreeRenderer(Generic[E]): @@ -157,7 +157,7 @@ class CursorTreeRenderer(Generic[E]): def _render_message(self, message_id: Id, - indent: AT, + indent: AttributedText, ) -> AttributedLines: width = self._width - len(indent) - self._renderer.meta_width - 1 @@ -175,7 +175,9 @@ class CursorTreeRenderer(Generic[E]): return lines - def _render_cursor(self, indent: AT = AT(),) -> AttributedLines: + def _render_cursor(self, + indent: AttributedText = AT(), + ) -> AttributedLines: lines = AttributedLines() width = self._width - len(indent) - self._renderer.meta_width - 1 meta_spaces = AT(" " * self._renderer.meta_width) @@ -187,7 +189,7 @@ class CursorTreeRenderer(Generic[E]): def _render_indent(self, cursor: bool = False, cursor_line: bool = False, - ) -> AT: + ) -> AttributedText: if self._indent_width < 1: return AT() @@ -213,7 +215,7 @@ class CursorTreeRenderer(Generic[E]): def _render_subtree(self, lines: AttributedLines, root_id: Id, - indent: AT = AT(), + indent: AttributedText = AT(), ) -> None: if self._anchor_id == root_id: @@ -633,5 +635,5 @@ class BasicCursorRenderer(CursorRenderer): return RenderedMessage(message.id, lines, meta) - def render_cursor(self, width: int) -> AT: + def render_cursor(self, width: int) -> AttributedText: return AT("") diff --git a/bowl/cursor_tree_widget.py b/cheuph/cursor_tree_widget.py similarity index 100% rename from bowl/cursor_tree_widget.py rename to cheuph/cursor_tree_widget.py diff --git a/bowl/element.py b/cheuph/element.py similarity index 79% rename from bowl/element.py rename to cheuph/element.py index b76bffe..973133c 100644 --- a/bowl/element.py +++ b/cheuph/element.py @@ -1,7 +1,7 @@ import datetime from typing import Hashable, List, Optional -from .markup import AT +from .markup import AttributedText __all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"] @@ -26,8 +26,10 @@ class Element: return self._parent_id class RenderedElement: - - def __init__(self, id: Id, lines: List[AT]) -> None: + def __init__(self, + id: Id, + lines: List[AttributedText], + ) -> None: self._id = id self._lines = lines @@ -37,7 +39,7 @@ class RenderedElement: return self._id @property - def lines(self) -> List[AT]: + def lines(self) -> List[AttributedText]: return self._lines class Message(Element): @@ -69,10 +71,15 @@ class Message(Element): class RenderedMessage(RenderedElement): - def __init__(self, id: Id, lines: List[AT], meta: AT) -> None: + def __init__(self, + id: Id, + lines: List[AttributedText], + meta: AttributedText, + ) -> None: + super().__init__(id, lines) self._meta = meta @property - def meta(self) -> AT: + def meta(self) -> AttributedText: return self._meta diff --git a/bowl/element_supply.py b/cheuph/element_supply.py similarity index 100% rename from bowl/element_supply.py rename to cheuph/element_supply.py diff --git a/bowl/euphoria/__init__.py b/cheuph/euphoria/__init__.py similarity index 86% rename from bowl/euphoria/__init__.py rename to cheuph/euphoria/__init__.py index 9e147ba..5bb2313 100644 --- a/bowl/euphoria/__init__.py +++ b/cheuph/euphoria/__init__.py @@ -4,9 +4,9 @@ from .edit_widgets import * from .euph_config import * from .euph_renderer import * from .launch_application import * -from .nick_list_widget import * from .room_widget import * from .single_room_application import * +from .user_list_widget import * __all__: List[str] = [] @@ -14,6 +14,6 @@ __all__ += edit_widgets.__all__ __all__ += euph_config.__all__ __all__ += euph_renderer.__all__ __all__ += launch_application.__all__ -__all__ += nick_list_widget.__all__ __all__ += room_widget.__all__ __all__ += single_room_application.__all__ +__all__ += user_list_widget.__all__ diff --git a/bowl/euphoria/edit_widgets.py b/cheuph/euphoria/edit_widgets.py similarity index 100% rename from bowl/euphoria/edit_widgets.py rename to cheuph/euphoria/edit_widgets.py diff --git a/bowl/euphoria/euph_config.py b/cheuph/euphoria/euph_config.py similarity index 91% rename from bowl/euphoria/euph_config.py rename to cheuph/euphoria/euph_config.py index 44d17ce..a795c84 100644 --- a/bowl/euphoria/euph_config.py +++ b/cheuph/euphoria/euph_config.py @@ -1,6 +1,7 @@ -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, TypeVar -from ..config import ConfigValueException, Kind, TransparentConfig, TreeLoader +from ..config import (ConfigValueException, Kind, Option, TransparentConfig, + TreeLoader) __all__ = ["EuphConfig", "EuphLoader"] @@ -9,16 +10,6 @@ class EuphConfig(TransparentConfig): def __init__(self, parent: Optional[TransparentConfig] = None) -> None: super().__init__(parent) - # behavior - - @property - def cookie_file(self) -> Optional[str]: - return self["behavior.cookie_file"] - - @property - def human(self) -> bool: - return self["behavior.human"] - # basic styles @property @@ -177,16 +168,6 @@ class EuphConfig(TransparentConfig): def borders_style(self) -> str: return self["visual.borders.style"] - # nick list - - @property - def nick_list_heading_style(self) -> str: - return self["visual.nick_list.heading_style"] - - @property - def nick_list_counter_style(self) -> str: - return self["visual.nick_list.counter_style"] - # other @property @@ -214,18 +195,12 @@ class EuphLoader(TreeLoader): SINGLE_CHAR = (lambda x: len(x) == 1, "must be single character") AT_LEAST_0 = (lambda x: x >= 0, "must be at least 0") AT_LEAST_1 = (lambda x: x >= 1, "must be at least 1") - OPTIONAL_STR = (lambda x: x is None or type(x) is str, - "must be a string or empty") def __init__(self) -> None: super().__init__() self._styles: Set[str] = set() - # behavior - self.add("behavior.cookie_file", Kind.RAW, None, self.OPTIONAL_STR) - self.add("behavior.human", Kind.BOOL, True) - # basic styles self.add_style("visual.room_style", "room") self.add_style("visual.nick_style", "nick") @@ -288,10 +263,6 @@ class EuphLoader(TreeLoader): self.SINGLE_CHAR) self.add_style("visual.borders.style", "gray") - # nick list - self.add_style("visual.nick_list.heading_style", "bold") - self.add_style("visual.nick_list.counter_style", "gray") - # other self.add("styles", Kind.DICT, self.DEFAULT_STYLES) diff --git a/bowl/euphoria/euph_renderer.py b/cheuph/euphoria/euph_renderer.py similarity index 99% rename from bowl/euphoria/euph_renderer.py rename to cheuph/euphoria/euph_renderer.py index 00421c0..c999a7d 100644 --- a/bowl/euphoria/euph_renderer.py +++ b/cheuph/euphoria/euph_renderer.py @@ -123,7 +123,7 @@ class EuphRenderer(CursorRenderer): right = AT(self._surround_right, attributes=self._surround_attrs) nick_str = left + nick + right + AT(" ") - nick_spaces = AT(" " * len(nick_str)) + nick_spaces = AT(" " * len(nick)) content = self._filter_unicode(message.content) lines = [] diff --git a/bowl/euphoria/launch_application.py b/cheuph/euphoria/launch_application.py similarity index 90% rename from bowl/euphoria/launch_application.py rename to cheuph/euphoria/launch_application.py index d996f0e..45ab388 100644 --- a/bowl/euphoria/launch_application.py +++ b/cheuph/euphoria/launch_application.py @@ -12,12 +12,12 @@ from .euph_config import EuphConfig, EuphLoader __all__ = ["DEFAULT_CONFIG_PATHS", "launch"] DEFAULT_CONFIG_PATHS = [ - "~/.config/bowl/bowl.yaml", - "~/.bowl/bowl.yaml", - "~/.bowl.yaml", + "~/.config/cheuph/cheuph.yaml", + "~/.cheuph/cheuph.yaml", + "~/.cheuph.yaml", ] -GITHUB_URL = "https://github.com/Garmelon/bowl" +GITHUB_URL = "https://github.com/Garmelon/cheuph" def parse_arguments() -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -57,8 +57,8 @@ def load_config(args: Any) -> EuphConfig: return config -def export_defaults(path_str: str) -> None: - path = pathlib.Path(path_str).expanduser() +def export_defaults(path: str) -> None: + path = pathlib.Path(path).expanduser() print(f"Exporting default config to {path}") loader = EuphLoader() diff --git a/bowl/euphoria/room_widget.py b/cheuph/euphoria/room_widget.py similarity index 81% rename from bowl/euphoria/room_widget.py rename to cheuph/euphoria/room_widget.py index 523c211..7772df0 100644 --- a/bowl/euphoria/room_widget.py +++ b/cheuph/euphoria/room_widget.py @@ -1,9 +1,8 @@ import asyncio -import pathlib -from enum import Enum from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar import urwid + import yaboli from ..attributed_text_widget import ATWidget @@ -15,7 +14,6 @@ from ..markup import AT, AttributedText, Attributes from .edit_widgets import EditWidget from .euph_config import EuphConfig from .euph_renderer import EuphRenderer -from .nick_list_widget import NickListWidget __all__ = ["RoomWidget"] @@ -131,33 +129,23 @@ class RoomLayout(urwid.WidgetWrap): string = self._edit_separator * tree_width return AT(string, attributes=self._border_attrs) - def set_edit_visible(self, visible: bool) -> None: + def set_edit_visible(self, visible: bool): if visible: self._left_wrap._w = self._edit_pile else: self._left_wrap._w = self._tree - def focus_on_edit(self) -> None: + def focus_on_edit(self): self._edit_pile.focus_position = 2 self._columns.focus_position = 0 - def focus_on_tree(self) -> None: + def focus_on_tree(self): self._edit_pile.focus_position = 0 self._columns.focus_position = 0 - def focus_on_nick_list(self) -> None: + def focus_on_user_list(self): self._columns.focus_position = 2 -class UiMode(Enum): - - CONNECTING = "connecting" - CONNECTION_FAILED = "connection failed" - SETTING_PASSWORD = "setting password" - AUTHENTICATING = "authenticating" - SETTING_NICK = "setting nick" - VIEWING = "viewing" - EDITING = "editing" - class RoomWidget(urwid.WidgetWrap): """ The RoomWidget connects to and displays a single yaboli room. @@ -171,6 +159,11 @@ class RoomWidget(urwid.WidgetWrap): event """ + CONNECTING = "connecting" + CONNECTION_FAILED = "connection_failed" + VIEWING = "viewing" + EDITING = "editing" + def __init__(self, roomname: str, config: EuphConfig, @@ -179,36 +172,17 @@ class RoomWidget(urwid.WidgetWrap): self.c = config + if log_amount < 1: + raise ValueError() # TODO add better text self._log_amount = log_amount - if self._log_amount < 1: - raise ValueError("log request amount must be at least 1") - self._mode: UiMode + self._mode: str self._requesting_logs = False self._hit_top_of_supply = False - url_format = yaboli.Room.URL_FORMAT - if self.c.human: - url_format += "?h=1" - - cookie_file = self.c.cookie_file - if cookie_file is not None: - cookie_file = str(pathlib.Path(cookie_file).expanduser()) - - self._room = yaboli.Room( - roomname, - url_format=url_format, - cookie_file=cookie_file, - ) - - self._room.register_event("connected", self.on_connected) + self._room = yaboli.Room(roomname) self._room.register_event("snapshot", self.on_snapshot) self._room.register_event("send", self.on_send) - self._room.register_event("join", self.on_join) - self._room.register_event("part", self.on_part) - self._room.register_event("nick", self.on_nick) - self._room.register_event("edit", self.on_edit) - self._room.register_event("disconnect", self.on_disconnect) self._supply = InMemorySupply[Message]() self._renderer = self._create_euph_renderer() @@ -323,12 +297,7 @@ class RoomWidget(urwid.WidgetWrap): return urwid.Edit(multiline=True) def _create_nick_list_widget(self) -> Any: - return NickListWidget( - heading_attrs={"style": self.c.nick_list_heading_style}, - counter_attrs={"style": self.c.nick_list_counter_style}, - nick_attrs={"style": self.c.nick_style}, - own_nick_attrs={"style": self.c.own_nick_style}, - ) + return urwid.SolidFill("n") def _create_room_layout_widget(self, room_name: Any, @@ -367,42 +336,50 @@ class RoomWidget(urwid.WidgetWrap): ## UI mode and mode switching + CONNECTING = "connecting" + CONNECTION_FAILED = "connection_failed" + SETTING_PASSWORD = "setting_password" + AUTHENTICATING = "authenticating" + SETTING_NICK = "setting_nick" + VIEWING = "viewing" + EDITING = "editing" + def switch_connecting(self) -> None: self._w = self._connecting - self._mode = UiMode.CONNECTING + self._mode = self.CONNECTING def switch_connection_failed(self) -> None: self._w = self._connection_failed - self._mode = UiMode.CONNECTION_FAILED + self._mode = self.CONNECTION_FAILED def switch_setting_password(self) -> None: self._w = self._overlay self._overlay.set_top(self._edit_password) - self._mode = UiMode.SETTING_PASSWORD + self._mode = self.SETTING_PASSWORD def switch_authenticating(self) -> None: self._w = self._overlay self._overlay.set_top(self._authenticating) - self._mode = UiMode.AUTHENTICATING + self._mode = self.AUTHENTICATING def switch_setting_nick(self) -> None: self._w = self._overlay self._box.original_widget = self._edit_nick self._edit_nick.text = self._room.session.nick self.update_edit_nick() - self._mode = UiMode.SETTING_NICK + self._mode = self.SETTING_NICK def switch_view(self) -> None: self._w = self._layout self._layout.set_edit_visible(False) self._layout.focus_on_tree() - self._mode = UiMode.VIEWING + self._mode = self.VIEWING def switch_edit(self) -> None: self._w = self._layout self._layout.set_edit_visible(True) self._layout.focus_on_edit() - self._mode = UiMode.EDITING + self._mode = self.EDITING # Updating various parts of the UI @@ -411,15 +388,12 @@ class RoomWidget(urwid.WidgetWrap): def update_nick_list(self) -> None: # Ensure that self._room.session and self._room.users exist - allowed = {UiMode.SETTING_NICK, UiMode.VIEWING, UiMode.EDITING} - if self._mode not in allowed: + if self._mode not in {self.SETTING_NICK, self.VIEWING, self.EDITING}: return - # Automatically rerenders - self._nick_list.session = self._room.session - self._nick_list.users = self._room.users + #self._nick_list.update(self._room.session, self._room.users) - def update_edit_nick(self) -> None: + def update_edit_nick(self): width = self._edit_nick.width self._overlay.set_overlay_parameters( align=urwid.CENTER, @@ -429,15 +403,15 @@ class RoomWidget(urwid.WidgetWrap): ) self._overlay._invalidate() - def change_own_nick(self) -> None: + # Reacting to changes + + def own_nick_change(self): self._renderer.nick = self._room.session.nick self._tree.invalidate_all() self.update_tree() - - self._nick_list.session = self._room.session self.update_nick_list() - def receive_message(self, msg: yaboli.Message) -> None: + def receive_message(self, msg: yaboli.Message): self._supply.add(Message( msg.message_id, msg.parent_id, @@ -453,15 +427,14 @@ class RoomWidget(urwid.WidgetWrap): def render(self, size: Tuple[int, int], focus: bool) -> None: canvas = super().render(size, focus) - if not self._hit_top_of_supply: - if self._tree.hit_top and not self._requesting_logs: - self._requesting_logs = True - self.request_logs() + if self._tree.hit_top and not self._requesting_logs: + self._requesting_logs = True + self.request_logs() return canvas def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: - if self._mode == UiMode.VIEWING: + if self._mode == self.VIEWING: if key in {"enter", "meta enter"} and not self._room.session.nick: self.switch_setting_nick() elif key == "enter": @@ -479,7 +452,7 @@ class RoomWidget(urwid.WidgetWrap): else: return super().keypress(size, key) - elif self._mode == UiMode.EDITING: + elif self._mode == self.EDITING: if key == "enter": if self._edit.edit_text: self.send(self._edit.edit_text, self._tree.cursor_id) @@ -491,7 +464,7 @@ class RoomWidget(urwid.WidgetWrap): else: return super().keypress(size, key) - elif self._mode == UiMode.SETTING_NICK: + elif self._mode == self.SETTING_NICK: if key == "enter": if self._edit_nick.text: self.nick(self._edit_nick.text) @@ -512,66 +485,37 @@ class RoomWidget(urwid.WidgetWrap): # Reacting to euph events - async def on_connected(self) -> None: - pass - - async def on_snapshot(self, messages: List[yaboli.LiveMessage]) -> None: + async def on_snapshot(self, messages: List[yaboli.Message]): for message in messages: self.receive_message(message) + self.update_tree() - self.change_own_nick() - self.update_nick_list() - - async def on_send(self, message: yaboli.LiveMessage) -> None: + async def on_send(self, message: yaboli.Message): self.receive_message(message) - - async def on_join(self, user: yaboli.LiveSession) -> None: - self.update_nick_list() - - async def on_part(self, user: yaboli.LiveSession) -> None: - self.update_nick_list() - - async def on_nick(self, - user: yaboli.LiveSession, - from_: str, - to: str, - ) -> None: - - self.update_nick_list() - - async def on_edit(self, message: yaboli.LiveMessage) -> None: - self.receive_message(message) - - async def on_disconnect(self, reason: str) -> None: - pass + self.update_tree() # Euph actions @synchronous - async def request_logs(self) -> None: + async def request_logs(self): oldest_id = self._supply.oldest_id() if oldest_id is not None: messages = await self._room.log(self._log_amount, oldest_id) - - if len(messages) == 0: - self._hit_top_of_supply = True - for message in messages: self.receive_message(message) + self.update_tree() self._requesting_logs = False @synchronous - async def nick(self, nick: str) -> None: - try: - await self._room.nick(nick) - self.change_own_nick() - except yaboli.EuphException: - pass + async def nick(self, nick: str): + new_nick = await self._room.nick(nick) + self.own_nick_change() @synchronous - async def send(self, content: str, parent_id: Optional[str]) -> None: + async def send(self, content: str, parent_id: Optional[str]): message = await self._room.send(content, parent_id=parent_id) self.receive_message(message) + self.update_tree() urwid.register_signal(RoomWidget, ["close"]) diff --git a/bowl/euphoria/single_room_application.py b/cheuph/euphoria/single_room_application.py similarity index 94% rename from bowl/euphoria/single_room_application.py rename to cheuph/euphoria/single_room_application.py index 37565bf..88bb938 100644 --- a/bowl/euphoria/single_room_application.py +++ b/cheuph/euphoria/single_room_application.py @@ -1,12 +1,16 @@ +import asyncio +import logging +from pathlib import Path from typing import Any, Optional import urwid +import yaml from ..attributed_text_widget import ATWidget -from ..markup import AT +from ..markup import AT, Attributes from .edit_widgets import EditWidget -from .euph_config import EuphConfig from .launch_application import launch +from .euph_config import EuphConfig, EuphLoader from .room_widget import RoomWidget __all__ = ["SingleRoomApplication", "launch_single_room_application"] @@ -59,11 +63,11 @@ class ChooseRoomWidget(urwid.WidgetWrap): def invalid_room_name(self, reason: str) -> None: text = AT(f"Invalid room name: {reason}\n", - style=self._error_style) + attributes=self._error_attrs) self.set_error(ATWidget(text, align=urwid.CENTER)) class SingleRoomApplication(urwid.WidgetWrap): - + # # The characters in the ALPHABET make up the characters that are allowed in # room names. ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" @@ -126,5 +130,5 @@ class SingleRoomApplication(urwid.WidgetWrap): return key -def launch_single_room_application() -> None: +def launch_single_room_application(): launch(SingleRoomApplication) diff --git a/bowl/exceptions.py b/cheuph/exceptions.py similarity index 99% rename from bowl/exceptions.py rename to cheuph/exceptions.py index dfd10b0..c113f41 100644 --- a/bowl/exceptions.py +++ b/cheuph/exceptions.py @@ -4,7 +4,6 @@ class MessageSupplyException(Exception): pass class ShouldNeverHappen(Exception): - def __init__(self, number: int) -> None: message = (f"SNV{number:05} - please contact @Garmy with the code on" " the left if you see this") diff --git a/bowl/markup.py b/cheuph/markup.py similarity index 97% rename from bowl/markup.py rename to cheuph/markup.py index ba132d5..7a9f9af 100644 --- a/bowl/markup.py +++ b/cheuph/markup.py @@ -32,7 +32,10 @@ class Chunk: # Common special methods - def __init__(self, text: str, attributes: Attributes = {}) -> None: + def __init__(self, + text: str, + attributes: Attributes = {}, + ) -> None: self._text = text self._attributes = dict(attributes) @@ -48,8 +51,7 @@ class Chunk: if not isinstance(other, Chunk): return NotImplemented - return (self._text == other._text and - self._attributes == other._attributes) + return self._text == other._text and self._attributes == other._attributes def __getitem__(self, key: Union[int, slice]) -> "Chunk": return Chunk(self.text[key], self._attributes) @@ -245,13 +247,9 @@ class AttributedText: name: str, default: Any = None, ) -> Any: - return self._at(pos).get(name, default) - def split_by(self, - attribute_name: str, - ) -> List[Tuple["AttributedText", Any]]: - + def split_by(self, attribute_name: str) -> List[Tuple["AttributedText", Any]]: blocks = [] chunks: List[Chunk] = [] @@ -299,7 +297,6 @@ class AttributedText: 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) @@ -322,7 +319,6 @@ class AttributedText: 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) diff --git a/bowl/rendered_element_cache.py b/cheuph/rendered_element_cache.py similarity index 100% rename from bowl/rendered_element_cache.py rename to cheuph/rendered_element_cache.py diff --git a/bowl/utils.py b/cheuph/utils.py similarity index 100% rename from bowl/utils.py rename to cheuph/utils.py diff --git a/demo.gif b/demo.gif deleted file mode 100644 index c306d8f..0000000 Binary files a/demo.gif and /dev/null differ diff --git a/setup.py b/setup.py index 9e5b4bc..19e242d 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ from setuptools import setup setup( - name="bowl", - version="1.0.0", + name="cheuph", + version="0.0.1", packages=[ - "bowl", - "bowl.euphoria", + "cheuph", + "cheuph.euphoria", ], entry_points={ "console_scripts": [ - "bowl = bowl.euphoria:launch_single_room_application", + "cheuph = cheuph.euphoria:launch_single_room_application", ], }, install_requires=[ diff --git a/test.py b/test.py new file mode 100644 index 0000000..7c5efcd --- /dev/null +++ b/test.py @@ -0,0 +1,80 @@ +import curses +import subprocess +import tempfile +from typing import Any, List, Optional + +from cheuph.element import Element, Id, RenderedElement +from cheuph.element_supply import MemoryElementSupply +from cheuph.markup import AttributedText +from cheuph.tree_display import TreeDisplay + + +class TestElement(Element): + DEPTHSTR = "| " + + def __init__(self, + id: Id, + parent_id: Optional[Id], + text: List[str], + ) -> None: + + super().__init__(id, parent_id) + self.text = text + + def render(self, + width: int, + depth: int, + highlighted: bool = False, + folded: bool = False, + ) -> RenderedElement: + + depth_text = self.DEPTHSTR * depth + lines = [f"{depth_text}{line}" for line in self.text] + attributed_lines = [AttributedText(line) for line in lines] + return RenderedElement(self, attributed_lines) + +def main(stdscr: Any) -> None: + messages = MemoryElementSupply() + messages.add(TestElement("a", None, ["test element a"])) + messages.add(TestElement("b", "a", ["test element b","child of a"])) + messages.add(TestElement("c", None, ["test element c"])) + + display = TreeDisplay(messages, 80, 15) + display.anchor_id = "a" + display.anchor_offset = 5 + + display.rerender() + display.render_display_lines() + + print("-"*80) + for line in display.display_lines: + print(str(line)) + print("-"*80) + +# while True: +# key = stdscr.getkey() +# +# if key in {"\x1b", "q"}: +# return +# +# elif key == "e": +# with tempfile.TemporaryDirectory() as tmpdirname: +# tmpfilename = tmpdirname + "/" + "tempfile" +# #stdscr.addstr(f"{curses.COLOR_PAIRS!r}\n") +# stdscr.addstr(f"{tmpdirname!r} | {tmpfilename!r}\n") +# +# stdscr.getkey() +# +# curses.endwin() +# subprocess.run(["nvim", tmpfilename]) +# stdscr.refresh() +# +# stdscr.getkey() +# +# with open(tmpfilename) as f: +# for line in f: +# stdscr.addstr(line) + + +#curses.wrapper(main) +main(None) diff --git a/test/test_element_rendering.py b/test/test_element_rendering.py index 2a9e0b3..32c07fb 100644 --- a/test/test_element_rendering.py +++ b/test/test_element_rendering.py @@ -1,14 +1,14 @@ import unittest -from bowl import CursorTreeRenderer +import cheuph __all__ = ["TestCursorTreeRenderer"] class TestCursorTreeRenderer(unittest.TestCase): def test_static_offset(self): - gao = CursorTreeRenderer.get_absolute_offset - gro = CursorTreeRenderer.get_relative_offset + gao = cheuph.CursorTreeRenderer.get_absolute_offset + gro = cheuph.CursorTreeRenderer.get_relative_offset self.assertEqual(0, gao(0.0, 6)) self.assertEqual(1, gao(0.2, 6)) diff --git a/test/test_markup.py b/test/test_markup.py index 6dc251f..67328b9 100644 --- a/test/test_markup.py +++ b/test/test_markup.py @@ -1,6 +1,6 @@ import unittest -from bowl import AT +from cheuph import AT __all__ = ["TestAttributedText"] diff --git a/test/test_rendered_element_cache.py b/test/test_rendered_element_cache.py index c6da75f..9c062b1 100644 --- a/test/test_rendered_element_cache.py +++ b/test/test_rendered_element_cache.py @@ -1,6 +1,6 @@ import unittest -from bowl import Element, RenderedElementCache +from cheuph import Element, RenderedElementCache __all__ = ["TestRenderedElementCache"] diff --git a/test_scripts/.gitignore b/test_scripts/.gitignore new file mode 100644 index 0000000..b1687c3 --- /dev/null +++ b/test_scripts/.gitignore @@ -0,0 +1,2 @@ +cheuph +yaboli diff --git a/test_scripts/display_attr_lines_wiget.py b/test_scripts/display_attr_lines_wiget.py new file mode 100644 index 0000000..70d1dd0 --- /dev/null +++ b/test_scripts/display_attr_lines_wiget.py @@ -0,0 +1,60 @@ +import urwid +import urwid.curses_display + +import cheuph +from cheuph import AT, AttributedLines, AttributedLinesWidget + + +class TestWidget(urwid.WidgetWrap): + def __init__(self): + long_line = AT("super", style="red") + long_line += AT(" long", style="cyan") + long_line += AT(" line", style="magenta") + lines = [ + ({}, AT("abc", style="green")), + ({"style": "blue"}, AT("Hello world")), + ({}, AT(" ").join([long_line] * 10)), + ] + self.lines = AttributedLinesWidget(AttributedLines(lines)) + super().__init__(self.lines) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "left": + self.lines.horizontal_offset -= 1 + elif key == "right": + self.lines.horizontal_offset += 1 + elif key == "home": + self.lines.horizontal_offset = 0 + elif key == "up": + self.lines.upper_offset += 1 + elif key == "down": + self.lines.upper_offset -= 1 + + def mouse_event(self, size, event, button, col, row, focus): + if event == "mouse press": + if button == 4: + self.lines.upper_offset += 1 + if button == 5: + self.lines.upper_offset -= 1 + +def main(): + screen = urwid.curses_display.Screen() + palette = [ + ("red", "light red", ""), + ("yellow", "yellow", ""), + ("green", "light green", ""), + ("blue", "light blue", ""), + ("magenta", "light magenta", ""), + ("cyan", "light cyan", ""), + ] + loop = urwid.MainLoop( + TestWidget(), + screen=screen, + palette=palette, + ) + loop.run() + +main() diff --git a/test_scripts/display_attr_text_widget.py b/test_scripts/display_attr_text_widget.py new file mode 100644 index 0000000..0357dab --- /dev/null +++ b/test_scripts/display_attr_text_widget.py @@ -0,0 +1,20 @@ +import urwid +import urwid.curses_display + +import cheuph +from cheuph import AT, AttributedTextWidget + + +class TestWidget(urwid.WidgetWrap): + def __init__(self): + text = AT("Hello world!\nThis is some text.\nThird line.") + self.text = AttributedTextWidget(text) + self.filler = urwid.Filler(self.text) + super().__init__(self.filler) + +def main(): + screen = urwid.curses_display.Screen() + loop = urwid.MainLoop(TestWidget(), screen=screen) + loop.run() + +main() diff --git a/test_scripts/display_pressed_keys.py b/test_scripts/display_pressed_keys.py new file mode 100644 index 0000000..f07da43 --- /dev/null +++ b/test_scripts/display_pressed_keys.py @@ -0,0 +1,31 @@ +import urwid +import urwid.curses_display + +class TestWidget(urwid.WidgetWrap): + KEY_LIMIT = 10 + + def __init__(self): + self.last_keys = [] + self.text = urwid.Text("No key pressed yet", align=urwid.CENTER) + self.filler = urwid.Filler(self.text) + super().__init__(self.filler) + + def selectable(self): + return True + + def keypress(self, size, key): + self.last_keys.append(repr(key)) + self.last_keys = self.last_keys[-self.KEY_LIMIT:] + self.text.set_text("\n".join(self.last_keys)) + + def mouse_event(self, size, event, button, col, row, focus): + self.last_keys.append(f"{event!r} {button!r} ({row}, {col})") + self.last_keys = self.last_keys[-self.KEY_LIMIT:] + self.text.set_text("\n".join(self.last_keys)) + +def main(): + screen = urwid.curses_display.Screen() + loop = urwid.MainLoop(TestWidget(), screen=screen) + loop.run() + +main() diff --git a/test_scripts/display_room_layout.py b/test_scripts/display_room_layout.py new file mode 100644 index 0000000..ac9f33e --- /dev/null +++ b/test_scripts/display_room_layout.py @@ -0,0 +1,30 @@ +import urwid +import urwid.curses_display + +import cheuph +from cheuph import AT, AttributedTextWidget +from cheuph.euphoria.room_widget import RoomLayout + + +def main(): + widget = RoomLayout( + AttributedTextWidget(AT("&test"), align=urwid.CENTER), + urwid.SolidFill("n"), + urwid.SolidFill("t"), + AttributedTextWidget(AT("edit\ning")), + nick_list_width = 15, + border_attrs = {"style": "dim"}, + ) + widget.set_edit_visible(True) + palette = [ + ("dim", "dark gray,bold", ""), + ] + screen = urwid.curses_display.Screen() + loop = urwid.MainLoop( + widget, + palette=palette, + #screen=screen, + ) + loop.run() + +main() diff --git a/test_scripts/test_cursor_tree_widget.py b/test_scripts/test_cursor_tree_widget.py new file mode 100644 index 0000000..14949eb --- /dev/null +++ b/test_scripts/test_cursor_tree_widget.py @@ -0,0 +1,35 @@ +import datetime + +import urwid +import urwid.curses_display + +import cheuph +from cheuph import (AT, BasicCursorRenderer, CursorTreeRenderer, + CursorTreeWidget, InMemorySupply, Message) + + +def add(supply, level, text, amount=4): + t = datetime.datetime(2019, 5, 7, 13, 25, 6) + if level < 0: return + for i in range(amount): + new_text = f"{text}->{i}" + supply.add(Message(new_text, text or None, t, str(i), new_text)) + add(supply, level - 1, new_text, amount=amount) + +def main(): + s = InMemorySupply() + r = BasicCursorRenderer() + t = CursorTreeRenderer(s, r) + + add(s, 4, "") + + #screen = urwid.curses_display.Screen() + event_loop = urwid.AsyncioEventLoop() + loop = urwid.MainLoop( + cheuph.CursorTreeWidget(t), + #screen=screen, + event_loop=event_loop, + ) + loop.run() + +main() diff --git a/test_scripts/test_launch_single_room_widget.py b/test_scripts/test_launch_single_room_widget.py new file mode 100644 index 0000000..aac63c4 --- /dev/null +++ b/test_scripts/test_launch_single_room_widget.py @@ -0,0 +1,21 @@ +import asyncio +import logging +from typing import List, Optional + +import urwid + +from cheuph.euphoria.single_room_application import SingleRoomApplication + +logging.disable() + +def main(): + loop = asyncio.get_event_loop() + main_loop = urwid.MainLoop( + SingleRoomApplication(), + event_loop=urwid.AsyncioEventLoop(loop=loop), + ) + + main_loop.run() + +if __name__ == "__main__": + main() diff --git a/todo.txt b/todo.txt deleted file mode 100644 index 6f4705b..0000000 --- a/todo.txt +++ /dev/null @@ -1,37 +0,0 @@ -- config - x colors - - key bindings -- documentation (especially of the config) - -- profiling/optimisation - -- detail mode -- fold threads -- nick list -- better key bindings/controls -- center cursor on screen (after scrolling the view without scrolling the cursor) -- mouse support -- searching for messages -- better message editing when the screen is full -- detect when the dimensions are too small (meta width etc.) and display warning -- green "unread message" markers -- highlight things in messages - - offline log browsing - - @mentions - - &rooms - - https://links - - :emojis: - - /me s -- word wrapping for messages -- multi-room support -- db backend - - download room log - - auto repair gaps in log - -x robust starting script -x install via pip from github - x runnable script -x parse command-line parameters -x nick list -x room_widget refactor -x save cookies diff --git a/tree_display.py b/tree_display.py new file mode 100644 index 0000000..2d9a4af --- /dev/null +++ b/tree_display.py @@ -0,0 +1,295 @@ +# Element supply of some sort +# Dict-/map-like +# Tree structure +# ↓ +# List of already formatted elements +# Each with a line height, indentation, ... +# List structure +# ↓ +# Messages and UI elements rendered to lines +# with meta-information, links/ids +# List structure, but on lines, not individual messages + +class Element: + pass + +class ElementSupply: + pass + +class TreeDisplay: + """ + Message line coordinates: + + n - Highest message + ... + 1 - Higher message + 0 - Lowest message + + Screen/line coordinates: + + h-1 - First line + h-2 - Second line + ... + 1 - Second to last line + 0 - Last line + + Terms: + + + | ... + | ... (above) + | ... + | + | | ... + | | | ... | + | ... + | ... (below) + | ... + + or + + + | ... + | ... (above) + | ... + | + | ... + | ... (below) + | ... + + The stem is a child of the base. The anchor is a direct or indirect child + of the stem, or it is the stem itself. + + The base id may also be None (the implicit parent of all top-level + messages in a room) + """ + + def __init__(self, window: Any) -> None: + self.window = window + + self._anchor_id = None + # Position of the formatted anchor's uppermost line on the screen + self._anchor_screen_pos = 0 + + + def render(self) -> None: + """ + Intermediate structures: + - Upwards and downwards list of elements + focused element + - Upwards and downwards list of rendered elements + focused element + - List of visible lines (saved and used for mouse clicks etc.) + + Steps of rendering: + 1. Load all necessary elements + 2. Render all messages with indentation + 3. Compile lines + + Steps of advanced rendering: + 1. Load focused element + render + 2. Load tree of focused element + render + 3. Load trees above and below + render, as many as necessary + 4. While loading and rendering trees, auto-collapse + 5. Move focus if focused element was hidden in an auto-collapse + ...? + """ + + # Step 1: Find and render the tree the anchor is in. + + stem_id = self._supply.find_stem_id(self._anchor_id, + base=self._base_id) + + tree = self._supply.get_tree(stem_id) + # The render might modify self._anchor_id, if the original anchor can't + # be displayed. + self._render_tree(tree) + + above, anchor, below = self._split_at_anchor(tree) + + # Step 2: Add more trees above and below, until the screen can be + # filled or there aren't any elements left in the store. + + # h_win = 7 + # 6 | <- h_above = 3 + # 5 | + # 4 | + # 3 | <- anchor, self._anchor_screen_pos = 3, anchor.height = 2 + # 2 | + # 1 | <- h_below = 2 + # 0 | + # + # 7 - 3 - 1 = 3 -- correct + # 3 - 2 + 1 = 2 -- correct + + height_window = None # TODO + + # All values are converted to zero indexed values in the calculations + height_above = (height_window - 1) - self._anchor_screen_pos + height_below = self._anchor_screen_pos - (anchor.height - 1) + + self._extend(above, height_above, base=self._base_id) + self._extend(below, height_below, base=self._base_id) + + self._lines = self._render_to_lines(above, anchor, below) + self._update_window(self._lines) + +# TreeDisplay plan(s): +# +# [ ] 1. Render a basic tree thing from an ElementSupply +# [ ] 1.1. Take/own a curses window +# [ ] 1.2. Keep track of the text internally +# [ ] 1.3. Retrieve elements from an ElementSupply +# [ ] 1.4. Render elements to text depending on width of curses window +# [ ] 1.5. Do indentation +# [ ] 1.6. Use "..." where a thing can't be rendered +# [ ] 2. Scroll and anchor to messages +# [ ] 2.1. Listen to key presses +# [ ] 2.2. Scroll up/down single lines +# [ ] 2.3. Render starting from the anchor +# [ ] 2.4. Some sort of culling, but preserve CONSISTENCY! +# [ ] 3. Focus on single message +# [ ] 3.1. Keep track of focused message +# [ ] 3.2. Move focused message +# [ ] 3.3. Keep message visible on screen +# [ ] 3.4. Set anchor to focus when focus is visible +# [ ] 3.5. Find anchor solution for when focus is offscreen +# [ ] 4. Collapse element threads +# [ ] 4.1. Collapse thread at any element +# [ ] 4.2. Auto-collapse threads when they can't be displayed +# [ ] 4.3. Focus collapsed messages +# [ ] 4.4. Move focus when a hidden message would have focus +# [ ] 5. Interaction with elements +# [ ] 5.1. Forward key presses +# [ ] 5.2. Mouse clicks + message attributes +# [ ] 5.3. Element visibility +# [ ] 5.4. Request more elements when the top of the element supply is hit +# [ ] 5.5. ... and various other things + +# STRUCTURE +# +# No async! +# +# The TreeView "owns" and completely fills one curses window. +# +# When rendering things, the TreeDisplay takes elements from the ElementSupply +# as needed. This should be a fast operation. +# +# When receiving key presses, the ones that are not interpreted by the TreeView +# are passed onto the currently focused element (if any). +# +# When receiving mouse clicks, the clicked-on element is focused and then the +# click and attributes of the clicked character are passed onto the focused +# element. +# +# (TODO: Notify focused elements? Make "viewed/new" state for elements +# possible?) +# +# +# +# DESIGN PRINCIPLES +# +# Layout: See below +# +# Color support: Definitely. +# No-color-mode: Not planned. +# => Colors required. + +# The tree display can display a tree-like structure of elements. +# +# Each element consists of: +# 1. a way to display the element +# 2. a way to forward key presses to the element +# 3. element-specific attributes (Attributes), including: +# 3.1 "id", the element's hashable id +# 3.2 optionally "parent_id", the element's parent's hashable id +# +# (TODO: A way to notify the element that it is visible?) +# +# (TODO: Jump to unread messages, mark messages as read, up/down arrows like +# instant, message jump tags?) +# +# (TODO: Curses + threading/interaction etc.?) +# +# +# +# LAYOUT +# +# A tree display's basic structure is something like this: +# +# +# | +# | | +# | | +# | +# | +# | | +# | | | +# | +# +# | +# +# It has an indentation string ("| " in the above example) that is prepended to +# each line according to its indentation. (TODO: Possibly add different types +# of indentation strings?) +# +# In general, "..." is inserted any time a message or other placeholder can't +# be displayed. (TODO: If "..." can't be displayed, it is shortened until it +# can be displayed?) +# +# Elements can be collapsed. Collapsed elements are displayed as "+ ()" +# where is the number of elements in the hidden subtree. +# +# If an element is so far right that it can't be displayed, the tree display +# first tries to collapse the tree. If the collapsed message can't be displayed +# either, it uses "..." as mentioned above. +# +# +# | +# | | +# | | +# | +# | + (3) +# | +# +# | +# | | +# | | | +# | | | | ... +# +# +# +# NAVIGATION +# +# For navigation, the following movements/keys are used (all other key presses +# are forwarded to the currently selected element, if there is one): +# +# LEFT (left arrow, h): move to the selected element's parent +# +# RIGHT (right arrow, l): move to the selected element's first child +# +# UP (up arrow, k): move to the element visually below the selected element +# +# DOWN (down arrow, j): move to the element visually above the selected element +# +# Mod + LEFT: move to the selected element's previous sibling, if one exists +# +# Mod + RIGHT: move to the selected element's next sibling, if one exists +# +# Mod + UP: scroll up by scroll step +# +# Mod + DOWN: scroll down by scroll step +# +# +# CURSES +# +# Main thread: +# - async +# - yaboli +# - curses: non-blocking input +# - curses: update visuals +# - run editor in async variant of subprocess or separate thread +# +# +# +# STRUCTURE +# +# ???