Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2eac58fe | |||
| 19d72b3e67 | |||
| 1faee1b550 | |||
| 6f0555c21b | |||
| d445586c92 | |||
| c0551f3797 | |||
| e3b39d83ba | |||
| 17d7cabba6 | |||
| 33a4afa73e | |||
| eff5ab31ba | |||
| d7f6d5a536 | |||
| d3cd63b67e | |||
| 520206d0f2 | |||
| c8b495c0e5 | |||
| 11bd7778cf |
38 changed files with 487 additions and 718 deletions
19
CHANGELOG.md
19
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
|
||||
|
|
|
|||
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# bowl
|
||||
|
||||
A TUI client for [euphoria.io](https://euphoria.io)
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
|
@ -15,7 +15,6 @@ from .utils import *
|
|||
|
||||
__all__: List[str] = []
|
||||
|
||||
|
||||
__all__ += attributed_lines.__all__
|
||||
__all__ += attributed_lines_widget.__all__
|
||||
__all__ += attributed_text_widget.__all__
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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("<cursor>")
|
||||
|
|
@ -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
|
||||
|
|
@ -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__
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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 = []
|
||||
|
|
@ -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()
|
||||
148
bowl/euphoria/nick_list_widget.py
Normal file
148
bowl/euphoria/nick_list_widget.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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"])
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
BIN
demo.gif
Normal file
BIN
demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 786 KiB |
10
setup.py
10
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=[
|
||||
|
|
|
|||
80
test.py
80
test.py
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import unittest
|
||||
|
||||
from cheuph import AT
|
||||
from bowl import AT
|
||||
|
||||
__all__ = ["TestAttributedText"]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import unittest
|
||||
|
||||
from cheuph import Element, RenderedElementCache
|
||||
from bowl import Element, RenderedElementCache
|
||||
|
||||
__all__ = ["TestRenderedElementCache"]
|
||||
|
||||
|
|
|
|||
2
test_scripts/.gitignore
vendored
2
test_scripts/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
cheuph
|
||||
yaboli
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
37
todo.txt
Normal file
37
todo.txt
Normal file
|
|
@ -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
|
||||
295
tree_display.py
295
tree_display.py
|
|
@ -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:
|
||||
|
||||
<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
|
||||
#
|
||||
# ???
|
||||
Loading…
Add table
Add a link
Reference in a new issue