#!/usr/bin/env python # -*- coding: utf-8 -*- # Mitter, a micro-blogging client with multiple interfaces. # Copyright (C) 2007-2010 Mitter Contributors # # 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 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 General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import gobject import gtk import pango import logging import re import gettext import webbrowser from cgi import escape as html_escape import timesince # ---------------------------------------------------------------------- # Constants for the message # ---------------------------------------------------------------------- MESSAGE_FORMAT = ('%(favorite_star)s' '%(full_name)s ' '(%(username)s' '%(message_type)s' '):' '%(read_status)s ' '%(protected_status)s ' '\n' '%(message)s\n' '%(message_age)s') URL_RE = re.compile(r'(https?://[^\s\n\r]+)', re.I) # ---------------------------------------------------------------------- # Logging # ---------------------------------------------------------------------- _log = logging.getLogger('ui.pygtk.messagegrid') # ---------------------------------------------------------------------- # I18n bits # ---------------------------------------------------------------------- t = gettext.translation('ui_pygtk', fallback=True) _ = t.gettext N_ = t.ngettext # ---------------------------------------------------------------------- # MessageGrid class # ---------------------------------------------------------------------- class MessageGrid(gtk.ScrolledWindow, gobject.GObject): """Custom message grid.""" __gsignals__ = { "count-changed": ( # the number of unread messages changed gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_INT, )), 'message-changed': ( # the selected message changed gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT, ))} def _get_count(self): """Number of unread messages.""" return self._message_count def _set_count(self, messages): """Update the number of unread messages.""" self._message_count = messages self.emit('count-changed', messages) return count = property(_get_count, _set_count) @property def selected(self): """Return the selected message or None if there is no selected message.""" (model, iter) = self._grid.get_selection().get_selected() if not iter: return None data = model.get_value(iter, 0) # just so we can track this message again data.grid = self data.iter = iter return data def __init__(self, avatar_cache): super(MessageGrid, self).__init__() self.avatars = avatar_cache self.link_color = '#0000ff' self.user_color = '#00ff00' self.tag_color = '#ff0000' self.group_color = '#ffff00' self.unread_char = '(o)' self.favorite_char = '(F)' self.unfavorite_char = '( )' self.protected_char = '(protected)' self._message_count = 0 store = gtk.ListStore(object) store.set_sort_column_id(0, gtk.SORT_ASCENDING) store.set_sort_func(0, self._order_datetime) self._grid = gtk.TreeView(store) self._grid.set_property('headers-visible', False) self._grid.set_rules_hint(True) # change color for each row user_renderer = gtk.CellRendererPixbuf() user_column = gtk.TreeViewColumn('User', user_renderer) user_column.set_fixed_width(54) # avatar size (we resize to 48x48) # TODO: Check the border size for this. "54" is guesswork. user_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) user_column.set_cell_data_func(user_renderer, self._cell_renderer_user) message_renderer = gtk.CellRendererText() message_renderer.set_property('wrap-mode', pango.WRAP_WORD) message_renderer.set_property('wrap-width', 200) message_column = gtk.TreeViewColumn('Message', message_renderer) message_column.set_cell_data_func(message_renderer, self._cell_renderer_message) self._grid.append_column(user_column) self._grid.append_column(message_column) self._grid.set_resize_mode(gtk.RESIZE_IMMEDIATE) self._grid.connect('cursor-changed', self._message_selected) self._grid.connect('button-press-event', self._click_message) self._grid.connect('popup-menu', self._message_popup) # Menu button self.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) self.add(self._grid) return #----------------------------------------------------------------------- # Renderers #----------------------------------------------------------------------- def _cell_renderer_user(self, column, cell, store, position): """Callback for the user column. Used to created the pixbuf of the userpic.""" data = store.get_value(position, 0) pic = data.author.avatar cell.set_property('pixbuf', self.avatars.get(self, pic)) return def _cell_renderer_message(self, column, cell, store, position): """Callback for the message column. We need this to adjust the markup property of the cell, as setting it as text won't do any markup processing.""" data = store.get_value(position, 0) time = timesince.timesince(data.message_time) message_values = {} # unescape escaped entities that pango is not okay with message_values['message'] = html_escape(data.message) message_values['username'] = html_escape(data.author.username) message_values['full_name'] = html_escape(data.author.name) message_values['message_age'] = time # highlight URLs mask = r'\1' % (self.link_color) message_values['message'] = URL_RE.sub(mask, message_values['message']) # highlight users if data.user_regexp: user_mask = '(%s)' % (data.user_regexp) user_re = re.compile(user_mask, re.I) mask = r'\1' % (self.user_color) message_values['message'] = user_re.sub(mask, message_values['message']) # highlight groups if data.group_regexp: group_mask = '(%s)' % (data.group_regexp) group_re = re.compile(group_mask, re.I) mask = r'\1' % (self.group_color) message_values['message'] = group_re.sub(mask, message_values['message']) # highlight tags if data.tag_regexp: tag_mask = '(%s)' % (data.tag_regexp) tag_re = re.compile(tag_mask, re.I) mask = r'\1' % (self.tag_color) message_values['message'] = tag_re.sub(mask, message_values['message']) # use a different highlight for the current user # TODO: How to handle this with several networks? #message = re.sub(r'(@'+self.twitter.username+')', # r'\1', # message) if not data.read: message_values['read_status'] = (' ' + self.unread_char) else: message_values['read_status'] = '' if data.favorite: message_values['favorite_star'] = (self.unfavorite_char) else: message_values['favorite_star'] = (self.favorite_char) info = [] if data.reposted_by: info.append(_(' — reposted by %s') % (data.reposted_by.username)) if data.parent_owner: info.append(_(' — in reply to %s') % (data.parent_owner.username)) if data.protected: message_values['protected_status'] = (self.protected_char) else: message_values['protected_status'] = '' message_values['message_type'] = ''.join(info) markup = MESSAGE_FORMAT % (message_values) # This log is massive, but it can help when the grid doesn't show any # messages (usually when there is something broken with the markup.) #_log.debug('Markup:\n%s' % (markup)) cell.set_property('markup', markup) return #----------------------------------------------------------------------- # Widget callbacks #----------------------------------------------------------------------- def _message_selected(self, view, user_data=None): """Callback when a row in the list is selected.""" message = self.selected if not message: return 0 if not message.read: self.count -= 1 message.read = True self.emit('message-changed', message) return 0 def _click_message(self, widget, event, user_data=None): """Check the click on the message and, if it's a right click, call the _message_popup function to show the popup.""" if event.button != 3: # not right click return False path = widget.get_path_at_pos(int(event.x), int(event.y)) if not path: return False (path, _, _, _) = path return self._url_popup(widget, path, event) def _message_popup(self, widget, user_data=None): """Builds the popup with the URLs in the message.""" (path, _) = widget.get_cursor() if not path: return True # create a syntetic event (the popup requires one) event = gtk.gdk.Event(gtk.gdk.BUTTON_PRESS) event.button = 3 return self._url_popup(widget, path, event) def _url_popup(self, widget, path, event): """Builds the popup with URLs in the cell pointed by *path*. Requires the *event* that the widget received.""" iter = widget.get_model().get_iter(path) message = widget.get_model().get_value(iter, 0) items = [] if message.link: items.append((_('Open on %s') % (message.network_name), message.link)) urls = URL_RE.findall(message.message) for url in urls: if len(url) > 20: title = url[:20] + '...' else: title = url items.append((title, url)) if len(items) == 0: return popup = gtk.Menu() for url in items: (title, url) = url item = gtk.MenuItem(title) item.connect('activate', self._open_url, url) popup.append(item) popup.show_all() popup.popup(None, None, None, event.button, event.time) return True def _order_datetime(self, model, iter1, iter2, user_data=None): """Used by the ListStore to sort the columns (in our case, "column") by date.""" message1 = model.get_value(iter1, 0) message2 = model.get_value(iter2, 0) if (not message1) or \ (not message1.message_time) or \ (message1.message_time > message2.message_time): return -1 if (not message2) or \ (not message2.message_time) or \ (message2.message_time > message1.message_time): return 1 return 0 def _open_url(self, widget, user_data=None): """Opens an URL (used mostly from popup menu items.)""" webbrowser.open_new_tab(user_data) return def update_window_size(self, size): """Called when the window size changes, so the grid needs to change its word-wrapping size.""" model = self._grid.get_model() if len(model) == 0: return column = self._grid.get_column(1) # 0=avatar, 1=message iter = model.get_iter_first() path = model.get_path(iter) width = size - 90 # 48 = icon size # TODO: Find out where those 37 pixels came from and/or if they # are platform specific. for renderer in column.get_cell_renderers(): renderer.set_property('wrap-width', width) while iter: path = model.get_path(iter) model.row_changed(path, iter) iter = model.iter_next(iter) return #----------------------------------------------------------------------- # Class methods #----------------------------------------------------------------------- @staticmethod def delete(message): """Removes a message from the grid.""" if not hasattr(message, 'grid'): _log.debug('No grid information in the message') return grid = message.grid grid.remove(message) return #----------------------------------------------------------------------- # Public functions #----------------------------------------------------------------------- def update(self, messages): """Update the grid with new messages.""" self._grid.freeze_notify() store = self._grid.get_model() # disconnect the store, so we are free to add rows without hogging the # system. Also, save the current selection and grid position so it can # be restore (selecting is lost when the model is connected and the # view moves with the new elements.) (model, iter) = self._grid.get_selection().get_selected() self._grid.set_model(None) for message in messages: message.read = False store.prepend([message]) # reconnect the store and re-select the previously selected line self._grid.set_model(store) if iter: path = store.get_path(iter) self._grid.get_selection().select_iter(iter) self._grid.scroll_to_cell(path, None, False, 0, 0) self._grid.thaw_notify() self._grid.queue_draw() new_messages = len(messages) _log.debug('%d new messages', new_messages) self.count += new_messages return def clear_posts(self): """Clear the list of posts from the grid.""" self._grid.get_model().clear() self.count = 0 return def mark_all_read(self): """Mark all messages as read.""" model = self._grid.get_model() if len(model) == 0: # no messages, so we don't worry (if I recall correctly, # get_iter_first will fail if the model is empty) return iter = model.get_iter_first() while iter: message = model.get_value(iter, 0) message.read = True iter = model.iter_next(iter) self.count = 0 return def queue_draw(self): """Make the grid to be redraw when possible.""" # we need this 'cause _grid is not exposed. self._grid.queue_draw() return def remove(self, message): """Remove a message from the list""" if not hasattr(message, 'iter'): _log.debug('No iterator information in the message') return iter = message.iter self._grid.get_model().remove(iter) return