From: Christian Heller Date: Mon, 1 Dec 2025 18:15:16 +0000 (+0100) Subject: Reorganize FormattingString code, rename into StylingString. X-Git-Url: https://plomlompom.com/repos/%7B%7Bdb.prefix%7D%7D/%7B%7Bprefix%7D%7D/move_up?a=commitdiff_plain;ds=inline;p=ircplom Reorganize FormattingString code, rename into StylingString. --- diff --git a/src/ircplom/client_tui.py b/src/ircplom/client_tui.py index d7da1c5..323e29d 100644 --- a/src/ircplom/client_tui.py +++ b/src/ircplom/client_tui.py @@ -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) diff --git a/src/ircplom/testing.py b/src/ircplom/testing.py index e6f6d35..a701699 100644 --- a/src/ircplom/testing.py +++ b/src/ircplom/testing.py @@ -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, diff --git a/src/ircplom/tui_base.py b/src/ircplom/tui_base.py index fdb02a3..f2cee7b 100644 --- a/src/ircplom/tui_base.py +++ b/src/ircplom/tui_base.py @@ -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()))