#!/usr/bin/env python2
# -*- coding: utf-8 -*-
# Primitivus: a SAT frontend
# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
from sat.core.i18n import _
from sat.core import log as logging
log = logging.getLogger(__name__)
import urwid
from urwid_satext import sat_widgets
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.quick_frontend import quick_chat
from sat_frontends.quick_frontend import quick_games
from sat_frontends.primitivus import game_tarot
from sat_frontends.primitivus.constants import Const as C
from sat_frontends.primitivus.keys import action_key_map as a_key
from sat_frontends.primitivus.widget import PrimitivusWidget
import time
import locale
from sat_frontends.tools import jid
from functools import total_ordering
import bisect
OCCUPANTS_FOOTER = _(u"{} occupants")
class MessageWidget(urwid.WidgetWrap):
def __init__(self, mess_data):
"""
@param mess_data(quick_chat.Message, None): message data
None: used only for non text widgets (e.g.: focus separator)
"""
self.mess_data = mess_data
mess_data.widgets.add(self)
self.timestamp = time.localtime(mess_data.timestamp)
super(MessageWidget, self).__init__(urwid.Text(self.markup))
@property
def markup(self):
return self._generateInfoMarkup() if self.mess_data.type == C.MESS_TYPE_INFO else self._generateMarkup()
@property
def info_type(self):
return self.mess_data.info_type
@property
def parent(self):
return self.mess_data.parent
@property
def message(self):
"""Return currently displayed message"""
return self.mess_data.main_message
@message.setter
def message(self, value):
self.mess_data.message = {'':value}
self.redraw()
@property
def type(self):
try:
return self.mess_data.type
except AttributeError:
return C.MESS_TYPE_INFO
def redraw(self):
self._w.set_text(self.markup)
def selectable(self):
return True
def keypress(self, size, key):
return key
def get_cursor_coords(self, size):
return 0, 0
def render(self, size, focus=False):
# Text widget doesn't render cursor, but we want one
# so we add it here
canvas = urwid.CompositeCanvas(self._w.render(size, focus))
if focus:
canvas.set_cursor(self.get_cursor_coords(size))
return canvas
def _generateInfoMarkup(self):
return ('info_msg', self.message)
def _generateMarkup(self):
"""Generate text markup according to message data and Widget options"""
markup = []
d = self.mess_data
mention = d.mention
# timestamp
if self.parent.show_timestamp:
# if the message was sent before today, we print the full date
time_format = u"%c" if self.timestamp < self.parent.day_change else u"%H:%M"
attr = 'msg_mention' if mention else 'date'
markup.append((attr, u"[{}]".format(time.strftime(time_format, self.timestamp).decode(locale.getlocale()[1]))))
else:
if mention:
markup.append(('msg_mention', '[*]'))
# nickname
if self.parent.show_short_nick:
markup.append(('my_nick' if d.own_mess else 'other_nick', "**" if d.own_mess else "*"))
else:
markup.append(('my_nick' if d.own_mess else 'other_nick', u"[{}] ".format(d.nick or '')))
msg = self.message # needed to generate self.selected_lang
if d.selected_lang:
markup.append(("msg_lang", u"[{}] ".format(d.selected_lang)))
# message body
markup.append(msg)
return markup
@total_ordering
class OccupantWidget(urwid.WidgetWrap):
def __init__(self, occupant_data):
self.occupant_data = occupant_data
occupant_data.widgets.add(self)
markup = self._generateMarkup()
super(OccupantWidget, self).__init__(urwid.Text(markup))
def __eq__(self, other):
if other is None:
return False
return self.occupant_data.nick == other.occupant_data.nick
def __lt__(self, other):
return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
@property
def parent(self):
return self.mess_data.parent
@property
def nick(self):
return self.occupant_data.nick
def selectable(self):
return True
def keypress(self, size, key):
return key
def get_cursor_coords(self, size):
return 0, 0
def render(self, size, focus=False):
# Text widget doesn't render cursor, but we want one
# so we add it here
canvas = urwid.CompositeCanvas(self._w.render(size, focus))
if focus:
canvas.set_cursor(self.get_cursor_coords(size))
return canvas
def _generateMarkup(self):
# TODO: role and affiliation are shown in a Q&D way
# should be more intuitive and themable
o = self.occupant_data
markup = []
markup.append(('info_msg', '{}{} '.format(
o.role[0].upper(),
o.affiliation[0].upper(),
)))
markup.append(o.nick)
return markup
class OccupantsWidget(urwid.WidgetWrap):
def __init__(self, parent):
self.parent = parent
self.occupants_walker = urwid.SimpleListWalker([])
self.occupants_footer = urwid.Text('', align='center')
self.updateFooter()
occupants_widget = urwid.Frame(urwid.ListBox(self.occupants_walker), footer=self.occupants_footer)
super(OccupantsWidget, self).__init__(occupants_widget)
occupants_list = sorted(self.parent.occupants.keys(), key=lambda o:o.lower())
for occupant in occupants_list:
occupant_data = self.parent.occupants[occupant]
self.occupants_walker.append(OccupantWidget(occupant_data))
def updateFooter(self):
"""update footer widget"""
txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants))
self.occupants_footer.set_text(txt)
def getNicks(self, start=u''):
"""Return nicks of all occupants
@param start(unicode): only return nicknames which start with this text
"""
return [w.nick for w in self.occupants_walker if isinstance(w, OccupantWidget) and w.nick.startswith(start)]
def addUser(self, occupant_data):
"""add a user to the list"""
bisect.insort(self.occupants_walker, OccupantWidget(occupant_data))
self.updateFooter()
self.parent.host.redraw() # FIXME: should not be necessary
def removeUser(self, occupant_data):
"""remove a user from the list"""
for widget in occupant_data.widgets:
self.occupants_walker.remove(widget)
self.updateFooter()
self.parent.host.redraw() # FIXME: should not be necessary
class Chat(PrimitivusWidget, quick_chat.QuickChat):
def __init__(self, host, target, type_=C.CHAT_ONE2ONE, occupants=None, subject=None, profiles=None):
quick_chat.QuickChat.__init__(self, host, target, type_, occupants, subject, profiles=profiles)
self.filters = [] # list of filter callbacks to apply
self.mess_walker = urwid.SimpleListWalker([])
self.mess_widgets = urwid.ListBox(self.mess_walker)
self.chat_widget = urwid.Frame(self.mess_widgets)
self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)])
self.pile = urwid.Pile([self.chat_colums])
PrimitivusWidget.__init__(self, self.pile, self.target)
# we must adapt the behaviour with the type
if type_ == C.CHAT_GROUP:
if len(self.chat_colums.contents) == 1:
self.occupants_widget = OccupantsWidget(self)
# FIXME
# , option_type=sat_widgets.ClickableText, on_click=self._occupantsClicked)
self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_widget)
self._appendOccupantsPanel()
self.host.addListener('presence', self.presenceListener, [profiles])
# focus marker is a separator indicated last visible message before focus was lost
self.focus_marker = None # link to current marker
self.focus_marker_set = None # True if a new marker has been inserted
self.day_change = time.strptime(time.strftime("%a %b %d 00:00:00 %Y")) # struct_time of day changing time
self.show_timestamp = True
self.show_short_nick = False
self.show_title = 1 # 0: clip title; 1: full title; 2: no title
self.postInit()
def keypress(self, size, key):
if key == a_key['OCCUPANTS_HIDE']: # user wants to (un)hide the occupants panel
if self.type == C.CHAT_GROUP:
widgets = [widget for (widget, options) in self.chat_colums.contents]
if self.occupants_panel in widgets:
self._removeOccupantsPanel()
else:
self._appendOccupantsPanel()
elif key == a_key['TIMESTAMP_HIDE']: # user wants to (un)hide timestamp
self.show_timestamp = not self.show_timestamp
self.redraw()
elif key == a_key['SHORT_NICKNAME']: # user wants to (not) use short nick
self.show_short_nick = not self.show_short_nick
self.redraw()
elif key == a_key['SUBJECT_SWITCH']: # user wants to (un)hide group's subject or change its apperance
if self.subject:
self.show_title = (self.show_title + 1) % 3
if self.show_title == 0:
self.setSubject(self.subject, 'clip')
elif self.show_title == 1:
self.setSubject(self.subject, 'space')
elif self.show_title == 2:
self.chat_widget.header = None
self._invalidate()
return super(Chat, self).keypress(size, key)
def completion(self, text, completion_data):
"""Completion method which complete nicknames in group chat
for params, see [sat_widgets.AdvancedEdit]
"""
if self.type != C.CHAT_GROUP:
return text
space = text.rfind(" ")
start = text[space + 1:]
words = self.occupants_widget.getNicks(start)
if not words:
return text
try:
word_idx = words.index(completion_data['last_word']) + 1
except (KeyError, ValueError):
word_idx = 0
else:
if word_idx == len(words):
word_idx = 0
word = completion_data['last_word'] = words[word_idx]
return u"{}{}{}".format(text[:space + 1], word, ': ' if space < 0 else '')
def getMenu(self):
"""Return Menu bar"""
menu = sat_widgets.Menu(self.host.loop)
if self.type == C.CHAT_GROUP:
self.host.addMenus(menu, C.MENU_ROOM, {'room_jid': self.target.bare})
game = _("Game")
menu.addMenu(game, "Tarot", self.onTarotRequest)
elif self.type == C.CHAT_ONE2ONE:
# FIXME: self.target is a bare jid, we need to check that
contact_list = self.host.contact_lists[self.profile]
if not self.target.resource:
full_jid = contact_list.getFullJid(self.target)
else:
full_jid = self.target
self.host.addMenus(menu, C.MENU_SINGLE, {'jid': full_jid})
return menu
def setFilter(self, args):
"""set filtering of messages
@param args(list[unicode]): filters following syntax "[filter]=[value]"
empty list to clear all filters
only lang=XX is handled for now
"""
del self.filters[:]
if args:
if args[0].startswith("lang="):
lang = args[0][5:].strip()
self.filters.append(lambda mess_data: lang in mess_data.message)
self.printMessages()
def presenceListener(self, entity, show, priority, statuses, profile):
"""Update entity's presence status
@param entity (jid.JID): entity updated
@param show: availability
@param priority: resource's priority
@param statuses: dict of statuses
@param profile: %(doc_profile)s
"""
# FIXME: disable for refactoring, need to be checked and re-enabled
return
# assert self.type == C.CHAT_GROUP
# if entity.bare != self.target:
# return
# self.update(entity)
def createMessage(self, message):
self.appendMessage(message)
def _user_moved(self, message):
"""return true if message is a user left/joined message
@param message(quick_chat.Message): message to add
"""
if message.type != C.MESS_TYPE_INFO:
return False
try:
info_type = message.extra['info_type']
except KeyError:
return False
else:
return info_type in quick_chat.ROOM_USER_MOVED
def appendMessage(self, message):
"""Create a MessageWidget and append it
Can merge messages together is desirable (e.g.: multiple joined/leave)
@param message(quick_chat.Message): message to add
"""
if self.filters:
if not all([f(message) for f in self.filters]):
return
if self._user_moved(message):
for wid in reversed(self.mess_walker):
# we merge in/out messages if no message was sent meanwhile
if not isinstance(wid, MessageWidget):
continue
if wid.mess_data.type != C.MESS_TYPE_INFO:
break
if wid.info_type in quick_chat.ROOM_USER_MOVED and wid.mess_data.nick == message.nick:
try:
count = wid.reentered_count
except AttributeError:
count = wid.reentered_count = 1
nick = wid.mess_data.nick
if message.info_type == quick_chat.ROOM_USER_LEFT:
wid.message = _(u"<= {nick} has left the room ({count})").format(nick=nick, count=count)
else:
wid.message = _(u"<=> {nick} re-entered the room ({count})") .format(nick=nick, count=count)
wid.reentered_count+=1
return
if ((self.host.selected_widget != self or not self.host.x_notify.hasFocus())
and self.focus_marker_set is not None):
if not self.focus_marker_set and not self._locked and self.mess_walker:
if self.focus_marker is not None:
self.mess_walker.remove(self.focus_marker)
self.focus_marker = urwid.Divider('—')
self.mess_walker.append(self.focus_marker)
self.focus_marker_set = True
else:
if self.focus_marker_set:
self.focus_marker_set = False
if not message.message:
log.error(u"Received an empty message for uid {}".format(message.uid))
else:
wid = MessageWidget(message)
self.mess_walker.append(wid)
self.mess_widgets.focus_position = len(self.mess_walker) - 1 # scroll down
self.host.redraw() # FIXME: should not be necessary
if wid.mess_data.mention:
from_jid = wid.mess_data.from_jid
msg = _(u'You have been mentioned by {nick} in {room}'.format(
nick=wid.mess_data.nick,
room=self.target,
))
self.host.notify(C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile)
elif self.type == C.CHAT_ONE2ONE:
from_jid = wid.mess_data.from_jid
msg = _(u'{entity} is talking to you'.format(
entity=from_jid,
))
self.host.notify(C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile)
def addUser(self, nick):
occupant = super(Chat, self).addUser(nick)
self.occupants_widget.addUser(occupant)
def removeUser(self, occupant_data):
occupant = super(Chat, self).removeUser(occupant_data)
if occupant is not None:
self.occupants_widget.removeUser(occupant)
def _occupantsClicked(self, list_wid, clicked_wid):
# FIXME: not called anymore after refactoring
assert self.type == C.CHAT_GROUP
nick = clicked_wid.getValue().value
if nick == self.nick:
# We ignore clicks on our own nick
return
contact_list = self.host.contact_lists[self.profile]
full_jid = jid.JID("%s/%s" % (self.target.bare, nick))
# we have a click on a nick, we need to create the widget if it doesn't exists
self.getOrCreatePrivateWidget(full_jid)
# now we select the new window
contact_list.setFocus(full_jid, True)
def _appendOccupantsPanel(self):
self.chat_colums.contents.append((self.occupants_panel, ('weight', 2, False)))
def _removeOccupantsPanel(self):
for widget, options in self.chat_colums.contents:
if widget is self.occupants_panel:
self.chat_colums.contents.remove((widget, options))
break
def addGamePanel(self, widget):
"""Insert a game panel to this Chat dialog.
@param widget (Widget): the game panel
"""
assert (len(self.pile.contents) == 1)
self.pile.contents.insert(0, (widget, ('weight', 1)))
self.pile.contents.insert(1, (urwid.Filler(urwid.Divider('-'), ('fixed', 1))))
self.host.redraw()
def removeGamePanel(self, widget):
"""Remove the game panel from this Chat dialog.
@param widget (Widget): the game panel
"""
assert (len(self.pile.contents) == 3)
del self.pile.contents[0]
self.host.redraw()
def setSubject(self, subject, wrap='space'):
"""Set title for a group chat"""
quick_chat.QuickChat.setSubject(self, subject)
self.subj_wid = urwid.Text(unicode(subject.replace('\n', '|') if wrap == 'clip' else subject),
align='left' if wrap == 'clip' else 'center', wrap=wrap)
self.chat_widget.header = urwid.AttrMap(self.subj_wid, 'title')
self.host.redraw()
## Messages
def printMessages(self, clear=True):
"""generate message widgets
@param clear(bool): clear message before printing if true
"""
if clear:
del self.mess_walker[:]
for message in self.messages.itervalues():
self.appendMessage(message)
def redraw(self):
"""redraw all messages"""
for w in self.mess_walker:
try:
w.redraw()
except AttributeError:
pass
def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'):
del self.mess_walker[:]
if search:
self.mess_walker.append(urwid.Text(_(u"Results for searching the globbing pattern: {}").format(search)))
self.mess_walker.append(urwid.Text(_(u"Type ':history ' to reset the chat history").format(search)))
super(Chat, self).updateHistory(size, search, profile)
def _onHistoryPrinted(self):
"""Refresh or scroll down the focus after the history is printed"""
self.printMessages(clear=False)
super(Chat, self)._onHistoryPrinted()
def onPrivateCreated(self, widget):
self.host.contact_lists[widget.profile].specialResourceVisible(widget.target)
def onSelected(self):
self.focus_marker_set = False
def notify(self, contact="somebody", msg=""):
"""Notify the user of a new message if primitivus doesn't have the focus.
@param contact (unicode): contact who wrote to the users
@param msg (unicode): the message that has been received
"""
# FIXME: not called anymore after refactoring
if msg == "":
return
if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
# we don't change focus if user is not at the bottom
# as that mean that he is probably watching discussion history
self.mess_widgets.focus_position = len(self.mess_walker) - 1
self.host.redraw()
if not self.host.x_notify.hasFocus():
if self.type == C.CHAT_ONE2ONE:
self.host.x_notify.sendNotification(_("Primitivus: %s is talking to you") % contact)
elif self.nick is not None and self.nick.lower() in msg.lower():
self.host.x_notify.sendNotification(_("Primitivus: %(user)s mentioned you in room '%(room)s'") % {'user': contact, 'room': self.target})
# MENU EVENTS #
def onTarotRequest(self, menu):
# TODO: move this to plugin_misc_tarot with dynamic menu
if len(self.occupants) != 4:
self.host.showPopUp(sat_widgets.Alert(_("Can't start game"), _("You need to be exactly 4 peoples in the room to start a Tarot game"), ok_cb=self.host.removePopUp))
else:
self.host.bridge.tarotGameCreate(self.target, list(self.occupants), self.profile)
# MISC EVENTS #
def onDelete(self):
# FIXME: to be checked after refactoring
quick_chat.QuickChat.onDelete(self)
if self.type == C.CHAT_GROUP:
self.host.removeListener('presence', self.presenceListener)
quick_widgets.register(quick_chat.QuickChat, Chat)
quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)