From e7d2bc3705341aa8d6c1a807d88c86b408459bb5 Mon Sep 17 00:00:00 2001 From: Aleksey Lim Date: Wed, 29 Dec 2010 14:45:11 +0000 Subject: Cleanup to code - sugar-lint fixes - move chatbox code to separate modules to reuse in other activities - more robust smiley parsing - remove pippy code --- (limited to 'chat') diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chat/__init__.py diff --git a/chat/box.py b/chat/box.py new file mode 100644 index 0000000..d201b20 --- /dev/null +++ b/chat/box.py @@ -0,0 +1,398 @@ +# Copyright 2007-2008 One Laptop Per Child +# Copyright 2009, Aleksey Lim +# Copyright 2010, Mukesh Gupta +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import re +import os +import time +import logging +from datetime import datetime +from gettext import gettext as _ +from os.path import join + +import gtk +import hippo +import pango +import cairo + +from sugar.graphics import style +from sugar.graphics.roundbox import CanvasRoundBox +from sugar.graphics.palette import Palette, CanvasInvoker +from sugar.presence import presenceservice +from sugar.graphics.menuitem import MenuItem +from sugar.activity.activity import get_activity_root, show_object_in_journal +from sugar.util import timestamp_to_elapsed_string +from sugar.datastore import datastore +from sugar import profile + +from chat import smilies + + +_URL_REGEXP = re.compile('((http|ftp)s?://)?' + '(([-a-zA-Z0-9]+[.])+[-a-zA-Z0-9]{2,}|([0-9]{1,3}[.]){3}[0-9]{1,3})' + '(:[1-9][0-9]{0,4})?(/[-a-zA-Z0-9/%~@&_+=;:,.?#]*[a-zA-Z0-9/])?') + + +class ChatBox(hippo.CanvasScrollbars): + + def __init__(self): + hippo.CanvasScrollbars.__init__(self) + + self.owner = presenceservice.get_instance().get_owner() + + # Auto vs manual scrolling: + self._scroll_auto = True + self._scroll_value = 0.0 + self._last_msg_sender = None + # Track last message, to combine several messages: + self._last_msg = None + self._chat_log = '' + + self._conversation = hippo.CanvasBox( + spacing=0, + background_color=style.COLOR_WHITE.get_int()) + + self.set_policy(hippo.ORIENTATION_HORIZONTAL, + hippo.SCROLLBAR_NEVER) + self.set_root(self._conversation) + + vadj = self.props.widget.get_vadjustment() + vadj.connect('changed', self._scroll_changed_cb) + vadj.connect('value-changed', self._scroll_value_changed_cb) + + def get_log(self): + return self._chat_log + + def add_text(self, buddy, text, status_message=False): + """Display text on screen, with name and colors. + + buddy -- buddy object or dict {nick: string, color: string} + (The dict is for loading the chat log from the journal, + when we don't have the buddy object any more.) + text -- string, what the buddy said + status_message -- boolean + False: show what buddy said + True: show what buddy did + + hippo layout: + .------------- rb ---------------. + | +name_vbox+ +----msg_vbox----+ | + | | | | | | + | | nick: | | +--msg_hbox--+ | | + | | | | | text | | | + | +---------+ | +------------+ | | + | | | | + | | +--msg_hbox--+ | | + | | | text | url | | | + | | +------------+ | | + | +----------------+ | + `--------------------------------' + """ + if not buddy: + buddy = self.owner + + if type(buddy) is dict: + # dict required for loading chat log from journal + nick = buddy['nick'] + color = buddy['color'] + else: + nick = buddy.props.nick + color = buddy.props.color + try: + color_stroke_html, color_fill_html = color.split(',') + except ValueError: + color_stroke_html, color_fill_html = ('#000000', '#888888') + + # Select text color based on fill color: + color_fill_rgba = style.Color(color_fill_html).get_rgba() + color_fill_gray = (color_fill_rgba[0] + color_fill_rgba[1] + + color_fill_rgba[2]) / 3 + color_stroke = style.Color(color_stroke_html).get_int() + color_fill = style.Color(color_fill_html).get_int() + + if color_fill_gray < 0.5: + text_color = style.COLOR_WHITE.get_int() + else: + text_color = style.COLOR_BLACK.get_int() + + self._add_log(nick, color, text, status_message) + + # Check for Right-To-Left languages: + if pango.find_base_dir(nick, -1) == pango.DIRECTION_RTL: + lang_rtl = True + else: + lang_rtl = False + + # Check if new message box or add text to previous: + new_msg = True + if self._last_msg_sender: + if not status_message: + if buddy == self._last_msg_sender: + # Add text to previous message + new_msg = False + + if not new_msg: + rb = self._last_msg + msg_vbox = rb.get_children()[1] + msg_hbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_HORIZONTAL) + msg_vbox.append(msg_hbox) + else: + rb = CanvasRoundBox(background_color=color_fill, + border_color=color_stroke, + padding=4) + rb.props.border_color = color_stroke # Bug #3742 + self._last_msg = rb + self._last_msg_sender = buddy + if not status_message: + name = hippo.CanvasText(text=nick + ': ', color=text_color) + name_vbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_VERTICAL) + name_vbox.append(name) + rb.append(name_vbox) + msg_vbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_VERTICAL) + rb.append(msg_vbox) + msg_hbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_HORIZONTAL) + msg_vbox.append(msg_hbox) + + if status_message: + self._last_msg_sender = None + + match = _URL_REGEXP.search(text) + while match: + # there is a URL in the text + starttext = text[:match.start()] + if starttext: + message = hippo.CanvasText( + text=starttext, + size_mode=hippo.CANVAS_SIZE_WRAP_WORD, + color=text_color, + xalign=hippo.ALIGNMENT_START) + msg_hbox.append(message) + url = text[match.start():match.end()] + + message = _CanvasLink( + text=url, + color=text_color) + attrs = pango.AttrList() + attrs.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, 32767)) + message.set_property("attributes", attrs) + message.connect('activated', self._link_activated_cb) + + # call interior magic which should mean just: + # CanvasInvoker().parent = message + CanvasInvoker(message) + + msg_hbox.append(message) + text = text[match.end():] + match = _URL_REGEXP.search(text) + + if text: + for word in smilies.parse(text): + if isinstance(word, cairo.ImageSurface): + item = hippo.CanvasImage( + image=word, + border=0, + border_color=style.COLOR_BUTTON_GREY.get_int(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER) + else: + item = hippo.CanvasText( + text=word, + size_mode=hippo.CANVAS_SIZE_WRAP_WORD, + color=text_color, + xalign=hippo.ALIGNMENT_START) + msg_hbox.append(item) + + # Order of boxes for RTL languages: + if lang_rtl: + msg_hbox.reverse() + if new_msg: + rb.reverse() + + if new_msg: + box = hippo.CanvasBox(padding=2) + box.append(rb) + self._conversation.append(box) + + def add_separator(self, timestamp): + """Add whitespace and timestamp between chat sessions.""" + time_with_current_year = (time.localtime(time.time())[0],) +\ + time.strptime(timestamp, "%b %d %H:%M:%S")[1:] + + timestamp_seconds = time.mktime(time_with_current_year) + if timestamp_seconds > time.time(): + time_with_previous_year = (time.localtime(time.time())[0]-1,) +\ + time.strptime(timestamp, "%b %d %H:%M:%S")[1:] + timestamp_seconds = time.mktime(time_with_previous_year) + + message = hippo.CanvasText( + text=timestamp_to_elapsed_string(timestamp_seconds), + color=style.COLOR_BUTTON_GREY.get_int(), + font_desc=style.FONT_NORMAL.get_pango_desc(), + xalign=hippo.ALIGNMENT_CENTER) + + box = hippo.CanvasBox(padding=2) + box.append(message) + self._conversation.append(box) + self.add_log_timestamp(timestamp) + + self._last_msg_sender = None + + def add_log_timestamp(self, existing_timestamp=None): + """Add a timestamp entry to the chat log.""" + if existing_timestamp is not None: + self._chat_log += '%s\t\t\n' % existing_timestamp + else: + self._chat_log += '%s\t\t\n' % ( + datetime.strftime(datetime.now(), '%b %d %H:%M:%S')) + + def _add_log(self, nick, color, text, status_message): + """Add the text to the chat log. + + nick -- string, buddy nickname + color -- string, buddy.props.color + text -- string, body of message + status_message -- boolean + """ + if not nick: + nick = '???' + if not color: + color = '#000000,#FFFFFF' + if not text: + text = '-' + if not status_message: + status_message = False + self._chat_log += '%s\t%s\t%s\t%d\t%s\n' % ( + datetime.strftime(datetime.now(), '%b %d %H:%M:%S'), + nick, color, status_message, text) + + def _scroll_value_changed_cb(self, adj, scroll=None): + """Turn auto scrolling on or off. + + If the user scrolled up, turn it off. + If the user scrolled to the bottom, turn it back on. + """ + if adj.get_value() < self._scroll_value: + self._scroll_auto = False + elif adj.get_value() == adj.upper - adj.page_size: + self._scroll_auto = True + + def _scroll_changed_cb(self, adj, scroll=None): + """Scroll the chat window to the bottom""" + if self._scroll_auto: + adj.set_value(adj.upper - adj.page_size) + self._scroll_value = adj.get_value() + + def _link_activated_cb(self, link): + url = _url_check_protocol(link.props.text) + self._show_via_journal(url) + + def _show_via_journal(self, url): + """Ask the journal to display a URL""" + logging.debug('Create journal entry for URL: %s', url) + jobject = datastore.create() + metadata = { + 'title': "%s: %s" % (_('URL from Chat'), url), + 'title_set_by_user': '1', + 'icon-color': profile.get_color().to_string(), + 'mime_type': 'text/uri-list', + } + for k, v in metadata.items(): + jobject.metadata[k] = v + file_path = join(get_activity_root(), 'instance', '%i_' % time.time()) + open(file_path, 'w').write(url + '\r\n') + os.chmod(file_path, 0755) + jobject.set_file_path(file_path) + datastore.write(jobject) + show_object_in_journal(jobject.object_id) + jobject.destroy() + os.unlink(file_path) + + +class _CanvasLink(hippo.CanvasLink): + + def __init__(self, **kwargs): + hippo.CanvasLink.__init__(self, **kwargs) + + def create_palette(self): + return _URLMenu(self.props.text) + + +class _URLMenu(Palette): + + def __init__(self, url): + Palette.__init__(self, url) + + self.owns_clipboard = False + self.url = _url_check_protocol(url) + + menu_item = MenuItem(_('Copy to Clipboard'), 'edit-copy') + menu_item.connect('activate', self._copy_to_clipboard_cb) + self.menu.append(menu_item) + menu_item.show() + + def create_palette(self): + pass + + def _copy_to_clipboard_cb(self, menuitem): + logging.debug('Copy %s to clipboard', self.url) + clipboard = gtk.clipboard_get() + targets = [("text/uri-list", 0, 0), + ("UTF8_STRING", 0, 1)] + + if not clipboard.set_with_data(targets, + self._clipboard_data_get_cb, + self._clipboard_clear_cb, + (self.url)): + logging.error('GtkClipboard.set_with_data failed!') + else: + self.owns_clipboard = True + + def _clipboard_data_get_cb(self, clipboard, selection, info, data): + logging.debug('_clipboard_data_get_cb data=%s target=%s', data, + selection.target) + if selection.target in ['text/uri-list']: + if not selection.set_uris([data]): + logging.debug('failed to set_uris') + else: + logging.debug('not uri') + if not selection.set_text(data): + logging.debug('failed to set_text') + + def _clipboard_clear_cb(self, clipboard, data): + logging.debug('clipboard_clear_cb') + self.owns_clipboard = False + + +def _url_check_protocol(url): + """Check that the url has a protocol, otherwise prepend https:// + + url -- string + + Returns url -- string + """ + protocols = ['http://', 'https://', 'ftp://', 'ftps://'] + no_protocol = True + for protocol in protocols: + if url.startswith(protocol): + no_protocol = False + if no_protocol: + url = 'http://' + url + return url diff --git a/chat/smilies.py b/chat/smilies.py new file mode 100644 index 0000000..c3cc244 --- /dev/null +++ b/chat/smilies.py @@ -0,0 +1,152 @@ +# Copyright 2010, Mukesh Gupta +# Copyright 2010, Aleksey Lim +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import os +from os.path import join, exists +from gettext import gettext as _ + +import gtk +import cairo + +from sugar.graphics import style +from sugar.activity.activity import get_activity_root, get_bundle_path + + +THEME = [ + ('smile', _('Smile'), [':-)', ':)']), + ('wink', _('Winking'), [';-)', ';)']), + ('confused', _('Confused'), [':-/', ':/']), + ('sad', _('Sad'), [':-(', ':(']), + ('grin', _('Grin'), [':-D', ':D']), + ('neutral', _('Neutral'), (':-|', ':|')), + ('shock', _('Shock'), [':-O', ':O', '=-O', '=O']), + ('cool', _('Cool'), ['B-)', 'B)', '8-)', '8)']), + ('tongue', _('Tongue'), [':-P', ':P']), + ('blush', _('Blushing'), [':">']), + ('weep', _('Weeping'), [":'-(", ":'("]), + ('angel', _('Angel'), ['O-)', 'O)', 'O:-)', 'O:)']), + ('shutup', _("Don't tell anyone"), (':-$', ':-$')), + ('angry', _('Angry'), ('x-(', 'x(', 'X-(', 'x-(')), + ('devil', _('Devil'), ('>:>', '>:)')), + ('nerd', _('Nerd'), (':-B', ':B')), + ('kiss', _('Kiss'), (':-*', ':*')), + ('laugh', _('Laughing'), [':))']), + ('sleep', _('Sleepy'), ['I-)']), + ('sick', _('Sick'), [':-&']), + ('eyebrow', _('Raised eyebrows'), ['/:)']), + ] + +SMILIES_SIZE = int(style.STANDARD_ICON_SIZE * 0.75) + +_catalog = None + + +def init(): + """Initialise smilies data.""" + global _catalog + + if _catalog is not None: + return + _catalog = {} + + png_dir = join(get_activity_root(), 'data', 'icons', 'smilies') + svg_dir = join(get_bundle_path(), 'icons', 'smilies') + + if not exists(png_dir): + os.makedirs(png_dir) + + for index, (name, hint, codes) in enumerate(THEME): + png_path = join(png_dir, name + '.png') + + for i in codes: + _catalog[i] = png_path + THEME[index] = (png_path, hint, codes) + + if not exists(png_path): + pixbuf = _from_svg_at_size( + join(svg_dir, name + '.svg'), + SMILIES_SIZE, SMILIES_SIZE, None, True) + pixbuf.save(png_path, 'png') + + +def parse(text): + """Initialise smilies data. + + :param text: + string to parse for smilies + :returns: + array of string parts and ciaro surfaces + + """ + result = [text] + + for smiley in sorted(_catalog.keys(), lambda x, y: cmp(len(y), len(x))): + smiley_surface = cairo.ImageSurface.create_from_png(_catalog[smiley]) + new_result = [] + + for word in result: + if isinstance(word, cairo.ImageSurface): + new_result.append(word) + else: + parts = word.split(smiley) + for i in parts[:-1]: + new_result.append(i) + new_result.append(smiley_surface) + new_result.append(parts[-1]) + + result = new_result + + return result + + +def _from_svg_at_size(filename=None, width=None, height=None, handle=None, + keep_ratio=True): + """Scale and load SVG into pixbuf.""" + import rsvg + + if not handle: + handle = rsvg.Handle(filename) + + dimensions = handle.get_dimension_data() + icon_width = dimensions[0] + icon_height = dimensions[1] + if icon_width != width or icon_height != height: + ratio_width = float(width) / icon_width + ratio_height = float(height) / icon_height + + if keep_ratio: + ratio = min(ratio_width, ratio_height) + if ratio_width != ratio: + ratio_width = ratio + width = int(icon_width * ratio) + elif ratio_height != ratio: + ratio_height = ratio + height = int(icon_height * ratio) + else: + ratio_width = 1 + ratio_height = 1 + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + context = cairo.Context(surface) + context.scale(ratio_width, ratio_height) + handle.render_cairo(context) + + loader = gtk.gdk.pixbuf_loader_new_with_mime_type('image/png') + surface.write_to_png(loader) + loader.close() + + return loader.get_pixbuf() -- cgit v0.9.1