#!/usr/bin/python # -*- coding: utf-8 -*- # Mitter, a Maemo client for Twitter. # Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import gtk import gobject gobject.threads_init() import logging import threading import Queue import re import urllib2 from mitterlib.ui.helpers.image_helpers import find_image from mitterlib import htmlize from mitterlib.constants import gpl_3, version #from mitterlib.ui.helpers.utils import str_len #from mitterlib.ui.helpers.notify import Notify from mitterlib.ui.helpers import timesince # Constants _log = logging.getLogger('ui.pygtk') URL_RE = re.compile(r'(https?://[^\s\n\r]+)', re.I) # ---------------------------------------------------------------------- # Helper Functions (not related to objects or that don't need direct access to # the objects contents.) # ---------------------------------------------------------------------- def _buffer_text(text_buffer): """Return the content of a gtk.TextBuffer.""" start = text_buffer.get_start_iter() end = text_buffer.get_end_iter() text = text_buffer.get_text(start, end, include_hidden_chars=False) return text # ---------------------------------------------------------------------- # Threading related objects. # These classes are based on the code available at http://gist.github.com/51686 # (c) 2008, John Stowers # ---------------------------------------------------------------------- class _IdleObject(gobject.GObject): """ Override gobject.GObject to always emit signals in the main thread by emmitting on an idle handler """ def __init__(self): gobject.GObject.__init__(self) def emit(self, *args): gobject.idle_add(gobject.GObject.emit,self,*args) class _WorkerThread(threading.Thread, _IdleObject): """ A single working thread. """ __gsignals__ = { "completed": ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)), # list/networkdata "exception": ( gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, (gobject.TYPE_PYOBJECT,)) # The exception } def __init__(self, function, *args, **kwargs): threading.Thread.__init__(self) _IdleObject.__init__(self) self._function = function self._args = args self._kwargs = kwargs def run(self): # call the function _log.debug('Thread %s calling %s', self.name, str(self._function)) args = self._args kwargs = self._kwargs try: result = self._function(*args, **kwargs) except Exception, exc: # Catch ALL exceptions # TODO: Check if this catch all warnins too! _log.debug('Exception %s', str(exc)) self.emit("exception", exc) return _log.debug('Thread %s completed', self.name) self.emit("completed", result) return class _ThreadManager(object): """Manages the threads.""" def __init__(self, max_threads=2): """Start the thread pool. The number of threads in the pool is defined by `pool_size`, defaults to 2.""" self._max_threads = max_threads self._thread_pool = [] self._running = [] self._thread_id = 0 return def _remove_thread(self, widget, arg=None): """Called when the thread completes. We remove it from the thread list (dictionary, actually) and start the next thread (if there is one).""" # not actually a widget. It's the object that emitted the signal, in # this case, the _WorkerThread object. thread_id = widget.name _log.debug('Thread %s completed, %d threads in the queue', thread_id, len(self._thread_pool)) self._running.remove(thread_id) if self._thread_pool: if len(self._running) < self._max_threads: next = self._thread_pool.pop() _log.debug('Dequeuing thread %s', next.name) self._running.append(next.name) next.start() return def add_work(self, complete_cb, exception_cb, func, *args, **kwargs): """Add a work to the thread list. `complete_cb` is the function to be called with the result of the work. `exception_cb` is the function to be called if there are any exceptions raised. Note that, once the work is complete, one of those will be called, not both. `func` is the function to be called in the secondary threads. `args` and `kwargs` are parameters passed to the function.""" thread = _WorkerThread(func, *args, **kwargs) thread_id = '%s' % (self._thread_id) thread.connect('completed', complete_cb) thread.connect('completed', self._remove_thread) thread.connect('exception', exception_cb) thread.setName(thread_id) if len(self._running) < self._max_threads: # immediatelly start the thread self._running.append(thread_id) thread.start() else: # add the thread to the queue running_names = ', '.join(self._running) _log.debug('Threads %s running, adding %s to the queue', running_names, thread_id) self._thread_pool.append(thread) self._thread_id += 1 return # ---------------------------------------------------------------------- # Custom cell renderer # ---------------------------------------------------------------------- class CellRendererNetworkData(gtk.GenericCellrenderer): """A customized cell renderer for messages.""" __gproperties__ = { 'message': (gobject.TYPE_PYOBJECT, 'message', 'A NetworkData object to be rendered', '', gobject.PARAM_WRITABLE) } def __init__(self): super(CellrendererNetworkData, self).__init__(self) self.set_property('mode', gtk.CELL_RENDERER_MODE_ACTIVATABLE) return def on_render(self, window, widget, bg_area, cell_area, exp_area, flags): """The render() method invokes the virtual render function of the gtk.CellRenderer. The three passed-in rectangles are areas of window. Most renderers will draw within cell_area; the xalign, yalign, xpad, and ypad properties of the gtk.CellRenderer should be honored with respect to cell_area. background_area includes the blank space around the cell, and also the area containing the tree expander; so the background_area rectangles for all cells tile to cover the entire window. expose_area is a clip rectangle. The flags value is one of: gtk.CELL_RENDERER_SELECTED, gtk.CELL_RENDERER_PRELIT, gtk.CELL_RENDERER_INSENSITIVE or gtk.CELL_RENDERER_SORTED""" layout = self.get_layout(widget) if flags & gtk.CELL_RENDERER_SELECTED: if widget.get_property('has-focus'): state = gtk.STATE_SELECTED else: satte = gtk.STATE_ACTIVE else: state = gtk.STATE_NORMAL widget.style.paint_layout( window, state, True, cell_area, widget, 'foo', cell_area.x + x_offset, cell_area.y + y_offset, layout ) return def on_get_size(sel, widget, cell_area): """The get_size() method obtains the width and height needed to render the cell. These values are returned as part of a tuple containing the x_offset, y_offset, width and height. get_size() is used by view widgets to determine the appropriate size for the cell_area to be passed to the gtk.CellRenderer.render() method. If cell_area is not None, the x and y offsets of the cell relative to this location are returned. Please note that the values set in the returned width and height, as well as those in x_offset and y_offset are inclusive of the xpad and ypad properties.""" return (x_offset, y_offset, width, height) gobject.type_register(CellRendererNetworkData) # ---------------------------------------------------------------------- # Mitter interface object # ---------------------------------------------------------------------- class Interface(object): """Linux/GTK interface for Mitter.""" NAMESPACE = 'pygtk' # ------------------------------------------------------------ # Widget creation functions # ------------------------------------------------------------ def _systray_setup(self): if not (self._app_icon and self._app_icon_alert): self._systray = None return self._systray = gtk.StatusIcon() self._systray.set_from_file(self._app_icon) #where is systray_cb??? self._systray.connect('activate', self.systray_cb) self._systray.connect('popup-menu', self.systray_popup) # should set tooltip to msg text if that come? self._systray.set_tooltip('Mitter: Click to toggle window visibility.') self._systray.set_visible(True) return def systray_popup(self, icon, button, time): sys_popup = gtk.Menu() restore = gtk.MenuItem("Restore", False) exit = gtk.MenuItem("Exit", False) sys_popup.append(restore) sys_popup.append(exit) sys_popup.popup(None, None, None, 3, time) sys_popup.show_all() def systray_cb(self, icon): #why window location move down and down? if self._main_window.is_active(): self._main_window.hide() else: self._main_window.show() def _create_main_window(self): """Returns the object with the main window and the attached widgets.""" main_window = gtk.Window(gtk.WINDOW_TOPLEVEL) #where is this method? self._systray_setup() 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) options_renderer = gtk.CellRendererPixbuf() options_renderer.set_fixed_size(16, 16) options_column = gtk.TreeViewColumn('Options', options_renderer) options_column.set_cell_data_func(options_renderer, self._cell_renderer_options) #options_column.set_fixed_width(16) # icon size #options_column.set_max_width(16) #options_column.set_expand(False) #options_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) self._grid.append_column(user_column) self._grid.append_column(options_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.""" # tasks (used by the menu and toolbar) refresh_action = gtk.Action('Refresh', '_Refresh', 'Update the listing', gtk.STOCK_REFRESH) #refresh_action.connect('activate', self.refresh) quit_action = gtk.Action('Quit', '_Quit', 'Exit Mitter', gtk.STOCK_QUIT) quit_action.connect('activate', self._quit_app) settings_action = gtk.Action('Settings', '_Settings', 'Settings', gtk.STOCK_PREFERENCES) #settings_action.connect('activate', self.show_settings) 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) 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) about_action = gtk.Action('About', '_About', 'About Mitter', gtk.STOCK_ABOUT) about_action.connect('activate', self._show_about) #shrink_url_action = gtk.Action('ShrinkURL', 'Shrink _URL', # 'Shrink selected URL', gtk.STOCK_EXECUTE) #shrink_url_action.connect('activate', self.shrink_url) #mute_action = gtk.ToggleAction('MuteNotify', '_Mute Notifications', # 'Mutes notifications on new tweets', gtk.STOCK_MEDIA_PAUSE) #mute_action.set_active(False) post_action = gtk.Action('Posts', '_Posts', 'Post management', None) file_action = gtk.Action('File', '_File', 'File', None) edit_action = gtk.Action('Edit', '_Edit', 'Edit', None) help_action = gtk.Action('Help', '_Help', 'Help', None) # action group (will have all the actions, 'cause we are not actually # grouping them, but Gtk requires them that way) self.action_group = gtk.ActionGroup('MainMenu') self.action_group.add_action_with_accel(refresh_action, 'F5') # None = use the default accelerator, based on the STOCK used. self.action_group.add_action_with_accel(quit_action, None) self.action_group.add_action(settings_action) self.action_group.add_action(delete_action) self.action_group.add_action(post_action) self.action_group.add_action(file_action) self.action_group.add_action(edit_action) self.action_group.add_action(help_action) self.action_group.add_action(about_action) #self.action_group.add_action_with_accel(shrink_url_action, 'u') #self.action_group.add_action_with_accel(mute_action, 'm') self.action_group.add_action_with_accel(self._update_action, 'Return') # definition of the UI uimanager = gtk.UIManager() uimanager.insert_action_group(self.action_group, 0) ui = ''' ''' uimanager.add_ui_from_string(ui) 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) #info_box = gtk.HBox(False, 0) #self._char_count = gtk.Label() #self._char_count.set_text('(140)') #info_box.pack_start(gtk.Label('What are you doing?')) #info_box.pack_start(self._char_count) update_area = gtk.VBox(True, 0) #update_area.pack_start(info_box) update_area.pack_start(update_box) return update_area def _create_statusbar(self): """Create the statusbar.""" statusbar = gtk.Statusbar() # TODO: Probaly set the context in the object. return statusbar def _show_about(self, widget): """Show the about dialog.""" about_window = gtk.AboutDialog() about_window.set_name('Mitter') about_window.set_version(version) about_window.set_copyright('2007-2009 Mitter Contributors') about_window.set_license(gpl_3) about_window.set_website('http://code.google.com/p/mitter') about_window.set_website_label('Mitter on GoogleCode') about_window.set_authors([ 'Main developers:', 'Julio Biason', 'Deepak Sarda', 'Gerald Kaszuba', ' ', 'And patches from:', 'Santiago Gala', 'Sugree Phatanapherom', 'Kristian Rietveld', '"Wiennat"', 'Philip Reynolds', 'Greg McIntyre', '"Alexander"']) if self._app_icon: _log.debug('Icon: %s', self._app_icon) about_window.set_logo(gtk.gdk.pixbuf_new_from_file( self._app_icon)) about_window.run() about_window.hide() # ------------------------------------------------------------ # Cell rendering functions # ------------------------------------------------------------ def _cell_renderer_user(self, column, cell, store, position): """Callback for the user column. Used to created the pixbuf of the userpic.""" data = store.get_value(position, 0) pic = data.avatar if not pic in self._avatars: self._threads.add_work(self._post_download_pic, self._exception_download_pic, self._download_pic, pic) # set the user avatar to the default image, so it won't get queued # again. Once downloaded, the _post_download_pic will update the # image and force a redraw. self._avatars[pic] = self._default_pixmap cell.set_property('pixbuf', self._avatars[pic]) return def _cell_renderer_message(self, column, cell, store, position): """Callback for the message column. We need this to adjust the markup property of the cell, as setting it as text won't do any markup processing.""" data = store.get_value(position, 0) message = data.message username = data.username time = timesince.timesince(data.message_time) # unescape escaped entities that pango is not okay with message = re.sub(r'&', r'&', message) _log.debug('Rendering message: %s', message) # highlight URLs mask = r'\1' % ( self._options[self.NAMESPACE]['link_colour']) message = URL_RE.sub(mask, message) # use a different highlight for the current user #message = re.sub(r'(@'+self.twitter.username+')', # r'\1', # message) markup = '%s (%s):\n%s\n%s' % \ (data.name, username, message, time) cell.set_property('markup', markup) return def _cell_renderer_options(self, column, cell, store, position): """Callback for the options renderer. Adds the delete icon if the message belongs to the user or reply if not.""" data = store.get_value(position, 0) cell.set_property('pixbuf', self._reply_pixbuf) return # ------------------------------------------------------------ # Helper functions # ------------------------------------------------------------ def _update_sensitivity(self, enabled): """Set the "sensitive" property of the update action and button. Both should have the same property, so whenever you need to disable/enable them, use this function.""" self._update_button.set_property('sensitive', enabled) self._update_action.set_property('sensitive', enabled) return # ------------------------------------------------------------ # Widget callback functions # ------------------------------------------------------------ def _count_chars(self, text_buffer): """Count the number of chars in the edit field and update the label that shows the available space.""" text = _buffer_text(text_buffer) count = len(text) self._update_button.set_label('(%d)' % (140 - count)) self._update_sensitivity(not (count == 0)) return True def _update_status(self, widget): """Update your status.""" _log.debug('Updating status.') status = _buffer_text(self._update_text.get_buffer()) status = status.strip() if not status: return _log.debug('Status: %s', status) self._update_sensitivity(False) self._threads.add_work(self._post_update_status, self._exception_update_status, self._connection.update, status) # TODO: We are not dealing with replies here yet return def _quit_app(self, widget=None, user_data=None): """Callback when the window is destroyed or the user selects "Quit".""" (x, y) = self._main_window.get_position() _log.debug('Current position: %d x %d', x, y) self._options[self.NAMESPACE]['position_x'] = x self._options[self.NAMESPACE]['position_y'] = y (width, height) = self._main_window.get_size() _log.debug('Current window size: %d x %d', width, height) self._options[self.NAMESPACE]['width'] = width self._options[self.NAMESPACE]['height'] = height gtk.main_quit() return def _grid_resize(self, widget, allocation, 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 _log.debug('Widget size: %d', allocation.width) column = self._grid.get_column(2) iter = model.get_iter_first() path = model.get_path(iter) cell_rectangle = self._grid.get_cell_area(path, column) # x_padding # focus-line-width focus_line_width = self._grid.style_get_property('focus-line-width') #rectangle_width = column_rectangle.width width = cell_rectangle.width - focus_line_width for renderer in column.get_cell_renderers(): renderer.set_property('wrap-width', width) renderer.set_property('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)