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
|
## 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__: 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__
|
||||||
|
|
@ -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, AttributedText, Attributes
|
from .markup import AT, Attributes
|
||||||
|
|
||||||
__all__ = ["Line", "AttributedLines"]
|
__all__ = ["Line", "AttributedLines"]
|
||||||
|
|
||||||
Line = Tuple[Attributes, AttributedText]
|
Line = Tuple[Attributes, AT]
|
||||||
|
|
||||||
class AttributedLines:
|
class AttributedLines:
|
||||||
"""
|
"""
|
||||||
|
|
@ -49,7 +49,8 @@ class AttributedLines:
|
||||||
|
|
||||||
def append_above(self,
|
def append_above(self,
|
||||||
attributes: Attributes,
|
attributes: Attributes,
|
||||||
text: AttributedText) -> None:
|
text: AT,
|
||||||
|
) -> 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.
|
||||||
|
|
@ -60,7 +61,8 @@ class AttributedLines:
|
||||||
|
|
||||||
def append_below(self,
|
def append_below(self,
|
||||||
attributes: Attributes,
|
attributes: Attributes,
|
||||||
text: AttributedText) -> None:
|
text: AT,
|
||||||
|
) -> 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.
|
||||||
|
|
@ -133,7 +135,7 @@ class AttributedLines:
|
||||||
horizontal_offset: int,
|
horizontal_offset: int,
|
||||||
offset_char: str = " ",
|
offset_char: str = " ",
|
||||||
overlap_char: str = "…",
|
overlap_char: str = "…",
|
||||||
) -> AttributedText:
|
) -> AT:
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
@ -152,7 +154,7 @@ class AttributedLines:
|
||||||
start_offset = horizontal_offset
|
start_offset = horizontal_offset
|
||||||
end_offset = start_offset + text_width
|
end_offset = start_offset + text_width
|
||||||
|
|
||||||
result: AttributedText = AT()
|
result: AT = AT()
|
||||||
|
|
||||||
if start_offset < 0:
|
if start_offset < 0:
|
||||||
pad_length = min(text_width, -start_offset)
|
pad_length = min(text_width, -start_offset)
|
||||||
|
|
@ -189,7 +191,7 @@ class AttributedLines:
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
horizontal_offset: int,
|
horizontal_offset: int,
|
||||||
) -> List[AttributedText]:
|
) -> List[AT]:
|
||||||
"""
|
"""
|
||||||
Renders all lines individually.
|
Renders all lines individually.
|
||||||
"""
|
"""
|
||||||
|
|
@ -205,7 +207,7 @@ class AttributedLines:
|
||||||
width: int,
|
width: int,
|
||||||
height: int,
|
height: int,
|
||||||
horizontal_offset: int,
|
horizontal_offset: int,
|
||||||
) -> AttributedText:
|
) -> AT:
|
||||||
"""
|
"""
|
||||||
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.
|
||||||
|
|
@ -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 AttributedTextWidget
|
from .attributed_text_widget import ATWidget
|
||||||
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 = AttributedTextWidget(AT())
|
self._text = ATWidget(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)
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ from typing import Any, List, Tuple, Union
|
||||||
|
|
||||||
import urwid
|
import urwid
|
||||||
|
|
||||||
from .markup import AttributedText
|
from .markup import AT
|
||||||
|
|
||||||
__all__ = ["AttributedTextWidget", "ATWidget"]
|
__all__ = ["AttributedTextWidget", "ATWidget"]
|
||||||
|
|
||||||
|
|
@ -15,9 +15,9 @@ class AttributedTextWidget(urwid.Text):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
text: AttributedText,
|
text: AT,
|
||||||
*args: Any,
|
*args: Any,
|
||||||
**kwargs: Any
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
text - an AttributedText object
|
text - an AttributedText object
|
||||||
|
|
@ -30,9 +30,7 @@ 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: AttributedText
|
def _convert_to_markup(text: AT) -> List[Union[str, Tuple[str, str]]]:
|
||||||
) -> 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]]]
|
||||||
|
|
@ -43,7 +41,7 @@ class AttributedTextWidget(urwid.Text):
|
||||||
|
|
||||||
return markup or [""]
|
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.
|
Set the content of the AttributedTextWidget.
|
||||||
"""
|
"""
|
||||||
|
|
@ -51,7 +49,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) -> AttributedText:
|
def get_attributed_text(self) -> AT:
|
||||||
"""
|
"""
|
||||||
Returns the currently used AttributedText.
|
Returns the currently used AttributedText.
|
||||||
|
|
||||||
|
|
@ -63,7 +61,7 @@ class AttributedTextWidget(urwid.Text):
|
||||||
return self._attributed_text
|
return self._attributed_text
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def attributed_text(self) -> AttributedText:
|
def attributed_text(self) -> AT:
|
||||||
return self.get_attributed_text()
|
return self.get_attributed_text()
|
||||||
|
|
||||||
ATWidget = AttributedTextWidget
|
ATWidget = AttributedTextWidget
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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"]
|
||||||
|
|
@ -43,6 +41,7 @@ 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()
|
||||||
|
|
@ -62,15 +61,22 @@ class Kind(Enum):
|
||||||
|
|
||||||
Condition = Callable[[Any], bool]
|
Condition = Callable[[Any], bool]
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Option:
|
class Option:
|
||||||
kind: Kind
|
|
||||||
default: Any
|
def __init__(self,
|
||||||
conditions: Iterable[Tuple[Condition, str]] = field(default_factory=list)
|
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:
|
def check_valid(self, value: Any) -> None:
|
||||||
if not self.kind.matches(value):
|
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)
|
self.apply_conditions(value)
|
||||||
|
|
||||||
|
|
@ -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, AttributedText, Attributes
|
from .markup import AT, 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) -> AttributedText:
|
def render_cursor(self, width: int) -> AT:
|
||||||
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: AttributedText,
|
indent: AT,
|
||||||
) -> AttributedLines:
|
) -> AttributedLines:
|
||||||
|
|
||||||
width = self._width - len(indent) - self._renderer.meta_width - 1
|
width = self._width - len(indent) - self._renderer.meta_width - 1
|
||||||
|
|
@ -175,9 +175,7 @@ class CursorTreeRenderer(Generic[E]):
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def _render_cursor(self,
|
def _render_cursor(self, indent: AT = AT(),) -> AttributedLines:
|
||||||
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)
|
||||||
|
|
@ -189,7 +187,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,
|
||||||
) -> AttributedText:
|
) -> AT:
|
||||||
|
|
||||||
if self._indent_width < 1:
|
if self._indent_width < 1:
|
||||||
return AT()
|
return AT()
|
||||||
|
|
@ -215,7 +213,7 @@ class CursorTreeRenderer(Generic[E]):
|
||||||
def _render_subtree(self,
|
def _render_subtree(self,
|
||||||
lines: AttributedLines,
|
lines: AttributedLines,
|
||||||
root_id: Id,
|
root_id: Id,
|
||||||
indent: AttributedText = AT(),
|
indent: AT = AT(),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
if self._anchor_id == root_id:
|
if self._anchor_id == root_id:
|
||||||
|
|
@ -635,5 +633,5 @@ class BasicCursorRenderer(CursorRenderer):
|
||||||
|
|
||||||
return RenderedMessage(message.id, lines, meta)
|
return RenderedMessage(message.id, lines, meta)
|
||||||
|
|
||||||
def render_cursor(self, width: int) -> AttributedText:
|
def render_cursor(self, width: int) -> AT:
|
||||||
return AT("<cursor>")
|
return AT("<cursor>")
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Hashable, List, Optional
|
from typing import Hashable, List, Optional
|
||||||
|
|
||||||
from .markup import AttributedText
|
from .markup import AT
|
||||||
|
|
||||||
__all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"]
|
__all__ = ["Id", "Element", "RenderedElement", "Message", "RenderedMessage"]
|
||||||
|
|
||||||
|
|
@ -26,10 +26,8 @@ class Element:
|
||||||
return self._parent_id
|
return self._parent_id
|
||||||
|
|
||||||
class RenderedElement:
|
class RenderedElement:
|
||||||
def __init__(self,
|
|
||||||
id: Id,
|
def __init__(self, id: Id, lines: List[AT]) -> None:
|
||||||
lines: List[AttributedText],
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
self._id = id
|
self._id = id
|
||||||
self._lines = lines
|
self._lines = lines
|
||||||
|
|
@ -39,7 +37,7 @@ class RenderedElement:
|
||||||
return self._id
|
return self._id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def lines(self) -> List[AttributedText]:
|
def lines(self) -> List[AT]:
|
||||||
return self._lines
|
return self._lines
|
||||||
|
|
||||||
class Message(Element):
|
class Message(Element):
|
||||||
|
|
@ -71,15 +69,10 @@ class Message(Element):
|
||||||
|
|
||||||
class RenderedMessage(RenderedElement):
|
class RenderedMessage(RenderedElement):
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self, id: Id, lines: List[AT], meta: AT) -> None:
|
||||||
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) -> AttributedText:
|
def meta(self) -> AT:
|
||||||
return self._meta
|
return self._meta
|
||||||
|
|
@ -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__
|
|
||||||
|
|
@ -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,
|
from ..config import ConfigValueException, Kind, TransparentConfig, TreeLoader
|
||||||
TreeLoader)
|
|
||||||
|
|
||||||
__all__ = ["EuphConfig", "EuphLoader"]
|
__all__ = ["EuphConfig", "EuphLoader"]
|
||||||
|
|
||||||
|
|
@ -10,6 +9,16 @@ 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
|
||||||
|
|
@ -168,6 +177,16 @@ 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
|
||||||
|
|
@ -195,12 +214,18 @@ 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")
|
||||||
|
|
@ -263,6 +288,10 @@ 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)
|
||||||
|
|
||||||
|
|
@ -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))
|
nick_spaces = AT(" " * len(nick_str))
|
||||||
|
|
||||||
content = self._filter_unicode(message.content)
|
content = self._filter_unicode(message.content)
|
||||||
lines = []
|
lines = []
|
||||||
|
|
@ -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/cheuph/cheuph.yaml",
|
"~/.config/bowl/bowl.yaml",
|
||||||
"~/.cheuph/cheuph.yaml",
|
"~/.bowl/bowl.yaml",
|
||||||
"~/.cheuph.yaml",
|
"~/.bowl.yaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
GITHUB_URL = "https://github.com/Garmelon/cheuph"
|
GITHUB_URL = "https://github.com/Garmelon/bowl"
|
||||||
|
|
||||||
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) -> None:
|
def export_defaults(path_str: str) -> None:
|
||||||
path = pathlib.Path(path).expanduser()
|
path = pathlib.Path(path_str).expanduser()
|
||||||
print(f"Exporting default config to {path}")
|
print(f"Exporting default config to {path}")
|
||||||
|
|
||||||
loader = EuphLoader()
|
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 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
|
||||||
|
|
@ -14,6 +15,7 @@ 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"]
|
||||||
|
|
||||||
|
|
@ -129,23 +131,33 @@ 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):
|
def set_edit_visible(self, visible: bool) -> None:
|
||||||
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):
|
def focus_on_edit(self) -> None:
|
||||||
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):
|
def focus_on_tree(self) -> None:
|
||||||
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_user_list(self):
|
def focus_on_nick_list(self) -> None:
|
||||||
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.
|
||||||
|
|
@ -159,11 +171,6 @@ 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,
|
||||||
|
|
@ -172,17 +179,36 @@ 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: str
|
self._mode: UiMode
|
||||||
self._requesting_logs = False
|
self._requesting_logs = False
|
||||||
self._hit_top_of_supply = 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("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()
|
||||||
|
|
@ -297,7 +323,12 @@ 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 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,
|
def _create_room_layout_widget(self,
|
||||||
room_name: Any,
|
room_name: Any,
|
||||||
|
|
@ -336,50 +367,42 @@ 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 = self.CONNECTING
|
self._mode = UiMode.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 = self.CONNECTION_FAILED
|
self._mode = UiMode.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 = self.SETTING_PASSWORD
|
self._mode = UiMode.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 = self.AUTHENTICATING
|
self._mode = UiMode.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 = self.SETTING_NICK
|
self._mode = UiMode.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 = self.VIEWING
|
self._mode = UiMode.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 = self.EDITING
|
self._mode = UiMode.EDITING
|
||||||
|
|
||||||
# Updating various parts of the UI
|
# Updating various parts of the UI
|
||||||
|
|
||||||
|
|
@ -388,12 +411,15 @@ 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
|
||||||
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
|
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
|
width = self._edit_nick.width
|
||||||
self._overlay.set_overlay_parameters(
|
self._overlay.set_overlay_parameters(
|
||||||
align=urwid.CENTER,
|
align=urwid.CENTER,
|
||||||
|
|
@ -403,15 +429,15 @@ class RoomWidget(urwid.WidgetWrap):
|
||||||
)
|
)
|
||||||
self._overlay._invalidate()
|
self._overlay._invalidate()
|
||||||
|
|
||||||
# Reacting to changes
|
def change_own_nick(self) -> None:
|
||||||
|
|
||||||
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):
|
def receive_message(self, msg: yaboli.Message) -> None:
|
||||||
self._supply.add(Message(
|
self._supply.add(Message(
|
||||||
msg.message_id,
|
msg.message_id,
|
||||||
msg.parent_id,
|
msg.parent_id,
|
||||||
|
|
@ -427,6 +453,7 @@ 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()
|
||||||
|
|
@ -434,7 +461,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 == self.VIEWING:
|
if self._mode == UiMode.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":
|
||||||
|
|
@ -452,7 +479,7 @@ class RoomWidget(urwid.WidgetWrap):
|
||||||
else:
|
else:
|
||||||
return super().keypress(size, key)
|
return super().keypress(size, key)
|
||||||
|
|
||||||
elif self._mode == self.EDITING:
|
elif self._mode == UiMode.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)
|
||||||
|
|
@ -464,7 +491,7 @@ class RoomWidget(urwid.WidgetWrap):
|
||||||
else:
|
else:
|
||||||
return super().keypress(size, key)
|
return super().keypress(size, key)
|
||||||
|
|
||||||
elif self._mode == self.SETTING_NICK:
|
elif self._mode == UiMode.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)
|
||||||
|
|
@ -485,37 +512,66 @@ class RoomWidget(urwid.WidgetWrap):
|
||||||
|
|
||||||
# Reacting to euph events
|
# 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:
|
for message in messages:
|
||||||
self.receive_message(message)
|
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.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):
|
async def request_logs(self) -> None:
|
||||||
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):
|
async def nick(self, nick: str) -> None:
|
||||||
new_nick = await self._room.nick(nick)
|
try:
|
||||||
self.own_nick_change()
|
await self._room.nick(nick)
|
||||||
|
self.change_own_nick()
|
||||||
|
except yaboli.EuphException:
|
||||||
|
pass
|
||||||
|
|
||||||
@synchronous
|
@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)
|
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"])
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
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, Attributes
|
from ..markup import AT
|
||||||
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"]
|
||||||
|
|
@ -63,11 +59,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",
|
||||||
attributes=self._error_attrs)
|
style=self._error_style)
|
||||||
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"
|
||||||
|
|
@ -130,5 +126,5 @@ class SingleRoomApplication(urwid.WidgetWrap):
|
||||||
|
|
||||||
return key
|
return key
|
||||||
|
|
||||||
def launch_single_room_application():
|
def launch_single_room_application() -> None:
|
||||||
launch(SingleRoomApplication)
|
launch(SingleRoomApplication)
|
||||||
|
|
@ -4,6 +4,7 @@ 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")
|
||||||
|
|
@ -32,10 +32,7 @@ class Chunk:
|
||||||
|
|
||||||
# Common special methods
|
# Common special methods
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self, text: str, attributes: Attributes = {}) -> None:
|
||||||
text: str,
|
|
||||||
attributes: Attributes = {},
|
|
||||||
) -> None:
|
|
||||||
self._text = text
|
self._text = text
|
||||||
self._attributes = dict(attributes)
|
self._attributes = dict(attributes)
|
||||||
|
|
||||||
|
|
@ -51,7 +48,8 @@ class Chunk:
|
||||||
if not isinstance(other, Chunk):
|
if not isinstance(other, Chunk):
|
||||||
return NotImplemented
|
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":
|
def __getitem__(self, key: Union[int, slice]) -> "Chunk":
|
||||||
return Chunk(self.text[key], self._attributes)
|
return Chunk(self.text[key], self._attributes)
|
||||||
|
|
@ -247,9 +245,13 @@ 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, attribute_name: str) -> List[Tuple["AttributedText", Any]]:
|
def split_by(self,
|
||||||
|
attribute_name: str,
|
||||||
|
) -> List[Tuple["AttributedText", Any]]:
|
||||||
|
|
||||||
blocks = []
|
blocks = []
|
||||||
|
|
||||||
chunks: List[Chunk] = []
|
chunks: List[Chunk] = []
|
||||||
|
|
@ -297,6 +299,7 @@ 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)
|
||||||
|
|
@ -319,6 +322,7 @@ 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
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
|
from setuptools import setup
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="cheuph",
|
name="bowl",
|
||||||
version="0.0.1",
|
version="1.0.0",
|
||||||
packages=[
|
packages=[
|
||||||
"cheuph",
|
"bowl",
|
||||||
"cheuph.euphoria",
|
"bowl.euphoria",
|
||||||
],
|
],
|
||||||
entry_points={
|
entry_points={
|
||||||
"console_scripts": [
|
"console_scripts": [
|
||||||
"cheuph = cheuph.euphoria:launch_single_room_application",
|
"bowl = bowl.euphoria:launch_single_room_application",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
install_requires=[
|
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 unittest
|
||||||
|
|
||||||
import cheuph
|
from bowl import CursorTreeRenderer
|
||||||
|
|
||||||
__all__ = ["TestCursorTreeRenderer"]
|
__all__ = ["TestCursorTreeRenderer"]
|
||||||
|
|
||||||
class TestCursorTreeRenderer(unittest.TestCase):
|
class TestCursorTreeRenderer(unittest.TestCase):
|
||||||
|
|
||||||
def test_static_offset(self):
|
def test_static_offset(self):
|
||||||
gao = cheuph.CursorTreeRenderer.get_absolute_offset
|
gao = CursorTreeRenderer.get_absolute_offset
|
||||||
gro = cheuph.CursorTreeRenderer.get_relative_offset
|
gro = 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))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from cheuph import AT
|
from bowl import AT
|
||||||
|
|
||||||
__all__ = ["TestAttributedText"]
|
__all__ = ["TestAttributedText"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from cheuph import Element, RenderedElementCache
|
from bowl import Element, RenderedElementCache
|
||||||
|
|
||||||
__all__ = ["TestRenderedElementCache"]
|
__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