home · contact · privacy
Reorganize how TUI determines what parts of screen to redraw. master
authorChristian Heller <c.heller@plomlompom.de>
Sun, 15 Jun 2025 18:22:44 +0000 (20:22 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Sun, 15 Jun 2025 18:22:44 +0000 (20:22 +0200)
ircplom/tui.py

index f36ff511efaaecc03d0cac9e80a8594dcb38e99a..61589b8464201da28ff3c35e02d9c0fbce547c32 100644 (file)
@@ -68,13 +68,19 @@ class _YX(NamedTuple):
 class _Widget(ABC):
     'Defines most basic TUI object API.'
 
+    @abstractmethod
+    def __init__(self, *args, **kwargs) -> None:
+        self.tainted = True
+
     @abstractmethod
     def set_geometry(self, measurements: _YX) -> None:
         'Update widget\'s measurements, re-generate content where necessary.'
+        self.tainted = True
 
     @abstractmethod
     def draw(self) -> None:
         'Print widget\'s content in shape appropriate to set geometry.'
+        self.tainted = False
 
 
 class _ScrollableWidget(_Widget, ABC):
@@ -92,12 +98,11 @@ class _ScrollableWidget(_Widget, ABC):
 
     @abstractmethod
     def _scroll(self, up=True) -> None:
-        pass
+        self.tainted = True
 
     def cmd__scroll(self, direction: str) -> None:
         'Scroll through stored content/history.'
         self._scroll(up=direction == 'up')
-        self.draw()
 
 
 class _LogWidget(_ScrollableWidget):
@@ -118,6 +123,7 @@ class _LogWidget(_ScrollableWidget):
         return len(wrapped_lines)
 
     def set_geometry(self, measurements: _YX) -> None:
+        super().set_geometry(measurements)
         self._view_size = measurements
         self._y_pgscroll = self._view_size.y // 2
         self._wrapped.clear()
@@ -138,8 +144,10 @@ class _LogWidget(_ScrollableWidget):
         if self._wrapped_idx < -1:
             self._history_idx -= 1
             self._wrapped_idx -= n_wrapped_lines
+        self.tainted = True
 
     def draw(self) -> None:
+        super().draw()
         start_idx = self._wrapped_idx - self._view_size.y + 1
         end_idx = self._wrapped_idx
         to_write = [t[1] for t in self._wrapped[start_idx:end_idx]]
@@ -153,6 +161,7 @@ class _LogWidget(_ScrollableWidget):
             self._write(line, i)
 
     def _scroll(self, up: bool = True) -> None:
+        super()._scroll(up)
         if up:
             self._wrapped_idx = max(self._view_size.y + 1 - len(self._wrapped),
                                     self._wrapped_idx - self._y_pgscroll)
@@ -170,19 +179,30 @@ class _PromptWidget(_ScrollableWidget):
     _width: int
     _prompt: str = _PROMPT_TEMPLATE
     _history_idx = 0
-    _input_buffer: str
+    _input_buffer_unsafe: str
     _cursor_x: int
 
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         self._reset_buffer('')
 
+    @property
+    def _input_buffer(self) -> str:
+        return self._input_buffer_unsafe[:]
+
+    @_input_buffer.setter
+    def _input_buffer(self, content) -> None:
+        self.tainted = True
+        self._input_buffer_unsafe = content
+
     def set_geometry(self, measurements: _YX) -> None:
+        super().set_geometry(measurements)
         self._y, self._width = measurements
 
     def draw(self) -> None:
+        super().draw()
         prefix = self._prompt[:]
-        content = self._input_buffer[:]
+        content = self._input_buffer
         if self._cursor_x == len(self._input_buffer):
             content += ' '
         half_width = (self._width - len(prefix)) // 2
@@ -203,10 +223,11 @@ class _PromptWidget(_ScrollableWidget):
         self._write(to_write[cursor_x_to_write + 1:])
 
     def _archive_prompt(self) -> None:
-        self.append(self._input_buffer[:])
+        self.append(self._input_buffer)
         self._reset_buffer('')
 
     def _scroll(self, up: bool = True) -> None:
+        super()._scroll(up)
         if up and -(self._history_idx) < len(self._history):
             if self._history_idx == 0 and self._input_buffer:
                 self._archive_prompt()
@@ -230,7 +251,6 @@ class _PromptWidget(_ScrollableWidget):
                               + to_append
                               + self._input_buffer[self._cursor_x - 1:])
         self._history_idx = 0
-        self.draw()
 
     def cmd__backspace(self) -> None:
         'Truncate current content by one character, if possible.'
@@ -239,7 +259,6 @@ class _PromptWidget(_ScrollableWidget):
             self._input_buffer = (self._input_buffer[:self._cursor_x]
                                   + self._input_buffer[self._cursor_x + 1:])
             self._history_idx = 0
-            self.draw()
 
     def cmd__move_cursor(self, direction: str) -> None:
         'Move cursor one space into direction ("left" or "right") if possible.'
@@ -250,7 +269,7 @@ class _PromptWidget(_ScrollableWidget):
             self._cursor_x += 1
         else:
             return
-        self.draw()
+        self.tainted = True
 
     def _reset_buffer(self, content: str) -> None:
         self._input_buffer = content
@@ -261,7 +280,6 @@ class _PromptWidget(_ScrollableWidget):
         to_return = self._input_buffer[:]
         if to_return:
             self._archive_prompt()
-            self.draw()
         return to_return
 
 
@@ -274,6 +292,7 @@ class _ConnectionPromptWidget(_PromptWidget):
                       nick: Optional[str] = None
                       ) -> None:
         'Update nickname-relevant knowledge to go into prompt string.'
+        self.tainted = True
         self._prompt = ''
         if nick:
             self._nickname = nick
@@ -298,12 +317,14 @@ class _Window(_Widget):
             self.set_geometry()
 
     def set_geometry(self, _=None) -> None:
+        super().set_geometry(_)
         assert _ is None
         self._y_status = self._term.size.y - 2
         self.log.set_geometry(_YX(self._y_status, self._term.size.x))
         self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
 
     def draw(self) -> None:
+        super().draw()
         idx_box = f'[{self.idx}]'
         status_line = idx_box + '=' * (self._term.size.x - len(idx_box))
         self._term.clear()
@@ -315,7 +336,15 @@ class _Window(_Widget):
         'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
         self._term.write(f'\033{_OSC52_PREFIX}?{_PASTE_DELIMITER}',
                          self._y_status)
-        self.draw()
+        self.tainted = True
+
+    def draw_tainted(self) -> None:
+        'Draw tainted parts of self.'
+        if self.tainted:
+            self.draw()
+            return
+        for widget in [w for w in (self.log, self.prompt) if w.tainted]:
+            widget.draw()
 
 
 class _ConnectionWindow(_Window, BroadcastConnMixin):
@@ -391,10 +420,8 @@ class _TuiLoop(Loop, BroadcastMixin):
             self._term.calc_geometry()
             for window in self._windows:
                 window.set_geometry()
-            self.window.draw()
         elif isinstance(event, _LogEvent):
             self.window.log.append(event.payload)
-            self.window.log.draw()
         elif isinstance(event, _TuiCmdEvent):
             cmd = self._cmd_name_to_cmd(event.payload[0])
             assert cmd is not None
@@ -418,13 +445,9 @@ class _TuiLoop(Loop, BroadcastMixin):
                                               nick=event.payload)
             elif isinstance(event, DisconnectedEvent):
                 conn_win.prompt.update_prompt(nick_confirmed=False)
-            if conn_win == self.window:
-                if isinstance(event, LogConnEvent):
-                    self.window.log.draw()
-                if isinstance(event, (NickSetEvent, DisconnectedEvent)):
-                    self.window.prompt.draw()
         else:
             return True
+        self.window.draw_tainted()
         self._term.flush()
         return True