#!/usr/bin/env 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 import webbrowser from cgi import escape as html_escape from mitterlib.ui.helpers.image_helpers import find_image 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) MESSAGE_FORMAT = '%s' \ '%s ' \ '(%s' \ '%s' \ '):' \ '%s\n' \ '%s\n' \ '%s' # ---------------------------------------------------------------------- # 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 d %s calling %s', self.getName(), str(self._function.__name__)) 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 inside thread: %s', str(exc)) self.emit("exception", exc) return _log.debug('Thread id %s completed', self.getName()) 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.getName() self._running.remove(thread_id) _log.debug('Thread id %s completed, %d threads in the queue, ' \ '%d still running', thread_id, len(self._thread_pool), len(self._running)) if self._thread_pool: if len(self._running) < self._max_threads: next = self._thread_pool.pop() _log.debug('Dequeuing thread %s', next.getName()) self._running.append(next.getName()) 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-%s' % (self._thread_id, func.__name__) thread.connect('completed', complete_cb) thread.connect('completed', self._remove_thread) thread.connect('exception', exception_cb) thread.connect('exception', self._remove_thread) 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 def clear(self): """Clear the thread pool list. This will cause the manager to stop working after the threads finish.""" self._thread_pool = [] self._running = [] 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']) (menu, toolbar, accelerators) = self._create_menu_and_toolbar() update_field = self._create_update_box() self._statusbar = self._create_statusbar() self._main_tabs = gtk.Notebook() (grid_widget, self._grid) = self._create_grid('_new_message_count') self._main_tabs.insert_page(grid_widget, gtk.Label('Messages')) (replies_widget, self._replies) = self._create_grid( '_new_replies_count') self._main_tabs.insert_page(replies_widget, gtk.Label('Replies')) update_box = gtk.VPaned() update_box.pack1(self._main_tabs, 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) self._message_count_updated() # now that all elements are created, connect the signals main_window.connect('destroy', self._quit_app) main_window.connect('delete-event', self._quit_app) main_window.connect('size-request', self._grid_resize) self._update_text.get_buffer().connect('changed', self._count_chars) self._update_text.connect('focus-out-event', self._remove_count_status) self._update_text.connect('focus-in-event', self._on_textarea_focus) return main_window def _create_grid(self, counter): """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) grid = gtk.TreeView(grid_store) grid.set_property('headers-visible', False) 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) grid.append_column(user_column) grid.append_column(message_column) grid.set_resize_mode(gtk.RESIZE_IMMEDIATE) grid.connect('cursor-changed', self._message_selected) grid.connect('button-press-event', self._click_message) grid.connect('popup-menu', self._message_popup) # Menu button grid.connect('cursor-changed', self._mark_message_read, counter) scrolled_window = gtk.ScrolledWindow() scrolled_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) scrolled_window.add(grid) return (scrolled_window, grid) 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) view_action = gtk.Action('View', '_View', 'View', None) action_group.add_action(view_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') self._cancel_action = gtk.Action('Cancel', '_Cancel', 'Cancel the update', gtk.STOCK_CANCEL) self._cancel_action.connect('activate', self._clear_text) action_group.add_action_with_accel(self._cancel_action, '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, 'l') 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, '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, 'p') self._favourite_action = gtk.Action('Favourite', '_Favourite', 'Toggle the favourite status of a message', gtk.STOCK_ABOUT) self._favourite_action.set_property('sensitive', False) self._favourite_action.connect('activate', self._favourite_message) action_group.add_action_with_accel(self._favourite_action, 'f') # view actions view_messages_action = gtk.Action('Messages', '_Messages', 'Display messages', None) view_messages_action.connect('activate', self._change_tab, 0) action_group.add_action_with_accel(view_messages_action, '1') view_replies_action = gtk.Action('Replies', '_Replies', 'Display replies', None) view_replies_action.connect('activate', self._change_tab, 1) action_group.add_action_with_accel(view_replies_action, '2') # 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() 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) info_box = gtk.HBox(True, 0) self._count_label = gtk.Label() self._count_label.set_justify(gtk.JUSTIFY_LEFT) self._reply_label = gtk.Label() 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) # 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_box 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 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) time = timesince.timesince(data.message_time) # unescape escaped entities that pango is not okay with message = html_escape(data.message) username = html_escape(data.username) full_name = html_escape(data.name) # 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? #message = re.sub(r'(@'+self.twitter.username+')', # r'\1', # message) if not data.read: read_status = '' else: read_status = '' if data.favourite: favourite = '★' else: favourite = '☆' info = [] if data.reposted_by: info.append(' — reposted by %s' % (data.reposted_by)) if data.parent_owner: info.append(' — in reply to %s' % (data.parent_owner)) info_message = ''.join(info) markup = MESSAGE_FORMAT % (favourite, full_name, username, info_message, 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) return def _update_statusbar(self, message): """Update the statusbar with the message.""" self._statusbar.pop(self._statusbar_context) 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, widget, path, event): """Builds the popup with URLs in the cell pointed by *path*. Requires the *event* that the widget received.""" iter = widget.get_model().get_iter(path) message = widget.get_model().get_value(iter, 0) items = [] link = self._connection.link(message) if link: network = self._connection.name(message.network) items.append(('Open on %s' % (network), link)) urls = URL_RE.findall(message.message) for url in urls: if len(url) > 20: title = url[:20] + '...' else: title = url items.append((title, url)) if len(items) == 0: return popup = gtk.Menu() for url in items: (title, url) = url item = gtk.MenuItem(title) item.connect('activate', self._open_url, url) popup.append(item) popup.show_all() popup.popup(None, None, None, event.button, event.time) return True def _message_count_updated(self): """Update the elements in the interface that should change in case of changes in the message count.""" child = self._main_tabs.get_nth_page(0) self._main_tabs.set_tab_label_text(child, 'Messages (%d)' % (self._new_message_count)) child = self._main_tabs.get_nth_page(1) self._main_tabs.set_tab_label_text(child, 'Replies (%d)' % (self._new_replies_count)) if self._statusicon: if self._new_message_count == 0: self._statusicon.set_from_pixbuf(self._images['main']) else: self._statusicon.set_from_pixbuf(self._images['new-messages']) return def _fill_store(self, store, data): """Using the results from the request, fill a gtk.Store.""" for message in data: message.read = False pic = message.avatar if not pic in self._avatars: # 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'] self._threads.add_work(self._post_download_pic, self._exception_download_pic, self._download_pic, pic) store.prepend([message]) store.sort_column_changed() return def _update_word_wrap(self, widget): """Update the word wrap for the widget.""" model = widget.get_model() if len(model) == 0: # don't try to update if there are no data to be updated return (win_width, win_height) = self._main_window.get_size() column = widget.get_column(1) iter = model.get_iter_first() path = model.get_path(iter) cell_rectangle = widget.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 _get_current_grid(self): """Return the grid of the current selected tab.""" page = self._main_tabs.get_current_page() if page == 0: return self._grid elif page == 1: return self._replies # ------------------------------------------------------------ # 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) if self._reply_message_id: suffix = '(replying to %s)' % (self._reply_message_id.username) else: suffix = '' # TODO: gettext to properly use "characters"/"character" text = '%d characters %s' % (count, suffix) self._statusbar.push(self._remove_count_status(), text) 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._remove_count_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() self._remove_count_status() # change the focus to the grid. page = self._main_tabs.get_current_page() child = self._main_tabs.get_nth_page(page) child.get_child().grab_focus() # notebook have ScrolledWindows, # TreeViews inside that. 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. self._threads.clear() 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.""" self._update_word_wrap(self._grid) self._update_word_wrap(self._replies) 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)) self._favourite_action.set_property('sensitive', self._connection.can_favourite(data)) return 0 def _clear_posts(self, widget, user_data=None): """Clear the list of posts from the grid.""" page = self._main_tabs.get_current_page() if page == 0: # messages self._grid.get_model().clear() self._new_message_count = 0 elif page == 1: self._replies.get_model().clear() self._new_replies_count = 0 self._message_count_updated() 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) confirm = gtk.MessageDialog(parent=self._main_window, type=gtk.MESSAGE_QUESTION, message_format='Delete this message?', buttons=gtk.BUTTONS_YES_NO); option = confirm.run() confirm.hide() _log.debug("Option selected: %s" % (option)) if option == -9: _log.debug("Delete cancelled"); return False 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.""" grid = self._get_current_grid() (model, iter) = grid.get_selection().get_selected() message = model.get_value(iter, 0) self._update_text.get_buffer().set_text( self._connection.reply_prefix(message)) 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.""" grid = self._get_current_grid() (model, iter) = 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 _favourite_message(self, widget, user_data=None): """Toggle the favourite status of a message.""" grid = self._get_current_grid() (model, iter) = grid.get_selection().get_selected() message = model.get_value(iter, 0) self._favourite_iter = iter if message.favourite: display = 'Removing message from %s from favourites...' else: display = 'Marking message from %s as favourite...' self._update_status(display % (message.username)) self._threads.add_work(self._post_favourite_message, self._exception_favourite_message, self._connection.favourite, 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, 'int')}} # 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, option['type']) 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, field_type) = self._fields[namespace][option] value = field.get_text() if field_type == 'int': value = int(value) 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 = widget.get_path_at_pos(event.x, event.y) if not path: return False (path, _, _, _) = path return self._url_popup(widget, 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, _) = widget.get_cursor() if not path: return True return self._url_popup(widget, 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, _) = widget.get_cursor() if not path: return True iter = widget.get_model().get_iter(path) message = widget.get_model().get_value(iter, 0) if message.read: return True # Do it generically, so we can reuse this function to both grids counter = getattr(self, user_data) setattr(self, user_data, counter - 1) message.read = True self._message_count_updated() return True def _change_tab(self, widget, user_data=None): """Change the notebook tab to display a differnt tab.""" if not user_data: user_data = 0 self._main_tabs.set_current_page(user_data) return def _remove_count_status(self, widget=None, user_data=None): """Find the context with the char count in the statusbar and remove it (pop). Returns the context of the count, in case some other function wants to add another message.""" count_context = self._statusbar.get_context_id('counter') self._statusbar.pop(count_context) return count_context def _on_textarea_focus(self, widget, user_data=None): """Called when the text area gets the focus. Just to add the counter again.""" self._count_chars(self._update_text.get_buffer()) # disable the message actions (they will be activated properly when # the user leaves the textarea.) self._delete_action.set_property('sensitive', False) self._reply_action.set_property('sensitive', False) self._repost_action.set_property('sensitive', False) self._favourite_action.set_property('sensitive', False) return # ------------------------------------------------------------ # 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() self._fill_store(store, results) self._grid.queue_draw() self._new_message_count += len(results) self._message_count_updated() # now get replies self._update_statusbar('Retrieving replies...') self._threads.add_work(self._post_get_replies, self._exception_get_messages, self._connection.replies) 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 ### replies callback def _post_get_replies(self, widget, results): """Called after we retrieve the replies.""" _log.debug("%d new replies", len(results)) store = self._replies.get_model() self._fill_store(store, results) self._replies.queue_draw() self._new_replies_count += len(results) self._message_count_updated() # 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._update_statusbar('New messages retrieved. Next update in ' \ '%d minutes.' % (interval)) self._refresh_id = gobject.timeout_add( interval * 60 * 1000, self._refresh, None) 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_statusbar('Your status was updated.') self._update_sensitivity(False) self._clear_text(None) 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 ### Results from the favourite call def _post_favourite_message(self, widget, data): """Called when the message was favourited successfully.""" _log.debug('Favourite status changed.') message = self._grid.get_model().get_value(self._favourite_iter, 0) if message.favourite: display = 'Message unfavourited.' else: display = 'Message favourited.' self._update_statusbar(display) message.favourite = not message.favourite self._favourite_iter = None return def _exception_favourite_message(self, widget, exception): """Called when the message couldn't be favourited.""" _log.debug('Favourite error.') _log.debug(str(exception)) error_win = gtk.MessageDialog(parent=self._main_window, type=gtk.MESSAGE_ERROR, message_format='Error changing favourite status of the ' \ '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._favourite_iter = None self._new_message_count = 0 self._new_replies_count = 0 return def __call__(self): """Call function; displays the interface. This method should appear on every interface.""" if self._options[self.NAMESPACE]['statusicon']: self._statusicon = gtk.StatusIcon() self._statusicon.set_from_pixbuf(self._images['main']) else: self._statusicon = None self._main_window = self._create_main_window() self._main_window.show_all() 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('--disable-statusicon', group=self.NAMESPACE, action='store_false', 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)