#!/usr/bin/python
# -*- coding: utf-8 -*-
# Mitter, a Maemo client for Twitter.
# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda
#
# 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 gtk
import gobject
gobject.threads_init()
import logging
import threading
import Queue
import re
import urllib2
from mitterlib.ui.helpers.image_helpers import find_image
from mitterlib import htmlize
from mitterlib.constants import gpl_3, version
from mitterlib.ui.helpers import timesince
# Constants
_log = logging.getLogger('ui.pygtk')
URL_RE = re.compile(r'(https?://[^\s\n\r]+)', re.I)
# ----------------------------------------------------------------------
# Helper Functions (not related to objects or that don't need direct access to
# the objects contents.)
# ----------------------------------------------------------------------
def _buffer_text(text_buffer):
"""Return the content of a gtk.TextBuffer."""
start = text_buffer.get_start_iter()
end = text_buffer.get_end_iter()
text = text_buffer.get_text(start, end, include_hidden_chars=False)
return text
# ----------------------------------------------------------------------
# Threading related objects.
# These classes are based on the code available at http://gist.github.com/51686
# (c) 2008, John Stowers
# ----------------------------------------------------------------------
class _IdleObject(gobject.GObject):
"""
Override gobject.GObject to always emit signals in the main thread
by emmitting on an idle handler
"""
def __init__(self):
gobject.GObject.__init__(self)
def emit(self, *args):
gobject.idle_add(gobject.GObject.emit, self, *args)
class _WorkerThread(threading.Thread, _IdleObject):
"""
A single working thread.
"""
__gsignals__ = {
"completed": (
gobject.SIGNAL_RUN_LAST,
gobject.TYPE_NONE,
(gobject.TYPE_PYOBJECT,)), # list/networkdata
"exception": (
gobject.SIGNAL_RUN_LAST,
gobject.TYPE_NONE,
(gobject.TYPE_PYOBJECT,)) # The exception
}
def __init__(self, function, *args, **kwargs):
threading.Thread.__init__(self)
_IdleObject.__init__(self)
self._function = function
self._args = args
self._kwargs = kwargs
def run(self):
# call the function
_log.debug('Thread %s calling %s', self.name, str(self._function))
args = self._args
kwargs = self._kwargs
try:
result = self._function(*args, **kwargs)
except Exception, exc: # Catch ALL exceptions
# TODO: Check if this catch all warnins too!
_log.debug('Exception %s', str(exc))
self.emit("exception", exc)
return
_log.debug('Thread %s completed', self.name)
self.emit("completed", result)
return
class _ThreadManager(object):
"""Manages the threads."""
def __init__(self, max_threads=2):
"""Start the thread pool. The number of threads in the pool is defined
by `pool_size`, defaults to 2."""
self._max_threads = max_threads
self._thread_pool = []
self._running = []
self._thread_id = 0
return
def _remove_thread(self, widget, arg=None):
"""Called when the thread completes. We remove it from the thread list
(dictionary, actually) and start the next thread (if there is one)."""
# not actually a widget. It's the object that emitted the signal, in
# this case, the _WorkerThread object.
thread_id = widget.name
_log.debug('Thread %s completed, %d threads in the queue', thread_id,
len(self._thread_pool))
self._running.remove(thread_id)
if self._thread_pool:
if len(self._running) < self._max_threads:
next = self._thread_pool.pop()
_log.debug('Dequeuing thread %s', next.name)
self._running.append(next.name)
next.start()
return
def add_work(self, complete_cb, exception_cb, func, *args, **kwargs):
"""Add a work to the thread list. `complete_cb` is the function to be
called with the result of the work. `exception_cb` is the function to
be called if there are any exceptions raised. Note that, once the
work is complete, one of those will be called, not both. `func` is the
function to be called in the secondary threads. `args` and `kwargs`
are parameters passed to the function."""
thread = _WorkerThread(func, *args, **kwargs)
thread_id = '%s' % (self._thread_id)
thread.connect('completed', complete_cb)
thread.connect('completed', self._remove_thread)
thread.connect('exception', exception_cb)
thread.setName(thread_id)
if len(self._running) < self._max_threads:
# immediatelly start the thread
self._running.append(thread_id)
thread.start()
else:
# add the thread to the queue
running_names = ', '.join(self._running)
_log.debug('Threads %s running, adding %s to the queue',
running_names, thread_id)
self._thread_pool.append(thread)
self._thread_id += 1
return
# ----------------------------------------------------------------------
# Mitter interface object
# ----------------------------------------------------------------------
class Interface(object):
"""Linux/GTK interface for Mitter."""
NAMESPACE = 'pygtk'
# ------------------------------------------------------------
# Widget creation functions
# ------------------------------------------------------------
def _create_main_window(self):
"""Returns the object with the main window and the attached
widgets."""
main_window = gtk.Window(gtk.WINDOW_TOPLEVEL)
initial_width = int(self._options[self.NAMESPACE]['width'])
initial_height = int(self._options[self.NAMESPACE]['height'])
_log.debug('Initial size: %d x %d', initial_width, initial_height)
initial_x = int(self._options[self.NAMESPACE]['position_x'])
initial_y = int(self._options[self.NAMESPACE]['position_y'])
_log.debug('Initial position: %d x %d', initial_x, initial_y)
main_window.set_title('Mitter')
main_window.set_size_request(450, 300) # very small minimal size
main_window.resize(initial_width, initial_height)
main_window.move(initial_x, initial_y)
if self._app_icon:
main_window.set_icon_from_file(self._app_icon)
main_window.connect('destroy', self._quit_app)
main_window.connect('delete-event', self._quit_app)
main_window.connect('size-request', self._grid_resize)
grid = self._create_grid()
(menu, toolbar, accelerators) = self._create_menu_and_toolbar()
update_field = self._create_update_box()
statusbar = self._create_statusbar()
update_box = gtk.VPaned()
update_box.pack1(grid, resize=True, shrink=False)
update_box.pack2(update_field, resize=False, shrink=True)
# TODO: Save the size of the update box in the config file.
box = gtk.VBox(False, 1)
box.pack_start(menu, False, True, 0)
box.pack_start(toolbar, False, False, 0)
box.pack_start(update_box, True, True, 0)
box.pack_start(statusbar, False, False, 0)
main_window.add(box)
main_window.add_accel_group(accelerators)
return main_window
def _create_grid(self):
"""Add the displaying grid."""
# Store NetworkData objects only
grid_store = gtk.ListStore(object)
grid_store.set_sort_column_id(0, gtk.SORT_ASCENDING)
grid_store.set_sort_func(0, self._order_datetime)
self._grid = gtk.TreeView(grid_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(48) # avatar size (we resize to 48x48)
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', gtk.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('row-activated', self.open_post)
#self.grid.connect('button-press-event', self.click_post)
#self.grid.connect('popup-menu',
# lambda view: self.show_post_popup(view, None))
scrolled_window = gtk.ScrolledWindow()
scrolled_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
scrolled_window.add(self._grid)
return scrolled_window
def _create_menu_and_toolbar(self):
"""Create the main menu and the toolbar."""
# UI elements
ui_elements = '''
'''
# The group with all actions; we are going to split them using the
# definitions inside the XML.
action_group = gtk.ActionGroup('Mitter')
# Actions related to the UI elements above
# Top-level menu actions
file_action = gtk.Action('File', '_File', 'File', None)
action_group.add_action(file_action)
edit_action = gtk.Action('Edit', '_Edit', 'Edit', None)
action_group.add_action(edit_action)
message_action = gtk.Action('Message', '_Message',
'Message related options', None)
action_group.add_action(message_action)
help_action = gtk.Action('Help', '_Help', 'Help', None)
action_group.add_action(help_action)
# File actions
quit_action = gtk.Action('Quit', '_Quit',
'Exit Mitter', gtk.STOCK_QUIT)
quit_action.connect('activate', self._quit_app)
action_group.add_action_with_accel(quit_action, None)
# Edit actions
refresh_action = gtk.Action('Refresh', '_Refresh',
'Update the listing', gtk.STOCK_REFRESH)
refresh_action.connect('activate', self._refresh)
action_group.add_action_with_accel(refresh_action, None)
self._update_action = gtk.Action('Update', '_Update',
'Update your status', gtk.STOCK_ADD)
self._update_action.set_property('sensitive', False)
self._update_action.connect('activate', self._update_status)
action_group.add_action_with_accel(self._update_action,
'Return')
clear_action = gtk.Action('Clear', '_Clear',
'Clear the message list', gtk.STOCK_CLEAR)
action_group.add_action_with_accel(clear_action, None)
# TODO: Connect
#shrink_url_action = gtk.Action('ShrinkURL', 'Shrink _URL',
# 'Shrink selected URL', gtk.STOCK_EXECUTE)
#shrink_url_action.connect('activate', self.shrink_url)
#self.action_group.add_action_with_accel(shrink_url_action, 'u')
#mute_action = gtk.ToggleAction('MuteNotify', '_Mute Notifications',
# 'Mutes notifications on new tweets', gtk.STOCK_MEDIA_PAUSE)
#mute_action.set_active(False)
#self.action_group.add_action_with_accel(mute_action, 'm')
settings_action = gtk.Action('Settings', '_Settings',
'Settings', gtk.STOCK_PREFERENCES)
#settings_action.connect('activate', self.show_settings)
action_group.add_action(settings_action)
# Message actions
self._delete_action = gtk.Action('Delete', '_Delete',
'Delete a post', gtk.STOCK_DELETE)
self._delete_action.set_property('sensitive', False)
# TODO: Reconnect
#delete_action.connect('activate', self.delete_tweet)
action_group.add_action_with_accel(self._delete_action, '')
# TODO: Check the accelerator
self._reply_action = gtk.Action('Reply', '_Reply',
"Send a response to someone's else message", gtk.STOCK_REDO)
self._reply_action.set_property('sensitive', False)
# TODO: Connect
# TODO: Connecting function should indicate in the status bar that
# a reply will be made
action_group.add_action_with_accel(self._reply_action, 'r')
# TODO: Find a better word (non-network related) to this
self._repost_action = gtk.Action('Repost', 'Re_post',
"Put someone's else message on your timeline",
gtk.STOCK_CONVERT)
self._repost_action.set_property('sensitive', False)
# TODO: Connect
# TODO: Connecting function should indicate in the status bar that
# a repost will be made.
action_group.add_action_with_accel(self._repost_action, 't')
# Help actions
about_action = gtk.Action('About', '_About', 'About Mitter',
gtk.STOCK_ABOUT)
about_action.connect('activate', self._show_about)
action_group.add_action(about_action)
# definition of the UI
uimanager = gtk.UIManager()
uimanager.insert_action_group(action_group, 0)
uimanager.add_ui_from_string(ui_elements)
main_menu = uimanager.get_widget('/MainMenu')
main_toolbar = uimanager.get_widget('/MainToolbar')
return (main_menu, main_toolbar, uimanager.get_accel_group())
def _create_update_box(self):
"""Create the widgets related to the update box"""
self._update_text = gtk.TextView()
text_buffer = self._update_text.get_buffer()
text_buffer.connect('changed', self._count_chars)
self._update_button = gtk.Button(label = '(140)')
self._update_button.connect('clicked', self._update_status)
self._update_button.set_property('sensitive', False)
# TODO: We'll need a cancel button to cancel replies/repost.
update_box = gtk.HBox(False, 0)
update_box.pack_start(self._update_text, expand=True, fill=True,
padding=0)
update_box.pack_start(self._update_button, expand=False, fill=False,
padding=0)
update_area = gtk.VBox(True, 0)
update_area.pack_start(update_box)
""" Spell checking the update box """
spell_check_enabled = self._options[self.NAMESPACE]['spell_check']
if spell_check_enabled:
try:
import gtkspell
import locale
self.spell_check_support = True
language = locale.getlocale()[0]
self.spell_check = gtkspell.Spell(self._update_text, language)
_log.debug('Spell checking turned on with language: %s' \
% (language))
except:
self._options[self.NAMESPACE]['spell_check'] = False
self.spell_check_support = False
_log.debug('Error initializing spell checking: ' \
'spell checking disabled')
return update_area
def _create_statusbar(self):
"""Create the statusbar."""
statusbar = gtk.Statusbar()
# TODO: Probaly set the context in the object.
return statusbar
def _show_about(self, widget):
"""Show the about dialog."""
about_window = gtk.AboutDialog()
about_window.set_name('Mitter')
about_window.set_version(version)
about_window.set_copyright('2007-2009 Mitter Contributors')
about_window.set_license(gpl_3)
about_window.set_website('http://code.google.com/p/mitter')
about_window.set_website_label('Mitter on GoogleCode')
about_window.set_authors([
'Main developers:',
'Julio Biason',
'Deepak Sarda',
'Gerald Kaszuba',
' ',
'And patches from:',
'Santiago Gala',
'Sugree Phatanapherom',
'Kristian Rietveld',
'"Wiennat"',
'Philip Reynolds',
'Greg McIntyre',
'"Alexander"'])
if self._app_icon:
_log.debug('Icon: %s', self._app_icon)
about_window.set_logo(gtk.gdk.pixbuf_new_from_file(
self._app_icon))
about_window.run()
about_window.hide()
# ------------------------------------------------------------
# Cell rendering functions
# ------------------------------------------------------------
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.avatar
if not pic in self._avatars:
self._threads.add_work(self._post_download_pic,
self._exception_download_pic,
self._download_pic,
pic)
# set the user avatar to the default image, so it won't get queued
# again. Once downloaded, the _post_download_pic will update the
# image and force a redraw.
self._avatars[pic] = self._default_pixmap
cell.set_property('pixbuf', self._avatars[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)
message = data.message
username = data.username
time = timesince.timesince(data.message_time)
# unescape escaped entities that pango is not okay with
message = re.sub(r'&', r'&', message)
#_log.debug('Rendering message: %s', message)
# highlight URLs
mask = r'\1' % (
self._options[self.NAMESPACE]['link_colour'])
message = URL_RE.sub(mask, message)
# use a different highlight for the current user
# TODO: How to handle this with several networks?
# SOLUTION: self._connection.is_self(message)
#message = re.sub(r'(@'+self.twitter.username+')',
# r'\1',
# message)
markup = '%s (%s):\n%s\n%s' % \
(data.name, username, message, time)
cell.set_property('markup', markup)
return
def _cell_renderer_options(self, column, cell, store, position):
"""Callback for the options renderer. Adds the delete icon if the
message belongs to the user or reply if not."""
data = store.get_value(position, 0)
cell.set_property('pixbuf', self._reply_pixbuf)
return
# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------
def _update_sensitivity(self, enabled):
"""Set the "sensitive" property of the update action and button. Both
should have the same property, so whenever you need to disable/enable
them, use this function."""
self._update_button.set_property('sensitive', enabled)
self._update_action.set_property('sensitive', enabled)
return
# ------------------------------------------------------------
# Widget callback functions
# ------------------------------------------------------------
def _count_chars(self, text_buffer):
"""Count the number of chars in the edit field and update the
label that shows the available space."""
text = _buffer_text(text_buffer)
count = len(text)
self._update_button.set_label('(%d)' % (140 - count))
self._update_sensitivity(not (count == 0))
return True
def _update_status(self, widget):
"""Update your status."""
_log.debug('Updating status.')
status = _buffer_text(self._update_text.get_buffer())
status = status.strip()
if not status:
return
_log.debug('Status: %s', status)
self._update_sensitivity(False)
self._threads.add_work(self._post_update_status,
self._exception_update_status,
self._connection.update,
status)
# TODO: We are not dealing with replies here yet
return
def _quit_app(self, widget=None, user_data=None):
"""Callback when the window is destroyed or the user selects
"Quit"."""
(x, y) = self._main_window.get_position()
_log.debug('Current position: %d x %d', x, y)
self._options[self.NAMESPACE]['position_x'] = x
self._options[self.NAMESPACE]['position_y'] = y
(width, height) = self._main_window.get_size()
_log.debug('Current window size: %d x %d', width, height)
self._options[self.NAMESPACE]['width'] = width
self._options[self.NAMESPACE]['height'] = height
gtk.main_quit()
return
def _grid_resize(self, widget, requisition, data=None):
"""Called when the window is resized. We use it to set the proper
word-wrapping in the message column."""
model = self._grid.get_model()
if len(model) == 0:
# nothing in the list, so we don't have what to set proper word
# wrapping
return
(win_width, win_height) = self._main_window.get_size()
#_log.debug('Widget size: %d', win_width)
column = self._grid.get_column(1)
iter = model.get_iter_first()
path = model.get_path(iter)
cell_rectangle = self._grid.get_cell_area(path, column)
width = win_width - 70 # 48 = icon size
# TODO: Find out where those 12 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
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 _message_selected(self, view, user_data=None):
"""Callback when a row in the list is selected. Mostly, we'll check
if the message is from the logged user and we change de "sensitive"
property of the message actions to reflect what the user can and
cannot do."""
(model, iter) = view.get_selection().get_selected()
data = model.get_value(iter, 0)
self._delete_action.set_property('sensitive',
self._connection.can_delete(data))
self._reply_action.set_property('sensitive',
self._connection.can_reply(data))
self._repost_action.set_property('sensitive',
self._connection.can_repost(data))
return 0
# ------------------------------------------------------------
# Network related functions
# ------------------------------------------------------------
def _refresh(self, widget=None):
"""Request a refresh. *widget* is the widget that called this
function (we basically ignore it.)"""
self._threads.add_work(self._post_get_messages,
self._exception_get_messages,
self._connection.messages)
return
### Results from the "messages" request
def _post_get_messages(self, widget, results):
"""Function called after the data from the messages list is
retrieved."""
_log.debug('%d new tweets', len(results))
store = self._grid.get_model()
for message in results:
_log.debug('Data: %s', str(message))
store.prepend([message])
store.sort_column_changed()
self._grid.queue_draw()
return
def _exception_get_messages(self, widget, exception):
"""Function called if the retrival of current messages returns an
exception."""
_log.debug(str(exception))
return
### image download function
def _download_pic(self, url):
"""Download a picture from the web. Can be used in a thread."""
request = urllib2.Request(url=url)
_log.debug('Starting request of %s' % (url))
response = urllib2.urlopen(request)
data = response.read()
_log.debug('Request completed')
return (url, data)
### Results from the picture request
def _post_download_pic(self, widget, data):
"""Called after the data from the picture is available."""
(url, data) = data
loader = gtk.gdk.PixbufLoader()
loader.write(data)
loader.close()
user_pic = loader.get_pixbuf()
user_pic = user_pic.scale_simple(48, 48, gtk.gdk.INTERP_BILINEAR)
self._avatars[url] = user_pic
self._grid.queue_draw()
return
def _exception_download_pic(self, widget, exception):
"""Called in case we have a problem downloading an user avatar."""
_log.debug('Exception trying to get an avatar.')
_log.debug(str(exception))
return
### Results for the update status call
def _post_update_status(self, widget, data):
"""Called when the status is updated correctly."""
self._update_sensitivity(True)
self._update_text.get_buffer().set_text('')
return
def _exception_update_status(self, widget, exception):
"""Called when there is an exception updating the status."""
# TODO: Need the check the type of exception we got.
_log.debug('Update error')
_log.debug(str(exception))
return
# ------------------------------------------------------------
# Required functions for all interfaces
# ------------------------------------------------------------
def __init__(self, connection, options):
"""Start the interface. `connection` is the :class:`Networks` object
with all the available networks. `options` is the :class:`ConfigOpt`
object with the configuration to run Mitter."""
self._connection = connection
self._options = options
self._avatars = {}
self._pic_queue = set()
# Load images
self._app_icon = find_image('mitter.png')
self._app_icon_alert = find_image('mitter-new.png')
self._reply_pixbuf = gtk.gdk.pixbuf_new_from_file(
find_image('reply.png'))
#self._delete_icon = find_image('icon_trash.gif')
unknown_pixbuf = find_image('unknown.png')
if unknown_pixbuf:
self._default_pixmap = gtk.gdk.pixbuf_new_from_file(
unknown_pixbuf)
else:
self._default_pixmap = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
has_alpha=False, bits_per_sample=8, width=48, height=48)
return
def __call__(self):
"""Call function; displays the interface. This method should appear on
every interface."""
self._main_window = self._create_main_window()
self._main_window.show_all()
self._threads = _ThreadManager()
# queue the first fetch
self._refresh()
gtk.main()
@classmethod
def options(self, options):
"""Add the options for this interface."""
options.add_group(self.NAMESPACE, 'GTK+ Interface')
options.add_option('--refresh-interval',
group=self.NAMESPACE,
option='refresh_interval',
help='Refresh interval',
type='int',
metavar='MINUTES',
default=5,
conflict_group='interface')
# Most of the options for non-cmd-options are useless, but I'm keeping
# them as documentation.
options.add_option(
group=self.NAMESPACE,
option='width',
help='Window width',
type='int',
metavar='PIXELS',
default=450,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=self.NAMESPACE,
option='height',
help='Window height',
type='int',
metavar='PIXELS',
default=300,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=self.NAMESPACE,
option='position_x',
help='Window position on the X axis',
type='int',
metavar='PIXELS',
default=5,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=self.NAMESPACE,
option='position_y',
help='Window position on the Y axis',
type='int',
metavar='PIXELS',
default=5,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=self.NAMESPACE,
option='max_status_display',
help='Maximum number of elements to keep internally',
type='int',
metavar='MESSAGES',
default=60,
conflict_group='interface',
is_cmd_option=False) # TODO: Should it be config only?
options.add_option(
group=self.NAMESPACE,
option='link_colour',
help='Color of links in the interface',
type='str',
metavar='COLOR',
default='blue',
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=self.NAMESPACE,
option='spell_check',
help='Spell checking update text',
type='boolean',
metavar='SPELL',
default=False,
conflict_group='interface',
is_cmd_option=False)