#!/usr/bin/python # # Custom twitter client... mostly for learning Python import sys, ConfigParser, os, re, optparse, shelve import gtk, gtk.glade, gobject from urllib2 import HTTPError,URLError from twitterwidgets import TweetPane from threading import enumerate,Condition import apithreads class Hrafn(): """ Display Tweets, post to twitter """ def __init__(self, resize): global config self.resize = resize self.lists = {} self.lists_cond = Condition() if config.get('global', 'trayicon') == '1': self.use_trayicon = True if config.get('global', 'taskbar_when_minimized') == '1': self.taskbar_min = True else: self.taskbar_min = False else: self.use_trayicon = False self.taskbar_min = True # Init the glade stuff here, so we don't have a race condition with # the lists-ready signal self.init_user_interface('./ui/default.glade') self.first_account_item = None # And init the DB stuff here db_file = os.path.expanduser(config.get('global', 'dbfile')) self.db = shelve.open(db_file) if not self.db.has_key('tokens'): self.db['tokens'] = [] if not self.db.has_key('active_page'): self.db['active_page'] = 0 self.num_entries = int(config.get('global', 'entries')) self.refresh_time = int(config.get('global', 'refreshtime')) if not self.db.has_key('active_user'): self.db['active_user'] = None # Now set up the accounts and their corresponding APIs self.accounts = {} for token in self.db['tokens']: api = apithreads.CustomApi(token, self) self.add_account(api) self.username = self.db['active_user'] self.minimized = False try: self.api = self.accounts[self.username] except KeyError: self.api = None if not self.db.has_key('open_tabs'): self.db['open_tabs'] = [] # refresh_time is in minutes... convert to seconds here self.refresh_time *= 60 self.reply_id = None # Load up all the programmatic GUI stuff self.init_widgets() def init_user_interface(self, path_to_skin): self.widget_tree=gtk.glade.XML(path_to_skin, "window") self.widget_tree.signal_autoconnect(self) # Get widgets from glade self.window = self.widget_tree.get_widget('window') self.tweet_notebook = self.widget_tree.get_widget('tweet_notebook') self.view_menu = self.widget_tree.get_widget('view_menu') self.accounts_menu = self.widget_tree.get_widget('accounts_menu') self.update_entry = self.widget_tree.get_widget('update_entry') self.update_count = self.widget_tree.get_widget('update_count') self.status_bar = self.widget_tree.get_widget('status_bar') self.search_entry = self.widget_tree.get_widget('search_entry') self.following_button = self.widget_tree.get_widget('following_button') self.at_button = self.widget_tree.get_widget('at_button') self.verified_label = self.widget_tree.get_widget('verified_label') self.account_label = self.widget_tree.get_widget('account_label') self.help_menu = self.widget_tree.get_widget('help_menu') def init_widgets(self): # Set the main window size if self.resize and self.db.has_key('width') and self.db.has_key('height'): self.window.resize(self.db['width'], self.db['height']) self.context_id = self.status_bar.get_context_id('message') # Add debug options to help menu if debug: menu_item = gtk.MenuItem('debug: Show Threads') menu_item.connect('activate', self.debug_show_threads) self.help_menu.append(menu_item) menu_item.show() # Add a system tray icon if self.use_trayicon: self.tray_icon = gtk.status_icon_new_from_file('ui/icon.svg') self.tray_icon.connect('activate', self.on_tray_icon_clicked) self.tray_icon.connect('popup-menu', self.on_tray_icon_popup) self.tray_menu = gtk.Menu() quit_item = gtk.ImageMenuItem(gtk.STOCK_QUIT) quit_item.connect('activate', self.gtk_main_quit) self.tray_menu.append(quit_item) quit_item.show() # Set the account label self.update_account_label() # Manual tweaks to the glade UI, to overcome its limitations self.tweet_notebook.remove_page(0) # Add the tabs from last session to the notebook page_num = self.db['active_page'] for tab, single_tweet, conversation in self.db['open_tabs']: self.add_to_notebook(tab, single_tweet, conversation) self.tweet_notebook.set_current_page(page_num) # Timer to update periodically gobject.timeout_add(self.refresh_time * 1000, self.update_windows) def update_account_label(self): if self.username is not None: self.account_label.set_text(self.username + ': ') # Spawns a thread for each pane, which updates that pane. def update_windows(self): for i in range(0, self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) self.update_single_window(pane) # We have to return true, so the timeout_add event will keep happening return True def update_single_window(self, pane): list_name = pane.get_list_name() # Single tweets should never be updated here if pane.get_single_tweet() is not None: return # Determine username and appropriate account to use account = None username = re.sub('/Home', '', list_name) if self.accounts.has_key(username): account = self.accounts[username] if account is None: username = re.sub('@', '', list_name) if self.accounts.has_key(username): account = self.accounts[username] if account is None: username = re.sub('/Direct Messages', '', list_name) if self.accounts.has_key(username): account = self.accounts[username] if account is None: username = re.sub(r'list: (.*)/.*', r'\1', list_name) if self.accounts.has_key(username): account = self.accounts[username] if account is None: account = self.api username = self.username apithreads.GetTweets(api=account, list_name=list_name, pane=pane, num_entries=self.num_entries, username=username ).start() def update_window_callback(self, widget): pane = self.get_current_pane() self.update_single_window(pane) def update_status(self): reply_id = self.reply_id text = self.update_entry.get_text() thread = apithreads.PostUpdate(self.api, text, reply_id) thread.sig_proxy.connect('update-posted', self.on_update_posted) self.update_entry.set_sensitive(False) self.update_status_bar('Posting...') thread.start() def update_status_callback(self, widget): self.update_status() def text_watcher(self, widget): ''' Watch text entered on the update_entry, update things ''' text_len = self.update_entry.get_text_length() new_count = str(text_len) + "/140" self.update_count.set_label(new_count) # If reply_id is set, unset it if we have removed the @ symbol if self.reply_id and not re.match('@', self.update_entry.get_text()): self.reply_id = None def gtk_main_quit(self, widget): self.db.close() gtk.main_quit() def on_about(self, widget): print "stub: Hrafn.on_about()" def on_reply(self, widget, data): self.update_entry.set_text('@' + data['screen_name'] + ' ') self.reply_id = data['id'] self.update_entry.grab_focus() def on_retweet(self, widget, data): thread = apithreads.PostRetweet(self.api, data['id']) thread.sig_proxy.connect('retweet-posted', self.on_retweet_posted) self.update_entry.set_sensitive(False) self.update_status_bar('Posting retweet...') thread.start() def on_reply_to(self, widget, data): self.add_to_notebook(data['name'], data['id']) def on_conversation(self, widget, data): self.add_to_notebook(data['name'], data['id'], True) def on_view_selected(self, event, username, name): if name == 'Home' or name == 'Direct Messages': full_name = username + '/' + name elif name == '@' + username: full_name = name else: full_name = 'list: ' + username + '/' + name # Now, add a new tab with this list self.add_to_notebook(full_name) # Remove one of the views from the tweet notebook. # Called when the close button is clicked on one of the views # or Ctrl + W is pressed while the view is active def remove_view(self, name, single_tweet, conversation): ot = self.db['open_tabs'] ot.remove((name,single_tweet,conversation)) self.db['open_tabs'] = ot for i in range(self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) if (pane.get_list_name() == name): self.tweet_notebook.remove_page(i) return def remove_view_callback(self, event, name, single_tweet, conversation): self.remove_view(name, single_tweet, conversation) def get_current_pane(self): return self.tweet_notebook.get_nth_page(self.tweet_notebook.get_current_page()) def add_to_notebook(self, name, single_tweet=None, conversation=False): # If it already exists, don't add it, just switch to it for i in range(self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) # Unless it is a single tweet... ignore those unless # we are also a single tweet... then, special logic if pane.get_single_tweet() is not None: if pane.get_single_tweet() == single_tweet: self.tweet_notebook.set_current_page(i) return elif pane.get_list_name() == name: self.tweet_notebook.set_current_page(i) return # Add the pane to the persistent database of open panes if (name, single_tweet, conversation) not in self.db['open_tabs']: ot = self.db['open_tabs'] ot.append((name,single_tweet,conversation)) self.db['open_tabs'] = ot is_user = False if re.match('user:', name): is_user = True entries=self.num_entries if single_tweet and not conversation: entries=1 new_pane = TweetPane(name, username=self.username, num_entries=entries, single_tweet=single_tweet, is_user=is_user, conversation=conversation) new_pane.connect('new-tweets', self.on_read_tweets_changed) new_pane.connect('tweets-read', self.on_read_tweets_changed) if is_user: # Find the lists this user is currently in, and pass those # to the pane found_lists = [] username = re.sub('user: ', '', name) self.lists_cond.acquire() while not self.lists.has_key(self.username): self.lists_cond.wait() for list_name in self.lists[self.username].keys(): try: i = self.lists[self.username][list_name].index(username) found_lists.append(list_name) except ValueError: pass new_pane.set_lists(found_lists) self.lists_cond.release() new_pane.user_box.connect('at-clicked', self.on_at_button_clicked) new_pane.user_box.connect('follow-clicked', self.on_follow_button_clicked) apithreads.GetFollowing(api=self.api, pane=new_pane, user=name).start() apithreads.GetUserInfo(api=self.api, pane=new_pane, user=name).start() self.tweet_notebook.append_page_menu(new_pane, new_pane.get_tab_label(), gtk.Label(name)) self.tweet_notebook.set_tab_reorderable(new_pane, True) new_pane.get_tab_label().connect('close-clicked', self.remove_view_callback, name, single_tweet, conversation) new_pane.connect('tweet-reply', self.on_reply) new_pane.connect('tweet-retweet', self.on_retweet) new_pane.connect('tweet-in-reply-to', self.on_reply_to) new_pane.connect('tweet-conversation', self.on_conversation) new_pane.connect('show-user', self.show_user_callback) new_pane.connect('show-hashtag', self.show_hashtag) # Special logic for single tweet pane if single_tweet is not None: if conversation: apithreads.GetConversation(api=self.api, pane=new_pane, root_tweet_id=single_tweet).start() else: apithreads.GetSingleTweet(api=self.api, pane=new_pane, single_tweet=single_tweet).start() else: self.update_single_window(new_pane) # Switch to the new pane self.tweet_notebook.set_current_page(-1) def on_tab_change(self, event, page, page_num): last_page = self.db['active_page'] self.db['active_page'] = page_num # Now get the new page, and set everything up pane = self.tweet_notebook.get_nth_page(page_num) pane.set_tweets_read() def on_tabs_reordered(self, widget, child, page_num): self.db['active_page'] = page_num # Clear the persistent tabs list, and recreate it # from scratch tab_names = [] for i in range(self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) tab_names.append((pane.get_list_name(), pane.get_single_tweet(), pane.get_conversation())) self.db['open_tabs'] = tab_names def on_search(self, event): search_string = self.search_entry.get_text() self.search_entry.set_text('') self.add_to_notebook(search_string) def update_status_bar(self, text): self.status_bar.pop(self.context_id) self.status_bar.push(self.context_id, text) def on_friendship_changed(self, widget, data): if data['success']: if data['follow']: self.update_status_bar('Now following ' + data['user_name']) else: self.update_status_bar('No longer following ' + data['user_name']) else: # didn't succeed if data['follow']: self.update_status_bar('Failed to follow ' + data['user_name']) else: self.update_status_bar('Failed to unfollow ' + data['user_name']) def show_user(self, name): self.add_to_notebook('user: ' + name) def show_user_callback(self, widget, data): self.show_user(data) def show_hashtag(self, widget, data): self.add_to_notebook('#' + data) def on_at_button_clicked(self, widget, user_name): self.add_to_notebook('@' + user_name) def on_follow_button_clicked(self, widget, follow): user_name = re.sub('^user: ', '', widget.get_list_name()) thread = apithreads.ChangeFriendship(self.api, widget, user_name, follow) thread.sig_proxy.connect('friendship-changed', self.on_friendship_changed) thread.start() def global_key_press_handler(self, widget, event): keyname = gtk.gdk.keyval_name(event.keyval) if keyname == 'w' and event.state & gtk.gdk.CONTROL_MASK: self.close_current_tab() # Ctrl + Shift + Tab or Ctrl + PgUp or Ctrl + Left should go to prev tab elif event.state & gtk.gdk.CONTROL_MASK and ((keyname == 'Tab' and event.state & gtk.gdk.SHIFT_MASK) or keyname == 'ISO_Left_Tab' or keyname == 'Page_Up' or keyname == 'Left'): if self.tweet_notebook.get_current_page() == 0: self.tweet_notebook.set_current_page(-1) else: self.tweet_notebook.prev_page() return True # Ctrl + Tab or Ctrl + PgDown or Ctrl + Right should go to next tab elif event.state & gtk.gdk.CONTROL_MASK and (keyname == 'Tab' or keyname == 'Page_Down' or keyname == 'Right'): if self.tweet_notebook.get_current_page() == self.tweet_notebook.get_n_pages() - 1: self.tweet_notebook.set_current_page(0) else: self.tweet_notebook.next_page() return True else: scrolltype = None if keyname == 'Page_Down': scrolltype = gtk.SCROLL_PAGE_FORWARD elif keyname == 'Page_Up': scrolltype = gtk.SCROLL_PAGE_BACKWARD elif keyname == 'Up': scrolltype = gtk.SCROLL_STEP_BACKWARD elif keyname == 'Down': scrolltype = gtk.SCROLL_STEP_FORWARD if scrolltype: self.get_current_pane().emit('scroll-child', scrolltype, False) return True def close_current_tab(self): current_pane = self.get_current_pane() self.remove_view(current_pane.get_list_name(), current_pane.get_single_tweet(), current_pane.get_conversation()) def on_account_changed(self, widget, new_account): if not (widget.get_active() and self.accounts.has_key(new_account)): return self.username = new_account self.api = self.accounts[self.username] self.db['active_user'] = self.username self.update_account_label() for i in range(0, self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) if re.match(r'user: ', pane.get_list_name()): user = re.sub(r'user: ', r'', pane.get_list_name()) apithreads.GetFollowing(api=self.api, pane=pane, user=user).start() def add_lists(self, username, list_data): ''' This function is called by a child thread. It takes list info from the API, stores it for later use, and uses the data to populate the Views menu ''' # Setup the new sub-menu outer_menu_item = gtk.MenuItem(username, False) self.view_menu.append(outer_menu_item) new_menu = gtk.Menu() outer_menu_item.set_submenu(new_menu) self.lists_cond.acquire() # Save the member info in a data structure for later usage self.lists[username] = list_data self.lists_cond.notify() self.lists_cond.release() list_names = list_data.keys() list_names.sort() # Insert the default list items list_names.insert(0, 'Home') list_names.insert(1, '@' + username) list_names.insert(2, 'Direct Messages') for l in list_names: # Add the item to the submenu, connect handler menu_item = gtk.MenuItem(l, False) new_menu.append(menu_item) menu_item.connect('activate', self.on_view_selected, username, l) menu_item.show() outer_menu_item.show() def on_resize(self, widget, event): self.db['width'] = event.width self.db['height'] = event.height def on_update_posted(self, widget, success): if success: self.reply_id = None self.update_entry.set_text("") self.update_status_bar('Tweet Posted') else: self.update_status_bar('Failed to post tweet') self.update_entry.set_sensitive(True) def on_retweet_posted(self, widget, success): if success: self.update_status_bar('Retweet Posted') else: self.update_status_bar('Failed to retweet') self.update_entry.set_sensitive(True) def debug_show_threads(self, widget): print 'debug_show_threads()' for thread in enumerate(): print 'debug: thread: ' + thread.name def on_file_add_account(self, widget): token = apithreads.get_access_token(self.window) if token is None: return api = apithreads.CustomApi(token, self) if not self.accounts.has_key(api.username): tokens = self.db['tokens'] tokens.append(token) self.db['tokens'] = tokens self.add_account(api) def add_account(self, api): username = api.username self.accounts[username] = api # Add account's menu item menu_item = gtk.RadioMenuItem(self.first_account_item, label=username, use_underline=False) if not self.first_account_item: self.first_account_item = menu_item menu_item.set_draw_as_radio(False) if not self.db.has_key('active_user'): self.db['active_user'] = username elif username == self.db['active_user']: menu_item.set_active(True) menu_item.connect('activate', self.on_account_changed, username) self.accounts_menu.append(menu_item) menu_item.show() def on_tray_icon_clicked(self, event): if self.minimized: self.window.deiconify() else: self.window.iconify() def on_tray_icon_popup(self, icon, button, activate_time): self.tray_menu.popup(None, None, gtk.status_icon_position_menu, button, activate_time, icon) def on_window_state_changed(self, window, event): if event.changed_mask & gtk.gdk.WINDOW_STATE_ICONIFIED: if event.new_window_state & gtk.gdk.WINDOW_STATE_ICONIFIED: self.minimized = True if not self.taskbar_min: self.window.set_property('skip-taskbar-hint', True) else: self.minimized = False self.window.set_property('skip-taskbar-hint', False) def on_read_tweets_changed(self, widget): unread_tweets = 0 for i in range(self.tweet_notebook.get_n_pages()): pane = self.tweet_notebook.get_nth_page(i) unread_tweets += pane.num_new_tweets if unread_tweets > 0: self.tray_icon.set_property('blinking', True) else: self.tray_icon.set_property('blinking', False) ### end class Hrafn def _check_config(self, config, config_file): new_data = False if not config.has_section('global'): config.add_section('global') new_data = True if not config.has_option('global', 'entries'): config.set('global', 'entries', '20') new_data = True if not config.has_option('global', 'refreshtime'): config.set('global', 'refreshtime', '5') new_data = True if not config.has_option('global', 'trayicon'): config.set('global', 'trayicon', '1') new_data = True if not config.has_option('global', 'taskbar_when_minimized'): config.set('global', 'taskbar_when_minimized', '0') new_data = True if not config.has_option('global', 'debug'): config.set('global', 'debug', '0') new_data = True if not config.has_option('global', 'dbfile'): config.set('global', 'dbfile', '~/.hrafn.db') new_data = True # Write out new config data, if needed if new_data: config_filehandle = open(os.path.expanduser(config_file), 'wb') config.write(config_filehandle) config_filehandle.close() # main if __name__ == "__main__": parser = optparse.OptionParser() parser.add_option('-c' ,'--config', dest="filename", default="~/.hrafn.conf", help="read configuration from FILENAME instead of the default ~/.hrafn.conf") parser.add_option('-n' ,'--no-resize', dest="resize", action='store_false', default=True, help="use the default window size instead of the size from the last session") (options, args) = parser.parse_args() config = ConfigParser.ConfigParser() config.read(os.path.expanduser(options.filename)) # Set config options to defaults, if they are not present _check_config(config, config_file) debug = False if config.get('global', 'debug') == 1: debug = True base_icon = gtk.gdk.pixbuf_new_from_file('ui/icon.svg') icon = base_icon.scale_simple(128, 128, gtk.gdk.INTERP_BILINEAR) gtk.window_set_default_icon(icon) my_twitter = Hrafn(options.resize) gtk.gdk.threads_init() gtk.gdk.threads_enter() gtk.main() gtk.gdk.threads_leave()