diff options
author | mathieui <mathieui@mathieui.net> | 2020-05-09 22:58:17 +0200 |
---|---|---|
committer | mathieui <mathieui@mathieui.net> | 2020-05-09 22:58:17 +0200 |
commit | f68fa1da5e2cccd396ee03eec12359ff905b7bc6 (patch) | |
tree | 135acd2d1ed2a4e7e382d9bd4f574ef6f54a023e | |
parent | d22b4b8c218cbbaee62002d751bd69bfe1d1deab (diff) | |
parent | ca85411af06c59ec63b9cc2c0b2b2f398da79e8a (diff) | |
download | poezio-f68fa1da5e2cccd396ee03eec12359ff905b7bc6.tar.gz poezio-f68fa1da5e2cccd396ee03eec12359ff905b7bc6.tar.bz2 poezio-f68fa1da5e2cccd396ee03eec12359ff905b7bc6.tar.xz poezio-f68fa1da5e2cccd396ee03eec12359ff905b7bc6.zip |
Merge branch 'split-message-rendering' into 'master'
split message rendering
See merge request poezio/poezio!48
30 files changed, 1363 insertions, 954 deletions
diff --git a/plugins/day_change.py b/plugins/day_change.py index 051b447b..5d3ab37c 100644 --- a/plugins/day_change.py +++ b/plugins/day_change.py @@ -4,11 +4,12 @@ date has changed. """ +import datetime from gettext import gettext as _ + +from poezio import timed_events, tabs from poezio.plugin import BasePlugin -import datetime -from poezio import tabs -from poezio import timed_events +from poezio.ui.types import InfoMessage class Plugin(BasePlugin): @@ -30,7 +31,7 @@ class Plugin(BasePlugin): for tab in self.core.tabs: if isinstance(tab, tabs.ChatTab): - tab.add_message(msg) + tab.add_message(InfoMessage(msg)) self.core.refresh_window() self.schedule_event() diff --git a/plugins/embed.py b/plugins/embed.py index 0c4a4a2a..aee7d44b 100644 --- a/plugins/embed.py +++ b/plugins/embed.py @@ -16,6 +16,7 @@ Usage from poezio import tabs from poezio.plugin import BasePlugin from poezio.theming import get_theme +from poezio.ui.types import Message class Plugin(BasePlugin): @@ -37,11 +38,13 @@ class Plugin(BasePlugin): if not isinstance(tab, tabs.MucTab): message['type'] = 'chat' tab.add_message( - message['body'], - nickname=tab.core.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=message['id'], - jid=tab.core.xmpp.boundjid, + Message( + message['body'], + nickname=tab.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=message['id'], + jid=tab.core.xmpp.boundjid, + ), typ=1, ) message.send() diff --git a/plugins/lastlog.py b/plugins/lastlog.py index 104399b4..49efa522 100644 --- a/plugins/lastlog.py +++ b/plugins/lastlog.py @@ -17,7 +17,8 @@ from datetime import datetime from poezio.plugin import BasePlugin from poezio import tabs -from poezio.text_buffer import Message, TextBuffer +from poezio.text_buffer import TextBuffer +from poezio.ui.types import InfoMessage def add_line( @@ -26,18 +27,7 @@ def add_line( datetime: Optional[datetime] = None, ) -> None: """Adds a textual entry in the TextBuffer""" - text_buffer.add_message( - text, - datetime, # Time - None, # Nickname - None, # Nick Color - False, # History - None, # User - False, # Highlight - None, # Identifier - None, # str_time - None, # Jid - ) + text_buffer.add_message(InfoMessage(text, time=datetime)) class Plugin(BasePlugin): diff --git a/plugins/otr.py b/plugins/otr.py index 2ddc332b..81e1621f 100644 --- a/plugins/otr.py +++ b/plugins/otr.py @@ -205,6 +205,7 @@ from poezio.tabs import StaticConversationTab, PrivateTab from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser from poezio.core.structs import Completion +from poezio.ui.types import InfoMessage, Message POLICY_FLAGS = { 'ALLOW_V1': False, @@ -385,25 +386,30 @@ class PoezioContext(Context): log.debug('OTR conversation with %s refreshed', self.peer) if self.getCurrentTrust(): msg = OTR_REFRESH_TRUSTED % format_dict - tab.add_message(msg, typ=self.log) + tab.add_message(InfoMessage(msg), typ=self.log) else: msg = OTR_REFRESH_UNTRUSTED % format_dict - tab.add_message(msg, typ=self.log) + tab.add_message(InfoMessage(msg), typ=self.log) hl(tab) elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT: log.debug('OTR conversation with %s finished', self.peer) if tab: - tab.add_message(OTR_END % format_dict, typ=self.log) + tab.add_message(InfoMessage(OTR_END % format_dict), typ=self.log) hl(tab) elif newstate == STATE_ENCRYPTED and tab: if self.getCurrentTrust(): - tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log) + tab.add_message(InfoMessage(OTR_START_TRUSTED % format_dict), typ=self.log) else: format_dict['our_fpr'] = self.user.getPrivkey() format_dict['remote_fpr'] = self.getCurrentKey() - tab.add_message(OTR_TUTORIAL % format_dict, typ=0) tab.add_message( - OTR_START_UNTRUSTED % format_dict, typ=self.log) + InfoMessage(OTR_TUTORIAL % format_dict), + typ=0 + ) + tab.add_message( + InfoMessage(OTR_START_UNTRUSTED % format_dict), + typ=self.log, + ) hl(tab) log.debug('Set encryption state of %s to %s', self.peer, @@ -639,7 +645,7 @@ class Plugin(BasePlugin): # Received an OTR error proto_error = err.args[0].error # pylint: disable=no-member format_dict['err'] = proto_error.decode('utf-8', errors='replace') - tab.add_message(OTR_ERROR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_ERROR % format_dict), typ=0) del msg['body'] del msg['html'] hl(tab) @@ -649,7 +655,7 @@ class Plugin(BasePlugin): # Encrypted message received, but unreadable as we do not have # an OTR session in place. text = MESSAGE_UNREADABLE % format_dict - tab.add_message(text, jid=msg['from'], typ=0) + tab.add_message(InfoMessage(text), typ=0) hl(tab) del msg['body'] del msg['html'] @@ -658,7 +664,7 @@ class Plugin(BasePlugin): except crypt.InvalidParameterError: # Malformed OTR payload and stuff text = MESSAGE_INVALID % format_dict - tab.add_message(text, jid=msg['from'], typ=0) + tab.add_message(InfoMessage(text), typ=0) hl(tab) del msg['body'] del msg['html'] @@ -669,7 +675,7 @@ class Plugin(BasePlugin): import traceback exc = traceback.format_exc() format_dict['exc'] = exc - tab.add_message(POTR_ERROR % format_dict, typ=0) + tab.add_message(InfoMessage(POTR_ERROR % format_dict), typ=0) log.error('Unspecified error in the OTR plugin', exc_info=True) return # No error, proceed with the message @@ -688,10 +694,10 @@ class Plugin(BasePlugin): abort = get_tlv(tlvs, potr.proto.SMPABORTTLV) if abort: ctx.reset_smp() - tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED_PEER % format_dict), typ=0) elif ctx.in_smp and not ctx.smpIsValid(): ctx.reset_smp() - tab.add_message(SMP_ABORTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED % format_dict), typ=0) elif smp1 or smp1q: # Received an SMP request (with a question or not) if smp1q: @@ -709,22 +715,22 @@ class Plugin(BasePlugin): # we did not initiate it ctx.smp_own = False format_dict['q'] = question - tab.add_message(SMP_REQUESTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_REQUESTED % format_dict), typ=0) elif smp2: # SMP reply received if not ctx.in_smp: ctx.reset_smp() else: - tab.add_message(SMP_PROGRESS % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_PROGRESS % format_dict), typ=0) elif smp3 or smp4: # Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received # in both cases it is the final message of the SMP exchange if ctx.smpIsSuccess(): - tab.add_message(SMP_SUCCESS % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_SUCCESS % format_dict), typ=0) if not ctx.getCurrentTrust(): - tab.add_message(SMP_RECIPROCATE % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_RECIPROCATE % format_dict), typ=0) else: - tab.add_message(SMP_FAIL % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_FAIL % format_dict), typ=0) ctx.reset_smp() hl(tab) self.core.refresh_window() @@ -780,12 +786,15 @@ class Plugin(BasePlugin): if decode_newlines: body = body.replace('<br/>', '\n').replace('<br>', '\n') tab.add_message( - body, - nickname=tab.nick, - jid=msg['from'], - forced_user=user, + Message( + body, + nickname=tab.nick, + jid=msg['from'], + user=user, + nick_color=nick_color + ), typ=ctx.log, - nick_color=nick_color) + ) hl(tab) self.core.refresh_window() del msg['body'] @@ -826,19 +835,22 @@ class Plugin(BasePlugin): tab.send_chat_state('inactive', always_send=True) tab.add_message( - msg['body'], - nickname=self.core.own_nick or tab.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=msg['id'], - jid=self.core.xmpp.boundjid, - typ=ctx.log) + Message( + msg['body'], + nickname=self.core.own_nick or tab.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=msg['id'], + jid=self.core.xmpp.boundjid, + ), + typ=ctx.log + ) # remove everything from the message so that it doesn’t get sent del msg['body'] del msg['replace'] del msg['html'] elif is_relevant(tab) and ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'): warning_msg = MESSAGE_NOT_SENT % format_dict - tab.add_message(warning_msg, typ=0) + tab.add_message(InfoMessage(warning_msg), typ=0) del msg['body'] del msg['replace'] del msg['html'] @@ -856,7 +868,7 @@ class Plugin(BasePlugin): ('\n - /message %s' % jid) for jid in res) format_dict['help'] = help_msg warning_msg = INCOMPATIBLE_TAB % format_dict - tab.add_message(warning_msg, typ=0) + tab.add_message(InfoMessage(warning_msg), typ=0) del msg['body'] del msg['replace'] del msg['html'] @@ -900,22 +912,22 @@ class Plugin(BasePlugin): self.otr_start(tab, name, format_dict) elif action == 'ourfpr': format_dict['fpr'] = self.account.getPrivkey() - tab.add_message(OTR_OWN_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_OWN_FPR % format_dict), typ=0) elif action == 'fpr': if name in self.contexts: ctx = self.contexts[name] if ctx.getCurrentKey() is not None: format_dict['fpr'] = ctx.getCurrentKey() - tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_REMOTE_FPR % format_dict), typ=0) else: - tab.add_message(OTR_NO_FPR % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_NO_FPR % format_dict), typ=0) elif action == 'drop': # drop the privkey (and obviously, end the current conversations before that) for context in self.contexts.values(): if context.state not in (STATE_FINISHED, STATE_PLAINTEXT): context.disconnect() self.account.drop_privkey() - tab.add_message(KEY_DROPPED % format_dict, typ=0) + tab.add_message(InfoMessage(KEY_DROPPED % format_dict), typ=0) elif action == 'trust': ctx = self.get_context(name) key = ctx.getCurrentKey() @@ -927,7 +939,7 @@ class Plugin(BasePlugin): format_dict['key'] = key ctx.setTrust(fpr, 'verified') self.account.saveTrusts() - tab.add_message(TRUST_ADDED % format_dict, typ=0) + tab.add_message(InfoMessage(TRUST_ADDED % format_dict), typ=0) elif action == 'untrust': ctx = self.get_context(name) key = ctx.getCurrentKey() @@ -939,7 +951,7 @@ class Plugin(BasePlugin): format_dict['key'] = key ctx.setTrust(fpr, '') self.account.saveTrusts() - tab.add_message(TRUST_REMOVED % format_dict, typ=0) + tab.add_message(InfoMessage(TRUST_REMOVED % format_dict), typ=0) self.core.refresh_window() def otr_start(self, tab, name, format_dict): @@ -954,7 +966,7 @@ class Plugin(BasePlugin): if otr.state != STATE_ENCRYPTED: format_dict['secs'] = secs text = OTR_NOT_ENABLED % format_dict - tab.add_message(text, typ=0) + tab.add_message(InfoMessage(text), typ=0) self.core.refresh_window() if secs > 0: @@ -962,7 +974,7 @@ class Plugin(BasePlugin): self.api.add_timed_event(event) body = self.get_context(name).sendMessage(0, b'?OTRv?').decode() self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body) - tab.add_message(OTR_REQUEST % format_dict, typ=0) + tab.add_message(InfoMessage(OTR_REQUEST % format_dict), typ=0) @staticmethod def completion_otr(the_input): @@ -1012,13 +1024,13 @@ class Plugin(BasePlugin): ctx.smpInit(secret, question) else: ctx.smpInit(secret) - tab.add_message(SMP_INITIATED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_INITIATED % format_dict), typ=0) elif action == 'answer': ctx.smpGotSecret(secret) elif action == 'abort': if ctx.in_smp: ctx.smpAbort() - tab.add_message(SMP_ABORTED % format_dict, typ=0) + tab.add_message(InfoMessage(SMP_ABORTED % format_dict), typ=0) self.core.refresh_window() @staticmethod diff --git a/poezio/core/core.py b/poezio/core/core.py index 14852ac2..8f25d551 100644 --- a/poezio/core/core.py +++ b/poezio/core/core.py @@ -53,8 +53,15 @@ from poezio.core.completions import CompletionCore from poezio.core.tabs import Tabs from poezio.core.commands import CommandCore from poezio.core.handlers import HandlerCore -from poezio.core.structs import POSSIBLE_SHOW, DEPRECATED_ERRORS, \ - ERROR_AND_STATUS_CODES, Command, Status +from poezio.core.structs import ( + Command, + Status, + DEPRECATED_ERRORS, + ERROR_AND_STATUS_CODES, + POSSIBLE_SHOW, +) + +from poezio.ui.types import Message, InfoMessage log = logging.getLogger(__name__) @@ -1317,7 +1324,7 @@ class Core: """ tab = self.tabs.by_name_and_class(jid, tabs.ConversationTab) if tab is not None: - tab.add_message(msg, typ=2) + tab.add_message(InfoMessage(msg), typ=2) if self.tabs.current_tab is tab: self.refresh_window() @@ -1349,7 +1356,12 @@ class Core: colors = get_theme().INFO_COLORS color = colors.get(typ.lower(), colors.get('default', None)) nb_lines = self.information_buffer.add_message( - msg, nickname=typ, nick_color=color) + Message( + txt=msg, + nickname=typ, + nick_color=color + ) + ) popup_on = config.get('information_buffer_popup_on').split() if isinstance(self.tabs.current_tab, tabs.RosterInfoTab): self.refresh_window() @@ -1579,17 +1591,6 @@ class Core: self.tab_win.resize(1, tabs.Tab.width, tabs.Tab.height - 2, 0) self.left_tab_win = None - def add_message_to_text_buffer(self, buff, txt, nickname=None): - """ - Add the message to the room if possible, else, add it to the Info window - (in the Info tab of the info window in the RosterTab) - """ - if not buff: - self.information('Trying to add a message in no room: %s' % txt, - 'Error') - return - buff.add_message(txt, nickname=nickname) - def full_screen_redraw(self): """ Completely erase and redraw the screen @@ -2061,15 +2062,18 @@ class Core: return error_message = self.get_error_message(error) tab.add_message( - error_message, - highlight=True, - nickname='Error', - nick_color=get_theme().COLOR_ERROR_MSG, - typ=2) + Message( + error_message, + highlight=True, + nickname='Error', + nick_color=get_theme().COLOR_ERROR_MSG, + ), + typ=2, + ) code = error['error']['code'] if code == '401': msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)' - tab.add_message(msg, typ=2) + tab.add_message(InfoMessage(msg), typ=2) if code == '409': if config.get('alternative_nickname') != '': if not tab.joined: @@ -2078,8 +2082,12 @@ class Core: else: if not tab.joined: tab.add_message( - 'You can join the room with an other nick, by typing "/join /other_nick"', - typ=2) + InfoMessage( + 'You can join the room with another nick, ' + 'by typing "/join /other_nick"' + ), + typ=2, + ) self.refresh_window() diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py index a68b6986..3e2a20a0 100644 --- a/poezio/core/handlers.py +++ b/poezio/core/handlers.py @@ -39,6 +39,7 @@ from poezio.logger import logger from poezio.roster import roster from poezio.text_buffer import CorrectionError, AckError from poezio.theming import dump_tuple, get_theme +from poezio.ui.types import XMLLog, Message as PMessage, BaseMessage, InfoMessage from poezio.core.commands import dumb_callback @@ -326,7 +327,7 @@ class HandlerCore: error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK), error_msg) if not tab.nack_message('\n' + error, message['id'], message['to']): - tab.add_message(error, typ=0) + tab.add_message(InfoMessage(error), typ=0) self.core.refresh_window() def on_normal_message(self, message): @@ -421,14 +422,17 @@ class HandlerCore: if not try_modify(): conversation.add_message( - body, - date, - nickname=remote_nick, - nick_color=color, - history=delayed, - identifier=message['id'], - jid=jid, - typ=1) + PMessage( + txt=body, + time=date, + nickname=remote_nick, + nick_color=color, + history=delayed, + identifier=message['id'], + jid=jid, + ), + typ=1, + ) if not own and 'private' in config.get('beep_on').split(): if not config.get_by_tabname('disable_beep', conv_jid.bare): @@ -750,6 +754,11 @@ class HandlerCore: old_state = tab.state delayed, date = common.find_delayed_tag(message) + + history = (tab.last_message_was_history is None and delayed) or \ + (tab.last_message_was_history and delayed) + tab.last_message_was_history = history + replaced = False if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None: replaced_id = message['replace']['id'] @@ -762,6 +771,7 @@ class HandlerCore: replaced_id, message['id'], time=delayed_date, + delayed=delayed, nickname=nick_from, user=user): self.core.events.trigger('highlight', message, tab) @@ -769,12 +779,16 @@ class HandlerCore: except CorrectionError: log.debug('Unable to correct a message', exc_info=True) if not replaced and tab.add_message( - body, - date, - nick_from, - history=delayed, - identifier=message['id'], - jid=message['from'], + PMessage( + txt=body, + time=date, + nickname=nick_from, + history=history, + delayed=delayed, + identifier=message['id'], + jid=message['from'], + user=user, + ), typ=1): self.core.events.trigger('highlight', message, tab) @@ -862,14 +876,16 @@ class HandlerCore: log.debug('Unable to correct a message', exc_info=True) if not replaced: tab.add_message( - body, - time=None, - nickname=sender_nick, - nick_color=get_theme().COLOR_OWN_NICK if sent else None, - forced_user=user, - identifier=message['id'], - jid=message['from'], - typ=1) + PMessage( + txt=body, + nickname=sender_nick, + nick_color=get_theme().COLOR_OWN_NICK if sent else None, + user=user, + identifier=message['id'], + jid=message['from'], + ), + typ=1, + ) if sent: tab.set_last_sent_message(message, correct=replaced) else: @@ -1361,36 +1377,52 @@ class HandlerCore: if show_unavailable or hide_unavailable or non_priv or logging_off\ or non_anon or semi_anon or full_anon: tab.add_message( - '\x19%(info_col)s}Info: A configuration change not privacy-related occurred.' % info_col, + InfoMessage( + 'Info: A configuration change not privacy-related occurred.' + ), typ=2) modif = True if show_unavailable: tab.add_message( - '\x19%(info_col)s}Info: The unavailable members are now shown.' % info_col, + InfoMessage( + 'Info: The unavailable members are now shown.' + ), typ=2) elif hide_unavailable: tab.add_message( - '\x19%(info_col)s}Info: The unavailable members are now hidden.' % info_col, + InfoMessage( + 'Info: The unavailable members are now hidden.', + ), typ=2) if non_anon: tab.add_message( - '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col, + InfoMessage( + '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col + ), typ=2) elif semi_anon: tab.add_message( - '\x19%(info_col)s}Info: The room is now semi-anonymous. (moderators-only JID)' % info_col, + InfoMessage( + 'Info: The room is now semi-anonymous. (moderators-only JID)', + ), typ=2) elif full_anon: tab.add_message( - '\x19%(info_col)s}Info: The room is now fully anonymous.' % info_col, + InfoMessage( + 'Info: The room is now fully anonymous.', + ), typ=2) if logging_on: tab.add_message( - '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col, + InfoMessage( + '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col + ), typ=2) elif logging_off: tab.add_message( - '\x19%(info_col)s}Info: This room is not logged anymore.' % info_col, + InfoMessage( + 'Info: This room is not logged anymore.', + ), typ=2) if modif: self.core.refresh_window() @@ -1434,15 +1466,17 @@ class HandlerCore: if nick_from: tab.add_message( - "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" - % fmt, - str_time=time, + InfoMessage( + "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" % fmt, + time=time, + ), typ=2) else: tab.add_message( - "\x19%(info_col)s}The subject is: \x19%(text_col)s}%(subject)s" - % fmt, - str_time=time, + InfoMessage( + "The subject is: \x19%(text_col)s}%(subject)s" % fmt, + time=time, + ), typ=2) tab.topic = subject tab.topic_from = nick_from @@ -1484,17 +1518,16 @@ class HandlerCore: """ jid_from = message['from'] self.core.information('%s requests your attention!' % jid_from, 'Info') - for tab in self.core.tabs: - if tab.jid == jid_from: - tab.state = 'attention' - self.core.refresh_tab_win() - return - for tab in self.core.tabs: - if tab.jid.bare == jid_from.bare: - tab.state = 'attention' - self.core.refresh_tab_win() - return - self.core.information('%s tab not found.' % jid_from, 'Error') + tab = ( + self.core.tabs.by_name_and_class( + jid_from.full, tabs.ChatTab + ) or self.core.tabs.by_name_and_class( + jid_from.bare, tabs.ChatTab + ) + ) + if tab and tab is not self.core.tabs.current_tab: + tab.state = "attention" + self.core.refresh_tab_win() def outgoing_stanza(self, stanza): """ @@ -1507,18 +1540,15 @@ class HandlerCore: xhtml_text, force=True).rstrip('\x19o').strip() else: poezio_colored = str(stanza) - char = get_theme().CHAR_XML_OUT - self.core.add_message_to_text_buffer( - self.core.xml_buffer, - poezio_colored, - nickname=char) + self.core.xml_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=False), + ) try: if self.core.xml_tab.match_stanza( ElementBase(ET.fromstring(stanza))): - self.core.add_message_to_text_buffer( - self.core.xml_tab.filtered_buffer, - poezio_colored, - nickname=char) + self.core.xml_tab.filtered_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=False), + ) except: # Most of the time what gets logged is whitespace pings. Skip. # And also skip tab updates. @@ -1541,17 +1571,14 @@ class HandlerCore: xhtml_text, force=True).rstrip('\x19o').strip() else: poezio_colored = str(stanza) - char = get_theme().CHAR_XML_IN - self.core.add_message_to_text_buffer( - self.core.xml_buffer, - poezio_colored, - nickname=char) + self.core.xml_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=True), + ) try: if self.core.xml_tab.match_stanza(stanza): - self.core.add_message_to_text_buffer( - self.core.xml_tab.filtered_buffer, - poezio_colored, - nickname=char) + self.core.xml_tab.filtered_buffer.add_message( + XMLLog(txt=poezio_colored, incoming=True), + ) except: log.debug('', exc_info=True) if isinstance(self.core.tabs.current_tab, tabs.XMLTab): diff --git a/poezio/mam.py b/poezio/mam.py index 0f745f30..50dad4a3 100644 --- a/poezio/mam.py +++ b/poezio/mam.py @@ -18,6 +18,7 @@ from poezio import tabs from poezio import xhtml, colors from poezio.config import config from poezio.text_buffer import TextBuffer +from poezio.ui.types import Message class DiscoInfoException(Exception): pass @@ -63,17 +64,15 @@ def add_line( nick = nick.split('/')[0] color = get_theme().COLOR_OWN_NICK text_buffer.add_message( - txt=text, - time=time, - nickname=nick, - nick_color=color, - history=True, - user=None, - highlight=False, - top=top, - identifier=None, - str_time=None, - jid=None, + Message( + txt=text, + time=time, + nickname=nick, + nick_color=color, + history=True, + user=None, + top=top, + ) ) diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py index fca54860..fbb0c4cf 100644 --- a/poezio/tabs/basetabs.py +++ b/poezio/tabs/basetabs.py @@ -31,7 +31,13 @@ from typing import ( TYPE_CHECKING, ) -from poezio import mam, poopt, timed_events, xhtml, windows +from poezio import ( + mam, + poopt, + timed_events, + xhtml, + windows +) from poezio.core.structs import Command, Completion, Status from poezio.common import safeJID from poezio.config import config @@ -39,7 +45,9 @@ from poezio.decorators import command_args_parser, refresh_wrapper from poezio.logger import logger from poezio.text_buffer import TextBuffer from poezio.theming import get_theme, dump_tuple -from poezio.windows.funcs import truncate_nick +from poezio.ui.funcs import truncate_nick +from poezio.ui.consts import LONG_FORMAT_LENGTH +from poezio.ui.types import BaseMessage, InfoMessage from slixmpp import JID, InvalidJID, Message @@ -565,40 +573,19 @@ class ChatTab(Tab): def general_jid(self) -> JID: raise NotImplementedError - def log_message(self, - txt: str, - nickname: str, - time: Optional[datetime] = None, - typ=1): + def log_message(self, message: BaseMessage, typ=1): """ Log the messages in the archives. """ name = self.jid.bare - if not logger.log_message(name, nickname, txt, date=time, typ=typ): + if not isinstance(message, Message): + return + if not logger.log_message(name, message.nickname, message.txt, date=message.time, typ=typ): self.core.information('Unable to write in the log file', 'Error') - def add_message(self, - txt, - time=None, - nickname=None, - forced_user=None, - nick_color=None, - identifier=None, - jid=None, - history=None, - typ=1, - highlight=False): - self.log_message(txt, nickname, time=time, typ=typ) - self._text_buffer.add_message( - txt, - time=time, - nickname=nickname, - highlight=highlight, - nick_color=nick_color, - history=history, - user=forced_user, - identifier=identifier, - jid=jid) + def add_message(self, message: BaseMessage, typ=1): + self.log_message(message, typ=typ) + self._text_buffer.add_message(message) def modify_message(self, txt, @@ -607,10 +594,10 @@ class ChatTab(Tab): user=None, jid=None, nickname=None): - self.log_message(txt, nickname, typ=1) message = self._text_buffer.modify_message( - txt, old_id, new_id, time=time, user=user, jid=jid) + txt, old_id, new_id, user=user, jid=jid) if message: + self.log_message(message, typ=1) self.text_win.modify_message(message.identifier, message) self.core.refresh_window() return True @@ -833,12 +820,8 @@ class ChatTab(Tab): if message.me: offset += 1 if timestamp: - if message.str_time: - offset += 1 + len(message.str_time) - if theme.CHAR_TIME_LEFT and message.str_time: - offset += 1 - if theme.CHAR_TIME_RIGHT and message.str_time: - offset += 1 + if message.history: + offset += 1 + LONG_FORMAT_LENGTH lines = poopt.cut_text(txt, self.text_win.width - offset - 1) for line in lines: built_lines.append(line) @@ -1007,7 +990,10 @@ class OneToOneTab(ChatTab): msg += 'status: %s, ' % status.message if status.show in SHOW_NAME: msg += 'show: %s, ' % SHOW_NAME[status.show] - self.add_message(msg[:-2], typ=2) + self.add_message( + InfoMessage(txt=msg[:-2]), + typ=2, + ) def ack_message(self, msg_id: str, msg_jid: JID): """ @@ -1039,11 +1025,14 @@ class OneToOneTab(ChatTab): message.send() body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True) self._text_buffer.add_message( - body, - nickname=self.core.own_nick, - nick_color=get_theme().COLOR_OWN_NICK, - identifier=message['id'], - jid=self.core.xmpp.boundjid) + Message( + body, + nickname=self.core.own_nick, + nick_color=get_theme().COLOR_OWN_NICK, + identifier=message['id'], + jid=self.core.xmpp.boundjid, + ) + ) self.refresh() def check_features(self): diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py index 410c5eda..70005f0f 100644 --- a/poezio/tabs/conversationtab.py +++ b/poezio/tabs/conversationtab.py @@ -28,6 +28,7 @@ from poezio.roster import roster from poezio.text_buffer import CorrectionError from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser +from poezio.ui.types import InfoMessage log = logging.getLogger(__name__) @@ -179,7 +180,7 @@ class ConversationTab(OneToOneTab): (' and their last status was %s' % status) if status else '', ) - self.add_message(msg) + self.add_message(InfoMessage(msg), typ=0) self.core.refresh_window() self.core.xmpp.plugin['xep_0012'].get_last_activity( @@ -200,17 +201,21 @@ class ConversationTab(OneToOneTab): if resource: status = ( 'Status: %s' % resource.status) if resource.status else '' - self._text_buffer.add_message( - "\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % { - 'show': resource.presence or 'available', - 'status': status, - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }) + self.add_message( + InfoMessage( + "Show: %(show)s, %(status)s" % { + 'show': resource.presence or 'available', + 'status': status, + } + ), + typ=0, + ) return True else: - self._text_buffer.add_message( - "\x19%(info_col)s}No information available\x19o" % - {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}) + self.add_message( + InfoMessage("No information available"), + typ=0, + ) return True @command_args_parser.quoted(0, 1) diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py index 73359610..f0c055b1 100644 --- a/poezio/tabs/muctab.py +++ b/poezio/tabs/muctab.py @@ -40,6 +40,7 @@ from poezio.roster import roster from poezio.theming import get_theme, dump_tuple from poezio.user import User from poezio.core.structs import Completion, Status +from poezio.ui.types import BaseMessage, Message, InfoMessage, StatusMessage log = logging.getLogger(__name__) @@ -75,6 +76,8 @@ class MucTab(ChatTab): self.users = [] # type: List[User] # private conversations self.privates = [] # type: List[Tab] + # Used to check if we are still receiving muc history + self.last_message_was_history = None # type: Optional[bool] self.topic = '' self.topic_from = '' # Self ping event, so we can cancel it when we leave the room @@ -197,8 +200,7 @@ class MucTab(ChatTab): 'color_spec': spec_col, 'nick': self.own_nick, } - - self.add_message(msg, typ=2) + self.add_message(InfoMessage(msg), typ=2) self.disconnect() muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick, message) @@ -301,7 +303,7 @@ class MucTab(ChatTab): 'role': user.role or 'None', 'status': '\n%s' % user.status if user.status else '' } - self.add_message(info, typ=0) + self.add_message(InfoMessage(info), typ=0) return True def change_topic(self, topic: str): @@ -327,9 +329,13 @@ class MucTab(ChatTab): else: user_string = '' - self._text_buffer.add_message( - "\x19%s}The subject of the room is: \x19%s}%s %s" % - (info_text, norm_text, self.topic, user_string)) + self.add_message( + InfoMessage( + "The subject of the room is: \x19%s}%s %s" % + (norm_text, self.topic, user_string), + ), + typ=0, + ) @refresh_wrapper.always def recolor(self, random_colors=False): @@ -558,28 +564,32 @@ class MucTab(ChatTab): 'nick_col': color, 'info_col': info_col, } - self.add_message(enable_message, typ=2) + self.add_message(InfoMessage(enable_message), typ=2) self.core.enable_private_tabs(self.jid.bare, enable_message) if '201' in status_codes: self.add_message( - '\x19%(info_col)s}Info: The room ' - 'has been created' % {'info_col': info_col}, - typ=0) + InfoMessage('Info: The room has been created'), + typ=0 + ) if '170' in status_codes: self.add_message( - '\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is publicly logged' % { - 'info_col': info_col, - 'warn_col': warn_col - }, + InfoMessage( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is publicly logged' % { + 'info_col': info_col, + 'warn_col': warn_col + }, + ), typ=0) if '100' in status_codes: self.add_message( - '\x19%(warn_col)s}Warning:\x19%(info_col)s}' - ' This room is not anonymous.' % { - 'info_col': info_col, - 'warn_col': warn_col - }, + InfoMessage( + '\x19%(warn_col)s}Warning:\x19%(info_col)s}' + ' This room is not anonymous.' % { + 'info_col': info_col, + 'warn_col': warn_col + }, + ), typ=0) def handle_presence_joined(self, presence, status_codes): @@ -635,18 +645,20 @@ class MucTab(ChatTab): def on_non_member_kicked(self): """We have been kicked because the MUC is members-only""" self.add_message( - '\x19%(info_col)s}You have been kicked because you ' - 'are not a member and the room is now members-only.' % - {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + InfoMessage( + 'You have been kicked because you ' + 'are not a member and the room is now members-only.' + ), typ=2) self.disconnect() def on_muc_shutdown(self): """We have been kicked because the MUC service is shutting down""" self.add_message( - '\x19%(info_col)s}You have been kicked because the' - ' MUC service is shutting down.' % - {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, + InfoMessage( + 'You have been kicked because the' + ' MUC service is shutting down.' + ), typ=2) self.disconnect() @@ -693,7 +705,7 @@ class MucTab(ChatTab): 'jid_color': dump_tuple(theme.COLOR_MUC_JID), 'color_spec': spec_col, } - self.add_message(msg, typ=2) + self.add_message(InfoMessage(msg), typ=2) self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick) def on_user_nick_change(self, presence, user, from_nick, from_room): @@ -723,14 +735,16 @@ class MucTab(ChatTab): old_color = color = 3 info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) self.add_message( - '\x19%(old_color)s}%(old)s\x19%(info_col)s} is' - ' now known as \x19%(color)s}%(new)s' % { - 'old': from_nick, - 'new': new_nick, - 'color': color, - 'old_color': old_color, - 'info_col': info_col - }, + InfoMessage( + '\x19%(old_color)s}%(old)s\x19%(info_col)s} is' + ' now known as \x19%(color)s}%(new)s' % { + 'old': from_nick, + 'new': new_nick, + 'color': color, + 'old_color': old_color, + 'info_col': info_col + }, + ), typ=2) # rename the private tabs if needed self.core.rename_private_tabs(self.jid.bare, from_nick, user) @@ -814,7 +828,7 @@ class MucTab(ChatTab): 'reason': reason.text, 'info_col': info_col } - self.add_message(kick_msg, typ=2) + self.add_message(InfoMessage(kick_msg), typ=2) def on_user_kicked(self, presence, user, from_nick): """ @@ -892,7 +906,7 @@ class MucTab(ChatTab): 'reason': reason.text, 'info_col': info_col } - self.add_message(kick_msg, typ=2) + self.add_message(InfoMessage(kick_msg), typ=2) def on_user_leave_groupchat(self, user: User, @@ -957,7 +971,7 @@ class MucTab(ChatTab): } if status: leave_msg += ' (\x19o%s\x19%s})' % (status, info_col) - self.add_message(leave_msg, typ=2) + self.add_message(InfoMessage(leave_msg), typ=2) self.core.on_user_left_private_conversation(from_room, user, status) def on_user_change_status(self, user, from_nick, from_room, affiliation, @@ -1016,7 +1030,7 @@ class MucTab(ChatTab): or show != user.show or status != user.status)) or ( affiliation != user.affiliation or role != user.role): # display the message in the room - self._text_buffer.add_message(msg) + self.add_message(InfoMessage(msg)) self.core.on_user_changed_status_in_private( '%s/%s' % (from_room, from_nick), Status(show, status)) self.users.remove(user) @@ -1042,13 +1056,15 @@ class MucTab(ChatTab): """ return self.topic.replace('\n', '|') - def log_message(self, txt, nickname, time=None, typ=1): + def log_message(self, msg: Message, typ=1): """ Log the messages in the archives, if it needs to be """ - if time is None and self.joined: # don't log the history messages - if not logger.log_message(self.jid.bare, nickname, txt, typ=typ): + if not isinstance(msg, Message): + return + if not msg.history and self.joined: # don't log the history messages + if not logger.log_message(self.jid.bare, msg.nickname, msg.txt, typ=typ): self.core.information('Unable to write in the log file', 'Error') @@ -1061,65 +1077,37 @@ class MucTab(ChatTab): return user return None - def add_message(self, txt, time=None, nickname=None, **kwargs): + def add_message(self, msg: BaseMessage, typ=1): """ Note that user can be None even if nickname is not None. It happens when we receive an history message said by someone who is not in the room anymore Return True if the message highlighted us. False otherwise. """ - # reset self-ping interval if self.self_ping_event: self.enable_self_ping_event() - - self.log_message(txt, nickname, time=time, typ=kwargs.get('typ', 1)) - args = dict() - for key, value in kwargs.items(): - if key not in ('typ', 'forced_user'): - args[key] = value - if nickname is not None: - user = self.get_user_by_name(nickname) - else: - user = None - - if user: - user.set_last_talked(datetime.now()) - args['user'] = user - if not user and kwargs.get('forced_user'): - args['user'] = kwargs['forced_user'] - - if (not time and nickname and nickname != self.own_nick - and self.state != 'current'): - if (self.state != 'highlight' - and config.get_by_tabname('notify_messages', self.jid.bare)): + super().add_message(msg, typ=typ) + if not isinstance(msg, Message): + return + if msg.user: + msg.user.set_last_talked(msg.time) + if config.get_by_tabname('notify_messages', self.jid.bare) and self.state != 'current': + if msg.nickname != self.own_nick and not msg.history: self.state = 'message' - if time and not txt.startswith('/me'): - txt = '\x19%(info_col)s}%(txt)s' % { - 'txt': txt, - 'info_col': dump_tuple(get_theme().COLOR_LOG_MSG) - } - elif not nickname: - txt = '\x19%(info_col)s}%(txt)s' % { - 'txt': txt, - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - } - elif not kwargs.get('highlight'): # TODO - args['highlight'] = self.do_highlight(txt, time, nickname) - time = time or datetime.now() - self._text_buffer.add_message(txt, time, nickname, **args) - return args.get('highlight', False) + msg.highlight = self.do_highlight(msg.txt, msg.nickname, msg.delayed) + return msg.highlight def modify_message(self, txt, old_id, new_id, time=None, + delayed: bool = False, nickname=None, user=None, jid=None): - self.log_message(txt, nickname, time=time, typ=1) - highlight = self.do_highlight(txt, time, nickname, corrected=True) + highlight = self.do_highlight(txt, nickname, delayed, corrected=True) message = self._text_buffer.modify_message( txt, old_id, @@ -1129,6 +1117,7 @@ class MucTab(ChatTab): user=user, jid=jid) if message: + self.log_message(message, typ=1) self.text_win.modify_message(message.identifier, message) return highlight return False @@ -1192,9 +1181,11 @@ class MucTab(ChatTab): def on_self_ping_failed(self, iq): if not self.lagged: self.lagged = True - info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) self._text_buffer.add_message( - "\x19%s}MUC service not responding." % info_text) + InfoMessage( + "MUC service not responding." + ), + ) self._state = 'disconnected' self.core.refresh_window() self.enable_self_ping_event() @@ -1202,9 +1193,9 @@ class MucTab(ChatTab): def reset_lag(self): if self.lagged: self.lagged = False - info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - self._text_buffer.add_message( - "\x19%s}MUC service is responding again." % info_text) + self.add_message( + InfoMessage("MUC service is responding again.") + ) if self != self.core.tabs.current_tab: self._state = 'joined' else: @@ -1323,28 +1314,38 @@ class MucTab(ChatTab): def build_highlight_regex(self, nickname): return re.compile(r"(^|\W)" + re.escape(nickname) + r"(\W|$)", re.I) - def is_highlight(self, txt, time, nickname, own_nick, highlight_on, - corrected=False): + def is_highlight(self, txt: str, nick: str, highlight_on: List[str], + delayed, corrected: bool = False): + """ + Highlight algorithm for MUC tabs + """ + highlighted = False - if (not time or corrected) and nickname and nickname != own_nick: - if self.build_highlight_regex(own_nick).search(txt): + if not delayed and not corrected: + if self.build_highlight_regex(nick).search(txt): highlighted = True else: - highlight_words = highlight_on.split(':') - for word in highlight_words: + for word in highlight_on: if word and word.lower() in txt.lower(): highlighted = True break return highlighted - def do_highlight(self, txt, time, nickname, corrected=False): + def do_highlight(self, txt, nickname, delayed, corrected=False): """ Set the tab color and returns the nick color """ own_nick = self.own_nick - highlight_on = config.get_by_tabname('highlight_on', self.general_jid) - highlighted = self.is_highlight(txt, time, nickname, own_nick, - highlight_on, corrected) + highlight_on = config.get_by_tabname( + 'highlight_on', + self.general_jid, + ).split(':') + + # Don't highlight on info message or our own messages + if not nickname or nickname == own_nick: + return False + + highlighted = self.is_highlight(txt, own_nick, highlight_on, delayed, corrected) if highlighted and self.joined: if self.state != 'current': self.state = 'highlight' @@ -1551,7 +1552,7 @@ class MucTab(ChatTab): buff.append('\n') message = ' '.join(buff) - self._text_buffer.add_message(message) + self.add_message(InfoMessage(message), typ=0) self.text_win.refresh() self.input.refresh() @@ -1650,16 +1651,18 @@ class MucTab(ChatTab): if all_errors: self.add_message( - 'Can\'t access affiliations', - highlight=True, - nickname='Error', - nick_color=theme.COLOR_ERROR_MSG, + Message( + 'Can\'t access affiliations', + highlight=True, + nickname='Error', + nick_color=theme.COLOR_ERROR_MSG, + ), typ=2, ) self.core.refresh_window() return None - self._text_buffer.add_message('Affiliations') + self._text_buffer.add_message(InfoMessage('Affiliations')) for iq in iqs: if isinstance(iq, (IqError, IqTimeout)): continue @@ -1672,12 +1675,12 @@ class MucTab(ChatTab): affiliation = items[0].get('affiliation') aff_char = aff_colors[affiliation] self._text_buffer.add_message( - ' %s%s' % (aff_char, affiliation.capitalize()), + InfoMessage(' %s%s' % (aff_char, affiliation.capitalize())), ) items = map(lambda i: i.get('jid'), items) for ajid in sorted(items): - self._text_buffer.add_message(' %s' % ajid) + self._text_buffer.add_message(InfoMessage(' %s' % ajid)) self.core.refresh_window() return None diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py index 7206240f..b43294a1 100644 --- a/poezio/tabs/privatetab.py +++ b/poezio/tabs/privatetab.py @@ -27,6 +27,7 @@ from poezio.decorators import refresh_wrapper from poezio.logger import logger from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser +from poezio.ui.types import BaseMessage, Message, InfoMessage log = logging.getLogger(__name__) @@ -106,12 +107,14 @@ class PrivateTab(OneToOneTab): def remove_information_element(plugin_name): del PrivateTab.additional_information[plugin_name] - def log_message(self, txt, nickname, time=None, typ=1): + def log_message(self, msg: BaseMessage, typ=1): """ Log the messages in the archives. """ + if not isinstance(msg, Message): + return if not logger.log_message( - self.jid.full, nickname, txt, date=time, typ=typ): + self.jid.full, msg.nickname, msg.txt, date=msg.time, typ=typ): self.core.information('Unable to write in the log file', 'Error') def on_close(self): @@ -163,8 +166,6 @@ class PrivateTab(OneToOneTab): self.core.events.trigger('private_say', msg, self) if not msg['body']: return - user = self.parent_muc.get_user_by_name(self.own_nick) - replaced = False if correct or msg['replace']['id']: msg['replace']['id'] = self.last_sent_message['id'] else: @@ -311,13 +312,15 @@ class PrivateTab(OneToOneTab): display a message. """ self.add_message( - '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now ' - 'known as \x19%(nick_col)s}%(new)s' % { - 'old': old_nick, - 'new': user.nick, - 'nick_col': dump_tuple(user.color), - 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) - }, + InfoMessage( + '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now ' + 'known as \x19%(nick_col)s}%(new)s' % { + 'old': old_nick, + 'new': user.nick, + 'nick_col': dump_tuple(user.color), + 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT) + }, + ), typ=2) new_jid = self.jid.bare + '/' + user.nick self.name = new_jid @@ -338,27 +341,31 @@ class PrivateTab(OneToOneTab): if not status_message: self.add_message( - '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' - '%(nick)s\x19%(info_col)s} has left the room' % { - 'nick': user.nick, - 'spec': theme.CHAR_QUIT, - 'nick_col': color, - 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), - 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) - }, + InfoMessage( + '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' + '%(nick)s\x19%(info_col)s} has left the room' % { + 'nick': user.nick, + 'spec': theme.CHAR_QUIT, + 'nick_col': color, + 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), typ=2) else: self.add_message( - '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' - '%(nick)s\x19%(info_col)s} has left the room' - ' (%(status)s)' % { - 'status': status_message, - 'nick': user.nick, - 'spec': theme.CHAR_QUIT, - 'nick_col': color, - 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), - 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) - }, + InfoMessage( + '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}' + '%(nick)s\x19%(info_col)s} has left the room' + ' (%(status)s)' % { + 'status': status_message, + 'nick': user.nick, + 'spec': theme.CHAR_QUIT, + 'nick_col': color, + 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), typ=2) return self.core.tabs.current_tab is self @@ -378,26 +385,28 @@ class PrivateTab(OneToOneTab): if user: color = dump_tuple(user.color) self.add_message( - '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19' - '%(info_col)s} joined the room' % { - 'nick': nick, - 'color': color, - 'spec': theme.CHAR_JOIN, - 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR), - 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) - }, + InfoMessage( + '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19' + '%(info_col)s} joined the room' % { + 'nick': nick, + 'color': color, + 'spec': theme.CHAR_JOIN, + 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR), + 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT) + }, + ), typ=2) return self.core.tabs.current_tab is self def activate(self, reason=None): self.on = True if reason: - self.add_message(txt=reason, typ=2) + self.add_message(InfoMessage(reason), typ=2) def deactivate(self, reason=None): self.on = False if reason: - self.add_message(txt=reason, typ=2) + self.add_message(InfoMessage(reason), typ=2) def matching_names(self): return [(3, self.jid.resource), (4, self.name)] @@ -407,9 +416,12 @@ class PrivateTab(OneToOneTab): error = '\x19%s}%s\x19o' % (dump_tuple(theme.COLOR_CHAR_NACK), error_message) self.add_message( - error, - highlight=True, - nickname='Error', - nick_color=theme.COLOR_ERROR_MSG, - typ=2) + Message( + error, + highlight=True, + nickname='Error', + nick_color=theme.COLOR_ERROR_MSG, + ), + typ=2, + ) self.core.refresh_window() diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py index 6f43cca1..50b8c0d5 100644 --- a/poezio/tabs/rostertab.py +++ b/poezio/tabs/rostertab.py @@ -27,6 +27,7 @@ from poezio.theming import get_theme, dump_tuple from poezio.decorators import command_args_parser from poezio.core.structs import Command, Completion from poezio.tabs import Tab +from poezio.ui.types import InfoMessage log = logging.getLogger(__name__) @@ -402,11 +403,11 @@ class RosterInfoTab(Tab): if not tab: log.debug('Received message from nonexistent tab: %s', message['from']) - message = '\x19%(info_col)s}Cannot send message to %(jid)s: contact blocked' % { + message = 'Cannot send message to %(jid)s: contact blocked' % { 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'jid': message['from'], } - tab.add_message(message) + tab.add_message(InfoMessage(message), typ=0) @command_args_parser.ignored def command_list_blocks(self): diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py index c4a50df8..2611d9d5 100644 --- a/poezio/tabs/xmltab.py +++ b/poezio/tabs/xmltab.py @@ -65,7 +65,7 @@ class XMLTab(Tab): self.filtered_buffer = text_buffer.TextBuffer() self.info_header = windows.XMLInfoWin() - self.text_win = windows.XMLTextWin() + self.text_win = windows.TextWin() self.core_buffer.add_window(self.text_win) self.default_help_message = windows.HelpText("/ to enter a command") @@ -262,7 +262,10 @@ class XMLTab(Tab): else: xml = self.core_buffer.messages[:] text = '\n'.join( - ('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) + ('%s %s %s' % ( + msg.time.strftime('%H:%M:%S'), + 'IN' if msg.incoming else 'OUT', + clean_text(msg.txt)) for msg in xml)) filename = os.path.expandvars(os.path.expanduser(args[0])) try: diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py index 2c0d192a..3b3ac051 100644 --- a/poezio/text_buffer.py +++ b/poezio/text_buffer.py @@ -11,93 +11,20 @@ independently by their TextWins. import logging log = logging.getLogger(__name__) -from typing import Dict, Union, Optional, List, Tuple +from typing import ( + Dict, + List, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) from datetime import datetime from poezio.config import config -from poezio.theming import get_theme, dump_tuple - - -class Message: - __slots__ = ('txt', 'nick_color', 'time', 'str_time', 'nickname', 'user', - 'identifier', 'top', 'highlight', 'me', 'old_message', 'revisions', - 'jid', 'ack') - - def __init__(self, - txt: str, - time: Optional[datetime], - nickname: Optional[str], - nick_color: Optional[Tuple], - history: bool, - user: Optional[str], - identifier: Optional[str], - top: Optional[bool] = False, - str_time: Optional[str] = None, - highlight: bool = False, - old_message: Optional['Message'] = None, - revisions: int = 0, - jid: Optional[str] = None, - ack: int = 0) -> None: - """ - Create a new Message object with parameters, check for /me messages, - and delayed messages - """ - time = time if time is not None else datetime.now() - if txt.startswith('/me '): - me = True - txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE), - txt[4:]) - else: - me = False - str_time = time.strftime("%H:%M:%S") - if history: - txt = txt.replace( - '\x19o', - '\x19o\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)) - str_time = time.strftime("%Y-%m-%d %H:%M:%S") - - self.txt = txt.replace('\t', ' ') + '\x19o' - self.nick_color = nick_color - self.time = time - self.str_time = str_time - self.nickname = nickname - self.user = user - self.identifier = identifier - self.top = top - 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) +from poezio.ui.types import Message, BaseMessage + +if TYPE_CHECKING: + from poezio.windows.text_win import TextWin class CorrectionError(Exception): @@ -120,50 +47,25 @@ class TextBuffer: messages_nb_limit = config.get('max_messages_in_memory') self._messages_nb_limit = messages_nb_limit # type: int # Message objects - self.messages = [] # type: List[Message] + self.messages = [] # type: List[BaseMessage] # COMPAT: Correction id -> Original message id. self.correction_ids = {} # type: Dict[str, str] # we keep track of one or more windows # so we can pass the new messages to them, as they are added, so # they (the windows) can build the lines from the new message - self._windows = [] + self._windows = [] # type: List[TextWin] def add_window(self, win) -> None: self._windows.append(win) @property - def last_message(self) -> Optional[Message]: + def last_message(self) -> Optional[BaseMessage]: return self.messages[-1] if self.messages else None - def add_message(self, - txt: str, - time: Optional[datetime] = None, - nickname: Optional[str] = None, - nick_color: Optional[Tuple] = None, - history: bool = False, - user: Optional[str] = None, - highlight: bool = False, - top: Optional[bool] = False, - identifier: Optional[str] = None, - str_time: Optional[str] = None, - jid: Optional[str] = None, - ack: int = 0) -> int: + def add_message(self, msg: BaseMessage): """ Create a message and add it to the text buffer """ - msg = Message( - txt, - time, - nickname, - nick_color, - history, - user, - identifier, - top, - str_time=str_time, - highlight=highlight, - jid=jid, - ack=ack) self.messages.append(msg) while len(self.messages) > self._messages_nb_limit: @@ -176,13 +78,11 @@ class TextBuffer: # build the lines from the new message nb = window.build_new_message( msg, - history=history, - highlight=highlight, timestamp=show_timestamps, - top=top, nick_size=nick_size) if ret_val == 0: ret_val = nb + top = isinstance(msg, Message) and msg.top if window.pos != 0 and top is False: window.scroll_up(nb) @@ -222,6 +122,8 @@ class TextBuffer: if i == -1: return None msg = self.messages[i] + if not isinstance(msg, Message): + return None if msg.ack == 1: # Message was already acked return False if msg.jid != jid: @@ -240,7 +142,7 @@ class TextBuffer: highlight: bool = False, time: Optional[datetime] = None, user: Optional[str] = None, - jid: Optional[str] = None): + jid: Optional[str] = None) -> Message: """ Correct a message in a text buffer. @@ -259,10 +161,11 @@ class TextBuffer: raise CorrectionError("nothing to replace") msg = self.messages[i] - + if not isinstance(msg, Message): + raise CorrectionError('Wrong message type') if msg.user and msg.user is not user: raise CorrectionError("Different users") - elif len(msg.str_time) > 8: # ugly + elif msg.delayed: raise CorrectionError("Delayed message") elif not msg.user and (msg.jid is None or jid is None): raise CorrectionError('Could not check the ' @@ -277,13 +180,12 @@ class TextBuffer: self.correction_ids[new_id] = orig_id message = Message( - txt, - time, - msg.nickname, - msg.nick_color, - False, - msg.user, - orig_id, + txt=txt, + time=time, + nickname=msg.nickname, + nick_color=msg.nick_color, + user=msg.user, + identifier=orig_id, highlight=highlight, old_message=msg, revisions=msg.revisions + 1, 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..0838d953 --- /dev/null +++ b/poezio/ui/consts.py @@ -0,0 +1,14 @@ +from datetime import datetime + +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' + +# Short date format (only show time) +SHORT_FORMAT = '%H:%M:%S' +SHORT_FORMAT_LENGTH = len(datetime.now().strftime(SHORT_FORMAT)) + +# Long date format (show date and time) +LONG_FORMAT = '%Y-%m-%d %H:%M:%S' +LONG_FORMAT_LENGTH = len(datetime.now().strftime(LONG_FORMAT)) diff --git a/poezio/windows/funcs.py b/poezio/ui/funcs.py index 22977374..023432ee 100644 --- a/poezio/windows/funcs.py +++ b/poezio/ui/funcs.py @@ -4,14 +4,14 @@ Standalone functions used by the modules import string from typing import Optional, List -from poezio.windows.base_wins import FORMAT_CHAR, format_chars +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 + to_find = chars or FORMAT_CHARS pos = -1 for char in to_find: p = text.find(char) @@ -22,12 +22,14 @@ def find_first_format_char(text: str, return pos -def truncate_nick(nick: Optional[str], size=10) -> Optional[str]: +def truncate_nick(nick: Optional[str], size=10) -> str: if size < 1: size = 1 - if nick and len(nick) > size: - return nick[:size] + '…' - return nick + if nick: + if len(nick) > size: + return nick[:size] + '…' + return nick + return '' def parse_attrs(text: str, previous: Optional[List[str]] = None) -> List[str]: diff --git a/poezio/ui/render.py b/poezio/ui/render.py new file mode 100644 index 00000000..b8368312 --- /dev/null +++ b/poezio/ui/render.py @@ -0,0 +1,253 @@ +import logging +import curses + +from datetime import datetime +from functools import singledispatch +from math import ceil, log10 +from typing import ( + List, + Tuple, + TYPE_CHECKING, +) + +from poezio import poopt +from poezio.theming import ( + get_theme, +) +from poezio.ui.consts import ( + FORMAT_CHAR, + LONG_FORMAT, + SHORT_FORMAT, +) +from poezio.ui.funcs import ( + truncate_nick, + parse_attrs, +) +from poezio.ui.types import ( + BaseMessage, + Message, + StatusMessage, + 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 = [] # type: 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='') + if msg.top: + generated_lines.reverse() + 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(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 + """ + offset = 0 + if with_timestamps: + logging.debug(msg) + 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) + if msg.me: + with win.colored_text(color=get_theme().COLOR_ME_MESSAGE): + win.addstr('* ') + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr(' ') + offset += 3 + else: + PreMessageHelpers.write_nickname(win, nick, color, msg.highlight) + offset += PreMessageHelpers.write_revisions(win, msg) + win.addstr('> ') + offset += 2 + 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: + if history: + format = LONG_FORMAT + else: + format = SHORT_FORMAT + logging.debug(time) + time_str = time.strftime(format) + color = get_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..ae72b6b9 --- /dev/null +++ b/poezio/ui/types.py @@ -0,0 +1,191 @@ + +from datetime import datetime +from math import ceil, log10 +from typing import Union, Optional, List, Tuple +from poezio.ui.funcs import truncate_nick +from poezio import poopt +from poezio.user import User +from poezio.theming import dump_tuple, get_theme +from poezio.ui.consts import ( + SHORT_FORMAT_LENGTH, + LONG_FORMAT_LENGTH, +) + + +class BaseMessage: + __slots__ = ('txt', 'time', 'identifier') + + 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: + return SHORT_FORMAT_LENGTH + 1 + + +class InfoMessage(BaseMessage): + 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 XMLLog(BaseMessage): + """XML Log message""" + __slots__ = ('txt', 'time', 'identifier', 'incoming') + + def __init__( + self, + txt: str, + incoming: bool, + ): + BaseMessage.__init__( + self, + txt=txt, + identifier='', + ) + self.txt = txt + self.identifier = '' + self.incoming = incoming + + def compute_offset(self, with_timestamps: bool, nick_size: int) -> int: + offset = 0 + theme = get_theme() + if with_timestamps: + offset += 1 + SHORT_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): + __slots__ = ('txt', 'time', 'identifier', 'format_string', 'format_args') + + 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): + __slots__ = ('txt', 'nick_color', 'time', 'nickname', 'user', 'delayed', 'history', + 'identifier', 'top', 'highlight', 'me', 'old_message', 'revisions', + 'jid', 'ack') + + 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] = '', + top: Optional[bool] = False, + highlight: bool = False, + old_message: Optional['Message'] = None, + revisions: int = 0, + jid: Optional[str] = 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.top = top + 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: + offset = 0 + if with_timestamps: + if self.history: + offset += 1 + LONG_FORMAT_LENGTH + else: + offset += 1 + SHORT_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 diff --git a/poezio/user.py b/poezio/user.py index 146a70da..bead0a93 100644 --- a/poezio/user.py +++ b/poezio/user.py @@ -97,7 +97,8 @@ class User: """ time: datetime object """ - self.last_talked = time + if time > self.last_talked: + self.last_talked = time def has_talked_since(self, t: int) -> bool: """ diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py index 8775b0a2..bbd6dc30 100644 --- a/poezio/windows/__init__.py +++ b/poezio/windows/__init__.py @@ -17,7 +17,7 @@ from poezio.windows.list import ListWin, ColumnHeaderWin from poezio.windows.misc import VerticalSeparator from poezio.windows.muc import UserList, Topic from poezio.windows.roster_win import RosterWin, ContactInfoWin -from poezio.windows.text_win import BaseTextWin, TextWin, XMLTextWin +from poezio.windows.text_win import TextWin from poezio.windows.image import ImageWin __all__ = [ @@ -28,5 +28,5 @@ __all__ = [ 'BookmarksInfoWin', 'ConfirmStatusWin', 'HelpText', 'Input', 'HistoryInput', 'MessageInput', 'CommandInput', 'ListWin', 'ColumnHeaderWin', 'VerticalSeparator', 'UserList', 'Topic', 'RosterWin', - 'ContactInfoWin', 'TextWin', 'XMLTextWin', 'ImageWin', 'BaseTextWin' + 'ContactInfoWin', 'TextWin', 'ImageWin' ] diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py index ac6b4804..d6c72912 100644 --- a/poezio/windows/base_wins.py +++ b/poezio/windows/base_wins.py @@ -20,10 +20,7 @@ from typing import Optional, Tuple, TYPE_CHECKING from poezio.theming import to_curses_attr, read_tuple -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' +from poezio.ui.consts import FORMAT_CHAR if TYPE_CHECKING: from _curses import _CursesWindow # pylint: disable=E0611 @@ -80,6 +77,19 @@ class Win: # of the screen. pass + @contextmanager + def colored_text(self, color: Optional[Tuple]=None, attr: Optional[int]=None): + """Context manager which sets up an attr/color when inside""" + if attr is None: + if color is not None: + attr = to_curses_attr(color) + else: + yield None + return + self._win.attron(attr) + yield None + self._win.attroff(attr) + def addstr(self, *args) -> None: """ Safe call to addstr diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py index 3a8d1863..d31130fe 100644 --- a/poezio/windows/info_wins.py +++ b/poezio/windows/info_wins.py @@ -10,7 +10,7 @@ from poezio.common import safeJID from poezio.config import config from poezio.windows.base_wins import Win -from poezio.windows.funcs import truncate_nick +from poezio.ui.funcs import truncate_nick from poezio.theming import get_theme, to_curses_attr diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py index 84b95599..5cca8803 100644 --- a/poezio/windows/inputs.py +++ b/poezio/windows/inputs.py @@ -10,8 +10,9 @@ from typing import List, Dict, Callable, Optional from poezio import keyboard from poezio import common from poezio import poopt -from poezio.windows.base_wins import Win, format_chars -from poezio.windows.funcs import find_first_format_char +from poezio.windows.base_wins import Win +from poezio.ui.consts import FORMAT_CHARS +from poezio.ui.funcs import find_first_format_char from poezio.config import config from poezio.theming import to_curses_attr @@ -487,7 +488,7 @@ class Input(Win): (\x0E to \x19 instead of \x19 + attr). We do not use any } char in this version """ - chars = format_chars + '\n' + chars = FORMAT_CHARS + '\n' if y is not None and x is not None: self.move(y, x) format_char = find_first_format_char(text, chars) @@ -497,7 +498,7 @@ class Input(Win): if text[format_char] == '\n': attr_char = '|' else: - attr_char = self.text_attributes[format_chars.index( + attr_char = self.text_attributes[FORMAT_CHARS.index( text[format_char])] self.addstr(text[:format_char]) self.addstr(attr_char, curses.A_REVERSE) @@ -696,7 +697,7 @@ class MessageInput(HistoryInput): def cb(attr_char): if attr_char in self.text_attributes: - char = format_chars[self.text_attributes.index(attr_char)] + char = FORMAT_CHARS[self.text_attributes.index(attr_char)] self.do_command(char, False) self.rewrite_text() diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py index f4c78c2c..a1b12ae9 100644 --- a/poezio/windows/text_win.py +++ b/poezio/windows/text_win.py @@ -9,36 +9,27 @@ from math import ceil, log10 from typing import Optional, List, Union from poezio.windows.base_wins import Win, FORMAT_CHAR -from poezio.windows.funcs import truncate_nick, parse_attrs +from poezio.ui.funcs import truncate_nick, parse_attrs +from poezio.text_buffer import TextBuffer from poezio import poopt from poezio.config import config from poezio.theming import to_curses_attr, get_theme, dump_tuple -from poezio.text_buffer import Message +from poezio.ui.types import Message, BaseMessage +from poezio.ui.render import Line, build_lines, write_pre log = logging.getLogger(__name__) -# 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: Message, 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 - - -class BaseTextWin(Win): +class TextWin(Win): __slots__ = ('lines_nb_limit', 'pos', 'built_lines', 'lock', 'lock_buffer', - 'separator_after') + 'separator_after', 'highlights', 'hl_pos', + 'nb_of_highlights_after_separator') def __init__(self, lines_nb_limit: Optional[int] = None) -> None: + Win.__init__(self) if lines_nb_limit is None: lines_nb_limit = config.get('max_lines_in_memory') - Win.__init__(self) self.lines_nb_limit = lines_nb_limit # type: int self.pos = 0 # Each new message is built and kept here. @@ -48,6 +39,17 @@ class BaseTextWin(Win): self.lock = False self.lock_buffer = [] # type: List[Union[None, Line]] self.separator_after = None # type: Optional[Line] + # the Lines of the highlights in that buffer + self.highlights = [] # type: List[Line] + # the current HL position in that list NaN means that we’re not on + # an hl. -1 is a valid position (it's before the first hl of the + # list. i.e the separator, in the case where there’s no hl before + # it.) + self.hl_pos = float('nan') + + # Keep track of the number of hl after the separator. + # This is useful to make “go to next highlight“ work after a “move to separator”. + self.nb_of_highlights_after_separator = 0 def toggle_lock(self) -> bool: if self.lock: @@ -80,25 +82,21 @@ class BaseTextWin(Win): self.pos = 0 return self.pos != pos - # TODO: figure out the type of history. def build_new_message(self, - message: Message, - history=None, + message: BaseMessage, clean: bool = True, highlight: bool = False, timestamp: bool = False, - top: Optional[bool] = False, nick_size: int = 10) -> int: """ Take one message, build it and add it to the list Return the number of lines that are built for the given message. """ - #pylint: disable=assignment-from-no-return - lines = self.build_message( - message, timestamp=timestamp, nick_size=nick_size) - if top: - lines.reverse() + lines = build_lines( + message, self.width, timestamp=timestamp, nick_size=nick_size + ) + if isinstance(message, Message) and message.top: for line in lines: self.built_lines.insert(0, line) else: @@ -108,20 +106,46 @@ class BaseTextWin(Win): self.built_lines.extend(lines) if not lines or not lines[0]: return 0 + if highlight: + self.highlights.append(lines[0]) + self.nb_of_highlights_after_separator += 1 + log.debug("Number of highlights after separator is now %s", + self.nb_of_highlights_after_separator) if clean: while len(self.built_lines) > self.lines_nb_limit: self.built_lines.pop(0) return len(lines) - def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]: - """ - Build a list of lines from a message, without adding it - to a list - """ - return [] - def refresh(self) -> None: - pass + log.debug('Refresh: %s', self.__class__.__name__) + if self.height <= 0: + return + if self.pos == 0: + lines = self.built_lines[-self.height:] + else: + lines = self.built_lines[-self.height - self.pos:-self.pos] + with_timestamps = config.get("show_timestamps") + nick_size = config.get("max_nick_length") + self._win.move(0, 0) + self._win.erase() + offset = 0 + for y, line in enumerate(lines): + if line: + msg = line.msg + if line.start_pos == 0: + offset = write_pre(msg, self, with_timestamps, nick_size) + elif y == 0: + offset = msg.compute_offset(with_timestamps, + nick_size) + self.write_text( + y, offset, + line.prepend + line.msg.txt[line.start_pos:line.end_pos]) + else: + self.write_line_separator(y) + if y != self.height - 1: + self.addstr('\n') + self._win.attrset(0) + self._refresh() def write_text(self, y: int, x: int, txt: str) -> None: """ @@ -129,22 +153,7 @@ class BaseTextWin(Win): """ self.addstr_colored(txt, y, x) - def write_time(self, time: str) -> int: - """ - Write the date on the yth line of the window - """ - if time: - color = get_theme().COLOR_TIME_STRING - curses_color = to_curses_attr(color) - self._win.attron(curses_color) - self.addstr(time) - self._win.attroff(curses_color) - self.addstr(' ') - return poopt.wcswidth(time) + 1 - return 0 - - # TODO: figure out the type of room. - def resize(self, height: int, width: int, y: int, x: int, room=None) -> None: + def resize(self, height: int, width: int, y: int, x: int, room: TextBuffer=None) -> None: if hasattr(self, 'width'): old_width = self.width else: @@ -161,8 +170,7 @@ class BaseTextWin(Win): if self.pos < 0: self.pos = 0 - # TODO: figure out the type of room. - def rebuild_everything(self, room) -> None: + def rebuild_everything(self, room: TextBuffer) -> None: self.built_lines = [] with_timestamps = config.get('show_timestamps') nick_size = config.get('max_nick_length') @@ -170,37 +178,46 @@ class BaseTextWin(Win): self.build_new_message( message, clean=False, - top=message.top, timestamp=with_timestamps, nick_size=nick_size) if self.separator_after is message: - self.build_new_message(None) + self.built_lines.append(None) while len(self.built_lines) > self.lines_nb_limit: self.built_lines.pop(0) - def __del__(self) -> None: - log.debug('** TextWin: deleting %s built lines', - (len(self.built_lines))) - del self.built_lines - + def remove_line_separator(self) -> None: + """ + Remove the line separator + """ + log.debug('remove_line_separator') + if None in self.built_lines: + self.built_lines.remove(None) + self.separator_after = None -class TextWin(BaseTextWin): - __slots__ = ('highlights', 'hl_pos', 'nb_of_highlights_after_separator') + def add_line_separator(self, room: TextBuffer = None) -> None: + """ + add a line separator at the end of messages list + room is a textbuffer that is needed to get the previous message + (in case of resize) + """ + if None not in self.built_lines: + self.built_lines.append(None) + self.nb_of_highlights_after_separator = 0 + log.debug("Resetting number of highlights after separator") + if room and room.messages: + self.separator_after = room.messages[-1] - def __init__(self, lines_nb_limit: Optional[int] = None) -> None: - BaseTextWin.__init__(self, lines_nb_limit) - # the Lines of the highlights in that buffer - self.highlights = [] # type: List[Line] - # the current HL position in that list NaN means that we’re not on - # an hl. -1 is a valid position (it's before the first hl of the - # list. i.e the separator, in the case where there’s no hl before - # it.) - self.hl_pos = float('nan') + def write_line_separator(self, y) -> None: + theme = get_theme() + char = theme.CHAR_NEW_TEXT_SEPARATOR + self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width, + to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR)) - # Keep track of the number of hl after the separator. - # This is useful to make “go to next highlight“ work after a “move to separator”. - self.nb_of_highlights_after_separator = 0 + def __del__(self) -> None: + log.debug('** TextWin: deleting %s built lines', + (len(self.built_lines))) + del self.built_lines def next_highlight(self) -> None: """ @@ -293,269 +310,6 @@ class TextWin(BaseTextWin): self.highlights) - self.nb_of_highlights_after_separator - 1 log.debug("self.hl_pos = %s", self.hl_pos) - def remove_line_separator(self) -> None: - """ - Remove the line separator - """ - log.debug('remove_line_separator') - if None in self.built_lines: - self.built_lines.remove(None) - self.separator_after = None - - # TODO: figure out the type of room. - def add_line_separator(self, room=None) -> None: - """ - add a line separator at the end of messages list - room is a textbuffer that is needed to get the previous message - (in case of resize) - """ - if None not in self.built_lines: - self.built_lines.append(None) - self.nb_of_highlights_after_separator = 0 - log.debug("Resetting number of highlights after separator") - if room and room.messages: - self.separator_after = room.messages[-1] - - # TODO: figure out the type of history. - def build_new_message(self, - message: Message, - history: bool = False, - clean: bool = True, - highlight: bool = False, - timestamp: bool = False, - top: Optional[bool] = False, - nick_size: int = 10) -> int: - """ - Take one message, build it and add it to the list - Return the number of lines that are built for the given - message. - """ - lines = self.build_message( - message, timestamp=timestamp, nick_size=nick_size) - if top: - lines.reverse() - for line in lines: - self.built_lines.insert(0, line) - else: - if self.lock: - self.lock_buffer.extend(lines) - else: - self.built_lines.extend(lines) - if not lines or not lines[0]: - return 0 - if highlight: - self.highlights.append(lines[0]) - self.nb_of_highlights_after_separator += 1 - log.debug("Number of highlights after separator is now %s", - self.nb_of_highlights_after_separator) - if clean: - while len(self.built_lines) > self.lines_nb_limit: - self.built_lines.pop(0) - return len(lines) - - def build_message(self, message: Optional[Message], timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]: - """ - Build a list of lines from a message, without adding it - to a list - """ - if message is None: # line separator - return [None] - txt = message.txt - if not txt: - return [] - theme = get_theme() - if len(message.str_time) > 8: - default_color = ( - FORMAT_CHAR + dump_tuple(theme.COLOR_LOG_MSG) + '}') # type: Optional[str] - else: - default_color = None - ret = [] # type: List[Union[None, Line]] - nick = truncate_nick(message.nickname, nick_size) - offset = 0 - if message.ack: - if message.ack > 0: - offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 - else: - offset += poopt.wcswidth(theme.CHAR_NACK) + 1 - if nick: - offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length - if message.revisions > 0: - offset += ceil(log10(message.revisions + 1)) - if message.me: - offset += 1 # '* ' before and ' ' after - if timestamp: - if message.str_time: - offset += 1 + len(message.str_time) - if theme.CHAR_TIME_LEFT and message.str_time: - offset += 1 - if theme.CHAR_TIME_RIGHT and message.str_time: - offset += 1 - lines = poopt.cut_text(txt, self.width - offset - 1) - prepend = default_color if default_color else '' - attrs = [] # type: List[str] - for line in lines: - saved = Line( - msg=message, - start_pos=line[0], - end_pos=line[1], - prepend=prepend) - attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) - if attrs: - prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) - else: - if default_color: - prepend = default_color - else: - prepend = '' - ret.append(saved) - return ret - - def refresh(self) -> None: - log.debug('Refresh: %s', self.__class__.__name__) - if self.height <= 0: - return - if self.pos == 0: - lines = self.built_lines[-self.height:] - else: - lines = self.built_lines[-self.height - self.pos:-self.pos] - with_timestamps = config.get("show_timestamps") - nick_size = config.get("max_nick_length") - self._win.move(0, 0) - self._win.erase() - offset = 0 - for y, line in enumerate(lines): - if line: - msg = line.msg - if line.start_pos == 0: - offset = self.write_pre_msg(msg, with_timestamps, - nick_size) - elif y == 0: - offset = self.compute_offset(msg, with_timestamps, - nick_size) - self.write_text( - y, offset, - line.prepend + line.msg.txt[line.start_pos:line.end_pos]) - else: - self.write_line_separator(y) - if y != self.height - 1: - self.addstr('\n') - self._win.attrset(0) - self._refresh() - - def compute_offset(self, msg, with_timestamps, nick_size) -> int: - offset = 0 - if with_timestamps and msg.str_time: - offset += poopt.wcswidth(msg.str_time) + 1 - - 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.ack: - theme = get_theme() - if msg.ack > 0: - offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 - else: - offset += poopt.wcswidth(theme.CHAR_NACK) + 1 - if msg.me: - offset += 3 - else: - offset += 2 - if msg.revisions: - offset += ceil(log10(msg.revisions + 1)) - offset += self.write_revisions(msg) - return offset - - def write_pre_msg(self, msg, with_timestamps, nick_size) -> int: - offset = 0 - if with_timestamps: - offset += self.write_time(msg.str_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 += self.write_ack() - else: - offset += self.write_nack() - if msg.me: - self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE)) - self.addstr('* ') - self.write_nickname(nick, color, msg.highlight) - offset += self.write_revisions(msg) - self.addstr(' ') - offset += 3 - else: - self.write_nickname(nick, color, msg.highlight) - offset += self.write_revisions(msg) - self.addstr('> ') - offset += 2 - return offset - - def write_revisions(self, msg) -> int: - if msg.revisions: - self._win.attron( - to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE)) - self.addstr('%d' % msg.revisions) - self._win.attrset(0) - return ceil(log10(msg.revisions + 1)) - return 0 - - def write_line_separator(self, y) -> None: - theme = get_theme() - char = theme.CHAR_NEW_TEXT_SEPARATOR - self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width, - to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR)) - - def write_ack(self) -> int: - theme = get_theme() - color = theme.COLOR_CHAR_ACK - self._win.attron(to_curses_attr(color)) - self.addstr(theme.CHAR_ACK_RECEIVED) - self._win.attroff(to_curses_attr(color)) - self.addstr(' ') - return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1 - - def write_nack(self) -> int: - theme = get_theme() - color = theme.COLOR_CHAR_NACK - self._win.attron(to_curses_attr(color)) - self.addstr(theme.CHAR_NACK) - self._win.attroff(to_curses_attr(color)) - self.addstr(' ') - return poopt.wcswidth(theme.CHAR_NACK) + 1 - - def write_nickname(self, nickname, color, highlight=False) -> None: - """ - Write the nickname, using the user's color - and return the number of written characters - """ - if not nickname: - return - if highlight: - hl_color = get_theme().COLOR_HIGHLIGHT_NICK - if hl_color == "reverse": - self._win.attron(curses.A_REVERSE) - else: - color = hl_color - if color: - self._win.attron(to_curses_attr(color)) - self.addstr(nickname) - if color: - self._win.attroff(to_curses_attr(color)) - if highlight and hl_color == "reverse": - self._win.attroff(curses.A_REVERSE) - def modify_message(self, old_id, message) -> None: """ Find a message, and replace it with a new one @@ -564,112 +318,23 @@ class TextWin(BaseTextWin): with_timestamps = config.get('show_timestamps') nick_size = config.get('max_nick_length') for i in range(len(self.built_lines) - 1, -1, -1): - if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id: + current = self.built_lines[i] + if current is not None and current.msg.identifier == old_id: index = i - while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id: + while ( + index >= 0 + and current is not None + and current.msg.identifier == old_id + ): self.built_lines.pop(index) index -= 1 + if index >= 0: + current = self.built_lines[index] index += 1 - lines = self.build_message( - message, timestamp=with_timestamps, nick_size=nick_size) + lines = build_lines( + message, self.width, timestamp=with_timestamps, nick_size=nick_size + ) for line in lines: self.built_lines.insert(index, line) index += 1 break - - def __del__(self) -> None: - log.debug('** TextWin: deleting %s built lines', - (len(self.built_lines))) - del self.built_lines - - -class XMLTextWin(BaseTextWin): - __slots__ = () - - def __init__(self) -> None: - BaseTextWin.__init__(self) - - def refresh(self) -> None: - log.debug('Refresh: %s', self.__class__.__name__) - theme = get_theme() - if self.height <= 0: - return - if self.pos == 0: - lines = self.built_lines[-self.height:] - else: - lines = self.built_lines[-self.height - self.pos:-self.pos] - self._win.move(0, 0) - self._win.erase() - for y, line in enumerate(lines): - if line: - msg = line.msg - if line.start_pos == 0: - if msg.nickname == theme.CHAR_XML_OUT: - color = theme.COLOR_XML_OUT - elif msg.nickname == theme.CHAR_XML_IN: - color = theme.COLOR_XML_IN - self.write_time(msg.str_time) - self.write_prefix(msg.nickname, color) - self.addstr(' ') - if y != self.height - 1: - self.addstr('\n') - self._win.attrset(0) - for y, line in enumerate(lines): - offset = 0 - # Offset for the timestamp (if any) plus a space after it - offset += len(line.msg.str_time) - # space - offset += 1 - - # Offset for the prefix - offset += poopt.wcswidth(truncate_nick(line.msg.nickname)) - # space - offset += 1 - - self.write_text( - y, offset, - line.prepend + line.msg.txt[line.start_pos:line.end_pos]) - if y != self.height - 1: - self.addstr('\n') - self._win.attrset(0) - self._refresh() - - def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Line]: - txt = message.txt - ret = [] - default_color = None - nick = truncate_nick(message.nickname, nick_size) - offset = 0 - if nick: - offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length - if message.str_time: - offset += 1 + len(message.str_time) - theme = get_theme() - if theme.CHAR_TIME_LEFT and message.str_time: - offset += 1 - if theme.CHAR_TIME_RIGHT and message.str_time: - offset += 1 - lines = poopt.cut_text(txt, self.width - offset - 1) - prepend = default_color if default_color else '' - attrs = [] # type: List[str] - for line in lines: - saved = Line( - msg=message, - start_pos=line[0], - end_pos=line[1], - prepend=prepend) - attrs = parse_attrs(message.txt[line[0]:line[1]], attrs) - if attrs: - prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs) - else: - if default_color: - prepend = default_color - else: - prepend = '' - ret.append(saved) - return ret - - def write_prefix(self, nickname, color) -> None: - self._win.attron(to_curses_attr(color)) - self.addstr(truncate_nick(nickname)) - self._win.attroff(to_curses_attr(color)) diff --git a/poezio/xhtml.py b/poezio/xhtml.py index 899985ef..0b234c29 100644 --- a/poezio/xhtml.py +++ b/poezio/xhtml.py @@ -488,7 +488,7 @@ def convert_simple_to_full_colors(text: str) -> str: a \x19n} formatted one. """ # TODO, have a single list of this. This is some sort of - # duplicate from windows.format_chars + # duplicate from ui.consts.FORMAT_CHARS mapping = str.maketrans({ '\x0E': '\x19b', '\x0F': '\x19o', @@ -116,7 +116,7 @@ setup(name="poezio", 'Programming Language :: Python :: 3 :: Only'], keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'], packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows', - 'poezio_plugins', 'poezio_themes'], + 'poezio.ui', 'poezio_plugins', 'poezio_themes'], package_dir={'poezio': 'poezio', 'poezio_plugins': 'plugins', 'poezio_themes': 'data/themes'}, diff --git a/test/test_ui/test_funcs.py b/test/test_ui/test_funcs.py new file mode 100644 index 00000000..0e61549c --- /dev/null +++ b/test/test_ui/test_funcs.py @@ -0,0 +1,46 @@ +from poezio.ui.funcs import ( + find_first_format_char, + parse_attrs, + truncate_nick, +) + + +def test_find_char_not_present(): + assert find_first_format_char("toto") == -1 + + +def test_find_char(): + assert find_first_format_char('a \x1A 1') == 2 + + +def test_truncate_nick(): + assert truncate_nick("toto") == "toto" + + +def test_truncate_nick_wrong_size(): + assert truncate_nick("toto", -10) == "t…" + + +def test_truncate_nick_too_long(): + nick = "012345678901234567" + assert truncate_nick(nick) == nick[:10] + "…" + + +def test_truncate_nick_no_nick(): + assert truncate_nick('') == '' + + +def test_parse_attrs(): + text = "\x19o\x19u\x19b\x19i\x191}\x19o\x194}" + assert parse_attrs(text) == ['4}'] + + +def test_parse_attrs_broken_char(): + text = "coucou\x19" + assert parse_attrs(text) == [] + + +def test_parse_attrs_previous(): + text = "coucou" + previous = ['u'] + assert parse_attrs(text, previous=previous) == previous diff --git a/test/test_ui/test_render.py b/test/test_ui/test_render.py new file mode 100644 index 00000000..e0db5a8f --- /dev/null +++ b/test/test_ui/test_render.py @@ -0,0 +1,145 @@ +import pytest +from contextlib import contextmanager +from datetime import datetime +from poezio.theming import get_theme +from poezio.ui.render import build_lines, Line, write_pre +from poezio.ui.consts import SHORT_FORMAT +from poezio.ui.types import BaseMessage, Message, StatusMessage, XMLLog + +def test_simple_build_basemsg(): + msg = BaseMessage(txt='coucou') + line = build_lines(msg, 100, True, 10)[0] + assert (line.start_pos, line.end_pos) == (0, 6) + + +def test_simple_render_message(): + msg = Message(txt='coucou', nickname='toto') + line = build_lines(msg, 100, True, 10)[0] + assert (line.start_pos, line.end_pos) == (0, 6) + + +def test_simple_render_xmllog(): + msg = XMLLog(txt='coucou', incoming=True) + line = build_lines(msg, 100, True, 10)[0] + assert (line.start_pos, line.end_pos) == (0, 6) + + +def test_simple_render_separator(): + line = build_lines(None, 100, True, 10)[0] + assert line is None + + +def test_simple_render_status(): + class Obj: + name = 'toto' + msg = StatusMessage("Coucou {name}", {'name': lambda: Obj.name}) + assert msg.txt == "Coucou toto" + Obj.name = 'titi' + build_lines(msg, 100, True, 10)[0] + assert msg.txt == "Coucou titi" + + +class FakeBuffer: + def __init__(self): + self.text = '' + + @contextmanager + def colored_text(self, *args, **kwargs): + yield None + + def addstr(self, txt): + self.text += txt + +@pytest.fixture(scope='function') +def buffer(): + return FakeBuffer() + +@pytest.fixture +def time(): + return datetime.strptime('2019-09-27 10:11:12', '%Y-%m-%d %H:%M:%S') + +def test_write_pre_basemsg(buffer): + str_time = '10:11:12' + time = datetime.strptime(str_time, '%H:%M:%S') + msg = BaseMessage(txt='coucou', time=time) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 ' + assert size == len(buffer.text) + +def test_write_pre_message_simple(buffer, time): + msg = Message(txt='coucou', nickname='toto', time=time) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 toto> ' + assert size == len(buffer.text) + + +def test_write_pre_message_simple_history(buffer, time): + msg = Message(txt='coucou', nickname='toto', time=time, history=True) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '2019-09-27 10:11:12 toto> ' + assert size == len(buffer.text) + + +def test_write_pre_message_highlight(buffer, time): + msg = Message(txt='coucou', nickname='toto', time=time, highlight=True) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 toto> ' + assert size == len(buffer.text) + +def test_write_pre_message_no_timestamp(buffer): + msg = Message(txt='coucou', nickname='toto') + size = write_pre(msg, buffer, False, 10) + assert buffer.text == 'toto> ' + assert size == len(buffer.text) + + +def test_write_pre_message_me(buffer, time): + msg = Message(txt='/me coucou', nickname='toto', time=time) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 * toto ' + assert size == len(buffer.text) + + +def test_write_pre_message_revisions(buffer, time): + msg = Message(txt='coucou', nickname='toto', time=time, revisions=5) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 toto5> ' + assert size == len(buffer.text) + +def test_write_pre_message_revisions_me(buffer, time): + msg = Message(txt='/me coucou', nickname='toto', time=time, revisions=5) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '10:11:12 * toto5 ' + assert size == len(buffer.text) + + +def test_write_pre_message_ack(buffer, time): + ack = get_theme().CHAR_ACK_RECEIVED + expected = '10:11:12 %s toto> ' % ack + msg = Message(txt='coucou', nickname='toto', time=time, ack=1) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == expected + assert size == len(buffer.text) + + +def test_write_pre_message_nack(buffer, time): + nack = get_theme().CHAR_NACK + expected = '10:11:12 %s toto> ' % nack + msg = Message(txt='coucou', nickname='toto', time=time, ack=-1) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == expected + assert size == len(buffer.text) + + +def test_write_pre_xmllog_in(buffer): + msg = XMLLog(txt="coucou", incoming=True) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '%s IN ' % msg.time.strftime('%H:%M:%S') + assert size == len(buffer.text) + + +def test_write_pre_xmllog_out(buffer): + msg = XMLLog(txt="coucou", incoming=False) + size = write_pre(msg, buffer, True, 10) + assert buffer.text == '%s OUT ' % msg.time.strftime('%H:%M:%S') + assert size == len(buffer.text) diff --git a/test/test_ui/test_types.py b/test/test_ui/test_types.py new file mode 100644 index 00000000..e4c6c010 --- /dev/null +++ b/test/test_ui/test_types.py @@ -0,0 +1,126 @@ +import pytest +from datetime import datetime + +from poezio.ui.types import BaseMessage, Message, XMLLog + + +def test_create_message(): + now = datetime.now() + msg = Message( + txt="coucou", + nickname="toto", + ) + assert now < msg.time < datetime.now() + + msg = Message( + txt="coucou", + nickname="toto", + time=now, + ) + assert msg.time == now + + +def test_message_offset_simple(): + msg = Message( + txt="coucou", + nickname="toto", + ) + example = "10:10:10 toto> " + assert msg.compute_offset(True, 10) == len(example) + + msg = Message( + txt="coucou", + nickname="toto", + history=True, + ) + example = "2019:09:01 10:10:10 toto> " + assert msg.compute_offset(True, 10) == len(example) + +def test_message_offset_no_nick(): + msg = Message( + txt="coucou", + nickname="", + ) + example = "10:10:10 " + assert msg.compute_offset(True, 10) == len(example) + +def test_message_offset_ack(): + msg = Message( + txt="coucou", + nickname="toto", + ack=1, + ) + example = "10:10:10 V toto> " + assert msg.compute_offset(True, 10) == len(example) + + msg = Message( + txt="coucou", + nickname="toto", + ack=-1, + ) + example = "10:10:10 X toto> " + assert msg.compute_offset(True, 10) == len(example) + + +def test_message_offset_me(): + msg = Message( + txt="/me coucou", + nickname="toto", + ) + example = "10:10:10 * toto " + assert msg.compute_offset(True, 10) == len(example) + + +def test_message_offset_revisions(): + msg = Message( + txt="coucou", + nickname="toto", + revisions=3, + ) + example = "10:10:10 toto3> " + assert msg.compute_offset(True, 10) == len(example) + + msg = Message( + txt="coucou", + nickname="toto", + revisions=250, + ) + example = "10:10:10 toto250> " + assert msg.compute_offset(True, 10) == len(example) + + +def test_message_repr_works(): + msg1 = Message( + txt="coucou", + nickname="toto", + revisions=250, + ) + msg2 = Message( + txt="coucou", + nickname="toto", + old_message=msg1 + ) + + assert repr(msg2) is not None + +def test_xmllog_offset(): + msg = XMLLog( + txt='toto', + incoming=True, + ) + example = '10:10:10 IN ' + assert msg.compute_offset(True, 10) == len(example) + + msg = XMLLog( + txt='toto', + incoming=False, + ) + example = '10:10:10 OUT ' + assert msg.compute_offset(True, 10) == len(example) + +def test_basemessage_offset(): + msg = BaseMessage( + txt='coucou', + ) + example = '10:10:10 ' + assert msg.compute_offset(True, 10) == len(example) |