summaryrefslogtreecommitdiff
path: root/poezio/ui
diff options
context:
space:
mode:
Diffstat (limited to 'poezio/ui')
-rw-r--r--poezio/ui/__init__.py0
-rw-r--r--poezio/ui/consts.py4
-rw-r--r--poezio/ui/funcs.py62
-rw-r--r--poezio/ui/render.py280
-rw-r--r--poezio/ui/types.py260
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