home · contact · privacy
Reorganize FormattingString code, rename into StylingString. master
authorChristian Heller <c.heller@plomlompom.de>
Mon, 1 Dec 2025 18:15:16 +0000 (19:15 +0100)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 1 Dec 2025 18:15:16 +0000 (19:15 +0100)
src/ircplom/client_tui.py
src/ircplom/testing.py
src/ircplom/tui_base.py

index d7da1c592666c6b191d8449bad4ff80cf83705c6..323e29db2f293814c5c24d4b1d1eb01e439ed12c 100644 (file)
@@ -12,7 +12,7 @@ from ircplom.client import (
 from ircplom.db_primitives import AutoAttrMixin, Dict, DictItem
 from ircplom.irc_conn import IrcMessage, NickUserHost
 from ircplom.tui_base import (
-        BaseTui, FormattingString, PromptWidget, TuiEvent, Window,
+        BaseTui, PromptWidget, StylingString, TuiEvent, Window,
         CMD_SHORTCUTS, LOG_FMT_ATTRS, LOG_FMT_TAG_ALERT, LOG_PREFIX_DEFAULT)
 
 CMD_SHORTCUTS['disconnect'] = 'window.disconnect'
@@ -60,7 +60,7 @@ class _ClientWindow(Window, ClientQueueMixin):
     def title(self) -> str:
         return f'{self.client_id}{self._title_separator}{self._title}'
 
-    def log(self, msg: FormattingString) -> None:
+    def log(self, msg: StylingString) -> None:
         super().log(msg)
         if self._path_logs is None\
                 or msg.stripped().startswith(LOG_PREFIX_DEFAULT):  # ignore TUI
@@ -471,7 +471,7 @@ class _ClientWindowsManager:
         return ret
 
     def log(self,
-            msg: FormattingString,
+            msg: StylingString,
             scope: _LogScope,
             alert=False,
             target='',
@@ -497,13 +497,13 @@ class _ClientWindowsManager:
         if not update.results:
             return False
         for scope, result in update.results:
-            msg = FormattingString('')
+            msg = StylingString('')
             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 += FormattingString(content, raw=transform == 'RAW')
+                msg += StylingString(content, store_raw=transform == 'RAW')
             out: Optional[bool] = None
             target = ''
             if update.full_path == ('message',):
@@ -613,7 +613,7 @@ class ClientKnowingTui(Client):
         self._client_tui_trigger(
                 'log',
                 scope=_LogScope.CHAT if target else _LogScope.DEBUG,
-                msg=FormattingString(msg),
+                msg=StylingString(msg),
                 alert=alert,
                 target=target,
                 out=out)
index e6f6d35babfdfdcba510088f8fcedf4bb4d188d0..a701699d1db36ac863c1fe383b038fc020dfbd06 100644 (file)
@@ -11,7 +11,7 @@ from ircplom.events import Event, Loop, QueueMixin
 from ircplom.client import IrcConnection, IrcConnSetup
 from ircplom.client_tui import ClientKnowingTui, ClientTui
 from ircplom.irc_conn import ERR_STR_TIMEOUT, IrcConnException, IrcMessage
-from ircplom.tui_base import FormattingString, TerminalInterface, TuiEvent
+from ircplom.tui_base import StylingString, TerminalInterface, TuiEvent
 
 
 PATH_TESTS = Path('tests')
@@ -81,7 +81,7 @@ class TestTerminal(QueueMixin, TerminalInterface):
                         = None if i else (attrs, c)
             self._cursor_x += len_to_term
 
-    def write(self, y: int, text: str | FormattingString) -> None:
+    def write(self, y: int, text: str | StylingString) -> None:
         self._cursor_x = 0
         super().write(y, text)
 
@@ -487,7 +487,7 @@ class TestingClientTui(ClientTui):
         return client
 
     def log(self,
-            msg: str | FormattingString,
+            msg: str | StylingString,
             formatting_tags=tuple(),
             prefix_char: Optional[str] = None,
             escape=True,
index fdb02a3f861a0e02588b20dcda19001d8238c23a..f2cee7bf2558e6076dfd9d847f4fd0a0460af8e2 100644 (file)
@@ -13,6 +13,13 @@ from blessed import Terminal as BlessedTerminal
 # ourselves
 from ircplom.events import AffectiveEvent, Loop, QueueMixin, QuitEvent
 
+_UNSET_IDX_NEG = 0
+_UNSET_IDX_POS = -1
+
+_DEFAULT_MARKUP = ('on_black', 'bright_white')
+_MIN_HEIGHT = 4
+_MIN_WIDTH = 32
+
 LOG_PREFIX_DEFAULT = '#'
 LOG_FMT_SEP = ' '
 LOG_FMT_TAG_ALERT = 'alert'
@@ -22,15 +29,13 @@ LOG_FMT_ATTRS: dict[str, tuple[str, ...]] = {
 }
 _WRAP_INDENT = 2
 
-_MIN_HEIGHT = 4
-_MIN_WIDTH = 32
-
 _TIMEOUT_KEYPRESS_LOOP = 0.5
 _B64_PREFIX = 'b64:'
 _OSC52_PREFIX = b']52;c;'
 _PASTE_DELIMITER = '\007'
 
 _PROMPT_TEMPLATE = '> '
+
 _ELL_IN = '<…'
 _ELL_OUT = '…>'
 
@@ -50,9 +55,6 @@ _KEYBINDINGS = {
 }
 CMD_SHORTCUTS: dict[str, str] = {}
 
-_UNSET_IDX_NEG = 0
-_UNSET_IDX_POS = -1
-
 
 class _YX(NamedTuple):
     y: int
@@ -62,133 +64,124 @@ class _YX(NamedTuple):
 _SIZE_UNSET = _YX(_UNSET_IDX_POS, _UNSET_IDX_POS)
 
 
-class FormattingString:
-    'For inserting terminal formatting directives, and escaping their syntax.'
-    _BRACKET_IN = '{'
-    _BRACKET_OUT = '}'
+class StylingString:
+    'For str content with optional styling markup, e.g. "Hi {bold,red|Bob}!".'
+    _BRACE_IN = '{'
+    _BRACE_OUT = '}'
     _SEP = '|'
 
-    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 __init__(self, text: str, store_raw=False) -> None:
+        self._str = text if store_raw else ''.join([self._BRACE_IN + c
+                                                    if self._is_brace(c) else c
+                                                    for c in text])
 
     def __add__(self, other: Self) -> Self:
-        return self.__class__(str(self) + str(other), raw=True)
+        return self.__class__(str(self) + str(other), store_raw=True)
 
     def __eq__(self, other) -> bool:
-        return isinstance(other, self.__class__) and self._text == other._text
+        return isinstance(other, self.__class__) and self._str == other._str
 
     def __str__(self) -> str:
-        return self._text
+        return self._str
 
     def attrd(self, *attrs) -> Self:
-        'Wrap in formatting applying attrs.'
-        return self.__class__(raw=True, text=(self._BRACKET_IN
-                                              + ','.join(list(attrs))
-                                              + self._SEP
-                                              + self._text
-                                              + self._BRACKET_OUT))
+        'Return variant wrapped in mark-up for attrs.'
+        return self.__class__(''.join((self._BRACE_IN, ','.join(list(attrs)),
+                                       self._SEP, self._str, self._BRACE_OUT)),
+                              store_raw=True)
 
     @classmethod
-    def _is_bracket(cls, c: str) -> bool:
-        return c in {cls._BRACKET_IN, cls._BRACKET_OUT}
+    def _is_brace(cls, c: str) -> bool:
+        return c in {cls._BRACE_IN, cls._BRACE_OUT}
 
     def stripped(self) -> str:
-        'Return without formatting directives.'
+        'Return without mark-up.'
         return ''.join([text for _, text in self.parts_w_attrs()])
 
     @classmethod
-    def _parse_for_formattings(cls,
-                               c: str,
-                               in_format_def: bool,
-                               formattings: list[str],
-                               ) -> tuple[bool, bool]:
-        do_not_print = True
-        if in_format_def:
-            if cls._is_bracket(c):
-                do_not_print = False
-                in_format_def = False
-                assert not formattings[-1]
-                formattings.pop(-1)
-            elif c == cls._SEP:
-                in_format_def = False
+    def _parse_for_markups(cls, char: str, in_tag: bool, markups: list[str]
+                           ) -> tuple[bool, bool]:
+        do_print = False
+        if in_tag:
+            if cls._is_brace(char):
+                do_print = True
+                in_tag = False
+                assert not markups[-1]
+                markups.pop(-1)
+            elif char == cls._SEP:
+                in_tag = False
             else:
-                formattings[-1] += c
-        elif cls._is_bracket(c):
-            if c == cls._BRACKET_IN:
-                in_format_def = True
-                formattings += ['']
-            elif c == cls._BRACKET_OUT:
-                formattings.pop(-1)
+                markups[-1] += char
+        elif cls._is_brace(char):
+            if char == cls._BRACE_IN:
+                in_tag = True
+                markups += ['']
+            elif char == cls._BRACE_OUT:
+                markups.pop(-1)
         else:
-            do_not_print = False
-        return do_not_print, in_format_def
+            do_print = True
+        return do_print, in_tag
 
     def parts_w_attrs(self) -> tuple[tuple[tuple[str, ...], str], ...]:
         'Break into individually formatted parts, with respective attributes.'
         next_part = ''
-        formattings: list[str] = []
-        in_format_def = False
+        markups: list[str] = []
+        in_tag = False
         to_ret: list[tuple[tuple[str, ...], str]] = []
-        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:
-                    for attr in [a for a in formatting.split(',') if a]:
-                        if attr.startswith('bright_'):
+        for char in str(self.attrd()):
+            if (not in_tag) and self._is_brace(char):
+                attrs_to_pass = list(_DEFAULT_MARKUP)
+                for markup in markups:
+                    for attr in [a for a in markup.split(',') if a]:
+                        if attr.startswith('on_'):
+                            attrs_to_pass[0] = attr
+                        elif attr.startswith('bright_'):
                             attrs_to_pass[1] = attr
                         else:
                             attrs_to_pass += [attr]
                 to_ret += [(tuple(attrs_to_pass), next_part)]
                 next_part = ''
-            do_not_print, in_format_def\
-                = self._parse_for_formattings(c, in_format_def, formattings)
-            if do_not_print:
-                continue
-            next_part += c
+            do_add, in_tag = self._parse_for_markups(char, in_tag, markups)
+            if do_add:
+                next_part += char
         return tuple(to_ret)
 
     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 parts within width, each preserving its source attributes.'
+        lines: list[str] = []
+        next_part = ''
         len_flattened = 0
         idx = -1
-        formattings: list[str] = []
-        in_format_def = False
-        len_wrapped_prefix = 0
-        while idx+1 < len(self._text):
+        markups: list[str] = []
+        in_tag = False
+        len_part_prefix = 0
+        while idx+1 < len(self._str):
             idx += 1
-            c = self._text[idx]
-            next_part_w_code += c
-            do_not_print, in_format_def\
-                = self._parse_for_formattings(c, in_format_def, formattings)
-            if do_not_print:
+            char = self._str[idx]
+            next_part += char
+            do_add, in_tag = self._parse_for_markups(char, in_tag, markups)
+            if not do_add:
                 continue
-            len_flattened += len_to_term(c)
+            len_flattened += len_to_term(char)
             if len_flattened <= width:
                 continue
-            walk_back = ([-(i+1) for i in range(len(next_part_w_code)
-                                                - 1 - len_wrapped_prefix)
-                          if next_part_w_code[-(i+1)].isspace()] + [-1])[0]
-            wrapped_lines += [next_part_w_code[:walk_back]]
+            walk_back = ([-(i+1)
+                          for i in range(len(next_part) - 1 - len_part_prefix)
+                          if next_part[-(i+1)].isspace()] + [-1])[0]
+            lines += [next_part[:walk_back]]
             idx = idx + walk_back
-            next_part_w_code = ''
-            for fmt in reversed(formattings):
-                next_part_w_code += self._BRACKET_IN + fmt + self._SEP
-            next_part_w_code += ' ' * _WRAP_INDENT
-            len_wrapped_prefix = len(next_part_w_code)
+            next_part = ''.join([self._BRACE_IN + fmt + self._SEP
+                                 for fmt in reversed(markups)]
+                                + [' ' * _WRAP_INDENT])
+            len_part_prefix = len(next_part)
             len_flattened = _WRAP_INDENT
-            wrapped_lines[-1] += self._BRACKET_OUT * len(formattings)
-        if next_part_w_code.rstrip():
-            wrapped_lines += [next_part_w_code]
-        assert not formattings
-        return tuple(self.__class__(text, raw=True) for text in wrapped_lines)
+            lines[-1] += self._BRACE_OUT * len(markups)
+        if next_part.rstrip():
+            lines += [next_part]
+        return tuple(self.__class__(line, store_raw=True) for line in lines)
 
 
 class _Widget(ABC):
@@ -277,7 +270,7 @@ class _ScrollableWidget(_WidgetAtom):
 
 class _WrappedHistoryLine(NamedTuple):
     history_idx_pos: int
-    text: FormattingString
+    text: StylingString
 
 
 class _HistoryWidget(_ScrollableWidget):
@@ -306,7 +299,7 @@ class _HistoryWidget(_ScrollableWidget):
 
     def _add_wrapped_from_offset_history(self,
                                          history_idx_pos: int,
-                                         line: FormattingString
+                                         line: StylingString
                                          ) -> int:
         lines = [_WrappedHistoryLine(
                     self._history_n_lines_cut + history_idx_pos,
@@ -333,9 +326,8 @@ class _HistoryWidget(_ScrollableWidget):
             self._y_pgscroll = self._size.y // 2
             self._wrapped.clear()
             for history_idx_pos, line in enumerate(self._history):
-                self._add_wrapped_from_offset_history(history_idx_pos,
-                                                      FormattingString(
-                                                          line, raw=True))
+                self._add_wrapped_from_offset_history(
+                    history_idx_pos, StylingString(line, store_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 = (
@@ -346,8 +338,8 @@ class _HistoryWidget(_ScrollableWidget):
                                                        - self._maxlen_log))))
             self.bookmark(keep_bookmark_at)
 
-    def append_fmt(self, to_append: FormattingString) -> None:
-        'Wrap .append around FormattingString, update history dependents.'
+    def append_fmt(self, to_append: StylingString) -> None:
+        'Wrap .append around StylingString, 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)
@@ -384,14 +376,14 @@ class _HistoryWidget(_ScrollableWidget):
         while len(visible_lines) < self._size.y - bool(add_scroll_info):
             visible_lines.insert(0, _WrappedHistoryLine(
                                          self._PADDING_HISTORY_IDX_POS,
-                                         FormattingString('')))
+                                         StylingString('')))
         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}] '
             scroll_info += 'v' * (self._size.x - len(scroll_info))
             self._write(len(visible_lines),
-                        FormattingString(scroll_info).attrd('reverse'))
+                        StylingString(scroll_info).attrd('reverse'))
         self._lowest_read_history_idx_pos\
             = max(self._lowest_read_history_idx_pos,
                   visible_lines[-1].history_idx_pos)
@@ -414,7 +406,7 @@ class _HistoryWidget(_ScrollableWidget):
         if not self._wrapped:
             return
         bookmark = _WrappedHistoryLine(self._BOOKMARK_HISTORY_IDX_POS,
-                                       FormattingString('-' * self._size.x))
+                                       StylingString('-' * self._size.x))
         lowest_read_wrapped_idx_neg\
             = (self._wrapped_top_idx_neg
                + self._bottom_wrapped_for_history_idx_pos(
@@ -498,9 +490,9 @@ class PromptWidget(_ScrollableWidget):
                                                  left=self.prefix[:],
                                                  right=content)
         self._write(self._size.y,
-                    FormattingString(to_write[:x_cursor])
-                    + FormattingString(to_write[x_cursor]).attrd('reverse')
-                    + FormattingString(to_write[x_cursor + 1:]))
+                    StylingString(to_write[:x_cursor])
+                    + StylingString(to_write[x_cursor]).attrd('reverse')
+                    + StylingString(to_write[x_cursor + 1:]))
 
     def _archive_prompt(self) -> None:
         self.append(self.input_buffer)
@@ -603,7 +595,7 @@ class Window(_Widget):
 
     def __init__(self,
                  idx: int,
-                 write: Callable[[int, str | FormattingString], None],
+                 write: Callable[[int, str | StylingString], None],
                  len_to_term: Callable[[str], int],
                  maxlen_log: int,
                  **kwargs
@@ -620,9 +612,9 @@ class Window(_Widget):
         'Log date of today if it has not been logged yet.'
         if today != self._last_today:
             self._last_today = today
-            self.log(FormattingString(today))
+            self.log(StylingString(today))
 
-    def log(self, msg: FormattingString) -> None:
+    def log(self, msg: StylingString) -> None:
         'Append msg to .history.'
         self.history.append_fmt(msg)
 
@@ -706,14 +698,13 @@ class TerminalInterface(ABC):
     def _write_w_attrs(self, text: str, attrs: tuple[str, ...]) -> None:
         pass
 
-    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)
+    def write(self, y: int, text: str | StylingString) -> None:
+        'Write line of text at y, enacting StylingString directives.'
         self._cursor_y = y
         attrs: tuple[str, ...] = tuple()
         len_written = 0
-        for attrs, part in text.parts_w_attrs():
+        for attrs, part in (StylingString(text) if isinstance(text, str)
+                            else text).parts_w_attrs():
             len_written += self.len_to_term(part)
             self._write_w_attrs(part, attrs)
         self._write_w_attrs(' ' * (self.size.x - len_written), tuple(attrs))
@@ -744,19 +735,19 @@ class BaseTui(QueueMixin):
         return [self.window]
 
     def log(self,
-            msg: str | FormattingString,
+            msg: str | StylingString,
             formatting_tags=tuple(),
             prefix_char: Optional[str] = None,
             **kwargs
             ) -> Optional[tuple[tuple[int, ...], str]]:
         'Write with timestamp, prefix to what window ._log_target_wins offers.'
         if isinstance(msg, str):
-            msg = FormattingString(msg)
+            msg = StylingString(msg)
         if prefix_char is None:
             prefix_char = LOG_PREFIX_DEFAULT
         now = str(datetime.now())
         today, time = now[:10], now[11:19]
-        msg = FormattingString(f'{prefix_char}{LOG_FMT_SEP}{time} ') + msg
+        msg = StylingString(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()))