diff --git a/mitterlib/ui/helpers/gtk_messagegrid.py b/mitterlib/ui/helpers/gtk_messagegrid.py new file mode 100644 index 0000000..f79eb8e --- /dev/null +++ b/mitterlib/ui/helpers/gtk_messagegrid.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Mitter, a micro-blogging client with multiple interfaces. +# Copyright (C) 2007-2010 Mitter Contributors +# +# 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 gobject +import gtk +import logging +import pango + +from cgi import escape as html_escape + +# ---------------------------------------------------------------------- +# String with the format to the message +# ---------------------------------------------------------------------- +MESSAGE_FORMAT = ('%(favourite_star)s' + '%(full_name)s ' + '(%(username)s' + '%(message_type)s' + '):' + '%(read_status)s ' + '%(protected_status)s ' + '\n' + '%(message)s\n' + '%(message_age)s') + +# ---------------------------------------------------------------------- +# Logging +# ---------------------------------------------------------------------- +_log = logging.getLogger('ui.pygtk.messagegrid') + +# ---------------------------------------------------------------------- +# MessageGrid class +# ---------------------------------------------------------------------- +class MessageGrid(gtk.ScrolledWindow, gobject.GObject): + """Custom message grid.""" + + __gsignals__ = { + "count-changed": ( + gobject.SIGNAL_RUN_LAST, + gobject.TYPE_NONE, + (gobject.TYPE_INT, ))} # The exception + + @property + def count(self): + """Number of unread messages.""" + return self._message_count + + @count.setter + def count(self, messages): + """Update the number of unread messages.""" + self._message_count = messages + self.emit('count-changed', messages) + return + + def __init__(self): + super(MessageGrid, self).__init__() + + self.avatars = None + self.link_color = '#0000ff' + self.unread_char = '(o)' + self.favorite_char = '(F)' + self.unfavorite_char = '( )' + self.protected_char = '(protected)' + + self._message_count = 0 + + store = gtk.ListStore(object) + store.set_sort_column_id(0, gtk.SORT_ASCENDING) + store.set_sort_func(0, self._order_datetime) + + self._grid = gtk.TreeView(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', pango.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) + + self.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS) + self.add(self._grid) + return + + #----------------------------------------------------------------------- + # Renderers + #----------------------------------------------------------------------- + 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.author.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) + + message_values = {} + + # unescape escaped entities that pango is not okay with + message_values['message'] = html_escape(data.message) + message_values['username'] = html_escape(data.author.username) + message_values['full_name'] = html_escape(data.author.name) + message_values['message_age'] = time + + # highlight URLs + mask = r'\1' % (self.link_color) + message_values['message'] = URL_RE.sub(mask, + message_values['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: + message_values['read_status'] = (' ' + self.unread_char) + else: + message_values['read_status'] = '' + + if data.favourite: + message_values['favourite_star'] = (self.unfavourite_char) + else: + message_values['favourite_star'] = (self.favourite_char) + + info = [] + if data.reposted_by: + info.append(_(' — reposted by %s') % + (data.reposted_by.username)) + + if data.parent_owner: + info.append(_(' — in reply to %s') % + (data.parent_owner.username)) + + if data.protected: + message_values['protected_status'] = (self.protected_char) + else: + message_values['protected_status'] = '' + + message_values['message_type'] = ''.join(info) + markup = MESSAGE_FORMAT % (message_values) + + cell.set_property('markup', markup) + + return + + #----------------------------------------------------------------------- + # Widget callbacks + #----------------------------------------------------------------------- + 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) + data.read = True + + self._delete_action.set_property('sensitive', data.deletable) + self._reply_action.set_property('sensitive', data.replyable) + self._repost_action.set_property('sensitive', data.repostable) + self._favourite_action.set_property('sensitive', data.favoritable) + + return 0 + + 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) + + def _message_popup(self, widget, user_data=None): + """Builds the popup with the URLs in the message.""" + _log.debug('Popup') + (path, _) = widget.get_cursor() + if not path: + return True + + # create a syntetic event (the popup requires one) + event = gtk.gdk.Event(gtk.gdk.BUTTON_PRESS) + event.button = 3 + return self._url_popup(widget, path, event) + + 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 = [] + if message.link: + items.append((_('Open on %s') % (message.network_name), + message.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 _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 + + self.count -= 1 + return True + + def update(self, messages): + """Update the grid with new messages.""" + self._grid.freeze() + store = self._grid.get_model() + for message in messages: + message.read = False + store.prepend([message]) + store.sort_column_changed() + self._grid.thaw() + 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 clear_posts(self, widget, user_data=None): + """Clear the list of posts from the grid.""" + self._grid.get_model().clear() + self.count = 0; + return + + def mark_all_read(self, widget, user_data=None): + """Mark all messages as read.""" + model = self._grid.get_model() + if len(model) == 0: + # no messages, so we don't worry (if I recall correctly, + # get_iter_first will fail if the model is empty) + return + + iter = model.get_iter_first() + while iter: + message = model.get_value(iter, 0) + message.read = True + iter = model.iter_next(iter) + + self.count = 0 + return + + 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 update_window_size(self, size): + """Called when the window size changes, so the grid needs to change + its word-wrapping size.""" + model = self._grid.get_model() + if len(model) == 0: + return + + column = self._grid.get_column(1) # 0=avatar, 1=message + iter = model.get_iter_first() + path = model.get_path(iter) + + width = win_width - 85 # 48 = icon size + # TODO: Find out where those 37 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 diff --git a/mitterlib/ui/ui_pygtk.py b/mitterlib/ui/ui_pygtk.py index 2a5880d..01d7315 100644 --- a/mitterlib/ui/ui_pygtk.py +++ b/mitterlib/ui/ui_pygtk.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Mitter, a multiple-interface client for microblogging services. +# Mitter, a micro-blogging client with multiple interfaces. # Copyright (C) 2007-2010 Mitter Contributors # # This program is free software: you can redistribute it and/or modify @@ -28,11 +28,10 @@ import urllib2 import webbrowser import gettext -from cgi import escape as html_escape - from mitterlib.ui.helpers.image_helpers import find_image from mitterlib.ui.helpers.gtk_threading import ThreadManager from mitterlib.ui.helpers.gtk_updatebox import UpdateBox +from mitterlib.ui.helpers.gtk_messagegrid import MessageGrid from mitterlib.constants import gpl_3, version from mitterlib.ui.helpers import timesince @@ -48,21 +47,6 @@ t = gettext.translation('ui_pygtk', fallback=True) _ = t.gettext N_ = t.ngettext -# ---------------------------------------------------------------------- -# String with the format to the message -# ---------------------------------------------------------------------- -MESSAGE_FORMAT = ('%(favourite_star)s' - '%(full_name)s ' - '(%(username)s' - '%(message_type)s' - '):' - '%(read_status)s ' - '%(protected_status)s ' - '\n' - '%(message)s\n' - '%(message_age)s') - - # ---------------------------------------------------------------------- # Mitter interface object # ---------------------------------------------------------------------- @@ -105,16 +89,8 @@ class Interface(object): self._statusbar = self._create_statusbar() self._main_tabs = gtk.Notebook() - self._grids = [] - - (grid_widget, grid) = self._create_grid('_new_message_count') - self._main_tabs.insert_page(grid_widget, gtk.Label('Messages')) - self._grids.append((grid, '_new_messages_count')) - - (replies_widget, replies) = self._create_grid( - '_new_replies_count') - self._main_tabs.insert_page(replies_widget, gtk.Label('Replies')) - self._grids.append((replies, '_new_replies_count')) + self._main_tabs.insert_page(MessageGrid(), gtk.Label('Messages')) + self._main_tabs.insert_page(MessageGrid(), gtk.Label('Replies')) update_box = gtk.VBox() update_box.set_property('border_width', 2) @@ -151,47 +127,6 @@ class Interface(object): 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.""" @@ -318,7 +253,8 @@ class Interface(object): 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) + _("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') @@ -399,90 +335,6 @@ class Interface(object): 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.author.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) - - message_values = {} - - # unescape escaped entities that pango is not okay with - message_values['message'] = html_escape(data.message) - message_values['username'] = html_escape(data.author.username) - message_values['full_name'] = html_escape(data.author.name) - message_values['message_age'] = time - - # highlight URLs - mask = r'\1' % ( - self._options[self.NAMESPACE]['link_colour']) - message_values['message'] = URL_RE.sub(mask, - message_values['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: - message_values['read_status'] = (' ' + - self._options[self.NAMESPACE]['unread_char']) - else: - message_values['read_status'] = '' - - if data.favourite: - message_values['favourite_star'] = ( - self._options[self.NAMESPACE]['unfavourite_char']) - else: - message_values['favourite_star'] = ( - self._options[self.NAMESPACE]['favourite_char']) - - info = [] - if data.reposted_by: - info.append(_(' — reposted by %s') % - (data.reposted_by.username)) - - if data.parent_owner: - info.append(_(' — in reply to %s') % - (data.parent_owner.username)) - - if data.protected: - message_values['protected_status'] = ( - self._options[self.NAMESPACE]['protected_char']) - else: - message_values['protected_status'] = '' - - message_values['message_type'] = ''.join(info) - markup = MESSAGE_FORMAT % (message_values) - - cell.set_property('markup', markup) - - return - - def _window_to_tray(self, statusicon, user_data=None): - """Minimize/display main window (as in minimize to tray.)""" - if self._main_window.get_property('visible'): - self._main_window.hide() - else: - self._main_window.show() - return - # ------------------------------------------------------------ # Helper functions # ------------------------------------------------------------ @@ -515,41 +367,6 @@ class Interface(object): self._reply_message_id = None 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.""" @@ -565,32 +382,36 @@ class Interface(object): if (self._new_message_count + self._new_replies_count) == 0: self._statusicon.set_from_pixbuf(self._images['icon']) else: - self._statusicon.set_from_pixbuf(self._images['new-messages']) - - return + self._statusicon.set_from_pixbuf( + self._images['new-messages']) - 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.author.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 # ------------------------------------------------------------ # Widget callback functions # ------------------------------------------------------------ + def _clear_posts(self, widget): + """Clear the posts in the currently selected tab.""" + page = self._main_tabs.get_current_page() + child = self._main_tabs.get_nth_page(page) + child.clear_posts() + return + + def _mark_all_read(self, widget): + """Mark all messages as read in the currently selected tab.""" + page = self._main_tabs.get_current_page() + child = self._main_tabs.get_nth_page(page) + child.mark_all_read() + return + + def _window_to_tray(self, statusicon, user_data=None): + """Minimize/display main window (as in minimize to tray.)""" + if self._main_window.get_property('visible'): + self._main_window.hide() + else: + self._main_window.show() + return + def _update_status(self, widget): """Update your status.""" if not self._update_field.get_property('visible'): @@ -649,99 +470,12 @@ class Interface(object): 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.""" - for grid in self._grids: - (grid, counter) = grid - model = grid.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 = grid.get_column(1) - iter = model.get_iter_first() - path = model.get_path(iter) - - width = win_width - 85 # 48 = icon size - # TODO: Find out where those 37 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)) - 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() - (grid, counter) = self._grids[page] - grid.get_model().clear() - setattr(self, counter, 0) - self._message_count_updated() - return - - def _mark_all_read(self, widget, user_data=None): - """Mark all messages as read.""" - page = self._main_tabs.get_current_page() - (grid, counter) = self._grids[page] - model = grid.get_model() - if len(model) == 0: - # no messages, so we don't worry (if I recall correctly, - # get_iter_first will fail if the model is empty) - return - - iter = model.get_iter_first() - while iter: - message = model.get_value(iter, 0) - message.read = True - iter = model.iter_next(iter) - - # update the counters - set(attr, self, counter, 0) - self._message_count_updated() + (win_width, win_height) = self._main_window.get_size() + total_tabs = self._main_tabs.get_n_pages() + for page in range(0, total_tabs): + child = self._main_tabs.get_nth_page(page) + child.update_window_size(win_width) return def _delete_message(self, widget, user_data=None): @@ -915,57 +649,6 @@ class Interface(object): self._options.save() 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) - - def _message_popup(self, widget, user_data=None): - """Builds the popup with the URLs in the message.""" - _log.debug('Popup') - (path, _) = widget.get_cursor() - if not path: - return True - - # create a syntetic event (the popup requires one) - event = gtk.gdk.Event(gtk.gdk.BUTTON_PRESS) - event.button = 3 - 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: @@ -1245,8 +928,8 @@ class Interface(object): return def __call__(self): - """Call function; displays the interface. This method should appear on - every interface.""" + """Call function; displays the interface. This method should + appear on every interface.""" if self._options[self.NAMESPACE]['statusicon']: self._statusicon = gtk.StatusIcon()