home · contact · privacy
Simplify base TUI code. master
authorChristian Heller <c.heller@plomlompom.de>
Mon, 11 Aug 2025 05:15:19 +0000 (07:15 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 11 Aug 2025 05:15:19 +0000 (07:15 +0200)
ircplom/tui_base.py

index 998a650d4cc25313ea8d79aa46d2d9ce7d15267b..415bf7b0079a8fef24db3b2123a8ff2ed7f3d202 100644 (file)
@@ -50,12 +50,13 @@ class _YX(NamedTuple):
     x: int
 
 
-class _Widget:
+class _Widget(ABC):
+    _tainted: bool = True
+    _sizes = _YX(-1, -1)
 
-    def __init__(self, **kwargs) -> None:
-        super().__init__(**kwargs)
-        self._tainted = True
-        self._drawable = False
+    @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.'
@@ -66,21 +67,23 @@ class _Widget:
         'If in need of re-drawing.'
         return self._tainted
 
-    def set_geometry(self, measurements: _YX) -> bool:
-        'Update widget\'s measurements, re-generate content where necessary.'
-        self._tainted = True
-        self._drawable = len([m for m in measurements if m < 0]) == 0
-        return self._drawable
+    def set_geometry(self, sizes: _YX) -> None:
+        'Update widget\'s sizues, re-generate content where necessary.'
+        self.taint()
+        self._sizes = sizes
 
-    def draw(self) -> bool:
+    def draw(self) -> None:
         'Print widget\'s content in shape appropriate to set geometry.'
-        if not self._drawable:
-            return False
-        self._tainted = False
-        return True
+        if self._drawable:
+            self._draw()
+            self._tainted = False
 
+    @abstractmethod
+    def _draw(self) -> None:
+        pass
 
-class _ScrollableWidget(_Widget, ABC):
+
+class _ScrollableWidget(_Widget):
     _history_idx: int
 
     def __init__(self, write: Callable[..., None], **kwargs) -> None:
@@ -94,7 +97,7 @@ class _ScrollableWidget(_Widget, ABC):
 
     @abstractmethod
     def _scroll(self, up=True) -> None:
-        self._tainted = True
+        self.taint()
 
     def cmd__scroll(self, direction: str) -> None:
         'Scroll through stored content/history.'
@@ -102,7 +105,6 @@ class _ScrollableWidget(_Widget, ABC):
 
 
 class _HistoryWidget(_ScrollableWidget):
-    _view_size: _YX
     _y_pgscroll: int
 
     def __init__(self, wrap: Callable[[str], list[str]], **kwargs) -> None:
@@ -116,70 +118,64 @@ class _HistoryWidget(_ScrollableWidget):
         self._wrapped += [(idx_original, line) for line in wrapped_lines]
         return len(wrapped_lines)
 
-    def set_geometry(self, measurements: _YX) -> bool:
-        if not super().set_geometry(measurements):
-            return False
-        self._view_size = measurements
-        self._y_pgscroll = self._view_size.y // 2
-        self._wrapped.clear()
-        self._wrapped += [(None, '')] * self._view_size.y
-        if not self._history:
-            return True
-        for idx_history, line in enumerate(self._history):
-            self._add_wrapped(idx_history, line)
-        wrapped_lines_for_history_idx = [
-                t for t in self._wrapped
-                if t[0] == len(self._history) + self._history_idx]
-        idx_their_last = self._wrapped.index(wrapped_lines_for_history_idx[-1])
-        self._wrapped_idx = idx_their_last - len(self._wrapped)
-        return True
+    def set_geometry(self, sizes: _YX) -> None:
+        super().set_geometry(sizes)
+        if self._drawable:
+            self._y_pgscroll = self._sizes.y // 2
+            self._wrapped.clear()
+            self._wrapped += [(None, '')] * self._sizes.y
+            if self._history:
+                for idx_history, line in enumerate(self._history):
+                    self._add_wrapped(idx_history, line)
+                wrapped_lines_for_history_idx = [
+                        t for t in self._wrapped
+                        if t[0] == len(self._history) + self._history_idx]
+                idx_their_last = self._wrapped.index(
+                        wrapped_lines_for_history_idx[-1])
+                self._wrapped_idx = idx_their_last - len(self._wrapped)
 
     def append(self, to_append: str) -> None:
         super().append(to_append)
-        self._tainted = True
+        self.taint()
         if self._history_idx < -1:
             self._history_idx -= 1
-        if not self._drawable:
-            return
-        n_wrapped_lines = self._add_wrapped(len(self._history) - 1, to_append)
-        if self._wrapped_idx < -1:
-            self._wrapped_idx -= n_wrapped_lines
-
-    def draw(self) -> bool:
-        if not super().draw():
-            return False
-        start_idx = self._wrapped_idx - self._view_size.y + 1
+        if self._drawable:
+            n_wrapped_lines = self._add_wrapped(len(self._history)
+                                                - 1, to_append)
+            if self._wrapped_idx < -1:
+                self._wrapped_idx -= n_wrapped_lines
+
+    def _draw(self) -> None:
+        start_idx = self._wrapped_idx - self._sizes.y + 1
         end_idx = self._wrapped_idx
         to_write = [t[1] for t in self._wrapped[start_idx:end_idx]]
         if self._wrapped_idx < -1:
             scroll_info = f'vvv [{(-1) * self._wrapped_idx}] '
-            scroll_info += 'v' * (self._view_size.x - len(scroll_info))
+            scroll_info += 'v' * (self._sizes.x - len(scroll_info))
             to_write += [scroll_info]
         else:
             to_write += [self._wrapped[self._wrapped_idx][1]]
         for i, line in enumerate(to_write):
             self._write(line, i)
-        return True
 
     def _scroll(self, up: bool = True) -> None:
         super()._scroll(up)
-        if not self._drawable:
-            return
-        if up:
-            self._wrapped_idx = max(self._view_size.y + 1 - len(self._wrapped),
-                                    self._wrapped_idx - self._y_pgscroll)
-        else:
-            self._wrapped_idx = min(-1,
-                                    self._wrapped_idx + self._y_pgscroll)
-        history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0]
-        if history_idx_to_wrapped_idx is not None:
-            self._history_idx = history_idx_to_wrapped_idx - len(self._history)
+        if self._drawable:
+            if up:
+                self._wrapped_idx = max(
+                        self._sizes.y + 1 - len(self._wrapped),
+                        self._wrapped_idx - self._y_pgscroll)
+            else:
+                self._wrapped_idx = min(
+                        -1, self._wrapped_idx + self._y_pgscroll)
+            history_idx_to_wrapped_idx = self._wrapped[self._wrapped_idx][0]
+            if history_idx_to_wrapped_idx is not None:
+                self._history_idx = history_idx_to_wrapped_idx\
+                        - len(self._history)
 
 
 class PromptWidget(_ScrollableWidget):
     'Manages/displays keyboard input field.'
-    _y: int
-    _width: int
     _history_idx: int = 0
     _input_buffer_unsafe: str
     _cursor_x: int
@@ -199,39 +195,31 @@ class PromptWidget(_ScrollableWidget):
 
     @_input_buffer.setter
     def _input_buffer(self, content) -> None:
-        self._tainted = True
+        self.taint()
         self._input_buffer_unsafe = content
 
-    def set_geometry(self, measurements: _YX) -> bool:
-        if not super().set_geometry(measurements):
-            return False
-        self._y, self._width = measurements
-        return True
-
-    def draw(self) -> bool:
-        if not super().draw():
-            return False
+    def _draw(self) -> None:
         prefix = self.prefix[:]
         content = self._input_buffer
         if self._cursor_x == len(self._input_buffer):
             content += ' '
-        half_width = (self._width - len(prefix)) // 2
+        half_width = (self._sizes.x - len(prefix)) // 2
         offset = 0
-        if len(prefix) + len(content) > self._width\
+        if len(prefix) + len(content) > self._sizes.x\
                 and self._cursor_x > half_width:
             prefix += _PROMPT_ELL_IN
-            offset = min(len(prefix) + len(content) - self._width,
+            offset = min(len(prefix) + len(content) - self._sizes.x,
                          self._cursor_x - half_width + len(_PROMPT_ELL_IN))
         cursor_x_to_write = len(prefix) + self._cursor_x - offset
         to_write = f'{prefix}{content[offset:]}'
-        if len(to_write) > self._width:
-            to_write = (to_write[:self._width - len(_PROMPT_ELL_OUT)]
+        if len(to_write) > self._sizes.x:
+            to_write = (to_write[:self._sizes.x-len(_PROMPT_ELL_OUT)]
                         + _PROMPT_ELL_OUT)
-        self._write(to_write[:cursor_x_to_write], self._y, padding=False)
+        self._write(to_write[:cursor_x_to_write], self._sizes.y,
+                    padding=False)
         self._write(to_write[cursor_x_to_write], attribute='reverse',
                     padding=False)
         self._write(to_write[cursor_x_to_write + 1:])
-        return True
 
     def _archive_prompt(self) -> None:
         self.append(self._input_buffer)
@@ -280,7 +268,7 @@ class PromptWidget(_ScrollableWidget):
             self._cursor_x += 1
         else:
             return
-        self._tainted = True
+        self.taint()
 
     def _reset_buffer(self, content: str) -> None:
         self._input_buffer = content
@@ -295,32 +283,22 @@ class PromptWidget(_ScrollableWidget):
 
 
 class _StatusLine(_Widget):
-    _y: int
-    _width: int
 
     def __init__(self, write: Callable, status_title: str, **kwargs) -> None:
         super().__init__(**kwargs)
         self._write = write
         self._status_title = status_title
 
-    def set_geometry(self, measurements: _YX) -> bool:
-        if not super().set_geometry(measurements):
-            return False
-        self._y, self._width = measurements
-        return True
-
-    def draw(self) -> bool:
-        if not super().draw():
-            return False
+    def _draw(self) -> None:
         title_box = f'{self._status_title}]'
-        status_line = title_box + '=' * (self._width - len(title_box))
-        self._write(status_line, self._y)
-        return True
+        status_line = title_box + '=' * (self._sizes.x - len(title_box))
+        self._write(status_line, self._sizes.y)
 
 
-class Window(_Widget):
-    'Widget filling entire screen with sub-widgets like .prompt, .history.'
+class Window:
+    'Collection of widgets filling entire screen.'
     _y_status: int
+    _drawable = False
     prompt: PromptWidget
 
     def __init__(self, idx: int, term: 'Terminal', **kwargs) -> None:
@@ -336,29 +314,26 @@ class Window(_Widget):
             self.set_geometry()
 
     def taint(self) -> None:
-        super().taint()
         self.history.taint()
         self._status_line.taint()
         self.prompt.taint()
 
     @property
     def tainted(self) -> bool:
-        return self._tainted or self.history.tainted or self.prompt.tainted
+        return (self._status_line.tainted or self.history.tainted
+                or self.prompt.tainted)
 
-    def set_geometry(self, _=None) -> bool:
-        assert _ is None
+    def set_geometry(self) -> None:
+        self._drawable = False
         if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH:
-            bad_yx = _YX(-1, -1)
-            super().set_geometry(bad_yx)
-            self.history.set_geometry(bad_yx)
-            self.prompt.set_geometry(bad_yx)
-            return False
-        super().set_geometry(_YX(0, 0))
+            for widget in (self.history, self._status_line, self.prompt):
+                widget.set_geometry(_YX(-1, -1))
+            return
         self._y_status = self._term.size.y - 2
         self.history.set_geometry(_YX(self._y_status, self._term.size.x))
         self._status_line.set_geometry(_YX(self._y_status, self._term.size.x))
         self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
-        return True
+        self._drawable = True
 
     @property
     def _title(self) -> str:
@@ -369,28 +344,26 @@ class Window(_Widget):
         'Window title to display in status line.'
         return f'{self.idx}) {self._title}'
 
-    def draw(self) -> bool:
-        if not super().draw():
-            if 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(line, y)
-            return False
-        for widget in [
-                w for w in (self.history, self.prompt, self._status_line)
-                if w.tainted]:
-            widget.draw()
-        return True
+    def draw_tainted(self) -> None:
+        if self._drawable:
+            for widget in [
+                    w for w in (self.history, self.prompt, self._status_line)
+                    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(line, y)
 
     def cmd__paste(self) -> None:
         'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
         self._term.write(f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}',
                          self._y_status)
-        self._tainted = True
+        self.taint()
 
 
 class TuiEvent(AffectiveEvent):
@@ -433,7 +406,7 @@ class BaseTui(QueueMixin):
 
     def redraw_affected(self) -> None:
         'On focused window call .draw, then flush screen.'
-        self.window.draw()
+        self.window.draw_tainted()
         self._term.flush()
 
     def _set_screen(self) -> None:
@@ -441,7 +414,6 @@ class BaseTui(QueueMixin):
         self._term.calc_geometry()
         for window in self._windows:
             window.set_geometry()
-        self._y_status = self._term.size.y - 2
         self.redraw_affected()
 
     @property