A micro-blogging tool with multiple interfaces.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

459 lines
16 KiB

#!/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 <http://www.gnu.org/licenses/>.
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'
'<b>%(full_name)s</b> '
'<small>(%(username)s'
'%(message_type)s'
')</small>:'
'%(read_status)s '
'%(protected_status)s '
'\n'
'%(message)s\n'
'<small>%(message_age)s</small>')
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'<u><span foreground="%s">\1</span></u>' % (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'<span foreground="%s">\1</span>' % (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'<span foreground="%s">\1</span>' % (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'<span foreground="%s">\1</span>' % (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'<span foreground="#FF6633">\1</span>',
# 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(_(' &#8212; <i>reposted by %s</i>') %
(data.reposted_by.username))
if data.parent_owner:
info.append(_(' &#8212; <i>in reply to %s</i>') %
(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:
self._grid.get_selection().select_iter(iter)
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