diff --git a/CHANGELOG.md b/CHANGELOG.md index d10949e..a00a968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,21 @@ ## Next version -Nothing yet. +- Add demo gif to readme +- Fix indentation of multi-line messages +- Stop using dataclass (for backwards compatibility with Python 3.6) -## 0.1.0 (2019-04-12) +## 1.0.0 (2019-06-21) -- use setuptools +- 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b3676 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 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/cheuph/__init__.py b/bowl/__init__.py similarity index 99% rename from cheuph/__init__.py rename to bowl/__init__.py index 8fbf27a..5eff0a7 100644 --- a/cheuph/__init__.py +++ b/bowl/__init__.py @@ -15,7 +15,6 @@ from .utils import * __all__: List[str] = [] - __all__ += attributed_lines.__all__ __all__ += attributed_lines_widget.__all__ __all__ += attributed_text_widget.__all__ diff --git a/cheuph/attributed_lines.py b/bowl/attributed_lines.py similarity index 95% rename from cheuph/attributed_lines.py rename to bowl/attributed_lines.py index 970ecab..925c26e 100644 --- a/cheuph/attributed_lines.py +++ b/bowl/attributed_lines.py @@ -5,11 +5,11 @@ import collections from typing import Any, Deque, Iterator, List, Optional, Set, Tuple -from .markup import AT, AttributedText, Attributes +from .markup import AT, Attributes __all__ = ["Line", "AttributedLines"] -Line = Tuple[Attributes, AttributedText] +Line = Tuple[Attributes, AT] class AttributedLines: """ @@ -49,7 +49,8 @@ class AttributedLines: def append_above(self, attributes: Attributes, - text: AttributedText) -> None: + text: AT, + ) -> None: """ Append a line above all already existing lines. The existing lines' offsets do not change. @@ -60,7 +61,8 @@ class AttributedLines: def append_below(self, attributes: Attributes, - text: AttributedText) -> None: + text: AT, + ) -> None: """ Append a line below all already existing lines. The existing lines' offsets do not change. @@ -133,7 +135,7 @@ class AttributedLines: horizontal_offset: int, offset_char: str = " ", overlap_char: str = "…", - ) -> AttributedText: + ) -> AT: """ Renders a single line to a specified width with a specified horizontal offset, applying all line-wide attributes to the result. The length of @@ -152,7 +154,7 @@ class AttributedLines: start_offset = horizontal_offset end_offset = start_offset + text_width - result: AttributedText = AT() + result: AT = AT() if start_offset < 0: pad_length = min(text_width, -start_offset) @@ -189,7 +191,7 @@ class AttributedLines: width: int, height: int, horizontal_offset: int, - ) -> List[AttributedText]: + ) -> List[AT]: """ Renders all lines individually. """ @@ -205,7 +207,7 @@ class AttributedLines: width: int, height: int, horizontal_offset: int, - ) -> AttributedText: + ) -> AT: """ Renders all lines and combines them into a single AttributedText by joining them with a newline. diff --git a/cheuph/attributed_lines_widget.py b/bowl/attributed_lines_widget.py similarity index 94% rename from cheuph/attributed_lines_widget.py rename to bowl/attributed_lines_widget.py index 0c3e2fb..5ce2011 100644 --- a/cheuph/attributed_lines_widget.py +++ b/bowl/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 AttributedTextWidget +from .attributed_text_widget import ATWidget from .markup import AT __all__ = ["AttributedLinesWidget"] @@ -17,7 +17,7 @@ class AttributedLinesWidget(urwid.WidgetWrap): """ def __init__(self, lines: Optional[AttributedLines] = None) -> None: - self._text = AttributedTextWidget(AT()) + self._text = ATWidget(AT()) self._filler = urwid.Filler(self._text, valign=urwid.TOP) super().__init__(self._filler) diff --git a/cheuph/attributed_text_widget.py b/bowl/attributed_text_widget.py similarity index 81% rename from cheuph/attributed_text_widget.py rename to bowl/attributed_text_widget.py index 9261209..f070486 100644 --- a/cheuph/attributed_text_widget.py +++ b/bowl/attributed_text_widget.py @@ -2,7 +2,7 @@ from typing import Any, List, Tuple, Union import urwid -from .markup import AttributedText +from .markup import AT __all__ = ["AttributedTextWidget", "ATWidget"] @@ -15,9 +15,9 @@ class AttributedTextWidget(urwid.Text): """ def __init__(self, - text: AttributedText, + text: AT, *args: Any, - **kwargs: Any + **kwargs: Any, ) -> None: """ text - an AttributedText object @@ -30,9 +30,7 @@ class AttributedTextWidget(urwid.Text): super().__init__(self._convert_to_markup(text), *args, **kwargs) @staticmethod - def _convert_to_markup(text: AttributedText - ) -> List[Union[str, Tuple[str, str]]]: - + def _convert_to_markup(text: AT) -> 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]]] @@ -43,7 +41,7 @@ class AttributedTextWidget(urwid.Text): return markup or [""] - def set_attributed_text(self, text: AttributedText) -> None: + def set_attributed_text(self, text: AT) -> None: """ Set the content of the AttributedTextWidget. """ @@ -51,7 +49,7 @@ class AttributedTextWidget(urwid.Text): self._attributed_text = text super().set_text(self._convert_to_markup(text)) - def get_attributed_text(self) -> AttributedText: + def get_attributed_text(self) -> AT: """ Returns the currently used AttributedText. @@ -63,7 +61,7 @@ class AttributedTextWidget(urwid.Text): return self._attributed_text @property - def attributed_text(self) -> AttributedText: + def attributed_text(self) -> AT: return self.get_attributed_text() ATWidget = AttributedTextWidget diff --git a/cheuph/config.py b/bowl/config.py similarity index 90% rename from cheuph/config.py rename to bowl/config.py index f545a4a..19fb1ec 100644 --- a/cheuph/config.py +++ b/bowl/config.py @@ -1,7 +1,5 @@ -from dataclasses import dataclass, field from enum import Enum, auto -from typing import (Any, Callable, Dict, Iterable, List, Optional, Tuple, - TypeVar) +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple __all__ = ["ConfigException", "ConfigValueException", "TransparentConfig", "Kind", "Condition", "Option", "TreeLoader"] @@ -43,6 +41,7 @@ class TransparentConfig: # Special config reading and writing classes class Kind(Enum): + BOOL = auto() DICT = auto() FLOAT = auto() @@ -62,15 +61,22 @@ class Kind(Enum): Condition = Callable[[Any], bool] -@dataclass class Option: - kind: Kind - default: Any - conditions: Iterable[Tuple[Condition, str]] = field(default_factory=list) + + def __init__(self, + kind: Kind, + default: Any, + conditions: Iterable[Tuple[Condition, str]] = frozenset(), + ) -> None: + + self.kind = kind + self.default = default + self.conditions = conditions 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/cheuph/cursor_rendering.py b/bowl/cursor_rendering.py similarity index 98% rename from cheuph/cursor_rendering.py rename to bowl/cursor_rendering.py index dc9f088..f4f2dbd 100644 --- a/cheuph/cursor_rendering.py +++ b/bowl/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, AttributedText, Attributes +from .markup import AT, 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) -> AttributedText: + def render_cursor(self, width: int) -> AT: pass class CursorTreeRenderer(Generic[E]): @@ -157,7 +157,7 @@ class CursorTreeRenderer(Generic[E]): def _render_message(self, message_id: Id, - indent: AttributedText, + indent: AT, ) -> AttributedLines: width = self._width - len(indent) - self._renderer.meta_width - 1 @@ -175,9 +175,7 @@ class CursorTreeRenderer(Generic[E]): return lines - def _render_cursor(self, - indent: AttributedText = AT(), - ) -> AttributedLines: + def _render_cursor(self, indent: AT = AT(),) -> AttributedLines: lines = AttributedLines() width = self._width - len(indent) - self._renderer.meta_width - 1 meta_spaces = AT(" " * self._renderer.meta_width) @@ -189,7 +187,7 @@ class CursorTreeRenderer(Generic[E]): def _render_indent(self, cursor: bool = False, cursor_line: bool = False, - ) -> AttributedText: + ) -> AT: if self._indent_width < 1: return AT() @@ -215,7 +213,7 @@ class CursorTreeRenderer(Generic[E]): def _render_subtree(self, lines: AttributedLines, root_id: Id, - indent: AttributedText = AT(), + indent: AT = AT(), ) -> None: if self._anchor_id == root_id: @@ -635,5 +633,5 @@ class BasicCursorRenderer(CursorRenderer): return RenderedMessage(message.id, lines, meta) - def render_cursor(self, width: int) -> AttributedText: + def render_cursor(self, width: int) -> AT: return AT("") diff --git a/cheuph/cursor_tree_widget.py b/bowl/cursor_tree_widget.py similarity index 100% rename from cheuph/cursor_tree_widget.py rename to bowl/cursor_tree_widget.py diff --git a/cheuph/element.py b/bowl/element.py similarity index 79% rename from cheuph/element.py rename to bowl/element.py index 973133c..b76bffe 100644 --- a/cheuph/element.py +++ b/bowl/element.py @@ -1,7 +1,7 @@ import datetime from typing import Hashable, List, Optional -from .markup import AttributedText +from .markup import AT __all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"] @@ -26,10 +26,8 @@ class Element: return self._parent_id class RenderedElement: - def __init__(self, - id: Id, - lines: List[AttributedText], - ) -> None: + + def __init__(self, id: Id, lines: List[AT]) -> None: self._id = id self._lines = lines @@ -39,7 +37,7 @@ class RenderedElement: return self._id @property - def lines(self) -> List[AttributedText]: + def lines(self) -> List[AT]: return self._lines class Message(Element): @@ -71,15 +69,10 @@ class Message(Element): class RenderedMessage(RenderedElement): - def __init__(self, - id: Id, - lines: List[AttributedText], - meta: AttributedText, - ) -> None: - + def __init__(self, id: Id, lines: List[AT], meta: AT) -> None: super().__init__(id, lines) self._meta = meta @property - def meta(self) -> AttributedText: + def meta(self) -> AT: return self._meta diff --git a/cheuph/element_supply.py b/bowl/element_supply.py similarity index 100% rename from cheuph/element_supply.py rename to bowl/element_supply.py diff --git a/cheuph/euphoria/__init__.py b/bowl/euphoria/__init__.py similarity index 86% rename from cheuph/euphoria/__init__.py rename to bowl/euphoria/__init__.py index 5bb2313..9e147ba 100644 --- a/cheuph/euphoria/__init__.py +++ b/bowl/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/cheuph/euphoria/edit_widgets.py b/bowl/euphoria/edit_widgets.py similarity index 100% rename from cheuph/euphoria/edit_widgets.py rename to bowl/euphoria/edit_widgets.py diff --git a/cheuph/euphoria/euph_config.py b/bowl/euphoria/euph_config.py similarity index 91% rename from cheuph/euphoria/euph_config.py rename to bowl/euphoria/euph_config.py index a795c84..44d17ce 100644 --- a/cheuph/euphoria/euph_config.py +++ b/bowl/euphoria/euph_config.py @@ -1,7 +1,6 @@ -from typing import Any, Dict, List, Optional, Set, TypeVar +from typing import Any, Dict, List, Optional, Set -from ..config import (ConfigValueException, Kind, Option, TransparentConfig, - TreeLoader) +from ..config import ConfigValueException, Kind, TransparentConfig, TreeLoader __all__ = ["EuphConfig", "EuphLoader"] @@ -10,6 +9,16 @@ 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 @@ -168,6 +177,16 @@ 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 @@ -195,12 +214,18 @@ 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") @@ -263,6 +288,10 @@ 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/cheuph/euphoria/euph_renderer.py b/bowl/euphoria/euph_renderer.py similarity index 99% rename from cheuph/euphoria/euph_renderer.py rename to bowl/euphoria/euph_renderer.py index c999a7d..00421c0 100644 --- a/cheuph/euphoria/euph_renderer.py +++ b/bowl/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)) + nick_spaces = AT(" " * len(nick_str)) content = self._filter_unicode(message.content) lines = [] diff --git a/cheuph/euphoria/launch_application.py b/bowl/euphoria/launch_application.py similarity index 90% rename from cheuph/euphoria/launch_application.py rename to bowl/euphoria/launch_application.py index 45ab388..d996f0e 100644 --- a/cheuph/euphoria/launch_application.py +++ b/bowl/euphoria/launch_application.py @@ -12,12 +12,12 @@ from .euph_config import EuphConfig, EuphLoader __all__ = ["DEFAULT_CONFIG_PATHS", "launch"] DEFAULT_CONFIG_PATHS = [ - "~/.config/cheuph/cheuph.yaml", - "~/.cheuph/cheuph.yaml", - "~/.cheuph.yaml", + "~/.config/bowl/bowl.yaml", + "~/.bowl/bowl.yaml", + "~/.bowl.yaml", ] -GITHUB_URL = "https://github.com/Garmelon/cheuph" +GITHUB_URL = "https://github.com/Garmelon/bowl" 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) -> None: - path = pathlib.Path(path).expanduser() +def export_defaults(path_str: str) -> None: + path = pathlib.Path(path_str).expanduser() print(f"Exporting default config to {path}") loader = EuphLoader() diff --git a/bowl/euphoria/nick_list_widget.py b/bowl/euphoria/nick_list_widget.py new file mode 100644 index 0000000..99b3b17 --- /dev/null +++ b/bowl/euphoria/nick_list_widget.py @@ -0,0 +1,148 @@ +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/cheuph/euphoria/room_widget.py b/bowl/euphoria/room_widget.py similarity index 81% rename from cheuph/euphoria/room_widget.py rename to bowl/euphoria/room_widget.py index 7772df0..523c211 100644 --- a/cheuph/euphoria/room_widget.py +++ b/bowl/euphoria/room_widget.py @@ -1,8 +1,9 @@ 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 @@ -14,6 +15,7 @@ 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"] @@ -129,23 +131,33 @@ class RoomLayout(urwid.WidgetWrap): string = self._edit_separator * tree_width return AT(string, attributes=self._border_attrs) - def set_edit_visible(self, visible: bool): + def set_edit_visible(self, visible: bool) -> None: if visible: self._left_wrap._w = self._edit_pile else: self._left_wrap._w = self._tree - def focus_on_edit(self): + def focus_on_edit(self) -> None: self._edit_pile.focus_position = 2 self._columns.focus_position = 0 - def focus_on_tree(self): + def focus_on_tree(self) -> None: self._edit_pile.focus_position = 0 self._columns.focus_position = 0 - def focus_on_user_list(self): + def focus_on_nick_list(self) -> None: 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. @@ -159,11 +171,6 @@ class RoomWidget(urwid.WidgetWrap): event """ - CONNECTING = "connecting" - CONNECTION_FAILED = "connection_failed" - VIEWING = "viewing" - EDITING = "editing" - def __init__(self, roomname: str, config: EuphConfig, @@ -172,17 +179,36 @@ 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: str + self._mode: UiMode self._requesting_logs = False self._hit_top_of_supply = False - self._room = yaboli.Room(roomname) + 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.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() @@ -297,7 +323,12 @@ class RoomWidget(urwid.WidgetWrap): return urwid.Edit(multiline=True) def _create_nick_list_widget(self) -> Any: - return urwid.SolidFill("n") + 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}, + ) def _create_room_layout_widget(self, room_name: Any, @@ -336,50 +367,42 @@ 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 = self.CONNECTING + self._mode = UiMode.CONNECTING def switch_connection_failed(self) -> None: self._w = self._connection_failed - self._mode = self.CONNECTION_FAILED + self._mode = UiMode.CONNECTION_FAILED def switch_setting_password(self) -> None: self._w = self._overlay self._overlay.set_top(self._edit_password) - self._mode = self.SETTING_PASSWORD + self._mode = UiMode.SETTING_PASSWORD def switch_authenticating(self) -> None: self._w = self._overlay self._overlay.set_top(self._authenticating) - self._mode = self.AUTHENTICATING + self._mode = UiMode.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 = self.SETTING_NICK + self._mode = UiMode.SETTING_NICK def switch_view(self) -> None: self._w = self._layout self._layout.set_edit_visible(False) self._layout.focus_on_tree() - self._mode = self.VIEWING + self._mode = UiMode.VIEWING def switch_edit(self) -> None: self._w = self._layout self._layout.set_edit_visible(True) self._layout.focus_on_edit() - self._mode = self.EDITING + self._mode = UiMode.EDITING # Updating various parts of the UI @@ -388,12 +411,15 @@ class RoomWidget(urwid.WidgetWrap): def update_nick_list(self) -> None: # Ensure that self._room.session and self._room.users exist - if self._mode not in {self.SETTING_NICK, self.VIEWING, self.EDITING}: + allowed = {UiMode.SETTING_NICK, UiMode.VIEWING, UiMode.EDITING} + if self._mode not in allowed: return - #self._nick_list.update(self._room.session, self._room.users) + # Automatically rerenders + self._nick_list.session = self._room.session + self._nick_list.users = self._room.users - def update_edit_nick(self): + def update_edit_nick(self) -> None: width = self._edit_nick.width self._overlay.set_overlay_parameters( align=urwid.CENTER, @@ -403,15 +429,15 @@ class RoomWidget(urwid.WidgetWrap): ) self._overlay._invalidate() - # Reacting to changes - - def own_nick_change(self): + def change_own_nick(self) -> None: 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): + def receive_message(self, msg: yaboli.Message) -> None: self._supply.add(Message( msg.message_id, msg.parent_id, @@ -427,14 +453,15 @@ class RoomWidget(urwid.WidgetWrap): def render(self, size: Tuple[int, int], focus: bool) -> None: canvas = super().render(size, focus) - if self._tree.hit_top and not self._requesting_logs: - self._requesting_logs = True - self.request_logs() + if not self._hit_top_of_supply: + 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 == self.VIEWING: + if self._mode == UiMode.VIEWING: if key in {"enter", "meta enter"} and not self._room.session.nick: self.switch_setting_nick() elif key == "enter": @@ -452,7 +479,7 @@ class RoomWidget(urwid.WidgetWrap): else: return super().keypress(size, key) - elif self._mode == self.EDITING: + elif self._mode == UiMode.EDITING: if key == "enter": if self._edit.edit_text: self.send(self._edit.edit_text, self._tree.cursor_id) @@ -464,7 +491,7 @@ class RoomWidget(urwid.WidgetWrap): else: return super().keypress(size, key) - elif self._mode == self.SETTING_NICK: + elif self._mode == UiMode.SETTING_NICK: if key == "enter": if self._edit_nick.text: self.nick(self._edit_nick.text) @@ -485,37 +512,66 @@ class RoomWidget(urwid.WidgetWrap): # Reacting to euph events - async def on_snapshot(self, messages: List[yaboli.Message]): + async def on_connected(self) -> None: + pass + + async def on_snapshot(self, messages: List[yaboli.LiveMessage]) -> None: for message in messages: self.receive_message(message) - self.update_tree() - async def on_send(self, message: yaboli.Message): + self.change_own_nick() + self.update_nick_list() + + async def on_send(self, message: yaboli.LiveMessage) -> None: self.receive_message(message) - self.update_tree() + + 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 # Euph actions @synchronous - async def request_logs(self): + async def request_logs(self) -> None: 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): - new_nick = await self._room.nick(nick) - self.own_nick_change() + async def nick(self, nick: str) -> None: + try: + await self._room.nick(nick) + self.change_own_nick() + except yaboli.EuphException: + pass @synchronous - async def send(self, content: str, parent_id: Optional[str]): + async def send(self, content: str, parent_id: Optional[str]) -> None: 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/cheuph/euphoria/single_room_application.py b/bowl/euphoria/single_room_application.py similarity index 94% rename from cheuph/euphoria/single_room_application.py rename to bowl/euphoria/single_room_application.py index 88bb938..37565bf 100644 --- a/cheuph/euphoria/single_room_application.py +++ b/bowl/euphoria/single_room_application.py @@ -1,16 +1,12 @@ -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, Attributes +from ..markup import AT 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"] @@ -63,11 +59,11 @@ class ChooseRoomWidget(urwid.WidgetWrap): def invalid_room_name(self, reason: str) -> None: text = AT(f"Invalid room name: {reason}\n", - attributes=self._error_attrs) + style=self._error_style) 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" @@ -130,5 +126,5 @@ class SingleRoomApplication(urwid.WidgetWrap): return key -def launch_single_room_application(): +def launch_single_room_application() -> None: launch(SingleRoomApplication) diff --git a/cheuph/exceptions.py b/bowl/exceptions.py similarity index 99% rename from cheuph/exceptions.py rename to bowl/exceptions.py index c113f41..dfd10b0 100644 --- a/cheuph/exceptions.py +++ b/bowl/exceptions.py @@ -4,6 +4,7 @@ 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/cheuph/markup.py b/bowl/markup.py similarity index 97% rename from cheuph/markup.py rename to bowl/markup.py index 7a9f9af..ba132d5 100644 --- a/cheuph/markup.py +++ b/bowl/markup.py @@ -32,10 +32,7 @@ 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) @@ -51,7 +48,8 @@ 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) @@ -247,9 +245,13 @@ 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] = [] @@ -297,6 +299,7 @@ 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) @@ -319,6 +322,7 @@ 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/cheuph/rendered_element_cache.py b/bowl/rendered_element_cache.py similarity index 100% rename from cheuph/rendered_element_cache.py rename to bowl/rendered_element_cache.py diff --git a/cheuph/utils.py b/bowl/utils.py similarity index 100% rename from cheuph/utils.py rename to bowl/utils.py diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..c306d8f Binary files /dev/null and b/demo.gif differ diff --git a/setup.py b/setup.py index 19e242d..9e5b4bc 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ from setuptools import setup setup( - name="cheuph", - version="0.0.1", + name="bowl", + version="1.0.0", packages=[ - "cheuph", - "cheuph.euphoria", + "bowl", + "bowl.euphoria", ], entry_points={ "console_scripts": [ - "cheuph = cheuph.euphoria:launch_single_room_application", + "bowl = bowl.euphoria:launch_single_room_application", ], }, install_requires=[ diff --git a/test.py b/test.py deleted file mode 100644 index 7c5efcd..0000000 --- a/test.py +++ /dev/null @@ -1,80 +0,0 @@ -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 32c07fb..2a9e0b3 100644 --- a/test/test_element_rendering.py +++ b/test/test_element_rendering.py @@ -1,14 +1,14 @@ import unittest -import cheuph +from bowl import CursorTreeRenderer __all__ = ["TestCursorTreeRenderer"] class TestCursorTreeRenderer(unittest.TestCase): def test_static_offset(self): - gao = cheuph.CursorTreeRenderer.get_absolute_offset - gro = cheuph.CursorTreeRenderer.get_relative_offset + gao = CursorTreeRenderer.get_absolute_offset + gro = 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 67328b9..6dc251f 100644 --- a/test/test_markup.py +++ b/test/test_markup.py @@ -1,6 +1,6 @@ import unittest -from cheuph import AT +from bowl import AT __all__ = ["TestAttributedText"] diff --git a/test/test_rendered_element_cache.py b/test/test_rendered_element_cache.py index 9c062b1..c6da75f 100644 --- a/test/test_rendered_element_cache.py +++ b/test/test_rendered_element_cache.py @@ -1,6 +1,6 @@ import unittest -from cheuph import Element, RenderedElementCache +from bowl import Element, RenderedElementCache __all__ = ["TestRenderedElementCache"] diff --git a/test_scripts/.gitignore b/test_scripts/.gitignore deleted file mode 100644 index b1687c3..0000000 --- a/test_scripts/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -cheuph -yaboli diff --git a/test_scripts/display_attr_lines_wiget.py b/test_scripts/display_attr_lines_wiget.py deleted file mode 100644 index 70d1dd0..0000000 --- a/test_scripts/display_attr_lines_wiget.py +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 0357dab..0000000 --- a/test_scripts/display_attr_text_widget.py +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index f07da43..0000000 --- a/test_scripts/display_pressed_keys.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index ac9f33e..0000000 --- a/test_scripts/display_room_layout.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 14949eb..0000000 --- a/test_scripts/test_cursor_tree_widget.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index aac63c4..0000000 --- a/test_scripts/test_launch_single_room_widget.py +++ /dev/null @@ -1,21 +0,0 @@ -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 new file mode 100644 index 0000000..6f4705b --- /dev/null +++ b/todo.txt @@ -0,0 +1,37 @@ +- 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 deleted file mode 100644 index 2d9a4af..0000000 --- a/tree_display.py +++ /dev/null @@ -1,295 +0,0 @@ -# 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 -# -# ???