Compare commits

..

No commits in common. "master" and "v0.1.0" have entirely different histories.

38 changed files with 718 additions and 487 deletions

View file

@ -2,21 +2,8 @@
## Next version ## Next version
- Add demo gif to readme Nothing yet.
- Fix indentation of multi-line messages
- Stop using dataclass (for backwards compatibility with Python 3.6)
## 1.0.0 (2019-06-21) ## 0.1.0 (2019-04-12)
- Add a readme - use setuptools
- 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

View file

@ -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
```

View file

@ -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()

View file

@ -15,6 +15,7 @@ from .utils import *
__all__: List[str] = [] __all__: List[str] = []
__all__ += attributed_lines.__all__ __all__ += attributed_lines.__all__
__all__ += attributed_lines_widget.__all__ __all__ += attributed_lines_widget.__all__
__all__ += attributed_text_widget.__all__ __all__ += attributed_text_widget.__all__

View file

@ -5,11 +5,11 @@
import collections import collections
from typing import Any, Deque, Iterator, List, Optional, Set, Tuple from typing import Any, Deque, Iterator, List, Optional, Set, Tuple
from .markup import AT, Attributes from .markup import AT, AttributedText, Attributes
__all__ = ["Line", "AttributedLines"] __all__ = ["Line", "AttributedLines"]
Line = Tuple[Attributes, AT] Line = Tuple[Attributes, AttributedText]
class AttributedLines: class AttributedLines:
""" """
@ -49,8 +49,7 @@ class AttributedLines:
def append_above(self, def append_above(self,
attributes: Attributes, attributes: Attributes,
text: AT, text: AttributedText) -> None:
) -> None:
""" """
Append a line above all already existing lines. The existing lines' Append a line above all already existing lines. The existing lines'
offsets do not change. offsets do not change.
@ -61,8 +60,7 @@ class AttributedLines:
def append_below(self, def append_below(self,
attributes: Attributes, attributes: Attributes,
text: AT, text: AttributedText) -> None:
) -> None:
""" """
Append a line below all already existing lines. The existing lines' Append a line below all already existing lines. The existing lines'
offsets do not change. offsets do not change.
@ -135,7 +133,7 @@ class AttributedLines:
horizontal_offset: int, horizontal_offset: int,
offset_char: str = " ", offset_char: str = " ",
overlap_char: str = "", overlap_char: str = "",
) -> AT: ) -> AttributedText:
""" """
Renders a single line to a specified width with a specified horizontal Renders a single line to a specified width with a specified horizontal
offset, applying all line-wide attributes to the result. The length of offset, applying all line-wide attributes to the result. The length of
@ -154,7 +152,7 @@ class AttributedLines:
start_offset = horizontal_offset start_offset = horizontal_offset
end_offset = start_offset + text_width end_offset = start_offset + text_width
result: AT = AT() result: AttributedText = AT()
if start_offset < 0: if start_offset < 0:
pad_length = min(text_width, -start_offset) pad_length = min(text_width, -start_offset)
@ -191,7 +189,7 @@ class AttributedLines:
width: int, width: int,
height: int, height: int,
horizontal_offset: int, horizontal_offset: int,
) -> List[AT]: ) -> List[AttributedText]:
""" """
Renders all lines individually. Renders all lines individually.
""" """
@ -207,7 +205,7 @@ class AttributedLines:
width: int, width: int,
height: int, height: int,
horizontal_offset: int, horizontal_offset: int,
) -> AT: ) -> AttributedText:
""" """
Renders all lines and combines them into a single AttributedText by Renders all lines and combines them into a single AttributedText by
joining them with a newline. joining them with a newline.

View file

@ -5,7 +5,7 @@ from typing import Optional, Tuple
import urwid import urwid
from .attributed_lines import AttributedLines from .attributed_lines import AttributedLines
from .attributed_text_widget import ATWidget from .attributed_text_widget import AttributedTextWidget
from .markup import AT from .markup import AT
__all__ = ["AttributedLinesWidget"] __all__ = ["AttributedLinesWidget"]
@ -17,7 +17,7 @@ class AttributedLinesWidget(urwid.WidgetWrap):
""" """
def __init__(self, lines: Optional[AttributedLines] = None) -> None: 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) self._filler = urwid.Filler(self._text, valign=urwid.TOP)
super().__init__(self._filler) super().__init__(self._filler)

View file

@ -2,7 +2,7 @@ from typing import Any, List, Tuple, Union
import urwid import urwid
from .markup import AT from .markup import AttributedText
__all__ = ["AttributedTextWidget", "ATWidget"] __all__ = ["AttributedTextWidget", "ATWidget"]
@ -15,9 +15,9 @@ class AttributedTextWidget(urwid.Text):
""" """
def __init__(self, def __init__(self,
text: AT, text: AttributedText,
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any
) -> None: ) -> None:
""" """
text - an AttributedText object text - an AttributedText object
@ -30,7 +30,9 @@ class AttributedTextWidget(urwid.Text):
super().__init__(self._convert_to_markup(text), *args, **kwargs) super().__init__(self._convert_to_markup(text), *args, **kwargs)
@staticmethod @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 # Wonder why mypy can't figure out the type signature of markup on its
# own... :P # own... :P
markup: List[Union[str, Tuple[str, str]]] markup: List[Union[str, Tuple[str, str]]]
@ -41,7 +43,7 @@ class AttributedTextWidget(urwid.Text):
return markup or [""] 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. Set the content of the AttributedTextWidget.
""" """
@ -49,7 +51,7 @@ class AttributedTextWidget(urwid.Text):
self._attributed_text = text self._attributed_text = text
super().set_text(self._convert_to_markup(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. Returns the currently used AttributedText.
@ -61,7 +63,7 @@ class AttributedTextWidget(urwid.Text):
return self._attributed_text return self._attributed_text
@property @property
def attributed_text(self) -> AT: def attributed_text(self) -> AttributedText:
return self.get_attributed_text() return self.get_attributed_text()
ATWidget = AttributedTextWidget ATWidget = AttributedTextWidget

View file

@ -1,5 +1,7 @@
from dataclasses import dataclass, field
from enum import Enum, auto 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", __all__ = ["ConfigException", "ConfigValueException", "TransparentConfig",
"Kind", "Condition", "Option", "TreeLoader"] "Kind", "Condition", "Option", "TreeLoader"]
@ -41,7 +43,6 @@ class TransparentConfig:
# Special config reading and writing classes # Special config reading and writing classes
class Kind(Enum): class Kind(Enum):
BOOL = auto() BOOL = auto()
DICT = auto() DICT = auto()
FLOAT = auto() FLOAT = auto()
@ -61,22 +62,15 @@ class Kind(Enum):
Condition = Callable[[Any], bool] Condition = Callable[[Any], bool]
@dataclass
class Option: class Option:
kind: Kind
def __init__(self, default: Any
kind: Kind, conditions: Iterable[Tuple[Condition, str]] = field(default_factory=list)
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: def check_valid(self, value: Any) -> None:
if not self.kind.matches(value): if not self.kind.matches(value):
raise ConfigValueException( raise ConfigValueException(f"value {value!r} does not match {self.kind}")
f"value {value!r} does not match {self.kind}")
self.apply_conditions(value) self.apply_conditions(value)

View file

@ -7,7 +7,7 @@ from .attributed_lines import AttributedLines
from .element import Element, Id, Message, RenderedElement, RenderedMessage from .element import Element, Id, Message, RenderedElement, RenderedMessage
from .element_supply import ElementSupply from .element_supply import ElementSupply
from .exceptions import ShouldNeverHappen from .exceptions import ShouldNeverHappen
from .markup import AT, Attributes from .markup import AT, AttributedText, Attributes
from .rendered_element_cache import RenderedElementCache from .rendered_element_cache import RenderedElementCache
__all__ = ["CursorRenderer", "CursorTreeRenderer", "BasicCursorRenderer"] __all__ = ["CursorRenderer", "CursorTreeRenderer", "BasicCursorRenderer"]
@ -28,7 +28,7 @@ class CursorRenderer(ABC, Generic[E, R]):
pass pass
@abstractmethod @abstractmethod
def render_cursor(self, width: int) -> AT: def render_cursor(self, width: int) -> AttributedText:
pass pass
class CursorTreeRenderer(Generic[E]): class CursorTreeRenderer(Generic[E]):
@ -157,7 +157,7 @@ class CursorTreeRenderer(Generic[E]):
def _render_message(self, def _render_message(self,
message_id: Id, message_id: Id,
indent: AT, indent: AttributedText,
) -> AttributedLines: ) -> AttributedLines:
width = self._width - len(indent) - self._renderer.meta_width - 1 width = self._width - len(indent) - self._renderer.meta_width - 1
@ -175,7 +175,9 @@ class CursorTreeRenderer(Generic[E]):
return lines return lines
def _render_cursor(self, indent: AT = AT(),) -> AttributedLines: def _render_cursor(self,
indent: AttributedText = AT(),
) -> AttributedLines:
lines = AttributedLines() lines = AttributedLines()
width = self._width - len(indent) - self._renderer.meta_width - 1 width = self._width - len(indent) - self._renderer.meta_width - 1
meta_spaces = AT(" " * self._renderer.meta_width) meta_spaces = AT(" " * self._renderer.meta_width)
@ -187,7 +189,7 @@ class CursorTreeRenderer(Generic[E]):
def _render_indent(self, def _render_indent(self,
cursor: bool = False, cursor: bool = False,
cursor_line: bool = False, cursor_line: bool = False,
) -> AT: ) -> AttributedText:
if self._indent_width < 1: if self._indent_width < 1:
return AT() return AT()
@ -213,7 +215,7 @@ class CursorTreeRenderer(Generic[E]):
def _render_subtree(self, def _render_subtree(self,
lines: AttributedLines, lines: AttributedLines,
root_id: Id, root_id: Id,
indent: AT = AT(), indent: AttributedText = AT(),
) -> None: ) -> None:
if self._anchor_id == root_id: if self._anchor_id == root_id:
@ -633,5 +635,5 @@ class BasicCursorRenderer(CursorRenderer):
return RenderedMessage(message.id, lines, meta) return RenderedMessage(message.id, lines, meta)
def render_cursor(self, width: int) -> AT: def render_cursor(self, width: int) -> AttributedText:
return AT("<cursor>") return AT("<cursor>")

View file

@ -1,7 +1,7 @@
import datetime import datetime
from typing import Hashable, List, Optional from typing import Hashable, List, Optional
from .markup import AT from .markup import AttributedText
__all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"] __all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"]
@ -26,8 +26,10 @@ class Element:
return self._parent_id return self._parent_id
class RenderedElement: class RenderedElement:
def __init__(self,
def __init__(self, id: Id, lines: List[AT]) -> None: id: Id,
lines: List[AttributedText],
) -> None:
self._id = id self._id = id
self._lines = lines self._lines = lines
@ -37,7 +39,7 @@ class RenderedElement:
return self._id return self._id
@property @property
def lines(self) -> List[AT]: def lines(self) -> List[AttributedText]:
return self._lines return self._lines
class Message(Element): class Message(Element):
@ -69,10 +71,15 @@ class Message(Element):
class RenderedMessage(RenderedElement): 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) super().__init__(id, lines)
self._meta = meta self._meta = meta
@property @property
def meta(self) -> AT: def meta(self) -> AttributedText:
return self._meta return self._meta

View file

@ -4,9 +4,9 @@ from .edit_widgets import *
from .euph_config import * from .euph_config import *
from .euph_renderer import * from .euph_renderer import *
from .launch_application import * from .launch_application import *
from .nick_list_widget import *
from .room_widget import * from .room_widget import *
from .single_room_application import * from .single_room_application import *
from .user_list_widget import *
__all__: List[str] = [] __all__: List[str] = []
@ -14,6 +14,6 @@ __all__ += edit_widgets.__all__
__all__ += euph_config.__all__ __all__ += euph_config.__all__
__all__ += euph_renderer.__all__ __all__ += euph_renderer.__all__
__all__ += launch_application.__all__ __all__ += launch_application.__all__
__all__ += nick_list_widget.__all__
__all__ += room_widget.__all__ __all__ += room_widget.__all__
__all__ += single_room_application.__all__ __all__ += single_room_application.__all__
__all__ += user_list_widget.__all__

View file

@ -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"] __all__ = ["EuphConfig", "EuphLoader"]
@ -9,16 +10,6 @@ class EuphConfig(TransparentConfig):
def __init__(self, parent: Optional[TransparentConfig] = None) -> None: def __init__(self, parent: Optional[TransparentConfig] = None) -> None:
super().__init__(parent) 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 # basic styles
@property @property
@ -177,16 +168,6 @@ class EuphConfig(TransparentConfig):
def borders_style(self) -> str: def borders_style(self) -> str:
return self["visual.borders.style"] 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 # other
@property @property
@ -214,18 +195,12 @@ class EuphLoader(TreeLoader):
SINGLE_CHAR = (lambda x: len(x) == 1, "must be single character") 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_0 = (lambda x: x >= 0, "must be at least 0")
AT_LEAST_1 = (lambda x: x >= 1, "must be at least 1") 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: def __init__(self) -> None:
super().__init__() super().__init__()
self._styles: Set[str] = set() 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 # basic styles
self.add_style("visual.room_style", "room") self.add_style("visual.room_style", "room")
self.add_style("visual.nick_style", "nick") self.add_style("visual.nick_style", "nick")
@ -288,10 +263,6 @@ class EuphLoader(TreeLoader):
self.SINGLE_CHAR) self.SINGLE_CHAR)
self.add_style("visual.borders.style", "gray") 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 # other
self.add("styles", Kind.DICT, self.DEFAULT_STYLES) self.add("styles", Kind.DICT, self.DEFAULT_STYLES)

View file

@ -123,7 +123,7 @@ class EuphRenderer(CursorRenderer):
right = AT(self._surround_right, attributes=self._surround_attrs) right = AT(self._surround_right, attributes=self._surround_attrs)
nick_str = left + nick + right + AT(" ") nick_str = left + nick + right + AT(" ")
nick_spaces = AT(" " * len(nick_str)) nick_spaces = AT(" " * len(nick))
content = self._filter_unicode(message.content) content = self._filter_unicode(message.content)
lines = [] lines = []

View file

@ -12,12 +12,12 @@ from .euph_config import EuphConfig, EuphLoader
__all__ = ["DEFAULT_CONFIG_PATHS", "launch"] __all__ = ["DEFAULT_CONFIG_PATHS", "launch"]
DEFAULT_CONFIG_PATHS = [ DEFAULT_CONFIG_PATHS = [
"~/.config/bowl/bowl.yaml", "~/.config/cheuph/cheuph.yaml",
"~/.bowl/bowl.yaml", "~/.cheuph/cheuph.yaml",
"~/.bowl.yaml", "~/.cheuph.yaml",
] ]
GITHUB_URL = "https://github.com/Garmelon/bowl" GITHUB_URL = "https://github.com/Garmelon/cheuph"
def parse_arguments() -> argparse.Namespace: def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -57,8 +57,8 @@ def load_config(args: Any) -> EuphConfig:
return config return config
def export_defaults(path_str: str) -> None: def export_defaults(path: str) -> None:
path = pathlib.Path(path_str).expanduser() path = pathlib.Path(path).expanduser()
print(f"Exporting default config to {path}") print(f"Exporting default config to {path}")
loader = EuphLoader() loader = EuphLoader()

View file

@ -1,9 +1,8 @@
import asyncio import asyncio
import pathlib
from enum import Enum
from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
import urwid import urwid
import yaboli import yaboli
from ..attributed_text_widget import ATWidget from ..attributed_text_widget import ATWidget
@ -15,7 +14,6 @@ from ..markup import AT, AttributedText, Attributes
from .edit_widgets import EditWidget from .edit_widgets import EditWidget
from .euph_config import EuphConfig from .euph_config import EuphConfig
from .euph_renderer import EuphRenderer from .euph_renderer import EuphRenderer
from .nick_list_widget import NickListWidget
__all__ = ["RoomWidget"] __all__ = ["RoomWidget"]
@ -131,33 +129,23 @@ class RoomLayout(urwid.WidgetWrap):
string = self._edit_separator * tree_width string = self._edit_separator * tree_width
return AT(string, attributes=self._border_attrs) return AT(string, attributes=self._border_attrs)
def set_edit_visible(self, visible: bool) -> None: def set_edit_visible(self, visible: bool):
if visible: if visible:
self._left_wrap._w = self._edit_pile self._left_wrap._w = self._edit_pile
else: else:
self._left_wrap._w = self._tree self._left_wrap._w = self._tree
def focus_on_edit(self) -> None: def focus_on_edit(self):
self._edit_pile.focus_position = 2 self._edit_pile.focus_position = 2
self._columns.focus_position = 0 self._columns.focus_position = 0
def focus_on_tree(self) -> None: def focus_on_tree(self):
self._edit_pile.focus_position = 0 self._edit_pile.focus_position = 0
self._columns.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 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): class RoomWidget(urwid.WidgetWrap):
""" """
The RoomWidget connects to and displays a single yaboli room. The RoomWidget connects to and displays a single yaboli room.
@ -171,6 +159,11 @@ class RoomWidget(urwid.WidgetWrap):
event event
""" """
CONNECTING = "connecting"
CONNECTION_FAILED = "connection_failed"
VIEWING = "viewing"
EDITING = "editing"
def __init__(self, def __init__(self,
roomname: str, roomname: str,
config: EuphConfig, config: EuphConfig,
@ -179,36 +172,17 @@ class RoomWidget(urwid.WidgetWrap):
self.c = config self.c = config
if log_amount < 1:
raise ValueError() # TODO add better text
self._log_amount = log_amount 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._requesting_logs = False
self._hit_top_of_supply = False self._hit_top_of_supply = False
url_format = yaboli.Room.URL_FORMAT self._room = yaboli.Room(roomname)
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("snapshot", self.on_snapshot)
self._room.register_event("send", self.on_send) 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._supply = InMemorySupply[Message]()
self._renderer = self._create_euph_renderer() self._renderer = self._create_euph_renderer()
@ -323,12 +297,7 @@ class RoomWidget(urwid.WidgetWrap):
return urwid.Edit(multiline=True) return urwid.Edit(multiline=True)
def _create_nick_list_widget(self) -> Any: def _create_nick_list_widget(self) -> Any:
return NickListWidget( return urwid.SolidFill("n")
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, def _create_room_layout_widget(self,
room_name: Any, room_name: Any,
@ -367,42 +336,50 @@ class RoomWidget(urwid.WidgetWrap):
## UI mode and mode switching ## 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: def switch_connecting(self) -> None:
self._w = self._connecting self._w = self._connecting
self._mode = UiMode.CONNECTING self._mode = self.CONNECTING
def switch_connection_failed(self) -> None: def switch_connection_failed(self) -> None:
self._w = self._connection_failed self._w = self._connection_failed
self._mode = UiMode.CONNECTION_FAILED self._mode = self.CONNECTION_FAILED
def switch_setting_password(self) -> None: def switch_setting_password(self) -> None:
self._w = self._overlay self._w = self._overlay
self._overlay.set_top(self._edit_password) self._overlay.set_top(self._edit_password)
self._mode = UiMode.SETTING_PASSWORD self._mode = self.SETTING_PASSWORD
def switch_authenticating(self) -> None: def switch_authenticating(self) -> None:
self._w = self._overlay self._w = self._overlay
self._overlay.set_top(self._authenticating) self._overlay.set_top(self._authenticating)
self._mode = UiMode.AUTHENTICATING self._mode = self.AUTHENTICATING
def switch_setting_nick(self) -> None: def switch_setting_nick(self) -> None:
self._w = self._overlay self._w = self._overlay
self._box.original_widget = self._edit_nick self._box.original_widget = self._edit_nick
self._edit_nick.text = self._room.session.nick self._edit_nick.text = self._room.session.nick
self.update_edit_nick() self.update_edit_nick()
self._mode = UiMode.SETTING_NICK self._mode = self.SETTING_NICK
def switch_view(self) -> None: def switch_view(self) -> None:
self._w = self._layout self._w = self._layout
self._layout.set_edit_visible(False) self._layout.set_edit_visible(False)
self._layout.focus_on_tree() self._layout.focus_on_tree()
self._mode = UiMode.VIEWING self._mode = self.VIEWING
def switch_edit(self) -> None: def switch_edit(self) -> None:
self._w = self._layout self._w = self._layout
self._layout.set_edit_visible(True) self._layout.set_edit_visible(True)
self._layout.focus_on_edit() self._layout.focus_on_edit()
self._mode = UiMode.EDITING self._mode = self.EDITING
# Updating various parts of the UI # Updating various parts of the UI
@ -411,15 +388,12 @@ class RoomWidget(urwid.WidgetWrap):
def update_nick_list(self) -> None: def update_nick_list(self) -> None:
# Ensure that self._room.session and self._room.users exist # Ensure that self._room.session and self._room.users exist
allowed = {UiMode.SETTING_NICK, UiMode.VIEWING, UiMode.EDITING} if self._mode not in {self.SETTING_NICK, self.VIEWING, self.EDITING}:
if self._mode not in allowed:
return return
# Automatically rerenders #self._nick_list.update(self._room.session, self._room.users)
self._nick_list.session = self._room.session
self._nick_list.users = self._room.users
def update_edit_nick(self) -> None: def update_edit_nick(self):
width = self._edit_nick.width width = self._edit_nick.width
self._overlay.set_overlay_parameters( self._overlay.set_overlay_parameters(
align=urwid.CENTER, align=urwid.CENTER,
@ -429,15 +403,15 @@ class RoomWidget(urwid.WidgetWrap):
) )
self._overlay._invalidate() 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._renderer.nick = self._room.session.nick
self._tree.invalidate_all() self._tree.invalidate_all()
self.update_tree() self.update_tree()
self._nick_list.session = self._room.session
self.update_nick_list() self.update_nick_list()
def receive_message(self, msg: yaboli.Message) -> None: def receive_message(self, msg: yaboli.Message):
self._supply.add(Message( self._supply.add(Message(
msg.message_id, msg.message_id,
msg.parent_id, msg.parent_id,
@ -453,7 +427,6 @@ class RoomWidget(urwid.WidgetWrap):
def render(self, size: Tuple[int, int], focus: bool) -> None: def render(self, size: Tuple[int, int], focus: bool) -> None:
canvas = super().render(size, focus) canvas = super().render(size, focus)
if not self._hit_top_of_supply:
if self._tree.hit_top and not self._requesting_logs: if self._tree.hit_top and not self._requesting_logs:
self._requesting_logs = True self._requesting_logs = True
self.request_logs() self.request_logs()
@ -461,7 +434,7 @@ class RoomWidget(urwid.WidgetWrap):
return canvas return canvas
def keypress(self, size: Tuple[int, int], key: str) -> Optional[str]: 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: if key in {"enter", "meta enter"} and not self._room.session.nick:
self.switch_setting_nick() self.switch_setting_nick()
elif key == "enter": elif key == "enter":
@ -479,7 +452,7 @@ class RoomWidget(urwid.WidgetWrap):
else: else:
return super().keypress(size, key) return super().keypress(size, key)
elif self._mode == UiMode.EDITING: elif self._mode == self.EDITING:
if key == "enter": if key == "enter":
if self._edit.edit_text: if self._edit.edit_text:
self.send(self._edit.edit_text, self._tree.cursor_id) self.send(self._edit.edit_text, self._tree.cursor_id)
@ -491,7 +464,7 @@ class RoomWidget(urwid.WidgetWrap):
else: else:
return super().keypress(size, key) return super().keypress(size, key)
elif self._mode == UiMode.SETTING_NICK: elif self._mode == self.SETTING_NICK:
if key == "enter": if key == "enter":
if self._edit_nick.text: if self._edit_nick.text:
self.nick(self._edit_nick.text) self.nick(self._edit_nick.text)
@ -512,66 +485,37 @@ class RoomWidget(urwid.WidgetWrap):
# Reacting to euph events # Reacting to euph events
async def on_connected(self) -> None: async def on_snapshot(self, messages: List[yaboli.Message]):
pass
async def on_snapshot(self, messages: List[yaboli.LiveMessage]) -> None:
for message in messages: for message in messages:
self.receive_message(message) self.receive_message(message)
self.update_tree()
self.change_own_nick() async def on_send(self, message: yaboli.Message):
self.update_nick_list()
async def on_send(self, message: yaboli.LiveMessage) -> None:
self.receive_message(message) 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 # Euph actions
@synchronous @synchronous
async def request_logs(self) -> None: async def request_logs(self):
oldest_id = self._supply.oldest_id() oldest_id = self._supply.oldest_id()
if oldest_id is not None: if oldest_id is not None:
messages = await self._room.log(self._log_amount, oldest_id) messages = await self._room.log(self._log_amount, oldest_id)
if len(messages) == 0:
self._hit_top_of_supply = True
for message in messages: for message in messages:
self.receive_message(message) self.receive_message(message)
self.update_tree()
self._requesting_logs = False self._requesting_logs = False
@synchronous @synchronous
async def nick(self, nick: str) -> None: async def nick(self, nick: str):
try: new_nick = await self._room.nick(nick)
await self._room.nick(nick) self.own_nick_change()
self.change_own_nick()
except yaboli.EuphException:
pass
@synchronous @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) message = await self._room.send(content, parent_id=parent_id)
self.receive_message(message) self.receive_message(message)
self.update_tree()
urwid.register_signal(RoomWidget, ["close"]) urwid.register_signal(RoomWidget, ["close"])

View file

@ -1,12 +1,16 @@
import asyncio
import logging
from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import urwid import urwid
import yaml
from ..attributed_text_widget import ATWidget from ..attributed_text_widget import ATWidget
from ..markup import AT from ..markup import AT, Attributes
from .edit_widgets import EditWidget from .edit_widgets import EditWidget
from .euph_config import EuphConfig
from .launch_application import launch from .launch_application import launch
from .euph_config import EuphConfig, EuphLoader
from .room_widget import RoomWidget from .room_widget import RoomWidget
__all__ = ["SingleRoomApplication", "launch_single_room_application"] __all__ = ["SingleRoomApplication", "launch_single_room_application"]
@ -59,11 +63,11 @@ class ChooseRoomWidget(urwid.WidgetWrap):
def invalid_room_name(self, reason: str) -> None: def invalid_room_name(self, reason: str) -> None:
text = AT(f"Invalid room name: {reason}\n", text = AT(f"Invalid room name: {reason}\n",
style=self._error_style) attributes=self._error_attrs)
self.set_error(ATWidget(text, align=urwid.CENTER)) self.set_error(ATWidget(text, align=urwid.CENTER))
class SingleRoomApplication(urwid.WidgetWrap): class SingleRoomApplication(urwid.WidgetWrap):
#
# The characters in the ALPHABET make up the characters that are allowed in # The characters in the ALPHABET make up the characters that are allowed in
# room names. # room names.
ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
@ -126,5 +130,5 @@ class SingleRoomApplication(urwid.WidgetWrap):
return key return key
def launch_single_room_application() -> None: def launch_single_room_application():
launch(SingleRoomApplication) launch(SingleRoomApplication)

View file

@ -4,7 +4,6 @@ class MessageSupplyException(Exception):
pass pass
class ShouldNeverHappen(Exception): class ShouldNeverHappen(Exception):
def __init__(self, number: int) -> None: def __init__(self, number: int) -> None:
message = (f"SNV{number:05} - please contact @Garmy with the code on" message = (f"SNV{number:05} - please contact @Garmy with the code on"
" the left if you see this") " the left if you see this")

View file

@ -32,7 +32,10 @@ class Chunk:
# Common special methods # Common special methods
def __init__(self, text: str, attributes: Attributes = {}) -> None: def __init__(self,
text: str,
attributes: Attributes = {},
) -> None:
self._text = text self._text = text
self._attributes = dict(attributes) self._attributes = dict(attributes)
@ -48,8 +51,7 @@ class Chunk:
if not isinstance(other, Chunk): if not isinstance(other, Chunk):
return NotImplemented return NotImplemented
return (self._text == other._text and return self._text == other._text and self._attributes == other._attributes
self._attributes == other._attributes)
def __getitem__(self, key: Union[int, slice]) -> "Chunk": def __getitem__(self, key: Union[int, slice]) -> "Chunk":
return Chunk(self.text[key], self._attributes) return Chunk(self.text[key], self._attributes)
@ -245,13 +247,9 @@ class AttributedText:
name: str, name: str,
default: Any = None, default: Any = None,
) -> Any: ) -> Any:
return self._at(pos).get(name, default) return self._at(pos).get(name, default)
def split_by(self, def split_by(self, attribute_name: str) -> List[Tuple["AttributedText", Any]]:
attribute_name: str,
) -> List[Tuple["AttributedText", Any]]:
blocks = [] blocks = []
chunks: List[Chunk] = [] chunks: List[Chunk] = []
@ -299,7 +297,6 @@ class AttributedText:
start: Optional[int] = None, start: Optional[int] = None,
stop: Optional[int] = None, stop: Optional[int] = None,
) -> "AttributedText": ) -> "AttributedText":
if start is None and stop is None: if start is None and stop is None:
chunks = (chunk.set(name, value) for chunk in self._chunks) chunks = (chunk.set(name, value) for chunk in self._chunks)
return AttributedText.from_chunks(chunks) return AttributedText.from_chunks(chunks)
@ -322,7 +319,6 @@ class AttributedText:
start: Optional[int] = None, start: Optional[int] = None,
stop: Optional[int] = None, stop: Optional[int] = None,
) -> "AttributedText": ) -> "AttributedText":
if start is None and stop is None: if start is None and stop is None:
chunks = (chunk.remove(name) for chunk in self._chunks) chunks = (chunk.remove(name) for chunk in self._chunks)
return AttributedText.from_chunks(chunks) return AttributedText.from_chunks(chunks)

BIN
demo.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 786 KiB

View file

@ -1,15 +1,15 @@
from setuptools import setup from setuptools import setup
setup( setup(
name="bowl", name="cheuph",
version="1.0.0", version="0.0.1",
packages=[ packages=[
"bowl", "cheuph",
"bowl.euphoria", "cheuph.euphoria",
], ],
entry_points={ entry_points={
"console_scripts": [ "console_scripts": [
"bowl = bowl.euphoria:launch_single_room_application", "cheuph = cheuph.euphoria:launch_single_room_application",
], ],
}, },
install_requires=[ install_requires=[

80
test.py Normal file
View file

@ -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)

View file

@ -1,14 +1,14 @@
import unittest import unittest
from bowl import CursorTreeRenderer import cheuph
__all__ = ["TestCursorTreeRenderer"] __all__ = ["TestCursorTreeRenderer"]
class TestCursorTreeRenderer(unittest.TestCase): class TestCursorTreeRenderer(unittest.TestCase):
def test_static_offset(self): def test_static_offset(self):
gao = CursorTreeRenderer.get_absolute_offset gao = cheuph.CursorTreeRenderer.get_absolute_offset
gro = CursorTreeRenderer.get_relative_offset gro = cheuph.CursorTreeRenderer.get_relative_offset
self.assertEqual(0, gao(0.0, 6)) self.assertEqual(0, gao(0.0, 6))
self.assertEqual(1, gao(0.2, 6)) self.assertEqual(1, gao(0.2, 6))

View file

@ -1,6 +1,6 @@
import unittest import unittest
from bowl import AT from cheuph import AT
__all__ = ["TestAttributedText"] __all__ = ["TestAttributedText"]

View file

@ -1,6 +1,6 @@
import unittest import unittest
from bowl import Element, RenderedElementCache from cheuph import Element, RenderedElementCache
__all__ = ["TestRenderedElementCache"] __all__ = ["TestRenderedElementCache"]

2
test_scripts/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
cheuph
yaboli

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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

295
tree_display.py Normal file
View file

@ -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:
<base>
| ...
| ... (above)
| ...
| <stem>
| | ...
| | | ... | <anchor>
| ...
| ... (below)
| ...
or
<base>
| ...
| ... (above)
| ...
| <stem and anchor>
| ...
| ... (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:
#
# <element>
# | <element>
# | | <element>
# | | <element>
# | <element>
# | <element>
# | | <element>
# | | | <element>
# | <element>
# <element>
# | <element>
#
# 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 "+ (<n>)"
# where <n> 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.
#
# <element>
# | <element>
# | | <element>
# | | <element>
# | <element>
# | + (3)
# | <element>
# <element>
# | <element>
# | | <element>
# | | | <element>
# | | | | ...
#
#
#
# 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
#
# ???