# # Known issues # # - Expander knob should be indented or completely removed. # Note: Adding a column for the icons and putting # the expander in the second column looks bad. # Possible Fix: Create a custom cellrenderer that renders # icon+text and optionally separator lines, this way # the expander knob should align up with the icons... # # - The entry loose focus sometimes when the CuemiacWindow appears, # really annoying! # # - No line breaking for long titles. Do we want this anyway? # should we just ellipsize? # # - Should reduce window size on row collapse. Should this be done in # CuemiacWidget or by the CuemiacWindow containing it? # Maybe CuemiacWidget should emit a signal when it collapses a row... # # - "hover-expand" does not work when moving the cursor with the keyboard. # # - No Multiscreen logic yet. # # - ** Generally focus issues all over the place! ** from os.path import * MYPATH = abspath(dirname(__file__)) import cgi import sys import gtk, gobject import gnome import gnome.ui, gnomeapplet from gettext import gettext as _ class Nest : """ A class used to handle nested results in the CuemiacModel """ def __init__(self, category_name): self.__nest_msg = category_name self.__num_children = 0 def get_name (self, text=None): return {"count" : self.__num_children} def get_verb (self): return self.__nest_msg def set_num_children (self, num): self.__num_children = num def inc_num_children (self): self.__num_children = self.__num_children + 1 class Separator : """ Just a dummy to stick in the TreeStore when we have a category separator """ pass class UnknownCategory (Exception): def __init__ (self, category_name, match): print "Unknown Category '%s' requested by %s" % (category_name, match.__class__) # See CuemiacModel for a description of this beast CATEGORIES = { "Files" : { "name": "Files", "nest": "%(count)s more files", "threshold": 3 }, "Actions" : { "name": "Actions", "nest": "%(count)s more actions", "threshold": 1 }, "News" : { "name": "News", "nest": "%(count)s more news items", "threshold": 3 }, "Contacts" : { "name": "Contacts", "nest": "%(count)s more contacts", "threshold": 3 }, "Emails" : { "name": "Emails", "nest": "%(count)s more emails", "threshold": 3 }, "Notes" : { "name": "Notes", "nest": "%(count)s more notes", "threshold": 3 }, "Volumes" : { "name": "Volumes", "nest": "%(count)s more volumes", "threshold": 3 }, "Google Search" : { "name": "Google Search", "nest": "%(count)s more online hits", "threshold": 2 }, "Calendar" : { "name": "Calendar", "nest": "%(count)s more calendar items", "threshold": 1 }, "Conversation" : { "name": "Conversation", "nest": "%(count)s more conversations", "threshold": 1 }, "Web Browser" : { "name":"Web Browser", "nest":"%(count)s more items", "threshold":5, }, "Programs" : { "name":"Programs", "nest":"%(count)s more programs", "threshold":3, }, #"" : { # "name":"", # "nest":"", # "threshold":5, #}, } class CuemiacModel (gtk.TreeStore): # Column name MATCHES = 0 def __init__ (self, category_infos): """ category_infos is a dict containing (case-sensitive) category names, with corresponding category information dicts. The category information dicts must contain the follwing key-value pairs: "name" : The *display name* of the category "nest" : what string to display for nested results (str) can refer to %(count)s to insert the number of childs. "threshold" : how many results before we start nesting them (int) The categories supplied in this dict are the only ones that Cuemiac model will allow. Request for other categories raise a UnknownCategory exception. IMPORTANT: The key values of the category_infos dict is NOT necesarily the same as category_infos[cat_name]["name"]. This is for internationalization reasons. Basically the keys of the dict are the only thing that uniquely determines a category. """ gtk.TreeStore.__init__ (self, gobject.TYPE_PYOBJECT) # Match object self.infos = category_infos # dict containing category descriptions self.categories = {} # a dict containing # {name : (display_name, gtk.TreeRowReference, gtk.TreeRowReference, count, threshold)) # where the tuple contains # (displayed_name, first_entry, nested_entry, total_num_hits, hits_before_hide) # for each category self.count = 0 def append (self, match): """ Appends the match to the category returned by match.get_category(). Automatically creates a new category for the match if necesary. """ if self.categories.has_key (match.get_category()): self.__append_to_category (match, self.categories [match.get_category()]) else: self.create_category_with_match (match) self.count = self.count + 1 def __append_to_category (self, match, category): cat_name, first, nest, count, threshold = category if count < threshold: pos = self.__get_row_ref_position (first) self.insert (None, pos+count, [match]) self.categories[cat_name] = (cat_name, first, nest, count + 1, threshold) elif count == threshold: # We need to nest the rest of the matches, # so append a Nest to the list nest_entry = Nest (self.infos[cat_name]["nest"]) pos = self.__get_row_ref_position (first) iter = self.insert (None, pos+count, [nest_entry]) # Create a ref to the nested match entry nest = gtk.TreeRowReference (self, self.get_path(iter)) # Now append the match nested in the NestedMatch gtk.TreeStore.append (self, iter, [match]) nest_entry.inc_num_children () self.categories[cat_name] = (cat_name, first, nest, count + 1, threshold) else: # Append nested, and increase the number of children in the # nested match iter = self.get_iter ( nest.get_path() ) gtk.TreeStore.append (self, iter, [match]) self[iter][self.MATCHES].inc_num_children () # FIXME We should show a count in the nested match self.categories[cat_name] = (cat_name, first, nest, count + 1, threshold) def create_category_with_match (self, match): """ Creates a new category with the given match as first entry. """ cat_name = match.get_category () if not self.infos.has_key (cat_name): print 'Unknown category:',cat_name raise UnknownCategory(cat_name, match) iter = gtk.TreeStore.append (self, None, [match]) gtk.TreeStore.append (self, None, [Separator()]) first = gtk.TreeRowReference (self, self.get_path(iter)) # Add the new category to our category list self.categories [cat_name] = (self.infos[cat_name]["name"], first, None, 1, self.infos[cat_name]["threshold"]) def clear (self): """ Clears this model of data. """ gtk.TreeStore.clear (self) self.categories = {} self.count = 0 def set_category_threshold (self, cat_name, threshold): display_name, first, nest, count, old_thres = self.categories[cat_name] self.categories[cat_name] = (display_name, first, nest, count, threshold) def __get_row_ref_position (self, row_ref): return int (self.get_string_from_iter (self.get_iter(row_ref.get_path()))) def __calc_max_category_width (self): max_width = 0 for cat_name in self.infos.iterkeys (): cat_width = len(self.infos[cat_name]["name"]) if cat_width > max_width: max_width = cat_width self.max_category_width = max_width def get_max_category_width (self): return self.max_category_width class CuemiacTreeView (gtk.TreeView): """ Shows a DeskbarCategoryModel. Used internally in the CuemiacWidget. """ __gsignals__ = { "match-selected" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, [gobject.TYPE_PYOBJECT]), } def __init__ (self, cuemiac_model): gtk.TreeView.__init__ (self, cuemiac_model) icon = gtk.CellRendererPixbuf () hit_title = gtk.CellRendererText () hits = gtk.TreeViewColumn ("Hits") hits.pack_start (icon) hits.pack_start (hit_title) hits.set_cell_data_func(hit_title, self.__get_match_title_for_cell) hits.set_cell_data_func(icon, self.__get_match_icon_for_cell) self.append_column (hits) #self.set_row_separator_func( # lambda model, iter: (model[iter][model.MATCHES].__class__ == Separator)) self.connect ("cursor-changed", self.__on_cursor_changed) self.set_property ("headers-visible", False) self.set_property ("hover-selection", True) self.set_property ("hover-expand", True) self.set_reorderable(True) self.connect ("button-press-event", self.__on_click) def __on_cursor_changed (self, view): model, iter = self.get_selection().get_selected() if model[iter][model.MATCHES].__class__ == Separator: self.get_selection().unselect_iter (iter) def __get_match_icon_for_cell (self, column, cell, model, iter, data=None): match = model[iter][model.MATCHES] if match.__class__ == Separator or match.__class__ == Nest: cell.set_property ("pixbuf", None) else: icon = match.get_icon() cell.set_property ("pixbuf", icon) def __get_match_title_for_cell (self, column, cell, model, iter, data=None): match = model[iter][model.MATCHES] if match.__class__ == Separator: cell.set_property ("markup", "") cell.set_property ("height", 20) return else: cell.set_property ("height", -1) t = "cuemiac" # Pass unescaped query to the matches verbs = {"text" : t} verbs.update(match.get_name(t)) # Escape the query now for display verbs["text"] = cgi.escape(verbs["text"]) cell.set_property ("markup", match.get_verb () % verbs) def clear (self): self.model.clear () def __on_click (self, widget, event): model, iter = self.get_selection().get_selected() match = model[iter][model.MATCHES] # # We initially expanded on a click anywhere # within the nested row. This was a mess. # #if match.__class__ == Nest: # path = model.get_path(iter) # if self.row_expanded (path): # self.collapse_row (path) # else: # self.expand_row (path, True) #else: # self.emit ("match-selected", match) self.emit ("match-selected", match) class CuemiacWidget (gtk.HBox): """ The Cuemiac. Nothing more to say. This is what you want. The rest of this file is just code. """ __gsignals__ = { "match-selected" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, [gobject.TYPE_PYOBJECT]), } def __init__ (self, cuemiac_model, cuemiac_treeview): gtk.HBox.__init__ (self) self.layout = gtk.Layout () # Place to draw category names on self.layout.set_border_width (6) self.layout.set_property ("width", 100) self.pack_start(self.layout, expand=True) self.model = cuemiac_model self.view = cuemiac_treeview self.cat_labels = {} # keys are category names and values are widgets self.pack_start(self.view, expand=True) self.view.connect_after ("size-allocate", self.align_category_labels) self.view.connect ("match-selected", lambda sender, match: self.emit ("match-selected", match)) self.layout.connect ("expose-event", self.layout_underline_categories) self.__init_category_labels () self.max_category_label_width = self.__calc_max_category_label_width () def __init_category_labels (self): """ Creates gtk.Labels for all categories in allowed by the model. Each label is realize()'ed so that we can calculate diemsnions later. Called by the constructor. """ for cat_name in self.model.infos.iterkeys(): label = gtk.Label () label.set_markup (""+self.model.infos[cat_name]["name"]+"") label.realize () self.cat_labels [cat_name] = label def __calc_max_category_label_width (self): """ Returns the max width in pixels of the category labels. """ max_width = 0 for cat_label in self.cat_labels.itervalues (): w, h = cat_label.get_layout().get_pixel_size() if w > max_width: max_width = w return max_width def append (self, match): """ Automagically append a match or list of matches to correct category(s), or create a new one(s) if needed. """ if type (match) == list: for hit in match: self.__append (hit) else: self.__append (match) def __append (self, match): """ Appends a single match to the correct category, or creates a new category for it if needeed. """ self.model.append (match) def get_label_for_category (self, cat_name, display_name): """ Returns a gtk.Label representing the category title string. HINT: You can obtain the dimensions of a realized label (like these) with w, h = label.get_layout().get_pixel_size() The normal get_allocation() and/or get_size_request() will not necesarily work unless the label has actually benn drawn on screen. """ return self.cat_labels [cat_name] def align_category_labels (self, sender, allocation): """ Callback to postion the category "labels". """ if self.view.window is None: return column = self.view.get_column(0) pad = self.layout.get_border_width () width = self.layout.get_allocation().width - pad rect = self.view.get_allocation () self.layout.set_size_request (self.max_category_label_width + 4*pad, rect.height) self.layout.bin_window.clear () cairo = self.layout.bin_window.cairo_create () cairo.set_source_rgb (0,0,0) cairo.set_line_width (0.5) for cat_name in self.model.categories.iterkeys (): display_name, first, nest, count, threshold = self.model.categories[cat_name] path = first.get_path() area = self.view.get_background_area(path, column) x,y = self.view.tree_to_widget_coords(area.x, area.y) label = self.get_label_for_category (cat_name, display_name) if not label.parent: self.layout.put(label, 0, y + self.view.style_get_property("vertical-separator")) label.show () # Calculate dimensions from Pango Layout. # Since we might not be drawn on screen yet, # normal gtk ops might not work... w, h = label.get_layout().get_pixel_size() self.layout.move(label, width - w, y + self.view.style_get_property("vertical-separator")) y = y + area.height - 2*self.view.style_get_property("vertical-separator") cairo.move_to (pad, y) cairo.line_to (width, y) cairo.stroke () def layout_underline_categories (self, view, event): # We have to do this in response to an expose event. # Doing it in align_category_labels does *not* work # since it triggers an expose event on self.layout, # which will overwrite any drawing. width = self.layout.get_allocation().width pad = self.layout.get_border_width () column = self.view.get_column(0) self.layout.bin_window.clear () cairo = self.layout.bin_window.cairo_create () cairo.set_source_rgb (0,0,0) cairo.set_line_width (0.5) # FIXME This does not seems to have any effect for cat_name in self.model.categories.iterkeys (): display_name, first, nest, count, threshold = self.model.categories[cat_name] path = first.get_path() area = self.view.get_background_area(path, column) x,y = self.view.tree_to_widget_coords(area.x, area.y) y = y + area.height - 2*self.view.style_get_property("vertical-separator") cairo.move_to (pad, y) cairo.line_to (width, y) cairo.stroke () return False def clear (self): """ Use this to clear the entire view and model structure of the Cuemiac. """ self.model.clear () if self.layout.window: for label in self.cat_labels.itervalues (): self.layout.remove (label) self.layout.bin_window.clear () class CuemiacWindow (gtk.Window): """ Borderless window aligning itself to a given widget """ def __init__(self, widgetToAlignWith, alignment): """ alignment should be one of gnomeapplet.ORIENT_{DOWN,UP,LEFT,RIGHT} The window will be placed accordingly relative to the given widget upon invoking .show(). """ gtk.Window.__init__(self, gtk.WINDOW_TOPLEVEL) self.set_decorated(False) self.widgetToAlignWith = widgetToAlignWith self.alignment = alignment self.__is_shown = False def show (self): """ Calculates the position and shows the window """ # Get our own dimensions & position self.realize() gtk.gdk.flush() window_width = (self.window.get_geometry())[2] window_height = (self.window.get_geometry())[3] # Skip the taskbar, and the pager, stick and stay on top self.stick() self.set_keep_above(True) self.set_skip_pager_hint(True) self.set_skip_taskbar_hint(True) self.set_type_hint (gtk.gdk.WINDOW_TYPE_HINT_DOCK) # Get the dimensions/position of the widgetToAlignWith self.widgetToAlignWith.realize() (x, y) = self.widgetToAlignWith.window.get_origin() (w, h) = self.get_size() (w, h) = self.size_request() target_w = self.widgetToAlignWith.allocation.width target_h = self.widgetToAlignWith.allocation.height screen = self.get_screen() found_monitor = False n = screen.get_n_monitors() for i in range(0, n): monitor = screen.get_monitor_geometry(i) if (x >= monitor.x and x <= monitor.x + monitor.width and \ y >= monitor.y and y <= monitor.y + monitor.height): found_monitor = True break if not found_monitor: monitor = gtk.gdk.Rectangle(0, 0, screen.get_width(), screen.get_width()) self.alignment if self.alignment == gnomeapplet.ORIENT_RIGHT: x += target_w if ((y + h) > monitor.y + monitor.height): y -= (y + h) - (monitor.y + monitor.height) if ((y + h) > (monitor.height / 2)): gravity = gtk.gdk.GRAVITY_SOUTH_WEST else: gravity = gtk.gdk.GRAVITY_NORTH_WEST elif self.alignment == gnomeapplet.ORIENT_LEFT: x -= w if ((y + h) > monitor.y + monitor.height): y -= (y + h) - (monitor.y + monitor.height) if ((y + h) > (monitor.height / 2)): gravity = gtk.gdk.GRAVITY_SOUTH_EAST else: gravity = gtk.gdk.GRAVITY_NORTH_EAST elif self.alignment == gnomeapplet.ORIENT_DOWN: y += target_h if ((x + w) > monitor.x + monitor.width): x -= (x + w) - (monitor.x + monitor.width) gravity = gtk.gdk.GRAVITY_NORTH_WEST elif self.alignment == gnomeapplet.ORIENT_UP: y -= h if ((x + w) > monitor.x + monitor.width): x -= (x + w) - (monitor.x + monitor.width) gravity = gtk.gdk.GRAVITY_SOUTH_WEST # -"Coordinates locked in captain." # -"Engage." self.move(x, y) #print "Move win to "+x+","+y self.set_gravity(gravity) gtk.Window.show (self) self.__is_shown = True def show_all (self): self.show () gtk.Window.show_all (self) def hide (self): self.__is_shown = False gtk.Window.hide (self) def is_shown (self): return self.__is_shown # --------------------BEGIN CUT-N-PASTE FROM DESKBAR-APPLET --------------- import gtk gtk.threads_init() import gnome.ui, gnomeapplet import getopt, sys from os.path import * # Allow to use uninstalled def _check(path): return exists(path) and isdir(path) and isfile(path+"/AUTHORS") name = join(dirname(__file__), "..") if _check(name): print 'Running uninstalled deskbar, modifying PYTHONPATH' sys.path.insert(0, abspath(name)) else: print "Running installed deskbar, using normal PYTHONPATH" # Now the path is set, import our applet import deskbar, deskbar.applet, deskbar.defs import gettext, locale gettext.bindtextdomain('deskbar-applet', abspath(join(deskbar.defs.DATA_DIR, "locale"))) gettext.textdomain('deskbar-applet') locale.bindtextdomain('deskbar-applet', abspath(join(deskbar.defs.DATA_DIR, "locale"))) locale.textdomain('deskbar-applet') # --------------------END CUT-N-PASTE FROM DESKBAR-APPLET --------------- modules = [] def on_module_loaded (loader, ctx): loader.initialize_module (ctx) modules.append (ctx) if ctx.module.is_async (): ctx.module.connect ("query-ready", lambda sender, match: cuemiac.append(match)) from module_list import ModuleLoader, ModuleList mloader = ModuleLoader (deskbar.MODULES_DIRS) mlist = ModuleList () mloader.connect ("module-loaded", on_module_loaded) mloader.load_all() #mloader.load (join(MYPATH, "handlers/beagle-live.py")) #mloader.load (join(MYPATH, "handlers/google-live.py")) #mloader.load (join(MYPATH, "handlers/volumes.py")) def on_entry_changed (entry): cuemiac.clear() text = entry.get_text().strip() if text == "": cwindow.hide () return for ctx in modules: if not ctx.enabled: continue if ctx.module.is_async (): ctx.module.query_async (text) else: cuemiac.append (ctx.module.query (text)) if not cwindow.is_shown (): cwindow.show_all () def on_entry_key_press (entry, event): if event.keyval == gtk.keysyms.Escape: # bind Escape to clear the GtkEntry if not entry.get_text().strip() == "": # If we cleared some text, tell async handlers to stop. for ctx in modules: if ctx.module.is_async (): ctx.module.stop_query () entry.set_text("") vbox = gtk.VBox () entry = gtk.Entry() entry.connect ("changed", on_entry_changed) entry.connect ("key-press-event", on_entry_key_press) cmodel = CuemiacModel (CATEGORIES) cview = CuemiacTreeView (cmodel) cuemiac = CuemiacWidget (cmodel, cview) cwindow = CuemiacWindow (entry, gnomeapplet.ORIENT_DOWN) cwindow.add (cuemiac) cwindow.connect ("focus-out-event", lambda widget,event: cwindow.hide()) def do_action(sender, match): if hasattr(match, 'action'): match.action(entry.get_text()) cuemiac.connect ("match-selected", do_action) vbox.pack_start (entry, False) #vbox.pack_start (cuemiac, True) window = gtk.Window() window.connect ("destroy", gtk.main_quit) window.add (vbox) window.show_all() cuemiac.show_all() gtk.main ()