diff options
Diffstat (limited to 'poezio/ui')
-rw-r--r-- | poezio/ui/__init__.py | 0 | ||||
-rw-r--r-- | poezio/ui/consts.py | 4 | ||||
-rw-r--r-- | poezio/ui/funcs.py | 62 | ||||
-rw-r--r-- | poezio/ui/render.py | 280 | ||||
-rw-r--r-- | poezio/ui/types.py | 260 |
5 files changed, 606 insertions, 0 deletions
diff --git a/poezio/ui/__init__.py b/poezio/ui/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/poezio/ui/__init__.py diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py new file mode 100644 index 00000000..91f19a82 --- /dev/null +++ b/poezio/ui/consts.py @@ -0,0 +1,4 @@ +FORMAT_CHAR = '\x19' +# These are non-printable chars, so they should never appear in the input, +# I guess. But maybe we can find better chars that are even less risky. +FORMAT_CHARS = '\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x1A' diff --git a/poezio/ui/funcs.py b/poezio/ui/funcs.py new file mode 100644 index 00000000..023432ee --- /dev/null +++ b/poezio/ui/funcs.py @@ -0,0 +1,62 @@ +""" +Standalone functions used by the modules +""" + +import string +from typing import Optional, List +from poezio.ui.consts import FORMAT_CHAR, FORMAT_CHARS + +DIGITS = string.digits + '-' + + +def find_first_format_char(text: str, + chars: str = None) -> int: + to_find = chars or FORMAT_CHARS + pos = -1 + for char in to_find: + p = text.find(char) + if p == -1: + continue + if pos == -1 or p < pos: + pos = p + return pos + + +def truncate_nick(nick: Optional[str], size=10) -> str: + if size < 1: + size = 1 + if nick: + if len(nick) > size: + return nick[:size] + '…' + return nick + return '' + + +def parse_attrs(text: str, previous: Optional[List[str]] = None) -> List[str]: + next_attr_char = text.find(FORMAT_CHAR) + if previous: + attrs = previous + else: + attrs = [] + while next_attr_char != -1 and text: + if next_attr_char + 1 < len(text): + attr_char = text[next_attr_char + 1].lower() + else: + attr_char = '\0' + if attr_char == 'o': + attrs = [] + elif attr_char == 'u': + attrs.append('u') + elif attr_char == 'b': + attrs.append('b') + elif attr_char == 'i': + attrs.append('i') + if attr_char in DIGITS and attr_char: + color_str = text[next_attr_char + 1:text.find('}', next_attr_char)] + if color_str: + attrs.append(color_str + '}') + text = text[next_attr_char + len(color_str) + 2:] + else: + text = text[next_attr_char + 2:] + next_attr_char = text.find(FORMAT_CHAR) + return attrs diff --git a/poezio/ui/render.py b/poezio/ui/render.py new file mode 100644 index 00000000..aad482b5 --- /dev/null +++ b/poezio/ui/render.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import curses + +from datetime import ( + datetime, + date, +) +from functools import singledispatch +from math import ceil, log10 +from typing import ( + List, + Optional, + Tuple, + TYPE_CHECKING, +) + +from poezio import poopt +from poezio.theming import ( + get_theme, +) +from poezio.ui.consts import ( + FORMAT_CHAR, +) +from poezio.ui.funcs import ( + truncate_nick, + parse_attrs, +) +from poezio.ui.types import ( + BaseMessage, + Message, + StatusMessage, + UIMessage, + XMLLog, +) + +if TYPE_CHECKING: + from poezio.windows import Win + +# msg is a reference to the corresponding Message object. text_start and +# text_end are the position delimiting the text in this line. +class Line: + __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend') + + def __init__(self, msg: BaseMessage, start_pos: int, end_pos: int, prepend: str) -> None: + self.msg = msg + self.start_pos = start_pos + self.end_pos = end_pos + self.prepend = prepend + + def __repr__(self): + return '(%s, %s)' % (self.start_pos, self.end_pos) + + +LinePos = Tuple[int, int] + + +def generate_lines(lines: List[LinePos], msg: BaseMessage, default_color: str = '') -> List[Line]: + line_objects = [] + attrs: List[str] = [] + prepend = default_color if default_color else '' + for line in lines: + saved = Line( + msg=msg, + start_pos=line[0], + end_pos=line[1], + prepend=prepend) + attrs = parse_attrs(msg.txt[line[0]:line[1]], attrs) + if attrs: + prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) + else: + if default_color: + prepend = default_color + else: + prepend = '' + line_objects.append(saved) + return line_objects + + +@singledispatch +def build_lines(msg: BaseMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@build_lines.register(type(None)) +def build_separator(*args, **kwargs): + return [None] + + +@build_lines.register(Message) +def build_message(msg: Message, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + """ + Build a list of lines from this message. + """ + txt = msg.txt + if not txt: + return [] + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(txt, width - offset - 1) + generated_lines = generate_lines(lines, msg, default_color='') + return generated_lines + + +@build_lines.register(StatusMessage) +def build_status(msg: StatusMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + msg.rebuild() + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@build_lines.register(XMLLog) +def build_xmllog(msg: XMLLog, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]: + offset = msg.compute_offset(timestamp, nick_size) + lines = poopt.cut_text(msg.txt, width - offset - 1) + return generate_lines(lines, msg, default_color='') + + +@singledispatch +def write_pre(msg: BaseMessage, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before text (only the timestamp)""" + if with_timestamps: + return PreMessageHelpers.write_time(win, False, msg.time) + return 0 + + +@write_pre.register(UIMessage) +def write_pre_uimessage(msg: UIMessage, win: Win, with_timestamps: bool, nick_size: int) -> int: + """ Write the prefix of a ui message log + - timestamp (short or long) + - level + """ + color: Optional[Tuple] + offset = 0 + if with_timestamps: + offset += PreMessageHelpers.write_time(win, False, msg.time) + + if not msg.level: # not a message, nothing to do afterwards + return offset + + level = truncate_nick(msg.level, nick_size) + offset += poopt.wcswidth(level) + color = msg.color + PreMessageHelpers.write_nickname(win, level, color, False) + win.addstr('> ') + offset += 2 + return offset + + +@write_pre.register(Message) +def write_pre_message(msg: Message, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before the body: + - timestamp (short or long) + - ack/nack + - nick (with a "* " for /me) + - LMC number if present + """ + color: Optional[Tuple] + offset = 0 + if with_timestamps: + offset += PreMessageHelpers.write_time(win, msg.history, msg.time) + + if not msg.nickname: # not a message, nothing to do afterwards + return offset + + nick = truncate_nick(msg.nickname, nick_size) + offset += poopt.wcswidth(nick) + if msg.nick_color: + color = msg.nick_color + elif msg.user: + color = msg.user.color + else: + color = None + if msg.ack: + if msg.ack > 0: + offset += PreMessageHelpers.write_ack(win) + else: + offset += PreMessageHelpers.write_nack(win) + theme = get_theme() + if msg.me: + with win.colored_text(color=theme.COLOR_ME_MESSAGE): + win.addstr(theme.CHAR_BEFORE_NICK_ME) + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr(theme.CHAR_AFTER_NICK_ME) + offset += len(theme.CHAR_BEFORE_NICK_ME) + len(theme.CHAR_AFTER_NICK_ME) + else: + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr(theme.CHAR_AFTER_NICK) + offset += len(theme.CHAR_AFTER_NICK) + return offset + + +@write_pre.register(XMLLog) +def write_pre_xmllog(msg: XMLLog, win: Win, with_timestamps: bool, nick_size: int) -> int: + """Write the part before the stanza (timestamp + IN/OUT)""" + offset = 0 + if with_timestamps: + offset += 1 + PreMessageHelpers.write_time(win, False, msg.time) + theme = get_theme() + if msg.incoming: + char = theme.CHAR_XML_IN + color = theme.COLOR_XML_IN + else: + char = theme.CHAR_XML_OUT + color = theme.COLOR_XML_OUT + nick = truncate_nick(char, nick_size) + offset += poopt.wcswidth(nick) + PreMessageHelpers.write_nickname(win, char, color) + win.addstr(' ') + return offset + +class PreMessageHelpers: + + @staticmethod + def write_revisions(buffer: Win, msg: Message) -> int: + if msg.revisions: + color = get_theme().COLOR_REVISIONS_MESSAGE + with buffer.colored_text(color=color): + buffer.addstr('%d' % msg.revisions) + return ceil(log10(msg.revisions + 1)) + return 0 + + @staticmethod + def write_ack(buffer: Win) -> int: + theme = get_theme() + color = theme.COLOR_CHAR_ACK + with buffer.colored_text(color=color): + buffer.addstr(theme.CHAR_ACK_RECEIVED) + buffer.addstr(' ') + return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 + + @staticmethod + def write_nack(buffer: Win) -> int: + theme = get_theme() + color = theme.COLOR_CHAR_NACK + with buffer.colored_text(color=color): + buffer.addstr(theme.CHAR_NACK) + buffer.addstr(' ') + return poopt.wcswidth(theme.CHAR_NACK) + 1 + + @staticmethod + def write_nickname(buffer: Win, nickname: str, color, highlight=False) -> None: + """ + Write the nickname, using the user's color + and return the number of written characters + """ + if not nickname: + return + attr = None + if highlight: + hl_color = get_theme().COLOR_HIGHLIGHT_NICK + if hl_color == "reverse": + attr = curses.A_REVERSE + else: + color = hl_color + with buffer.colored_text(color=color, attr=attr): + buffer.addstr(nickname) + + @staticmethod + def write_time(buffer: Win, history: bool, time: datetime) -> int: + """ + Write the date on the yth line of the window + """ + if time: + theme = get_theme() + if history and time.date() != date.today(): + format = theme.LONG_TIME_FORMAT + else: + format = theme.SHORT_TIME_FORMAT + time_str = time.strftime(format) + color = theme.COLOR_TIME_STRING + with buffer.colored_text(color=color): + buffer.addstr(time_str) + buffer.addstr(' ') + return poopt.wcswidth(time_str) + 1 + return 0 diff --git a/poezio/ui/types.py b/poezio/ui/types.py new file mode 100644 index 00000000..27ccbd62 --- /dev/null +++ b/poezio/ui/types.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from datetime import datetime +from math import ceil, log10 +from typing import Optional, Tuple, Dict, Any, Callable + +from slixmpp import JID + +from poezio import poopt +from poezio.theming import dump_tuple, get_theme +from poezio.ui.funcs import truncate_nick +from poezio.user import User + + +class BaseMessage: + """Base class for all ui-related messages""" + __slots__ = ('txt', 'time', 'identifier') + + txt: str + identifier: str + time: datetime + + def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None): + self.txt = txt + self.identifier = identifier + if time is not None: + self.time = time + else: + self.time = datetime.now() + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the offset of the message""" + theme = get_theme() + return theme.SHORT_TIME_FORMAT_LENGTH + 1 + + +class EndOfArchive(BaseMessage): + """Marker added to a buffer when we reach the end of a MAM archive""" + + +class InfoMessage(BaseMessage): + """Information message""" + def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None): + txt = ('\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)) + txt + super().__init__(txt=txt, identifier=identifier, time=time) + + +class UIMessage(BaseMessage): + """Message displayed through poezio UI""" + __slots__ = ('level', 'color') + level: str + color: Optional[Tuple] + + def __init__(self, txt: str, level: str): + BaseMessage.__init__(self, txt=txt) + self.level = level.capitalize() + colors = get_theme().INFO_COLORS + self.color = colors.get(level.lower(), colors.get('default', None)) + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the x-position at which the message should be printed""" + offset = 0 + theme = get_theme() + if with_timestamps: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + level = self.level + if not level: # not a message, nothing to do afterwards + return offset + level = truncate_nick(level, nick_size) or '' + offset += poopt.wcswidth(level) + offset += 2 + return offset + + +class LoggableTrait: + """Trait for classes of messages that should go through the logger""" + pass + + +class PersistentInfoMessage(InfoMessage, LoggableTrait): + """Information message thatt will be logged""" + pass + + +class MucOwnLeaveMessage(InfoMessage, LoggableTrait): + """Status message displayed on our room leave/kick/ban""" + + +class MucOwnJoinMessage(InfoMessage, LoggableTrait): + """Status message displayed on our room join""" + + +class XMLLog(BaseMessage): + """XML Log message""" + __slots__ = ('incoming') + incoming: bool + + def __init__( + self, + txt: str, + incoming: bool, + ): + BaseMessage.__init__( + self, + txt=txt, + ) + self.incoming = incoming + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + offset = 0 + theme = get_theme() + if with_timestamps: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + if self.incoming: + nick = theme.CHAR_XML_IN + else: + nick = theme.CHAR_XML_OUT + nick = truncate_nick(nick, nick_size) or '' + offset += 1 + len(nick) + return offset + + +class StatusMessage(BaseMessage): + """A dynamically formatted status message""" + __slots__ = ('format_string', 'format_args') + format_string: str + format_args: Dict[str, Callable[[], Any]] + + def __init__(self, format_string: str, format_args: dict): + BaseMessage.__init__( + self, + txt='', + ) + self.format_string = format_string + self.format_args = format_args + self.rebuild() + + def rebuild(self): + real_args = {} + for key, func in self.format_args.items(): + real_args[key] = func() + self.txt = self.format_string.format(**real_args) + + +class Message(BaseMessage, LoggableTrait): + __slots__ = ('nick_color', 'nickname', 'user', 'delayed', 'history', + 'highlight', 'me', 'old_message', 'revisions', + 'jid', 'ack') + nick_color: Optional[Tuple] + nickname: Optional[str] + user: Optional[User] + delayed: bool + history: bool + highlight: bool + me: bool + old_message: Optional[Message] + revisions: int + jid: Optional[JID] + ack: int + + def __init__(self, + txt: str, + nickname: Optional[str], + time: Optional[datetime] = None, + nick_color: Optional[Tuple] = None, + delayed: bool = False, + history: bool = False, + user: Optional[User] = None, + identifier: Optional[str] = '', + highlight: bool = False, + old_message: Optional[Message] = None, + revisions: int = 0, + jid: Optional[JID] = None, + ack: int = 0) -> None: + """ + Create a new Message object with parameters, check for /me messages, + and delayed messages + """ + BaseMessage.__init__( + self, + txt=txt.replace('\t', ' ') + '\x19o', + identifier=identifier or '', + time=time, + ) + if txt.startswith('/me '): + me = True + txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE), + txt[4:]) + else: + me = False + self.txt = txt + self.delayed = delayed or history + self.history = history + self.nickname = nickname + self.nick_color = nick_color + self.user = user + self.highlight = highlight + self.me = me + self.old_message = old_message + self.revisions = revisions + self.jid = jid + self.ack = ack + + def _other_elems(self) -> str: + "Helper for the repr_message function" + acc = [] + fields = list(self.__slots__) + fields.remove('old_message') + for field in fields: + acc.append('%s=%s' % (field, repr(getattr(self, field)))) + return 'Message(%s, %s' % (', '.join(acc), 'old_message=') + + def __repr__(self) -> str: + """ + repr() for the Message class, for debug purposes, since the default + repr() is recursive, so it can stack overflow given too many revisions + of a message + """ + init = self._other_elems() + acc = [init] + next_message = self.old_message + rev = 1 + while next_message is not None: + acc.append(next_message._other_elems()) + next_message = next_message.old_message + rev += 1 + acc.append('None') + while rev: + acc.append(')') + rev -= 1 + return ''.join(acc) + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + """Compute the x-position at which the message should be printed""" + offset = 0 + theme = get_theme() + if with_timestamps: + if self.history: + offset += 1 + theme.LONG_TIME_FORMAT_LENGTH + else: + offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH + + if not self.nickname: # not a message, nothing to do afterwards + return offset + + nick = truncate_nick(self.nickname, nick_size) or '' + offset += poopt.wcswidth(nick) + if self.ack: + theme = get_theme() + if self.ack > 0: + offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 + else: + offset += poopt.wcswidth(theme.CHAR_NACK) + 1 + if self.me: + offset += 3 + else: + offset += 2 + if self.revisions: + offset += ceil(log10(self.revisions + 1)) + return offset |