From 1cd0b4d6ea1f231c7786a01ebf3852419a4b762a Mon Sep 17 00:00:00 2001 From: mathieui Date: Sun, 7 Dec 2014 20:50:24 +0100 Subject: Fix #2570 (add /filter_jid to XMLTab, and syntax highlighting) Also add /filter_from and /filter_to, and allow chaining filters. --- src/core/commands.py | 2 +- src/core/core.py | 2 +- src/core/handlers.py | 38 ++++++- src/tabs/xmltab.py | 168 +++++++++++++++++++++++++------ src/theming.py | 7 ++ src/windows/__init__.py | 2 +- src/windows/text_win.py | 262 +++++++++++++++++++++++++++++++++++------------- 7 files changed, 375 insertions(+), 106 deletions(-) diff --git a/src/core/commands.py b/src/core/commands.py index 715e213e..2777833b 100644 --- a/src/core/commands.py +++ b/src/core/commands.py @@ -994,11 +994,11 @@ def command_message(self, args): @command_args_parser.ignored def command_xml_tab(self): """/xml_tab""" - self.xml_tab = True xml_tab = self.focus_tab_named('XMLTab', tabs.XMLTab) if not xml_tab: tab = tabs.XMLTab() self.add_tab(tab, True) + self.xml_tab = tab @command_args_parser.quoted(1) def command_adhoc(self, args): diff --git a/src/core/core.py b/src/core/core.py index 54d48aa4..79d19087 100644 --- a/src/core/core.py +++ b/src/core/core.py @@ -90,7 +90,7 @@ class Core(object): self.tab_win = windows.GlobalInfoBar() # Whether the XML tab is opened - self.xml_tab = False + self.xml_tab = None self.xml_buffer = TextBuffer() self.tabs = [] diff --git a/src/core/handlers.py b/src/core/handlers.py index dc6238d5..e0c89abf 100644 --- a/src/core/handlers.py +++ b/src/core/handlers.py @@ -17,7 +17,8 @@ from os import path from slixmpp import InvalidJID from slixmpp.stanza import Message -from slixmpp.xmlstream.stanzabase import StanzaBase +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase +from xml.etree import ElementTree as ET import bookmark import common @@ -37,6 +38,18 @@ from theming import dump_tuple, get_theme from . commands import dumb_callback +try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import HtmlFormatter + LEXER = get_lexer_by_name('xml') + FORMATTER = HtmlFormatter(noclasses=True) +except ImportError: + def highlight(text, *args, **kwargs): + return text + LEXER = None + FORMATTER = None + def on_session_start_features(self, _): """ Enable carbons & blocking on session start if wanted and possible @@ -1104,7 +1117,17 @@ def outgoing_stanza(self, stanza): We are sending a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x191}<--\x19o %s' % stanza) + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True) + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + try: + if self.xml_tab.match_stanza(ElementBase(ET.fromstring(stanza))): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_OUT) + except: + log.debug('', exc_info=True) + if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() @@ -1114,7 +1137,16 @@ def incoming_stanza(self, stanza): We are receiving a new stanza, write it in the xml buffer if needed. """ if self.xml_tab: - self.add_message_to_text_buffer(self.xml_buffer, '\x192}-->\x19o %s' % stanza) + xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER) + poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True) + self.add_message_to_text_buffer(self.xml_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + try: + if self.xml_tab.match_stanza(stanza): + self.add_message_to_text_buffer(self.xml_tab.filtered_buffer, poezio_colored, + nickname=get_theme().CHAR_XML_IN) + except: + log.debug('', exc_info=True) if isinstance(self.current_tab(), tabs.XMLTab): self.current_tab().refresh() self.doupdate() diff --git a/src/tabs/xmltab.py b/src/tabs/xmltab.py index 84ecf00c..30b5b1c3 100644 --- a/src/tabs/xmltab.py +++ b/src/tabs/xmltab.py @@ -13,23 +13,62 @@ log = logging.getLogger(__name__) import curses import os from slixmpp.xmlstream import matcher -from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.stanzabase import ElementBase +from xml.etree import ElementTree as ET from . import Tab +import text_buffer import windows from xhtml import clean_text from decorators import command_args_parser +from common import safeJID + + +class MatchJID(object): + + def __init__(self, jid, dest=''): + self.jid = jid + self.dest = dest + + def match(self, xml): + from_ = safeJID(xml['from']) + to_ = safeJID(xml['to']) + if self.jid.full == self.jid.bare: + from_ = from_.bare + to_ = to_.bare + + if self.dest == 'from': + return from_ == self.jid + elif self.dest == 'to': + return to_ == self.jid + return self.jid in (from_, to_) + + def __repr__(self): + return '%s%s%s' % (self.dest, ': ' if self.dest else '', self.jid) + +MATCHERS_MAPPINGS = { + MatchJID: ('JID', lambda obj: repr(obj)), + matcher.MatcherId: ('ID', lambda obj: obj._criteria), + matcher.MatchXMLMask: ('XMLMask', lambda obj: obj._criteria), + matcher.MatchXPath: ('XPath', lambda obj: obj._criteria) +} class XMLTab(Tab): def __init__(self): Tab.__init__(self) self.state = 'normal' self.name = 'XMLTab' - self.text_win = windows.TextWin() - self.core.xml_buffer.add_window(self.text_win) + self.filters = [] + + self.core_buffer = self.core.xml_buffer + self.filtered_buffer = text_buffer.TextBuffer() + self.info_header = windows.XMLInfoWin() + self.text_win = windows.XMLTextWin() + self.core_buffer.add_window(self.text_win) self.default_help_message = windows.HelpText("/ to enter a command") + self.register_command('close', self.close, shortdesc=_("Close this tab.")) self.register_command('clear', self.command_clear, @@ -42,8 +81,21 @@ class XMLTab(Tab): shortdesc=_('Filter by id.')) self.register_command('filter_xpath', self.command_filter_xpath, usage='', - desc=_('Show only the stanzas matching the xpath .'), + desc=_('Show only the stanzas matching the xpath .' + ' Any occurrences of %n will be replaced by jabber:client.'), shortdesc=_('Filter by XPath.')) + self.register_command('filter_jid', self.command_filter_jid, + usage='', + desc=_('Show only the stanzas matching the jid in from= or to=.'), + shortdesc=_('Filter by JID.')) + self.register_command('filter_from', self.command_filter_from, + usage='', + desc=_('Show only the stanzas matching the jid in from=.'), + shortdesc=_('Filter by JID from.')) + self.register_command('filter_to', self.command_filter_to, + usage='', + desc=_('Show only the stanzas matching the jid in to=.'), + shortdesc=_('Filter by JID to.')) self.register_command('filter_xmlmask', self.command_filter_xmlmask, usage=_(''), desc=_('Show only the stanzas matching the given xml mask.'), @@ -64,6 +116,34 @@ class XMLTab(Tab): self.filter_type = '' self.filter = '' + def gen_filter_repr(self): + if not self.filters: + self.filter_type = '' + self.filter = '' + return + filter_types = map(lambda x: MATCHERS_MAPPINGS[type(x)][0], self.filters) + filter_strings = map(lambda x: MATCHERS_MAPPINGS[type(x)][1](x), self.filters) + self.filter_type = ','.join(filter_types) + self.filter = ','.join(filter_strings) + + def update_filters(self, matcher): + if not self.filters: + messages = self.core_buffer.messages[:] + self.filtered_buffer.messages = [] + self.core_buffer.del_window(self.text_win) + self.filtered_buffer.add_window(self.text_win) + else: + messages = self.filtered_buffer.messages + self.filtered_buffer.messages = [] + self.filters.append(matcher) + new_messages = [] + for msg in messages: + if self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))): + new_messages.append(msg) + self.filtered_buffer.messages = new_messages + self.text_win.rebuild_everything(self.filtered_buffer) + self.gen_filter_repr() + def on_freeze(self): """ Freeze the display. @@ -71,46 +151,66 @@ class XMLTab(Tab): self.text_win.toggle_lock() self.refresh() + def match_stanza(self, stanza): + for matcher in self.filters: + if not matcher.match(stanza): + return False + return True + @command_args_parser.raw def command_filter_xmlmask(self, mask): """/filter_xmlmask """ try: - handler = Callback('custom matcher', matcher.MatchXMLMask(mask), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XML Mask Filter" - self.filter = mask + self.update_filters(matcher.MatchXMLMask(mask)) self.refresh() except: self.core.information('Invalid XML Mask', 'Error') self.command_reset('') + @command_args_parser.raw + def command_filter_to(self, jid): + """/filter_jid_to """ + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='to')) + self.refresh() + + @command_args_parser.raw + def command_filter_from(self, jid): + """/filter_jid_from """ + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj, dest='from')) + self.refresh() + + @command_args_parser.raw + def command_filter_jid(self, jid): + """/filter_jid """ + jid_obj = safeJID(jid) + if not jid_obj: + return self.core.information('Invalid JID: %s' % jid, 'Error') + + self.update_filters(MatchJID(jid_obj)) + self.refresh() + @command_args_parser.quoted(1) def command_filter_id(self, args): """/filter_id """ if args is None: return self.core.command_help('filter_id') - self.core.xmpp.remove_handler('custom matcher') - handler = Callback('custom matcher', matcher.MatcherId(arg), - self.core.incoming_stanza) - self.core.xmpp.register_handler(handler) - self.filter_type = "Id Filter" - self.filter = args[0] + self.update_filters(matcher.MatcherId(args[0])) self.refresh() @command_args_parser.raw def command_filter_xpath(self, xpath): """/filter_xpath """ try: - handler = Callback('custom matcher', matcher.MatchXPath( - xpath.replace('%n', self.core.xmpp.default_ns)), - self.core.incoming_stanza) - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(handler) - self.filter_type = "XPath Filter" - self.filter = xpath + self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns))) self.refresh() except: self.core.information('Invalid XML Path', 'Error') @@ -119,8 +219,11 @@ class XMLTab(Tab): @command_args_parser.ignored def command_reset(self): """/reset""" - self.core.xmpp.remove_handler('custom matcher') - self.core.xmpp.register_handler(self.core.all_stanzas) + if self.filters: + self.filters = [] + self.filtered_buffer.del_window(self.text_win) + self.core_buffer.add_window(self.text_win) + self.text_win.rebuild_everything(self.core_buffer) self.filter_type = '' self.filter = '' self.refresh() @@ -130,8 +233,11 @@ class XMLTab(Tab): """/dump """ if args is None: return self.core.command_help('dump') - xml = self.core.xml_buffer.messages[:] - text = '\n'.join(('%s %s' % (msg.str_time, clean_text(msg.txt)) for msg in xml)) + if self.filters: + xml = self.filtered_buffer.messages[:] + else: + xml = self.core_buffer.messages[:] + text = '\n'.join(('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt)) for msg in xml)) filename = os.path.expandvars(os.path.expanduser(args[0])) try: with open(filename, 'w') as fd: @@ -167,8 +273,12 @@ class XMLTab(Tab): """ /clear """ - self.core.xml_buffer.messages = [] - self.text_win.rebuild_everything(self.core.xml_buffer) + if self.filters: + buffer = self.core_buffer + else: + buffer = self.filtered_buffer + buffer.messages = [] + self.text_win.rebuild_everything(buffer) self.refresh() self.core.doupdate() diff --git a/src/theming.py b/src/theming.py index 1e9d6c40..3ca1a89f 100755 --- a/src/theming.py +++ b/src/theming.py @@ -178,6 +178,13 @@ class Theme(object): CHAR_AFFILIATION_MEMBER = '+' CHAR_AFFILIATION_NONE = '-' + + # XML Tab + CHAR_XML_IN = 'IN ' + CHAR_XML_OUT = 'OUT' + COLOR_XML_IN = (1, -1) + COLOR_XML_OUT = (2, -1) + # Color for the /me message COLOR_ME_MESSAGE = (6, -1) diff --git a/src/windows/__init__.py b/src/windows/__init__.py index 9e165201..f9ca7108 100644 --- a/src/windows/__init__.py +++ b/src/windows/__init__.py @@ -15,5 +15,5 @@ from . list import ListWin, ColumnHeaderWin from . misc import VerticalSeparator from . muc import UserList, Topic from . roster_win import RosterWin, ContactInfoWin -from . text_win import TextWin +from . text_win import TextWin, XMLTextWin diff --git a/src/windows/text_win.py b/src/windows/text_win.py index 6fe74f41..380142bb 100644 --- a/src/windows/text_win.py +++ b/src/windows/text_win.py @@ -18,7 +18,7 @@ from config import config from theming import to_curses_attr, get_theme, dump_tuple -class TextWin(Win): +class BaseTextWin(Win): def __init__(self, lines_nb_limit=None): if lines_nb_limit is None: lines_nb_limit = config.get('max_lines_in_memory') @@ -30,19 +30,6 @@ class TextWin(Win): self.lock = False self.lock_buffer = [] - - # the Lines of the highlights in that buffer - self.highlights = [] - # 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 - self.separator_after = None def toggle_lock(self): @@ -60,6 +47,113 @@ class TextWin(Win): self.built_lines.append(line) self.lock = False + def scroll_up(self, dist=14): + pos = self.pos + self.pos += dist + if self.pos + self.height > len(self.built_lines): + self.pos = len(self.built_lines) - self.height + if self.pos < 0: + self.pos = 0 + return self.pos != pos + + def scroll_down(self, dist=14): + pos = self.pos + self.pos -= dist + if self.pos <= 0: + self.pos = 0 + return self.pos != pos + + def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False): + """ + 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) + if self.lock: + self.lock_buffer.extend(lines) + else: + self.built_lines.extend(lines) + if not lines or not lines[0]: + return 0 + if clean: + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + return len(lines) + + def build_message(self, message, timestamp=False): + """ + Build a list of lines from a message, without adding it + to a list + """ + pass + + def refresh(self): + pass + + def write_text(self, y, x, txt): + """ + write the text of a line. + """ + self.addstr_colored(txt, y, x) + + def write_time(self, time): + """ + Write the date on the yth line of the window + """ + if time: + self.addstr(time) + self.addstr(' ') + + def resize(self, height, width, y, x, room=None): + if hasattr(self, 'width'): + old_width = self.width + else: + old_width = None + self._resize(height, width, y, x) + if room and self.width != old_width: + self.rebuild_everything(room) + + # reposition the scrolling after resize + # (see #2450) + buf_size = len(self.built_lines) + if buf_size - self.pos < self.height: + self.pos = buf_size - self.height + if self.pos < 0: + self.pos = 0 + + def rebuild_everything(self, room): + self.built_lines = [] + with_timestamps = config.get('show_timestamps') + for message in room.messages: + self.build_new_message(message, clean=False, timestamp=with_timestamps) + if self.separator_after is message: + self.build_new_message(None) + while len(self.built_lines) > self.lines_nb_limit: + self.built_lines.pop(0) + + def __del__(self): + log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) + del self.built_lines + +class TextWin(BaseTextWin): + def __init__(self, lines_nb_limit=None): + BaseTextWin.__init__(self, lines_nb_limit) + + # the Lines of the highlights in that buffer + self.highlights = [] + # 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 + + self.separator_after = None + def next_highlight(self): """ Go to the next highlight in the buffer. @@ -130,22 +224,6 @@ class TextWin(Win): if self.pos < 0 or self.pos >= len(self.built_lines): self.pos = 0 - def scroll_up(self, dist=14): - pos = self.pos - self.pos += dist - if self.pos + self.height > len(self.built_lines): - self.pos = len(self.built_lines) - self.height - if self.pos < 0: - self.pos = 0 - return self.pos != pos - - def scroll_down(self, dist=14): - pos = self.pos - self.pos -= dist - if self.pos <= 0: - self.pos = 0 - return self.pos != pos - def scroll_to_separator(self): """ Scroll until separator is centered. If no separator is @@ -343,12 +421,6 @@ class TextWin(Win): self.width, to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR)) - def write_text(self, y, x, txt): - """ - write the text of a line. - """ - self.addstr_colored(txt, y, x) - def write_ack(self): color = get_theme().COLOR_CHAR_ACK self._win.attron(to_curses_attr(color)) @@ -377,41 +449,6 @@ class TextWin(Win): if highlight and hl_color == "reverse": self._win.attroff(curses.A_REVERSE) - def write_time(self, time): - """ - Write the date on the yth line of the window - """ - if time: - self.addstr(time) - self.addstr(' ') - - def resize(self, height, width, y, x, room=None): - if hasattr(self, 'width'): - old_width = self.width - else: - old_width = None - self._resize(height, width, y, x) - if room and self.width != old_width: - self.rebuild_everything(room) - - # reposition the scrolling after resize - # (see #2450) - buf_size = len(self.built_lines) - if buf_size - self.pos < self.height: - self.pos = buf_size - self.height - if self.pos < 0: - self.pos = 0 - - def rebuild_everything(self, room): - self.built_lines = [] - with_timestamps = config.get('show_timestamps') - for message in room.messages: - self.build_new_message(message, clean=False, timestamp=with_timestamps) - if self.separator_after is message: - self.build_new_message(None) - while len(self.built_lines) > self.lines_nb_limit: - self.built_lines.pop(0) - def modify_message(self, old_id, message): """ Find a message, and replace it with a new one @@ -435,3 +472,86 @@ class TextWin(Win): log.debug('** TextWin: deleting %s built lines', (len(self.built_lines))) del self.built_lines +class XMLTextWin(BaseTextWin): + def __init__(self): + BaseTextWin.__init__(self) + + def refresh(self): + 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, timestamp=False): + txt = message.txt + ret = [] + default_color = None + nick = truncate_nick(message.nickname) + offset = 0 + if nick: + offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length + if message.str_time: + offset += 1 + len(message.str_time) + if get_theme().CHAR_TIME_LEFT and message.str_time: + offset += 1 + if get_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 = [] + 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): + self._win.attron(to_curses_attr(color)) + self.addstr(truncate_nick(nickname)) + self._win.attroff(to_curses_attr(color)) + -- cgit v1.2.3