From 32bc9af2d874c34ab3e92ff76c71623240ca7f83 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 28 May 2019 11:17:58 +0000 Subject: [PATCH] Implement basic AttributedLinesWidget --- cheuph/attributed_lines.py | 157 +++++++++++++++++++++++++++++- cheuph/attributed_lines_widget.py | 66 ++++++++++++- cheuph/attributed_text_widget.py | 8 -- 3 files changed, 217 insertions(+), 14 deletions(-) diff --git a/cheuph/attributed_lines.py b/cheuph/attributed_lines.py index 16697ed..552d76f 100644 --- a/cheuph/attributed_lines.py +++ b/cheuph/attributed_lines.py @@ -1,5 +1,12 @@ -__all__ = ["AttributedLines"] +import collections +from typing import Deque, Iterator, List, Optional, Tuple +from .markup import AT, AttributedText, Attributes + +__all__ = ["Line", "AttributedLines"] + + +Line = Tuple[Attributes, AttributedText] class AttributedLines: """ @@ -7,11 +14,153 @@ class AttributedLines: 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. + line to an 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 + def __init__(self, lines: Optional[List[Line]] = None) -> None: + self.upper_offset = 0 + self._lines: Deque[Line] = collections.deque(lines or []) + + def __iter__(self) -> Iterator[Line]: + return self._lines.__iter__() + + def __len__(self) -> int: + return len(self._lines) + + @property + def lower_offset(self) -> int: + # When there's one element in the list, the lower and upper offsets are + # the same. From that follows that in an empty list, the lower offset + # must be smaller than the upper offset. + return self.upper_offset + (len(self) - 1) + + @lower_offset.setter + def lower_offset(self, lower_offset: int) -> None: + self.upper_offset = lower_offset - (len(self) - 1) + + def append_above(self, line: Line) -> None: + self._lines.appendleft(line) + self.upper_offset -= 1 + + def append_below(self, line: Line) -> None: + self._lines.append(line) + # lower offset does not need to be modified since it's calculated based + # on the upper offset + + def expand_above(self, lines: "AttributedLines") -> None: + """ + Prepend an AttributedLines, ignoring its offsets and using the current + AttributedLines's offsets instead. + """ + + self._lines.extendleft(lines._lines) + self.upper_offset -= len(lines) + + def expand_below(self, lines: "AttributedLines") -> None: + """ + Append an AttributedLines, ignoring its offsets and using the current + AttributedLines's offsets instead. + """ + + self._lines.extend(lines._lines) + # lower offset does not need to be modified since it's calculated based + # on the upper offset + + def between(self, start_offset: int, end_offset: int) -> "AttributedLines": + lines = [] + + for i, line in enumerate(self): + line_offset = self.upper_offset + i + if start_offset <= line_offset <= end_offset: + lines.append(line) + + attr_lines = AttributedLines(lines) + attr_lines.upper_offset = max(start_offset, self.upper_offset) + return attr_lines + + def to_size(self, start_offset: int, end_offset: int) -> "AttributedLines": + between = self.between(start_offset, end_offset) + + while between.upper_offset > start_offset: + between.append_above(({}, AT())) + + while between.lower_offset < end_offset: + between.append_below(({}, AT())) + + return between + + @staticmethod + def render_line( + line: Line, + width: int, + horizontal_offset: int, + offset_char: str = " ", + overlap_char: str = "…", + ) -> AttributedText: + + attributes, text = line + # column to the right is reserved for the overlap char + text_width = width - 1 + + start_offset = horizontal_offset + end_offset = start_offset + text_width + + result: AttributedText = AT() + + if start_offset < 0: + pad_length = min(text_width, -start_offset) + result += AT(offset_char * pad_length) + + if end_offset < 0: + pass # the text should not be displayed at all + elif end_offset < len(text): + if start_offset > 0: + result += text[start_offset:end_offset] + else: + result += text[:end_offset] + else: + if start_offset > 0: + result += text[start_offset:] + else: + result += text + + if end_offset > len(text): + pad_length = min(text_width, end_offset - len(text)) + result += AT(offset_char * pad_length) + + if end_offset < len(text): + result += AT(overlap_char) + else: + result += AT(offset_char) + + for k, v in attributes.items(): + result = result.set(k, v) + + return result + + def render_lines(self, + width: int, + height: int, + horizontal_offset: int, + ) -> List[AttributedText]: + + lines = [] + + for line in self.to_size(0, height - 1): + lines.append(self.render_line(line, width, horizontal_offset)) + + return lines + + def render(self, + width: int, + height: int, + horizontal_offset: int, + ) -> AttributedText: + + lines = self.render_lines(width, height, + horizontal_offset=horizontal_offset) + return AT("\n").join(lines) diff --git a/cheuph/attributed_lines_widget.py b/cheuph/attributed_lines_widget.py index 1d1123c..1de6bc4 100644 --- a/cheuph/attributed_lines_widget.py +++ b/cheuph/attributed_lines_widget.py @@ -1,7 +1,15 @@ +from typing import Any, Optional, Tuple + +import urwid + +from .attributed_lines import AttributedLines +from .attributed_text_widget import AttributedTextWidget +from .markup import AT, AttributedText, Attributes + __all__ = ["AttributedLinesWidget"] -class AttributedLinesWidget: +class AttributedLinesWidget(urwid.WidgetWrap): """ 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) @@ -9,6 +17,60 @@ class AttributedLinesWidget: When clicked, it sends an event containing the attributes of the character that was just clicked. + + Uses the following config values: + - "filler_symbol" + - "overflow_symbol" """ - pass + def __init__(self, + lines: Optional[AttributedLines] = None, + ) -> None: + + self._horizontal_offset = 0 + + self._text = AttributedTextWidget(AT()) + self._filler = urwid.Filler(self._text, valign=urwid.TOP) + super().__init__(self._filler) + + self.set_lines(lines or AttributedLines()) + + @property + def horizontal_offset(self) -> int: + return self._horizontal_offset + + @horizontal_offset.setter + def horizontal_offset(self, offset: int) -> None: + if offset != self._horizontal_offset: + self._horizontal_offset = offset + self._invalidate() + + @property + def upper_offset(self) -> int: + return self._lines.upper_offset + + @upper_offset.setter + def upper_offset(self, offset: int) -> None: + self._lines.upper_offset = offset + self._invalidate() + + @property + def lower_offset(self) -> int: + return self._lines.lower_offset + + @lower_offset.setter + def lower_offset(self, offset: int) -> None: + self._lines.lower_offset = offset + self._invalidate() + + def set_lines(self, lines: AttributedLines) -> None: + self._lines = lines + self._invalidate() + + def render(self, size: Tuple[int, int], focus: bool) -> None: + width, height = size + + text = self._lines.render(width, height, self.horizontal_offset) + self._text.set_attributed_text(text) + + return super().render(size, focus) diff --git a/cheuph/attributed_text_widget.py b/cheuph/attributed_text_widget.py index f4377ae..10308c5 100644 --- a/cheuph/attributed_text_widget.py +++ b/cheuph/attributed_text_widget.py @@ -52,14 +52,6 @@ class AttributedTextWidget(urwid.Text): self._attributed_text = text super().set_text(self._convert_to_markup(text)) - def set_text(self, *args: Any, **kwargs: Any) -> None: - """ - 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.