Lay out new client structure

This commit is contained in:
Joscha 2019-05-25 13:12:28 +00:00
parent 6c4bfe2752
commit 7da4bf36d5
21 changed files with 174 additions and 1137 deletions

View file

@ -1,20 +1,29 @@
from typing import List
from .attributed_lines import *
from .attributed_lines_widget import *
from .attributed_text_widget import *
from .config import *
from .element import *
from .element_supply import *
from .exceptions import *
from .markup import *
from .tree_display import *
from .tree_list import *
from .widgets import *
from .message import *
from .message_cache import *
from .message_editor_widget import *
from .message_supply import *
from .message_tree_widget import *
from .user_list_widget import *
__all__: List[str] = []
__all__ += attributed_lines.__all__
__all__ += attributed_lines_widget.__all__
__all__ += attributed_text_widget.__all__
__all__ += config.__all__
__all__ += element.__all__
__all__ += element_supply.__all__
__all__ += exceptions.__all__
__all__ += markup.__all__
__all__ += tree_display.__all__
__all__ += tree_list.__all__
__all__ += widgets.__all__
__all__ += message.__all__
__all__ += message_cache.__all__
__all__ += message_editor_widget.__all__
__all__ += message_supply.__all__
__all__ += message_tree_widget.__all__
__all__ += user_list_widget.__all__

View 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

View 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

View file

@ -33,7 +33,7 @@ class AttributedTextWidget(urwid.Text):
def _convert_to_markup(text: AttributedText
) -> 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
markup: List[Union[str, Tuple[str, str]]]
markup = [
@ -51,6 +51,14 @@ class AttributedTextWidget(urwid.Text):
self._attributed_text = 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:
"""
Returns the currently used AttributedText.

View file

@ -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
__all__ = ["Fields", "Config", "ConfigView"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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

View file

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

View file

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

View 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

View file

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

View file

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