Sunday, January 28, 2007

Deskbar Applet Plugin

Deskbar Applet is a versatile Gnome panel applet. It places a small text entry box on your panel, and then allows you to perform a number of actions based on what you type in the text box. It's a VERY useful applet, and can replace a number of applets. For example, if you type a word, it will give you the option to look the word up in a dictionary, on wikipedia, or to execute a command if the word happens to be a command name. You can install applet handlers that allow you to install software packages, perform calculations, and do a number of other things. Since deskbar applet is written in python, it's very easy to extend its functionality, with just a little python knowledge! Read on to find out how...

Thanks to Leo Szumel for helping me out with this!

In this tutorial, we'll explain the various components of a deskbar-applet handler, while going through the design of a handler called Noted, which was created by my friend Leo Szumel.

Overview: How it all works



The deskbar applet works by passing the text that you type into the search box to a number of different handlers. Each Handler is responsible for parsing the text and deciding whether or not the text is of any interest to that parser. Because *every* handler will process EVERY text item, this portion of the script should be lean and mean.

If the parser for your handler decides that it can do something with what's been typed into the text box, then it returns one or more Match objects. Each Match object represents one line that appears in the results drop-down of the deskbar applet. The Match object is responsible for telling the deskbar applet how it should be represented in the list (icon and text string) which category it should appear under, and what action should be taken if the user clicks on it.

So, in the case of our example application: when the user types text into the search box, it is passed along to NotedHandler.query(). NotedHandler.query() then searches through all files in a previously specified directory, and records which files contain the text in the query. Each time a match is found, a new GrepMatch object is created, with the name of the file.

Deskbar-applet lists all of these matches (which have an associated display string) in the drop down box. If the user decides to click on a Match associated with a GrepMatch object, then GrepMatch.action() is called, which will run your favorite texteditor (using the EDITOR environment variable) on the file in question.

Starting Out: A Skeleton for the Plugin



So, we begin with a skeleton for the plugin. The entire skeleton plugin can be downloaded for editing Here.

import deskbar.Handler
import deskbar.Match
from gettext import gettext as _

_debug=True
if _debug:
import traceback


This imports the modules needed for proper deskbar Handler operation. Note that gettext isn't necessary, but "real" GNOME apps should include translation capability, which is provided by gettext. When you're done debugging your app, you can set _debug=False.

HANDLERS = {
"HelloHandler" : {
"name" : _("Hello"),
"description" : _("Greet the world"),
"version" : "0.1",
}
}


This is the registration variable for the HANDLER. The most important thing to notice here is that the key for the dictionary item (HelloHandler in this case) should be the name of the Handler subclass that you create later. To fit with the convention of other Deskbar applets, the name should use title capitalization ("Make Thing Like This"), and the description should be a sentence without the final punctuation.
#This is the bare minimum handler. No matter what the query is, it returns one match. The match has no interesting information.

class HelloHandler(deskbar.Handler.Handler):
def __init__ (self):
deskbar.Handler.Handler.__init__ (self, "gnome-logo-icon")
def query(self, text):
return [HelloMatch(self)]


Now, we create the Handler subclass, which is the meat of the handler. You must always include the __init__ function as shown here, to set the icon that is displayed next to the match.

If you want to include other one-time initialization functionality, you can include any other one-time initialization functionality in a function called initialize. Since we don't need to initialize anything here, we've left it out.

The Handler.query() is where the magic happens. The text variable passed to the function contains the text that was entered into the deskbar applet search box. In this function, what normally happens is that the developer will look at the text, decide if it's relevant to the action of the handler, and if so, create a match. In our skeleton, we just always return one single Match item.


class HelloMatch(deskbar.Match.Match):
def get_verb(self):
return "Hello World"
def get_category(self):
return None
def action(self):
pass


Finally, here is the bare minimum of non-trivial functionality for the Match subclass. The get_verb function tells Deskbar Applet what to display for this Match in the dropdown list. The get_category shoudl return a key into the categories dictionary, that will specify in which section of the dropdown list this match should occur. More on this later. Finally, the action function is the callback that is execute when the user clicks on the item in the dropdown list that is associated with this Match.

if __name__ == '__main__':
import sys

print __file__,': Running unit test'
ah = HelloHandler()
ah.initialize()

hits = ah.query(sys.argv[1])

for h in hits:
print '%s: action %s ? (y/n/q)' % (h.get_category(), h.get_verb()),
ask = sys.stdin.read(1)
if ask == 'y':
h.action()
if ask == 'q':
break
print


Finally, we present here an optional (but highly recommended) unit test for Deskbar Applets. This will allow you to call your handler on the command line, passing it a query as a command-line option. You can use it to validate the behavior of your plugin without having to fuss with the deskbar-applet interface.

For the skeleton applet, the result should be: None: action Hello World ? (y/n/q)

And then it will exit regardless of your choice, since no action is defined.

Modifying the skeleton to create a "real" handler.



Now, we'll outline the development of a real plugin that allows you to grep all files in a given directory for the text in the query box. Here is the complete code for the handler. We start with the preliminary requirements:

from deskbar.Handler import Handler
from deskbar.Match import Match
from gettext import gettext as _

import glob, re, os

NOTEDIR = '/home/leo/f/notes'

HANDLERS = {
'NotedHandler' : {
'name':'Noted',
'description':'Search my note directory',
'categories': {
'my notes': {
'name': 'My Notes',
}
}
}
}


So, what's changed here? Not much. We include a few more imports that will provide other python functionality that we'll need later. A global variable NOTEDIR is defined. This will tell the plugin where the files that should be grepped are stored. Eventually, we will examine how to make this a parameter that can be configured in the deskbar applet preferences dialog. Finally, the necessary changes are made to the HANDLERS variable. Note the addition of a new dictionary item: categories. This allows us to specify custom categories other than the ones provided with deskbar applet. The general format is 'categories' : { 'categorykey' : { 'name': 'Category Name' } } Since we want all of our grep results to be grouped together, we create a new category for them.

Creating the main handler: class NoteHandler(deskbar.Handler.Handler)



As we've already mentioned, each time you type (or update) text in the search box, the deskbar applet goes through the entire list of registered plugins, and calls Handler.query() for each plugin (so each plugin should have subclassed the deskbar.Handler.Handler class). So, let's look at how to modify the skeleton Handler class to provide our file searching functionality.

class NotedHandler(Handler):
def __init__(self):
Handler.__init__(self, 'text-editor.png')

def initialize(self):
# might one day do some file modification tracking
# as an optimization
pass


The first part is easy: we just name the class, and choose the icon that we want showing up next to any matches in the dropdown list. We don't have any initialization to perform, but we may want to do some indexing or filesystem monitoring to optimize behavior, so we define a stub intialization function to remind ourselves.

def query(self, query, max=15):
# files to search
candidates = glob.glob(NOTEDIR + '/*')

# find filename matches
matches = [c for c in candidates if query in c]

# find file contents matches
for c in candidates:
if os.path.isfile(c)
f = open(c)
if re.search(query, f.read()):
matches.append(c)
f.close()
print matches

return [NotedMatch(self, file, query) for file in matches[:max]]


Finally, the good stuff! Again, the query variable passed to the query function is the text that was typed into the search box for deskbar-applet. Here we also introduce a new parameter for query: max. The max parameter allows you to specify how many matches should be returned at most.

The first step is to get all of the files in the directory we're searching. Next, we search filenames for the query in question. If any filenames contain the query, they're added to the results. Finally, we open each file, and search its contents for the query. If the contents contain the query, the file is added to the results.

Now, we have a list of strings containing matching filenames. The last step is to return the Match objects that will represent each of these files! Don't worry about the details of how the NotedMatch objects are instantiated... just notice that one is created for each of the filename matches.

Creating the match objects: deskbar.Match.Match



Now, we will investigate the creation of the Match objects. We're almost done! I don't know about you, but I think this was easy!

class NotedMatch(Match):
def __init__ (self, backend, file, query, **kwargs):
Match.__init__(self, backend, **kwargs)
self.file = file
self.query = query


First, we create the intialization portion of the match. The first parameter, backend, must always be present, and should be passed as the self object of the Handler that created the match. Even if you don't use it, it will be passed to the superclass's __init__ function. We pass two parameters of our own custom interest: file, which is the filename of the file containing the query that was made.

def get_verb(self):
return 'Edit %s' % (self.file)

def get_category(self):
return 'my notes'


Next, we create the informational get_verb and get_category. These behave just as explained above. Remember that we created our own category with the key of 'my notes', so that category will be used for all of these matches.

With these portions completed, we already have a pretty functional plugin! It will tell us, in the dropdown list, up to 15 files that contain the query that the user typed into the deskbar-applet search box! But, wouldn't it be easier if we could easily open the file? Well, that's the whole point of the drop down menu! So, let's implement our FINAL piece of code:

def action(self, text=None):
editors = ['gvim -c "/%s"' % self.query,
'$VISUAL',
'gedit']

for ed in editors:
if os.system(ed + ' ' + self.file) == 0:
break


This is the code that is executed when you click on the line in the dropdown box associated with this match! As you can see, it simply opens the file in either gvim, the editor specified in $VISUAL environment variable, or, as a fallback, gedit. We like gvim, because you can open the file with the query actually highlighted!

Eventually, this choice will also be moved to the Preferences GUI configuration tool, allowing the user to specify any editor command! But that is for a later day.

Congratulations! You've written a deskbar applet handler!

Summary: Overview of components:


HANDLERS variable




  • A dictionary that describes your plugin.



deskbar.Match.Match




  • get_verb - This function returns the text that should be presented in the drop down list that corresponds to a particular match object.

  • get_category - This function returns a category name, specifying where the Match should appear in the list.

  • action - This function performs a series of actions that the user is expecting, having clicked on the Match in the deskbar-applet dropdown list.



deskbar.Handler.Handler



  • __init__ - This is only called when the plugin is loaded! You should call the deskbar.Handler.Handler.__init__ function here, passing the icon file that you'd like to use.

  • initialize - This is called after __init__ to set up the plugin. Remember, the deskbar-applet will not be ready until all initialize sequences are complete, so it shouldn't take too long!

  • query - This function is called everytime the text in the search box is updated. The function should read the text, decide if it's interesting, and if so, return one or more Match objects. This is called a lot, so it should be tightly coded.


Part Two: Adding Configuration capability.



So, we have a nice fancy file searching plugin. But, there's still a problem... the directory that we're monitoring is hard-coded into the plugin. This is not a nice solution. We could try using environment variables, but a nicer solution would be to allow the user to configure the directory from the deskbar-applet.

When you go to the Deskbar Applet preferences dialog, you will notice that for some handlers, the "More..." button is activated, and can be clicked. Adding this sort of functionality to your handler is very easy. The only deskbar-related action that you need to take is to add an entry to your HANDLER dictionary entry, and two associated functions. First, let's look at the needed change to HANDLER:

GCONF_NOTED_PATH = deskbar.GCONF_DIR+"/noted/path"

HANDLERS = {
'NotedHandler' : {
'name':'Noted',
'description':'Search my note directory',
'requirements': _check_requirements,
'categories': {
'mynotes': {
'name': 'My Notes',
}
}
}
}


Notice the new 'requirements' entry in the dictionary. This entry simply provides a pointer to a function that the handler uses to decide if it needs to be configured, or if it can be configured. So, the next step is to implement this function.

Notice that we also create the GCONF_NOTED_PATH variable, which points to the location in the gconf database of our noted configuration.

def _check_requirements():
#We need the user to choose a directory to monitor
if not deskbar.GCONF_CLIENT.get_string(GCONF_NOTED_PATH):
return (deskbar.Handler.HANDLER_HAS_REQUIREMENTS, _("You need to choose a directory for Noted to search."), _on_config_account)
else:
return (deskbar.Handler.HANDLER_IS_CONFIGURABLE, _("You can change the directory that Noted searches."), _on_config_account)



So, what's going on here? Well, the function that you place in the 'requirements' item of your HANDLERS dictionary entry should return one of two symbols if your handler is configurable. The first return option, deskbar.Handler.HANDLER_HAS_REQUIREMENTS tells deskbar-applet that you can not run this plugin until it is configured. The other option, deskbar.Handler.HANDLER_IS_CONFIGURABLE, tells deskbar applet that your handler is ready to be run, but that the user has the option of changing some settings. Both of these options will cause the "More..." button in the preferences dialog to be activated. The text string returned is displayed to the left of the "More..." button in the preferences dialog. Finally, the third argument should be a function that is called when the "More..." button is pressed.

So, our next step is to implement the configuration action handler.

def _on_config_account(dialog):
dialog = gtk.Dialog(_("Noted Configuration"), dialog,
gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
(gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT,
gtk.STOCK_OK, gtk.RESPONSE_ACCEPT))

table = gtk.Table(rows=2, columns=2)
table.attach(gtk.Label(_("Choose a path for Noted to search:")), 0, 2, 0, 1)
user_entry = gtk.Entry()
t = deskbar.GCONF_CLIENT.get_string(GCONF_NOTED_PATH)
if t != None:
user_entry.set_text(t)
table.attach(user_entry, 1, 2, 1, 2)
table.show_all()
dialog.vbox.add(table)
response = dialog.run()
dialog.destroy()

if response == gtk.RESPONSE_ACCEPT and user_entry.get_text() != "":
deskbar.GCONF_CLIENT.set_string(GCONF_NOTED_PATH, user_entry.get_text())


All this function does is create a single GTK dialog with a text entry box, and OK and CANCEL buttons. For more details about creating your own GTK dialog, you should refer to a GTK tutorial . The important thing to notice here is that our dialog gets and sets the gconf variable GCONF_NOTED_PATH that we defined previously.

The Deskbar Applet specific development has been done! All that we need to do now is modify the Handlers that we defined earlier to use our gconf variable. All we need to do is add one line to our query function!

def query(self, query, max=15):
####HERE'S THE NEW LINE
NOTEDIR = deskbar.GCONF_CLIENT.get_string(GCONF_NOTED_PATH)

# files to search
candidates = glob.glob(NOTEDIR + '/*')


Ok, we're all done! Now the Noted handler will search all files in the directory that you configure via the GUI!