next_part += c
return tuple(to_ret)
- def wrap(self, width: int, length_to_term: Callable) -> tuple[Self, ...]:
+ 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 = ''
= self._parse_for_formattings(c, in_format_def, formattings)
if do_not_print:
continue
- len_flattened += length_to_term(c)
+ len_flattened += len_to_term(c)
if len_flattened <= width:
continue
walk_back = ([-(i+1) for i in range(len(next_part_w_code)
x: int
-class _Widget(ABC):
- _tainted: bool = True
- _sizes = _YX(-1, -1)
+_SIZE_UNSET = _YX(-1, -1)
- @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.'
- self._tainted = True
+class _Widget(ABC):
+ _size = _SIZE_UNSET
@property
def tainted(self) -> bool:
'If in need of re-drawing.'
return self._tainted
- def set_geometry(self, sizes: _YX) -> None:
- 'Update widget\'s sizues, re-generate content where necessary.'
- self.taint()
- self._sizes = sizes
+ @tainted.setter
+ def tainted(self, value: bool) -> None:
+ 'Declare need of re-drawing.'
+ self._tainted = value
+
+ @property
+ def _drawable(self) -> bool:
+ return len([length for length in self._size if length < 1]) == 0
+
+ @abstractmethod
+ def set_geometry(self, size: _YX) -> None:
+ 'Update widget\'s sizes, re-generate content where necessary.'
+
+ @abstractmethod
+ def draw_tainted(self) -> None:
+ "If .tainted, print widget's content in .set_geometry()-defined shape."
+
+
+class _WidgetAtom(_Widget, ABC):
+ _tainted: bool = True
def _make_x_scroll(self, padchar: str, cursor_x: int, left: str, right: str
) -> tuple[int, str]:
'Build line of static left, right scrolled with cursor_x if too large.'
to_write = left[:]
offset = 0
- width_gap = self._sizes.x - len(left + right)
+ width_gap = self._size.x - len(left + right)
if width_gap < 0:
- half_width = (self._sizes.x - len(left)) // 2
+ half_width = (self._size.x - len(left)) // 2
if cursor_x > half_width:
to_write += _ELL_IN
offset = len(_ELL_IN) + min(-width_gap, cursor_x - half_width)
to_write += right[offset:]
- if len(to_write) > self._sizes.x:
- to_write = to_write[:self._sizes.x - len(_ELL_OUT)] + _ELL_OUT
+ if len(to_write) > self._size.x:
+ to_write = to_write[:self._size.x - len(_ELL_OUT)] + _ELL_OUT
else:
to_write += (right if width_gap == 0
else ((padchar * width_gap + right) if padchar == '='
else (right + padchar * width_gap)))
return len(left) - offset + cursor_x, to_write
- def draw(self) -> None:
- 'Print widget\'s content in shape appropriate to set geometry.'
- if self._drawable:
+ def set_geometry(self, size: _YX) -> None:
+ self.tainted = True
+ self._size = size
+
+ def draw_tainted(self) -> None:
+ if self._drawable and self.tainted:
self._draw()
- self._tainted = False
+ self.tainted = False
@abstractmethod
def _draw(self) -> None:
pass
-class _ScrollableWidget(_Widget):
+class _ScrollableWidget(_WidgetAtom):
_history_idx_neg: int
def __init__(self, write: Callable[..., None], **kwargs) -> None:
@abstractmethod
def _scroll(self, up=True) -> None:
- self.taint()
+ self.tainted = True
def cmd__scroll(self, direction: str) -> None:
'Scroll through stored content/history.'
self._scroll(up=direction == 'up')
+class _WrappedHistoryLine(NamedTuple):
+ history_idx_pos: int
+ text: FormattingString
+
+
class _HistoryWidget(_ScrollableWidget):
- _wrapped_idx_neg: int
+ _wrapped_idx_neg: int # negative index of lowest visible wrapped line
_y_pgscroll: int
_UNSET_IDX_NEG: int = 0
_UNSET_IDX_POS: int = -1
+ _BOTTOM_IDX_NEG: int = -1
_COMPARAND_HISTORY_IDX_POS: int = -2
_BOOKMARK_HISTORY_IDX_POS: int = -3
_PADDING_HISTORY_IDX_POS: int = -4
def __init__(self,
maxlen_log: int,
- length_to_term: Callable[[str], int],
+ len_to_term: Callable[[str], int],
**kwargs
) -> None:
super().__init__(**kwargs)
self._maxlen_log = maxlen_log
- self._length_to_term = length_to_term
- self._wrapped: list[tuple[int, FormattingString]] = []
- self._newest_read_history_idx_pos = self._UNSET_IDX_POS
- self._history_offset = 0
+ self._len_to_term = len_to_term
+ self._wrapped: list[_WrappedHistoryLine] = []
+ self._lowest_read_history_idx_pos = self._UNSET_IDX_POS
+ self._bookmark_idx_neg = self._UNSET_IDX_NEG
+ self._history_n_lines_cut = 0
self._history_idx_neg = self._UNSET_IDX_NEG
- 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]
+ def _add_wrapped_from_offset_history(self,
+ history_idx_pos: int,
+ line: FormattingString
+ ) -> int:
+ lines = [_WrappedHistoryLine(
+ self._history_n_lines_cut + history_idx_pos,
+ text)
+ for text in line.wrap(self._size.x, self._len_to_term)]
+ self._wrapped += lines
return len(lines)
@property
def _len_full_history(self) -> int:
- return self._history_offset + len(self._history)
+ return self._history_n_lines_cut + len(self._history)
- def set_geometry(self, sizes: _YX) -> None:
- super().set_geometry(sizes)
+ def set_geometry(self, size: _YX) -> None:
+ super().set_geometry(size)
if self._drawable:
- self._y_pgscroll = self._sizes.y // 2
+ self._y_pgscroll = self._size.y // 2
self._wrapped.clear()
for history_idx_pos, line in enumerate(self._history):
- self._add_wrapped(history_idx_pos,
- FormattingString(line, raw=True))
+ self._add_wrapped_from_offset_history(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 = (
self._UNSET_IDX_NEG if (not self._wrapped)
- else (-len(self._wrapped)
- + self._last_wrapped_idx_pos_for_hist_idx_pos(
+ else (self._wrapped_top_idx_neg
+ + self._bottom_wrapped_for_history_idx_pos(
self._len_full_history + max(self._history_idx_neg,
- self._maxlen_log))))
self.bookmark()
def append_fmt(self, to_append: FormattingString) -> None:
'Wrap .append around FormattingString, 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)
+ if cur_val == self._UNSET_IDX_NEG:
+ setattr(self, full_name, self._BOTTOM_IDX_NEG)
+ elif cur_val < self._BOTTOM_IDX_NEG:
+ setattr(self, full_name, cur_val - grow_by)
+
super().append(str(to_append))
- self.taint()
- if not self._UNSET_IDX_NEG != self._history_idx_neg >= -1:
- self._history_idx_neg -= 1
+ self.tainted = True
+ start_unset_dec_non_bottom('history', grow_by=1)
if self._drawable:
- n_wrapped = self._add_wrapped(len(self._history) - 1, to_append)
- if not self._UNSET_IDX_NEG != self._wrapped_idx_neg >= -1:
- self._wrapped_idx_neg -= n_wrapped
+ wrapped_growth\
+ = self._add_wrapped_from_offset_history(len(self._history) - 1,
+ to_append)
+ start_unset_dec_non_bottom('wrapped', grow_by=wrapped_growth)
+ if self._bookmark_idx_neg != self._UNSET_IDX_NEG:
+ self._bookmark_idx_neg -= wrapped_growth
if len(self._history) > self._maxlen_log:
- self._history = self._history[1:]
- self._history_offset += 1
+ self._history.pop(0)
+ self._history_n_lines_cut += 1
self._history_idx_neg = max(self._history_idx_neg,
-self._maxlen_log)
- wrap_offset = 0
- for wrap_idx_pos, t in enumerate(self._wrapped):
- if t[0] == self._history_offset:
- wrap_offset = wrap_idx_pos
- break
- self._wrapped = self._wrapped[wrap_offset:]
+ while self._wrapped[0].history_idx_pos < self._history_n_lines_cut:
+ self._wrapped.pop(0)
self._wrapped_idx_neg = max(self._wrapped_idx_neg,
- -len(self._wrapped))
+ self._wrapped_top_idx_neg)
def _draw(self) -> None:
add_scroll_info = self._wrapped_idx_neg < -1
- start_idx_neg = (self._wrapped_idx_neg
- - self._sizes.y + 1 + bool(add_scroll_info))
- 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,
- FormattingString('')))
- for idx, line in enumerate([lt[1] for lt in wrapped]):
+ low_idx_neg = (self._wrapped_idx_neg + 1) if add_scroll_info else None
+ high_idx_neg = (low_idx_neg or -1) - self._size.y + 1
+ visible_lines = self._wrapped[high_idx_neg:low_idx_neg]
+ while len(visible_lines) < self._size.y - bool(add_scroll_info):
+ visible_lines.insert(0, _WrappedHistoryLine(
+ self._PADDING_HISTORY_IDX_POS,
+ FormattingString('')))
+ 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._sizes.x - len(scroll_info))
- self._write(len(wrapped),
+ scroll_info += 'v' * (self._size.x - len(scroll_info))
+ self._write(len(visible_lines),
FormattingString(scroll_info).attrd('reverse'))
- self._newest_read_history_idx_pos\
- = max(self._newest_read_history_idx_pos, wrapped[-1][0])
+ self._lowest_read_history_idx_pos\
+ = max(self._lowest_read_history_idx_pos,
+ visible_lines[-1].history_idx_pos)
def bookmark(self) -> None:
'Store next idx to what most recent line we have (been) scrolled.'
- 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)
- del self._wrapped[bookmark_idx_neg]
- if bookmark_idx_neg > self._wrapped_idx_neg:
+ if self._bookmark_idx_neg != self._UNSET_IDX_NEG\
+ and len(self._wrapped) > -self._bookmark_idx_neg:
+ del self._wrapped[self._bookmark_idx_neg]
+ if self._bookmark_idx_neg > self._wrapped_idx_neg:
self._wrapped_idx_neg += 1
- if self._newest_read_history_idx_pos < self._history_offset:
+ self._bookmark_idx_neg = self._UNSET_IDX_NEG
+ if self._lowest_read_history_idx_pos < self._history_n_lines_cut:
return
if not self._wrapped:
return
- self._wrapped.insert(self._bookmark_wrapped_idx_pos, bookmark)
- self._wrapped_idx_neg -= int(self._bookmark_wrapped_idx_neg
- > self._wrapped_idx_neg)
-
- @property
- def _bookmark_wrapped_idx_pos(self) -> int:
- return self._last_wrapped_idx_pos_for_hist_idx_pos(
- self._newest_read_history_idx_pos) + 1
+ bookmark = _WrappedHistoryLine(self._BOOKMARK_HISTORY_IDX_POS,
+ FormattingString('-' * self._size.x))
+ lowest_read_wrapped_idx_neg\
+ = (self._wrapped_top_idx_neg
+ + self._bottom_wrapped_for_history_idx_pos(
+ self._lowest_read_history_idx_pos))
+ if lowest_read_wrapped_idx_neg == self._BOTTOM_IDX_NEG:
+ self._bookmark_idx_neg = self._BOTTOM_IDX_NEG
+ self._wrapped += [bookmark]
+ else:
+ self._bookmark_idx_neg = lowest_read_wrapped_idx_neg
+ self._wrapped.insert(self._bookmark_idx_neg + 1, bookmark)
+ if self._bookmark_idx_neg > self._wrapped_idx_neg + 1:
+ self._wrapped_idx_neg -= 1
@property
- def _bookmark_wrapped_idx_neg(self) -> int:
- return self._bookmark_wrapped_idx_pos - len(self._wrapped)
+ def _wrapped_top_idx_neg(self) -> int:
+ return -len(self._wrapped)
- def _last_wrapped_idx_pos_for_hist_idx_pos(self, hist_idx_pos: int) -> int:
- return [idx for idx, t in enumerate(self._wrapped)
- if t[0] == hist_idx_pos][-1]
+ def _bottom_wrapped_for_history_idx_pos(self, hist_idx_pos: int) -> int:
+ return [idx for idx, line in enumerate(self._wrapped)
+ if line.history_idx_pos == hist_idx_pos][-1]
@property
def has_unread_highlight(self) -> bool:
@property
def n_lines_unread(self) -> int:
'How many new lines have been logged since last focus.'
- return (self._len_full_history
- - (self._newest_read_history_idx_pos + 1))
+ return self._len_full_history - self._lowest_read_history_idx_pos - 1
def _scroll(self, up: bool = True) -> None:
super()._scroll(up)
if self._drawable and self._wrapped:
if up and len(self._wrapped) > 2:
self._wrapped_idx_neg = max(
- -len(self._wrapped),
+ self._wrapped_top_idx_neg,
self._wrapped_idx_neg - self._y_pgscroll)
else:
self._wrapped_idx_neg = min(
- -1, self._wrapped_idx_neg + self._y_pgscroll)
- idx = self._wrapped_idx_neg - int(
- self._wrapped_idx_neg == self._bookmark_wrapped_idx_neg)
- self._history_idx_neg = (-self._len_full_history
- + max(0, self._wrapped[idx][0]))
+ self._BOTTOM_IDX_NEG,
+ self._wrapped_idx_neg + self._y_pgscroll)
+ idx = self._wrapped_idx_neg - int(self._wrapped_idx_neg
+ == self._bookmark_idx_neg)
+ self._history_idx_neg = (max(0, self._wrapped[idx].history_idx_pos)
+ - self._len_full_history)
class PromptWidget(_ScrollableWidget):
@input_buffer.setter
def input_buffer(self, content) -> None:
- self.taint()
+ self.tainted = True
self._input_buffer_unsafe = content[:]
def _draw(self) -> None:
cursor_x=self._cursor_x,
left=self.prefix[:],
right=content)
- self._write(self._sizes.y,
+ self._write(self._size.y,
FormattingString(to_write[:x_cursor])
+ FormattingString(to_write[x_cursor]).attrd('reverse')
+ FormattingString(to_write[x_cursor + 1:]))
self._cursor_x += 1
else:
return
- self.taint()
+ self.tainted = True
def _reset_buffer(self, content: str) -> None:
self.input_buffer = content
return to_return
-class _StatusLine(_Widget):
+class _StatusLine(_WidgetAtom):
def __init__(self, write: Callable, windows: list['Window'], **kwargs
) -> None:
cursor_x = len(win_listing) + (len(item) // 2)
win_listing += item
assert isinstance(focus, Window)
- self._write(self._sizes.y, self._make_x_scroll(padchar='=',
- cursor_x=cursor_x,
- left=focus.title + ')',
- right=win_listing)[1])
+ self._write(self._size.y, self._make_x_scroll(padchar='=',
+ cursor_x=cursor_x,
+ left=focus.title + ')',
+ right=win_listing)[1])
-class Window:
+class Window(_Widget):
'Collection of widgets filling entire screen.'
_y_status: int
- _drawable = False
prompt: PromptWidget
_title = ':start'
_last_today = ''
- def __init__(self, idx: int, term: 'Terminal', maxlen_log: int, **kwargs
+ def __init__(self,
+ idx: int,
+ write: Callable[[int, str | FormattingString], None],
+ len_to_term: Callable[[str], int],
+ maxlen_log: int,
+ **kwargs
) -> None:
super().__init__(**kwargs)
self.idx = idx
- self._term = term
+ self._write = write
self.history = _HistoryWidget(maxlen_log=maxlen_log,
- length_to_term=self._term.length_to_term,
- write=self._term.write)
- self.prompt = self.__annotations__['prompt'](write=self._term.write)
- if hasattr(self._term, 'size'):
- self.set_geometry()
+ len_to_term=len_to_term,
+ write=self._write)
+ self.prompt = self.__annotations__['prompt'](write=self._write)
def ensure_date(self, today: str) -> None:
'Log date of today if it has not been logged yet.'
'Append msg to .history.'
self.history.append_fmt(msg)
- def taint(self) -> None:
- 'Declare all widgets as in need of re-drawing.'
- self.history.taint()
- self.prompt.taint()
-
@property
def tainted(self) -> bool:
- 'If any widget in need of re-drawing.'
return self.history.tainted or self.prompt.tainted
- def set_geometry(self) -> None:
- 'Set geometry for widgets.'
- self._drawable = False
- if self._term.size.y < _MIN_HEIGHT or self._term.size.x < _MIN_WIDTH:
+ @tainted.setter
+ def tainted(self, value: bool) -> None:
+ self.history.tainted = value
+ self.prompt.tainted = value
+
+ def set_geometry(self, size: _YX) -> None:
+ self._size = _SIZE_UNSET
+ if size.y < _MIN_HEIGHT or size.x < _MIN_WIDTH:
for widget in (self.history, self.prompt):
- widget.set_geometry(_YX(-1, -1))
+ widget.set_geometry(_SIZE_UNSET)
return
- self._y_status = self._term.size.y - 2
- self.history.set_geometry(_YX(self._y_status, self._term.size.x))
- self.prompt.set_geometry(_YX(self._term.size.y - 1, self._term.size.x))
- self._drawable = True
+ self._size = size
+ self._y_status = self._size.y - 2
+ self.history.set_geometry(_YX(self._y_status, self._size.x))
+ self.prompt.set_geometry(_YX(self._size.y - 1, self._size.x))
@property
def title(self) -> str:
return self._title
def draw_tainted(self) -> None:
- 'Draw tainted widgets (or message that screen too small).'
if self._drawable:
- for widget in [w for w in (self.history, self.prompt)
- 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(y, line)
+ for widget in (self.history, self.prompt):
+ widget.draw_tainted()
+ return
+ msg = 'screen too small'
+ if self._size.x * self._size.y >= len(msg):
+ y = 0
+ line = ''
+ for c in msg:
+ line += c
+ if len(line) == self._size.x:
+ self._write(y, line)
+ y += 1
+ line = ''
def cmd__paste(self) -> None:
'Write OSC 52 ? sequence to get encoded clipboard paste into stdin.'
- self._term.write(0, f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
- self.taint()
+ self._write(0, f'\033{_OSC52_PREFIX.decode()}?{_PASTE_DELIMITER}')
+ self.tainted = True
class TuiEvent(AffectiveEvent):
'Flush terminal.'
@abstractmethod
- def length_to_term(self, text: str) -> int:
+ def len_to_term(self, text: str) -> int:
'How many cells of terminal screen text width will demand.'
@abstractmethod
attrs: tuple[str, ...] = tuple()
len_written = 0
for attrs, part in text.parts_w_attrs():
- len_written += self.length_to_term(part)
+ len_written += self.len_to_term(part)
self._write_w_attrs(part, attrs)
self._write_w_attrs(' ' * (self.size.x - len_written), tuple(attrs))
affected_win_indices += [win.idx]
win.ensure_date(today)
win.log(msg)
- self._status_line.taint()
+ self._status_line.tainted = True
return tuple(affected_win_indices), msg.stripped()
def _new_window(self, win_class=Window, **kwargs) -> Window:
new_idx = len(self._windows)
- win = win_class(idx=new_idx, term=self._term,
- maxlen_log=self._MAXLEN_LOG, **kwargs)
+ win = win_class(idx=new_idx,
+ write=self._term.write,
+ len_to_term=self._term.len_to_term,
+ maxlen_log=self._MAXLEN_LOG,
+ **kwargs)
+ if hasattr(self._term, 'size'):
+ win.set_geometry(self._term.size)
self._windows += [win]
return win
def redraw_affected(self) -> None:
- 'On focused window call .draw, then flush screen.'
+ 'On focused window and status line, call .draw_tainted, then flush.'
self.window.draw_tainted()
- if self._status_line.tainted:
- self._status_line.draw()
+ self._status_line.draw_tainted()
self._term.flush()
def _set_screen(self) -> None:
'Hide cursor, calc screen geometry into wins, call .redraw_affected.'
self._term.set_size_hide_cursor()
for window in self._windows:
- window.set_geometry()
- self._status_line.set_geometry(_YX(self._term.size.y - 2,
- self._term.size.x))
+ window.set_geometry(self._term.size)
+ self._status_line.set_geometry(
+ self._term.size._replace(y=self._term.size.y - 2))
self.redraw_affected()
@property
return self._windows[self._window_idx]
def _switch_window(self, idx: int) -> None:
- self.window.taint()
+ self.window.tainted = True
self.window.history.bookmark()
self._status_line.idx_focus = self._window_idx = idx
- self._status_line.taint()
+ self._status_line.tainted = True
@property
def _commands(self) -> dict[str, tuple[Callable[..., None | Optional[str]],
+ f'(given {len(toks)}, need {n_args_min})'
else:
alert = cmd(*toks)
- self._status_line.taint()
+ self._status_line.tainted = True
else:
alert = 'not prefixed by /'
if alert:
def flush(self) -> None:
print('', end='', flush=True)
- def length_to_term(self, text: str) -> int:
+ def len_to_term(self, text: str) -> int:
# ._blessed.length can slow down things notably: only use where needed!
return len(text) if text.isascii() else self._blessed.length(text)