summaryrefslogtreecommitdiff
path: root/poezio
diff options
context:
space:
mode:
authorEmmanuel Gil Peyrot <linkmauve@linkmauve.fr>2016-03-31 18:54:41 +0100
committerEmmanuel Gil Peyrot <linkmauve@linkmauve.fr>2016-06-11 20:49:43 +0100
commit332a5c2553db41de777473a1e1be9cd1522c9496 (patch)
tree3ee06a59f147ccc4009b35cccfbe2461bcd18310 /poezio
parentcf44cf7cdec9fdb35caa372563d57e7045dc29dd (diff)
downloadpoezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.gz
poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.bz2
poezio-332a5c2553db41de777473a1e1be9cd1522c9496.tar.xz
poezio-332a5c2553db41de777473a1e1be9cd1522c9496.zip
Move the src directory to poezio, for better cython compatibility.
Diffstat (limited to 'poezio')
-rw-r--r--poezio/args.py28
-rw-r--r--poezio/bookmarks.py289
-rw-r--r--poezio/common.py483
-rw-r--r--poezio/config.py685
-rw-r--r--poezio/connection.py223
-rw-r--r--poezio/contact.py197
-rw-r--r--poezio/core/__init__.py8
-rw-r--r--poezio/core/commands.py999
-rw-r--r--poezio/core/completions.py387
-rw-r--r--poezio/core/core.py2102
-rw-r--r--poezio/core/handlers.py1354
-rw-r--r--poezio/core/structs.py49
-rwxr-xr-xpoezio/daemon.py82
-rw-r--r--poezio/decorators.py139
-rw-r--r--poezio/events.py87
-rw-r--r--poezio/fifo.py71
-rw-r--r--poezio/fixes.py97
-rwxr-xr-xpoezio/keyboard.py168
-rw-r--r--poezio/logger.py284
-rw-r--r--poezio/multiuserchat.py196
-rw-r--r--poezio/pep.py221
-rw-r--r--poezio/plugin.py485
-rw-r--r--poezio/plugin_manager.py384
-rw-r--r--poezio/poezio.py115
-rw-r--r--poezio/poezio_shlex.py276
-rw-r--r--poezio/pooptmodule.c486
-rw-r--r--poezio/roster.py334
-rw-r--r--poezio/roster_sorting.py90
-rw-r--r--poezio/singleton.py20
-rw-r--r--poezio/size_manager.py46
-rw-r--r--poezio/tabs/__init__.py13
-rw-r--r--poezio/tabs/adhoc_commands_list.py57
-rw-r--r--poezio/tabs/basetabs.py881
-rw-r--r--poezio/tabs/bookmarkstab.py145
-rw-r--r--poezio/tabs/conversationtab.py484
-rw-r--r--poezio/tabs/data_forms.py75
-rw-r--r--poezio/tabs/listtab.py202
-rw-r--r--poezio/tabs/muclisttab.py70
-rw-r--r--poezio/tabs/muctab.py1720
-rw-r--r--poezio/tabs/privatetab.py362
-rw-r--r--poezio/tabs/rostertab.py1280
-rw-r--r--poezio/tabs/xmltab.py360
-rw-r--r--poezio/text_buffer.py242
-rwxr-xr-xpoezio/theming.py534
-rw-r--r--poezio/timed_events.py58
-rw-r--r--poezio/user.py121
-rw-r--r--poezio/windows/__init__.py20
-rw-r--r--poezio/windows/base_wins.py168
-rw-r--r--poezio/windows/bookmark_forms.py278
-rw-r--r--poezio/windows/data_forms.py472
-rw-r--r--poezio/windows/funcs.py54
-rw-r--r--poezio/windows/info_bar.py106
-rw-r--r--poezio/windows/info_wins.py311
-rw-r--r--poezio/windows/input_placeholders.py77
-rw-r--r--poezio/windows/inputs.py768
-rw-r--r--poezio/windows/list.py236
-rw-r--r--poezio/windows/misc.py60
-rw-r--r--poezio/windows/muc.py143
-rw-r--r--poezio/windows/roster_win.py387
-rw-r--r--poezio/windows/text_win.py597
-rw-r--r--poezio/xhtml.py543
61 files changed, 21209 insertions, 0 deletions
diff --git a/poezio/args.py b/poezio/args.py
new file mode 100644
index 00000000..63e77927
--- /dev/null
+++ b/poezio/args.py
@@ -0,0 +1,28 @@
+"""
+Module related to the argument parsing
+
+There is a fallback to the deprecated optparse if argparse is not found
+"""
+from os import path
+from argparse import ArgumentParser, SUPPRESS
+
+def parse_args(CONFIG_PATH=''):
+ """
+ Parse the arguments from the command line
+ """
+ parser = ArgumentParser('poezio')
+ parser.add_argument("-c", "--check-config", dest="check_config",
+ action='store_true',
+ help='Check the config file')
+ parser.add_argument("-d", "--debug", dest="debug",
+ help="The file where debug will be written",
+ metavar="DEBUG_FILE")
+ parser.add_argument("-f", "--file", dest="filename",
+ default=path.join(CONFIG_PATH, 'poezio.cfg'),
+ help="The config file you want to use",
+ metavar="CONFIG_FILE")
+ parser.add_argument("-v", "--version", dest="version",
+ help=SUPPRESS, metavar="VERSION",
+ default="1.0-dev")
+ options = parser.parse_args()
+ return options
diff --git a/poezio/bookmarks.py b/poezio/bookmarks.py
new file mode 100644
index 00000000..c7d26a51
--- /dev/null
+++ b/poezio/bookmarks.py
@@ -0,0 +1,289 @@
+"""
+Bookmarks module
+
+Therein the bookmark class is defined, representing one conference room.
+This object is used to generate elements for both local and remote
+bookmark storage. It can also parse xml Elements.
+
+This module also defines several functions for retrieving and updating
+bookmarks, both local and remote.
+
+Poezio start scenario:
+
+- upon inital connection, poezio will disco#info the server
+- the available storage methods will be stored in the available_storage dict
+ (either 'pep' or 'privatexml')
+- if only one is available, poezio will set the use_bookmarks_method config option
+ to it. If both are, it will be set to 'privatexml' (or if it was previously set, the
+ value will be kept).
+- it will then query the preferred storages for bookmarks and cache them locally
+ (Bookmark objects with a method='remote' attribute)
+
+Adding a remote bookmark:
+
+- New Bookmark object added to the list with storage='remote'
+- All bookmarks are sent to the storage selected in use_bookmarks_method
+ if there was an error, the user is notified.
+
+
+"""
+
+import functools
+import logging
+
+from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL
+from slixmpp import JID
+from common import safeJID
+from config import config
+
+log = logging.getLogger(__name__)
+
+
+class Bookmark(object):
+
+ def __init__(self, jid, name=None, autojoin=False, nick=None, password=None, method='local'):
+ self.jid = jid
+ self.name = name or jid
+ self.autojoin = autojoin
+ self.nick = nick
+ self.password = password
+ self._method = method
+
+ @property
+ def method(self):
+ return self._method
+
+ @method.setter
+ def method(self, value):
+ if value not in ('local', 'remote'):
+ log.debug('Could not set bookmark storing method: %s', value)
+ return
+ self._method = value
+
+ def __repr__(self):
+ return '<%s%s|%s>' % (self.jid,
+ ('/'+self.nick) if self.nick else '',
+ self.method)
+
+ def stanza(self):
+ """
+ Generate a <conference/> stanza from the instance
+ """
+ el = Conference()
+ el['name'] = self.name
+ el['jid'] = self.jid
+ el['autojoin'] = 'true' if self.autojoin else 'false'
+ if self.nick:
+ el['nick'] = self.nick
+ if self.password:
+ el['password'] = self.password
+ return el
+
+ def local(self):
+ """Generate a str for local storage"""
+ local = self.jid
+ if self.nick:
+ local += '/%s' % self.nick
+ local += ':'
+ if self.password:
+ config.set_and_save('password', self.password, section=self.jid)
+ return local
+
+ @functools.singledispatch
+ @staticmethod
+ def parse(el):
+ """
+ Generate a Bookmark object from a <conference/> element
+ (this is a fallback for raw XML Elements)
+ """
+ jid = el.get('jid')
+ name = el.get('name')
+ autojoin = True if el.get('autojoin', 'false').lower() in ('true', '1') else False
+ nick = None
+ for n in el.iter('nick'):
+ nick = n.text
+ password = None
+ for p in el.iter('password'):
+ password = p.text
+
+ return Bookmark(jid, name, autojoin, nick, password, method='remote')
+
+ @staticmethod
+ @parse.register(Conference)
+ def parse_from_stanza(el):
+ """
+ Parse a Conference element into a Bookmark object
+ """
+ jid = el['jid']
+ autojoin = el['autojoin']
+ password = el['password']
+ nick = el['nick']
+ name = el['name']
+ return Bookmark(jid, name, autojoin, nick, password, method='remote')
+
+class BookmarkList(object):
+
+ def __init__(self):
+ self.bookmarks = []
+ preferred = config.get('use_bookmarks_method').lower()
+ if preferred not in ('pep', 'privatexml'):
+ preferred = 'privatexml'
+ self.preferred = preferred
+ self.available_storage = {
+ 'privatexml': False,
+ 'pep': False,
+ }
+
+ def __getitem__(self, key):
+ if isinstance(key, (str, JID)):
+ for i in self.bookmarks:
+ if key == i.jid:
+ return i
+ else:
+ return self.bookmarks[key]
+
+ def __in__(self, key):
+ if isinstance(key, (str, JID)):
+ for bookmark in self.bookmarks:
+ if bookmark.jid == key:
+ return True
+ else:
+ return key in self.bookmarks
+ return False
+
+ def remove(self, key):
+ if isinstance(key, (str, JID)):
+ for i in self.bookmarks[:]:
+ if i.jid == key:
+ self.bookmarks.remove(i)
+ else:
+ self.bookmarks.remove(key)
+
+ def __iter__(self):
+ return iter(self.bookmarks)
+
+ def local(self):
+ return [bm for bm in self.bookmarks if bm.method == 'local']
+
+ def remote(self):
+ return [bm for bm in self.bookmarks if bm.method == 'remote']
+
+ def set(self, new):
+ self.bookmarks = new
+
+ def append(self, bookmark):
+ bookmark_exists = self[bookmark.jid]
+ if not bookmark_exists:
+ self.bookmarks.append(bookmark)
+ else:
+ self.bookmarks.remove(bookmark_exists)
+ self.bookmarks.append(bookmark)
+
+ def set_bookmarks_method(self, value):
+ if self.available_storage.get(value):
+ self.preferred = value
+ config.set_and_save('use_bookmarks_method', value)
+
+ def save_remote(self, xmpp, callback):
+ """Save the remote bookmarks."""
+ if not any(self.available_storage.values()):
+ return
+ method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223'
+
+ if method:
+ xmpp.plugin['xep_0048'].set_bookmarks(stanza_storage(self.bookmarks),
+ method=method,
+ callback=callback)
+ def save_local(self):
+ """Save the local bookmarks."""
+ local = ''.join(bookmark.local() for bookmark in self if bookmark.method == 'local')
+ config.set_and_save('rooms', local)
+
+ def save(self, xmpp, core=None, callback=None):
+ """Save all the bookmarks."""
+ self.save_local()
+ def _cb(iq):
+ if callback:
+ callback(iq)
+ if iq["type"] == "error" and core:
+ core.information('Could not save remote bookmarks.', 'Error')
+ elif core:
+ core.information('Bookmarks saved', 'Info')
+ if config.get('use_remote_bookmarks'):
+ self.save_remote(xmpp, _cb)
+
+ def get_pep(self, xmpp, callback):
+ """Add the remotely stored bookmarks via pep to the list."""
+ def _cb(iq):
+ if iq['type'] == 'result':
+ for conf in iq['pubsub']['items']['item']['bookmarks']['conferences']:
+ if isinstance(conf, URL):
+ continue
+ b = Bookmark.parse(conf)
+ self.append(b)
+ if callback:
+ callback(iq)
+
+ xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb)
+
+ def get_privatexml(self, xmpp, callback):
+ """
+ Fetch the remote bookmarks stored via privatexml.
+ """
+ def _cb(iq):
+ if iq['type'] == 'result':
+ for conf in iq['private']['bookmarks']['conferences']:
+ b = Bookmark.parse(conf)
+ self.append(b)
+ if callback:
+ callback(iq)
+
+ xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb)
+
+ def get_remote(self, xmpp, information, callback):
+ """Add the remotely stored bookmarks to the list."""
+ force = config.get('force_remote_bookmarks')
+ if xmpp.anon or not (any(self.available_storage.values()) or force):
+ information('No remote bookmark storage available', 'Warning')
+ return
+
+ if force and not any(self.available_storage.values()):
+ old_callback = callback
+ method = 'pep' if self.preferred == 'pep' else 'privatexml'
+ def new_callback(result):
+ if result['type'] != 'error':
+ self.available_storage[method] = True
+ old_callback(result)
+ else:
+ information('No remote bookmark storage available', 'Warning')
+ callback = new_callback
+
+ if self.preferred == 'pep':
+ self.get_pep(xmpp, callback=callback)
+ else:
+ self.get_privatexml(xmpp, callback=callback)
+
+ def get_local(self):
+ """Add the locally stored bookmarks to the list."""
+ rooms = config.get('rooms')
+ if not rooms:
+ return
+ rooms = rooms.split(':')
+ for room in rooms:
+ jid = safeJID(room)
+ if jid.bare == '':
+ continue
+ if jid.resource != '':
+ nick = jid.resource
+ else:
+ nick = None
+ passwd = config.get_by_tabname('password', jid.bare, fallback=False) or None
+ b = Bookmark(jid.bare, autojoin=True, nick=nick, password=passwd, method='local')
+ self.append(b)
+
+def stanza_storage(bookmarks):
+ """Generate a <storage/> stanza with the conference elements."""
+ storage = Bookmarks()
+ for b in (b for b in bookmarks if b.method == 'remote'):
+ storage.append(b.stanza())
+ return storage
diff --git a/poezio/common.py b/poezio/common.py
new file mode 100644
index 00000000..a62c83f1
--- /dev/null
+++ b/poezio/common.py
@@ -0,0 +1,483 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Various useful functions.
+"""
+
+from sys import version_info
+from datetime import datetime, timedelta
+from slixmpp import JID, InvalidJID
+
+import base64
+import os
+import mimetypes
+import hashlib
+import subprocess
+import time
+import string
+import poezio_shlex as shlex
+
+
+# Needed to avoid datetime.datetime.timestamp()
+# on python < 3.3. Older versions do not get good dst detection.
+OLD_PYTHON = (version_info.major + version_info.minor/10) < 3.3
+
+ROOM_STATE_NONE = 11
+ROOM_STATE_CURRENT = 10
+ROOM_STATE_PRIVATE = 15
+ROOM_STATE_MESSAGE = 12
+ROOM_STATE_HL = 13
+
+def get_base64_from_file(path):
+ """
+ Convert the content of a file to base64
+
+ :param str path: The path of the file to convert.
+ :return: A tuple of (encoded data, mime type, sha1 hash) if
+ the file exists and does not exceeds the upper size limit of 16384.
+ :return: (None, None, error message) if it fails
+ :rtype: :py:class:`tuple`
+
+ """
+ if not os.path.isfile(path):
+ return (None, None, "File does not exist")
+ size = os.path.getsize(path)
+ if size > 16384:
+ return (None, None,"File is too big")
+ fdes = open(path, 'rb')
+ data = fdes.read()
+ encoded = base64.encodestring(data)
+ sha1 = hashlib.sha1(data).hexdigest()
+ mime_type = mimetypes.guess_type(path)[0]
+ return (encoded, mime_type, sha1)
+
+def get_output_of_command(command):
+ """
+ Runs a command and returns its output.
+
+ :param str command: The command to run.
+ :return: The output or None
+ :rtype: :py:class:`str`
+ """
+ try:
+ return subprocess.check_output(command.split()).decode('utf-8').split('\n')
+ except subprocess.CalledProcessError:
+ return None
+
+def is_in_path(command, return_abs_path=False):
+ """
+ Check if *command* is in the $PATH or not.
+
+ :param str command: The command to be checked.
+ :param bool return_abs_path: Return the absolute path of the command instead
+ of True if the command is found.
+ :return: True if the command is found, the command path if the command is found
+ and *return_abs_path* is True, otherwise False.
+
+ """
+ for directory in os.getenv('PATH').split(os.pathsep):
+ try:
+ if command in os.listdir(directory):
+ if return_abs_path:
+ return os.path.join(directory, command)
+ else:
+ return True
+ except OSError:
+ # If the user has non directories in his path
+ pass
+ return False
+
+DISTRO_INFO = {
+ 'Arch Linux': '/etc/arch-release',
+ 'Aurox Linux': '/etc/aurox-release',
+ 'Conectiva Linux': '/etc/conectiva-release',
+ 'CRUX': '/usr/bin/crux',
+ 'Debian GNU/Linux': '/etc/debian_version',
+ 'Fedora Linux': '/etc/fedora-release',
+ 'Gentoo Linux': '/etc/gentoo-release',
+ 'Linux from Scratch': '/etc/lfs-release',
+ 'Mandrake Linux': '/etc/mandrake-release',
+ 'Slackware Linux': '/etc/slackware-version',
+ 'Solaris/Sparc': '/etc/release',
+ 'Source Mage': '/etc/sourcemage_version',
+ 'SUSE Linux': '/etc/SuSE-release',
+ 'Sun JDS': '/etc/sun-release',
+ 'PLD Linux': '/etc/pld-release',
+ 'Yellow Dog Linux': '/etc/yellowdog-release',
+ # many distros use the /etc/redhat-release for compatibility
+ # so Redhat is the last
+ 'Redhat Linux': '/etc/redhat-release'
+}
+
+def get_os_info():
+ """
+ Returns a detailed and well formated string containing
+ informations about the operating system
+
+ :rtype: str
+ """
+ if os.name == 'posix':
+ executable = 'lsb_release'
+ params = ' --description --codename --release --short'
+ full_path_to_executable = is_in_path(executable, return_abs_path = True)
+ if full_path_to_executable:
+ command = executable + params
+ process = subprocess.Popen([command], shell=True,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ close_fds=True)
+ process.wait()
+ output = process.stdout.readline().decode('utf-8').strip()
+ # some distros put n/a in places, so remove those
+ output = output.replace('n/a', '').replace('N/A', '')
+ return output
+
+ # lsb_release executable not available, so parse files
+ for distro_name in DISTRO_INFO:
+ path_to_file = DISTRO_INFO[distro_name]
+ if os.path.exists(path_to_file):
+ if os.access(path_to_file, os.X_OK):
+ # the file is executable (f.e. CRUX)
+ # yes, then run it and get the first line of output.
+ text = get_output_of_command(path_to_file)[0]
+ else:
+ fdes = open(path_to_file, encoding='utf-8')
+ text = fdes.readline().strip() # get only first line
+ fdes.close()
+ if path_to_file.endswith('version'):
+ # sourcemage_version and slackware-version files
+ # have all the info we need (name and version of distro)
+ if not os.path.basename(path_to_file).startswith(
+ 'sourcemage') or not\
+ os.path.basename(path_to_file).startswith('slackware'):
+ text = distro_name + ' ' + text
+ elif path_to_file.endswith('aurox-release') or \
+ path_to_file.endswith('arch-release'):
+ # file doesn't have version
+ text = distro_name
+ elif path_to_file.endswith('lfs-release'):
+ # file just has version
+ text = distro_name + ' ' + text
+ os_info = text.replace('\n', '')
+ return os_info
+
+ # our last chance, ask uname and strip it
+ uname_output = get_output_of_command('uname -sr')
+ if uname_output is not None:
+ os_info = uname_output[0] # only first line
+ return os_info
+ os_info = 'N/A'
+ return os_info
+
+def datetime_tuple(timestamp):
+ """
+ Convert a timestamp using strptime and the format: %Y%m%dT%H:%M:%S.
+
+ Because various datetime formats are used, the following exceptions
+ are handled:
+
+ * Optional milliseconds appened to the string are removed
+ * Optional Z (that means UTC) appened to the string are removed
+ * XEP-082 datetime strings have all '-' chars removed to meet the above format.
+
+ :param str timestamp: The string containing the formatted date.
+ :return: The date.
+ :rtype: :py:class:`datetime.datetime`
+ """
+ timestamp = timestamp.replace('-', '', 2).replace(':', '')
+ date = timestamp[:15]
+ tz_msg = timestamp[15:]
+ try:
+ ret = datetime.strptime(date, '%Y%m%dT%H%M%S')
+ except Exception:
+ ret = datetime.now()
+ # add the message timezone if any
+ try:
+ if tz_msg and tz_msg != 'Z':
+ tz_mod = -1 if tz_msg[0] == '-' else 1
+ tz_msg = time.strptime(tz_msg[1:], '%H%M')
+ tz_msg = tz_msg.tm_hour * 3600 + tz_msg.tm_min * 60
+ tz_msg = timedelta(seconds=tz_mod * tz_msg)
+ ret -= tz_msg
+ except Exception:
+ pass # ignore if we got a badly-formatted offset
+ # convert UTC to local time, with DST etc.
+ if time.daylight and time.localtime().tm_isdst:
+ tz = timedelta(seconds=-time.altzone)
+ else:
+ tz = timedelta(seconds=-time.timezone)
+ ret += tz
+ return ret
+
+def get_utc_time(local_time=None):
+ """
+ Get the current UTC time
+
+ :param datetime local_time: The current local time
+ :return: The current UTC time
+ """
+ if local_time is None:
+ local_time = datetime.now()
+ isdst = time.localtime().tm_isdst
+ else:
+ if OLD_PYTHON:
+ isdst = time.localtime(int(local_time.strftime("%s"))).tm_isdst
+ else:
+ isdst = time.localtime(int(local_time.timestamp())).tm_isdst
+
+ if time.daylight and isdst:
+ tz = timedelta(seconds=time.altzone)
+ else:
+ tz = timedelta(seconds=time.timezone)
+
+ utc_time = local_time + tz
+
+ return utc_time
+
+def get_local_time(utc_time):
+ """
+ Get the local time from an UTC time
+ """
+ if OLD_PYTHON:
+ isdst = time.localtime(int(utc_time.strftime("%s"))).tm_isdst
+ else:
+ isdst = time.localtime(int(utc_time.timestamp())).tm_isdst
+
+ if time.daylight and isdst:
+ tz = timedelta(seconds=time.altzone)
+ else:
+ tz = timedelta(seconds=time.timezone)
+
+ local_time = utc_time - tz
+
+ return local_time
+
+def find_delayed_tag(message):
+ """
+ Check if a message is delayed or not.
+
+ :param slixmpp.Message message: The message to check.
+ :return: A tuple containing (True, the datetime) or (False, None)
+ :rtype: :py:class:`tuple`
+ """
+
+ delay_tag = message.find('{urn:xmpp:delay}delay')
+ if delay_tag is not None:
+ delayed = True
+ date = datetime_tuple(delay_tag.attrib['stamp'])
+ else:
+ # We support the OLD and deprecated XEP: http://xmpp.org/extensions/xep-0091.html
+ # But it sucks, please, Jabber servers, don't do this :(
+ delay_tag = message.find('{jabber:x:delay}x')
+ if delay_tag is not None:
+ delayed = True
+ date = datetime_tuple(delay_tag.attrib['stamp'])
+ else:
+ delayed = False
+ date = None
+ return (delayed, date)
+
+def shell_split(st):
+ """
+ Split a string correctly according to the quotes
+ around the elements.
+
+ :param str st: The string to split.
+ :return: A list of the different of the string.
+ :rtype: :py:class:`list`
+
+ >>> shell_split('"sdf 1" "toto 2"')
+ ['sdf 1', 'toto 2']
+ """
+ sh = shlex.shlex(st)
+ ret = []
+ w = sh.get_token()
+ while w and w[2] is not None:
+ ret.append(w[2])
+ if w[1] == len(st):
+ return ret
+ w = sh.get_token()
+ return ret
+
+def find_argument(pos, text, quoted=True):
+ """
+ Split an input into a list of arguments, return the number of the
+ argument selected by pos.
+
+ If the position searched is outside the string, or in a space between words,
+ then it will return the position of an hypothetical new argument.
+
+ See the doctests of the two methods for example behaviors.
+
+ :param int pos: The position to search.
+ :param str text: The text to analyze.
+ :param quoted: Whether to take quotes into account or not.
+ :rtype: int
+ """
+ if quoted:
+ return find_argument_quoted(pos, text)
+ else:
+ return find_argument_unquoted(pos, text)
+
+def find_argument_quoted(pos, text):
+ """
+ Get the number of the argument at position pos in
+ a string with possibly quoted text.
+ """
+ sh = shlex.shlex(text)
+ count = -1
+ w = sh.get_token()
+ while w and w[2] is not None:
+ count += 1
+ if w[0] <= pos < w[1]:
+ return count
+ w = sh.get_token()
+
+ return count + 1
+
+def find_argument_unquoted(pos, text):
+ """
+ Get the number of the argument at position pos in
+ a string without interpreting quotes.
+ """
+ ret = text.split()
+ search = 0
+ argnum = 0
+ for i, elem in enumerate(ret):
+ elem_start = text.find(elem, search)
+ elem_end = elem_start + len(elem)
+ search = elem_end
+ if elem_start <= pos < elem_end:
+ return i
+ argnum = i
+ return argnum + 1
+
+def parse_str_to_secs(duration=''):
+ """
+ Parse a string of with a number of d, h, m, s.
+
+ :param str duration: The formatted string.
+ :return: The number of seconds represented by the string
+ :rtype: :py:class:`int`
+
+ >>> parse_str_to_secs("1d3m1h")
+ 90180
+ """
+ values = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
+ result = 0
+ tmp = '0'
+ for char in duration:
+ if char in string.digits:
+ tmp += char
+ elif char in values:
+ tmp_i = int(tmp)
+ result += tmp_i * values[char]
+ tmp = '0'
+ else:
+ return 0
+ if tmp != '0':
+ result += int(tmp)
+ return result
+
+def parse_secs_to_str(duration=0):
+ """
+ Do the reverse operation of :py:func:`parse_str_to_secs`.
+
+ Parse a number of seconds to a human-readable string.
+ The string has the form XdXhXmXs. 0 units are removed.
+
+ :param int duration: The duration, in seconds.
+ :return: A formatted string containing the duration.
+ :rtype: :py:class:`str`
+
+ >>> parse_secs_to_str(3601)
+ '1h1s'
+ """
+ secs, mins, hours, days = 0, 0, 0, 0
+ result = ''
+ secs = duration % 60
+ mins = (duration % 3600) // 60
+ hours = (duration % 86400) // 3600
+ days = duration // 86400
+
+ result += '%sd' % days if days else ''
+ result += '%sh' % hours if hours else ''
+ result += '%sm' % mins if mins else ''
+ result += '%ss' % secs if secs else ''
+ if not result:
+ result = '0s'
+ return result
+
+def format_tune_string(infos):
+ """
+ Contruct a string from a dict created from an "User tune" event.
+
+ :param dict infos: The informations
+ :return: The formatted string
+ :rtype: :py:class:`str`
+ """
+ elems = []
+ track = infos.get('track')
+ if track:
+ elems.append(track)
+ title = infos.get('title')
+ if title:
+ elems.append(title)
+ else:
+ elems.append('Unknown title')
+ elems.append('-')
+ artist = infos.get('artist')
+ if artist:
+ elems.append(artist)
+ else:
+ elems.append('Unknown artist')
+
+ rating = infos.get('rating')
+ if rating:
+ elems.append('[ ' + rating + '/10' + ' ]')
+ length = infos.get('length')
+ if length:
+ length = int(length)
+ secs = length % 60
+ mins = length // 60
+ secs = str(secs).zfill(2)
+ mins = str(mins).zfill(2)
+ elems.append('[' + mins + ':' + secs + ']')
+ return ' '.join(elems)
+
+def format_gaming_string(infos):
+ """
+ Construct a string from a dict containing the "user gaming"
+ informations.
+ (for now, only use address and name)
+
+ :param dict infos: The informations
+ :returns: The formatted string
+ :rtype: :py:class:`str`
+ """
+ name = infos.get('name')
+ if not name:
+ return ''
+
+ server_address = infos.get('server_address')
+ if server_address:
+ return '%s on %s' % (name, server_address)
+ return name
+
+def safeJID(*args, **kwargs):
+ """
+ Construct a :py:class:`slixmpp.JID` object from a string.
+
+ Used to avoid tracebacks during is stringprep fails
+ (fall back to a JID with an empty string).
+ """
+ try:
+ return JID(*args, **kwargs)
+ except InvalidJID:
+ return JID('')
+
diff --git a/poezio/config.py b/poezio/config.py
new file mode 100644
index 00000000..7f0c75f6
--- /dev/null
+++ b/poezio/config.py
@@ -0,0 +1,685 @@
+"""
+Defines the global config instance, used to get or set (and save) values
+from/to the config file.
+
+This module has the particularity that some imports and global variables
+are delayed because it would mean doing an incomplete setup of the python
+loggers.
+
+TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order
+to remove our ugly custom I/O methods.
+"""
+
+DEFSECTION = "Poezio"
+
+import logging.config
+import os
+import stat
+import sys
+import pkg_resources
+
+from configparser import RawConfigParser, NoOptionError, NoSectionError
+from os import environ, makedirs, path, remove
+from shutil import copy2
+from args import parse_args
+
+DEFAULT_CONFIG = {
+ 'Poezio': {
+ 'ack_message_receipts': True,
+ 'add_space_after_completion': True,
+ 'after_completion': ',',
+ 'alternative_nickname': '',
+ 'auto_reconnect': True,
+ 'autorejoin_delay': '5',
+ 'autorejoin': False,
+ 'beep_on': 'highlight private invite',
+ 'ca_cert_path': '',
+ 'certificate': '',
+ 'certfile': '',
+ 'ciphers': 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK:!SRP:!3DES:!aNULL',
+ 'connection_check_interval': 60,
+ 'connection_timeout_delay': 10,
+ 'create_gaps': False,
+ 'custom_host': '',
+ 'custom_port': '',
+ 'default_nick': '',
+ 'deterministic_nick_colors': True,
+ 'nick_color_aliases': True,
+ 'display_activity_notifications': False,
+ 'display_gaming_notifications': False,
+ 'display_mood_notifications': False,
+ 'display_tune_notifications': False,
+ 'display_user_color_in_join_part': True,
+ 'enable_carbons': True,
+ 'enable_user_activity': True,
+ 'enable_user_gaming': True,
+ 'enable_user_mood': True,
+ 'enable_user_nick': True,
+ 'enable_user_tune': True,
+ 'enable_vertical_tab_list': False,
+ 'enable_xhtml_im': True,
+ 'eval_password': '',
+ 'exec_remote': False,
+ 'extract_inline_images': True,
+ 'filter_info_messages': '',
+ 'force_encryption': True,
+ 'force_remote_bookmarks': False,
+ 'go_to_previous_tab_on_alt_number': False,
+ 'group_corrections': True,
+ 'hide_exit_join': -1,
+ 'hide_status_change': 120,
+ 'hide_user_list': False,
+ 'highlight_on': '',
+ 'ignore_certificate': False,
+ 'ignore_private': False,
+ 'information_buffer_popup_on': 'error roster warning help info',
+ 'jid': '',
+ 'keyfile': '',
+ 'lang': 'en',
+ 'lazy_resize': True,
+ 'load_log': 10,
+ 'log_dir': '',
+ 'log_errors': True,
+ 'max_lines_in_memory': 2048,
+ 'max_messages_in_memory': 2048,
+ 'max_nick_length': 25,
+ 'muc_history_length': 50,
+ 'notify_messages': True,
+ 'open_all_bookmarks': False,
+ 'password': '',
+ 'plugins_autoload': '',
+ 'plugins_conf_dir': '',
+ 'plugins_dir': '',
+ 'popup_time': 4,
+ 'private_auto_response': '',
+ 'remote_fifo_path': './',
+ 'request_message_receipts': True,
+ 'resource': '',
+ 'rooms': '',
+ 'roster_group_sort': 'name',
+ 'roster_show_offline': False,
+ 'roster_sort': 'jid:show',
+ 'save_status': True,
+ 'self_ping_delay': 0,
+ 'send_chat_states': True,
+ 'send_initial_presence': True,
+ 'send_os_info': True,
+ 'send_poezio_info': True,
+ 'send_time': True,
+ 'separate_history': False,
+ 'server': 'anon.jeproteste.info',
+ 'show_composing_tabs': 'direct',
+ 'show_inactive_tabs': True,
+ 'show_jid_in_conversations': True,
+ 'show_muc_jid': True,
+ 'show_roster_jids': True,
+ 'show_roster_subscriptions': '',
+ 'show_s2s_errors': True,
+ 'show_tab_names': False,
+ 'show_tab_numbers': True,
+ 'show_timestamps': True,
+ 'show_useless_separator': True,
+ 'status': '',
+ 'status_message': '',
+ 'theme': 'default',
+ 'themes_dir': '',
+ 'tmp_image_dir': '',
+ 'use_bookmarks_method': '',
+ 'use_log': False,
+ 'use_remote_bookmarks': True,
+ 'user_list_sort': 'desc',
+ 'use_tab_nicks': True,
+ 'vertical_tab_list_size': 20,
+ 'vertical_tab_list_sort': 'desc',
+ 'whitespace_interval': 300,
+ 'words': ''
+ },
+ 'bindings': {
+ 'M-i': '^I'
+ },
+ 'var': {
+ 'folded_roster_groups': '',
+ 'info_win_height': 2
+ },
+ 'muc_colors': {
+ }
+}
+
+class Config(RawConfigParser):
+ """
+ load/save the config to a file
+ """
+ def __init__(self, file_name, default=None):
+ RawConfigParser.__init__(self, None)
+ # make the options case sensitive
+ self.optionxform = str
+ self.file_name = file_name
+ self.read_file()
+ self.default = default
+
+ def read_file(self):
+ try:
+ RawConfigParser.read(self, self.file_name, encoding='utf-8')
+ except TypeError: # python < 3.2 sucks
+ RawConfigParser.read(self, self.file_name)
+ # Check config integrity and fix it if it’s wrong
+ # only when the object is the main config
+ if self.__class__ is Config:
+ for section in ('bindings', 'var'):
+ if not self.has_section(section):
+ self.add_section(section)
+
+ def get(self, option, default=None, section=DEFSECTION):
+ """
+ get a value from the config but return
+ a default value if it is not found
+ The type of default defines the type
+ returned
+ """
+ if default is None:
+ if self.default:
+ default = self.default.get(section, {}).get(option)
+ else:
+ default = ''
+
+ try:
+ if type(default) == int:
+ res = self.getint(option, section)
+ elif type(default) == float:
+ res = self.getfloat(option, section)
+ elif type(default) == bool:
+ res = self.getboolean(option, section)
+ else:
+ res = self.getstr(option, section)
+ except (NoOptionError, NoSectionError, ValueError, AttributeError):
+ return default
+
+ if res is None:
+ return default
+ return res
+
+ def get_by_tabname(self, option, tabname,
+ fallback=True, fallback_server=True, default=''):
+ """
+ Try to get the value for the option. First we look in
+ a section named `tabname`, if the option is not present
+ in the section, we search for the global option if fallback is
+ True. And we return `default` as a fallback as a last resort.
+ """
+ if self.default and (not default) and fallback:
+ default = self.default.get(DEFSECTION, {}).get(option, '')
+ if tabname in self.sections():
+ if option in self.options(tabname):
+ # We go the tab-specific option
+ return self.get(option, default, tabname)
+ if fallback_server:
+ return self.get_by_servname(tabname, option, default, fallback)
+ if fallback:
+ # We fallback to the global option
+ return self.get(option, default)
+ return default
+
+ def get_by_servname(self, jid, option, default, fallback=True):
+ """
+ Try to get the value of an option for a server
+ """
+ server = safeJID(jid).server
+ if server:
+ server = '@' + server
+ if server in self.sections() and option in self.options(server):
+ return self.get(option, default, server)
+ if fallback:
+ return self.get(option, default)
+ return default
+
+
+ def __get(self, option, section=DEFSECTION, **kwargs):
+ """
+ facility for RawConfigParser.get
+ """
+ return RawConfigParser.get(self, section, option, **kwargs)
+
+ def _get(self, section, conv, option, **kwargs):
+ """
+ Redirects RawConfigParser._get
+ """
+ return conv(self.__get(option, section, **kwargs))
+
+ def getstr(self, option, section=DEFSECTION):
+ """
+ get a value and returns it as a string
+ """
+ return self.__get(option, section)
+
+ def getint(self, option, section=DEFSECTION):
+ """
+ get a value and returns it as an int
+ """
+ return RawConfigParser.getint(self, section, option)
+
+ def getfloat(self, option, section=DEFSECTION):
+ """
+ get a value and returns it as a float
+ """
+ return RawConfigParser.getfloat(self, section, option)
+
+ def getboolean(self, option, section=DEFSECTION):
+ """
+ get a value and returns it as a boolean
+ """
+ return RawConfigParser.getboolean(self, section, option)
+
+ def write_in_file(self, section, option, value):
+ """
+ Our own way to save write the value in the file
+ Just find the right section, and then find the
+ right option, and edit it.
+ """
+ result = self._parse_file()
+ if not result:
+ return False
+ else:
+ sections, result_lines = result
+
+ if not section in sections:
+ result_lines.append('[%s]' % section)
+ result_lines.append('%s = %s' % (option, value))
+ else:
+ begin, end = sections[section]
+ pos = find_line(result_lines, begin, end, option)
+
+ if pos is -1:
+ result_lines.insert(end, '%s = %s' % (option, value))
+ else:
+ result_lines[pos] = '%s = %s' % (option, value)
+
+ return self._write_file(result_lines)
+
+ def remove_in_file(self, section, option):
+ """
+ Our own way to remove an option from the file.
+ """
+ result = self._parse_file()
+ if not result:
+ return False
+ else:
+ sections, result_lines = result
+
+ if not section in sections:
+ log.error('Tried to remove the option %s from a non-'
+ 'existing section (%s)', option, section)
+ return True
+ else:
+ begin, end = sections[section]
+ pos = find_line(result_lines, begin, end, option)
+
+ if pos is -1:
+ log.error('Tried to remove a non-existing option %s'
+ ' from section %s', option, section)
+ return True
+ else:
+ del result_lines[pos]
+
+ return self._write_file(result_lines)
+
+ def _write_file(self, lines):
+ """
+ Write the config file, write to a temporary file
+ before copying it to the final destination
+ """
+ try:
+ prefix, file = path.split(self.file_name)
+ filename = path.join(prefix, '.%s.tmp' % file)
+ fd = os.fdopen(
+ os.open(
+ filename,
+ os.O_WRONLY | os.O_CREAT,
+ 0o600),
+ 'w')
+ for line in lines:
+ fd.write('%s\n' % line)
+ fd.close()
+ copy2(filename, self.file_name)
+ remove(filename)
+ except:
+ success = False
+ log.error('Unable to save the config file.', exc_info=True)
+ else:
+ success = True
+ return success
+
+ def _parse_file(self):
+ """
+ Parse the config file and return the list of sections with
+ their start and end positions, and the lines in the file.
+
+ Duplicate sections are preserved but ignored for the parsing.
+
+ Returns an empty tuple if reading fails
+ """
+ if file_ok(self.file_name):
+ try:
+ with open(self.file_name, 'r', encoding='utf-8') as df:
+ lines_before = [line.strip() for line in df]
+ except:
+ log.error('Unable to read the config file %s',
+ self.file_name,
+ exc_info=True)
+ return tuple()
+ else:
+ lines_before = []
+
+ sections = {}
+ duplicate_section = False
+ current_section = ''
+ current_line = 0
+
+ for line in lines_before:
+ if line.startswith('['):
+ if not duplicate_section and current_section:
+ sections[current_section][1] = current_line
+
+ duplicate_section = False
+ current_section = line[1:-1]
+
+ if current_section in sections:
+ log.error('Error while reading the configuration file,'
+ ' skipping until next section')
+ duplicate_section = True
+ else:
+ sections[current_section] = [current_line, current_line]
+
+ current_line += 1
+ if not duplicate_section and current_section:
+ sections[current_section][1] = current_line
+
+ return (sections, lines_before)
+
+ def set_and_save(self, option, value, section=DEFSECTION):
+ """
+ set the value in the configuration then save it
+ to the file
+ """
+ # Special case for a 'toggle' value. We take the current value
+ # and set the opposite. Warning if the no current value exists
+ # or it is not a bool.
+ if value == "toggle":
+ current = self.get(option, "", section)
+ if isinstance(current, bool):
+ value = str(not current)
+ else:
+ if current.lower() == "false":
+ value = "true"
+ elif current.lower() == "true":
+ value = "false"
+ else:
+ return ('Could not toggle option: %s.'
+ ' Current value is %s.' %
+ (option, current or "empty"),
+ 'Warning')
+ if self.has_section(section):
+ RawConfigParser.set(self, section, option, value)
+ else:
+ self.add_section(section)
+ RawConfigParser.set(self, section, option, value)
+ if not self.write_in_file(section, option, value):
+ return ('Unable to write in the config file', 'Error')
+ return ("%s=%s" % (option, value), 'Info')
+
+ def remove_and_save(self, option, section=DEFSECTION):
+ """
+ Remove an option and then save it the config file
+ """
+ if self.has_section(section):
+ RawConfigParser.remove_option(self, section, option)
+ if not self.remove_in_file(section, option):
+ return ('Unable to save the config file', 'Error')
+ return ('Option %s deleted' % option, 'Info')
+
+ def silent_set(self, option, value, section=DEFSECTION):
+ """
+ Set a value, save, and return True on success and False on failure
+ """
+ if self.has_section(section):
+ RawConfigParser.set(self, section, option, value)
+ else:
+ self.add_section(section)
+ RawConfigParser.set(self, section, option, value)
+ return self.write_in_file(section, option, value)
+
+ def set(self, option, value, section=DEFSECTION):
+ """
+ Set the value of an option temporarily
+ """
+ try:
+ RawConfigParser.set(self, section, option, value)
+ except NoSectionError:
+ pass
+
+ def to_dict(self):
+ """
+ Returns a dict of the form {section: {option: value, option: value}, …}
+ """
+ res = {}
+ for section in self.sections():
+ res[section] = {}
+ for option in self.options(section):
+ res[section][option] = self.get(option, "", section)
+ return res
+
+
+def find_line(lines, start, end, option):
+ """
+ Get the number of the line containing the option in the
+ relevant part of the config file.
+
+ Returns -1 if the option isn’t found
+ """
+ current = start
+ for line in lines[start:end]:
+ if (line.startswith('%s ' % option) or
+ line.startswith('%s=' % option)):
+ return current
+ current += 1
+ return -1
+
+def file_ok(filepath):
+ """
+ Returns True if the file exists and is readable and writeable,
+ False otherwise.
+ """
+ val = path.exists(filepath)
+ val &= os.access(filepath, os.R_OK | os.W_OK)
+ return bool(val)
+
+def check_create_config_dir():
+ """
+ create the configuration directory if it doesn't exist
+ """
+ CONFIG_HOME = environ.get("XDG_CONFIG_HOME")
+ if not CONFIG_HOME:
+ CONFIG_HOME = path.join(environ.get('HOME'), '.config')
+ CONFIG_PATH = path.join(CONFIG_HOME, 'poezio')
+
+ try:
+ makedirs(CONFIG_PATH)
+ except OSError:
+ pass
+ return CONFIG_PATH
+
+def check_create_cache_dir():
+ """
+ create the cache directory if it doesn't exist
+ also create the subdirectories
+ """
+ global CACHE_DIR
+ CACHE_HOME = environ.get("XDG_CACHE_HOME")
+ if not CACHE_HOME:
+ CACHE_HOME = path.join(environ.get('HOME'), '.cache')
+ CACHE_DIR = path.join(CACHE_HOME, 'poezio')
+
+ try:
+ makedirs(CACHE_DIR)
+ makedirs(path.join(CACHE_DIR, 'images'))
+ except OSError:
+ pass
+
+def check_config():
+ """
+ Check the config file and print results
+ """
+ result = {'missing': [], 'changed': []}
+ for option in DEFAULT_CONFIG['Poezio']:
+ value = config.get(option)
+ if value != DEFAULT_CONFIG['Poezio'][option]:
+ result['changed'].append((option, value, DEFAULT_CONFIG['Poezio'][option]))
+ else:
+ value = config.get(option, default='')
+ upper = value.upper()
+ default = str(DEFAULT_CONFIG['Poezio'][option]).upper()
+ if upper != default:
+ result['missing'].append(option)
+
+ result['changed'].sort(key=lambda x: x[0])
+ result['missing'].sort()
+ if result['changed']:
+ print('\033[1mOptions changed from the default configuration:\033[0m\n')
+ for option, new_value, default in result['changed']:
+ print(' \033[1m%s\033[0m = \033[33m%s\033[0m (default: \033[32m%s\033[0m)' % (option, new_value, default))
+
+ if result['missing']:
+ print('\n\033[1mMissing options:\033[0m (the defaults are used)\n')
+ for option in result['missing']:
+ print(' \033[31m%s\033[0m' % option)
+
+def run_cmdline_args(CONFIG_PATH):
+ "Parse the command line arguments"
+ global options
+ options = parse_args(CONFIG_PATH)
+
+ # Copy a default file if none exists
+ if not path.isfile(options.filename):
+ default = path.join(path.dirname(__file__), '../data/default_config.cfg')
+ other = pkg_resources.resource_filename('poezio', 'default_config.cfg')
+ if path.isfile(default):
+ copy2(default, options.filename)
+ elif path.isfile(other):
+ copy2(other, options.filename)
+
+ # Inside the nixstore and possibly other distributions, the reference
+ # file is readonly, so is the copy.
+ # Make it writable by the user who just created it.
+ if os.path.exists(options.filename):
+ os.chmod(options.filename,
+ os.stat(options.filename).st_mode | stat.S_IWUSR)
+
+ global firstrun
+ firstrun = True
+
+def create_global_config():
+ "Create the global config object, or crash"
+ try:
+ global config
+ config = Config(options.filename, DEFAULT_CONFIG)
+ except:
+ import traceback
+ sys.stderr.write('Poezio was unable to read or'
+ ' parse the config file.\n')
+ traceback.print_exc(limit=0)
+ sys.exit(1)
+
+def check_create_log_dir():
+ "Create the poezio logging directory if it doesn’t exist"
+ global LOG_DIR
+ LOG_DIR = config.get('log_dir')
+
+ if not LOG_DIR:
+
+ data_dir = environ.get('XDG_DATA_HOME')
+ if not data_dir:
+ home = environ.get('HOME')
+ data_dir = path.join(home, '.local', 'share')
+
+ LOG_DIR = path.join(data_dir, 'poezio', 'logs')
+
+ LOG_DIR = path.expanduser(LOG_DIR)
+
+ try:
+ makedirs(LOG_DIR)
+ except:
+ pass
+
+def setup_logging():
+ "Change the logging config according to the cmdline options and config"
+ if config.get('log_errors'):
+ LOGGING_CONFIG['root']['handlers'].append('error')
+ LOGGING_CONFIG['handlers']['error'] = {
+ 'level': 'ERROR',
+ 'class': 'logging.FileHandler',
+ 'filename': path.join(LOG_DIR, 'errors.log'),
+ 'formatter': 'simple',
+ }
+
+ if options.debug:
+ LOGGING_CONFIG['root']['handlers'].append('debug')
+ LOGGING_CONFIG['handlers']['debug'] = {
+ 'level':'DEBUG',
+ 'class':'logging.FileHandler',
+ 'filename': options.debug,
+ 'formatter': 'simple',
+ }
+
+
+ if LOGGING_CONFIG['root']['handlers']:
+ logging.config.dictConfig(LOGGING_CONFIG)
+ else:
+ logging.basicConfig(level=logging.CRITICAL)
+
+ global log
+ log = logging.getLogger(__name__)
+
+def post_logging_setup():
+ # common imports slixmpp, which creates then its loggers, so
+ # it needs to be after logger configuration
+ from common import safeJID as JID
+ global safeJID
+ safeJID = JID
+
+LOGGING_CONFIG = {
+ 'version': 1,
+ 'disable_existing_loggers': True,
+ 'formatters': {
+ 'simple': {
+ 'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s'
+ }
+ },
+ 'handlers': {
+ },
+ 'root': {
+ 'handlers': [],
+ 'propagate': True,
+ 'level': 'DEBUG',
+ }
+}
+
+# True if this is the first run, in this case we will display
+# some help in the info buffer
+firstrun = False
+
+# Global config object. Is setup in poezio.py
+config = None
+
+# The logger object for this module
+log = None
+
+# The command-line options
+options = None
+
+# delayed import from common.py
+safeJID = None
+
+# the global log dir
+LOG_DIR = ''
+
+# the global cache dir
+CACHE_DIR = ''
diff --git a/poezio/connection.py b/poezio/connection.py
new file mode 100644
index 00000000..c4cc8b6b
--- /dev/null
+++ b/poezio/connection.py
@@ -0,0 +1,223 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Defines the Connection class
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+
+import getpass
+import subprocess
+import sys
+
+import slixmpp
+from slixmpp.plugins.xep_0184 import XEP_0184
+
+import common
+import fixes
+from common import safeJID
+from config import config, options
+
+class Connection(slixmpp.ClientXMPP):
+ """
+ Receives everything from Jabber and emits the
+ appropriate signals
+ """
+ __init = False
+ def __init__(self):
+ resource = config.get('resource')
+
+ keyfile = config.get('keyfile')
+ certfile = config.get('certfile')
+
+ if config.get('jid'):
+ # Field used to know if we are anonymous or not.
+ # many features will be handled differently
+ # depending on this setting
+ self.anon = False
+ jid = '%s' % config.get('jid')
+ if resource:
+ jid = '%s/%s'% (jid, resource)
+ password = config.get('password')
+ eval_password = config.get('eval_password')
+ if not password and not eval_password and not (keyfile and certfile):
+ password = getpass.getpass()
+ elif not password and not (keyfile and certfile):
+ sys.stderr.write("No password or certificates provided, using the eval_password command.\n")
+ process = subprocess.Popen(['sh', '-c', eval_password], stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, close_fds=True)
+ code = process.wait()
+ if code != 0:
+ sys.stderr.write('The eval_password command (%s) returned a '
+ 'nonzero status code: %s.\n' % (eval_password, code))
+ sys.stderr.write('Poezio will now exit\n')
+ sys.exit(code)
+ password = process.stdout.readline().decode('utf-8').strip('\n')
+ else: # anonymous auth
+ self.anon = True
+ jid = config.get('server')
+ if resource:
+ jid = '%s/%s' % (jid, resource)
+ password = None
+ jid = safeJID(jid)
+ # TODO: use the system language
+ slixmpp.ClientXMPP.__init__(self, jid, password,
+ lang=config.get('lang'))
+
+ force_encryption = config.get('force_encryption')
+ if force_encryption:
+ self['feature_mechanisms'].unencrypted_plain = False
+ self['feature_mechanisms'].unencrypted_digest = False
+ self['feature_mechanisms'].unencrypted_cram = False
+ self['feature_mechanisms'].unencrypted_scram = False
+
+ self.keyfile = config.get('keyfile')
+ self.certfile = config.get('certfile')
+ if keyfile and not certfile:
+ log.error('keyfile is present in configuration file without certfile')
+ elif certfile and not keyfile:
+ log.error('certfile is present in configuration file without keyfile')
+
+ self.core = None
+ self.auto_reconnect = config.get('auto_reconnect')
+ self.reconnect_max_attempts = 0
+ self.auto_authorize = None
+ # prosody defaults, lowest is AES128-SHA, it should be a minimum
+ # for anything that came out after 2002
+ self.ciphers = config.get('ciphers',
+ 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK'
+ ':!SRP:!3DES:!aNULL')
+ self.ca_certs = config.get('ca_cert_path') or None
+ interval = config.get('whitespace_interval')
+ if int(interval) > 0:
+ self.whitespace_keepalive_interval = int(interval)
+ else:
+ self.whitespace_keepalive = False
+ self.register_plugin('xep_0004')
+ self.register_plugin('xep_0012')
+ self.register_plugin('xep_0030')
+ self.register_plugin('xep_0045')
+ self.register_plugin('xep_0048')
+ self.register_plugin('xep_0050')
+ self.register_plugin('xep_0054')
+ self.register_plugin('xep_0060')
+ self.register_plugin('xep_0066')
+ self.register_plugin('xep_0071')
+ self.register_plugin('xep_0077')
+ self.plugin['xep_0077'].create_account = False
+ self.register_plugin('xep_0085')
+ self.register_plugin('xep_0115')
+
+ # monkey-patch xep_0184 to avoid requesting receipts for messages
+ # without a body
+ XEP_0184._filter_add_receipt_request = fixes._filter_add_receipt_request
+ self.register_plugin('xep_0184')
+ self.plugin['xep_0184'].auto_ack = config.get('ack_message_receipts')
+ self.plugin['xep_0184'].auto_request = config.get('request_message_receipts')
+
+ self.register_plugin('xep_0191')
+ self.register_plugin('xep_0198')
+ self.register_plugin('xep_0199')
+
+ if config.get('enable_user_tune'):
+ self.register_plugin('xep_0118')
+
+ if config.get('enable_user_nick'):
+ self.register_plugin('xep_0172')
+
+ if config.get('enable_user_mood'):
+ self.register_plugin('xep_0107')
+
+ if config.get('enable_user_activity'):
+ self.register_plugin('xep_0108')
+
+ if config.get('enable_user_gaming'):
+ self.register_plugin('xep_0196')
+
+ if config.get('send_poezio_info'):
+ info = {'name':'poezio',
+ 'version': options.version}
+ if config.get('send_os_info'):
+ info['os'] = common.get_os_info()
+ self.plugin['xep_0030'].set_identities(
+ identities=set([('client', 'pc', None, 'Poezio')]))
+ else:
+ info = {'name': '', 'version': ''}
+ self.plugin['xep_0030'].set_identities(
+ identities=set([('client', 'pc', None, '')]))
+ self.register_plugin('xep_0092', pconfig=info)
+ if config.get('send_time'):
+ self.register_plugin('xep_0202')
+ self.register_plugin('xep_0224')
+ self.register_plugin('xep_0231')
+ self.register_plugin('xep_0249')
+ self.register_plugin('xep_0257')
+ self.register_plugin('xep_0280')
+ self.register_plugin('xep_0297')
+ self.register_plugin('xep_0308')
+ self.register_plugin('xep_0319')
+ self.register_plugin('xep_0334')
+ self.register_plugin('xep_0352')
+ self.init_plugins()
+
+ def set_keepalive_values(self, option=None, value=None):
+ """
+ Called after the XMPP session has been started, or triggered when one of
+ "connection_timeout_delay" and "connection_check_interval" options
+ is changed. Unload and reload the ping plugin, with the new values.
+ """
+ if not self.is_connected():
+ # Happens when we change the value with /set while we are not
+ # connected. Do nothing in that case
+ return
+ ping_interval = config.get('connection_check_interval')
+ timeout_delay = config.get('connection_timeout_delay')
+ if timeout_delay <= 0:
+ # We help the stupid user (with a delay of 0, poezio will try to
+ # reconnect immediately because the timeout is immediately
+ # passed)
+ # 1 second is short, but, well
+ timeout_delay = 1
+ self.plugin['xep_0199'].disable_keepalive()
+ # If the ping_interval is 0 or less, we just disable the keepalive
+ if ping_interval > 0:
+ self.plugin['xep_0199'].enable_keepalive(ping_interval,
+ timeout_delay)
+
+ def start(self):
+ """
+ Connect and process events.
+ """
+ custom_host = config.get('custom_host')
+ custom_port = config.get('custom_port', 5222)
+ if custom_port == -1:
+ custom_port = 5222
+ if custom_host:
+ self.connect((custom_host, custom_port))
+ elif custom_port != 5222 and custom_port != -1:
+ self.connect((self.boundjid.host, custom_port))
+ else:
+ self.connect()
+
+ def send_raw(self, data):
+ """
+ Overrides XMLStream.send_raw, with an event added
+ """
+ if self.core:
+ self.core.outgoing_stanza(data)
+ slixmpp.ClientXMPP.send_raw(self, data)
+
+class MatchAll(slixmpp.xmlstream.matcher.base.MatcherBase):
+ """
+ Callback to retrieve all the stanzas for the XML tab
+ """
+ def match(self, xml):
+ "match everything"
+ return True
diff --git a/poezio/contact.py b/poezio/contact.py
new file mode 100644
index 00000000..c670e5bc
--- /dev/null
+++ b/poezio/contact.py
@@ -0,0 +1,197 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Defines the Resource and Contact classes, which are used in
+the roster.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+from common import safeJID
+from collections import defaultdict
+
+class Resource(object):
+ """
+ Defines a roster item.
+ It's a precise resource.
+ """
+ def __init__(self, jid, data):
+ """
+ data: the dict to use as a source
+ """
+ self._jid = jid # Full jid
+ self._data = data
+
+ @property
+ def jid(self):
+ return self._jid
+
+ @property
+ def priority(self):
+ return self._data.get('priority') or 0
+
+ @property
+ def presence(self):
+ return self._data.get('show') or ''
+
+ show = presence
+
+ @property
+ def status(self):
+ return self._data.get('status') or ''
+
+ def __repr__(self):
+ return '<%s>' % self._jid
+
+ def __eq__(self, value):
+ if not isinstance(value, Resource):
+ return False
+ return self.jid == value.jid and self._data == value._data
+
+class Contact(object):
+ """
+ This a way to gather multiple resources from the same bare JID.
+ This class contains zero or more Resource object and useful methods
+ to get the resource with the highest priority, etc
+ """
+ def __init__(self, item):
+ """
+ item: a slixmpp RosterItem pointing to that contact
+ """
+ self.__item = item
+ self.folded_states = defaultdict(lambda: True)
+ self._name = ''
+ self.error = None
+ self.tune = {}
+ self.gaming = {}
+ self.mood = ''
+ self.activity = ''
+
+ @property
+ def groups(self):
+ """Name of the groups the contact is in"""
+ return self.__item['groups'] or ['none']
+
+ @property
+ def bare_jid(self):
+ """The bare jid of the contact"""
+ return self.__item.jid
+
+ @property
+ def name(self):
+ """The name of the contact or an empty string."""
+ return self.__item['name'] or self._name or ''
+
+ @name.setter
+ def name(self, value):
+ """Set the name of the contact with user nickname"""
+ self._name = value
+
+ @property
+ def ask(self):
+ if self.__item['pending_out']:
+ return 'asked'
+
+ @property
+ def pending_in(self):
+ """We received a subscribe stanza from this contact."""
+ return self.__item['pending_in']
+
+ @pending_in.setter
+ def pending_in(self, value):
+ self.__item['pending_in'] = value
+
+ @property
+ def pending_out(self):
+ """We sent a subscribe stanza to this contact."""
+ return self.__item['pending_out']
+
+ @pending_out.setter
+ def pending_out(self, value):
+ self.__item['pending_out'] = value
+
+ @property
+ def resources(self):
+ """List of the available resources as Resource objects"""
+ return (Resource(
+ '%s%s' % (self.bare_jid, ('/' + key) if key else ''),
+ self.__item.resources[key]
+ ) for key in self.__item.resources.keys())
+
+ @property
+ def subscription(self):
+ return self.__item['subscription']
+
+ def __contains__(self, value):
+ return value in self.__item.resources or safeJID(value).resource in self.__item.resources
+
+ def __len__(self):
+ """Number of resources"""
+ return len(self.__item.resources)
+
+ def __bool__(self):
+ """This contacts exists even when he has no resources"""
+ return True
+
+ def __getitem__(self, key):
+ """Return the corresponding Resource object, or None"""
+ res = safeJID(key).resource
+ resources = self.__item.resources
+ item = resources.get(res, None) or resources.get(key, None)
+ return Resource(key, item) if item else None
+
+ def subscribe(self):
+ """Subscribe to this JID"""
+ self.__item.subscribe()
+
+ def authorize(self):
+ """Authorize this JID"""
+ self.__item.authorize()
+
+ def unauthorize(self):
+ """Unauthorize this JID"""
+ self.__item.unauthorize()
+
+ def unsubscribe(self):
+ """Unsubscribe from this JID"""
+ self.__item.unsubscribe()
+
+ def get(self, key, default=None):
+ """Same as __getitem__, but with a configurable default"""
+ return self[key] or default
+
+ def get_resources(self):
+ """Return all resources, sorted by priority """
+ compare_resources = lambda x: x.priority
+ return sorted(self.resources, key=compare_resources, reverse=True)
+
+ def get_highest_priority_resource(self):
+ """Return the resource with the highest priority"""
+ resources = self.get_resources()
+ if resources:
+ return resources[0]
+ return None
+
+ def folded(self, group_name='none'):
+ """
+ Return the Folded state of a contact for this group
+ """
+ return self.folded_states[group_name]
+
+ def toggle_folded(self, group='none'):
+ """
+ Fold if it's unfolded, and vice versa
+ """
+ self.folded_states[group] = not self.folded_states[group]
+
+ def __repr__(self):
+ ret = '<Contact: %s' % self.bare_jid
+ for resource in self.resources:
+ ret += '\n\t\t%s' % resource
+ return ret + ' />\n'
diff --git a/poezio/core/__init__.py b/poezio/core/__init__.py
new file mode 100644
index 00000000..6a82e2bb
--- /dev/null
+++ b/poezio/core/__init__.py
@@ -0,0 +1,8 @@
+"""
+Core class, splitted into smaller chunks
+"""
+
+from . core import Core
+from . structs import Command, Status, possible_show, DEPRECATED_ERRORS, \
+ ERROR_AND_STATUS_CODES
+
diff --git a/poezio/core/commands.py b/poezio/core/commands.py
new file mode 100644
index 00000000..a0a636c1
--- /dev/null
+++ b/poezio/core/commands.py
@@ -0,0 +1,999 @@
+"""
+Global commands which are to be linked to the Core class
+"""
+
+import logging
+
+log = logging.getLogger(__name__)
+
+import os
+from datetime import datetime
+from xml.etree import cElementTree as ET
+
+from slixmpp.xmlstream.stanzabase import StanzaBase
+from slixmpp.xmlstream.handler import Callback
+from slixmpp.xmlstream.matcher import StanzaPath
+
+import common
+import fixes
+import pep
+import tabs
+from bookmarks import Bookmark
+from common import safeJID
+from config import config, DEFAULT_CONFIG, options as config_opts
+import multiuserchat as muc
+from plugin import PluginConfig
+from roster import roster
+from theming import dump_tuple, get_theme
+from decorators import command_args_parser
+
+from . structs import Command, possible_show
+
+
+@command_args_parser.quoted(0, 1)
+def command_help(self, args):
+ """
+ /help [command_name]
+ """
+ if not args:
+ color = dump_tuple(get_theme().COLOR_HELP_COMMANDS)
+ acc = []
+ buff = ['Global commands:']
+ for command in self.commands:
+ if isinstance(self.commands[command], Command):
+ acc.append(' \x19%s}%s\x19o - %s' % (
+ color,
+ command,
+ self.commands[command].short))
+ else:
+ acc.append(' \x19%s}%s\x19o' % (color, command))
+ acc = sorted(acc)
+ buff.extend(acc)
+ acc = []
+ buff.append('Tab-specific commands:')
+ commands = self.current_tab().commands
+ for command in commands:
+ if isinstance(commands[command], Command):
+ acc.append(' \x19%s}%s\x19o - %s' % (
+ color,
+ command,
+ commands[command].short))
+ else:
+ acc.append(' \x19%s}%s\x19o' % (color, command))
+ acc = sorted(acc)
+ buff.extend(acc)
+
+ msg = '\n'.join(buff)
+ msg += "\nType /help <command_name> to know what each command does"
+ else:
+ command = args[0].lstrip('/').strip()
+
+ if command in self.current_tab().commands:
+ tup = self.current_tab().commands[command]
+ elif command in self.commands:
+ tup = self.commands[command]
+ else:
+ self.information('Unknown command: %s' % command, 'Error')
+ return
+ if isinstance(tup, Command):
+ msg = 'Usage: /%s %s\n' % (command, tup.usage)
+ msg += tup.desc
+ else:
+ msg = tup[1]
+ self.information(msg, 'Help')
+
+@command_args_parser.quoted(1)
+def command_runkey(self, args):
+ """
+ /runkey <key>
+ """
+ def replace_line_breaks(key):
+ "replace ^J with \n"
+ if key == '^J':
+ return '\n'
+ return key
+ if args is None:
+ return self.command_help('runkey')
+ char = args[0]
+ func = self.key_func.get(char, None)
+ if func:
+ func()
+ else:
+ res = self.do_command(replace_line_breaks(char), False)
+ if res:
+ self.refresh_window()
+
+@command_args_parser.quoted(1, 1, [None])
+def command_status(self, args):
+ """
+ /status <status> [msg]
+ """
+ if args is None:
+ return self.command_help('status')
+
+ if not args[0] in possible_show.keys():
+ return self.command_help('status')
+
+ show = possible_show[args[0]]
+ msg = args[1]
+
+ pres = self.xmpp.make_presence()
+ if msg:
+ pres['status'] = msg
+ pres['type'] = show
+ self.events.trigger('send_normal_presence', pres)
+ pres.send()
+ current = self.current_tab()
+ is_muctab = isinstance(current, tabs.MucTab)
+ if is_muctab and current.joined and show in ('away', 'xa'):
+ current.send_chat_state('inactive')
+ for tab in self.tabs:
+ if isinstance(tab, tabs.MucTab) and tab.joined:
+ muc.change_show(self.xmpp, tab.name, tab.own_nick, show, msg)
+ if hasattr(tab, 'directed_presence'):
+ del tab.directed_presence
+ self.set_status(show, msg)
+ if is_muctab and current.joined and show not in ('away', 'xa'):
+ current.send_chat_state('active')
+
+@command_args_parser.quoted(1, 2, [None, None])
+def command_presence(self, args):
+ """
+ /presence <JID> [type] [status]
+ """
+ if args is None:
+ return self.command_help('presence')
+
+ jid, type, status = args[0], args[1], args[2]
+ if jid == '.' and isinstance(self.current_tab(), tabs.ChatTab):
+ jid = self.current_tab().name
+ if type == 'available':
+ type = None
+ try:
+ pres = self.xmpp.make_presence(pto=jid, ptype=type, pstatus=status)
+ self.events.trigger('send_normal_presence', pres)
+ pres.send()
+ except:
+ self.information('Could not send directed presence', 'Error')
+ log.debug('Could not send directed presence to %s', jid, exc_info=True)
+ return
+ tab = self.get_tab_by_name(jid)
+ if tab:
+ if type in ('xa', 'away'):
+ tab.directed_presence = False
+ chatstate = 'inactive'
+ else:
+ tab.directed_presence = True
+ chatstate = 'active'
+ if tab == self.current_tab():
+ tab.send_chat_state(chatstate, True)
+ if isinstance(tab, tabs.MucTab):
+ for private in tab.privates:
+ private.directed_presence = tab.directed_presence
+ if self.current_tab() in tab.privates:
+ self.current_tab().send_chat_state(chatstate, True)
+
+@command_args_parser.quoted(1)
+def command_theme(self, args=None):
+ """/theme <theme name>"""
+ if args is None:
+ return self.command_help('theme')
+ self.command_set('theme %s' % (args[0],))
+
+@command_args_parser.quoted(1)
+def command_win(self, args):
+ """
+ /win <number>
+ """
+ if args is None:
+ return self.command_help('win')
+
+ nb = args[0]
+ try:
+ nb = int(nb)
+ except ValueError:
+ pass
+ if self.current_tab_nb == nb:
+ return
+ self.previous_tab_nb = self.current_tab_nb
+ old_tab = self.current_tab()
+ if isinstance(nb, int):
+ if 0 <= nb < len(self.tabs):
+ if not self.tabs[nb]:
+ return
+ self.current_tab_nb = nb
+ else:
+ matchs = []
+ for tab in self.tabs:
+ for name in tab.matching_names():
+ if nb.lower() in name[1].lower():
+ matchs.append((name[0], tab))
+ self.current_tab_nb = tab.nb
+ if not matchs:
+ return
+ tab = min(matchs, key=lambda m: m[0])[1]
+ self.current_tab_nb = tab.nb
+ old_tab.on_lose_focus()
+ self.current_tab().on_gain_focus()
+ self.refresh_window()
+
+@command_args_parser.quoted(2)
+def command_move_tab(self, args):
+ """
+ /move_tab old_pos new_pos
+ """
+ if args is None:
+ return self.command_help('move_tab')
+
+ current_tab = self.current_tab()
+ if args[0] == '.':
+ args[0] = current_tab.nb
+ if args[1] == '.':
+ args[1] = current_tab.nb
+
+ def get_nb_from_value(value):
+ "parse the cmdline to guess the tab the users wants"
+ ref = None
+ try:
+ ref = int(value)
+ except ValueError:
+ old_tab = None
+ for tab in self.tabs:
+ if not old_tab and value == tab.name:
+ old_tab = tab
+ if not old_tab:
+ self.information("Tab %s does not exist" % args[0], "Error")
+ return None
+ ref = old_tab.nb
+ return ref
+ old = get_nb_from_value(args[0])
+ new = get_nb_from_value(args[1])
+ if new is None or old is None:
+ return self.information('Unable to move the tab.', 'Info')
+ result = self.insert_tab(old, new)
+ if not result:
+ self.information('Unable to move the tab.', 'Info')
+ else:
+ self.current_tab_nb = self.tabs.index(current_tab)
+ self.refresh_window()
+
+@command_args_parser.quoted(0, 1)
+def command_list(self, args):
+ """
+ /list [server]
+ Opens a MucListTab containing the list of the room in the specified server
+ """
+ if args is None:
+ return self.command_help('list')
+ elif args:
+ jid = safeJID(args[0])
+ else:
+ if not isinstance(self.current_tab(), tabs.MucTab):
+ return self.information('Please provide a server', 'Error')
+ jid = safeJID(self.current_tab().name).server
+ list_tab = tabs.MucListTab(jid)
+ self.add_tab(list_tab, True)
+ cb = list_tab.on_muc_list_item_received
+ self.xmpp.plugin['xep_0030'].get_items(jid=jid,
+ callback=cb)
+
+@command_args_parser.quoted(1)
+def command_version(self, args):
+ """
+ /version <jid>
+ """
+ def callback(res):
+ "Callback for /version"
+ if not res:
+ return self.information('Could not get the software'
+ ' version from %s' % jid,
+ 'Warning')
+ version = '%s is running %s version %s on %s' % (
+ jid,
+ res.get('name') or 'an unknown software',
+ res.get('version') or 'unknown',
+ res.get('os') or 'an unknown platform')
+ self.information(version, 'Info')
+
+ if args is None:
+ return self.command_help('version')
+
+ jid = safeJID(args[0])
+ if jid.resource or jid not in roster:
+ fixes.get_version(self.xmpp, jid, callback=callback)
+ elif jid in roster:
+ for resource in roster[jid].resources:
+ fixes.get_version(self.xmpp, resource.jid, callback=callback)
+ else:
+ fixes.get_version(self.xmpp, jid, callback=callback)
+
+@command_args_parser.quoted(0, 2)
+def command_join(self, args):
+ """
+ /join [room][/nick] [password]
+ """
+ password = None
+ if len(args) == 0:
+ tab = self.current_tab()
+ if not isinstance(tab, (tabs.MucTab, tabs.PrivateTab)):
+ return
+ room = safeJID(tab.name).bare
+ nick = tab.own_nick
+ else:
+ if args[0].startswith('@'): # we try to join a server directly
+ server_root = True
+ info = safeJID(args[0][1:])
+ else:
+ info = safeJID(args[0])
+ server_root = False
+ if info == '' and len(args[0]) > 1 and args[0][0] == '/':
+ nick = args[0][1:]
+ elif info.resource == '':
+ nick = self.own_nick
+ else:
+ nick = info.resource
+ if info.bare == '': # happens with /join /nickname, which is OK
+ tab = self.current_tab()
+ if not isinstance(tab, tabs.MucTab):
+ return
+ room = tab.name
+ if nick == '':
+ nick = tab.own_nick
+ else:
+ room = info.bare
+ # no server is provided, like "/join hello":
+ # use the server of the current room if available
+ # check if the current room's name has a server
+ if room.find('@') == -1 and not server_root:
+ if isinstance(self.current_tab(), tabs.MucTab) and\
+ self.current_tab().name.find('@') != -1:
+ domain = safeJID(self.current_tab().name).domain
+ room += '@%s' % domain
+ else:
+ room = args[0]
+ room = room.lower()
+ if room in self.pending_invites:
+ del self.pending_invites[room]
+ tab = self.get_tab_by_name(room, tabs.MucTab)
+ if tab is not None:
+ self.focus_tab_named(tab.name)
+ if tab.own_nick == nick and tab.joined:
+ self.information('/join: Nothing to do.', 'Info')
+ else:
+ tab.command_part('')
+ tab.own_nick = nick
+ tab.join()
+
+ return
+
+ if room.startswith('@'):
+ room = room[1:]
+ if len(args) == 2: # a password is provided
+ password = args[1]
+ if password is None: # try to use a saved password
+ password = config.get_by_tabname('password', room, fallback=False)
+ if tab is not None:
+ if password:
+ tab.password = password
+ tab.join()
+ else:
+ tab = self.open_new_room(room, nick, password=password)
+ tab.join()
+
+ if tab.joined:
+ self.enable_private_tabs(room)
+ tab.state = "normal"
+ if tab == self.current_tab():
+ tab.refresh()
+ self.doupdate()
+
+@command_args_parser.quoted(0, 2)
+def command_bookmark_local(self, args):
+ """
+ /bookmark_local [room][/nick] [password]
+ """
+ if not args and not isinstance(self.current_tab(), tabs.MucTab):
+ return
+ password = args[1] if len(args) > 1 else None
+ jid = args[0] if args else None
+
+ _add_bookmark(self, jid, True, password, 'local')
+
+@command_args_parser.quoted(0, 3)
+def command_bookmark(self, args):
+ """
+ /bookmark [room][/nick] [autojoin] [password]
+ """
+ if not args and not isinstance(self.current_tab(), tabs.MucTab):
+ return
+ jid = args[0] if args else ''
+ password = args[2] if len(args) > 2 else None
+
+ if not config.get('use_remote_bookmarks'):
+ return _add_bookmark(self, jid, True, password, 'local')
+
+ if len(args) > 1:
+ autojoin = False if args[1].lower() != 'true' else True
+ else:
+ autojoin = True
+
+ _add_bookmark(self, jid, autojoin, password, 'remote')
+
+def _add_bookmark(self, jid, autojoin, password, method):
+ nick = None
+ if not jid:
+ tab = self.current_tab()
+ roomname = tab.name
+ if tab.joined and tab.own_nick != self.own_nick:
+ nick = tab.own_nick
+ if password is None and tab.password is not None:
+ password = tab.password
+ elif jid == '*':
+ return _add_wildcard_bookmarks(self, method)
+ else:
+ info = safeJID(jid)
+ roomname, nick = info.bare, info.resource
+ if roomname == '':
+ if not isinstance(self.current_tab(), tabs.MucTab):
+ return
+ roomname = self.current_tab().name
+ bookmark = self.bookmarks[roomname]
+ if bookmark is None:
+ bookmark = Bookmark(roomname)
+ self.bookmarks.append(bookmark)
+ bookmark.method = method
+ bookmark.autojoin = autojoin
+ if nick:
+ bookmark.nick = nick
+ if password:
+ bookmark.password = password
+ def callback(iq):
+ if iq["type"] != "error":
+ self.information('Bookmark added.', 'Info')
+ else:
+ self.information("Could not add the bookmarks.", "Info")
+ self.bookmarks.save_local()
+ self.bookmarks.save_remote(self.xmpp, callback)
+
+def _add_wildcard_bookmarks(self, method):
+ new_bookmarks = []
+ for tab in self.get_tabs(tabs.MucTab):
+ bookmark = self.bookmarks[tab.name]
+ if not bookmark:
+ bookmark = Bookmark(tab.name, autojoin=True,
+ method=method)
+ new_bookmarks.append(bookmark)
+ else:
+ bookmark.method = method
+ new_bookmarks.append(bookmark)
+ self.bookmarks.remove(bookmark)
+ new_bookmarks.extend(self.bookmarks.bookmarks)
+ self.bookmarks.set(new_bookmarks)
+ def _cb(iq):
+ if iq["type"] != "error":
+ self.information("Bookmarks saved.", "Info")
+ else:
+ self.information("Could not save the remote bookmarks.", "Info")
+ self.bookmarks.save_local()
+ self.bookmarks.save_remote(self.xmpp, _cb)
+
+@command_args_parser.ignored
+def command_bookmarks(self):
+ """/bookmarks"""
+ tab = self.get_tab_by_name('Bookmarks', tabs.BookmarksTab)
+ old_tab = self.current_tab()
+ if tab:
+ self.current_tab_nb = tab.nb
+ else:
+ tab = tabs.BookmarksTab(self.bookmarks)
+ self.tabs.append(tab)
+ self.current_tab_nb = tab.nb
+ old_tab.on_lose_focus()
+ tab.on_gain_focus()
+ self.refresh_window()
+
+@command_args_parser.quoted(0, 1)
+def command_remove_bookmark(self, args):
+ """/remove_bookmark [jid]"""
+
+ def cb(success):
+ if success:
+ self.information('Bookmark deleted', 'Info')
+ else:
+ self.information('Error while deleting the bookmark', 'Error')
+
+ if not args:
+ tab = self.current_tab()
+ if isinstance(tab, tabs.MucTab) and self.bookmarks[tab.name]:
+ self.bookmarks.remove(tab.name)
+ self.bookmarks.save(self.xmpp, callback=cb)
+ else:
+ self.information('No bookmark to remove', 'Info')
+ else:
+ if self.bookmarks[args[0]]:
+ self.bookmarks.remove(args[0])
+ self.bookmarks.save(self.xmpp, callback=cb)
+ else:
+ self.information('No bookmark to remove', 'Info')
+
+@command_args_parser.quoted(0, 3)
+def command_set(self, args):
+ """
+ /set [module|][section] <option> [value]
+ """
+ if args is None or len(args) == 0:
+ config_dict = config.to_dict()
+ lines = []
+ theme = get_theme()
+ for section_name, section in config_dict.items():
+ lines.append('\x19%(section_col)s}[%(section)s]\x19o' %
+ {
+ 'section': section_name,
+ 'section_col': dump_tuple(theme.COLOR_INFORMATION_TEXT),
+ })
+ for option_name, option_value in section.items():
+ lines.append('%s\x19%s}=\x19o%s' % (option_name,
+ dump_tuple(theme.COLOR_REVISIONS_MESSAGE),
+ option_value))
+ info = ('Current options:\n%s' % '\n'.join(lines), 'Info')
+ elif len(args) == 1:
+ option = args[0]
+ value = config.get(option)
+ if value is None and '=' in option:
+ args = option.split('=', 1)
+ info = ('%s=%s' % (option, value), 'Info')
+ if len(args) == 2:
+ if '|' in args[0]:
+ plugin_name, section = args[0].split('|')[:2]
+ if not section:
+ section = plugin_name
+ option = args[1]
+ if not plugin_name in self.plugin_manager.plugins:
+ file_name = self.plugin_manager.plugins_conf_dir
+ file_name = os.path.join(file_name, plugin_name + '.cfg')
+ plugin_config = PluginConfig(file_name, plugin_name)
+ else:
+ plugin_config = self.plugin_manager.plugins[plugin_name].config
+ value = plugin_config.get(option, default='', section=section)
+ info = ('%s=%s' % (option, value), 'Info')
+ else:
+ possible_section = args[0]
+ if config.has_section(possible_section):
+ section = possible_section
+ option = args[1]
+ value = config.get(option, section=section)
+ info = ('%s=%s' % (option, value), 'Info')
+ else:
+ option = args[0]
+ value = args[1]
+ info = config.set_and_save(option, value)
+ self.trigger_configuration_change(option, value)
+ elif len(args) == 3:
+ if '|' in args[0]:
+ plugin_name, section = args[0].split('|')[:2]
+ if not section:
+ section = plugin_name
+ option = args[1]
+ value = args[2]
+ if not plugin_name in self.plugin_manager.plugins:
+ file_name = self.plugin_manager.plugins_conf_dir
+ file_name = os.path.join(file_name, plugin_name + '.cfg')
+ plugin_config = PluginConfig(file_name, plugin_name)
+ else:
+ plugin_config = self.plugin_manager.plugins[plugin_name].config
+ info = plugin_config.set_and_save(option, value, section)
+ else:
+ if args[0] == '.':
+ name = safeJID(self.current_tab().name).bare
+ if not name:
+ self.information('Invalid tab to use the "." argument.',
+ 'Error')
+ return
+ section = name
+ else:
+ section = args[0]
+ option = args[1]
+ value = args[2]
+ info = config.set_and_save(option, value, section)
+ self.trigger_configuration_change(option, value)
+ elif len(args) > 3:
+ return self.command_help('set')
+ self.information(*info)
+
+@command_args_parser.quoted(1, 2)
+def command_set_default(self, args):
+ """
+ /set_default [section] <option>
+ """
+ if len(args) == 1:
+ option = args[0]
+ section = 'Poezio'
+ elif len(args) == 2:
+ section = args[0]
+ option = args[1]
+ else:
+ return self.command_help('set_default')
+
+ default_config = DEFAULT_CONFIG.get(section, tuple())
+ if option not in default_config:
+ info = ("Option %s has no default value" % (option), "Error")
+ return self.information(*info)
+ self.command_set('%s %s %s' % (section, option, default_config[option]))
+
+@command_args_parser.quoted(1)
+def command_toggle(self, args):
+ """
+ /toggle <option>
+ shortcut for /set <option> toggle
+ """
+ if args is None:
+ return self.command_help('toggle')
+
+ if args[0]:
+ self.command_set('%s toggle' % args[0])
+
+@command_args_parser.quoted(1, 1)
+def command_server_cycle(self, args):
+ """
+ Do a /cycle on each room of the given server.
+ If none, do it on the current tab
+ """
+ tab = self.current_tab()
+ message = ""
+ if args:
+ domain = args[0]
+ if len(args) == 2:
+ message = args[1]
+ else:
+ if isinstance(tab, tabs.MucTab):
+ domain = safeJID(tab.name).domain
+ else:
+ return self.information("No server specified", "Error")
+ for tab in self.get_tabs(tabs.MucTab):
+ if tab.name.endswith(domain):
+ if tab.joined:
+ muc.leave_groupchat(tab.core.xmpp,
+ tab.name,
+ tab.own_nick,
+ message)
+ tab.joined = False
+ if tab.name == domain:
+ self.command_join('"@%s/%s"' %(tab.name, tab.own_nick))
+ else:
+ self.command_join('"%s/%s"' %(tab.name, tab.own_nick))
+
+@command_args_parser.quoted(1)
+def command_last_activity(self, args):
+ """
+ /last_activity <jid>
+ """
+ def callback(iq):
+ "Callback for the last activity"
+ if iq['type'] != 'result':
+ if iq['error']['type'] == 'auth':
+ self.information('You are not allowed to see the '
+ 'activity of this contact.',
+ 'Error')
+ else:
+ self.information('Error retrieving the activity', 'Error')
+ return
+ seconds = iq['last_activity']['seconds']
+ status = iq['last_activity']['status']
+ from_ = iq['from']
+ if not safeJID(from_).user:
+ msg = 'The uptime of %s is %s.' % (
+ from_,
+ common.parse_secs_to_str(seconds))
+ else:
+ msg = 'The last activity of %s was %s ago%s' % (
+ from_,
+ common.parse_secs_to_str(seconds),
+ (' and his/her last status was %s' % status) if status else '')
+ self.information(msg, 'Info')
+
+ if args is None:
+ return self.command_help('last_activity')
+ jid = safeJID(args[0])
+ self.xmpp.plugin['xep_0012'].get_last_activity(jid,
+ callback=callback)
+
+@command_args_parser.quoted(0, 2)
+def command_mood(self, args):
+ """
+ /mood [<mood> [text]]
+ """
+ if not args:
+ return self.xmpp.plugin['xep_0107'].stop()
+
+ mood = args[0]
+ if mood not in pep.MOODS:
+ return self.information('%s is not a correct value for a mood.'
+ % mood,
+ 'Error')
+ if len(args) == 2:
+ text = args[1]
+ else:
+ text = None
+ self.xmpp.plugin['xep_0107'].publish_mood(mood, text,
+ callback=dumb_callback)
+
+@command_args_parser.quoted(0, 3)
+def command_activity(self, args):
+ """
+ /activity [<general> [specific] [text]]
+ """
+ length = len(args)
+ if not length:
+ return self.xmpp.plugin['xep_0108'].stop()
+
+ general = args[0]
+ if general not in pep.ACTIVITIES:
+ return self.information('%s is not a correct value for an activity'
+ % general,
+ 'Error')
+ specific = None
+ text = None
+ if length == 2:
+ if args[1] in pep.ACTIVITIES[general]:
+ specific = args[1]
+ else:
+ text = args[1]
+ elif length == 3:
+ specific = args[1]
+ text = args[2]
+ if specific and specific not in pep.ACTIVITIES[general]:
+ return self.information('%s is not a correct value '
+ 'for an activity' % specific,
+ 'Error')
+ self.xmpp.plugin['xep_0108'].publish_activity(general, specific, text,
+ callback=dumb_callback)
+
+@command_args_parser.quoted(0, 2)
+def command_gaming(self, args):
+ """
+ /gaming [<game name> [server address]]
+ """
+ if not args:
+ return self.xmpp.plugin['xep_0196'].stop()
+
+ name = args[0]
+ if len(args) > 1:
+ address = args[1]
+ else:
+ address = None
+ return self.xmpp.plugin['xep_0196'].publish_gaming(name=name,
+ server_address=address,
+ callback=dumb_callback)
+
+@command_args_parser.quoted(2, 1, [None])
+def command_invite(self, args):
+ """/invite <to> <room> [reason]"""
+
+ if args is None:
+ return self.command_help('invite')
+
+ reason = args[2]
+ to = safeJID(args[0])
+ room = safeJID(args[1]).bare
+ self.invite(to.full, room, reason=reason)
+ self.information('Invited %s to %s' % (to.bare, room), 'Info')
+
+@command_args_parser.quoted(1, 1, [''])
+def command_decline(self, args):
+ """/decline <room@server.tld> [reason]"""
+ if args is None:
+ return self.command_help('decline')
+ jid = safeJID(args[0])
+ if jid.bare not in self.pending_invites:
+ return
+ reason = args[1]
+ del self.pending_invites[jid.bare]
+ self.xmpp.plugin['xep_0045'].decline_invite(jid.bare,
+ self.pending_invites[jid.bare],
+ reason)
+
+### Commands without a completion in this class ###
+
+@command_args_parser.ignored
+def command_invitations(self):
+ """/invitations"""
+ build = ""
+ for invite in self.pending_invites:
+ build += "%s by %s" % (invite,
+ safeJID(self.pending_invites[invite]).bare)
+ if self.pending_invites:
+ build = "You are invited to the following rooms:\n" + build
+ else:
+ build = "You do not have any pending invitations."
+ self.information(build, 'Info')
+
+@command_args_parser.quoted(0, 1, [None])
+def command_quit(self, args):
+ """
+ /quit [message]
+ """
+ if not self.xmpp.is_connected():
+ self.exit()
+ return
+
+ msg = args[0]
+ if config.get('enable_user_mood'):
+ self.xmpp.plugin['xep_0107'].stop()
+ if config.get('enable_user_activity'):
+ self.xmpp.plugin['xep_0108'].stop()
+ if config.get('enable_user_gaming'):
+ self.xmpp.plugin['xep_0196'].stop()
+ self.save_config()
+ self.plugin_manager.disable_plugins()
+ self.disconnect(msg)
+ self.xmpp.add_event_handler("disconnected", self.exit, disposable=True)
+
+@command_args_parser.quoted(0, 1, [''])
+def command_destroy_room(self, args):
+ """
+ /destroy_room [JID]
+ """
+ room = safeJID(args[0]).bare
+ if room:
+ muc.destroy_room(self.xmpp, room)
+ elif isinstance(self.current_tab(), tabs.MucTab) and not args[0]:
+ muc.destroy_room(self.xmpp, self.current_tab().general_jid)
+ else:
+ self.information('Invalid JID: "%s"' % args[0], 'Error')
+
+@command_args_parser.quoted(1, 1, [''])
+def command_bind(self, args):
+ """
+ Bind a key.
+ """
+ if args is None:
+ return self.command_help('bind')
+
+ if not config.silent_set(args[0], args[1], section='bindings'):
+ self.information('Unable to write in the config file', 'Error')
+
+ if args[1]:
+ self.information('%s is now bound to %s' % (args[0], args[1]), 'Info')
+ else:
+ self.information('%s is now unbound' % args[0], 'Info')
+
+@command_args_parser.raw
+def command_rawxml(self, args):
+ """
+ /rawxml <xml stanza>
+ """
+
+ if not args:
+ return
+
+ stanza = args
+ try:
+ stanza = StanzaBase(self.xmpp, xml=ET.fromstring(stanza))
+ if stanza.xml.tag == 'iq' and stanza.xml.attrib.get('type') in ('get', 'set'):
+ iq_id = stanza.xml.attrib.get('id')
+ if not iq_id:
+ iq_id = self.xmpp.new_id()
+ stanza['id'] = iq_id
+
+ def iqfunc(iq):
+ "handler for an iq reply"
+ self.information('%s' % iq, 'Iq')
+ self.xmpp.remove_handler('Iq %s' % iq_id)
+
+ self.xmpp.register_handler(
+ Callback('Iq %s' % iq_id,
+ StanzaPath('iq@id=%s' % iq_id),
+ iqfunc
+ )
+ )
+ log.debug('handler')
+ log.debug('%s %s', stanza.xml.tag, stanza.xml.attrib)
+
+ stanza.send()
+ except:
+ self.information('Could not send custom stanza', 'Error')
+ log.debug('/rawxml: Could not send custom stanza (%s)',
+ repr(stanza),
+ exc_info=True)
+
+
+@command_args_parser.quoted(1, 256)
+def command_load(self, args):
+ """
+ /load <plugin> [<otherplugin> …]
+ # TODO: being able to load more than 256 plugins at once, hihi.
+ """
+ for plugin in args:
+ self.plugin_manager.load(plugin)
+
+@command_args_parser.quoted(1, 256)
+def command_unload(self, args):
+ """
+ /unload <plugin> [<otherplugin> …]
+ """
+ for plugin in args:
+ self.plugin_manager.unload(plugin)
+
+@command_args_parser.ignored
+def command_plugins(self):
+ """
+ /plugins
+ """
+ self.information("Plugins currently in use: %s" %
+ repr(list(self.plugin_manager.plugins.keys())),
+ 'Info')
+
+@command_args_parser.quoted(1, 1)
+def command_message(self, args):
+ """
+ /message <jid> [message]
+ """
+ if args is None:
+ return self.command_help('message')
+ jid = safeJID(args[0])
+ if not jid.user and not jid.domain and not jid.resource:
+ return self.information('Invalid JID.', 'Error')
+ tab = self.get_conversation_by_jid(jid.full, False, fallback_barejid=False)
+ muc = self.get_tab_by_name(jid.bare, typ=tabs.MucTab)
+ if not tab and not muc:
+ tab = self.open_conversation_window(jid.full, focus=True)
+ elif muc:
+ tab = self.get_tab_by_name(jid.full, typ=tabs.PrivateTab)
+ if tab:
+ self.focus_tab_named(tab.name)
+ else:
+ tab = self.open_private_window(jid.bare, jid.resource)
+ else:
+ self.focus_tab_named(tab.name)
+ if len(args) == 2:
+ tab.command_say(args[1])
+
+@command_args_parser.ignored
+def command_xml_tab(self):
+ """/xml_tab"""
+ 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):
+ if not args:
+ return self.command_help('ad-hoc')
+ jid = safeJID(args[0])
+ list_tab = tabs.AdhocCommandsListTab(jid)
+ self.add_tab(list_tab, True)
+ cb = list_tab.on_list_received
+ self.xmpp.plugin['xep_0050'].get_commands(jid=jid, local=False,
+ callback=cb)
+
+@command_args_parser.ignored
+def command_self(self):
+ """
+ /self
+ """
+ status = self.get_status()
+ show, message = status.show, status.message
+ nick = self.own_nick
+ jid = self.xmpp.boundjid.full
+ info = ('Your JID is %s\nYour current status is "%s" (%s)'
+ '\nYour default nickname is %s\nYou are running poezio %s' % (
+ jid,
+ message if message else '',
+ show if show else 'available',
+ nick,
+ config_opts.version))
+ self.information(info, 'Info')
+
+
+@command_args_parser.ignored
+def command_reload(self):
+ """
+ /reload
+ """
+ self.reload_config()
+
+def dumb_callback(*args, **kwargs):
+ "mock callback"
+
diff --git a/poezio/core/completions.py b/poezio/core/completions.py
new file mode 100644
index 00000000..9fd44f1b
--- /dev/null
+++ b/poezio/core/completions.py
@@ -0,0 +1,387 @@
+"""
+Completions for the global commands
+"""
+import logging
+
+log = logging.getLogger(__name__)
+
+import os
+from functools import reduce
+
+import common
+import pep
+import tabs
+from common import safeJID
+from config import config
+from roster import roster
+
+from . structs import possible_show
+
+
+def completion_help(self, the_input):
+ """Completion for /help."""
+ commands = sorted(self.commands.keys()) + sorted(self.current_tab().commands.keys())
+ return the_input.new_completion(commands, 1, quotify=False)
+
+
+def completion_status(self, the_input):
+ """
+ Completion of /status
+ """
+ if the_input.get_argument_position() == 1:
+ return the_input.new_completion([status for status in possible_show], 1, ' ', quotify=False)
+
+
+def completion_presence(self, the_input):
+ """
+ Completion of /presence
+ """
+ arg = the_input.get_argument_position()
+ if arg == 1:
+ return the_input.auto_completion([jid for jid in roster.jids()], '', quotify=True)
+ elif arg == 2:
+ return the_input.auto_completion([status for status in possible_show], '', quotify=True)
+
+
+def completion_theme(self, the_input):
+ """ Completion for /theme"""
+ themes_dir = config.get('themes_dir')
+ themes_dir = themes_dir or\
+ os.path.join(os.environ.get('XDG_DATA_HOME') or\
+ os.path.join(os.environ.get('HOME'), '.local', 'share'),
+ 'poezio', 'themes')
+ themes_dir = os.path.expanduser(themes_dir)
+ try:
+ names = os.listdir(themes_dir)
+ except OSError as e:
+ log.error('Completion for /theme failed', exc_info=True)
+ return
+ theme_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py']
+ if not 'default' in theme_files:
+ theme_files.append('default')
+ return the_input.new_completion(theme_files, 1, '', quotify=False)
+
+
+def completion_win(self, the_input):
+ """Completion for /win"""
+ l = []
+ for tab in self.tabs:
+ l.extend(tab.matching_names())
+ l = [i[1] for i in l]
+ return the_input.new_completion(l, 1, '', quotify=False)
+
+
+def completion_join(self, the_input):
+ """
+ Completion for /join
+
+ Try to complete the MUC JID:
+ if only a resource is provided, complete with the default nick
+ if only a server is provided, complete with the rooms from the
+ disco#items of that server
+ if only a nodepart is provided, complete with the servers of the
+ current joined rooms
+ """
+ n = the_input.get_argument_position(quoted=True)
+ args = common.shell_split(the_input.text)
+ if n != 1:
+ # we are not on the 1st argument of the command line
+ return False
+ if len(args) == 1:
+ args.append('')
+ jid = safeJID(args[1])
+ if args[1].endswith('@') and not jid.user and not jid.server:
+ jid.user = args[1][:-1]
+
+ relevant_rooms = []
+ relevant_rooms.extend(sorted(self.pending_invites.keys()))
+ bookmarks = {str(elem.jid): False for elem in self.bookmarks}
+ for tab in self.get_tabs(tabs.MucTab):
+ name = tab.name
+ if name in bookmarks and not tab.joined:
+ bookmarks[name] = True
+ relevant_rooms.extend(sorted(room[0] for room in bookmarks.items() if room[1]))
+
+ if the_input.last_completion:
+ return the_input.new_completion([], 1, quotify=True)
+
+ if jid.user:
+ # we are writing the server: complete the server
+ serv_list = []
+ for tab in self.get_tabs(tabs.MucTab):
+ if tab.joined:
+ serv_list.append('%s@%s'% (jid.user, safeJID(tab.name).host))
+ serv_list.extend(relevant_rooms)
+ return the_input.new_completion(serv_list, 1, quotify=True)
+ elif args[1].startswith('/'):
+ # we completing only a resource
+ return the_input.new_completion(['/%s' % self.own_nick], 1, quotify=True)
+ else:
+ return the_input.new_completion(relevant_rooms, 1, quotify=True)
+
+
+def completion_version(self, the_input):
+ """Completion for /version"""
+ comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), [])
+ return the_input.new_completion(sorted(comp), 1, quotify=False)
+
+
+def completion_list(self, the_input):
+ """Completion for /list"""
+ muc_serv_list = []
+ for tab in self.get_tabs(tabs.MucTab): # TODO, also from an history
+ if tab.name not in muc_serv_list:
+ muc_serv_list.append(safeJID(tab.name).server)
+ if muc_serv_list:
+ return the_input.new_completion(muc_serv_list, 1, quotify=False)
+
+
+def completion_move_tab(self, the_input):
+ """Completion for /move_tab"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ nodes = [tab.name for tab in self.tabs if tab]
+ nodes.remove('Roster')
+ return the_input.new_completion(nodes, 1, ' ', quotify=True)
+
+
+def completion_runkey(self, the_input):
+ """
+ Completion for /runkey
+ """
+ list_ = []
+ list_.extend(self.key_func.keys())
+ list_.extend(self.current_tab().key_func.keys())
+ return the_input.new_completion(list_, 1, quotify=False)
+
+
+def completion_bookmark(self, the_input):
+ """Completion for /bookmark"""
+ args = common.shell_split(the_input.text)
+ n = the_input.get_argument_position(quoted=True)
+
+ if n == 2:
+ return the_input.new_completion(['true', 'false'], 2, quotify=True)
+ if n >= 3:
+ return
+
+ if len(args) == 1:
+ args.append('')
+ jid = safeJID(args[1])
+
+ if jid.server and (jid.resource or jid.full.endswith('/')):
+ tab = self.get_tab_by_name(jid.bare, tabs.MucTab)
+ nicks = [tab.own_nick] if tab else []
+ default = os.environ.get('USER') if os.environ.get('USER') else 'poezio'
+ nick = config.get('default_nick')
+ if not nick:
+ if not default in nicks:
+ nicks.append(default)
+ else:
+ if not nick in nicks:
+ nicks.append(nick)
+ jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
+ return the_input.new_completion(jids_list, 1, quotify=True)
+ muc_list = [tab.name for tab in self.get_tabs(tabs.MucTab)]
+ muc_list.sort()
+ muc_list.append('*')
+ return the_input.new_completion(muc_list, 1, quotify=True)
+
+
+def completion_remove_bookmark(self, the_input):
+ """Completion for /remove_bookmark"""
+ return the_input.new_completion([bm.jid for bm in self.bookmarks], 1, quotify=False)
+
+
+def completion_decline(self, the_input):
+ """Completion for /decline"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ return the_input.auto_completion(sorted(self.pending_invites.keys()), 1, '', quotify=True)
+
+
+def completion_bind(self, the_input):
+ n = the_input.get_argument_position()
+ if n == 1:
+ args = [key for key in self.key_func if not key.startswith('_')]
+ elif n == 2:
+ args = [key for key in self.key_func]
+ else:
+ return
+
+ return the_input.new_completion(args, n, '', quotify=False)
+
+
+def completion_message(self, the_input):
+ """Completion for /message"""
+ n = the_input.get_argument_position(quoted=True)
+ if n >= 2:
+ return
+ l = []
+ for jid in roster.jids():
+ if len(roster[jid]):
+ l.append(jid)
+ for resource in roster[jid].resources:
+ l.append(resource.jid)
+ return the_input.new_completion(l, 1, '', quotify=True)
+
+
+def completion_invite(self, the_input):
+ """Completion for /invite"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), [])
+ comp = sorted(comp)
+ bares = sorted(roster[contact].bare_jid for contact in roster.jids() if len(roster[contact]))
+ off = sorted(jid for jid in roster.jids() if jid not in bares)
+ comp = comp + bares + off
+ return the_input.new_completion(comp, n, quotify=True)
+ elif n == 2:
+ rooms = []
+ for tab in self.get_tabs(tabs.MucTab):
+ if tab.joined:
+ rooms.append(tab.name)
+ rooms.sort()
+ return the_input.new_completion(rooms, n, '', quotify=True)
+
+
+def completion_activity(self, the_input):
+ """Completion for /activity"""
+ n = the_input.get_argument_position(quoted=True)
+ args = common.shell_split(the_input.text)
+ if n == 1:
+ return the_input.new_completion(sorted(pep.ACTIVITIES.keys()), n, quotify=True)
+ elif n == 2:
+ if args[1] in pep.ACTIVITIES:
+ l = list(pep.ACTIVITIES[args[1]])
+ l.remove('category')
+ l.sort()
+ return the_input.new_completion(l, n, quotify=True)
+
+
+def completion_mood(self, the_input):
+ """Completion for /mood"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ return the_input.new_completion(sorted(pep.MOODS.keys()), 1, quotify=True)
+
+
+def completion_last_activity(self, the_input):
+ """
+ Completion for /last_activity <jid>
+ """
+ n = the_input.get_argument_position(quoted=False)
+ if n >= 2:
+ return
+ comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), [])
+ return the_input.new_completion(sorted(comp), 1, '', quotify=False)
+
+
+def completion_server_cycle(self, the_input):
+ """Completion for /server_cycle"""
+ serv_list = set()
+ for tab in self.get_tabs(tabs.MucTab):
+ serv = safeJID(tab.name).server
+ serv_list.add(serv)
+ return the_input.new_completion(sorted(serv_list), 1, ' ')
+
+
+def completion_set(self, the_input):
+ """Completion for /set"""
+ args = common.shell_split(the_input.text)
+ n = the_input.get_argument_position(quoted=True)
+ if n >= len(args):
+ args.append('')
+ if n == 1:
+ if '|' in args[1]:
+ plugin_name, section = args[1].split('|')[:2]
+ if not plugin_name in self.plugin_manager.plugins:
+ return the_input.new_completion([], n, quotify=True)
+ plugin = self.plugin_manager.plugins[plugin_name]
+ end_list = ['%s|%s' % (plugin_name, section) for section in plugin.config.sections()]
+ else:
+ end_list = set(config.options('Poezio'))
+ end_list.update(config.default.get('Poezio', {}))
+ end_list = list(end_list)
+ end_list.sort()
+ elif n == 2:
+ if '|' in args[1]:
+ plugin_name, section = args[1].split('|')[:2]
+ if not plugin_name in self.plugin_manager.plugins:
+ return the_input.new_completion([''], n, quotify=True)
+ plugin = self.plugin_manager.plugins[plugin_name]
+ end_list = set(plugin.config.options(section or plugin_name))
+ if plugin.config.default:
+ end_list.update(plugin.config.default.get(section or plugin_name, {}))
+ end_list = list(end_list)
+ end_list.sort()
+ elif not config.has_option('Poezio', args[1]):
+ if config.has_section(args[1]):
+ end_list = config.options(args[1])
+ end_list.append('')
+ else:
+ end_list = []
+ else:
+ end_list = [str(config.get(args[1], '')), '']
+ elif n == 3:
+ if '|' in args[1]:
+ plugin_name, section = args[1].split('|')[:2]
+ if not plugin_name in self.plugin_manager.plugins:
+ return the_input.new_completion([''], n, quotify=True)
+ plugin = self.plugin_manager.plugins[plugin_name]
+ end_list = [str(plugin.config.get(args[2], '', section or plugin_name)), '']
+ else:
+ if not config.has_section(args[1]):
+ end_list = ['']
+ else:
+ end_list = [str(config.get(args[2], '', args[1])), '']
+ else:
+ return
+ return the_input.new_completion(end_list, n, quotify=True)
+
+
+def completion_set_default(self, the_input):
+ """ Completion for /set_default
+ """
+ args = common.shell_split(the_input.text)
+ n = the_input.get_argument_position(quoted=True)
+ if n >= len(args):
+ args.append('')
+ if n == 1 or (n == 2 and config.has_section(args[1])):
+ return self.completion_set(the_input)
+ return []
+
+
+def completion_toggle(self, the_input):
+ "Completion for /toggle"
+ return the_input.new_completion(config.options('Poezio'), 1, quotify=False)
+
+
+def completion_bookmark_local(self, the_input):
+ """Completion for /bookmark_local"""
+ n = the_input.get_argument_position(quoted=True)
+ args = common.shell_split(the_input.text)
+
+ if n >= 2:
+ return
+ if len(args) == 1:
+ args.append('')
+ jid = safeJID(args[1])
+
+ if jid.server and (jid.resource or jid.full.endswith('/')):
+ tab = self.get_tab_by_name(jid.bare, tabs.MucTab)
+ nicks = [tab.own_nick] if tab else []
+ default = os.environ.get('USER') if os.environ.get('USER') else 'poezio'
+ nick = config.get('default_nick')
+ if not nick:
+ if not default in nicks:
+ nicks.append(default)
+ else:
+ if not nick in nicks:
+ nicks.append(nick)
+ jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
+ return the_input.new_completion(jids_list, 1, quotify=True)
+ muc_list = [tab.name for tab in self.get_tabs(tabs.MucTab)]
+ muc_list.append('*')
+ return the_input.new_completion(muc_list, 1, quotify=True)
+
diff --git a/poezio/core/core.py b/poezio/core/core.py
new file mode 100644
index 00000000..f32099f1
--- /dev/null
+++ b/poezio/core/core.py
@@ -0,0 +1,2102 @@
+"""
+Module defining the Core class, which is the central orchestrator
+of poezio and contains the main loop, the list of tabs, sets the state
+of everything; it also contains global commands, completions and event
+handlers but those are defined in submodules in order to avoir cluttering
+this file.
+"""
+import logging
+
+log = logging.getLogger(__name__)
+
+import asyncio
+import shutil
+import curses
+import os
+import pipes
+import sys
+import time
+
+from slixmpp.xmlstream.handler import Callback
+
+import connection
+import decorators
+import events
+import singleton
+import tabs
+import theming
+import timed_events
+import windows
+
+from bookmarks import BookmarkList
+from common import safeJID
+from config import config, firstrun
+from contact import Contact, Resource
+from daemon import Executor
+from fifo import Fifo
+from logger import logger
+from plugin_manager import PluginManager
+from roster import roster
+from size_manager import SizeManager
+from text_buffer import TextBuffer
+from theming import get_theme
+import keyboard
+
+from . import completions
+from . import commands
+from . import handlers
+from . structs import possible_show, DEPRECATED_ERRORS, \
+ ERROR_AND_STATUS_CODES, Command, Status
+
+
+class Core(object):
+ """
+ “Main” class of poezion
+ """
+
+ def __init__(self):
+ # All uncaught exception are given to this callback, instead
+ # of being displayed on the screen and exiting the program.
+ sys.excepthook = self.on_exception
+ self.connection_time = time.time()
+ self.stdscr = None
+ status = config.get('status')
+ status = possible_show.get(status, None)
+ self.status = Status(show=status,
+ message=config.get('status_message'))
+ self.running = True
+ self.xmpp = singleton.Singleton(connection.Connection)
+ self.xmpp.core = self
+ self.keyboard = keyboard.Keyboard()
+ roster.set_node(self.xmpp.client_roster)
+ decorators.refresh_wrapper.core = self
+ self.bookmarks = BookmarkList()
+ self.debug = False
+ self.remote_fifo = None
+ # a unique buffer used to store global informations
+ # that are displayed in almost all tabs, in an
+ # information window.
+ self.information_buffer = TextBuffer()
+ self.information_win_size = config.get('info_win_height', section='var')
+ self.information_win = windows.TextWin(300)
+ self.information_buffer.add_window(self.information_win)
+ self.left_tab_win = None
+
+ self.tab_win = windows.GlobalInfoBar()
+ # Whether the XML tab is opened
+ self.xml_tab = None
+ self.xml_buffer = TextBuffer()
+
+ self.tabs = []
+ self._current_tab_nb = 0
+ self.previous_tab_nb = 0
+
+ own_nick = config.get('default_nick')
+ own_nick = own_nick or self.xmpp.boundjid.user
+ own_nick = own_nick or os.environ.get('USER')
+ own_nick = own_nick or 'poezio'
+ self.own_nick = own_nick
+
+ self.plugins_autoloaded = False
+ self.plugin_manager = PluginManager(self)
+ self.events = events.EventHandler()
+
+ self.size = SizeManager(self, windows.Win)
+
+ # Set to True whenever we consider that we have been disconnected
+ # from the server because of a legitimate reason (bad credentials,
+ # or explicit disconnect from the user for example), in that case we
+ # should not try to auto-reconnect, even if auto_reconnect is true
+ # in the user config.
+ self.legitimate_disconnect = False
+
+ # global commands, available from all tabs
+ # a command is tuple of the form:
+ # (the function executing the command. Takes a string as argument,
+ # a string representing the help message,
+ # a completion function, taking a Input as argument. Can be None)
+ # The completion function should return True if a completion was
+ # made ; False otherwise
+ self.commands = {}
+ self.register_initial_commands()
+
+ # We are invisible
+ if not config.get('send_initial_presence'):
+ del self.commands['status']
+ del self.commands['show']
+
+ # A list of integers. For example if the user presses Alt+j, 2, 1,
+ # we will insert 2, then 1 in that list, and we will finally build
+ # the number 21 and use it with command_win, before clearing the
+ # list.
+ self.room_number_jump = []
+ self.key_func = KeyDict()
+ # Key bindings associated with handlers
+ # and pseudo-keys used to map actions below.
+ key_func = {
+ "KEY_PPAGE": self.scroll_page_up,
+ "KEY_NPAGE": self.scroll_page_down,
+ "^B": self.scroll_line_up,
+ "^F": self.scroll_line_down,
+ "^X": self.scroll_half_down,
+ "^S": self.scroll_half_up,
+ "KEY_F(5)": self.rotate_rooms_left,
+ "^P": self.rotate_rooms_left,
+ "M-[-D": self.rotate_rooms_left,
+ 'kLFT3': self.rotate_rooms_left,
+ "KEY_F(6)": self.rotate_rooms_right,
+ "^N": self.rotate_rooms_right,
+ "M-[-C": self.rotate_rooms_right,
+ 'kRIT3': self.rotate_rooms_right,
+ "KEY_F(4)": self.toggle_left_pane,
+ "KEY_F(7)": self.shrink_information_win,
+ "KEY_F(8)": self.grow_information_win,
+ "KEY_RESIZE": self.call_for_resize,
+ 'M-e': self.go_to_important_room,
+ 'M-r': self.go_to_roster,
+ 'M-z': self.go_to_previous_tab,
+ '^L': self.full_screen_redraw,
+ 'M-j': self.go_to_room_number,
+ 'M-D': self.scroll_info_up,
+ 'M-C': self.scroll_info_down,
+ 'M-k': self.escape_next_key,
+ ######## actions mappings ##########
+ '_bookmark': self.command_bookmark,
+ '_bookmark_local': self.command_bookmark_local,
+ '_close_tab': self.close_tab,
+ '_disconnect': self.disconnect,
+ '_quit': self.command_quit,
+ '_redraw_screen': self.full_screen_redraw,
+ '_reload_theme': self.command_theme,
+ '_remove_bookmark': self.command_remove_bookmark,
+ '_room_left': self.rotate_rooms_left,
+ '_room_right': self.rotate_rooms_right,
+ '_show_roster': self.go_to_roster,
+ '_scroll_down': self.scroll_page_down,
+ '_scroll_up': self.scroll_page_up,
+ '_scroll_info_up': self.scroll_info_up,
+ '_scroll_info_down': self.scroll_info_down,
+ '_server_cycle': self.command_server_cycle,
+ '_show_bookmarks': self.command_bookmarks,
+ '_show_important_room': self.go_to_important_room,
+ '_show_invitations': self.command_invitations,
+ '_show_plugins': self.command_plugins,
+ '_show_xmltab': self.command_xml_tab,
+ '_toggle_pane': self.toggle_left_pane,
+ ###### status actions ######
+ '_available': lambda: self.command_status('available'),
+ '_away': lambda: self.command_status('away'),
+ '_chat': lambda: self.command_status('chat'),
+ '_dnd': lambda: self.command_status('dnd'),
+ '_xa': lambda: self.command_status('xa'),
+ ##### Custom actions ########
+ '_exc_': self.try_execute,
+ }
+ self.key_func.update(key_func)
+
+ # Add handlers
+ self.xmpp.add_event_handler('connecting', self.on_connecting)
+ self.xmpp.add_event_handler('connected', self.on_connected)
+ self.xmpp.add_event_handler('connection_failed', self.on_failed_connection)
+ self.xmpp.add_event_handler('disconnected', self.on_disconnected)
+ self.xmpp.add_event_handler('stream_error', self.on_stream_error)
+ self.xmpp.add_event_handler('failed_all_auth', self.on_failed_all_auth)
+ self.xmpp.add_event_handler('no_auth', self.on_no_auth)
+ self.xmpp.add_event_handler("session_start", self.on_session_start)
+ self.xmpp.add_event_handler("session_start",
+ self.on_session_start_features)
+ self.xmpp.add_event_handler("groupchat_presence",
+ self.on_groupchat_presence)
+ self.xmpp.add_event_handler("groupchat_message",
+ self.on_groupchat_message)
+ self.xmpp.add_event_handler("groupchat_invite",
+ self.on_groupchat_invitation)
+ self.xmpp.add_event_handler("groupchat_direct_invite",
+ self.on_groupchat_direct_invitation)
+ self.xmpp.add_event_handler("groupchat_decline",
+ self.on_groupchat_decline)
+ self.xmpp.add_event_handler("groupchat_config_status",
+ self.on_status_codes)
+ self.xmpp.add_event_handler("groupchat_subject",
+ self.on_groupchat_subject)
+ self.xmpp.add_event_handler("message", self.on_message)
+ self.xmpp.add_event_handler("message_error", self.on_error_message)
+ self.xmpp.add_event_handler("receipt_received", self.on_receipt)
+ self.xmpp.add_event_handler("got_online", self.on_got_online)
+ self.xmpp.add_event_handler("got_offline", self.on_got_offline)
+ self.xmpp.add_event_handler("roster_update", self.on_roster_update)
+ self.xmpp.add_event_handler("changed_status", self.on_presence)
+ self.xmpp.add_event_handler("presence_error", self.on_presence_error)
+ self.xmpp.add_event_handler("roster_subscription_request",
+ self.on_subscription_request)
+ self.xmpp.add_event_handler("roster_subscription_authorized",
+ self.on_subscription_authorized)
+ self.xmpp.add_event_handler("roster_subscription_remove",
+ self.on_subscription_remove)
+ self.xmpp.add_event_handler("roster_subscription_removed",
+ self.on_subscription_removed)
+ self.xmpp.add_event_handler("message_xform", self.on_data_form)
+ self.xmpp.add_event_handler("chatstate_active",
+ self.on_chatstate_active)
+ self.xmpp.add_event_handler("chatstate_composing",
+ self.on_chatstate_composing)
+ self.xmpp.add_event_handler("chatstate_paused",
+ self.on_chatstate_paused)
+ self.xmpp.add_event_handler("chatstate_gone",
+ self.on_chatstate_gone)
+ self.xmpp.add_event_handler("chatstate_inactive",
+ self.on_chatstate_inactive)
+ self.xmpp.add_event_handler("attention", self.on_attention)
+ self.xmpp.add_event_handler("ssl_cert", self.validate_ssl)
+ self.xmpp.add_event_handler("ssl_invalid_chain", self.ssl_invalid_chain)
+ self.xmpp.add_event_handler('carbon_received', self.on_carbon_received)
+ self.xmpp.add_event_handler('carbon_sent', self.on_carbon_sent)
+
+ self.all_stanzas = Callback('custom matcher',
+ connection.MatchAll(None),
+ self.incoming_stanza)
+ self.xmpp.register_handler(self.all_stanzas)
+ if config.get('enable_user_tune'):
+ self.xmpp.add_event_handler("user_tune_publish",
+ self.on_tune_event)
+ if config.get('enable_user_nick'):
+ self.xmpp.add_event_handler("user_nick_publish",
+ self.on_nick_received)
+ if config.get('enable_user_mood'):
+ self.xmpp.add_event_handler("user_mood_publish",
+ self.on_mood_event)
+ if config.get('enable_user_activity'):
+ self.xmpp.add_event_handler("user_activity_publish",
+ self.on_activity_event)
+ if config.get('enable_user_gaming'):
+ self.xmpp.add_event_handler("user_gaming_publish",
+ self.on_gaming_event)
+
+ self.initial_joins = []
+
+ self.connected_events = {}
+
+ self.pending_invites = {}
+
+ # a dict of the form {'config_option': [list, of, callbacks]}
+ # Whenever a configuration option is changed (using /set or by
+ # reloading a new config using a signal), all the associated
+ # callbacks are triggered.
+ # Use Core.add_configuration_handler("option", callback) to add a
+ # handler
+ # Note that the callback will be called when it’s changed in the
+ # global section, OR in a special section.
+ # As a special case, handlers can be associated with the empty
+ # string option (""), they will be called for every option change
+ # The callback takes two argument: the config option, and the new
+ # value
+ self.configuration_change_handlers = {"": []}
+ self.add_configuration_handler("create_gaps",
+ self.on_gaps_config_change)
+ self.add_configuration_handler("request_message_receipts",
+ self.on_request_receipts_config_change)
+ self.add_configuration_handler("ack_message_receipts",
+ self.on_ack_receipts_config_change)
+ self.add_configuration_handler("plugins_dir",
+ self.on_plugins_dir_config_change)
+ self.add_configuration_handler("plugins_conf_dir",
+ self.on_plugins_conf_dir_config_change)
+ self.add_configuration_handler("connection_timeout_delay",
+ self.xmpp.set_keepalive_values)
+ self.add_configuration_handler("connection_check_interval",
+ self.xmpp.set_keepalive_values)
+ self.add_configuration_handler("themes_dir",
+ theming.update_themes_dir)
+ self.add_configuration_handler("theme",
+ self.on_theme_config_change)
+ self.add_configuration_handler("use_bookmarks_method",
+ self.on_bookmarks_method_config_change)
+ self.add_configuration_handler("password",
+ self.on_password_change)
+ self.add_configuration_handler("enable_vertical_tab_list",
+ self.on_vertical_tab_list_config_change)
+ self.add_configuration_handler("vertical_tab_list_size",
+ self.on_vertical_tab_list_config_change)
+ self.add_configuration_handler("deterministic_nick_colors",
+ self.on_nick_determinism_changed)
+ self.add_configuration_handler("enable_carbons",
+ self.on_carbons_switch)
+ self.add_configuration_handler("hide_user_list",
+ self.on_hide_user_list_change)
+
+ self.add_configuration_handler("", self.on_any_config_change)
+
+ def on_any_config_change(self, option, value):
+ """
+ Update the roster, in case a roster option changed.
+ """
+ roster.modified()
+
+ def add_configuration_handler(self, option, callback):
+ """
+ Add a callback, associated with the given option. It will be called
+ each time the configuration option is changed using /set or by
+ reloading the configuration with a signal
+ """
+ if option not in self.configuration_change_handlers:
+ self.configuration_change_handlers[option] = []
+ self.configuration_change_handlers[option].append(callback)
+
+ def trigger_configuration_change(self, option, value):
+ """
+ Triggers all the handlers associated with the given configuration
+ option
+ """
+ # First call the callbacks associated with any configuration change
+ for callback in self.configuration_change_handlers[""]:
+ callback(option, value)
+ # and then the callbacks associated with this specific option, if
+ # any
+ if option not in self.configuration_change_handlers:
+ return
+ for callback in self.configuration_change_handlers[option]:
+ callback(option, value)
+
+ def on_hide_user_list_change(self, option, value):
+ """
+ Called when the hide_user_list option changes
+ """
+ self.call_for_resize()
+
+ def on_bookmarks_method_config_change(self, option, value):
+ """
+ Called when the use_bookmarks_method option changes
+ """
+ if value not in ('pep', 'privatexml'):
+ return
+ self.bookmarks.preferred = value
+ self.bookmarks.save(self.xmpp, core=self)
+
+ def on_gaps_config_change(self, option, value):
+ """
+ Called when the option create_gaps is changed.
+ Remove all gaptabs if switching from gaps to nogaps.
+ """
+ if value.lower() == "false":
+ self.tabs = list(tab for tab in self.tabs if tab)
+
+ def on_request_receipts_config_change(self, option, value):
+ """
+ Called when the request_message_receipts option changes
+ """
+ self.xmpp.plugin['xep_0184'].auto_request = config.get(option,
+ default=True)
+
+ def on_ack_receipts_config_change(self, option, value):
+ """
+ Called when the ack_message_receipts option changes
+ """
+ self.xmpp.plugin['xep_0184'].auto_ack = config.get(option, default=True)
+
+ def on_plugins_dir_config_change(self, option, value):
+ """
+ Called when the plugins_dir option is changed
+ """
+ path = os.path.expanduser(value)
+ self.plugin_manager.on_plugins_dir_change(path)
+
+ def on_vertical_tab_list_config_change(self, option, value):
+ """
+ Called when the enable_vertical_tab_list option is changed
+ """
+ self.call_for_resize()
+
+ def on_plugins_conf_dir_config_change(self, option, value):
+ """
+ Called when the plugins_conf_dir option is changed
+ """
+ path = os.path.expanduser(value)
+ self.plugin_manager.on_plugins_conf_dir_change(path)
+
+ def on_theme_config_change(self, option, value):
+ """
+ Called when the theme option is changed
+ """
+ error_msg = theming.reload_theme()
+ if error_msg:
+ self.information(error_msg, 'Warning')
+ self.refresh_window()
+
+ def on_password_change(self, option, value):
+ """
+ Set the new password in the slixmpp.ClientXMPP object
+ """
+ self.xmpp.password = value
+
+
+ def on_nick_determinism_changed(self, option, value):
+ """If we change the value to true, we call /recolor on all the MucTabs, to
+ make the current nick colors reflect their deterministic value.
+ """
+ if value.lower() == "true":
+ for tab in self.get_tabs(tabs.MucTab):
+ tab.command_recolor('')
+
+ def on_carbons_switch(self, option, value):
+ """Whenever the user enables or disables carbons using /set, we should
+ inform the server immediately, this way we do not require a restart
+ for the change to take effect
+ """
+ if value:
+ self.xmpp.plugin['xep_0280'].enable()
+ else:
+ self.xmpp.plugin['xep_0280'].disable()
+
+ def reload_config(self):
+ # reload all log files
+ log.debug("Reloading the log files…")
+ logger.reload_all()
+ log.debug("Log files reloaded.")
+ # reload the theme
+ log.debug("Reloading the theme…")
+ theming.reload_theme()
+ log.debug("Theme reloaded.")
+ # reload the config from the disk
+ log.debug("Reloading the config…")
+ # Copy the old config in a dict
+ old_config = config.to_dict()
+ config.read_file()
+ # Compare old and current config, to trigger the callbacks of all
+ # modified options
+ for section in config.sections():
+ old_section = old_config.get(section, {})
+ for option in config.options(section):
+ old_value = old_section.get(option)
+ new_value = config.get(option, default="", section=section)
+ if new_value != old_value:
+ self.trigger_configuration_change(option, new_value)
+ log.debug("Config reloaded.")
+ # in case some roster options have changed
+ roster.modified()
+
+ def sigusr_handler(self, num, stack):
+ """
+ Handle SIGUSR1 (10)
+ When caught, reload all the possible files.
+ """
+ log.debug("SIGUSR1 caught, reloading the files…")
+ self.reload_config()
+
+ def exit_from_signal(self, *args, **kwargs):
+ """
+ Quit when receiving SIGHUP or SIGTERM or SIGPIPE
+
+ do not save the config because it is not a normal exit
+ (and only roster UI things are not yet saved)
+ """
+ sig = args[0]
+ signals = {
+ 1: 'SIGHUP',
+ 13: 'SIGPIPE',
+ 15: 'SIGTERM',
+ }
+
+ log.error("%s received. Exiting…", signals[sig])
+ if config.get('enable_user_mood'):
+ self.xmpp.plugin['xep_0107'].stop()
+ if config.get('enable_user_activity'):
+ self.xmpp.plugin['xep_0108'].stop()
+ if config.get('enable_user_gaming'):
+ self.xmpp.plugin['xep_0196'].stop()
+ self.plugin_manager.disable_plugins()
+ self.disconnect('%s received' % signals.get(sig))
+ self.xmpp.add_event_handler("disconnected", self.exit, disposable=True)
+
+ def autoload_plugins(self):
+ """
+ Load the plugins on startup.
+ """
+ plugins = config.get('plugins_autoload')
+ if ':' in plugins:
+ for plugin in plugins.split(':'):
+ self.plugin_manager.load(plugin)
+ else:
+ for plugin in plugins.split():
+ self.plugin_manager.load(plugin)
+ self.plugins_autoloaded = True
+
+ def start(self):
+ """
+ Init curses, create the first tab, etc
+ """
+ self.stdscr = curses.initscr()
+ self.init_curses(self.stdscr)
+ self.call_for_resize()
+ default_tab = tabs.RosterInfoTab()
+ default_tab.on_gain_focus()
+ self.tabs.append(default_tab)
+ self.information('Welcome to poezio!', 'Info')
+ if firstrun:
+ self.information(
+ 'It seems that it is the first time you start poezio.\n'
+ 'The online help is here http://doc.poez.io/\n'
+ 'No room is joined by default, but you can join poezio’s'
+ ' chatroom (with /join poezio@muc.poez.io), where you can'
+ ' ask for help or tell us how great it is.',
+ 'Help')
+ self.refresh_window()
+ self.xmpp.plugin['xep_0012'].begin_idle(jid=self.xmpp.boundjid)
+
+ def exit(self, event=None):
+ log.debug("exit(%s)" % (event,))
+ asyncio.get_event_loop().stop()
+
+ def on_exception(self, typ, value, trace):
+ """
+ When an exception is raised, just reset curses and call
+ the original exception handler (will nicely print the traceback)
+ """
+ try:
+ self.reset_curses()
+ except:
+ pass
+ sys.__excepthook__(typ, value, trace)
+
+ def sigwinch_handler(self):
+ """A work-around for ncurses resize stuff, which sucks. Normally, ncurses
+ catches SIGWINCH itself. In its signal handler, it updates the
+ windows structures (for example the size, etc) and it
+ ungetch(KEY_RESIZE). That way, the next time we call getch() we know
+ that a resize occured and we can act on it. BUT poezio doesn’t call
+ getch() until it knows it will return something. The problem is we
+ can’t know that, because stdin is not affected by this KEY_RESIZE
+ value (it is only inserted in a ncurses internal fifo that we can’t
+ access).
+
+ The (ugly) solution is to handle SIGWINCH ourself, trigger the
+ change of the internal windows sizes stored in ncurses module, using
+ sizes that we get using shutil, ungetch the KEY_RESIZE value and
+ then call getch to handle the resize on poezio’s side properly.
+ """
+ size = shutil.get_terminal_size()
+ curses.resizeterm(size.lines, size.columns)
+ curses.ungetch(curses.KEY_RESIZE)
+ self.on_input_readable()
+
+ def on_input_readable(self):
+ """
+ main loop waiting for the user to press a key
+ """
+ def replace_line_breaks(key):
+ "replace ^J with \n"
+ if key == '^J':
+ return '\n'
+ return key
+ def separate_chars_from_bindings(char_list):
+ """
+ returns a list of lists. For example if you give
+ ['a', 'b', 'KEY_BACKSPACE', 'n', 'u'], this function returns
+ [['a', 'b'], ['KEY_BACKSPACE'], ['n', 'u']]
+
+ This way, in case of lag (for example), we handle the typed text
+ by “batch” as much as possible (instead of one char at a time,
+ which implies a refresh after each char, which is very slow),
+ but we still handle the special chars (backspaces, arrows,
+ ctrl+x ou alt+x, etc) one by one, which avoids the issue of
+ printing them OR ignoring them in that case. This should
+ resolve the “my ^W are ignored when I lag ;(”.
+ """
+ res = []
+ current = []
+ for char in char_list:
+ assert char
+ # Transform that stupid char into what we actually meant
+ if char == '\x1f':
+ char = '^/'
+ if len(char) == 1:
+ current.append(char)
+ else:
+ # special case for the ^I key, it’s considered as \t
+ # only when pasting some text, otherwise that’s the ^I
+ # (or M-i) key, which stands for completion by default.
+ if char == '^I' and len(char_list) != 1:
+ current.append('\t')
+ continue
+ if current:
+ res.append(current)
+ current = []
+ res.append([char])
+ if current:
+ res.append(current)
+ return res
+
+ log.debug("Input is readable.")
+ big_char_list = [replace_key_with_bound(key)\
+ for key in self.read_keyboard()]
+ log.debug("Got from keyboard: %s", (big_char_list,))
+
+ # whether to refresh after ALL keys have been handled
+ for char_list in separate_chars_from_bindings(big_char_list):
+ # Special case for M-x where x is a number
+ if len(char_list) == 1:
+ char = char_list[0]
+ if char.startswith('M-') and len(char) == 3:
+ try:
+ nb = int(char[2])
+ except ValueError:
+ pass
+ else:
+ if self.current_tab().nb == nb and config.get('go_to_previous_tab_on_alt_number'):
+ self.go_to_previous_tab()
+ else:
+ self.command_win('%d' % nb)
+ # search for keyboard shortcut
+ func = self.key_func.get(char, None)
+ if func:
+ func()
+ else:
+ self.do_command(replace_line_breaks(char), False)
+ else:
+ self.do_command(''.join(char_list), True)
+ if self.status.show not in ('xa', 'away'):
+ self.xmpp.plugin['xep_0319'].idle()
+ self.doupdate()
+
+ def save_config(self):
+ """
+ Save config in the file just before exit
+ """
+ ok = roster.save_to_config_file()
+ ok = ok and config.silent_set('info_win_height',
+ self.information_win_size,
+ 'var')
+ if not ok:
+ self.information('Unable to save runtime preferences'
+ ' in the config file',
+ 'Error')
+
+ def on_roster_enter_key(self, roster_row):
+ """
+ when enter is pressed on the roster window
+ """
+ if isinstance(roster_row, Contact):
+ if not self.get_conversation_by_jid(roster_row.bare_jid, False):
+ self.open_conversation_window(roster_row.bare_jid)
+ else:
+ self.focus_tab_named(roster_row.bare_jid)
+ if isinstance(roster_row, Resource):
+ if not self.get_conversation_by_jid(roster_row.jid,
+ False,
+ fallback_barejid=False):
+ self.open_conversation_window(roster_row.jid)
+ else:
+ self.focus_tab_named(roster_row.jid)
+ self.refresh_window()
+
+ def get_conversation_messages(self):
+ """
+ Returns a list of all the messages in the current chat.
+ If the current tab is not a ChatTab, returns None.
+
+ Messages are namedtuples of the form
+ ('txt nick_color time str_time nickname user')
+ """
+ if not isinstance(self.current_tab(), tabs.ChatTab):
+ return None
+ return self.current_tab().get_conversation_messages()
+
+ def insert_input_text(self, text):
+ """
+ Insert the given text into the current input
+ """
+ self.do_command(text, True)
+
+
+##################### Anything related to command execution ###################
+
+ def execute(self, line):
+ """
+ Execute the /command or just send the line on the current room
+ """
+ if line == "":
+ return
+ if line.startswith('/'):
+ command = line.strip()[:].split()[0][1:]
+ arg = line[2+len(command):] # jump the '/' and the ' '
+ # example. on "/link 0 open", command = "link" and arg = "0 open"
+ if command in self.commands:
+ func = self.commands[command][0]
+ func(arg)
+ return
+ else:
+ self.information("Unknown command (%s)" % (command),
+ 'Error')
+
+ def exec_command(self, command):
+ """
+ Execute an external command on the local or a remote machine,
+ depending on the conf. For example, to open a link in a browser, do
+ exec_command(["firefox", "http://poezio.eu"]), and this will call
+ the command on the correct computer.
+
+ The command argument is a list of strings, not quoted or escaped in
+ any way. The escaping is done here if needed.
+
+ The remote execution is done
+ by writing the command on a fifo. That fifo has to be on the
+ machine where poezio is running, and accessible (through sshfs for
+ example) from the local machine (where poezio is not running). A
+ very simple daemon (daemon.py) reads on that fifo, and executes any
+ command that is read in it. Since we can only write strings to that
+ fifo, each argument has to be pipes.quote()d. That way the
+ shlex.split on the reading-side of the daemon will be safe.
+
+ You cannot use a real command line with pipes, redirections etc, but
+ this function supports a simple case redirection to file: if the
+ before-last argument of the command is ">" or ">>", then the last
+ argument is considered to be a filename where the command stdout
+ will be written. For example you can do exec_command(["echo",
+ "coucou les amis coucou coucou", ">", "output.txt"]) and this will
+ work. If you try to do anything else, your |, [, <<, etc will be
+ interpreted as normal command arguments, not shell special tokens.
+ """
+ if config.get('exec_remote'):
+ # We just write the command in the fifo
+ fifo_path = config.get('remote_fifo_path')
+ if not self.remote_fifo:
+ try:
+ self.remote_fifo = Fifo(os.path.join(fifo_path,
+ 'poezio.fifo'),
+ 'w')
+ except (OSError, IOError) as exc:
+ log.error('Could not open the fifo for writing (%s)',
+ os.path.join(fifo_path, './', 'poezio.fifo'),
+ exc_info=True)
+ self.information('Could not open the fifo '
+ 'file for writing: %s' % exc,
+ 'Error')
+ return
+
+ args = (pipes.quote(arg.replace('\n', ' ')) for arg in command)
+ command_str = ' '.join(args) + '\n'
+ try:
+ self.remote_fifo.write(command_str)
+ except (IOError) as exc:
+ log.error('Could not write in the fifo (%s): %s',
+ os.path.join(fifo_path, './', 'poezio.fifo'),
+ repr(command),
+ exc_info=True)
+ self.information('Could not execute %s: %s' % (command, exc),
+ 'Error')
+ self.remote_fifo = None
+ else:
+ executor = Executor(command)
+ try:
+ executor.start()
+ except ValueError as exc:
+ log.error('Could not execute command (%s)',
+ repr(command),
+ exc_info=True)
+ self.information('%s' % exc, 'Error')
+
+
+ def do_command(self, key, raw):
+ """
+ Execute the action associated with a key
+
+ Or if keyboard.continuation_keys_callback is set, call it instead. See
+ the comment of this variable.
+ """
+ if not key:
+ return
+ if keyboard.continuation_keys_callback is not None:
+ # Reset the callback to None BEFORE calling it, because this
+ # callback MAY set a new callback itself, and we don’t want to
+ # erase it in that case
+ cb = keyboard.continuation_keys_callback
+ keyboard.continuation_keys_callback = None
+ cb(key)
+ else:
+ self.current_tab().on_input(key, raw)
+
+
+ def try_execute(self, line):
+ """
+ Try to execute a command in the current tab
+ """
+ line = '/' + line
+ try:
+ self.current_tab().execute_command(line)
+ except:
+ log.error('Execute failed (%s)', line, exc_info=True)
+
+
+########################## TImed Events #######################################
+
+ def remove_timed_event(self, event):
+ """Remove an existing timed event"""
+ event.handler.cancel()
+
+ def add_timed_event(self, event):
+ """Add a new timed event"""
+ event.handler = asyncio.get_event_loop().call_later(event.delay,
+ event.callback,
+ *event.args)
+
+####################### XMPP-related actions ##################################
+
+ def get_status(self):
+ """
+ Get the last status that was previously set
+ """
+ return self.status
+
+ def set_status(self, pres, msg):
+ """
+ Set our current status so we can remember
+ it and use it back when needed (for example to display it
+ or to use it when joining a new muc)
+ """
+ self.status = Status(show=pres, message=msg)
+ if config.get('save_status'):
+ ok = config.silent_set('status', pres if pres else '')
+ msg = msg.replace('\n', '|') if msg else ''
+ ok = ok and config.silent_set('status_message', msg)
+ if not ok:
+ self.information('Unable to save the status in '
+ 'the config file', 'Error')
+
+ def get_bookmark_nickname(self, room_name):
+ """
+ Returns the nickname associated with a bookmark
+ or the default nickname
+ """
+ bm = self.bookmarks[room_name]
+ if bm:
+ return bm.nick
+ return self.own_nick
+
+ def disconnect(self, msg='', reconnect=False):
+ """
+ Disconnect from remote server and correctly set the states of all
+ parts of the client (for example, set the MucTabs as not joined, etc)
+ """
+ self.legitimate_disconnect = True
+ msg = msg or ''
+ for tab in self.get_tabs(tabs.MucTab):
+ tab.command_part(msg)
+ self.xmpp.disconnect()
+ if reconnect:
+ # Add a one-time event to reconnect as soon as we are
+ # effectively disconnected
+ self.xmpp.add_event_handler('disconnected', lambda event: self.xmpp.connect(), disposable=True)
+
+ def send_message(self, msg):
+ """
+ Function to use in plugins to send a message in the current
+ conversation.
+ Returns False if the current tab is not a conversation tab
+ """
+ if not isinstance(self.current_tab(), tabs.ChatTab):
+ return False
+ self.current_tab().command_say(msg)
+ return True
+
+ def invite(self, jid, room, reason=None):
+ """
+ Checks if the sender supports XEP-0249, then send an invitation,
+ or a mediated one if it does not.
+ TODO: allow passwords
+ """
+ def callback(iq):
+ if not iq:
+ return
+ if 'jabber:x:conference' in iq['disco_info'].get_features():
+ self.xmpp.plugin['xep_0249'].send_invitation(
+ jid,
+ room,
+ reason=reason)
+ else: # fallback
+ self.xmpp.plugin['xep_0045'].invite(room, jid,
+ reason=reason or '')
+
+ self.xmpp.plugin['xep_0030'].get_info(jid=jid, timeout=5,
+ callback=callback)
+
+ def get_error_message(self, stanza, deprecated=False):
+ """
+ Takes a stanza of the form <message type='error'><error/></message>
+ and return a well formed string containing the error informations
+ """
+ sender = stanza.attrib['from']
+ msg = stanza['error']['type']
+ condition = stanza['error']['condition']
+ code = stanza['error']['code']
+ body = stanza['error']['text']
+ if not body:
+ if deprecated:
+ if code in DEPRECATED_ERRORS:
+ body = DEPRECATED_ERRORS[code]
+ else:
+ body = condition or 'Unknown error'
+ else:
+ if code in ERROR_AND_STATUS_CODES:
+ body = ERROR_AND_STATUS_CODES[code]
+ else:
+ body = condition or 'Unknown error'
+ if code:
+ message = '%(from)s: %(code)s - %(msg)s: %(body)s' % {
+ 'from': sender, 'msg': msg, 'body': body, 'code': code}
+ else:
+ message = '%(from)s: %(msg)s: %(body)s' % {
+ 'from': sender, 'msg': msg, 'body': body}
+ return message
+
+
+####################### Tab logic-related things ##############################
+
+ ### Tab getters ###
+
+ def get_tabs(self, cls=tabs.Tab):
+ "Get all the tabs of a type"
+ return filter(lambda tab: isinstance(tab, cls), self.tabs)
+
+ def current_tab(self):
+ """
+ returns the current room, the one we are viewing
+ """
+ self.current_tab_nb = self.current_tab_nb
+ return self.tabs[self.current_tab_nb]
+
+ def get_conversation_by_jid(self, jid, create=True, fallback_barejid=True):
+ """
+ From a JID, get the tab containing the conversation with it.
+ If none already exist, and create is "True", we create it
+ and return it. Otherwise, we return None.
+
+ If fallback_barejid is True, then this method will seek other
+ tabs with the same barejid, instead of searching only by fulljid.
+ """
+ jid = safeJID(jid)
+ # We first check if we have a static conversation opened
+ # with this precise resource
+ conversation = self.get_tab_by_name(jid.full,
+ tabs.StaticConversationTab)
+ if jid.bare == jid.full and not conversation:
+ conversation = self.get_tab_by_name(jid.full,
+ tabs.DynamicConversationTab)
+
+ if not conversation and fallback_barejid:
+ # If not, we search for a conversation with the bare jid
+ conversation = self.get_tab_by_name(jid.bare,
+ tabs.DynamicConversationTab)
+ if not conversation:
+ if create:
+ # We create a dynamic conversation with the bare Jid if
+ # nothing was found (and we lock it to the resource
+ # later)
+ conversation = self.open_conversation_window(jid.bare,
+ False)
+ else:
+ conversation = None
+ return conversation
+
+ def get_tab_by_name(self, name, typ=None):
+ """
+ Get the tab with the given name.
+ If typ is provided, return a tab of this type only
+ """
+ for tab in self.tabs:
+ if tab.name == name:
+ if (typ and isinstance(tab, typ)) or\
+ not typ:
+ return tab
+ return None
+
+ def get_tab_by_number(self, number):
+ if 0 <= number < len(self.tabs):
+ return self.tabs[number]
+ return None
+
+ def add_tab(self, new_tab, focus=False):
+ """
+ Appends the new_tab in the tab list and
+ focus it if focus==True
+ """
+ self.tabs.append(new_tab)
+ if focus:
+ self.command_win("%s" % new_tab.nb)
+
+ def insert_tab_nogaps(self, old_pos, new_pos):
+ """
+ Move tabs without creating gaps
+ old_pos: old position of the tab
+ new_pos: desired position of the tab
+ """
+ tab = self.tabs[old_pos]
+ if new_pos < old_pos:
+ self.tabs.pop(old_pos)
+ self.tabs.insert(new_pos, tab)
+ elif new_pos > old_pos:
+ self.tabs.insert(new_pos, tab)
+ self.tabs.remove(tab)
+ else:
+ return False
+ return True
+
+ def insert_tab_gaps(self, old_pos, new_pos):
+ """
+ Move tabs and create gaps in the eventual remaining space
+ old_pos: old position of the tab
+ new_pos: desired position of the tab
+ """
+ tab = self.tabs[old_pos]
+ target = None if new_pos >= len(self.tabs) else self.tabs[new_pos]
+ if not target:
+ if new_pos < len(self.tabs):
+ old_tab = self.tabs[old_pos]
+ self.tabs[new_pos], self.tabs[old_pos] = old_tab, tabs.GapTab()
+ else:
+ self.tabs.append(self.tabs[old_pos])
+ self.tabs[old_pos] = tabs.GapTab()
+ else:
+ if new_pos > old_pos:
+ self.tabs.insert(new_pos, tab)
+ self.tabs[old_pos] = tabs.GapTab()
+ elif new_pos < old_pos:
+ self.tabs[old_pos] = tabs.GapTab()
+ self.tabs.insert(new_pos, tab)
+ else:
+ return False
+ i = self.tabs.index(tab)
+ done = False
+ # Remove the first Gap on the right in the list
+ # in order to prevent global shifts when there is empty space
+ while not done:
+ i += 1
+ if i >= len(self.tabs):
+ done = True
+ elif not self.tabs[i]:
+ self.tabs.pop(i)
+ done = True
+ # Remove the trailing gaps
+ i = len(self.tabs) - 1
+ while isinstance(self.tabs[i], tabs.GapTab):
+ self.tabs.pop()
+ i -= 1
+ return True
+
+ def insert_tab(self, old_pos, new_pos=99999):
+ """
+ Insert a tab at a position, changing the number of the following tabs
+ returns False if it could not move the tab, True otherwise
+ """
+ if old_pos <= 0 or old_pos >= len(self.tabs):
+ return False
+ elif new_pos <= 0:
+ return False
+ elif new_pos == old_pos:
+ return False
+ elif not self.tabs[old_pos]:
+ return False
+ if config.get('create_gaps'):
+ return self.insert_tab_gaps(old_pos, new_pos)
+ return self.insert_tab_nogaps(old_pos, new_pos)
+
+ ### Move actions (e.g. go to next room) ###
+
+ def rotate_rooms_right(self, args=None):
+ """
+ rotate the rooms list to the right
+ """
+ self.current_tab().on_lose_focus()
+ self.current_tab_nb += 1
+ while not self.tabs[self.current_tab_nb]:
+ self.current_tab_nb += 1
+ self.current_tab().on_gain_focus()
+ self.refresh_window()
+
+ def rotate_rooms_left(self, args=None):
+ """
+ rotate the rooms list to the right
+ """
+ self.current_tab().on_lose_focus()
+ self.current_tab_nb -= 1
+ while not self.tabs[self.current_tab_nb]:
+ self.current_tab_nb -= 1
+ self.current_tab().on_gain_focus()
+ self.refresh_window()
+
+ def go_to_room_number(self):
+ """
+ Read 2 more chars and go to the tab
+ with the given number
+ """
+ def read_next_digit(digit):
+ try:
+ nb = int(digit)
+ except ValueError:
+ # If it is not a number, we do nothing. If it was the first
+ # one, we do not wait for a second one by re-setting the
+ # callback
+ self.room_number_jump.clear()
+ else:
+ self.room_number_jump.append(digit)
+ if len(self.room_number_jump) == 2:
+ arg = "".join(self.room_number_jump)
+ self.room_number_jump.clear()
+ self.command_win(arg)
+ else:
+ # We need to read more digits
+ keyboard.continuation_keys_callback = read_next_digit
+ keyboard.continuation_keys_callback = read_next_digit
+
+ def go_to_roster(self):
+ "Select the roster as the current tab"
+ self.command_win('0')
+
+ def go_to_previous_tab(self):
+ "Go to the previous tab"
+ self.command_win('%s' % (self.previous_tab_nb,))
+
+ def go_to_important_room(self):
+ """
+ Go to the next room with activity, in the order defined in the
+ dict tabs.STATE_PRIORITY
+ """
+ # shortcut
+ priority = tabs.STATE_PRIORITY
+ tab_refs = {}
+ # put all the active tabs in a dict of lists by state
+ for tab in self.tabs:
+ if not tab:
+ continue
+ if tab.state not in tab_refs:
+ tab_refs[tab.state] = [tab]
+ else:
+ tab_refs[tab.state].append(tab)
+ # sort the state by priority and remove those with negative priority
+ states = sorted(tab_refs.keys(),
+ key=(lambda x: priority.get(x, 0)),
+ reverse=True)
+ states = [state for state in states if priority.get(state, -1) >= 0]
+
+ for state in states:
+ for tab in tab_refs[state]:
+ if (tab.nb < self.current_tab_nb and
+ tab_refs[state][-1].nb > self.current_tab_nb):
+ continue
+ self.command_win('%s' % tab.nb)
+ return
+ return
+
+ def focus_tab_named(self, tab_name, type_=None):
+ """Returns True if it found a tab to focus on"""
+ for tab in self.tabs:
+ if tab.name == tab_name:
+ if (type_ and (isinstance(tab, type_))) or not type_:
+ self.command_win('%s' % (tab.nb,))
+ return True
+ return False
+
+ @property
+ def current_tab_nb(self):
+ """Wrapper for the current tab number"""
+ return self._current_tab_nb
+
+ @current_tab_nb.setter
+ def current_tab_nb(self, value):
+ """
+ Prevents the tab number from going over the total number of opened
+ tabs, or under 0
+ """
+ old = self._current_tab_nb
+ if value >= len(self.tabs):
+ self._current_tab_nb = 0
+ elif value < 0:
+ self._current_tab_nb = len(self.tabs) - 1
+ else:
+ self._current_tab_nb = value
+ if old != self._current_tab_nb and self.tabs[self._current_tab_nb]:
+ self.events.trigger('tab_change', old, self._current_tab_nb)
+
+ ### Opening actions ###
+
+ def open_conversation_window(self, jid, focus=True):
+ """
+ Open a new conversation tab and focus it if needed. If a resource is
+ provided, we open a StaticConversationTab, else a
+ DynamicConversationTab
+ """
+ if safeJID(jid).resource:
+ new_tab = tabs.StaticConversationTab(jid)
+ else:
+ new_tab = tabs.DynamicConversationTab(jid)
+ if not focus:
+ new_tab.state = "private"
+ self.add_tab(new_tab, focus)
+ self.refresh_window()
+ return new_tab
+
+ def open_private_window(self, room_name, user_nick, focus=True):
+ """
+ Open a Private conversation in a MUC and focus if needed.
+ """
+ complete_jid = room_name+'/'+user_nick
+ # if the room exists, focus it and return
+ for tab in self.get_tabs(tabs.PrivateTab):
+ if tab.name == complete_jid:
+ self.command_win('%s' % tab.nb)
+ return tab
+ # create the new tab
+ tab = self.get_tab_by_name(room_name, tabs.MucTab)
+ if not tab:
+ return None
+ new_tab = tabs.PrivateTab(complete_jid, tab.own_nick)
+ if hasattr(tab, 'directed_presence'):
+ new_tab.directed_presence = tab.directed_presence
+ if not focus:
+ new_tab.state = "private"
+ # insert it in the tabs
+ self.add_tab(new_tab, focus)
+ self.refresh_window()
+ tab.privates.append(new_tab)
+ return new_tab
+
+ def open_new_room(self, room, nick, *, password=None, focus=True):
+ """
+ Open a new tab.MucTab containing a muc Room, using the specified nick
+ """
+ new_tab = tabs.MucTab(room, nick, password=password)
+ self.add_tab(new_tab, focus)
+ self.refresh_window()
+ return new_tab
+
+ def open_new_form(self, form, on_cancel, on_send, **kwargs):
+ """
+ Open a new tab containing the form
+ The callback are called with the completed form as parameter in
+ addition with kwargs
+ """
+ form_tab = tabs.DataFormsTab(form, on_cancel, on_send, kwargs)
+ self.add_tab(form_tab, True)
+
+ ### Modifying actions ###
+
+ def rename_private_tabs(self, room_name, old_nick, new_nick):
+ """
+ Call this method when someone changes his/her nick in a MUC,
+ this updates the name of all the opened private conversations
+ with him/her
+ """
+ tab = self.get_tab_by_name('%s/%s' % (room_name, old_nick),
+ tabs.PrivateTab)
+ if tab:
+ tab.rename_user(old_nick, new_nick)
+
+ def on_user_left_private_conversation(self, room_name, nick, status_message):
+ """
+ The user left the MUC: add a message in the associated
+ private conversation
+ """
+ tab = self.get_tab_by_name('%s/%s' % (room_name, nick), tabs.PrivateTab)
+ if tab:
+ tab.user_left(status_message, nick)
+
+ def on_user_rejoined_private_conversation(self, room_name, nick):
+ """
+ The user joined a MUC: add a message in the associated
+ private conversation
+ """
+ tab = self.get_tab_by_name('%s/%s' % (room_name, nick), tabs.PrivateTab)
+ if tab:
+ tab.user_rejoined(nick)
+
+ def disable_private_tabs(self, room_name, reason=None):
+ """
+ Disable private tabs when leaving a room
+ """
+ if reason is None:
+ reason = '\x195}You left the chatroom\x193}'
+ for tab in self.get_tabs(tabs.PrivateTab):
+ if tab.name.startswith(room_name):
+ tab.deactivate(reason=reason)
+
+ def enable_private_tabs(self, room_name, reason=None):
+ """
+ Enable private tabs when joining a room
+ """
+ if reason is None:
+ reason = '\x195}You joined the chatroom\x193}'
+ for tab in self.get_tabs(tabs.PrivateTab):
+ if tab.name.startswith(room_name):
+ tab.activate(reason=reason)
+
+ def on_user_changed_status_in_private(self, jid, msg):
+ tab = self.get_tab_by_name(jid, tabs.ChatTab)
+ if tab: # display the message in private
+ tab.add_message(msg, typ=2)
+
+ def close_tab(self, tab=None):
+ """
+ Close the given tab. If None, close the current one
+ """
+ was_current = tab is None
+ tab = tab or self.current_tab()
+ if isinstance(tab, tabs.RosterInfoTab):
+ return # The tab 0 should NEVER be closed
+ del tab.key_func # Remove self references
+ del tab.commands # and make the object collectable
+ tab.on_close()
+ nb = tab.nb
+ if was_current:
+ if self.previous_tab_nb != nb:
+ self.current_tab_nb = self.previous_tab_nb
+ self.previous_tab_nb = 0
+ if config.get('create_gaps'):
+ if nb >= len(self.tabs) - 1:
+ self.tabs.remove(tab)
+ nb -= 1
+ while not self.tabs[nb]: # remove the trailing gaps
+ self.tabs.pop()
+ nb -= 1
+ else:
+ self.tabs[nb] = tabs.GapTab()
+ else:
+ self.tabs.remove(tab)
+ if tab and tab.name in logger.fds:
+ logger.fds[tab.name].close()
+ log.debug("Log file for %s closed.", tab.name)
+ del logger.fds[tab.name]
+ if self.current_tab_nb >= len(self.tabs):
+ self.current_tab_nb = len(self.tabs) - 1
+ while not self.tabs[self.current_tab_nb]:
+ self.current_tab_nb -= 1
+ if was_current:
+ self.current_tab().on_gain_focus()
+ self.refresh_window()
+ import gc
+ gc.collect()
+ log.debug('___ Referrers of closing tab:\n%s\n______',
+ gc.get_referrers(tab))
+ del tab
+
+ def add_information_message_to_conversation_tab(self, jid, msg):
+ """
+ Search for a ConversationTab with the given jid (full or bare),
+ if yes, add the given message to it
+ """
+ tab = self.get_tab_by_name(jid, tabs.ConversationTab)
+ if tab:
+ tab.add_message(msg, typ=2)
+ if self.current_tab() is tab:
+ self.refresh_window()
+
+
+####################### Curses and ui-related stuff ###########################
+
+ def doupdate(self):
+ "Do a curses update"
+ if not self.running:
+ return
+ curses.doupdate()
+
+ def information(self, msg, typ=''):
+ """
+ Displays an informational message in the "Info" buffer
+ """
+ filter_messages = config.get('filter_info_messages').split(':')
+ for words in filter_messages:
+ if words and words in msg:
+ log.debug('Did not show the message:\n\t%s> %s', typ, msg)
+ return False
+ 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)
+ popup_on = config.get('information_buffer_popup_on').split()
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+ elif typ != '' and typ.lower() in popup_on:
+ popup_time = config.get('popup_time') + (nb_lines - 1) * 2
+ self.pop_information_win_up(nb_lines, popup_time)
+ else:
+ if self.information_win_size != 0:
+ self.information_win.refresh()
+ self.current_tab().input.refresh()
+ return True
+
+ def init_curses(self, stdscr):
+ """
+ ncurses initialization
+ """
+ curses.curs_set(1)
+ curses.noecho()
+ curses.nonl()
+ curses.raw()
+ stdscr.idlok(1)
+ stdscr.keypad(1)
+ curses.start_color()
+ curses.use_default_colors()
+ theming.reload_theme()
+ curses.ungetch(" ") # H4X: without this, the screen is
+ stdscr.getkey() # erased on the first "getkey()"
+
+ def reset_curses(self):
+ """
+ Reset terminal capabilities to what they were before ncurses
+ init
+ """
+ curses.echo()
+ curses.nocbreak()
+ curses.curs_set(1)
+ curses.endwin()
+
+ @property
+ def informations(self):
+ return self.information_buffer
+
+ def refresh_window(self):
+ """
+ Refresh everything
+ """
+ nocursor = curses.curs_set(0)
+ self.current_tab().state = 'current'
+ self.current_tab().refresh()
+ self.doupdate()
+ curses.curs_set(nocursor)
+
+ def refresh_tab_win(self):
+ """
+ Refresh the window containing the tab list
+ """
+ self.current_tab().refresh_tab_win()
+ self.refresh_input()
+ self.doupdate()
+
+ def refresh_input(self):
+ """
+ Refresh the input if it exists
+ """
+ if self.current_tab().input:
+ self.current_tab().input.refresh()
+ self.doupdate()
+
+ def scroll_page_down(self, args=None):
+ """
+ Scroll a page down, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_scroll_down():
+ self.refresh_window()
+ return True
+
+ def scroll_page_up(self, args=None):
+ """
+ Scroll a page up, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_scroll_up():
+ self.refresh_window()
+ return True
+
+ def scroll_line_up(self, args=None):
+ """
+ Scroll a line up, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_line_up():
+ self.refresh_window()
+ return True
+
+ def scroll_line_down(self, args=None):
+ """
+ Scroll a line down, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_line_down():
+ self.refresh_window()
+ return True
+
+ def scroll_half_up(self, args=None):
+ """
+ Scroll half a screen down, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_half_scroll_up():
+ self.refresh_window()
+ return True
+
+ def scroll_half_down(self, args=None):
+ """
+ Scroll half a screen down, if possible.
+ Returns True on success, None on failure.
+ """
+ if self.current_tab().on_half_scroll_down():
+ self.refresh_window()
+ return True
+
+ def grow_information_win(self, nb=1):
+ """
+ Expand the information win a number of lines
+ """
+ if self.information_win_size >= self.current_tab().height -5 or \
+ self.information_win_size+nb >= self.current_tab().height-4 or\
+ self.size.core_degrade_y:
+ return 0
+ self.information_win_size += nb
+ self.resize_global_information_win()
+ for tab in self.tabs:
+ tab.on_info_win_size_changed()
+ self.refresh_window()
+ return nb
+
+ def shrink_information_win(self, nb=1):
+ """
+ Reduce the size of the information win
+ """
+ if self.information_win_size == 0 or self.size.core_degrade_y:
+ return
+ self.information_win_size -= nb
+ if self.information_win_size < 0:
+ self.information_win_size = 0
+ self.resize_global_information_win()
+ for tab in self.tabs:
+ tab.on_info_win_size_changed()
+ self.refresh_window()
+
+ def scroll_info_up(self):
+ """
+ Scroll the information buffer up
+ """
+ self.information_win.scroll_up(self.information_win.height)
+ if not isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.information_win.refresh()
+ else:
+ info = self.current_tab().information_win
+ info.scroll_up(info.height)
+ self.refresh_window()
+
+ def scroll_info_down(self):
+ """
+ Scroll the information buffer down
+ """
+ self.information_win.scroll_down(self.information_win.height)
+ if not isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.information_win.refresh()
+ else:
+ info = self.current_tab().information_win
+ info.scroll_down(info.height)
+ self.refresh_window()
+
+ def pop_information_win_up(self, size, time):
+ """
+ Temporarly increase the size of the information win of size lines
+ during time seconds.
+ After that delay, the size will decrease from size lines.
+ """
+ if time <= 0 or size <= 0:
+ return
+ result = self.grow_information_win(size)
+ timed_event = timed_events.DelayedEvent(time,
+ self.shrink_information_win,
+ result)
+ self.add_timed_event(timed_event)
+ self.refresh_window()
+
+ def toggle_left_pane(self):
+ """
+ Enable/disable the left panel.
+ """
+ enabled = config.get('enable_vertical_tab_list')
+ if not config.silent_set('enable_vertical_tab_list', str(not enabled)):
+ self.information('Unable to write in the config file', 'Error')
+ self.call_for_resize()
+
+ def resize_global_information_win(self):
+ """
+ Resize the global_information_win only once at each resize.
+ """
+ if self.information_win_size > tabs.Tab.height - 6:
+ self.information_win_size = tabs.Tab.height - 6
+ if tabs.Tab.height < 6:
+ self.information_win_size = 0
+ height = (tabs.Tab.height - 1 - self.information_win_size
+ - tabs.Tab.tab_win_height())
+ self.information_win.resize(self.information_win_size,
+ tabs.Tab.width,
+ height,
+ 0)
+
+ def resize_global_info_bar(self):
+ """
+ Resize the GlobalInfoBar only once at each resize
+ """
+ height, width = self.stdscr.getmaxyx()
+ if config.get('enable_vertical_tab_list'):
+
+ if self.size.core_degrade_x:
+ return
+ try:
+ height, _ = self.stdscr.getmaxyx()
+ truncated_win = self.stdscr.subwin(height,
+ config.get('vertical_tab_list_size'),
+ 0, 0)
+ except:
+ log.error('Curses error on infobar resize', exc_info=True)
+ return
+ self.left_tab_win = windows.VerticalGlobalInfoBar(truncated_win)
+ elif not self.size.core_degrade_y:
+ 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,
+ time=None, nickname=None, history=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')
+ else:
+ buff.add_message(txt, time, nickname, history=history)
+
+ def full_screen_redraw(self):
+ """
+ Completely erase and redraw the screen
+ """
+ self.stdscr.clear()
+ self.refresh_window()
+
+ def call_for_resize(self):
+ """
+ Called when we want to resize the screen
+ """
+ # If we have the tabs list on the left, we just give a truncated
+ # window to each Tab class, so they draw themself in the portion of
+ # the screen that they can occupy, and we draw the tab list on the
+ # remaining space, on the left
+ height, width = self.stdscr.getmaxyx()
+ if (config.get('enable_vertical_tab_list') and
+ not self.size.core_degrade_x):
+ try:
+ scr = self.stdscr.subwin(0,
+ config.get('vertical_tab_list_size'))
+ except:
+ log.error('Curses error on resize', exc_info=True)
+ return
+ else:
+ scr = self.stdscr
+ tabs.Tab.resize(scr)
+ self.resize_global_info_bar()
+ self.resize_global_information_win()
+ for tab in self.tabs:
+ if config.get('lazy_resize'):
+ tab.need_resize = True
+ else:
+ tab.resize()
+ if self.tabs:
+ self.full_screen_redraw()
+
+ def read_keyboard(self):
+ """
+ Get the next keyboard key pressed and returns it. It blocks until
+ something can be read on stdin, this function must be called only if
+ there is something to read. No timeout ever occurs.
+ """
+ return self.keyboard.get_user_input(self.stdscr)
+
+ def escape_next_key(self):
+ """
+ Tell the Keyboard object that the next key pressed by the user
+ should be escaped. See Keyboard.get_user_input
+ """
+ self.keyboard.escape_next_key()
+
+####################### Commands and completions ##############################
+
+ def register_command(self, name, func, **kwargs):
+ """
+ Add a command
+ """
+ desc = kwargs.get('desc', '')
+ shortdesc = kwargs.get('shortdesc', '')
+ completion = kwargs.get('completion')
+ usage = kwargs.get('usage', '')
+ if name in self.commands:
+ return
+ if not desc and shortdesc:
+ desc = shortdesc
+ self.commands[name] = Command(func, desc, completion, shortdesc, usage)
+
+ def register_initial_commands(self):
+ """
+ Register the commands when poezio starts
+ """
+ self.register_command('help', self.command_help,
+ usage='[command]',
+ shortdesc='\\_o< KOIN KOIN KOIN',
+ completion=self.completion_help)
+ self.register_command('join', self.command_join,
+ usage="[room_name][@server][/nick] [password]",
+ desc="Join the specified room. You can specify a nickname "
+ "after a slash (/). If no nickname is specified, you will"
+ " use the default_nick in the configuration file. You can"
+ " omit the room name: you will then join the room you\'re"
+ " looking at (useful if you were kicked). You can also "
+ "provide a room_name without specifying a server, the "
+ "server of the room you're currently in will be used. You"
+ " can also provide a password to join the room.\nExamples"
+ ":\n/join room@server.tld\n/join room@server.tld/John\n"
+ "/join room2\n/join /me_again\n/join\n/join room@server"
+ ".tld/my_nick password\n/join / password",
+ shortdesc='Join a room',
+ completion=self.completion_join)
+ self.register_command('exit', self.command_quit,
+ desc='Just disconnect from the server and exit poezio.',
+ shortdesc='Exit poezio.')
+ self.register_command('quit', self.command_quit,
+ desc='Just disconnect from the server and exit poezio.',
+ shortdesc='Exit poezio.')
+ self.register_command('next', self.rotate_rooms_right,
+ shortdesc='Go to the next room.')
+ self.register_command('prev', self.rotate_rooms_left,
+ shortdesc='Go to the previous room.')
+ self.register_command('win', self.command_win,
+ usage='<number or name>',
+ shortdesc='Go to the specified room',
+ completion=self.completion_win)
+ self.commands['w'] = self.commands['win']
+ self.register_command('move_tab', self.command_move_tab,
+ usage='<source> <destination>',
+ desc="Insert the <source> tab at the position of "
+ "<destination>. This will make the following tabs shift in"
+ " some cases (refer to the documentation). A tab can be "
+ "designated by its number or by the beginning of its "
+ "address. You can use \".\" as a shortcut for the current "
+ "tab.",
+ shortdesc='Move a tab.',
+ completion=self.completion_move_tab)
+ self.register_command('destroy_room', self.command_destroy_room,
+ usage='[room JID]',
+ desc='Try to destroy the room [room JID], or the current'
+ ' tab if it is a multi-user chat and [room JID] is '
+ 'not given.',
+ shortdesc='Destroy a room.',
+ completion=None)
+ self.register_command('show', self.command_status,
+ usage='<availability> [status message]',
+ desc="Sets your availability and (optionally) your status "
+ "message. The <availability> argument is one of \"available"
+ ", chat, away, afk, dnd, busy, xa\" and the optional "
+ "[status message] argument will be your status message.",
+ shortdesc='Change your availability.',
+ completion=self.completion_status)
+ self.commands['status'] = self.commands['show']
+ self.register_command('bookmark_local', self.command_bookmark_local,
+ usage="[roomname][/nick] [password]",
+ desc="Bookmark Local: Bookmark locally the specified room "
+ "(you will then auto-join it on each poezio start). This"
+ " commands uses almost the same syntaxe as /join. Type "
+ "/help join for syntax examples. Note that when typing "
+ "\"/bookmark\" on its own, the room will be bookmarked "
+ "with the nickname you\'re currently using in this room "
+ "(instead of default_nick)",
+ shortdesc='Bookmark a room locally.',
+ completion=self.completion_bookmark_local)
+ self.register_command('bookmark', self.command_bookmark,
+ usage="[roomname][/nick] [autojoin] [password]",
+ desc="Bookmark: Bookmark online the specified room (you "
+ "will then auto-join it on each poezio start if autojoin"
+ " is specified and is 'true'). This commands uses almost"
+ " the same syntax as /join. Type /help join for syntax "
+ "examples. Note that when typing \"/bookmark\" alone, the"
+ " room will be bookmarked with the nickname you\'re "
+ "currently using in this room (instead of default_nick).",
+ shortdesc="Bookmark a room online.",
+ completion=self.completion_bookmark)
+ self.register_command('set', self.command_set,
+ usage="[plugin|][section] <option> [value]",
+ desc="Set the value of an option in your configuration file."
+ " You can, for example, change your default nickname by "
+ "doing `/set default_nick toto` or your resource with `/set"
+ " resource blabla`. You can also set options in specific "
+ "sections with `/set bindings M-i ^i` or in specific plugin"
+ " with `/set mpd_client| host 127.0.0.1`. `toggle` can be "
+ "used as a special value to toggle a boolean option.",
+ shortdesc="Set the value of an option",
+ completion=self.completion_set)
+ self.register_command('set_default', self.command_set_default,
+ usage="[section] <option>",
+ desc="Set the default value of an option. For example, "
+ "`/set_default resource` will reset the resource "
+ "option. You can also reset options in specific "
+ "sections by doing `/set_default section option`.",
+ shortdesc="Set the default value of an option",
+ completion=self.completion_set_default)
+ self.register_command('toggle', self.command_toggle,
+ usage='<option>',
+ desc='Shortcut for /set <option> toggle',
+ shortdesc='Toggle an option',
+ completion=self.completion_toggle)
+ self.register_command('theme', self.command_theme,
+ usage='[theme name]',
+ desc="Reload the theme defined in the config file. If theme"
+ "_name is provided, set that theme before reloading it.",
+ shortdesc='Load a theme',
+ completion=self.completion_theme)
+ self.register_command('list', self.command_list,
+ usage='[server]',
+ desc="Get the list of public chatrooms"
+ " on the specified server.",
+ shortdesc='List the rooms.',
+ completion=self.completion_list)
+ self.register_command('message', self.command_message,
+ usage='<jid> [optional message]',
+ desc="Open a conversation with the specified JID (even if it"
+ " is not in our roster), and send a message to it, if the "
+ "message is specified.",
+ shortdesc='Send a message',
+ completion=self.completion_message)
+ self.register_command('version', self.command_version,
+ usage='<jid>',
+ desc="Get the software version of the given JID (usually its"
+ " XMPP client and Operating System).",
+ shortdesc='Get the software version of a JID.',
+ completion=self.completion_version)
+ self.register_command('server_cycle', self.command_server_cycle,
+ usage='[domain] [message]',
+ desc='Disconnect and reconnect in all the rooms in domain.',
+ shortdesc='Cycle a range of rooms',
+ completion=self.completion_server_cycle)
+ self.register_command('bind', self.command_bind,
+ usage='<key> <equ>',
+ desc="Bind a key to another key or to a “command”. For "
+ "example \"/bind ^H KEY_UP\" makes Control + h do the"
+ " same same as the Up key.",
+ completion=self.completion_bind,
+ shortdesc='Bind a key to another key.')
+ self.register_command('load', self.command_load,
+ usage='<plugin> [<otherplugin> …]',
+ shortdesc='Load the specified plugin(s)',
+ completion=self.plugin_manager.completion_load)
+ self.register_command('unload', self.command_unload,
+ usage='<plugin> [<otherplugin> …]',
+ shortdesc='Unload the specified plugin(s)',
+ completion=self.plugin_manager.completion_unload)
+ self.register_command('plugins', self.command_plugins,
+ shortdesc='Show the plugins in use.')
+ self.register_command('presence', self.command_presence,
+ usage='<JID> [type] [status]',
+ desc="Send a directed presence to <JID> and using"
+ " [type] and [status] if provided.",
+ shortdesc='Send a directed presence.',
+ completion=self.completion_presence)
+ self.register_command('rawxml', self.command_rawxml,
+ usage='<xml>',
+ shortdesc='Send a custom xml stanza.')
+ self.register_command('invite', self.command_invite,
+ usage='<jid> <room> [reason]',
+ desc='Invite jid in room with reason.',
+ shortdesc='Invite someone in a room.',
+ completion=self.completion_invite)
+ self.register_command('invitations', self.command_invitations,
+ shortdesc='Show the pending invitations.')
+ self.register_command('bookmarks', self.command_bookmarks,
+ shortdesc='Show the current bookmarks.')
+ self.register_command('remove_bookmark', self.command_remove_bookmark,
+ usage='[jid]',
+ desc="Remove the specified bookmark, or the "
+ "bookmark on the current tab, if any.",
+ shortdesc='Remove a bookmark',
+ completion=self.completion_remove_bookmark)
+ self.register_command('xml_tab', self.command_xml_tab,
+ shortdesc='Open an XML tab.')
+ self.register_command('runkey', self.command_runkey,
+ usage='<key>',
+ shortdesc='Execute the action defined for <key>.',
+ completion=self.completion_runkey)
+ self.register_command('self', self.command_self,
+ shortdesc='Remind you of who you are.')
+ self.register_command('last_activity', self.command_last_activity,
+ usage='<jid>',
+ desc='Informs you of the last activity of a JID.',
+ shortdesc='Get the activity of someone.',
+ completion=self.completion_last_activity)
+ self.register_command('ad-hoc', self.command_adhoc,
+ usage='<jid>',
+ shortdesc='List available ad-hoc commands on the given jid')
+ self.register_command('reload', self.command_reload,
+ shortdesc='Reload the config. You can achieve the same by '
+ 'sending SIGUSR1 to poezio.')
+
+ if config.get('enable_user_activity'):
+ self.register_command('activity', self.command_activity,
+ usage='[<general> [specific] [text]]',
+ desc='Send your current activity to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting an activity".',
+ shortdesc='Send your activity.',
+ completion=self.completion_activity)
+ if config.get('enable_user_mood'):
+ self.register_command('mood', self.command_mood,
+ usage='[<mood> [text]]',
+ desc='Send your current mood to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting a mood".',
+ shortdesc='Send your mood.',
+ completion=self.completion_mood)
+ if config.get('enable_user_gaming'):
+ self.register_command('gaming', self.command_gaming,
+ usage='[<game name> [server address]]',
+ desc='Send your current gaming activity to '
+ 'your contacts. Nothing means "stop '
+ 'broadcasting a gaming activity".',
+ shortdesc='Send your gaming activity.',
+ completion=None)
+
+####################### XMPP Event Handlers ##################################
+ on_session_start_features = handlers.on_session_start_features
+ on_carbon_received = handlers.on_carbon_received
+ on_carbon_sent = handlers.on_carbon_sent
+ on_groupchat_invitation = handlers.on_groupchat_invitation
+ on_groupchat_direct_invitation = handlers.on_groupchat_direct_invitation
+ on_groupchat_decline = handlers.on_groupchat_decline
+ on_message = handlers.on_message
+ on_error_message = handlers.on_error_message
+ on_normal_message = handlers.on_normal_message
+ on_nick_received = handlers.on_nick_received
+ on_gaming_event = handlers.on_gaming_event
+ on_mood_event = handlers.on_mood_event
+ on_activity_event = handlers.on_activity_event
+ on_tune_event = handlers.on_tune_event
+ on_groupchat_message = handlers.on_groupchat_message
+ on_muc_own_nickchange = handlers.on_muc_own_nickchange
+ on_groupchat_private_message = handlers.on_groupchat_private_message
+ on_chatstate_active = handlers.on_chatstate_active
+ on_chatstate_inactive = handlers.on_chatstate_inactive
+ on_chatstate_composing = handlers.on_chatstate_composing
+ on_chatstate_paused = handlers.on_chatstate_paused
+ on_chatstate_gone = handlers.on_chatstate_gone
+ on_chatstate = handlers.on_chatstate
+ on_chatstate_normal_conversation = handlers.on_chatstate_normal_conversation
+ on_chatstate_private_conversation = \
+ handlers.on_chatstate_private_conversation
+ on_chatstate_groupchat_conversation = \
+ handlers.on_chatstate_groupchat_conversation
+ on_roster_update = handlers.on_roster_update
+ on_subscription_request = handlers.on_subscription_request
+ on_subscription_authorized = handlers.on_subscription_authorized
+ on_subscription_remove = handlers.on_subscription_remove
+ on_subscription_removed = handlers.on_subscription_removed
+ on_presence = handlers.on_presence
+ on_presence_error = handlers.on_presence_error
+ on_got_offline = handlers.on_got_offline
+ on_got_online = handlers.on_got_online
+ on_groupchat_presence = handlers.on_groupchat_presence
+ on_failed_connection = handlers.on_failed_connection
+ on_disconnected = handlers.on_disconnected
+ on_stream_error = handlers.on_stream_error
+ on_failed_all_auth = handlers.on_failed_all_auth
+ on_no_auth = handlers.on_no_auth
+ on_connected = handlers.on_connected
+ on_connecting = handlers.on_connecting
+ on_session_start = handlers.on_session_start
+ on_status_codes = handlers.on_status_codes
+ on_groupchat_subject = handlers.on_groupchat_subject
+ on_data_form = handlers.on_data_form
+ on_receipt = handlers.on_receipt
+ on_attention = handlers.on_attention
+ room_error = handlers.room_error
+ check_bookmark_storage = handlers.check_bookmark_storage
+ outgoing_stanza = handlers.outgoing_stanza
+ incoming_stanza = handlers.incoming_stanza
+ validate_ssl = handlers.validate_ssl
+ ssl_invalid_chain = handlers.ssl_invalid_chain
+ on_next_adhoc_step = handlers.on_next_adhoc_step
+ on_adhoc_error = handlers.on_adhoc_error
+ cancel_adhoc_command = handlers.cancel_adhoc_command
+ validate_adhoc_step = handlers.validate_adhoc_step
+ terminate_adhoc_command = handlers.terminate_adhoc_command
+ command_help = commands.command_help
+ command_runkey = commands.command_runkey
+ command_status = commands.command_status
+ command_presence = commands.command_presence
+ command_theme = commands.command_theme
+ command_win = commands.command_win
+ command_move_tab = commands.command_move_tab
+ command_list = commands.command_list
+ command_version = commands.command_version
+ command_join = commands.command_join
+ command_bookmark_local = commands.command_bookmark_local
+ command_bookmark = commands.command_bookmark
+ command_bookmarks = commands.command_bookmarks
+ command_destroy_room = commands.command_destroy_room
+ command_remove_bookmark = commands.command_remove_bookmark
+ command_set = commands.command_set
+ command_set_default = commands.command_set_default
+ command_toggle = commands.command_toggle
+ command_server_cycle = commands.command_server_cycle
+ command_last_activity = commands.command_last_activity
+ command_mood = commands.command_mood
+ command_activity = commands.command_activity
+ command_gaming = commands.command_gaming
+ command_invite = commands.command_invite
+ command_decline = commands.command_decline
+ command_invitations = commands.command_invitations
+ command_quit = commands.command_quit
+ command_bind = commands.command_bind
+ command_rawxml = commands.command_rawxml
+ command_load = commands.command_load
+ command_unload = commands.command_unload
+ command_plugins = commands.command_plugins
+ command_message = commands.command_message
+ command_xml_tab = commands.command_xml_tab
+ command_adhoc = commands.command_adhoc
+ command_self = commands.command_self
+ command_reload = commands.command_reload
+ completion_help = completions.completion_help
+ completion_status = completions.completion_status
+ completion_presence = completions.completion_presence
+ completion_theme = completions.completion_theme
+ completion_win = completions.completion_win
+ completion_join = completions.completion_join
+ completion_version = completions.completion_version
+ completion_list = completions.completion_list
+ completion_move_tab = completions.completion_move_tab
+ completion_runkey = completions.completion_runkey
+ completion_bookmark = completions.completion_bookmark
+ completion_remove_bookmark = completions.completion_remove_bookmark
+ completion_decline = completions.completion_decline
+ completion_bind = completions.completion_bind
+ completion_message = completions.completion_message
+ completion_invite = completions.completion_invite
+ completion_activity = completions.completion_activity
+ completion_mood = completions.completion_mood
+ completion_last_activity = completions.completion_last_activity
+ completion_server_cycle = completions.completion_server_cycle
+ completion_set = completions.completion_set
+ completion_set_default = completions.completion_set_default
+ completion_toggle = completions.completion_toggle
+ completion_bookmark_local = completions.completion_bookmark_local
+
+
+
+class KeyDict(dict):
+ """
+ A dict, with a wrapper for get() that will return a custom value
+ if the key starts with _exc_
+ """
+ def get(self, k, d=None):
+ if isinstance(k, str) and k.startswith('_exc_') and len(k) > 5:
+ return lambda: dict.get(self, '_exc_')(k[5:])
+ return dict.get(self, k, d)
+
+def replace_key_with_bound(key):
+ """
+ Replace an inputted key with the one defined as its replacement
+ in the config
+ """
+ bind = config.get(key, default=key, section='bindings')
+ if not bind:
+ bind = key
+ return bind
+
+
diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py
new file mode 100644
index 00000000..8cc08179
--- /dev/null
+++ b/poezio/core/handlers.py
@@ -0,0 +1,1354 @@
+"""
+XMPP-related handlers for the Core class
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import asyncio
+import curses
+import functools
+import ssl
+import sys
+import time
+from datetime import datetime
+from hashlib import sha1, sha512
+from os import path
+
+from slixmpp import InvalidJID
+from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase
+from xml.etree import ElementTree as ET
+
+import common
+import fixes
+import pep
+import tabs
+import windows
+import xhtml
+import multiuserchat as muc
+from common import safeJID
+from config import config, CACHE_DIR
+from contact import Resource
+from logger import logger
+from roster import roster
+from text_buffer import CorrectionError, AckError
+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)
+ PYGMENTS = True
+except ImportError:
+ PYGMENTS = False
+
+def _join_initial_rooms(self, bookmarks):
+ """Join all rooms given in the iterator `bookmarks`"""
+ for bm in bookmarks:
+ if not (bm.autojoin or config.get('open_all_bookmarks')):
+ continue
+ tab = self.get_tab_by_name(bm.jid, tabs.MucTab)
+ nick = bm.nick if bm.nick else self.own_nick
+ if not tab:
+ self.open_new_room(bm.jid, nick, focus=False,
+ password=bm.password)
+ self.initial_joins.append(bm.jid)
+ # do not join rooms that do not have autojoin
+ # but display them anyway
+ if bm.autojoin:
+ muc.join_groupchat(self, bm.jid, nick,
+ passwd=bm.password,
+ status=self.status.message,
+ show=self.status.show)
+
+def check_bookmark_storage(self, features):
+ private = 'jabber:iq:private' in features
+ pep_ = 'http://jabber.org/protocol/pubsub#publish' in features
+ self.bookmarks.available_storage['private'] = private
+ self.bookmarks.available_storage['pep'] = pep_
+ def _join_remote_only(iq):
+ if iq['type'] == 'error':
+ type_ = iq['error']['type']
+ condition = iq['error']['condition']
+ if not (type_ == 'cancel' and condition == 'item-not-found'):
+ self.information('Unable to fetch the remote'
+ ' bookmarks; %s: %s' % (type_, condition),
+ 'Error')
+ return
+ remote_bookmarks = self.bookmarks.remote()
+ _join_initial_rooms(self, remote_bookmarks)
+ if not self.xmpp.anon and config.get('use_remote_bookmarks'):
+ self.bookmarks.get_remote(self.xmpp, self.information, _join_remote_only)
+
+def on_session_start_features(self, _):
+ """
+ Enable carbons & blocking on session start if wanted and possible
+ """
+ def callback(iq):
+ if not iq:
+ return
+ features = iq['disco_info']['features']
+ rostertab = self.get_tab_by_name('Roster', tabs.RosterInfoTab)
+ rostertab.check_blocking(features)
+ rostertab.check_saslexternal(features)
+ if (config.get('enable_carbons') and
+ 'urn:xmpp:carbons:2' in features):
+ self.xmpp.plugin['xep_0280'].enable()
+ self.check_bookmark_storage(features)
+
+ self.xmpp.plugin['xep_0030'].get_info(jid=self.xmpp.boundjid.domain,
+ callback=callback)
+
+def on_carbon_received(self, message):
+ """
+ Carbon <received/> received
+ """
+ def ignore_message(recv):
+ log.debug('%s has category conference, ignoring carbon',
+ recv['from'].server)
+ def receive_message(recv):
+ recv['to'] = self.xmpp.boundjid.full
+ if recv['receipt']:
+ return self.on_receipt(recv)
+ self.on_normal_message(recv)
+
+ recv = message['carbon_received']
+ if (recv['from'].bare not in roster or
+ roster[recv['from'].bare].subscription == 'none'):
+ fixes.has_identity(self.xmpp, recv['from'].server,
+ identity='conference',
+ on_true=functools.partial(ignore_message, recv),
+ on_false=functools.partial(receive_message, recv))
+ return
+ else:
+ receive_message(recv)
+
+def on_carbon_sent(self, message):
+ """
+ Carbon <sent/> received
+ """
+ def ignore_message(sent):
+ log.debug('%s has category conference, ignoring carbon',
+ sent['to'].server)
+ def send_message(sent):
+ sent['from'] = self.xmpp.boundjid.full
+ self.on_normal_message(sent)
+
+ sent = message['carbon_sent']
+ if (sent['to'].bare not in roster or
+ roster[sent['to'].bare].subscription == 'none'):
+ fixes.has_identity(self.xmpp, sent['to'].server,
+ identity='conference',
+ on_true=functools.partial(ignore_message, sent),
+ on_false=functools.partial(send_message, sent))
+ else:
+ send_message(sent)
+
+### Invites ###
+
+def on_groupchat_invitation(self, message):
+ """
+ Mediated invitation received
+ """
+ jid = message['from']
+ if jid.bare in self.pending_invites:
+ return
+ # there are 2 'x' tags in the messages, making message['x'] useless
+ invite = StanzaBase(self.xmpp, xml=message.find('{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite'))
+ inviter = invite['from']
+ reason = invite['reason']
+ password = invite['password']
+ msg = "You are invited to the room %s by %s" % (jid.full, inviter.full)
+ if reason:
+ msg += "because: %s" % reason
+ if password:
+ msg += ". The password is \"%s\"." % password
+ self.information(msg, 'Info')
+ if 'invite' in config.get('beep_on').split():
+ curses.beep()
+ logger.log_roster_change(inviter.full, 'invited you to %s' % jid.full)
+ self.pending_invites[jid.bare] = inviter.full
+
+def on_groupchat_decline(self, decline):
+ "Mediated invitation declined; skip for now"
+ pass
+
+def on_groupchat_direct_invitation(self, message):
+ """
+ Direct invitation received
+ """
+ room = safeJID(message['groupchat_invite']['jid'])
+ if room.bare in self.pending_invites:
+ return
+
+ inviter = message['from']
+ reason = message['groupchat_invite']['reason']
+ password = message['groupchat_invite']['password']
+ continue_ = message['groupchat_invite']['continue']
+ msg = "You are invited to the room %s by %s" % (room, inviter.full)
+
+ if password:
+ msg += ' (password: "%s")' % password
+ if continue_:
+ msg += '\nto continue the discussion'
+ if reason:
+ msg += "\nreason: %s" % reason
+
+ self.information(msg, 'Info')
+ if 'invite' in config.get('beep_on').split():
+ curses.beep()
+
+ self.pending_invites[room.bare] = inviter.full
+ logger.log_roster_change(inviter.full, 'invited you to %s' % room.bare)
+
+### "classic" messages ###
+
+def on_message(self, message):
+ """
+ When receiving private message from a muc OR a normal message
+ (from one of our contacts)
+ """
+ if message.find('{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite') != None:
+ return
+ if message['type'] == 'groupchat':
+ return
+ # Differentiate both type of messages, and call the appropriate handler.
+ jid_from = message['from']
+ for tab in self.get_tabs(tabs.MucTab):
+ if tab.name == jid_from.bare:
+ if message['type'] == 'chat':
+ return self.on_groupchat_private_message(message)
+ return self.on_normal_message(message)
+
+def on_error_message(self, message):
+ """
+ When receiving any message with type="error"
+ """
+ jid_from = message['from']
+ for tab in self.get_tabs(tabs.MucTab):
+ if tab.name == jid_from.bare:
+ if message['type'] == 'error':
+ return self.room_error(message, jid_from.bare)
+ else:
+ return self.on_groupchat_private_message(message)
+ tab = self.get_conversation_by_jid(message['from'], create=False)
+ error_msg = self.get_error_message(message, deprecated=True)
+ if not tab:
+ return self.information(error_msg, 'Error')
+ 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)
+ self.refresh_window()
+
+
+def on_normal_message(self, message):
+ """
+ When receiving "normal" messages (not a private message from a
+ muc participant)
+ """
+ if message['type'] == 'error':
+ return
+ elif message['type'] == 'headline' and message['body']:
+ return self.information('%s says: %s' % (message['from'], message['body']), 'Headline')
+
+ use_xhtml = config.get('enable_xhtml_im')
+ tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images')
+ extract_images = config.get('extract_inline_images')
+ body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml,
+ tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ if not body:
+ return
+
+ remote_nick = ''
+ # normal message, we are the recipient
+ if message['to'].bare == self.xmpp.boundjid.bare:
+ conv_jid = message['from']
+ jid = conv_jid
+ color = get_theme().COLOR_REMOTE_USER
+ # check for a name
+ if conv_jid.bare in roster:
+ remote_nick = roster[conv_jid.bare].name
+ # check for a received nick
+ if not remote_nick and config.get('enable_user_nick'):
+ if message.xml.find('{http://jabber.org/protocol/nick}nick') is not None:
+ remote_nick = message['nick']['nick']
+ if not remote_nick:
+ remote_nick = conv_jid.user
+ if not remote_nick:
+ remote_nick = conv_jid.full
+ own = False
+ # we wrote the message (happens with carbons)
+ elif message['from'].bare == self.xmpp.boundjid.bare:
+ conv_jid = message['to']
+ jid = self.xmpp.boundjid
+ color = get_theme().COLOR_OWN_NICK
+ remote_nick = self.own_nick
+ own = True
+ # we are not part of that message, drop it
+ else:
+ return
+
+ conversation = self.get_conversation_by_jid(conv_jid, create=True)
+ if isinstance(conversation, tabs.DynamicConversationTab) and conv_jid.resource:
+ conversation.lock(conv_jid.resource)
+
+ if not own and not conversation.nick:
+ conversation.nick = remote_nick
+ elif not own: # keep a fixed nick during the whole conversation
+ remote_nick = conversation.nick
+
+ self.events.trigger('conversation_msg', message, conversation)
+ if not message['body']:
+ return
+ body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml,
+ tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ delayed, date = common.find_delayed_tag(message)
+
+ def try_modify():
+ replaced_id = message['replace']['id']
+ if replaced_id and config.get_by_tabname('group_corrections',
+ conv_jid.bare):
+ try:
+ conversation.modify_message(body, replaced_id, message['id'], jid=jid,
+ nickname=remote_nick)
+ return True
+ except CorrectionError:
+ log.debug('Unable to correct a message', exc_info=True)
+ return False
+
+ if not try_modify():
+ conversation.add_message(body, date,
+ nickname=remote_nick,
+ nick_color=color,
+ history=delayed,
+ identifier=message['id'],
+ jid=jid,
+ typ=1)
+
+ if conversation.remote_wants_chatstates is None and not delayed:
+ if message['chat_state']:
+ conversation.remote_wants_chatstates = True
+ else:
+ conversation.remote_wants_chatstates = False
+ if not own and 'private' in config.get('beep_on').split():
+ if not config.get_by_tabname('disable_beep', conv_jid.bare):
+ curses.beep()
+ if self.current_tab() is not conversation:
+ if not own:
+ conversation.state = 'private'
+ self.refresh_tab_win()
+ else:
+ conversation.set_state('normal')
+ self.refresh_tab_win()
+ else:
+ self.refresh_window()
+
+def on_nick_received(self, message):
+ """
+ Called when a pep notification for an user nickname
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ if item.xml.find('{http://jabber.org/protocol/nick}nick'):
+ contact.name = item['nick']['nick']
+ else:
+ contact.name = ''
+
+def on_gaming_event(self, message):
+ """
+ Called when a pep notification for user gaming
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_gaming = contact.gaming
+ if item.xml.find('{urn:xmpp:gaming:0}gaming'):
+ item = item['gaming']
+ # only name and server_address are used for now
+ contact.gaming = {
+ 'character_name': item['character_name'],
+ 'character_profile': item['character_profile'],
+ 'name': item['name'],
+ 'level': item['level'],
+ 'uri': item['uri'],
+ 'server_name': item['server_name'],
+ 'server_address': item['server_address'],
+ }
+ else:
+ contact.gaming = {}
+
+ if contact.gaming:
+ logger.log_roster_change(contact.bare_jid, 'is playing %s' % (common.format_gaming_string(contact.gaming)))
+
+ if old_gaming != contact.gaming and config.get_by_tabname('display_gaming_notifications', contact.bare_jid):
+ if contact.gaming:
+ self.information('%s is playing %s' % (contact.bare_jid, common.format_gaming_string(contact.gaming)), 'Gaming')
+ else:
+ self.information(contact.bare_jid + ' stopped playing.', 'Gaming')
+
+def on_mood_event(self, message):
+ """
+ Called when a pep notification for an user mood
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ roster.modified()
+ item = message['pubsub_event']['items']['item']
+ old_mood = contact.mood
+ if item.xml.find('{http://jabber.org/protocol/mood}mood'):
+ mood = item['mood']['value']
+ if mood:
+ mood = pep.MOODS.get(mood, mood)
+ text = item['mood']['text']
+ if text:
+ mood = '%s (%s)' % (mood, text)
+ contact.mood = mood
+ else:
+ contact.mood = ''
+ else:
+ contact.mood = ''
+
+ if contact.mood:
+ logger.log_roster_change(contact.bare_jid, 'has now the mood: %s' % contact.mood)
+
+ if old_mood != contact.mood and config.get_by_tabname('display_mood_notifications', contact.bare_jid):
+ if contact.mood:
+ self.information('Mood from '+ contact.bare_jid + ': ' + contact.mood, 'Mood')
+ else:
+ self.information(contact.bare_jid + ' stopped having his/her mood.', 'Mood')
+
+def on_activity_event(self, message):
+ """
+ Called when a pep notification for an user activity
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ roster.modified()
+ item = message['pubsub_event']['items']['item']
+ old_activity = contact.activity
+ if item.xml.find('{http://jabber.org/protocol/activity}activity'):
+ try:
+ activity = item['activity']['value']
+ except ValueError:
+ return
+ if activity[0]:
+ general = pep.ACTIVITIES.get(activity[0])
+ s = general['category']
+ if activity[1]:
+ s = s + '/' + general.get(activity[1], 'other')
+ text = item['activity']['text']
+ if text:
+ s = '%s (%s)' % (s, text)
+ contact.activity = s
+ else:
+ contact.activity = ''
+ else:
+ contact.activity = ''
+
+ if contact.activity:
+ logger.log_roster_change(contact.bare_jid, 'has now the activity %s' % contact.activity)
+
+ if old_activity != contact.activity and config.get_by_tabname('display_activity_notifications', contact.bare_jid):
+ if contact.activity:
+ self.information('Activity from '+ contact.bare_jid + ': ' + contact.activity, 'Activity')
+ else:
+ self.information(contact.bare_jid + ' stopped doing his/her activity.', 'Activity')
+
+def on_tune_event(self, message):
+ """
+ Called when a pep notification for an user tune
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ roster.modified()
+ item = message['pubsub_event']['items']['item']
+ old_tune = contact.tune
+ if item.xml.find('{http://jabber.org/protocol/tune}tune'):
+ item = item['tune']
+ contact.tune = {
+ 'artist': item['artist'],
+ 'length': item['length'],
+ 'rating': item['rating'],
+ 'source': item['source'],
+ 'title': item['title'],
+ 'track': item['track'],
+ 'uri': item['uri']
+ }
+ else:
+ contact.tune = {}
+
+ if contact.tune:
+ logger.log_roster_change(message['from'].bare, 'is now listening to %s' % common.format_tune_string(contact.tune))
+
+ if old_tune != contact.tune and config.get_by_tabname('display_tune_notifications', contact.bare_jid):
+ if contact.tune:
+ self.information(
+ 'Tune from '+ message['from'].bare + ': ' + common.format_tune_string(contact.tune),
+ 'Tune')
+ else:
+ self.information(contact.bare_jid + ' stopped listening to music.', 'Tune')
+
+def on_groupchat_message(self, message):
+ """
+ Triggered whenever a message is received from a multi-user chat room.
+ """
+ if message['subject']:
+ return
+ room_from = message['from'].bare
+
+ if message['type'] == 'error': # Check if it's an error
+ return self.room_error(message, room_from)
+
+ tab = self.get_tab_by_name(room_from, tabs.MucTab)
+ if not tab:
+ self.information("message received for a non-existing room: %s" % (room_from))
+ muc.leave_groupchat(self.xmpp, room_from, self.own_nick, msg='')
+ return
+
+ nick_from = message['mucnick']
+ user = tab.get_user_by_name(nick_from)
+ if user and user in tab.ignores:
+ return
+
+ self.events.trigger('muc_msg', message, tab)
+ use_xhtml = config.get('enable_xhtml_im')
+ tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images')
+ extract_images = config.get('extract_inline_images')
+ body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml,
+ tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ if not body:
+ return
+
+ old_state = tab.state
+ delayed, date = common.find_delayed_tag(message)
+ replaced_id = message['replace']['id']
+ replaced = False
+ if replaced_id is not '' and config.get_by_tabname('group_corrections',
+ message['from'].bare):
+ try:
+ delayed_date = date or datetime.now()
+ if tab.modify_message(body, replaced_id, message['id'],
+ time=delayed_date,
+ nickname=nick_from, user=user):
+ self.events.trigger('highlight', message, tab)
+ replaced = True
+ 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'], typ=1):
+ self.events.trigger('highlight', message, tab)
+
+ if message['from'].resource == tab.own_nick:
+ tab.last_sent_message = message
+
+ if tab is self.current_tab():
+ tab.text_win.refresh()
+ tab.info_header.refresh(tab, tab.text_win)
+ tab.input.refresh()
+ self.doupdate()
+ elif tab.state != old_state:
+ self.refresh_tab_win()
+ current = self.current_tab()
+ if hasattr(current, 'input') and current.input:
+ current.input.refresh()
+ self.doupdate()
+
+ if 'message' in config.get('beep_on').split():
+ if (not config.get_by_tabname('disable_beep', room_from)
+ and self.own_nick != message['from'].resource):
+ curses.beep()
+
+def on_muc_own_nickchange(self, muc):
+ "We changed our nick in a MUC"
+ for tab in self.get_tabs(tabs.PrivateTab):
+ if tab.parent_muc == muc:
+ tab.own_nick = muc.own_nick
+
+def on_groupchat_private_message(self, message):
+ """
+ We received a Private Message (from someone in a Muc)
+ """
+ jid = message['from']
+ nick_from = jid.resource
+ if not nick_from:
+ return self.on_groupchat_message(message)
+
+ room_from = jid.bare
+ use_xhtml = config.get('enable_xhtml_im')
+ tmp_dir = config.get('tmp_image_dir') or path.join(CACHE_DIR, 'images')
+ extract_images = config.get('extract_inline_images')
+ body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml,
+ tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ tab = self.get_tab_by_name(jid.full, tabs.PrivateTab) # get the tab with the private conversation
+ ignore = config.get_by_tabname('ignore_private', room_from)
+ if not tab: # It's the first message we receive: create the tab
+ if body and not ignore:
+ tab = self.open_private_window(room_from, nick_from, False)
+ if ignore:
+ self.events.trigger('ignored_private', message, tab)
+ msg = config.get_by_tabname('private_auto_response', room_from)
+ if msg and body:
+ self.xmpp.send_message(mto=jid.full, mbody=msg, mtype='chat')
+ return
+ self.events.trigger('private_msg', message, tab)
+ body = xhtml.get_body_from_message_stanza(message, use_xhtml=use_xhtml,
+ tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ if not body or not tab:
+ return
+ replaced_id = message['replace']['id']
+ replaced = False
+ user = tab.parent_muc.get_user_by_name(nick_from)
+ if replaced_id is not '' and config.get_by_tabname('group_corrections',
+ room_from):
+ try:
+ tab.modify_message(body, replaced_id, message['id'], user=user, jid=message['from'],
+ nickname=nick_from)
+ replaced = True
+ except CorrectionError:
+ log.debug('Unable to correct a message', exc_info=True)
+ if not replaced:
+ tab.add_message(body, time=None, nickname=nick_from,
+ forced_user=user,
+ identifier=message['id'],
+ jid=message['from'],
+ typ=1)
+
+ if tab.remote_wants_chatstates is None:
+ if message['chat_state']:
+ tab.remote_wants_chatstates = True
+ else:
+ tab.remote_wants_chatstates = False
+ if 'private' in config.get('beep_on').split():
+ if not config.get_by_tabname('disable_beep', jid.full):
+ curses.beep()
+ if tab is self.current_tab():
+ self.refresh_window()
+ else:
+ tab.state = 'private'
+ self.refresh_tab_win()
+
+### Chatstates ###
+
+def on_chatstate_active(self, message):
+ self.on_chatstate(message, "active")
+
+def on_chatstate_inactive(self, message):
+ self.on_chatstate(message, "inactive")
+
+def on_chatstate_composing(self, message):
+ self.on_chatstate(message, "composing")
+
+def on_chatstate_paused(self, message):
+ self.on_chatstate(message, "paused")
+
+def on_chatstate_gone(self, message):
+ self.on_chatstate(message, "gone")
+
+def on_chatstate(self, message, state):
+ if message['type'] == 'chat':
+ if not self.on_chatstate_normal_conversation(message, state):
+ tab = self.get_tab_by_name(message['from'].full, tabs.PrivateTab)
+ if not tab:
+ return
+ self.on_chatstate_private_conversation(message, state)
+ elif message['type'] == 'groupchat':
+ self.on_chatstate_groupchat_conversation(message, state)
+
+def on_chatstate_normal_conversation(self, message, state):
+ tab = self.get_conversation_by_jid(message['from'], False)
+ if not tab:
+ return False
+ tab.remote_wants_chatstates = True
+ self.events.trigger('normal_chatstate', message, tab)
+ tab.chatstate = state
+ if state == 'gone' and isinstance(tab, tabs.DynamicConversationTab):
+ tab.unlock()
+ if tab == self.current_tab():
+ tab.refresh_info_header()
+ self.doupdate()
+ else:
+ _composing_tab_state(tab, state)
+ self.refresh_tab_win()
+ return True
+
+def on_chatstate_private_conversation(self, message, state):
+ """
+ Chatstate received in a private conversation from a MUC
+ """
+ tab = self.get_tab_by_name(message['from'].full, tabs.PrivateTab)
+ if not tab:
+ return
+ tab.remote_wants_chatstates = True
+ self.events.trigger('private_chatstate', message, tab)
+ tab.chatstate = state
+ if tab == self.current_tab():
+ tab.refresh_info_header()
+ self.doupdate()
+ else:
+ _composing_tab_state(tab, state)
+ self.refresh_tab_win()
+ return True
+
+def on_chatstate_groupchat_conversation(self, message, state):
+ """
+ Chatstate received in a MUC
+ """
+ nick = message['mucnick']
+ room_from = message.get_mucroom()
+ tab = self.get_tab_by_name(room_from, tabs.MucTab)
+ if tab and tab.get_user_by_name(nick):
+ self.events.trigger('muc_chatstate', message, tab)
+ tab.get_user_by_name(nick).chatstate = state
+ if tab == self.current_tab():
+ if not self.size.tab_degrade_x:
+ tab.user_win.refresh(tab.users)
+ tab.input.refresh()
+ self.doupdate()
+ else:
+ _composing_tab_state(tab, state)
+ self.refresh_tab_win()
+
+### subscription-related handlers ###
+
+def on_roster_update(self, iq):
+ """
+ The roster was received.
+ """
+ for item in iq['roster']:
+ try:
+ jid = item['jid']
+ except InvalidJID:
+ jid = item._get_attr('jid', '')
+ log.error('Invalid JID: "%s"', jid, exc_info=True)
+ else:
+ if item['subscription'] == 'remove':
+ del roster[jid]
+ else:
+ roster.update_contact_groups(jid)
+ roster.update_size()
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_subscription_request(self, presence):
+ """subscribe received"""
+ jid = presence['from'].bare
+ contact = roster[jid]
+ if contact and contact.subscription in ('from', 'both'):
+ return
+ elif contact and contact.subscription == 'to':
+ self.xmpp.sendPresence(pto=jid, ptype='subscribed')
+ self.xmpp.sendPresence(pto=jid)
+ else:
+ if not contact:
+ contact = roster.get_and_set(jid)
+ roster.update_contact_groups(contact)
+ contact.pending_in = True
+ self.information('%s wants to subscribe to your presence, use '
+ '/accept <jid> or /deny <jid> in the roster '
+ 'tab to accept or reject the query.' % jid,
+ 'Roster')
+ self.get_tab_by_number(0).state = 'highlight'
+ roster.modified()
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_subscription_authorized(self, presence):
+ """subscribed received"""
+ jid = presence['from'].bare
+ contact = roster[jid]
+ if contact.subscription not in ('both', 'from'):
+ self.information('%s accepted your contact proposal' % jid, 'Roster')
+ if contact.pending_out:
+ contact.pending_out = False
+
+ roster.modified()
+
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_subscription_remove(self, presence):
+ """unsubscribe received"""
+ jid = presence['from'].bare
+ contact = roster[jid]
+ if not contact:
+ return
+ roster.modified()
+ self.information('%s does not want to receive your status anymore.' % jid, 'Roster')
+ self.get_tab_by_number(0).state = 'highlight'
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_subscription_removed(self, presence):
+ """unsubscribed received"""
+ jid = presence['from'].bare
+ contact = roster[jid]
+ if not contact:
+ return
+ roster.modified()
+ if contact.pending_out:
+ self.information('%s rejected your contact proposal' % jid, 'Roster')
+ contact.pending_out = False
+ else:
+ self.information('%s does not want you to receive his/her/its status anymore.'%jid, 'Roster')
+ self.get_tab_by_number(0).state = 'highlight'
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+### Presence-related handlers ###
+
+def on_presence(self, presence):
+ if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'):
+ return
+ jid = presence['from']
+ contact = roster[jid.bare]
+ tab = self.get_conversation_by_jid(jid, create=False)
+ if isinstance(tab, tabs.DynamicConversationTab):
+ if tab.get_dest_jid() != jid.full:
+ tab.unlock(from_=jid.full)
+ elif presence['type'] == 'unavailable':
+ tab.unlock()
+ if contact is None:
+ return
+ roster.modified()
+ contact.error = None
+ self.events.trigger('normal_presence', presence, contact[jid.full])
+ tab = self.get_conversation_by_jid(jid, create=False)
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+ elif self.current_tab() == tab:
+ tab.refresh()
+ self.doupdate()
+
+def on_presence_error(self, presence):
+ jid = presence['from']
+ contact = roster[jid.bare]
+ if not contact:
+ return
+ roster.modified()
+ contact.error = presence['error']['type'] + ': ' + presence['error']['condition']
+ # reset chat states status on presence error
+ tab = self.get_tab_by_name(jid.full, tabs.ConversationTab)
+ if tab:
+ tab.remote_wants_chatstates = None
+
+def on_got_offline(self, presence):
+ """
+ A JID got offline
+ """
+ if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'):
+ return
+ jid = presence['from']
+ if not logger.log_roster_change(jid.bare, 'got offline'):
+ self.information('Unable to write in the log file', 'Error')
+ # If a resource got offline, display the message in the conversation with this
+ # precise resource.
+ contact = roster[jid.bare]
+ name = jid.bare
+ if contact:
+ roster.connected -= 1
+ if contact.name:
+ name = contact.name
+ if jid.resource:
+ self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x191}offline' % name)
+ self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x191}offline' % name)
+ self.information('\x193}%s \x195}is \x191}offline' % name, 'Roster')
+ roster.modified()
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_got_online(self, presence):
+ """
+ A JID got online
+ """
+ if presence.match('presence/muc') or presence.xml.find('{http://jabber.org/protocol/muc#user}x'):
+ return
+ jid = presence['from']
+ contact = roster[jid.bare]
+ if contact is None:
+ # Todo, handle presence coming from contacts not in roster
+ return
+ roster.connected += 1
+ roster.modified()
+ if not logger.log_roster_change(jid.bare, 'got online'):
+ self.information('Unable to write in the log file', 'Error')
+ resource = Resource(jid.full, {
+ 'priority': presence.get_priority() or 0,
+ 'status': presence['status'],
+ 'show': presence['show'],
+ })
+ self.events.trigger('normal_presence', presence, resource)
+ name = contact.name if contact.name else jid.bare
+ self.add_information_message_to_conversation_tab(jid.full, '\x195}%s is \x194}online' % name)
+ if time.time() - self.connection_time > 10:
+ # We do not display messages if we recently logged in
+ if presence['status']:
+ self.information("\x193}%s \x195}is \x194}online\x195} (\x19o%s\x195})" % (name, presence['status']), "Roster")
+ else:
+ self.information("\x193}%s \x195}is \x194}online\x195}" % name, "Roster")
+ self.add_information_message_to_conversation_tab(jid.bare, '\x195}%s is \x194}online' % name)
+ if isinstance(self.current_tab(), tabs.RosterInfoTab):
+ self.refresh_window()
+
+def on_groupchat_presence(self, presence):
+ """
+ Triggered whenever a presence stanza is received from a user in a multi-user chat room.
+ Display the presence on the room window and update the
+ presence information of the concerned user
+ """
+ from_room = presence['from'].bare
+ tab = self.get_tab_by_name(from_room, tabs.MucTab)
+ if tab:
+ self.events.trigger('muc_presence', presence, tab)
+ tab.handle_presence(presence)
+
+
+### Connection-related handlers ###
+
+def on_failed_connection(self, error):
+ """
+ We cannot contact the remote server
+ """
+ self.information("Connection to remote server failed: %s" % (error,), 'Error')
+
+def on_disconnected(self, event):
+ """
+ When we are disconnected from remote server
+ """
+ roster.connected = 0
+ # Stop the ping plugin. It would try to send stanza on regular basis
+ self.xmpp.plugin['xep_0199'].disable_keepalive()
+ roster.modified()
+ for tab in self.get_tabs(tabs.MucTab):
+ tab.disconnect()
+ msg_typ = 'Error' if not self.legitimate_disconnect else 'Info'
+ self.information("Disconnected from server.", msg_typ)
+ if not self.legitimate_disconnect and config.get('auto_reconnect', True):
+ self.information("Auto-reconnecting.", 'Info')
+ self.xmpp.start()
+
+def on_stream_error(self, event):
+ """
+ When we receive a stream error
+ """
+ if event and event['text']:
+ self.information('Stream error: %s' % event['text'], 'Error')
+
+def on_failed_all_auth(self, event):
+ """
+ Authentication failed
+ """
+ self.information("Authentication failed (bad credentials?).",
+ 'Error')
+ self.legitimate_disconnect = True
+
+def on_no_auth(self, event):
+ """
+ Authentication failed (no mech)
+ """
+ self.information("Authentication failed, no login method available.",
+ 'Error')
+ self.legitimate_disconnect = True
+
+def on_connected(self, event):
+ """
+ Remote host responded, but we are not yet authenticated
+ """
+ self.information("Connected to server.", 'Info')
+
+def on_connecting(self, event):
+ """
+ Just before we try to connect to the server
+ """
+ self.legitimate_disconnect = False
+
+def on_session_start(self, event):
+ """
+ Called when we are connected and authenticated
+ """
+ self.connection_time = time.time()
+ if not self.plugins_autoloaded: # Do not reload plugins on reconnection
+ self.autoload_plugins()
+ self.information("Authentication success.", 'Info')
+ self.information("Your JID is %s" % self.xmpp.boundjid.full, 'Info')
+ if not self.xmpp.anon:
+ # request the roster
+ self.xmpp.get_roster()
+ roster.update_contact_groups(self.xmpp.boundjid.bare)
+ # send initial presence
+ if config.get('send_initial_presence'):
+ pres = self.xmpp.make_presence()
+ pres['show'] = self.status.show
+ pres['status'] = self.status.message
+ self.events.trigger('send_normal_presence', pres)
+ pres.send()
+ self.bookmarks.get_local()
+ # join all the available bookmarks. As of yet, this is just the local ones
+ _join_initial_rooms(self, self.bookmarks)
+
+ if config.get('enable_user_nick'):
+ self.xmpp.plugin['xep_0172'].publish_nick(nick=self.own_nick, callback=dumb_callback)
+ asyncio.async(self.xmpp.plugin['xep_0115'].update_caps())
+ # Start the ping's plugin regular event
+ self.xmpp.set_keepalive_values()
+
+### Other handlers ###
+
+def on_status_codes(self, message):
+ """
+ Handle groupchat messages with status codes.
+ Those are received when a room configuration change occurs.
+ """
+ room_from = message['from']
+ tab = self.get_tab_by_name(room_from, tabs.MucTab)
+ status_codes = set([s.attrib['code'] for s in message.findall('{%s}x/{%s}status' % (tabs.NS_MUC_USER, tabs.NS_MUC_USER))])
+ if '101' in status_codes:
+ self.information('Your affiliation in the room %s changed' % room_from, 'Info')
+ elif tab and status_codes:
+ show_unavailable = '102' in status_codes
+ hide_unavailable = '103' in status_codes
+ non_priv = '104' in status_codes
+ logging_on = '170' in status_codes
+ logging_off = '171' in status_codes
+ non_anon = '172' in status_codes
+ semi_anon = '173' in status_codes
+ full_anon = '174' in status_codes
+ modif = False
+ 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 occured.' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ modif = True
+ if show_unavailable:
+ tab.add_message('\x19%(info_col)s}Info: The unavailable members are now shown.' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ elif hide_unavailable:
+ tab.add_message('\x19%(info_col)s}Info: The unavailable members are now hidden.' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ if non_anon:
+ tab.add_message('\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ elif semi_anon:
+ tab.add_message('\x19%(info_col)s}Info: The room is now semi-anonymous. (moderators-only JID)' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ elif full_anon:
+ tab.add_message('\x19%(info_col)s}Info: The room is now fully anonymous.' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ if logging_on:
+ tab.add_message('\x191}Warning: \x19%(info_col)s}This room is publicly logged' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ elif logging_off:
+ tab.add_message('\x19%(info_col)s}Info: This room is not logged anymore.' %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ typ=2)
+ if modif:
+ self.refresh_window()
+
+def on_groupchat_subject(self, message):
+ """
+ Triggered when the topic is changed.
+ """
+ nick_from = message['mucnick']
+ room_from = message.get_mucroom()
+ tab = self.get_tab_by_name(room_from, tabs.MucTab)
+ subject = message['subject']
+ if subject is None or not tab:
+ return
+ if subject != tab.topic:
+ # Do not display the message if the subject did not change or if we
+ # receive an empty topic when joining the room.
+ if nick_from:
+ tab.add_message("\x19%(info_col)s}%(nick)s set the subject to: %(subject)s" %
+ {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT), 'nick':nick_from, 'subject':subject},
+ time=None,
+ typ=2)
+ else:
+ tab.add_message("\x19%(info_col)s}The subject is: %(subject)s" %
+ {'subject':subject, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
+ time=None,
+ typ=2)
+ tab.topic = subject
+ tab.topic_from = nick_from
+ if self.get_tab_by_name(room_from, tabs.MucTab) is self.current_tab():
+ self.refresh_window()
+
+def on_receipt(self, message):
+ """
+ When a delivery receipt is received (XEP-0184)
+ """
+ jid = message['from']
+ msg_id = message['receipt']
+ if not msg_id:
+ return
+
+ conversation = self.get_tab_by_name(jid, tabs.ChatTab)
+ conversation = conversation or self.get_tab_by_name(jid.bare, tabs.ChatTab)
+ if not conversation:
+ return
+
+ try:
+ conversation.ack_message(msg_id, self.xmpp.boundjid)
+ except AckError:
+ log.debug('Error while receiving an ack', exc_info=True)
+
+def on_data_form(self, message):
+ """
+ When a data form is received
+ """
+ self.information('%s' % message)
+
+def on_attention(self, message):
+ """
+ Attention probe received.
+ """
+ jid_from = message['from']
+ self.information('%s requests your attention!' % jid_from, 'Info')
+ for tab in self.tabs:
+ if tab.name == jid_from:
+ tab.state = 'attention'
+ self.refresh_tab_win()
+ return
+ for tab in self.tabs:
+ if tab.name == jid_from.bare:
+ tab.state = 'attention'
+ self.refresh_tab_win()
+ return
+ self.information('%s tab not found.' % jid_from, 'Error')
+
+def room_error(self, error, room_name):
+ """
+ Display the error in the tab
+ """
+ tab = self.get_tab_by_name(room_name, tabs.MucTab)
+ if not tab:
+ 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)
+ 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)
+ if code == '409':
+ if config.get('alternative_nickname') != '':
+ self.command_join('%s/%s'% (tab.name, tab.own_nick+config.get('alternative_nickname')))
+ else:
+ if not tab.joined:
+ tab.add_message('You can join the room with an other nick, by typing "/join /other_nick"', typ=2)
+ self.refresh_window()
+
+def outgoing_stanza(self, stanza):
+ """
+ We are sending a new stanza, write it in the xml buffer if needed.
+ """
+ if self.xml_tab:
+ if PYGMENTS:
+ xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER)
+ poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip()
+ else:
+ poezio_colored = '%s' % stanza
+ 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()
+
+def incoming_stanza(self, stanza):
+ """
+ We are receiving a new stanza, write it in the xml buffer if needed.
+ """
+ if self.xml_tab:
+ if PYGMENTS:
+ xhtml_text = highlight('%s' % stanza, LEXER, FORMATTER)
+ poezio_colored = xhtml.xhtml_to_poezio_colors(xhtml_text, force=True).rstrip('\x19o').strip()
+ else:
+ poezio_colored = '%s' % stanza
+ 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()
+
+def ssl_invalid_chain(self, tb):
+ self.information('The certificate sent by the server is invalid.', 'Error')
+ self.disconnect()
+
+def validate_ssl(self, pem):
+ """
+ Check the server certificate using the slixmpp ssl_cert event
+ """
+ if config.get('ignore_certificate'):
+ return
+ cert = config.get('certificate')
+ # update the cert representation when it uses the old one
+ if cert and not ':' in cert:
+ cert = ':'.join(i + j for i, j in zip(cert[::2], cert[1::2])).upper()
+ config.set_and_save('certificate', cert)
+
+ der = ssl.PEM_cert_to_DER_cert(pem)
+ sha1_digest = sha1(der).hexdigest().upper()
+ sha1_found_cert = ':'.join(i + j for i, j in zip(sha1_digest[::2], sha1_digest[1::2]))
+ sha2_digest = sha512(der).hexdigest().upper()
+ sha2_found_cert = ':'.join(i + j for i, j in zip(sha2_digest[::2], sha2_digest[1::2]))
+ if cert:
+ if sha1_found_cert == cert:
+ log.debug('Cert %s OK', sha1_found_cert)
+ log.debug('Current hash is SHA-1, moving to SHA-2 (%s)',
+ sha2_found_cert)
+ config.set_and_save('certificate', sha2_found_cert)
+ return
+ elif sha2_found_cert == cert:
+ log.debug('Cert %s OK', sha2_found_cert)
+ return
+ else:
+ saved_input = self.current_tab().input
+ log.debug('\nWARNING: CERTIFICATE CHANGED old: %s, new: %s\n', cert, sha2_found_cert)
+ self.information('New certificate found (sha-2 hash:'
+ ' %s)\nPlease validate or abort' % sha2_found_cert,
+ 'Warning')
+ def check_input():
+ self.current_tab().input = saved_input
+ if input.value:
+ self.information('Setting new certificate: old: %s, new: %s' % (cert, sha2_found_cert), 'Info')
+ log.debug('Setting certificate to %s', sha2_found_cert)
+ if not config.silent_set('certificate', sha2_found_cert):
+ self.information('Unable to write in the config file', 'Error')
+ else:
+ self.information('You refused to validate the certificate. You are now disconnected', 'Info')
+ self.disconnect()
+ new_loop.stop()
+ asyncio.set_event_loop(old_loop)
+ input = windows.YesNoInput(text="WARNING! Server certificate has changed, accept? (y/n)", callback=check_input)
+ self.current_tab().input = input
+ input.resize(1, self.current_tab().width, self.current_tab().height-1, 0)
+ input.refresh()
+ self.doupdate()
+ old_loop = asyncio.get_event_loop()
+ new_loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(new_loop)
+ new_loop.add_reader(sys.stdin, self.on_input_readable)
+ curses.beep()
+ new_loop.run_forever()
+ else:
+ log.debug('First time. Setting certificate to %s', sha2_found_cert)
+ if not config.silent_set('certificate', sha2_found_cert):
+ self.information('Unable to write in the config file', 'Error')
+
+def _composing_tab_state(tab, state):
+ """
+ Set a tab state to or from the "composing" state
+ according to the config and the current tab state
+ """
+ if isinstance(tab, tabs.MucTab):
+ values = ('true', 'muc')
+ elif isinstance(tab, tabs.PrivateTab):
+ values = ('true', 'direct', 'private')
+ elif isinstance(tab, tabs.ConversationTab):
+ values = ('true', 'direct', 'conversation')
+ else:
+ return # should not happen
+
+ show = config.get('show_composing_tabs')
+ show = show in values
+
+ if tab.state != 'composing' and state == 'composing':
+ if show:
+ if tabs.STATE_PRIORITY[tab.state] > tabs.STATE_PRIORITY[state]:
+ return
+ tab.save_state()
+ tab.state = 'composing'
+ elif tab.state == 'composing' and state != 'composing':
+ tab.restore_state()
+
+### Ad-hoc commands
+
+def on_next_adhoc_step(self, iq, adhoc_session):
+ status = iq['command']['status']
+ xform = iq.xml.find('{http://jabber.org/protocol/commands}command/{jabber:x:data}x')
+ if xform is not None:
+ form = self.xmpp.plugin['xep_0004'].buildForm(xform)
+ else:
+ form = None
+
+ if status == 'error':
+ return self.information("An error occured while executing the command")
+
+ if status == 'executing':
+ if not form:
+ self.information("Adhoc command step does not contain a data-form. Aborting the execution.", "Error")
+ return self.xmpp.plugin['xep_0050'].cancel_command(adhoc_session)
+ on_validate = self.validate_adhoc_step
+ on_cancel = self.cancel_adhoc_command
+ if status == 'completed':
+ on_validate = lambda form, session: self.close_tab()
+ on_cancel = lambda form, session: self.close_tab()
+
+ # If a form is available, use it, and add the Notes from the
+ # response to it, if any
+ if form:
+ for note in iq['command']['notes']:
+ form.add_field(type='fixed', label=note[1])
+ self.open_new_form(form, on_cancel, on_validate,
+ session=adhoc_session)
+ else: # otherwise, just display an information
+ # message
+ notes = '\n'.join([note[1] for note in iq['command']['notes']])
+ self.information("Adhoc command %s: %s" % (status, notes), "Info")
+
+def on_adhoc_error(self, iq, adhoc_session):
+ self.xmpp.plugin['xep_0050'].terminate_command(adhoc_session)
+ error_message = self.get_error_message(iq)
+ self.information("An error occured while executing the command: %s" % (error_message),
+ 'Error')
+
+def cancel_adhoc_command(self, form, session):
+ self.xmpp.plugin['xep_0050'].cancel_command(session)
+ self.close_tab()
+
+def validate_adhoc_step(self, form, session):
+ session['payload'] = form
+ self.xmpp.plugin['xep_0050'].continue_command(session)
+ self.close_tab()
+
+def terminate_adhoc_command(self, form, session):
+ self.xmpp.plugin['xep_0050'].terminate_command(session)
+ self.close_tab()
diff --git a/poezio/core/structs.py b/poezio/core/structs.py
new file mode 100644
index 00000000..4ce0ef43
--- /dev/null
+++ b/poezio/core/structs.py
@@ -0,0 +1,49 @@
+"""
+Module defining structures useful to the core class and related methods
+"""
+import collections
+
+# http://xmpp.org/extensions/xep-0045.html#errorstatus
+ERROR_AND_STATUS_CODES = {
+ '401': 'A password is required',
+ '403': 'Permission denied',
+ '404': 'The room doesn’t exist',
+ '405': 'Your are not allowed to create a new room',
+ '406': 'A reserved nick must be used',
+ '407': 'You are not in the member list',
+ '409': 'This nickname is already in use or has been reserved',
+ '503': 'The maximum number of users has been reached',
+ }
+
+# http://xmpp.org/extensions/xep-0086.html
+DEPRECATED_ERRORS = {
+ '302': 'Redirect',
+ '400': 'Bad request',
+ '401': 'Not authorized',
+ '402': 'Payment required',
+ '403': 'Forbidden',
+ '404': 'Not found',
+ '405': 'Not allowed',
+ '406': 'Not acceptable',
+ '407': 'Registration required',
+ '408': 'Request timeout',
+ '409': 'Conflict',
+ '500': 'Internal server error',
+ '501': 'Feature not implemented',
+ '502': 'Remote server error',
+ '503': 'Service unavailable',
+ '504': 'Remote server timeout',
+ '510': 'Disconnected',
+}
+
+possible_show = {'available':None,
+ 'chat':'chat',
+ 'away':'away',
+ 'afk':'away',
+ 'dnd':'dnd',
+ 'busy':'dnd',
+ 'xa':'xa'
+ }
+
+Status = collections.namedtuple('Status', 'show message')
+Command = collections.namedtuple('Command', 'func desc comp short usage')
diff --git a/poezio/daemon.py b/poezio/daemon.py
new file mode 100755
index 00000000..6325d8df
--- /dev/null
+++ b/poezio/daemon.py
@@ -0,0 +1,82 @@
+#/usr/bin/env python3
+# Copyright 2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+This file is a standalone program that reads commands on
+stdin and executes them (each line should be a command).
+
+Usage: cat some_fifo | ./daemon.py
+
+Poezio writes commands in the fifo, and this daemon executes them on the
+local machine.
+Note that you should not start this daemon if you do not trust the remote
+machine that is running poezio, since this could make it run any (dangerous)
+command on your local machine.
+"""
+
+import sys
+import threading
+import subprocess
+import shlex
+import logging
+
+from subprocess import DEVNULL
+
+log = logging.getLogger(__name__)
+
+class Executor(threading.Thread):
+ """
+ Just a class to execute commands in a thread. This way, the execution
+ can totally fail, we don’t care, and we can start commands without
+ having to wait for them to return.
+ WARNING: Be careful to properly escape what is untrusted by using
+ pipes.quote (or shlex.quote with python 3.3) for example.
+ """
+ def __init__(self, command, remote=False):
+ threading.Thread.__init__(self)
+ self.command = command
+ self.remote = remote
+ # check for > or >> special case
+ self.filename = None
+ self.redirection_mode = 'w'
+ if len(command) >= 3:
+ if command[-2] in ('>', '>>'):
+ self.filename = command.pop(-1)
+ if command[-1] == '>>':
+ self.redirection_mode = 'a'
+ command.pop(-1)
+
+ def run(self):
+ log.debug('executing %s', self.command)
+ stdout = DEVNULL
+ if self.filename:
+ try:
+ stdout = open(self.filename, self.redirection_mode)
+ except (OSError, IOError):
+ log.error('Could not open redirection file: %s', self.filename, exc_info=True)
+ return
+ try:
+ subprocess.call(self.command, stdout=stdout, stderr=DEVNULL)
+ except:
+ if self.remote:
+ import traceback
+ print(traceback.format_exc())
+ else:
+ log.error('Could not execute %s:', self.command, exc_info=True)
+
+def main():
+ while True:
+ line = sys.stdin.readline()
+ if line == '':
+ break
+ command = shlex.split(line)
+ e = Executor(command, remote=True)
+ e.start()
+
+if __name__ == '__main__':
+ main()
diff --git a/poezio/decorators.py b/poezio/decorators.py
new file mode 100644
index 00000000..c4ea6563
--- /dev/null
+++ b/poezio/decorators.py
@@ -0,0 +1,139 @@
+"""
+Module containing various decorators
+"""
+
+import common
+
+class RefreshWrapper(object):
+ def __init__(self):
+ self.core = None
+
+ def conditional(self, func):
+ """
+ Decorator to refresh the UI if the wrapped function
+ returns True
+ """
+ def wrap(*args, **kwargs):
+ ret = func(*args, **kwargs)
+ if self.core and ret:
+ self.core.refresh_window()
+ return ret
+ return wrap
+
+ def always(self, func):
+ """
+ Decorator that refreshs the UI no matter what after the function
+ """
+ def wrap(*args, **kwargs):
+ ret = func(*args, **kwargs)
+ if self.core:
+ self.core.refresh_window()
+ return ret
+ return wrap
+
+ def update(self, func):
+ """
+ Decorator that only updates the screen
+ """
+ def wrap(*args, **kwargs):
+ ret = func(*args, **kwargs)
+ if self.core:
+ self.core.doupdate()
+ return ret
+ return wrap
+
+refresh_wrapper = RefreshWrapper()
+
+class CommandArgParser(object):
+ """Modify the string argument of the function into a list of strings
+ containing the right number of extracted arguments, or None if we don’t
+ have enough.
+ """
+ @staticmethod
+ def raw(func):
+ """Just call the function with a single string, which is the original string
+ untouched
+ """
+ def wrap(self, args, *a, **kw):
+ return func(self, args, *a, **kw)
+ return wrap
+
+ @staticmethod
+ def ignored(func):
+ """
+ Call the function without any argument
+ """
+ def wrap(self, args, *a, **kw):
+ return func(self, *a, **kw)
+ return wrap
+
+ @staticmethod
+ def quoted(mandatory, optional=0, defaults=[],
+ ignore_trailing_arguments=False):
+
+ """The function receives a list with a number of arguments that is between
+ the numbers `mandatory` and `optional`.
+
+ If the string doesn’t contain at least `mandatory` arguments, we return
+ None because the given arguments are invalid.
+
+ If there are any remaining arguments after `mandatory` and `optional`
+ arguments have been found (and “ignore_trailing_arguments" is not True),
+ we happen them to the last argument of the list.
+
+ An argument is a string (with or without whitespaces) between to quotes
+ ("), or a whitespace separated word (if not inside quotes).
+
+ The argument `defaults` is a list of strings that are used when an
+ optional argument is missing. For example if we accept one optional
+ argument, zero is available but we have one value in the `defaults`
+ list, we use that string inplace. The `defaults` list can only
+ replace missing optional arguments, not mandatory ones. And it
+ should not contain more than `mandatory` values. Also you cannot
+
+ Example:
+ This method needs at least one argument, and accepts up to 3
+ arguments
+
+ >> @command_args_parser.quoted(1, 2, ['default for first arg'], False)
+ >> def f(args):
+ >> print(args)
+
+ >> f('coucou les amis') # We have one mandatory and two optional
+ ['coucou', 'les', 'amis']
+ >> f('"coucou les amis" "PROUT PROUT"') # One mandator and only one optional,
+ # no default for the second
+ ['coucou les amis', 'PROUT PROUT']
+ >> f('') # Not enough args for mandatory number
+ None
+ >> f('"coucou les potes"') # One mandatory, and use the default value
+ # for the first optional
+ ['coucou les potes, 'default for first arg']
+ >> f('"un et demi" deux trois quatre cinq six') # We have three trailing arguments
+ ['un et demi', 'deux', 'trois quatre cinq six']
+
+ """
+ def first(func):
+ def second(self, args, *a, **kw):
+ default_args = defaults
+ args = common.shell_split(args)
+ if len(args) < mandatory:
+ return func(self, None, *a, **kw)
+ res, args = args[:mandatory], args[mandatory:]
+ if optional == -1:
+ opt_args = args[:]
+ else:
+ opt_args = args[:optional]
+
+ if opt_args:
+ res += opt_args
+ args = args[len(opt_args):]
+ default_args = default_args[len(opt_args):]
+ res += default_args
+ if args and res and not ignore_trailing_arguments:
+ res[-1] += " " + " ".join(args)
+ return func(self, res, *a, **kw)
+ return second
+ return first
+
+command_args_parser = CommandArgParser()
diff --git a/poezio/events.py b/poezio/events.py
new file mode 100644
index 00000000..15ef3e35
--- /dev/null
+++ b/poezio/events.py
@@ -0,0 +1,87 @@
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Defines the EventHandler class.
+The list of available events is here:
+http://poezio.eu/doc/en/plugins.html#_poezio_events
+"""
+
+class EventHandler(object):
+ """
+ A class keeping a list of possible events that are triggered
+ by poezio. You (a plugin for example) can add an event handler
+ associated with an event name, and whenever that event is triggered,
+ the callback is called.
+ """
+ def __init__(self):
+ self.events = {
+ 'highlight': [],
+ 'muc_say': [],
+ 'muc_say_after': [],
+ 'conversation_say': [],
+ 'conversation_say_after': [],
+ 'private_say': [],
+ 'private_say_after': [],
+ 'conversation_msg': [],
+ 'private_msg': [],
+ 'muc_msg': [],
+ 'conversation_chatstate': [],
+ 'muc_chatstate': [],
+ 'private_chatstate': [],
+ 'normal_presence': [],
+ 'muc_presence': [],
+ 'muc_join': [],
+ 'joining_muc': [],
+ 'changing_nick': [],
+ 'muc_kick': [],
+ 'muc_nickchange': [],
+ 'muc_ban': [],
+ 'send_normal_presence': [],
+ 'ignored_private': [],
+ 'tab_change': [],
+ }
+
+ def add_event_handler(self, name, callback, position=0):
+ """
+ Add a callback to a given event.
+ Note that if that event name doesn’t exist, it just returns False.
+ If it was successfully added, it returns True
+ position: 0 means insert at the beginning, -1 means end
+ """
+ if name not in self.events:
+ return False
+
+ if position >= 0:
+ self.events[name].insert(position, callback)
+ else:
+ self.events[name].append(callback)
+
+ return True
+
+ def trigger(self, name, *args, **kwargs):
+ """
+ Call all the callbacks associated to the given event name.
+ """
+ callbacks = self.events.get(name, None)
+ if callbacks is None:
+ return
+ for callback in callbacks:
+ callback(*args, **kwargs)
+
+ def del_event_handler(self, name, callback):
+ """
+ Remove the callback from the list of callbacks of the given event
+ """
+ if not name:
+ for event in self.events:
+ while callback in self.events[event]:
+ self.events[event].remove(callback)
+ return True
+ else:
+ if callback in self.events[name]:
+ self.events[name].remove(callback)
+
diff --git a/poezio/fifo.py b/poezio/fifo.py
new file mode 100644
index 00000000..863ef228
--- /dev/null
+++ b/poezio/fifo.py
@@ -0,0 +1,71 @@
+"""
+Defines the Fifo class
+
+This fifo allows simple communication between a remote poezio
+and a local computer, with ssh+cat.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import os
+import threading
+
+class OpenTrick(threading.Thread):
+ """
+ A threaded trick to make the open for writing succeed.
+ A fifo cannot be opened for writing if it has not been
+ yet opened by the other hand for reading.
+ So, we just open the fifo for reading and we do not close
+ it afterwards, because if the other reader disconnects,
+ we will receive a SIGPIPE. And we do not want that.
+
+ (we never read anything from it, obviously)
+ """
+ def __init__(self, path):
+ threading.Thread.__init__(self)
+ self.path = path
+ self.fd = None
+
+ def run(self):
+ self.fd = open(self.path, 'r', encoding='utf-8')
+
+
+class Fifo(object):
+ """
+ Just a simple file handler, writing and reading in a fifo.
+ Mode is either 'r' or 'w', just like the mode for the open()
+ function.
+ """
+ def __init__(self, path, mode):
+ self.trick = None
+ if not os.path.exists(path):
+ os.mkfifo(path)
+ if mode == 'w':
+ self.trick = OpenTrick(path)
+ # that thread will wait until we open it for writing
+ self.trick.start()
+ self.fd = open(path, mode, encoding='utf-8')
+
+ def write(self, data):
+ """
+ Try to write on the fifo. If that fails, this means
+ that nothing has that fifo opened, so the writing is useless,
+ so we just return (and display an error telling that, somewhere).
+ """
+ self.fd.write(data)
+ self.fd.flush()
+
+ def readline(self):
+ "Read a line from the fifo"
+ return self.fd.readline()
+
+ def __del__(self):
+ "Close opened fds"
+ try:
+ self.fd.close()
+ if self.trick:
+ self.trick.fd.close()
+ except:
+ log.error('Unable to close descriptors for the fifo',
+ exc_info=True)
diff --git a/poezio/fixes.py b/poezio/fixes.py
new file mode 100644
index 00000000..3840a093
--- /dev/null
+++ b/poezio/fixes.py
@@ -0,0 +1,97 @@
+"""
+Module used to provide fixes for slixmpp functions not yet fixed
+upstream.
+
+TODO: Check that they are fixed and remove those hacks
+"""
+
+from slixmpp.stanza import Message
+from slixmpp.xmlstream import ET
+
+import logging
+
+log = logging.getLogger(__name__)
+
+def has_identity(xmpp, jid, identity, on_true=None, on_false=None):
+ def _cb(iq):
+ ident = lambda x: x[0]
+ res = identity in map(ident, iq['disco_info']['identities'])
+ if res and on_true is not None:
+ on_true()
+ if not res and on_false is not None:
+ on_false()
+ xmpp.plugin['xep_0030'].get_info(jid=jid, callback=_cb)
+
+def get_version(xmpp, jid, callback=None, **kwargs):
+ def handle_result(res):
+ if res and res['type'] != 'error':
+ ret = res['software_version'].values
+ else:
+ ret = False
+ if callback:
+ callback(ret)
+ return ret
+ iq = xmpp.make_iq_get(ito=jid)
+ iq['query'] = 'jabber:iq:version'
+ result = iq.send(callback=handle_result if callback else None)
+ if not callback:
+ return handle_result(result)
+
+
+def get_room_form(xmpp, room, callback):
+ def _cb(result):
+ if result["type"] == "error":
+ return callback(None)
+ xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x')
+ if xform is None:
+ return callback(None)
+ form = xmpp.plugin['xep_0004'].buildForm(xform)
+ return callback(form)
+
+ iq = xmpp.make_iq_get(ito=room)
+ query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
+ iq.append(query)
+ iq.send(callback=_cb)
+
+def _filter_add_receipt_request(self, stanza):
+ """
+ Auto add receipt requests to outgoing messages, if:
+
+ - ``self.auto_request`` is set to ``True``
+ - The message is not for groupchat
+ - The message does not contain a receipt acknowledgment
+ - The recipient is a bare JID or, if a full JID, one
+ that has the ``urn:xmpp:receipts`` feature enabled
+ - The message has a body
+
+ The disco cache is checked if a full JID is specified in
+ the outgoing message, which may mean a round-trip disco#info
+ delay for the first message sent to the JID if entity caps
+ are not used.
+ """
+
+ if not self.auto_request:
+ return stanza
+
+ if not isinstance(stanza, Message):
+ return stanza
+
+ if stanza['request_receipt']:
+ return stanza
+
+ if not stanza['type'] in self.ack_types:
+ return stanza
+
+ if stanza['receipt']:
+ return stanza
+
+ if not stanza['body']:
+ return stanza
+
+ # hack
+ if stanza['to'].resource and not hasattr(stanza, '_add_receipt'):
+ return stanza
+
+ stanza['request_receipt'] = True
+ return stanza
+
diff --git a/poezio/keyboard.py b/poezio/keyboard.py
new file mode 100755
index 00000000..ccf9e752
--- /dev/null
+++ b/poezio/keyboard.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Functions to interact with the keyboard
+Mainly, read keys entered and return a string (most
+of the time ONE char, but may be longer if it's a keyboard
+shortcut, like ^A, M-a or KEY_RESIZE)
+"""
+
+import curses
+import curses.ascii
+import logging
+log = logging.getLogger(__name__)
+
+# A callback that will handle the next key entered by the user. For
+# example if the user presses Ctrl+j, we set a callbacks, and the
+# next key pressed by the user will be passed to this callback
+# instead of the normal process of executing global keybard
+# shortcuts or inserting text in the current output. The callback
+# is always reset to None afterwards (to resume the normal
+# processing of keys)
+continuation_keys_callback = None
+
+def get_next_byte(s):
+ """
+ Read the next byte of the utf-8 char
+ ncurses seems to return a string of the byte
+ encoded in latin-1. So what we get is NOT what we typed
+ unless we do the conversion…
+ """
+ try:
+ c = s.getkey()
+ except:
+ return (None, None)
+ if len(c) >= 4:
+ return (None, c)
+ return (ord(c), c.encode('latin-1')) # returns a number and a bytes object
+
+def get_char_list(s):
+ ret_list = []
+ while True:
+ try:
+ key = s.get_wch()
+ except curses.error:
+ # No input, this means a timeout occurs.
+ return ret_list
+ except ValueError: # invalid input
+ log.debug('Invalid character entered.')
+ return ret_list
+ # Set to non-blocking. We try to read more bytes. If there are no
+ # more data to read, it will immediately timeout and return with the
+ # data we have so far
+ s.timeout(0)
+ if isinstance(key, int):
+ ret_list.append(curses.keyname(key).decode())
+ else:
+ if curses.ascii.isctrl(key):
+ key = curses.unctrl(key).decode()
+ # Here special cases for alt keys, where we get a ^[ and then a second char
+ if key == '^[':
+ try:
+ part = s.get_wch()
+ if part == '[':
+ # CTRL+arrow and meta+arrow keys have a long format
+ part += s.get_wch() + s.get_wch() + s.get_wch() + s.get_wch()
+ except curses.error:
+ pass
+ except ValueError: # invalid input
+ log.debug('Invalid character entered.')
+ else:
+ key = 'M-%s' % part
+ # and an even more special case for keys like
+ # ctrl+arrows, where we get ^[, then [, then a third
+ # char.
+ if key == 'M-[':
+ try:
+ part = s.get_wch()
+ except curses.error:
+ pass
+ except ValueError:
+ log.debug('Invalid character entered.')
+ else:
+ key = '%s-%s' % (key, part)
+ if key == '\x7f' or key == '\x08':
+ key = '^?'
+ elif key == '\r':
+ key = '^M'
+ ret_list.append(key)
+
+class Keyboard(object):
+ def __init__(self):
+ self.escape = False
+
+ def escape_next_key(self):
+ """
+ The next key pressed by the user should be escaped. e.g. if the user
+ presses ^N, keyboard.get_user_input() will return ["^", "N"] instead
+ of ["^N"]. This will display ^N in the input, instead of
+ interpreting the key binding.
+ """
+ self.escape = True
+
+ def get_user_input(self, s):
+ """
+ Returns a list of all the available characters to read (for example it
+ may contain a whole text if there’s some lag, or the user pasted text,
+ or the user types really really fast). Also it can return None, meaning
+ that it’s time to do some other checks (because this function is
+ blocking, we need to get out of it every now and then even if nothing
+ was entered).
+ """
+ # Disable the timeout
+ s.timeout(-1)
+ ret_list = get_char_list(s)
+ if not ret_list:
+ return ret_list
+ if len(ret_list) != 1:
+ if ret_list[-1] == '^M':
+ ret_list.pop(-1)
+ ret_list = [char if char != '^M' else '^J' for char in ret_list]
+ if self.escape:
+ # Modify the first char of the list into its escaped version (i.e one or more char)
+ key = ret_list.pop(0)
+ for char in key[::-1]:
+ ret_list.insert(0, char)
+ self.escape = False
+ return ret_list
+
+if __name__ == '__main__':
+ import sys
+ keyboard = Keyboard()
+ s = curses.initscr()
+ curses.noecho()
+ curses.cbreak()
+ s.keypad(1)
+ curses.start_color()
+ curses.use_default_colors()
+ curses.init_pair(1, 2, -1)
+ s.attron(curses.A_BOLD | curses.color_pair(1))
+ s.addstr('Type Ctrl-c to close\n')
+ s.attroff(curses.A_BOLD | curses.color_pair(1))
+
+ pressed_chars = []
+ while True:
+
+ try:
+ chars = keyboard.get_user_input(s)
+ for char in chars if chars else '':
+ s.addstr('%s ' % (char))
+ pressed_chars.append(chars)
+
+ except KeyboardInterrupt:
+ break
+ curses.echo()
+ curses.cbreak()
+ curses.curs_set(1)
+ curses.endwin()
+ for char_list in pressed_chars:
+ if char_list:
+ print(' '.join((char for char in char_list)), end=' ')
+ print()
+ sys.exit(0)
diff --git a/poezio/logger.py b/poezio/logger.py
new file mode 100644
index 00000000..7efa8f61
--- /dev/null
+++ b/poezio/logger.py
@@ -0,0 +1,284 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+The logger module that handles logging of the poezio
+conversations and roster changes
+"""
+
+import mmap
+import os
+import re
+from os import makedirs
+from datetime import datetime
+
+import common
+from config import config
+from xhtml import clean_text
+from theming import dump_tuple, get_theme
+
+import logging
+
+log = logging.getLogger(__name__)
+
+from config import LOG_DIR as log_dir
+
+message_log_re = re.compile(r'MR (\d{4})(\d{2})(\d{2})T'
+ r'(\d{2}):(\d{2}):(\d{2})Z '
+ r'(\d+) <([^ ]+)>  (.*)')
+info_log_re = re.compile(r'MI (\d{4})(\d{2})(\d{2})T'
+ r'(\d{2}):(\d{2}):(\d{2})Z '
+ r'(\d+) (.*)')
+
+def parse_message_line(msg):
+ if re.match(message_log_re, msg):
+ return [i for i in re.split(message_log_re, msg) if i]
+ elif re.match(info_log_re, msg):
+ return [i for i in re.split(info_log_re, msg) if i]
+ return False
+
+
+class Logger(object):
+ """
+ Appends things to files. Error/information/warning logs
+ and also log the conversations to logfiles
+ """
+ def __init__(self):
+ self.logfile = config.get('logfile')
+ self.roster_logfile = None
+ # a dict of 'groupchatname': file-object (opened)
+ self.fds = dict()
+
+ def __del__(self):
+ for opened_file in self.fds.values():
+ if opened_file:
+ try:
+ opened_file.close()
+ except: # Can't close? too bad
+ pass
+
+ def reload_all(self):
+ """Close and reload all the file handles (on SIGHUP)"""
+ for opened_file in self.fds.values():
+ if opened_file:
+ opened_file.close()
+ log.debug('All log file handles closed')
+ for room in self.fds:
+ self.fds[room] = self.check_and_create_log_dir(room)
+ log.debug('Log handle for %s re-created', room)
+
+ def check_and_create_log_dir(self, room, open_fd=True):
+ """
+ Check that the directory where we want to log the messages
+ exists. if not, create it
+ """
+ if not config.get_by_tabname('use_log', room):
+ return
+ try:
+ makedirs(log_dir)
+ except OSError as e:
+ if e.errno != 17: # file exists
+ log.error('Unable to create the log dir', exc_info=True)
+ except:
+ log.error('Unable to create the log dir', exc_info=True)
+ return
+ if not open_fd:
+ return
+ try:
+ fd = open(os.path.join(log_dir, room), 'a')
+ self.fds[room] = fd
+ return fd
+ except IOError:
+ log.error('Unable to open the log file (%s)',
+ os.path.join(log_dir, room),
+ exc_info=True)
+
+ def get_logs(self, jid, nb=10):
+ """
+ Get the nb last messages from the log history for the given jid.
+ Note that a message may be more than one line in these files, so
+ this function is a little bit more complicated than “read the last
+ nb lines”.
+ """
+ if config.get_by_tabname('load_log', jid) <= 0:
+ return
+
+ if not config.get_by_tabname('use_log', jid):
+ return
+
+ if nb <= 0:
+ return
+
+ self.check_and_create_log_dir(jid, open_fd=False)
+
+ try:
+ fd = open(os.path.join(log_dir, jid), 'rb')
+ except FileNotFoundError:
+ log.info('Non-existing log file (%s)',
+ os.path.join(log_dir, jid),
+ exc_info=True)
+ return
+ except OSError:
+ log.error('Unable to open the log file (%s)',
+ os.path.join(log_dir, jid),
+ exc_info=True)
+ return
+ if not fd:
+ return
+
+ # read the needed data from the file, we just search nb messages by
+ # searching "\nM" nb times from the end of the file. We use mmap to
+ # do that efficiently, instead of seek()s and read()s which are costly.
+ with fd:
+ try:
+ m = mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ)
+ except Exception: # file probably empty
+ log.error('Unable to mmap the log file for (%s)',
+ os.path.join(log_dir, jid),
+ exc_info=True)
+ return
+ pos = m.rfind(b"\nM") # start of messages begin with MI or MR,
+ # after a \n
+ # number of message found so far
+ count = 0
+ while pos != -1 and count < nb-1:
+ count += 1
+ pos = m.rfind(b"\nM", 0, pos)
+ if pos == -1: # If we don't have enough lines in the file
+ pos = 1 # 1, because we do -1 just on the next line
+ # to get 0 (start of the file)
+ lines = m[pos-1:].decode(errors='replace').splitlines()
+
+ messages = []
+ color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)
+
+ # now convert that data into actual Message objects
+ idx = 0
+ while idx < len(lines):
+ if lines[idx].startswith(' '): # should not happen ; skip
+ idx += 1
+ log.debug('fail?')
+ continue
+ tup = parse_message_line(lines[idx])
+ idx += 1
+ if not tup or 7 > len(tup) > 10: # skip
+ log.debug('format? %s', tup)
+ continue
+ time = [int(i) for index, i in enumerate(tup) if index < 6]
+ message = {'lines': [],
+ 'history': True,
+ 'time': common.get_local_time(datetime(*time))}
+ size = int(tup[6])
+ if len(tup) == 8: #info line
+ message['lines'].append(color+tup[7])
+ else: # message line
+ message['nickname'] = tup[7]
+ message['lines'].append(color+tup[8])
+ while size != 0 and idx < len(lines):
+ message['lines'].append(lines[idx][1:])
+ size -= 1
+ idx += 1
+ message['txt'] = '\n'.join(message['lines'])
+ del message['lines']
+ messages.append(message)
+
+ return messages
+
+ def log_message(self, jid, nick, msg, date=None, typ=1):
+ """
+ log the message in the appropriate jid's file
+ type:
+ 0 = Don’t log
+ 1 = Message
+ 2 = Status/whatever
+ """
+ if not typ:
+ return True
+
+ jid = str(jid).replace('/', '\\')
+ if not config.get_by_tabname('use_log', jid):
+ return True
+ if jid in self.fds.keys():
+ fd = self.fds[jid]
+ else:
+ fd = self.check_and_create_log_dir(jid)
+ if not fd:
+ return True
+ try:
+ msg = clean_text(msg)
+ if date is None:
+ str_time = common.get_utc_time().strftime('%Y%m%dT%H:%M:%SZ')
+ else:
+ str_time = common.get_utc_time(date).strftime('%Y%m%dT%H:%M:%SZ')
+ if typ == 1:
+ prefix = 'MR'
+ else:
+ prefix = 'MI'
+ lines = msg.split('\n')
+ first_line = lines.pop(0)
+ nb_lines = str(len(lines)).zfill(3)
+
+ if nick:
+ nick = '<' + nick + '>'
+ fd.write(' '.join((prefix, str_time, nb_lines, nick, ' '+first_line, '\n')))
+ else:
+ fd.write(' '.join((prefix, str_time, nb_lines, first_line, '\n')))
+ for line in lines:
+ fd.write(' %s\n' % line)
+ except:
+ log.error('Unable to write in the log file (%s)',
+ os.path.join(log_dir, jid),
+ exc_info=True)
+ return False
+ else:
+ try:
+ fd.flush() # TODO do something better here?
+ except:
+ log.error('Unable to flush the log file (%s)',
+ os.path.join(log_dir, jid),
+ exc_info=True)
+ return False
+ return True
+
+ def log_roster_change(self, jid, message):
+ """
+ Log a roster change
+ """
+ if not config.get_by_tabname('use_log', jid):
+ return True
+ self.check_and_create_log_dir('', open_fd=False)
+ if not self.roster_logfile:
+ try:
+ self.roster_logfile = open(os.path.join(log_dir, 'roster.log'), 'a')
+ except IOError:
+ log.error('Unable to create the log file (%s)',
+ os.path.join(log_dir, 'roster.log'),
+ exc_info=True)
+ return False
+ try:
+ str_time = common.get_utc_time().strftime('%Y%m%dT%H:%M:%SZ')
+ message = clean_text(message)
+ lines = message.split('\n')
+ first_line = lines.pop(0)
+ nb_lines = str(len(lines)).zfill(3)
+ self.roster_logfile.write('MI %s %s %s %s\n' % (str_time, nb_lines, jid, first_line))
+ for line in lines:
+ self.roster_logfile.write(' %s\n' % line)
+ self.roster_logfile.flush()
+ except:
+ log.error('Unable to write in the log file (%s)',
+ os.path.join(log_dir, 'roster.log'),
+ exc_info=True)
+ return False
+ return True
+
+def create_logger():
+ "Create the global logger object"
+ global logger
+ logger = Logger()
+
+logger = None
diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py
new file mode 100644
index 00000000..b7b12305
--- /dev/null
+++ b/poezio/multiuserchat.py
@@ -0,0 +1,196 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Implementation of the XEP-0045: Multi-User Chat.
+Add some facilities that are not available on the XEP_0045
+slix plugin
+"""
+
+from xml.etree import cElementTree as ET
+
+from common import safeJID
+import logging
+log = logging.getLogger(__name__)
+
+NS_MUC_ADMIN = 'http://jabber.org/protocol/muc#admin'
+NS_MUC_OWNER = 'http://jabber.org/protocol/muc#owner'
+
+
+def destroy_room(xmpp, room, reason='', altroom=''):
+ """
+ destroy a room
+ """
+ room = safeJID(room)
+ if not room:
+ return False
+ iq = xmpp.make_iq_set()
+ iq['to'] = room
+ query = ET.Element('{%s}query' % NS_MUC_OWNER)
+ destroy = ET.Element('{%s}destroy' % NS_MUC_OWNER)
+ if altroom:
+ destroy.attrib['jid'] = altroom
+ if reason:
+ xreason = ET.Element('{%s}reason' % NS_MUC_OWNER)
+ xreason.text = reason
+ destroy.append(xreason)
+ query.append(destroy)
+ iq.append(query)
+ def callback(iq):
+ if not iq or iq['type'] == 'error':
+ xmpp.core.information('Unable to destroy room %s' % room,
+ 'Info')
+ else:
+ xmpp.core.information('Room %s destroyed' % room, 'Info')
+ iq.send(callback=callback)
+ return True
+
+def send_private_message(xmpp, jid, line):
+ """
+ Send a private message
+ """
+ jid = safeJID(jid)
+ xmpp.send_message(mto=jid, mbody=line, mtype='chat')
+
+def send_groupchat_message(xmpp, jid, line):
+ """
+ Send a message to the groupchat
+ """
+ jid = safeJID(jid)
+ xmpp.send_message(mto=jid, mbody=line, mtype='groupchat')
+
+def change_show(xmpp, jid, own_nick, show, status):
+ """
+ Change our 'Show'
+ """
+ jid = safeJID(jid)
+ pres = xmpp.make_presence(pto='%s/%s' % (jid, own_nick))
+ if show: # if show is None, don't put a <show /> tag. It means "available"
+ pres['type'] = show
+ if status:
+ pres['status'] = status
+ pres.send()
+
+def change_subject(xmpp, jid, subject):
+ """
+ Change the room subject
+ """
+ jid = safeJID(jid)
+ msg = xmpp.make_message(jid)
+ msg['type'] = 'groupchat'
+ msg['subject'] = subject
+ msg.send()
+
+def change_nick(core, jid, nick, status=None, show=None):
+ """
+ Change our own nick in a room
+ """
+ xmpp = core.xmpp
+ presence = xmpp.make_presence(pshow=show, pstatus=status, pto=safeJID('%s/%s' % (jid, nick)))
+ core.events.trigger('changing_nick', presence)
+ presence.send()
+
+def join_groupchat(core, jid, nick, passwd='', status=None, show=None, seconds=None):
+ xmpp = core.xmpp
+ stanza = xmpp.make_presence(pto='%s/%s' % (jid, nick), pstatus=status, pshow=show)
+ x = ET.Element('{http://jabber.org/protocol/muc}x')
+ if passwd:
+ passelement = ET.Element('password')
+ passelement.text = passwd
+ x.append(passelement)
+ if seconds is not None:
+ history = ET.Element('{http://jabber.org/protocol/muc}history')
+ history.attrib['seconds'] = str(seconds)
+ x.append(history)
+ stanza.append(x)
+ core.events.trigger('joining_muc', stanza)
+ to = stanza["to"]
+ stanza.send()
+ xmpp.plugin['xep_0045'].rooms[jid] = {}
+ xmpp.plugin['xep_0045'].ourNicks[jid] = to.resource
+
+def leave_groupchat(xmpp, jid, own_nick, msg):
+ """
+ Leave the groupchat
+ """
+ jid = safeJID(jid)
+ try:
+ xmpp.plugin['xep_0045'].leaveMUC(jid, own_nick, msg)
+ except KeyError:
+ log.debug("muc.leave_groupchat: could not leave the room %s",
+ jid, exc_info=True)
+
+def set_user_role(xmpp, jid, nick, reason, role, callback=None):
+ """
+ (try to) Set the role of a MUC user
+ (role = 'none': eject user)
+ """
+ jid = safeJID(jid)
+ iq = xmpp.make_iq_set()
+ query = ET.Element('{%s}query' % NS_MUC_ADMIN)
+ item = ET.Element('{%s}item' % NS_MUC_ADMIN, {'nick':nick, 'role':role})
+ if reason:
+ reason_el = ET.Element('{%s}reason' % NS_MUC_ADMIN)
+ reason_el.text = reason
+ item.append(reason_el)
+ query.append(item)
+ iq.append(query)
+ iq['to'] = jid
+ if callback:
+ return iq.send(callback=callback)
+ try:
+ return iq.send()
+ except Exception as e:
+ return e.iq
+
+def set_user_affiliation(xmpp, muc_jid, affiliation, nick=None, jid=None, reason=None, callback=None):
+ """
+ (try to) Set the affiliation of a MUC user
+ """
+ muc_jid = safeJID(muc_jid)
+ query = ET.Element('{http://jabber.org/protocol/muc#admin}query')
+ if nick:
+ item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'nick':nick})
+ else:
+ item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'jid':str(jid)})
+
+ if reason:
+ reason_item = ET.Element('{http://jabber.org/protocol/muc#admin}reason')
+ reason_item.text = reason
+ item.append(reason_item)
+
+ query.append(item)
+ iq = xmpp.make_iq_set(query)
+ iq['to'] = muc_jid
+ if callback:
+ return iq.send(callback=callback)
+ try:
+ return xmpp.plugin['xep_0045'].setAffiliation(str(muc_jid), str(jid) if jid else None, nick, affiliation)
+ except:
+ import traceback
+ log.debug('Error setting the affiliation: %s', traceback.format_exc())
+ return False
+
+def cancel_config(xmpp, room):
+ query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
+ x = ET.Element('{jabber:x:data}x', type='cancel')
+ query.append(x)
+ iq = xmpp.make_iq_set(query)
+ iq['to'] = room
+ iq.send()
+
+def configure_room(xmpp, room, form):
+ if form is None:
+ return
+ iq = xmpp.make_iq_set()
+ iq['to'] = room
+ query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
+ form = form.getXML('submit')
+ query.append(form)
+ iq.append(query)
+ iq.send()
+
diff --git a/poezio/pep.py b/poezio/pep.py
new file mode 100644
index 00000000..0f7a1ced
--- /dev/null
+++ b/poezio/pep.py
@@ -0,0 +1,221 @@
+"""
+Collection of mappings for PEP moods/activities
+extracted directly from the XEP
+"""
+
+MOODS = {
+ 'afraid': 'Afraid',
+ 'amazed': 'Amazed',
+ 'angry': 'Angry',
+ 'amorous': 'Amorous',
+ 'annoyed': 'Annoyed',
+ 'anxious': 'Anxious',
+ 'aroused': 'Aroused',
+ 'ashamed': 'Ashamed',
+ 'bored': 'Bored',
+ 'brave': 'Brave',
+ 'calm': 'Calm',
+ 'cautious': 'Cautious',
+ 'cold': 'Cold',
+ 'confident': 'Confident',
+ 'confused': 'Confused',
+ 'contemplative': 'Contemplative',
+ 'contented': 'Contented',
+ 'cranky': 'Cranky',
+ 'crazy': 'Crazy',
+ 'creative': 'Creative',
+ 'curious': 'Curious',
+ 'dejected': 'Dejected',
+ 'depressed': 'Depressed',
+ 'disappointed': 'Disappointed',
+ 'disgusted': 'Disgusted',
+ 'dismayed': 'Dismayed',
+ 'distracted': 'Distracted',
+ 'embarrassed': 'Embarrassed',
+ 'envious': 'Envious',
+ 'excited': 'Excited',
+ 'flirtatious': 'Flirtatious',
+ 'frustrated': 'Frustrated',
+ 'grumpy': 'Grumpy',
+ 'guilty': 'Guilty',
+ 'happy': 'Happy',
+ 'hopeful': 'Hopeful',
+ 'hot': 'Hot',
+ 'humbled': 'Humbled',
+ 'humiliated': 'Humiliated',
+ 'hungry': 'Hungry',
+ 'hurt': 'Hurt',
+ 'impressed': 'Impressed',
+ 'in_awe': 'In awe',
+ 'in_love': 'In love',
+ 'indignant': 'Indignant',
+ 'interested': 'Interested',
+ 'intoxicated': 'Intoxicated',
+ 'invincible': 'Invincible',
+ 'jealous': 'Jealous',
+ 'lonely': 'Lonely',
+ 'lucky': 'Lucky',
+ 'mean': 'Mean',
+ 'moody': 'Moody',
+ 'nervous': 'Nervous',
+ 'neutral': 'Neutral',
+ 'offended': 'Offended',
+ 'outraged': 'Outraged',
+ 'playful': 'Playful',
+ 'proud': 'Proud',
+ 'relaxed': 'Relaxed',
+ 'relieved': 'Relieved',
+ 'remorseful': 'Remorseful',
+ 'restless': 'Restless',
+ 'sad': 'Sad',
+ 'sarcastic': 'Sarcastic',
+ 'serious': 'Serious',
+ 'shocked': 'Shocked',
+ 'shy': 'Shy',
+ 'sick': 'Sick',
+ 'sleepy': 'Sleepy',
+ 'spontaneous': 'Spontaneous',
+ 'stressed': 'Stressed',
+ 'strong': 'Strong',
+ 'surprised': 'Surprised',
+ 'thankful': 'Thankful',
+ 'thirsty': 'Thirsty',
+ 'tired': 'Tired',
+ 'undefined': 'Undefined',
+ 'weak': 'Weak',
+ 'worried': 'Worried'
+}
+
+
+
+
+ACTIVITIES = {
+ 'doing_chores': {
+ 'category': 'Doing_chores',
+
+ 'buying_groceries': 'Buying groceries',
+ 'cleaning': 'Cleaning',
+ 'cooking': 'Cooking',
+ 'doing_maintenance': 'Doing maintenance',
+ 'doing_the_dishes': 'Doing the dishes',
+ 'doing_the_laundry': 'Doing the laundry',
+ 'gardening': 'Gardening',
+ 'running_an_errand': 'Running an errand',
+ 'walking_the_dog': 'Walking the dog',
+ 'other': 'Other',
+ },
+ 'drinking': {
+ 'category': 'Drinking',
+
+ 'having_a_beer': 'Having a beer',
+ 'having_coffee': 'Having coffee',
+ 'having_tea': 'Having tea',
+ 'other': 'Other',
+ },
+ 'eating': {
+ 'category':'Eating',
+
+ 'having_breakfast': 'Having breakfast',
+ 'having_a_snack': 'Having a snack',
+ 'having_dinner': 'Having dinner',
+ 'having_lunch': 'Having lunch',
+ 'other': 'Other',
+ },
+ 'exercising': {
+ 'category': 'Exercising',
+
+ 'cycling': 'Cycling',
+ 'dancing': 'Dancing',
+ 'hiking': 'Hiking',
+ 'jogging': 'Jogging',
+ 'playing_sports': 'Playing sports',
+ 'running': 'Running',
+ 'skiing': 'Skiing',
+ 'swimming': 'Swimming',
+ 'working_out': 'Working out',
+ 'other': 'Other',
+ },
+ 'grooming': {
+ 'category': 'Grooming',
+
+ 'at_the_spa': 'At the spa',
+ 'brushing_teeth': 'Brushing teeth',
+ 'getting_a_haircut': 'Getting a haircut',
+ 'shaving': 'Shaving',
+ 'taking_a_bath': 'Taking a bath',
+ 'taking_a_shower': 'Taking a shower',
+ 'other': 'Other',
+ },
+ 'having_appointment': {
+ 'category': 'Having appointment',
+
+ 'other': 'Other',
+ },
+ 'inactive': {
+ 'category': 'Inactive',
+
+ 'day_off': 'Day_off',
+ 'hanging_out': 'Hanging out',
+ 'hiding': 'Hiding',
+ 'on_vacation': 'On vacation',
+ 'praying': 'Praying',
+ 'scheduled_holiday': 'Scheduled holiday',
+ 'sleeping': 'Sleeping',
+ 'thinking': 'Thinking',
+ 'other': 'Other',
+ },
+ 'relaxing': {
+ 'category': 'Relaxing',
+
+ 'fishing': 'Fishing',
+ 'gaming': 'Gaming',
+ 'going_out': 'Going out',
+ 'partying': 'Partying',
+ 'reading': 'Reading',
+ 'rehearsing': 'Rehearsing',
+ 'shopping': 'Shopping',
+ 'smoking': 'Smoking',
+ 'socializing': 'Socializing',
+ 'sunbathing': 'Sunbathing',
+ 'watching_a_movie': 'Watching a movie',
+ 'watching_tv': 'Watching tv',
+ 'other': 'Other',
+ },
+ 'talking': {
+ 'category': 'Talking',
+
+ 'in_real_life': 'In real life',
+ 'on_the_phone': 'On the phone',
+ 'on_video_phone': 'On video phone',
+ 'other': 'Other',
+ },
+ 'traveling': {
+ 'category': 'Traveling',
+
+ 'commuting': 'Commuting',
+ 'driving': 'Driving',
+ 'in_a_car': 'In a car',
+ 'on_a_bus': 'On a bus',
+ 'on_a_plane': 'On a plane',
+ 'on_a_train': 'On a train',
+ 'on_a_trip': 'On a trip',
+ 'walking': 'Walking',
+ 'cycling': 'Cycling',
+ 'other': 'Other',
+ },
+ 'undefined': {
+ 'category': 'Undefined',
+
+ 'other': 'Other',
+ },
+ 'working': {
+ 'category': 'Working',
+
+ 'coding': 'Coding',
+ 'in_a_meeting': 'In a meeting',
+ 'writing': 'Writing',
+ 'studying': 'Studying',
+ 'other': 'Other',
+ }
+ }
+
diff --git a/poezio/plugin.py b/poezio/plugin.py
new file mode 100644
index 00000000..bf30c981
--- /dev/null
+++ b/poezio/plugin.py
@@ -0,0 +1,485 @@
+"""
+Define the PluginConfig and Plugin classes, plus the SafetyMetaclass.
+These are used in the plugin system added in poezio 0.7.5
+(see plugin_manager.py)
+"""
+import os
+from functools import partial
+from configparser import RawConfigParser
+from timed_events import TimedEvent, DelayedEvent
+import config
+import inspect
+import traceback
+import logging
+log = logging.getLogger(__name__)
+
+class PluginConfig(config.Config):
+ """
+ Plugin configuration object.
+ They are accessible inside the plugin with self.config
+ and behave like the core Config object.
+ """
+ def __init__(self, filename, module_name, default=None):
+ config.Config.__init__(self, filename, default=default)
+ self.module_name = module_name
+ self.read()
+
+ def get(self, option, default=None, section=None):
+ if not section:
+ section = self.module_name
+ return config.Config.get(self, option, default, section)
+
+ def set(self, option, default, section=None):
+ if not section:
+ section = self.module_name
+ return config.Config.set_and_save(self, option, default, section)
+
+ def remove(self, option, section=None):
+ if not section:
+ section = self.module_name
+ return config.Config.remove_and_save(self, option, section)
+
+ def read(self):
+ """Read the config file"""
+ RawConfigParser.read(self, self.file_name)
+ if not self.has_section(self.module_name):
+ self.add_section(self.module_name)
+
+ def options(self, section=None):
+ """
+ Return the options of the section
+ If no section is given, it defaults to the plugin name.
+ """
+ if not section:
+ section = self.module_name
+ if not self.has_section(section):
+ self.add_section(section)
+ return config.Config.options(self, section)
+
+ def write(self):
+ """Write the config to the disk"""
+ try:
+ fp = open(self.file_name, 'w')
+ RawConfigParser.write(self, fp)
+ fp.close()
+ return True
+ except IOError:
+ return False
+
+
+class SafetyMetaclass(type):
+ # A hack
+ core = None
+
+ @staticmethod
+ def safe_func(f):
+ def helper(*args, **kwargs):
+ try:
+ return f(*args, **kwargs)
+ except:
+ if inspect.stack()[1][1] == inspect.getfile(f):
+ raise
+ elif SafetyMetaclass.core:
+ log.error('Error in a plugin', exc_info=True)
+ SafetyMetaclass.core.information(traceback.format_exc())
+ return None
+ return helper
+
+ def __new__(meta, name, bases, class_dict):
+ for k, v in class_dict.items():
+ if inspect.isfunction(v):
+ if k != '__init__' and k != 'init':
+ class_dict[k] = SafetyMetaclass.safe_func(v)
+ return type.__new__(meta, name, bases, class_dict)
+
+class PluginWrap(object):
+ """
+ A wrapper to implicitly pass the module name to PluginAPI
+ """
+ def __init__(self, api, module):
+ self.api = api
+ self.module = module
+
+ def __getattribute__(self, name):
+ api = object.__getattribute__(self, 'api')
+ module = object.__getattribute__(self, 'module')
+ return partial(getattr(api, name), module)
+
+class PluginAPI(object):
+ """
+ The public API exposed to the plugins.
+ Its goal is to limit the use of the raw Core object
+ as much as possible.
+ """
+
+ def __init__(self, core, plugin_manager):
+ self.core = core
+ self.plugin_manager = plugin_manager
+
+ def __getitem__(self, value):
+ return PluginWrap(self, value)
+
+ def send_message(self, _, *args, **kwargs):
+ """
+ Send a message to the current tab.
+
+ :param str msg: The message to send.
+ """
+ return self.core.send_message(*args, **kwargs)
+
+ def get_conversation_messages(self, _, *args, **kwargs):
+ """
+ Get all the Messages of the current Tab.
+
+ :returns: The list of :py:class:`text_buffer.Message` objects.
+ :returns: None if the Tab does not inherit from ChatTab.
+ :rtype: :py:class:`list`
+ """
+ return self.core.get_conversation_messages()
+
+ def add_timed_event(self, _, *args, **kwargs):
+ """
+ Schedule a timed event.
+
+ :param timed_events.TimedEvent event: The timed event to schedule.
+ """
+ return self.core.add_timed_event(*args, **kwargs)
+
+ def remove_timed_event(self, _, *args, **kwargs):
+ """
+ Unschedule a timed event.
+
+ :param timed_events.TimedEvent event: The event to unschedule.
+ """
+ return self.core.remove_timed_event(*args, **kwargs)
+
+ def create_timed_event(self, _, *args, **kwargs):
+ """
+ Create a timed event, but do not schedule it;
+ :py:func:`~PluginAPI.add_timed_event` must be used for that.
+
+ :param datetime.datetime date: The time at which the handler must be executed
+ :param function callback: The handler that will be executed
+ :param \*args: Optional arguments passed to the handler.
+ :return: The created event.
+ :rtype: :py:class:`timed_events.TimedEvent`
+ """
+ return TimedEvent(*args, **kwargs)
+
+ def create_delayed_event(self, _, *args, **kwargs):
+ """
+ Create a delayed event, but do not schedule it;
+ :py:func:`~PluginAPI.add_timed_event` must be used for that.
+
+ A delayed event is a timed event with a delay from the time
+ this function is called (instead of a datetime).
+
+ :param int delay: The number of seconds to schedule the execution
+ :param function callback: The handler that will be executed
+ :param \*args: Optional arguments passed to the handler.
+ :return: The created event.
+ :rtype: :py:class:`timed_events.DelayedEvent`
+ """
+ return DelayedEvent(*args, **kwargs)
+
+ def information(self, _, *args, **kwargs):
+ """
+ Display a new message in the information buffer.
+
+ :param str msg: The message to display.
+ :param str typ: The message type (e.g. Info, Error…)
+ """
+ return self.core.information(*args, **kwargs)
+
+ def current_tab(self, _):
+ """
+ Get the current Tab.
+
+ :returns: The current tab.
+ """
+ return self.core.current_tab()
+
+ def get_status(self, _):
+ """
+ Get the current user global status.
+
+ :returns Status: The current status.
+ """
+ return self.core.get_status()
+
+ def run_command(self, _, *args, **kwargs):
+ """
+ Run a command from the current tab.
+ (a command starts with a /, if not, it’s a message)
+
+ :param str line: The command to run.
+ """
+ return self.core.current_tab().execute_command(*args, **kwargs)
+
+ def all_tabs(self, _):
+ """
+ Return a list of all opened tabs
+
+ :returns list: The list of tabs.
+ """
+ return self.core.tabs
+
+ def add_command(self, module, *args, **kwargs):
+ """
+ Add a global command.
+
+ :param str name: The name of the command (/name)
+ :param function handler: The function called when the command is run.
+ :param str help: The complete help for that command.
+ :param str short: A short description of the command.
+ :param function completion: The completion function for that command
+ (optional)
+ :param str usage: A string showing the required and optional args
+ of the command. Optional args should be surrounded by []
+ and mandatory args should be surrounded by <>.
+
+ Example string: "<server> [port]"
+
+ :raises Exception: If the command already exists.
+ """
+ return self.plugin_manager.add_command(module, *args, **kwargs)
+
+ def del_command(self, module, *args, **kwargs):
+ """
+ Remove a global command.
+
+ :param str name: The name of the command to remove.
+ That command _must_ have been added by the same plugin
+ """
+ return self.plugin_manager.del_command(module, *args, **kwargs)
+
+ def add_key(self, module, *args, **kwargs):
+ """
+ Associate a global binding to a handler.
+
+ :param str key: The curses representation of the binding.
+ :param function handler: The function called when the binding is pressed.
+
+ :raise Exception: If the binding is already present.
+ """
+ return self.plugin_manager.add_key(module, *args, **kwargs)
+
+ def del_key(self, module, *args, **kwargs):
+ """
+ Remove a global binding.
+
+ :param str key: The binding to remove.
+ """
+ return self.plugin_manager.del_key(module, *args, **kwargs)
+
+ def add_tab_key(self, module, *args, **kwargs):
+ """
+ Associate a binding to a handler, but only for a certain tab type.
+
+ :param Tab tab_type: The type of tab to target.
+ :param str key: The binding to add.
+ :param function handler: The function called when the binding is pressed
+ """
+ return self.plugin_manager.add_tab_key(module, *args, **kwargs)
+
+ def del_tab_key(self, module, *args, **kwargs):
+ """
+ Remove a binding added with add_tab_key
+
+ :param tabs.Tab tab_type: The type of tab to target.
+ :param str key: The binding to remove.
+ """
+ return self.plugin_manager.del_tab_key(module, *args, **kwargs)
+
+ def add_tab_command(self, module, *args, **kwargs):
+ """
+ Add a command to only one type of tab.
+
+ :param tabs.Tab tab_type: The type of Tab to target.
+ :param str name: The name of the command (/name)
+ :param function handler: The function called when the command is run.
+ :param str help: The complete help for that command.
+ :param str short: A short description of the command.
+ :param function completion: The completion function for that command
+ (optional)
+ :param str usage: A string showing the required and optional args
+ of the command. Optional args should be surrounded by []
+ and mandatory args should be surrounded by <>.
+
+ Example string: "<server> [port]"
+
+ :raise Exception: If the command already exists.
+ """
+ return self.plugin_manager.add_tab_command(module, *args, **kwargs)
+
+ def del_tab_command(self, module, *args, **kwargs):
+ """
+ Remove a tab-specific command.
+
+ :param tabs.Tab tab_type: The type of tab to target.
+ :param str name: The name of the command to remove.
+ That command _must_ have been added by the same plugin
+ """
+ return self.plugin_manager.del_tab_command(module, *args, **kwargs)
+
+ def add_event_handler(self, module, *args, **kwargs):
+ """
+ Add an event handler for a poezio event.
+
+ :param str event_name: The event name.
+ :param function handler: The handler function.
+ :param int position: The position of that handler in the handler list.
+ This is useful for plugins like GPG or OTR, which must be the last
+ function called on the text.
+ Defaults to 0.
+
+ A complete list of those events can be found at
+ https://doc.poez.io/dev/events.html
+ """
+ return self.plugin_manager.add_event_handler(module, *args, **kwargs)
+
+ def del_event_handler(self, module, *args, **kwargs):
+ """
+ Remove a handler for a poezio event.
+
+ :param str event_name: The name of the targeted event.
+ :param function handler: The function to remove from the handlers.
+ """
+ return self.plugin_manager.del_event_handler(module, *args, **kwargs)
+
+ def add_slix_event_handler(self, module, event_name, handler):
+ """
+ Add an event handler for a slixmpp event.
+
+ :param str event_name: The event name.
+ :param function handler: The handler function.
+
+ A list of the slixmpp events can be found here
+ http://sleekxmpp.com/event_index.html
+ """
+ self.core.xmpp.add_event_handler(event_name, handler)
+
+ def del_slix_event_handler(self, module, event_name, handler):
+ """
+ Remove a handler for a slixmpp event
+
+ :param str event_name: The name of the targeted event.
+ :param function handler: The function to remove from the handlers.
+ """
+ self.core.xmpp.del_event_handler(event_name, handler)
+
+class BasePlugin(object, metaclass=SafetyMetaclass):
+ """
+ Class that all plugins derive from.
+ """
+
+ default_config = None
+
+ def __init__(self, plugin_api, core, plugins_conf_dir):
+ self.core = core
+ # More hack; luckily we'll never have more than one core object
+ SafetyMetaclass.core = core
+ conf = os.path.join(plugins_conf_dir, self.__module__+'.cfg')
+ try:
+ self.config = PluginConfig(conf, self.__module__,
+ default=self.default_config)
+ except Exception:
+ log.debug('Error while creating the plugin config', exc_info=True)
+ self.config = PluginConfig(conf, self.__module__)
+ self._api = plugin_api[self.name]
+ self.init()
+
+ @property
+ def name(self):
+ """
+ Get the name (module name) of the plugin.
+ """
+ return self.__module__
+
+ @property
+ def api(self):
+ return self._api
+
+ def init(self):
+ """
+ Method called at the creation of the plugin.
+
+ Do not overwrite __init__ and use this instead.
+ """
+ pass
+
+ def cleanup(self):
+ """
+ Called when the plugin is unloaded.
+
+ Overwrite this if you want to erase or save things before the plugin is disabled.
+ """
+ pass
+
+ def unload(self):
+ self.cleanup()
+
+ def add_command(self, name, handler, help, completion=None, short='', usage=''):
+ """
+ Add a global command.
+ You cannot overwrite the existing commands.
+ """
+ return self.api.add_command(name, handler, help,
+ completion=completion, short=short, usage=usage)
+
+ def del_command(self, name):
+ """
+ Remove a global command.
+ This only works if the command was added by the plugin
+ """
+ return self.api.del_command(name)
+
+ def add_key(self, key, handler):
+ """
+ Add a global keybind
+ """
+ return self.api.add_key(key, handler)
+
+ def del_key(self, key):
+ """
+ Remove a global keybind
+ """
+ return self.api.del_key(key)
+
+ def add_tab_key(self, tab_type, key, handler):
+ """
+ Add a keybind only for a type of tab.
+ """
+ return self.api.add_tab_key(tab_type, key, handler)
+
+ def del_tab_key(self, tab_type, key):
+ """
+ Remove a keybind added through add_tab_key.
+ """
+ return self.api.del_tab_key(tab_type, key)
+
+ def add_tab_command(self, tab_type, name, handler, help, completion=None, short='', usage=''):
+ """
+ Add a command only for a type of tab.
+ """
+ return self.api.add_tab_command(tab_type, name, handler, help,
+ completion=completion, short=short, usage=usage)
+
+ def del_tab_command(self, tab_type, name):
+ """
+ Delete a command added through add_tab_command.
+ """
+ return self.api.del_tab_command(tab_type, name)
+
+ def add_event_handler(self, event_name, handler, position=0):
+ """
+ Add an event handler to the event event_name.
+ An optional position in the event handler list can be provided.
+ """
+ return self.api.add_event_handler(event_name, handler, position)
+
+ def del_event_handler(self, event_name, handler):
+ """
+ Remove 'handler' from the event list for 'event_name'.
+ """
+ return self.api.del_event_handler(event_name, handler)
diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py
new file mode 100644
index 00000000..549753a9
--- /dev/null
+++ b/poezio/plugin_manager.py
@@ -0,0 +1,384 @@
+"""
+Plugin manager module.
+Define the PluginManager class, the one that glues all the plugins and
+the API together. Defines also a bunch of variables related to the
+plugin env.
+"""
+
+import os
+from os import path
+import logging
+
+import core
+import tabs
+from plugin import PluginAPI
+from config import config
+
+log = logging.getLogger(__name__)
+
+class PluginManager(object):
+ """
+ Plugin Manager
+ Contains all the references to the plugins
+ And keeps track of everything the plugin has done through the API.
+ """
+ def __init__(self, core):
+ self.core = core
+ # module name -> module object
+ self.modules = {}
+ # module name -> plugin object
+ self.plugins = {}
+ # module name -> dict of commands loaded for the module
+ self.commands = {}
+ # module name -> list of event_name/handler pairs loaded for the module
+ self.event_handlers = {}
+ # module name -> dict of tab types; tab type -> commands
+ # loaded by the module
+ self.tab_commands = {}
+ # module name → dict of keys/handlers loaded for the module
+ self.keys = {}
+ # module name → dict of tab types; tab type → list of keybinds (tuples)
+ self.tab_keys = {}
+ self.roster_elements = {}
+
+ from importlib import machinery
+ self.finder = machinery.PathFinder()
+
+ self.initial_set_plugins_dir()
+ self.initial_set_plugins_conf_dir()
+ self.fill_load_path()
+
+ self.plugin_api = PluginAPI(core, self)
+
+ def disable_plugins(self):
+ for plugin in set(self.plugins.keys()):
+ try:
+ self.unload(plugin, notify=False)
+ except:
+ pass
+
+ def load(self, name, notify=True):
+ """
+ Load a plugin.
+ """
+ if name in self.plugins:
+ self.unload(name)
+
+ try:
+ module = None
+ loader = self.finder.find_module(name, self.load_path)
+ if not loader:
+ self.core.information('Could not find plugin: %s' % name)
+ return
+ module = loader.load_module()
+ except Exception as e:
+ log.debug("Could not load plugin %s", name, exc_info=True)
+ self.core.information("Could not load plugin %s: %s" % (name, e),
+ 'Error')
+ finally:
+ if not module:
+ return
+
+ self.modules[name] = module
+ self.commands[name] = {}
+ self.keys[name] = {}
+ self.tab_keys[name] = {}
+ self.tab_commands[name] = {}
+ self.event_handlers[name] = []
+ try:
+ self.plugins[name] = None
+ self.plugins[name] = module.Plugin(self.plugin_api, self.core,
+ self.plugins_conf_dir)
+ except Exception as e:
+ log.error('Error while loading the plugin %s', name, exc_info=True)
+ if notify:
+ self.core.information('Unable to load the plugin %s: %s' %
+ (name, e),
+ 'Error')
+ self.unload(name, notify=False)
+ else:
+ if notify:
+ self.core.information('Plugin %s loaded' % name, 'Info')
+
+ def unload(self, name, notify=True):
+ if name in self.plugins:
+ try:
+ for command in self.commands[name].keys():
+ del self.core.commands[command]
+ for key in self.keys[name].keys():
+ del self.core.key_func[key]
+ for tab in list(self.tab_commands[name].keys()):
+ for command in self.tab_commands[name][tab][:]:
+ self.del_tab_command(name, getattr(tabs, tab),
+ command[0])
+ del self.tab_commands[name][tab]
+ for tab in list(self.tab_keys[name].keys()):
+ for key in self.tab_keys[name][tab][:]:
+ self.del_tab_key(name, getattr(tabs, tab), key[0])
+ del self.tab_keys[name][tab]
+ for event_name, handler in self.event_handlers[name][:]:
+ self.del_event_handler(name, event_name, handler)
+
+ if self.plugins[name] is not None:
+ self.plugins[name].unload()
+ del self.plugins[name]
+ del self.commands[name]
+ del self.keys[name]
+ del self.tab_commands[name]
+ del self.event_handlers[name]
+ if notify:
+ self.core.information('Plugin %s unloaded' % name, 'Info')
+ except Exception as e:
+ log.debug("Could not unload plugin %s", name, exc_info=True)
+ self.core.information("Could not unload plugin %s: %s" %
+ (name, e),
+ 'Error')
+
+ def add_command(self, module_name, name, handler, help,
+ completion=None, short='', usage=''):
+ """
+ Add a global command.
+ """
+ if name in self.core.commands:
+ raise Exception("Command '%s' already exists" % (name,))
+
+ commands = self.commands[module_name]
+ commands[name] = core.Command(handler, help, completion, short, usage)
+ self.core.commands[name] = commands[name]
+
+ def del_command(self, module_name, name):
+ """
+ Remove a global command added through add_command.
+ """
+ if name in self.commands[module_name]:
+ del self.commands[module_name][name]
+ if name in self.core.commands:
+ del self.core.commands[name]
+
+ def add_tab_command(self, module_name, tab_type, name, handler, help,
+ completion=None, short='', usage=''):
+ """
+ Add a command only for a type of Tab.
+ """
+ commands = self.tab_commands[module_name]
+ t = tab_type.__name__
+ if name in tab_type.plugin_commands:
+ return
+ if not t in commands:
+ commands[t] = []
+ commands[t].append((name, handler, help, completion))
+ tab_type.plugin_commands[name] = core.Command(handler, help,
+ completion, short, usage)
+ for tab in self.core.tabs:
+ if isinstance(tab, tab_type):
+ tab.update_commands()
+
+ def del_tab_command(self, module_name, tab_type, name):
+ """
+ Remove a command added through add_tab_command.
+ """
+ commands = self.tab_commands[module_name]
+ t = tab_type.__name__
+ if not t in commands:
+ return
+ for command in commands[t]:
+ if command[0] == name:
+ commands[t].remove(command)
+ del tab_type.plugin_commands[name]
+ for tab in self.core.tabs:
+ if isinstance(tab, tab_type) and name in tab.commands:
+ del tab.commands[name]
+
+ def add_tab_key(self, module_name, tab_type, key, handler):
+ """
+ Associate a key binding to a handler only for a type of Tab.
+ """
+ keys = self.tab_keys[module_name]
+ t = tab_type.__name__
+ if key in tab_type.plugin_keys:
+ return
+ if not t in keys:
+ keys[t] = []
+ keys[t].append((key, handler))
+ tab_type.plugin_keys[key] = handler
+ for tab in self.core.tabs:
+ if isinstance(tab, tab_type):
+ tab.update_keys()
+
+ def del_tab_key(self, module_name, tab_type, key):
+ """
+ Remove a key binding added through add_tab_key.
+ """
+ keys = self.tab_keys[module_name]
+ t = tab_type.__name__
+ if not t in keys:
+ return
+ for _key in keys[t]:
+ if _key[0] == key:
+ keys[t].remove(_key)
+ del tab_type.plugin_keys[key]
+ for tab in self.core.tabs:
+ if isinstance(tab, tab_type) and key in tab.key_func:
+ del tab.key_func[key]
+
+ def add_key(self, module_name, key, handler):
+ """
+ Associate a global key binding to a handler, except if it
+ already exists.
+ """
+ if key in self.core.key_func:
+ raise Exception("Key '%s' already exists" % (key,))
+ keys = self.keys[module_name]
+ keys[key] = handler
+ self.core.key_func[key] = handler
+
+ def del_key(self, module_name, key):
+ """
+ Remove a global key binding added by a plugin.
+ """
+ if key in self.keys[module_name]:
+ del self.keys[module_name][key]
+ if key in self.core.key_func:
+ del self.core.commands[key]
+
+ def add_event_handler(self, module_name, event_name, handler, position=0):
+ """
+ Add an event handler. If event_name isn’t in the event list, assume
+ it is a slixmpp event.
+ """
+ eh = self.event_handlers[module_name]
+ eh.append((event_name, handler))
+ if event_name in self.core.events.events:
+ self.core.events.add_event_handler(event_name, handler, position)
+ else:
+ self.core.xmpp.add_event_handler(event_name, handler)
+
+ def del_event_handler(self, module_name, event_name, handler):
+ """
+ Remove an event handler if it exists.
+ """
+ if event_name in self.core.events.events:
+ self.core.events.del_event_handler(None, handler)
+ else:
+ self.core.xmpp.del_event_handler(event_name, handler)
+ eh = self.event_handlers[module_name]
+ eh = list(filter(lambda e: e != (event_name, handler), eh))
+
+ def completion_load(self, the_input):
+ """
+ completion function that completes the name of the plugins, from
+ all .py files in plugins_dir
+ """
+ try:
+ names = set()
+ for path in self.load_path:
+ try:
+ add = set(os.listdir(path))
+ names |= add
+ except:
+ pass
+ except OSError as e:
+ self.core.information('Completion failed: %s' % e, 'Error')
+ return
+ plugins_files = [name[:-3] for name in names if name.endswith('.py')
+ and name != '__init__.py' and not name.startswith('.')]
+ plugins_files.sort()
+ position = the_input.get_argument_position(quoted=False)
+ return the_input.new_completion(plugins_files, position, '',
+ quotify=False)
+
+ def completion_unload(self, the_input):
+ """
+ completion function that completes the name of loaded plugins
+ """
+ position = the_input.get_argument_position(quoted=False)
+ return the_input.new_completion(sorted(self.plugins.keys()), position,
+ '', quotify=False)
+
+ def on_plugins_dir_change(self, new_value):
+ self.plugins_dir = new_value
+ self.check_create_plugins_dir()
+ self.fill_load_path()
+
+ def on_plugins_conf_dir_change(self, new_value):
+ self.plugins_conf_dir = new_value
+ self.check_create_plugins_conf_dir()
+
+ def initial_set_plugins_conf_dir(self):
+ """
+ Create the plugins_conf_dir
+ """
+ plugins_conf_dir = config.get('plugins_conf_dir')
+ if not plugins_conf_dir:
+ config_home = os.environ.get('XDG_CONFIG_HOME')
+ if not config_home:
+ config_home = os.path.join(os.environ.get('HOME'), '.config')
+ plugins_conf_dir = os.path.join(config_home, 'poezio', 'plugins')
+ self.plugins_conf_dir = os.path.expanduser(plugins_conf_dir)
+ self.check_create_plugins_conf_dir()
+
+ def check_create_plugins_conf_dir(self):
+ """
+ Create the plugins config directory if it does not exist.
+ Returns True on success, False on failure.
+ """
+ if not os.access(self.plugins_conf_dir, os.R_OK | os.X_OK):
+ try:
+ os.makedirs(self.plugins_conf_dir)
+ except OSError:
+ log.error('Unable to create the plugin conf dir: %s',
+ self.plugins_conf_dir, exc_info=True)
+ return False
+ return True
+
+ def initial_set_plugins_dir(self):
+ """
+ Set the plugins_dir on start
+ """
+ plugins_dir = config.get('plugins_dir')
+ plugins_dir = plugins_dir or\
+ os.path.join(os.environ.get('XDG_DATA_HOME') or\
+ os.path.join(os.environ.get('HOME'),
+ '.local', 'share'),
+ 'poezio', 'plugins')
+ self.plugins_dir = os.path.expanduser(plugins_dir)
+ self.check_create_plugins_dir()
+
+ def check_create_plugins_dir(self):
+ """
+ Create the plugins directory if it does not exist.
+ Returns True on success, False on failure.
+ """
+ if not os.access(self.plugins_dir, os.R_OK | os.X_OK):
+ try:
+ os.makedirs(self.plugins_dir, exist_ok=True)
+ except OSError:
+ log.error('Unable to create the plugins dir: %s',
+ self.plugins_dir, exc_info=True)
+ return False
+ return True
+
+ def fill_load_path(self):
+ """
+ Append the global packages and the source directory if available
+ """
+
+ self.load_path = []
+
+ default_plugin_path = path.join(path.dirname(path.dirname(__file__)),
+ 'plugins')
+
+ if os.access(default_plugin_path, os.R_OK | os.X_OK):
+ self.load_path.insert(0, default_plugin_path)
+
+ if os.access(self.plugins_dir, os.R_OK | os.X_OK):
+ self.load_path.append(self.plugins_dir)
+
+ try:
+ import poezio_plugins
+ except:
+ pass
+ else:
+ if poezio_plugins.__path__:
+ self.load_path.append(list(poezio_plugins.__path__)[0])
+
diff --git a/poezio/poezio.py b/poezio/poezio.py
new file mode 100644
index 00000000..9fb6fb73
--- /dev/null
+++ b/poezio/poezio.py
@@ -0,0 +1,115 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+
+"""
+Starting point of poezio. Launches both the Connection and Gui
+"""
+
+import sys
+import os
+import signal
+import logging
+
+sys.path.append(os.path.dirname(os.path.abspath(__file__)))
+
+import singleton
+
+def test_curses():
+ """
+ Check if the system ncurses linked with python has unicode capabilities.
+ """
+ import curses
+ if hasattr(curses, 'unget_wch'):
+ return True
+ print("""\
+ERROR: The current python executable is linked with a ncurses version that \
+has no unicode capabilities.
+
+This could mean that:
+ - python was built on a system where readline is linked against \
+libncurses and not libncursesw
+ - python was built without ncursesw headers available
+
+Please file a bug for your distribution or fix that on your system and then \
+recompile python.
+Poezio is currently unable to read your input or draw its interface properly,\
+ so it will now exit.""")
+ return False
+
+
+def main():
+ """
+ Enter point
+ """
+ sys.stdout.write("\x1b]0;poezio\x07")
+ sys.stdout.flush()
+ import config
+ config_path = config.check_create_config_dir()
+ config.run_cmdline_args(config_path)
+ config.create_global_config()
+ config.check_create_log_dir()
+ config.check_create_cache_dir()
+ config.setup_logging()
+ config.post_logging_setup()
+
+ from config import options
+
+ if options.check_config:
+ config.check_config()
+ sys.exit(0)
+
+ import theming
+ theming.update_themes_dir()
+
+ import logger
+ logger.create_logger()
+
+ import roster
+ roster.create_roster()
+
+ import core
+
+ log = logging.getLogger('')
+
+ signal.signal(signal.SIGINT, signal.SIG_IGN) # ignore ctrl-c
+ cocore = singleton.Singleton(core.Core)
+ signal.signal(signal.SIGUSR1, cocore.sigusr_handler) # reload the config
+ signal.signal(signal.SIGHUP, cocore.exit_from_signal)
+ signal.signal(signal.SIGTERM, cocore.exit_from_signal)
+ if options.debug:
+ cocore.debug = True
+ cocore.start()
+
+ from slixmpp.exceptions import IqError, IqTimeout
+ def swallow_iqerrors(loop, context):
+ """Do not log unhandled iq errors and timeouts"""
+ if not isinstance(context['exception'], (IqError, IqTimeout)):
+ loop.default_exception_handler(context)
+
+ # Warning: asyncio must always be imported after the config. Otherwise
+ # the asyncio logger will not follow our configuration and won't write
+ # the tracebacks in the correct file, etc
+ import asyncio
+ loop = asyncio.get_event_loop()
+ loop.set_exception_handler(swallow_iqerrors)
+
+ loop.add_reader(sys.stdin, cocore.on_input_readable)
+ loop.add_signal_handler(signal.SIGWINCH, cocore.sigwinch_handler)
+ cocore.xmpp.start()
+ loop.run_forever()
+ # We reach this point only when loop.stop() is called
+ try:
+ cocore.reset_curses()
+ except:
+ pass
+
+if __name__ == '__main__':
+ if test_curses():
+ main()
+ else:
+ sys.exit(1)
diff --git a/poezio/poezio_shlex.py b/poezio/poezio_shlex.py
new file mode 100644
index 00000000..032baeee
--- /dev/null
+++ b/poezio/poezio_shlex.py
@@ -0,0 +1,276 @@
+"""
+A lexical analyzer class for simple shell-like syntaxes.
+
+Tweaked for the specific needs of parsing poezio input.
+
+"""
+
+# Module and documentation by Eric S. Raymond, 21 Dec 1998
+# Input stacking and error message cleanup added by ESR, March 2000
+# push_source() and pop_source() made explicit by ESR, January 2001.
+# Posix compliance, split(), string arguments, and
+# iterator interface by Gustavo Niemeyer, April 2003.
+
+import os
+import re
+import sys
+from collections import deque
+
+from io import StringIO
+
+__all__ = ["shlex", "split", "quote"]
+
+class shlex(object):
+ """
+ A custom version of the shlex in the stdlib to yield more information
+ """
+ def __init__(self, instream=None, infile=None, posix=True):
+ if isinstance(instream, str):
+ instream = StringIO(instream)
+ if instream is not None:
+ self.instream = instream
+ self.infile = infile
+ else:
+ self.instream = sys.stdin
+ self.infile = None
+ self.posix = posix
+ self.eof = ''
+ self.commenters = ''
+ self.wordchars = ('abcdfeghijklmnopqrstuvwxyz'
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_')
+ if self.posix:
+ self.wordchars += ('ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ'
+ 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ')
+ self.whitespace = ' \t\r\n'
+ self.whitespace_split = True
+ self.quotes = '"'
+ self.escape = '\\'
+ self.escapedquotes = '"'
+ self.state = ' '
+ self.pushback = deque()
+ self.lineno = 1
+ self.debug = 0
+ self.token = ''
+ self.filestack = deque()
+ self.source = None
+ if self.debug:
+ print('shlex: reading from %s, line %d' \
+ % (self.instream, self.lineno))
+
+ def push_token(self, tok):
+ "Push a token onto the stack popped by the get_token method"
+ if self.debug >= 1:
+ print("shlex: pushing token " + repr(tok))
+ self.pushback.appendleft(tok)
+
+ def push_source(self, newstream, newfile=None):
+ "Push an input source onto the lexer's input source stack."
+ if isinstance(newstream, str):
+ newstream = StringIO(newstream)
+ self.filestack.appendleft((self.infile, self.instream, self.lineno))
+ self.infile = newfile
+ self.instream = newstream
+ self.lineno = 1
+ if self.debug:
+ if newfile is not None:
+ print('shlex: pushing to file %s' % (self.infile,))
+ else:
+ print('shlex: pushing to stream %s' % (self.instream,))
+
+ def pop_source(self):
+ "Pop the input source stack."
+ self.instream.close()
+ (self.infile, self.instream, self.lineno) = self.filestack.popleft()
+ if self.debug:
+ print('shlex: popping to %s, line %d' \
+ % (self.instream, self.lineno))
+ self.state = ' '
+
+ def get_token(self):
+ "Get a token from the input stream (or from stack if it's nonempty)"
+ if self.pushback:
+ tok = self.pushback.popleft()
+ if self.debug >= 1:
+ print("shlex: popping token " + repr(tok))
+ return tok
+ # No pushback. Get a token.
+ start, end, raw = self.read_token()
+ return start, end, raw
+
+ def read_token(self):
+ quoted = False
+ escapedstate = ' '
+ token_start = 0
+ token_end = -1
+ # read one char from the stream at once
+ while True:
+ nextchar = self.instream.read(1)
+ if nextchar == '\n':
+ self.lineno = self.lineno + 1
+ if self.debug >= 3:
+ print("shlex: in state", repr(self.state), \
+ "I see character:", repr(nextchar))
+ if self.state is None:
+ self.token = '' # past end of file
+ token_end = self.instream.tell()
+ break
+ elif self.state == ' ':
+ if not nextchar:
+ self.state = None # end of file
+ token_end = self.instream.tell()
+ break
+ elif nextchar in self.whitespace:
+ if self.debug >= 2:
+ print("shlex: I see whitespace in whitespace state")
+ if self.token or (self.posix and quoted):
+ token_end = self.instream.tell() - 1
+ break # emit current token
+ else:
+ continue
+ elif nextchar in self.wordchars:
+ token_start = self.instream.tell() - 1
+ self.token = nextchar
+ self.state = 'a'
+ elif nextchar in self.quotes:
+ token_start = self.instream.tell() - 1
+ self.state = nextchar
+ elif self.whitespace_split:
+ token_start = self.instream.tell() - 1
+ self.token = nextchar
+ self.state = 'a'
+ else:
+ token_start = self.instream.tell() - 1
+ self.token = nextchar
+ if self.token or (self.posix and quoted):
+ token_end = self.instream.tell() - 1
+ break # emit current token
+ else:
+ continue
+ elif self.state in self.quotes:
+ quoted = True
+ if not nextchar: # end of file
+ if self.debug >= 2:
+ print("shlex: I see EOF in quotes state")
+ # XXX what error should be raised here?
+ token_end = self.instream.tell()
+ break
+ if nextchar == self.state:
+ if not self.posix:
+ self.token = self.token + nextchar
+ self.state = ' '
+ token_end = self.instream.tell()
+ break
+ else:
+ self.state = 'a'
+ elif self.posix and nextchar in self.escape and \
+ self.state in self.escapedquotes:
+ escapedstate = self.state
+ self.state = nextchar
+ else:
+ self.token = self.token + nextchar
+ elif self.state in self.escape:
+ if not nextchar: # end of file
+ if self.debug >= 2:
+ print("shlex: I see EOF in escape state")
+ # XXX what error should be raised here?
+ token_end = self.instream.tell()
+ break
+ # only the quote may be escaped
+ if escapedstate in self.quotes and nextchar != escapedstate:
+ self.token = self.token + self.state
+ self.token = self.token + nextchar
+ self.state = escapedstate
+ elif self.state == 'a':
+ if not nextchar:
+ self.state = None # end of file
+ token_end = self.instream.tell()
+ break
+ elif nextchar in self.whitespace:
+ if self.debug >= 2:
+ print("shlex: I see whitespace in word state")
+ self.state = ' '
+ if self.token or (self.posix and quoted):
+ token_end = self.instream.tell() - 1
+ break # emit current token
+ else:
+ continue
+ elif nextchar in self.wordchars or nextchar in self.quotes \
+ or self.whitespace_split:
+ self.token = self.token + nextchar
+ else:
+ self.pushback.appendleft(nextchar)
+ if self.debug >= 2:
+ print("shlex: I see punctuation in word state")
+ self.state = ' '
+ if self.token:
+ token_end = self.instream.tell()
+ break # emit current token
+ else:
+ continue
+ result = self.token
+ self.token = ''
+ if self.posix and not quoted and result == '':
+ result = None
+ if self.debug > 1:
+ if result:
+ print("shlex: raw token=" + repr(result))
+ else:
+ print("shlex: raw token=EOF")
+ return (token_start, token_end, result)
+
+ def sourcehook(self, newfile):
+ "Hook called on a filename to be sourced."
+ if newfile[0] == '"':
+ newfile = newfile[1:-1]
+ # This implements cpp-like semantics for relative-path inclusion.
+ if isinstance(self.infile, str) and not os.path.isabs(newfile):
+ newfile = os.path.join(os.path.dirname(self.infile), newfile)
+ return (newfile, open(newfile, "r"))
+
+ def error_leader(self, infile=None, lineno=None):
+ "Emit a C-compiler-like, Emacs-friendly error-message leader."
+ if infile is None:
+ infile = self.infile
+ if lineno is None:
+ lineno = self.lineno
+ return "\"%s\", line %d: " % (infile, lineno)
+
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ token = self.get_token()
+ if token and token[0] == self.eof:
+ raise StopIteration
+ return token
+
+def split(s, comments=False, posix=True):
+ lex = shlex(s, posix=posix)
+ lex.whitespace_split = True
+ if not comments:
+ lex.commenters = ''
+ return list(lex)
+
+
+_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII).search
+
+def quote(s):
+ """Return a shell-escaped version of the string *s*."""
+ if not s:
+ return "''"
+ if _find_unsafe(s) is None:
+ return s
+
+ # use single quotes, and put single quotes into double quotes
+ # the string $'b is then quoted as '$'"'"'b'
+ return "'" + s.replace("'", "'\"'\"'") + "'"
+
+
+if __name__ == '__main__':
+ lexer = shlex(instream=sys.argv[1])
+ while 1:
+ tt = lexer.get_token()
+ if tt:
+ print("Token: " + repr(tt))
+ else:
+ break
diff --git a/poezio/pooptmodule.c b/poezio/pooptmodule.c
new file mode 100644
index 00000000..69fb7f6f
--- /dev/null
+++ b/poezio/pooptmodule.c
@@ -0,0 +1,486 @@
+/* Copyright 2010-2011 Florent Le Coz <louiz@louiz.org> */
+
+/* This file is part of Poezio. */
+
+/* Poezio is free software: you can redistribute it and/or modify */
+/* it under the terms of the zlib license. See the COPYING file. */
+
+/** The poopt python3 module
+**/
+
+/* This file is a python3 module for poezio, used to replace some time-critical
+python functions that are too slow. */
+
+#define PY_SSIZE_T_CLEAN
+
+#include "Python.h"
+
+PyObject *ErrorObject;
+
+/***
+ Internal functions
+ ***/
+
+/**
+ Just checking if the return value is -1. In some (all?) implementations,
+ wcwidth("😆") returns -1 while it should return 1. In these cases, we
+ return 1 instead because this is by far the most probable real value.
+ Since the string is received from python, and the unicode character is
+ extracted with mbrtowc(), and supposing these two compononents are not
+ bugged, and since poezio’s code should never pass '\t', '\n' or their
+ friends, a return value of -1 from wcwidth() is considered to be a bug in
+ wcwidth() (until proven otherwise). xwcwidth() is here to work around
+ this bug. */
+static int xwcwidth(wchar_t c)
+{
+ const int res = wcwidth(c);
+ if (res == -1 && c != '\x19')
+ return 1;
+ return res;
+}
+
+/***
+ The module functions
+ ***/
+
+/**
+ cut_text: takes a string and returns a tuple of int.
+
+ Each two int tuple is a line, represented by the ending position it
+ (where it should be cut). Not that this position is calculed using the
+ position of the python string characters, not just the individual bytes.
+
+ For example,
+ poopt_cut_text("vivent les réfrigérateurs", 6);
+ will return [(0, 6), (7, 10), (11, 17), (17, 22), (22, 24)], meaning that
+ the lines are
+ "vivent", "les", "réfrig", "érateu" and "rs"
+
+*/
+PyDoc_STRVAR(poopt_cut_text_doc, "cut_text(text, width)\n\n\nReturn a list of two-tuple, the first int is the starting position of the line and the second is its end.");
+
+static PyObject* poopt_cut_text(PyObject* self, PyObject* args)
+{
+ /* The list of tuples that we return */
+ PyObject* retlist = PyList_New(0);
+ /* The temporary name for the tuples */
+ PyObject* tmp;
+
+ /* Get the python arguments */
+ const size_t width;
+ const char* buffer;
+ const Py_ssize_t buffer_len;
+
+ if (PyArg_ParseTuple(args, "s#k", &buffer, &buffer_len, &width) == 0)
+ return NULL;
+
+ /* Pointer to the end of the string */
+ const char* const end = buffer + buffer_len;
+
+ /* The position, considering unicode chars (aka, the position in the
+ * python string). This is used to determine the position in the python
+ * string at which we should cut */
+ unsigned int spos = 0;
+
+ /* The start position (in the python-string) of the next line */
+ unsigned int start_pos = 0;
+
+ /* The position of the last space seen in the current line. This is used
+ * to cut on spaces instead of cutting inside words, if possible (aka if
+ * there is a space) */
+ int last_space = -1;
+ /* The number of columns taken by chars between start_pos and last_space */
+ size_t cols_until_space = 0;
+
+ /* The number of bytes consumed by mbrtowc. We advance the buffer ptr by this value */
+ size_t consumed;
+
+ /* Number of columns taken to display the current line so far */
+ size_t columns = 0;
+
+ /* The unicode character found by mbrtowc */
+ wchar_t wc;
+
+ while (buffer < end)
+ {
+ /* Special case to jump poezio special characters that are contained
+ * in the python string, but should not be counted as chars, because
+ * they will not be displayed. Those are the formatting chars (to
+ * insert colors or things like that in the string) */
+ if (*buffer == 25) /* \x19 */
+ {
+ /* Jump everything until the end of this format marker, but
+ * without increasing the number of columns of the current
+ * line. Because these chars are not printed. */
+ while (buffer < end && *buffer != 'u' &&
+ *buffer != 'a' && *buffer != 'i' &&
+ *buffer != 'b' && *buffer != 'o' &&
+ *buffer != '}')
+ {
+ buffer++;
+ spos++;
+ }
+ buffer++;
+ spos++;
+ continue;
+ }
+ /* Find the next unicode character (a wchar_t) in the string. This
+ * may consume from one to 4 bytes. */
+ consumed = mbrtowc(&wc, buffer, end-buffer, NULL);
+ if (consumed == 0)
+ break ;
+ else if ((size_t)-1 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -1: Invalid multibyte sequence.");
+ return NULL;
+ }
+ else if ((size_t)-2 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -2: Could not parse a complete multibyte character.");
+ return NULL;
+ }
+
+ buffer += consumed;
+
+ /* This is one condition to end the line: an explicit \n is found */
+ if (wc == (wchar_t)'\n')
+ {
+ spos++;
+ tmp = Py_BuildValue("II", start_pos, spos);
+ if (PyList_Append(retlist, tmp) == -1)
+ {
+ Py_XDECREF(tmp);
+ return NULL;
+ }
+ Py_XDECREF(tmp);
+ /* And then initiate a new line */
+ start_pos = spos;
+ last_space = -1;
+ columns = 0;
+ continue ;
+ }
+
+ /* Get the number of columns needed to display this character. May be 0, 1 or 2 */
+ const size_t cols = xwcwidth(wc);
+
+ /* This is the second condition to end the line: we have consumed
+ * enough columns to fill a whole line */
+ if (columns + cols > width)
+ { /* If possible, cut on a space */
+ if (last_space != -1)
+ {
+ tmp = Py_BuildValue("II", start_pos, last_space);
+ if (PyList_Append(retlist, tmp) == -1)
+ {
+ Py_XDECREF(tmp);
+ return NULL;
+ }
+ Py_XDECREF(tmp);
+ start_pos = last_space + 1;
+ last_space = -1;
+ columns -= (cols_until_space + 1);
+ }
+ else
+ {
+ /* Otherwise, cut in the middle of a word */
+ tmp = Py_BuildValue("II", start_pos, spos);
+ if (PyList_Append(retlist, tmp) == -1)
+ {
+ Py_XDECREF(tmp);
+ return NULL;
+ }
+ Py_XDECREF(tmp);
+ start_pos = spos;
+ columns = 0;
+ }
+ }
+ /* We save the position of the last space seen in this line, and the
+ number of columns we have until now. This helps us keep track of
+ the columns to count when we will use that space as a cutting
+ point, later */
+ if (wc == (wchar_t)' ')
+ {
+ last_space = spos;
+ cols_until_space = columns;
+ }
+ /* We advanced from one char, increment spos by one and add the
+ * char's columns to the line's columns */
+ columns += cols;
+ spos++;
+ }
+ /* We are at the end of the string, append the last line, not finished */
+ tmp = Py_BuildValue("II", start_pos, spos);
+ if (PyList_Append(retlist, tmp) == -1)
+ {
+ Py_XDECREF(tmp);
+ return NULL;
+ }
+ Py_XDECREF(tmp);
+ return retlist;
+}
+
+/**
+ wcswidth: An emulation of the POSIX wcswidth(3) function using wcwidth
+ and mbrtowc.
+*/
+PyDoc_STRVAR(poopt_wcswidth_doc, "wcswidth(s)\n\n\nThe wcswidth() function returns the number of columns needed to represent the wide-character string pointed to by s. Raise UnicodeError if an invalid unicode value is passed");
+static PyObject* poopt_wcswidth(PyObject* self, PyObject* args)
+{
+ const char* string;
+ const Py_ssize_t len;
+ if (PyArg_ParseTuple(args, "s#", &string, &len) == 0)
+ return NULL;
+ const char* const end = string + len;
+ wchar_t wc;
+ int res = 0;
+ while (string < end)
+ {
+ const size_t consumed = mbrtowc(&wc, string, end-string, NULL);
+ if (consumed == 0)
+ break ;
+ else if ((size_t)-1 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -1: Invalid multibyte sequence.");
+ return NULL;
+ }
+ else if ((size_t)-2 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -2: Could not parse a complete multibyte character.");
+ return NULL;
+ }
+ string += consumed;
+ res += xwcwidth(wc);
+ }
+ return Py_BuildValue("i", res);
+}
+
+/**
+ cut_by_columns: takes a python string and a number of columns, returns a
+ python string truncated to take at most that many columns
+ For example cut_by_columns(n, "エメルカ") will return:
+ - n == 5 -> "エメ" (which takes only 4 columns since we can't cut the
+ next character in half)
+ - n == 2 -> "エ"
+ - n == 1 -> ""
+ - n == 42 -> "エメルカ"
+ - etc
+*/
+PyDoc_STRVAR(poopt_cut_by_columns_doc, "cut_by_columns(n, string)\n\n\nreturns a string truncated to take at most n columns");
+static PyObject* poopt_cut_by_columns(PyObject* self, PyObject* args)
+{
+ const char* start;
+ const Py_ssize_t len;
+ const size_t limit;
+ if (PyArg_ParseTuple(args, "s#k", &start, &len, &limit) == 0)
+ return NULL;
+
+ const char* const end = start + len;
+ const char* ptr = start;
+ wchar_t wc;
+
+ /* The number of columns that the string would take so far */
+ size_t columns = 0;
+
+ while (ptr < end)
+ {
+ if (columns == limit)
+ break ;
+ const size_t consumed = mbrtowc(&wc, ptr, end-ptr, NULL);
+ if (consumed == 0)
+ break ;
+ else if ((size_t)-1 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -1: Invalid multibyte sequence.");
+ return NULL;
+ }
+ else if ((size_t)-2 == consumed)
+ {
+ PyErr_SetString(PyExc_UnicodeError,
+ "mbrtowc returned -2: Could not parse a complete multibyte character.");
+ return NULL;
+ }
+ const size_t cols = wcwidth(wc);
+ if (columns + cols > limit)
+ /* Adding the next character would exceed the column limit */
+ break ;
+ ptr += consumed;
+ columns += cols;
+ }
+ return Py_BuildValue("s#", start, ptr - start);
+}
+
+/***
+ Module initialization. Just taken from the xxmodule.c template from the
+ python sources.
+ ***/
+static PyTypeObject Str_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ "pooptmodule.Str", /*tp_name*/
+ 0, /*tp_basicsize*/
+ 0, /*tp_itemsize*/
+ /* methods */
+ 0, /*tp_dealloc*/
+ 0, /*tp_print*/
+ 0, /*tp_getattr*/
+ 0, /*tp_setattr*/
+ 0, /*tp_reserved*/
+ 0, /*tp_repr*/
+ 0, /*tp_as_number*/
+ 0, /*tp_as_sequence*/
+ 0, /*tp_as_mapping*/
+ 0, /*tp_hash*/
+ 0, /*tp_call*/
+ 0, /*tp_str*/
+ 0, /*tp_getattro*/
+ 0, /*tp_setattro*/
+ 0, /*tp_as_buffer*/
+ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/
+ 0, /*tp_doc*/
+ 0, /*tp_traverse*/
+ 0, /*tp_clear*/
+ 0, /*tp_richcompare*/
+ 0, /*tp_weaklistoffset*/
+ 0, /*tp_iter*/
+ 0, /*tp_iternext*/
+ 0, /*tp_methods*/
+ 0, /*tp_members*/
+ 0, /*tp_getset*/
+ 0, /* see PyInit_xx */ /*tp_base*/
+ 0, /*tp_dict*/
+ 0, /*tp_descr_get*/
+ 0, /*tp_descr_set*/
+ 0, /*tp_dictoffset*/
+ 0, /*tp_init*/
+ 0, /*tp_alloc*/
+ 0, /*tp_new*/
+ 0, /*tp_free*/
+ 0, /*tp_is_gc*/
+};
+
+static PyObject *
+null_richcompare(PyObject *self, PyObject *other, int op)
+{
+ Py_INCREF(Py_NotImplemented);
+ return Py_NotImplemented;
+}
+
+static PyTypeObject Null_Type = {
+ PyVarObject_HEAD_INIT(NULL, 0)
+ "pooptmodule.Null", /*tp_name*/
+ 0, /*tp_basicsize*/
+ 0, /*tp_itemsize*/
+ /* methods */
+ 0, /*tp_dealloc*/
+ 0, /*tp_print*/
+ 0, /*tp_getattr*/
+ 0, /*tp_setattr*/
+ 0, /*tp_reserved*/
+ 0, /*tp_repr*/
+ 0, /*tp_as_number*/
+ 0, /*tp_as_sequence*/
+ 0, /*tp_as_mapping*/
+ 0, /*tp_hash*/
+ 0, /*tp_call*/
+ 0, /*tp_str*/
+ 0, /*tp_getattro*/
+ 0, /*tp_setattro*/
+ 0, /*tp_as_buffer*/
+ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /*tp_flags*/
+ 0, /*tp_doc*/
+ 0, /*tp_traverse*/
+ 0, /*tp_clear*/
+ null_richcompare, /*tp_richcompare*/
+ 0, /*tp_weaklistoffset*/
+ 0, /*tp_iter*/
+ 0, /*tp_iternext*/
+ 0, /*tp_methods*/
+ 0, /*tp_members*/
+ 0, /*tp_getset*/
+ 0, /* see PyInit_xx */ /*tp_base*/
+ 0, /*tp_dict*/
+ 0, /*tp_descr_get*/
+ 0, /*tp_descr_set*/
+ 0, /*tp_dictoffset*/
+ 0, /*tp_init*/
+ 0, /*tp_alloc*/
+ 0, /* see PyInit_xx */ /*tp_new*/
+ 0, /*tp_free*/
+ 0, /*tp_is_gc*/
+};
+
+
+/* List of functions defined in the module */
+static PyMethodDef poopt_methods[] = {
+ {"cut_text", poopt_cut_text, METH_VARARGS, poopt_cut_text_doc},
+ {"wcswidth", poopt_wcswidth, METH_VARARGS, poopt_wcswidth_doc},
+ {"cut_by_columns", poopt_cut_by_columns, METH_VARARGS, poopt_cut_by_columns_doc},
+ {} /* sentinel */
+};
+
+PyDoc_STRVAR(module_doc,
+ "This is a template module just for instruction. And poopt.");
+
+/* Initialization function for the module (*must* be called PyInit_xx) */
+
+static struct PyModuleDef pooptmodule = {
+ PyModuleDef_HEAD_INIT,
+ "poopt",
+ module_doc,
+ -1,
+ poopt_methods,
+ NULL,
+ NULL,
+ NULL,
+ NULL
+};
+
+PyMODINIT_FUNC
+PyInit_poopt(void)
+{
+ PyObject *m = NULL;
+
+ /* Due to cross platform compiler issues the slots must be filled
+ * here. It's required for portability to Windows without requiring
+ * C++. */
+ Null_Type.tp_base = &PyBaseObject_Type;
+ Null_Type.tp_new = PyType_GenericNew;
+ Str_Type.tp_base = &PyUnicode_Type;
+
+ /* Finalize the type object including setting type of the new type
+ * object; doing it here is required for portability, too. */
+ /* if (PyType_Ready(&Xxo_Type) < 0) */
+ /* goto fail; */
+
+ /* Create the module and add the functions */
+ m = PyModule_Create(&pooptmodule);
+ if (m == NULL)
+ goto fail;
+
+ /* Add some symbolic constants to the module */
+ if (ErrorObject == NULL) {
+ ErrorObject = PyErr_NewException("poopt.error", NULL, NULL);
+ if (ErrorObject == NULL)
+ goto fail;
+ }
+ Py_INCREF(ErrorObject);
+ PyModule_AddObject(m, "error", ErrorObject);
+
+ /* Add Str */
+ if (PyType_Ready(&Str_Type) < 0)
+ goto fail;
+ PyModule_AddObject(m, "Str", (PyObject *)&Str_Type);
+
+ /* Add Null */
+ if (PyType_Ready(&Null_Type) < 0)
+ goto fail;
+ PyModule_AddObject(m, "Null", (PyObject *)&Null_Type);
+ return m;
+ fail:
+ Py_XDECREF(m);
+ return NULL;
+}
diff --git a/poezio/roster.py b/poezio/roster.py
new file mode 100644
index 00000000..ba7da63e
--- /dev/null
+++ b/poezio/roster.py
@@ -0,0 +1,334 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+
+"""
+Defines the Roster and RosterGroup classes
+"""
+import logging
+log = logging.getLogger(__name__)
+
+from config import config
+from contact import Contact
+from roster_sorting import SORTING_METHODS, GROUP_SORTING_METHODS
+
+from os import path as p
+from datetime import datetime
+from common import safeJID
+from slixmpp.exceptions import IqError, IqTimeout
+
+
+class Roster(object):
+ """
+ The proxy class to get the roster from slixmpp.
+ Caches Contact and RosterGroup objects.
+ """
+
+ def __init__(self):
+ """
+ node: the RosterSingle from slixmpp
+ """
+ self.__node = None
+ self.contact_filter = None # A tuple(function, *args)
+ # function to filter contacts,
+ # on search, for example
+ self.folded_groups = set(config.get('folded_roster_groups',
+ section='var').split(':'))
+ self.groups = {}
+ self.contacts = {}
+ self.length = 0
+ self.connected = 0
+
+ # Used for caching roster infos
+ self.last_built = datetime.now()
+ self.last_modified = datetime.now()
+
+ def modified(self):
+ self.last_modified = datetime.now()
+
+ @property
+ def needs_rebuild(self):
+ return self.last_modified >= self.last_built
+
+ def __getitem__(self, key):
+ """Get a Contact from his bare JID"""
+ key = safeJID(key).bare
+ if key in self.contacts and self.contacts[key] is not None:
+ return self.contacts[key]
+ if key in self.jids():
+ contact = Contact(self.__node[key])
+ self.contacts[key] = contact
+ return contact
+
+ def __setitem__(self, key, value):
+ """Set the a Contact value for the bare jid key"""
+ self.contacts[key] = value
+
+ def remove(self, jid):
+ """Send a removal iq to the server"""
+ jid = safeJID(jid).bare
+ if self.__node[jid]:
+ try:
+ self.__node[jid].send_presence(ptype='unavailable')
+ self.__node.remove(jid)
+ except (IqError, IqTimeout):
+ log.debug('IqError when removing %s:', jid, exc_info=True)
+
+ def __delitem__(self, jid):
+ """Remove a contact from the roster view"""
+ jid = safeJID(jid).bare
+ contact = self[jid]
+ if not contact:
+ return
+ del self.contacts[contact.bare_jid]
+
+ for group in list(self.groups.values()):
+ group.remove(contact)
+ if not group:
+ del self.groups[group.name]
+ self.modified()
+
+ def __iter__(self):
+ """Iterate over the jids of the contacts"""
+ return iter(self.contacts.values())
+
+ def __contains__(self, key):
+ """True if the bare jid is in the roster, false otherwise"""
+ return safeJID(key).bare in self.jids()
+
+ @property
+ def jid(self):
+ """Our JID"""
+ return self.__node.jid
+
+ def get_and_set(self, jid):
+ contact = self.contacts.get(jid)
+ if contact is None:
+ contact = Contact(self.__node[jid])
+ self.contacts[jid] = contact
+ return contact
+ return contact
+
+ def set_node(self, value):
+ """Set the slixmpp RosterSingle for our roster"""
+ self.__node = value
+
+ def get_groups(self, sort=''):
+ """Return a list of the RosterGroups"""
+ group_list = sorted(
+ (group for group in self.groups.values() if group),
+ key=lambda x: x.name.lower() if x.name else ''
+ )
+
+ for sorting in sort.split(':'):
+ if sorting == 'reverse':
+ group_list = list(reversed(group_list))
+ else:
+ method = GROUP_SORTING_METHODS.get(sorting, lambda x: 0)
+ group_list = sorted(group_list, key=method)
+ return group_list
+
+ def get_group(self, name):
+ """Return a group or create it if not present"""
+ if name in self.groups:
+ return self.groups[name]
+ self.groups[name] = RosterGroup(name, folded=name in self.folded_groups)
+
+ def add(self, jid):
+ """Subscribe to a jid"""
+ self.__node.subscribe(jid)
+
+ def jids(self):
+ """List of the contact JIDS"""
+ l = []
+ for key in self.__node.keys():
+ contact = self.get_and_set(key)
+ if key != self.jid and (contact and self.exists(contact)):
+ l.append(key)
+ self.update_size(l)
+ return l
+
+ def update_size(self, jids=None):
+ if jids is None:
+ jids = self.jids()
+ self.length = len(jids)
+
+ def get_contacts(self):
+ """
+ Return a list of all the contacts
+ """
+ return [self[jid] for jid in self.jids()]
+
+ def get_contacts_sorted_filtered(self, sort=''):
+ """
+ Return a list of all the contacts sorted with a criteria
+ """
+ contact_list = []
+ for contact in self.get_contacts():
+ if contact.bare_jid != self.jid:
+ if self.contact_filter:
+ if self.contact_filter[0](contact, self.contact_filter[1]):
+ contact_list.append(contact)
+ else:
+ contact_list.append(contact)
+ contact_list = sorted(contact_list, key=SORTING_METHODS['name'])
+
+ for sorting in sort.split(':'):
+ if sorting == 'reverse':
+ contact_list = list(reversed(contact_list))
+ else:
+ method = SORTING_METHODS.get(sorting, lambda x: 0)
+ contact_list = sorted(contact_list, key=method)
+ return contact_list
+
+ def save_to_config_file(self):
+ """
+ Save various information to the config file
+ e.g. the folded groups
+ """
+ folded_groups = ':'.join([group.name for group in self.groups.values()\
+ if group.folded])
+ log.debug('folded:%s\n', folded_groups)
+ return config.silent_set('folded_roster_groups', folded_groups, 'var')
+
+ def get_nb_connected_contacts(self):
+ """
+ Get the number of connected contacts
+ """
+ return self.connected
+
+ def update_contact_groups(self, contact):
+ """Regenerate the RosterGroups when receiving a contact update"""
+ if not isinstance(contact, Contact):
+ contact = self.get_and_set(contact)
+ if not contact:
+ return
+ for name, group in self.groups.items():
+ if name in contact.groups and contact not in group:
+ group.add(contact)
+ elif contact in group and name not in contact.groups:
+ group.remove(contact)
+
+ for group in contact.groups:
+ if not group in self.groups:
+ self.groups[group] = RosterGroup(group, folded=group in self.folded_groups)
+ self.groups[group].add(contact)
+
+ def __len__(self):
+ """
+ Return the number of contacts
+ (used to return the display size, but now we have
+ the display cache in RosterWin for that)
+ """
+ return self.length
+
+ def __repr__(self):
+ ret = '== Roster:\nContacts:\n'
+ for contact in self.contacts.values():
+ ret += '%s\n' % (contact,)
+ ret += 'Groups\n'
+ for group in self.groups:
+ ret += '%s\n' % (group,)
+ return ret + '\n'
+
+ def export(self, path):
+ """Export a list of bare jids to a given file"""
+ if p.isfile(path):
+ return
+ try:
+ f = open(path, 'w+', encoding='utf-8')
+ f.writelines([str(i) + "\n" for i in self.contacts if self[i] and (self[i].subscription == "both" or self[i].ask)])
+ f.close()
+ return True
+ except IOError:
+ return
+ except OSError:
+ return
+
+ def exists(self, contact):
+ if not contact:
+ return False
+ for group in contact.groups:
+ if contact not in self.groups.get(group, tuple()):
+ return False
+ return True
+
+
+class RosterGroup(object):
+ """
+ A RosterGroup is a group containing contacts
+ It can be Friends/Family etc, but also can be
+ Online/Offline or whatever
+ """
+ def __init__(self, name, contacts=None, folded=False):
+ if not contacts:
+ contacts = []
+ self.contacts = set(contacts)
+ self.name = name if name is not None else ''
+ self.folded = folded # if the group content is to be shown
+
+ def __iter__(self):
+ """Iterate over the contacts"""
+ return iter(self.contacts)
+
+ def __repr__(self):
+ return '<Roster_group: %s; %s>' % (self.name, self.contacts)
+
+ def __len__(self):
+ """Number of contacts in the group"""
+ return len(self.contacts)
+
+ def __contains__(self, contact):
+ """
+ Return a bool, telling if the contact is in the group
+ """
+ return contact in self.contacts
+
+ def add(self, contact):
+ """Add a contact to the group"""
+ self.contacts.add(contact)
+
+ def remove(self, contact):
+ """Remove a contact from the group if present"""
+ if contact in self.contacts:
+ self.contacts.remove(contact)
+
+ def get_contacts(self, contact_filter=None, sort=''):
+ """Return the group contacts, filtered and sorted"""
+ contact_list = self.contacts.copy() if not contact_filter\
+ else [contact for contact in self.contacts.copy() if contact_filter[0](contact, contact_filter[1])]
+ contact_list = sorted(contact_list, key=SORTING_METHODS['name'])
+
+ for sorting in sort.split(':'):
+ if sorting == 'reverse':
+ contact_list = list(reversed(contact_list))
+ else:
+ method = SORTING_METHODS.get(sorting, lambda x: 0)
+ contact_list = sorted(contact_list, key=method)
+ return contact_list
+
+ def toggle_folded(self):
+ """Fold/unfold the group in the roster"""
+ self.folded = not self.folded
+ if self.folded:
+ if self.name not in roster.folded_groups:
+ roster.folded_groups.add(self.name)
+ else:
+ if self.name in roster.folded_groups:
+ roster.folded_groups.remove(self.name)
+
+ def get_nb_connected_contacts(self):
+ """Return the number of connected contacts"""
+ return len([1 for contact in self.contacts if len(contact)])
+
+def create_roster():
+ "Create the global roster object"
+ global roster
+ roster = Roster()
+
+# Shared roster object
+roster = None
diff --git a/poezio/roster_sorting.py b/poezio/roster_sorting.py
new file mode 100644
index 00000000..c57f0dce
--- /dev/null
+++ b/poezio/roster_sorting.py
@@ -0,0 +1,90 @@
+"""
+Defines the roster sorting methods used in roster.py
+(for contacts/groups)
+"""
+
+########################### Contacts sorting ############################
+
+PRESENCE_PRIORITY = {'unavailable': 5,
+ 'xa': 4,
+ 'away': 3,
+ 'dnd': 2,
+ '': 1,
+ 'available': 1}
+
+def sort_jid(contact):
+ """Sort by contact JID"""
+ return contact.bare_jid
+
+def sort_show(contact):
+ """Sort by show (from high availability to low)"""
+ res = contact.get_highest_priority_resource()
+ if not res:
+ return 5
+ show = res.presence
+ if show not in PRESENCE_PRIORITY:
+ return 0
+ return PRESENCE_PRIORITY[show]
+
+def sort_resource_nb(contact):
+ """Sort by number of connected resources"""
+ return - len(contact)
+
+def sort_name(contact):
+ """Sort by name (case insensitive)"""
+ return contact.name.lower() or contact.bare_jid
+
+def sort_sname(contact):
+ """Sort by name (case sensitive)"""
+ return contact.name or contact.bare_jid
+
+def sort_online(contact):
+ """Sort by connected/disconnected"""
+ result = sort_show(contact)
+ return 0 if result < 5 else 1
+
+SORTING_METHODS = {
+ 'jid': sort_jid,
+ 'sname': sort_sname,
+ 'show': sort_show,
+ 'resource': sort_resource_nb,
+ 'name': sort_name,
+ 'online': sort_online,
+}
+
+
+######################## Roster Groups sorting ##########################
+
+def sort_group_name(group):
+ """Sort by name (case insensitive)"""
+ return group.name.lower()
+
+def sort_group_sname(group):
+ """Sort by name (case-sensitive)"""
+ return group.name
+
+def sort_group_folded(group):
+ """Sort by folded/unfolded"""
+ return group.folded
+
+def sort_group_connected(group):
+ """Sort by number of connected contacts"""
+ return - group.get_nb_connected_contacts()
+
+def sort_group_size(group):
+ """Sort by group size"""
+ return - len(group)
+
+def sort_group_none(group):
+ """Put the none group at the end, if any"""
+ return 0 if group.name != 'none' else 1
+
+GROUP_SORTING_METHODS = {
+ 'name': sort_group_name,
+ 'fold': sort_group_folded,
+ 'connected': sort_group_connected,
+ 'size': sort_group_size,
+ 'none': sort_group_none,
+ 'sname': sort_group_sname,
+}
+
diff --git a/poezio/singleton.py b/poezio/singleton.py
new file mode 100644
index 00000000..9133012b
--- /dev/null
+++ b/poezio/singleton.py
@@ -0,0 +1,20 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Defines a Singleton function that initialize an object
+of the given class if it was never instantiated yet. Else, returns
+the previously instantiated object.
+This method is the only one that I can come up with that do not call
+__init__() each time.
+"""
+
+instances = {}
+def Singleton(cls, *args, **kwargs):
+ if not cls in instances:
+ instances[cls] = cls(*args, **kwargs)
+ return instances[cls]
diff --git a/poezio/size_manager.py b/poezio/size_manager.py
new file mode 100644
index 00000000..1cad83fd
--- /dev/null
+++ b/poezio/size_manager.py
@@ -0,0 +1,46 @@
+"""
+Size Manager:
+ used to check size boundaries of the whole window and
+ specific tabs
+"""
+THRESHOLD_WIDTH_DEGRADE = 45
+THRESHOLD_HEIGHT_DEGRADE = 10
+
+FULL_WIDTH_DEGRADE = 66
+FULL_HEIGHT_DEGRADE = 10
+
+class SizeManager(object):
+
+ def __init__(self, core, win_cls):
+ self._win_class = win_cls
+ self._core = core
+
+ @property
+ def tab_scr(self):
+ return self._win_class._tab_win
+
+ @property
+ def core_scr(self):
+ return self._core.stdscr
+
+ @property
+ def tab_degrade_x(self):
+ _, x = self.tab_scr.getmaxyx()
+ return x < THRESHOLD_WIDTH_DEGRADE
+
+ @property
+ def tab_degrade_y(self):
+ y, x = self.tab_scr.getmaxyx()
+ return y < THRESHOLD_HEIGHT_DEGRADE
+
+ @property
+ def core_degrade_x(self):
+ y, x = self.core_scr.getmaxyx()
+ return x < FULL_WIDTH_DEGRADE
+
+ @property
+ def core_degrade_y(self):
+ y, x = self.core_scr.getmaxyx()
+ return y < FULL_HEIGHT_DEGRADE
+
+
diff --git a/poezio/tabs/__init__.py b/poezio/tabs/__init__.py
new file mode 100644
index 00000000..d0a881a6
--- /dev/null
+++ b/poezio/tabs/__init__.py
@@ -0,0 +1,13 @@
+from . basetabs import Tab, ChatTab, GapTab, OneToOneTab
+from . basetabs import STATE_PRIORITY
+from . rostertab import RosterInfoTab
+from . muctab import MucTab, NS_MUC_USER
+from . privatetab import PrivateTab
+from . conversationtab import ConversationTab, StaticConversationTab,\
+ DynamicConversationTab
+from . xmltab import XMLTab
+from . listtab import ListTab
+from . muclisttab import MucListTab
+from . adhoc_commands_list import AdhocCommandsListTab
+from . data_forms import DataFormsTab
+from . bookmarkstab import BookmarksTab
diff --git a/poezio/tabs/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py
new file mode 100644
index 00000000..10ebf22b
--- /dev/null
+++ b/poezio/tabs/adhoc_commands_list.py
@@ -0,0 +1,57 @@
+"""
+A tab listing the ad-hoc commands on a specific JID. The user can
+select one of them and start executing it, or just close the tab and do
+nothing.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+from . import ListTab
+
+from slixmpp.plugins.xep_0030.stanza.items import DiscoItem
+
+class AdhocCommandsListTab(ListTab):
+ plugin_commands = {}
+ plugin_keys = {}
+
+ def __init__(self, jid):
+ ListTab.__init__(self, jid.full,
+ "“Enter”: execute selected command.",
+ 'Ad-hoc commands of JID %s (Loading)' % jid,
+ (('Node', 0), ('Description', 1)))
+ self.key_func['^M'] = self.execute_selected_command
+
+ def execute_selected_command(self):
+ if not self.listview or not self.listview.get_selected_row():
+ return
+ node, name, jid = self.listview.get_selected_row()
+ session = {'next': self.core.on_next_adhoc_step,
+ 'error': self.core.on_adhoc_error}
+ self.core.xmpp.plugin['xep_0050'].start_command(jid, node, session)
+
+ def get_columns_sizes(self):
+ return {'Node': int(self.width * 3 / 8),
+ 'Description': int(self.width * 5 / 8)}
+
+ def on_list_received(self, iq):
+ """
+ Fill the listview with the value from the received iq
+ """
+ if iq['type'] == 'error':
+ self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text'])
+ return
+ def get_items():
+ substanza = iq['disco_items']
+ for item in substanza['substanzas']:
+ if isinstance(item, DiscoItem):
+ yield item
+ items = [(item['node'], item['name'] or '', item['jid']) for item in get_items()]
+ self.listview.set_lines(items)
+ self.info_header.message = 'Ad-hoc commands of JID %s' % self.name
+ if self.core.current_tab() is self:
+ self.refresh()
+ else:
+ self.state = 'highlight'
+ self.refresh_tab_win()
+ self.core.doupdate()
diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py
new file mode 100644
index 00000000..bb0c0ea4
--- /dev/null
+++ b/poezio/tabs/basetabs.py
@@ -0,0 +1,881 @@
+"""
+Module for the base Tabs
+
+The root class Tab defines the generic interface and attributes of a
+tab. A tab organizes various Windows around the screen depending
+of the tab specificity. If the tab shows messages, it will also
+reference a buffer containing the messages.
+
+Each subclass should redefine its own refresh() and resize() method
+according to its windows.
+
+This module also defines ChatTabs, the parent class for all tabs
+revolving around chats.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import singleton
+import string
+import time
+import weakref
+from datetime import datetime, timedelta
+from xml.etree import cElementTree as ET
+
+import core
+import timed_events
+import windows
+import xhtml
+from common import safeJID
+from config import config
+from decorators import refresh_wrapper
+from logger import logger
+from text_buffer import TextBuffer
+from theming import get_theme, dump_tuple
+from decorators import command_args_parser
+
+# getters for tab colors (lambdas, so that they are dynamic)
+STATE_COLORS = {
+ 'disconnected': lambda: get_theme().COLOR_TAB_DISCONNECTED,
+ 'scrolled': lambda: get_theme().COLOR_TAB_SCROLLED,
+ 'nonempty': lambda: get_theme().COLOR_TAB_NONEMPTY,
+ 'joined': lambda: get_theme().COLOR_TAB_JOINED,
+ 'message': lambda: get_theme().COLOR_TAB_NEW_MESSAGE,
+ 'composing': lambda: get_theme().COLOR_TAB_COMPOSING,
+ 'highlight': lambda: get_theme().COLOR_TAB_HIGHLIGHT,
+ 'private': lambda: get_theme().COLOR_TAB_PRIVATE,
+ 'normal': lambda: get_theme().COLOR_TAB_NORMAL,
+ 'current': lambda: get_theme().COLOR_TAB_CURRENT,
+ 'attention': lambda: get_theme().COLOR_TAB_ATTENTION,
+ }
+VERTICAL_STATE_COLORS = {
+ 'disconnected': lambda: get_theme().COLOR_VERTICAL_TAB_DISCONNECTED,
+ 'scrolled': lambda: get_theme().COLOR_VERTICAL_TAB_SCROLLED,
+ 'nonempty': lambda: get_theme().COLOR_VERTICAL_TAB_NONEMPTY,
+ 'joined': lambda: get_theme().COLOR_VERTICAL_TAB_JOINED,
+ 'message': lambda: get_theme().COLOR_VERTICAL_TAB_NEW_MESSAGE,
+ 'composing': lambda: get_theme().COLOR_VERTICAL_TAB_COMPOSING,
+ 'highlight': lambda: get_theme().COLOR_VERTICAL_TAB_HIGHLIGHT,
+ 'private': lambda: get_theme().COLOR_VERTICAL_TAB_PRIVATE,
+ 'normal': lambda: get_theme().COLOR_VERTICAL_TAB_NORMAL,
+ 'current': lambda: get_theme().COLOR_VERTICAL_TAB_CURRENT,
+ 'attention': lambda: get_theme().COLOR_VERTICAL_TAB_ATTENTION,
+ }
+
+
+# priority of the different tab states when using Alt+e
+# higher means more priority, < 0 means not selectable
+STATE_PRIORITY = {
+ 'normal': -1,
+ 'current': -1,
+ 'disconnected': 0,
+ 'nonempty': 0.1,
+ 'scrolled': 0.5,
+ 'joined': 0.8,
+ 'composing': 0.9,
+ 'message': 1,
+ 'highlight': 2,
+ 'private': 2,
+ 'attention': 3
+ }
+
+class Tab(object):
+ tab_core = None
+ size_manager = None
+
+ plugin_commands = {}
+ plugin_keys = {}
+ def __init__(self):
+ if not hasattr(self, 'name'):
+ self.name = self.__class__.__name__
+ self.input = None
+ self.closed = False
+ self._state = 'normal'
+ self._prev_state = None
+
+ self.need_resize = False
+ self.key_func = {} # each tab should add their keys in there
+ # and use them in on_input
+ self.commands = {} # and their own commands
+
+
+ @property
+ def size(self):
+ if not Tab.size_manager:
+ Tab.size_manager = self.core.size
+ return Tab.size_manager
+
+ @property
+ def core(self):
+ if not Tab.tab_core:
+ Tab.tab_core = singleton.Singleton(core.Core)
+ return Tab.tab_core
+
+ @property
+ def nb(self):
+ for index, tab in enumerate(self.core.tabs):
+ if tab == self:
+ return index
+ return len(self.core.tabs)
+
+ @property
+ def tab_win(self):
+ if not Tab.tab_core:
+ Tab.tab_core = singleton.Singleton(core.Core)
+ return Tab.tab_core.tab_win
+
+ @property
+ def left_tab_win(self):
+ if not Tab.tab_core:
+ Tab.tab_core = singleton.Singleton(core.Core)
+ return Tab.tab_core.left_tab_win
+
+ @staticmethod
+ def tab_win_height():
+ """
+ Returns 1 or 0, depending on if we are using the vertical tab list
+ or not.
+ """
+ if config.get('enable_vertical_tab_list'):
+ return 0
+ return 1
+
+ @property
+ def info_win(self):
+ return self.core.information_win
+
+ @property
+ def color(self):
+ return STATE_COLORS[self._state]()
+
+ @property
+ def vertical_color(self):
+ return VERTICAL_STATE_COLORS[self._state]()
+
+ @property
+ def state(self):
+ return self._state
+
+ @state.setter
+ def state(self, value):
+ if not value in STATE_COLORS:
+ log.debug("Invalid value for tab state: %s", value)
+ elif STATE_PRIORITY[value] < STATE_PRIORITY[self._state] and \
+ value not in ('current', 'disconnected') and \
+ not (self._state == 'scrolled' and value == 'disconnected'):
+ log.debug("Did not set state because of lower priority, asked: %s, kept: %s", value, self._state)
+ elif self._state == 'disconnected' and value not in ('joined', 'current'):
+ log.debug('Did not set state because disconnected tabs remain visible')
+ else:
+ self._state = value
+ if self._state == 'current':
+ self._prev_state = None
+
+ def set_state(self, value):
+ self._state = value
+
+ def save_state(self):
+ if self._state != 'composing':
+ self._prev_state = self._state
+
+ def restore_state(self):
+ if self.state == 'composing' and self._prev_state:
+ self._state = self._prev_state
+ self._prev_state = None
+ elif not self._prev_state:
+ self._state = 'normal'
+
+ @staticmethod
+ def resize(scr):
+ Tab.height, Tab.width = scr.getmaxyx()
+ windows.Win._tab_win = scr
+
+ def missing_command_callback(self, command_name):
+ """
+ Callback executed when a command is not found.
+ Returns True if the callback took care of displaying
+ the error message, False otherwise.
+ """
+ return False
+
+ def register_command(self, name, func, *, desc='', shortdesc='', completion=None, usage=''):
+ """
+ Add a command
+ """
+ if name in self.commands:
+ return
+ if not desc and shortdesc:
+ desc = shortdesc
+ self.commands[name] = core.Command(func, desc, completion, shortdesc, usage)
+
+ def complete_commands(self, the_input):
+ """
+ Does command completion on the specified input for both global and tab-specific
+ commands.
+ This should be called from the completion method (on tab, for example), passing
+ the input where completion is to be made.
+ It can completion the command name itself or an argument of the command.
+ Returns True if a completion was made, False else.
+ """
+ txt = the_input.get_text()
+ # check if this is a command
+ if txt.startswith('/') and not txt.startswith('//'):
+ position = the_input.get_argument_position(quoted=False)
+ if position == 0:
+ words = ['/%s'% (name) for name in sorted(self.core.commands)] +\
+ ['/%s' % (name) for name in sorted(self.commands)]
+ the_input.new_completion(words, 0)
+ # Do not try to cycle command completion if there was only
+ # one possibily. The next tab will complete the argument.
+ # Otherwise we would need to add a useless space before being
+ # able to complete the arguments.
+ hit_copy = set(the_input.hit_list)
+ while not hit_copy:
+ whitespace = the_input.text.find(' ')
+ if whitespace == -1:
+ whitespace = len(the_input.text)
+ the_input.text = the_input.text[:whitespace-1] + the_input.text[whitespace:]
+ the_input.new_completion(words, 0)
+ hit_copy = set(the_input.hit_list)
+ if len(hit_copy) == 1:
+ the_input.do_command(' ')
+ the_input.reset_completion()
+ return True
+ # check if we are in the middle of the command name
+ elif len(txt.split()) > 1 or\
+ (txt.endswith(' ') and not the_input.last_completion):
+ command_name = txt.split()[0][1:]
+ if command_name in self.commands:
+ command = self.commands[command_name]
+ elif command_name in self.core.commands:
+ command = self.core.commands[command_name]
+ else: # Unknown command, cannot complete
+ return False
+ if command[2] is None:
+ return False # There's no completion function
+ else:
+ return command[2](the_input)
+ return False
+
+ def execute_command(self, provided_text):
+ """
+ Execute the command in the input and return False if
+ the input didn't contain a command
+ """
+ txt = provided_text or self.input.key_enter()
+ if txt.startswith('/') and not txt.startswith('//') and\
+ not txt.startswith('/me '):
+ command = txt.strip().split()[0][1:]
+ arg = txt[2+len(command):] # jump the '/' and the ' '
+ func = None
+ if command in self.commands: # check tab-specific commands
+ func = self.commands[command][0]
+ elif command in self.core.commands: # check global commands
+ func = self.core.commands[command][0]
+ else:
+ low = command.lower()
+ if low in self.commands:
+ func = self.commands[low][0]
+ elif low in self.core.commands:
+ func = self.core.commands[low][0]
+ else:
+ if self.missing_command_callback is not None:
+ error_handled = self.missing_command_callback(low)
+ if not error_handled:
+ self.core.information("Unknown command (%s)" %
+ (command),
+ 'Error')
+ if command in ('correct', 'say'): # hack
+ arg = xhtml.convert_simple_to_full_colors(arg)
+ else:
+ arg = xhtml.clean_text_simple(arg)
+ if func:
+ if hasattr(self.input, "reset_completion"):
+ self.input.reset_completion()
+ func(arg)
+ return True
+ else:
+ return False
+
+ def refresh_tab_win(self):
+ if config.get('enable_vertical_tab_list'):
+ if self.left_tab_win and not self.size.core_degrade_x:
+ self.left_tab_win.refresh()
+ elif not self.size.core_degrade_y:
+ self.tab_win.refresh()
+
+ def refresh(self):
+ """
+ Called on each screen refresh (when something has changed)
+ """
+ pass
+
+ def get_name(self):
+ """
+ get the name of the tab
+ """
+ return self.name
+
+ def get_nick(self):
+ """
+ Get the nick of the tab (defaults to its name)
+ """
+ return self.name
+
+ def get_text_window(self):
+ """
+ Returns the principal TextWin window, if there's one
+ """
+ return None
+
+ def on_input(self, key, raw):
+ """
+ raw indicates if the key should activate the associated command or not.
+ """
+ pass
+
+ def update_commands(self):
+ for c in self.plugin_commands:
+ if not c in self.commands:
+ self.commands[c] = self.plugin_commands[c]
+
+ def update_keys(self):
+ for k in self.plugin_keys:
+ if not k in self.key_func:
+ self.key_func[k] = self.plugin_keys[k]
+
+ def on_lose_focus(self):
+ """
+ called when this tab loses the focus.
+ """
+ self.state = 'normal'
+
+ def on_gain_focus(self):
+ """
+ called when this tab gains the focus.
+ """
+ self.state = 'current'
+
+ def on_scroll_down(self):
+ """
+ Defines what happens when we scroll down
+ """
+ pass
+
+ def on_scroll_up(self):
+ """
+ Defines what happens when we scroll up
+ """
+ pass
+
+ def on_line_up(self):
+ """
+ Defines what happens when we scroll one line up
+ """
+ pass
+
+ def on_line_down(self):
+ """
+ Defines what happens when we scroll one line up
+ """
+ pass
+
+ def on_half_scroll_down(self):
+ """
+ Defines what happens when we scroll half a screen down
+ """
+ pass
+
+ def on_half_scroll_up(self):
+ """
+ Defines what happens when we scroll half a screen up
+ """
+ pass
+
+ def on_info_win_size_changed(self):
+ """
+ Called when the window with the informations is resized
+ """
+ pass
+
+ def on_close(self):
+ """
+ Called when the tab is to be closed
+ """
+ if self.input:
+ self.input.on_delete()
+ self.closed = True
+
+ def matching_names(self):
+ """
+ Returns a list of strings that are used to name a tab with the /win
+ command. For example you could switch to a tab that returns
+ ['hello', 'coucou'] using /win hel, or /win coucou
+ If not implemented in the tab, it just doesn’t match with anything.
+ """
+ return []
+
+ def __del__(self):
+ log.debug('------ Closing tab %s', self.__class__.__name__)
+
+class GapTab(Tab):
+
+ def __bool__(self):
+ return False
+
+ def __len__(self):
+ return 0
+
+ @property
+ def name(self):
+ return ''
+
+ def refresh(self):
+ log.debug('WARNING: refresh() called on a gap tab, this should not happen')
+
+class ChatTab(Tab):
+ """
+ A tab containing a chat of any type.
+ Just use this class instead of Tab if the tab needs a recent-words completion
+ Also, ^M is already bound to on_enter
+ And also, add the /say command
+ """
+ plugin_commands = {}
+ plugin_keys = {}
+ def __init__(self, jid=''):
+ Tab.__init__(self)
+ self.name = jid
+ self.text_win = None
+ self._text_buffer = TextBuffer()
+ self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive"
+ # We keep a reference of the event that will set our chatstate to "paused", so that
+ # we can delete it or change it if we need to
+ self.timed_event_paused = None
+ # Keeps the last sent message to complete it easily in completion_correct, and to replace it.
+ self.last_sent_message = None
+ self.key_func['M-v'] = self.move_separator
+ self.key_func['M-h'] = self.scroll_separator
+ self.key_func['M-/'] = self.last_words_completion
+ self.key_func['^M'] = self.on_enter
+ self.register_command('say', self.command_say,
+ usage='<message>',
+ shortdesc='Send the message.')
+ self.register_command('xhtml', self.command_xhtml,
+ usage='<custom xhtml>',
+ shortdesc='Send custom XHTML.')
+ self.register_command('clear', self.command_clear,
+ shortdesc='Clear the current buffer.')
+ self.register_command('correct', self.command_correct,
+ desc='Fix the last message with whatever you want.',
+ shortdesc='Correct the last message.',
+ completion=self.completion_correct)
+ self.chat_state = None
+ self.update_commands()
+ self.update_keys()
+
+ # Get the logs
+ log_nb = config.get('load_log')
+ logs = self.load_logs(log_nb)
+
+ if logs:
+ for message in logs:
+ self._text_buffer.add_message(**message)
+
+ @property
+ def is_muc(self):
+ return False
+
+ def load_logs(self, log_nb):
+ logs = logger.get_logs(safeJID(self.name).bare, log_nb)
+ return logs
+
+ def log_message(self, txt, nickname, time=None, typ=1):
+ """
+ Log the messages in the archives.
+ """
+ name = safeJID(self.name).bare
+ if not logger.log_message(name, nickname, txt, date=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 modify_message(self, txt, old_id, new_id, 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)
+ if message:
+ self.text_win.modify_message(old_id, message)
+ self.core.refresh_window()
+ return True
+ return False
+
+ def last_words_completion(self):
+ """
+ Complete the input with words recently said
+ """
+ # build the list of the recent words
+ char_we_dont_want = string.punctuation+' ’„“”…«»'
+ words = list()
+ for msg in self._text_buffer.messages[:-40:-1]:
+ if not msg:
+ continue
+ txt = xhtml.clean_text(msg.txt)
+ for char in char_we_dont_want:
+ txt = txt.replace(char, ' ')
+ for word in txt.split():
+ if len(word) >= 4 and word not in words:
+ words.append(word)
+ words.extend([word for word in config.get('words').split(':') if word])
+ self.input.auto_completion(words, ' ', quotify=False)
+
+ def on_enter(self):
+ txt = self.input.key_enter()
+ if txt:
+ if not self.execute_command(txt):
+ if txt.startswith('//'):
+ txt = txt[1:]
+ self.command_say(xhtml.convert_simple_to_full_colors(txt))
+ self.cancel_paused_delay()
+
+ @command_args_parser.raw
+ def command_xhtml(self, xhtml):
+ """"
+ /xhtml <custom xhtml>
+ """
+ message = self.generate_xhtml_message(xhtml)
+ if message:
+ message.send()
+
+ def generate_xhtml_message(self, arg):
+ if not arg:
+ return
+ try:
+ body = xhtml.clean_text(xhtml.xhtml_to_poezio_colors(arg))
+ ET.fromstring(arg)
+ except:
+ self.core.information('Could not send custom xhtml', 'Error')
+ log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
+ return
+
+ msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg['body'] = body
+ msg.enable('html')
+ msg['html']['body'] = arg
+ return msg
+
+ def get_dest_jid(self):
+ return self.name
+
+ @refresh_wrapper.always
+ def command_clear(self, ignored):
+ """
+ /clear
+ """
+ self._text_buffer.messages = []
+ self.text_win.rebuild_everything(self._text_buffer)
+
+ def send_chat_state(self, state, always_send=False):
+ """
+ Send an empty chatstate message
+ """
+ if not self.is_muc or self.joined:
+ if state in ('active', 'inactive', 'gone') and self.inactive and not always_send:
+ return
+ if (config.get_by_tabname('send_chat_states', self.general_jid)
+ and self.remote_wants_chatstates is not False):
+ msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg['type'] = self.message_type
+ msg['chat_state'] = state
+ self.chat_state = state
+ msg.send()
+ return True
+
+ def send_composing_chat_state(self, empty_after):
+ """
+ Send the "active" or "composing" chatstate, depending
+ on the the current status of the input
+ """
+ name = self.general_jid
+ if (config.get_by_tabname('send_chat_states', name)
+ and self.remote_wants_chatstates):
+ needed = 'inactive' if self.inactive else 'active'
+ self.cancel_paused_delay()
+ if not empty_after:
+ if self.chat_state != "composing":
+ self.send_chat_state("composing")
+ self.set_paused_delay(True)
+ elif empty_after and self.chat_state != needed:
+ self.send_chat_state(needed, True)
+
+ def set_paused_delay(self, composing):
+ """
+ we create a timed event that will put us to paused
+ in a few seconds
+ """
+ if not config.get_by_tabname('send_chat_states', self.general_jid):
+ return
+ # First, cancel the delay if it already exists, before rescheduling
+ # it at a new date
+ self.cancel_paused_delay()
+ new_event = timed_events.DelayedEvent(4, self.send_chat_state, 'paused')
+ self.core.add_timed_event(new_event)
+ self.timed_event_paused = new_event
+
+ def cancel_paused_delay(self):
+ """
+ Remove that event from the list and set it to None.
+ Called for example when the input is emptied, or when the message
+ is sent
+ """
+ if self.timed_event_paused is not None:
+ self.core.remove_timed_event(self.timed_event_paused)
+ self.timed_event_paused = None
+
+ @command_args_parser.raw
+ def command_correct(self, line):
+ """
+ /correct <fixed message>
+ """
+ if not line:
+ self.core.command_help('correct')
+ return
+ if not self.last_sent_message:
+ self.core.information('There is no message to correct.')
+ return
+ self.command_say(line, correct=True)
+
+ def completion_correct(self, the_input):
+ if self.last_sent_message and the_input.get_argument_position() == 1:
+ return the_input.auto_completion([self.last_sent_message['body']], '', quotify=False)
+
+ @property
+ def inactive(self):
+ """Whether we should send inactive or active as a chatstate"""
+ return self.core.status.show in ('xa', 'away') or\
+ (hasattr(self, 'directed_presence') and not self.directed_presence)
+
+ def move_separator(self):
+ self.text_win.remove_line_separator()
+ self.text_win.add_line_separator(self._text_buffer)
+ self.text_win.refresh()
+ self.input.refresh()
+
+ def get_conversation_messages(self):
+ return self._text_buffer.messages
+
+ def check_scrolled(self):
+ if self.text_win.pos != 0:
+ self.state = 'scrolled'
+
+ @command_args_parser.raw
+ def command_say(self, line, correct=False):
+ pass
+
+ def on_line_up(self):
+ return self.text_win.scroll_up(1)
+
+ def on_line_down(self):
+ return self.text_win.scroll_down(1)
+
+ def on_scroll_up(self):
+ return self.text_win.scroll_up(self.text_win.height-1)
+
+ def on_scroll_down(self):
+ return self.text_win.scroll_down(self.text_win.height-1)
+
+ def on_half_scroll_up(self):
+ return self.text_win.scroll_up((self.text_win.height-1) // 2)
+
+ def on_half_scroll_down(self):
+ return self.text_win.scroll_down((self.text_win.height-1) // 2)
+
+ @refresh_wrapper.always
+ def scroll_separator(self):
+ self.text_win.scroll_to_separator()
+
+class OneToOneTab(ChatTab):
+
+ def __init__(self, jid=''):
+ ChatTab.__init__(self, jid)
+
+ # Set to true once the first disco is done
+ self.__initial_disco = False
+ # change this to True or False when
+ # we know that the remote user wants chatstates, or not.
+ # None means we don’t know yet, and we send only "active" chatstates
+ self._remote_wants_chatstates = None
+ self.remote_supports_attention = True
+ self.remote_supports_receipts = True
+ self.check_features()
+
+ @property
+ def remote_wants_chatstates(self):
+ return self._remote_wants_chatstates
+
+ @remote_wants_chatstates.setter
+ def remote_wants_chatstates(self, value):
+ old_value = self._remote_wants_chatstates
+ self._remote_wants_chatstates = value
+ if (old_value is None and value != None) or \
+ (old_value != value and value != None):
+ ok = get_theme().CHAR_OK
+ nope = get_theme().CHAR_EMPTY
+ support = ok if value else nope
+ if value:
+ msg = '\x19%s}Contact supports chat states [%s].'
+ else:
+ msg = '\x19%s}Contact does not support chat states [%s].'
+ color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ msg = msg % (color, support)
+ self.add_message(msg, typ=0)
+ self.core.refresh_window()
+
+ def ack_message(self, msg_id, msg_jid):
+ """
+ Ack a message
+ """
+ new_msg = self._text_buffer.ack_message(msg_id, msg_jid)
+ if new_msg:
+ self.text_win.modify_message(msg_id, new_msg)
+ self.core.refresh_window()
+
+ def nack_message(self, error, msg_id, msg_jid):
+ """
+ Ack a message
+ """
+ new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid)
+ if new_msg:
+ self.text_win.modify_message(msg_id, new_msg)
+ self.core.refresh_window()
+ return True
+ return False
+
+ @command_args_parser.raw
+ def command_xhtml(self, xhtml_data):
+ message = self.generate_xhtml_message(xhtml_data)
+ if message:
+ message['type'] = 'chat'
+ if self.remote_supports_receipts:
+ message._add_receipt = True
+ if self.remote_wants_chatstates:
+ message['chat_sate'] = 'active'
+ message.send()
+ body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True)
+ self._text_buffer.add_message(body, nickname=self.core.own_nick,
+ identifier=message['id'],)
+ self.refresh()
+
+ def check_features(self):
+ "check the features supported by the other party"
+ if safeJID(self.get_dest_jid()).resource:
+ self.core.xmpp.plugin['xep_0030'].get_info(
+ jid=self.get_dest_jid(), timeout=5,
+ callback=self.features_checked)
+
+ @command_args_parser.raw
+ def command_attention(self, message):
+ """/attention [message]"""
+ if message is not '':
+ self.command_say(message, attention=True)
+ else:
+ msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg['type'] = 'chat'
+ msg['attention'] = True
+ msg.send()
+
+ @command_args_parser.raw
+ def command_say(self, line, correct=False, attention=False):
+ pass
+
+ def missing_command_callback(self, command_name):
+ if command_name not in ('correct', 'attention'):
+ return False
+
+ if command_name == 'correct':
+ feature = 'message correction'
+ elif command_name == 'attention':
+ feature = 'attention requests'
+ msg = ('%s does not support %s, therefore the /%s '
+ 'command is currently disabled in this tab.')
+ msg = msg % (self.name, feature, command_name)
+ self.core.information(msg, 'Info')
+ return True
+
+ def _feature_attention(self, features):
+ "Check for the 'attention' features"
+ if 'urn:xmpp:attention:0' in features:
+ self.remote_supports_attention = True
+ self.register_command('attention', self.command_attention,
+ usage='[message]',
+ shortdesc='Request the attention.',
+ desc='Attention: Request the attention of '
+ 'the contact. Can also send a message'
+ ' along with the attention.')
+ else:
+ self.remote_supports_attention = False
+ return self.remote_supports_attention
+
+ def _feature_correct(self, features):
+ "Check for the 'correction' feature"
+ if not 'urn:xmpp:message-correct:0' in features:
+ if 'correct' in self.commands:
+ del self.commands['correct']
+ elif not 'correct' in self.commands:
+ self.register_command('correct', self.command_correct,
+ desc='Fix the last message with whatever you want.',
+ shortdesc='Correct the last message.',
+ completion=self.completion_correct)
+ return 'correct' in self.commands
+
+ def _feature_receipts(self, features):
+ "Check for the 'receipts' feature"
+ if 'urn:xmpp:receipts' in features:
+ self.remote_supports_receipts = True
+ else:
+ self.remote_supports_receipts = False
+ return self.remote_supports_receipts
+
+ def features_checked(self, iq):
+ "Features check callback"
+ features = iq['disco_info'].get_features() or []
+ before = ('correct' in self.commands,
+ self.remote_supports_attention,
+ self.remote_supports_receipts)
+ correct = self._feature_correct(features)
+ attention = self._feature_attention(features)
+ receipts = self._feature_receipts(features)
+
+ if (correct, attention, receipts) == before and self.__initial_disco:
+ return
+ else:
+ self.__initial_disco = True
+
+ if not (correct or attention or receipts):
+ return # don’t display anything
+
+ ok = get_theme().CHAR_OK
+ nope = get_theme().CHAR_EMPTY
+
+ correct = ok if correct else nope
+ attention = ok if attention else nope
+ receipts = ok if receipts else nope
+
+ msg = ('\x19%s}Contact supports: correction [%s], '
+ 'attention [%s], receipts [%s].')
+ color = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ msg = msg % (color, correct, attention, receipts)
+ self.add_message(msg, typ=0)
+ self.core.refresh_window()
+
+
diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py
new file mode 100644
index 00000000..7f5069ea
--- /dev/null
+++ b/poezio/tabs/bookmarkstab.py
@@ -0,0 +1,145 @@
+"""
+Defines the data-forms Tab
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import windows
+from bookmarks import Bookmark, BookmarkList, stanza_storage
+from tabs import Tab
+from common import safeJID
+
+
+class BookmarksTab(Tab):
+ """
+ A tab displaying lines of bookmarks, each bookmark having
+ a 4 widgets to set the jid/password/autojoin/storage method
+ """
+ plugin_commands = {}
+ def __init__(self, bookmarks: BookmarkList):
+ Tab.__init__(self)
+ self.name = "Bookmarks"
+ self.bookmarks = bookmarks
+ self.new_bookmarks = []
+ self.removed_bookmarks = []
+ self.header_win = windows.ColumnHeaderWin(('room@server/nickname',
+ 'password',
+ 'autojoin',
+ 'storage'))
+ self.bookmarks_win = windows.BookmarksWin(self.bookmarks,
+ self.height-4,
+ self.width, 1, 0)
+ self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, '
+ '↑↓: change lines, tab: change '
+ 'column, M-a: add bookmark, C-k'
+ ': delete bookmark')
+ self.info_header = windows.BookmarksInfoWin()
+ self.key_func['KEY_UP'] = self.bookmarks_win.go_to_previous_line_input
+ self.key_func['KEY_DOWN'] = self.bookmarks_win.go_to_next_line_input
+ self.key_func['^I'] = self.bookmarks_win.go_to_next_horizontal_input
+ self.key_func['^G'] = self.on_cancel
+ self.key_func['^Y'] = self.on_save
+ self.key_func['M-a'] = self.add_bookmark
+ self.key_func['^K'] = self.del_bookmark
+ self.resize()
+ self.update_commands()
+
+ def add_bookmark(self):
+ new_bookmark = Bookmark(safeJID('room@example.tld/nick'), method='local')
+ self.new_bookmarks.append(new_bookmark)
+ self.bookmarks_win.add_bookmark(new_bookmark)
+
+ def del_bookmark(self):
+ current = self.bookmarks_win.del_current_bookmark()
+ if current in self.new_bookmarks:
+ self.new_bookmarks.remove(current)
+ else:
+ self.removed_bookmarks.append(current)
+
+ def on_cancel(self):
+ self.core.close_tab()
+ return True
+
+ def on_save(self):
+ self.bookmarks_win.save()
+ if find_duplicates(self.new_bookmarks):
+ self.core.information('Duplicate bookmarks in list (saving aborted)', 'Error')
+ return
+ for bm in self.new_bookmarks:
+ if safeJID(bm.jid):
+ if not self.bookmarks[bm.jid]:
+ self.bookmarks.append(bm)
+ else:
+ self.core.information('Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick), 'Error')
+ return
+
+ for bm in self.removed_bookmarks:
+ if bm in self.bookmarks:
+ self.bookmarks.remove(bm)
+
+ def send_cb(success):
+ if success:
+ self.core.information('Bookmarks saved.', 'Info')
+ else:
+ self.core.information('Remote bookmarks not saved.', 'Error')
+ log.debug('alerte %s', str(stanza_storage(self.bookmarks.bookmarks)))
+ self.bookmarks.save(self.core.xmpp, callback=send_cb)
+ self.core.close_tab()
+ return True
+
+ def on_input(self, key, raw=False):
+ if key in self.key_func:
+ res = self.key_func[key]()
+ if res:
+ return res
+ self.bookmarks_win.refresh_current_input()
+ else:
+ self.bookmarks_win.on_input(key)
+
+ def resize(self):
+ self.need_resize = False
+ self.header_win.resize_columns({
+ 'room@server/nickname': self.width//3,
+ 'password': self.width//3,
+ 'autojoin': self.width//6,
+ 'storage': self.width//6
+ })
+ info_height = self.core.information_win_size
+ tab_height = Tab.tab_win_height()
+ self.header_win.resize(1, self.width, 0, 0)
+ self.bookmarks_win.resize(self.height - 3 - tab_height - info_height,
+ self.width, 1, 0)
+ self.help_win.resize(1, self.width, self.height - 1, 0)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - tab_height - info_height, 0)
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height - 3:
+ return
+ info_height = self.core.information_win_size
+ tab_height = Tab.tab_win_height()
+ self.bookmarks_win.resize(self.height - 3 - tab_height - info_height,
+ self.width, 1, 0)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - tab_height - info_height, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ self.header_win.refresh()
+ self.refresh_tab_win()
+ self.help_win.refresh()
+ self.info_header.refresh(self.bookmarks.preferred)
+ self.info_win.refresh()
+ self.bookmarks_win.refresh()
+
+
+def find_duplicates(bm_list):
+ jids = set()
+ for bookmark in bm_list:
+ if bookmark.jid in jids:
+ return True
+ jids.add(bookmark.jid)
+ return False
+
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
new file mode 100644
index 00000000..1d8c60a4
--- /dev/null
+++ b/poezio/tabs/conversationtab.py
@@ -0,0 +1,484 @@
+"""
+Module for the ConversationTabs
+
+A ConversationTab is a direct chat between two JIDs, outside of a room.
+
+There are two different instances of a ConversationTab:
+- A DynamicConversationTab that implements XEP-0296 (best practices for
+ resource locking), which means it will switch the resource it is
+ focused on depending on the presences received. This is the default.
+- A StaticConversationTab that will stay focused on one resource all
+ the time.
+
+"""
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+from . basetabs import OneToOneTab, Tab
+
+import common
+import fixes
+import windows
+import xhtml
+from common import safeJID
+from config import config
+from decorators import refresh_wrapper
+from roster import roster
+from theming import get_theme, dump_tuple
+from decorators import command_args_parser
+
+class ConversationTab(OneToOneTab):
+ """
+ The tab containg a normal conversation (not from a MUC)
+ Must not be instantiated, use Static or Dynamic version only.
+ """
+ plugin_commands = {}
+ plugin_keys = {}
+ additional_informations = {}
+ message_type = 'chat'
+ def __init__(self, jid):
+ OneToOneTab.__init__(self, jid)
+ self.nick = None
+ self.nick_sent = False
+ self.state = 'normal'
+ self.name = jid # a conversation tab is linked to one specific full jid OR bare jid
+ self.text_win = windows.TextWin()
+ self._text_buffer.add_window(self.text_win)
+ self.upper_bar = windows.ConversationStatusMessageWin()
+ self.input = windows.MessageInput()
+ # keys
+ self.key_func['^I'] = self.completion
+ # commands
+ self.register_command('unquery', self.command_unquery,
+ shortdesc='Close the tab.')
+ self.register_command('close', self.command_unquery,
+ shortdesc='Close the tab.')
+ self.register_command('version', self.command_version,
+ desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).',
+ shortdesc='Get the software version of the user.')
+ self.register_command('info', self.command_info,
+ shortdesc='Get the status of the contact.')
+ self.register_command('last_activity', self.command_last_activity,
+ usage='[jid]',
+ desc='Get the last activity of the given or the current contact.',
+ shortdesc='Get the activity.',
+ completion=self.core.completion_last_activity)
+ self.resize()
+ self.update_commands()
+ self.update_keys()
+
+ @property
+ def general_jid(self):
+ return safeJID(self.name).bare
+
+ @staticmethod
+ def add_information_element(plugin_name, callback):
+ """
+ Lets a plugin add its own information to the ConversationInfoWin
+ """
+ ConversationTab.additional_informations[plugin_name] = callback
+
+ @staticmethod
+ def remove_information_element(plugin_name):
+ del ConversationTab.additional_informations[plugin_name]
+
+ def completion(self):
+ self.complete_commands(self.input)
+
+ @command_args_parser.raw
+ def command_say(self, line, attention=False, correct=False):
+ msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg['type'] = 'chat'
+ msg['body'] = line
+ if not self.nick_sent:
+ msg['nick'] = self.core.own_nick
+ self.nick_sent = True
+ # trigger the event BEFORE looking for colors.
+ # and before displaying the message in the window
+ # This lets a plugin insert \x19xxx} colors, that will
+ # be converted in xhtml.
+ self.core.events.trigger('conversation_say', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ return
+ replaced = False
+ if correct or msg['replace']['id']:
+ msg['replace']['id'] = self.last_sent_message['id']
+ if config.get_by_tabname('group_corrections', self.name):
+ try:
+ self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'], jid=self.core.xmpp.boundjid,
+ nickname=self.core.own_nick)
+ replaced = True
+ except:
+ log.error('Unable to correct a message', exc_info=True)
+ else:
+ del msg['replace']
+ if msg['body'].find('\x19') != -1:
+ msg.enable('html')
+ msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
+ msg['body'] = xhtml.clean_text(msg['body'])
+ if (config.get_by_tabname('send_chat_states', self.general_jid) and
+ self.remote_wants_chatstates is not False):
+ needed = 'inactive' if self.inactive else 'active'
+ msg['chat_state'] = needed
+ if attention and self.remote_supports_attention:
+ msg['attention'] = True
+ self.core.events.trigger('conversation_say_after', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ return
+ if not replaced:
+ self.add_message(msg['body'],
+ nickname=self.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=msg['id'],
+ jid=self.core.xmpp.boundjid,
+ typ=1)
+
+ self.last_sent_message = msg
+ if self.remote_supports_receipts:
+ msg._add_receipt = True
+ msg.send()
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+
+ @command_args_parser.quoted(0, 1)
+ def command_last_activity(self, args):
+ """
+ /last_activity [jid]
+ """
+ if args and args[0]:
+ return self.core.command_last_activity(args[0])
+
+ def callback(iq):
+ if iq['type'] != 'result':
+ if iq['error']['type'] == 'auth':
+ self.core.information('You are not allowed to see the activity of this contact.', 'Error')
+ else:
+ self.core.information('Error retrieving the activity', 'Error')
+ return
+ seconds = iq['last_activity']['seconds']
+ status = iq['last_activity']['status']
+ from_ = iq['from']
+ msg = '\x19%s}The last activity of %s was %s ago%s'
+ if not safeJID(from_).user:
+ msg = '\x19%s}The uptime of %s is %s.' % (
+ dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ from_,
+ common.parse_secs_to_str(seconds))
+ else:
+ msg = '\x19%s}The last activity of %s was %s ago%s' % (
+ dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ from_,
+ common.parse_secs_to_str(seconds),
+ (' and his/her last status was %s' % status) if status else '',)
+ self.add_message(msg)
+ self.core.refresh_window()
+
+ self.core.xmpp.plugin['xep_0012'].get_last_activity(self.get_dest_jid(), callback=callback)
+
+ @refresh_wrapper.conditional
+ @command_args_parser.ignored
+ def command_info(self):
+ contact = roster[self.get_dest_jid()]
+ jid = safeJID(self.get_dest_jid())
+ if contact:
+ if jid.resource:
+ resource = contact[jid.full]
+ else:
+ resource = contact.get_highest_priority_resource()
+ else:
+ resource = None
+ 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.show or 'available', 'status': status, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)})
+ 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)})
+ return True
+
+ @command_args_parser.ignored
+ def command_unquery(self):
+ self.core.close_tab()
+
+ @command_args_parser.quoted(0, 1)
+ def command_version(self, args):
+ """
+ /version [jid]
+ """
+ def callback(res):
+ if not res:
+ return self.core.information('Could not get the software version from %s' % (jid,), 'Warning')
+ version = '%s is running %s version %s on %s' % (jid,
+ res.get('name') or 'an unknown software',
+ res.get('version') or 'unknown',
+ res.get('os') or 'an unknown platform')
+ self.core.information(version, 'Info')
+ if args:
+ return self.core.command_version(args[0])
+ jid = safeJID(self.name)
+ if not jid.resource:
+ if jid in roster:
+ resource = roster[jid].get_highest_priority_resource()
+ jid = resource.jid if resource else jid
+ fixes.get_version(self.core.xmpp, jid,
+ callback=callback)
+
+ def resize(self):
+ self.need_resize = False
+ if self.size.tab_degrade_y:
+ display_bar = False
+ info_win_height = 0
+ tab_win_height = 0
+ bar_height = 0
+ else:
+ display_bar = True
+ info_win_height = self.core.information_win_size
+ tab_win_height = Tab.tab_win_height()
+ bar_height = 1
+
+ self.text_win.resize(self.height - 2 - bar_height - info_win_height
+ - tab_win_height,
+ self.width, bar_height, 0)
+ self.text_win.rebuild_everything(self._text_buffer)
+ if display_bar:
+ self.upper_bar.resize(1, self.width, 0, 0)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - info_win_height
+ - tab_win_height,
+ 0)
+ self.input.resize(1, self.width, self.height - 1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ display_bar = display_info_win = not self.size.tab_degrade_y
+
+ self.text_win.refresh()
+
+ if display_bar:
+ self.upper_bar.refresh(self.get_dest_jid(), roster[self.get_dest_jid()])
+ self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()], self.text_win, self.chatstate, ConversationTab.additional_informations)
+
+ if display_info_win:
+ self.info_win.refresh()
+ self.refresh_tab_win()
+ self.input.refresh()
+
+ def refresh_info_header(self):
+ self.info_header.refresh(self.get_dest_jid(), roster[self.get_dest_jid()],
+ self.text_win, self.chatstate, ConversationTab.additional_informations)
+ self.input.refresh()
+
+ def get_nick(self):
+ jid = safeJID(self.name)
+ contact = roster[jid.bare]
+ if contact:
+ return contact.name or jid.user
+ else:
+ if self.nick:
+ return self.nick
+ return jid.user
+
+ def on_input(self, key, raw):
+ if not raw and key in self.key_func:
+ self.key_func[key]()
+ return False
+ self.input.do_command(key, raw=raw)
+ empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//'))
+ self.send_composing_chat_state(empty_after)
+ return False
+
+ def on_lose_focus(self):
+ contact = roster[self.get_dest_jid()]
+ jid = safeJID(self.get_dest_jid())
+ if contact:
+ if jid.resource:
+ resource = contact[jid.full]
+ else:
+ resource = contact.get_highest_priority_resource()
+ else:
+ resource = None
+ if self.input.text:
+ self.state = 'nonempty'
+ else:
+ self.state = 'normal'
+ self.text_win.remove_line_separator()
+ self.text_win.add_line_separator(self._text_buffer)
+ if (config.get_by_tabname('send_chat_states', self.general_jid)
+ and (not self.input.get_text()
+ or not self.input.get_text().startswith('//'))):
+ if resource:
+ self.send_chat_state('inactive')
+ self.check_scrolled()
+
+ def on_gain_focus(self):
+ contact = roster[self.get_dest_jid()]
+ jid = safeJID(self.get_dest_jid())
+ if contact:
+ if jid.resource:
+ resource = contact[jid.full]
+ else:
+ resource = contact.get_highest_priority_resource()
+ else:
+ resource = None
+
+ self.state = 'current'
+ curses.curs_set(1)
+ if (config.get_by_tabname('send_chat_states', self.general_jid)
+ and (not self.input.get_text()
+ or not self.input.get_text().startswith('//'))):
+ if resource:
+ self.send_chat_state('active')
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height-3:
+ return
+ self.text_win.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0)
+ self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0)
+
+ def get_text_window(self):
+ return self.text_win
+
+ def on_close(self):
+ Tab.on_close(self)
+ if config.get_by_tabname('send_chat_states', self.general_jid):
+ self.send_chat_state('gone')
+
+ def matching_names(self):
+ res = []
+ jid = safeJID(self.name)
+ res.append((2, jid.bare))
+ res.append((1, jid.user))
+ contact = roster[self.name]
+ if contact and contact.name:
+ res.append((0, contact.name))
+ return res
+
+class DynamicConversationTab(ConversationTab):
+ """
+ A conversation tab associated with one bare JID that can be “locked” to
+ a full jid, and unlocked, as described in the XEP-0296.
+ Only one DynamicConversationTab can be opened for a given jid.
+ """
+ def __init__(self, jid, resource=None):
+ self.locked_resource = None
+ self.name = safeJID(jid).bare
+ if resource:
+ self.lock(resource)
+ self.info_header = windows.DynamicConversationInfoWin()
+ ConversationTab.__init__(self, jid)
+ self.register_command('unlock', self.unlock_command,
+ shortdesc='Unlock the conversation from a particular resource.')
+
+ def lock(self, resource):
+ """
+ Lock the tab to the resource.
+ """
+ assert(resource)
+ if resource != self.locked_resource:
+ self.locked_resource = resource
+ info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
+
+ message = ('%(info)sConversation locked to '
+ '%(jid_c)s%(jid)s/%(resource)s%(info)s.') % {
+ 'info': info,
+ 'jid_c': jid_c,
+ 'jid': self.name,
+ 'resource': resource}
+ self.add_message(message, typ=0)
+ self.check_features()
+
+ def unlock_command(self, arg=None):
+ self.unlock()
+ self.refresh_info_header()
+
+ def unlock(self, from_=None):
+ """
+ Unlock the tab from a resource. It is now “associated” with the bare
+ jid.
+ """
+ self.remote_wants_chatstates = None
+ if self.locked_resource != None:
+ self.locked_resource = None
+ info = '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ jid_c = '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID)
+
+ if from_:
+ message = ('%(info)sConversation unlocked (received activity'
+ ' from %(jid_c)s%(jid)s%(info)s).') % {
+ 'info': info,
+ 'jid_c': jid_c,
+ 'jid': from_}
+ self.add_message(message, typ=0)
+ else:
+ message = '%sConversation unlocked.' % info
+ self.add_message(message, typ=0)
+
+ def get_dest_jid(self):
+ """
+ Returns the full jid (using the locked resource), or the bare jid if
+ the conversation is not locked.
+ """
+ if self.locked_resource:
+ return "%s/%s" % (self.name, self.locked_resource)
+ return self.name
+
+ def refresh(self):
+ """
+ Different from the parent class only for the info_header object.
+ """
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ display_bar = display_info_win = not self.size.tab_degrade_y
+
+ self.text_win.refresh()
+ if display_bar:
+ self.upper_bar.refresh(self.name, roster[self.name])
+ if self.locked_resource:
+ displayed_jid = "%s/%s" % (self.name, self.locked_resource)
+ else:
+ displayed_jid = self.name
+ self.info_header.refresh(displayed_jid, roster[self.name],
+ self.text_win, self.chatstate,
+ ConversationTab.additional_informations)
+ if display_info_win:
+ self.info_win.refresh()
+
+ self.refresh_tab_win()
+ self.input.refresh()
+
+ def refresh_info_header(self):
+ """
+ Different from the parent class only for the info_header object.
+ """
+ if self.locked_resource:
+ displayed_jid = "%s/%s" % (self.name, self.locked_resource)
+ else:
+ displayed_jid = self.name
+ self.info_header.refresh(displayed_jid, roster[self.name],
+ self.text_win, self.chatstate, ConversationTab.additional_informations)
+ self.input.refresh()
+
+class StaticConversationTab(ConversationTab):
+ """
+ A conversation tab associated with one Full JID. It cannot be locked to
+ an different resource or unlocked.
+ """
+ def __init__(self, jid):
+ assert(safeJID(jid).resource)
+ self.info_header = windows.ConversationInfoWin()
+ ConversationTab.__init__(self, jid)
+
+
diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py
new file mode 100644
index 00000000..0fad2974
--- /dev/null
+++ b/poezio/tabs/data_forms.py
@@ -0,0 +1,75 @@
+"""
+Defines the data-forms Tab
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import windows
+from tabs import Tab
+
+class DataFormsTab(Tab):
+ """
+ A tab contaning various window type, displaying
+ a form that the user needs to fill.
+ """
+ plugin_commands = {}
+ def __init__(self, form, on_cancel, on_send, kwargs):
+ Tab.__init__(self)
+ self._form = form
+ self._on_cancel = on_cancel
+ self._on_send = on_send
+ self._kwargs = kwargs
+ self.fields = []
+ for field in self._form:
+ self.fields.append(field)
+ self.topic_win = windows.Topic()
+ self.form_win = windows.FormWin(form, self.height-4, self.width, 1, 0)
+ self.help_win = windows.HelpText("Ctrl+Y: send form, Ctrl+G: cancel")
+ self.help_win_dyn = windows.HelpText()
+ self.key_func['KEY_UP'] = self.form_win.go_to_previous_input
+ self.key_func['KEY_DOWN'] = self.form_win.go_to_next_input
+ self.key_func['^G'] = self.on_cancel
+ self.key_func['^Y'] = self.on_send
+ self.resize()
+ self.update_commands()
+
+ def on_cancel(self):
+ self._on_cancel(self._form, **self._kwargs)
+ return True
+
+ def on_send(self):
+ self._form.reply()
+ self.form_win.reply()
+ self._on_send(self._form, **self._kwargs)
+ return True
+
+ def on_input(self, key, raw=False):
+ if key in self.key_func:
+ res = self.key_func[key]()
+ if res:
+ return res
+ self.help_win_dyn.refresh(self.form_win.get_help_message())
+ self.form_win.refresh_current_input()
+ else:
+ self.form_win.on_input(key)
+
+ def resize(self):
+ self.need_resize = False
+ self.topic_win.resize(1, self.width, 0, 0)
+ self.form_win.resize(self.height - 3 - Tab.tab_win_height(),
+ self.width, 1, 0)
+ self.help_win.resize(1, self.width, self.height - 1, 0)
+ self.help_win_dyn.resize(1, self.width,
+ self.height - 2 - Tab.tab_win_height(), 0)
+ self.lines = []
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ self.topic_win.refresh(self._form['title'])
+ self.refresh_tab_win()
+ self.help_win.refresh()
+ self.help_win_dyn.refresh(self.form_win.get_help_message())
+ self.form_win.refresh()
+
diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py
new file mode 100644
index 00000000..4d8bab9c
--- /dev/null
+++ b/poezio/tabs/listtab.py
@@ -0,0 +1,202 @@
+"""
+A generic tab that displays a serie of items in a scrollable, searchable,
+sortable list. It should be inherited, to actually provide methods that
+insert items in the list, and that lets the user interact with them.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+import collections
+
+import windows
+from common import safeJID
+from decorators import refresh_wrapper
+
+from . import Tab
+
+
+class ListTab(Tab):
+ plugin_commands = {}
+ plugin_keys = {}
+
+ def __init__(self, name, help_message, header_text, cols):
+ """Parameters:
+ name: The name of the tab
+ help_message: The default help message displayed instead of the
+ input
+ header_text: The text displayed on the header line, at the top of
+ the tab
+ cols: a tuple of 2-tuples. e.g. (('column1_name', number),
+ ('column2_name', number))
+ """
+ Tab.__init__(self)
+ self.state = 'normal'
+ self.name = name
+ columns = collections.OrderedDict()
+ for col, num in cols:
+ columns[col] = num
+ self.list_header = windows.ColumnHeaderWin(list(columns))
+ self.listview = windows.ListWin(columns)
+ self.info_header = windows.MucListInfoWin(header_text)
+ self.default_help_message = windows.HelpText(help_message)
+ self.input = self.default_help_message
+ self.key_func["KEY_DOWN"] = self.move_cursor_down
+ self.key_func["KEY_UP"] = self.move_cursor_up
+ self.key_func['^I'] = self.completion
+ self.key_func["/"] = self.on_slash
+ self.key_func['KEY_LEFT'] = self.list_header.sel_column_left
+ self.key_func['KEY_RIGHT'] = self.list_header.sel_column_right
+ self.key_func[' '] = self.sort_by
+ self.register_command('close', self.close,
+ shortdesc='Close this tab.')
+ self.resize()
+ self.update_keys()
+ self.update_commands()
+
+ def get_columns_sizes(self):
+ """
+ Must be implemented in subclasses. Must return a dict like this:
+ {'column1_name': size1,
+ 'column2_name': size2}
+ Where the size are calculated based on the size of the tab etc
+ """
+ raise NotImplementedError
+
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ if self.size.tab_degrade_y:
+ display_info_win = False
+ else:
+ display_info_win = True
+
+ self.info_header.refresh(window=self.listview)
+ if display_info_win:
+ self.info_win.refresh()
+ self.refresh_tab_win()
+ self.list_header.refresh()
+ self.listview.refresh()
+ self.input.refresh()
+
+ def resize(self):
+ if self.size.tab_degrade_y:
+ info_win_height = 0
+ tab_win_height = 0
+ else:
+ info_win_height = self.core.information_win_size
+ tab_win_height = Tab.tab_win_height()
+
+ self.info_header.resize(1, self.width,
+ self.height - 2 - info_win_height
+ - tab_win_height,
+ 0)
+ column_size = self.get_columns_sizes()
+ self.list_header.resize_columns(column_size)
+ self.list_header.resize(1, self.width, 0, 0)
+ self.listview.resize_columns(column_size)
+ self.listview.resize(self.height - 3 - info_win_height - tab_win_height,
+ self.width, 1, 0)
+ self.input.resize(1, self.width, self.height-1, 0)
+
+ def on_slash(self):
+ """
+ '/' is pressed, activate the input
+ """
+ curses.curs_set(1)
+ self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.input.do_command("/") # we add the slash
+
+ def close(self, arg=None):
+ self.input.on_delete()
+ self.core.close_tab(self)
+
+ def set_error(self, msg, code, body):
+ """
+ If there's an error (retrieving the values etc)
+ """
+ self._error_message = 'Error: %(code)s - %(msg)s: %(body)s' % {'msg':msg, 'body':body, 'code':code}
+ self.info_header.message = self._error_message
+ self.info_header.refresh()
+ curses.doupdate()
+
+ def sort_by(self):
+ if self.list_header.get_order():
+ self.listview.sort_by_column(
+ col_name=self.list_header.get_sel_column(),
+ asc=False)
+ self.list_header.set_order(False)
+ self.list_header.refresh()
+ else:
+ self.listview.sort_by_column(
+ col_name=self.list_header.get_sel_column(),
+ asc=True)
+ self.list_header.set_order(True)
+ self.list_header.refresh()
+ self.core.doupdate()
+
+ @refresh_wrapper.always
+ def reset_help_message(self, _=None):
+ if self.closed:
+ return True
+ curses.curs_set(0)
+ self.input = self.default_help_message
+ self.input.resize(1, self.width, self.height-1, 0)
+ return True
+
+ def execute_slash_command(self, txt):
+ if txt.startswith('/'):
+ self.input.key_enter()
+ self.execute_command(txt)
+ return self.reset_help_message()
+
+ def completion(self):
+ if isinstance(self.input, windows.Input):
+ self.complete_commands(self.input)
+
+ def on_input(self, key, raw):
+ res = self.input.do_command(key, raw=raw)
+ if res and not isinstance(self.input, windows.Input):
+ return True
+ elif res:
+ return False
+ if not raw and key in self.key_func:
+ return self.key_func[key]()
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height-3:
+ return
+ self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0)
+ self.listview.resize(self.height-3-self.core.information_win_size - Tab.tab_win_height(), self.width, 1, 0)
+
+ def on_lose_focus(self):
+ self.state = 'normal'
+
+ def on_gain_focus(self):
+ self.state = 'current'
+ curses.curs_set(0)
+
+ def on_scroll_up(self):
+ return self.listview.scroll_up()
+
+ def on_scroll_down(self):
+ return self.listview.scroll_down()
+
+ def move_cursor_up(self):
+ self.listview.move_cursor_up()
+ self.listview.refresh()
+ self.core.doupdate()
+
+ def move_cursor_down(self):
+ self.listview.move_cursor_down()
+ self.listview.refresh()
+ self.core.doupdate()
+
+ def matching_names(self):
+ return [(2, self.name)]
+
+
diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py
new file mode 100644
index 00000000..92d55190
--- /dev/null
+++ b/poezio/tabs/muclisttab.py
@@ -0,0 +1,70 @@
+"""
+A MucListTab is a tab listing the rooms on a conference server.
+
+It has no functionnality except scrolling the list, and allowing the
+user to join the rooms.
+"""
+import logging
+log = logging.getLogger(__name__)
+
+from . import ListTab
+
+from slixmpp.plugins.xep_0030.stanza.items import DiscoItem
+
+class MucListTab(ListTab):
+ """
+ A tab listing rooms from a specific server, displaying various information,
+ scrollable, and letting the user join them, etc
+ """
+ plugin_commands = {}
+ plugin_keys = {}
+
+ def __init__(self, server):
+ ListTab.__init__(self, server,
+ "“j”: join room.",
+ 'Chatroom list on server %s (Loading)' % server,
+ (('node-part', 0), ('name', 2), ('users', 3)))
+ self.key_func['j'] = self.join_selected
+ self.key_func['J'] = self.join_selected_no_focus
+ self.key_func['^M'] = self.join_selected
+
+ def get_columns_sizes(self):
+ return {'node-part': int(self.width* 2 / 8),
+ 'name': int(self.width * 5 / 8),
+ 'users': self.width - int(self.width * 2 / 8)
+ - int(self.width * 5 / 8)}
+
+ def join_selected_no_focus(self):
+ return
+
+ def on_muc_list_item_received(self, iq):
+ """
+ Callback called when a disco#items result is received
+ Used with command_list
+ """
+ if iq['type'] == 'error':
+ self.set_error(iq['error']['type'], iq['error']['code'], iq['error']['text'])
+ return
+ def get_items():
+ substanza = iq['disco_items']
+ for item in substanza['substanzas']:
+ if isinstance(item, DiscoItem):
+ yield (item['jid'], item['node'], item['name'])
+ items = [(item[0].split('@')[0],
+ item[0],
+ item[2] or '', '') for item in get_items()]
+ self.listview.set_lines(items)
+ self.info_header.message = 'Chatroom list on server %s' % self.name
+ if self.core.current_tab() is self:
+ self.refresh()
+ else:
+ self.state = 'highlight'
+ self.refresh_tab_win()
+ self.core.doupdate()
+
+ def join_selected(self):
+ row = self.listview.get_selected_row()
+ if not row:
+ return
+ self.core.command_join(row[1])
+
diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py
new file mode 100644
index 00000000..1f3ec6d8
--- /dev/null
+++ b/poezio/tabs/muctab.py
@@ -0,0 +1,1720 @@
+"""
+Module for the MucTab
+
+A MucTab is a tab for multi-user chats as defined in XEP-0045.
+
+It keeps track of many things such as part/joins, maintains an
+user list, and updates private tabs when necessary.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import bisect
+import curses
+import os
+import random
+import re
+from datetime import datetime
+
+from . import ChatTab, Tab
+
+import common
+import fixes
+import multiuserchat as muc
+import timed_events
+import windows
+import xhtml
+from common import safeJID
+from config import config
+from decorators import refresh_wrapper, command_args_parser
+from logger import logger
+from roster import roster
+from theming import get_theme, dump_tuple
+from user import User
+
+
+SHOW_NAME = {
+ 'dnd': 'busy',
+ 'away': 'away',
+ 'xa': 'not available',
+ 'chat': 'chatty',
+ '': 'available'
+ }
+
+NS_MUC_USER = 'http://jabber.org/protocol/muc#user'
+
+
+class MucTab(ChatTab):
+ """
+ The tab containing a multi-user-chat room.
+ It contains an userlist, an input, a topic, an information and a chat zone
+ """
+ message_type = 'groupchat'
+ plugin_commands = {}
+ plugin_keys = {}
+ def __init__(self, jid, nick, password=None):
+ self.joined = False
+ ChatTab.__init__(self, jid)
+ if self.joined == False:
+ self._state = 'disconnected'
+ self.own_nick = nick
+ self.name = jid
+ self.password = password
+ self.users = []
+ self.privates = [] # private conversations
+ self.topic = ''
+ self.topic_from = ''
+ self.remote_wants_chatstates = True
+ # Self ping event, so we can cancel it when we leave the room
+ self.self_ping_event = None
+ # We send active, composing and paused states to the MUC because
+ # the chatstate may or may not be filtered by the MUC,
+ # that’s not our problem.
+ self.topic_win = windows.Topic()
+ self.text_win = windows.TextWin()
+ self._text_buffer.add_window(self.text_win)
+ self.v_separator = windows.VerticalSeparator()
+ self.user_win = windows.UserList()
+ self.info_header = windows.MucInfoWin()
+ self.input = windows.MessageInput()
+ self.ignores = [] # set of Users
+ # keys
+ self.key_func['^I'] = self.completion
+ self.key_func['M-u'] = self.scroll_user_list_down
+ self.key_func['M-y'] = self.scroll_user_list_up
+ self.key_func['M-n'] = self.go_to_next_hl
+ self.key_func['M-p'] = self.go_to_prev_hl
+ # commands
+ self.register_command('ignore', self.command_ignore,
+ usage='<nickname>',
+ desc='Ignore a specified nickname.',
+ shortdesc='Ignore someone',
+ completion=self.completion_ignore)
+ self.register_command('unignore', self.command_unignore,
+ usage='<nickname>',
+ desc='Remove the specified nickname from the ignore list.',
+ shortdesc='Unignore someone.',
+ completion=self.completion_unignore)
+ self.register_command('kick', self.command_kick,
+ usage='<nick> [reason]',
+ desc='Kick the user with the specified nickname.'
+ ' You also can give an optional reason.',
+ shortdesc='Kick someone.',
+ completion=self.completion_quoted)
+ self.register_command('ban', self.command_ban,
+ usage='<nick> [reason]',
+ desc='Ban the user with the specified nickname.'
+ ' You also can give an optional reason.',
+ shortdesc='Ban someone',
+ completion=self.completion_quoted)
+ self.register_command('role', self.command_role,
+ usage='<nick> <role> [reason]',
+ desc='Set the role of an user. Roles can be:'
+ ' none, visitor, participant, moderator.'
+ ' You also can give an optional reason.',
+ shortdesc='Set the role of an user.',
+ completion=self.completion_role)
+ self.register_command('affiliation', self.command_affiliation,
+ usage='<nick or jid> <affiliation>',
+ desc='Set the affiliation of an user. Affiliations can be:'
+ ' outcast, none, member, admin, owner.',
+ shortdesc='Set the affiliation of an user.',
+ completion=self.completion_affiliation)
+ self.register_command('topic', self.command_topic,
+ usage='<subject>',
+ desc='Change the subject of the room.',
+ shortdesc='Change the subject.',
+ completion=self.completion_topic)
+ self.register_command('query', self.command_query,
+ usage='<nick> [message]',
+ desc='Open a private conversation with <nick>. This nick'
+ ' has to be present in the room you\'re currently in.'
+ ' If you specified a message after the nickname, it '
+ 'will immediately be sent to this user.',
+ shortdesc='Query an user.',
+ completion=self.completion_quoted)
+ self.register_command('part', self.command_part,
+ usage='[message]',
+ desc='Disconnect from a room. You can'
+ ' specify an optional message.',
+ shortdesc='Leave the room.')
+ self.register_command('close', self.command_close,
+ usage='[message]',
+ desc='Disconnect from a room and close the tab.'
+ ' You can specify an optional message if '
+ 'you are still connected.',
+ shortdesc='Close the tab.')
+ self.register_command('nick', self.command_nick,
+ usage='<nickname>',
+ desc='Change your nickname in the current room.',
+ shortdesc='Change your nickname.',
+ completion=self.completion_nick)
+ self.register_command('recolor', self.command_recolor,
+ usage='[random]',
+ desc='Re-assign a color to all participants of the'
+ ' current room, based on the last time they talked.'
+ ' Use this if the participants currently talking '
+ 'have too many identical colors. Use /recolor random'
+ ' for a non-deterministic result.',
+ shortdesc='Change the nicks colors.',
+ completion=self.completion_recolor)
+ self.register_command('color', self.command_color,
+ usage='<nick> <color>',
+ desc='Fix a color for a nick. Use "unset" instead of a color'
+ ' to remove the attribution',
+ shortdesc='Fix a color for a nick.',
+ completion=self.completion_color)
+ self.register_command('cycle', self.command_cycle,
+ usage='[message]',
+ desc='Leave the current room and rejoin it immediately.',
+ shortdesc='Leave and re-join the room.')
+ self.register_command('info', self.command_info,
+ usage='<nickname>',
+ desc='Display some information about the user '
+ 'in the MUC: its/his/her role, affiliation,'
+ ' status and status message.',
+ shortdesc='Show an user\'s infos.',
+ completion=self.completion_info)
+ self.register_command('configure', self.command_configure,
+ desc='Configure the current room, through a form.',
+ shortdesc='Configure the room.')
+ self.register_command('version', self.command_version,
+ usage='<jid or nick>',
+ desc='Get the software version of the given JID'
+ ' or nick in room (usually its XMPP client'
+ ' and Operating System).',
+ shortdesc='Get the software version of a jid.',
+ completion=self.completion_version)
+ self.register_command('names', self.command_names,
+ desc='Get the users in the room with their roles.',
+ shortdesc='List the users.')
+ self.register_command('invite', self.command_invite,
+ desc='Invite a contact to this room',
+ usage='<jid> [reason]',
+ shortdesc='Invite a contact to this room',
+ completion=self.completion_invite)
+
+ if self.core.xmpp.boundjid.server == "gmail.com": #gmail sucks
+ del self.commands["nick"]
+
+ self.resize()
+ self.update_commands()
+ self.update_keys()
+
+ @property
+ def general_jid(self):
+ return self.name
+
+ @property
+ def is_muc(self):
+ return True
+
+ @property
+ def last_connection(self):
+ last_message = self._text_buffer.last_message
+ if last_message:
+ return last_message.time
+ return None
+
+ @refresh_wrapper.always
+ def go_to_next_hl(self):
+ """
+ Go to the next HL in the room, or the last
+ """
+ self.text_win.next_highlight()
+
+ @refresh_wrapper.always
+ def go_to_prev_hl(self):
+ """
+ Go to the previous HL in the room, or the first
+ """
+ self.text_win.previous_highlight()
+
+ def completion_version(self, the_input):
+ """Completion for /version"""
+ compare_users = lambda x: x.last_talked
+ userlist = []
+ for user in sorted(self.users, key=compare_users, reverse=True):
+ if user.nick != self.own_nick:
+ userlist.append(user.nick)
+ comp = []
+ for jid in (jid for jid in roster.jids() if len(roster[jid])):
+ for resource in roster[jid].resources:
+ comp.append(resource.jid)
+ comp.sort()
+ userlist.extend(comp)
+
+ return the_input.auto_completion(userlist, quotify=False)
+
+ def completion_info(self, the_input):
+ """Completion for /info"""
+ compare_users = lambda x: x.last_talked
+ userlist = []
+ for user in sorted(self.users, key=compare_users, reverse=True):
+ userlist.append(user.nick)
+ return the_input.auto_completion(userlist, quotify=False)
+
+ def completion_nick(self, the_input):
+ """Completion for /nick"""
+ nicks = [os.environ.get('USER'),
+ config.get('default_nick'),
+ self.core.get_bookmark_nickname(self.name)]
+ nicks = [i for i in nicks if i]
+ return the_input.auto_completion(nicks, '', quotify=False)
+
+ def completion_recolor(self, the_input):
+ if the_input.get_argument_position() == 1:
+ return the_input.new_completion(['random'], 1, '', quotify=False)
+ return True
+
+ def completion_color(self, the_input):
+ """Completion for /color"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ userlist = [user.nick for user in self.users]
+ if self.own_nick in userlist:
+ userlist.remove(self.own_nick)
+ return the_input.new_completion(userlist, 1, '', quotify=True)
+ elif n == 2:
+ colors = [i for i in xhtml.colors if i]
+ colors.sort()
+ colors.append('unset')
+ colors.append('random')
+ return the_input.new_completion(colors, 2, '', quotify=False)
+
+ def completion_ignore(self, the_input):
+ """Completion for /ignore"""
+ userlist = [user.nick for user in self.users]
+ if self.own_nick in userlist:
+ userlist.remove(self.own_nick)
+ userlist.sort()
+ return the_input.auto_completion(userlist, quotify=False)
+
+ def completion_role(self, the_input):
+ """Completion for /role"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ userlist = [user.nick for user in self.users]
+ if self.own_nick in userlist:
+ userlist.remove(self.own_nick)
+ return the_input.new_completion(userlist, 1, '', quotify=True)
+ elif n == 2:
+ possible_roles = ['none', 'visitor', 'participant', 'moderator']
+ return the_input.new_completion(possible_roles, 2, '',
+ quotify=True)
+
+ def completion_affiliation(self, the_input):
+ """Completion for /affiliation"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ userlist = [user.nick for user in self.users]
+ if self.own_nick in userlist:
+ userlist.remove(self.own_nick)
+ jidlist = [user.jid.bare for user in self.users]
+ if self.core.xmpp.boundjid.bare in jidlist:
+ jidlist.remove(self.core.xmpp.boundjid.bare)
+ userlist.extend(jidlist)
+ return the_input.new_completion(userlist, 1, '', quotify=True)
+ elif n == 2:
+ possible_affiliations = ['none', 'member', 'admin',
+ 'owner', 'outcast']
+ return the_input.new_completion(possible_affiliations, 2, '',
+ quotify=True)
+
+ @command_args_parser.quoted(1, 1, [''])
+ def command_invite(self, args):
+ """/invite <jid> [reason]"""
+ if args is None:
+ return self.core.command_help('invite')
+ jid, reason = args
+ self.core.command_invite('%s %s "%s"' % (jid, self.name, reason))
+
+ def completion_invite(self, the_input):
+ """Completion for /invite"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ return the_input.new_completion(roster.jids(), 1, quotify=True)
+
+ def scroll_user_list_up(self):
+ self.user_win.scroll_up()
+ self.user_win.refresh(self.users)
+ self.input.refresh()
+
+ def scroll_user_list_down(self):
+ self.user_win.scroll_down()
+ self.user_win.refresh(self.users)
+ self.input.refresh()
+
+ @command_args_parser.quoted(1)
+ def command_info(self, args):
+ """
+ /info <nick>
+ """
+ if args is None:
+ return self.core.command_help('info')
+ nick = args[0]
+ user = self.get_user_by_name(nick)
+ if not user:
+ return self.core.information("Unknown user: %s" % nick, "Error")
+ theme = get_theme()
+ inf = '\x19' + dump_tuple(theme.COLOR_INFORMATION_TEXT) + '}'
+ if user.jid:
+ user_jid = '%s (\x19%s}%s\x19o%s)' % (
+ inf,
+ dump_tuple(theme.COLOR_MUC_JID),
+ user.jid,
+ inf)
+ else:
+ user_jid = ''
+ info = ('\x19%s}%s\x19o%s%s: show: \x19%s}%s\x19o%s, affiliation:'
+ ' \x19%s}%s\x19o%s, role: \x19%s}%s\x19o%s') % (
+ dump_tuple(user.color),
+ nick,
+ user_jid,
+ inf,
+ dump_tuple(theme.color_show(user.show)),
+ user.show or 'Available',
+ inf,
+ dump_tuple(theme.color_role(user.role)),
+ user.affiliation or 'None',
+ inf,
+ dump_tuple(theme.color_role(user.role)),
+ user.role or 'None',
+ '\n%s' % user.status if user.status else '')
+ self.add_message(info, typ=0)
+ self.core.refresh_window()
+
+ @command_args_parser.quoted(0)
+ def command_configure(self, ignored):
+ """
+ /configure
+ """
+ def on_form_received(form):
+ if not form:
+ self.core.information(
+ 'Could not retrieve the configuration form',
+ 'Error')
+ return
+ self.core.open_new_form(form, self.cancel_config, self.send_config)
+
+ fixes.get_room_form(self.core.xmpp, self.name, on_form_received)
+
+ def cancel_config(self, form):
+ """
+ The user do not want to send his/her config, send an iq cancel
+ """
+ muc.cancel_config(self.core.xmpp, self.name)
+ self.core.close_tab()
+
+ def send_config(self, form):
+ """
+ The user sends his/her config to the server
+ """
+ muc.configure_room(self.core.xmpp, self.name, form)
+ self.core.close_tab()
+
+ @command_args_parser.raw
+ def command_cycle(self, msg):
+ """/cycle [reason]"""
+ self.command_part(msg)
+ self.disconnect()
+ self.user_win.pos = 0
+ self.core.disable_private_tabs(self.name)
+ self.join()
+
+ def join(self):
+ """
+ Join the room
+ """
+ status = self.core.get_status()
+ if self.last_connection:
+ delta = datetime.now() - self.last_connection
+ seconds = delta.seconds + delta.days * 24 * 3600
+ else:
+ seconds = 0
+ muc.join_groupchat(self.core, self.name, self.own_nick,
+ self.password,
+ status=status.message,
+ show=status.show,
+ seconds=seconds)
+
+ @command_args_parser.quoted(0, 1, [''])
+ def command_recolor(self, args):
+ """
+ /recolor [random]
+ Re-assign color to the participants of the room
+ """
+ deterministic = config.get_by_tabname('deterministic_nick_colors', self.name)
+ if deterministic:
+ for user in self.users:
+ if user.nick == self.own_nick:
+ continue
+ color = self.search_for_color(user.nick)
+ if color != '':
+ continue
+ user.set_deterministic_color()
+ if args[0] == 'random':
+ self.core.information('"random" was provided, but poezio is '
+ 'configured to use deterministic colors',
+ 'Warning')
+ self.user_win.refresh(self.users)
+ self.input.refresh()
+ return
+ compare_users = lambda x: x.last_talked
+ users = list(self.users)
+ sorted_users = sorted(users, key=compare_users, reverse=True)
+ full_sorted_users = sorted_users[:]
+ # search our own user, to remove it from the list
+ # Also remove users whose color is fixed
+ for user in full_sorted_users:
+ color = self.search_for_color(user.nick)
+ if user.nick == self.own_nick:
+ sorted_users.remove(user)
+ user.color = get_theme().COLOR_OWN_NICK
+ elif color != '':
+ sorted_users.remove(user)
+ user.change_color(color, deterministic)
+ colors = list(get_theme().LIST_COLOR_NICKNAMES)
+ if args[0] == 'random':
+ random.shuffle(colors)
+ for i, user in enumerate(sorted_users):
+ user.color = colors[i % len(colors)]
+ self.text_win.rebuild_everything(self._text_buffer)
+ self.user_win.refresh(self.users)
+ self.text_win.refresh()
+ self.input.refresh()
+
+ @command_args_parser.quoted(2, 2, [''])
+ def command_color(self, args):
+ """
+ /color <nick> <color>
+ Fix a color for a nick.
+ Use "unset" instead of a color to remove the attribution.
+ User "random" to attribute a random color.
+ """
+ if args is None:
+ return self.core.command_help('color')
+ nick = args[0]
+ color = args[1].lower()
+ user = self.get_user_by_name(nick)
+ if not color in xhtml.colors and color not in ('unset', 'random'):
+ return self.core.information("Unknown color: %s" % color, 'Error')
+ if user and user.nick == self.own_nick:
+ return self.core.information("You cannot change the color of your"
+ " own nick.", 'Error')
+ if color == 'unset':
+ if config.remove_and_save(nick, 'muc_colors'):
+ self.core.information('Color for nick %s unset' % (nick))
+ else:
+ if color == 'random':
+ color = random.choice(list(xhtml.colors))
+ if user:
+ user.change_color(color)
+ config.set_and_save(nick, color, 'muc_colors')
+ nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name)
+ if nick_color_aliases:
+ # if any user in the room has a nick which is an alias of the
+ # nick, update its color
+ for tab in self.core.get_tabs(MucTab):
+ for u in tab.users:
+ nick_alias = re.sub('^_*', '', u.nick)
+ nick_alias = re.sub('_*$', '', nick_alias)
+ if nick_alias == nick:
+ u.change_color(color)
+ self.text_win.rebuild_everything(self._text_buffer)
+ self.user_win.refresh(self.users)
+ self.text_win.refresh()
+ self.input.refresh()
+
+ @command_args_parser.quoted(1)
+ def command_version(self, args):
+ """
+ /version <jid or nick>
+ """
+ def callback(res):
+ if not res:
+ return self.core.information('Could not get the software '
+ 'version from %s' % (jid,),
+ 'Warning')
+ version = '%s is running %s version %s on %s' % (
+ jid,
+ res.get('name') or 'an unknown software',
+ res.get('version') or 'unknown',
+ res.get('os') or 'an unknown platform')
+ self.core.information(version, 'Info')
+ if args is None:
+ return self.core.command_help('version')
+ nick = args[0]
+ if nick in [user.nick for user in self.users]:
+ jid = safeJID(self.name).bare
+ jid = safeJID(jid + '/' + nick)
+ else:
+ jid = safeJID(nick)
+ fixes.get_version(self.core.xmpp, jid,
+ callback=callback)
+
+ @command_args_parser.quoted(1)
+ def command_nick(self, args):
+ """
+ /nick <nickname>
+ """
+ if args is None:
+ return self.core.command_help('nick')
+ nick = args[0]
+ if not self.joined:
+ return self.core.information('/nick only works in joined rooms',
+ 'Info')
+ current_status = self.core.get_status()
+ if not safeJID(self.name + '/' + nick):
+ return self.core.information('Invalid nick', 'Info')
+ muc.change_nick(self.core, self.name, nick,
+ current_status.message,
+ current_status.show)
+
+ @command_args_parser.quoted(0, 1, [''])
+ def command_part(self, args):
+ """
+ /part [msg]
+ """
+ arg = args[0]
+ msg = None
+ if self.joined:
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ char_quit = get_theme().CHAR_QUIT
+ spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR)
+
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(get_theme().COLOR_OWN_NICK)
+ else:
+ color = 3
+
+ if arg:
+ msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
+ 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
+ ' left the chatroom'
+ ' (\x19o%(reason)s\x19%(info_col)s})') % {
+ 'info_col': info_col, 'reason': arg,
+ 'spec': char_quit, 'color': color,
+ 'color_spec': spec_col,
+ 'nick': self.own_nick,
+ }
+ else:
+ msg = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} '
+ 'You (\x19%(color)s}%(nick)s\x19%(info_col)s})'
+ ' left the chatroom') % {
+ 'info_col': info_col,
+ 'spec': char_quit, 'color': color,
+ 'color_spec': spec_col,
+ 'nick': self.own_nick,
+ }
+
+ self.add_message(msg, typ=2)
+ self.disconnect()
+ muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick, arg)
+ self.core.disable_private_tabs(self.name, reason=msg)
+ if self == self.core.current_tab():
+ self.refresh()
+ self.core.doupdate()
+
+ @command_args_parser.raw
+ def command_close(self, msg):
+ """
+ /close [msg]
+ """
+ self.command_part(msg)
+ self.core.close_tab()
+
+ @command_args_parser.quoted(1, 1)
+ def command_query(self, args):
+ """
+ /query <nick> [message]
+ """
+ if args is None:
+ return self.core.command_help('query')
+ nick = args[0]
+ r = None
+ for user in self.users:
+ if user.nick == nick:
+ r = self.core.open_private_window(self.name, user.nick)
+ if r and len(args) == 2:
+ msg = args[1]
+ self.core.current_tab().command_say(
+ xhtml.convert_simple_to_full_colors(msg))
+ if not r:
+ self.core.information("Cannot find user: %s" % nick, 'Error')
+
+ @command_args_parser.raw
+ def command_topic(self, subject):
+ """
+ /topic [new topic]
+ """
+ if not subject:
+ self._text_buffer.add_message(
+ "\x19%s}The subject of the room is: %s %s" %
+ (dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ self.topic,
+ '(set by %s)' % self.topic_from if self.topic_from
+ else ''))
+ self.refresh()
+ return
+
+ muc.change_subject(self.core.xmpp, self.name, subject)
+
+ @command_args_parser.quoted(0)
+ def command_names(self, args):
+ """
+ /names
+ """
+ if not self.joined:
+ return
+
+ aff = {
+ 'owner': get_theme().CHAR_AFFILIATION_OWNER,
+ 'admin': get_theme().CHAR_AFFILIATION_ADMIN,
+ 'member': get_theme().CHAR_AFFILIATION_MEMBER,
+ 'none': get_theme().CHAR_AFFILIATION_NONE,
+ }
+
+ colors = {}
+ colors["visitor"] = dump_tuple(get_theme().COLOR_USER_VISITOR)
+ colors["moderator"] = dump_tuple(get_theme().COLOR_USER_MODERATOR)
+ colors["participant"] = dump_tuple(get_theme().COLOR_USER_PARTICIPANT)
+ color_other = dump_tuple(get_theme().COLOR_USER_NONE)
+
+ buff = ['Users: %s \n' % len(self.users)]
+ for user in self.users:
+ affiliation = aff.get(user.affiliation,
+ get_theme().CHAR_AFFILIATION_NONE)
+ color = colors.get(user.role, color_other)
+ buff.append('\x19%s}%s\x19o\x19%s}%s\x19o' % (
+ color, affiliation, dump_tuple(user.color), user.nick))
+
+ buff.append('\n')
+ message = ' '.join(buff)
+
+ self._text_buffer.add_message(message)
+ self.text_win.refresh()
+ self.input.refresh()
+
+ def completion_topic(self, the_input):
+ if the_input.get_argument_position() == 1:
+ return the_input.auto_completion([self.topic], '', quotify=False)
+
+ def completion_quoted(self, the_input):
+ """Nick completion, but with quotes"""
+ if the_input.get_argument_position(quoted=True) == 1:
+ compare_users = lambda x: x.last_talked
+ word_list = []
+ for user in sorted(self.users, key=compare_users, reverse=True):
+ if user.nick != self.own_nick:
+ word_list.append(user.nick)
+
+ return the_input.new_completion(word_list, 1, quotify=True)
+
+ @command_args_parser.quoted(1, 1)
+ def command_kick(self, args):
+ """
+ /kick <nick> [reason]
+ """
+ if args is None:
+ return self.core.command_help('kick')
+ if len(args) == 2:
+ msg = ' "%s"' % args[1]
+ else:
+ msg = ''
+ self.command_role('"'+args[0]+ '" none'+msg)
+
+ @command_args_parser.quoted(1, 1)
+ def command_ban(self, args):
+ """
+ /ban <nick> [reason]
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ self.core.room_error(iq, self.name)
+ if args is None:
+ return self.core.command_help('ban')
+ if len(args) > 1:
+ msg = args[1]
+ else:
+ msg = ''
+ nick = args[0]
+
+ if nick in [user.nick for user in self.users]:
+ res = muc.set_user_affiliation(self.core.xmpp, self.name,
+ 'outcast', nick=nick,
+ callback=callback, reason=msg)
+ else:
+ res = muc.set_user_affiliation(self.core.xmpp, self.name,
+ 'outcast', jid=safeJID(nick),
+ callback=callback, reason=msg)
+ if not res:
+ self.core.information('Could not ban user', 'Error')
+
+ @command_args_parser.quoted(2, 1, [''])
+ def command_role(self, args):
+ """
+ /role <nick> <role> [reason]
+ Changes the role of an user
+ roles can be: none, visitor, participant, moderator
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ self.core.room_error(iq, self.name)
+
+ if args is None:
+ return self.core.command_help('role')
+
+ nick, role, reason = args[0], args[1].lower(), args[2]
+
+ valid_roles = ('none', 'visitor', 'participant', 'moderator')
+
+ if not self.joined or role not in valid_roles:
+ return self.core.information('The role must be one of ' + ', '.join(valid_roles),
+ 'Error')
+
+ if not safeJID(self.name + '/' + nick):
+ return self.core.information('Invalid nick', 'Info')
+ muc.set_user_role(self.core.xmpp, self.name, nick, reason, role,
+ callback=callback)
+
+ @command_args_parser.quoted(2)
+ def command_affiliation(self, args):
+ """
+ /affiliation <nick> <role>
+ Changes the affiliation of an user
+ affiliations can be: outcast, none, member, admin, owner
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ self.core.room_error(iq, self.name)
+
+ if args is None:
+ return self.core.command_help('affiliation')
+
+ nick, affiliation = args[0], args[1].lower()
+
+ if not self.joined:
+ return
+
+ valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner')
+ if affiliation not in valid_affiliations:
+ return self.core.information('The affiliation must be one of ' + ', '.join(valid_affiliations),
+ 'Error')
+
+ if nick in [user.nick for user in self.users]:
+ res = muc.set_user_affiliation(self.core.xmpp, self.name,
+ affiliation, nick=nick,
+ callback=callback)
+ else:
+ res = muc.set_user_affiliation(self.core.xmpp, self.name,
+ affiliation, jid=safeJID(nick),
+ callback=callback)
+ if not res:
+ self.core.information('Could not set affiliation', 'Error')
+
+ @command_args_parser.raw
+ def command_say(self, line, correct=False):
+ """
+ /say <message>
+ Or normal input + enter
+ """
+ needed = 'inactive' if self.inactive else 'active'
+ msg = self.core.xmpp.make_message(self.name)
+ msg['type'] = 'groupchat'
+ msg['body'] = line
+ # trigger the event BEFORE looking for colors.
+ # This lets a plugin insert \x19xxx} colors, that will
+ # be converted in xhtml.
+ self.core.events.trigger('muc_say', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ return
+ if msg['body'].find('\x19') != -1:
+ msg.enable('html')
+ msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
+ msg['body'] = xhtml.clean_text(msg['body'])
+ if (config.get_by_tabname('send_chat_states', self.general_jid)
+ and self.remote_wants_chatstates is not False):
+ msg['chat_state'] = needed
+ if correct:
+ msg['replace']['id'] = self.last_sent_message['id']
+ self.cancel_paused_delay()
+ self.core.events.trigger('muc_say_after', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ return
+ self.last_sent_message = msg
+ msg.send()
+ self.chat_state = needed
+
+ @command_args_parser.raw
+ def command_xhtml(self, msg):
+ message = self.generate_xhtml_message(msg)
+ if message:
+ message['type'] = 'groupchat'
+ message.send()
+
+ @command_args_parser.quoted(1)
+ def command_ignore(self, args):
+ """
+ /ignore <nick>
+ """
+ if args is None:
+ return self.core.command_help('ignore')
+
+ nick = args[0]
+ user = self.get_user_by_name(nick)
+ if not user:
+ self.core.information('%s is not in the room' % nick)
+ elif user in self.ignores:
+ self.core.information('%s is already ignored' % nick)
+ else:
+ self.ignores.append(user)
+ self.core.information("%s is now ignored" % nick, 'info')
+
+ @command_args_parser.quoted(1)
+ def command_unignore(self, args):
+ """
+ /unignore <nick>
+ """
+ if args is None:
+ return self.core.command_help('unignore')
+
+ nick = args[0]
+ user = self.get_user_by_name(nick)
+ if not user:
+ self.core.information('%s is not in the room' % nick)
+ elif user not in self.ignores:
+ self.core.information('%s is not ignored' % nick)
+ else:
+ self.ignores.remove(user)
+ self.core.information('%s is now unignored' % nick)
+
+ def completion_unignore(self, the_input):
+ if the_input.get_argument_position() == 1:
+ users = [user.nick for user in self.ignores]
+ return the_input.auto_completion(users, quotify=False)
+
+ def resize(self):
+ """
+ Resize the whole window. i.e. all its sub-windows
+ """
+ self.need_resize = False
+ if config.get('hide_user_list') or self.size.tab_degrade_x:
+ display_user_list = False
+ text_width = self.width
+ else:
+ display_user_list = True
+ text_width = (self.width // 10) * 9
+
+ if self.size.tab_degrade_y:
+ display_info_win = False
+ tab_win_height = 0
+ info_win_height = 0
+ else:
+ display_info_win = True
+ tab_win_height = Tab.tab_win_height()
+ info_win_height = self.core.information_win_size
+
+
+ self.user_win.resize(self.height - 3 - info_win_height
+ - tab_win_height,
+ self.width - (self.width // 10) * 9 - 1,
+ 1,
+ (self.width // 10) * 9 + 1)
+ self.v_separator.resize(self.height - 3 - info_win_height - tab_win_height,
+ 1, 1, 9 * (self.width // 10))
+
+ self.topic_win.resize(1, self.width, 0, 0)
+
+ self.text_win.resize(self.height - 3 - info_win_height
+ - tab_win_height,
+ text_width, 1, 0)
+ self.text_win.rebuild_everything(self._text_buffer)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - info_win_height
+ - tab_win_height,
+ 0)
+ self.input.resize(1, self.width, self.height-1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ if config.get('hide_user_list') or self.size.tab_degrade_x:
+ display_user_list = False
+ else:
+ display_user_list = True
+ display_info_win = not self.size.tab_degrade_y
+
+ self.topic_win.refresh(self.get_single_line_topic())
+ self.text_win.refresh()
+ if display_user_list:
+ self.v_separator.refresh()
+ self.user_win.refresh(self.users)
+ self.info_header.refresh(self, self.text_win)
+ self.refresh_tab_win()
+ if display_info_win:
+ self.info_win.refresh()
+ self.input.refresh()
+
+ def on_input(self, key, raw):
+ if not raw and key in self.key_func:
+ self.key_func[key]()
+ return False
+ self.input.do_command(key, raw=raw)
+ empty_after = self.input.get_text() == ''
+ empty_after = empty_after or (self.input.get_text().startswith('/')
+ and not
+ self.input.get_text().startswith('//'))
+ self.send_composing_chat_state(empty_after)
+ return False
+
+ def completion(self):
+ """
+ Called when Tab is pressed, complete the nickname in the input
+ """
+ if self.complete_commands(self.input):
+ return
+
+ # If we are not completing a command or a command argument,
+ # complete a nick
+ compare_users = lambda x: x.last_talked
+ word_list = []
+ for user in sorted(self.users, key=compare_users, reverse=True):
+ if user.nick != self.own_nick:
+ word_list.append(user.nick)
+ after = config.get('after_completion') + ' '
+ input_pos = self.input.pos
+ if ' ' not in self.input.get_text()[:input_pos] or (
+ self.input.last_completion and
+ self.input.get_text()[:input_pos] ==
+ self.input.last_completion + after):
+ add_after = after
+ else:
+ if not config.get('add_space_after_completion'):
+ add_after = ''
+ else:
+ add_after = ' '
+ self.input.auto_completion(word_list, add_after, quotify=False)
+ empty_after = self.input.get_text() == ''
+ empty_after = empty_after or (self.input.get_text().startswith('/')
+ and not
+ self.input.get_text().startswith('//'))
+ self.send_composing_chat_state(empty_after)
+
+ def get_nick(self):
+ if not config.get('show_muc_jid'):
+ return safeJID(self.name).user
+ return self.name
+
+ def get_text_window(self):
+ return self.text_win
+
+ def on_lose_focus(self):
+ if self.joined:
+ if self.input.text:
+ self.state = 'nonempty'
+ else:
+ self.state = 'normal'
+ else:
+ self.state = 'disconnected'
+ self.text_win.remove_line_separator()
+ self.text_win.add_line_separator(self._text_buffer)
+ if (config.get_by_tabname('send_chat_states', self.general_jid) and
+ not self.input.get_text()):
+ self.send_chat_state('inactive')
+ self.check_scrolled()
+
+ def on_gain_focus(self):
+ self.state = 'current'
+ if (self.text_win.built_lines and self.text_win.built_lines[-1] is None
+ and not config.get('show_useless_separator')):
+ self.text_win.remove_line_separator()
+ curses.curs_set(1)
+ if self.joined and config.get_by_tabname('send_chat_states',
+ self.general_jid) and not self.input.get_text():
+ self.send_chat_state('active')
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height-3:
+ return
+ if config.get("hide_user_list"):
+ text_width = self.width
+ else:
+ text_width = (self.width//10)*9
+ self.user_win.resize(self.height - 3 - self.core.information_win_size
+ - Tab.tab_win_height(),
+ self.width - (self.width // 10) * 9 - 1,
+ 1,
+ (self.width // 10) * 9 + 1)
+ self.v_separator.resize(self.height - 3 - self.core.information_win_size - Tab.tab_win_height(),
+ 1, 1, 9 * (self.width // 10))
+ self.text_win.resize(self.height - 3 - self.core.information_win_size
+ - Tab.tab_win_height(),
+ text_width, 1, 0)
+ self.info_header.resize(1, self.width,
+ self.height-2-self.core.information_win_size
+ - Tab.tab_win_height(),
+ 0)
+
+ def handle_presence(self, presence):
+ from_nick = presence['from'].resource
+ from_room = presence['from'].bare
+ xpath = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER)
+ status_codes = set()
+ for status_code in presence.findall(xpath):
+ status_codes.add(status_code.attrib['code'])
+
+ # Check if it's not an error presence.
+ if presence['type'] == 'error':
+ return self.core.room_error(presence, from_room)
+ affiliation = presence['muc']['affiliation']
+ show = presence['show']
+ status = presence['status']
+ role = presence['muc']['role']
+ jid = presence['muc']['jid']
+ typ = presence['type']
+ deterministic = config.get_by_tabname('deterministic_nick_colors', self.name)
+ color = self.search_for_color(from_nick)
+ if not self.joined: # user in the room BEFORE us.
+ # ignore redondant presence message, see bug #1509
+ if (from_nick not in [user.nick for user in self.users]
+ and typ != "unavailable"):
+ new_user = User(from_nick, affiliation, show,
+ status, role, jid, deterministic, color)
+ bisect.insort_left(self.users, new_user)
+ self.core.events.trigger('muc_join', presence, self)
+ if '110' in status_codes or self.own_nick == from_nick:
+ # second part of the condition is a workaround for old
+ # ejabberd or every gateway in the world that just do
+ # not send a 110 status code with the presence
+ self.own_nick = from_nick
+ self.joined = True
+ if self.name in self.core.initial_joins:
+ self.core.initial_joins.remove(self.name)
+ self._state = 'normal'
+ elif self != self.core.current_tab():
+ self._state = 'joined'
+ if (self.core.current_tab() is self
+ and self.core.status.show not in ('xa', 'away')):
+ self.send_chat_state('active')
+ new_user.color = get_theme().COLOR_OWN_NICK
+
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(new_user.color)
+ else:
+ color = 3
+
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ warn_col = dump_tuple(get_theme().COLOR_WARNING_TEXT)
+ spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR)
+
+ self.add_message(
+ '\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You '
+ '(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined'
+ ' the chatroom' %
+ {
+ 'nick': from_nick,
+ 'spec': get_theme().CHAR_JOIN,
+ 'color_spec': spec_col,
+ 'nick_col': color,
+ 'info_col': info_col,
+ },
+ typ=2)
+ if '201' in status_codes:
+ self.add_message(
+ '\x19%(info_col)s}Info: The room '
+ 'has been created' %
+ {'info_col': info_col},
+ 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},
+ 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},
+ typ=0)
+ if self.core.current_tab() is not self:
+ self.refresh_tab_win()
+ self.core.current_tab().input.refresh()
+ self.core.doupdate()
+ self.core.enable_private_tabs(self.name)
+ # Enable the self ping event, to regularly check if we
+ # are still in the room.
+ self.enable_self_ping_event()
+ else:
+ change_nick = '303' in status_codes
+ kick = '307' in status_codes and typ == 'unavailable'
+ ban = '301' in status_codes and typ == 'unavailable'
+ shutdown = '332' in status_codes and typ == 'unavailable'
+ non_member = '322' in status_codes and typ == 'unavailable'
+ user = self.get_user_by_name(from_nick)
+ # New user
+ if not user:
+ self.core.events.trigger('muc_join', presence, self)
+ self.on_user_join(from_nick, affiliation, show, status, role,
+ jid, color)
+ # nick change
+ elif change_nick:
+ self.core.events.trigger('muc_nickchange', presence, self)
+ self.on_user_nick_change(presence, user, from_nick, from_room)
+ elif ban:
+ self.core.events.trigger('muc_ban', presence, self)
+ self.core.on_user_left_private_conversation(from_room,
+ from_nick, status)
+ self.on_user_banned(presence, user, from_nick)
+ # kick
+ elif kick:
+ self.core.events.trigger('muc_kick', presence, self)
+ self.core.on_user_left_private_conversation(from_room,
+ from_nick, status)
+ self.on_user_kicked(presence, user, from_nick)
+ elif shutdown:
+ self.core.events.trigger('muc_shutdown', presence, self)
+ self.on_muc_shutdown()
+ elif non_member:
+ self.core.events.trigger('muc_shutdown', presence, self)
+ self.on_non_member_kicked()
+ # user quit
+ elif typ == 'unavailable':
+ self.on_user_leave_groupchat(user, jid, status,
+ from_nick, from_room)
+ # status change
+ else:
+ self.on_user_change_status(user, from_nick, from_room,
+ affiliation, role, show, status)
+ if self.core.current_tab() is self:
+ self.text_win.refresh()
+ self.user_win.refresh_if_changed(self.users)
+ self.info_header.refresh(self, self.text_win)
+ self.input.refresh()
+ self.core.doupdate()
+
+ 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)},
+ 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)},
+ typ=2)
+ self.disconnect()
+
+ def on_user_join(self, from_nick, affiliation, show, status, role, jid, color):
+ """
+ When a new user joins the groupchat
+ """
+ deterministic = config.get_by_tabname('deterministic_nick_colors', self.name)
+ user = User(from_nick, affiliation,
+ show, status, role, jid, deterministic, color)
+ bisect.insort_left(self.users, user)
+ hide_exit_join = config.get_by_tabname('hide_exit_join',
+ self.general_jid)
+ if hide_exit_join != 0:
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR)
+ char_join = get_theme().CHAR_JOIN
+ if not jid.full:
+ msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s'
+ '\x19%(info_col)s} joined the chatroom') % {
+ 'nick': from_nick, 'spec': char_join,
+ 'color': color,
+ 'info_col': info_col,
+ 'color_spec': spec_col,
+ }
+ else:
+ msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s'
+ '\x19%(info_col)s} (\x19%(jid_color)s}%(jid)s\x19'
+ '%(info_col)s}) joined the chatroom') % {
+ 'spec': char_join, 'nick': from_nick,
+ 'color':color, 'jid':jid.full,
+ 'info_col': info_col,
+ 'jid_color': dump_tuple(get_theme().COLOR_MUC_JID),
+ 'color_spec': spec_col,
+ }
+ self.add_message(msg, typ=2)
+ self.core.on_user_rejoined_private_conversation(self.name, from_nick)
+
+ def on_user_nick_change(self, presence, user, from_nick, from_room):
+ new_nick = presence.find('{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)
+ ).attrib['nick']
+ if user.nick == self.own_nick:
+ self.own_nick = new_nick
+ # also change our nick in all private discussions of this room
+ self.core.on_muc_own_nickchange(self)
+ else:
+ color = config.get_by_tabname(new_nick, 'muc_colors')
+ if color != '':
+ deterministic = config.get_by_tabname('deterministic_nick_colors',
+ self.name)
+ user.change_color(color, deterministic)
+ user.change_nick(new_nick)
+ self.users.remove(user)
+ bisect.insort_left(self.users, user)
+
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ self.add_message('\x19%(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, 'info_col': info_col},
+ typ=2)
+ # rename the private tabs if needed
+ self.core.rename_private_tabs(self.name, from_nick, new_nick)
+
+ def on_user_banned(self, presence, user, from_nick):
+ """
+ When someone is banned from a muc
+ """
+ self.users.remove(user)
+ by = presence.find('{%s}x/{%s}item/{%s}actor' %
+ (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ reason = presence.find('{%s}x/{%s}item/{%s}reason' %
+ (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ by = by.attrib['jid'] if by is not None else None
+
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ char_kick = get_theme().CHAR_KICK
+
+ if from_nick == self.own_nick: # we are banned
+ if by:
+ kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
+ ' have been banned by \x194}%(by)s') % {
+ 'spec': char_kick, 'by': by,
+ 'info_col': info_col}
+ else:
+ kick_msg = ('\x191}%(spec)s \x193}You\x19'
+ '%(info_col)s} have been banned.') % {
+ 'spec': char_kick, 'info_col': info_col}
+ self.core.disable_private_tabs(self.name, reason=kick_msg)
+ self.disconnect()
+ self.refresh_tab_win()
+ self.core.current_tab().input.refresh()
+ self.core.doupdate()
+ if config.get_by_tabname('autorejoin', self.general_jid):
+ delay = config.get_by_tabname('autorejoin_delay',
+ self.general_jid)
+ delay = common.parse_str_to_secs(delay)
+ if delay <= 0:
+ muc.join_groupchat(self.core, self.name, self.own_nick)
+ else:
+ self.core.add_timed_event(timed_events.DelayedEvent(
+ delay,
+ muc.join_groupchat,
+ self.core,
+ self.name,
+ self.own_nick))
+
+ else:
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+
+ if by:
+ kick_msg = ('\x191}%(spec)s \x19%(color)s}'
+ '%(nick)s\x19%(info_col)s} '
+ 'has been banned by \x194}%(by)s') % {
+ 'spec': char_kick, 'nick': from_nick,
+ 'color': color, 'by': by,
+ 'info_col': info_col}
+ else:
+ kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
+ '\x19%(info_col)s} has been banned') % {
+ 'spec': char_kick, 'nick': from_nick,
+ 'color': color, 'info_col': info_col}
+ if reason is not None and reason.text:
+ kick_msg += ('\x19%(info_col)s} Reason: \x196}'
+ '%(reason)s\x19%(info_col)s}') % {
+ 'reason': reason.text, 'info_col': info_col}
+ self.add_message(kick_msg, typ=2)
+
+ def on_user_kicked(self, presence, user, from_nick):
+ """
+ When someone is kicked from a muc
+ """
+ self.users.remove(user)
+ actor_elem = presence.find('{%s}x/{%s}item/{%s}actor' %
+ (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ reason = presence.find('{%s}x/{%s}item/{%s}reason' %
+ (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ by = None
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ char_kick = get_theme().CHAR_KICK
+ if actor_elem is not None:
+ by = actor_elem.get('nick') or actor_elem.get('jid')
+ if from_nick == self.own_nick: # we are kicked
+ if by:
+ kick_msg = ('\x191}%(spec)s \x193}You\x19'
+ '%(info_col)s} have been kicked'
+ ' by \x193}%(by)s') % {
+ 'spec': char_kick, 'by': by,
+ 'info_col': info_col}
+ else:
+ kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
+ ' have been kicked.') % {
+ 'spec': char_kick,
+ 'info_col': info_col}
+ self.core.disable_private_tabs(self.name, reason=kick_msg)
+ self.disconnect()
+ self.refresh_tab_win()
+ self.core.current_tab().input.refresh()
+ self.core.doupdate()
+ # try to auto-rejoin
+ if config.get_by_tabname('autorejoin', self.general_jid):
+ delay = config.get_by_tabname('autorejoin_delay',
+ self.general_jid)
+ delay = common.parse_str_to_secs(delay)
+ if delay <= 0:
+ muc.join_groupchat(self.core, self.name, self.own_nick)
+ else:
+ self.core.add_timed_event(timed_events.DelayedEvent(
+ delay,
+ muc.join_groupchat,
+ self.core,
+ self.name,
+ self.own_nick))
+ else:
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+ if by:
+ kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
+ '\x19%(info_col)s} has been kicked by '
+ '\x193}%(by)s') % {
+ 'spec': char_kick, 'nick':from_nick,
+ 'color':color, 'by':by, 'info_col': info_col}
+ else:
+ kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
+ '\x19%(info_col)s} has been kicked') % {
+ 'spec': char_kick, 'nick': from_nick,
+ 'color':color, 'info_col': info_col}
+ if reason is not None and reason.text:
+ kick_msg += ('\x19%(info_col)s} Reason: \x196}'
+ '%(reason)s') % {
+ 'reason': reason.text, 'info_col': info_col}
+ self.add_message(kick_msg, typ=2)
+
+ def on_user_leave_groupchat(self, user, jid, status, from_nick, from_room):
+ """
+ When an user leaves a groupchat
+ """
+ self.users.remove(user)
+ if self.own_nick == user.nick:
+ # We are now out of the room.
+ # Happens with some buggy (? not sure) servers
+ self.disconnect()
+ self.core.disable_private_tabs(from_room)
+ self.refresh_tab_win()
+
+ hide_exit_join = config.get_by_tabname('hide_exit_join',
+ self.general_jid)
+
+ if hide_exit_join <= -1 or user.has_talked_since(hide_exit_join):
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR)
+
+ if not jid.full:
+ leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}'
+ '%(nick)s\x19%(info_col)s} has left the '
+ 'chatroom') % {
+ 'nick':from_nick, 'color':color,
+ 'spec':get_theme().CHAR_QUIT,
+ 'info_col': info_col,
+ 'color_spec': spec_col}
+ else:
+ jid_col = dump_tuple(get_theme().COLOR_MUC_JID)
+ leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}'
+ '%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}'
+ '%(jid)s\x19%(info_col)s}) has left the '
+ 'chatroom') % {
+ 'spec':get_theme().CHAR_QUIT,
+ 'nick':from_nick, 'color':color,
+ 'jid':jid.full, 'info_col': info_col,
+ 'color_spec': spec_col,
+ 'jid_col': jid_col}
+ if status:
+ leave_msg += ' (\x19o%s\x19%s})' % (status, info_col)
+ self.add_message(leave_msg, typ=2)
+ self.core.on_user_left_private_conversation(from_room, from_nick,
+ status)
+
+ def on_user_change_status(
+ self, user, from_nick, from_room, affiliation, role, show, status):
+ """
+ When an user changes her status
+ """
+ # build the message
+ display_message = False # flag to know if something significant enough
+ # to be displayed has changed
+ if config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ color = dump_tuple(user.color)
+ else:
+ color = 3
+ if from_nick == self.own_nick:
+ msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % {
+ 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'color': color}
+ else:
+ msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % {
+ 'nick': from_nick, 'color': color,
+ 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}
+ if affiliation != user.affiliation:
+ msg += 'affiliation: %s, ' % affiliation
+ display_message = True
+ if role != user.role:
+ msg += 'role: %s, ' % role
+ display_message = True
+ if show != user.show and show in SHOW_NAME:
+ msg += 'show: %s, ' % SHOW_NAME[show]
+ display_message = True
+ if status != user.status:
+ # if the user sets his status to nothing
+ if status:
+ msg += 'status: %s, ' % status
+ display_message = True
+ elif show in SHOW_NAME and show == user.show:
+ msg += 'show: %s, ' % SHOW_NAME[show]
+ display_message = True
+ if not display_message:
+ return
+ msg = msg[:-2] # remove the last ", "
+ hide_status_change = config.get_by_tabname('hide_status_change',
+ self.general_jid)
+ if hide_status_change < -1:
+ hide_status_change = -1
+ if ((hide_status_change == -1 or \
+ user.has_talked_since(hide_status_change) or\
+ user.nick == self.own_nick)\
+ and\
+ (affiliation != user.affiliation or\
+ role != user.role 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.core.on_user_changed_status_in_private('%s/%s' %
+ (from_room, from_nick),
+ msg)
+ self.users.remove(user)
+ # finally, effectively change the user status
+ user.update(affiliation, show, status, role)
+ bisect.insort_left(self.users, user)
+
+ def disconnect(self):
+ """
+ Set the state of the room as not joined, so
+ we can know if we can join it, send messages to it, etc
+ """
+ self.users = []
+ if self is not self.core.current_tab():
+ self.state = 'disconnected'
+ self.joined = False
+ self.disable_self_ping_event()
+
+ def get_single_line_topic(self):
+ """
+ Return the topic as a single-line string (for the window header)
+ """
+ return self.topic.replace('\n', '|')
+
+ def log_message(self, txt, nickname, time=None, 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.name, nickname, txt, typ=typ):
+ self.core.information('Unable to write in the log file',
+ 'Error')
+
+ def do_highlight(self, txt, time, nickname):
+ """
+ Set the tab color and returns the nick color
+ """
+ highlighted = False
+ if not time and nickname and nickname != self.own_nick and self.joined:
+
+ if re.search(r'\b' + self.own_nick.lower() + r'\b', txt.lower()):
+ if self.state != 'current':
+ self.state = 'highlight'
+ highlighted = True
+ else:
+ highlight_words = config.get_by_tabname('highlight_on',
+ self.general_jid)
+ highlight_words = highlight_words.split(':')
+ for word in highlight_words:
+ if word and word.lower() in txt.lower():
+ if self.state != 'current':
+ self.state = 'highlight'
+ highlighted = True
+ break
+ if highlighted:
+ beep_on = config.get('beep_on').split()
+ if 'highlight' in beep_on and 'message' not in beep_on:
+ if not config.get_by_tabname('disable_beep', self.name):
+ curses.beep()
+ return highlighted
+
+ def get_user_by_name(self, nick):
+ """
+ Gets the user associated with the given nick, or None if not found
+ """
+ for user in self.users:
+ if user.nick == nick:
+ return user
+ return None
+
+ def add_message(self, txt, time=None, nickname=None, **kwargs):
+ """
+ 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.name)):
+ 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)
+
+ def modify_message(self, txt, old_id, new_id,
+ time=None, nickname=None, user=None, jid=None):
+ self.log_message(txt, nickname, time=time, typ=1)
+ highlight = self.do_highlight(txt, time, nickname)
+ message = self._text_buffer.modify_message(txt, old_id, new_id,
+ highlight=highlight,
+ time=time, user=user,
+ jid=jid)
+ if message:
+ self.text_win.modify_message(old_id, message)
+ return highlight
+ return False
+
+ def matching_names(self):
+ return [(1, safeJID(self.name).user), (3, self.name)]
+
+ def enable_self_ping_event(self):
+ delay = config.get_by_tabname("self_ping_delay", self.general_jid, default=0)
+ if delay <= 0: # use 0 or some negative value to disable it
+ return
+ self.disable_self_ping_event()
+ self.self_ping_event = timed_events.DelayedEvent(delay, self.send_self_ping)
+ self.core.add_timed_event(self.self_ping_event)
+
+ def disable_self_ping_event(self):
+ if self.self_ping_event is not None:
+ self.core.remove_timed_event(self.self_ping_event)
+ self.self_ping_event = None
+
+ def send_self_ping(self):
+ to = self.name + "/" + self.own_nick
+ self.core.xmpp.plugin['xep_0199'].send_ping(jid=to,
+ callback=self.on_self_ping_result,
+ timeout_callback=self.on_self_ping_failed,
+ timeout=60)
+
+ def on_self_ping_result(self, iq):
+ if iq["type"] == "error":
+ self.command_cycle(iq["error"]["text"] or "not in this room")
+ self.core.refresh_window()
+ else: # Re-send a self-ping in a few seconds
+ self.enable_self_ping_event()
+
+ def search_for_color(self, nick):
+ """
+ Search for the color of a nick in the config file.
+ Also, look at the colors of its possible aliases if nick_color_aliases
+ is set.
+ """
+ color = config.get_by_tabname(nick, 'muc_colors')
+ if color != '':
+ return color
+ nick_color_aliases = config.get_by_tabname('nick_color_aliases', self.name)
+ if nick_color_aliases:
+ nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick)
+ color = config.get_by_tabname(nick_alias, 'muc_colors')
+ return color
+
+ def on_self_ping_failed(self, iq):
+ self.command_cycle("the MUC server is not responding")
+ self.core.refresh_window()
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
new file mode 100644
index 00000000..a715a922
--- /dev/null
+++ b/poezio/tabs/privatetab.py
@@ -0,0 +1,362 @@
+"""
+Module for the PrivateTab
+
+A PrivateTab is a private conversation opened with someone from a MUC
+(see muctab.py). The conversation happens with both JID being relative
+to the MUC (room@server/nick1 and room@server/nick2).
+
+This tab references his parent room, and is modified to keep track of
+both participant’s nicks. It also has slightly different features than
+the ConversationTab (such as tab-completion on nicks from the room).
+
+"""
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+from . import OneToOneTab, MucTab, Tab
+
+import fixes
+import windows
+import xhtml
+from common import safeJID
+from config import config
+from decorators import refresh_wrapper
+from logger import logger
+from theming import get_theme, dump_tuple
+from decorators import command_args_parser
+
+class PrivateTab(OneToOneTab):
+ """
+ The tab containg a private conversation (someone from a MUC)
+ """
+ message_type = 'chat'
+ plugin_commands = {}
+ additional_informations = {}
+ plugin_keys = {}
+ def __init__(self, name, nick):
+ OneToOneTab.__init__(self, name)
+ self.own_nick = nick
+ self.name = name
+ self.text_win = windows.TextWin()
+ self._text_buffer.add_window(self.text_win)
+ self.info_header = windows.PrivateInfoWin()
+ self.input = windows.MessageInput()
+ # keys
+ self.key_func['^I'] = self.completion
+ # commands
+ self.register_command('info', self.command_info,
+ desc='Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.',
+ shortdesc='Info about the user.')
+ self.register_command('unquery', self.command_unquery,
+ shortdesc='Close the tab.')
+ self.register_command('close', self.command_unquery,
+ shortdesc='Close the tab.')
+ self.register_command('version', self.command_version,
+ desc='Get the software version of the current interlocutor (usually its XMPP client and Operating System).',
+ shortdesc='Get the software version of a jid.')
+ self.resize()
+ self.parent_muc = self.core.get_tab_by_name(safeJID(name).bare, MucTab)
+ self.on = True
+ self.update_commands()
+ self.update_keys()
+
+ @property
+ def general_jid(self):
+ return self.name
+
+ def get_dest_jid(self):
+ return self.name
+
+ @property
+ def nick(self):
+ return self.get_nick()
+
+ @staticmethod
+ def add_information_element(plugin_name, callback):
+ """
+ Lets a plugin add its own information to the PrivateInfoWin
+ """
+ PrivateTab.additional_informations[plugin_name] = callback
+
+ @staticmethod
+ def remove_information_element(plugin_name):
+ del PrivateTab.additional_informations[plugin_name]
+
+ def load_logs(self, log_nb):
+ logs = logger.get_logs(safeJID(self.name).full.replace('/', '\\'), log_nb)
+ return logs
+
+ def log_message(self, txt, nickname, time=None, typ=1):
+ """
+ Log the messages in the archives.
+ """
+ if not logger.log_message(self.name, nickname, txt, date=time, typ=typ):
+ self.core.information('Unable to write in the log file', 'Error')
+
+ def on_close(self):
+ self.parent_muc.privates.remove(self)
+
+ def completion(self):
+ """
+ Called when Tab is pressed, complete the nickname in the input
+ """
+ if self.complete_commands(self.input):
+ return
+
+ # If we are not completing a command or a command's argument, complete a nick
+ compare_users = lambda x: x.last_talked
+ word_list = [user.nick for user in sorted(self.parent_muc.users, key=compare_users, reverse=True)\
+ if user.nick != self.own_nick]
+ after = config.get('after_completion') + ' '
+ input_pos = self.input.pos
+ if ' ' not in self.input.get_text()[:input_pos] or (self.input.last_completion and\
+ self.input.get_text()[:input_pos] == self.input.last_completion + after):
+ add_after = after
+ else:
+ add_after = ''
+ self.input.auto_completion(word_list, add_after, quotify=False)
+ empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//'))
+ self.send_composing_chat_state(empty_after)
+
+ @command_args_parser.raw
+ def command_say(self, line, attention=False, correct=False):
+ if not self.on:
+ return
+ msg = self.core.xmpp.make_message(self.name)
+ msg['type'] = 'chat'
+ msg['body'] = line
+ # trigger the event BEFORE looking for colors.
+ # This lets a plugin insert \x19xxx} colors, that will
+ # be converted in xhtml.
+ self.core.events.trigger('private_say', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ 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']
+ if config.get_by_tabname('group_corrections', self.name):
+ try:
+ self.modify_message(msg['body'], self.last_sent_message['id'], msg['id'],
+ user=user, jid=self.core.xmpp.boundjid, nickname=self.own_nick)
+ replaced = True
+ except:
+ log.error('Unable to correct a message', exc_info=True)
+ else:
+ del msg['replace']
+
+ if msg['body'].find('\x19') != -1:
+ msg.enable('html')
+ msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
+ msg['body'] = xhtml.clean_text(msg['body'])
+ if (config.get_by_tabname('send_chat_states', self.general_jid) and
+ self.remote_wants_chatstates is not False):
+ needed = 'inactive' if self.inactive else 'active'
+ msg['chat_state'] = needed
+ if attention and self.remote_supports_attention:
+ msg['attention'] = True
+ self.core.events.trigger('private_say_after', msg, self)
+ if not msg['body']:
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+ return
+ if not replaced:
+ self.add_message(msg['body'],
+ nickname=self.own_nick or self.core.own_nick,
+ forced_user=user,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=msg['id'],
+ jid=self.core.xmpp.boundjid,
+ typ=1)
+
+ self.last_sent_message = msg
+ if self.remote_supports_receipts:
+ msg._add_receipt = True
+ msg.send()
+ self.cancel_paused_delay()
+ self.text_win.refresh()
+ self.input.refresh()
+
+ @command_args_parser.ignored
+ def command_unquery(self):
+ """
+ /unquery
+ """
+ self.core.close_tab()
+
+ @command_args_parser.quoted(0, 1)
+ def command_version(self, args):
+ """
+ /version
+ """
+ def callback(res):
+ if not res:
+ return self.core.information('Could not get the software version from %s' % (jid,), 'Warning')
+ version = '%s is running %s version %s on %s' % (jid,
+ res.get('name') or 'an unknown software',
+ res.get('version') or 'unknown',
+ res.get('os') or 'an unknown platform')
+ self.core.information(version, 'Info')
+ if args:
+ return self.core.command_version(args[0])
+ jid = safeJID(self.name)
+ fixes.get_version(self.core.xmpp, jid,
+ callback=callback)
+
+ @command_args_parser.quoted(0, 1)
+ def command_info(self, arg):
+ """
+ /info
+ """
+ if arg and arg[0]:
+ self.parent_muc.command_info(arg[0])
+ else:
+ user = safeJID(self.name).resource
+ self.parent_muc.command_info(user)
+
+ def resize(self):
+ self.need_resize = False
+
+ if self.size.tab_degrade_y:
+ info_win_height = 0
+ tab_win_height = 0
+ else:
+ info_win_height = self.core.information_win_size
+ tab_win_height = Tab.tab_win_height()
+
+ self.text_win.resize(self.height - 2 - info_win_height - tab_win_height,
+ self.width, 0, 0)
+ self.text_win.rebuild_everything(self._text_buffer)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - info_win_height
+ - tab_win_height,
+ 0)
+ self.input.resize(1, self.width, self.height-1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ display_info_win = not self.size.tab_degrade_y
+
+ self.text_win.refresh()
+ self.info_header.refresh(self.name, self.text_win, self.chatstate,
+ PrivateTab.additional_informations)
+ if display_info_win:
+ self.info_win.refresh()
+
+ self.refresh_tab_win()
+ self.input.refresh()
+
+ def refresh_info_header(self):
+ self.info_header.refresh(self.name, self.text_win, self.chatstate, PrivateTab.additional_informations)
+ self.input.refresh()
+
+ def get_nick(self):
+ return safeJID(self.name).resource
+
+ def on_input(self, key, raw):
+ if not raw and key in self.key_func:
+ self.key_func[key]()
+ return False
+ self.input.do_command(key, raw=raw)
+ if not self.on:
+ return False
+ empty_after = self.input.get_text() == '' or (self.input.get_text().startswith('/') and not self.input.get_text().startswith('//'))
+ tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab)
+ if tab and tab.joined:
+ self.send_composing_chat_state(empty_after)
+ return False
+
+ def on_lose_focus(self):
+ if self.input.text:
+ self.state = 'nonempty'
+ else:
+ self.state = 'normal'
+
+ self.text_win.remove_line_separator()
+ self.text_win.add_line_separator(self._text_buffer)
+ tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab)
+ if tab and tab.joined and config.get_by_tabname('send_chat_states',
+ self.general_jid) and not self.input.get_text() and self.on:
+ self.send_chat_state('inactive')
+ self.check_scrolled()
+
+ def on_gain_focus(self):
+ self.state = 'current'
+ curses.curs_set(1)
+ tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab)
+ if tab and tab.joined and config.get_by_tabname('send_chat_states',
+ self.general_jid,) and not self.input.get_text() and self.on:
+ self.send_chat_state('active')
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height-3:
+ return
+ self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0)
+ self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0)
+
+ def get_text_window(self):
+ return self.text_win
+
+ @refresh_wrapper.conditional
+ def rename_user(self, old_nick, new_nick):
+ """
+ The user changed her nick in the corresponding muc: update the tab’s name and
+ display a message.
+ """
+ self.add_message('\x193}%(old)s\x19%(info_col)s} is now known as \x193}%(new)s' % {'old':old_nick, 'new':new_nick, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2)
+ new_jid = safeJID(self.name).bare+'/'+new_nick
+ self.name = new_jid
+ return self.core.current_tab() is self
+
+ @refresh_wrapper.conditional
+ def user_left(self, status_message, from_nick):
+ """
+ The user left the associated MUC
+ """
+ self.deactivate()
+ if not status_message:
+ self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2)
+ else:
+ self.add_message('\x191}%(spec)s \x193}%(nick)s\x19%(info_col)s} has left the room (%(status)s)"' % {'nick':from_nick, 'spec':get_theme().CHAR_QUIT, 'status': status_message, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2)
+ return self.core.current_tab() is self
+
+ @refresh_wrapper.conditional
+ def user_rejoined(self, nick):
+ """
+ The user (or at least someone with the same nick) came back in the MUC
+ """
+ self.activate()
+ self.check_features()
+ tab = self.core.get_tab_by_name(safeJID(self.name).bare, MucTab)
+ color = 3
+ if tab and config.get_by_tabname('display_user_color_in_join_part',
+ self.general_jid):
+ user = tab.get_user_by_name(nick)
+ if user:
+ color = dump_tuple(user.color)
+ self.add_message('\x194}%(spec)s \x19%(color)s}%(nick)s\x19%(info_col)s} joined the room' % {'nick':nick, 'color': color, 'spec':get_theme().CHAR_JOIN, 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}, typ=2)
+ return self.core.current_tab() is self
+
+ def activate(self, reason=None):
+ self.on = True
+ if reason:
+ self.add_message(txt=reason, typ=2)
+
+ def deactivate(self, reason=None):
+ self.on = False
+ self.remote_wants_chatstates = None
+ if reason:
+ self.add_message(txt=reason, typ=2)
+
+ def matching_names(self):
+ return [(3, safeJID(self.name).resource), (4, self.name)]
+
+
diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py
new file mode 100644
index 00000000..a5c22304
--- /dev/null
+++ b/poezio/tabs/rostertab.py
@@ -0,0 +1,1280 @@
+"""
+The RosterInfoTab is the tab showing roster info, the list of contacts,
+half of it is dedicated to showing the information buffer, and a small
+rectangle shows the current contact info.
+
+This module also includes functions to match users in the roster.
+"""
+import logging
+log = logging.getLogger(__name__)
+
+import base64
+import curses
+import difflib
+import os
+import ssl
+from os import getenv, path
+from functools import partial
+
+from . import Tab
+
+import common
+import windows
+from common import safeJID
+from config import config
+from contact import Contact, Resource
+from decorators import refresh_wrapper
+from roster import RosterGroup, roster
+from theming import get_theme, dump_tuple
+from decorators import command_args_parser
+
+class RosterInfoTab(Tab):
+ """
+ A tab, splitted in two, containing the roster and infos
+ """
+ plugin_commands = {}
+ plugin_keys = {}
+ def __init__(self):
+ Tab.__init__(self)
+ self.name = "Roster"
+ self.v_separator = windows.VerticalSeparator()
+ self.information_win = windows.TextWin()
+ self.core.information_buffer.add_window(self.information_win)
+ self.roster_win = windows.RosterWin()
+ self.contact_info_win = windows.ContactInfoWin()
+ self.default_help_message = windows.HelpText("Enter commands with “/”. “o”: toggle offline show")
+ self.input = self.default_help_message
+ self.state = 'normal'
+ self.key_func['^I'] = self.completion
+ self.key_func["/"] = self.on_slash
+ # disable most of the roster features when in anonymous mode
+ if not self.core.xmpp.anon:
+ self.key_func[' '] = self.on_space
+ self.key_func["KEY_UP"] = self.move_cursor_up
+ self.key_func["KEY_DOWN"] = self.move_cursor_down
+ self.key_func["M-u"] = self.move_cursor_to_next_contact
+ self.key_func["M-y"] = self.move_cursor_to_prev_contact
+ self.key_func["M-U"] = self.move_cursor_to_next_group
+ self.key_func["M-Y"] = self.move_cursor_to_prev_group
+ self.key_func["M-[1;5B"] = self.move_cursor_to_next_group
+ self.key_func["M-[1;5A"] = self.move_cursor_to_prev_group
+ self.key_func["l"] = self.command_last_activity
+ self.key_func["o"] = self.toggle_offline_show
+ self.key_func["v"] = self.get_contact_version
+ self.key_func["i"] = self.show_contact_info
+ self.key_func["s"] = self.start_search
+ self.key_func["S"] = self.start_search_slow
+ self.key_func["n"] = self.change_contact_name
+ self.register_command('deny', self.command_deny,
+ usage='[jid]',
+ desc='Deny your presence to the provided JID (or the '
+ 'selected contact in your roster), who is asking'
+ 'you to be in his/here roster.',
+ shortdesc='Deny an user your presence.',
+ completion=self.completion_deny)
+ self.register_command('accept', self.command_accept,
+ usage='[jid]',
+ desc='Allow the provided JID (or the selected contact '
+ 'in your roster), to see your presence.',
+ shortdesc='Allow an user your presence.',
+ completion=self.completion_deny)
+ self.register_command('add', self.command_add,
+ usage='<jid>',
+ desc='Add the specified JID to your roster, ask him to'
+ ' allow you to see his presence, and allow him to'
+ ' see your presence.',
+ shortdesc='Add an user to your roster.')
+ self.register_command('name', self.command_name,
+ usage='<jid> [name]',
+ shortdesc='Set the given JID\'s name.',
+ completion=self.completion_name)
+ self.register_command('groupadd', self.command_groupadd,
+ usage='<jid> <group>',
+ desc='Add the given JID to the given group.',
+ shortdesc='Add an user to a group',
+ completion=self.completion_groupadd)
+ self.register_command('groupmove', self.command_groupmove,
+ usage='<jid> <old group> <new group>',
+ desc='Move the given JID from the old group to the new group.',
+ shortdesc='Move an user to another group.',
+ completion=self.completion_groupmove)
+ self.register_command('groupremove', self.command_groupremove,
+ usage='<jid> <group>',
+ desc='Remove the given JID from the given group.',
+ shortdesc='Remove an user from a group.',
+ completion=self.completion_groupremove)
+ self.register_command('remove', self.command_remove,
+ usage='[jid]',
+ desc='Remove the specified JID from your roster. This '
+ 'will unsubscribe you from its presence, cancel '
+ 'its subscription to yours, and remove the item '
+ 'from your roster.',
+ shortdesc='Remove an user from your roster.',
+ completion=self.completion_remove)
+ self.register_command('export', self.command_export,
+ usage='[/path/to/file]',
+ desc='Export your contacts into /path/to/file if '
+ 'specified, or $HOME/poezio_contacts if not.',
+ shortdesc='Export your roster to a file.',
+ completion=partial(self.completion_file, 1))
+ self.register_command('import', self.command_import,
+ usage='[/path/to/file]',
+ desc='Import your contacts from /path/to/file if '
+ 'specified, or $HOME/poezio_contacts if not.',
+ shortdesc='Import your roster from a file.',
+ completion=partial(self.completion_file, 1))
+ self.register_command('password', self.command_password,
+ usage='<password>',
+ shortdesc='Change your password')
+
+ self.register_command('reconnect', self.command_reconnect,
+ desc='Disconnect from the remote server if you are '
+ 'currently connected and then connect to it again.',
+ shortdesc='Disconnect and reconnect to the server.')
+ self.register_command('disconnect', self.command_disconnect,
+ desc='Disconnect from the remote server.',
+ shortdesc='Disconnect from the server.')
+ self.register_command('clear', self.command_clear,
+ shortdesc='Clear the info buffer.')
+ self.register_command('last_activity', self.command_last_activity,
+ usage='<jid>',
+ desc='Informs you of the last activity of a JID.',
+ shortdesc='Get the activity of someone.',
+ completion=self.core.completion_last_activity)
+
+ self.resize()
+ self.update_commands()
+ self.update_keys()
+
+ def check_blocking(self, features):
+ if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon:
+ self.register_command('block', self.command_block,
+ usage='[jid]',
+ shortdesc='Prevent a JID from talking to you.',
+ completion=self.completion_block)
+ self.register_command('unblock', self.command_unblock,
+ usage='[jid]',
+ shortdesc='Allow a JID to talk to you.',
+ completion=self.completion_unblock)
+ self.register_command('list_blocks', self.command_list_blocks,
+ shortdesc='Show the blocked contacts.')
+ self.core.xmpp.del_event_handler('session_start', self.check_blocking)
+ self.core.xmpp.add_event_handler('blocked_message', self.on_blocked_message)
+
+ def check_saslexternal(self, features):
+ if 'urn:xmpp:saslcert:1' in features and not self.core.xmpp.anon:
+ self.register_command('certs', self.command_certs,
+ desc='List the fingerprints of certificates'
+ ' which can connect to your account.',
+ shortdesc='List allowed client certs.')
+ self.register_command('cert_add', self.command_cert_add,
+ desc='Add a client certificate to the authorized ones. '
+ 'It must have an unique name and be contained in '
+ 'a PEM file. [management] is a boolean indicating'
+ ' if a client connected using this certificate can'
+ ' manage the certificates itself.',
+ shortdesc='Add a client certificate.',
+ usage='<name> <certificate path> [management]',
+ completion=self.completion_cert_add)
+ self.register_command('cert_disable', self.command_cert_disable,
+ desc='Remove a certificate from the list '
+ 'of allowed ones. Clients currently '
+ 'using this certificate will not be '
+ 'forcefully disconnected.',
+ shortdesc='Disable a certificate',
+ usage='<name>')
+ self.register_command('cert_revoke', self.command_cert_revoke,
+ desc='Remove a certificate from the list '
+ 'of allowed ones. Clients currently '
+ 'using this certificate will be '
+ 'forcefully disconnected.',
+ shortdesc='Revoke a certificate',
+ usage='<name>')
+ self.register_command('cert_fetch', self.command_cert_fetch,
+ desc='Retrieve a certificate with its '
+ 'name. It will be stored in <path>.',
+ shortdesc='Fetch a certificate',
+ usage='<name> <path>',
+ completion=self.completion_cert_fetch)
+
+ @property
+ def selected_row(self):
+ return self.roster_win.get_selected_row()
+
+ @command_args_parser.ignored
+ def command_certs(self):
+ """
+ /certs
+ """
+ def cb(iq):
+ if iq['type'] == 'error':
+ self.core.information('Unable to retrieve the certificate list.',
+ 'Error')
+ return
+ certs = []
+ for item in iq['sasl_certs']['items']:
+ users = '\n'.join(item['users'])
+ certs.append((item['name'], users))
+
+ if not certs:
+ return self.core.information('No certificates found', 'Info')
+ msg = 'Certificates:\n'
+ msg += '\n'.join(((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1])) for item in certs))
+ self.core.information(msg, 'Info')
+
+ self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3)
+
+ @command_args_parser.quoted(2, 1)
+ def command_cert_add(self, args):
+ """
+ /cert_add <name> <certfile> [cert-management]
+ """
+ if not args or len(args) < 2:
+ return self.core.command_help('cert_add')
+ def cb(iq):
+ if iq['type'] == 'error':
+ self.core.information('Unable to add the certificate.', 'Error')
+ else:
+ self.core.information('Certificate added.', 'Info')
+
+ name = args[0]
+
+ try:
+ with open(args[1]) as fd:
+ crt = fd.read()
+ crt = crt.replace(ssl.PEM_FOOTER, '').replace(ssl.PEM_HEADER, '').replace(' ', '').replace('\n', '')
+ except Exception as e:
+ self.core.information('Unable to read the certificate: %s' % e, 'Error')
+ return
+
+ if len(args) > 2:
+ management = args[2]
+ if management:
+ management = management.lower()
+ if management not in ('false', '0'):
+ management = True
+ else:
+ management = False
+ else:
+ management = False
+ else:
+ management = True
+
+ self.core.xmpp.plugin['xep_0257'].add_cert(name, crt, callback=cb,
+ allow_management=management)
+
+ def completion_cert_add(self, the_input):
+ """
+ completion for /cert_add <name> <path> [management]
+ """
+ text = the_input.get_text()
+ args = common.shell_split(text)
+ n = the_input.get_argument_position()
+ log.debug('%s %s %s', the_input.text, n, the_input.pos)
+ if n == 1:
+ return
+ elif n == 2:
+ return self.completion_file(2, the_input)
+ elif n == 3:
+ return the_input.new_completion(['true', 'false'], n)
+
+ @command_args_parser.quoted(1)
+ def command_cert_disable(self, args):
+ """
+ /cert_disable <name>
+ """
+ if not args:
+ return self.core.command_help('cert_disable')
+ def cb(iq):
+ if iq['type'] == 'error':
+ self.core.information('Unable to disable the certificate.', 'Error')
+ else:
+ self.core.information('Certificate disabled.', 'Info')
+
+ name = args[0]
+
+ self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb)
+
+ @command_args_parser.quoted(1)
+ def command_cert_revoke(self, args):
+ """
+ /cert_revoke <name>
+ """
+ if not args:
+ return self.core.command_help('cert_revoke')
+ def cb(iq):
+ if iq['type'] == 'error':
+ self.core.information('Unable to revoke the certificate.', 'Error')
+ else:
+ self.core.information('Certificate revoked.', 'Info')
+
+ name = args[0]
+
+ self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb)
+
+
+ @command_args_parser.quoted(2)
+ def command_cert_fetch(self, args):
+ """
+ /cert_fetch <name> <path>
+ """
+ if not args or len(args) < 2:
+ return self.core.command_help('cert_fetch')
+ def cb(iq):
+ if iq['type'] == 'error':
+ self.core.information('Unable to fetch the certificate.',
+ 'Error')
+ return
+
+ cert = None
+ for item in iq['sasl_certs']['items']:
+ if item['name'] == name:
+ cert = base64.b64decode(item['x509cert'])
+ break
+
+ if not cert:
+ return self.core.information('Certificate not found.', 'Info')
+
+ cert = ssl.DER_cert_to_PEM_cert(cert)
+ with open(path, 'w') as fd:
+ fd.write(cert)
+
+ self.core.information('File stored at %s' % path, 'Info')
+
+ name = args[0]
+ path = args[1]
+
+ self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb)
+
+ def completion_cert_fetch(self, the_input):
+ """
+ completion for /cert_fetch <name> <path>
+ """
+ text = the_input.get_text()
+ args = common.shell_split(text)
+ n = the_input.get_argument_position()
+ log.debug('%s %s %s', the_input.text, n, the_input.pos)
+ if n == 1:
+ return
+ elif n == 2:
+ return self.completion_file(2, the_input)
+
+ def on_blocked_message(self, message):
+ """
+ When we try to send a message to a blocked contact
+ """
+ tab = self.core.get_conversation_by_jid(message['from'], False)
+ 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' % {
+ 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'jid': message['from'],
+ }
+ tab.add_message(message)
+
+ @command_args_parser.quoted(0, 1)
+ def command_block(self, args):
+ """
+ /block [jid]
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ return self.core.information('Could not block the contact.', 'Error')
+ elif iq['type'] == 'result':
+ return self.core.information('Contact blocked.', 'Info')
+
+ item = self.roster_win.selected_row
+ if args:
+ jid = safeJID(args[0])
+ elif isinstance(item, Contact):
+ jid = item.bare_jid
+ elif isinstance(item, Resource):
+ jid = item.jid.bare
+ self.core.xmpp.plugin['xep_0191'].block(jid, callback=callback)
+
+ def completion_block(self, the_input):
+ """
+ Completion for /block
+ """
+ if the_input.get_argument_position() == 1:
+ jids = roster.jids()
+ return the_input.new_completion(jids, 1, '', quotify=False)
+
+ @command_args_parser.quoted(0, 1)
+ def command_unblock(self, args):
+ """
+ /unblock [jid]
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ return self.core.information('Could not unblock the contact.', 'Error')
+ elif iq['type'] == 'result':
+ return self.core.information('Contact unblocked.', 'Info')
+
+ item = self.roster_win.selected_row
+ if args:
+ jid = safeJID(args[0])
+ elif isinstance(item, Contact):
+ jid = item.bare_jid
+ elif isinstance(item, Resource):
+ jid = item.jid.bare
+ self.core.xmpp.plugin['xep_0191'].unblock(jid, callback=callback)
+
+ def completion_unblock(self, the_input):
+ """
+ Completion for /unblock
+ """
+ def on_result(iq):
+ if iq['type'] == 'error':
+ return
+ l = sorted(str(item) for item in iq['blocklist']['items'])
+ return the_input.new_completion(l, 1, quotify=False)
+
+ if the_input.get_argument_position():
+ self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result)
+ return True
+
+ @command_args_parser.ignored
+ def command_list_blocks(self):
+ """
+ /list_blocks
+ """
+ def callback(iq):
+ if iq['type'] == 'error':
+ return self.core.information('Could not retrieve the blocklist.', 'Error')
+ s = 'List of blocked JIDs:\n'
+ items = (str(item) for item in iq['blocklist']['items'])
+ jids = '\n'.join(items)
+ if jids:
+ s += jids
+ else:
+ s = 'No blocked JIDs.'
+ self.core.information(s, 'Info')
+
+ self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback)
+
+ @command_args_parser.ignored
+ def command_reconnect(self):
+ """
+ /reconnect
+ """
+ if self.core.xmpp.is_connected():
+ self.core.disconnect(reconnect=True)
+ else:
+ self.core.xmpp.connect()
+
+ @command_args_parser.ignored
+ def command_disconnect(self):
+ """
+ /disconnect
+ """
+ self.core.disconnect()
+
+ @command_args_parser.quoted(0, 1)
+ def command_last_activity(self, args):
+ """
+ /activity [jid]
+ """
+ item = self.roster_win.selected_row
+ if args:
+ jid = args[0]
+ elif isinstance(item, Contact):
+ jid = item.bare_jid
+ elif isinstance(item, Resource):
+ jid = item.jid
+ else:
+ self.core.information('No JID selected.', 'Error')
+ return
+ self.core.command_last_activity(jid)
+
+ def resize(self):
+ self.need_resize = False
+ if self.size.tab_degrade_x:
+ display_info = False
+ roster_width = self.width
+ else:
+ display_info = True
+ roster_width = self.width // 2
+ if self.size.tab_degrade_y:
+ display_contact_win = False
+ contact_win_h = 0
+ else:
+ display_contact_win = True
+ contact_win_h = 4
+ if self.size.tab_degrade_y:
+ tab_win_height = 0
+ else:
+ tab_win_height = Tab.tab_win_height()
+
+ info_width = self.width - roster_width - 1
+ if display_info:
+ self.v_separator.resize(self.height - 1 - tab_win_height,
+ 1, 0, roster_width)
+ self.information_win.resize(self.height - 1 - tab_win_height
+ - contact_win_h,
+ info_width, 0, roster_width + 1,
+ self.core.information_buffer)
+ if display_contact_win:
+ self.contact_info_win.resize(contact_win_h,
+ info_width,
+ self.height - tab_win_height
+ - contact_win_h - 1,
+ roster_width + 1)
+ self.roster_win.resize(self.height - 1 - Tab.tab_win_height(),
+ roster_width, 0, 0)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.default_help_message.resize(1, self.width, self.height-1, 0)
+
+ def completion(self):
+ # Check if we are entering a command (with the '/' key)
+ if isinstance(self.input, windows.Input) and\
+ not self.input.help_message:
+ self.complete_commands(self.input)
+
+ def completion_file(self, complete_number, the_input):
+ """
+ Generic quoted completion for files/paths
+ (use functools.partial to use directly as a completion
+ for a command)
+ """
+ text = the_input.get_text()
+ args = common.shell_split(text)
+ n = the_input.get_argument_position()
+ if n == complete_number:
+ if args[n-1] == '' or len(args) < n+1:
+ home = os.getenv('HOME') or '/'
+ return the_input.new_completion([home, '/tmp'], n, quotify=True)
+ path_ = args[n]
+ if path.isdir(path_):
+ dir_ = path_
+ base = ''
+ else:
+ dir_ = path.dirname(path_)
+ base = path.basename(path_)
+ try:
+ names = os.listdir(dir_)
+ except OSError:
+ names = []
+ names_filtered = [name for name in names if name.startswith(base)]
+ if names_filtered:
+ names = names_filtered
+ if not names:
+ names = [path_]
+ end_list = []
+ for name in names:
+ value = os.path.join(dir_, name)
+ if not name.startswith('.'):
+ end_list.append(value)
+
+ return the_input.new_completion(end_list, n, quotify=True)
+
+ @command_args_parser.ignored
+ def command_clear(self):
+ """
+ /clear
+ """
+ self.core.information_buffer.messages = []
+ self.information_win.rebuild_everything(self.core.information_buffer)
+ self.core.information_win.rebuild_everything(self.core.information_buffer)
+ self.refresh()
+
+ @command_args_parser.quoted(1)
+ def command_password(self, args):
+ """
+ /password <password>
+ """
+ def callback(iq):
+ if iq['type'] == 'result':
+ self.core.information('Password updated', 'Account')
+ if config.get('password'):
+ config.silent_set('password', args[0])
+ else:
+ self.core.information('Unable to change the password', 'Account')
+ self.core.xmpp.plugin['xep_0077'].change_password(args[0], callback=callback)
+
+ @command_args_parser.quoted(0, 1)
+ def command_deny(self, args):
+ """
+ /deny [jid]
+ Denies a JID from our roster
+ """
+ if not args:
+ item = self.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ self.core.information('No subscription to deny')
+ return
+ else:
+ jid = safeJID(args[0]).bare
+ if not jid in [jid for jid in roster.jids()]:
+ self.core.information('No subscription to deny')
+ return
+
+ contact = roster[jid]
+ if contact:
+ contact.unauthorize()
+ self.core.information('Subscription to %s was revoked' % jid,
+ 'Roster')
+
+ @command_args_parser.quoted(1)
+ def command_add(self, args):
+ """
+ Add the specified JID to the roster, and set automatically
+ accept the reverse subscription
+ """
+ if args is None:
+ self.core.information('No JID specified', 'Error')
+ return
+ jid = safeJID(safeJID(args[0]).bare)
+ if not str(jid):
+ self.core.information('The provided JID (%s) is not valid' % (args[0],), 'Error')
+ return
+ if jid in roster and roster[jid].subscription in ('to', 'both'):
+ return self.core.information('Already subscribed.', 'Roster')
+ roster.add(jid)
+ roster.modified()
+ self.core.information('%s was added to the roster' % jid, 'Roster')
+
+ @command_args_parser.quoted(1, 1)
+ def command_name(self, args):
+ """
+ Set a name for the specified JID in your roster
+ """
+ def callback(iq):
+ if not iq:
+ self.core.information('The name could not be set.', 'Error')
+ log.debug('Error in /name:\n%s', iq)
+ if args is None:
+ return self.core.command_help('name')
+ jid = safeJID(args[0]).bare
+ name = args[1] if len(args) == 2 else ''
+
+ contact = roster[jid]
+ if contact is None:
+ self.core.information('No such JID in roster', 'Error')
+ return
+
+ groups = set(contact.groups)
+ if 'none' in groups:
+ groups.remove('none')
+ subscription = contact.subscription
+ self.core.xmpp.update_roster(jid, name=name, groups=groups,
+ subscription=subscription, callback=callback)
+
+ @command_args_parser.quoted(2)
+ def command_groupadd(self, args):
+ """
+ Add the specified JID to the specified group
+ """
+ if args is None:
+ return self.core.command_help('groupadd')
+ jid = safeJID(args[0]).bare
+ group = args[1]
+
+ contact = roster[jid]
+ if contact is None:
+ self.core.information('No such JID in roster', 'Error')
+ return
+
+ new_groups = set(contact.groups)
+ if group in new_groups:
+ self.core.information('JID already in group', 'Error')
+ return
+
+ roster.modified()
+ new_groups.add(group)
+ try:
+ new_groups.remove('none')
+ except KeyError:
+ pass
+
+ name = contact.name
+ subscription = contact.subscription
+
+ def callback(iq):
+ if iq:
+ roster.update_contact_groups(jid)
+ else:
+ self.core.information('The group could not be set.', 'Error')
+ log.debug('Error in groupadd:\n%s', iq)
+
+ self.core.xmpp.update_roster(jid, name=name, groups=new_groups,
+ subscription=subscription, callback=callback)
+
+ @command_args_parser.quoted(3)
+ def command_groupmove(self, args):
+ """
+ Remove the specified JID from the first specified group and add it to the second one
+ """
+ if args is None:
+ return self.core.command_help('groupmove')
+ jid = safeJID(args[0]).bare
+ group_from = args[1]
+ group_to = args[2]
+
+ contact = roster[jid]
+ if not contact:
+ self.core.information('No such JID in roster', 'Error')
+ return
+
+ new_groups = set(contact.groups)
+ if 'none' in new_groups:
+ new_groups.remove('none')
+
+ if group_to == 'none' or group_from == 'none':
+ self.core.information('"none" is not a group.', 'Error')
+ return
+
+ if group_from not in new_groups:
+ self.core.information('JID not in first group', 'Error')
+ return
+
+ if group_to in new_groups:
+ self.core.information('JID already in second group', 'Error')
+ return
+
+ if group_to == group_from:
+ self.core.information('The groups are the same.', 'Error')
+ return
+
+ roster.modified()
+ new_groups.add(group_to)
+ if 'none' in new_groups:
+ new_groups.remove('none')
+
+ new_groups.remove(group_from)
+ name = contact.name
+ subscription = contact.subscription
+
+ def callback(iq):
+ if iq:
+ roster.update_contact_groups(contact)
+ else:
+ self.core.information('The group could not be set')
+ log.debug('Error in groupmove:\n%s', iq)
+
+ self.core.xmpp.update_roster(jid, name=name, groups=new_groups,
+ subscription=subscription, callback=callback)
+
+ @command_args_parser.quoted(2)
+ def command_groupremove(self, args):
+ """
+ Remove the specified JID from the specified group
+ """
+ if args is None:
+ return self.core.command_help('groupremove')
+
+ jid = safeJID(args[0]).bare
+ group = args[1]
+
+ contact = roster[jid]
+ if contact is None:
+ self.core.information('No such JID in roster', 'Error')
+ return
+
+ new_groups = set(contact.groups)
+ try:
+ new_groups.remove('none')
+ except KeyError:
+ pass
+ if group not in new_groups:
+ self.core.information('JID not in group', 'Error')
+ return
+
+ roster.modified()
+
+ new_groups.remove(group)
+ name = contact.name
+ subscription = contact.subscription
+
+ def callback(iq):
+ if iq:
+ roster.update_contact_groups(jid)
+ else:
+ self.core.information('The group could not be set')
+ log.debug('Error in groupremove:\n%s', iq)
+
+ self.core.xmpp.update_roster(jid, name=name, groups=new_groups,
+ subscription=subscription, callback=callback)
+
+ @command_args_parser.quoted(0, 1)
+ def command_remove(self, args):
+ """
+ Remove the specified JID from the roster. i.e.: unsubscribe
+ from its presence, and cancel its subscription to our.
+ """
+ if args:
+ jid = safeJID(args[0]).bare
+ else:
+ item = self.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ self.core.information('No roster item to remove')
+ return
+ roster.remove(jid)
+ del roster[jid]
+
+ @command_args_parser.quoted(0, 1)
+ def command_import(self, args):
+ """
+ Import the contacts
+ """
+ if args:
+ if args[0].startswith('/'):
+ filepath = args[0]
+ else:
+ filepath = path.join(getenv('HOME'), args[0])
+ else:
+ filepath = path.join(getenv('HOME'), 'poezio_contacts')
+ if not path.isfile(filepath):
+ self.core.information('The file %s does not exist' % filepath, 'Error')
+ return
+ try:
+ handle = open(filepath, 'r', encoding='utf-8')
+ lines = handle.readlines()
+ handle.close()
+ except IOError:
+ self.core.information('Could not open %s' % filepath, 'Error')
+ log.error('Unable to correct a message', exc_info=True)
+ return
+ for jid in lines:
+ self.command_add(jid.lstrip('\n'))
+ self.core.information('Contacts imported from %s' % filepath, 'Info')
+
+ @command_args_parser.quoted(0, 1)
+ def command_export(self, args):
+ """
+ Export the contacts
+ """
+ if args:
+ if args[0].startswith('/'):
+ filepath = args[0]
+ else:
+ filepath = path.join(getenv('HOME'), args[0])
+ else:
+ filepath = path.join(getenv('HOME'), 'poezio_contacts')
+ if path.isfile(filepath):
+ self.core.information('The file already exists', 'Error')
+ return
+ elif not path.isdir(path.dirname(filepath)):
+ self.core.information('Parent directory not found', 'Error')
+ return
+ if roster.export(filepath):
+ self.core.information('Contacts exported to %s' % filepath, 'Info')
+ else:
+ self.core.information('Failed to export contacts to %s' % filepath, 'Info')
+
+ def completion_remove(self, the_input):
+ """
+ Completion for /remove
+ """
+ jids = [jid for jid in roster.jids()]
+ return the_input.auto_completion(jids, '', quotify=False)
+
+ def completion_name(self, the_input):
+ """Completion for /name"""
+ n = the_input.get_argument_position()
+ if n == 1:
+ jids = [jid for jid in roster.jids()]
+ return the_input.new_completion(jids, n, quotify=True)
+ return False
+
+ def completion_groupadd(self, the_input):
+ n = the_input.get_argument_position()
+ if n == 1:
+ jids = sorted(jid for jid in roster.jids())
+ return the_input.new_completion(jids, n, '', quotify=True)
+ elif n == 2:
+ groups = sorted(group for group in roster.groups if group != 'none')
+ return the_input.new_completion(groups, n, '', quotify=True)
+ return False
+
+ def completion_groupmove(self, the_input):
+ args = common.shell_split(the_input.text)
+ n = the_input.get_argument_position()
+ if n == 1:
+ jids = sorted(jid for jid in roster.jids())
+ return the_input.new_completion(jids, n, '', quotify=True)
+ elif n == 2:
+ contact = roster[args[1]]
+ if not contact:
+ return False
+ groups = list(contact.groups)
+ if 'none' in groups:
+ groups.remove('none')
+ return the_input.new_completion(groups, n, '', quotify=True)
+ elif n == 3:
+ groups = sorted(group for group in roster.groups)
+ return the_input.new_completion(groups, n, '', quotify=True)
+ return False
+
+ def completion_groupremove(self, the_input):
+ args = common.shell_split(the_input.text)
+ n = the_input.get_argument_position()
+ if n == 1:
+ jids = sorted(jid for jid in roster.jids())
+ return the_input.new_completion(jids, n, '', quotify=True)
+ elif n == 2:
+ contact = roster[args[1]]
+ if contact is None:
+ return False
+ groups = sorted(contact.groups)
+ try:
+ groups.remove('none')
+ except ValueError:
+ pass
+ return the_input.new_completion(groups, n, '', quotify=True)
+ return False
+
+ def completion_deny(self, the_input):
+ """
+ Complete the first argument from the list of the
+ contact with ask=='subscribe'
+ """
+ jids = sorted(str(contact.bare_jid) for contact in roster.contacts.values()
+ if contact.pending_in)
+ return the_input.new_completion(jids, 1, '', quotify=False)
+
+ @command_args_parser.quoted(0, 1)
+ def command_accept(self, args):
+ """
+ Accept a JID from in roster. Authorize it AND subscribe to it
+ """
+ if not args:
+ item = self.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ self.core.information('No subscription to accept')
+ return
+ else:
+ jid = safeJID(args[0]).bare
+ nodepart = safeJID(jid).user
+ jid = safeJID(jid)
+ # crappy transports putting resources inside the node part
+ if '\\2f' in nodepart:
+ jid.user = nodepart.split('\\2f')[0]
+ contact = roster[jid]
+ if contact is None:
+ return
+ contact.pending_in = False
+ roster.modified()
+ self.core.xmpp.send_presence(pto=jid, ptype='subscribed')
+ self.core.xmpp.client_roster.send_last_presence()
+ if contact.subscription in ('from', 'none') and not contact.pending_out:
+ self.core.xmpp.send_presence(pto=jid, ptype='subscribe', pnick=self.core.own_nick)
+
+ self.core.information('%s is now authorized' % jid, 'Roster')
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+
+ display_info = not self.size.tab_degrade_x
+ display_contact_win = not self.size.tab_degrade_y
+
+ self.roster_win.refresh(roster)
+ if display_info:
+ self.v_separator.refresh()
+ self.information_win.refresh()
+ if display_contact_win:
+ self.contact_info_win.refresh(
+ self.roster_win.get_selected_row())
+ self.refresh_tab_win()
+ self.input.refresh()
+
+ def on_input(self, key, raw):
+ if key == '^M':
+ selected_row = self.roster_win.get_selected_row()
+ res = self.input.do_command(key, raw=raw)
+ if res and not isinstance(self.input, windows.Input):
+ return True
+ elif res:
+ return False
+ if key == '^M':
+ self.core.on_roster_enter_key(selected_row)
+ return selected_row
+ elif not raw and key in self.key_func:
+ return self.key_func[key]()
+
+ @refresh_wrapper.conditional
+ def toggle_offline_show(self):
+ """
+ Show or hide offline contacts
+ """
+ option = 'roster_show_offline'
+ value = config.get(option)
+ success = config.silent_set(option, str(not value))
+ roster.modified()
+ if not success:
+ self.core.information('Unable to write in the config file', 'Error')
+ return True
+
+ def on_slash(self):
+ """
+ '/' is pressed, we enter "input mode"
+ """
+ if isinstance(self.input, windows.YesNoInput):
+ return
+ curses.curs_set(1)
+ self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.input.do_command("/") # we add the slash
+
+ def reset_help_message(self, _=None):
+ self.input = self.default_help_message
+ if self.core.current_tab() is self:
+ curses.curs_set(0)
+ self.input.refresh()
+ self.core.doupdate()
+ return True
+
+ def execute_slash_command(self, txt):
+ if txt.startswith('/'):
+ self.input.key_enter()
+ self.execute_command(txt)
+ return self.reset_help_message()
+
+ def on_lose_focus(self):
+ self.state = 'normal'
+
+ def on_gain_focus(self):
+ self.state = 'current'
+ if isinstance(self.input, windows.HelpText):
+ curses.curs_set(0)
+ else:
+ curses.curs_set(1)
+
+ @refresh_wrapper.conditional
+ def move_cursor_down(self):
+ if isinstance(self.input, windows.Input) and not self.input.history_disabled:
+ return
+ return self.roster_win.move_cursor_down()
+
+ @refresh_wrapper.conditional
+ def move_cursor_up(self):
+ if isinstance(self.input, windows.Input) and not self.input.history_disabled:
+ return
+ return self.roster_win.move_cursor_up()
+
+ def move_cursor_to_prev_contact(self):
+ self.roster_win.move_cursor_up()
+ while not isinstance(self.roster_win.get_selected_row(), Contact):
+ if not self.roster_win.move_cursor_up():
+ break
+ self.roster_win.refresh(roster)
+
+ def move_cursor_to_next_contact(self):
+ self.roster_win.move_cursor_down()
+ while not isinstance(self.roster_win.get_selected_row(), Contact):
+ if not self.roster_win.move_cursor_down():
+ break
+ self.roster_win.refresh(roster)
+
+ def move_cursor_to_prev_group(self):
+ self.roster_win.move_cursor_up()
+ while not isinstance(self.roster_win.get_selected_row(), RosterGroup):
+ if not self.roster_win.move_cursor_up():
+ break
+ self.roster_win.refresh(roster)
+
+ def move_cursor_to_next_group(self):
+ self.roster_win.move_cursor_down()
+ while not isinstance(self.roster_win.get_selected_row(), RosterGroup):
+ if not self.roster_win.move_cursor_down():
+ break
+ self.roster_win.refresh(roster)
+
+ def on_scroll_down(self):
+ return self.roster_win.move_cursor_down(self.height // 2)
+
+ def on_scroll_up(self):
+ return self.roster_win.move_cursor_up(self.height // 2)
+
+ @refresh_wrapper.conditional
+ def on_space(self):
+ if isinstance(self.input, windows.Input):
+ return
+ selected_row = self.roster_win.get_selected_row()
+ if isinstance(selected_row, RosterGroup):
+ selected_row.toggle_folded()
+ roster.modified()
+ return True
+ elif isinstance(selected_row, Contact):
+ group = "none"
+ found_group = False
+ pos = self.roster_win.pos
+ while not found_group and pos >= 0:
+ row = self.roster_win.roster_cache[pos]
+ pos -= 1
+ if isinstance(row, RosterGroup):
+ found_group = True
+ group = row.name
+ selected_row.toggle_folded(group)
+ roster.modified()
+ return True
+ return False
+
+ def get_contact_version(self):
+ """
+ Show the versions of the resource(s) currently selected
+ """
+ selected_row = self.roster_win.get_selected_row()
+ if isinstance(selected_row, Contact):
+ for resource in selected_row.resources:
+ self.core.command_version(str(resource.jid))
+ elif isinstance(selected_row, Resource):
+ self.core.command_version(str(selected_row.jid))
+ else:
+ self.core.information('Nothing to get versions from', 'Info')
+
+ def show_contact_info(self):
+ """
+ Show the contact info (resource number, status, presence, etc)
+ when 'i' is pressed.
+ """
+ selected_row = self.roster_win.get_selected_row()
+ if isinstance(selected_row, Contact):
+ cont = selected_row
+ res = selected_row.get_highest_priority_resource()
+ acc = []
+ acc.append('Contact: %s (%s)' % (cont.bare_jid, res.presence if res else 'unavailable'))
+ if res:
+ acc.append('%s connected resource%s' % (len(cont), '' if len(cont) == 1 else 's'))
+ acc.append('Current status: %s' % res.status)
+ if cont.tune:
+ acc.append('Tune: %s' % common.format_tune_string(cont.tune))
+ if cont.mood:
+ acc.append('Mood: %s' % cont.mood)
+ if cont.activity:
+ acc.append('Activity: %s' % cont.activity)
+ if cont.gaming:
+ acc.append('Game: %s' % (common.format_gaming_string(cont.gaming)))
+ msg = '\n'.join(acc)
+ elif isinstance(selected_row, Resource):
+ res = selected_row
+ msg = 'Resource: %s (%s)\nCurrent status: %s\nPriority: %s' % (
+ res.jid,
+ res.presence,
+ res.status,
+ res.priority)
+ elif isinstance(selected_row, RosterGroup):
+ rg = selected_row
+ msg = 'Group: %s [%s/%s] contacts online' % (
+ rg.name,
+ rg.get_nb_connected_contacts(),
+ len(rg),)
+ else:
+ msg = None
+ if msg:
+ self.core.information(msg, 'Info')
+
+ def change_contact_name(self):
+ """
+ Auto-fill a /name command when 'n' is pressed
+ """
+ selected_row = self.roster_win.get_selected_row()
+ if isinstance(selected_row, Contact):
+ jid = selected_row.bare_jid
+ elif isinstance(selected_row, Resource):
+ jid = safeJID(selected_row.jid).bare
+ else:
+ return
+ self.on_slash()
+ self.input.text = '/name "%s" ' % jid
+ self.input.key_end()
+ self.input.refresh()
+
+ @refresh_wrapper.always
+ def start_search(self):
+ """
+ Start the search. The input should appear with a short instruction
+ in it.
+ """
+ if isinstance(self.input, windows.YesNoInput):
+ return
+ curses.curs_set(1)
+ self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.input.disable_history()
+ roster.modified()
+ self.refresh()
+ return True
+
+ @refresh_wrapper.always
+ def start_search_slow(self):
+ if isinstance(self.input, windows.YesNoInput):
+ return
+ curses.curs_set(1)
+ self.input = windows.CommandInput("[Search]", self.on_search_terminate, self.on_search_terminate, self.set_roster_filter_slow)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.input.disable_history()
+ return True
+
+ def set_roster_filter_slow(self, txt):
+ roster.contact_filter = (jid_and_name_match_slow, txt)
+ roster.modified()
+ self.refresh()
+ return False
+
+ def set_roster_filter(self, txt):
+ roster.contact_filter = (jid_and_name_match, txt)
+ roster.modified()
+ self.refresh()
+ return False
+
+ @refresh_wrapper.always
+ def on_search_terminate(self, txt):
+ curses.curs_set(0)
+ roster.contact_filter = None
+ self.reset_help_message()
+ roster.modified()
+ return True
+
+ def on_close(self):
+ return
+
+def diffmatch(search, string):
+ """
+ Use difflib and a loop to check if search_pattern can
+ be 'almost' found INSIDE a string.
+ 'almost' being defined by difflib
+ """
+ if len(search) > len(string):
+ return False
+ l = len(search)
+ ratio = 0.7
+ for i in range(len(string) - l + 1):
+ if difflib.SequenceMatcher(None, search, string[i:i+l]).ratio() >= ratio:
+ return True
+ return False
+
+def jid_and_name_match(contact, txt):
+ """
+ Match jid with text precisely
+ """
+ if not txt:
+ return True
+ txt = txt.lower()
+ if txt in safeJID(contact.bare_jid).bare.lower():
+ return True
+ if txt in contact.name.lower():
+ return True
+ return False
+
+def jid_and_name_match_slow(contact, txt):
+ """
+ A function used to know if a contact in the roster should
+ be shown in the roster
+ """
+ if not txt:
+ return True # Everything matches when search is empty
+ user = safeJID(contact.bare_jid).bare
+ if diffmatch(txt, user):
+ return True
+ if contact.name and diffmatch(txt, contact.name):
+ return True
+ return False
diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py
new file mode 100644
index 00000000..b063ad35
--- /dev/null
+++ b/poezio/tabs/xmltab.py
@@ -0,0 +1,360 @@
+"""
+The XMLTab is here for debugging purposes, it shows the incoming and
+outgoing stanzas. It has a few useful functions that can filter stanzas
+in order to only show the relevant ones, and it can also be frozen or
+unfrozen on demand so that the relevant information is not drowned by
+the traffic.
+"""
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+import os
+from slixmpp.xmlstream import matcher
+from slixmpp.xmlstream.tostring import tostring
+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, refresh_wrapper
+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: tostring(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.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,
+ shortdesc='Clear the current buffer.')
+ self.register_command('reset', self.command_reset,
+ shortdesc='Reset the stanza filter.')
+ self.register_command('filter_id', self.command_filter_id,
+ usage='<id>',
+ desc='Show only the stanzas with the id <id>.',
+ shortdesc='Filter by id.')
+ self.register_command('filter_xpath', self.command_filter_xpath,
+ usage='<xpath>',
+ desc='Show only the stanzas matching the xpath <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='<jid>',
+ desc='Show only the stanzas matching the jid <jid> in from= or to=.',
+ shortdesc='Filter by JID.')
+ self.register_command('filter_from', self.command_filter_from,
+ usage='<jid>',
+ desc='Show only the stanzas matching the jid <jid> in from=.',
+ shortdesc='Filter by JID from.')
+ self.register_command('filter_to', self.command_filter_to,
+ usage='<jid>',
+ desc='Show only the stanzas matching the jid <jid> in to=.',
+ shortdesc='Filter by JID to.')
+ self.register_command('filter_xmlmask', self.command_filter_xmlmask,
+ usage='<xml mask>',
+ desc='Show only the stanzas matching the given xml mask.',
+ shortdesc='Filter by xml mask.')
+ self.register_command('dump', self.command_dump,
+ usage='<filename>',
+ desc='Writes the content of the XML buffer into a file.',
+ shortdesc='Write in a file.')
+ self.input = self.default_help_message
+ self.key_func['^T'] = self.close
+ self.key_func['^I'] = self.completion
+ self.key_func["KEY_DOWN"] = self.on_scroll_down
+ self.key_func["KEY_UP"] = self.on_scroll_up
+ self.key_func["^K"] = self.on_freeze
+ self.key_func["/"] = self.on_slash
+ self.resize()
+ # Used to display the infobar
+ 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:
+ try:
+ if msg.txt.strip() and self.match_stanza(ElementBase(ET.fromstring(clean_text(msg.txt)))):
+ new_messages.append(msg)
+ except ET.ParseError:
+ log.debug('Malformed XML : %s', msg.txt, exc_info=True)
+ 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.
+ """
+ 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 <xml mask>"""
+ try:
+ self.update_filters(matcher.MatchXMLMask(mask))
+ self.refresh()
+ except Exception as e:
+ self.core.information('Invalid XML Mask: %s' % e, 'Error')
+ self.command_reset('')
+
+ @command_args_parser.raw
+ def command_filter_to(self, jid):
+ """/filter_jid_to <jid>"""
+ 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>"""
+ 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>"""
+ 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 <id>"""
+ if args is None:
+ return self.core.command_help('filter_id')
+
+ self.update_filters(matcher.MatcherId(args[0]))
+ self.refresh()
+
+ @command_args_parser.raw
+ def command_filter_xpath(self, xpath):
+ """/filter_xpath <xpath>"""
+ try:
+ self.update_filters(matcher.MatchXPath(xpath.replace('%n', self.core.xmpp.default_ns)))
+ self.refresh()
+ except:
+ self.core.information('Invalid XML Path', 'Error')
+ self.command_reset('')
+
+ @command_args_parser.ignored
+ def command_reset(self):
+ """/reset"""
+ 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()
+
+ @command_args_parser.quoted(1)
+ def command_dump(self, args):
+ """/dump <filename>"""
+ if args is None:
+ return self.core.command_help('dump')
+ 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:
+ fd.write(text)
+ except Exception as e:
+ self.core.information('Could not write the XML dump: %s' % e, 'Error')
+
+ def on_slash(self):
+ """
+ '/' is pressed, activate the input
+ """
+ curses.curs_set(1)
+ self.input = windows.CommandInput("", self.reset_help_message, self.execute_slash_command)
+ self.input.resize(1, self.width, self.height-1, 0)
+ self.input.do_command("/") # we add the slash
+
+ @refresh_wrapper.always
+ def reset_help_message(self, _=None):
+ if self.closed:
+ return True
+ if self.core.current_tab() is self:
+ curses.curs_set(0)
+ self.input = self.default_help_message
+ return True
+
+ def on_scroll_up(self):
+ return self.text_win.scroll_up(self.text_win.height-1)
+
+ def on_scroll_down(self):
+ return self.text_win.scroll_down(self.text_win.height-1)
+
+ @command_args_parser.ignored
+ def command_clear(self):
+ """
+ /clear
+ """
+ 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()
+
+ def execute_slash_command(self, txt):
+ if txt.startswith('/'):
+ self.input.key_enter()
+ self.execute_command(txt)
+ return self.reset_help_message()
+
+ def completion(self):
+ if isinstance(self.input, windows.Input):
+ self.complete_commands(self.input)
+
+ def on_input(self, key, raw):
+ res = self.input.do_command(key, raw=raw)
+ if res:
+ return True
+ if not raw and key in self.key_func:
+ return self.key_func[key]()
+
+ def close(self, arg=None):
+ self.core.close_tab()
+
+ def resize(self):
+ self.need_resize = False
+ if self.size.tab_degrade_y:
+ info_win_size = 0
+ tab_win_height = 0
+ else:
+ info_win_size = self.core.information_win_size
+ tab_win_height = Tab.tab_win_height()
+
+ self.text_win.resize(self.height - info_win_size - tab_win_height - 2,
+ self.width, 0, 0)
+ self.text_win.rebuild_everything(self.core.xml_buffer)
+ self.info_header.resize(1, self.width,
+ self.height - 2 - info_win_size
+ - tab_win_height,
+ 0)
+ self.input.resize(1, self.width, self.height-1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+
+ if self.size.tab_degrade_y:
+ display_info_win = False
+ else:
+ display_info_win = True
+
+ self.text_win.refresh()
+ self.info_header.refresh(self.filter_type, self.filter, self.text_win)
+ self.refresh_tab_win()
+ if display_info_win:
+ self.info_win.refresh()
+ self.input.refresh()
+
+ def on_lose_focus(self):
+ self.state = 'normal'
+
+ def on_gain_focus(self):
+ self.state = 'current'
+ curses.curs_set(0)
+
+ def on_close(self):
+ self.command_clear('')
+ self.core.xml_tab = False
+
+ def on_info_win_size_changed(self):
+ if self.core.information_win_size >= self.height-3:
+ return
+ self.text_win.resize(self.height-2-self.core.information_win_size - Tab.tab_win_height(), self.width, 0, 0)
+ self.info_header.resize(1, self.width, self.height-2-self.core.information_win_size - Tab.tab_win_height(), 0)
+
+
diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py
new file mode 100644
index 00000000..dd5bc58a
--- /dev/null
+++ b/poezio/text_buffer.py
@@ -0,0 +1,242 @@
+"""
+Define the TextBuffer class
+
+A text buffer contains a list of intermediate representations of messages
+(not xml stanzas, but neither the Lines used in windows.py.
+
+Each text buffer can be linked to multiple windows, that will be rendered
+independantly by their TextWins.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import collections
+
+from datetime import datetime
+from config import config
+from theming import get_theme, dump_tuple
+
+message_fields = ('txt nick_color time str_time nickname user identifier'
+ ' highlight me old_message revisions jid ack')
+Message = collections.namedtuple('Message', message_fields)
+
+class CorrectionError(Exception):
+ pass
+
+class AckError(Exception):
+ pass
+
+def other_elems(self):
+ "Helper for the repr_message function"
+ acc = ['Message(']
+ fields = message_fields.split()
+ fields.remove('old_message')
+ for field in fields:
+ acc.append('%s=%s' % (field, repr(getattr(self, field))))
+ return ', '.join(acc) + ', old_message='
+
+def repr_message(self):
+ """
+ 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 = other_elems(self)
+ acc = [init]
+ next_message = self.old_message
+ rev = 1
+ while next_message:
+ acc.append(other_elems(next_message))
+ next_message = next_message.old_message
+ rev += 1
+ acc.append('None')
+ while rev:
+ acc.append(')')
+ rev -= 1
+ return ''.join(acc)
+
+Message.__repr__ = repr_message
+Message.__str__ = repr_message
+
+class TextBuffer(object):
+ """
+ This class just keep trace of messages, in a list with various
+ informations and attributes.
+ """
+ def __init__(self, messages_nb_limit=None):
+
+ if messages_nb_limit is None:
+ messages_nb_limit = config.get('max_messages_in_memory')
+ self.messages_nb_limit = messages_nb_limit
+ # Message objects
+ self.messages = []
+ # 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 = []
+
+ def add_window(self, win):
+ self.windows.append(win)
+
+ @property
+ def last_message(self):
+ return self.messages[-1] if self.messages else None
+
+
+ @staticmethod
+ def make_message(txt, time, nickname, nick_color, history, user,
+ identifier, str_time=None, highlight=False,
+ old_message=None, revisions=0, jid=None, ack=0):
+ """
+ Create a new Message object with parameters, check for /me messages,
+ and delayed messages
+ """
+ time = time or datetime.now()
+ if txt.startswith('/me '):
+ me = True
+ txt = '\x19%s}%s' % (dump_tuple(get_theme().COLOR_ME_MESSAGE),
+ txt[4:])
+ else:
+ me = False
+ 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")
+ else:
+ if str_time is None:
+ str_time = time.strftime("%H:%M:%S")
+ else:
+ str_time = ''
+
+ msg = Message(
+ txt='%s\x19o'%(txt.replace('\t', ' '),),
+ nick_color=nick_color,
+ time=time,
+ str_time=str_time,
+ nickname=nickname,
+ user=user,
+ identifier=identifier,
+ highlight=highlight,
+ me=me,
+ old_message=old_message,
+ revisions=revisions,
+ jid=jid,
+ ack=ack)
+ log.debug('Set message %s with %s.', identifier, msg)
+ return msg
+
+ def add_message(self, txt, time=None, nickname=None,
+ nick_color=None, history=None, user=None, highlight=False,
+ identifier=None, str_time=None, jid=None, ack=0):
+ """
+ Create a message and add it to the text buffer
+ """
+ msg = self.make_message(txt, time, nickname, nick_color, history,
+ user, identifier, str_time=str_time,
+ highlight=highlight, jid=jid, ack=ack)
+ self.messages.append(msg)
+
+ while len(self.messages) > self.messages_nb_limit:
+ self.messages.pop(0)
+
+ ret_val = None
+ show_timestamps = config.get('show_timestamps')
+ nick_size = config.get('max_nick_length')
+ for window in self.windows: # make the associated windows
+ # build the lines from the new message
+ nb = window.build_new_message(msg, history=history,
+ highlight=highlight,
+ timestamp=show_timestamps,
+ nick_size=nick_size)
+ if ret_val is None:
+ ret_val = nb
+ if window.pos != 0:
+ window.scroll_up(nb)
+
+ return ret_val or 1
+
+ def _find_message(self, old_id):
+ """
+ Find a message in the text buffer from its message id
+ """
+ for i in range(len(self.messages) -1, -1, -1):
+ msg = self.messages[i]
+ if msg.identifier == old_id:
+ return i
+ return -1
+
+ def ack_message(self, old_id, jid):
+ """Mark a message as acked"""
+ return self.edit_ack(1, old_id, jid)
+
+ def nack_message(self, error, old_id, jid):
+ """Mark a message as errored"""
+ return self.edit_ack(-1, old_id, jid, append=error)
+
+ def edit_ack(self, value, old_id, jid, append=''):
+ """
+ Edit the ack status of a message, and optionally
+ append some text.
+ """
+ i = self._find_message(old_id)
+ if i == -1:
+ return
+ msg = self.messages[i]
+ if msg.jid != jid:
+ raise AckError('Wrong JID for message id %s (was %s, expected %s)' %
+ (old_id, msg.jid, jid))
+
+ new_msg = list(msg)
+ new_msg[12] = value
+ if append:
+ new_msg[0] = new_msg[0] + append
+ new_msg = Message(*new_msg)
+ self.messages[i] = new_msg
+ return new_msg
+
+ def modify_message(self, txt, old_id, new_id, highlight=False,
+ time=None, user=None, jid=None):
+ """
+ Correct a message in a text buffer.
+ """
+
+ i = self._find_message(old_id)
+
+ if i == -1:
+ log.debug('Message %s not found in text_buffer, abort replacement.',
+ old_id)
+ raise CorrectionError("nothing to replace")
+
+ msg = self.messages[i]
+
+ if msg.user and msg.user is not user:
+ raise CorrectionError("Different users")
+ elif len(msg.str_time) > 8: # ugly
+ raise CorrectionError("Delayed message")
+ elif not msg.user and (msg.jid is None or jid is None):
+ raise CorrectionError('Could not check the '
+ 'identity of the sender')
+ elif not msg.user and msg.jid != jid:
+ raise CorrectionError('Messages %s and %s have not been '
+ 'sent by the same fullJID' %
+ (old_id, new_id))
+
+ if not time:
+ time = msg.time
+ message = self.make_message(txt, time, msg.nickname,
+ msg.nick_color, None, msg.user,
+ new_id, highlight=highlight,
+ old_message=msg,
+ revisions=msg.revisions + 1,
+ jid=jid)
+ self.messages[i] = message
+ log.debug('Replacing message %s with %s.', old_id, new_id)
+ return message
+
+ def del_window(self, win):
+ self.windows.remove(win)
+
+ def __del__(self):
+ size = len(self.messages)
+ log.debug('** Deleting %s messages from textbuffer', size)
diff --git a/poezio/theming.py b/poezio/theming.py
new file mode 100755
index 00000000..5d263741
--- /dev/null
+++ b/poezio/theming.py
@@ -0,0 +1,534 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Define the variables (colors and some other stuff) that are
+used when drawing the interface.
+
+Colors are numbers from -1 to 7 (if only 8 colors are supported) or -1 to 255
+if 256 colors are available.
+If only 8 colors are available, all colors > 8 are converted using the
+table_256_to_16 dict.
+
+XHTML-IM colors are converted to -1 -> 255 colors if available, or directly to
+-1 -> 8 if we are in 8-color-mode.
+
+A pair_color is a background-foreground pair. All possible pairs are not created
+at startup, because that would create 256*256 pairs, and almost all of them
+would never be used.
+
+A theme should define color tuples, like ``(200, -1)``, and when they are to
+be used by poezio's interface, they will be created once, and kept in a list for
+later usage.
+A color tuple is of the form ``(foreground, background, optional)``
+A color of -1 means the default color. So if you do not want to have
+a background color, use ``(x, -1)``.
+The optional third value of the tuple defines additional information. It
+is a string and can contain one or more of these characters:
+
+- ``b``: bold
+- ``u``: underlined
+- ``x``: blink
+
+For example, ``(200, 208, 'bu')`` is bold, underlined and pink foreground on
+orange background.
+
+A theme file is a python file containing one object named 'theme', which is an
+instance of a class (derived from the Theme class) defined in that same file.
+For example, in pinkytheme.py:
+
+.. code-block:: python
+
+ import theming
+ class PinkyTheme(theming.Theme):
+ COLOR_NORMAL_TEXT = (200, -1)
+
+ theme = PinkyTheme()
+
+if the command '/theme pinkytheme' is issued, we import the pinkytheme.py file
+and set the global variable 'theme' to pinkytheme.theme.
+
+And in poezio's code we just use ``theme.COLOR_NORMAL_TEXT`` etc
+
+Since a theme inherites from the Theme class (defined here), if a color is not defined in a
+theme file, the color is the default one.
+
+Some values in that class are a list of color tuple.
+For example ``[(1, -1), (2, -1), (3, -1)]``
+Such a list SHOULD contain at least one color tuple.
+It is used for example to define color gradient, etc.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+from config import config
+
+import curses
+import os
+from os import path
+
+from importlib import machinery
+finder = machinery.PathFinder()
+
+class Theme(object):
+ """
+ The theme class, from which all themes should inherit.
+ All of the following values can be replaced in subclasses, in
+ order to create a new theme.
+
+ Do not edit this file if you want to change the theme to suit your
+ needs. Create a new theme and share it if you think it can be useful
+ for others.
+ """
+ @classmethod
+ def color_role(cls, role):
+ role_mapping = {
+ 'moderator': cls.COLOR_USER_MODERATOR,
+ 'participant': cls.COLOR_USER_PARTICIPANT,
+ 'visitor': cls.COLOR_USER_VISITOR,
+ 'none': cls.COLOR_USER_NONE,
+ '': cls.COLOR_USER_NONE
+ }
+ return role_mapping.get(role, cls.COLOR_USER_NONE)
+
+ @classmethod
+ def char_affiliation(cls, affiliation):
+ affiliation_mapping = {
+ 'owner': cls.CHAR_AFFILIATION_OWNER,
+ 'admin': cls.CHAR_AFFILIATION_ADMIN,
+ 'member': cls.CHAR_AFFILIATION_MEMBER,
+ 'none': cls.CHAR_AFFILIATION_NONE
+ }
+ return affiliation_mapping.get(affiliation, cls.CHAR_AFFILIATION_NONE)
+
+ @classmethod
+ def color_show(cls, show):
+ show_mapping = {
+ 'xa': cls.COLOR_STATUS_XA,
+ 'none': cls.COLOR_STATUS_NONE,
+ 'dnd': cls.COLOR_STATUS_DND,
+ 'away': cls.COLOR_STATUS_AWAY,
+ 'chat': cls.COLOR_STATUS_CHAT,
+ '': cls.COLOR_STATUS_ONLINE,
+ 'available': cls.COLOR_STATUS_ONLINE,
+ 'unavailable': cls.COLOR_STATUS_UNAVAILABLE,
+ }
+ return show_mapping.get(show, cls.COLOR_STATUS_NONE)
+
+ @classmethod
+ def char_subscription(cls, sub, keep='incomplete'):
+ sub_mapping = {
+ 'from': cls.CHAR_ROSTER_FROM,
+ 'both': cls.CHAR_ROSTER_BOTH,
+ 'none': cls.CHAR_ROSTER_NONE,
+ 'to': cls.CHAR_ROSTER_TO,
+ }
+ if keep == 'incomplete' and sub == 'both':
+ return ''
+ if keep in ('both', 'none', 'to', 'from'):
+ return sub_mapping[sub] if sub == keep else ''
+ return sub_mapping.get(sub, '')
+
+ # Message text color
+ COLOR_NORMAL_TEXT = (-1, -1)
+ COLOR_INFORMATION_TEXT = (5, -1) # TODO
+ COLOR_WARNING_TEXT = (1, -1)
+
+ # Color of the commands in the help message
+ COLOR_HELP_COMMANDS = (208, -1)
+
+ # "reverse" is a special value, available only for this option. It just
+ # takes the nick colors and reverses it. A theme can still specify a
+ # fixed color if need be.
+ COLOR_HIGHLIGHT_NICK = "reverse"
+
+ # Color of the participant JID in a MUC
+ COLOR_MUC_JID = (4, -1)
+
+ # User list color
+ COLOR_USER_VISITOR = (239, -1)
+ COLOR_USER_PARTICIPANT = (4, -1)
+ COLOR_USER_NONE = (0, -1)
+ COLOR_USER_MODERATOR = (1, -1)
+
+ # nickname colors
+ COLOR_REMOTE_USER = (5, -1)
+
+ # The character printed in color (COLOR_STATUS_*) before the nickname
+ # in the user list
+ CHAR_STATUS = '|'
+
+ # The characters used for the chatstates in the user list
+ # in a MUC
+ CHAR_CHATSTATE_ACTIVE = 'A'
+ CHAR_CHATSTATE_COMPOSING = 'X'
+ CHAR_CHATSTATE_PAUSED = 'p'
+
+ # These characters are used for the affiliation in the user list
+ # in a MUC
+ CHAR_AFFILIATION_OWNER = '~'
+ CHAR_AFFILIATION_ADMIN = '&'
+ 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)
+
+ # Color for the number of revisions of a message
+ COLOR_REVISIONS_MESSAGE = (3, -1, 'b')
+
+ # Color for various important text. For example the "?" before JIDs in
+ # the roster that require an user action.
+ COLOR_IMPORTANT_TEXT = (3, 5, 'b')
+
+ # Separators
+ COLOR_VERTICAL_SEPARATOR = (4, -1)
+ COLOR_NEW_TEXT_SEPARATOR = (2, -1)
+ COLOR_MORE_INDICATOR = (6, 4)
+
+ # Time
+ CHAR_TIME_LEFT = ''
+ CHAR_TIME_RIGHT = ''
+ COLOR_TIME_STRING = (-1, -1)
+
+ # Tabs
+ COLOR_TAB_NORMAL = (7, 4)
+ COLOR_TAB_NONEMPTY = (7, 4)
+ COLOR_TAB_SCROLLED = (5, 4)
+ COLOR_TAB_JOINED = (82, 4)
+ COLOR_TAB_CURRENT = (7, 6)
+ COLOR_TAB_COMPOSING = (7, 5)
+ COLOR_TAB_NEW_MESSAGE = (7, 5)
+ COLOR_TAB_HIGHLIGHT = (7, 3)
+ COLOR_TAB_PRIVATE = (7, 2)
+ COLOR_TAB_ATTENTION = (7, 1)
+ COLOR_TAB_DISCONNECTED = (7, 8)
+
+ COLOR_VERTICAL_TAB_NORMAL = (4, -1)
+ COLOR_VERTICAL_TAB_NONEMPTY = (4, -1)
+ COLOR_VERTICAL_TAB_JOINED = (82, -1)
+ COLOR_VERTICAL_TAB_SCROLLED = (66, -1)
+ COLOR_VERTICAL_TAB_CURRENT = (7, 4)
+ COLOR_VERTICAL_TAB_NEW_MESSAGE = (5, -1)
+ COLOR_VERTICAL_TAB_COMPOSING = (5, -1)
+ COLOR_VERTICAL_TAB_HIGHLIGHT = (3, -1)
+ COLOR_VERTICAL_TAB_PRIVATE = (2, -1)
+ COLOR_VERTICAL_TAB_ATTENTION = (1, -1)
+ COLOR_VERTICAL_TAB_DISCONNECTED = (8, -1)
+
+ # Nickname colors
+ # A list of colors randomly attributed to nicks in MUCs
+ # Setting more colors makes it harder to have two nicks with the same color,
+ # avoiding confusions.
+ LIST_COLOR_NICKNAMES = [
+ (1, -1), (2, -1), (3, -1), (4, -1), (5, -1), (6, -1), (9, -1),
+ (10, -1), (11, -1), (12, -1), (13, -1), (14, -1), (19, -1),
+ (20, -1), (21, -1), (22, -1), (23, -1), (24, -1), (25, -1),
+ (26, -1), (27, -1), (28, -1), (29, -1), (30, -1), (31, -1),
+ (32, -1), (33, -1), (34, -1), (35, -1), (36, -1), (37, -1),
+ (38, -1), (39, -1), (40, -1), (41, -1), (42, -1), (43, -1),
+ (44, -1), (45, -1), (46, -1), (47, -1), (48, -1), (49, -1),
+ (50, -1), (51, -1), (54, -1), (55, -1), (56, -1), (57, -1),
+ (58, -1), (60, -1), (61, -1), (62, -1), (63, -1), (64, -1),
+ (65, -1), (66, -1), (67, -1), (68, -1), (69, -1), (70, -1),
+ (71, -1), (72, -1), (73, -1), (74, -1), (75, -1), (76, -1),
+ (77, -1), (78, -1), (79, -1), (80, -1), (81, -1), (82, -1),
+ (83, -1), (84, -1), (85, -1), (86, -1), (87, -1), (88, -1),
+ (89, -1), (90, -1), (91, -1), (92, -1), (93, -1), (94, -1),
+ (95, -1), (96, -1), (97, -1), (98, -1), (99, -1), (100, -1),
+ (101, -1), (103, -1), (104, -1), (105, -1), (106, -1), (107, -1),
+ (108, -1), (109, -1), (110, -1), (111, -1), (112, -1), (113, -1),
+ (114, -1), (115, -1), (116, -1), (117, -1), (118, -1), (119, -1),
+ (120, -1), (121, -1), (122, -1), (123, -1), (124, -1), (125, -1),
+ (126, -1), (127, -1), (128, -1), (129, -1), (130, -1), (131, -1),
+ (132, -1), (133, -1), (134, -1), (135, -1), (136, -1), (137, -1),
+ (138, -1), (139, -1), (140, -1), (141, -1), (142, -1), (143, -1),
+ (144, -1), (145, -1), (146, -1), (147, -1), (148, -1), (149, -1),
+ (150, -1), (151, -1), (152, -1), (153, -1), (154, -1), (155, -1),
+ (156, -1), (157, -1), (158, -1), (159, -1), (160, -1), (161, -1),
+ (162, -1), (163, -1), (164, -1), (165, -1), (166, -1), (167, -1),
+ (168, -1), (169, -1), (170, -1), (171, -1), (172, -1), (173, -1),
+ (174, -1), (175, -1), (176, -1), (177, -1), (178, -1), (179, -1),
+ (180, -1), (181, -1), (182, -1), (183, -1), (184, -1), (185, -1),
+ (186, -1), (187, -1), (188, -1), (189, -1), (190, -1), (191, -1),
+ (192, -1), (193, -1), (196, -1), (197, -1), (198, -1), (199, -1),
+ (200, -1), (201, -1), (202, -1), (203, -1), (204, -1), (205, -1),
+ (206, -1), (207, -1), (208, -1), (209, -1), (210, -1), (211, -1),
+ (212, -1), (213, -1), (214, -1), (215, -1), (216, -1), (217, -1),
+ (218, -1), (219, -1), (220, -1), (221, -1), (222, -1), (223, -1),
+ (224, -1), (225, -1), (226, -1), (227, -1)]
+
+ # This is your own nickname
+ COLOR_OWN_NICK = (254, -1)
+
+ COLOR_LOG_MSG = (5, -1)
+ # This is for in-tab error messages
+ COLOR_ERROR_MSG = (9, 7, 'b')
+ # Status color
+ COLOR_STATUS_XA = (16, 90)
+ COLOR_STATUS_NONE = (16, 4)
+ COLOR_STATUS_DND = (16, 1)
+ COLOR_STATUS_AWAY = (16, 3)
+ COLOR_STATUS_CHAT = (16, 2)
+ COLOR_STATUS_UNAVAILABLE = (-1, 247)
+ COLOR_STATUS_ONLINE = (16, 4)
+
+ # Bars
+ COLOR_WARNING_PROMPT = (16, 1, 'b')
+ COLOR_INFORMATION_BAR = (7, 4)
+ COLOR_TOPIC_BAR = (7, 4)
+ COLOR_SCROLLABLE_NUMBER = (220, 4, 'b')
+ COLOR_SELECTED_ROW = (-1, 33)
+ COLOR_PRIVATE_NAME = (-1, 4)
+ COLOR_CONVERSATION_NAME = (2, 4)
+ COLOR_CONVERSATION_RESOURCE = (121, 4)
+ COLOR_GROUPCHAT_NAME = (7, 4)
+ COLOR_COLUMN_HEADER = (36, 4)
+ COLOR_COLUMN_HEADER_SEL = (4, 36)
+
+ # Strings for special messages (like join, quit, nick change, etc)
+ # Special messages
+ CHAR_JOIN = '--->'
+ CHAR_QUIT = '<---'
+ CHAR_KICK = '-!-'
+ CHAR_NEW_TEXT_SEPARATOR = '- '
+ CHAR_OK = '✔'
+ CHAR_ERROR = '✖'
+ CHAR_EMPTY = ' '
+ CHAR_ACK_RECEIVED = CHAR_OK
+ CHAR_NACK = CHAR_ERROR
+ CHAR_COLUMN_ASC = ' ▲'
+ CHAR_COLUMN_DESC = ' ▼'
+ CHAR_ROSTER_ERROR = CHAR_ERROR
+ CHAR_ROSTER_TUNE = '♪'
+ CHAR_ROSTER_ASKED = '?'
+ CHAR_ROSTER_ACTIVITY = 'A'
+ CHAR_ROSTER_MOOD = 'M'
+ CHAR_ROSTER_GAMING = 'G'
+ CHAR_ROSTER_FROM = '←'
+ CHAR_ROSTER_BOTH = '↔'
+ CHAR_ROSTER_TO = '→'
+ CHAR_ROSTER_NONE = '⇹'
+
+ COLOR_CHAR_ACK = (2, -1)
+ COLOR_CHAR_NACK = (1, -1)
+
+ COLOR_ROSTER_GAMING = (6, -1)
+ COLOR_ROSTER_MOOD = (2, -1)
+ COLOR_ROSTER_ACTIVITY = (3, -1)
+ COLOR_ROSTER_TUNE = (6, -1)
+ COLOR_ROSTER_ERROR = (1, -1)
+ COLOR_ROSTER_SUBSCRIPTION = (-1, -1)
+
+ COLOR_JOIN_CHAR = (4, -1)
+ COLOR_QUIT_CHAR = (1, -1)
+ COLOR_KICK_CHAR = (1, -1)
+
+ # Vertical tab list color
+ COLOR_VERTICAL_TAB_NUMBER = (34, -1)
+
+ # Info messages color (the part before the ">")
+ INFO_COLORS = {
+ 'info': (5, -1),
+ 'error': (16, 1),
+ 'warning': (1, -1),
+ 'roster': (2, -1),
+ 'help': (10, -1),
+ 'headline': (11, -1, 'b'),
+ 'tune': (6, -1),
+ 'gaming': (6, -1),
+ 'mood': (2, -1),
+ 'activity': (3, -1),
+ 'default': (7, -1),
+ }
+
+# This is the default theme object, used if no theme is defined in the conf
+theme = Theme()
+
+# a dict "color tuple -> color_pair"
+# Each time we use a color tuple, we check if it has already been used.
+# If not we create a new color_pair and keep it in that dict, to use it
+# the next time.
+curses_colors_dict = {}
+
+table_256_to_16 = [
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
+ 0, 4, 4, 4, 12, 12, 2, 6, 4, 4, 12, 12, 2, 2, 6, 4,
+ 12, 12, 2, 2, 2, 6, 12, 12, 10, 10, 10, 10, 14, 12, 10, 10,
+ 10, 10, 10, 14, 1, 5, 4, 4, 12, 12, 3, 8, 4, 4, 12, 12,
+ 2, 2, 6, 4, 12, 12, 2, 2, 2, 6, 12, 12, 10, 10, 10, 10,
+ 14, 12, 10, 10, 10, 10, 10, 14, 1, 1, 5, 4, 12, 12, 1, 1,
+ 5, 4, 12, 12, 3, 3, 8, 4, 12, 12, 2, 2, 2, 6, 12, 12,
+ 10, 10, 10, 10, 14, 12, 10, 10, 10, 10, 10, 14, 1, 1, 1, 5,
+ 12, 12, 1, 1, 1, 5, 12, 12, 1, 1, 1, 5, 12, 12, 3, 3,
+ 3, 7, 12, 12, 10, 10, 10, 10, 14, 12, 10, 10, 10, 10, 10, 14,
+ 9, 9, 9, 9, 13, 12, 9, 9, 9, 9, 13, 12, 9, 9, 9, 9,
+ 13, 12, 9, 9, 9, 9, 13, 12, 11, 11, 11, 11, 7, 12, 10, 10,
+ 10, 10, 10, 14, 9, 9, 9, 9, 9, 13, 9, 9, 9, 9, 9, 13,
+ 9, 9, 9, 9, 9, 13, 9, 9, 9, 9, 9, 13, 9, 9, 9, 9,
+ 9, 13, 11, 11, 11, 11, 11, 15, 0, 0, 0, 0, 0, 0, 8, 8,
+ 8, 8, 8, 8, 7, 7, 7, 7, 7, 7, 15, 15, 15, 15, 15, 15
+]
+
+load_path = []
+
+def color_256_to_16(color):
+ if color == -1:
+ return color
+ return table_256_to_16[color]
+
+def dump_tuple(tup):
+ """
+ Dump a tuple to a string of fg,bg,attr (optional)
+ """
+ return ','.join(str(i) for i in tup)
+
+def read_tuple(_str):
+ """
+ Read a tuple dumped with dump_tumple
+ """
+ attrs = _str.split(',')
+ char = attrs[2] if len(attrs) > 2 else None
+ return (int(attrs[0]), int(attrs[1])), char
+
+def to_curses_attr(color_tuple):
+ """
+ Takes a color tuple (as defined at the top of this file) and
+ returns a valid curses attr that can be passed directly to attron() or attroff()
+ """
+ # extract the color from that tuple
+ if len(color_tuple) == 3:
+ colors = (color_tuple[0], color_tuple[1])
+ else:
+ colors = color_tuple
+
+ bold = False
+ if curses.COLORS != 256:
+ # We are not in a term supporting 256 colors, so we convert
+ # colors to numbers between -1 and 8
+ colors = (color_256_to_16(colors[0]), color_256_to_16(colors[1]))
+ if colors[0] >= 8:
+ colors = (colors[0] - 8, colors[1])
+ bold = True
+ if colors[1] >= 8:
+ colors = (colors[0], colors[1] - 8)
+
+ # check if we already used these colors
+ try:
+ pair = curses_colors_dict[colors]
+ except KeyError:
+ pair = len(curses_colors_dict) + 1
+ curses.init_pair(pair, colors[0], colors[1])
+ curses_colors_dict[colors] = pair
+ curses_pair = curses.color_pair(pair)
+ if len(color_tuple) == 3:
+ additional_val = color_tuple[2]
+ if 'b' in additional_val or bold is True:
+ curses_pair = curses_pair | curses.A_BOLD
+ if 'u' in additional_val:
+ curses_pair = curses_pair | curses.A_UNDERLINE
+ if 'a' in additional_val:
+ curses_pair = curses_pair | curses.A_BLINK
+ return curses_pair
+
+def get_theme():
+ """
+ Returns the current theme
+ """
+ return theme
+
+def update_themes_dir(option=None, value=None):
+ global load_path
+ load_path = []
+
+ # import from the git sources
+ default_dir = path.join(
+ path.dirname(path.dirname(__file__)),
+ 'data/themes')
+ if path.exists(default_dir):
+ load_path.append(default_dir)
+
+ # import from the user-defined prefs
+ themes_dir = path.expanduser(
+ value or
+ config.get('themes_dir') or
+ path.join(os.environ.get('XDG_DATA_HOME') or
+ path.join(os.environ.get('HOME'), '.local', 'share'),
+ 'poezio', 'themes')
+ )
+ try:
+ os.makedirs(themes_dir)
+ except OSError as e:
+ if e.errno != 17:
+ log.error('Unable to create the themes dir (%s)', themes_dir)
+ else:
+ load_path.append(themes_dir)
+ else:
+ load_path.append(themes_dir)
+
+ # system-wide import
+ try:
+ import poezio_themes
+ except:
+ pass
+ else:
+ if poezio_themes.__path__:
+ load_path.append(list(poezio_themes.__path__)[0])
+
+ log.debug('Theme load path: %s', load_path)
+
+def reload_theme():
+ theme_name = config.get('theme')
+ global theme
+ if theme_name == 'default' or not theme_name.strip():
+ theme = Theme()
+ return
+ new_theme = None
+ exc = None
+ try:
+ loader = finder.find_module(theme_name, load_path)
+ if not loader:
+ return 'Failed to load the theme %s' % theme_name
+ new_theme = loader.load_module()
+ except Exception as e:
+ log.error('Failed to load the theme %s', theme_name, exc_info=True)
+ exc = e
+
+ if not new_theme:
+ return 'Failed to load theme: %s' % exc
+
+ if hasattr(new_theme, 'theme'):
+ theme = new_theme.theme
+ else:
+ return 'No theme present in the theme file'
+
+if __name__ == '__main__':
+ # Display some nice text with nice colors
+ s = curses.initscr()
+ curses.start_color()
+ curses.use_default_colors()
+ s.addstr('%s colors detected\n\n' % curses.COLORS, to_curses_attr((3, -1)))
+ for i in range(curses.COLORS):
+ s.addstr('%s ' % i, to_curses_attr((i, -1)))
+ s.addstr('\n')
+ s.refresh()
+ try:
+ s.getkey()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ curses.endwin()
+ print()
+
diff --git a/poezio/timed_events.py b/poezio/timed_events.py
new file mode 100644
index 00000000..7f43d05f
--- /dev/null
+++ b/poezio/timed_events.py
@@ -0,0 +1,58 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Timed events are the standard way to schedule events for later in poezio.
+
+Once created, they must be added to the list of checked events with
+:py:func:`Core.add_timed_event` (within poezio) or with
+:py:func:`.PluginAPI.add_timed_event` (within a plugin).
+"""
+
+import asyncio
+import logging
+
+log = logging.getLogger(__name__)
+
+import datetime
+
+class DelayedEvent(object):
+ """
+ A TimedEvent, but with the date calculated from now + a delay in seconds.
+ Use it if you want an event to happen in, e.g. 6 seconds.
+ """
+ def __init__(self, delay, callback, *args):
+ """
+ Create a new DelayedEvent.
+
+ :param int delay: The number of seconds.
+ :param function callback: The handler that will be executed.
+ :param \*args: Optional arguments passed to the handler.
+ """
+ self.callback = callback
+ self.args = args
+ self.delay = delay
+ # An asyncio handler, as returned by call_later() or call_at()
+ self.handler = None
+
+class TimedEvent(DelayedEvent):
+ """
+ An event with a callback that is called when the specified time is passed.
+
+ The callback and its arguments should be passed as the lasts arguments.
+ """
+ def __init__(self, date, callback, *args):
+ """
+ Create a new timed event.
+
+ :param datetime.datetime date: Time at which the callback must be run.
+ :param function callback: The handler that will be executed.
+ :param \*args: Optional arguments passed to the handler.
+ """
+ delta = date - datetime.datetime.now()
+ delay = delta.total_seconds()
+ DelayedEvent.__init__(self, delay, callback, *args)
diff --git a/poezio/user.py b/poezio/user.py
new file mode 100644
index 00000000..4142869b
--- /dev/null
+++ b/poezio/user.py
@@ -0,0 +1,121 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Define the user class.
+An user is a MUC participant, not a roster contact (see contact.py)
+"""
+
+from random import choice
+from datetime import timedelta, datetime
+from hashlib import md5
+import xhtml
+
+from theming import get_theme
+
+import logging
+log = logging.getLogger(__name__)
+
+ROLE_DICT = {
+ '':0,
+ 'none':0,
+ 'visitor':1,
+ 'participant':2,
+ 'moderator':3
+ }
+
+class User(object):
+ """
+ keep trace of an user in a Room
+ """
+ __slots__ = ('last_talked', 'jid', 'chatstate', 'affiliation', 'show', 'status', 'role', 'nick', 'color')
+
+ def __init__(self, nick, affiliation, show, status, role, jid, deterministic=True, color=''):
+ self.last_talked = datetime(1, 1, 1) # The oldest possible time
+ self.update(affiliation, show, status, role)
+ self.change_nick(nick)
+ if color != '':
+ self.change_color(color, deterministic)
+ else:
+ if deterministic:
+ self.set_deterministic_color()
+ else:
+ self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ self.jid = jid
+ self.chatstate = None
+
+ def set_deterministic_color(self):
+ theme = get_theme()
+ mod = len(theme.LIST_COLOR_NICKNAMES)
+ nick_pos = int(md5(self.nick.encode('utf-8')).hexdigest(), 16) % mod
+ self.color = theme.LIST_COLOR_NICKNAMES[nick_pos]
+
+ def update(self, affiliation, show, status, role):
+ self.affiliation = affiliation
+ self.show = show
+ self.status = status
+ if role not in ROLE_DICT: # avoid unvalid roles
+ role = ''
+ self.role = role
+
+ def change_nick(self, nick):
+ self.nick = nick
+
+ def change_color(self, color_name, deterministic=False):
+ color = xhtml.colors.get(color_name)
+ if color == None:
+ log.error('Unknown color "%s"' % color_name)
+ if deterministic:
+ self.set_deterministic_color()
+ else:
+ self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ else:
+ self.color = (color, -1)
+
+ def set_last_talked(self, time):
+ """
+ time: datetime object
+ """
+ self.last_talked = time
+
+ def has_talked_since(self, t):
+ """
+ t: int
+ Return True if the user talked since the last s seconds
+ """
+ if self.last_talked is None:
+ return False
+ delta = timedelta(0, t)
+ if datetime.now() - delta > self.last_talked:
+ return False
+ return True
+
+ def __repr__(self):
+ return ">%s<" % (self.nick)
+
+ def __eq__(self, b):
+ return self.role == b.role and self.nick == b.nick
+
+ def __gt__(self, b):
+ if ROLE_DICT[self.role] == ROLE_DICT[b.role]:
+ return self.nick.lower() > b.nick.lower()
+ return ROLE_DICT[self.role] < ROLE_DICT[b.role]
+
+ def __ge__(self, b):
+ if ROLE_DICT[self.role] == ROLE_DICT[b.role]:
+ return self.nick.lower() >= b.nick.lower()
+ return ROLE_DICT[self.role] <= ROLE_DICT[b.role]
+
+ def __lt__(self, b):
+ if ROLE_DICT[self.role] == ROLE_DICT[b.role]:
+ return self.nick.lower() < b.nick.lower()
+ return ROLE_DICT[self.role] > ROLE_DICT[b.role]
+
+ def __le__(self, b):
+ if ROLE_DICT[self.role] == ROLE_DICT[b.role]:
+ return self.nick.lower() <= b.nick.lower()
+ return ROLE_DICT[self.role] >= ROLE_DICT[b.role]
diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py
new file mode 100644
index 00000000..5ec73961
--- /dev/null
+++ b/poezio/windows/__init__.py
@@ -0,0 +1,20 @@
+"""
+Module exporting all the Windows, which are wrappers around curses wins
+used to display information on the screen
+"""
+
+from . base_wins import Win
+from . data_forms import FormWin
+from . bookmark_forms import BookmarksWin
+from . info_bar import GlobalInfoBar, VerticalGlobalInfoBar
+from . info_wins import InfoWin, XMLInfoWin, PrivateInfoWin, MucListInfoWin, \
+ ConversationInfoWin, DynamicConversationInfoWin, MucInfoWin, \
+ ConversationStatusMessageWin, BookmarksInfoWin
+from . input_placeholders import HelpText, YesNoInput
+from . inputs import Input, HistoryInput, MessageInput, CommandInput
+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, XMLTextWin
+
diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py
new file mode 100644
index 00000000..8df214d2
--- /dev/null
+++ b/poezio/windows/base_wins.py
@@ -0,0 +1,168 @@
+"""
+Define the base window object and the constants/"globals" used
+by the file of this module.
+
+A window is a little part of the screen, for example the input window,
+the text window, the roster window, etc.
+A Tab (see the poezio.tabs module) is composed of multiple Windows
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import collections
+import curses
+import string
+
+import core
+import singleton
+from 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']
+
+# different colors allowed in the input
+allowed_color_digits = ('0', '1', '2', '3', '4', '5', '6', '7')
+
+# msg is a reference to the corresponding Message tuple. text_start and
+# text_end are the position delimiting the text in this line.
+Line = collections.namedtuple('Line', 'msg start_pos end_pos prepend')
+
+LINES_NB_LIMIT = 4096
+
+class DummyWin(object):
+ def __getattribute__(self, name):
+ if name != '__bool__':
+ return lambda *args, **kwargs: (0, 0)
+ else:
+ return object.__getattribute__(self, name)
+
+ def __bool__(self):
+ return False
+
+class Win(object):
+ _win_core = None
+ _tab_win = None
+ def __init__(self):
+ self._win = None
+ self.height, self.width = 0, 0
+
+ def _resize(self, height, width, y, x):
+ if height == 0 or width == 0:
+ self.height, self.width = height, width
+ return
+ self.height, self.width, self.x, self.y = height, width, x, y
+ try:
+ self._win = Win._tab_win.derwin(height, width, y, x)
+ except:
+ log.debug('DEBUG: mvwin returned ERR. Please investigate')
+ if self._win is None:
+ self._win = DummyWin()
+
+ def resize(self, height, width, y, x):
+ """
+ Override if something has to be done on resize
+ """
+ self._resize(height, width, y, x)
+
+ def _refresh(self):
+ self._win.noutrefresh()
+
+ def addnstr(self, *args):
+ """
+ Safe call to addnstr
+ """
+ try:
+ self._win.addnstr(*args)
+ except:
+ # this actually mostly returns ERR, but works.
+ # more specifically, when the added string reaches the end
+ # of the screen.
+ pass
+
+ def addstr(self, *args):
+ """
+ Safe call to addstr
+ """
+ try:
+ self._win.addstr(*args)
+ except:
+ pass
+
+ def move(self, y, x):
+ try:
+ self._win.move(y, x)
+ except:
+ self._win.move(0, 0)
+
+ def addstr_colored(self, text, y=None, x=None):
+ """
+ Write a string on the window, setting the
+ attributes as they are in the string.
+ For example:
+ \x19bhello → hello in bold
+ \x191}Bonj\x192}our → 'Bonj' in red and 'our' in green
+ next_attr_char is the \x19 delimiter
+ attr_char is the char following it, it can be
+ one of 'u', 'b', 'c[0-9]'
+ """
+ if y is not None and x is not None:
+ self.move(y, x)
+ next_attr_char = text.find(FORMAT_CHAR)
+ while next_attr_char != -1 and text:
+ if next_attr_char + 1 < len(text):
+ attr_char = text[next_attr_char+1].lower()
+ else:
+ attr_char = str()
+ if next_attr_char != 0:
+ self.addstr(text[:next_attr_char])
+ if attr_char == 'o':
+ self._win.attrset(0)
+ elif attr_char == 'u':
+ self._win.attron(curses.A_UNDERLINE)
+ elif attr_char == 'b':
+ self._win.attron(curses.A_BOLD)
+ if (attr_char in string.digits or attr_char == '-') and attr_char != '':
+ color_str = text[next_attr_char+1:text.find('}', next_attr_char)]
+ if ',' in color_str:
+ tup, char = read_tuple(color_str)
+ self._win.attron(to_curses_attr(tup))
+ if char:
+ if char == 'o':
+ self._win.attrset(0)
+ elif char == 'u':
+ self._win.attron(curses.A_UNDERLINE)
+ elif char == 'b':
+ self._win.attron(curses.A_BOLD)
+ else:
+ # this will reset previous bold/uderline sequences if any was used
+ self._win.attroff(curses.A_UNDERLINE)
+ self._win.attroff(curses.A_BOLD)
+ elif color_str:
+ self._win.attron(to_curses_attr((int(color_str), -1)))
+ text = text[next_attr_char+len(color_str)+2:]
+ else:
+ text = text[next_attr_char+2:]
+ next_attr_char = text.find(FORMAT_CHAR)
+ self.addstr(text)
+
+ def finish_line(self, color=None):
+ """
+ Write colored spaces until the end of line
+ """
+ (y, x) = self._win.getyx()
+ size = self.width - x
+ if color:
+ self.addnstr(' '*size, size, to_curses_attr(color))
+ else:
+ self.addnstr(' '*size, size)
+
+ @property
+ def core(self):
+ if not Win._win_core:
+ Win._win_core = singleton.Singleton(core.Core)
+ return Win._win_core
+
diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py
new file mode 100644
index 00000000..de1043c9
--- /dev/null
+++ b/poezio/windows/bookmark_forms.py
@@ -0,0 +1,278 @@
+"""
+Windows used inthe bookmarkstab
+"""
+import curses
+
+from . import Win
+from . inputs import Input
+from . data_forms import FieldInput
+from theming import to_curses_attr, get_theme
+from common import safeJID
+
+class BookmarkJIDInput(FieldInput, Input):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Input.__init__(self)
+ jid = safeJID(field.jid)
+ jid.resource = field.nick or None
+ self.text = jid.full
+ self.pos = len(self.text)
+ self.color = get_theme().COLOR_NORMAL_TEXT
+
+ def save(self):
+ jid = safeJID(self.get_text())
+ self._field.jid = jid.bare
+ self._field.name = jid.bare
+ self._field.nick = jid.resource
+
+ def get_help_message(self):
+ return 'Edit the text'
+
+class BookmarkMethodInput(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ self.options = ('local', 'remote')
+ # val_pos is the position of the currently selected option
+ self.val_pos = self.options.index(field.method)
+
+ def do_command(self, key):
+ if key == 'KEY_LEFT':
+ if self.val_pos > 0:
+ self.val_pos -= 1
+ elif key == 'KEY_RIGHT':
+ if self.val_pos < len(self.options)-1:
+ self.val_pos += 1
+ else:
+ return
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addnstr(0, 0, ' '*self.width, self.width)
+ if self.val_pos > 0:
+ self.addstr(0, 0, '←')
+ if self.val_pos < len(self.options)-1:
+ self.addstr(0, self.width-1, '→')
+ if self.options:
+ option = self.options[self.val_pos]
+ self.addstr(0, self.width//2-len(option)//2, option)
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def save(self):
+ self._field.method = self.options[self.val_pos]
+
+ def get_help_message(self):
+ return '←, →: Select a value amongst the others'
+
+class BookmarkPasswordInput(FieldInput, Input):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Input.__init__(self)
+ self.text = field.password or ''
+ self.pos = len(self.text)
+ self.color = get_theme().COLOR_NORMAL_TEXT
+
+ def rewrite_text(self):
+ self._win.erase()
+ if self.color:
+ self._win.attron(to_curses_attr(self.color))
+ self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1]))
+ if self.color:
+ (y, x) = self._win.getyx()
+ size = self.width-x
+ self.addnstr(' '*size, size, to_curses_attr(self.color))
+ self.addstr(0, self.pos, '')
+ if self.color:
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def save(self):
+ self._field.password = self.get_text() or None
+
+ def get_help_message(self):
+ return 'Edit the secret text'
+
+class BookmarkAutojoinWin(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ self.last_key = 'KEY_RIGHT'
+ self.value = field.autojoin
+
+ def do_command(self, key):
+ if key == 'KEY_LEFT' or key == 'KEY_RIGHT':
+ self.value = not self.value
+ self.last_key = key
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ format_string = '←{:^%s}→' % 7
+ inp = format_string.format(repr(self.value))
+ self.addstr(0, 0, inp)
+ if self.last_key == 'KEY_RIGHT':
+ self.move(0, 8)
+ else:
+ self.move(0, 0)
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def save(self):
+ self._field.autojoin = self.value
+
+ def get_help_message(self):
+ return '← and →: change the value between True and False'
+
+
+class BookmarksWin(Win):
+ def __init__(self, bookmarks, height, width, y, x):
+ self._win = Win._tab_win.derwin(height, width, y, x)
+ self.scroll_pos = 0
+ self._current_input = 0
+ self.current_horizontal_input = 0
+ self._bookmarks = list(bookmarks)
+ self.lines = []
+ for bookmark in sorted(self._bookmarks, key=lambda x: x.jid):
+ self.lines.append((BookmarkJIDInput(bookmark),
+ BookmarkPasswordInput(bookmark),
+ BookmarkAutojoinWin(bookmark),
+ BookmarkMethodInput(bookmark)))
+
+ @property
+ def current_input(self):
+ return self._current_input
+
+ @current_input.setter
+ def current_input(self, value):
+ if 0 <= self._current_input < len(self.lines):
+ if 0 <= value < len(self.lines):
+ self.lines[self._current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self._current_input = value
+ else:
+ self._current_input = 0
+
+ def add_bookmark(self, bookmark):
+ self.lines.append((BookmarkJIDInput(bookmark),
+ BookmarkPasswordInput(bookmark),
+ BookmarkAutojoinWin(bookmark),
+ BookmarkMethodInput(bookmark)))
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_horizontal_input = 0
+ self.current_input = len(self.lines) - 1
+ if self.current_input - self.scroll_pos > self.height-1:
+ self.scroll_pos = self.current_input - self.height + 1
+ self.refresh()
+
+ def del_current_bookmark(self):
+ if self.lines:
+ bm = self.lines[self.current_input][0]._field
+ to_delete = self.current_input
+ self.current_input -= 1
+ del self.lines[to_delete]
+ if self.scroll_pos:
+ self.scroll_pos -= 1
+ self.refresh()
+ return bm
+
+ def resize(self, height, width, y, x):
+ self.height = height
+ self.width = width
+ self._win = Win._tab_win.derwin(height, width, y, x)
+ # Adjust the scroll position, if resizing made the window too small
+ # for the cursor to be visible
+ while self.current_input - self.scroll_pos > self.height-1:
+ self.scroll_pos += 1
+
+ def go_to_next_line_input(self):
+ if not self.lines:
+ return
+ if self.current_input == len(self.lines) - 1:
+ return
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ # Adjust the scroll position if the current_input would be outside
+ # of the visible area
+ if self.current_input + 1 - self.scroll_pos > self.height-1:
+ self.current_input += 1
+ self.scroll_pos += 1
+ self.refresh()
+ else:
+ self.current_input += 1
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def go_to_previous_line_input(self):
+ if not self.lines:
+ return
+ if self.current_input == 0:
+ return
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_input -= 1
+ # Adjust the scroll position if the current_input would be outside
+ # of the visible area
+ if self.current_input < self.scroll_pos:
+ self.scroll_pos = self.current_input
+ self.refresh()
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def go_to_next_horizontal_input(self):
+ if not self.lines:
+ return
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_horizontal_input += 1
+ if self.current_horizontal_input > 3:
+ self.current_horizontal_input = 0
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def go_to_previous_horizontal_input(self):
+ if not self.lines:
+ return
+ if self.current_horizontal_input == 0:
+ return
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_horizontal_input -= 1
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def on_input(self, key):
+ if not self.lines:
+ return
+ self.lines[self.current_input][self.current_horizontal_input].do_command(key)
+
+ def refresh(self):
+ # store the cursor status
+ self._win.erase()
+ y = - self.scroll_pos
+ for i in range(len(self.lines)):
+ self.lines[i][0].resize(1, self.width//3, y + 1, 0)
+ self.lines[i][1].resize(1, self.width//3, y + 1, self.width//3)
+ self.lines[i][2].resize(1, self.width//6, y + 1, 2*self.width//3)
+ self.lines[i][3].resize(1, self.width//6, y + 1, 5*self.width//6)
+ y += 1
+ self._refresh()
+ for i, inp in enumerate(self.lines):
+ if i < self.scroll_pos:
+ continue
+ if i >= self.height + self.scroll_pos:
+ break
+ for j in range(4):
+ inp[j].refresh()
+
+ if self.lines and self.current_input < self.height-1:
+ self.lines[self.current_input][self.current_horizontal_input].set_color(get_theme().COLOR_SELECTED_ROW)
+ self.lines[self.current_input][self.current_horizontal_input].refresh()
+ if not self.lines:
+ curses.curs_set(0)
+ else:
+ curses.curs_set(1)
+
+ def refresh_current_input(self):
+ if self.lines:
+ self.lines[self.current_input][self.current_horizontal_input].refresh()
+
+ def save(self):
+ for line in self.lines:
+ for item in line:
+ item.save()
+
diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py
new file mode 100644
index 00000000..410648ec
--- /dev/null
+++ b/poezio/windows/data_forms.py
@@ -0,0 +1,472 @@
+"""
+Windows used by the DataFormsTab.
+
+We only need to export the FormWin (which is not a real Win, as it
+does not inherit from the Win base class), as it will create the
+others when needed.
+"""
+
+from . import Win
+from . inputs import Input
+
+from theming import to_curses_attr, get_theme
+
+class FieldInput(object):
+ """
+ All input type in a data form should inherite this class,
+ in addition with windows.Input or any relevant class from the
+ 'windows' library.
+ """
+ def __init__(self, field):
+ self._field = field
+ self.color = get_theme().COLOR_NORMAL_TEXT
+
+ def set_color(self, color):
+ self.color = color
+ self.refresh()
+
+ def update_field_value(self, value):
+ raise NotImplementedError
+
+ def resize(self, height, width, y, x):
+ self._resize(height, width, y, x)
+
+ def is_dummy(self):
+ return False
+
+ def reply(self):
+ """
+ Set the correct response value in the field
+ """
+ raise NotImplementedError
+
+ def get_help_message(self):
+ """
+ Should return a string explaining the keys of the input.
+ Will be displayed at each refresh on a line at the bottom of the tab.
+ """
+ return ''
+
+class ColoredLabel(Win):
+ def __init__(self, text):
+ self.text = text
+ self.color = get_theme().COLOR_NORMAL_TEXT
+ Win.__init__(self)
+
+ def resize(self, height, width, y, x):
+ self._resize(height, width, y, x)
+
+ def set_color(self, color):
+ self.color = color
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addstr(0, 0, self.text)
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+
+class DummyInput(FieldInput, Win):
+ """
+ Used for fields that do not require any input ('fixed')
+ """
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+
+ def do_command(self):
+ return
+
+ def refresh(self):
+ return
+
+ def is_dummy(self):
+ return True
+
+class BooleanWin(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ self.last_key = 'KEY_RIGHT'
+ self.value = bool(field.getValue())
+
+ def do_command(self, key):
+ if key == 'KEY_LEFT' or key == 'KEY_RIGHT':
+ self.value = not self.value
+ self.last_key = key
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addnstr(0, 0, ' '*(8), self.width)
+ self.addstr(0, 2, "%s"%self.value)
+ self.addstr(0, 8, '→')
+ self.addstr(0, 0, '←')
+ if self.last_key == 'KEY_RIGHT':
+ self.addstr(0, 8, '')
+ else:
+ self.addstr(0, 0, '')
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def reply(self):
+ self._field['label'] = ''
+ self._field.setAnswer(self.value)
+
+ def get_help_message(self):
+ return '← and →: change the value between True and False'
+
+class TextMultiWin(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ self.options = field.getValue()
+ if not isinstance(self.options, list):
+ self.options = self.options.split('\n') if self.options else []
+ self.val_pos = 0
+ self.edition_input = None
+ if not isinstance(self.options, list):
+ if isinstance(self.options, str):
+ self.options = [self.options]
+ else:
+ self.options = []
+ self.options.append('')
+
+ def do_command(self, key):
+ if not self.edition_input:
+ if key == 'KEY_LEFT':
+ if self.val_pos > 0:
+ self.val_pos -= 1
+ elif key == 'KEY_RIGHT':
+ if self.val_pos < len(self.options)-1:
+ self.val_pos += 1
+ elif key == '^M':
+ self.edition_input = Input()
+ self.edition_input.color = self.color
+ self.edition_input.resize(self.height, self.width, self.y, self.x)
+ self.edition_input.text = self.options[self.val_pos]
+ self.edition_input.key_end()
+ else:
+ if key == '^M':
+ self.options[self.val_pos] = self.edition_input.get_text()
+ if not self.options[self.val_pos] and self.val_pos != len(self.options) -1:
+ del self.options[self.val_pos]
+ if self.val_pos == len(self.options) -1:
+ self.val_pos -= 1
+ self.edition_input = None
+ if not self.options or self.options[-1] != '':
+ self.options.append('')
+ else:
+ self.edition_input.do_command(key)
+ self.refresh()
+
+ def refresh(self):
+ if not self.edition_input:
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addnstr(0, 0, ' '*self.width, self.width)
+ option = self.options[self.val_pos]
+ self.addstr(0, self.width//2-len(option)//2, option)
+ if self.val_pos > 0:
+ self.addstr(0, 0, '←')
+ if self.val_pos < len(self.options)-1:
+ self.addstr(0, self.width-1, '→')
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+ else:
+ self.edition_input.refresh()
+
+ def reply(self):
+ values = [val for val in self.options if val]
+ self._field.setAnswer(values)
+
+ def get_help_message(self):
+ if not self.edition_input:
+ help_msg = '← and →: browse the available entries. '
+ if self.val_pos == len(self.options)-1:
+ help_msg += 'Enter: add an entry'
+ else:
+ help_msg += 'Enter: edit this entry'
+ else:
+ help_msg = 'Enter: finish editing this entry.'
+ return help_msg
+
+class ListMultiWin(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ values = field.getValue() or []
+ self.options = [[option, True if option['value'] in values else False]\
+ for option in field.get_options()]
+ self.val_pos = 0
+
+ def do_command(self, key):
+ if key == 'KEY_LEFT':
+ if self.val_pos > 0:
+ self.val_pos -= 1
+ elif key == 'KEY_RIGHT':
+ if self.val_pos < len(self.options)-1:
+ self.val_pos += 1
+ elif key == ' ':
+ self.options[self.val_pos][1] = not self.options[self.val_pos][1]
+ else:
+ return
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addnstr(0, 0, ' '*self.width, self.width)
+ if self.val_pos > 0:
+ self.addstr(0, 0, '←')
+ if self.val_pos < len(self.options)-1:
+ self.addstr(0, self.width-1, '→')
+ if self.options:
+ option = self.options[self.val_pos]
+ self.addstr(0, self.width//2-len(option)//2, option[0]['label'])
+ self.addstr(0, 2, '✔' if option[1] else '☐')
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def reply(self):
+ self._field['label'] = ''
+ self._field.delOptions()
+ values = [option[0]['value'] for option in self.options if option[1] is True]
+ self._field.setAnswer(values)
+
+ def get_help_message(self):
+ return '←, →: Switch between the value. Space: select or unselect a value'
+
+class ListSingleWin(FieldInput, Win):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Win.__init__(self)
+ # the option list never changes
+ self.options = field.getOptions()
+ # val_pos is the position of the currently selected option
+ self.val_pos = 0
+ for i, option in enumerate(self.options):
+ if field.getValue() == option['value']:
+ self.val_pos = i
+
+ def do_command(self, key):
+ if key == 'KEY_LEFT':
+ if self.val_pos > 0:
+ self.val_pos -= 1
+ elif key == 'KEY_RIGHT':
+ if self.val_pos < len(self.options)-1:
+ self.val_pos += 1
+ else:
+ return
+ self.refresh()
+
+ def refresh(self):
+ self._win.erase()
+ self._win.attron(to_curses_attr(self.color))
+ self.addnstr(0, 0, ' '*self.width, self.width)
+ if self.val_pos > 0:
+ self.addstr(0, 0, '←')
+ if self.val_pos < len(self.options)-1:
+ self.addstr(0, self.width-1, '→')
+ if self.options:
+ option = self.options[self.val_pos]['label']
+ self.addstr(0, self.width//2-len(option)//2, option)
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def reply(self):
+ self._field['label'] = ''
+ self._field.delOptions()
+ self._field.setAnswer(self.options[self.val_pos]['value'])
+
+ def get_help_message(self):
+ return '←, →: Select a value amongst the others'
+
+class TextSingleWin(FieldInput, Input):
+ def __init__(self, field):
+ FieldInput.__init__(self, field)
+ Input.__init__(self)
+ self.text = field.getValue() if isinstance(field.getValue(), str)\
+ else ""
+ self.pos = len(self.text)
+ self.color = get_theme().COLOR_NORMAL_TEXT
+
+ def reply(self):
+ self._field['label'] = ''
+ self._field.setAnswer(self.get_text())
+
+ def get_help_message(self):
+ return 'Edit the text'
+
+class TextPrivateWin(TextSingleWin):
+ def __init__(self, field):
+ TextSingleWin.__init__(self, field)
+
+ def rewrite_text(self):
+ self._win.erase()
+ if self.color:
+ self._win.attron(to_curses_attr(self.color))
+ self.addstr('*'*len(self.text[self.view_pos:self.view_pos+self.width-1]))
+ if self.color:
+ (y, x) = self._win.getyx()
+ size = self.width-x
+ self.addnstr(' '*size, size, to_curses_attr(self.color))
+ self.addstr(0, self.pos, '')
+ if self.color:
+ self._win.attroff(to_curses_attr(self.color))
+ self._refresh()
+
+ def get_help_message(self):
+ return 'Edit the secret text'
+
+class FormWin(object):
+ """
+ A window, with some subwins (the various inputs).
+ On init, create all the subwins.
+ On resize, move and resize all the subwin and define how the text will be written
+ On refresh, write all the text, and refresh all the subwins
+ """
+ input_classes = {'boolean': BooleanWin,
+ 'fixed': DummyInput,
+ 'jid-multi': TextMultiWin,
+ 'jid-single': TextSingleWin,
+ 'list-multi': ListMultiWin,
+ 'list-single': ListSingleWin,
+ 'text-multi': TextMultiWin,
+ 'text-private': TextPrivateWin,
+ 'text-single': TextSingleWin,
+ }
+ def __init__(self, form, height, width, y, x):
+ self._form = form
+ self._win = Win._tab_win.derwin(height, width, y, x)
+ self.scroll_pos = 0
+ self.current_input = 0
+ self.inputs = [] # dict list
+ for (name, field) in self._form.getFields().items():
+ if field['type'] == 'hidden':
+ continue
+ try:
+ input_class = self.input_classes[field['type']]
+ except IndexError:
+ continue
+ label = field['label']
+ desc = field['desc']
+ if field['type'] == 'fixed':
+ label = field.getValue()
+ inp = input_class(field)
+ self.inputs.append({'label':ColoredLabel(label),
+ 'description': desc,
+ 'input':inp})
+
+ def resize(self, height, width, y, x):
+ self.height = height
+ self.width = width
+ self._win = Win._tab_win.derwin(height, width, y, x)
+ # Adjust the scroll position, if resizing made the window too small
+ # for the cursor to be visible
+ while self.current_input - self.scroll_pos > self.height-1:
+ self.scroll_pos += 1
+
+ def reply(self):
+ """
+ Set the response values in the form, for each field
+ from the corresponding input
+ """
+ for inp in self.inputs:
+ if inp['input'].is_dummy():
+ continue
+ else:
+ inp['input'].reply()
+ self._form['title'] = ''
+ self._form['instructions'] = ''
+
+ def go_to_next_input(self):
+ if not self.inputs:
+ return
+ if self.current_input == len(self.inputs) - 1:
+ return
+ self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_input += 1
+ jump = 0
+ while self.current_input+jump != len(self.inputs) - 1 and self.inputs[self.current_input+jump]['input'].is_dummy():
+ jump += 1
+ if self.inputs[self.current_input+jump]['input'].is_dummy():
+ return
+ self.current_input += jump
+ # If moving made the current input out of the visible screen, we
+ # adjust the scroll position and we redraw the whole thing. We don’t
+ # call refresh() if this is not the case, because
+ # refresh_current_input() is always called anyway, so this is not
+ # needed
+ if self.current_input - self.scroll_pos > self.height-1:
+ self.scroll_pos += 1
+ self.refresh()
+ self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW)
+ self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def go_to_previous_input(self):
+ if not self.inputs:
+ return
+ if self.current_input == 0:
+ return
+ self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_NORMAL_TEXT)
+ self.current_input -= 1
+ jump = 0
+ while self.current_input-jump > 0 and self.inputs[self.current_input+jump]['input'].is_dummy():
+ jump += 1
+ if self.inputs[self.current_input+jump]['input'].is_dummy():
+ return
+ # Adjust the scroll position if the current_input would be outside
+ # of the visible area
+ if self.current_input < self.scroll_pos:
+ self.scroll_pos = self.current_input
+ self.refresh()
+ self.current_input -= jump
+ self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW)
+ self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW)
+
+ def on_input(self, key):
+ if not self.inputs:
+ return
+ self.inputs[self.current_input]['input'].do_command(key)
+
+ def refresh(self):
+ self._win.erase()
+ y = -self.scroll_pos
+ i = 0
+ for name, field in self._form.getFields().items():
+ if field['type'] == 'hidden':
+ continue
+ self.inputs[i]['label'].resize(1, self.width//2, y + 1, 0)
+ self.inputs[i]['input'].resize(1, self.width//2, y+1, self.width//2)
+ # TODO: display the field description
+ y += 1
+ i += 1
+ self._win.refresh()
+ for i, inp in enumerate(self.inputs):
+ if i < self.scroll_pos:
+ continue
+ if i >= self.height + self.scroll_pos:
+ break
+ inp['label'].refresh()
+ inp['input'].refresh()
+ inp['label'].refresh()
+ if self.inputs and self.current_input < self.height-1:
+ self.inputs[self.current_input]['input'].set_color(get_theme().COLOR_SELECTED_ROW)
+ self.inputs[self.current_input]['input'].refresh()
+ self.inputs[self.current_input]['label'].set_color(get_theme().COLOR_SELECTED_ROW)
+ self.inputs[self.current_input]['label'].refresh()
+
+ def refresh_current_input(self):
+ self.inputs[self.current_input]['input'].refresh()
+
+ def get_help_message(self):
+ if self.inputs and self.current_input < self.height-1 and self.inputs[self.current_input]['input']:
+ return self.inputs[self.current_input]['input'].get_help_message()
+ return ''
+
diff --git a/poezio/windows/funcs.py b/poezio/windows/funcs.py
new file mode 100644
index 00000000..f1401628
--- /dev/null
+++ b/poezio/windows/funcs.py
@@ -0,0 +1,54 @@
+"""
+Standalone functions used by the modules
+"""
+
+import string
+
+from . base_wins import FORMAT_CHAR, format_chars
+
+def find_first_format_char(text, chars=None):
+ if chars is None:
+ chars = format_chars
+ pos = -1
+ for char in chars:
+ p = text.find(char)
+ if p == -1:
+ continue
+ if pos == -1 or p < pos:
+ pos = p
+ return pos
+
+def truncate_nick(nick, size=10):
+ if size < 1:
+ size = 1
+ if nick and len(nick) > size:
+ return nick[:size]+'…'
+ return nick
+
+def parse_attrs(text, previous=None):
+ next_attr_char = text.find(FORMAT_CHAR)
+ if previous:
+ attrs = previous
+ else:
+ attrs = []
+ while next_attr_char != -1 and text:
+ if next_attr_char + 1 < len(text):
+ attr_char = text[next_attr_char+1].lower()
+ else:
+ attr_char = str()
+ if attr_char == 'o':
+ attrs = []
+ elif attr_char == 'u':
+ attrs.append('u')
+ elif attr_char == 'b':
+ attrs.append('b')
+ if attr_char in string.digits and attr_char != '':
+ color_str = text[next_attr_char+1:text.find('}', next_attr_char)]
+ if color_str:
+ attrs.append(color_str + '}')
+ text = text[next_attr_char+len(color_str)+2:]
+ else:
+ text = text[next_attr_char+2:]
+ next_attr_char = text.find(FORMAT_CHAR)
+ return attrs
+
diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py
new file mode 100644
index 00000000..abd956cd
--- /dev/null
+++ b/poezio/windows/info_bar.py
@@ -0,0 +1,106 @@
+"""
+Module defining the global info bar
+
+This window is the one listing the current opened tabs in poezio.
+The GlobalInfoBar can be either horizontal or vertical
+(VerticalGlobalInfoBar).
+"""
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+
+from config import config
+from . import Win
+from theming import get_theme, to_curses_attr
+
+class GlobalInfoBar(Win):
+ def __init__(self):
+ Win.__init__(self)
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ self.addstr(0, 0, "[", to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ create_gaps = config.get('create_gaps')
+ show_names = config.get('show_tab_names')
+ show_nums = config.get('show_tab_numbers')
+ use_nicks = config.get('use_tab_nicks')
+ show_inactive = config.get('show_inactive_tabs')
+ # ignore any remaining gap tabs if the feature is not enabled
+ if create_gaps:
+ sorted_tabs = self.core.tabs[:]
+ else:
+ sorted_tabs = [tab for tab in self.core.tabs if tab]
+
+ for nb, tab in enumerate(sorted_tabs):
+ if not tab: continue
+ color = tab.color
+ if not show_inactive and color is get_theme().COLOR_TAB_NORMAL:
+ continue
+ try:
+ if show_nums or not show_names:
+ self.addstr("%s" % str(nb), to_curses_attr(color))
+ if show_names:
+ self.addstr(' ', to_curses_attr(color))
+ if show_names:
+ if use_nicks:
+ self.addstr("%s" % str(tab.get_nick()), to_curses_attr(color))
+ else:
+ self.addstr("%s" % tab.name, to_curses_attr(color))
+ self.addstr("|", to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ except: # end of line
+ break
+ (y, x) = self._win.getyx()
+ self.addstr(y, x-1, '] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ (y, x) = self._win.getyx()
+ remaining_size = self.width - x
+ self.addnstr(' '*remaining_size, remaining_size,
+ to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self._refresh()
+
+class VerticalGlobalInfoBar(Win):
+ def __init__(self, scr):
+ Win.__init__(self)
+ self._win = scr
+
+ def refresh(self):
+ height, width = self._win.getmaxyx()
+ self._win.erase()
+ sorted_tabs = [tab for tab in self.core.tabs if tab]
+ if not config.get('show_inactive_tabs'):
+ sorted_tabs = [tab for tab in sorted_tabs if\
+ tab.vertical_color != get_theme().COLOR_VERTICAL_TAB_NORMAL]
+ nb_tabs = len(sorted_tabs)
+ use_nicks = config.get('use_tab_nicks')
+ if nb_tabs >= height:
+ for y, tab in enumerate(sorted_tabs):
+ if tab.vertical_color == get_theme().COLOR_VERTICAL_TAB_CURRENT:
+ pos = y
+ break
+ # center the current tab as much as possible
+ if pos < height//2:
+ sorted_tabs = sorted_tabs[:height]
+ elif nb_tabs - pos <= height//2:
+ sorted_tabs = sorted_tabs[-height:]
+ else:
+ sorted_tabs = sorted_tabs[pos-height//2 : pos+height//2]
+ asc_sort = (config.get('vertical_tab_list_sort') == 'asc')
+ for y, tab in enumerate(sorted_tabs):
+ color = tab.vertical_color
+ if asc_sort:
+ y = height - y - 1
+ self.addstr(y, 0, "%2d" % tab.nb,
+ to_curses_attr(get_theme().COLOR_VERTICAL_TAB_NUMBER))
+ self.addstr('.')
+ if use_nicks:
+ self.addnstr("%s" % tab.get_nick(), width - 4, to_curses_attr(color))
+ else:
+ self.addnstr("%s" % tab.name, width - 4, to_curses_attr(color))
+ separator = to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)
+ self._win.attron(separator)
+ self._win.vline(0, width-1, curses.ACS_VLINE, height)
+ self._win.attroff(separator)
+ self._refresh()
diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py
new file mode 100644
index 00000000..f6aebd35
--- /dev/null
+++ b/poezio/windows/info_wins.py
@@ -0,0 +1,311 @@
+"""
+Module defining all the "info wins", ie the bar which is on top of the
+info buffer in normal tabs
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+from common import safeJID
+from config import config
+
+from . import Win
+from . funcs import truncate_nick
+from theming import get_theme, to_curses_attr
+
+class InfoWin(Win):
+ """
+ Base class for all the *InfoWin, used in various tabs. For example
+ MucInfoWin, etc. Provides some useful methods.
+ """
+ def __init__(self):
+ Win.__init__(self)
+
+ def print_scroll_position(self, window):
+ """
+ Print, like in Weechat, a -MORE(n)- where n
+ is the number of available lines to scroll
+ down
+ """
+ if window.pos > 0:
+ plus = ' -MORE(%s)-' % window.pos
+ self.addstr(plus, to_curses_attr(get_theme().COLOR_SCROLLABLE_NUMBER))
+
+class XMLInfoWin(InfoWin):
+ """
+ Info about the latest xml filter used and the state of the buffer.
+ """
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, filter_t='', filter='', window=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ bar = to_curses_attr(get_theme().COLOR_INFORMATION_BAR)
+ if not filter_t:
+ self.addstr('[No filter]', bar)
+ else:
+ info = '[%s] %s' % (filter_t, filter)
+ self.addstr(info, bar)
+ self.print_scroll_position(window)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+class PrivateInfoWin(InfoWin):
+ """
+ The line above the information window, displaying informations
+ about the MUC user we are talking to
+ """
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, name, window, chatstate, informations):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ self.write_room_name(name)
+ self.print_scroll_position(window)
+ self.write_chatstate(chatstate)
+ self.write_additional_informations(informations, name)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def write_additional_informations(self, informations, jid):
+ """
+ Write all informations added by plugins by getting the
+ value returned by the callbacks.
+ """
+ for key in informations:
+ self.addstr(informations[key](jid), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_room_name(self, name):
+ jid = safeJID(name)
+ room_name, nick = jid.bare, jid.resource
+ self.addstr(nick, to_curses_attr(get_theme().COLOR_PRIVATE_NAME))
+ txt = ' from room %s' % room_name
+ self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_chatstate(self, state):
+ if state:
+ self.addstr(' %s' % (state,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+class MucListInfoWin(InfoWin):
+ """
+ The live above the information window, displaying informations
+ about the muc server being listed
+ """
+ def __init__(self, message=''):
+ InfoWin.__init__(self)
+ self.message = message
+
+ def refresh(self, name=None, window=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ if name:
+ self.addstr(name, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ else:
+ self.addstr(self.message, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ if window:
+ self.print_scroll_position(window)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+class ConversationInfoWin(InfoWin):
+ """
+ The line above the information window, displaying informations
+ about the user we are talking to
+ """
+
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, jid, contact, window, chatstate, informations):
+ # contact can be None, if we receive a message
+ # from someone not in our roster. In this case, we display
+ # only the maximum information from the message we can get.
+ log.debug('Refresh: %s', self.__class__.__name__)
+ jid = safeJID(jid)
+ if contact:
+ if jid.resource:
+ resource = contact[jid.full]
+ else:
+ resource = contact.get_highest_priority_resource()
+ else:
+ resource = None
+ # if contact is None, then resource is None too:
+ # user is not in the roster so we know almost nothing about it
+ # If contact is a Contact, then
+ # resource can now be a Resource: user is in the roster and online
+ # or resource is None: user is in the roster but offline
+ self._win.erase()
+ if config.get('show_jid_in_conversations'):
+ self.write_contact_jid(jid)
+ self.write_contact_informations(contact)
+ self.write_resource_information(resource)
+ self.print_scroll_position(window)
+ self.write_chatstate(chatstate)
+ self.write_additional_informations(informations, jid)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def write_additional_informations(self, informations, jid):
+ """
+ Write all informations added by plugins by getting the
+ value returned by the callbacks.
+ """
+ for key in informations:
+ self.addstr(informations[key](jid),
+ to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_resource_information(self, resource):
+ """
+ Write the informations about the resource
+ """
+ if not resource:
+ presence = "unavailable"
+ else:
+ presence = resource.presence
+ color = get_theme().color_show(presence)
+ self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(get_theme().CHAR_STATUS, to_curses_attr(color))
+ self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_contact_informations(self, contact):
+ """
+ Write the informations about the contact
+ """
+ if not contact:
+ self.addstr("(contact not in roster)", to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ return
+ display_name = contact.name
+ if display_name:
+ self.addstr('%s '%(display_name), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_contact_jid(self, jid):
+ """
+ Just write the jid that we are talking to
+ """
+ self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(jid.full, to_curses_attr(get_theme().COLOR_CONVERSATION_NAME))
+ self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_chatstate(self, state):
+ if state:
+ self.addstr(' %s' % (state,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+class DynamicConversationInfoWin(ConversationInfoWin):
+ def write_contact_jid(self, jid):
+ """
+ Just displays the resource in an other color
+ """
+ log.debug("write_contact_jid DynamicConversationInfoWin, jid: %s",
+ jid.resource)
+ self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(jid.bare, to_curses_attr(get_theme().COLOR_CONVERSATION_NAME))
+ if jid.resource:
+ self.addstr("/%s" % (jid.resource,), to_curses_attr(get_theme().COLOR_CONVERSATION_RESOURCE))
+ self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+class MucInfoWin(InfoWin):
+ """
+ The line just above the information window, displaying informations
+ about the MUC we are viewing
+ """
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, room, window=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ self.write_room_name(room)
+ self.write_participants_number(room)
+ self.write_own_nick(room)
+ self.write_disconnected(room)
+ self.write_role(room)
+ if window:
+ self.print_scroll_position(window)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def write_room_name(self, room):
+ self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(room.name, to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME))
+ self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_participants_number(self, room):
+ self.addstr('{', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(str(len(room.users)), to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME))
+ self.addstr('} ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_disconnected(self, room):
+ """
+ Shows a message if the room is not joined
+ """
+ if not room.joined:
+ self.addstr(' -!- Not connected ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_own_nick(self, room):
+ """
+ Write our own nick in the info bar
+ """
+ nick = room.own_nick
+ if not nick:
+ return
+ self.addstr(truncate_nick(nick, 13), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+ def write_role(self, room):
+ """
+ Write our own role and affiliation
+ """
+ own_user = None
+ for user in room.users:
+ if user.nick == room.own_nick:
+ own_user = user
+ break
+ if not own_user:
+ return
+ txt = ' ('
+ if own_user.affiliation != 'none':
+ txt += own_user.affiliation+', '
+ txt += own_user.role+')'
+ self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+class ConversationStatusMessageWin(InfoWin):
+ """
+ The upper bar displaying the status message of the contact
+ """
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, jid, contact):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ jid = safeJID(jid)
+ if contact:
+ if jid.resource:
+ resource = contact[jid.full]
+ else:
+ resource = contact.get_highest_priority_resource()
+ else:
+ resource = None
+ self._win.erase()
+ if resource:
+ self.write_status_message(resource)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def write_status_message(self, resource):
+ self.addstr(resource.status, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
+class BookmarksInfoWin(InfoWin):
+ def __init__(self):
+ InfoWin.__init__(self)
+
+ def refresh(self, preferred):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ self.write_remote_status(preferred)
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def write_remote_status(self, preferred):
+ self.addstr('Remote storage: %s' % preferred, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+
diff --git a/poezio/windows/input_placeholders.py b/poezio/windows/input_placeholders.py
new file mode 100644
index 00000000..496417d1
--- /dev/null
+++ b/poezio/windows/input_placeholders.py
@@ -0,0 +1,77 @@
+"""
+Classes used to replace the input in some tabs or special situations,
+but which are not inputs.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+
+from . import Win
+from theming import get_theme, to_curses_attr
+
+
+class HelpText(Win):
+ """
+ A Window just displaying a read-only message.
+ Usually used to replace an Input when the tab is in
+ command mode.
+ """
+ def __init__(self, text=''):
+ Win.__init__(self)
+ self.txt = text
+
+ def refresh(self, txt=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ if txt:
+ self.txt = txt
+ self._win.erase()
+ self.addstr(0, 0, self.txt[:self.width-1], to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self._refresh()
+
+ def do_command(self, key, raw=False):
+ return False
+
+ def on_delete(self):
+ return
+
+class YesNoInput(Win):
+ """
+ A Window just displaying a Yes/No input
+ Used to ask a confirmation
+ """
+ def __init__(self, text='', callback=None):
+ Win.__init__(self)
+ self.key_func = {
+ 'y' : self.on_yes,
+ 'n' : self.on_no,
+ }
+ self.txt = text
+ self.value = None
+ self.callback = callback
+
+ def on_yes(self):
+ self.value = True
+
+ def on_no(self):
+ self.value = False
+
+ def refresh(self, txt=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ if txt:
+ self.txt = txt
+ self._win.erase()
+ self.addstr(0, 0, self.txt[:self.width-1], to_curses_attr(get_theme().COLOR_WARNING_PROMPT))
+ self.finish_line(get_theme().COLOR_WARNING_PROMPT)
+ self._refresh()
+
+ def do_command(self, key, raw=False):
+ if key.lower() in self.key_func:
+ self.key_func[key]()
+ if self.value is not None and self.callback is not None:
+ return self.callback()
+
+ def on_delete(self):
+ return
+
diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py
new file mode 100644
index 00000000..80f0c900
--- /dev/null
+++ b/poezio/windows/inputs.py
@@ -0,0 +1,768 @@
+"""
+Text inputs.
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+import string
+
+import keyboard
+import common
+import poopt
+from . import Win
+from . base_wins import format_chars
+from . funcs import find_first_format_char
+from config import config
+from theming import to_curses_attr
+
+
+class Input(Win):
+ """
+ The simplest Input possible, provides just a way to edit a single line
+ of text. It also has a clipboard, common to all Inputs.
+ Doesn't have any history.
+ It doesn't do anything when enter is pressed either.
+ This should be herited for all kinds of Inputs, for example MessageInput
+ or the little inputs in dataforms, etc, adding specific features (completion etc)
+ It features two kinds of completion, but they have to be called from outside (the Tab),
+ passing the list of items that can be used to complete. The completion can be used
+ in a very flexible way.
+ """
+ text_attributes = ['b', 'o', 'u', '1', '2', '3', '4', '5', '6', '7', 't']
+ clipboard = '' # A common clipboard for all the inputs, this makes
+ # it easy cut and paste text between various input
+ def __init__(self):
+ self.key_func = {
+ "KEY_LEFT": self.key_left,
+ "KEY_RIGHT": self.key_right,
+ "KEY_END": self.key_end,
+ "KEY_HOME": self.key_home,
+ "KEY_DC": self.key_dc,
+ '^D': self.key_dc,
+ 'M-b': self.jump_word_left,
+ "M-[1;5D": self.jump_word_left,
+ "kRIT5": self.jump_word_right,
+ "kLFT5": self.jump_word_left,
+ '^W': self.delete_word,
+ 'M-d': self.delete_next_word,
+ '^K': self.delete_end_of_line,
+ '^U': self.delete_beginning_of_line,
+ '^Y': self.paste_clipboard,
+ '^A': self.key_home,
+ '^E': self.key_end,
+ 'M-f': self.jump_word_right,
+ "M-[1;5C": self.jump_word_right,
+ "KEY_BACKSPACE": self.key_backspace,
+ "M-KEY_BACKSPACE": self.delete_word,
+ '^?': self.key_backspace,
+ "M-^?": self.delete_word,
+ # '^J': self.add_line_break,
+ }
+ Win.__init__(self)
+ self.text = ''
+ self.pos = 0 # The position of the “cursor” in the text
+ # (not only in the view)
+ self.view_pos = 0 # The position (in the text) of the
+ # first character displayed on the
+ # screen
+ self.on_input = None # callback called on any key pressed
+ self.color = None # use this color on addstr
+
+ def on_delete(self):
+ """
+ Remove all references kept to a tab, so that the tab
+ can be garbage collected
+ """
+ del self.key_func
+
+ def set_color(self, color):
+ self.color = color
+ self.rewrite_text()
+
+ def is_empty(self):
+ if self.text:
+ return False
+ return True
+
+ def is_cursor_at_end(self):
+ """
+ Whether or not the cursor is at the end of the text.
+ """
+ assert len(self.text) >= self.pos
+ if len(self.text) == self.pos:
+ return True
+ return False
+
+ def jump_word_left(self):
+ """
+ Move the cursor one word to the left
+ """
+ if self.pos == 0:
+ return True
+ separators = string.punctuation+' '
+ while self.pos > 0 and self.text[self.pos-1] in separators:
+ self.key_left()
+ while self.pos > 0 and self.text[self.pos-1] not in separators:
+ self.key_left()
+ return True
+
+ def jump_word_right(self):
+ """
+ Move the cursor one word to the right
+ """
+ if self.is_cursor_at_end():
+ return True
+ separators = string.punctuation+' '
+ while not self.is_cursor_at_end() and self.text[self.pos] in separators:
+ self.key_right()
+ while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
+ self.key_right()
+ return True
+
+ def delete_word(self):
+ """
+ Delete the word just before the cursor
+ """
+ separators = string.punctuation+' '
+ while self.pos > 0 and self.text[self.pos-1] in separators:
+ self.key_backspace()
+ while self.pos > 0 and self.text[self.pos-1] not in separators:
+ self.key_backspace()
+ return True
+
+ def delete_next_word(self):
+ """
+ Delete the word just after the cursor
+ """
+ separators = string.punctuation+' '
+ while not self.is_cursor_at_end() and self.text[self.pos] in separators:
+ self.key_dc()
+ while not self.is_cursor_at_end() and self.text[self.pos] not in separators:
+ self.key_dc()
+ return True
+
+ def delete_end_of_line(self):
+ """
+ Cut the text from cursor to the end of line
+ """
+ if self.is_cursor_at_end():
+ return False
+ Input.clipboard = self.text[self.pos:]
+ self.text = self.text[:self.pos]
+ self.key_end()
+ return True
+
+ def delete_beginning_of_line(self):
+ """
+ Cut the text from cursor to the beginning of line
+ """
+ if self.pos == 0:
+ return True
+ Input.clipboard = self.text[:self.pos]
+ self.text = self.text[self.pos:]
+ self.key_home()
+ return True
+
+ def paste_clipboard(self):
+ """
+ Insert what is in the clipboard at the cursor position
+ """
+ if not Input.clipboard:
+ return True
+ for letter in Input.clipboard:
+ self.do_command(letter, False)
+ self.rewrite_text()
+ return True
+
+ def key_dc(self):
+ """
+ delete char just after the cursor
+ """
+ self.reset_completion()
+ if self.is_cursor_at_end():
+ return True # end of line, nothing to delete
+ self.text = self.text[:self.pos]+self.text[self.pos+1:]
+ self.rewrite_text()
+ return True
+
+ def key_home(self):
+ """
+ Go to the beginning of line
+ """
+ self.reset_completion()
+ self.pos = 0
+ self.rewrite_text()
+ return True
+
+ def key_end(self, reset=False):
+ """
+ Go to the end of line
+ """
+ if reset:
+ self.reset_completion()
+ self.pos = len(self.text)
+ assert self.is_cursor_at_end()
+ self.rewrite_text()
+ return True
+
+ def key_left(self, jump=True, reset=True):
+ """
+ Move the cursor one char to the left
+ """
+ if reset:
+ self.reset_completion()
+ if self.pos == 0:
+ return True
+ self.pos -= 1
+ if reset:
+ self.rewrite_text()
+ return True
+
+ def key_right(self, jump=True, reset=True):
+ """
+ Move the cursor one char to the right
+ """
+ if reset:
+ self.reset_completion()
+ if self.is_cursor_at_end():
+ return True
+ self.pos += 1
+ if reset:
+ self.rewrite_text()
+ return True
+
+ def key_backspace(self, reset=True):
+ """
+ Delete the char just before the cursor
+ """
+ self.reset_completion()
+ if self.pos == 0:
+ return
+ self.key_left()
+ self.key_dc()
+ return True
+
+ def auto_completion(self, word_list, add_after='', quotify=True):
+ """
+ Complete the input, from a list of words
+ if add_after is None, we use the value defined in completion
+ plus a space, after the completion. If it's a string, we use it after the
+ completion (with no additional space)
+ """
+ if quotify:
+ for i, word in enumerate(word_list[:]):
+ word_list[i] = '"' + word + '"'
+ self.normal_completion(word_list, add_after)
+ return True
+
+ def new_completion(self, word_list, argument_position=-1, add_after='', quotify=True, override=False):
+ """
+ Complete the argument at position ``argument_postion`` in the input.
+ If ``quotify`` is ``True``, then the completion will operate on block of words
+ (e.g. "toto titi") whereas if it is ``False``, it will operate on words (e.g
+ "toto", "titi").
+
+ The completions may modify other parts of the input when completing an argument,
+ for example removing useless double quotes around single-words, or setting the
+ space between each argument to only one space.
+
+ The case where we complete the first argument is special, because we complete
+ the command, and we do not want to modify anything else in the input.
+
+ This method is the one that should be used if the command being completed
+ has several arguments.
+ """
+ if argument_position == 0:
+ self._new_completion_first(word_list)
+ else:
+ self._new_completion_args(word_list, argument_position, add_after, quotify, override)
+ self.rewrite_text()
+ return True
+
+ def _new_completion_args(self, word_list, argument_position=-1, add_after='', quoted=True, override=False):
+ """
+ Case for completing arguments with position ≠ 0
+ """
+ if quoted:
+ words = common.shell_split(self.text)
+ else:
+ words = self.text.split()
+ if argument_position >= len(words):
+ current = ''
+ else:
+ current = words[argument_position]
+
+ if quoted:
+ split_words = words[1:]
+ words = [words[0]]
+ for word in split_words:
+ if ' ' in word or '\\' in word:
+ words.append('"' + word + '"')
+ else:
+ words.append(word)
+ current_l = current.lower()
+ if self.last_completion is not None:
+ self.hit_list.append(self.hit_list.pop(0))
+ else:
+ if override:
+ hit_list = word_list
+ else:
+ hit_list = []
+ for word in word_list:
+ if word.lower().startswith(current_l):
+ hit_list.append(word)
+ if not hit_list:
+ return
+ self.hit_list = hit_list
+
+ if argument_position >= len(words):
+ if quoted and ' ' in self.hit_list[0]:
+ words.append('"'+self.hit_list[0]+'"')
+ else:
+ words.append(self.hit_list[0])
+ else:
+ if quoted and ' ' in self.hit_list[0]:
+ words[argument_position] = '"'+self.hit_list[0]+'"'
+ else:
+ words[argument_position] = self.hit_list[0]
+
+ new_pos = -1
+ for i, word in enumerate(words):
+ if argument_position >= i:
+ new_pos += len(word) + 1
+
+ self.last_completion = self.hit_list[0]
+ self.text = words[0] + ' ' + ' '.join(words[1:])
+ self.pos = new_pos
+
+ def _new_completion_first(self, word_list):
+ """
+ Special case of completing the command itself:
+ we don’t want to change anything to the input doing that
+ """
+ space_pos = self.text.find(' ')
+ if space_pos != -1:
+ current, follow = self.text[:space_pos], self.text[space_pos:]
+ else:
+ current, follow = self.text, ''
+
+ if self.last_completion:
+ self.hit_list.append(self.hit_list.pop(0))
+ else:
+ hit_list = []
+ for word in word_list:
+ if word.lower().startswith(current):
+ hit_list.append(word)
+ if not hit_list:
+ return
+ self.hit_list = hit_list
+
+ self.last_completion = self.hit_list[0]
+ self.text = self.hit_list[0] + follow
+ self.pos = len(self.hit_list[0])
+
+ def get_argument_position(self, quoted=True):
+ """
+ Get the argument number at the current position
+ """
+ command_stop = self.text.find(' ')
+ if command_stop == -1 or self.pos <= command_stop:
+ return 0
+ text = self.text[command_stop+1:]
+ pos = self.pos - len(self.text) + len(text) - 1
+ val = common.find_argument(pos, text, quoted=quoted) + 1
+ return val
+
+ def reset_completion(self):
+ """
+ Reset the completion list (called on ALL keys except tab)
+ """
+ self.hit_list = []
+ self.last_completion = None
+
+ def normal_completion(self, word_list, after):
+ """
+ Normal completion
+ """
+ pos = self.pos
+ if pos < len(self.text) and after.endswith(' ') and self.text[pos] == ' ':
+ after = after[:-1] # remove the last space if we are already on a space
+ if not self.last_completion:
+ space_before_cursor = self.text.rfind(' ', 0, pos)
+ if space_before_cursor != -1:
+ begin = self.text[space_before_cursor+1:pos]
+ else:
+ begin = self.text[:pos]
+ hit_list = [] # list of matching hits
+ for word in word_list:
+ if word.lower().startswith(begin.lower()):
+ hit_list.append(word)
+ elif word.startswith('"') and word.lower()[1:].startswith(begin.lower()):
+ hit_list.append(word)
+ if len(hit_list) == 0:
+ return
+ self.hit_list = hit_list
+ end = len(begin)
+ else:
+ begin = self.last_completion
+ end = len(begin) + len(after)
+ self.hit_list.append(self.hit_list.pop(0)) # rotate list
+
+ self.text = self.text[:pos-end] + self.text[pos:]
+ pos -= end
+ hit = self.hit_list[0] # take the first hit
+ self.text = self.text[:pos] + hit + after + self.text[pos:]
+ for _ in range(end):
+ try:
+ self.key_left(reset=False)
+ except:
+ pass
+ for _ in range(len(hit) + len(after)):
+ self.key_right(reset=False)
+
+ self.rewrite_text()
+ self.last_completion = hit
+
+ def do_command(self, key, reset=True, raw=False):
+ if key in self.key_func:
+ res = self.key_func[key]()
+ if not raw and self.on_input:
+ self.on_input(self.get_text())
+ return res
+ if not raw and (not key or len(key) > 1):
+ return False # ignore non-handled keyboard shortcuts
+ if reset:
+ self.reset_completion()
+ # Insert the char at the cursor position
+ self.text = self.text[:self.pos]+key+self.text[self.pos:]
+ self.pos += len(key)
+ if reset:
+ self.rewrite_text()
+ if self.on_input:
+ self.on_input(self.get_text())
+
+ return True
+
+ def add_line_break(self):
+ """
+ Add a (real) \n to the line
+ """
+ self.do_command('\n')
+
+ def get_text(self):
+ """
+ Return the text entered so far
+ """
+ return self.text
+
+ def addstr_colored_lite(self, text, y=None, x=None):
+ """
+ Just like addstr_colored, with the single-char attributes
+ (\x0E to \x19 instead of \x19 + attr). We do not use any }
+ char in this version
+ """
+ chars = format_chars[:]
+ chars.append('\n')
+ if y is not None and x is not None:
+ self.move(y, x)
+ format_char = find_first_format_char(text, chars)
+ while format_char != -1:
+ if text[format_char] == '\n':
+ attr_char = '|'
+ else:
+ attr_char = self.text_attributes[
+ format_chars.index(text[format_char])]
+ self.addstr(text[:format_char])
+ self.addstr(attr_char, curses.A_REVERSE)
+ text = text[format_char+1:]
+ if attr_char == 'o':
+ self._win.attrset(0)
+ elif attr_char == 'u':
+ self._win.attron(curses.A_UNDERLINE)
+ elif attr_char == 'b':
+ self._win.attron(curses.A_BOLD)
+ elif attr_char in string.digits and attr_char != '':
+ self._win.attron(to_curses_attr((int(attr_char), -1)))
+ format_char = find_first_format_char(text, chars)
+ self.addstr(text)
+
+ def rewrite_text(self):
+ """
+ Refresh the line onscreen, but first, always adjust the
+ view_pos. Also, each FORMAT_CHAR+attr_char count only take
+ one screen column (this is done in addstr_colored_lite), we
+ have to do some special calculations to find the correct
+ length of text to display, and the position of the cursor.
+ """
+ self.adjust_view_pos()
+ text = self.text
+ self._win.erase()
+ if self.color:
+ self._win.attron(to_curses_attr(self.color))
+ displayed_text = text[self.view_pos:self.view_pos+self.width-1].replace('\t', '\x18')
+ self._win.attrset(0)
+ self.addstr_colored_lite(displayed_text)
+ # Fill the rest of the line with the input color
+ if self.color:
+ (_, x) = self._win.getyx()
+ size = self.width - x
+ self.addnstr(' ' * size, size, to_curses_attr(self.color))
+ self.addstr(0,
+ poopt.wcswidth(displayed_text[:self.pos-self.view_pos]), '')
+ if self.color:
+ self._win.attroff(to_curses_attr(self.color))
+ curses.curs_set(1)
+ self._refresh()
+
+ def adjust_view_pos(self):
+ """
+ Adjust the position of the View, if needed (for example if the
+ cursor moved and would now be out of the view, we adapt the
+ view_pos so that we can always see our cursor)
+ """
+ # start of the input
+ if self.pos == 0:
+ self.view_pos = 0
+ return
+ # cursor outside of the screen (left)
+ if self.pos <= self.view_pos:
+ self.view_pos = self.pos - max(1 * self.width // 3, 1)
+ # cursor outside of the screen (right)
+ elif self.pos >= self.view_pos + self.width - 1:
+ self.view_pos = self.pos - max(2 * self.width // 3, 2)
+
+ if self.view_pos < 0:
+ self.view_pos = 0
+
+ # text small enough to fit inside the window entirely:
+ # remove scrolling if present
+ if poopt.wcswidth(self.text) < self.width:
+ self.view_pos = 0
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self.rewrite_text()
+
+ def clear_text(self):
+ self.text = ''
+ self.pos = 0
+ self.rewrite_text()
+
+ def key_enter(self):
+ txt = self.get_text()
+ self.clear_text()
+ return txt
+
+class HistoryInput(Input):
+ """
+ An input with colors and stuff, plus an history
+ ^R allows to search inside the history (as in a shell)
+ """
+ history = list()
+
+ def __init__(self):
+ Input.__init__(self)
+ self.help_message = ''
+ self.current_completed = ''
+ self.key_func['^R'] = self.toggle_search
+ self.search = False
+ if config.get('separate_history'):
+ self.history = list()
+
+ def toggle_search(self):
+ if self.help_message:
+ return
+ self.search = not self.search
+ self.refresh()
+
+ def update_completed(self):
+ """
+ Find a match for the current text
+ """
+ if not self.text:
+ return
+ for i in self.history:
+ if self.text in i:
+ self.current_completed = i
+ return
+ self.current_completed = ''
+
+ def history_enter(self):
+ """
+ Enter was pressed, set the text to the
+ current completion and disable history
+ search
+ """
+ if self.search:
+ self.search = False
+ if self.current_completed:
+ self.text = self.current_completed
+ self.current_completed = ''
+ self.refresh()
+ return True
+ self.refresh()
+ return False
+
+ def key_up(self):
+ """
+ Get the previous line in the history
+ """
+ self.reset_completion()
+ if self.histo_pos == -1 and self.get_text():
+ if not self.history or self.history[0] != self.get_text():
+ # add the message to history, we do not want to lose it
+ self.history.insert(0, self.get_text())
+ self.histo_pos += 1
+ if self.histo_pos < len(self.history) - 1:
+ self.histo_pos += 1
+ self.text = self.history[self.histo_pos]
+ self.key_end()
+ return True
+
+ def key_down(self):
+ """
+ Get the next line in the history
+ """
+ self.reset_completion()
+ if self.histo_pos > 0:
+ self.histo_pos -= 1
+ self.text = self.history[self.histo_pos]
+ elif self.histo_pos <= 0 and self.get_text():
+ if not self.history or self.history[0] != self.get_text():
+ # add the message to history, we do not want to lose it
+ self.history.insert(0, self.get_text())
+ self.text = ''
+ self.histo_pos = -1
+ self.key_end()
+ return True
+
+class MessageInput(HistoryInput):
+ """
+ The input featuring history and that is being used in
+ Conversation, Muc and Private tabs
+ Also letting the user enter colors or other text markups
+ """
+ history = list() # The history is common to all MessageInput
+
+ def __init__(self):
+ HistoryInput.__init__(self)
+ self.last_completion = None
+ self.histo_pos = -1
+ self.key_func["KEY_UP"] = self.key_up
+ self.key_func["M-A"] = self.key_up
+ self.key_func["KEY_DOWN"] = self.key_down
+ self.key_func["M-B"] = self.key_down
+ self.key_func['^C'] = self.enter_attrib
+
+ def enter_attrib(self):
+ """
+ Read one more char (c), add the corresponding char from formats_char to the text string
+ """
+ def cb(attr_char):
+ if attr_char in self.text_attributes:
+ char = format_chars[self.text_attributes.index(attr_char)]
+ self.do_command(char, False)
+ self.rewrite_text()
+ keyboard.continuation_keys_callback = cb
+
+ def key_enter(self):
+ if self.history_enter():
+ return
+
+ txt = self.get_text()
+ if len(txt) != 0:
+ if not self.history or self.history[0] != txt:
+ # add the message to history, but avoid duplicates
+ self.history.insert(0, txt)
+ self.histo_pos = -1
+ self.clear_text()
+ return txt
+
+class CommandInput(HistoryInput):
+ """
+ An input with an help message in the left, with three given callbacks:
+ one when when successfully 'execute' the command and when we abort it.
+ The last callback is optional and is called on any input key
+ This input is used, for example, in the RosterTab when, to replace the
+ HelpMessage when a command is started
+ The on_input callback
+ """
+ history = list()
+
+ def __init__(self, help_message, on_abort, on_success, on_input=None):
+ HistoryInput.__init__(self)
+ self.on_abort = on_abort
+ self.on_success = on_success
+ self.on_input = on_input
+ self.help_message = help_message
+ self.key_func['^M'] = self.success
+ self.key_func['^G'] = self.abort
+ self.key_func['^C'] = self.abort
+ self.key_func["KEY_UP"] = self.key_up
+ self.key_func["M-A"] = self.key_up
+ self.key_func["KEY_DOWN"] = self.key_down
+ self.key_func["M-B"] = self.key_down
+ self.histo_pos = -1
+
+ def do_command(self, key, refresh=True, raw=False):
+ res = Input.do_command(self, key, refresh, raw)
+ if self.on_input:
+ self.on_input(self.get_text())
+ return res
+
+ def disable_history(self):
+ """
+ Disable the history (up/down) keys
+ """
+ if 'KEY_UP' in self.key_func:
+ del self.key_func['KEY_UP']
+ if 'KEY_DOWN' in self.key_func:
+ del self.key_func['KEY_DOWN']
+
+ @property
+ def history_disabled(self):
+ return 'KEY_UP' not in self.key_func and 'KEY_DOWN' not in self.key_func
+
+ def success(self):
+ """
+ call the success callback, passing the text as argument
+ """
+ self.on_input = None
+ if self.search:
+ self.history_enter()
+ res = self.on_success(self.get_text())
+ return res
+
+ def abort(self):
+ """
+ Call the abort callback, passing the text as argument
+ """
+ self.on_input = None
+ return self.on_abort(self.get_text())
+
+ def on_delete(self):
+ """
+ SERIOUSLY BIG WTF.
+
+ I can do
+ self.key_func.clear()
+
+ but not
+ del self.key_func
+ because that would raise an AttributeError exception. WTF.
+ """
+ self.on_abort = None
+ self.on_success = None
+ self.on_input = None
+ self.key_func.clear()
+
+ def key_enter(self):
+ txt = self.get_text()
+ if len(txt) != 0:
+ if not self.history or self.history[0] != txt:
+ # add the message to history, but avoid duplicates
+ self.history.insert(0, txt)
+ self.histo_pos = -1
+
diff --git a/poezio/windows/list.py b/poezio/windows/list.py
new file mode 100644
index 00000000..677df6ff
--- /dev/null
+++ b/poezio/windows/list.py
@@ -0,0 +1,236 @@
+"""
+Windows relevant for the listing tabs, not much else
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+from . import Win
+from theming import to_curses_attr, get_theme
+
+
+class ListWin(Win):
+ """
+ A list (with no depth, so not for the roster) that can be
+ scrolled up and down, with one selected line at a time
+ """
+ def __init__(self, columns, with_headers=True):
+ Win.__init__(self)
+ self._columns = columns # a dict {'column_name': tuple_index}
+ self._columns_sizes = {} # a dict {'column_name': size}
+ self.sorted_by = (None, None) # for example: ('name', '↑')
+ self.lines = [] # a list of dicts
+ self._selected_row = 0
+ self._starting_pos = 0 # The column number from which we start the refresh
+
+ @property
+ def pos(self):
+ if len(self.lines) > self.height:
+ return len(self.lines)
+ else:
+ return 0
+
+ def empty(self):
+ """
+ emtpy the list and reset some important values as well
+ """
+ self.lines = []
+ self._selected_row = 0
+ self._starting_pos = 0
+
+ def resize_columns(self, dic):
+ """
+ Resize the width of the columns
+ """
+ self._columns_sizes = dic
+
+ def sort_by_column(self, col_name, asc=True):
+ """
+ Sort the list by the given column, ascendant or descendant
+ """
+ if not col_name:
+ return
+ elif asc:
+ self.lines.sort(key=lambda x: x[self._columns[col_name]])
+ else:
+ self.lines.sort(key=lambda x: x[self._columns[col_name]],
+ reverse=True)
+ self.refresh()
+ curses.doupdate()
+
+ def add_lines(self, lines):
+ """
+ Append some lines at the end of the list
+ """
+ if not lines:
+ return
+ self.lines.extend(lines)
+
+ def set_lines(self, lines):
+ """
+ Set the lines to another list
+ """
+ if not lines:
+ return
+ self.lines = lines
+
+ def get_selected_row(self):
+ """
+ Return the tuple representing the selected row
+ """
+ if self._selected_row is not None and self.lines:
+ return self.lines[self._selected_row]
+ return None
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ lines = self.lines[self._starting_pos:self._starting_pos+self.height]
+ for y, line in enumerate(lines):
+ x = 0
+ for col in self._columns.items():
+ try:
+ txt = line[col[1]] or ''
+ except KeyError:
+ txt = ''
+ size = self._columns_sizes[col[0]]
+ txt += ' ' * (size-len(txt))
+ if not txt:
+ continue
+ if line is self.lines[self._selected_row]:
+ self.addstr(y, x, txt[:size],
+ to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ else:
+ self.addstr(y, x, txt[:size])
+ x += size
+ self._refresh()
+
+ def move_cursor_down(self):
+ """
+ Move the cursor Down
+ """
+ if not self.lines:
+ return
+ if self._selected_row < len(self.lines) - 1:
+ self._selected_row += 1
+ while self._selected_row >= self._starting_pos + self.height:
+ self._starting_pos += self.height // 2
+ if self._starting_pos < 0:
+ self._starting_pos = 0
+ return True
+
+ def move_cursor_up(self):
+ """
+ Move the cursor Up
+ """
+ if not self.lines:
+ return
+ if self._selected_row > 0:
+ self._selected_row -= 1
+ while self._selected_row < self._starting_pos:
+ self._starting_pos -= self.height // 2
+ return True
+
+ def scroll_down(self):
+ if not self.lines:
+ return
+ self._selected_row += self.height
+ if self._selected_row > len(self.lines) - 1:
+ self._selected_row = len(self.lines) -1
+ while self._selected_row >= self._starting_pos + self.height:
+ self._starting_pos += self.height // 2
+ if self._starting_pos < 0:
+ self._starting_pos = 0
+ return True
+
+ def scroll_up(self):
+ if not self.lines:
+ return
+ self._selected_row -= self.height + 1
+ if self._selected_row < 0:
+ self._selected_row = 0
+ while self._selected_row < self._starting_pos:
+ self._starting_pos -= self.height // 2
+ return True
+
+class ColumnHeaderWin(Win):
+ """
+ A class displaying the column's names
+ """
+ def __init__(self, columns):
+ Win.__init__(self)
+ self._columns = columns
+ self._columns_sizes = {}
+ self._column_sel = ''
+ self._column_order = ''
+ self._column_order_asc = False
+
+ def resize_columns(self, dic):
+ self._columns_sizes = dic
+
+ def get_columns(self):
+ return self._columns
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ x = 0
+ for col in self._columns:
+ txt = col
+ if col in self._column_order:
+ if self._column_order_asc:
+ txt += get_theme().CHAR_COLUMN_ASC
+ else:
+ txt += get_theme().CHAR_COLUMN_DESC
+ #⇓⇑↑↓⇧⇩▲▼
+ size = self._columns_sizes[col]
+ txt += ' ' * (size-len(txt))
+ if col in self._column_sel:
+ self.addstr(0, x, txt, to_curses_attr(get_theme().COLOR_COLUMN_HEADER_SEL))
+ else:
+ self.addstr(0, x, txt, to_curses_attr(get_theme().COLOR_COLUMN_HEADER))
+ x += size
+ self._refresh()
+
+ def sel_column(self, dic):
+ self._column_sel = dic
+
+ def get_sel_column(self):
+ return self._column_sel
+
+ def set_order(self, order):
+ self._column_order = self._column_sel
+ self._column_order_asc = order
+
+ def get_order(self):
+ if self._column_sel == self._column_order:
+ return self._column_order_asc
+ else:
+ return False
+
+ def sel_column_left(self):
+ if self._column_sel in self._columns:
+ index = self._columns.index(self._column_sel)
+ if index > 1:
+ index = index -1
+ else:
+ index = 0
+ else:
+ index = 0
+ self._column_sel = self._columns[index]
+ self.refresh()
+
+ def sel_column_right(self):
+ if self._column_sel in self._columns:
+ index = self._columns.index(self._column_sel)
+ if index < len(self._columns)-2:
+ index = index +1
+ else:
+ index = len(self._columns) -1
+ else:
+ index = len(self._columns) - 1
+ self._column_sel = self._columns[index]
+ self.refresh()
+
diff --git a/poezio/windows/misc.py b/poezio/windows/misc.py
new file mode 100644
index 00000000..07c91bbd
--- /dev/null
+++ b/poezio/windows/misc.py
@@ -0,0 +1,60 @@
+"""
+Wins that don’t fit any category
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+from . import Win
+from theming import get_theme, to_curses_attr
+
+class VerticalSeparator(Win):
+ """
+ Just a one-column window, with just a line in it, that is
+ refreshed only on resize, but never on refresh, for efficiency
+ """
+ def __init__(self):
+ Win.__init__(self)
+
+ def rewrite_line(self):
+ self._win.vline(0, 0, curses.ACS_VLINE, self.height,
+ to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR))
+ self._refresh()
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self.rewrite_line()
+
+
+class SimpleTextWin(Win):
+ def __init__(self, text):
+ Win.__init__(self)
+ self._text = text
+ self.built_lines = []
+
+ def rebuild_text(self):
+ """
+ Transform the text in lines than can then be
+ displayed without any calculation or anything
+ at refresh() time
+ It is basically called on each resize
+ """
+ self.built_lines = []
+ for line in self._text.split('\n'):
+ while len(line) >= self.width:
+ limit = line[:self.width].rfind(' ')
+ if limit <= 0:
+ limit = self.width
+ self.built_lines.append(line[:limit])
+ line = line[limit:]
+ self.built_lines.append(line)
+
+ def refresh(self):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ for y, line in enumerate(self.built_lines):
+ self.addstr_colored(line, y, 0)
+ self._refresh()
+
diff --git a/poezio/windows/muc.py b/poezio/windows/muc.py
new file mode 100644
index 00000000..84775787
--- /dev/null
+++ b/poezio/windows/muc.py
@@ -0,0 +1,143 @@
+"""
+Windows specific to a MUC
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+
+from . import Win
+
+import poopt
+from config import config
+from theming import to_curses_attr, get_theme
+
+def userlist_to_cache(userlist):
+ result = []
+ for user in userlist:
+ result.append((user.nick, user.status, user.chatstate, user.affiliation, user.role))
+ return result
+
+class UserList(Win):
+ def __init__(self):
+ Win.__init__(self)
+ self.pos = 0
+ self.cache = []
+
+ def scroll_up(self):
+ self.pos += self.height-1
+ return True
+
+ def scroll_down(self):
+ pos = self.pos
+ self.pos -= self.height-1
+ if self.pos < 0:
+ self.pos = 0
+ return self.pos != pos
+
+ def draw_plus(self, y):
+ self.addstr(y, self.width-2, '++', to_curses_attr(get_theme().COLOR_MORE_INDICATOR))
+
+
+ def refresh_if_changed(self, users):
+ old = self.cache
+ new = userlist_to_cache(users[self.pos:self.pos+self.height])
+ if len(old) != len(new):
+ self.cache = new
+ self.refresh(users)
+ return
+ for i in range(len(old)):
+ if old[i] != new[i]:
+ self.cache = new
+ self.refresh(users)
+
+ def refresh(self, users):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ if config.get('hide_user_list'):
+ return # do not refresh if this win is hidden.
+ if len(users) < self.height:
+ self.pos = 0
+ elif self.pos >= len(users) - self.height and self.pos != 0:
+ self.pos = len(users) - self.height
+ self._win.erase()
+ asc_sort = (config.get('user_list_sort').lower() == 'asc')
+ if asc_sort:
+ y, x = self._win.getmaxyx()
+ y -= 1
+ else:
+ y = 0
+
+ for user in users[self.pos:self.pos+self.height]:
+ self.draw_role_affiliation(y, user)
+ self.draw_status_chatstate(y, user)
+ self.addstr(y, 2,
+ poopt.cut_by_columns(user.nick, self.width - 2),
+ to_curses_attr(user.color))
+ if asc_sort:
+ y -= 1
+ else:
+ y += 1
+ if y == self.height:
+ break
+ # draw indicators of position in the list
+ if self.pos > 0:
+ if asc_sort:
+ self.draw_plus(self.height-1)
+ else:
+ self.draw_plus(0)
+ if self.pos + self.height < len(users):
+ if asc_sort:
+ self.draw_plus(0)
+ else:
+ self.draw_plus(self.height-1)
+ self._refresh()
+
+ def draw_role_affiliation(self, y, user):
+ theme = get_theme()
+ color = theme.color_role(user.role)
+ symbol = theme.char_affiliation(user.affiliation)
+ self.addstr(y, 1, symbol, to_curses_attr(color))
+
+ def draw_status_chatstate(self, y, user):
+ show_col = get_theme().color_show(user.show)
+ if user.chatstate == 'composing':
+ char = get_theme().CHAR_CHATSTATE_COMPOSING
+ elif user.chatstate == 'active':
+ char = get_theme().CHAR_CHATSTATE_ACTIVE
+ elif user.chatstate == 'paused':
+ char = get_theme().CHAR_CHATSTATE_PAUSED
+ else:
+ char = get_theme().CHAR_STATUS
+ self.addstr(y, 0, char, to_curses_attr(show_col))
+
+ def resize(self, height, width, y, x):
+ separator = to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)
+ self._resize(height, width, y, x)
+ self._win.attron(separator)
+ self._win.vline(0, 0, curses.ACS_VLINE, self.height)
+ self._win.attroff(separator)
+
+class Topic(Win):
+ def __init__(self):
+ Win.__init__(self)
+ self._message = ''
+
+ def refresh(self, topic=None):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ if topic:
+ msg = topic[:self.width-1]
+ else:
+ msg = self._message[:self.width-1]
+ self.addstr(0, 0, msg, to_curses_attr(get_theme().COLOR_TOPIC_BAR))
+ (y, x) = self._win.getyx()
+ remaining_size = self.width - x
+ if remaining_size:
+ self.addnstr(' '*remaining_size, remaining_size,
+ to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self._refresh()
+
+ def set_message(self, message):
+ self._message = message
+
diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py
new file mode 100644
index 00000000..a2e2badd
--- /dev/null
+++ b/poezio/windows/roster_win.py
@@ -0,0 +1,387 @@
+"""
+Windows used with the roster (window displaying the contacts, and the
+one showing detailed info on the current selection)
+"""
+import logging
+log = logging.getLogger(__name__)
+
+from datetime import datetime
+
+from . import Win
+
+import common
+from config import config
+from contact import Contact, Resource
+from roster import RosterGroup
+from theming import get_theme, to_curses_attr
+
+
+class RosterWin(Win):
+
+ def __init__(self):
+ Win.__init__(self)
+ self.pos = 0 # cursor position in the contact list
+ self.start_pos = 1 # position of the start of the display
+ self.selected_row = None
+ self.roster_cache = []
+
+ @property
+ def roster_len(self):
+ return len(self.roster_cache)
+
+ def move_cursor_down(self, number=1):
+ """
+ Return True if we scrolled, False otherwise
+ """
+ pos = self.pos
+ if self.pos < self.roster_len-number:
+ self.pos += number
+ else:
+ self.pos = self.roster_len - 1
+ if self.pos >= self.start_pos-1 + self.height-1:
+ if number == 1:
+ self.scroll_down(8)
+ else:
+ self.scroll_down(self.pos-self.start_pos - self.height // 2)
+ self.update_pos()
+ return pos != self.pos
+
+ def move_cursor_up(self, number=1):
+ """
+ Return True if we scrolled, False otherwise
+ """
+ pos = self.pos
+ if self.pos-number >= 0:
+ self.pos -= number
+ else:
+ self.pos = 0
+ if self.pos <= self.start_pos:
+ if number == 1:
+ self.scroll_up(8)
+ else:
+ self.scroll_up(self.start_pos-self.pos + self.height // 2)
+ self.update_pos()
+ return pos != self.pos
+
+ def update_pos(self):
+ if len(self.roster_cache) > self.pos and self.pos >= 0:
+ self.selected_row = self.roster_cache[self.pos]
+ elif self.roster_cache:
+ self.selected_row = self.roster_cache[-1]
+
+ def scroll_down(self, number=8):
+ pos = self.start_pos
+ if self.start_pos + number <= self.roster_len-1:
+ self.start_pos += number
+ else:
+ self.start_pos = self.roster_len-1
+ return self.start_pos != pos
+
+ def scroll_up(self, number=8):
+ pos = self.start_pos
+ if self.start_pos - number > 0:
+ self.start_pos -= number
+ else:
+ self.start_pos = 1
+ return self.start_pos != pos
+
+ def build_roster_cache(self, roster):
+ """
+ Regenerates the roster cache if needed
+ """
+ if not roster.needs_rebuild:
+ return
+ log.debug('The roster has changed, rebuilding the cache…')
+ # This is a search
+ if roster.contact_filter:
+ self.roster_cache = []
+ sort = config.get('roster_sort', 'jid:show') or 'jid:show'
+ for contact in roster.get_contacts_sorted_filtered(sort):
+ self.roster_cache.append(contact)
+ else:
+ show_offline = config.get('roster_show_offline') or roster.contact_filter
+ sort = config.get('roster_sort') or 'jid:show'
+ group_sort = config.get('roster_group_sort') or 'name'
+ self.roster_cache = []
+ # build the cache
+ for group in roster.get_groups(group_sort):
+ contacts_filtered = group.get_contacts(roster.contact_filter)
+ if (not show_offline and group.get_nb_connected_contacts() == 0) or not contacts_filtered:
+ continue # Ignore empty groups
+ self.roster_cache.append(group)
+ if group.folded:
+ continue # ignore folded groups
+ for contact in group.get_contacts(roster.contact_filter, sort):
+ if not show_offline and len(contact) == 0:
+ continue # ignore offline contacts
+ self.roster_cache.append(contact)
+ if not contact.folded(group.name):
+ for resource in contact.get_resources():
+ self.roster_cache.append(resource)
+ roster.last_built = datetime.now()
+ if self.selected_row in self.roster_cache:
+ if self.pos < self.roster_len and self.roster_cache[self.pos] != self.selected_row:
+ self.pos = self.roster_cache.index(self.selected_row)
+
+ def refresh(self, roster):
+ """
+ We display a number of lines from the roster cache
+ (and rebuild it if needed)
+ """
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self.build_roster_cache(roster)
+ # make sure we are within bounds
+ self.move_cursor_up((self.roster_len + self.pos) if self.pos >= self.roster_len else 0)
+ if not self.roster_cache:
+ self.selected_row = None
+ self._win.erase()
+ self._win.move(0, 0)
+ self.draw_roster_information(roster)
+ y = 1
+ group = "none"
+ # scroll down if needed
+ if self.start_pos+self.height <= self.pos+2:
+ self.scroll_down(self.pos - self.start_pos - self.height + (self.height//2))
+ # draw the roster from the cache
+ roster_view = self.roster_cache[self.start_pos-1:self.start_pos+self.height]
+
+ options = {
+ 'show_roster_sub': config.get('show_roster_subscriptions'),
+ 'show_s2s_errors': config.get('show_s2s_errors'),
+ 'show_roster_jids': config.get('show_roster_jids')
+ }
+
+ for item in roster_view:
+ draw_selected = False
+ if y -2 + self.start_pos == self.pos:
+ draw_selected = True
+ self.selected_row = item
+
+ if isinstance(item, RosterGroup):
+ self.draw_group(y, item, draw_selected)
+ group = item.name
+ elif isinstance(item, Contact):
+ self.draw_contact_line(y, item, draw_selected, group, **options)
+ elif isinstance(item, Resource):
+ self.draw_resource_line(y, item, draw_selected)
+
+ y += 1
+
+ if self.start_pos > 1:
+ self.draw_plus(1)
+ if self.start_pos + self.height-2 < self.roster_len:
+ self.draw_plus(self.height-1)
+ self._refresh()
+
+
+ def draw_plus(self, y):
+ """
+ Draw the indicator that shows that
+ the list is longer than what is displayed
+ """
+ self.addstr(y, self.width-5, '++++', to_curses_attr(get_theme().COLOR_MORE_INDICATOR))
+
+ def draw_roster_information(self, roster):
+ """
+ The header at the top
+ """
+ self.addstr('Roster: %s/%s contacts' % (
+ roster.get_nb_connected_contacts(),
+ len(roster)),
+ to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+
+ def draw_group(self, y, group, colored):
+ """
+ Draw a groupname on a line
+ """
+ if colored:
+ self._win.attron(to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ if group.folded:
+ self.addstr(y, 0, '[+] ')
+ else:
+ self.addstr(y, 0, '[-] ')
+ contacts = " (%s/%s)" % (group.get_nb_connected_contacts(), len(group))
+ self.addstr(y, 4, self.truncate_name(group.name, len(contacts)+4) + contacts)
+ if colored:
+ self._win.attroff(to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ self.finish_line()
+
+ def truncate_name(self, name, added):
+ if len(name) + added <= self.width:
+ return name
+ return name[:self.width - added - 1] + '…'
+
+ def draw_contact_line(self, y, contact, colored, group, show_roster_sub=False,
+ show_s2s_errors=True, show_roster_jids=False):
+ """
+ Draw on a line all informations about one contact.
+ This is basically the highest priority resource's informations
+ Use 'color' to draw the jid/display_name to show what is
+ the currently selected contact in the list
+ """
+
+ theme = get_theme()
+ resource = contact.get_highest_priority_resource()
+ if not resource:
+ # There's no online resource
+ presence = 'unavailable'
+ nb = ''
+ else:
+ presence = resource.presence
+ nb = ' (%s)' % len(contact)
+ color = theme.color_show(presence)
+ added = 2 + len(theme.CHAR_STATUS) + len(nb)
+
+ self.addstr(y, 0, ' ')
+ self.addstr(theme.CHAR_STATUS, to_curses_attr(color))
+
+ self.addstr(' ')
+ if resource:
+ self.addstr('[+] ' if contact.folded(group) else '[-] ')
+ added += 4
+ if contact.ask:
+ added += len(get_theme().CHAR_ROSTER_ASKED)
+ if show_s2s_errors and contact.error:
+ added += len(get_theme().CHAR_ROSTER_ERROR)
+ if contact.tune:
+ added += len(get_theme().CHAR_ROSTER_TUNE)
+ if contact.mood:
+ added += len(get_theme().CHAR_ROSTER_MOOD)
+ if contact.activity:
+ added += len(get_theme().CHAR_ROSTER_ACTIVITY)
+ if contact.gaming:
+ added += len(get_theme().CHAR_ROSTER_GAMING)
+ if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'):
+ added += len(theme.char_subscription(contact.subscription, keep=show_roster_sub))
+
+ if not show_roster_jids and contact.name:
+ display_name = '%s' % contact.name
+ elif contact.name and contact.name != contact.bare_jid:
+ display_name = '%s (%s)' % (contact.name, contact.bare_jid)
+ else:
+ display_name = '%s' % (contact.bare_jid,)
+
+ display_name = self.truncate_name(display_name, added) + nb
+
+ if colored:
+ self.addstr(display_name, to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ else:
+ self.addstr(display_name)
+
+ if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both', 'none'):
+ self.addstr(theme.char_subscription(contact.subscription, keep=show_roster_sub), to_curses_attr(theme.COLOR_ROSTER_SUBSCRIPTION))
+ if contact.ask:
+ self.addstr(get_theme().CHAR_ROSTER_ASKED, to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT))
+ if show_s2s_errors and contact.error:
+ self.addstr(get_theme().CHAR_ROSTER_ERROR, to_curses_attr(get_theme().COLOR_ROSTER_ERROR))
+ if contact.tune:
+ self.addstr(get_theme().CHAR_ROSTER_TUNE, to_curses_attr(get_theme().COLOR_ROSTER_TUNE))
+ if contact.activity:
+ self.addstr(get_theme().CHAR_ROSTER_ACTIVITY, to_curses_attr(get_theme().COLOR_ROSTER_ACTIVITY))
+ if contact.mood:
+ self.addstr(get_theme().CHAR_ROSTER_MOOD, to_curses_attr(get_theme().COLOR_ROSTER_MOOD))
+ if contact.gaming:
+ self.addstr(get_theme().CHAR_ROSTER_GAMING, to_curses_attr(get_theme().COLOR_ROSTER_GAMING))
+ self.finish_line()
+
+ def draw_resource_line(self, y, resource, colored):
+ """
+ Draw a specific resource line
+ """
+ color = get_theme().color_show(resource.presence)
+ self.addstr(y, 4, get_theme().CHAR_STATUS, to_curses_attr(color))
+ if colored:
+ self.addstr(y, 6, self.truncate_name(str(resource.jid), 6), to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ else:
+ self.addstr(y, 6, self.truncate_name(str(resource.jid), 6))
+ self.finish_line()
+
+ def get_selected_row(self):
+ if self.pos >= len(self.roster_cache):
+ return self.selected_row
+ if len(self.roster_cache) > 0:
+ self.selected_row = self.roster_cache[self.pos]
+ return self.roster_cache[self.pos]
+ return None
+
+class ContactInfoWin(Win):
+ def __init__(self):
+ Win.__init__(self)
+
+ def draw_contact_info(self, contact):
+ """
+ draw the contact information
+ """
+ resource = contact.get_highest_priority_resource()
+ if contact:
+ jid = contact.bare_jid
+ elif resource:
+ jid = resource.jid
+ else:
+ jid = 'example@example.com' # should never happen
+ if resource:
+ presence = resource.presence
+ else:
+ presence = 'unavailable'
+ i = 0
+ self.addstr(0, 0, '%s (%s)'%(jid, presence,), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ i += 1
+ self.addstr(i, 0, 'Subscription: %s' % (contact.subscription,))
+ self.finish_line()
+ i += 1
+ if contact.ask:
+ if contact.ask == 'asked':
+ self.addstr(i, 0, 'Ask: %s' % (contact.ask,), to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT))
+ else:
+ self.addstr(i, 0, 'Ask: %s' % (contact.ask,))
+ self.finish_line()
+ i += 1
+ if resource:
+ self.addstr(i, 0, 'Status: %s' % (resource.status))
+ self.finish_line()
+ i += 1
+
+ if contact.error:
+ self.addstr(i, 0, 'Error: %s' % contact.error, to_curses_attr(get_theme().COLOR_ROSTER_ERROR))
+ self.finish_line()
+ i += 1
+
+ if contact.tune:
+ self.addstr(i, 0, 'Tune: %s' % common.format_tune_string(contact.tune), to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
+ self.finish_line()
+ i += 1
+
+ if contact.mood:
+ self.addstr(i, 0, 'Mood: %s' % contact.mood, to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
+ self.finish_line()
+ i += 1
+
+ if contact.activity:
+ self.addstr(i, 0, 'Activity: %s' % contact.activity, to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
+ self.finish_line()
+ i += 1
+
+ if contact.gaming:
+ self.addstr(i, 0, 'Game: %s' % common.format_gaming_string(contact.gaming), to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
+ self.finish_line()
+ i += 1
+
+ def draw_group_info(self, group):
+ """
+ draw the group information
+ """
+ self.addstr(0, 0, group.name, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+
+ def refresh(self, selected_row):
+ log.debug('Refresh: %s', self.__class__.__name__)
+ self._win.erase()
+ if isinstance(selected_row, RosterGroup):
+ self.draw_group_info(selected_row)
+ elif isinstance(selected_row, Contact):
+ self.draw_contact_info(selected_row)
+ # elif isinstance(selected_row, Resource):
+ # self.draw_contact_info(None, selected_row)
+ self._refresh()
diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py
new file mode 100644
index 00000000..fd1fe546
--- /dev/null
+++ b/poezio/windows/text_win.py
@@ -0,0 +1,597 @@
+"""
+TextWin, the window showing the text messages and info messages in poezio.
+Can be locked, scrolled, has a separator, etc…
+"""
+
+import logging
+log = logging.getLogger(__name__)
+
+import curses
+from math import ceil, log10
+
+from . import Win
+from . base_wins import FORMAT_CHAR, Line
+from . funcs import truncate_nick, parse_attrs
+
+import poopt
+from config import config
+from theming import to_curses_attr, get_theme, dump_tuple
+
+
+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')
+ Win.__init__(self)
+ self.lines_nb_limit = lines_nb_limit
+ self.pos = 0
+ self.built_lines = [] # Each new message is built and kept here.
+ # on resize, we rebuild all the messages
+
+ self.lock = False
+ self.lock_buffer = []
+ self.separator_after = None
+
+ def toggle_lock(self):
+ if self.lock:
+ self.release_lock()
+ else:
+ self.acquire_lock()
+ return self.lock
+
+ def acquire_lock(self):
+ self.lock = True
+
+ def release_lock(self):
+ for line in self.lock_buffer:
+ 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, nick_size=10):
+ """
+ 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 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, nick_size=10):
+ """
+ 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:
+ 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
+
+ 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')
+ nick_size = config.get('max_nick_length')
+ for message in room.messages:
+ self.build_new_message(message, clean=False, timestamp=with_timestamps, nick_size=nick_size)
+ 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.
+ (depending on which highlight was selected before)
+ if the buffer is already positionned on the last, of if there are no
+ highlights, scroll to the end of the buffer.
+ """
+ log.debug('Going to the next highlight…')
+ if (not self.highlights or self.hl_pos != self.hl_pos or
+ self.hl_pos >= len(self.highlights) - 1):
+ self.hl_pos = float('nan')
+ self.pos = 0
+ return
+ hl_size = len(self.highlights) - 1
+ if self.hl_pos < hl_size:
+ self.hl_pos += 1
+ else:
+ self.hl_pos = hl_size
+ log.debug("self.hl_pos = %s", self.hl_pos)
+ hl = self.highlights[self.hl_pos]
+ pos = None
+ while not pos:
+ try:
+ pos = self.built_lines.index(hl)
+ except ValueError:
+ self.highlights = self.highlights[self.hl_pos+1:]
+ if not self.highlights:
+ self.hl_pos = float('nan')
+ self.pos = 0
+ return
+ self.hl_pos = 0
+ hl = self.highlights[0]
+ self.pos = len(self.built_lines) - pos - self.height
+ if self.pos < 0 or self.pos >= len(self.built_lines):
+ self.pos = 0
+
+ def previous_highlight(self):
+ """
+ Go to the previous highlight in the buffer.
+ (depending on which highlight was selected before)
+ if the buffer is already positionned on the first, or if there are no
+ highlights, scroll to the end of the buffer.
+ """
+ log.debug('Going to the previous highlight…')
+ if not self.highlights or self.hl_pos <= 0:
+ self.hl_pos = float('nan')
+ self.pos = 0
+ return
+ if self.hl_pos != self.hl_pos:
+ self.hl_pos = len(self.highlights) - 1
+ else:
+ self.hl_pos -= 1
+ log.debug("self.hl_pos = %s", self.hl_pos)
+ hl = self.highlights[self.hl_pos]
+ pos = None
+ while not pos:
+ try:
+ pos = self.built_lines.index(hl)
+ except ValueError:
+ self.highlights = self.highlights[self.hl_pos+1:]
+ if not self.highlights:
+ self.hl_pos = float('nan')
+ self.pos = 0
+ return
+ self.hl_pos = 0
+ hl = self.highlights[0]
+ self.pos = len(self.built_lines) - pos - self.height
+ if self.pos < 0 or self.pos >= len(self.built_lines):
+ self.pos = 0
+
+ def scroll_to_separator(self):
+ """
+ Scroll until separator is centered. If no separator is
+ present, scroll at the top of the window
+ """
+ if None in self.built_lines:
+ self.pos = len(self.built_lines) - self.built_lines.index(None) - self.height + 1
+ if self.pos < 0:
+ self.pos = 0
+ else:
+ self.pos = len(self.built_lines) - self.height + 1
+ # Chose a proper position (not too high)
+ self.scroll_up(0)
+ # Make “next highlight” work afterwards. This makes it easy to
+ # review all the highlights since the separator was placed, in
+ # the correct order.
+ self.hl_pos = len(self.highlights) - self.nb_of_highlights_after_separator - 1
+ log.debug("self.hl_pos = %s", self.hl_pos)
+
+ def remove_line_separator(self):
+ """
+ Remove the line separator
+ """
+ log.debug('remove_line_separator')
+ if None in self.built_lines:
+ self.built_lines.remove(None)
+ self.separator_after = None
+
+ def add_line_separator(self, room=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("Reseting number of highlights after separator")
+ if room and room.messages:
+ self.separator_after = room.messages[-1]
+
+ def build_new_message(self, message, history=None, clean=True, highlight=False, timestamp=False, nick_size=10):
+ """
+ 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 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, timestamp=False, nick_size=10):
+ """
+ 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 []
+ if len(message.str_time) > 8:
+ default_color = (FORMAT_CHAR + dump_tuple(get_theme().COLOR_LOG_MSG)
+ + '}')
+ else:
+ default_color = None
+ ret = []
+ nick = truncate_nick(message.nickname, nick_size)
+ offset = 0
+ if message.ack:
+ if message.ack > 0:
+ offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
+ else:
+ offset += poopt.wcswidth(get_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 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 refresh(self):
+ 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):
+ 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:
+ if msg.ack > 0:
+ offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
+ else:
+ offset += poopt.wcswidth(get_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):
+ 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):
+ 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):
+ char = get_theme().CHAR_NEW_TEXT_SEPARATOR
+ self.addnstr(y, 0,
+ char * (self.width // len(char) - 1),
+ self.width,
+ to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR))
+
+ def write_ack(self):
+ color = get_theme().COLOR_CHAR_ACK
+ self._win.attron(to_curses_attr(color))
+ self.addstr(get_theme().CHAR_ACK_RECEIVED)
+ self._win.attroff(to_curses_attr(color))
+ self.addstr(' ')
+ return poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
+
+ def write_nack(self):
+ color = get_theme().COLOR_CHAR_NACK
+ self._win.attron(to_curses_attr(color))
+ self.addstr(get_theme().CHAR_NACK)
+ self._win.attroff(to_curses_attr(color))
+ self.addstr(' ')
+ return poopt.wcswidth(get_theme().CHAR_NACK) + 1
+
+ def write_nickname(self, nickname, color, highlight=False):
+ """
+ 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):
+ """
+ Find a message, and replace it with a new one
+ (instead of rebuilding everything in order to correct a message)
+ """
+ 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:
+ index = i
+ while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id:
+ self.built_lines.pop(index)
+ index -= 1
+ index += 1
+ lines = self.build_message(message, timestamp=with_timestamps, nick_size=nick_size)
+ for line in lines:
+ self.built_lines.insert(index, line)
+ index += 1
+ break
+
+ def __del__(self):
+ 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, nick_size=10):
+ 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)
+ 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))
+
diff --git a/poezio/xhtml.py b/poezio/xhtml.py
new file mode 100644
index 00000000..b84ce943
--- /dev/null
+++ b/poezio/xhtml.py
@@ -0,0 +1,543 @@
+# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
+#
+# This file is part of Poezio.
+#
+# Poezio is free software: you can redistribute it and/or modify
+# it under the terms of the zlib license. See the COPYING file.
+
+"""
+Various methods to convert
+shell colors to poezio colors,
+xhtml code to shell colors,
+poezio colors to xhtml code
+"""
+
+import base64
+import curses
+import hashlib
+import re
+from os import path
+from slixmpp.xmlstream import ET
+from urllib.parse import unquote
+
+from io import BytesIO
+from xml import sax
+from xml.sax import saxutils
+
+digits = '0123456789' # never trust the modules
+
+XHTML_NS = 'http://www.w3.org/1999/xhtml'
+
+# HTML named colors
+colors = {
+ 'aliceblue': 231,
+ 'antiquewhite': 231,
+ 'aqua': 51,
+ 'aquamarine': 122,
+ 'azure': 231,
+ 'beige': 231,
+ 'bisque': 230,
+ 'black': 232,
+ 'blanchedalmond': 230,
+ 'blue': 21,
+ 'blueviolet': 135,
+ 'brown': 124,
+ 'burlywood': 223,
+ 'cadetblue': 109,
+ 'chartreuse': 118,
+ 'chocolate': 172,
+ 'coral': 209,
+ 'cornflowerblue': 111,
+ 'cornsilk': 231,
+ 'crimson': 197,
+ 'cyan': 51,
+ 'darkblue': 19,
+ 'darkcyan': 37,
+ 'darkgoldenrod': 178,
+ 'darkgray': 247,
+ 'darkgreen': 28,
+ 'darkgrey': 247,
+ 'darkkhaki': 186,
+ 'darkmagenta': 127,
+ 'darkolivegreen': 65,
+ 'darkorange': 214,
+ 'darkorchid': 134,
+ 'darkred': 124,
+ 'darksalmon': 216,
+ 'darkseagreen': 151,
+ 'darkslateblue': 61,
+ 'darkslategray': 59,
+ 'darkslategrey': 59,
+ 'darkturquoise': 44,
+ 'darkviolet': 128,
+ 'deeppink': 199,
+ 'deepskyblue': 45,
+ 'dimgray': 241,
+ 'dimgrey': 241,
+ 'dodgerblue': 39,
+ 'firebrick': 160,
+ 'floralwhite': 231,
+ 'forestgreen': 34,
+ 'fuchsia': 201,
+ 'gainsboro': 252,
+ 'ghostwhite': 231,
+ 'gold': 226,
+ 'goldenrod': 214,
+ 'gray': 244,
+ 'green': 34,
+ 'greenyellow': 191,
+ 'grey': 244,
+ 'honeydew': 231,
+ 'hotpink': 212,
+ 'indianred': 174,
+ 'indigo': 55,
+ 'ivory': 231,
+ 'khaki': 229,
+ 'lavender': 231,
+ 'lavenderblush': 231,
+ 'lawngreen': 118,
+ 'lemonchiffon': 230,
+ 'lightblue': 195,
+ 'lightcoral': 217,
+ 'lightcyan': 231,
+ 'lightgoldenrodyellow': 230,
+ 'lightgray': 251,
+ 'lightgreen': 157,
+ 'lightgrey': 251,
+ 'lightpink': 224,
+ 'lightsalmon': 216,
+ 'lightseagreen': 43,
+ 'lightskyblue': 153,
+ 'lightslategray': 109,
+ 'lightslategrey': 109,
+ 'lightsteelblue': 189,
+ 'lightyellow': 231,
+ 'lime': 46,
+ 'limegreen': 77,
+ 'linen': 231,
+ 'magenta': 201,
+ 'maroon': 124,
+ 'mediumaquamarine': 115,
+ 'mediumblue': 20,
+ 'mediumorchid': 170,
+ 'mediumpurple': 141,
+ 'mediumseagreen': 78,
+ 'mediumslateblue': 105,
+ 'mediumspringgreen': 49,
+ 'mediumturquoise': 80,
+ 'mediumvioletred': 163,
+ 'midnightblue': 18,
+ 'mintcream': 231,
+ 'mistyrose': 231,
+ 'moccasin': 230,
+ 'navajowhite': 230,
+ 'navy': 19,
+ 'oldlace': 231,
+ 'olive': 142,
+ 'olivedrab': 106,
+ 'orange': 214,
+ 'orangered': 202,
+ 'orchid': 213,
+ 'palegoldenrod': 229,
+ 'palegreen': 157,
+ 'paleturquoise': 195,
+ 'palevioletred': 211,
+ 'papayawhip': 231,
+ 'peachpuff': 230,
+ 'peru': 179,
+ 'pink': 224,
+ 'plum': 219,
+ 'powderblue': 195,
+ 'purple': 127,
+ 'red': 196,
+ 'rosybrown': 181,
+ 'royalblue': 69,
+ 'saddlebrown': 130,
+ 'salmon': 216,
+ 'sandybrown': 216,
+ 'seagreen': 72,
+ 'seashell': 231,
+ 'sienna': 131,
+ 'silver': 250,
+ 'skyblue': 153,
+ 'slateblue': 104,
+ 'slategray': 109,
+ 'slategrey': 109,
+ 'snow': 231,
+ 'springgreen': 48,
+ 'steelblue': 74,
+ 'tan': 187,
+ 'teal': 37,
+ 'thistle': 225,
+ 'tomato': 209,
+ 'turquoise': 86,
+ 'violet': 219,
+ 'wheat': 230,
+ 'white': 255,
+ 'whitesmoke': 255,
+ 'yellow': 226,
+ 'yellowgreen': 149
+}
+
+whitespace_re = re.compile(r'\s+')
+
+xhtml_attr_re = re.compile(r'\x19-?\d[^}]*}|\x19[buaio]')
+xhtml_data_re = re.compile(r'data:image/([a-z]+);base64,(.+)')
+poezio_color_double = re.compile(r'(?:\x19\d+}|\x19\d)+(\x19\d|\x19\d+})')
+poezio_format_trim = re.compile(r'(\x19\d+}|\x19\d|\x19[buaio]|\x19o)+\x19o')
+
+xhtml_simple_attr_re = re.compile(r'\x19\d')
+
+def get_body_from_message_stanza(message, use_xhtml=False,
+ tmp_dir=None, extract_images=False):
+ """
+ Returns a string with xhtml markups converted to
+ poezio colors if there's an xhtml_im element, or
+ the body (without any color) otherwise
+ """
+ if use_xhtml:
+ xhtml = message['html'].xml
+ xhtml_body = xhtml.find('{http://www.w3.org/1999/xhtml}body')
+ if xhtml_body:
+ content = xhtml_to_poezio_colors(xhtml_body, tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ content = content if content else message['body']
+ return content or " "
+ return message['body']
+
+def ncurses_color_to_html(color):
+ """
+ Takes an int between 0 and 256 and returns
+ a string of the form #XXXXXX representing an
+ html color.
+ """
+ if color <= 15:
+ try:
+ (r, g, b) = curses.color_content(color)
+ except: # fallback in faulty terminals (e.g. xterm)
+ (r, g, b) = curses.color_content(color%8)
+ r = r / 1000 * 6 - 0.01
+ g = g / 1000 * 6 - 0.01
+ b = b / 1000 * 6 - 0.01
+ elif color <= 231:
+ color = color - 16
+ r = color % 6
+ color = color / 6
+ g = color % 6
+ color = color / 6
+ b = color % 6
+ else:
+ color -= 232
+ r = g = b = color / 24 * 6
+ return '#%02X%02X%02X' % (r*256/6, g*256/6, b*256/6)
+
+def parse_css(css):
+ def get_color(value):
+ if value[0] == '#':
+ value = value[1:]
+ length = len(value)
+ if length != 3 and length != 6:
+ return -1
+ value = int(value, 16)
+ if length == 6:
+ r = int(value >> 16)
+ g = int((value >> 8) & 0xff)
+ b = int(value & 0xff)
+ if r == g == b:
+ return 232 + int(r/10.6251)
+ div = 42.51
+ else:
+ r = int(value >> 8)
+ g = int((value >> 4) & 0xf)
+ b = int(value & 0xf)
+ if r == g == b:
+ return 232 + int(1.54*r)
+ div = 2.51
+ return 6*6*int(r/div) + 6*int(g/div) + int(b/div) + 16
+ if value in colors:
+ return colors[value]
+ return -1
+ shell = ''
+ rules = css.split(';')
+ for rule in rules:
+ if ':' not in rule:
+ continue
+ key, value = rule.split(':', 1)
+ key = key.strip()
+ value = value.strip()
+ if key == 'background-color':
+ pass#shell += '\x191'
+ elif key == 'color':
+ color = get_color(value)
+ if color != -1:
+ shell += '\x19%d}' % color
+ elif key == 'font-style':
+ shell += '\x19i'
+ elif key == 'font-weight':
+ shell += '\x19b'
+ elif key == 'margin-left':
+ shell += ' '
+ elif key == 'text-align':
+ pass
+ elif key == 'text-decoration':
+ if value == 'underline':
+ shell += '\x19u'
+ elif value == 'blink':
+ shell += '\x19a'
+ return shell
+
+def trim(string):
+ return re.sub(whitespace_re, ' ', string)
+
+class XHTMLHandler(sax.ContentHandler):
+ def __init__(self, force_ns=False, tmp_dir=None, extract_images=False):
+ self.builder = []
+ self.formatting = []
+ self.attrs = []
+ self.list_state = []
+ self.is_pre = False
+ self.a_start = 0
+ # do not care about xhtml-in namespace
+ self.force_ns = force_ns
+
+ self.tmp_dir = tmp_dir
+ self.extract_images = extract_images
+
+ @property
+ def result(self):
+ sanitized = re.sub(poezio_color_double, r'\1', ''.join(self.builder).strip())
+ return re.sub(poezio_format_trim, '\x19o', sanitized)
+
+ def append_formatting(self, formatting):
+ self.formatting.append(formatting)
+ self.builder.append(formatting)
+
+ def pop_formatting(self):
+ self.formatting.pop()
+ self.builder.append('\x19o' + ''.join(self.formatting))
+
+ def characters(self, characters):
+ self.builder.append(characters if self.is_pre else trim(characters))
+
+ def startElementNS(self, name, _, attrs):
+ if name[0] != XHTML_NS and not self.force_ns:
+ return
+
+ builder = self.builder
+ attrs = {name: value for ((ns, name), value) in attrs.items() if ns is None}
+ self.attrs.append(attrs)
+
+ if 'style' in attrs:
+ style = parse_css(attrs['style'])
+ self.append_formatting(style)
+
+ name = name[1]
+ if name == 'a':
+ self.append_formatting('\x19u')
+ self.a_start = len(self.builder)
+ elif name == 'blockquote':
+ builder.append('“')
+ elif name == 'br':
+ builder.append('\n')
+ elif name == 'cite':
+ self.append_formatting('\x19u')
+ elif name == 'em':
+ self.append_formatting('\x19i')
+ elif name == 'img':
+ if re.match(xhtml_data_re, attrs['src']) and self.extract_images:
+ type_, data = [i for i in re.split(xhtml_data_re, attrs['src']) if i]
+ bin_data = base64.b64decode(unquote(data))
+ filename = hashlib.sha1(bin_data).hexdigest() + '.' + type_
+ filepath = path.join(self.tmp_dir, filename)
+ if not path.exists(filepath):
+ try:
+ with open(filepath, 'wb') as fd:
+ fd.write(bin_data)
+ builder.append('file://%s' % filepath)
+ except Exception as e:
+ builder.append('[Error while saving image: %s]' % e)
+ else:
+ builder.append('file://%s' % filepath)
+ else:
+ builder.append(trim(attrs['src']))
+ if 'alt' in attrs:
+ builder.append(' (%s)' % trim(attrs['alt']))
+ elif name == 'ul':
+ self.list_state.append('ul')
+ elif name == 'ol':
+ self.list_state.append(1)
+ elif name == 'li':
+ try:
+ state = self.list_state[-1]
+ except IndexError:
+ state = 'ul'
+ if state == 'ul':
+ builder.append('\n• ')
+ else:
+ builder.append('\n%d) ' % state)
+ state += 1
+ self.list_state[-1] = state
+ elif name == 'p':
+ builder.append('\n')
+ elif name == 'pre':
+ builder.append('\n')
+ self.is_pre = True
+ elif name == 'strong':
+ self.append_formatting('\x19b')
+
+ def endElementNS(self, name, _):
+ if name[0] != XHTML_NS and not self.force_ns:
+ return
+
+ builder = self.builder
+ attrs = self.attrs.pop()
+ name = name[1]
+
+ if name == 'a':
+ self.pop_formatting()
+ # do not display the link twice
+ text_elements = filter(lambda x: not x.startswith('\x19'),
+ self.builder[self.a_start:])
+ link_text = ''.join(text_elements).strip()
+ if 'href' in attrs and attrs['href'] != link_text:
+ builder.append(' (%s)' % trim(attrs['href']))
+ elif name == 'blockquote':
+ builder.append('”')
+ elif name in ('cite', 'em', 'strong'):
+ self.pop_formatting()
+ elif name in ('ol', 'p', 'ul'):
+ builder.append('\n')
+ elif name == 'pre':
+ builder.append('\n')
+ self.is_pre = False
+
+ if 'style' in attrs:
+ self.pop_formatting()
+
+ if 'title' in attrs:
+ builder.append(' [' + attrs['title'] + ']')
+
+def xhtml_to_poezio_colors(xml, force=False, tmp_dir=None, extract_images=None):
+ if isinstance(xml, str):
+ xml = xml.encode('utf8')
+ elif not isinstance(xml, bytes):
+ xml = ET.tostring(xml)
+
+ handler = XHTMLHandler(force_ns=force, tmp_dir=tmp_dir,
+ extract_images=extract_images)
+ parser = sax.make_parser()
+ parser.setFeature(sax.handler.feature_namespaces, True)
+ parser.setContentHandler(handler)
+ parser.parse(BytesIO(xml))
+ return handler.result
+
+def clean_text(s):
+ """
+ Remove all xhtml-im attributes (\x19etc) from the string with the
+ complete color format, i.e \x19xxx}
+ """
+ s = re.sub(xhtml_attr_re, "", s)
+ return s
+
+def clean_text_simple(string):
+ """
+ Remove all \x19 from the string formatted with simple colors:
+ \x198
+ """
+ pos = string.find('\x19')
+ while pos != -1:
+ string = string[:pos] + string[pos+2:]
+ pos = string.find('\x19')
+ return string
+
+def convert_simple_to_full_colors(text):
+ """
+ takes a \x19n formatted string and returns
+ a \x19n} formatted one.
+ """
+ # TODO, have a single list of this. This is some sort of
+ # dusplicate from windows.format_chars
+ mapping = str.maketrans({'\x0E': '\x19b', '\x0F': '\x19o', '\x10': '\x19u',
+ '\x11': '\x191', '\x12': '\x192', '\x13': '\x193',
+ '\x14': '\x194', '\x15': '\x195', '\x16': '\x196',
+ '\x17': '\x197', '\x18': '\x198', '\x19': '\x199'})
+ text = text.translate(mapping)
+ def add_curly_bracket(match):
+ return match.group(0) + '}'
+ return re.sub(xhtml_simple_attr_re, add_curly_bracket, text)
+
+number_to_color_names = {
+ 1: 'red',
+ 2: 'green',
+ 3: 'yellow',
+ 4: 'blue',
+ 5: 'violet',
+ 6: 'turquoise',
+ 7: 'white'
+}
+
+def format_inline_css(_dict):
+ return ''.join(('%s: %s;' % (key, value) for key, value in _dict.items()))
+
+def poezio_colors_to_html(string):
+ """
+ Convert poezio colors to html
+ (e.g. \x191}: <span style='color: red'>)
+ """
+ # Maintain a list of the current css attributes used
+ # And check if a tag is open (by design, we only open
+ # spans tag, and they cannot be nested.
+ current_attrs = {}
+ tag_open = False
+ next_attr_char = string.find('\x19')
+ build = ["<body xmlns='http://www.w3.org/1999/xhtml'><p>"]
+
+ def check_property(key, value):
+ nonlocal tag_open
+ if current_attrs.get(key, None) == value:
+ return
+ current_attrs[key] = value
+ if tag_open:
+ tag_open = False
+ build.append('</span>')
+
+ while next_attr_char != -1:
+ attr_char = string[next_attr_char+1].lower()
+
+ if next_attr_char != 0 and string[:next_attr_char]:
+ if current_attrs and not tag_open:
+ build.append('<span style="%s">' % format_inline_css(current_attrs))
+ tag_open = True
+ build.append(saxutils.escape(string[:next_attr_char]))
+
+ if attr_char == 'o':
+ if tag_open:
+ build.append('</span>')
+ tag_open = False
+ current_attrs = {}
+ elif attr_char == 'b':
+ check_property('font-weight', 'bold')
+ elif attr_char == 'u':
+ check_property('text-decoration', 'underline')
+
+ if attr_char in digits:
+ number_str = string[next_attr_char+1:string.find('}', next_attr_char)]
+ number = int(number_str)
+ if number in number_to_color_names:
+ check_property('color', number_to_color_names.get(number, 'black'))
+ else:
+ check_property('color', ncurses_color_to_html(number))
+ string = string[next_attr_char+len(number_str)+2:]
+ else:
+ string = string[next_attr_char+2:]
+ next_attr_char = string.find('\x19')
+
+ if current_attrs and not tag_open and string:
+ build.append('<span style="%s">' % format_inline_css(current_attrs))
+ tag_open = True
+ build.append(saxutils.escape(string))
+ if tag_open:
+ build.append('</span>')
+ build.append("</p></body>")
+ text = ''.join(build)
+ return text.replace('\n', '<br />')