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.
 
 

854 lines
31 KiB

#!/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 <http://www.gnu.org/licenses/>.
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 <john.stowers@gmail.com>
# ----------------------------------------------------------------------
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)
box = gtk.VBox(False, 1)
box.pack_start(menu, False, True, 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.check_post)
#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 = '''
<ui>
<toolbar name="MainToolbar">
<toolitem action="Refresh" />
<separator />
<toolitem action="Delete" />
<toolitem action="Reply" />
<toolitem action="Retweet" />
<separator />
<toolitem action="Settings" />
<toolitem action="Quit" />
</toolbar>
<menubar name="MainMenu">
<menu action="File">
<menuitem action="Quit" />
</menu>
<menu action="Edit">
<menuitem action="Refresh" />
<menuitem action="Update" />
<menuitem action="ShrinkURL" />
<menuitem action="MuteNotify" />
<separator />
<menuitem action="Settings" />
</menu>
<menu action="Message">
<menuitem action="Delete" />
<menuitem action="Reply" />
<menuitem action="Retweet" />
</menu>
<menu action="Help">
<menuitem action="About" />
</menu>
</menubar>
</ui>
'''
# 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, 'F5')
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,
'<Ctrl>Return')
#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, '<Ctrl>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, '<Ctrl>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
delete_action = gtk.Action('Delete', '_Delete', 'Delete a post',
gtk.STOCK_DELETE)
delete_action.set_property('sensitive', False)
#delete_action.connect('activate', self.delete_tweet)
action_group.add_action(delete_action)
reply_action = gtk.Action('Reply', '_Reply',
"Send a response to someone's else message", gtk.STOCK_REDO)
reply_action.set_property('sensitive', False)
# TODO: Connect
action_group.add_action(reply_action)
# TODO: Find a better word (non-network related) to this
retweet_action = gtk.Action('Retweet', 'Re_tweet',
"Put someone's else message on your timeline",
gtk.STOCK_CONVERT)
retweet_action.set_property('sensitive', False)
# TODO: Connect
action_group.add_action(retweet_action)
# 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)
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'&amp;', message)
#_log.debug('Rendering message: %s', message)
# highlight URLs
mask = r'<span foreground="%s">\1</span>' % (
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?
#message = re.sub(r'(@'+self.twitter.username+')',
# r'<span foreground="#FF6633">\1</span>',
# message)
markup = '<b>%s</b> <small>(%s)</small>:\n%s\n<small>%s</small>' % \
(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."""
_log.debug('Counting chars')
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
# ------------------------------------------------------------
# Network related functions
# ------------------------------------------------------------
### 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._threads.add_work(self._post_get_messages,
self._exception_get_messages,
self._connection.messages)
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)