home · contact · privacy
Use FormattingString for type checks and to replace external escaping hints.
authorChristian Heller <c.heller@plomlompom.de>
Tue, 28 Oct 2025 21:05:38 +0000 (22:05 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Tue, 28 Oct 2025 21:05:38 +0000 (22:05 +0100)
src/ircplom/client_tui.py
src/ircplom/testing.py
src/ircplom/tui_base.py

index eca0d321457d4b7245b288f17d645bc67396753a..eb02f87b749adc7a0412c1895bc9f740013f2374 100644 (file)
@@ -59,7 +59,7 @@ class _ClientWindow(Window, ClientQueueMixin):
     def title(self) -> str:
         return f'{self.client_id}{self._title_separator}{self._title}'
 
-    def log(self, msg: str) -> None:
+    def log(self, msg: FormattingString) -> None:
         super().log(msg)
         if self._path_logs is None:
             return
@@ -81,7 +81,7 @@ class _ClientWindow(Window, ClientQueueMixin):
             chat_path.mkdir(parents=True)
         with chat_path.joinpath(f'{self._last_today}.txt'
                                 ).open('a', encoding='utf8') as f:
-            f.write(FormattingString(msg).stripped() + '\n')
+            f.write(msg.stripped() + '\n')
 
     def _send_msg(self, verb: str, params: tuple[str, ...]) -> None:
         self._client_trigger('send_w_params_tuple', verb=verb, params=params)
@@ -433,12 +433,11 @@ class _ClientWindowsManager:
         return ret
 
     def log(self,
-            msg: str,
+            msg: FormattingString,
             scope: _LogScope,
             alert=False,
             target='',
-            out: Optional[bool] = None,
-            escape=True
+            out: Optional[bool] = None
             ) -> None:
         'From parsing scope, kwargs, build prefix before sending to logger.'
         formatting_tags = [LOG_FMT_TAG_ALERT] if alert else []
@@ -447,12 +446,11 @@ class _ClientWindowsManager:
             break
         kwargs = {'target': target} if target else {}
         self._tui_log(
-                msg,
+                msg=msg,
                 formatting_tags=tuple(formatting_tags),
                 scope=scope,
                 prefix_char=(_LOG_PREFIX_SERVER if out is None
                              else (_LOG_PREFIX_OUT if out else LOG_PREFIX_IN)),
-                escape=escape,
                 **kwargs)
 
     def update_db(self, update: _Update) -> bool:
@@ -461,14 +459,13 @@ class _ClientWindowsManager:
         if not update.results:
             return False
         for scope, result in update.results:
-            msg = ''
+            msg = FormattingString('')
             for item in result:
                 transform, content = item.split(':', maxsplit=1)
                 if transform in {'NICK', 'NUH'}:
                     nuh = self.db.users[content]
                     content = str(nuh) if transform == 'NUH' else nuh.nick
-                msg += (content if transform == 'RAW'
-                        else FormattingString(content).escape())
+                msg += FormattingString(content, raw=transform == 'RAW')
             out: Optional[bool] = None
             target = ''
             if update.full_path == ('message',):
@@ -478,7 +475,7 @@ class _ClientWindowsManager:
             elif scope in {_LogScope.CHAT, _LogScope.USER,
                            _LogScope.USER_NO_CHANNELS}:
                 target = update.full_path[1]
-            self.log(msg, scope=scope, target=target, out=out, escape=False)
+            self.log(msg, scope=scope, target=target, out=out)
         for win in [w for w in self.windows if isinstance(w, _ChatWindow)]:
             win.set_prompt_prefix()
         return bool([w for w in self.windows if w.tainted])
@@ -551,7 +548,7 @@ class ClientKnowingTui(Client):
         self._client_tui_trigger(
                 'log',
                 scope=_LogScope.CHAT if target else _LogScope.DEBUG,
-                msg=msg,
+                msg=FormattingString(msg),
                 alert=alert,
                 target=target,
                 out=out)
index 6ddb1c26cb4642ae0975f46d6ad2e017411e65af..7232ad529d9c98962913f628bb3cb43d8e32e083 100644 (file)
@@ -80,7 +80,7 @@ class TestTerminal(QueueMixin, TerminalInterface):
                         = None if i else (attrs, c)
             self._cursor_x += len_to_term
 
-    def write(self, y: int, text: str) -> None:
+    def write(self, y: int, text: str | FormattingString) -> None:
         self._cursor_x = 0
         super().write(y, text)
 
@@ -436,7 +436,7 @@ class TestingClientTui(ClientTui):
         return client
 
     def log(self,
-            msg: str,
+            msg: str | FormattingString,
             formatting_tags=tuple(),
             prefix_char: Optional[str] = None,
             escape=True,
@@ -445,8 +445,7 @@ class TestingClientTui(ClientTui):
         def test_after(cmd_name: str, args: tuple[str, ...], ret) -> None:
             assert cmd_name == _MARK_LOG, f'WANTED {_MARK_LOG}, GOT {cmd_name}'
             win_ids, logged = ret
-            fmt, time_str, msg_sans_time\
-                = FormattingString(logged).stripped().split(' ', maxsplit=2)
+            fmt, time_str, msg_sans_time = logged.split(' ', maxsplit=2)
             msg_sans_time = fmt + ' ' + msg_sans_time
             assert len(time_str) == 8
             for c in time_str[:2] + time_str[3:5] + time_str[6:]:
index 208220fb20dd80ce11c43a87c7d16f15249eaf66..68decea763053df70406e42f807463e7c2234f26 100644 (file)
@@ -6,7 +6,7 @@ from contextlib import contextmanager
 from datetime import datetime
 from inspect import _empty as inspect_empty, signature, stack
 from signal import SIGWINCH, signal
-from typing import (Callable, Generator, Iterator, NamedTuple, Optional,
+from typing import (Callable, Generator, Iterator, NamedTuple, Optional, Self,
                     Sequence)
 # requirements.txt
 from blessed import Terminal as BlessedTerminal
@@ -57,29 +57,33 @@ class FormattingString:
     _BRACKET_OUT = '}'
     _SEP = '|'
 
-    def __init__(self, text: str) -> None:
-        self._text = text
+    def __init__(self, text: str, raw=False) -> None:
+        self._text: str = (
+                text if raw
+                else ''.join([(self._BRACKET_IN + c if self._is_bracket(c)
+                               else c) for c in text]))
+
+    def __add__(self, other: Self) -> Self:
+        return self.__class__(str(self) + str(other), raw=True)
+
+    def __eq__(self, other) -> bool:
+        return isinstance(other, self.__class__) and self._text == other._text
 
     def __str__(self) -> str:
         return self._text
 
-    def attrd(self, *attrs) -> str:
+    def attrd(self, *attrs) -> Self:
         'Wrap in formatting applying attrs.'
-        return (self._BRACKET_IN
-                + ','.join(list(attrs))
-                + self._SEP
-                + self._text
-                + self._BRACKET_OUT)
+        return self.__class__(raw=True, text=(self._BRACKET_IN
+                                              + ','.join(list(attrs))
+                                              + self._SEP
+                                              + self._text
+                                              + self._BRACKET_OUT))
 
     @classmethod
     def _is_bracket(cls, c: str) -> bool:
         return c in {cls._BRACKET_IN, cls._BRACKET_OUT}
 
-    def escape(self) -> str:
-        'Preserve formatting characters by prefixing them with ._BRACKET_IN.'
-        return ''.join([(self._BRACKET_IN + c if self._is_bracket(c)
-                         else c) for c in self._text])
-
     def stripped(self) -> str:
         'Return without formatting directives.'
         return ''.join([text for _, text in self.parts_w_attrs()])
@@ -117,7 +121,7 @@ class FormattingString:
         formattings: list[str] = []
         in_format_def = False
         to_ret: list[tuple[tuple[str, ...], str]] = []
-        for c in self.attrd():
+        for c in str(self.attrd()):
             if (not in_format_def) and self._is_bracket(c):
                 attrs_to_pass = ['on_black', 'bright_white']
                 for formatting in formattings:
@@ -135,7 +139,7 @@ class FormattingString:
             next_part += c
         return tuple(to_ret)
 
-    def wrap(self, width: int, length_to_term: Callable) -> tuple[str, ...]:
+    def wrap(self, width: int, length_to_term: Callable) -> tuple[Self, ...]:
         'Break into sequence respecting width, preserving attributes per part.'
         wrapped_lines: list[str] = []
         next_part_w_code = ''
@@ -170,7 +174,7 @@ class FormattingString:
         if next_part_w_code.rstrip():
             wrapped_lines += [next_part_w_code]
         assert not formattings
-        return tuple(wrapped_lines)
+        return tuple(self.__class__(text, raw=True) for text in wrapped_lines)
 
 
 class _YX(NamedTuple):
@@ -249,14 +253,14 @@ class _HistoryWidget(_ScrollableWidget):
         super().__init__(**kwargs)
         self._maxlen_log = maxlen_log
         self._length_to_term = length_to_term
-        self._wrapped: list[tuple[int, str]] = []
+        self._wrapped: list[tuple[int, FormattingString]] = []
         self._newest_read_history_idx_pos = self._UNSET_IDX_POS
         self._history_offset = 0
         self._history_idx_neg = self._UNSET_IDX_NEG
 
-    def _add_wrapped(self, history_idx_pos: int, line: str) -> int:
-        lines = FormattingString(line).wrap(self._sizes.x,
-                                            self._length_to_term)
+    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]
         return len(lines)
@@ -271,7 +275,8 @@ class _HistoryWidget(_ScrollableWidget):
             self._y_pgscroll = self._sizes.y // 2
             self._wrapped.clear()
             for history_idx_pos, line in enumerate(self._history):
-                self._add_wrapped(history_idx_pos, line)
+                self._add_wrapped(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 = (
@@ -282,8 +287,9 @@ class _HistoryWidget(_ScrollableWidget):
                                                        - self._maxlen_log))))
             self.bookmark()
 
-    def append(self, to_append: str) -> None:
-        super().append(to_append)
+    def append_fmt(self, to_append: FormattingString) -> None:
+        'Wrap .append around FormattingString, update history dependents.'
+        super().append(str(to_append))
         self.taint()
         if not self._UNSET_IDX_NEG != self._history_idx_neg >= -1:
             self._history_idx_neg -= 1
@@ -312,7 +318,8 @@ class _HistoryWidget(_ScrollableWidget):
         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, ''))
+            wrapped.insert(0, (self._PADDING_HISTORY_IDX_POS,
+                               FormattingString('')))
         for idx, line in enumerate([lt[1] for lt in wrapped]):
             self._write(idx, line)
         if add_scroll_info:
@@ -325,7 +332,8 @@ class _HistoryWidget(_ScrollableWidget):
 
     def bookmark(self) -> None:
         'Store next idx to what most recent line we have (been) scrolled.'
-        bookmark = (self._BOOKMARK_HISTORY_IDX_POS, '-' * self._sizes.x)
+        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)
@@ -426,10 +434,10 @@ class PromptWidget(_ScrollableWidget):
         if len(to_write) < self._sizes.x:
             to_write += ' '
         self._write(self._sizes.y,
-                    to_write[:cursor_x_to_write]
+                    FormattingString(to_write[:cursor_x_to_write])
                     + FormattingString(to_write[cursor_x_to_write]
                                        ).attrd('reverse')
-                    + to_write[cursor_x_to_write + 1:])
+                    + FormattingString(to_write[cursor_x_to_write + 1:]))
 
     def _archive_prompt(self) -> None:
         self.append(self.input_buffer)
@@ -544,11 +552,11 @@ class Window:
         'Log date of today if it has not been logged yet.'
         if today != self._last_today:
             self._last_today = today
-            self.log(today)
+            self.log(FormattingString(today))
 
-    def log(self, msg: str) -> None:
+    def log(self, msg: FormattingString) -> None:
         'Append msg to .history.'
-        self.history.append(msg)
+        self.history.append_fmt(msg)
 
     def taint(self) -> None:
         'Declare all widgets as in need of re-drawing.'
@@ -631,12 +639,14 @@ class TerminalInterface(ABC):
     def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
         pass
 
-    def write(self, y: int, text: str) -> None:
+    def write(self, y: int, text: str | FormattingString) -> None:
         'Write line of text at y, enacting FormattingString directives.'
+        if isinstance(text, str):
+            text = FormattingString(text)
         self._cursor_y = y
         attrs: tuple[str, ...] = tuple()
         len_written = 0
-        for attrs, part in FormattingString(text).parts_w_attrs():
+        for attrs, part in text.parts_w_attrs():
             len_written += self.length_to_term(part)
             self._write_w_attrs(part, attrs)
         self._write_w_attrs(' ' * (self.size.x - len_written), tuple(attrs))
@@ -666,31 +676,30 @@ class BaseTui(QueueMixin):
         return [self.window]
 
     def log(self,
-            msg: str,
+            msg: str | FormattingString,
             formatting_tags=tuple(),
             prefix_char: Optional[str] = None,
-            escape=True,
             **kwargs
             ) -> Optional[tuple[tuple[int, ...], str]]:
         'Write with timestamp, prefix to what window ._log_target_wins offers.'
-        if escape:
-            msg = FormattingString(msg).escape()
+        if isinstance(msg, str):
+            msg = FormattingString(msg)
         if prefix_char is None:
             prefix_char = _LOG_PREFIX_DEFAULT
         now = str(datetime.now())
         today, time = now[:10], now[11:19]
-        msg = f'{prefix_char}{LOG_FMT_SEP}{time} {msg}'
+        msg = FormattingString(f'{prefix_char}{LOG_FMT_SEP}{time} ') + msg
         msg_attrs: list[str] = list(LOG_FMT_ATTRS[prefix_char])
         for tag in formatting_tags:
             msg_attrs += list(LOG_FMT_ATTRS.get(tag, tuple()))
-        msg = FormattingString(msg).attrd(*msg_attrs)
+        msg = msg.attrd(*msg_attrs)
         affected_win_indices = []
         for win in self._log_target_wins(**kwargs):
             affected_win_indices += [win.idx]
             win.ensure_date(today)
             win.log(msg)
         self._status_line.taint()
-        return tuple(affected_win_indices), msg
+        return tuple(affected_win_indices), msg.stripped()
 
     def _new_window(self, win_class=Window, **kwargs) -> Window:
         new_idx = len(self._windows)