home ยท contact ยท privacy
Clean up TUI widgets code, especially that of HistoryWidget. master
authorChristian Heller <c.heller@plomlompom.de>
Sat, 29 Nov 2025 16:37:48 +0000 (17:37 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Sat, 29 Nov 2025 16:37:48 +0000 (17:37 +0100)
src/ircplom/client_tui.py
src/ircplom/testing.py
src/ircplom/tui_base.py

index 4080f2b227fdb0d04f10ad9d3995a3873456a104..d7da1c592666c6b191d8449bad4ff80cf83705c6 100644 (file)
@@ -138,7 +138,7 @@ class _ChatPrompt(PromptWidget):
     def set_prefix_data(self, nick: str) -> None:
         'Update prompt prefix with nickname data.'
         if nick != self._nickname:
     def set_prefix_data(self, nick: str) -> None:
         'Update prompt prefix with nickname data.'
         if nick != self._nickname:
-            self._tainted = True
+            self.tainted = True
             self._nickname = nick
 
     def enter(self) -> str:
             self._nickname = nick
 
     def enter(self) -> str:
index 0199e52cc2fb744cd5e241633a6ecba5d2468b17..e6f6d35babfdfdcba510088f8fcedf4bb4d188d0 100644 (file)
@@ -70,12 +70,12 @@ class TestTerminal(QueueMixin, TerminalInterface):
             for _ in range(self.size.x):
                 line += [(tuple(), ' ')]
 
             for _ in range(self.size.x):
                 line += [(tuple(), ' ')]
 
-    def length_to_term(self, text: str) -> int:
+    def len_to_term(self, text: str) -> int:
         return len(text) + len(tuple(c for c in text if c == '๐Ÿ’“'))
 
     def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
         for c in text:
         return len(text) + len(tuple(c for c in text if c == '๐Ÿ’“'))
 
     def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
         for c in text:
-            len_to_term = self.length_to_term(c)
+            len_to_term = self.len_to_term(c)
             for i in range(len_to_term):
                 self._screen[self._cursor_y][self._cursor_x+i]\
                         = None if i else (attrs, c)
             for i in range(len_to_term):
                 self._screen[self._cursor_y][self._cursor_x+i]\
                         = None if i else (attrs, c)
@@ -100,7 +100,7 @@ class TestTerminal(QueueMixin, TerminalInterface):
         assert 0 <= y < self.size.y
         if text.endswith(_SCREENLINE_PADDING_SUFFIX):
             text = text[:-len(_SCREENLINE_PADDING_SUFFIX)]
         assert 0 <= y < self.size.y
         if text.endswith(_SCREENLINE_PADDING_SUFFIX):
             text = text[:-len(_SCREENLINE_PADDING_SUFFIX)]
-            text += ' ' * (self.size.x - self.length_to_term(text))
+            text += ' ' * (self.size.x - self.len_to_term(text))
         jumped_nones = 0
         for idx, cell_expected in enumerate(
                 (tuple(attrs_str.split(_SEP_1)), c) for c in text):
         jumped_nones = 0
         for idx, cell_expected in enumerate(
                 (tuple(attrs_str.split(_SEP_1)), c) for c in text):
index 2181a8b5d8e37ecd78c1d87474cdc1201ee66eda..46370175e3a5f5fe0aa89ef6ee0ee65bf5a3ff2c 100644 (file)
@@ -139,7 +139,10 @@ class FormattingString:
             next_part += c
         return tuple(to_ret)
 
             next_part += c
         return tuple(to_ret)
 
-    def wrap(self, width: int, length_to_term: Callable) -> tuple[Self, ...]:
+    def wrap(self,
+             width: int,
+             len_to_term: Callable[[str], int]
+             ) -> tuple[Self, ...]:
         'Break into sequence respecting width, preserving attributes per part.'
         wrapped_lines: list[str] = []
         next_part_w_code = ''
         'Break into sequence respecting width, preserving attributes per part.'
         wrapped_lines: list[str] = []
         next_part_w_code = ''
@@ -156,7 +159,7 @@ class FormattingString:
                 = self._parse_for_formattings(c, in_format_def, formattings)
             if do_not_print:
                 continue
                 = self._parse_for_formattings(c, in_format_def, formattings)
             if do_not_print:
                 continue
-            len_flattened += length_to_term(c)
+            len_flattened += len_to_term(c)
             if len_flattened <= width:
                 continue
             walk_back = ([-(i+1) for i in range(len(next_part_w_code)
             if len_flattened <= width:
                 continue
             walk_back = ([-(i+1) for i in range(len(next_part_w_code)
@@ -182,60 +185,73 @@ class _YX(NamedTuple):
     x: int
 
 
     x: int
 
 
-class _Widget(ABC):
-    _tainted: bool = True
-    _sizes = _YX(-1, -1)
+_SIZE_UNSET = _YX(-1, -1)
 
 
-    @property
-    def _drawable(self) -> bool:
-        return len([m for m in self._sizes if m < 1]) == 0
 
 
-    def taint(self) -> None:
-        'Declare as in need of re-drawing.'
-        self._tainted = True
+class _Widget(ABC):
+    _size = _SIZE_UNSET
 
     @property
     def tainted(self) -> bool:
         'If in need of re-drawing.'
         return self._tainted
 
 
     @property
     def tainted(self) -> bool:
         'If in need of re-drawing.'
         return self._tainted
 
-    def set_geometry(self, sizes: _YX) -> None:
-        'Update widget\'s sizues, re-generate content where necessary.'
-        self.taint()
-        self._sizes = sizes
+    @tainted.setter
+    def tainted(self, value: bool) -> None:
+        'Declare need of re-drawing.'
+        self._tainted = value
+
+    @property
+    def _drawable(self) -> bool:
+        return len([length for length in self._size if length < 1]) == 0
+
+    @abstractmethod
+    def set_geometry(self, size: _YX) -> None:
+        'Update widget\'s sizes, re-generate content where necessary.'
+
+    @abstractmethod
+    def draw_tainted(self) -> None:
+        "If .tainted, print widget's content in .set_geometry()-defined shape."
+
+
+class _WidgetAtom(_Widget, ABC):
+    _tainted: bool = True
 
     def _make_x_scroll(self, padchar: str, cursor_x: int, left: str, right: str
                        ) -> tuple[int, str]:
         'Build line of static left, right scrolled with cursor_x if too large.'
         to_write = left[:]
         offset = 0
 
     def _make_x_scroll(self, padchar: str, cursor_x: int, left: str, right: str
                        ) -> tuple[int, str]:
         'Build line of static left, right scrolled with cursor_x if too large.'
         to_write = left[:]
         offset = 0
-        width_gap = self._sizes.x - len(left + right)
+        width_gap = self._size.x - len(left + right)
         if width_gap < 0:
         if width_gap < 0:
-            half_width = (self._sizes.x - len(left)) // 2
+            half_width = (self._size.x - len(left)) // 2
             if cursor_x > half_width:
                 to_write += _ELL_IN
                 offset = len(_ELL_IN) + min(-width_gap, cursor_x - half_width)
             to_write += right[offset:]
             if cursor_x > half_width:
                 to_write += _ELL_IN
                 offset = len(_ELL_IN) + min(-width_gap, cursor_x - half_width)
             to_write += right[offset:]
-            if len(to_write) > self._sizes.x:
-                to_write = to_write[:self._sizes.x - len(_ELL_OUT)] + _ELL_OUT
+            if len(to_write) > self._size.x:
+                to_write = to_write[:self._size.x - len(_ELL_OUT)] + _ELL_OUT
         else:
             to_write += (right if width_gap == 0
                          else ((padchar * width_gap + right) if padchar == '='
                                else (right + padchar * width_gap)))
         return len(left) - offset + cursor_x, to_write
 
         else:
             to_write += (right if width_gap == 0
                          else ((padchar * width_gap + right) if padchar == '='
                                else (right + padchar * width_gap)))
         return len(left) - offset + cursor_x, to_write
 
-    def draw(self) -> None:
-        'Print widget\'s content in shape appropriate to set geometry.'
-        if self._drawable:
+    def set_geometry(self, size: _YX) -> None:
+        self.tainted = True
+        self._size = size
+
+    def draw_tainted(self) -> None:
+        if self._drawable and self.tainted:
             self._draw()
             self._draw()
-            self._tainted = False
+            self.tainted = False
 
     @abstractmethod
     def _draw(self) -> None:
         pass
 
 
 
     @abstractmethod
     def _draw(self) -> None:
         pass
 
 
-class _ScrollableWidget(_Widget):
+class _ScrollableWidget(_WidgetAtom):
     _history_idx_neg: int
 
     def __init__(self, write: Callable[..., None], **kwargs) -> None:
     _history_idx_neg: int
 
     def __init__(self, write: Callable[..., None], **kwargs) -> None:
@@ -249,137 +265,160 @@ class _ScrollableWidget(_Widget):
 
     @abstractmethod
     def _scroll(self, up=True) -> None:
 
     @abstractmethod
     def _scroll(self, up=True) -> None:
-        self.taint()
+        self.tainted = True
 
     def cmd__scroll(self, direction: str) -> None:
         'Scroll through stored content/history.'
         self._scroll(up=direction == 'up')
 
 
 
     def cmd__scroll(self, direction: str) -> None:
         'Scroll through stored content/history.'
         self._scroll(up=direction == 'up')
 
 
+class _WrappedHistoryLine(NamedTuple):
+    history_idx_pos: int
+    text: FormattingString
+
+
 class _HistoryWidget(_ScrollableWidget):
 class _HistoryWidget(_ScrollableWidget):
-    _wrapped_idx_neg: int
+    _wrapped_idx_neg: int  # negative index of lowest visible wrapped line
     _y_pgscroll: int
     _UNSET_IDX_NEG: int = 0
     _UNSET_IDX_POS: int = -1
     _y_pgscroll: int
     _UNSET_IDX_NEG: int = 0
     _UNSET_IDX_POS: int = -1
+    _BOTTOM_IDX_NEG: int = -1
     _COMPARAND_HISTORY_IDX_POS: int = -2
     _BOOKMARK_HISTORY_IDX_POS: int = -3
     _PADDING_HISTORY_IDX_POS: int = -4
 
     def __init__(self,
                  maxlen_log: int,
     _COMPARAND_HISTORY_IDX_POS: int = -2
     _BOOKMARK_HISTORY_IDX_POS: int = -3
     _PADDING_HISTORY_IDX_POS: int = -4
 
     def __init__(self,
                  maxlen_log: int,
-                 length_to_term: Callable[[str], int],
+                 len_to_term: Callable[[str], int],
                  **kwargs
                  ) -> None:
         super().__init__(**kwargs)
         self._maxlen_log = maxlen_log
                  **kwargs
                  ) -> None:
         super().__init__(**kwargs)
         self._maxlen_log = maxlen_log
-        self._length_to_term = length_to_term
-        self._wrapped: list[tuple[int, FormattingString]] = []
-        self._newest_read_history_idx_pos = self._UNSET_IDX_POS
-        self._history_offset = 0
+        self._len_to_term = len_to_term
+        self._wrapped: list[_WrappedHistoryLine] = []
+        self._lowest_read_history_idx_pos = self._UNSET_IDX_POS
+        self._bookmark_idx_neg = self._UNSET_IDX_NEG
+        self._history_n_lines_cut = 0
         self._history_idx_neg = self._UNSET_IDX_NEG
 
         self._history_idx_neg = self._UNSET_IDX_NEG
 
-    def _add_wrapped(self, history_idx_pos: int, line: FormattingString
-                     ) -> int:
-        lines = line.wrap(self._sizes.x, self._length_to_term)
-        self._wrapped += [(self._history_offset + history_idx_pos, line)
-                          for line in lines]
+    def _add_wrapped_from_offset_history(self,
+                                         history_idx_pos: int,
+                                         line: FormattingString
+                                         ) -> int:
+        lines = [_WrappedHistoryLine(
+                    self._history_n_lines_cut + history_idx_pos,
+                    text)
+                 for text in line.wrap(self._size.x, self._len_to_term)]
+        self._wrapped += lines
         return len(lines)
 
     @property
     def _len_full_history(self) -> int:
         return len(lines)
 
     @property
     def _len_full_history(self) -> int:
-        return self._history_offset + len(self._history)
+        return self._history_n_lines_cut + len(self._history)
 
 
-    def set_geometry(self, sizes: _YX) -> None:
-        super().set_geometry(sizes)
+    def set_geometry(self, size: _YX) -> None:
+        super().set_geometry(size)
         if self._drawable:
         if self._drawable:
-            self._y_pgscroll = self._sizes.y // 2
+            self._y_pgscroll = self._size.y // 2
             self._wrapped.clear()
             for history_idx_pos, line in enumerate(self._history):
             self._wrapped.clear()
             for history_idx_pos, line in enumerate(self._history):
-                self._add_wrapped(history_idx_pos,
-                                  FormattingString(line, raw=True))
+                self._add_wrapped_from_offset_history(history_idx_pos,
+                                                      FormattingString(
+                                                          line, raw=True))
             # ensure that of the full line identified by ._history_idx_neg,
             # ._wrapped_idx_neg point to the lowest of its wrap parts
             self._wrapped_idx_neg = (
                 self._UNSET_IDX_NEG if (not self._wrapped)
             # ensure that of the full line identified by ._history_idx_neg,
             # ._wrapped_idx_neg point to the lowest of its wrap parts
             self._wrapped_idx_neg = (
                 self._UNSET_IDX_NEG if (not self._wrapped)
-                else (-len(self._wrapped)
-                      + self._last_wrapped_idx_pos_for_hist_idx_pos(
+                else (self._wrapped_top_idx_neg
+                      + self._bottom_wrapped_for_history_idx_pos(
                           self._len_full_history + max(self._history_idx_neg,
                                                        - self._maxlen_log))))
             self.bookmark()
 
     def append_fmt(self, to_append: FormattingString) -> None:
         'Wrap .append around FormattingString, update history dependents.'
                           self._len_full_history + max(self._history_idx_neg,
                                                        - self._maxlen_log))))
             self.bookmark()
 
     def append_fmt(self, to_append: FormattingString) -> None:
         'Wrap .append around FormattingString, update history dependents.'
+        def start_unset_dec_non_bottom(attr_name: str, grow_by: int) -> None:
+            full_name = f'_{attr_name}_idx_neg'
+            cur_val = getattr(self, full_name)
+            if cur_val == self._UNSET_IDX_NEG:
+                setattr(self, full_name, self._BOTTOM_IDX_NEG)
+            elif cur_val < self._BOTTOM_IDX_NEG:
+                setattr(self, full_name, cur_val - grow_by)
+
         super().append(str(to_append))
         super().append(str(to_append))
-        self.taint()
-        if not self._UNSET_IDX_NEG != self._history_idx_neg >= -1:
-            self._history_idx_neg -= 1
+        self.tainted = True
+        start_unset_dec_non_bottom('history', grow_by=1)
         if self._drawable:
         if self._drawable:
-            n_wrapped = self._add_wrapped(len(self._history) - 1, to_append)
-            if not self._UNSET_IDX_NEG != self._wrapped_idx_neg >= -1:
-                self._wrapped_idx_neg -= n_wrapped
+            wrapped_growth\
+                = self._add_wrapped_from_offset_history(len(self._history) - 1,
+                                                        to_append)
+            start_unset_dec_non_bottom('wrapped', grow_by=wrapped_growth)
+            if self._bookmark_idx_neg != self._UNSET_IDX_NEG:
+                self._bookmark_idx_neg -= wrapped_growth
         if len(self._history) > self._maxlen_log:
         if len(self._history) > self._maxlen_log:
-            self._history = self._history[1:]
-            self._history_offset += 1
+            self._history.pop(0)
+            self._history_n_lines_cut += 1
             self._history_idx_neg = max(self._history_idx_neg,
                                         -self._maxlen_log)
             self._history_idx_neg = max(self._history_idx_neg,
                                         -self._maxlen_log)
-            wrap_offset = 0
-            for wrap_idx_pos, t in enumerate(self._wrapped):
-                if t[0] == self._history_offset:
-                    wrap_offset = wrap_idx_pos
-                    break
-            self._wrapped = self._wrapped[wrap_offset:]
+            while self._wrapped[0].history_idx_pos < self._history_n_lines_cut:
+                self._wrapped.pop(0)
             self._wrapped_idx_neg = max(self._wrapped_idx_neg,
             self._wrapped_idx_neg = max(self._wrapped_idx_neg,
-                                        -len(self._wrapped))
+                                        self._wrapped_top_idx_neg)
 
     def _draw(self) -> None:
         add_scroll_info = self._wrapped_idx_neg < -1
 
     def _draw(self) -> None:
         add_scroll_info = self._wrapped_idx_neg < -1
-        start_idx_neg = (self._wrapped_idx_neg
-                         - self._sizes.y + 1 + bool(add_scroll_info))
-        end_idx_neg = (self._wrapped_idx_neg + 1) if add_scroll_info else None
-        wrapped = self._wrapped[start_idx_neg:end_idx_neg]
-        while len(wrapped) < self._sizes.y - bool(add_scroll_info):
-            wrapped.insert(0, (self._PADDING_HISTORY_IDX_POS,
-                               FormattingString('')))
-        for idx, line in enumerate([lt[1] for lt in wrapped]):
+        low_idx_neg = (self._wrapped_idx_neg + 1) if add_scroll_info else None
+        high_idx_neg = (low_idx_neg or -1) - self._size.y + 1
+        visible_lines = self._wrapped[high_idx_neg:low_idx_neg]
+        while len(visible_lines) < self._size.y - bool(add_scroll_info):
+            visible_lines.insert(0, _WrappedHistoryLine(
+                                         self._PADDING_HISTORY_IDX_POS,
+                                         FormattingString('')))
+        for idx, line in enumerate([line.text for line in visible_lines]):
             self._write(idx, line)
         if add_scroll_info:
             scroll_info = f'vvv [{(-1) * self._history_idx_neg - 1}] '
             self._write(idx, line)
         if add_scroll_info:
             scroll_info = f'vvv [{(-1) * self._history_idx_neg - 1}] '
-            scroll_info += 'v' * (self._sizes.x - len(scroll_info))
-            self._write(len(wrapped),
+            scroll_info += 'v' * (self._size.x - len(scroll_info))
+            self._write(len(visible_lines),
                         FormattingString(scroll_info).attrd('reverse'))
                         FormattingString(scroll_info).attrd('reverse'))
-        self._newest_read_history_idx_pos\
-            = max(self._newest_read_history_idx_pos, wrapped[-1][0])
+        self._lowest_read_history_idx_pos\
+            = max(self._lowest_read_history_idx_pos,
+                  visible_lines[-1].history_idx_pos)
 
     def bookmark(self) -> None:
         'Store next idx to what most recent line we have (been) scrolled.'
 
     def bookmark(self) -> None:
         'Store next idx to what most recent line we have (been) scrolled.'
-        bookmark = (self._BOOKMARK_HISTORY_IDX_POS,
-                    FormattingString('-' * self._sizes.x))
-        if bookmark in self._wrapped:
-            bookmark_idx_neg\
-                = self._wrapped.index(bookmark) - len(self._wrapped)
-            del self._wrapped[bookmark_idx_neg]
-            if bookmark_idx_neg > self._wrapped_idx_neg:
+        if self._bookmark_idx_neg != self._UNSET_IDX_NEG\
+                and len(self._wrapped) > -self._bookmark_idx_neg:
+            del self._wrapped[self._bookmark_idx_neg]
+            if self._bookmark_idx_neg > self._wrapped_idx_neg:
                 self._wrapped_idx_neg += 1
                 self._wrapped_idx_neg += 1
-        if self._newest_read_history_idx_pos < self._history_offset:
+        self._bookmark_idx_neg = self._UNSET_IDX_NEG
+        if self._lowest_read_history_idx_pos < self._history_n_lines_cut:
             return
         if not self._wrapped:
             return
             return
         if not self._wrapped:
             return
-        self._wrapped.insert(self._bookmark_wrapped_idx_pos, bookmark)
-        self._wrapped_idx_neg -= int(self._bookmark_wrapped_idx_neg
-                                     > self._wrapped_idx_neg)
-
-    @property
-    def _bookmark_wrapped_idx_pos(self) -> int:
-        return self._last_wrapped_idx_pos_for_hist_idx_pos(
-                self._newest_read_history_idx_pos) + 1
+        bookmark = _WrappedHistoryLine(self._BOOKMARK_HISTORY_IDX_POS,
+                                       FormattingString('-' * self._size.x))
+        lowest_read_wrapped_idx_neg\
+            = (self._wrapped_top_idx_neg
+               + self._bottom_wrapped_for_history_idx_pos(
+                   self._lowest_read_history_idx_pos))
+        if lowest_read_wrapped_idx_neg == self._BOTTOM_IDX_NEG:
+            self._bookmark_idx_neg = self._BOTTOM_IDX_NEG
+            self._wrapped += [bookmark]
+        else:
+            self._bookmark_idx_neg = lowest_read_wrapped_idx_neg
+            self._wrapped.insert(self._bookmark_idx_neg + 1, bookmark)
+        if self._bookmark_idx_neg > self._wrapped_idx_neg + 1:
+            self._wrapped_idx_neg -= 1
 
     @property
 
     @property
-    def _bookmark_wrapped_idx_neg(self) -> int:
-        return self._bookmark_wrapped_idx_pos - len(self._wrapped)
+    def _wrapped_top_idx_neg(self) -> int:
+        return -len(self._wrapped)
 
 
-    def _last_wrapped_idx_pos_for_hist_idx_pos(self, hist_idx_pos: int) -> int:
-        return [idx for idx, t in enumerate(self._wrapped)
-                if t[0] == hist_idx_pos][-1]
+    def _bottom_wrapped_for_history_idx_pos(self, hist_idx_pos: int) -> int:
+        return [idx for idx, line in enumerate(self._wrapped)
+                if line.history_idx_pos == hist_idx_pos][-1]
 
     @property
     def has_unread_highlight(self) -> bool:
 
     @property
     def has_unread_highlight(self) -> bool:
@@ -390,23 +429,23 @@ class _HistoryWidget(_ScrollableWidget):
     @property
     def n_lines_unread(self) -> int:
         'How many new lines have been logged since last focus.'
     @property
     def n_lines_unread(self) -> int:
         'How many new lines have been logged since last focus.'
-        return (self._len_full_history
-                - (self._newest_read_history_idx_pos + 1))
+        return self._len_full_history - self._lowest_read_history_idx_pos - 1
 
     def _scroll(self, up: bool = True) -> None:
         super()._scroll(up)
         if self._drawable and self._wrapped:
             if up and len(self._wrapped) > 2:
                 self._wrapped_idx_neg = max(
 
     def _scroll(self, up: bool = True) -> None:
         super()._scroll(up)
         if self._drawable and self._wrapped:
             if up and len(self._wrapped) > 2:
                 self._wrapped_idx_neg = max(
-                        -len(self._wrapped),
+                        self._wrapped_top_idx_neg,
                         self._wrapped_idx_neg - self._y_pgscroll)
             else:
                 self._wrapped_idx_neg = min(
                         self._wrapped_idx_neg - self._y_pgscroll)
             else:
                 self._wrapped_idx_neg = min(
-                        -1, self._wrapped_idx_neg + self._y_pgscroll)
-            idx = self._wrapped_idx_neg - int(
-                    self._wrapped_idx_neg == self._bookmark_wrapped_idx_neg)
-            self._history_idx_neg = (-self._len_full_history
-                                     + max(0, self._wrapped[idx][0]))
+                        self._BOTTOM_IDX_NEG,
+                        self._wrapped_idx_neg + self._y_pgscroll)
+            idx = self._wrapped_idx_neg - int(self._wrapped_idx_neg
+                                              == self._bookmark_idx_neg)
+            self._history_idx_neg = (max(0, self._wrapped[idx].history_idx_pos)
+                                     - self._len_full_history)
 
 
 class PromptWidget(_ScrollableWidget):
 
 
 class PromptWidget(_ScrollableWidget):
@@ -431,7 +470,7 @@ class PromptWidget(_ScrollableWidget):
 
     @input_buffer.setter
     def input_buffer(self, content) -> None:
 
     @input_buffer.setter
     def input_buffer(self, content) -> None:
-        self.taint()
+        self.tainted = True
         self._input_buffer_unsafe = content[:]
 
     def _draw(self) -> None:
         self._input_buffer_unsafe = content[:]
 
     def _draw(self) -> None:
@@ -442,7 +481,7 @@ class PromptWidget(_ScrollableWidget):
                                                  cursor_x=self._cursor_x,
                                                  left=self.prefix[:],
                                                  right=content)
                                                  cursor_x=self._cursor_x,
                                                  left=self.prefix[:],
                                                  right=content)
-        self._write(self._sizes.y,
+        self._write(self._size.y,
                     FormattingString(to_write[:x_cursor])
                     + FormattingString(to_write[x_cursor]).attrd('reverse')
                     + FormattingString(to_write[x_cursor + 1:]))
                     FormattingString(to_write[:x_cursor])
                     + FormattingString(to_write[x_cursor]).attrd('reverse')
                     + FormattingString(to_write[x_cursor + 1:]))
@@ -493,7 +532,7 @@ class PromptWidget(_ScrollableWidget):
             self._cursor_x += 1
         else:
             return
             self._cursor_x += 1
         else:
             return
-        self.taint()
+        self.tainted = True
 
     def _reset_buffer(self, content: str) -> None:
         self.input_buffer = content
 
     def _reset_buffer(self, content: str) -> None:
         self.input_buffer = content
@@ -507,7 +546,7 @@ class PromptWidget(_ScrollableWidget):
         return to_return
 
 
         return to_return
 
 
-class _StatusLine(_Widget):
+class _StatusLine(_WidgetAtom):
 
     def __init__(self, write: Callable, windows: list['Window'], **kwargs
                  ) -> None:
 
     def __init__(self, write: Callable, windows: list['Window'], **kwargs
                  ) -> None:
@@ -533,31 +572,33 @@ class _StatusLine(_Widget):
                 cursor_x = len(win_listing) + (len(item) // 2)
             win_listing += item
         assert isinstance(focus, Window)
                 cursor_x = len(win_listing) + (len(item) // 2)
             win_listing += item
         assert isinstance(focus, Window)
-        self._write(self._sizes.y, self._make_x_scroll(padchar='=',
-                                                       cursor_x=cursor_x,
-                                                       left=focus.title + ')',
-                                                       right=win_listing)[1])
+        self._write(self._size.y, self._make_x_scroll(padchar='=',
+                                                      cursor_x=cursor_x,
+                                                      left=focus.title + ')',
+                                                      right=win_listing)[1])
 
 
 
 
-class Window:
+class Window(_Widget):
     'Collection of widgets filling entire screen.'
     _y_status: int
     'Collection of widgets filling entire screen.'
     _y_status: int
-    _drawable = False
     prompt: PromptWidget
     _title = ':start'
     _last_today = ''
 
     prompt: PromptWidget
     _title = ':start'
     _last_today = ''
 
-    def __init__(self, idx: int, term: 'Terminal', maxlen_log: int, **kwargs
+    def __init__(self,
+                 idx: int,
+                 write: Callable[[int, str | FormattingString], None],
+                 len_to_term: Callable[[str], int],
+                 maxlen_log: int,
+                 **kwargs
                  ) -> None:
         super().__init__(**kwargs)
         self.idx = idx
                  ) -> None:
         super().__init__(**kwargs)
         self.idx = idx
-        self._term = term
+        self._write = write
         self.history = _HistoryWidget(maxlen_log=maxlen_log,
         self.history = _HistoryWidget(maxlen_log=maxlen_log,
-                                      length_to_term=self._term.length_to_term,
-                                      write=self._term.write)
-        self.prompt = self.__annotations__['prompt'](write=self._term.write)
-        if hasattr(self._term, 'size'):
-            self.set_geometry()
+                                      len_to_term=len_to_term,
+                                      write=self._write)
+        self.prompt = self.__annotations__['prompt'](write=self._write)
 
     def ensure_date(self, today: str) -> None:
         'Log date of today if it has not been logged yet.'
 
     def ensure_date(self, today: str) -> None:
         'Log date of today if it has not been logged yet.'
@@ -569,27 +610,25 @@ class Window:
         'Append msg to .history.'
         self.history.append_fmt(msg)
 
         'Append msg to .history.'
         self.history.append_fmt(msg)
 
-    def taint(self) -> None:
-        'Declare all widgets as in need of re-drawing.'
-        self.history.taint()
-        self.prompt.taint()
-
     @property
     def tainted(self) -> bool:
     @property
     def tainted(self) -> bool:
-        'If any widget in need of re-drawing.'
         return self.history.tainted or self.prompt.tainted
 
         return self.history.tainted or self.prompt.tainted
 
-    def set_geometry(self) -> None:
-        'Set geometry for widgets.'
-        self._drawable = False
-        if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH:
+    @tainted.setter
+    def tainted(self, value: bool) -> None:
+        self.history.tainted = value
+        self.prompt.tainted = value
+
+    def set_geometry(self, size: _YX) -> None:
+        self._size = _SIZE_UNSET
+        if size.y < _MIN_HEIGHT or size.x < _MIN_WIDTH:
             for widget in (self.history, self.prompt):
             for widget in (self.history, self.prompt):
-                widget.set_geometry(_YX(-1, -1))
+                widget.set_geometry(_SIZE_UNSET)
             return
             return
-        self._y_status = self._term.size.y - 2
-        self.history.set_geometry(_YX(self._y_status, self._term.size.x))
-        self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
-        self._drawable = True
+        self._size = size
+        self._y_status = self._size.y - 2
+        self.history.set_geometry(_YX(self._y_status, self._size.x))
+        self.prompt.set_geometry(_YX(self._size.y - 1, self._size.x))
 
     @property
     def title(self) -> str:
 
     @property
     def title(self) -> str:
@@ -597,24 +636,25 @@ class Window:
         return self._title
 
     def draw_tainted(self) -> None:
         return self._title
 
     def draw_tainted(self) -> None:
-        'Draw tainted widgets (or message that screen too small).'
         if self._drawable:
         if self._drawable:
-            for widget in [w for w in (self.history, self.prompt)
-                           if w.tainted]:
-                widget.draw()
-        elif self._term.size.x > 0:
-            lines = ['']
-            for i, c in enumerate('screen too small'):
-                if i > 0 and 0 == i % self._term.size.x:
-                    lines += ['']
-                lines[-1] += c
-            for y, line in enumerate(lines):
-                self._term.write(y, line)
+            for widget in (self.history, self.prompt):
+                widget.draw_tainted()
+            return
+        msg = 'screen too small'
+        if self._size.x * self._size.y >= len(msg):
+            y = 0
+            line = ''
+            for c in msg:
+                line += c
+                if len(line) == self._size.x:
+                    self._write(y, line)
+                    y += 1
+                    line = ''
 
     def cmd__paste(self) -> None:
         'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
 
     def cmd__paste(self) -> None:
         'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
-        self._term.write(0, f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
-        self.taint()
+        self._write(0, f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
+        self.tainted = True
 
 
 class TuiEvent(AffectiveEvent):
 
 
 class TuiEvent(AffectiveEvent):
@@ -643,7 +683,7 @@ class TerminalInterface(ABC):
         'Flush terminal.'
 
     @abstractmethod
         'Flush terminal.'
 
     @abstractmethod
-    def length_to_term(self, text: str) -> int:
+    def len_to_term(self, text: str) -> int:
         'How many cells of terminal screen text width will demand.'
 
     @abstractmethod
         'How many cells of terminal screen text width will demand.'
 
     @abstractmethod
@@ -658,7 +698,7 @@ class TerminalInterface(ABC):
         attrs: tuple[str, ...] = tuple()
         len_written = 0
         for attrs, part in text.parts_w_attrs():
         attrs: tuple[str, ...] = tuple()
         len_written = 0
         for attrs, part in text.parts_w_attrs():
-            len_written += self.length_to_term(part)
+            len_written += self.len_to_term(part)
             self._write_w_attrs(part, attrs)
         self._write_w_attrs(' ' * (self.size.x - len_written), tuple(attrs))
 
             self._write_w_attrs(part, attrs)
         self._write_w_attrs(' ' * (self.size.x - len_written), tuple(attrs))
 
@@ -710,30 +750,34 @@ class BaseTui(QueueMixin):
             affected_win_indices += [win.idx]
             win.ensure_date(today)
             win.log(msg)
             affected_win_indices += [win.idx]
             win.ensure_date(today)
             win.log(msg)
-        self._status_line.taint()
+        self._status_line.tainted = True
         return tuple(affected_win_indices), msg.stripped()
 
     def _new_window(self, win_class=Window, **kwargs) -> Window:
         new_idx = len(self._windows)
         return tuple(affected_win_indices), msg.stripped()
 
     def _new_window(self, win_class=Window, **kwargs) -> Window:
         new_idx = len(self._windows)
-        win = win_class(idx=new_idx, term=self._term,
-                        maxlen_log=self._MAXLEN_LOG, **kwargs)
+        win = win_class(idx=new_idx,
+                        write=self._term.write,
+                        len_to_term=self._term.len_to_term,
+                        maxlen_log=self._MAXLEN_LOG,
+                        **kwargs)
+        if hasattr(self._term, 'size'):
+            win.set_geometry(self._term.size)
         self._windows += [win]
         return win
 
     def redraw_affected(self) -> None:
         self._windows += [win]
         return win
 
     def redraw_affected(self) -> None:
-        'On focused window call .draw, then flush screen.'
+        'On focused window and status line, call .draw_tainted, then flush.'
         self.window.draw_tainted()
         self.window.draw_tainted()
-        if self._status_line.tainted:
-            self._status_line.draw()
+        self._status_line.draw_tainted()
         self._term.flush()
 
     def _set_screen(self) -> None:
         'Hide cursor, calc screen geometry into wins, call .redraw_affected.'
         self._term.set_size_hide_cursor()
         for window in self._windows:
         self._term.flush()
 
     def _set_screen(self) -> None:
         'Hide cursor, calc screen geometry into wins, call .redraw_affected.'
         self._term.set_size_hide_cursor()
         for window in self._windows:
-            window.set_geometry()
-        self._status_line.set_geometry(_YX(self._term.size.y - 2,
-                                           self._term.size.x))
+            window.set_geometry(self._term.size)
+        self._status_line.set_geometry(
+                self._term.size._replace(y=self._term.size.y - 2))
         self.redraw_affected()
 
     @property
         self.redraw_affected()
 
     @property
@@ -742,10 +786,10 @@ class BaseTui(QueueMixin):
         return self._windows[self._window_idx]
 
     def _switch_window(self, idx: int) -> None:
         return self._windows[self._window_idx]
 
     def _switch_window(self, idx: int) -> None:
-        self.window.taint()
+        self.window.tainted = True
         self.window.history.bookmark()
         self._status_line.idx_focus = self._window_idx = idx
         self.window.history.bookmark()
         self._status_line.idx_focus = self._window_idx = idx
-        self._status_line.taint()
+        self._status_line.tainted = True
 
     @property
     def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]],
 
     @property
     def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]],
@@ -828,7 +872,7 @@ class BaseTui(QueueMixin):
                                 + f'(given {len(toks)}, need {n_args_min})'
                     else:
                         alert = cmd(*toks)
                                 + f'(given {len(toks)}, need {n_args_min})'
                     else:
                         alert = cmd(*toks)
-                        self._status_line.taint()
+                        self._status_line.tainted = True
         else:
             alert = 'not prefixed by /'
         if alert:
         else:
             alert = 'not prefixed by /'
         if alert:
@@ -922,7 +966,7 @@ class Terminal(QueueMixin, TerminalInterface):
     def flush(self) -> None:
         print('', end='', flush=True)
 
     def flush(self) -> None:
         print('', end='', flush=True)
 
-    def length_to_term(self, text: str) -> int:
+    def len_to_term(self, text: str) -> int:
         # ._blessed.length can slow down things notably: only use where needed!
         return len(text) if text.isascii() else self._blessed.length(text)
 
         # ._blessed.length can slow down things notably: only use where needed!
         return len(text) if text.isascii() else self._blessed.length(text)