Lay out new client structure
This commit is contained in:
parent
6c4bfe2752
commit
7da4bf36d5
21 changed files with 174 additions and 1137 deletions
|
|
@ -1,20 +1,29 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from .attributed_lines import *
|
||||||
|
from .attributed_lines_widget import *
|
||||||
|
from .attributed_text_widget import *
|
||||||
from .config import *
|
from .config import *
|
||||||
from .element import *
|
|
||||||
from .element_supply import *
|
|
||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .markup import *
|
from .markup import *
|
||||||
from .tree_display import *
|
from .message import *
|
||||||
from .tree_list import *
|
from .message_cache import *
|
||||||
from .widgets import *
|
from .message_editor_widget import *
|
||||||
|
from .message_supply import *
|
||||||
|
from .message_tree_widget import *
|
||||||
|
from .user_list_widget import *
|
||||||
|
|
||||||
__all__: List[str] = []
|
__all__: List[str] = []
|
||||||
|
|
||||||
|
__all__ += attributed_lines.__all__
|
||||||
|
__all__ += attributed_lines_widget.__all__
|
||||||
|
__all__ += attributed_text_widget.__all__
|
||||||
__all__ += config.__all__
|
__all__ += config.__all__
|
||||||
__all__ += element.__all__
|
|
||||||
__all__ += element_supply.__all__
|
|
||||||
__all__ += exceptions.__all__
|
__all__ += exceptions.__all__
|
||||||
__all__ += markup.__all__
|
__all__ += markup.__all__
|
||||||
__all__ += tree_display.__all__
|
__all__ += message.__all__
|
||||||
__all__ += tree_list.__all__
|
__all__ += message_cache.__all__
|
||||||
__all__ += widgets.__all__
|
__all__ += message_editor_widget.__all__
|
||||||
|
__all__ += message_supply.__all__
|
||||||
|
__all__ += message_tree_widget.__all__
|
||||||
|
__all__ += user_list_widget.__all__
|
||||||
|
|
|
||||||
16
cheuph/attributed_lines.py
Normal file
16
cheuph/attributed_lines.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
__all__ = ["AttributedLines"]
|
||||||
|
|
||||||
|
class AttributedLines:
|
||||||
|
"""
|
||||||
|
AttributedLines is a list of lines of AttributedText that maintains a
|
||||||
|
vertical offset.
|
||||||
|
|
||||||
|
When rendering a tree of messages, the RenderedMessage-s are drawn line by
|
||||||
|
line to an AttributedLines. AttributedLines. The AttributedLines is then
|
||||||
|
displayed in an AttributedLinesWidget.
|
||||||
|
|
||||||
|
Multiple AttributedLines can be concatenated, keeping either the first or
|
||||||
|
the second AttributedLines's offset.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
15
cheuph/attributed_lines_widget.py
Normal file
15
cheuph/attributed_lines_widget.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
__all__ = ["AttributedLinesWidget", "ALWidget"]
|
||||||
|
|
||||||
|
class AttributedLinesWidget:
|
||||||
|
"""
|
||||||
|
This widget draws lines of AttributedText with a horizontal and a vertical
|
||||||
|
offset. It can retrieve the attributes of any character by its (x, y)
|
||||||
|
coordinates. Line-wide attributes may be specified.
|
||||||
|
|
||||||
|
When clicked, it sends an event containing the attributes of the character
|
||||||
|
that was just clicked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
ALWidget = AttributedLinesWidget
|
||||||
|
|
@ -33,7 +33,7 @@ class AttributedTextWidget(urwid.Text):
|
||||||
def _convert_to_markup(text: AttributedText
|
def _convert_to_markup(text: AttributedText
|
||||||
) -> List[Union[str, Tuple[str, str]]]:
|
) -> List[Union[str, Tuple[str, str]]]:
|
||||||
|
|
||||||
# Wonder why it 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]]]
|
||||||
markup = [
|
markup = [
|
||||||
|
|
@ -51,6 +51,14 @@ 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 set_text(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
This function should not be used directly. Instead, use
|
||||||
|
set_attributed_text().
|
||||||
|
"""
|
||||||
|
|
||||||
|
raise NotImplementedError("use set_attributed_text() instead")
|
||||||
|
|
||||||
def get_attributed_text(self) -> AttributedText:
|
def get_attributed_text(self) -> AttributedText:
|
||||||
"""
|
"""
|
||||||
Returns the currently used AttributedText.
|
Returns the currently used AttributedText.
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
# TODO define a config structure including config element descriptions and
|
||||||
|
# default values
|
||||||
|
#
|
||||||
|
# TODO improve interface for accessing config values
|
||||||
|
#
|
||||||
|
# TODO load from and save to yaml file (only the values which differ from the
|
||||||
|
# defaults or which were explicitly set)
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
__all__ = ["Fields", "Config", "ConfigView"]
|
__all__ = ["Fields", "Config", "ConfigView"]
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import abc
|
|
||||||
from typing import Hashable, List, Optional
|
|
||||||
|
|
||||||
from .exceptions import ElementException, TreeException
|
|
||||||
from .markup import AttributedText
|
|
||||||
|
|
||||||
__all__ = ["Id", "Element", "RenderedElement"]
|
|
||||||
|
|
||||||
Id = Hashable
|
|
||||||
|
|
||||||
class Element(abc.ABC):
|
|
||||||
def __init__(self,
|
|
||||||
id: Id,
|
|
||||||
parent_id: Optional[Id],
|
|
||||||
) -> None:
|
|
||||||
self._id = id
|
|
||||||
self._parent_id = parent_id
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self) -> Id:
|
|
||||||
return self._id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parent_id(self) -> Optional[Id]:
|
|
||||||
return self._parent_id
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def render(self,
|
|
||||||
width: int,
|
|
||||||
depth: int,
|
|
||||||
highlighted: bool = False,
|
|
||||||
folded: bool = False,
|
|
||||||
) -> "RenderedElement":
|
|
||||||
pass
|
|
||||||
|
|
||||||
class RenderedElement:
|
|
||||||
def __init__(self,
|
|
||||||
element: Element,
|
|
||||||
rendered: List[AttributedText],
|
|
||||||
) -> None:
|
|
||||||
self._element = element
|
|
||||||
self._lines = rendered
|
|
||||||
|
|
||||||
@property
|
|
||||||
def element(self) -> Element:
|
|
||||||
return self._element
|
|
||||||
|
|
||||||
@property
|
|
||||||
def lines(self) -> List[AttributedText]:
|
|
||||||
return self._lines
|
|
||||||
|
|
||||||
@property
|
|
||||||
def height(self) -> int:
|
|
||||||
return len(self._lines)
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
import abc
|
|
||||||
from typing import Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from .element import Element, Id
|
|
||||||
from .exceptions import TreeException
|
|
||||||
|
|
||||||
__all__ = ["ElementSupply", "MemoryElementSupply"]
|
|
||||||
|
|
||||||
class ElementSupply(abc.ABC):
|
|
||||||
"""
|
|
||||||
An ElementSupply is an interface to query some resource containing
|
|
||||||
Elements. The elements could for example be kept in memory, in a database
|
|
||||||
or somewhere else.
|
|
||||||
|
|
||||||
The element ids must be unique, and the elements and their parents must
|
|
||||||
form one or more trees (i. e. must not contain any cycles).
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def get(self, element_id: Id) -> Element:
|
|
||||||
"""
|
|
||||||
Get a single element by its id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_parent_id(self, element_id: Id) -> Optional[Id]:
|
|
||||||
"""
|
|
||||||
Get the id of the parent's element.
|
|
||||||
|
|
||||||
This function is redundant, since you can just use element.parent_id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.get(element_id).parent_id
|
|
||||||
|
|
||||||
def get_parent(self, element_id: Id) -> Optional[Element]:
|
|
||||||
"""
|
|
||||||
Like get_parent_id, but returns the Element instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
parent_id = self.get_parent_id(element_id)
|
|
||||||
|
|
||||||
if parent_id is not None:
|
|
||||||
return self.get(parent_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def get_children_ids(self, element_id: Optional[Id]) -> List[Id]:
|
|
||||||
"""
|
|
||||||
Get a list of the ids of all the element's children.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_children(self, element_id: Optional[Id]) -> List[Element]:
|
|
||||||
"""
|
|
||||||
Get a list of all children of an element.
|
|
||||||
|
|
||||||
If the id passed is None, return a list of all top-level elements
|
|
||||||
instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
children_ids = self.get_children_ids(element_id)
|
|
||||||
|
|
||||||
children: List[Element] = []
|
|
||||||
for child_id in children_ids:
|
|
||||||
children.append(self.get(child_id))
|
|
||||||
|
|
||||||
return children
|
|
||||||
|
|
||||||
def get_previous_id(self, element_id: Id) -> Optional[Id]:
|
|
||||||
"""
|
|
||||||
Get the id of an element's previous sibling (i. e. the sibling just
|
|
||||||
above it).
|
|
||||||
|
|
||||||
Returns None if there is no previous sibling.
|
|
||||||
|
|
||||||
Depending on the amount of elements in your ElementSupply, the default
|
|
||||||
implementation might get very slow and/or use a lot of memory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
siblings = self.get_children_ids(self.get_parent_id(element_id))
|
|
||||||
index = siblings.index(element_id)
|
|
||||||
|
|
||||||
if index <= 0:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return siblings[index - 1]
|
|
||||||
|
|
||||||
def get_previous(self, element_id: Id) -> Optional[Element]:
|
|
||||||
"""
|
|
||||||
Like get_previous_id(), but returns the Element instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
previous_id = self.get_previous_id(element_id)
|
|
||||||
|
|
||||||
if previous_id is not None:
|
|
||||||
return self.get(previous_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_next_id(self, element_id: Id) -> Optional[Id]:
|
|
||||||
"""
|
|
||||||
Get the id of an element's next sibling (i. e. the sibling just below
|
|
||||||
it).
|
|
||||||
|
|
||||||
Returns None if there is no next sibling.
|
|
||||||
|
|
||||||
Depending on the amount of elements in your ElementSupply, the default
|
|
||||||
implementation might get very slow and/or use a lot of memory.
|
|
||||||
"""
|
|
||||||
|
|
||||||
siblings = self.get_children_ids(self.get_parent_id(element_id))
|
|
||||||
index = siblings.index(element_id)
|
|
||||||
|
|
||||||
if index >= len(siblings) - 1:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return siblings[index + 1]
|
|
||||||
|
|
||||||
def get_next(self, element_id: Id) -> Optional[Element]:
|
|
||||||
"""
|
|
||||||
Like get_next_id(), but returns the Element instead.
|
|
||||||
"""
|
|
||||||
|
|
||||||
next_id = self.get_next_id(element_id)
|
|
||||||
|
|
||||||
if next_id is not None:
|
|
||||||
return self.get(next_id)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_furthest_ancestor_id(self,
|
|
||||||
element_id: Id,
|
|
||||||
root_id: Optional[Id] = None,
|
|
||||||
) -> Id:
|
|
||||||
current_id = element_id
|
|
||||||
|
|
||||||
while True:
|
|
||||||
parent_id = self.get_parent_id(current_id)
|
|
||||||
|
|
||||||
if parent_id == root_id:
|
|
||||||
return current_id
|
|
||||||
elif parent_id is None:
|
|
||||||
raise TreeException(
|
|
||||||
"Reached implicit root before hitting specified root")
|
|
||||||
|
|
||||||
current_id = parent_id
|
|
||||||
|
|
||||||
def get_furthest_ancestor(self,
|
|
||||||
element_id: Id,
|
|
||||||
root_id: Optional[Id] = None,
|
|
||||||
) -> Element:
|
|
||||||
return self.get(self.get_furthest_ancestor_id(element_id,
|
|
||||||
root_id=root_id))
|
|
||||||
|
|
||||||
class MemoryElementSupply(ElementSupply):
|
|
||||||
"""
|
|
||||||
An in-memory implementation of an ElementSupply that works with any type of
|
|
||||||
Element.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._elements: Dict[Id, Element] = {}
|
|
||||||
self._children: Dict[Optional[Id], Set[Id]] = {None: set()}
|
|
||||||
|
|
||||||
def add(self, element: Element) -> None:
|
|
||||||
"""
|
|
||||||
Add a new element or overwrite an existing element with the same id.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if element.id in self._elements:
|
|
||||||
self.remove(element.id)
|
|
||||||
|
|
||||||
self._elements[element.id] = element
|
|
||||||
self._children[element.id] = set()
|
|
||||||
self._children[element.parent_id].add(element.id)
|
|
||||||
|
|
||||||
def remove(self, element_id: Id) -> None:
|
|
||||||
"""
|
|
||||||
Remove an element. This function does nothing if the element doesn't
|
|
||||||
exist in this ElementSupply.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if element_id in self._elements:
|
|
||||||
element = self.get(element_id)
|
|
||||||
|
|
||||||
self._elements.pop(element_id)
|
|
||||||
self._children.pop(element_id)
|
|
||||||
self._children[element.parent_id].remove(element.id)
|
|
||||||
|
|
||||||
def get(self, element_id: Id) -> Element:
|
|
||||||
result = self._elements.get(element_id)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
raise TreeException(f"Element with id {element_id!r} could not be found")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def get_children_ids(self, element_id: Optional[Id]) -> List[Id]:
|
|
||||||
result = self._children.get(element_id)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
raise TreeException(f"Element with id {element_id!r} could not be found")
|
|
||||||
|
|
||||||
return list(sorted(result))
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .single_room_application import *
|
|
||||||
from .util import *
|
|
||||||
|
|
||||||
__all__: List[str] = []
|
|
||||||
__all__ += single_room_application.__all__
|
|
||||||
__all__ += util.__all__
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import asyncio
|
|
||||||
from typing import Any, List, Optional
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
import yaboli
|
|
||||||
|
|
||||||
from ..config import Config
|
|
||||||
from ..markup import AT
|
|
||||||
from ..widgets import ATWidget
|
|
||||||
|
|
||||||
|
|
||||||
class CenteredTextWidget(urwid.WidgetWrap):
|
|
||||||
def __init__(self, lines: List[AT]):
|
|
||||||
max_width = max(map(len, lines))
|
|
||||||
text = AT("\n").join(lines)
|
|
||||||
filler = urwid.Filler(ATWidget(text, align="center"))
|
|
||||||
super().__init__(filler)
|
|
||||||
|
|
||||||
class RoomWidget(urwid.WidgetWrap):
|
|
||||||
"""
|
|
||||||
The RoomWidget connects to and displays a single yaboli room.
|
|
||||||
|
|
||||||
Its life cycle looks like this:
|
|
||||||
1. Create widget
|
|
||||||
2. Call connect() (while the event loop is running)
|
|
||||||
3. Keep widget around and occasionally display it
|
|
||||||
4. Call disconnect() (while the event loop is runnning)
|
|
||||||
5. When the room should be destroyed/forgotten about, it sends a "close"
|
|
||||||
event
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, config: Config, roomname: str) -> None:
|
|
||||||
self.c = config
|
|
||||||
self._room = yaboli.Room(roomname)
|
|
||||||
|
|
||||||
super().__init__(self._connecting_widget())
|
|
||||||
self._room_view = self._connected_widget()
|
|
||||||
|
|
||||||
def _connecting_widget(self) -> Any:
|
|
||||||
lines = [AT("Connecting to ")
|
|
||||||
+ AT("&" + self.room.name, style=self.c.v.element.room)
|
|
||||||
+ AT("...")]
|
|
||||||
return CenteredTextWidget(lines)
|
|
||||||
|
|
||||||
def _connected_widget(self) -> Any:
|
|
||||||
lines = [AT("Connected to ")
|
|
||||||
+ AT("&" + self.room.name, style=self.c.v.element.room)
|
|
||||||
+ AT(".")]
|
|
||||||
return CenteredTextWidget(lines)
|
|
||||||
|
|
||||||
def _connection_failed_widget(self) -> Any:
|
|
||||||
lines = [AT("Could not connect to ")
|
|
||||||
+ AT("&" + self.room.name, style=self.c.v.element.room)
|
|
||||||
+ AT(".")]
|
|
||||||
return CenteredTextWidget(lines)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def room(self) -> yaboli.Room:
|
|
||||||
return self._room
|
|
||||||
|
|
||||||
# Start up the connection and room
|
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
|
||||||
success = await self._room.connect()
|
|
||||||
if success:
|
|
||||||
self._w = self._room_view
|
|
||||||
else:
|
|
||||||
self._w = self._connection_failed_widget()
|
|
||||||
urwid.emit_signal(self, "close")
|
|
||||||
|
|
||||||
def connect(self) -> None:
|
|
||||||
asyncio.create_task(self._connect())
|
|
||||||
|
|
||||||
# Handle input
|
|
||||||
|
|
||||||
#def selectable(self) -> bool:
|
|
||||||
# return True
|
|
||||||
|
|
||||||
#def keypress(self, size: Any, key: str) -> Optional[str]:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
urwid.register_signal(RoomWidget, ["close"])
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from ..config import Config
|
|
||||||
from .room_widget import RoomWidget
|
|
||||||
|
|
||||||
__all__ = ["SingleRoomApplication"]
|
|
||||||
|
|
||||||
class ChooseRoomWidget(urwid.WidgetWrap):
|
|
||||||
def __init__(self, config: Config) -> None:
|
|
||||||
self.c = config
|
|
||||||
|
|
||||||
self.error = None
|
|
||||||
self.text = urwid.Text("Choose a room:", align=urwid.CENTER)
|
|
||||||
self.edit = urwid.Edit("&", align=urwid.CENTER)
|
|
||||||
self.pile = urwid.Pile([
|
|
||||||
self.text,
|
|
||||||
urwid.AttrMap(self.edit, self.c.v.element.room),
|
|
||||||
])
|
|
||||||
self.filler = urwid.Filler(self.pile)
|
|
||||||
super().__init__(self.filler)
|
|
||||||
|
|
||||||
def render(self, size: Any, focus: Any) -> Any:
|
|
||||||
if self.error:
|
|
||||||
width, _ = size
|
|
||||||
rows = self.error.rows((width,), focus)
|
|
||||||
self.filler.bottom = rows
|
|
||||||
|
|
||||||
return super().render(size, focus)
|
|
||||||
|
|
||||||
def set_error(self, text: Any) -> None:
|
|
||||||
self.error = urwid.Text(text, align=urwid.CENTER)
|
|
||||||
self.pile = urwid.Pile([
|
|
||||||
self.error,
|
|
||||||
self.text,
|
|
||||||
urwid.AttrMap(self.edit, self.c.v.element.room),
|
|
||||||
])
|
|
||||||
self.filler = urwid.Filler(self.pile)
|
|
||||||
self._w = self.filler
|
|
||||||
|
|
||||||
def unset_error(self) -> None:
|
|
||||||
self.error = None
|
|
||||||
self.pile = urwid.Pile([
|
|
||||||
self.text,
|
|
||||||
urwid.AttrMap(self.edit, self.c.v.element.room),
|
|
||||||
])
|
|
||||||
self.filler = urwid.Filler(self.pile)
|
|
||||||
self._w = self.filler
|
|
||||||
|
|
||||||
def could_not_connect(self, roomname: str) -> None:
|
|
||||||
text = [
|
|
||||||
"Could not connect to ",
|
|
||||||
(self.c.v.element.room, "&" + roomname),
|
|
||||||
".\n",
|
|
||||||
]
|
|
||||||
self.set_error(text)
|
|
||||||
|
|
||||||
def invalid_room_name(self, reason: str) -> None:
|
|
||||||
text = [f"Invalid room name: {reason}\n"]
|
|
||||||
self.set_error(text)
|
|
||||||
|
|
||||||
class SingleRoomApplication(urwid.WidgetWrap):
|
|
||||||
# The characters in the ALPHABET make up the characters that are allowed in
|
|
||||||
# room names.
|
|
||||||
ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
|
|
||||||
# These are other characters or character combinations necessary for the
|
|
||||||
# editor to function well.
|
|
||||||
ALLOWED_EDITOR_KEYS = {
|
|
||||||
"backspace", "delete",
|
|
||||||
"left", "right",
|
|
||||||
"home", "end",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, config: Config) -> None:
|
|
||||||
self.c = config
|
|
||||||
|
|
||||||
self.choose_room = ChooseRoomWidget(self.c)
|
|
||||||
super().__init__(self.choose_room)
|
|
||||||
|
|
||||||
def selectable(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def switch_to_choose(self) -> None:
|
|
||||||
self.choose_room.could_not_connect(self.choose_room.edit.edit_text)
|
|
||||||
self._w = self.choose_room
|
|
||||||
|
|
||||||
def keypress(self, size: Any, key: str) -> Optional[str]:
|
|
||||||
if self._w == self.choose_room:
|
|
||||||
if key == "esc":
|
|
||||||
raise urwid.ExitMainLoop()
|
|
||||||
|
|
||||||
self.choose_room.unset_error()
|
|
||||||
|
|
||||||
if key == "enter":
|
|
||||||
roomname = self.choose_room.edit.edit_text
|
|
||||||
|
|
||||||
if roomname:
|
|
||||||
room = RoomWidget(self.c, roomname)
|
|
||||||
urwid.connect_signal(room, "close", self.switch_to_choose)
|
|
||||||
room.connect()
|
|
||||||
self._w = room
|
|
||||||
else:
|
|
||||||
self.choose_room.invalid_room_name("too short")
|
|
||||||
|
|
||||||
elif not super().selectable():
|
|
||||||
return key
|
|
||||||
# Make sure we only enter valid room names
|
|
||||||
elif key.lower() in self.ALPHABET:
|
|
||||||
return super().keypress(size, key.lower())
|
|
||||||
elif key in self.ALLOWED_EDITOR_KEYS:
|
|
||||||
return super().keypress(size, key)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif super().selectable():
|
|
||||||
return super().keypress(size, key)
|
|
||||||
|
|
||||||
return key
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
from typing import List, Tuple, Union
|
|
||||||
|
|
||||||
from ..config import Config
|
|
||||||
|
|
||||||
__all__ = ["UtilException","Palette", "palette_from_config", "DEFAULT_CONFIG"]
|
|
||||||
|
|
||||||
class UtilException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Palette = List[Union[Tuple[str, str], Tuple[str, str, str],
|
|
||||||
Tuple[str, str, str, str]]]
|
|
||||||
|
|
||||||
def palette_from_config(conf: Config) -> Palette:
|
|
||||||
palette: Palette = []
|
|
||||||
|
|
||||||
styles = conf.tree["style"]
|
|
||||||
for style, info in styles.items():
|
|
||||||
# First, do the alias stuff
|
|
||||||
alias = info.get("alias")
|
|
||||||
if isinstance(alias, str):
|
|
||||||
if alias in styles:
|
|
||||||
palette.append((style, alias))
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
raise UtilException((f"style.{style}.alias must be the name of"
|
|
||||||
" another style"))
|
|
||||||
elif alias is not None:
|
|
||||||
raise UtilException(f"style.{style}.alias must be a string")
|
|
||||||
|
|
||||||
# Foreground/background
|
|
||||||
fg = info.get("fg")
|
|
||||||
bg = info.get("bg")
|
|
||||||
|
|
||||||
if not isinstance(fg, str) and fg is not None:
|
|
||||||
raise TypeError(f"style.{style}.fg must be a string")
|
|
||||||
|
|
||||||
if not isinstance(bg, str) and bg is not None:
|
|
||||||
raise TypeError(f"style.{style}.bg must be a string")
|
|
||||||
|
|
||||||
fg = fg or ""
|
|
||||||
bg = bg or ""
|
|
||||||
|
|
||||||
palette.append((style, fg, bg))
|
|
||||||
|
|
||||||
return palette
|
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
|
||||||
"element": {
|
|
||||||
"room": "room",
|
|
||||||
},
|
|
||||||
"style": {
|
|
||||||
"room": {
|
|
||||||
"fg": "light blue, bold",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
36
cheuph/message.py
Normal file
36
cheuph/message.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
__all__ = ["Message", "RenderedMessage"]
|
||||||
|
|
||||||
|
class Message:
|
||||||
|
"""
|
||||||
|
A Message represents a single euphoria message. It contains the information
|
||||||
|
and functionality necessary to render itself to lines of AttributedText.
|
||||||
|
|
||||||
|
It does not contain information that usually changes, like a list of its
|
||||||
|
child messages, or if it is currently folded.
|
||||||
|
|
||||||
|
A message's content is assumed to never change. Truncated messages are
|
||||||
|
never untruncated and displayed in full. Thus, the Message can ignore
|
||||||
|
truncation status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RenderedMessage:
|
||||||
|
"""
|
||||||
|
A RenderedMessage is the result of rendering a Message. It contains lines
|
||||||
|
of AttributedText, the target width to which the Message was rendered, its
|
||||||
|
final dimensions and possibly some other useful information.
|
||||||
|
|
||||||
|
It only contains the rendered sender nick and message body, NOT the
|
||||||
|
message's indentation.
|
||||||
|
|
||||||
|
A RenderedMessage is immutable. It can be used in a cache to prevent
|
||||||
|
re-rendering each message every time it is needed (preventing word wrapping
|
||||||
|
and other fancy calculations to be repeated on every re-render).
|
||||||
|
|
||||||
|
It is also useful for scrolling and cursor movement, since the height of
|
||||||
|
the message displayed on screen is not inherent to the Message object, but
|
||||||
|
rather the result of rendering a Message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
8
cheuph/message_cache.py
Normal file
8
cheuph/message_cache.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
__all__ = ["RenderedMessageCache"]
|
||||||
|
class RenderedMessageCache:
|
||||||
|
"""
|
||||||
|
This is a cache for RenderedMessage-s. Message-s should not need to be
|
||||||
|
redrawn every frame (and every in-between calculation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
12
cheuph/message_editor_widget.py
Normal file
12
cheuph/message_editor_widget.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
__all__ = ["MessageEditorWidget"]
|
||||||
|
|
||||||
|
class MessageEditorWidget:
|
||||||
|
"""
|
||||||
|
This widget allows the user to compose a new message. It is based on
|
||||||
|
urwid's Edit widget.
|
||||||
|
|
||||||
|
One day, it will (hopefully) support syntax highlighting and tab
|
||||||
|
completion.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
14
cheuph/message_supply.py
Normal file
14
cheuph/message_supply.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
__all__ = ["MessageSupply"]
|
||||||
|
|
||||||
|
class MessageSupply:
|
||||||
|
"""
|
||||||
|
A MessageSupply holds all of a room's known messages. It can be queried in
|
||||||
|
different ways. Messages can also be added to or removed from the MessageSupply
|
||||||
|
as they are received by the client.
|
||||||
|
|
||||||
|
The MessageSupply may use a database or keep messages in memory. A
|
||||||
|
MessageSupply may also wrap around another MessageSupply, to provide caching or
|
||||||
|
similar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
26
cheuph/message_tree_widget.py
Normal file
26
cheuph/message_tree_widget.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
__all__ = ["MessageTreeWidget"]
|
||||||
|
|
||||||
|
class MessageTreeWidget:
|
||||||
|
"""
|
||||||
|
This widget displays an ElementSupply, including user interface like a
|
||||||
|
cursor or folding markers. It usually is part of a RoomWidget. It also
|
||||||
|
keeps a RenderedMessageCache (and maybe even other caches).
|
||||||
|
|
||||||
|
It receives key presses and mouse clicks from its parent widget. It
|
||||||
|
receives redraw requests and cache invalidation notices from the
|
||||||
|
RoomWidget.
|
||||||
|
|
||||||
|
It doesn't directly receive new messages. Rather, the RoomWidget adds them
|
||||||
|
to the ElementSupply and then submits a cache invalidation notice and a
|
||||||
|
redraw request.
|
||||||
|
|
||||||
|
It emits a "room top hit" event (unnamed as of yet). When the RoomWidget
|
||||||
|
receives this event, it should retrieve more messages from the server.
|
||||||
|
|
||||||
|
It emits a "edit" event (unnamed as of yet) when the user attempts to edit
|
||||||
|
a message. When the RoomWidget receives this event, it should open a text
|
||||||
|
editor to compose a new message, and send that message once the user
|
||||||
|
finishes editing it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
import collections
|
|
||||||
from typing import Any, List, Optional, Set
|
|
||||||
|
|
||||||
from .element import Element, Id, RenderedElement
|
|
||||||
from .element_supply import ElementSupply
|
|
||||||
from .exceptions import TreeException
|
|
||||||
from .markup import AttributedText
|
|
||||||
from .tree_list import TreeList
|
|
||||||
|
|
||||||
__all__ = ["TreeDisplay"]
|
|
||||||
|
|
||||||
class TreeDisplay:
|
|
||||||
"""
|
|
||||||
This class renders elements from an ElementSupply to a list of lines, which
|
|
||||||
can then be drawn onto a curses window. It maintains two "pointers" to
|
|
||||||
specific messages: An anchor, which is used for scrolling, and a cursor,
|
|
||||||
which highlights elements and can be used for interaction.
|
|
||||||
|
|
||||||
ANCHOR
|
|
||||||
|
|
||||||
The anchor is a message at a fixed vertical screen position. This position
|
|
||||||
is called the anchor offset and it specifies the position of the first line
|
|
||||||
of the anchor message relative to the top of the screen.
|
|
||||||
|
|
||||||
A position of 0 would mean that the anchor is displayed at the top of the
|
|
||||||
screen, where a position of (height - 1) would mean that only the first
|
|
||||||
line of the anchor is displayed at the bottom of the screen (the rest is
|
|
||||||
offscreen).
|
|
||||||
|
|
||||||
If no anchor is set, any attempt to render will result in a blank screen
|
|
||||||
(as if the room was empty) since the anchor is the point from which the
|
|
||||||
TreeDisplay starts to render the tree(s).
|
|
||||||
|
|
||||||
CURSOR
|
|
||||||
|
|
||||||
The cursor is a highlighted message that is meant to be used for user
|
|
||||||
interaction.
|
|
||||||
|
|
||||||
The element that the cursor points to is passed highlighed=True in its
|
|
||||||
render() method, whereas all other elements are passed highlighted=False.
|
|
||||||
The cursor can also point to None (i. e. no message), in which case no
|
|
||||||
element is highlighted.
|
|
||||||
|
|
||||||
At the moment, the TreeDisplay contains no cursor movement code, so the
|
|
||||||
user of the TreeDisplay has to implement their own by setting the cursor to
|
|
||||||
the respective element id themselves. You might also want to set the anchor
|
|
||||||
to the cursor and maybe center it on screen when the cursor is moved.
|
|
||||||
|
|
||||||
RENDERING
|
|
||||||
|
|
||||||
Rendering consists of these steps:
|
|
||||||
|
|
||||||
0. Initialize the TreeDisplay with the correct screen width and height and
|
|
||||||
keep the width and height up to date when the window resolution changes.
|
|
||||||
1. Update the internal TreeList through one of various functions
|
|
||||||
2. Cut out the display lines (the text that is later visible on screen)
|
|
||||||
3. Draw the display lines to the curses window
|
|
||||||
|
|
||||||
Step 2 uses the contents of the TreeList from step 1, but DOESN'T happen
|
|
||||||
automatically when the TreeList changes. It is also guaranteed to not
|
|
||||||
modify the display lines if it fails in some way.
|
|
||||||
|
|
||||||
The display lines are supposed to contain the same text that is currently
|
|
||||||
visible on the screen, and can be used to look up mouse clicks (see the
|
|
||||||
"id" attribute in the "RENDERING - technical details" section below).
|
|
||||||
They're a simple format for translating between elements and onscreen text.
|
|
||||||
They are always as wide as the current width. Any missing characters are
|
|
||||||
filled with spaces.
|
|
||||||
|
|
||||||
RENDERING - technical details
|
|
||||||
|
|
||||||
The process of rendering results in a TreeList and a list of lines (called
|
|
||||||
display lines) representing the text that should be displayed on the
|
|
||||||
screen.
|
|
||||||
|
|
||||||
The TreeList's (vertical) offset of messages corresponds to the line on the
|
|
||||||
screen where the message will be drawn. This means that the anchor
|
|
||||||
message's offset in the TreeList will be the anchor offset referred to in
|
|
||||||
the ANCHOR section above.
|
|
||||||
|
|
||||||
Like the Elements and RenderedElements, the TreeDisplay and TreeList also
|
|
||||||
use AttributedStrings for rendered content, especially in the display
|
|
||||||
lines. In addition to the attributes added by the Element during rendering,
|
|
||||||
the TreeDisplay also adds the following attributes to the display lines:
|
|
||||||
|
|
||||||
1. "id" - the id of the element visible on this line
|
|
||||||
2. "parent" - the parent id (or None) of the element visible on this line
|
|
||||||
3. "cursor" - True, only added if the cursor points to the element visible
|
|
||||||
on this line
|
|
||||||
|
|
||||||
When an Element is rendered (producing a RenderedElement), its render()
|
|
||||||
function is called with:
|
|
||||||
1. the element's depth/level (top-level elements have a depth of 0)
|
|
||||||
1. the current screen width
|
|
||||||
2. whether the element is highlighted (by the cursor)
|
|
||||||
3. whether the element is folded
|
|
||||||
|
|
||||||
The RenderedElement contains one or more lines (zero lines may break the
|
|
||||||
TreeDisplay, not sure yet) of AttributedText, which may contain formatting
|
|
||||||
information (such as text color or style). This means that an Element can
|
|
||||||
decide its own height.
|
|
||||||
|
|
||||||
These lines should generally stay within the width passed to render(), but
|
|
||||||
may exceed it in certain exceptional situations (e. g. too close to the
|
|
||||||
right side of the screen). Because of this, the TreeDisplay supports
|
|
||||||
horizontal scrolling.
|
|
||||||
|
|
||||||
SCROLLING
|
|
||||||
|
|
||||||
The most basic form of scrolling is to just increase or decrease the anchor
|
|
||||||
offset. Depending on the application, this approach is enough. In some
|
|
||||||
cases, more complex behaviour is required.
|
|
||||||
|
|
||||||
For example, if you're displaying a section from a live tree structure like
|
|
||||||
a chat room (see https://euphoria.io), there is no static top-most or
|
|
||||||
bottom-most message to anchor to (unless you want to download the whole
|
|
||||||
room's log). Also, new messages may appear at any time in (almost) any
|
|
||||||
place.
|
|
||||||
|
|
||||||
With the above, basic scrolling model, offscreen conversations would often
|
|
||||||
slide around the visible messages, leading to frustration of the user. In
|
|
||||||
cases like this, an alternative scrolling model can be employed:
|
|
||||||
|
|
||||||
First, change the anchor offset as required and render the messages. Then,
|
|
||||||
select the middle-most message as the new anchor and adapt the anchor
|
|
||||||
offset such that the message stays in the same position.
|
|
||||||
|
|
||||||
FOLDING
|
|
||||||
|
|
||||||
Finally, the TreeDisplay supports folding and unfolding elements, making
|
|
||||||
their subtrees invisible.
|
|
||||||
|
|
||||||
Folding happens on elements. When an element is folded, it is still
|
|
||||||
displayed but its (direct and indirect) child messages are no longer
|
|
||||||
displayed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
supply: ElementSupply,
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
supply - the ElementSupply that this TreeDisplay should use
|
|
||||||
width - the width of the target window (in characters)
|
|
||||||
height - the width of the target window (in lines)
|
|
||||||
|
|
||||||
To use the TreeDisplay, you might also want to:
|
|
||||||
- set the anchor
|
|
||||||
- make sure the anchor is visible
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
#self._root_id: Optional[Id] = None # TODO add root stuff
|
|
||||||
|
|
||||||
self.anchor_id: Optional[Id] = None
|
|
||||||
self.anchor_offset: int = 0
|
|
||||||
|
|
||||||
self.cursor_id: Optional[Id] = None
|
|
||||||
|
|
||||||
self.horizontal_offset: int = 0
|
|
||||||
|
|
||||||
# Object references
|
|
||||||
self._supply = supply
|
|
||||||
self._folded: Set[Id] = set()
|
|
||||||
|
|
||||||
self._rendered: Optional[TreeList] = None
|
|
||||||
self._display_lines: Optional[List[AttributedText]] = None
|
|
||||||
|
|
||||||
# RENDERING
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display_lines(self) -> List[AttributedText]:
|
|
||||||
if self._display_lines is None:
|
|
||||||
raise TreeException((
|
|
||||||
"No display lines available (have you called"
|
|
||||||
" render_display_lines() yet?)"
|
|
||||||
))
|
|
||||||
|
|
||||||
return self._display_lines
|
|
||||||
|
|
||||||
def rerender(self) -> None:
|
|
||||||
"""
|
|
||||||
This function updates the internal TreeList (step 1).
|
|
||||||
|
|
||||||
It should be called when the ElementSupply changes or when the anchor,
|
|
||||||
anchor offset, cursor, width or height are manually changed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.anchor_id is None:
|
|
||||||
# As described in the class docstring, we have no starting point
|
|
||||||
# for rendering, so we don't even attempt it.
|
|
||||||
self._rendered = None
|
|
||||||
return
|
|
||||||
|
|
||||||
ancestor_id = self._supply.get_furthest_ancestor_id(self.anchor_id)
|
|
||||||
ancestor_tree = self._render_tree(ancestor_id)
|
|
||||||
|
|
||||||
self._rendered = TreeList(ancestor_tree, self.anchor_id)
|
|
||||||
self._rendered.offset_by(self.anchor_offset)
|
|
||||||
|
|
||||||
self._fill_screen_upwards()
|
|
||||||
self._fill_screen_downwards()
|
|
||||||
|
|
||||||
def _render_tree(self,
|
|
||||||
tree_id: Id,
|
|
||||||
depth: int = 0
|
|
||||||
) -> List[RenderedElement]:
|
|
||||||
|
|
||||||
elements: List[RenderedElement] = []
|
|
||||||
|
|
||||||
highlighted = tree_id == self.cursor_id
|
|
||||||
folded = tree_id in self._folded
|
|
||||||
|
|
||||||
tree = self._supply.get(tree_id)
|
|
||||||
rendered = tree.render(width=self.width, depth=depth,
|
|
||||||
highlighted=highlighted, folded=folded)
|
|
||||||
|
|
||||||
elements.append(rendered)
|
|
||||||
|
|
||||||
if not folded:
|
|
||||||
for child_id in self._supply.get_children_ids(tree_id):
|
|
||||||
subelements = self._render_tree(child_id, depth=depth+1)
|
|
||||||
elements.extend(subelements)
|
|
||||||
|
|
||||||
return elements
|
|
||||||
|
|
||||||
def _fill_screen_upwards(self) -> None:
|
|
||||||
if self._rendered is None:
|
|
||||||
raise TreeException((
|
|
||||||
"Can't fill screen upwards without a TreeList. This exception"
|
|
||||||
" should never occur."
|
|
||||||
))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if self._rendered.upper_offset <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
above_tree_id = self._supply.get_previous_id(
|
|
||||||
self._rendered.upper_tree_id)
|
|
||||||
|
|
||||||
if above_tree_id is None:
|
|
||||||
break # We've hit the top of the supply
|
|
||||||
|
|
||||||
self._rendered.add_above(self._render_tree(above_tree_id))
|
|
||||||
|
|
||||||
def _fill_screen_downwards(self) -> None:
|
|
||||||
"""
|
|
||||||
Eerily similar to _fill_screen_upwards()...
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._rendered is None:
|
|
||||||
raise TreeException((
|
|
||||||
"Can't fill screen downwards without a TreeList. This exception"
|
|
||||||
" should never occur."
|
|
||||||
))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if self._rendered.lower_offset >= self.height - 1:
|
|
||||||
break
|
|
||||||
|
|
||||||
below_tree_id = self._supply.get_next_id(
|
|
||||||
self._rendered.lower_tree_id)
|
|
||||||
|
|
||||||
if below_tree_id is None:
|
|
||||||
break # We've hit the bottom of the supply
|
|
||||||
|
|
||||||
self._rendered.add_below(self._render_tree(below_tree_id))
|
|
||||||
|
|
||||||
def render_display_lines(self) -> None:
|
|
||||||
"""
|
|
||||||
This function updates the display lines (step 2).
|
|
||||||
|
|
||||||
It should be called just before drawing the display lines to the curses
|
|
||||||
window.
|
|
||||||
"""
|
|
||||||
filler_line = AttributedText(" " * self.width)
|
|
||||||
|
|
||||||
if self._rendered is None:
|
|
||||||
self._display_lines = [filler_line] * self.height
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
if self._rendered.upper_offset > 0:
|
|
||||||
# Fill the screen with empty lines until we hit the actual messages
|
|
||||||
lines.extend([filler_line] * self._rendered.upper_offset)
|
|
||||||
|
|
||||||
rendered_lines = self._rendered.to_lines(start=0, stop=self.height-1)
|
|
||||||
lines.extend(line for line, rendered in rendered_lines)
|
|
||||||
|
|
||||||
if self._rendered.lower_offset < self.height - 1:
|
|
||||||
# Fill the rest of the screen with empty lines
|
|
||||||
lines_left = self.height - 1 - self._rendered.lower_offset
|
|
||||||
lines.extend([filler_line] * lines_left)
|
|
||||||
|
|
||||||
self._display_lines = lines
|
|
||||||
|
|
||||||
# SCROLLING
|
|
||||||
|
|
||||||
def center_anchor(self) -> None:
|
|
||||||
"""
|
|
||||||
Center the anchor vertically on the screen.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def ensure_anchor_is_visible(self) -> None:
|
|
||||||
"""
|
|
||||||
Scroll up or down far enough that the anchor is completely visible.
|
|
||||||
|
|
||||||
If the anchor is higher than the screen, scroll such that the first
|
|
||||||
line of the anchor is at the top of the screen.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
def anchor_center_element(self) -> None:
|
|
||||||
"""
|
|
||||||
Select the element closest to the center of the screen (vertically) as
|
|
||||||
anchor. Set the anchor offset such that no scrolling happens.
|
|
||||||
|
|
||||||
This function updates the internal TreeList (step 1).
|
|
||||||
"""
|
|
||||||
|
|
||||||
pass # TODO
|
|
||||||
|
|
||||||
# FOLDING
|
|
||||||
|
|
||||||
def is_folded(self, element_id: Id) -> bool:
|
|
||||||
"""
|
|
||||||
Check whether an element is folded.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return element_id in self._folded
|
|
||||||
|
|
||||||
def fold(self, element_id: Id) -> None:
|
|
||||||
"""
|
|
||||||
Fold an element.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._folded.add(element_id)
|
|
||||||
|
|
||||||
def unfold(self, element_id: Id) -> None:
|
|
||||||
"""
|
|
||||||
Unfold an element.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if element_id in self._folded:
|
|
||||||
self._folded.remove(element_id)
|
|
||||||
|
|
||||||
def toggle_fold(self, element_id: Id) -> bool:
|
|
||||||
"""
|
|
||||||
Toggle whether an element is folded.
|
|
||||||
|
|
||||||
Returns whether the element is folded now.
|
|
||||||
|
|
||||||
This does not render anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.is_folded(element_id):
|
|
||||||
self.unfold(element_id)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.fold(element_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Terminology:
|
|
||||||
#
|
|
||||||
# root
|
|
||||||
# ancestor
|
|
||||||
# parent
|
|
||||||
# sibling
|
|
||||||
# child
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import collections
|
|
||||||
from typing import Deque, List, Optional, Tuple
|
|
||||||
|
|
||||||
from .element import Id, RenderedElement
|
|
||||||
from .markup import AttributedText
|
|
||||||
|
|
||||||
__all__ = ["TreeList"]
|
|
||||||
|
|
||||||
class TreeList:
|
|
||||||
"""
|
|
||||||
This class is the stage between tree-like Element structures and lines of
|
|
||||||
text like the TreeDisplay's DisplayLines.
|
|
||||||
|
|
||||||
It keeps track of the results of rendering Element trees, and also the top
|
|
||||||
and bottom tree's ids, so the TreeList can be expanded easily by appending
|
|
||||||
trees to the top and bottom.
|
|
||||||
|
|
||||||
Despite its name, the "trees" it stores are just flat lists, and they're
|
|
||||||
stored in a flat deque one message at a time. Its name comes from how i is
|
|
||||||
used with rendered Element trees.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
tree: List[RenderedElement],
|
|
||||||
anchor_id: Id,
|
|
||||||
) -> None:
|
|
||||||
self._deque: Deque[RenderedElement] = collections.deque()
|
|
||||||
|
|
||||||
# The offsets can be thought of as the index of a line relative to the
|
|
||||||
# anchor's first line.
|
|
||||||
#
|
|
||||||
# The upper offset is the index of the uppermost message's first line.
|
|
||||||
# The lower offset is the index of the lowermost message's LAST line.
|
|
||||||
self._upper_offset: int
|
|
||||||
self._lower_offset: int
|
|
||||||
|
|
||||||
# The upper and lower tree ids are the ids of the uppermost or
|
|
||||||
# lowermost tree added to the TreeList. They can be used to request the
|
|
||||||
# previous or next tree from an ElementSupply.
|
|
||||||
self._upper_tree_id: Id
|
|
||||||
self._lower_tree_id: Id
|
|
||||||
|
|
||||||
self._add_first_tree(tree, anchor_id)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def upper_offset(self) -> int:
|
|
||||||
return self._upper_offset
|
|
||||||
|
|
||||||
@property
|
|
||||||
def lower_offset(self) -> int:
|
|
||||||
return self._lower_offset
|
|
||||||
|
|
||||||
@property
|
|
||||||
def upper_tree_id(self) -> Id:
|
|
||||||
return self._upper_tree_id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def lower_tree_id(self) -> Id:
|
|
||||||
return self._lower_tree_id
|
|
||||||
|
|
||||||
def offset_by(self, delta: int) -> None:
|
|
||||||
"""
|
|
||||||
Change all the TreeList's offsets by a delta (which is added to each
|
|
||||||
offset).
|
|
||||||
"""
|
|
||||||
|
|
||||||
self._upper_offset += delta
|
|
||||||
self._lower_offset += delta
|
|
||||||
|
|
||||||
def _add_first_tree(self,
|
|
||||||
tree: List[RenderedElement],
|
|
||||||
anchor_id: Id
|
|
||||||
) -> None:
|
|
||||||
if len(tree) == 0:
|
|
||||||
raise ValueError("The tree must contain at least one element")
|
|
||||||
|
|
||||||
tree_id = tree[0].element.id
|
|
||||||
self._upper_tree_id = tree_id
|
|
||||||
self._lower_tree_id = tree_id
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
found_anchor = False
|
|
||||||
|
|
||||||
for rendered in tree:
|
|
||||||
if rendered.element.id == anchor_id:
|
|
||||||
found_anchor = True
|
|
||||||
self._upper_offset = -offset
|
|
||||||
|
|
||||||
offset += rendered.height
|
|
||||||
|
|
||||||
if not found_anchor:
|
|
||||||
raise ValueError("The initial tree must contain the anchor")
|
|
||||||
|
|
||||||
# Subtracting 1 because the lower offset is the index of the lowermost
|
|
||||||
# message's last line, not the first line of a hypothetical message
|
|
||||||
# below that.
|
|
||||||
self._lower_offset = offset - 1
|
|
||||||
|
|
||||||
self._deque.extend(tree)
|
|
||||||
|
|
||||||
def add_above(self, tree: List[RenderedElement]) -> None:
|
|
||||||
"""
|
|
||||||
Add a rendered tree above all current trees.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if len(tree) == 0:
|
|
||||||
raise ValueError("The tree must contain at least one element")
|
|
||||||
|
|
||||||
self._upper_tree_id = tree[0].element.id
|
|
||||||
|
|
||||||
for rendered in reversed(tree):
|
|
||||||
self._deque.appendleft(rendered)
|
|
||||||
self._upper_offset -= rendered.height
|
|
||||||
|
|
||||||
# Alternative to the above for loop
|
|
||||||
#delta = sum(map(lambda r: r.height, tree))
|
|
||||||
#self._upper_offset -= delta
|
|
||||||
#self._deque.extendLeft(reversed(tree))
|
|
||||||
|
|
||||||
def add_below(self, tree: List[RenderedElement]) -> None:
|
|
||||||
"""
|
|
||||||
Add a rendered tree below all current trees.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if len(tree) == 0:
|
|
||||||
raise ValueError("The tree must contain at least one element")
|
|
||||||
|
|
||||||
self._lower_tree_id = tree[0].element.id
|
|
||||||
|
|
||||||
for rendered in tree:
|
|
||||||
self._deque.append(rendered)
|
|
||||||
self._lower_offset += rendered.height
|
|
||||||
|
|
||||||
# Alternative to the above for loop
|
|
||||||
#delta = sum(map(lambda r: r.height, tree))
|
|
||||||
#self._lower_offset += delta
|
|
||||||
#self._deque.extend(tree)
|
|
||||||
|
|
||||||
def to_lines(self,
|
|
||||||
start: Optional[int] = None,
|
|
||||||
stop: Optional[int] = None,
|
|
||||||
) -> List[Tuple[AttributedText, RenderedElement]]:
|
|
||||||
|
|
||||||
offset = self.upper_offset
|
|
||||||
lines: List[Tuple[AttributedText, RenderedElement]] = []
|
|
||||||
|
|
||||||
# I'm creating this generator instead of using two nested for loops
|
|
||||||
# below, because I want to be able to break out of the for loop without
|
|
||||||
# the code getting too ugly, and because it's fun :)
|
|
||||||
all_lines = ((line, rendered)
|
|
||||||
for rendered in self._deque
|
|
||||||
for line in rendered.lines)
|
|
||||||
|
|
||||||
for line, rendered in all_lines:
|
|
||||||
after_start = start is not None and offset >= start
|
|
||||||
before_stop = stop is not None and offset <= stop
|
|
||||||
|
|
||||||
if after_start and before_stop:
|
|
||||||
lines.append((line, rendered))
|
|
||||||
|
|
||||||
if not before_stop:
|
|
||||||
break
|
|
||||||
|
|
||||||
offset += 1
|
|
||||||
|
|
||||||
return lines
|
|
||||||
11
cheuph/user_list_widget.py
Normal file
11
cheuph/user_list_widget.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
__all__ = ["UserListWidget"]
|
||||||
|
|
||||||
|
class UserListWidget:
|
||||||
|
"""
|
||||||
|
This widget displays 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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from .attributed_text_widget import *
|
|
||||||
from .tree_display_widget import *
|
|
||||||
|
|
||||||
__all__: List[str] = []
|
|
||||||
__all__ += attributed_text_widget.__all__
|
|
||||||
__all__ += tree_display_widget.__all__
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
from typing import Any, FrozenSet
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from ..element_supply import ElementSupply
|
|
||||||
from ..markup import AT
|
|
||||||
from ..tree_display import TreeDisplay
|
|
||||||
from .attributed_text_widget import AttributedTextWidget
|
|
||||||
|
|
||||||
__all__ = ["TreeDisplayWidget"]
|
|
||||||
|
|
||||||
class TreeDisplayWidget(urwid.WidgetWrap):
|
|
||||||
def __init__(self, supply: ElementSupply) -> None:
|
|
||||||
self._display = TreeDisplay(supply, 80, 50)
|
|
||||||
|
|
||||||
self._sizing = frozenset({"box"})
|
|
||||||
self._selectable = False
|
|
||||||
|
|
||||||
# I could set wrap="clip", but the TreeDisplay should already cut its
|
|
||||||
# display_lines to the correct width, based on its size. Leaving the
|
|
||||||
# wrap on might help with users spotting things going wrong.
|
|
||||||
self._text_widget = AttributedTextWidget(AT())
|
|
||||||
|
|
||||||
super().__init__(urwid.Filler(self._text_widget))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def display(self) -> TreeDisplay:
|
|
||||||
return self._display
|
|
||||||
|
|
||||||
def render(self, size: Any, focus: Any) -> Any:
|
|
||||||
self._display.width, self._display.height = size
|
|
||||||
self._display.rerender()
|
|
||||||
self._display.render_display_lines()
|
|
||||||
|
|
||||||
text = AT("\n").join(self._display.display_lines)
|
|
||||||
self._text_widget.set_attributed_text(text)
|
|
||||||
|
|
||||||
return self._w.render(size, focus)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue