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.
1274 lines
46 KiB
1274 lines
46 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 |
|
import webbrowser |
|
|
|
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 d %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 id %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 id %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._images['main']: |
|
main_window.set_icon(self._images['main']) |
|
|
|
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() |
|
self._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(self._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('button-press-event', self._click_message) |
|
self._grid.connect('popup-menu', self._message_popup) # Menu button |
|
self._grid.connect('cursor-changed', self._mark_message_read) |
|
|
|
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" /> |
|
<toolitem action="Clear" /> |
|
<separator /> |
|
<toolitem action="Delete" /> |
|
<toolitem action="Reply" /> |
|
<toolitem action="Repost" /> |
|
<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="Clear" /> |
|
<separator /> |
|
<menuitem action="Update" /> |
|
<menuitem action="Cancel" /> |
|
<menuitem action="ShrinkURL" /> |
|
<menuitem action="MuteNotify" /> |
|
<separator /> |
|
<menuitem action="Settings" /> |
|
</menu> |
|
<menu action="Message"> |
|
<menuitem action="Delete" /> |
|
<menuitem action="Reply" /> |
|
<menuitem action="Repost" /> |
|
</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, 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, |
|
'<Ctrl>Return') |
|
|
|
self._cancel_action = gtk.Action('Cancel', '_Cancel', |
|
'Cancel the update', gtk.STOCK_CANCEL) |
|
self._cancel_action.set_property('sensitive', False) |
|
self._cancel_action.connect('activate', self._clear_text) |
|
action_group.add_action_with_accel(self._cancel_action, |
|
'<Ctrl>Escape') |
|
|
|
clear_action = gtk.Action('Clear', '_Clear', |
|
'Clear the message list', gtk.STOCK_CLEAR) |
|
clear_action.connect('activate', self._clear_posts) |
|
action_group.add_action_with_accel(clear_action, '<Ctrl>l') |
|
|
|
#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 |
|
self._delete_action = gtk.Action('Delete', '_Delete', |
|
'Delete a post', gtk.STOCK_DELETE) |
|
self._delete_action.set_property('sensitive', False) |
|
self._delete_action.connect('activate', self._delete_message) |
|
action_group.add_action_with_accel(self._delete_action, 'Delete') |
|
|
|
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) |
|
self._reply_action.connect('activate', self._reply_message) |
|
action_group.add_action_with_accel(self._reply_action, '<Ctrl>r') |
|
|
|
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) |
|
self._repost_action.connect('activate', self._repost_message) |
|
action_group.add_action_with_accel(self._repost_action, '<Ctrl>p') |
|
|
|
# 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() |
|
self._update_text.set_property('wrap-mode', gtk.WRAP_WORD) |
|
text_buffer = self._update_text.get_buffer() |
|
text_buffer.connect('changed', self._count_chars) |
|
|
|
self._update_button = gtk.Button(label='Send') |
|
self._update_button.connect('clicked', self._update_status) |
|
self._update_button.set_property('sensitive', False) |
|
|
|
self._cancel_button = gtk.Button(label='Cancel') |
|
self._cancel_button.connect('clicked', self._clear_text) |
|
self._cancel_button.set_property('sensitive', False) |
|
|
|
info_box = gtk.HBox(True, 0) |
|
self._count_label = gtk.Label() |
|
self._count_label.set_justify(gtk.JUSTIFY_LEFT) |
|
self._reply_label = gtk.Label() |
|
|
|
info_box.pack_start(self._count_label, expand=True, fill=True) |
|
info_box.pack_start(self._reply_label, expand=True, fill=True) |
|
|
|
self._count_chars(text_buffer) |
|
|
|
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_box.pack_start(self._cancel_button, expand=False, fill=False, |
|
padding=0) |
|
|
|
update_area = gtk.VBox(True, 0) |
|
update_area.pack_start(info_box) |
|
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() |
|
self._statusbar_context = statusbar.get_context_id('Mitter') |
|
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._images['main']: |
|
about_window.set_logo(self._images['main']) |
|
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._images['avatar'] |
|
|
|
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) |
|
message = re.sub(r'<', r'<', message) |
|
message = re.sub(r'>', r'>', 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) |
|
|
|
if not data.read: |
|
read_status = '<big>●</big>' |
|
else: |
|
read_status = '' |
|
|
|
if data.reposted_by: |
|
markup = '<b>%s</b> <small>(%s — <i>reposted by %s</i>)' \ |
|
'</small>:%s\n%s\n<small>%s</small>' % \ |
|
(data.name, username, data.reposted_by, read_status, |
|
message, time) |
|
else: |
|
markup = '<b>%s</b> <small>(%s)</small>:%s\n%s\n' \ |
|
'<small>%s</small>' % \ |
|
(data.name, username, read_status, 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) |
|
self._cancel_button.set_property('sensitive', enabled) |
|
self._cancel_action.set_property('sensitive', enabled) |
|
return |
|
|
|
def _update_statusbar(self, message): |
|
"""Update the statusbar with the message.""" |
|
self._statusbar.push(self._statusbar_context, message) |
|
return |
|
|
|
def _refresh(self, widget=None): |
|
"""Request a refresh. *widget* is the widget that called this |
|
function (we basically ignore it.)""" |
|
if self._refresh_id: |
|
# "De-queue" the next refresh |
|
_log.debug('Dequeuing next refresh') |
|
gobject.source_remove(self._refresh_id) |
|
self._refresh_id = None |
|
|
|
# do the refresh |
|
self._update_statusbar('Retrieving messages...') |
|
self._threads.add_work(self._post_get_messages, |
|
self._exception_get_messages, |
|
self._connection.messages) |
|
|
|
return |
|
|
|
def _clear_reply(self): |
|
"""Clear the info about a reply.""" |
|
self._reply_message_id = None |
|
self._reply_label.set_text('') |
|
return |
|
|
|
def _url_popup(self, path, event): |
|
"""Builds the popup with URLs in the cell pointed by *path*. Requires |
|
the *event* that the widget received.""" |
|
iter = self._grid.get_model().get_iter(path) |
|
message = self._grid.get_model().get_value(iter, 0) |
|
|
|
popup = gtk.Menu() |
|
|
|
urls = URL_RE.findall(message.message) |
|
if len(urls) == 0: |
|
item = gtk.MenuItem('No URLs in message.') |
|
item.set_property('sensitive', False) |
|
popup.append(item) |
|
popup.show_all() |
|
popup.popup(None, None, None, event.button, event.time) |
|
return True |
|
|
|
for url in urls: |
|
if len(url) > 20: |
|
title = url[:20] + '...' |
|
else: |
|
title = 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 |
|
|
|
# ------------------------------------------------------------ |
|
# 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) |
|
|
|
# TODO: gettext to properly use "characters"/"character" |
|
self._count_label.set_text('%d characters' % 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_statusbar('Sending update...') |
|
self._update_sensitivity(False) |
|
self._threads.add_work(self._post_update_status, |
|
self._exception_update_status, |
|
self._connection.update, |
|
status=status, |
|
reply_to=self._reply_message_id) |
|
return |
|
|
|
def _clear_text(self, widget): |
|
"""Clear the text field.""" |
|
self._update_text.get_buffer().set_text('') |
|
|
|
self._delete_iter = None |
|
self._clear_reply() |
|
|
|
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 |
|
|
|
# TODO: Kill any threads running. |
|
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() |
|
if not iter: |
|
return |
|
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 |
|
|
|
def _clear_posts(self, widget, user_data=None): |
|
"""Clear the list of posts from the grid.""" |
|
self._grid.get_model().clear() |
|
self._new_message_count = 0 |
|
if self._statusicon: |
|
self._statusicon.set_from_pixbuf(self._images['main']) |
|
return |
|
|
|
def _delete_message(self, widget, user_data=None): |
|
"""Delete a message.""" |
|
(model, iter) = self._grid.get_selection().get_selected() |
|
message = model.get_value(iter, 0) |
|
|
|
self._update_statusbar('Deleting message...') |
|
self._delete_iter = iter |
|
_log.debug('Deleting messing %d', message.id) |
|
self._threads.add_work(self._post_delete_message, |
|
self._exception_delete_message, |
|
self._connection.delete_message, |
|
message) |
|
return |
|
|
|
def _reply_message(self, widget, user_data=None): |
|
"""Reply to someone else's message.""" |
|
(model, iter) = self._grid.get_selection().get_selected() |
|
message = model.get_value(iter, 0) |
|
|
|
self._reply_label.set_text('Replying to %s' % (message.username)) |
|
self._reply_message_id = message |
|
self._update_text.grab_focus() |
|
|
|
return |
|
|
|
def _repost_message(self, widget, user_data=None): |
|
"""Repost someone else's message on your timeline.""" |
|
(model, iter) = self._grid.get_selection().get_selected() |
|
message = model.get_value(iter, 0) |
|
self._update_statusbar('Reposting %s message...' % |
|
(message.username)) |
|
self._threads.add_work(self._post_repost_message, |
|
self._exception_repost_message, |
|
self._connection.repost, |
|
message) |
|
return |
|
|
|
def _show_settings(self, widget, user_data=None): |
|
"""Display the settings window.""" |
|
settings_window = gtk.Dialog(title='Settings', |
|
parent=self._main_window, |
|
flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, |
|
buttons=(gtk.STOCK_OK, 0)) |
|
|
|
# the tabs |
|
tabs = gtk.Notebook() |
|
|
|
# first page is the interface settings |
|
self._refresh_interval_field = gtk.SpinButton() |
|
self._refresh_interval_field.set_range(1, 99) |
|
self._refresh_interval_field.set_numeric(True) |
|
self._refresh_interval_field.set_value( |
|
self._options[self.NAMESPACE]['refresh_interval']) |
|
self._refresh_interval_field.set_increments(1, 5) |
|
|
|
interface_box = gtk.Table(rows=1, columns=2, homogeneous=False) |
|
interface_box.attach(gtk.Label('Refresh interval (minutes):'), |
|
0, 1, 0, 1) |
|
interface_box.attach(self._refresh_interval_field, 0, 1, 1, 2) |
|
interface_box.show_all() |
|
|
|
tabs.insert_page(interface_box, gtk.Label('Interface')) |
|
|
|
|
|
# We store the fields in a dictionary, inside dictionaries for each |
|
# NAMESPACE. To set the values, we just run the dictionaries setting |
|
# self._options. |
|
self._fields = {self.NAMESPACE: {'refresh_interval': |
|
self._refresh_interval_field}} |
|
|
|
# next pages are each network settings |
|
net_options = self._connection.settings() |
|
for network in net_options: |
|
network_name = network['name'] |
|
|
|
rows = len(network['options']) |
|
net_box = gtk.Table(rows=rows, columns=2, homogeneous=False) |
|
|
|
self._fields[network_name] = {} |
|
row = 0 |
|
for option in network['options']: |
|
option_name = option['name'] |
|
option_value = '' |
|
|
|
try: |
|
option_value = self._options[network_name][option_name] |
|
except KeyError: |
|
pass |
|
|
|
new_field = gtk.Entry() |
|
new_field.set_text(option_value) |
|
# Ony "str" and "passwd" are valid type and both use Entry() |
|
if option['type'] == 'passwd': |
|
new_field.set_visibility(False) |
|
|
|
net_box.attach(gtk.Label(option_name), row, row+1, 0, 1) |
|
net_box.attach(new_field, row, row+1, 1, 2) |
|
|
|
row += 1 |
|
|
|
self._fields[network_name][option_name] = new_field |
|
|
|
net_box.show_all() |
|
tabs.insert_page(net_box, gtk.Label(network_name)) |
|
|
|
tabs.show_all() |
|
settings_window.vbox.pack_start(tabs, True, True, 0) |
|
settings_window.connect('response', self._update_settings) |
|
settings_window.run() |
|
settings_window.hide() |
|
|
|
def _update_settings(self, widget, response_id=0, user_data=None): |
|
"""Update the interface settings.""" |
|
for namespace in self._fields: |
|
for option in self._fields[namespace]: |
|
field = self._fields[namespace][option] |
|
value = field.get_text() |
|
self._options[namespace][option] = value |
|
|
|
return True |
|
|
|
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 = self._grid.get_path_at_pos(event.x, event.y) |
|
if not path: |
|
return False |
|
|
|
(path, _, _, _) = path |
|
return self._url_popup(path, event) |
|
return True |
|
|
|
def _message_popup(self, widget, event, user_data=None): |
|
"""Builds the popup with the URLs in the message.""" |
|
_log.debug('Popup') |
|
(path, _) = self._grid.get_cursor() |
|
if not path: |
|
return True |
|
|
|
return self._url_popup(path, event) |
|
|
|
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 _mark_message_read(self, widget, user_data=None): |
|
"""Mark a message as read when it's selected.""" |
|
(path, _) = self._grid.get_cursor() |
|
if not path: |
|
return True |
|
|
|
iter = self._grid.get_model().get_iter(path) |
|
message = self._grid.get_model().get_value(iter, 0) |
|
|
|
if message.read: |
|
return True |
|
|
|
message.read = True |
|
self._new_message_count -= 1 |
|
|
|
if self._new_message_count == 0 and self._statusicon: |
|
self._statusicon.set_from_pixbuf(self._images['main']) |
|
|
|
return True |
|
|
|
# ------------------------------------------------------------ |
|
# 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)) |
|
interval = self._options[self.NAMESPACE]['refresh_interval'] |
|
self._update_statusbar('%d new messages retrieved. Next update in ' \ |
|
'%d minutes.' % (len(results), interval)) |
|
|
|
store = self._grid.get_model() |
|
for message in results: |
|
_log.debug('Data: %s', str(message)) |
|
message.read = False |
|
store.prepend([message]) |
|
store.sort_column_changed() |
|
if len(results) > 0: |
|
# scroll to the first cell, to "show" that there are new items. |
|
iter = self._grid.get_model().get_iter_first() |
|
path = self._grid.get_model().get_path(iter) |
|
self._grid.scroll_to_cell(path) |
|
self._grid.queue_draw() |
|
|
|
# once our update went fine, we can queue the next one. This avoids |
|
# any problems if case there is an exception. |
|
|
|
interval = self._options[self.NAMESPACE]['refresh_interval'] |
|
_log.debug('Queueing next refresh in %d minutes', interval) |
|
self._refresh_id = gobject.timeout_add( |
|
interval * 60 * 1000, |
|
self._refresh, None) |
|
|
|
self._new_message_count += len(results) |
|
if self._new_message_count > 0 and self._statusicon: |
|
self._statusicon.set_from_pixbuf(self._images['new-messages']) |
|
|
|
return |
|
|
|
def _exception_get_messages(self, widget, exception): |
|
"""Function called if the retrival of current messages returns an |
|
exception.""" |
|
_log.debug(str(exception)) |
|
error_win = gtk.MessageDialog(parent=self._main_window, |
|
type=gtk.MESSAGE_ERROR, |
|
message_format='Error retrieving current messages. ' \ |
|
'Auto-refresh disabled. Use the "Refresh" option ' \ |
|
'to re-enable it.', |
|
buttons=gtk.BUTTONS_OK) |
|
error_win.run() |
|
error_win.hide() |
|
self._update_statusbar('Auto-update disabled') |
|
if self._statusicon: |
|
self._statusicon.set_from_pixbuf(self._images['icon-error']) |
|
return |
|
|
|
### image download function |
|
def _download_pic(self, url): |
|
"""Download a picture from the web. Can be used in a thread.""" |
|
#if self._avatars[url] != self._images['avatar']: |
|
# return |
|
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.""" |
|
if not data: |
|
# image appeared in the queue |
|
return |
|
|
|
(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._clear_text(None) |
|
self._update_statusbar('Your status was updated.') |
|
self._clear_reply() |
|
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)) |
|
error_win = gtk.MessageDialog(parent=self._main_window, |
|
type=gtk.MESSAGE_ERROR, |
|
message_format='Error updating your status. Please ' \ |
|
'try again.', |
|
buttons=gtk.BUTTONS_OK) |
|
error_win.run() |
|
error_win.hide() |
|
return |
|
|
|
### Results for the delete message call |
|
def _post_delete_message(self, widget, data): |
|
"""Called when the message is deleted successfully.""" |
|
_log.debug('Message deleted.') |
|
if self._delete_iter: |
|
self._grid.get_model().remove(self._delete_iter) |
|
self._delete_iter = None |
|
self._update_statusbar('Message deleted.') |
|
return |
|
|
|
def _exception_delete_message(self, widget, exception): |
|
"""Called when the message cannot be deleted.""" |
|
_log.debug('Delete error') |
|
_log.debug(str(exception)) |
|
return |
|
|
|
### Results for the repost message call |
|
def _post_repost_message(self, widget, data): |
|
"""Called when the message is reposted successfully.""" |
|
_log.debug('Repost successful') |
|
self._update_statusbar('Message reposted') |
|
return |
|
|
|
def _exception_repost_message(self, widget, exception): |
|
"""Called when the message cannot be reposted.""" |
|
_log.debug('Repost error.') |
|
_log.debug(str(exception)) |
|
|
|
error_win = gtk.MessageDialog(parent=self._main_window, |
|
type=gtk.MESSAGE_ERROR, |
|
message_format='Error reposting message. Please ' \ |
|
'try again.', |
|
buttons=gtk.BUTTONS_OK) |
|
error_win.run() |
|
error_win.hide() |
|
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 |
|
unknown_pixbuf = find_image('unknown.png') |
|
if unknown_pixbuf: |
|
default_pixmap = gtk.gdk.pixbuf_new_from_file( |
|
unknown_pixbuf) |
|
else: |
|
default_pixmap = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, |
|
has_alpha=False, bits_per_sample=8, width=48, height=48) |
|
|
|
self._images = {} |
|
self._images['main'] = gtk.gdk.pixbuf_new_from_file( |
|
find_image('mitter.png')) |
|
self._images['new-messages'] = gtk.gdk.pixbuf_new_from_file( |
|
find_image('mitter-new.png')) |
|
self._images['icon-error'] = gtk.gdk.pixbuf_new_from_file( |
|
find_image('mitter-error.png')) |
|
self._images['avatar'] = default_pixmap |
|
|
|
# This is the ugly bit for speeding up things and making |
|
# interthread communication. |
|
self._delete_iter = None |
|
self._reply_message_id = None |
|
self._new_message_count = 0 |
|
|
|
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() |
|
|
|
if self._options[self.NAMESPACE]['statusicon']: |
|
self._statusicon = gtk.StatusIcon() |
|
self._statusicon.set_from_pixbuf(self._images['main']) |
|
else: |
|
self._statusicon = None |
|
self._threads = _ThreadManager() |
|
|
|
# queue the first fetch |
|
self._refresh_id = None # The auto-refresh manager. |
|
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') |
|
options.add_option('--use-statusicon', |
|
group=self.NAMESPACE, |
|
option='statusicon', |
|
default=True, |
|
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)
|
|
|